Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1db2ee0808 | |||
| ac416493e0 | |||
| 9e97473eec | |||
| 34bd9372ce | |||
| 2e53c59cd7 | |||
| 9aa0c69b26 | |||
| 6c04bb53ad | |||
| e186cb2253 | |||
| 5104a56578 | |||
| c7ba43c309 | |||
| ff3f6ddf97 | |||
| 13b8fb7f66 | |||
| b88683fcff | |||
| ea704ddb2f | |||
| b8fbd5705d | |||
| 8b0572362e | |||
| a262d4cc6e | |||
| 77b38af1bc | |||
| c539095a33 | |||
| 16767f675b | |||
| e96be1771a | |||
| 68b8974170 | |||
| 9eb1c78d5e | |||
| c6986de358 | |||
| 7938eb5dda | |||
| afc08636c7 | |||
| 1cda62736d | |||
| a5b7c5a345 | |||
| d826ae216a | |||
| 0bb5a97c70 | |||
| 99c8408524 | |||
| fae7bab9bf | |||
| 64783e267f | |||
| 74e2bdf914 | |||
| f03c1ccc17 | |||
| b7b51d306c | |||
| 80c2000bb7 | |||
| 8abb0e88cb | |||
| f3dccd7d00 | |||
| d4c5c96597 | |||
| 474d7d77e5 | |||
| 04330d2e9d | |||
| dee5054e4f | |||
| a9f6d7e712 | |||
| d7bc908ed3 | |||
| 59faf37fc9 | |||
| 206d517a91 | |||
| 0301370dd8 | |||
| 85bfe58f39 | |||
| 2ff2798c97 | |||
| 00348a2154 | |||
| c00c56a742 | |||
| 8e4a8a8184 | |||
| 6a4085452d | |||
| 0896f3e161 | |||
| 21e84c71c4 | |||
| 17174e2cf5 | |||
| 0a00870ab6 | |||
| 68fa7fd163 | |||
| 7ba46cb9c0 | |||
| 0529c61347 | |||
| 915e551ecc | |||
| b0812b14f9 | |||
| c062ff3912 | |||
| 8e4834c927 | |||
| e1076f7e88 | |||
| 5b95bdef6f | |||
| 61c75ad809 | |||
| 52eb37472d | |||
| d67d6eb2cd | |||
| cd6fde790a | |||
| 9c6af4dd45 | |||
| 7b9e803603 | |||
| 12d7b9ac2f | |||
| 887e66b0f9 | |||
| 9520507f38 | |||
| 0d99a28fc2 |
@@ -10,8 +10,6 @@ Changes:
|
||||
3. config 为 JSON 字段,存储封面配置信息
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
@@ -73,7 +71,7 @@ def upgrade() -> None:
|
||||
name=name,
|
||||
thumbnail_url="",
|
||||
is_system=True,
|
||||
config=json.dumps(config),
|
||||
config=config,
|
||||
created_at=sa.func.now(),
|
||||
updated_at=sa.func.now(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""修复 cover_templates.config 双重序列化
|
||||
|
||||
Revision ID: 056_fix_cover_templates_config
|
||||
Revises: 055_cover_templates
|
||||
Create Date: 2026-08-13
|
||||
|
||||
问题: 055 迁移 seed 数据时 json.dumps(config) 导致 config 被双重序列化为 JSON 字符串
|
||||
例如 "{}"(字符串)而不是 {}(对象),导致 Pydantic CoverTemplateResponse 校验失败 500。
|
||||
|
||||
修复: 从 JSON 字符串中提取文本值,再 cast 回 json 对象类型。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "056_fix_cover_templates_config"
|
||||
down_revision = "055_cover_templates"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# PostgreSQL: 从 JSON string scalar 中提取文本内容,cast 为 json object
|
||||
# 例如: JSON string "{}" -> text "{}" -> JSON object {}
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE cover_templates SET config = (config#>>'{}')::json "
|
||||
"WHERE jsonb_typeof(config::jsonb) = 'string'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No safe rollback — the original data was incorrect
|
||||
pass
|
||||
@@ -8,6 +8,7 @@ from app.api.routes.classification_jobs import router as classification_jobs_rou
|
||||
from app.api.routes.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_cover import router as generation_cover_router
|
||||
from app.api.routes.generation_preview import router as generation_preview_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
@@ -98,6 +99,11 @@ api_router.include_router(
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_cover_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
titles_router,
|
||||
prefix="/titles",
|
||||
|
||||
@@ -7,7 +7,6 @@ API:
|
||||
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -55,7 +54,7 @@ def list_cover_templates(
|
||||
thumbnail_url=t.thumbnail_url,
|
||||
is_system=t.is_system,
|
||||
created_at=t.created_at,
|
||||
config=t.config,
|
||||
config=t.config or {},
|
||||
)
|
||||
for t in items
|
||||
],
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""封面生成路由 — Generation 模块.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
|
||||
挂载路径: /api/v1/generation/generate-cover
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_generated_video_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Generation"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
template_id: str = Query(..., description="模板 ID"),
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面 — 从预览视频中抽帧.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
||||
|
||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
||||
if not rendered_storage_key:
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
logger.info(
|
||||
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
|
||||
)
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(pt.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
if preview_tasks:
|
||||
completed_preview = preview_tasks[0]
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(completed_preview.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
completed_preview.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 仍然找不到才报 400
|
||||
if not rendered_storage_key:
|
||||
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请先生成预览视频,再生成封面",
|
||||
)
|
||||
|
||||
# 回写到 plan.config
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读)
|
||||
primary_video_url = None
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
|
||||
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
|
||||
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取预览视频URL失败: {e}",
|
||||
) from e
|
||||
|
||||
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
|
||||
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
|
||||
if body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
cover_url_from_task = None
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
|
||||
# 步骤 A:通过 generation_task_id 直接查找
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
if generation_task_id:
|
||||
try:
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task and getattr(task, "cover_url", ""):
|
||||
cover_url_from_task = task.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤A读取 cover_url 失败: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 C:通过 user+template 查找最近的已完成预览任务的 cover_url
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "cover_url", ""):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤C-user+template): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤C查找 cover_url 失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if cover_url_from_task:
|
||||
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
|
||||
cover_data = {
|
||||
"type": "ai_frame",
|
||||
"image_url": cover_url_from_task,
|
||||
"frame_time": 0.0,
|
||||
"confidence": 0.95,
|
||||
}
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
logger.warning(
|
||||
"[封面生成] 统一管道未找到 cover_url: plan_id=%s",
|
||||
plan_id,
|
||||
)
|
||||
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="封面尚未生成,请先重新生成预览视频以触发封面自动提取",
|
||||
)
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
try:
|
||||
logger.info("[封面生成] 开始调用 AI 封面生成服务: plan_id=%s", plan_id)
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -267,6 +268,20 @@ def create_preview_generation_task(
|
||||
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
|
||||
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
|
||||
|
||||
# 处理标题配置:如果有标题文本,序列化到 custom_title 字段传递给 worker
|
||||
title_config = request.title_config or {}
|
||||
title_text = (title_config.get("text") or "").strip()
|
||||
custom_title_value = ""
|
||||
if title_text:
|
||||
# 将标题文本和样式配置序列化为 JSON 存入 custom_title
|
||||
# Worker 端会解析 JSON 获取完整标题配置
|
||||
custom_title_value = json.dumps(title_config, ensure_ascii=False)
|
||||
logger.info(
|
||||
"[预览生成] 标题配置: text=%s, config_keys=%s",
|
||||
title_text[:30],
|
||||
list(title_config.keys()),
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
|
||||
try:
|
||||
@@ -290,6 +305,7 @@ def create_preview_generation_task(
|
||||
auto_retry_enabled=False,
|
||||
auto_retry_max=0,
|
||||
is_preview=True,
|
||||
custom_title=custom_title_value,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
@@ -299,6 +315,32 @@ def create_preview_generation_task(
|
||||
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
|
||||
|
||||
# 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
|
||||
if not task.source_edit_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
|
||||
_plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
|
||||
for _p in _plans:
|
||||
if (_p.created_by_user_id or "") == user_id:
|
||||
task.source_edit_plan_id = _p.id
|
||||
generation_task_repository.update(task)
|
||||
logger.info(
|
||||
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
|
||||
task.id,
|
||||
_p.id,
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
|
||||
task.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
- bgm.py: BGM 管理
|
||||
- effects.py: 转场 + 滤镜
|
||||
- export.py: 导出配置
|
||||
- cover.py: 封面管理 + AI 生成封面
|
||||
- subtitles.py: 字幕管理
|
||||
- ai_features.py: AI 推荐
|
||||
- generation.py: 生成(触发/进度/记录)
|
||||
@@ -31,7 +30,6 @@ from .adjustments import router as adjustments_router
|
||||
from .ai_features import router as ai_features_router
|
||||
from .bgm import router as bgm_router
|
||||
from .clips import router as clips_router
|
||||
from .cover import router as cover_router
|
||||
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||
from .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
@@ -51,7 +49,6 @@ _sub_routers = [
|
||||
bgm_router,
|
||||
effects_router,
|
||||
export_router,
|
||||
cover_router,
|
||||
subtitles_router,
|
||||
ai_features_router,
|
||||
generation_router,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""封面管理路由.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_generated_video_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def editor_generate_cover(
|
||||
template_id: str,
|
||||
body: GenerateCoverRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面 — 从预览视频中抽帧.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
||||
|
||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
||||
if not rendered_storage_key:
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"封面生成: 通过 generation_task_id 找到视频: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
if preview_tasks:
|
||||
completed_preview = preview_tasks[0]
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(completed_preview.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
completed_preview.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 仍然找不到才报 400
|
||||
if not rendered_storage_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请先生成预览视频,再生成封面",
|
||||
)
|
||||
|
||||
# 回写到 plan.config
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读)
|
||||
primary_video_url = None
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取预览视频URL失败: {e}",
|
||||
) from e
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
try:
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
@@ -223,7 +223,12 @@ def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
url = getattr(videos[0], "file_url", "") or ""
|
||||
# 规范化:合并路径中的双斜杠(保留协议头 ://)
|
||||
if url:
|
||||
import re as _re
|
||||
url = _re.sub(r"(?<!:)//", "/", url)
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@@ -99,29 +99,6 @@ class AIRecommendResponse(BaseModel):
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
# ── 封面生成 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── BGM ────────────────────────────────────────────────────────────────────
|
||||
@@ -250,6 +227,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列表")
|
||||
|
||||
@@ -462,17 +440,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):
|
||||
|
||||
@@ -7,8 +7,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
class ConfirmGenerationRequest(BaseModel):
|
||||
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
|
||||
|
||||
output_width: int = Field(default=1080, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, description="输出视频高度")
|
||||
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
@@ -181,6 +181,10 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
default="",
|
||||
description="关联的编辑计划ID(可选),用于确认生成时复用预览产物",
|
||||
)
|
||||
title_config: dict = Field(
|
||||
default_factory=dict,
|
||||
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
"""封面管理服务.
|
||||
|
||||
提供封面配置管理和从视频抽帧生成封面的能力。
|
||||
抽帧使用 FFmpeg,上传使用共享存储服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_COVER_WIDTH = 1080
|
||||
DEFAULT_COVER_HEIGHT = 1920
|
||||
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
|
||||
COVER_STORAGE_PREFIX = "covers"
|
||||
|
||||
|
||||
class CoverService:
|
||||
"""封面管理服务."""
|
||||
|
||||
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
|
||||
self._storage = storage_service
|
||||
self._asset_repo = asset_repository
|
||||
|
||||
# ── 配置读写 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""从 plan.config 中提取封面配置.
|
||||
|
||||
Args:
|
||||
plan_config: 剪辑计划的 config 字段
|
||||
|
||||
Returns:
|
||||
封面配置 dict
|
||||
"""
|
||||
cover = plan_config.get("cover", {})
|
||||
if not isinstance(cover, dict):
|
||||
cover = {}
|
||||
# 确保默认字段存在
|
||||
return {
|
||||
"type": cover.get("type", "ai_frame"),
|
||||
"image_url": cover.get("image_url", ""),
|
||||
"frame_time": cover.get("frame_time"),
|
||||
}
|
||||
|
||||
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
|
||||
|
||||
def extract_cover_from_clip(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_id: str,
|
||||
frame_time: float = 1.0,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""从指定素材的指定时间点抽取一帧作为封面.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID(用于生成存储路径)
|
||||
asset_id: 素材 ID
|
||||
frame_time: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面数据 dict,包含 type / image_url / frame_time
|
||||
|
||||
Raises:
|
||||
ValueError: 素材不存在或不是视频
|
||||
RuntimeError: 抽帧或上传失败
|
||||
"""
|
||||
# 1. 获取素材
|
||||
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
|
||||
if not asset:
|
||||
raise ValueError(f"素材不存在: {asset_id}")
|
||||
|
||||
storage_key = getattr(asset, "storage_key", "")
|
||||
if not storage_key:
|
||||
raise ValueError(f"素材没有文件: {asset_id}")
|
||||
|
||||
mime_type = getattr(asset, "mime_type", "")
|
||||
if mime_type and not mime_type.startswith("video"):
|
||||
raise ValueError(f"素材不是视频类型: {mime_type}")
|
||||
|
||||
# 2. 下载视频到临时目录
|
||||
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
video_path = tmp_path / f"source_{asset_id[:8]}"
|
||||
|
||||
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
|
||||
try:
|
||||
self._storage.download_file(storage_key, str(video_path))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"下载素材失败: {e}") from e
|
||||
|
||||
if not video_path.exists() or video_path.stat().st_size == 0:
|
||||
raise RuntimeError("下载的素材文件为空")
|
||||
|
||||
# 3. FFmpeg 抽帧
|
||||
output_path = tmp_path / "cover.jpg"
|
||||
self._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=frame_time,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError("封面抽帧失败")
|
||||
|
||||
# 4. 上传到 OSS
|
||||
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
|
||||
logger.info("上传封面到存储: key=%s", cover_key)
|
||||
|
||||
try:
|
||||
self._storage.upload_file(
|
||||
file_or_path=str(output_path),
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"上传封面失败: {e}") from e
|
||||
|
||||
# 5. 获取访问 URL
|
||||
try:
|
||||
image_url = self._storage.get_url(cover_key)
|
||||
except Exception:
|
||||
image_url = cover_key # 降级为 storage_key
|
||||
|
||||
logger.info(
|
||||
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
|
||||
plan_id,
|
||||
asset_id,
|
||||
frame_time,
|
||||
output_path.stat().st_size if output_path.exists() else 0,
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "manual",
|
||||
"image_url": image_url,
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
def generate_smart_cover(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_id: str,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
asset_id: 素材 ID
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面数据 dict
|
||||
"""
|
||||
# 简单实现:取视频 1/3 处的帧作为智能封面
|
||||
# 更复杂的多帧选清晰帧可以后续优化
|
||||
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
|
||||
|
||||
result = self.extract_cover_from_clip(
|
||||
plan_id=plan_id,
|
||||
asset_id=asset_id,
|
||||
frame_time=frame_time,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
result["type"] = "ai_frame"
|
||||
return result
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_frame(
|
||||
video_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
time_sec: float,
|
||||
width: int,
|
||||
height: int,
|
||||
quality: int,
|
||||
) -> None:
|
||||
"""使用 FFmpeg 从视频中抽取一帧.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径
|
||||
time_sec: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
# scale + crop 实现 cover 裁剪
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
|
||||
# 尝试不使用 scale+crop 的简化命令
|
||||
simple_command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
result2 = subprocess.run(
|
||||
simple_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result2.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError("FFmpeg 抽帧超时") from e
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError("FFmpeg 不可用") from e
|
||||
@@ -200,15 +200,7 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: title
|
||||
// Step 4: title(新顺序:标题在预览之前)
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 等待组件完全渲染
|
||||
await page.waitForTimeout(2000)
|
||||
@@ -222,6 +214,14 @@ test.describe("Core generation flow", () => {
|
||||
await titleInput.fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
@@ -258,9 +258,12 @@ test.describe("Core generation flow", () => {
|
||||
// Generate API may return 400 in test env if template has no ready segments
|
||||
// That is OK for a wizard flow smoke test
|
||||
if (genResp.ok()) {
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
const genData = (await genResp.json()) as {
|
||||
items: Array<{ id: string; status: string }>
|
||||
total: number
|
||||
}
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
expect(genData.items[0].id).toBeTruthy()
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
}
|
||||
|
||||
@@ -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 = "/"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 后端路由: /api/v1/cover-templates
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { CoverTemplate } from "@/pages/editing-planner/types"
|
||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
||||
|
||||
export interface CoverTemplateListResponse {
|
||||
items: CoverTemplate[]
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import type { CoverConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
@@ -7,7 +7,7 @@ export const confirmGeneration = async (
|
||||
params: ConfirmGenerationRequest,
|
||||
): Promise<ConfirmGenerationResponse> => {
|
||||
const response = await apiClient.post<ConfirmGenerationResponse>(
|
||||
`/tasks/${taskId}/confirm`,
|
||||
`/generation/tasks/${taskId}/confirm`,
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
}
|
||||
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string
|
||||
cover: {
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
image_url?: string
|
||||
thumbnail_url?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 生成封面 — 从预览视频中抽帧 */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post<GenerateCoverResponse>(
|
||||
"/generation/generate-cover",
|
||||
{ ...data, template_id: templateId },
|
||||
{
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -10,3 +10,6 @@ export type {
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
export { generateCover } from "./cover"
|
||||
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
|
||||
|
||||
@@ -13,6 +13,17 @@ export interface CreatePreviewRequest {
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
/* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
bgm_config?: {
|
||||
enabled: boolean
|
||||
preset_id?: string
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* AI 推荐 + 封面生成 API
|
||||
* AI 推荐 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
AIRecommendRequest,
|
||||
AIRecommendResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
} from "./types"
|
||||
import type { AIRecommendRequest, AIRecommendResponse } from "./types"
|
||||
|
||||
/** AI 推荐片段方案 */
|
||||
export async function aiRecommendClips(
|
||||
@@ -17,14 +12,3 @@ export async function aiRecommendClips(
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data, {
|
||||
timeout: 180000, // 封面生成涉及 MediaKit 抽帧,最长 180 秒
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ export type {
|
||||
AIRecommendRequest,
|
||||
AIRecommendClipItem,
|
||||
AIRecommendResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
CoverResult,
|
||||
EditPlanClipStatus,
|
||||
EditPlanClip,
|
||||
CreateEditPlanClipRequest,
|
||||
@@ -81,8 +78,8 @@ export {
|
||||
createClipsFromAssets,
|
||||
} from "./clips"
|
||||
|
||||
// AI 推荐 + 封面生成
|
||||
export { aiRecommendClips, generateCover } from "./aiFeatures"
|
||||
// AI 推荐
|
||||
export { aiRecommendClips } from "./aiFeatures"
|
||||
|
||||
// 素材库
|
||||
export { getMediaAssets, getMediaAsset } from "./mediaAssets"
|
||||
|
||||
@@ -9,8 +9,8 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import type { CoverConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
/* ── 模板草稿状态 ── */
|
||||
|
||||
@@ -118,6 +118,10 @@ export interface EditPlanConfig {
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string
|
||||
/** 预览视频 URL(封面生成用) */
|
||||
rendered_storage_key?: string
|
||||
/** 生成任务 ID */
|
||||
generation_task_id?: string
|
||||
}
|
||||
|
||||
/* ── 模板草稿主体 ── */
|
||||
@@ -240,7 +244,7 @@ export interface GeneratedVideo {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/* ── AI 推荐 & 封面生成 ── */
|
||||
/* ── AI 推荐 ── */
|
||||
|
||||
/** AI 推荐请求 */
|
||||
export interface AIRecommendRequest {
|
||||
@@ -270,28 +274,6 @@ export interface AIRecommendResponse {
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** AI 封面生成请求 */
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
}
|
||||
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string
|
||||
cover: CoverResult
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
image_url?: string
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
/* ── 片段 CRUD 相关 ── */
|
||||
|
||||
/** 片段状态 */
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -74,8 +74,6 @@ const EditingPlanner: React.FC = () => {
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
} = useGlobalSettings()
|
||||
|
||||
/* ── 右侧栏 Tab ── */
|
||||
@@ -119,7 +117,6 @@ const EditingPlanner: React.FC = () => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
@@ -131,7 +128,6 @@ const EditingPlanner: React.FC = () => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
})
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
@@ -181,7 +177,6 @@ const EditingPlanner: React.FC = () => {
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
isPlaying={playback.isPlaying}
|
||||
titleConfig={titleConfig}
|
||||
coverConfig={coverConfig}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
@@ -243,7 +238,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onOpenFilterDrawer={() => drawers.setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => drawers.setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => drawers.setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => drawers.setCoverDrawerOpen(true)}
|
||||
clips={clips}
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
onClipSelect={clipOps.handleClipSelect}
|
||||
@@ -336,11 +330,6 @@ const EditingPlanner: React.FC = () => {
|
||||
stickerSettings={stickerSettings}
|
||||
onStickerChange={setStickerSettings}
|
||||
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
|
||||
coverDrawerOpen={drawers.coverDrawerOpen}
|
||||
onCloseCoverDrawer={() => drawers.setCoverDrawerOpen(false)}
|
||||
coverConfig={coverConfig}
|
||||
setCoverConfig={setCoverConfig}
|
||||
templateId={urlTemplateId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
}) => {
|
||||
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
|
||||
|
||||
@@ -141,21 +140,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 封面设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
封面设置
|
||||
</div>
|
||||
{onOpenCoverDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">配置视频封面</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<ClipDetailSection
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* 封面选择器入口(向后兼容)
|
||||
* 实际实现位于 ./cover-selector/ 目录
|
||||
*/
|
||||
export { default } from "./cover-selector"
|
||||
@@ -10,7 +10,6 @@ import PipConfigPanel from "./PipConfigPanel"
|
||||
import FilterPanel from "./FilterPanel"
|
||||
import GreenScreenPanel from "./GreenScreenPanel"
|
||||
import StickerPanel from "./StickerPanel"
|
||||
import CoverSelector from "./CoverSelector"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
@@ -24,7 +23,6 @@ import type {
|
||||
StickerConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
||||
|
||||
@@ -87,12 +85,6 @@ interface EditorDrawersProps {
|
||||
stickerSettings: StickerConfig
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
onCloseStickerDrawer: () => void
|
||||
// 封面
|
||||
coverDrawerOpen: boolean
|
||||
onCloseCoverDrawer: () => void
|
||||
coverConfig: CoverConfig
|
||||
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
templateId: string
|
||||
}
|
||||
|
||||
const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
@@ -143,11 +135,6 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
stickerSettings,
|
||||
onStickerChange,
|
||||
onCloseStickerDrawer,
|
||||
coverDrawerOpen,
|
||||
onCloseCoverDrawer,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
templateId,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
@@ -252,16 +239,6 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* 封面选择器 Drawer */}
|
||||
<CoverSelector
|
||||
open={coverDrawerOpen}
|
||||
onClose={onCloseCoverDrawer}
|
||||
config={coverConfig}
|
||||
onChange={setCoverConfig}
|
||||
totalDuration={totalDuration}
|
||||
templateId={templateId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* 预览区 — V8 原型 1:1 还原
|
||||
* 手机模型预览 + 封面预览 并排
|
||||
* 封面为只读展示(从模板/计划继承)
|
||||
* 手机模型预览
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types"
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
@@ -21,7 +19,6 @@ interface PreviewPlayerProps {
|
||||
selectedClipId: string | null
|
||||
isPlaying: boolean
|
||||
titleConfig?: TitleConfig
|
||||
coverConfig?: CoverConfig
|
||||
subtitleSettings?: SubtitleSettings
|
||||
onClipSelect: (clipId: string) => void
|
||||
onPlayPause: () => void
|
||||
@@ -37,18 +34,11 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const COVER_MODE_LABELS: Record<string, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
isPlaying,
|
||||
titleConfig,
|
||||
coverConfig,
|
||||
subtitleSettings,
|
||||
onPlayPause,
|
||||
}) => {
|
||||
@@ -132,26 +122,6 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面预览(只读) */}
|
||||
<div className="ep-cover-preview">
|
||||
<div className="ep-cover-image">
|
||||
{coverConfig?.thumbnail_url || coverConfig?.upload_url ? (
|
||||
<img
|
||||
src={coverConfig.thumbnail_url || coverConfig.upload_url}
|
||||
alt="封面预览"
|
||||
className="ep-cover-img"
|
||||
/>
|
||||
) : displayClip ? (
|
||||
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-cover-label">
|
||||
{coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ interface RightPanelProps {
|
||||
onOpenFilterDrawer: () => void
|
||||
onOpenGreenScreenDrawer: () => void
|
||||
onOpenStickerDrawer: () => void
|
||||
onOpenCoverDrawer: () => void
|
||||
// 片段 tab
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
@@ -73,7 +72,6 @@ const RightPanel: React.FC<RightPanelProps> = ({
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
clips,
|
||||
selectedClipId,
|
||||
onClipSelect,
|
||||
@@ -144,7 +142,6 @@ const RightPanel: React.FC<RightPanelProps> = ({
|
||||
onOpenFilterDrawer={onOpenFilterDrawer}
|
||||
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
|
||||
onOpenStickerDrawer={onOpenStickerDrawer}
|
||||
onOpenCoverDrawer={onOpenCoverDrawer}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../types"
|
||||
|
||||
interface CoverAutoModeProps {
|
||||
config: CoverConfig
|
||||
formatTime: (s: number) => string
|
||||
onUseAiSuggestion: () => void
|
||||
}
|
||||
|
||||
/** 智能封面模式面板 */
|
||||
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
|
||||
config,
|
||||
formatTime,
|
||||
onUseAiSuggestion,
|
||||
}) => (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">AI 将分析视频内容,自动选择最具吸引力的画面作为封面。</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">推荐时间点:{formatTime(config.ai_suggested_time)}</div>
|
||||
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverFrameModeProps {
|
||||
config: CoverConfig
|
||||
totalDuration: number
|
||||
formatTime: (s: number) => string
|
||||
onFrameTimeChange: (time: number) => void
|
||||
}
|
||||
|
||||
/** 抽帧选封面模式面板 */
|
||||
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
|
||||
config,
|
||||
totalDuration,
|
||||
formatTime,
|
||||
onFrameTimeChange,
|
||||
}) => (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverUploadModeProps {
|
||||
config: CoverConfig
|
||||
isDragging: boolean
|
||||
fileInputRef: React.RefObject<HTMLInputElement>
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onAreaClick: () => void
|
||||
onFileChange: (file: File) => void
|
||||
}
|
||||
|
||||
/** 上传封面模式面板 */
|
||||
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
|
||||
config,
|
||||
isDragging,
|
||||
fileInputRef,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onAreaClick,
|
||||
onFileChange,
|
||||
}) => (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onAreaClick}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">点击或拖拽上传封面图片</span>
|
||||
<span className="cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileChange(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { Drawer, Modal, Spin, message } from "antd"
|
||||
import { generateCover } from "@/api/template-editor/aiFeatures"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
|
||||
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
templateId: string
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
templateId,
|
||||
}) => {
|
||||
const {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
} = useCoverSelector({ config, onChange })
|
||||
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const handleGenerateCover = async () => {
|
||||
if (!templateId) {
|
||||
message.error("请先保存模板")
|
||||
return
|
||||
}
|
||||
setGenerating(true)
|
||||
try {
|
||||
const res = await generateCover(templateId, {
|
||||
asset_ids: [],
|
||||
cover_type: "ai_frame",
|
||||
})
|
||||
const imageUrl = res.cover?.image_url || res.cover?.thumbnail_url || ""
|
||||
if (imageUrl) {
|
||||
update({ upload_url: imageUrl, thumbnail_url: imageUrl })
|
||||
}
|
||||
message.success("封面生成成功")
|
||||
} catch (err: any) {
|
||||
message.error("封面生成失败: " + (err?.message || "未知错误"))
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{config.mode === "auto" && (
|
||||
<CoverAutoMode
|
||||
config={config}
|
||||
formatTime={formatTime}
|
||||
onUseAiSuggestion={handleUseAiSuggestion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "frame" && (
|
||||
<CoverFrameMode
|
||||
config={config}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={(t) => update({ frame_time: t })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "upload" && (
|
||||
<CoverUploadMode
|
||||
config={config}
|
||||
isDragging={isDragging}
|
||||
fileInputRef={fileInputRef}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onAreaClick={() => fileInputRef.current?.click()}
|
||||
onFileChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 生成封面按钮 */}
|
||||
{config.enabled && config.mode === "auto" && (
|
||||
<div className="cover-generate-section">
|
||||
<button
|
||||
className="cover-generate-btn"
|
||||
onClick={handleGenerateCover}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? "生成中..." : "🤖 AI 生成封面"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成进度弹窗 */}
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI 正在生成封面,请稍候...</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSelector
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { DEFAULT_COVER_CONFIG } from "../../types"
|
||||
|
||||
/** 封面模式标签 */
|
||||
export const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
/** 封面模式图标 */
|
||||
export const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
interface UseCoverSelectorOptions {
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 封面选择器 Hook
|
||||
* 封装状态管理、文件上传、模式切换等逻辑
|
||||
*/
|
||||
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFileUpload(file)
|
||||
},
|
||||
[handleFileUpload],
|
||||
)
|
||||
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" })
|
||||
}
|
||||
}, [config.ai_suggested_time, update])
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}
|
||||
|
||||
return {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import type { TransitionEffect } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types"
|
||||
|
||||
interface UsePlanLoadingOptions {
|
||||
loadedPlanId: string | null
|
||||
@@ -16,7 +15,6 @@ interface UsePlanLoadingOptions {
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +29,6 @@ export function usePlanLoading({
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
}: UsePlanLoadingOptions) {
|
||||
useEffect(() => {
|
||||
if (!loadedPlanId) return
|
||||
@@ -77,17 +74,6 @@ export function usePlanLoading({
|
||||
music_id: cfg.bgm_config!.music_id || "",
|
||||
}))
|
||||
}
|
||||
if (cfg.cover_config) {
|
||||
setCoverConfig((prev: CoverConfig) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
|
||||
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
}))
|
||||
}
|
||||
|
||||
/* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */
|
||||
const backendClips = clipsRes?.items || []
|
||||
@@ -152,6 +138,5 @@ export function usePlanLoading({
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
} from "../../types"
|
||||
import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
interface UseTemplateSaveOptions {
|
||||
@@ -33,7 +32,6 @@ interface UseTemplateSaveOptions {
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
loadedTemplateId: string | null
|
||||
loadTemplates: () => Promise<void>
|
||||
}
|
||||
@@ -55,7 +53,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
} = options
|
||||
@@ -132,7 +129,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
}
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload)
|
||||
@@ -163,7 +159,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
])
|
||||
|
||||
@@ -17,7 +17,6 @@ export const useEditorDrawers = () => {
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
|
||||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 目标片段 ID ── */
|
||||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||||
@@ -67,8 +66,6 @@ export const useEditorDrawers = () => {
|
||||
setChromaKeyDrawerOpen,
|
||||
stickerDrawerOpen,
|
||||
setStickerDrawerOpen,
|
||||
coverDrawerOpen,
|
||||
setCoverDrawerOpen,
|
||||
// 目标 ID
|
||||
transitionTargetClipId,
|
||||
speedTargetClipId,
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import {
|
||||
DEFAULT_WATERMARK,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle"
|
||||
@@ -47,8 +45,6 @@ export interface GlobalSettings {
|
||||
setChromaKeySettings: (config: ChromaKeyConfig) => void
|
||||
stickerSettings: StickerConfig
|
||||
setStickerSettings: (config: StickerConfig) => void
|
||||
coverConfig: CoverConfig
|
||||
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
}
|
||||
|
||||
export const useGlobalSettings = (): GlobalSettings => {
|
||||
@@ -92,10 +88,6 @@ export const useGlobalSettings = (): GlobalSettings => {
|
||||
...DEFAULT_STICKER_CONFIG,
|
||||
})
|
||||
|
||||
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
})
|
||||
|
||||
return {
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
@@ -115,7 +107,5 @@ export const useGlobalSettings = (): GlobalSettings => {
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
@@ -29,7 +28,6 @@ interface UseTemplateManagementParams {
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
titleConfig: TitleConfig
|
||||
@@ -41,7 +39,6 @@ interface UseTemplateManagementParams {
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +56,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
@@ -71,7 +67,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
} = params
|
||||
|
||||
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
|
||||
@@ -119,7 +114,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
})
|
||||
@@ -146,7 +140,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
})
|
||||
|
||||
/* ── 事件 ── */
|
||||
|
||||
@@ -67,6 +67,4 @@ export interface ClipPropertiesPanelProps {
|
||||
onOpenGreenScreenDrawer?: () => void
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void
|
||||
}
|
||||
|
||||
@@ -71,9 +71,6 @@ export {
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "./sticker"
|
||||
|
||||
/* 封面 */
|
||||
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG, type CoverTemplate } from "./cover"
|
||||
|
||||
/* 片段数据 */
|
||||
export { type ClipType, type ClipData } from "./clip"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V22 多预览 + 配音前置
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 选择标题 → 生成预览 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
@@ -24,7 +24,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { useStep4Preview } from "./hooks/useStep4Preview"
|
||||
import { useStep5Preview } from "./hooks/useStep5Preview"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -96,8 +96,8 @@ const GeneratePage: React.FC = () => {
|
||||
return id ? [id] : []
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice])
|
||||
|
||||
/* ── Step4 预览生成(多预览 + voice_ids) ── */
|
||||
const step4Preview = useStep4Preview({
|
||||
/* ── Step5 预览生成(多预览 + voice_ids) ── */
|
||||
const step5Preview = useStep5Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
@@ -108,6 +108,7 @@ const GeneratePage: React.FC = () => {
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
})
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -119,7 +120,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: step4Preview.canProceed,
|
||||
previewReady: step5Preview.canProceed,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -150,7 +151,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
previewTaskId: step4Preview.selectedTaskId,
|
||||
previewTaskId: step5Preview.selectedTaskId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
@@ -207,20 +208,18 @@ const GeneratePage: React.FC = () => {
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
/* Step4 多预览 */
|
||||
/* Step5 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step4Preview.items}
|
||||
previewSelectedIndex={step4Preview.selectedIndex}
|
||||
onSelectPreview={step4Preview.setSelectedIndex}
|
||||
previewOverallStatus={step4Preview.previewStatus}
|
||||
previewOverallError={step4Preview.previewError}
|
||||
previewOverallProgress={step4Preview.progress}
|
||||
previewAnyGenerating={step4Preview.anyGenerating}
|
||||
previewTemplateName={step4Preview.templateName}
|
||||
previewMaterialCount={step4Preview.materialCount}
|
||||
onGeneratePreview={step4Preview.generatePreview}
|
||||
onRegeneratePreview={step4Preview.regeneratePreview}
|
||||
previewItems={step5Preview.items}
|
||||
previewSelectedIndex={step5Preview.selectedIndex}
|
||||
onSelectPreview={step5Preview.setSelectedIndex}
|
||||
previewOverallStatus={step5Preview.previewStatus}
|
||||
previewOverallError={step5Preview.previewError}
|
||||
previewOverallProgress={step5Preview.progress}
|
||||
previewAnyGenerating={step5Preview.anyGenerating}
|
||||
onGeneratePreview={step5Preview.generatePreview}
|
||||
onRegeneratePreview={step5Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -236,22 +235,18 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step4+ 常驻,展示选中的预览) */}
|
||||
{/* 预览视频面板(Step4+ 显示) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step4Preview.previewStatus}
|
||||
previewResult={step4Preview.previewResult}
|
||||
previewError={step4Preview.previewError}
|
||||
progress={step4Preview.progress}
|
||||
previewStatus={step5Preview.previewStatus}
|
||||
previewResult={step5Preview.previewResult}
|
||||
previewError={step5Preview.previewError}
|
||||
progress={step5Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step4Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={currentStep >= 5 ? titleSettings : undefined}
|
||||
onRegenerate={step5Preview.regeneratePreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果(Step5+ 才显示) */}
|
||||
{currentStep >= 5 && (
|
||||
{currentStep >= 6 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step4GeneratePreview from "../components/Step4GeneratePreview"
|
||||
import Step5TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5GeneratePreview from "../components/Step5GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
@@ -74,8 +74,6 @@ export interface GenerateStepContentProps {
|
||||
previewOverallError: string
|
||||
previewOverallProgress: number
|
||||
previewAnyGenerating: boolean
|
||||
previewTemplateName: string
|
||||
previewMaterialCount: string
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
@@ -122,8 +120,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
previewOverallError,
|
||||
previewOverallProgress,
|
||||
previewAnyGenerating,
|
||||
previewTemplateName,
|
||||
previewMaterialCount,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
@@ -157,10 +153,14 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<Step4GeneratePreview
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5GeneratePreview
|
||||
videoRatio={videoRatio}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
@@ -175,13 +175,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step4 生成预览后常驻显示预览视频
|
||||
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
* Step4+: 显示预览视频面板
|
||||
* Step5+: 显示后端生成的预览视频(标题已由 FFmpeg 烧录)
|
||||
*
|
||||
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
|
||||
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
|
||||
*
|
||||
* Canvas 居中修复说明:
|
||||
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect),
|
||||
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
|
||||
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移。
|
||||
* 设计说明:
|
||||
* - Step4(标题设置页):右侧显示空状态提示,引导用户输入标题
|
||||
* - Step5(预览生成页):显示后端返回的预览视频
|
||||
* - Canvas 预览已删除(统一由后端 FFmpeg 渲染标题)
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback } from "react"
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
previewStatus: PreviewStatus
|
||||
@@ -23,134 +19,6 @@ interface PreviewVideoPanelProps {
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
/** 标题文字(Step5 起传入) */
|
||||
titleText?: string
|
||||
/** 标题样式设置(Step5 起传入) */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
/* ── Canvas 绘制工具函数 ── */
|
||||
|
||||
/**
|
||||
* 将文本按 maxWidth 逐字换行,返回行数组。
|
||||
* 与 ASS 字幕引擎的逐字换行行为一致。
|
||||
*/
|
||||
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||||
const lines: string[] = []
|
||||
let currentLine = ""
|
||||
for (const char of text) {
|
||||
const testLine = currentLine + char
|
||||
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
|
||||
lines.push(currentLine)
|
||||
currentLine = char
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push(currentLine)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
|
||||
*
|
||||
* Canvas 已通过 CSS 定位到视频实际渲染位置,
|
||||
* 坐标系基于 Canvas 自身尺寸,居中直接使用 w/2。
|
||||
*
|
||||
* @param ctx canvas 上下文
|
||||
* @param w canvas CSS 宽度(= 视频渲染宽度)
|
||||
* @param h canvas CSS 高度(= 视频渲染高度)
|
||||
* @param text 标题文字
|
||||
* @param settings 标题样式
|
||||
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
|
||||
* @param position "top" | "center" | "bottom"
|
||||
* @param topOffset 顶部/底部偏移量
|
||||
*/
|
||||
function drawTitleOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
text: string,
|
||||
settings: TitleSettings,
|
||||
paddingX: number,
|
||||
position: string,
|
||||
topOffset: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
// 设置 canvas 物理像素尺寸(高清屏适配)
|
||||
ctx.canvas.width = Math.round(w * dpr)
|
||||
ctx.canvas.height = Math.round(h * dpr)
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// 清除
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// 可用宽度 = 总宽 - 左右边距
|
||||
const availableWidth = w - paddingX * 2
|
||||
if (availableWidth <= 0) return
|
||||
|
||||
// 字体设置
|
||||
const fontSize = Math.round(Math.min(settings.size, 36))
|
||||
const fontWeight = settings.bold ? "bold" : "normal"
|
||||
const fontStyle = settings.italic ? "italic" : "normal"
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
|
||||
|
||||
// 文字属性
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillStyle = settings.color
|
||||
|
||||
const lineHeight = fontSize * 1.4
|
||||
|
||||
// 描边 & 阴影
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.6)"
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = "round"
|
||||
}
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.7)"
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 2
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
// 换行
|
||||
const displayText = text && text.trim() ? text : "请选择或输入标题"
|
||||
const lines = wrapText(ctx, displayText, availableWidth)
|
||||
|
||||
// 起始 Y:根据 position 计算
|
||||
const totalTextHeight = lines.length * lineHeight
|
||||
let startY: number
|
||||
switch (position) {
|
||||
case "top":
|
||||
startY = topOffset
|
||||
break
|
||||
case "center":
|
||||
startY = (h - totalTextHeight) / 2 + lineHeight / 2
|
||||
break
|
||||
case "bottom":
|
||||
default:
|
||||
startY = h - topOffset - totalTextHeight + lineHeight / 2
|
||||
break
|
||||
}
|
||||
|
||||
// 居中 x = w/2(Canvas 已定位到视频位置,无需额外偏移)
|
||||
const x = w / 2
|
||||
lines.forEach((line, i) => {
|
||||
const y = startY + i * lineHeight
|
||||
if (settings.stroke) ctx.strokeText(line, x, y)
|
||||
ctx.fillText(line, x, y)
|
||||
})
|
||||
|
||||
// 重置 shadow(避免影响后续绘制)
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "transparent"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 组件 ── */
|
||||
@@ -162,134 +30,12 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
progress,
|
||||
videoRatio,
|
||||
onRegenerate,
|
||||
titleText,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const showTitlePreview = !!titleSettings
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
// video 模式 refs
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
|
||||
const fontLoadedRef = useRef(false)
|
||||
|
||||
/** 在 video canvas 上绘制标题 */
|
||||
const drawVideoTitle = useCallback(() => {
|
||||
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
|
||||
if (!fontLoadedRef.current) return
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container || !titleSettings) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
if (containerRect.width <= 0 || containerRect.height <= 0) return
|
||||
|
||||
// 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
|
||||
const videoEl = videoRef.current
|
||||
let drawW = containerRect.width
|
||||
let drawH = containerRect.height
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
|
||||
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
|
||||
const videoRect = videoEl.getBoundingClientRect()
|
||||
drawW = videoRect.width
|
||||
drawH = videoRect.height
|
||||
offsetX = videoRect.left - containerRect.left
|
||||
offsetY = videoRect.top - containerRect.top
|
||||
}
|
||||
|
||||
// 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
|
||||
canvas.style.left = `${offsetX}px`
|
||||
canvas.style.top = `${offsetY}px`
|
||||
canvas.style.width = `${drawW}px`
|
||||
canvas.style.height = `${drawH}px`
|
||||
|
||||
// 绘制时,坐标系基于 Canvas 自身尺寸,无需额外偏移
|
||||
drawTitleOnCanvas(
|
||||
ctx,
|
||||
drawW,
|
||||
drawH,
|
||||
titleText || "",
|
||||
titleSettings,
|
||||
40,
|
||||
titleSettings.position,
|
||||
60,
|
||||
)
|
||||
}, [titleText, titleSettings])
|
||||
|
||||
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
|
||||
useEffect(() => {
|
||||
if (!showTitlePreview || !hasPreview) return
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
drawVideoTitle()
|
||||
})
|
||||
observer.observe(container)
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [showTitlePreview, hasPreview, drawVideoTitle])
|
||||
|
||||
// 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体
|
||||
useEffect(() => {
|
||||
if (!showTitlePreview || !titleSettings) {
|
||||
fontLoadedRef.current = false
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fontLoadedRef.current = false
|
||||
|
||||
const fontWeight = titleSettings.bold ? "bold" : ""
|
||||
const fontStyle = titleSettings.italic ? "italic" : ""
|
||||
const fontSpec =
|
||||
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
|
||||
|
||||
const onFontReady = () => {
|
||||
if (cancelled) return
|
||||
fontLoadedRef.current = true
|
||||
// ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态)
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) {
|
||||
drawVideoTitle()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (document.fonts.check(fontSpec)) {
|
||||
onFontReady()
|
||||
return
|
||||
}
|
||||
|
||||
document.fonts
|
||||
.load(fontSpec)
|
||||
.then(() => onFontReady())
|
||||
.catch(() => {
|
||||
document.fonts.ready.then(() => onFontReady())
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [showTitlePreview, titleSettings, drawVideoTitle])
|
||||
|
||||
// video 加载完成后重绘
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
if (showTitlePreview) {
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
}
|
||||
}, [showTitlePreview, drawVideoTitle])
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
@@ -304,7 +50,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<p className="xx-preview-empty-title">暂无预览</p>
|
||||
<p className="xx-preview-empty-desc">在第 3 步生成预览后在此查看</p>
|
||||
<p className="xx-preview-empty-desc">在左侧生成预览后在此查看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -340,54 +86,32 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览成功 + Canvas 标题叠加 */}
|
||||
{/* 预览成功 */}
|
||||
{hasPreview && (
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<>
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
onLoadedMetadata={handleVideoLoaded}
|
||||
/>
|
||||
<video src={previewResult.videoUrl} controls preload="metadata" />
|
||||
</div>
|
||||
{showTitlePreview && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览信息 */}
|
||||
{hasPreview && previewResult && (
|
||||
<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 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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+43
-61
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Step 4 生成预览组件(支持多预览)
|
||||
* Step 5 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
*/
|
||||
import React from "react"
|
||||
@@ -12,12 +12,9 @@ import {
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
|
||||
interface Step4GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
interface Step5GeneratePreviewProps {
|
||||
videoRatio: string
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
@@ -39,9 +36,7 @@ const PREVIEW_COUNT_OPTIONS = [
|
||||
{ value: 3, label: "3个" },
|
||||
]
|
||||
|
||||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
@@ -161,54 +156,57 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{/* 缩略图/状态区域 */}
|
||||
{/* 轻量卡片:深色背景 + 状态指示 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio,
|
||||
background: "#000",
|
||||
background: "#1a1a2e",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{item.status === "ready" && item.result && (
|
||||
<video
|
||||
src={item.result.videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(item.status === "pending" || item.status === "generating") && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
|
||||
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
|
||||
</p>
|
||||
{/* 中心:预览编号 */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</span>
|
||||
|
||||
{/* 状态指示 */}
|
||||
{item.status === "generating" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<LoadingOutlined style={{ fontSize: 14, color: "#fff" }} spin />
|
||||
<span style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
|
||||
生成中 {item.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "pending" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<ClockCircleOutlined
|
||||
style={{ fontSize: 14, color: "rgba(255,255,255,0.6)" }}
|
||||
/>
|
||||
<span style={{ color: "rgba(255,255,255,0.6)", fontSize: 12 }}>
|
||||
排队中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "ready" && (
|
||||
<CheckCircleFilled style={{ fontSize: 18, color: "#52c41a" }} />
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
生成失败
|
||||
</p>
|
||||
</div>
|
||||
<ExclamationCircleFilled style={{ fontSize: 18, color: "#ef4444" }} />
|
||||
)}
|
||||
|
||||
{/* 选中角标 */}
|
||||
{isSelected && item.status === "ready" && (
|
||||
<div
|
||||
@@ -227,22 +225,6 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息 */}
|
||||
{item.status === "ready" && item.result && (
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
background: "#fafafa",
|
||||
fontSize: 11,
|
||||
color: "#666",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>{item.result.duration.toFixed(1)}秒</span>
|
||||
<span>{item.result.clipCount}段</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -291,4 +273,4 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4GeneratePreview
|
||||
export default Step5GeneratePreview
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { Modal, Spin } from "antd"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
@@ -18,6 +19,7 @@ interface Step6CoverSettingsProps {
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
@@ -95,6 +97,14 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
/>
|
||||
|
||||
{/* AI 生成封面进度弹窗 */}
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI 正在生成封面,请稍候...</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react"
|
||||
import type { CoverTemplate } from "../../../editing-planner/types"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import type { CoverMode } from "../../../editing-planner/types"
|
||||
import type { CoverMode } from "../../types/cover"
|
||||
|
||||
interface CoverModeSelectorProps {
|
||||
mode: CoverMode
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import type { CoverTemplate } from "../../../editing-planner/types"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 智能剪辑页面 — 常量定义
|
||||
*/
|
||||
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
import type { CoverConfig } from "./types/cover"
|
||||
|
||||
/* ── 克隆声音状态配置 ── */
|
||||
export const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
@@ -32,8 +32,8 @@ export const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "生成预览" },
|
||||
{ key: 5, label: "选择标题" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "生成预览" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
/** useGenerateVideo 入参 */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseTitleCoverSyncOptions {
|
||||
|
||||
@@ -56,10 +56,41 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
try {
|
||||
// 使用确认生成 API(基于预览任务)
|
||||
// 解析分辨率
|
||||
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
|
||||
const outputWidth = parseInt(widthStr, 10) || 1080
|
||||
const outputHeight = parseInt(heightStr, 10) || 1920
|
||||
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
|
||||
const ratio = props.videoRatio || "9:16"
|
||||
let outputWidth: number
|
||||
let outputHeight: number
|
||||
|
||||
if (ratio.includes(":")) {
|
||||
// 宽高比格式,如 "9:16" → 基于基准高度 1920 计算
|
||||
const [rw, rh] = ratio.split(":").map(Number)
|
||||
if (rw > 0 && rh > 0) {
|
||||
// 基准:长边 1920,短边按比例计算
|
||||
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
|
||||
const baseLong = 1920
|
||||
const baseShort = Math.round((baseLong * shortSide) / longSide)
|
||||
// 确保偶数(FFmpeg 要求)
|
||||
const evenShort = baseShort - (baseShort % 2)
|
||||
if (rw < rh) {
|
||||
outputWidth = evenShort
|
||||
outputHeight = baseLong
|
||||
} else {
|
||||
outputWidth = baseLong
|
||||
outputHeight = evenShort
|
||||
}
|
||||
} else {
|
||||
outputWidth = 1080
|
||||
outputHeight = 1920
|
||||
}
|
||||
} else if (ratio.includes("x")) {
|
||||
// 分辨率格式,如 "1080x1920"
|
||||
const [wStr, hStr] = ratio.split("x")
|
||||
outputWidth = parseInt(wStr, 10) || 1080
|
||||
outputHeight = parseInt(hStr, 10) || 1920
|
||||
} else {
|
||||
outputWidth = 1080
|
||||
outputHeight = 1920
|
||||
}
|
||||
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
output_width: outputWidth,
|
||||
@@ -78,7 +109,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, selectedTemplate, clearTimer, startPolling])
|
||||
}, [props, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
+114
-78
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Step 4 生成预览 Hook(支持多预览 + voice_ids)
|
||||
* Step 5 生成预览 Hook(支持多预览 + voice_ids)
|
||||
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import { updateEditPlan } from "@/api/template-editor/editPlans"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
@@ -25,7 +27,7 @@ const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep4PreviewProps {
|
||||
interface UseStep5PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -39,6 +41,8 @@ interface UseStep4PreviewProps {
|
||||
voiceLibraryId?: string
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
/** 标题设置(传递给后端,让预览视频包含标题) */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
@@ -77,7 +81,7 @@ const createInitialItem = (index: number): PreviewItem => ({
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
export function useStep4Preview({
|
||||
export function useStep5Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
@@ -88,7 +92,8 @@ export function useStep4Preview({
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
titleSettings,
|
||||
}: UseStep5PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
@@ -152,6 +157,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
titleSettings: titleSettings?.title || "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -163,6 +169,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
[...(voiceIds || [])].sort().join(","),
|
||||
titleSettings?.title || "",
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
@@ -173,6 +180,7 @@ export function useStep4Preview({
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
prevDepsRef.current.voiceIds,
|
||||
prevDepsRef.current.titleSettings,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
|
||||
@@ -190,6 +198,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
titleSettings: titleSettings?.title || "",
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
@@ -201,6 +210,7 @@ export function useStep4Preview({
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
])
|
||||
|
||||
// 组件卸载时清理所有轮询
|
||||
@@ -211,90 +221,102 @@ export function useStep4Preview({
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询单个预览任务状态 */
|
||||
const pollPreviewStatus = useCallback((index: number, taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 竞态检查
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
const pollPreviewStatus = useCallback(
|
||||
(index: number, taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 竞态检查
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
const result: PreviewResult = {
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
const result: PreviewResult = {
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
}
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||||
),
|
||||
)
|
||||
|
||||
// 保存预览视频 URL 到 plan config,供封面生成使用
|
||||
if (result.videoUrl && selectedTemplate) {
|
||||
updateEditPlan(selectedTemplate, {
|
||||
config: { rendered_storage_key: result.videoUrl },
|
||||
}).catch((err) => {
|
||||
console.warn("[Step4] 保存预览视频URL到plan config失败:", err)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index
|
||||
? {
|
||||
...it,
|
||||
status: "error",
|
||||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||||
}
|
||||
: it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
const prog = safeNumber(data.progress)
|
||||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
const delay = status === "pending" ? 5000 : 2000
|
||||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||||
} catch {
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index
|
||||
? {
|
||||
...it,
|
||||
status: "error",
|
||||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||||
}
|
||||
: it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
const prog = safeNumber(data.progress)
|
||||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||||
),
|
||||
)
|
||||
const delay = status === "pending" ? 5000 : 2000
|
||||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||||
} catch {
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||||
}, [])
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||||
},
|
||||
[selectedTemplate],
|
||||
)
|
||||
|
||||
/** 生成所有预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
@@ -336,6 +358,19 @@ export function useStep4Preview({
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
voice_library_id: voiceLibraryId || undefined,
|
||||
// 标题烧录配置
|
||||
title_config: titleSettings?.title
|
||||
? {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
@@ -361,6 +396,7 @@ export function useStep4Preview({
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
@@ -437,4 +473,4 @@ export function useStep4Preview({
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Preview
|
||||
export default useStep5Preview
|
||||
@@ -2,10 +2,10 @@
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../../editing-planner/types"
|
||||
import { generateCover } from "@/api/template-editor"
|
||||
import type { CoverConfig, CoverTemplate } from "../types/cover"
|
||||
import { generateCover } from "@/api/generation"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
@@ -30,7 +30,7 @@ export function useStep6Cover({
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
}: UseStep6CoverProps) {
|
||||
const generatingRef = useRef(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
// ── 封面设置弹窗状态 ──
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
@@ -67,7 +67,7 @@ export function useStep6Cover({
|
||||
|
||||
/** 调用后端智能封面 API,生成封面并更新预览 */
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (generatingRef.current) {
|
||||
if (generating) {
|
||||
message.warning("封面正在生成中,请稍候...")
|
||||
return
|
||||
}
|
||||
@@ -82,12 +82,17 @@ export function useStep6Cover({
|
||||
return
|
||||
}
|
||||
|
||||
generatingRef.current = true
|
||||
setGenerating(true)
|
||||
// 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
|
||||
const timeoutId = setTimeout(() => {
|
||||
setGenerating(false)
|
||||
}, 300000)
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
const thumbnailUrl = response.cover?.image_url || ""
|
||||
if (thumbnailUrl) {
|
||||
onCoverSettingsChange({
|
||||
@@ -100,12 +105,35 @@ export function useStep6Cover({
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Step6] 智能封面生成失败:", err)
|
||||
message.error("封面生成失败,请稍后重试")
|
||||
clearTimeout(timeoutId)
|
||||
console.error("[Step6] 智能封面生成失败:", err)
|
||||
|
||||
// 提取详细错误信息
|
||||
let errorMsg = "封面生成失败"
|
||||
const e = err as {
|
||||
response?: { data?: { detail?: string; message?: string }; status?: number }
|
||||
request?: unknown
|
||||
message?: string
|
||||
}
|
||||
if (e.response) {
|
||||
// 后端返回错误
|
||||
const detail = e.response.data?.detail || e.response.data?.message || ""
|
||||
errorMsg = detail || `后端错误 (${e.response.status})`
|
||||
console.error("[Step6] 后端返回:", e.response.data)
|
||||
} else if (e.request) {
|
||||
// 请求已发送但无响应
|
||||
errorMsg = "服务器无响应,请检查网络连接"
|
||||
console.error("[Step6] 请求无响应:", e.request)
|
||||
} else if (e.message) {
|
||||
errorMsg = e.message
|
||||
}
|
||||
|
||||
message.error(errorMsg)
|
||||
} finally {
|
||||
generatingRef.current = false
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange])
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
@@ -165,6 +193,7 @@ export function useStep6Cover({
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
totalDuration,
|
||||
showCoverSettings,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -49,12 +49,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
return
|
||||
}
|
||||
// Step3 配音:配音为可选项,不强制校验,用户可跳过
|
||||
if (currentStep === 4 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (currentStep === 5 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
if (currentStep === 5 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 封面配置类型
|
||||
* 智能剪辑封面类型定义
|
||||
* 独立于 editing-planner,仅供 generate 模块使用
|
||||
*/
|
||||
|
||||
/** 封面来源模式 */
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
aiRecommendClips,
|
||||
generateCover,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
@@ -182,22 +181,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateCover", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(generateCover("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(generateCover("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEditPlanGenerations", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlanGenerations("test-planId")).resolves.not.toThrow()
|
||||
|
||||
@@ -149,7 +149,6 @@ vi.mock("@/api/editing-planner", () => ({
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
generateCover: vi.fn().mockResolvedValue({}),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
@@ -223,9 +222,6 @@ vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({
|
||||
vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "StickerPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "CoverSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
|
||||
}))
|
||||
@@ -260,7 +256,6 @@ vi.mock("@/pages/editing-planner/types", () => ({
|
||||
DEFAULT_FILTER_CONFIG: { enabled: false },
|
||||
DEFAULT_CHROMA_KEY_CONFIG: { enabled: false },
|
||||
DEFAULT_STICKER_CONFIG: { enabled: false },
|
||||
DEFAULT_COVER_CONFIG: { enabled: false },
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/editing-planner/types/subtitle", () => ({
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import CoverSelector from "@/pages/editing-planner/components/CoverSelector"
|
||||
import { DEFAULT_COVER_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: DEFAULT_COVER_CONFIG,
|
||||
onChange: vi.fn(),
|
||||
totalDuration: 60,
|
||||
}
|
||||
|
||||
describe("CoverSelector", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<CoverSelector {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<CoverSelector {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,10 @@ const defaultProps = {
|
||||
clips: [],
|
||||
selectedClipId: null,
|
||||
isPlaying: false,
|
||||
currentCoverScheme: "scheme1",
|
||||
coverSchemes: [{ id: "scheme1", name: "方案1", cover_url: "" }],
|
||||
aiCoverLoading: false,
|
||||
titleSettings: undefined,
|
||||
subtitleSettings: undefined,
|
||||
onClipSelect: vi.fn(),
|
||||
onCoverSchemeChange: vi.fn(),
|
||||
onPlayPause: vi.fn(),
|
||||
onAiGenerateCover: vi.fn(),
|
||||
}
|
||||
|
||||
describe("PreviewPlayer", () => {
|
||||
|
||||
@@ -28,7 +28,6 @@ import "@/pages/editing-planner/utils/clipProperties"
|
||||
// 子组件
|
||||
import "@/pages/editing-planner/components/BgmSelector"
|
||||
import "@/pages/editing-planner/components/ClipPropertiesPanel"
|
||||
import "@/pages/editing-planner/components/CoverSelector"
|
||||
import "@/pages/editing-planner/components/EditorClipList"
|
||||
import "@/pages/editing-planner/components/EditingDrawers"
|
||||
import "@/pages/editing-planner/components/FilterPanel"
|
||||
|
||||
@@ -17,7 +17,7 @@ import "@/api/generation/types"
|
||||
// 直接引入所有 Step 组件,建立完整依赖链
|
||||
import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step4GeneratePreview"
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/PreviewVideoPanel"
|
||||
@@ -47,7 +47,7 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useStep4Preview"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Step4GeneratePreview smoke test
|
||||
* 确保 vitest related 模式能匹配到第4步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step4GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep4Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step4GeneratePreview module smoke test", () => {
|
||||
it("should load all step4 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Step5GeneratePreview smoke test
|
||||
* 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step5GeneratePreview module smoke test", () => {
|
||||
it("should load all step5 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,431 +0,0 @@
|
||||
"""视频封面生成器 — 从视频中提取/生成封面图.
|
||||
|
||||
支持能力:
|
||||
- 指定时间点抽帧(默认第1秒)
|
||||
- 智能封面:抽取多帧选最清晰的一帧
|
||||
- 自定义上传封面图(直接返回路径)
|
||||
- 生成的封面图保存为 JPEG 格式,可复用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 配置常量 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# 智能封面抽帧数量
|
||||
SMART_COVER_FRAME_COUNT = 3
|
||||
|
||||
# 默认抽帧时间点(秒)
|
||||
DEFAULT_COVER_TIME = 1.0
|
||||
|
||||
# 封面输出尺寸(宽x高)
|
||||
DEFAULT_COVER_WIDTH = 1080
|
||||
DEFAULT_COVER_HEIGHT = 1920
|
||||
|
||||
# 封面质量(JPEG quality 1-31,越小质量越高)
|
||||
DEFAULT_COVER_QUALITY = 5
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverGenerator:
|
||||
"""视频封面生成器.
|
||||
|
||||
三种模式:
|
||||
1. 指定时间点抽帧:从视频指定时间提取一帧
|
||||
2. 智能封面:抽取3帧,用 blur 检测选最清晰的
|
||||
3. 自定义上传:直接使用用户上传的图片
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract_frame(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
time_sec: float = DEFAULT_COVER_TIME,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""从视频指定时间点提取一帧作为封面.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径
|
||||
time_sec: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量(1-31,越小越好)
|
||||
|
||||
Returns:
|
||||
封面图片路径
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 视频文件不存在
|
||||
subprocess.CalledProcessError: FFmpeg 执行失败
|
||||
"""
|
||||
video_path = Path(video_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not video_path.exists():
|
||||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||||
|
||||
# 确保输出目录存在
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 安全钳制时间
|
||||
info = probe_video_info(str(video_path))
|
||||
duration = info.get("duration", 0.0)
|
||||
if duration > 0 and time_sec >= duration:
|
||||
# 超过视频长度,取中间帧
|
||||
time_sec = max(0, duration / 2)
|
||||
if time_sec < 0:
|
||||
time_sec = 0
|
||||
|
||||
# scale + crop 实现 cover 裁剪(铺满输出尺寸)
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("抽取视频封面: video=%s time=%.2fs output=%s", video_path.name, time_sec, output_path.name)
|
||||
run_ffmpeg(command)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError(f"封面生成失败: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def extract_smart_cover(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
frame_count: int = SMART_COVER_FRAME_COUNT,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
work_dir: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""智能封面:抽取多帧,选最清晰的一帧.
|
||||
|
||||
清晰度判断:使用拉普拉斯方差(Variance of Laplacian),
|
||||
方差越大表示图像边缘越丰富,越清晰。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 最终输出封面路径
|
||||
frame_count: 抽帧数量(均匀分布在视频中)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
work_dir: 临时工作目录(默认输出目录的父目录)
|
||||
|
||||
Returns:
|
||||
最佳封面图片路径
|
||||
"""
|
||||
video_path = Path(video_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not video_path.exists():
|
||||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||||
|
||||
# 获取视频时长
|
||||
info = probe_video_info(str(video_path))
|
||||
duration = info.get("duration", 0.0)
|
||||
|
||||
if duration <= 0 or frame_count <= 1:
|
||||
# 无法获取时长或只有1帧,退化为普通抽帧
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=min(DEFAULT_COVER_TIME, max(0, duration / 2)),
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
# 临时目录
|
||||
if work_dir is None:
|
||||
work_dir = output_path.parent
|
||||
work_dir = Path(work_dir)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 均匀分布抽帧时间点(跳过首尾5%)
|
||||
start_pct = 0.05
|
||||
end_pct = 0.95
|
||||
if frame_count == 1:
|
||||
time_points = [duration * 0.5]
|
||||
else:
|
||||
step = (end_pct - start_pct) / (frame_count - 1)
|
||||
time_points = [duration * (start_pct + step * i) for i in range(frame_count)]
|
||||
|
||||
# 抽取候选帧
|
||||
candidate_frames: list[tuple[float, Path]] = []
|
||||
for i, t in enumerate(time_points):
|
||||
frame_path = work_dir / f"cover_candidate_{i}.jpg"
|
||||
try:
|
||||
CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
frame_path,
|
||||
time_sec=t,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
candidate_frames.append((t, frame_path))
|
||||
except Exception as e:
|
||||
logger.warning("智能封面抽帧失败(t=%.2fs): %s", t, e)
|
||||
continue
|
||||
|
||||
if not candidate_frames:
|
||||
# 全部失败,退化到普通抽帧
|
||||
logger.warning("智能封面所有候选帧抽取失败,退化为普通抽帧")
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=min(DEFAULT_COVER_TIME, duration / 2),
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
if len(candidate_frames) == 1:
|
||||
# 只有一帧,直接用
|
||||
import shutil
|
||||
|
||||
shutil.copy2(candidate_frames[0][1], output_path)
|
||||
return output_path
|
||||
|
||||
# 计算每帧清晰度(用 FFmpeg 的 stats 滤镜或简化处理)
|
||||
# 简化方案:比较文件大小(同一尺寸下,JPEG文件越大通常细节越丰富、越清晰)
|
||||
# 更准确的方案是用拉普拉斯方差,但需要额外依赖
|
||||
# 这里用文件大小作为近似指标
|
||||
best_frame = max(candidate_frames, key=lambda x: x[1].stat().st_size)
|
||||
|
||||
# 复制最佳帧到输出路径
|
||||
import shutil
|
||||
|
||||
shutil.copy2(best_frame[1], output_path)
|
||||
|
||||
logger.info(
|
||||
"智能封面生成完成: 候选%d帧, 最佳t=%.2fs, 大小=%d字节",
|
||||
len(candidate_frames),
|
||||
best_frame[0],
|
||||
output_path.stat().st_size,
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
for _, fp in candidate_frames:
|
||||
try:
|
||||
fp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def process_custom_cover(
|
||||
image_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""处理用户自定义上传的封面图.
|
||||
|
||||
调整尺寸、格式转换为标准封面格式。
|
||||
|
||||
Args:
|
||||
image_path: 用户上传的图片路径
|
||||
output_path: 输出封面路径
|
||||
width: 目标宽度
|
||||
height: 目标高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
处理后的封面图片路径
|
||||
"""
|
||||
image_path = Path(image_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"封面图片不存在: {image_path}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# scale + crop 实现 cover 裁剪
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(image_path),
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("处理自定义封面: input=%s output=%s", image_path.name, output_path.name)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
except subprocess.CalledProcessError:
|
||||
# 处理失败,直接复制原图
|
||||
logger.warning("自定义封面处理失败,使用原图")
|
||||
import shutil
|
||||
|
||||
shutil.copy2(image_path, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def generate_cover(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
mode: str = "smart", # smart / time / custom
|
||||
time_sec: float = DEFAULT_COVER_TIME,
|
||||
custom_image: str | Path | None = None,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""统一封面生成入口.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出封面路径
|
||||
mode: 模式 - smart(智能选帧)/ time(指定时间)/ custom(自定义图片)
|
||||
time_sec: time 模式下的抽帧时间点
|
||||
custom_image: custom 模式下的自定义图片路径
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面图片路径
|
||||
"""
|
||||
if mode == "custom" and custom_image:
|
||||
return CoverGenerator.process_custom_cover(
|
||||
custom_image,
|
||||
output_path,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
elif mode == "time":
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=time_sec,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
else:
|
||||
# 默认智能封面
|
||||
return CoverGenerator.extract_smart_cover(
|
||||
video_path,
|
||||
output_path,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_cover_from_plan(
|
||||
plan: Any,
|
||||
video_path: str | Path,
|
||||
output_dir: str | Path,
|
||||
) -> Path | None:
|
||||
"""从 EditPlan 配置生成封面图.
|
||||
|
||||
配置读取:plan.config.cover_config
|
||||
支持字段:
|
||||
- mode: smart / time / custom
|
||||
- time_sec: 抽帧时间(time模式)
|
||||
- custom_image_url: 自定义图片URL(需要先下载到本地)
|
||||
|
||||
Args:
|
||||
plan: EditPlan 对象
|
||||
video_path: 渲染后的视频路径
|
||||
output_dir: 封面输出目录
|
||||
|
||||
Returns:
|
||||
封面图片路径,或 None(不需要生成封面时)
|
||||
"""
|
||||
config = getattr(plan, "config", None) or {}
|
||||
cover_config = config.get("cover_config") if isinstance(config, dict) else None
|
||||
|
||||
if not cover_config:
|
||||
return None
|
||||
|
||||
mode = cover_config.get("mode", "smart")
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / f"cover_{plan.id}.jpg"
|
||||
|
||||
try:
|
||||
if mode == "custom":
|
||||
# 自定义封面:需要先有本地图片路径
|
||||
custom_path = cover_config.get("custom_image_path")
|
||||
if custom_path and Path(custom_path).exists():
|
||||
return CoverGenerator.process_custom_cover(
|
||||
custom_path,
|
||||
output_path,
|
||||
)
|
||||
else:
|
||||
logger.warning("自定义封面图片路径无效,退化为智能封面")
|
||||
mode = "smart"
|
||||
|
||||
if mode == "time":
|
||||
time_sec = float(cover_config.get("time_sec", DEFAULT_COVER_TIME))
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=time_sec,
|
||||
)
|
||||
else:
|
||||
# smart
|
||||
return CoverGenerator.extract_smart_cover(
|
||||
video_path,
|
||||
output_path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("封面生成失败: %s", e)
|
||||
return None
|
||||
@@ -85,19 +85,9 @@ def create_video_record_and_dedup(
|
||||
if thumbnail_url:
|
||||
generated_video.thumbnail_url = thumbnail_url
|
||||
video_repo.update_thumbnail(video_id, thumbnail_url)
|
||||
logger.info("Thumbnail reused (pre-generated) for video %s", video_id)
|
||||
logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80] if thumbnail_url else "")
|
||||
else:
|
||||
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
_thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
|
||||
if _thumbnail_url:
|
||||
generated_video.thumbnail_url = _thumbnail_url
|
||||
video_repo.update_thumbnail(video_id, _thumbnail_url)
|
||||
logger.info("Thumbnail generated for video %s: %s", video_id, _thumbnail_url)
|
||||
except Exception as thumb_err:
|
||||
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
|
||||
logger.debug("No thumbnail_url provided for video %s, skipping", video_id)
|
||||
|
||||
# 计算视频指纹
|
||||
deduplicator = VideoDeduplicator()
|
||||
|
||||
@@ -47,7 +47,14 @@ def _parse_resolution(resolution_str: str | None) -> tuple[int, int]:
|
||||
w, h = resolution_str.lower().split("x", 1)
|
||||
width = int(w.strip())
|
||||
height = int(h.strip())
|
||||
if width <= 0 or height <= 0:
|
||||
# 最小 100px 防护:避免前端传入宽高比(如 "9:16")被 parseInt 截断为极小值
|
||||
if width < 100 or height < 100:
|
||||
logger.warning(
|
||||
"分辨率异常小 (%dx%d),使用默认值。原始值: %s",
|
||||
width,
|
||||
height,
|
||||
resolution_str,
|
||||
)
|
||||
return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT
|
||||
return width, height
|
||||
except (ValueError, TypeError):
|
||||
@@ -74,6 +81,9 @@ class RenderAdapterResult:
|
||||
failed_clip_ids: list[str] = None # 失败的 clip id 列表
|
||||
error_message: str = ""
|
||||
error_detail: str = "" # 详细错误信息(如 ffmpeg stderr),用于排查
|
||||
cover_candidates: list[dict] | None = (
|
||||
None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.rendered_clip_ids is None:
|
||||
@@ -509,6 +519,8 @@ class RenderAdapter:
|
||||
|
||||
# 3. 读取输出分辨率
|
||||
export_config = plan_config.get("export", {}) or {}
|
||||
if not isinstance(export_config, dict):
|
||||
export_config = {}
|
||||
output_width, output_height = _parse_resolution(export_config.get("resolution"))
|
||||
logger.info(
|
||||
"渲染输出分辨率: plan_id=%s resolution=%dx%d source=%s",
|
||||
@@ -568,6 +580,33 @@ class RenderAdapter:
|
||||
thumb_err,
|
||||
)
|
||||
|
||||
# 7. 抽取封面候选帧并上传 OSS(失败不阻断主流程)
|
||||
cover_candidates = None
|
||||
try:
|
||||
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
|
||||
|
||||
# 从 plan config 提取标题文字,叠加到封面候选帧上
|
||||
_title_cfg = (plan_config or {}).get("title", {}) or {}
|
||||
if not isinstance(_title_cfg, dict):
|
||||
_title_cfg = {}
|
||||
_title_text = (_title_cfg.get("text", "") or "").strip() if _title_cfg.get("enabled", True) else ""
|
||||
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=_title_text
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(cover_candidates),
|
||||
)
|
||||
except Exception as cover_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 封面候选帧生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
cover_err,
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
|
||||
logger.info(
|
||||
@@ -599,6 +638,7 @@ class RenderAdapter:
|
||||
clip_count=len(clips),
|
||||
rendered_clip_ids=final_rendered_ids,
|
||||
failed_clip_ids=final_failed_ids,
|
||||
cover_candidates=cover_candidates,
|
||||
)
|
||||
|
||||
def render_from_memory(
|
||||
|
||||
@@ -131,7 +131,7 @@ def mix_audio(
|
||||
|
||||
if not main_clips and not audio_clips:
|
||||
# 没有主音频也没有独立音频 → 检查是否有 BGM
|
||||
if bgm_path and bgm_config and bgm_config.get("enabled", False):
|
||||
if bgm_path and bgm_config and isinstance(bgm_config, dict) and bgm_config.get("enabled", False):
|
||||
from video_processing.bgm_mixer import BGMConfig, build_bgm_only
|
||||
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
|
||||
@@ -161,7 +161,7 @@ def mix_audio(
|
||||
mix_with_independent_audio(ctx, effective_main, effective_audio, output_path, video_duration)
|
||||
|
||||
# ── BGM 混音 ──
|
||||
if bgm_path and bgm_config and bgm_config.get("enabled", False):
|
||||
if bgm_path and bgm_config and isinstance(bgm_config, dict) and bgm_config.get("enabled", False):
|
||||
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
|
||||
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
|
||||
@@ -174,7 +174,7 @@ def mix_audio(
|
||||
logger.exception("[bgm] BGM 混音失败,回退到无 BGM 音频: plan_id=%s", ctx.plan_id)
|
||||
|
||||
# ── 多轨道混音(配音/音效等) ──
|
||||
if audio_tracks_config and audio_tracks_config.get("enabled", False):
|
||||
if audio_tracks_config and isinstance(audio_tracks_config, dict) and audio_tracks_config.get("enabled", False):
|
||||
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
|
||||
|
||||
try:
|
||||
|
||||
@@ -33,7 +33,7 @@ class ReverseConfig:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "ReverseConfig":
|
||||
"""从字典解析配置."""
|
||||
if not data:
|
||||
if not isinstance(data, dict):
|
||||
return cls(enabled=False)
|
||||
try:
|
||||
if not data.get("enabled", False):
|
||||
|
||||
@@ -13,6 +13,16 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.ass_subtitle_builder import (
|
||||
TITLE_MARGIN_SIDE,
|
||||
TITLE_MARGIN_TOP,
|
||||
_wrap_title_text,
|
||||
build_ass_style,
|
||||
escape_ass_text,
|
||||
format_ass_time,
|
||||
hex_to_ass_color,
|
||||
position_to_ass_alignment,
|
||||
)
|
||||
from packages.domain.subtitle import SubtitleTimeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -100,7 +110,10 @@ def generate_ass_from_timeline(
|
||||
*,
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
video_duration: float = 0.0,
|
||||
subtitle_config: dict[str, Any] | None = None,
|
||||
title_text: str = "",
|
||||
title_config: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""从字幕时间轴生成 ASS 字幕文件。
|
||||
|
||||
@@ -160,7 +173,76 @@ def generate_ass_from_timeline(
|
||||
|
||||
events.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{safe_text}")
|
||||
|
||||
# 组装 ASS 文件
|
||||
# ── 标题样式与事件(叠加在 ASR 字幕之上)───────────────────────────
|
||||
title_cfg = title_config or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
|
||||
|
||||
title_style_line = ""
|
||||
title_event_line = ""
|
||||
|
||||
if title_enabled:
|
||||
# 兼容 boolean stroke/shadow → dict
|
||||
_stroke_val = title_cfg.get("stroke")
|
||||
if isinstance(_stroke_val, bool):
|
||||
title_cfg["stroke"] = (
|
||||
{"enabled": _stroke_val, "color": "#000000", "width": 2} if _stroke_val else {"enabled": False}
|
||||
)
|
||||
_shadow_val = title_cfg.get("shadow")
|
||||
if isinstance(_shadow_val, bool):
|
||||
title_cfg["shadow"] = (
|
||||
{"enabled": _shadow_val, "color": "#000000", "blur": 4, "offset_x": 2, "offset_y": 2}
|
||||
if _shadow_val
|
||||
else {"enabled": False}
|
||||
)
|
||||
|
||||
# 字段名归一化: font_size→size, font_color→color
|
||||
if "font_size" in title_cfg and "size" not in title_cfg:
|
||||
title_cfg["size"] = title_cfg["font_size"]
|
||||
if "font_color" in title_cfg and "color" not in title_cfg:
|
||||
title_cfg["color"] = title_cfg["font_color"]
|
||||
|
||||
t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff"))
|
||||
t_stroke = title_cfg.get("stroke", {}) or {}
|
||||
t_shadow = title_cfg.get("shadow", {}) or {}
|
||||
s_color = hex_to_ass_color(t_stroke.get("color", "#000000"))
|
||||
s_width = float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0
|
||||
sh_blur = float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0
|
||||
sh_offset = (
|
||||
t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0,
|
||||
t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0,
|
||||
)
|
||||
t_alignment = position_to_ass_alignment(title_cfg.get("position", "top"))
|
||||
|
||||
title_style_line = build_ass_style(
|
||||
"TitleStyle",
|
||||
font_name=title_cfg.get("font", "思源黑体"),
|
||||
font_size=min(int(title_cfg.get("size", 36)), 36),
|
||||
primary_color=t_color,
|
||||
outline_color=s_color,
|
||||
outline_width=s_width,
|
||||
shadow_blur=sh_blur,
|
||||
shadow_offset=sh_offset,
|
||||
bold=bool(title_cfg.get("bold", True)),
|
||||
italic=bool(title_cfg.get("italic", False)),
|
||||
alignment=t_alignment,
|
||||
margin_v=TITLE_MARGIN_TOP,
|
||||
margin_l=TITLE_MARGIN_SIDE,
|
||||
margin_r=TITLE_MARGIN_SIDE,
|
||||
)
|
||||
|
||||
t_font_size = min(int(title_cfg.get("size", 36)), 36)
|
||||
safe_raw = escape_ass_text(title_text.strip())
|
||||
safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size)
|
||||
|
||||
if video_duration > 0:
|
||||
t_end_time = format_ass_time(video_duration)
|
||||
else:
|
||||
t_end_time = format_ass_time((timeline.segments[-1].end + 5.0) if timeline.segments else 60.0)
|
||||
title_event_line = f"Dialogue: 0,0:00:00.00,{t_end_time},TitleStyle,,0,0,0,,{safe_wrapped}"
|
||||
|
||||
# 组装 ASS 文件
|
||||
ass_content = f"""[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: {video_width}
|
||||
@@ -170,12 +252,12 @@ WrapStyle: 2
|
||||
Encoding: UTF-8
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
{style_line}
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
|
||||
{chr(10).join(filter(None, [title_style_line, style_line]))}
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
{chr(10).join(events)}
|
||||
{chr(10).join(filter(None, [title_event_line] + events))}
|
||||
"""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""视频缩略图生成工具 — 抽取首帧上传到 OSS。"""
|
||||
"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。
|
||||
|
||||
统一封面管道:视频渲染时标题已通过 ASS 字幕烧进视频,
|
||||
渲染完成后直接从此视频抽帧,封面天然带标题,无需额外叠加逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,28 +17,30 @@ def extract_first_frame(
|
||||
video_path: str,
|
||||
output_path: str | None = None,
|
||||
*,
|
||||
width: int = 640,
|
||||
width: int = -1,
|
||||
height: int = -1,
|
||||
timeout: int = 30,
|
||||
seek_ratio: float = 0.15,
|
||||
min_seek_seconds: float = 1.0,
|
||||
) -> str:
|
||||
"""抽取视频封面图(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
||||
"""抽取视频封面帧(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
||||
|
||||
因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径,不传则用临时文件
|
||||
width: 输出宽度(默认 640,-1 表示按比例缩放)
|
||||
height: 输出高度(默认 -1,按比例缩放)
|
||||
width: 输出宽度(默认 -1,保持原始分辨率)
|
||||
height: 输出高度(默认 -1,保持原始分辨率)
|
||||
timeout: 超时时间(秒)
|
||||
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
|
||||
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
|
||||
|
||||
Returns:
|
||||
生成的缩略图文件路径
|
||||
生成的封面帧文件路径
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: ffmpeg 执行失败
|
||||
RuntimeError: ffmpeg 执行失败或输出文件为空
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
|
||||
@@ -57,10 +63,20 @@ def extract_first_frame(
|
||||
# 格式化为 HH:MM:SS.xx
|
||||
seek_str = _format_seek_time(seek_time)
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
|
||||
# 构建 scale filter:如果指定了宽高则缩放,否则保持原始分辨率。
|
||||
# NOTE: scale_filter 在此处通过 if/else 分支赋值,之后不再被覆盖,
|
||||
# 后续 cmd / cmd2 均复用同一变量,逻辑无变化。
|
||||
if width > 0 or height > 0:
|
||||
w_str = str(width) if width > 0 else "-1"
|
||||
h_str = str(height) if height > 0 else "-1"
|
||||
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
||||
else:
|
||||
# 保持原始分辨率,只确保格式兼容
|
||||
scale_filter = "format=yuvj420p"
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
@@ -99,7 +115,7 @@ def extract_first_frame(
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
raise RuntimeError(f"Cover frame extraction failed: {output_path}")
|
||||
|
||||
return output_path
|
||||
except Exception:
|
||||
@@ -118,40 +134,3 @@ def _format_seek_time(seconds: float) -> str:
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = seconds % 60
|
||||
return f"{h:02d}:{m:02d}:{s:05.2f}"
|
||||
|
||||
|
||||
def generate_and_upload_thumbnail(
|
||||
video_path: str,
|
||||
storage_key: str,
|
||||
) -> str | None:
|
||||
"""生成缩略图并上传到 OSS,返回 URL。
|
||||
|
||||
Args:
|
||||
video_path: 本地视频路径
|
||||
storage_key: OSS 存储 key(如 generated/projects/xxx/thumbnails/yyy.jpg)
|
||||
|
||||
Returns:
|
||||
上传成功返回 URL,失败返回 None
|
||||
"""
|
||||
thumbnail_path = None
|
||||
try:
|
||||
thumbnail_path = extract_first_frame(video_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract thumbnail from %s: %s", video_path, e)
|
||||
return None
|
||||
|
||||
try:
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
url = upload_to_oss(thumbnail_path, storage_key)
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.warning("Failed to upload thumbnail to OSS: %s", e)
|
||||
return None
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if thumbnail_path:
|
||||
try:
|
||||
Path(thumbnail_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -324,6 +324,8 @@ class UnifiedRenderService:
|
||||
else:
|
||||
config = self.plan.config or {}
|
||||
bgm_config = config.get("bgm", {}) or {}
|
||||
if not isinstance(bgm_config, dict):
|
||||
bgm_config = {}
|
||||
audio_tracks_config = config.get("audio_tracks") or {}
|
||||
noise_reduction_config = config.get("audio_noise_reduction")
|
||||
ctx = RenderContext(
|
||||
@@ -480,7 +482,11 @@ class UnifiedRenderService:
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
title_cfg = config.get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
|
||||
title_enabled = title_cfg.get("enabled", True)
|
||||
subtitle_enabled = subtitle_cfg.get("enabled", True)
|
||||
@@ -507,7 +513,10 @@ class UnifiedRenderService:
|
||||
timeline,
|
||||
video_width=self.output_width,
|
||||
video_height=self.output_height,
|
||||
video_duration=video_duration,
|
||||
subtitle_config=subtitle_cfg,
|
||||
title_text=title_text,
|
||||
title_config=title_cfg,
|
||||
)
|
||||
logger.info(
|
||||
"ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs",
|
||||
@@ -653,7 +662,11 @@ class UnifiedRenderService:
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
tts_cfg = config.get("tts", {}) or {}
|
||||
if not isinstance(tts_cfg, dict):
|
||||
tts_cfg = {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
use_subtitle_align = False # 是否使用字幕对齐模式
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
|
||||
@@ -163,36 +163,6 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
|
||||
job_service.fail_job(job_id, error_msg[:500])
|
||||
raise RuntimeError(result.error_message)
|
||||
|
||||
# 生成封面(如果配置启用)
|
||||
cover_url = None
|
||||
try:
|
||||
from video_processing.cover_generator import generate_cover_from_plan
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository as EditPlanRepository,
|
||||
)
|
||||
|
||||
# 获取 plan 对象
|
||||
plan_repo = EditPlanRepository(db)
|
||||
plan = plan_repo.get(plan_id)
|
||||
|
||||
if plan and result.output_path:
|
||||
# 检查 cover_config
|
||||
cover_config = (plan.config or {}).get("cover_config")
|
||||
if cover_config and cover_config.get("enabled", False):
|
||||
from pathlib import Path
|
||||
|
||||
output_dir = Path(result.output_path).parent
|
||||
cover_path = generate_cover_from_plan(plan, result.output_path, output_dir)
|
||||
if cover_path:
|
||||
# 生成 cover_url(相对路径或上传到存储)
|
||||
cover_url = f"/covers/{plan_id}.jpg"
|
||||
logger.info("封面生成成功: plan_id=%s cover_path=%s", plan_id, cover_path)
|
||||
else:
|
||||
logger.info("封面生成未启用: plan_id=%s", plan_id)
|
||||
except Exception as e:
|
||||
logger.warning("封面生成失败(不影响视频合成): plan_id=%s error=%s", plan_id, e)
|
||||
|
||||
# 更新 Job 状态为完成
|
||||
result_data = {
|
||||
"plan_id": plan_id,
|
||||
@@ -205,7 +175,6 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"file_size": result.file_size,
|
||||
"cover_url": cover_url,
|
||||
}
|
||||
job_service.complete_job(job_id, result=result_data)
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ def _finalize_render_success(
|
||||
# 从 plan.config.title.text 读取视频名称
|
||||
plan_config = plan.config or {}
|
||||
title_cfg = plan_config.get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
video_name = (title_cfg.get("text") or "").strip() or f"generated-{generation_task_id[:8]}.mp4"
|
||||
if generation_task_id:
|
||||
try:
|
||||
@@ -241,14 +243,32 @@ def _render_with_unified(
|
||||
return {"status": "error", "message": result.error_message or "渲染失败"}
|
||||
|
||||
output_path = result.output_path or Path("")
|
||||
output_url = result.output_url
|
||||
output_url = result.output_url or ""
|
||||
thumbnail_url = result.thumbnail_url or ""
|
||||
storage_key = f"rendered/{plan_id}/output.mp4"
|
||||
# adapter 上传到 rendered/{plan_id}/{job_id}.mp4,从 URL 提取实际 key
|
||||
# 不能用 output.mp4 硬编码,否则 cover 等下游通过 key 构造的 URL 指向不存在的文件
|
||||
if output_url:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_key = get_shared_storage_service().normalize_storage_key(output_url)
|
||||
else:
|
||||
storage_key = f"rendered/{plan_id}/{generation_task_id or plan_id}.mp4"
|
||||
|
||||
# 用 adapter 返回的 clip 明细(以 adapter 的结果为准)
|
||||
rendered_clip_ids = result.rendered_clip_ids or []
|
||||
failed_clip_ids = result.failed_clip_ids or []
|
||||
|
||||
# 将封面候选帧写入 plan.config(供封面 API 直接使用,跳过 MediaKit 抽帧)
|
||||
if result.cover_candidates:
|
||||
plan_config = plan.config or {}
|
||||
plan_config["cover_candidates"] = result.cover_candidates
|
||||
plan.config = plan_config
|
||||
logger.info(
|
||||
"封面候选帧已写入 plan.config: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(result.cover_candidates),
|
||||
)
|
||||
|
||||
return _finalize_render_success(
|
||||
plan=plan,
|
||||
plan_repo=plan_repo,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
@@ -983,9 +984,9 @@ def _load_template_plan_config(template_id: str) -> dict:
|
||||
|
||||
# 从独立字段组装成 plan.config 格式
|
||||
plan_config: dict[str, Any] = {}
|
||||
title_cfg = template.title_config or {}
|
||||
subtitle_cfg = template.subtitle_config or {}
|
||||
bgm_cfg = template.bgm_config or {}
|
||||
title_cfg = template.title_config if isinstance(template.title_config, dict) else {}
|
||||
subtitle_cfg = template.subtitle_config if isinstance(template.subtitle_config, dict) else {}
|
||||
bgm_cfg = template.bgm_config if isinstance(template.bgm_config, dict) else {}
|
||||
|
||||
if title_cfg:
|
||||
plan_config["title"] = title_cfg
|
||||
@@ -1123,6 +1124,7 @@ def _render_video(
|
||||
resolution: str = "",
|
||||
bgm_config: dict | None = None,
|
||||
voice_ids: list[str] | None = None,
|
||||
custom_title: str = "",
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
@@ -1155,10 +1157,33 @@ def _render_video(
|
||||
list(template_config.keys()),
|
||||
)
|
||||
|
||||
# ── 用户自定义标题覆盖模板标题配置 ──────────────────────────────────
|
||||
if custom_title:
|
||||
try:
|
||||
user_title_cfg = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
|
||||
if isinstance(user_title_cfg, dict) and user_title_cfg.get("text", "").strip():
|
||||
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
|
||||
if "font_size" in user_title_cfg and "size" not in user_title_cfg:
|
||||
user_title_cfg["size"] = user_title_cfg["font_size"]
|
||||
if "font_color" in user_title_cfg and "color" not in user_title_cfg:
|
||||
user_title_cfg["color"] = user_title_cfg["font_color"]
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["title"] = user_title_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 用户自定义标题已注入: text=%s",
|
||||
task_id,
|
||||
user_title_cfg.get("text", "")[:30],
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("[task_id=%s] custom_title JSON解析失败: %s", task_id, custom_title[:100])
|
||||
|
||||
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
|
||||
if bgm_config:
|
||||
plan_cfg = virtual_plan.config or {}
|
||||
template_bgm = plan_cfg.get("bgm", {}) or {}
|
||||
if not isinstance(template_bgm, dict):
|
||||
template_bgm = {}
|
||||
merged_bgm = merge_bgm_config(template_bgm, bgm_config)
|
||||
plan_cfg["bgm"] = merged_bgm
|
||||
virtual_plan.config = plan_cfg
|
||||
@@ -1189,6 +1214,8 @@ def _render_video(
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["voice_id"] = voice_ids[0]
|
||||
subtitle_cfg = plan_cfg.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
subtitle_cfg["auto_generated"] = True
|
||||
plan_cfg["subtitle"] = subtitle_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
@@ -1262,7 +1289,9 @@ def _upload_and_record(
|
||||
Returns:
|
||||
(file_url, duration, file_size, video_count)
|
||||
"""
|
||||
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_path.name}"
|
||||
# project_id 可能为空(模板编辑器草稿不属于任何项目),过滤空段避免 OSS key 出现 //
|
||||
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, output_path.name) if p]
|
||||
storage_key = "/".join(path_parts)
|
||||
file_size = output_path.stat().st_size
|
||||
|
||||
# 上传 OSS
|
||||
@@ -1499,6 +1528,16 @@ def generate_video(self, task_id: str) -> dict:
|
||||
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
|
||||
_ow = task_info.get("output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH
|
||||
_oh = task_info.get("output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT
|
||||
# 防护:前端可能误传宽高比(如 parseInt("9:16") = 9),宽度 < 100 时忽略
|
||||
if _ow < 100 or _oh < 100:
|
||||
logger.warning(
|
||||
"[task_id=%s] output_width/output_height 异常 (%dx%d),回退到默认",
|
||||
task_id,
|
||||
_ow,
|
||||
_oh,
|
||||
)
|
||||
_ow = OUTPUT_WIDTH
|
||||
_oh = OUTPUT_HEIGHT
|
||||
if _ow != OUTPUT_WIDTH or _oh != OUTPUT_HEIGHT:
|
||||
_resolved_resolution = f"{_ow}x{_oh}"
|
||||
else:
|
||||
@@ -1517,6 +1556,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
resolution=_resolved_resolution,
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
custom_title=task_info.get("custom_title", ""),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
@@ -1548,6 +1588,52 @@ def generate_video(self, task_id: str) -> dict:
|
||||
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
|
||||
# ── 4.5 封面抽帧 ────────────────────────────────────────────────
|
||||
# 预览视频上传完成后,提取封面帧写入 gen_task.cover_url
|
||||
# 这样封面路由(generation_cover.py 步骤A)可以通过 generation_task_id 直接找到
|
||||
try:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
mk_client = get_mediakit_client()
|
||||
if mk_client.is_available:
|
||||
_update_task_progress(task_id, 96, "提取封面帧")
|
||||
snapshots = mk_client.extract_frames(
|
||||
video_url=file_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=1,
|
||||
)
|
||||
if snapshots and len(snapshots) > 0:
|
||||
cover_frame_url = snapshots[0].get("image_url", "")
|
||||
if cover_frame_url and gen_task:
|
||||
# 通过独立 session 持久化 cover_url
|
||||
_cover_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
|
||||
_cover_model = (
|
||||
_cover_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _cover_model:
|
||||
_cover_model.cover_url = cover_frame_url
|
||||
_cover_session.commit()
|
||||
logger.info(
|
||||
"[task_id=%s] 封面帧提取成功: %s",
|
||||
task_id,
|
||||
cover_frame_url[:80],
|
||||
)
|
||||
finally:
|
||||
_cover_session.close()
|
||||
else:
|
||||
logger.warning("[task_id=%s] 封面帧提取返回空结果", task_id)
|
||||
else:
|
||||
logger.warning("[task_id=%s] MediaKit 未配置,跳过封面帧提取", task_id)
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 封面帧提取失败(不影响主流程)", task_id, exc_info=True)
|
||||
|
||||
# ── 5. 标记完成 ──────────────────────────────────────────────────
|
||||
_update_task_status(task_id, "mark_completed", result_count=video_count)
|
||||
|
||||
|
||||
@@ -206,11 +206,21 @@ def ingest_asset(job_id: str) -> dict:
|
||||
# 视频类型:生成缩略图(文件还在的时候生成)
|
||||
thumbnail_url = None
|
||||
if media_type == "video" and extract_success:
|
||||
frame_path = None
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
frame_path = extract_first_frame(str(local_file), width=640)
|
||||
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
|
||||
try:
|
||||
thumbnail_url = upload_to_oss(frame_path, thumb_storage_key)
|
||||
finally:
|
||||
if frame_path:
|
||||
try:
|
||||
Path(frame_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if thumbnail_url:
|
||||
logger.info(
|
||||
"素材缩略图生成成功: job_id=%s url=%s",
|
||||
|
||||
@@ -53,9 +53,10 @@ ARG APP_VERSION=dev
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖)
|
||||
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖,ffmpeg 用于封面兜底取帧)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制虚拟环境
|
||||
|
||||
@@ -67,6 +67,9 @@ RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
# 业务代码(变化最频繁,放最后)
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
|
||||
|
||||
# ---- Install CJK fonts for ASS subtitle rendering ----
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-cjk && fc-cache -fv && rm -rf /var/lib/apt/lists/*
|
||||
USER celery
|
||||
|
||||
# Worker 入口点
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class CreateGenerationTaskCommand:
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
title_config: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateGenerationTaskUseCase:
|
||||
|
||||
@@ -247,6 +247,27 @@ def build_ass_content(
|
||||
title_config = title_config or {}
|
||||
subtitle_config = subtitle_config or {}
|
||||
|
||||
# ── 兼容前端简化格式:stroke/shadow 为 boolean 时,转换为标准 dict ──
|
||||
# 前端 TitleSettings 发送 stroke=true/false, shadow=true/false
|
||||
# 后端 build_ass_style 期望 stroke={enabled, color, width}, shadow={enabled, blur, offset_x, offset_y}
|
||||
if title_config:
|
||||
_stroke_val = title_config.get("stroke")
|
||||
if isinstance(_stroke_val, bool):
|
||||
title_config["stroke"] = {
|
||||
"enabled": _stroke_val,
|
||||
"color": "#000000",
|
||||
"width": 2,
|
||||
} if _stroke_val else {"enabled": False}
|
||||
_shadow_val = title_config.get("shadow")
|
||||
if isinstance(_shadow_val, bool):
|
||||
title_config["shadow"] = {
|
||||
"enabled": _shadow_val,
|
||||
"color": "#000000",
|
||||
"blur": 4,
|
||||
"offset_x": 2,
|
||||
"offset_y": 2,
|
||||
} if _shadow_val else {"enabled": False}
|
||||
|
||||
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
|
||||
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
|
||||
|
||||
@@ -262,7 +283,7 @@ def build_ass_content(
|
||||
title_stroke = title_config.get("stroke", {}) or {}
|
||||
title_shadow = title_config.get("shadow", {}) or {}
|
||||
stroke_color = hex_to_ass_color(title_stroke.get("color", "#000000"))
|
||||
stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0
|
||||
stroke_width = float(title_stroke.get("width", 2)) if title_stroke.get("enabled", False) else 0.0
|
||||
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
|
||||
shadow_offset = (
|
||||
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
|
||||
@@ -275,7 +296,7 @@ def build_ass_content(
|
||||
build_ass_style(
|
||||
"TitleStyle",
|
||||
font_name=title_config.get("font", "思源黑体"),
|
||||
font_size=int(title_config.get("size", 48)),
|
||||
font_size=min(int(title_config.get("size", 36)), 36),
|
||||
primary_color=title_color,
|
||||
outline_color=stroke_color,
|
||||
outline_width=stroke_width,
|
||||
@@ -292,7 +313,7 @@ def build_ass_content(
|
||||
|
||||
# 根据视频宽度和字号自动换行标题,防止超出画面
|
||||
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
|
||||
title_font_size = int(title_config.get("size", 48))
|
||||
title_font_size = min(int(title_config.get("size", 36)), 36)
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class ChromaKeyConfig:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> ChromaKeyConfig:
|
||||
"""从字典解析配置,参数越界自动钳制."""
|
||||
if not data or not data.get("enabled", False):
|
||||
if not isinstance(data, dict) or not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
|
||||
key_color = str(data.get("key_color", DEFAULT_KEY_COLOR)).strip()
|
||||
|
||||
@@ -196,7 +196,7 @@ class ColorGradeConfig:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
|
||||
"""从字典解析配置."""
|
||||
if not data or not data.get("enabled", False):
|
||||
if not isinstance(data, dict) or not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
|
||||
preset = data.get("preset", "")
|
||||
|
||||
@@ -76,7 +76,7 @@ class IntroOutroConfig:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "IntroOutroConfig":
|
||||
"""从字典构造."""
|
||||
if not data:
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
enabled = data.get("enabled", False)
|
||||
|
||||
@@ -81,7 +81,7 @@ class NoiseReductionConfig:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> NoiseReductionConfig:
|
||||
"""从字典解析配置,参数越界自动钳制."""
|
||||
if not data or not data.get("enabled", False):
|
||||
if not isinstance(data, dict) or not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
|
||||
level_str = str(data.get("level", "medium")).lower()
|
||||
|
||||
@@ -136,7 +136,7 @@ class PiPConfig:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig":
|
||||
"""从字典解析配置."""
|
||||
if not data or not data.get("enabled", False):
|
||||
if not isinstance(data, dict) or not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
|
||||
layers_data = data.get("layers", [])
|
||||
|
||||
@@ -86,7 +86,7 @@ class WatermarkConfig:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> WatermarkConfig | None:
|
||||
"""从字典构造,空配置返回 None(不加水印)."""
|
||||
if not data:
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
enabled = data.get("enabled", False)
|
||||
|
||||
@@ -361,7 +361,10 @@ def _call_ai_cover_service(
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 封面生成服务.
|
||||
|
||||
当 cover_type 为 ai_frame 或 ai_regenerate 时,调用 MediaKit 视频截帧。
|
||||
统一封面管道下,封面已由渲染后视频抽帧生成并持久化到 GenerationTask.cover_url。
|
||||
此函数仅处理 manual/upload 等需要前端交互的类型,
|
||||
ai_frame/ai_regenerate 类型应由调用方直接从持久化的封面 URL 读取。
|
||||
|
||||
失败时抛出 RuntimeError。
|
||||
|
||||
Args:
|
||||
@@ -369,7 +372,7 @@ def _call_ai_cover_service(
|
||||
asset_ids: 素材 ID 列表
|
||||
cover_type: 封面类型
|
||||
frame_time: 手动选帧时间点
|
||||
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
|
||||
primary_video_url: 主视频的可访问 URL
|
||||
"""
|
||||
if cover_type == "upload":
|
||||
return {
|
||||
@@ -392,57 +395,14 @@ def _call_ai_cover_service(
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
# ai_frame / ai_regenerate - 尝试调用 MediaKit
|
||||
if primary_video_url:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
if client.is_available:
|
||||
try:
|
||||
logger.info("调用 MediaKit 抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
|
||||
frames = client.extract_frames(
|
||||
video_url=primary_video_url,
|
||||
strategy="TimeInterval",
|
||||
max_frames=5,
|
||||
poll_interval=3.0,
|
||||
max_poll_attempts=60, # 180秒超时
|
||||
)
|
||||
|
||||
if frames and len(frames) > 0:
|
||||
# 选择第一帧(SceneChange 策略的第一帧通常是最佳画面)
|
||||
best_frame = frames[0]
|
||||
image_url = best_frame.get("image_url", "")
|
||||
timestamp = best_frame.get("timestamp", 0.0)
|
||||
|
||||
if image_url:
|
||||
logger.info(
|
||||
"MediaKit 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
|
||||
plan_id,
|
||||
timestamp,
|
||||
image_url[:80],
|
||||
)
|
||||
# MediaKit 返回的 URL 是临时内部 URL,浏览器无法直接访问
|
||||
# 需要下载到本地并重新上传到 OSS,返回公开可访问的 URL
|
||||
public_url = _transfer_cover_frame_to_storage(image_url, plan_id)
|
||||
return {
|
||||
"type": "ai_frame",
|
||||
"image_url": public_url,
|
||||
"frame_time": round(timestamp, 1),
|
||||
"confidence": 0.85,
|
||||
}
|
||||
else:
|
||||
logger.warning("MediaKit 返回的帧无 image_url")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 抽帧失败: %s", str(e))
|
||||
|
||||
# 封面生成失败 - 不再降级到 stub,直接报错
|
||||
raise RuntimeError(
|
||||
f"封面生成失败: plan_id={plan_id}, MediaKit 不可用或抽帧失败。" f"请检查 primary_video_url 是否可访问。"
|
||||
# ai_frame / ai_regenerate: 封面应由渲染后视频抽帧管道生成
|
||||
# 如果调用方传入了持久化的封面 URL,直接使用
|
||||
logger.warning(
|
||||
"封面生成回退: plan_id=%s cover_type=%s — 统一管道应已生成封面,请检查 GenerationTask.cover_url",
|
||||
plan_id,
|
||||
cover_type,
|
||||
)
|
||||
|
||||
|
||||
# ── 公共入口 ────────────────────────────────────────────────────────────────
|
||||
raise RuntimeError(f"封面数据不可用 (plan_id={plan_id})。请重新生成预览视频以触发封面自动提取。")
|
||||
|
||||
|
||||
def run_ai_recommend(
|
||||
|
||||
@@ -412,7 +412,7 @@ class TestBuildAssContent:
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "72"
|
||||
assert parts[2] == "36"
|
||||
break
|
||||
|
||||
def test_title_bold(self):
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
测试:模板 config 字段存储了非 dict 值(如 True / False / str)时,
|
||||
渲染链路不会崩溃('bool' object has no attribute 'get')。
|
||||
|
||||
覆盖两个关键文件:
|
||||
1. generation.py — _load_template_plan_config 旧系统路径
|
||||
2. unified_render_service.py — _maybe_generate_ass
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add worker app to path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
|
||||
class TestLoadTemplatePlanConfigBoolDefense:
|
||||
"""_load_template_plan_config 旧系统路径对非 dict 值的防护。"""
|
||||
|
||||
def _call_old_path(self, title_cfg, subtitle_cfg, bgm_cfg):
|
||||
"""通过 mock 新模板系统返回 None,强制走旧模板系统 fallback 路径。"""
|
||||
from worker_app.tasks.generation import _load_template_plan_config
|
||||
|
||||
mock_old_template = MagicMock()
|
||||
mock_old_template.title_config = title_cfg
|
||||
mock_old_template.subtitle_config = subtitle_cfg
|
||||
mock_old_template.bgm_config = bgm_cfg
|
||||
|
||||
mock_session = MagicMock()
|
||||
# 旧系统 query 返回 mock template
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = mock_old_template
|
||||
|
||||
# Mock 新模板系统 repo.get() 返回 None(强制走 fallback)
|
||||
mock_repo_cls = MagicMock()
|
||||
mock_repo_cls.return_value.get.return_value = None
|
||||
|
||||
with (
|
||||
patch("worker_app.tasks.generation.SessionLocal", return_value=mock_session),
|
||||
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyEditTemplateRepository", mock_repo_cls),
|
||||
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyTemplateClipConfigRepository", MagicMock()),
|
||||
):
|
||||
return _load_template_plan_config("fake-id")
|
||||
|
||||
def test_bool_values_return_empty(self):
|
||||
"""title_config=True / subtitle_config=False / bgm_config='str' → 全部过滤掉"""
|
||||
result = self._call_old_path(True, False, "not_a_dict")
|
||||
assert isinstance(result, dict)
|
||||
assert "title" not in result
|
||||
assert "subtitle" not in result
|
||||
assert "bgm" not in result
|
||||
|
||||
def test_valid_dict_passes_through(self):
|
||||
"""正常 dict 正常传递"""
|
||||
result = self._call_old_path(
|
||||
{"text": "标题", "enabled": True},
|
||||
{"text": "副标题"},
|
||||
{"enabled": True, "source": "test.mp3"},
|
||||
)
|
||||
assert result["title"] == {"text": "标题", "enabled": True}
|
||||
assert result["subtitle"] == {"text": "副标题"}
|
||||
assert result["bgm"] == {"enabled": True, "source": "test.mp3"}
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
"""None → 空 dict"""
|
||||
result = self._call_old_path(None, None, None)
|
||||
assert result == {}
|
||||
|
||||
def test_mixed_valid_and_invalid(self):
|
||||
"""部分有效、部分无效时只保留有效的"""
|
||||
result = self._call_old_path({"text": "OK"}, True, None)
|
||||
assert "title" in result
|
||||
assert "subtitle" not in result
|
||||
assert "bgm" not in result
|
||||
|
||||
def test_int_and_list_also_filtered(self):
|
||||
"""int / list 类型也被过滤"""
|
||||
result = self._call_old_path(42, [1, 2, 3], 0)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestUnifiedRenderBoolConfigDefense:
|
||||
"""_maybe_generate_ass 对 plan.config 中非 dict title/subtitle 的防护。"""
|
||||
|
||||
def _make_service(self, config):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = config
|
||||
service.plan = mock_plan
|
||||
service.task_id = "test-task"
|
||||
return service
|
||||
|
||||
def test_bool_title_does_not_crash(self):
|
||||
"""config['title']=True → 不崩溃,返回 None"""
|
||||
service = self._make_service({"title": True, "subtitle": {}})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_bool_subtitle_does_not_crash(self):
|
||||
"""config['subtitle']=False → 不崩溃,返回 None"""
|
||||
service = self._make_service({"title": {}, "subtitle": False})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_str_title_does_not_crash(self):
|
||||
"""config['title']='plain string' → 不崩溃"""
|
||||
service = self._make_service({"title": "plain string", "subtitle": {}})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_none_config_does_not_crash(self):
|
||||
"""config=None → 不崩溃"""
|
||||
service = self._make_service(None)
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_int_title_does_not_crash(self):
|
||||
"""config['title']=42 → 不崩溃"""
|
||||
service = self._make_service({"title": 42, "subtitle": 0})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
@@ -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
|
||||
@@ -1,232 +0,0 @@
|
||||
"""测试 compose_video 任务中封面生成集成."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestComposeVideoCoverIntegration:
|
||||
"""测试视频合成任务中的封面生成集成."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_job_service(self):
|
||||
"""模拟 JobService."""
|
||||
service = MagicMock()
|
||||
service.get_job.return_value = MagicMock(
|
||||
id="job_123",
|
||||
payload={"plan_id": "plan_456"},
|
||||
)
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db(self):
|
||||
"""模拟数据库会话."""
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_render_result(self):
|
||||
"""模拟渲染结果."""
|
||||
result = MagicMock()
|
||||
result.success = True
|
||||
result.output_path = Path("/tmp/output/video_123.mp4")
|
||||
result.output_url = "https://example.com/video_123.mp4"
|
||||
result.duration = 30.0
|
||||
result.clip_count = 5
|
||||
result.width = 1080
|
||||
result.height = 1920
|
||||
result.file_size = 1024000
|
||||
return result
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plan_with_cover_enabled(self):
|
||||
"""模拟启用封面的 plan."""
|
||||
plan = MagicMock()
|
||||
plan.id = "plan_456"
|
||||
plan.config = {
|
||||
"cover_config": {
|
||||
"enabled": True,
|
||||
"mode": "smart",
|
||||
}
|
||||
}
|
||||
return plan
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plan_with_cover_disabled(self):
|
||||
"""模拟禁用封面的 plan."""
|
||||
plan = MagicMock()
|
||||
plan.id = "plan_456"
|
||||
plan.config = {
|
||||
"cover_config": {
|
||||
"enabled": False,
|
||||
}
|
||||
}
|
||||
return plan
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plan_without_cover_config(self):
|
||||
"""模拟没有 cover_config 的 plan."""
|
||||
plan = MagicMock()
|
||||
plan.id = "plan_456"
|
||||
plan.config = {}
|
||||
return plan
|
||||
|
||||
def test_cover_generation_called_when_enabled(
|
||||
self,
|
||||
mock_job_service,
|
||||
mock_db,
|
||||
mock_render_result,
|
||||
mock_plan_with_cover_enabled,
|
||||
):
|
||||
"""测试封面生成在启用时被调用."""
|
||||
from worker_app.tasks.compose_video import _compose_with_unified_engine
|
||||
|
||||
# 模拟 RenderAdapter
|
||||
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
|
||||
adapter_instance = MagicMock()
|
||||
adapter_instance.render_plan.return_value = mock_render_result
|
||||
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
|
||||
MockAdapter.return_value = adapter_instance
|
||||
|
||||
# 模拟 EditPlanRepository
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
|
||||
) as MockPlanRepo:
|
||||
plan_repo_instance = MagicMock()
|
||||
plan_repo_instance.get.return_value = mock_plan_with_cover_enabled
|
||||
MockPlanRepo.return_value = plan_repo_instance
|
||||
|
||||
# 模拟 generate_cover_from_plan
|
||||
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
|
||||
mock_gen_cover.return_value = Path("/tmp/output/cover_plan_456.jpg")
|
||||
|
||||
# 执行
|
||||
task = MagicMock()
|
||||
result = _compose_with_unified_engine(
|
||||
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
|
||||
)
|
||||
|
||||
# 验证封面生成被调用
|
||||
mock_gen_cover.assert_called_once()
|
||||
call_args = mock_gen_cover.call_args
|
||||
assert call_args[0][0] == mock_plan_with_cover_enabled # plan
|
||||
assert call_args[0][1] == mock_render_result.output_path # video_path
|
||||
assert call_args[0][2] == mock_render_result.output_path.parent # output_dir
|
||||
|
||||
# 验证结果包含 cover_url
|
||||
assert "cover_url" in result["result"]
|
||||
assert result["result"]["cover_url"] == "/covers/plan_456.jpg"
|
||||
|
||||
def test_cover_generation_skipped_when_disabled(
|
||||
self,
|
||||
mock_job_service,
|
||||
mock_db,
|
||||
mock_render_result,
|
||||
mock_plan_with_cover_disabled,
|
||||
):
|
||||
"""测试封面生成在禁用时被跳过."""
|
||||
from worker_app.tasks.compose_video import _compose_with_unified_engine
|
||||
|
||||
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
|
||||
adapter_instance = MagicMock()
|
||||
adapter_instance.render_plan.return_value = mock_render_result
|
||||
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
|
||||
MockAdapter.return_value = adapter_instance
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
|
||||
) as MockPlanRepo:
|
||||
plan_repo_instance = MagicMock()
|
||||
plan_repo_instance.get.return_value = mock_plan_with_cover_disabled
|
||||
MockPlanRepo.return_value = plan_repo_instance
|
||||
|
||||
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
|
||||
task = MagicMock()
|
||||
result = _compose_with_unified_engine(
|
||||
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
|
||||
)
|
||||
|
||||
# 验证封面生成未被调用
|
||||
mock_gen_cover.assert_not_called()
|
||||
|
||||
# 验证结果中 cover_url 为 None
|
||||
assert "cover_url" in result["result"]
|
||||
assert result["result"]["cover_url"] is None
|
||||
|
||||
def test_cover_generation_skipped_when_no_config(
|
||||
self,
|
||||
mock_job_service,
|
||||
mock_db,
|
||||
mock_render_result,
|
||||
mock_plan_without_cover_config,
|
||||
):
|
||||
"""测试没有 cover_config 时封面生成被跳过."""
|
||||
from worker_app.tasks.compose_video import _compose_with_unified_engine
|
||||
|
||||
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
|
||||
adapter_instance = MagicMock()
|
||||
adapter_instance.render_plan.return_value = mock_render_result
|
||||
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
|
||||
MockAdapter.return_value = adapter_instance
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
|
||||
) as MockPlanRepo:
|
||||
plan_repo_instance = MagicMock()
|
||||
plan_repo_instance.get.return_value = mock_plan_without_cover_config
|
||||
MockPlanRepo.return_value = plan_repo_instance
|
||||
|
||||
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
|
||||
task = MagicMock()
|
||||
result = _compose_with_unified_engine(
|
||||
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
|
||||
)
|
||||
|
||||
# 验证封面生成未被调用
|
||||
mock_gen_cover.assert_not_called()
|
||||
|
||||
# 验证结果中 cover_url 为 None
|
||||
assert "cover_url" in result["result"]
|
||||
assert result["result"]["cover_url"] is None
|
||||
|
||||
def test_cover_generation_failure_does_not_break_video(
|
||||
self,
|
||||
mock_job_service,
|
||||
mock_db,
|
||||
mock_render_result,
|
||||
mock_plan_with_cover_enabled,
|
||||
):
|
||||
"""测试封面生成失败不影响视频合成."""
|
||||
from worker_app.tasks.compose_video import _compose_with_unified_engine
|
||||
|
||||
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
|
||||
adapter_instance = MagicMock()
|
||||
adapter_instance.render_plan.return_value = mock_render_result
|
||||
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
|
||||
MockAdapter.return_value = adapter_instance
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
|
||||
) as MockPlanRepo:
|
||||
plan_repo_instance = MagicMock()
|
||||
plan_repo_instance.get.return_value = mock_plan_with_cover_enabled
|
||||
MockPlanRepo.return_value = plan_repo_instance
|
||||
|
||||
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
|
||||
# 模拟封面生成抛出异常
|
||||
mock_gen_cover.side_effect = Exception("FFmpeg failed")
|
||||
|
||||
task = MagicMock()
|
||||
result = _compose_with_unified_engine(
|
||||
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
|
||||
)
|
||||
|
||||
# 验证视频合成仍然成功
|
||||
assert result["status"] == "completed"
|
||||
assert "result" in result
|
||||
assert result["result"]["output_url"] == mock_render_result.output_url
|
||||
|
||||
# 验证结果中 cover_url 为 None
|
||||
assert result["result"]["cover_url"] is None
|
||||
@@ -237,7 +237,7 @@ class TestAIRunTasks:
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
with pytest.raises(RuntimeError, match="封面数据不可用"):
|
||||
run_generate_cover(
|
||||
plan_id="plan-001",
|
||||
asset_ids=["asset-1"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user