Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2beef01279 | |||
| 4c9b9fa50f |
@@ -1,82 +0,0 @@
|
||||
"""确认生成 API 改造:为 generation_tasks 表添加 source_task_id、output_width、output_height、cover_url、custom_title 字段
|
||||
|
||||
Revision ID: 054_confirm_gen_fields
|
||||
Revises: 053_generation_task_is_preview
|
||||
Create Date: 2026-08-16
|
||||
|
||||
Changes:
|
||||
1. generation_tasks 表新增 source_task_id(来源预览任务 ID,带索引)
|
||||
2. generation_tasks 表新增 output_width / output_height(动态输出分辨率)
|
||||
3. generation_tasks 表新增 cover_url / custom_title(自定义封面和标题)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "054_confirm_gen_fields"
|
||||
down_revision = "053_generation_task_is_preview"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
is_pg = conn.dialect.name == "postgresql"
|
||||
|
||||
if is_pg:
|
||||
# 幂等检查:source_task_id 列是否已存在
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'source_task_id'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
# source_task_id
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("source_task_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# output_width
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_width", sa.Integer, nullable=False, server_default=sa.text("1280")),
|
||||
)
|
||||
|
||||
# output_height
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_height", sa.Integer, nullable=False, server_default=sa.text("720")),
|
||||
)
|
||||
|
||||
# cover_url
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("cover_url", sa.String(1000), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# custom_title
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("custom_title", sa.String(500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# 索引
|
||||
op.create_index(
|
||||
"ix_generation_tasks_source_task_id",
|
||||
"generation_tasks",
|
||||
["source_task_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_source_task_id", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "custom_title")
|
||||
op.drop_column("generation_tasks", "cover_url")
|
||||
op.drop_column("generation_tasks", "output_height")
|
||||
op.drop_column("generation_tasks", "output_width")
|
||||
op.drop_column("generation_tasks", "source_task_id")
|
||||
@@ -1,84 +0,0 @@
|
||||
"""封面模板表 cover_templates
|
||||
|
||||
Revision ID: 055_cover_templates
|
||||
Revises: 054_confirm_gen_fields
|
||||
Create Date: 2026-08-09
|
||||
|
||||
Changes:
|
||||
1. 新建 cover_templates 表,支持系统预置和用户自定义封面模板
|
||||
2. user_id 为 NULL 表示系统模板,is_system 标记区分
|
||||
3. config 为 JSON 字段,存储封面配置信息
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "055_cover_templates"
|
||||
down_revision = "054_confirm_gen_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
SYSTEM_TEMPLATES = [
|
||||
("a8b0120fd98e44788f5a6590f983d327", "默认模板", {}),
|
||||
("6d8c501b11424432b3df3a45ae89b1a9", "大胆红", {"background_color": "#ef4444"}),
|
||||
("04937fb57fea4bad95e7883e71a6b246", "优雅黑", {"background_color": "#111827"}),
|
||||
("3ff9cc821174437ca53931073e7f536e", "渐变蓝", {"background_color": "#3b82f6"}),
|
||||
("db51b3ea8f1a4f4caa94bf2d51f27d11", "渐变紫", {"background_color": "#8b5cf6"}),
|
||||
("5027d113432a4f798a3b4ee1644d66af", "暖橙", {"background_color": "#f97316"}),
|
||||
("0e10def2b5a148d686416494474726c2", "清新绿", {"background_color": "#22c55e"}),
|
||||
("38ea98ac00c04bada064006d880546f0", "科技蓝", {"background_color": "#06b6d4"}),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
result = conn.execute(sa.text("SELECT to_regclass('public.cover_templates')"))
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"cover_templates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=True, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("thumbnail_url", sa.String(1000), nullable=False, server_default=""),
|
||||
sa.Column("is_system", sa.Boolean, nullable=False, server_default=sa.false(), index=True),
|
||||
sa.Column("config", sa.JSON, nullable=False, server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# 预置系统模板 seed 数据
|
||||
cover_templates = sa.table(
|
||||
"cover_templates",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("user_id", sa.String),
|
||||
sa.column("name", sa.String),
|
||||
sa.column("thumbnail_url", sa.String),
|
||||
sa.column("is_system", sa.Boolean),
|
||||
sa.column("config", sa.JSON),
|
||||
sa.column("created_at", sa.DateTime),
|
||||
sa.column("updated_at", sa.DateTime),
|
||||
)
|
||||
|
||||
for tid, name, config in SYSTEM_TEMPLATES:
|
||||
conn.execute(
|
||||
cover_templates.insert().values(
|
||||
id=tid,
|
||||
user_id=None,
|
||||
name=name,
|
||||
thumbnail_url="",
|
||||
is_system=True,
|
||||
config=json.dumps(config),
|
||||
created_at=sa.func.now(),
|
||||
updated_at=sa.func.now(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("cover_templates")
|
||||
@@ -5,7 +5,6 @@ from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
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_preview import router as generation_preview_router
|
||||
@@ -46,10 +45,6 @@ api_router.include_router(
|
||||
prefix="/tags",
|
||||
tags=["Tag"],
|
||||
)
|
||||
api_router.include_router(
|
||||
cover_templates_router,
|
||||
tags=["CoverTemplate"],
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["TaskCenter"],
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
"""封面模板 CRUD 路由。
|
||||
|
||||
API:
|
||||
GET /api/v1/cover-templates - 列出当前用户可见的模板
|
||||
POST /api/v1/cover-templates - 创建自定义模板
|
||||
PUT /api/v1/cover-templates/{id} - 更新模板
|
||||
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_cover_template_repository
|
||||
from app.schemas.cover_template import (
|
||||
CoverTemplateResponse,
|
||||
CreateCoverTemplateRequest,
|
||||
ListCoverTemplatesResponse,
|
||||
UpdateCoverTemplateRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
|
||||
from packages.domain.cover_template import CoverTemplate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/cover-templates", tags=["CoverTemplate"])
|
||||
|
||||
|
||||
@router.get("", response_model=ListCoverTemplatesResponse)
|
||||
def list_cover_templates(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: Any = Depends(get_cover_template_repository),
|
||||
) -> ListCoverTemplatesResponse:
|
||||
"""列出当前用户可见的封面模板(系统模板 + 用户自定义模板)。
|
||||
|
||||
当数据库表不存在时(迁移未执行),降级返回空列表而非 500。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
items = repo.list_for_user(user_id, skip=skip, limit=limit)
|
||||
total = repo.count_for_user(user_id)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("cover_templates 表查询失败(可能未迁移),返回空列表: %s", exc)
|
||||
return ListCoverTemplatesResponse(items=[], total=0)
|
||||
return ListCoverTemplatesResponse(
|
||||
items=[
|
||||
CoverTemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
thumbnail_url=t.thumbnail_url,
|
||||
is_system=t.is_system,
|
||||
created_at=t.created_at,
|
||||
config=t.config,
|
||||
)
|
||||
for t in items
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CoverTemplateResponse, status_code=201)
|
||||
def create_cover_template(
|
||||
request: CreateCoverTemplateRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: Any = Depends(get_cover_template_repository),
|
||||
) -> CoverTemplateResponse:
|
||||
"""创建用户自定义封面模板。"""
|
||||
user_id = authenticated_user.user.id
|
||||
config_dict = request.config.model_dump() if request.config else {}
|
||||
template = CoverTemplate.create_user(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
config=config_dict,
|
||||
thumbnail_url=request.thumbnail_url,
|
||||
)
|
||||
try:
|
||||
created = repo.create(template)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("cover_templates 表不可用(可能未迁移): %s", exc)
|
||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
||||
return CoverTemplateResponse(
|
||||
id=created.id,
|
||||
name=created.name,
|
||||
thumbnail_url=created.thumbnail_url,
|
||||
is_system=created.is_system,
|
||||
created_at=created.created_at,
|
||||
config=created.config,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{template_id}", response_model=CoverTemplateResponse)
|
||||
def update_cover_template(
|
||||
template_id: str,
|
||||
request: UpdateCoverTemplateRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: Any = Depends(get_cover_template_repository),
|
||||
) -> CoverTemplateResponse:
|
||||
"""更新封面模板(仅允许更新自己的模板)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
template = repo.get(template_id)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("cover_templates 表不可用: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
if template.is_system:
|
||||
raise HTTPException(status_code=403, detail="系统模板不可修改")
|
||||
if template.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="无权修改该模板")
|
||||
|
||||
if request.name is not None:
|
||||
template.update(name=request.name)
|
||||
if request.config is not None:
|
||||
template.update(config=request.config.model_dump())
|
||||
if request.thumbnail_url is not None:
|
||||
template.update(thumbnail_url=request.thumbnail_url)
|
||||
|
||||
updated = repo.update(template)
|
||||
return CoverTemplateResponse(
|
||||
id=updated.id,
|
||||
name=updated.name,
|
||||
thumbnail_url=updated.thumbnail_url,
|
||||
is_system=updated.is_system,
|
||||
created_at=updated.created_at,
|
||||
config=updated.config,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=204, response_class=Response)
|
||||
def delete_cover_template(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: Any = Depends(get_cover_template_repository),
|
||||
) -> None:
|
||||
"""删除用户自定义封面模板(系统模板不可删除)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
template = repo.get(template_id)
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
logger.warning("cover_templates 表不可用: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
if template.is_system:
|
||||
raise HTTPException(status_code=403, detail="系统模板不可删除")
|
||||
if template.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="无权删除该模板")
|
||||
repo.delete(template_id)
|
||||
@@ -17,7 +17,6 @@ from app.core.task_enqueue import (
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
@@ -46,6 +45,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PREVIEW_RESOLUTION = "854x480"
|
||||
|
||||
# 模板 mode → 视频比例映射
|
||||
_TEMPLATE_MODE_TO_RATIO = {
|
||||
@@ -55,7 +55,24 @@ _TEMPLATE_MODE_TO_RATIO = {
|
||||
}
|
||||
|
||||
|
||||
def _infer_video_ratio_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
||||
def _calc_preview_resolution(video_ratio: str = "") -> str:
|
||||
"""根据视频比例计算预览分辨率(短边 480,长边按比例)。
|
||||
|
||||
支持的比例:16:9, 9:16, 1:1, 4:3, 3:4, 其他默认 16:9。
|
||||
"""
|
||||
ratio_map = {
|
||||
"16:9": "854x480",
|
||||
"9:16": "480x854",
|
||||
"1:1": "480x480",
|
||||
"4:3": "640x480",
|
||||
"3:4": "480x640",
|
||||
}
|
||||
return ratio_map.get(video_ratio.strip(), PREVIEW_RESOLUTION)
|
||||
|
||||
|
||||
def _infer_video_ratio_from_template(
|
||||
template_id: str, db: Session, user_id: str = ""
|
||||
) -> str:
|
||||
"""从模板 mode 推断视频比例,前端未传 video_ratio 时使用。
|
||||
|
||||
Returns:
|
||||
@@ -85,7 +102,9 @@ def _infer_video_ratio_from_template(template_id: str, db: Session, user_id: str
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_strategy_id_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
||||
def _resolve_strategy_id_from_template(
|
||||
template_id: str, db: Session, user_id: str = ""
|
||||
) -> str:
|
||||
"""从模板读取 editing_mode / mode 作为 strategy_id。
|
||||
|
||||
优先查新模板系统(EditTemplate.editing_mode),fallback 旧模板(Template.mode)。
|
||||
@@ -155,6 +174,29 @@ def _mark_task_failed(repo, task, reason: str) -> None:
|
||||
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
|
||||
|
||||
|
||||
def _sign_video_url(raw_url: str) -> str:
|
||||
"""为私有 OSS bucket 的视频 URL 生成预签名下载链接。
|
||||
|
||||
有效期 2 小时,签名失败时降级返回原始 URL。
|
||||
"""
|
||||
if not raw_url:
|
||||
return ""
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
signed = storage.get_download_url(raw_url, expires_seconds=7200)
|
||||
# 如果返回的 URL 与原始 URL 完全不同且不是签名 URL(说明 bucket 未配置),
|
||||
# 降级返回原始 URL
|
||||
if signed and signed != raw_url:
|
||||
return signed
|
||||
if signed == raw_url:
|
||||
return raw_url
|
||||
# signed 为空或与 raw_url 无关,返回原始
|
||||
return raw_url
|
||||
except Exception:
|
||||
logger.warning("[预览] URL签名失败,降级返回原始URL: %s", raw_url[:100], exc_info=True)
|
||||
return raw_url
|
||||
|
||||
|
||||
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
|
||||
"""将领域任务对象转换为预览响应 DTO。
|
||||
|
||||
@@ -171,12 +213,8 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
|
||||
if generated_videos:
|
||||
first_video = generated_videos[0]
|
||||
raw_url = getattr(first_video, "file_url", "") or ""
|
||||
# rendered/* 已配置公开读,直接用裸 URL
|
||||
if raw_url.startswith("http"):
|
||||
video_url = raw_url
|
||||
else:
|
||||
storage = get_storage_service()
|
||||
video_url = storage.get_url(raw_url)
|
||||
# P0 修复:私有 bucket 需要预签名 URL,否则前端 403 → 黑屏
|
||||
video_url = _sign_video_url(raw_url)
|
||||
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
|
||||
file_size = int(getattr(first_video, "file_size", 0) or 0)
|
||||
|
||||
@@ -198,7 +236,7 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
|
||||
status=task.status.value if hasattr(task.status, "value") else str(task.status),
|
||||
progress=float(task.progress or 0.0),
|
||||
is_preview=bool(getattr(task, "is_preview", True)),
|
||||
resolution=getattr(task, "resolution", "") or "",
|
||||
resolution=getattr(task, "resolution", PREVIEW_RESOLUTION) or PREVIEW_RESOLUTION,
|
||||
video_url=video_url,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
@@ -219,11 +257,10 @@ def create_preview_generation_task(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
asset_repo=Depends(get_asset_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""创建预览生成任务。
|
||||
|
||||
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。
|
||||
预览为完整时长的低清版(480p + 低码率),效果与正式生成一致,仅清晰度降低。
|
||||
|
||||
Args:
|
||||
request: 预览任务创建请求(template_id + asset_ids 等)
|
||||
@@ -275,17 +312,17 @@ def create_preview_generation_task(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id=strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
voice_library_id="",
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
title_ids=list(request.title_ids),
|
||||
voice_ids=list(request.voice_ids),
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
source_edit_plan_id="",
|
||||
asset_select_mode="",
|
||||
batch_id="",
|
||||
video_title=request.video_title,
|
||||
resolution="",
|
||||
resolution=_calc_preview_resolution(video_ratio),
|
||||
bgm_config=request.bgm_config or {},
|
||||
auto_retry_enabled=False,
|
||||
auto_retry_max=0,
|
||||
|
||||
@@ -26,7 +26,6 @@ from app.schemas.generated_video import (
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
@@ -63,12 +62,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -298,12 +291,6 @@ def create_generation_task(
|
||||
bgm_config=request.bgm_config,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
is_preview=request.is_preview,
|
||||
source_task_id=request.source_task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -344,120 +331,6 @@ def create_generation_task(
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/confirm", response_model=BatchGenerationTaskResponse)
|
||||
def confirm_generation(
|
||||
task_id: str,
|
||||
request: ConfirmGenerationRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
|
||||
|
||||
预览已使用 1080p / CRF 23 / medium 渲染,品质与正式生成一致。
|
||||
确认时直接将预览任务标记为正式产出,无需重新渲染,实现秒出。
|
||||
仅当预览任务未完成时,才创建新的正式任务走渲染流程。
|
||||
"""
|
||||
# 1. 查找源预览任务
|
||||
source_task = generation_task_repository.get(task_id)
|
||||
if source_task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Preview task {task_id} not found")
|
||||
|
||||
# 2. 权限检查
|
||||
if source_task.created_by_user_id and source_task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
if source_task.project_id:
|
||||
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
|
||||
if source_task.is_completed and getattr(source_task, "is_preview", False):
|
||||
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
|
||||
req_w = request.output_width or 0
|
||||
req_h = request.output_height or 0
|
||||
src_w = getattr(source_task, "output_width", 0) or 0
|
||||
src_h = getattr(source_task, "output_height", 0) or 0
|
||||
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
|
||||
|
||||
if resolution_match:
|
||||
source_task.mark_confirmed(
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
)
|
||||
generation_task_repository.update(source_task)
|
||||
logger.info(
|
||||
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
|
||||
task_id,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(source_task)],
|
||||
total=1,
|
||||
)
|
||||
# 分辨率不一致,跳过复用,走新建任务流程
|
||||
logger.info(
|
||||
"[确认生成] 分辨率不一致,跳过复用: task_id=%s, src=%sx%s, req=%sx%s",
|
||||
task_id,
|
||||
src_w,
|
||||
src_h,
|
||||
req_w,
|
||||
req_h,
|
||||
)
|
||||
|
||||
# 4. 预览任务未完成,创建新的正式任务走渲染流程
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
new_task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=source_task.project_id,
|
||||
asset_library_id=source_task.asset_library_id,
|
||||
strategy_id=source_task.strategy_id,
|
||||
voice_library_id=source_task.voice_library_id,
|
||||
template_id=source_task.template_id,
|
||||
asset_ids=source_task.asset_ids,
|
||||
title_ids=source_task.title_ids,
|
||||
voice_ids=source_task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=source_task.source_edit_plan_id or "",
|
||||
asset_select_mode=source_task.asset_select_mode,
|
||||
video_title=getattr(source_task, "video_title", ""),
|
||||
resolution=getattr(source_task, "resolution", ""),
|
||||
is_preview=False,
|
||||
source_task_id=task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 调度 worker
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
new_task,
|
||||
generation_task_repository,
|
||||
user_id=authenticated_user.user.id,
|
||||
log_prefix="[确认生成]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(new_task)],
|
||||
total=1,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
def list_generation_tasks(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -555,12 +428,6 @@ def retry_generation_task(
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -27,14 +27,18 @@ from packages.domain.edit_plan import EditPlanStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||
def _auto_fallback_draft_to_editing(
|
||||
svc: EditPlanService, plan_id: str, plan_check
|
||||
) -> None:
|
||||
"""自动兜底 1: draft → editing"""
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
|
||||
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||
def _auto_fallback_copy_template_clips(
|
||||
svc: EditPlanService, plan_id: str, plan_check, db: Session
|
||||
) -> None:
|
||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
@@ -49,15 +53,15 @@ def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
clip_type=cfg.clip_type.value
|
||||
if hasattr(cfg.clip_type, "value")
|
||||
else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
transition_effect=cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect,
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
|
||||
@@ -86,14 +90,17 @@ def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_
|
||||
)
|
||||
|
||||
|
||||
def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list:
|
||||
def _auto_fallback_assign_assets(
|
||||
svc: EditPlanService, plan_id: str, plan_check
|
||||
) -> list:
|
||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r",
|
||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d "
|
||||
"clips_without_asset=%d config_asset_ids=%r",
|
||||
plan_id,
|
||||
len(all_clips),
|
||||
len(clips_without_asset),
|
||||
@@ -154,74 +161,43 @@ def _auto_fallback_auto_material_mode(
|
||||
clips_without_asset: list,
|
||||
asset_library_repo: Any,
|
||||
asset_repo: Any,
|
||||
user_id: str = "",
|
||||
) -> None:
|
||||
"""自动兜底 4: 自动选素材分配给无素材片段
|
||||
|
||||
查找策略(按优先级):
|
||||
1. plan 有 project_id → 从项目素材库查找
|
||||
2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找
|
||||
"""
|
||||
"""自动兜底 4: 项目有视频素材库时自动选素材"""
|
||||
if not clips_without_asset:
|
||||
return
|
||||
|
||||
ready_videos: list = []
|
||||
source_desc = ""
|
||||
|
||||
# 策略 1: 通过 project_id 查找项目素材库
|
||||
if plan_check.project_id:
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
source_desc = f"素材库 {video_lib.name}"
|
||||
|
||||
# 策略 2: 通过 user_id 查找用户上传的素材
|
||||
if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"):
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材",
|
||||
plan_id,
|
||||
user_id,
|
||||
)
|
||||
ready_videos = asset_repo.find_ready_videos_by_user(user_id)
|
||||
source_desc = f"用户上传 (user_id={user_id[:8]}...)"
|
||||
|
||||
if not ready_videos:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)",
|
||||
plan_id,
|
||||
plan_check.project_id or "(empty)",
|
||||
user_id[:8] + "..." if user_id else "(empty)",
|
||||
)
|
||||
if not plan_check.project_id:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)",
|
||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
)
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 从 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
if ready_videos:
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材",
|
||||
plan_id,
|
||||
video_lib.name,
|
||||
len(ready_videos),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""封面管理路由.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
- GET /cover 封面配置
|
||||
- PUT /cover 更新封面
|
||||
- POST /cover/extract 抽帧生成封面
|
||||
- POST /cover/smart 智能选帧
|
||||
- POST /generate-cover AI 生成封面
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -9,20 +13,19 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_generated_video_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
CoverConfigResponse,
|
||||
CoverExtractRequest,
|
||||
CoverGenerateResponse,
|
||||
CoverSmartRequest,
|
||||
CoverUpdateRequest,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
@@ -31,126 +34,188 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
@router.get("/cover", response_model=CoverConfigResponse)
|
||||
def get_editor_cover(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverConfigResponse:
|
||||
"""获取草稿封面配置"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
config = plan.config or {}
|
||||
cover_config = config.get("cover", {})
|
||||
|
||||
return CoverConfigResponse(
|
||||
type=cover_config.get("cover_type", "auto"),
|
||||
image_url=cover_config.get("cover_image_url", ""),
|
||||
frame_time=cover_config.get("frame_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/cover", response_model=CoverConfigResponse)
|
||||
def update_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverConfigResponse:
|
||||
"""更新草稿封面配置"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
current_cover = dict(config.get("cover", {}))
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
current_cover.update(update_data)
|
||||
|
||||
config["cover"] = current_cover
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
return CoverConfigResponse(
|
||||
type=current_cover.get("cover_type", "auto"),
|
||||
image_url=current_cover.get("cover_image_url", ""),
|
||||
frame_time=current_cover.get("frame_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cover/extract", response_model=CoverGenerateResponse)
|
||||
def extract_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverExtractRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverGenerateResponse:
|
||||
"""从指定片段抽帧生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
clip = plan_svc.get_clip(body.clip_id)
|
||||
if not clip or clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=400, detail="片段不存在或不属于当前草稿")
|
||||
|
||||
cover_url = f"cover/extract/{plan_id}_{body.clip_id}_{body.frame_time}.jpg"
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
cover_config = dict(config.get("cover", {}))
|
||||
cover_config.update(
|
||||
{
|
||||
"cover_type": "extract",
|
||||
"cover_image_url": cover_url,
|
||||
"clip_id": body.clip_id,
|
||||
"frame_time": body.frame_time,
|
||||
}
|
||||
)
|
||||
config["cover"] = cover_config
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器封面抽帧: template_id=%s plan_id=%s clip_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
type="extract",
|
||||
image_url=cover_url,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cover/smart", response_model=CoverGenerateResponse)
|
||||
def smart_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverSmartRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverGenerateResponse:
|
||||
"""智能选帧生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
cover_url = f"cover/smart/{plan_id}_smart.jpg"
|
||||
strategy = getattr(body, "strategy", "auto")
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
cover_config = dict(config.get("cover", {}))
|
||||
cover_config.update(
|
||||
{
|
||||
"cover_type": "smart",
|
||||
"cover_image_url": cover_url,
|
||||
"strategy": strategy,
|
||||
}
|
||||
)
|
||||
config["cover"] = cover_config
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器智能封面: template_id=%s plan_id=%s strategy=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
strategy,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
type="smart",
|
||||
image_url=cover_url,
|
||||
frame_time=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def editor_generate_cover(
|
||||
template_id: str,
|
||||
body: GenerateCoverRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面 — 从预览视频中抽帧.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
"""
|
||||
"""AI 生成封面"""
|
||||
_, 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/* 已配置公开读)
|
||||
# 获取第一个视频的下载 URL(用于 MediaKit 抽帧)
|
||||
primary_video_url = None
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
if body.asset_ids and body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
try:
|
||||
from app.database import get_db_session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
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
|
||||
with get_db_session() as session:
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
first_asset = asset_repo.get(body.asset_ids[0])
|
||||
if first_asset and first_asset.storage_key:
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_download_url(first_asset.storage_key)
|
||||
logger.info(
|
||||
"获取视频URL用于封面生成: asset_id=%s url=%s",
|
||||
body.asset_ids[0],
|
||||
primary_video_url[:80] if primary_video_url else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取视频URL失败,将使用stub封面: %s", str(e))
|
||||
|
||||
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
|
||||
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,
|
||||
)
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
@@ -158,7 +223,7 @@ def editor_generate_cover(
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
"模板编辑器封面生成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
|
||||
@@ -18,7 +18,6 @@ from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
)
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
@@ -29,7 +28,6 @@ from sqlalchemy.orm import Session
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
@@ -73,60 +71,25 @@ def generate_editor_draft(
|
||||
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
|
||||
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
|
||||
_auto_fallback_auto_material_mode(
|
||||
plan_svc,
|
||||
plan_id,
|
||||
plan_check,
|
||||
clips_without_asset,
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id=str(current_user.user.id),
|
||||
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
|
||||
)
|
||||
|
||||
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
reusable_task.mark_confirmed()
|
||||
gen_task_repo.update(reusable_task)
|
||||
|
||||
# 将产物 URL 写入 plan config
|
||||
rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db)
|
||||
plan_svc.update_plan_config(
|
||||
plan_id,
|
||||
{
|
||||
"generation_task_id": reusable_task.id,
|
||||
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
|
||||
},
|
||||
)
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
logger.info(
|
||||
"模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
reusable_task.id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=reusable_task.id,
|
||||
clip_count=len((plan_check.config or {}).get("clips", [])),
|
||||
)
|
||||
|
||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
||||
try:
|
||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
if not can_gen:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=reason
|
||||
)
|
||||
|
||||
try:
|
||||
clip_count = plan_svc.mark_clips_ready(plan_id)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
user_id = current_user.user.id
|
||||
_check_queue_limits(gen_task_repo, user_id)
|
||||
|
||||
@@ -160,7 +123,9 @@ def generate_editor_draft(
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
plan_status=updated_plan.status.value
|
||||
if hasattr(updated_plan.status, "value")
|
||||
else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
@@ -182,53 +147,6 @@ def generate_editor_draft(
|
||||
) from _e
|
||||
|
||||
|
||||
def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None":
|
||||
"""查找该 plan 关联的已完成预览任务,判断是否可复用。
|
||||
|
||||
复用条件:
|
||||
1. 存在 source_edit_plan_id == plan_id 的已完成预览任务
|
||||
2. plan 在预览完成后未被修改(updated_at <= 预览完成时间)
|
||||
|
||||
Returns:
|
||||
可复用的 GenerationTask,或 None
|
||||
"""
|
||||
try:
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for task in tasks:
|
||||
if not getattr(task, "is_preview", False):
|
||||
continue
|
||||
if not task.is_completed:
|
||||
continue
|
||||
# 检查 plan 是否在预览完成后被修改
|
||||
completed_at = getattr(task, "completed_at", None)
|
||||
if completed_at and hasattr(plan, "updated_at"):
|
||||
plan_updated = plan.updated_at
|
||||
# 如果 plan.updated_at 为空,无法判断是否修改过,跳过
|
||||
if plan_updated is None:
|
||||
continue
|
||||
# 如果 plan 在预览完成后又被修改了,不能复用
|
||||
if plan_updated > completed_at:
|
||||
continue
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
"""获取任务的输出视频 URL。"""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
||||
def get_editor_generation_status(
|
||||
template_id: str,
|
||||
@@ -242,7 +160,9 @@ def get_editor_generation_status(
|
||||
try:
|
||||
gen_status = plan_svc.get_generation_status(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
plan = gen_status["plan"]
|
||||
clips = gen_status["clips"]
|
||||
@@ -260,22 +180,25 @@ def get_editor_generation_status(
|
||||
for c in clips
|
||||
]
|
||||
|
||||
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
|
||||
raw_video_url = (plan.config or {}).get("rendered_url", "")
|
||||
video_url = ""
|
||||
if raw_video_url:
|
||||
if raw_video_url.startswith("http"):
|
||||
video_url = raw_video_url # 已经是完整 URL
|
||||
else:
|
||||
try:
|
||||
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
|
||||
except Exception as e:
|
||||
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
|
||||
video_url = raw_video_url
|
||||
try:
|
||||
video_url = storage_service.get_download_url(
|
||||
raw_video_url, expires_seconds=86400
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"生成视频签名URL失败: template_id=%s error=%s", template_id, e
|
||||
)
|
||||
video_url = raw_video_url
|
||||
|
||||
progress = gen_status.get("progress", 0.0)
|
||||
error_message = gen_status.get("error_message", "")
|
||||
gen_task_status = gen_status.get("generation_task_status")
|
||||
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
plan_status_val = (
|
||||
plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
)
|
||||
if plan_status_val == "completed" and progress < 100:
|
||||
progress = 100.0
|
||||
|
||||
|
||||
@@ -257,6 +257,43 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
# ── 封面配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverConfigResponse(BaseModel):
|
||||
"""封面配置响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||
image_url: str = Field(default="", description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverUpdateRequest(BaseModel):
|
||||
"""更新封面配置请求"""
|
||||
|
||||
type: Optional[str] = Field(default=None, description="封面类型")
|
||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverExtractRequest(BaseModel):
|
||||
"""从片段抽帧生成封面请求"""
|
||||
|
||||
clip_id: str = Field(..., description="片段 ID")
|
||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverSmartRequest(BaseModel):
|
||||
"""智能选帧请求"""
|
||||
|
||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||
|
||||
|
||||
class CoverGenerateResponse(BaseModel):
|
||||
"""封面生成响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型")
|
||||
image_url: str = Field(..., description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── 导出配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRe
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||
SQLAlchemyClassificationJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.cover_template_repository import (
|
||||
SQLAlchemyCoverTemplateRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.duplication_repository import (
|
||||
SQLAlchemyDuplicationRecordRepository,
|
||||
)
|
||||
@@ -131,13 +128,6 @@ def get_project_repository(
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
|
||||
def get_cover_template_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyCoverTemplateRepository:
|
||||
"""Provide the SQLAlchemy cover template repository implementation."""
|
||||
return SQLAlchemyCoverTemplateRepository(session)
|
||||
|
||||
|
||||
def get_tag_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> TagRepository:
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""封面模板 Schema。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoverTemplateConfig(BaseModel):
|
||||
"""封面模板配置。"""
|
||||
|
||||
background_enabled: bool = Field(default=True, description="是否启用背景")
|
||||
background_color: str = Field(default="#000000", description="背景颜色")
|
||||
portrait_enabled: bool = Field(default=True, description="是否显示人像")
|
||||
title_text: str = Field(default="", description="主标题文字")
|
||||
subtitle_text: str = Field(default="", description="副标题文字")
|
||||
mask_enabled: bool = Field(default=False, description="是否启用蒙版")
|
||||
|
||||
|
||||
class CreateCoverTemplateRequest(BaseModel):
|
||||
"""创建封面模板请求。"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
||||
thumbnail_url: str = Field(default="", description="缩略图 URL")
|
||||
config: CoverTemplateConfig | None = Field(default=None, description="模板配置")
|
||||
|
||||
|
||||
class UpdateCoverTemplateRequest(BaseModel):
|
||||
"""更新封面模板请求。"""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200, description="模板名称")
|
||||
thumbnail_url: str | None = Field(default=None, description="缩略图 URL")
|
||||
config: CoverTemplateConfig | None = Field(default=None, description="模板配置")
|
||||
|
||||
|
||||
class CoverTemplateResponse(BaseModel):
|
||||
"""封面模板响应。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
thumbnail_url: str
|
||||
is_system: bool
|
||||
created_at: datetime
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ListCoverTemplatesResponse(BaseModel):
|
||||
"""封面模板列表响应。"""
|
||||
|
||||
items: list[CoverTemplateResponse]
|
||||
total: int = Field(default=0, ge=0)
|
||||
@@ -4,15 +4,6 @@ from datetime import datetime
|
||||
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="输出视频高度")
|
||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
"""创建生成任务请求。
|
||||
|
||||
@@ -66,13 +57,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
# ── 预览 / 确认生成 ──
|
||||
is_preview: bool = Field(default=False, description="是否为预览任务")
|
||||
source_task_id: str = Field(default="", description="来源预览任务 ID(确认生成时传入)")
|
||||
output_width: int = Field(default=1280, description="输出视频宽度")
|
||||
output_height: int = Field(default=720, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -103,12 +87,6 @@ class GenerationTaskResponse(BaseModel):
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
bgm_config: dict = Field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
@@ -154,16 +132,13 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
"""创建预览生成任务请求。
|
||||
|
||||
仅支持模板模式:template_id + asset_ids 等素材 ID 列表。
|
||||
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset)。
|
||||
预览为完整时长低清版(480p + 低码率)。
|
||||
"""
|
||||
|
||||
template_id: str
|
||||
asset_ids: list[str] = Field(default_factory=list)
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
voice_library_id: str = Field(
|
||||
default="", description="配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材"
|
||||
)
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
duration: float = Field(default=0.0, ge=0, description="期望视频时长(秒),0 表示由模板决定")
|
||||
video_ratio: str = Field(default="", description="视频比例,如 16:9 / 9:16,为空使用模板默认")
|
||||
@@ -177,10 +152,6 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
le=10,
|
||||
description="预览视频生成数量,范围 1-10,默认 1",
|
||||
)
|
||||
source_edit_plan_id: str = Field(
|
||||
default="",
|
||||
description="关联的编辑计划ID(可选),用于确认生成时复用预览产物",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
|
||||
@@ -215,7 +215,7 @@ class CoverService:
|
||||
import subprocess
|
||||
|
||||
# scale + crop 实现 cover 裁剪
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
@@ -258,8 +258,6 @@ class CoverService:
|
||||
"1",
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
|
||||
@@ -374,7 +374,7 @@ class VideoComposeService:
|
||||
EditPlanStatus.EDITING,
|
||||
EditPlanStatus.RENDERING,
|
||||
),
|
||||
"rendered_url": plan.config.get("rendered_storage_key", "") or plan.config.get("rendered_url", ""),
|
||||
"rendered_url": plan.config.get("rendered_url", ""),
|
||||
}
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,10 +50,10 @@ type AssetListResponse = {
|
||||
}
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
test.setTimeout(180_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
@@ -205,21 +205,18 @@ test.describe("Core generation flow", () => {
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 120_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 等待组件完全渲染
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Antd AutoComplete 的 placeholder 渲染在 span 上,input 无 placeholder 属性
|
||||
// 使用 Antd AutoComplete 特有的 class 定位输入框
|
||||
const titleInput = page.locator(".ant-select-auto-complete input")
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// 如果 AI 自动选择标题模式开启,先切换到手动模式以显示输入框
|
||||
const aiSwitch = page.locator(".xx-title-ai-toggle .xx-switch.active")
|
||||
if (await aiSwitch.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await aiSwitch.click()
|
||||
}
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await titleInput.fill(titleText)
|
||||
await page.getByPlaceholder("输入或从标题库选择…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
@@ -230,16 +227,13 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
|
||||
// Wait for generation API to be called
|
||||
// 确认生成走新流程:POST /tasks/{taskId}/confirm(复用预览产物)
|
||||
// 或旧流程:POST /editor/generate(向后兼容)
|
||||
// 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
|
||||
// 等 generate 接口返回,确认生成流程启动
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
(path.endsWith("/confirm") || path.endsWith("/editor/generate"))
|
||||
)
|
||||
return response.request().method() === "POST" && path.endsWith("/editor/generate")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
@@ -255,15 +249,10 @@ test.describe("Core generation flow", () => {
|
||||
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
|
||||
)
|
||||
}
|
||||
// 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()
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
}
|
||||
expect(genResp.ok()).toBeTruthy()
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
|
||||
// Generation may fail in test env (no worker), that's OK
|
||||
// Just verify the flow started - check page shows generation-related UI
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 封面模板 CRUD API
|
||||
* 后端路由: /api/v1/cover-templates
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { CoverTemplate } from "@/pages/editing-planner/types"
|
||||
|
||||
export interface CoverTemplateListResponse {
|
||||
items: CoverTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CoverTemplateCreateRequest {
|
||||
name: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
|
||||
|
||||
/** 获取封面模板列表 */
|
||||
export async function fetchCoverTemplates(): Promise<CoverTemplateListResponse> {
|
||||
const response = await apiClient.get<CoverTemplateListResponse>("/cover-templates")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建封面模板 */
|
||||
export async function createCoverTemplate(
|
||||
data: CoverTemplateCreateRequest,
|
||||
): Promise<CoverTemplate> {
|
||||
const response = await apiClient.post<CoverTemplate>("/cover-templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新封面模板 */
|
||||
export async function updateCoverTemplate(
|
||||
id: string,
|
||||
data: CoverTemplateUpdateRequest,
|
||||
): Promise<CoverTemplate> {
|
||||
const response = await apiClient.put<CoverTemplate>(`/cover-templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除封面模板(系统模板不可删) */
|
||||
export async function deleteCoverTemplate(id: string): Promise<void> {
|
||||
await apiClient.delete(`/cover-templates/${id}`)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { ConfirmGenerationRequest, ConfirmGenerationResponse } from "./types"
|
||||
|
||||
/** 确认生成 — 基于预览任务创建正式生成任务 */
|
||||
export const confirmGeneration = async (
|
||||
taskId: string,
|
||||
params: ConfirmGenerationRequest,
|
||||
): Promise<ConfirmGenerationResponse> => {
|
||||
const response = await apiClient.post<ConfirmGenerationResponse>(
|
||||
`/tasks/${taskId}/confirm`,
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
Executable → Regular
-4
@@ -3,10 +3,6 @@ export type {
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
ConfirmGenerationResponse,
|
||||
ConfirmGenerationTaskItem,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
Executable → Regular
-47
@@ -5,11 +5,8 @@ export type PreviewStatus = "pending" | "generating" | "completed" | "failed" |
|
||||
export interface CreatePreviewRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
source_edit_plan_id?: string
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
/** 配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材 */
|
||||
voice_library_id?: string
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
@@ -48,47 +45,3 @@ export interface PreviewTaskResponse {
|
||||
finished_at?: string
|
||||
generate_duration?: number
|
||||
}
|
||||
|
||||
/** 确认生成请求体 — 基于预览任务创建正式生成任务 */
|
||||
export interface ConfirmGenerationRequest {
|
||||
/** 输出视频宽度,默认 1080 */
|
||||
output_width?: number
|
||||
/** 输出视频高度,默认 1920 */
|
||||
output_height?: number
|
||||
/** 自定义封面图片 URL */
|
||||
cover_url?: string
|
||||
/** 自定义视频标题 */
|
||||
custom_title?: string
|
||||
}
|
||||
|
||||
/** 确认生成响应 */
|
||||
export interface ConfirmGenerationResponse {
|
||||
items: ConfirmGenerationTaskItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 确认生成返回的任务项 */
|
||||
export interface ConfirmGenerationTaskItem {
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
strategy_id: string
|
||||
voice_library_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
source_edit_plan_id: string
|
||||
asset_select_mode: string
|
||||
batch_id: string
|
||||
is_preview: boolean
|
||||
source_task_id: string
|
||||
output_width: number
|
||||
output_height: number
|
||||
cover_url: string
|
||||
custom_title: string
|
||||
status: string
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ 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 秒
|
||||
})
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -288,7 +288,6 @@ export interface CoverResult {
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
image_url?: string
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ interface ModalComponent extends React.FC<ModalProps> {
|
||||
warning: (config: ModalFuncProps) => ReturnType<typeof AntModal.warning>
|
||||
}
|
||||
|
||||
const Modal: ModalComponent = ({ className, v21 = true, centered = true, children, ...rest }) => {
|
||||
const Modal: ModalComponent = ({ className, v21 = true, children, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-modal", className)
|
||||
return (
|
||||
<AntModal className={v21Class} centered={centered} {...rest}>
|
||||
<AntModal className={v21Class} {...rest}>
|
||||
{children}
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -33,7 +32,6 @@
|
||||
gap: var(--space-sm);
|
||||
position: sticky;
|
||||
top: var(--space-md);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.xx-asset-library-item {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react"
|
||||
import { Select as AntSelect } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { Modal as AntModal, Select as AntSelect } from "antd"
|
||||
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
@@ -25,7 +24,7 @@ const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
onCategoryChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<Modal
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -44,7 +43,7 @@ const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchClassifyModal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
@@ -12,7 +12,7 @@ export interface PlayModalProps {
|
||||
}
|
||||
|
||||
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
|
||||
<Modal
|
||||
<AntModal
|
||||
title={asset?.name ?? "播放"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
@@ -28,7 +28,7 @@ const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
|
||||
<p className="xx-asset-empty-fallback-id">素材 ID: {asset?.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default PlayModal
|
||||
|
||||
@@ -14,13 +14,7 @@ export interface ResultDrawerProps {
|
||||
}
|
||||
|
||||
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
|
||||
<Drawer
|
||||
title={`${title} — 操作结果`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
>
|
||||
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
|
||||
{result && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
.ep-v8-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 68px);
|
||||
height: 100vh;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
color: var(--text-primary, #1e293b);
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
@@ -6408,35 +6408,3 @@
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ═══ 封面 AI 生成按钮 ═══ */
|
||||
.cover-generate-section {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.cover-generate-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--color-primary, #1677ff);
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary, #1677ff);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cover-generate-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover, #4096ff);
|
||||
border-color: var(--color-primary-hover, #4096ff);
|
||||
}
|
||||
|
||||
.cover-generate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -243,7 +243,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 +335,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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
|
||||
title="滤镜调色"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="filter-panel-drawer"
|
||||
|
||||
@@ -48,7 +48,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
||||
title="绿幕抠像"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="green-screen-panel-drawer"
|
||||
|
||||
@@ -63,7 +63,6 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({ open, onClose, config
|
||||
title="🎬 片头片尾设置"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="intro-outro-panel-drawer"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -74,7 +74,6 @@ const SpeedPanel: React.FC<SpeedPanelProps> = ({ open, onClose, config, onChange
|
||||
title="⚡ 片段调速"
|
||||
placement="right"
|
||||
width={380}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="speed-panel-drawer"
|
||||
|
||||
@@ -34,7 +34,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
title="💬 字幕样式配置"
|
||||
placement="right"
|
||||
width={380}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
|
||||
@@ -53,7 +53,6 @@ const TransitionSelector: React.FC<TransitionSelectorProps> = ({
|
||||
title={`🎬 ${title}`}
|
||||
placement="right"
|
||||
width={480}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="transition-selector-drawer"
|
||||
|
||||
@@ -42,7 +42,6 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
|
||||
title="🔖 水印设置"
|
||||
placement="right"
|
||||
width={400}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="watermark-panel-drawer"
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { Drawer, Modal, Spin, message } from "antd"
|
||||
import { generateCover } from "@/api/template-editor/aiFeatures"
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
|
||||
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
|
||||
@@ -15,7 +14,6 @@ interface CoverSelectorProps {
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
templateId: string
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
@@ -24,7 +22,6 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
templateId,
|
||||
}) => {
|
||||
const {
|
||||
fileInputRef,
|
||||
@@ -39,31 +36,6 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
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="封面选择"
|
||||
@@ -161,33 +133,12 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,5 +68,4 @@ export interface ClipPropertiesPanelProps {
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void
|
||||
}
|
||||
|
||||
@@ -30,20 +30,3 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
}
|
||||
|
||||
/** 封面模板 */
|
||||
export interface CoverTemplate {
|
||||
id: string
|
||||
name: string
|
||||
thumbnail_url: string
|
||||
is_system: boolean
|
||||
created_at: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export {
|
||||
} from "./sticker"
|
||||
|
||||
/* 封面 */
|
||||
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG, type CoverTemplate } from "./cover"
|
||||
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG } from "./cover"
|
||||
|
||||
/* 片段数据 */
|
||||
export { type ClipType, type ClipData } from "./clip"
|
||||
|
||||
@@ -106,7 +106,6 @@ const GeneratePage: React.FC = () => {
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
})
|
||||
|
||||
@@ -150,7 +149,6 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
previewTaskId: step4Preview.selectedTaskId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -169,7 +169,6 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
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)
|
||||
@@ -311,7 +310,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-video">
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
@@ -328,7 +327,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
|
||||
<div className="xx-preview-video xx-preview-video--error">
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
</div>
|
||||
<p className="xx-preview-error-msg">
|
||||
@@ -343,7 +342,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 预览成功 + Canvas 标题叠加 */}
|
||||
{hasPreview && (
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewResult.videoUrl}
|
||||
|
||||
@@ -42,7 +42,7 @@ const PREVIEW_COUNT_OPTIONS = [
|
||||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
videoRatio,
|
||||
videoRatio: _videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
items,
|
||||
@@ -55,7 +55,6 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
|
||||
const isIdle = overallStatus === "idle"
|
||||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||||
|
||||
@@ -135,13 +134,11 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||||
<div
|
||||
className="xx-preview-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||||
gap: 12,
|
||||
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
|
||||
margin: "0 auto 16px",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
@@ -164,7 +161,8 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
{/* 缩略图/状态区域 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio,
|
||||
aspectRatio: "9/16",
|
||||
maxHeight: 180,
|
||||
background: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
|
||||
import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker"
|
||||
import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
@@ -18,20 +18,15 @@ interface Step6CoverSettingsProps {
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
generateAutoCover,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
handleSelectTemplate,
|
||||
handleEditTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
} = useStep6Cover({
|
||||
coverSettings: props.coverSettings,
|
||||
onCoverSettingsChange: props.onCoverSettingsChange,
|
||||
@@ -40,9 +35,32 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
})
|
||||
|
||||
const handleAutoGenerate = () => {
|
||||
generateAutoCover()
|
||||
}
|
||||
// 进入 auto 模式时自动触发智能封面生成
|
||||
const autoTriggeredRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// 切换模式、禁用封面或素材变更时重置触发标记
|
||||
if (coverSettings.mode !== "auto" || !coverSettings.enabled) {
|
||||
autoTriggeredRef.current = false
|
||||
return
|
||||
}
|
||||
// 有素材且未生成过封面时自动触发
|
||||
if (
|
||||
coverSettings.mode === "auto" &&
|
||||
!coverSettings.thumbnail_url &&
|
||||
!autoTriggeredRef.current &&
|
||||
props.assetIds &&
|
||||
props.assetIds.length > 0
|
||||
) {
|
||||
autoTriggeredRef.current = true
|
||||
generateAutoCover()
|
||||
}
|
||||
}, [
|
||||
coverSettings.enabled,
|
||||
coverSettings.mode,
|
||||
coverSettings.thumbnail_url,
|
||||
generateAutoCover,
|
||||
props.assetIds,
|
||||
])
|
||||
|
||||
// 预览图:优先 thumbnail_url,其次 upload_url
|
||||
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
|
||||
@@ -51,50 +69,70 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
<div className="xx-cover-actions">
|
||||
<Button buttonType="primary" onClick={handleAutoGenerate}>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
⚙️ 封面设置
|
||||
</Button>
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverSettings.enabled}
|
||||
onChange={(e) => toggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="xx-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="封面预览" className="xx-cover-preview-img" />
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>点击"自动生成封面"或选择模板</span>
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<CoverModeSelector
|
||||
mode={coverSettings.mode}
|
||||
onModeChange={setMode}
|
||||
modeLabels={COVER_MODE_LABELS}
|
||||
modeIcons={COVER_MODE_ICONS}
|
||||
/>
|
||||
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-badge">
|
||||
<span style={{ fontSize: 24 }}>🤖</span>
|
||||
<span>AI 智能选帧</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{coverSettings.mode === "frame" && (
|
||||
<FrameCoverPicker
|
||||
frameTime={coverSettings.frame_time}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={setFrameTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
{coverSettings.mode === "upload" && (
|
||||
<UploadCoverPicker uploadUrl={coverSettings.upload_url} onUpload={handleUpload} />
|
||||
)}
|
||||
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="封面预览" className="xx-cover-preview-img" />
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{coverSettings.mode === "auto"
|
||||
? "AI 正在选择..."
|
||||
: coverSettings.mode === "frame"
|
||||
? `帧 ${formatTime(coverSettings.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">9:16</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">9:16</div>
|
||||
</div>
|
||||
|
||||
<CoverSettingsModal
|
||||
open={showCoverSettings}
|
||||
onClose={() => setShowCoverSettings(false)}
|
||||
templates={coverTemplates}
|
||||
loading={templatesLoading}
|
||||
error={templatesError}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onEditTemplate={handleEditTemplate}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
onCreateNew={() => {
|
||||
setShowCoverSettings(false)
|
||||
setShowCoverEditor(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<CoverEditorModal
|
||||
open={showCoverEditor}
|
||||
onClose={() => setShowCoverEditor(false)}
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useState } from "react"
|
||||
import type { CoverTemplate } from "../../../editing-planner/types"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
interface CoverEditorModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
template: CoverTemplate | null
|
||||
onSave: (template: CoverTemplate) => void
|
||||
}
|
||||
|
||||
interface SectionState {
|
||||
basic: boolean
|
||||
portrait: boolean
|
||||
background: boolean
|
||||
title: boolean
|
||||
subtitle: boolean
|
||||
mask: boolean
|
||||
}
|
||||
|
||||
const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, template, onSave }) => {
|
||||
const [name, setName] = useState(template?.name || "")
|
||||
const [sections, setSections] = useState<SectionState>({
|
||||
basic: true,
|
||||
portrait: false,
|
||||
background: false,
|
||||
title: true,
|
||||
subtitle: true,
|
||||
mask: false,
|
||||
})
|
||||
|
||||
const toggleSection = (key: keyof SectionState) => {
|
||||
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!template) return
|
||||
onSave({ ...template, name })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={1000}
|
||||
title="自定义封面编辑器"
|
||||
centered
|
||||
footer={null}
|
||||
>
|
||||
<div className="xx-cover-editor-header">
|
||||
<input
|
||||
className="xx-cover-editor-name-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入模板名称"
|
||||
/>
|
||||
<div className="xx-cover-editor-header-actions">
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={handleSave}>
|
||||
保存模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-cover-editor-layout">
|
||||
{/* 左侧折叠面板 */}
|
||||
<div className="xx-cover-editor-left">
|
||||
{[
|
||||
{ key: "basic" as const, label: "基础设置" },
|
||||
{ key: "portrait" as const, label: "人像设置" },
|
||||
{ key: "background" as const, label: "背景设置", toggle: true },
|
||||
{ key: "title" as const, label: "主标题" },
|
||||
{ key: "subtitle" as const, label: "副标题" },
|
||||
{ key: "mask" as const, label: "蒙版", toggle: true },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="xx-cover-editor-section">
|
||||
<div
|
||||
className="xx-cover-editor-section-header"
|
||||
onClick={() => toggleSection(item.key)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{sections[item.key] ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections[item.key] && (
|
||||
<div className="xx-cover-editor-section-body">
|
||||
{item.toggle ? (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<input type="checkbox" defaultChecked={false} />
|
||||
已开启
|
||||
</label>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-tertiary)" }}>暂无配置项</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右侧画布预览 */}
|
||||
<div className="xx-cover-editor-right">
|
||||
<div className="xx-cover-editor-canvas">
|
||||
{/* 人像占位 */}
|
||||
<div className="xx-cover-editor-portrait">
|
||||
{/* 四角拖拽手柄 */}
|
||||
<span className="xx-cover-editor-handle" style={{ top: -4, left: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ top: -4, right: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ bottom: -4, left: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ bottom: -4, right: -4 }} />
|
||||
</div>
|
||||
{/* 文字占位 */}
|
||||
<div className="xx-cover-editor-title-placeholder">主标题文字</div>
|
||||
<div className="xx-cover-editor-subtitle-placeholder">副标题文字</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverEditorModal
|
||||
@@ -1,112 +0,0 @@
|
||||
import React from "react"
|
||||
import type { CoverTemplate } from "../../../editing-planner/types"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
interface CoverSettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
templates: CoverTemplate[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
selectedTemplateId: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
onEditTemplate: (template: CoverTemplate) => void
|
||||
onDeleteTemplate: (id: string) => void
|
||||
onCreateNew: () => void
|
||||
}
|
||||
|
||||
const GRADIENT_MAP: Record<string, string> = {
|
||||
default: "linear-gradient(135deg, #e0e0e0, #c0c0c0)",
|
||||
"bold-red": "linear-gradient(135deg, #ef4444, #b91c1c)",
|
||||
"elegant-black": "linear-gradient(135deg, #374151, #111827)",
|
||||
"gradient-blue": "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
||||
"gradient-purple": "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
"warm-orange": "linear-gradient(135deg, #f97316, #ea580c)",
|
||||
"fresh-green": "linear-gradient(135deg, #22c55e, #15803d)",
|
||||
"tech-blue": "linear-gradient(135deg, #06b6d4, #0e7490)",
|
||||
}
|
||||
|
||||
const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
templates,
|
||||
loading = false,
|
||||
error = null,
|
||||
selectedTemplateId,
|
||||
onSelectTemplate,
|
||||
onEditTemplate,
|
||||
onDeleteTemplate,
|
||||
onCreateNew,
|
||||
}) => {
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
|
||||
<div className="xx-cover-modal-toolbar">
|
||||
<Button buttonType="primary">选择素材文件</Button>
|
||||
<Button buttonType="ghost">导出全部</Button>
|
||||
<Button buttonType="primary" onClick={onCreateNew}>
|
||||
创建新模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "var(--text-secondary)" }}>
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="xx-cover-template-grid">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onSelectTemplate(tpl.id)}
|
||||
>
|
||||
<div
|
||||
className="xx-cover-template-thumb"
|
||||
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
|
||||
>
|
||||
🖼️
|
||||
</div>
|
||||
<div className="xx-cover-template-info">
|
||||
<div className="xx-cover-template-name">
|
||||
{tpl.name}
|
||||
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统模板</span>}
|
||||
</div>
|
||||
<div className="xx-cover-template-date">{tpl.created_at}</div>
|
||||
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
|
||||
编辑
|
||||
</Button>
|
||||
{!tpl.is_system && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
if (confirm("确定删除此模板?")) {
|
||||
onDeleteTemplate(tpl.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="ghost" buttonSize="sm">
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSettingsModal
|
||||
@@ -891,7 +891,7 @@
|
||||
|
||||
/* ── 视频预览 ── */
|
||||
.xx-preview-video {
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
@@ -2356,8 +2356,6 @@
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px dashed var(--border-color);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-generate-hint {
|
||||
@@ -2390,8 +2388,6 @@
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-loading-text {
|
||||
@@ -2407,15 +2403,13 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 错误状态(手机屏尺寸)── */
|
||||
/* ── 错误状态 ── */
|
||||
.xx-preview-error {
|
||||
text-align: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-error-text {
|
||||
@@ -2462,9 +2456,6 @@
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
max-width: 320px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.xx-preview-plan-card {
|
||||
@@ -2579,16 +2570,6 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── 整体进度条(手机屏尺寸)── */
|
||||
.xx-preview-progress-bar {
|
||||
max-width: 320px;
|
||||
margin: 12px auto;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-progress-bar-wrap {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
@@ -2666,7 +2647,7 @@
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2888,196 +2869,3 @@
|
||||
.ant-modal-close {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* ── 封面设置区域改造样式 ── */
|
||||
|
||||
/* 封面操作按钮区 */
|
||||
.xx-cover-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 已选模板文字 */
|
||||
.xx-cover-selected-template {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 封面设置弹窗 - 工具栏 */
|
||||
.xx-cover-modal-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 封面模板网格 */
|
||||
.xx-cover-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 封面模板卡片 */
|
||||
.xx-cover-template-card {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.xx-cover-template-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.xx-cover-template-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
/* 卡片缩略图 */
|
||||
.xx-cover-template-thumb {
|
||||
aspect-ratio: 9/16;
|
||||
background: linear-gradient(135deg, #f0f0f0, #e0e0e0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* 卡片信息区 */
|
||||
.xx-cover-template-info {
|
||||
padding: 8px;
|
||||
}
|
||||
.xx-cover-template-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.xx-cover-template-badge {
|
||||
font-size: 11px;
|
||||
color: #7c3aed;
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xx-cover-template-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.xx-cover-template-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 封面编辑器 */
|
||||
.xx-cover-editor-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 500px;
|
||||
}
|
||||
.xx-cover-editor-left {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.xx-cover-editor-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.xx-cover-editor-canvas {
|
||||
width: 225px;
|
||||
height: 400px;
|
||||
background: #ddd;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.xx-cover-editor-portrait {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 45%;
|
||||
background: #a8d4f0;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
.xx-cover-editor-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #333;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
/* 编辑器折叠面板 */
|
||||
.xx-cover-editor-section {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xx-cover-editor-section-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.xx-cover-editor-section-body {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 编辑器顶部 */
|
||||
.xx-cover-editor-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.xx-cover-editor-name-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.xx-cover-editor-header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 编辑器画布内文字占位 */
|
||||
.xx-cover-editor-title-placeholder {
|
||||
position: absolute;
|
||||
top: 72%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xx-cover-editor-subtitle-placeholder {
|
||||
position: absolute;
|
||||
top: 82%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,60 @@
|
||||
import type { UseGenerateVideoProps } from "./types"
|
||||
import { buildVoiceConfig } from "./voiceConfig"
|
||||
|
||||
/**
|
||||
* 构建 updateEditPlan 的 payload
|
||||
* 从 props 中提取需要的字段,组装成 API 所需的 config 结构
|
||||
*/
|
||||
export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
|
||||
return {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing" as const,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成前置校验
|
||||
|
||||
Executable → Regular
-2
@@ -19,8 +19,6 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 预览任务的 task_id(用于新确认生成 API) */
|
||||
previewTaskId: string
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
Executable → Regular
+9
-13
@@ -5,11 +5,11 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { confirmGeneration } from "@/api/generation"
|
||||
import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
import { validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
@@ -55,19 +55,15 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 使用确认生成 API(基于预览任务)
|
||||
// 解析分辨率
|
||||
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
|
||||
const outputWidth = parseInt(widthStr, 10) || 1080
|
||||
const outputHeight = parseInt(heightStr, 10) || 1920
|
||||
const payload = buildEditPlanPayload(props)
|
||||
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
custom_title: props.titleSettings.title || "",
|
||||
})
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, payload)
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
startPolling()
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
|
||||
@@ -35,8 +35,6 @@ interface UseStep4PreviewProps {
|
||||
videoRatio: string
|
||||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||||
voiceIds?: string[]
|
||||
/** 配音素材库ID(用户选择的上传音频或AI配音素材) */
|
||||
voiceLibraryId?: string
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
}
|
||||
@@ -86,7 +84,6 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
@@ -335,7 +332,6 @@ export function useStep4Preview({
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
voice_library_id: voiceLibraryId || undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
@@ -359,7 +355,6 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
@@ -408,9 +403,6 @@ export function useStep4Preview({
|
||||
|
||||
const canProceed = anyReady
|
||||
|
||||
/** 当前选中预览的 taskId(用于确认生成时复用预览产物) */
|
||||
const selectedTaskId = selectedResult?.taskId ?? ""
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
@@ -432,8 +424,6 @@ export function useStep4Preview({
|
||||
anyGenerating,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
// 确认生成复用预览产物
|
||||
selectedTaskId,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
/**
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
|
||||
* 封装封面设置的交互逻辑
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../../editing-planner/types"
|
||||
import { useCallback, useRef } from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants"
|
||||
import { generateCover } from "@/api/template-editor"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
updateCoverTemplate,
|
||||
deleteCoverTemplate,
|
||||
} from "@/api/cover-templates"
|
||||
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
@@ -32,157 +26,89 @@ export function useStep6Cover({
|
||||
}: UseStep6CoverProps) {
|
||||
const generatingRef = useRef(false)
|
||||
|
||||
// ── 封面设置弹窗状态 ──
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
const [showCoverEditor, setShowCoverEditor] = useState(false)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState("default")
|
||||
const [editingTemplate, setEditingTemplate] = useState<CoverTemplate | null>(null)
|
||||
const [coverTemplates, setCoverTemplates] = useState<CoverTemplate[]>([])
|
||||
|
||||
// ── API 加载状态 ──
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
|
||||
/** 从后端加载封面模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setTemplatesLoading(true)
|
||||
setTemplatesError(null)
|
||||
try {
|
||||
const res = await fetchCoverTemplates()
|
||||
setCoverTemplates(res.items || [])
|
||||
} catch (err) {
|
||||
console.error("[Step6] 加载封面模板失败:", err)
|
||||
setTemplatesError("加载模板失败,请稍后重试")
|
||||
} finally {
|
||||
setTemplatesLoading(false)
|
||||
}
|
||||
const formatTime = useCallback((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}`
|
||||
}, [])
|
||||
|
||||
/** 弹窗打开时加载模板列表 */
|
||||
useEffect(() => {
|
||||
if (showCoverSettings) {
|
||||
loadTemplates()
|
||||
}
|
||||
}, [showCoverSettings, loadTemplates])
|
||||
const toggleEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
onCoverSettingsChange({ ...coverSettings, enabled })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode: CoverConfig["mode"]) => {
|
||||
onCoverSettingsChange({ ...coverSettings, mode })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const setFrameTime = useCallback(
|
||||
(frameTime: number) => {
|
||||
onCoverSettingsChange({ ...coverSettings, frame_time: frameTime })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const url = ev.target?.result as string
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
/** 调用后端智能封面 API,生成封面并更新预览 */
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (generatingRef.current) {
|
||||
message.warning("封面正在生成中,请稍候...")
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedTemplate) {
|
||||
message.error("请先选择模板")
|
||||
return
|
||||
}
|
||||
|
||||
if (assetIds.length === 0) {
|
||||
message.error("请先选择素材")
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedTemplate || assetIds.length === 0 || generatingRef.current) return
|
||||
generatingRef.current = true
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
})
|
||||
const thumbnailUrl = response.cover?.image_url || ""
|
||||
const thumbnailUrl = response.cover?.thumbnail_url || ""
|
||||
if (thumbnailUrl) {
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
ai_suggested_time: response.cover?.frame_time ?? null,
|
||||
})
|
||||
message.success("封面生成成功")
|
||||
} else {
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Step6] 智能封面生成失败:", err)
|
||||
message.error("封面生成失败,请稍后重试")
|
||||
} finally {
|
||||
generatingRef.current = false
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
setSelectedTemplateId(id)
|
||||
}, [])
|
||||
|
||||
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
|
||||
setEditingTemplate(tpl)
|
||||
setShowCoverEditor(true)
|
||||
}, [])
|
||||
|
||||
/** 保存模板(创建或更新) */
|
||||
const handleSaveTemplate = useCallback(
|
||||
async (tpl: CoverTemplate) => {
|
||||
try {
|
||||
if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) {
|
||||
const updated = await updateCoverTemplate(tpl.id, {
|
||||
name: tpl.name,
|
||||
config: tpl.config,
|
||||
})
|
||||
setCoverTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
|
||||
} else {
|
||||
const created = await createCoverTemplate({
|
||||
name: tpl.name,
|
||||
config: tpl.config,
|
||||
})
|
||||
setCoverTemplates((prev) => [...prev, created])
|
||||
}
|
||||
setShowCoverEditor(false)
|
||||
} catch (err) {
|
||||
console.error("[Step6] 保存模板失败:", err)
|
||||
}
|
||||
},
|
||||
[coverTemplates],
|
||||
)
|
||||
|
||||
/** 删除模板 */
|
||||
const handleDeleteTemplate = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await deleteCoverTemplate(id)
|
||||
setCoverTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
if (selectedTemplateId === id) {
|
||||
setSelectedTemplateId("default")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Step6] 删除模板失败:", err)
|
||||
}
|
||||
},
|
||||
[selectedTemplateId],
|
||||
)
|
||||
|
||||
const selectedTemplateName =
|
||||
coverTemplates.find((t) => t.id === selectedTemplateId)?.name || "默认"
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
generateAutoCover,
|
||||
totalDuration,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
setSelectedTemplateId,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
selectedTemplateName,
|
||||
handleSelectTemplate,
|
||||
handleEditTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
loadTemplates,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
DEFAULT_COVER_SETTINGS,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -991,17 +991,19 @@
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1400px) {
|
||||
.xx-products-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 992px) {
|
||||
.xx-products-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-products-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-primary);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-status-tabs {
|
||||
@@ -56,10 +56,10 @@
|
||||
|
||||
/* 表格 */
|
||||
.task-table {
|
||||
background: var(--bg-primary);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-table .ant-table {
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
.task-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
|
||||
.task-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 任务 ID */
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
/* 操作按钮 */
|
||||
.task-retry-btn {
|
||||
color: var(--color-primary-500);
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.task-retry-btn:hover {
|
||||
@@ -139,7 +139,7 @@
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-error-header {
|
||||
@@ -148,7 +148,7 @@
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--error);
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error-icon {
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
|
||||
.task-error-message {
|
||||
color: var(--error);
|
||||
color: var(--error-500, #ef4444);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
.task-error-stack pre {
|
||||
margin: var(--space-xs) 0 0 0;
|
||||
padding: var(--space-sm);
|
||||
background: var(--bg-primary);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
@@ -224,7 +224,7 @@
|
||||
.task-error {
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
color: var(--error);
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error .anticon {
|
||||
|
||||
@@ -439,7 +439,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
@@ -717,13 +717,13 @@
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 1400px) {
|
||||
@media (max-width: 1200px) {
|
||||
.xx-templates-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (max-width: 768px) {
|
||||
.xx-templates-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { CopyOutlined, CheckOutlined } from "@ant-design/icons"
|
||||
import { Button, Input } from "@/components/ui"
|
||||
import { AI_KEYWORD_MAX_LENGTH } from "../../constants/titleLibrary"
|
||||
@@ -28,7 +28,7 @@ export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
|
||||
onAdopt,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
<AntModal
|
||||
title="AI 生成标题"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -116,6 +116,6 @@ export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</AntModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import type { TitleType } from "../../types/titleLibrary"
|
||||
import { TITLE_MAX_LENGTH } from "../../constants/titleLibrary"
|
||||
@@ -30,7 +30,7 @@ export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
<AntModal
|
||||
title="新建标题"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -84,6 +84,6 @@ export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AntModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -33,7 +32,6 @@
|
||||
gap: var(--space-sm);
|
||||
position: sticky;
|
||||
top: var(--space-md);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.xx-title-category-item {
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: var(--z-toast);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -310,7 +310,7 @@
|
||||
.vc-edit-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay-bg);
|
||||
|
||||
@@ -351,19 +351,18 @@
|
||||
.vmat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow-x: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vmat-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr 80px 160px 120px 60px 70px 80px;
|
||||
min-width: 700px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
@@ -374,7 +373,6 @@
|
||||
.vmat-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr 80px 160px 120px 60px 70px 80px;
|
||||
min-width: 700px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
@@ -1064,7 +1062,6 @@
|
||||
.vmat-list.batch-mode .vmat-list-header,
|
||||
.vmat-list.batch-mode .vmat-row {
|
||||
grid-template-columns: 30px 48px 1fr 80px 160px 120px 60px 70px 80px;
|
||||
min-width: 730px;
|
||||
}
|
||||
|
||||
/* ─── 标签筛选药丸条 ─────────────────────────────────────── */
|
||||
|
||||
@@ -733,7 +733,7 @@
|
||||
.xx-clone-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay-bg, rgba(0, 0, 0, 0.4));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { render, cleanup, act } from "@testing-library/react"
|
||||
import { render, cleanup } from "@testing-library/react"
|
||||
import TtsPanel from "@/pages/editing-planner/components/TtsPanel"
|
||||
import { DEFAULT_TTS_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
@@ -16,28 +16,17 @@ const defaultProps = {
|
||||
}
|
||||
|
||||
describe("TtsPanel", () => {
|
||||
afterEach(async () => {
|
||||
// Flush pending React updates before cleanup to avoid
|
||||
// "window is not defined" errors after jsdom teardown
|
||||
await act(async () => {})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it("should render without crashing", async () => {
|
||||
let container: HTMLElement
|
||||
await act(async () => {
|
||||
const result = render(<TtsPanel {...defaultProps} />)
|
||||
container = result.container
|
||||
})
|
||||
expect(container!).toBeTruthy()
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<TtsPanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", async () => {
|
||||
let container: HTMLElement
|
||||
await act(async () => {
|
||||
const result = render(<TtsPanel {...defaultProps} open={false} />)
|
||||
container = result.container
|
||||
})
|
||||
expect(container!).toBeTruthy()
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<TtsPanel {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@ class CoverGenerator:
|
||||
time_sec = 0
|
||||
|
||||
# scale + crop 实现 cover 裁剪(铺满输出尺寸)
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
@@ -281,7 +281,7 @@ class CoverGenerator:
|
||||
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"
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
|
||||
@@ -159,7 +159,7 @@ class VideoProcessor:
|
||||
try:
|
||||
(
|
||||
ffmpeg.input(video_path, ss=timestamp)
|
||||
.output(output_path, vframes=1, format="image2", vcodec="mjpeg", pix_fmt="yuvj420p")
|
||||
.output(output_path, vframes=1, format="image2", vcodec="mjpeg")
|
||||
.overwrite_output()
|
||||
.run(capture_stdout=True, capture_stderr=True)
|
||||
)
|
||||
|
||||
Executable → Regular
+37
-29
@@ -465,7 +465,7 @@ class RenderAdapter:
|
||||
失败不阻断主流程,返回 None。
|
||||
"""
|
||||
try:
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
from services.asr_service_factory import get_asr_service
|
||||
|
||||
return get_asr_service()
|
||||
except Exception as e:
|
||||
@@ -485,6 +485,7 @@ class RenderAdapter:
|
||||
rendered_clip_ids: list[str] | None = None,
|
||||
failed_clip_ids: list[str] | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
is_preview: bool = False,
|
||||
) -> RenderAdapterResult:
|
||||
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
|
||||
|
||||
@@ -503,11 +504,11 @@ class RenderAdapter:
|
||||
|
||||
self._report_progress(progress_cb, 40.0, "执行视频渲染")
|
||||
|
||||
# 2. 初始化 ASR
|
||||
plan_config = plan.config or {}
|
||||
asr_service = self._get_asr_service()
|
||||
# 2. 初始化 ASR(预览模式跳过,节省启动开销)
|
||||
asr_service = None if is_preview else self._get_asr_service()
|
||||
|
||||
# 3. 读取输出分辨率
|
||||
plan_config = plan.config or {}
|
||||
export_config = plan_config.get("export", {}) or {}
|
||||
output_width, output_height = _parse_resolution(export_config.get("resolution"))
|
||||
logger.info(
|
||||
@@ -529,23 +530,27 @@ class RenderAdapter:
|
||||
bgm_path=bgm_path,
|
||||
asr_service=asr_service,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
is_preview=is_preview,
|
||||
)
|
||||
result = render_svc.render()
|
||||
|
||||
# 4.5 渲染后校验输出完整性
|
||||
validation = validate_video_output(result.output_path)
|
||||
if not validation.valid:
|
||||
logger.error(
|
||||
"[render-adapter] 渲染输出校验失败: plan_id=%s job_id=%s error=%s",
|
||||
plan_id,
|
||||
job_id,
|
||||
validation.error_message,
|
||||
)
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=f"渲染输出校验失败: {validation.error_message}",
|
||||
error_detail=validation.error_message,
|
||||
)
|
||||
# 4.5 渲染后校验输出完整性(预览模式跳过,节省耗时)
|
||||
if is_preview:
|
||||
logger.info("[render-adapter] 预览模式:跳过输出校验")
|
||||
else:
|
||||
validation = validate_video_output(result.output_path)
|
||||
if not validation.valid:
|
||||
logger.error(
|
||||
"[render-adapter] 渲染输出校验失败: plan_id=%s job_id=%s error=%s",
|
||||
plan_id,
|
||||
job_id,
|
||||
validation.error_message,
|
||||
)
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=f"渲染输出校验失败: {validation.error_message}",
|
||||
error_detail=validation.error_message,
|
||||
)
|
||||
self._report_progress(progress_cb, 80.0, "上传渲染结果")
|
||||
|
||||
# 5. 上传结果
|
||||
@@ -554,19 +559,20 @@ class RenderAdapter:
|
||||
|
||||
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
|
||||
|
||||
# 6. 生成封面缩略图
|
||||
# 6. 生成缩略图(预览模式跳过,节省耗时)
|
||||
thumbnail_url = ""
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
if not is_preview:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
thumb_err,
|
||||
)
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
thumb_err,
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
|
||||
@@ -612,6 +618,7 @@ class RenderAdapter:
|
||||
work_dir: Path | None = None,
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
is_preview: bool = False,
|
||||
) -> RenderAdapterResult:
|
||||
"""使用内存中的 plan/clips/asset_path_map 直接渲染。
|
||||
|
||||
@@ -670,6 +677,7 @@ class RenderAdapter:
|
||||
job_id=job_id,
|
||||
progress_cb=progress_cb,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
is_preview=is_preview,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
@@ -60,7 +60,7 @@ def extract_first_frame(
|
||||
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
|
||||
@@ -151,6 +151,7 @@ class UnifiedRenderService:
|
||||
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
|
||||
bgm_path: str | None = None, # BGM 本地文件路径
|
||||
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
|
||||
is_preview: bool = False, # 预览模式:ultrafast 编码 + 跳过非必要步骤
|
||||
):
|
||||
self.plan = plan
|
||||
self.clips = clips
|
||||
@@ -163,6 +164,7 @@ class UnifiedRenderService:
|
||||
self.asr_service = asr_service
|
||||
self.bgm_path = bgm_path
|
||||
self.voiceover_audio_path = voiceover_audio_path
|
||||
self.is_preview = is_preview
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
self._speed_engine = SpeedEngine()
|
||||
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
|
||||
@@ -677,40 +679,20 @@ class UnifiedRenderService:
|
||||
len(top_text),
|
||||
)
|
||||
# 方式B:voice_id + 自动字幕 → 字幕对齐配音(预设配音模式)
|
||||
# ASR 可用时走字幕对齐模式,不可用时降级为整段配音
|
||||
elif top_voice_id and subtitle_cfg.get("auto_generated", False):
|
||||
if self.asr_service is not None:
|
||||
# 字幕对齐模式
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
use_subtitle_align = True
|
||||
logger.info(
|
||||
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
)
|
||||
else:
|
||||
# 降级:整段配音(预览模式 ASR 不可用时)
|
||||
# 拼接字幕文本作为配音内容
|
||||
subtitle_text_content = subtitle_cfg.get("text", "") or ""
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": subtitle_text_content,
|
||||
"align_mode": "full",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
logger.info(
|
||||
"[unified-render] ASR 不可用,降级为整段配音模式: plan_id=%s voice_id=%s text_len=%d",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
len(subtitle_text_content),
|
||||
)
|
||||
elif top_voice_id and subtitle_cfg.get("auto_generated", False) and self.asr_service is not None:
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
use_subtitle_align = True
|
||||
logger.info(
|
||||
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
)
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
# 前端一键生成页面传 config.voice_id + config.custom_text,
|
||||
@@ -1248,9 +1230,9 @@ class UnifiedRenderService:
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"23",
|
||||
"28" if self.is_preview else "23",
|
||||
"-preset",
|
||||
"medium",
|
||||
"ultrafast" if self.is_preview else "medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
@@ -1492,17 +1474,9 @@ class UnifiedRenderService:
|
||||
raise ValueError("没有可渲染的图层")
|
||||
|
||||
# 收集所有 clips(按图层顺序,同层按 order)
|
||||
# 排除纯音频 clips — 它们由 mix_audio() 独立处理,不应出现在视频 filter_complex 中
|
||||
# 例如:voice.mp3 没有视频流,如果加入 all_clips 会生成 [N:v] 引用导致 FFmpeg 报错
|
||||
all_clips: list[ResolvedClip] = []
|
||||
for layer in layers:
|
||||
for clip in layer.clips:
|
||||
if clip.clip_type == "audio":
|
||||
continue
|
||||
all_clips.append(clip)
|
||||
|
||||
if not all_clips:
|
||||
raise ValueError("没有可渲染的视频片段(所有片段均为纯音频)")
|
||||
all_clips.extend(layer.clips)
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
@@ -1586,14 +1560,10 @@ class UnifiedRenderService:
|
||||
filter_parts.append(filter_str)
|
||||
preprocessed_labels.append(label)
|
||||
|
||||
# Step 2: 同层 clips 用 xfade 串联(跳过纯音频层,由 mix_audio() 独立处理)
|
||||
# Step 2: 同层 clips 用 xfade 串联
|
||||
layer_output_labels: dict[str, str] = {}
|
||||
for layer in layers:
|
||||
# 音频层不参与视频 filter_complex,跳过
|
||||
video_clips_in_layer = [c for c in layer.clips if c.clip_type != "audio"]
|
||||
if not video_clips_in_layer:
|
||||
continue
|
||||
layer_clip_indices = [all_clips.index(c) for c in video_clips_in_layer]
|
||||
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
|
||||
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
|
||||
# 使用调速后的实际时长,与 Step 1 的调速处理保持一致
|
||||
layer_durations = [UnifiedRenderService._clip_adjusted_duration(all_clips[i]) for i in layer_clip_indices]
|
||||
@@ -1774,9 +1744,9 @@ class UnifiedRenderService:
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"23",
|
||||
"28" if self.is_preview else "23",
|
||||
"-preset",
|
||||
"medium",
|
||||
"ultrafast" if self.is_preview else "medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
@@ -1785,10 +1755,11 @@ class UnifiedRenderService:
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"执行渲染: plan_id=%s inputs=%d output=%s",
|
||||
"执行渲染: plan_id=%s inputs=%d output=%s preview=%s",
|
||||
self.plan.id,
|
||||
input_args.count("-i"),
|
||||
output_path,
|
||||
self.is_preview,
|
||||
)
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
|
||||
@@ -157,8 +157,6 @@ class AssetAnalyzer:
|
||||
"1",
|
||||
"-q:v",
|
||||
"2", # 高质量
|
||||
"-pix_fmt",
|
||||
"yuvj420p", # mjpeg 需要全范围 YUV
|
||||
"-f",
|
||||
"image2",
|
||||
output_path,
|
||||
|
||||
@@ -1039,12 +1039,7 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
"video_title": getattr(gen_task, "video_title", "") or "",
|
||||
"resolution": getattr(gen_task, "resolution", "") or "",
|
||||
"bgm_config": dict(getattr(gen_task, "bgm_config", {}) or {}),
|
||||
"source_task_id": getattr(gen_task, "source_task_id", "") or "",
|
||||
"output_width": getattr(gen_task, "output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH,
|
||||
"output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT,
|
||||
"cover_url": getattr(gen_task, "cover_url", "") or "",
|
||||
"custom_title": getattr(gen_task, "custom_title", "") or "",
|
||||
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
|
||||
"is_preview": bool(getattr(gen_task, "is_preview", False)),
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1057,7 +1052,6 @@ def _download_all_assets(
|
||||
task_asset_ids: list[str],
|
||||
voice_library_id: str,
|
||||
task_id: str,
|
||||
voice_ids: list[str] | None = None,
|
||||
) -> tuple[list[Path], str | None]:
|
||||
"""下载视频素材和配音素材。
|
||||
|
||||
@@ -1066,9 +1060,6 @@ def _download_all_assets(
|
||||
|
||||
Note: gen_task 不传入下载函数(session 已关闭),
|
||||
主函数在下载前后已有汇总日志。
|
||||
|
||||
配音下载逻辑:优先使用 voice_library_id(配音素材库资产);
|
||||
若为空则 fallback 到 voice_ids[0](前端选择的音频 asset_id)。
|
||||
"""
|
||||
logger.info("[task_id=%s] [下载素材] 开始下载视频素材", task_id)
|
||||
download_start = time.monotonic()
|
||||
@@ -1088,24 +1079,11 @@ def _download_all_assets(
|
||||
)
|
||||
|
||||
audio_path: str | None = None
|
||||
# 配音下载:优先 voice_library_id,fallback 到 voice_ids[0]
|
||||
effective_voice_id = voice_library_id
|
||||
if not effective_voice_id and voice_ids:
|
||||
effective_voice_id = voice_ids[0]
|
||||
logger.info(
|
||||
"[task_id=%s] [下载配音] voice_library_id 为空,fallback 到 voice_ids[0]=%s",
|
||||
task_id,
|
||||
effective_voice_id,
|
||||
)
|
||||
if effective_voice_id:
|
||||
if voice_library_id:
|
||||
local_audio = temp_path / "voice.mp3"
|
||||
if _download_voice_asset(effective_voice_id, local_audio):
|
||||
if _download_voice_asset(voice_library_id, local_audio):
|
||||
audio_path = str(local_audio)
|
||||
logger.info(
|
||||
"[task_id=%s] [下载配音] 配音下载成功 (source=%s)",
|
||||
task_id,
|
||||
"voice_library_id" if voice_library_id else "voice_ids",
|
||||
)
|
||||
logger.info("[task_id=%s] [下载配音] 配音下载成功", task_id)
|
||||
|
||||
return downloaded_videos, audio_path
|
||||
|
||||
@@ -1122,13 +1100,15 @@ def _render_video(
|
||||
output_name: str,
|
||||
resolution: str = "",
|
||||
bgm_config: dict | None = None,
|
||||
voice_ids: list[str] | None = None,
|
||||
is_preview: bool = False,
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/缩略图逻辑。
|
||||
|
||||
Args:
|
||||
is_preview: 是否为预览生成,若是则强制 480p + 低码率
|
||||
|
||||
Returns:
|
||||
(output_path, render_duration)
|
||||
"""
|
||||
@@ -1175,7 +1155,18 @@ def _render_video(
|
||||
# 注意:必须拷贝字典,避免预览模式修改污染源对象(模板配置)
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
export_cfg = dict(plan_cfg.get("export", {}) or {})
|
||||
if resolution:
|
||||
if is_preview:
|
||||
# 预览模式:短边 480p + 低码率,但尊重视频比例(竖屏模板不应强制横屏)
|
||||
preview_res = resolution if resolution else "854x480"
|
||||
export_cfg["resolution"] = preview_res
|
||||
export_cfg["bitrate"] = "1M"
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 预览模式:分辨率=%s, 码率=%s",
|
||||
task_id,
|
||||
preview_res,
|
||||
"1M",
|
||||
)
|
||||
elif resolution:
|
||||
# 用户在 API 调用时指定的分辨率优先级最高
|
||||
export_cfg["resolution"] = resolution
|
||||
elif not export_cfg.get("resolution"):
|
||||
@@ -1184,20 +1175,6 @@ def _render_video(
|
||||
plan_cfg["export"] = export_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
|
||||
# 注入用户选择的配音 voice_id(ASR 字幕对齐模式)
|
||||
if voice_ids:
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["voice_id"] = voice_ids[0]
|
||||
subtitle_cfg = plan_cfg.get("subtitle", {}) or {}
|
||||
subtitle_cfg["auto_generated"] = True
|
||||
plan_cfg["subtitle"] = subtitle_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 预览配音已注入: voice_id=%s",
|
||||
task_id,
|
||||
voice_ids[0],
|
||||
)
|
||||
|
||||
total_duration = sum(c.duration for c in virtual_clips)
|
||||
logger.info(
|
||||
"[task_id=%s] [剪辑计划] 片段数=%d, 总时长=%.1fs",
|
||||
@@ -1224,6 +1201,7 @@ def _render_video(
|
||||
job_id=task_id,
|
||||
work_dir=temp_path,
|
||||
voiceover_audio_path=voice_path,
|
||||
is_preview=is_preview,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1423,7 +1401,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
task_asset_ids=task_asset_ids,
|
||||
voice_library_id=voice_library_id,
|
||||
task_id=task_id,
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
@@ -1436,74 +1413,8 @@ def generate_video(self, task_id: str) -> dict:
|
||||
|
||||
_update_task_progress(task_id, 30, "素材下载完成")
|
||||
|
||||
# ── 2.5 MediaKit 视频理解(渲染前分析素材内容)─────────────────
|
||||
asset_analyses = {}
|
||||
if task_asset_ids:
|
||||
try:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
mk_client = get_mediakit_client()
|
||||
if mk_client.is_available:
|
||||
storage_svc = get_shared_storage_service()
|
||||
asset_urls = []
|
||||
valid_asset_ids = []
|
||||
|
||||
# 获取素材 URL(从 downloaded_videos 获取本地路径或从 asset 表获取 OSS URL)
|
||||
for i, asset_id in enumerate(task_asset_ids[:5]):
|
||||
try:
|
||||
# 优先使用已下载的本地文件
|
||||
if i < len(downloaded_videos) and downloaded_videos[i]:
|
||||
# 本地文件路径,需要上传或直接用 OSS URL
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
|
||||
_s = SessionLocal()
|
||||
try:
|
||||
_ar = SQLAlchemyAssetRepository(_s)
|
||||
asset = _ar.get(asset_id)
|
||||
if asset and getattr(asset, "storage_key", ""):
|
||||
asset_urls.append(storage_svc.get_url(asset.storage_key))
|
||||
valid_asset_ids.append(asset_id)
|
||||
finally:
|
||||
_s.close()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if asset_urls:
|
||||
_update_task_progress(task_id, 35, "MediaKit 视频理解中...")
|
||||
analyses = mk_client.analyze_videos(
|
||||
prompt="分析这个视频的主要内容,描述场景、物体、人物动作和主题",
|
||||
video_urls=asset_urls,
|
||||
level="Economy",
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=15,
|
||||
)
|
||||
if analyses:
|
||||
for i, content in enumerate(analyses):
|
||||
if i < len(valid_asset_ids) and content:
|
||||
asset_analyses[valid_asset_ids[i]] = content
|
||||
logger.info("[task_id=%s] MediaKit 视频理解完成: %d 个素材", task_id, len(asset_urls))
|
||||
|
||||
# 保存分析结果到 extra_meta
|
||||
if asset_analyses and gen_task:
|
||||
gen_task.extra_meta = {**(gen_task.extra_meta or {}), "asset_analyses": asset_analyses}
|
||||
_repo.update(gen_task)
|
||||
_flush_logs(task_id, gen_task)
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] MediaKit 视频理解失败,继续渲染", task_id, exc_info=True)
|
||||
|
||||
# ── 3. 渲染 + 混音 ───────────────────────────────────────────────
|
||||
_update_task_progress(task_id, 40, "开始渲染")
|
||||
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
|
||||
_ow = task_info.get("output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH
|
||||
_oh = task_info.get("output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT
|
||||
if _ow != OUTPUT_WIDTH or _oh != OUTPUT_HEIGHT:
|
||||
_resolved_resolution = f"{_ow}x{_oh}"
|
||||
else:
|
||||
_resolved_resolution = task_info.get("resolution", "")
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
task_id=task_id,
|
||||
downloaded_videos=downloaded_videos,
|
||||
@@ -1514,9 +1425,9 @@ def generate_video(self, task_id: str) -> dict:
|
||||
user_id=user_id,
|
||||
temp_path=temp_path,
|
||||
output_name=output_name,
|
||||
resolution=_resolved_resolution,
|
||||
resolution=task_info.get("resolution", ""),
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
is_preview=task_info.get("is_preview", False),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
@@ -1659,54 +1659,6 @@
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "is_preview",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "BOOLEAN",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "source_task_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "output_width",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "INTEGER",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "output_height",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "INTEGER",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "cover_url",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(1000)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "custom_title",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(500)",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
@@ -1765,13 +1717,6 @@
|
||||
],
|
||||
"name": "ix_generation_tasks_template_id",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"source_task_id"
|
||||
],
|
||||
"name": "ix_generation_tasks_source_task_id",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"primary_key": [
|
||||
@@ -3570,4 +3515,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable → Regular
-4
@@ -47,8 +47,6 @@ services:
|
||||
|
||||
container_name: xiaoxia-api-${ENV:-staging}
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
|
||||
# 环境变量文件(包含数据库密码等敏感信息)
|
||||
env_file:
|
||||
@@ -104,8 +102,6 @@ services:
|
||||
|
||||
container_name: xiaoxia-worker-${ENV:-staging}
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 300s
|
||||
stop_signal: SIGTERM
|
||||
|
||||
env_file:
|
||||
- ../../.env
|
||||
|
||||
@@ -145,7 +145,7 @@ docker run -d \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
|
||||
@@ -81,7 +81,7 @@ export WEB_DOCKERFILE=infra/docker/web-artifact.Dockerfile
|
||||
export WEB_NGINX_CONF=infra/docker/nginx-production.conf
|
||||
|
||||
docker network create xiaoxia-net-production 2>/dev/null || true
|
||||
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}"
|
||||
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-1}"
|
||||
export WORKER_MAX_TASKS_PER_CHILD="${WORKER_MAX_TASKS_PER_CHILD:-100}"
|
||||
|
||||
if [ "${ALLOW_PRODUCTION_BUILDS:-false}" = "true" ]; then
|
||||
|
||||
@@ -108,7 +108,7 @@ docker run -d \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
|
||||
Executable → Regular
@@ -1,41 +0,0 @@
|
||||
"""封面模板 InMemory 仓储实现。"""
|
||||
|
||||
from packages.domain.cover_template import CoverTemplate
|
||||
|
||||
|
||||
class InMemoryCoverTemplateRepository:
|
||||
"""内存中的封面模板仓储,用于测试。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._templates: dict[str, CoverTemplate] = {}
|
||||
|
||||
def create(self, template: CoverTemplate) -> CoverTemplate:
|
||||
self._templates[template.id] = template
|
||||
return template
|
||||
|
||||
def get(self, template_id: str) -> CoverTemplate | None:
|
||||
return self._templates.get(template_id)
|
||||
|
||||
def list_for_user(self, user_id: str, skip: int = 0, limit: int = 100) -> list[CoverTemplate]:
|
||||
"""列出系统模板 + 用户自己的模板。"""
|
||||
visible = [t for t in self._templates.values() if t.is_system or t.user_id == user_id]
|
||||
visible.sort(key=lambda t: (t.is_system, t.created_at), reverse=True)
|
||||
return visible[skip : skip + limit]
|
||||
|
||||
def count_for_user(self, user_id: str) -> int:
|
||||
return sum(1 for t in self._templates.values() if t.is_system or t.user_id == user_id)
|
||||
|
||||
def update(self, template: CoverTemplate) -> CoverTemplate:
|
||||
if template.id not in self._templates:
|
||||
raise ValueError(f"模板 {template.id} 不存在")
|
||||
self._templates[template.id] = template
|
||||
return template
|
||||
|
||||
def delete(self, template_id: str) -> bool:
|
||||
if template_id in self._templates:
|
||||
del self._templates[template_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_system_templates(self) -> list[CoverTemplate]:
|
||||
return [t for t in self._templates.values() if t.is_system]
|
||||
@@ -284,24 +284,6 @@ class SQLAlchemyAssetRepository:
|
||||
)
|
||||
return int(result or 0)
|
||||
|
||||
def find_ready_videos_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
limit: int = 50,
|
||||
) -> list[Asset]:
|
||||
"""查找用户上传的所有就绪视频素材。"""
|
||||
query = self.session.query(AssetModel).filter(
|
||||
AssetModel.uploaded_by_user_id == user_id,
|
||||
AssetModel.status == "ready",
|
||||
AssetModel.file_type == "video",
|
||||
)
|
||||
query = query.order_by(AssetModel.created_at.desc())
|
||||
if limit > 0:
|
||||
query = query.limit(limit)
|
||||
models = query.all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def search_candidates(
|
||||
self,
|
||||
project_id: str,
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"""封面模板 SQLAlchemy 仓储实现。"""
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import CoverTemplateModel
|
||||
from packages.domain.cover_template import CoverTemplate
|
||||
|
||||
|
||||
class SQLAlchemyCoverTemplateRepository:
|
||||
"""封面模板仓储实现。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, template: CoverTemplate) -> CoverTemplate:
|
||||
model = CoverTemplateModel(
|
||||
id=template.id,
|
||||
user_id=template.user_id,
|
||||
name=template.name,
|
||||
thumbnail_url=template.thumbnail_url,
|
||||
is_system=template.is_system,
|
||||
config=template.config,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return template
|
||||
|
||||
def get(self, template_id: str) -> CoverTemplate | None:
|
||||
model = self.session.query(CoverTemplateModel).filter(CoverTemplateModel.id == template_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def list_for_user(self, user_id: str, skip: int = 0, limit: int = 100) -> list[CoverTemplate]:
|
||||
"""列出用户可见的模板:系统模板 + 用户自己的模板。"""
|
||||
models = (
|
||||
self.session.query(CoverTemplateModel)
|
||||
.filter(
|
||||
or_(
|
||||
CoverTemplateModel.is_system == True, # noqa: E712
|
||||
CoverTemplateModel.user_id == user_id,
|
||||
)
|
||||
)
|
||||
.order_by(CoverTemplateModel.is_system.desc(), CoverTemplateModel.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def count_for_user(self, user_id: str) -> int:
|
||||
return (
|
||||
self.session.query(CoverTemplateModel)
|
||||
.filter(
|
||||
or_(
|
||||
CoverTemplateModel.is_system == True, # noqa: E712
|
||||
CoverTemplateModel.user_id == user_id,
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def update(self, template: CoverTemplate) -> CoverTemplate:
|
||||
model = self.session.query(CoverTemplateModel).filter(CoverTemplateModel.id == template.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"模板 {template.id} 不存在")
|
||||
model.name = template.name
|
||||
model.thumbnail_url = template.thumbnail_url
|
||||
model.config = template.config
|
||||
model.updated_at = template.updated_at
|
||||
self.session.commit()
|
||||
return template
|
||||
|
||||
def delete(self, template_id: str) -> bool:
|
||||
model = self.session.query(CoverTemplateModel).filter(CoverTemplateModel.id == template_id).first()
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def list_system_templates(self) -> list[CoverTemplate]:
|
||||
models = (
|
||||
self.session.query(CoverTemplateModel)
|
||||
.filter(CoverTemplateModel.is_system == True) # noqa: E712
|
||||
.order_by(CoverTemplateModel.created_at)
|
||||
.all()
|
||||
)
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: CoverTemplateModel) -> CoverTemplate:
|
||||
return CoverTemplate(
|
||||
id=model.id,
|
||||
user_id=model.user_id,
|
||||
name=model.name,
|
||||
thumbnail_url=model.thumbnail_url,
|
||||
is_system=model.is_system,
|
||||
config=model.config or {},
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
@@ -37,11 +37,6 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
resolution=getattr(model, "resolution", "") or "",
|
||||
bgm_config=dict(getattr(model, "bgm_config", {}) or {}),
|
||||
is_preview=bool(getattr(model, "is_preview", False)),
|
||||
source_task_id=getattr(model, "source_task_id", "") or "",
|
||||
output_width=getattr(model, "output_width", 1280) or 1280,
|
||||
output_height=getattr(model, "output_height", 720) or 720,
|
||||
cover_url=getattr(model, "cover_url", "") or "",
|
||||
custom_title=getattr(model, "custom_title", "") or "",
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
@@ -81,11 +76,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
resolution=task.resolution or "",
|
||||
bgm_config=task.bgm_config or {},
|
||||
is_preview=task.is_preview or False,
|
||||
source_task_id=task.source_task_id or "",
|
||||
output_width=task.output_width,
|
||||
output_height=task.output_height,
|
||||
cover_url=task.cover_url or "",
|
||||
custom_title=task.custom_title or "",
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
@@ -148,22 +138,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
)
|
||||
return [_to_domain(m) for m in models]
|
||||
|
||||
def list_latest_completed_preview(self, user_id: str, template_id: str, limit: int = 1) -> list[GenerationTask]:
|
||||
"""按用户+模板查找最近已完成的预览任务。"""
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.created_by_user_id == user_id,
|
||||
GenerationTaskModel.template_id == template_id,
|
||||
GenerationTaskModel.is_preview,
|
||||
GenerationTaskModel.status == "completed",
|
||||
)
|
||||
.order_by(GenerationTaskModel.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [_to_domain(m) for m in models]
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]:
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
@@ -268,11 +242,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.bgm_config = task.bgm_config or {}
|
||||
if hasattr(model, "is_preview"):
|
||||
model.is_preview = task.is_preview or False
|
||||
model.source_task_id = task.source_task_id or ""
|
||||
model.output_width = task.output_width
|
||||
model.output_height = task.output_height
|
||||
model.cover_url = task.cover_url or ""
|
||||
model.custom_title = task.custom_title or ""
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -293,11 +293,6 @@ class GenerationTaskModel(Base):
|
||||
video_title = Column(String(255), nullable=False, default="")
|
||||
resolution = Column(String(20), nullable=False, default="")
|
||||
is_preview = Column(Boolean, nullable=False, default=False, index=True)
|
||||
source_task_id = Column(String(32), nullable=False, default="", index=True)
|
||||
output_width = Column(Integer, nullable=False, default=1280)
|
||||
output_height = Column(Integer, nullable=False, default=720)
|
||||
cover_url = Column(String(1000), nullable=False, default="")
|
||||
custom_title = Column(String(500), nullable=False, default="")
|
||||
bgm_config = Column(JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
@@ -600,18 +595,3 @@ class VideoShareModel(Base):
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class CoverTemplateModel(Base):
|
||||
"""封面模板"""
|
||||
|
||||
__tablename__ = "cover_templates"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=True, index=True) # NULL = 系统模板
|
||||
name = Column(String(200), nullable=False)
|
||||
thumbnail_url = Column(String(1000), nullable=False, default="")
|
||||
is_system = Column(Boolean, nullable=False, default=False, index=True)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -27,11 +27,6 @@ class CreateGenerationTaskCommand:
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
|
||||
|
||||
class CreateGenerationTaskUseCase:
|
||||
@@ -63,11 +58,6 @@ class CreateGenerationTaskUseCase:
|
||||
auto_retry_enabled=command.auto_retry_enabled,
|
||||
auto_retry_max=command.auto_retry_max,
|
||||
is_preview=command.is_preview,
|
||||
source_task_id=command.source_task_id,
|
||||
output_width=command.output_width,
|
||||
output_height=command.output_height,
|
||||
cover_url=command.cover_url,
|
||||
custom_title=command.custom_title,
|
||||
)
|
||||
return self.generation_task_repository.create(task)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from .classification import (
|
||||
ClassificationJob,
|
||||
ClassificationJobStatus,
|
||||
)
|
||||
from .cover_template import CoverTemplate
|
||||
from .duplication import DuplicateSegment, DuplicationRecord
|
||||
from .edit_plan import EditPlan, EditPlanStatus
|
||||
from .edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
@@ -37,7 +36,6 @@ __all__ = [
|
||||
"AssetLibrary",
|
||||
"AssetLibraryKind",
|
||||
"AssetStatus",
|
||||
"CoverTemplate",
|
||||
"ClassificationJob",
|
||||
"ClassificationJobStatus",
|
||||
"ClassificationStatus",
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""封面模板领域实体。"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CoverTemplate:
|
||||
"""封面模板实体,支持系统预置和用户自定义。"""
|
||||
|
||||
id: str
|
||||
user_id: str | None # None 表示系统模板
|
||||
name: str
|
||||
thumbnail_url: str
|
||||
is_system: bool
|
||||
config: dict[str, Any]
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create_system(
|
||||
cls,
|
||||
name: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
thumbnail_url: str = "",
|
||||
) -> "CoverTemplate":
|
||||
"""创建系统模板。"""
|
||||
template_id = uuid4().hex
|
||||
return cls(
|
||||
id=template_id,
|
||||
user_id=None,
|
||||
name=name.strip(),
|
||||
thumbnail_url=thumbnail_url,
|
||||
is_system=True,
|
||||
config=config or {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_user(
|
||||
cls,
|
||||
user_id: str,
|
||||
name: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
thumbnail_url: str = "",
|
||||
) -> "CoverTemplate":
|
||||
"""创建用户自定义模板。"""
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("模板名称不能为空")
|
||||
template_id = uuid4().hex
|
||||
return cls(
|
||||
id=template_id,
|
||||
user_id=user_id,
|
||||
name=clean_name,
|
||||
thumbnail_url=thumbnail_url,
|
||||
is_system=False,
|
||||
config=config or {},
|
||||
)
|
||||
|
||||
def update(
|
||||
self,
|
||||
name: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
thumbnail_url: str | None = None,
|
||||
) -> None:
|
||||
"""更新模板属性。"""
|
||||
if name is not None:
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("模板名称不能为空")
|
||||
self.name = clean_name
|
||||
if config is not None:
|
||||
self.config = config
|
||||
if thumbnail_url is not None:
|
||||
self.thumbnail_url = thumbnail_url
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
@@ -116,11 +116,6 @@ class GenerationTask:
|
||||
resolution: str = ""
|
||||
bgm_config: dict = field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
logs: str = "[]"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -147,11 +142,6 @@ class GenerationTask:
|
||||
auto_retry_enabled: bool = False,
|
||||
auto_retry_max: int = 0,
|
||||
is_preview: bool = False,
|
||||
source_task_id: str = "",
|
||||
output_width: int = 1280,
|
||||
output_height: int = 720,
|
||||
cover_url: str = "",
|
||||
custom_title: str = "",
|
||||
) -> "GenerationTask":
|
||||
if not project_id.strip() and not template_id.strip():
|
||||
raise ValueError("project_id 或 template_id 至少需要提供一个")
|
||||
@@ -177,11 +167,6 @@ class GenerationTask:
|
||||
auto_retry_enabled=auto_retry_enabled,
|
||||
auto_retry_max=auto_retry_max,
|
||||
is_preview=is_preview,
|
||||
source_task_id=source_task_id,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
cover_url=cover_url,
|
||||
custom_title=custom_title,
|
||||
)
|
||||
|
||||
# ── 状态查询 ────────────────────────────────────────────────────────────
|
||||
@@ -296,30 +281,6 @@ class GenerationTask:
|
||||
self.transition_to(GenerationTaskStatus.CANCELLED)
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_confirmed(
|
||||
self,
|
||||
*,
|
||||
cover_url: str = "",
|
||||
custom_title: str = "",
|
||||
output_width: int = 0,
|
||||
output_height: int = 0,
|
||||
) -> None:
|
||||
"""将预览任务确认为正式产出。
|
||||
|
||||
预览渲染品质已与正式生成一致(1080p, CRF 23, medium),
|
||||
确认时直接复用已有产物,无需重新渲染。
|
||||
"""
|
||||
self.is_preview = False
|
||||
if cover_url:
|
||||
self.cover_url = cover_url
|
||||
if custom_title:
|
||||
self.custom_title = custom_title
|
||||
if output_width > 0:
|
||||
self.output_width = output_width
|
||||
if output_height > 0:
|
||||
self.output_height = output_height
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── 日志辅助 ────────────────────────────────────────────────────────────
|
||||
|
||||
_MAX_LOGS = 200
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""封面模板仓储接口定义。"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from packages.domain.cover_template import CoverTemplate
|
||||
|
||||
|
||||
class CoverTemplateRepository(ABC):
|
||||
"""封面模板仓储抽象接口。"""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, template: CoverTemplate) -> CoverTemplate:
|
||||
"""创建封面模板。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get(self, template_id: str) -> CoverTemplate | None:
|
||||
"""根据 ID 获取模板。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_for_user(self, user_id: str, skip: int = 0, limit: int = 100) -> list[CoverTemplate]:
|
||||
"""列出用户可见的模板(系统模板 + 用户自定义模板)。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def count_for_user(self, user_id: str) -> int:
|
||||
"""统计用户可见的模板数量。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(self, template: CoverTemplate) -> CoverTemplate:
|
||||
"""更新模板。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, template_id: str) -> bool:
|
||||
"""删除模板(仅允许删除用户自定义模板)。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_system_templates(self) -> list[CoverTemplate]:
|
||||
"""列出所有系统模板。"""
|
||||
pass
|
||||
@@ -296,62 +296,6 @@ def _call_ai_recommend_service(
|
||||
# ── AI 封面生成 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _transfer_cover_frame_to_storage(frame_url: str, plan_id: str) -> str:
|
||||
"""下载 MediaKit 帧图并上传到 OSS,返回公开可访问的 URL.
|
||||
|
||||
Args:
|
||||
frame_url: MediaKit 返回的帧图 URL(内部/临时 URL)
|
||||
plan_id: 剪辑计划 ID(用于生成存储路径)
|
||||
|
||||
Returns:
|
||||
公开可访问的 URL;如果下载/上传失败则返回原始 URL
|
||||
"""
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# 下载帧图
|
||||
logger.info("下载 MediaKit 帧图: plan_id=%s url=%s", plan_id, frame_url[:80])
|
||||
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
if not resp.content:
|
||||
logger.warning("MediaKit 帧图下载为空,返回原始 URL")
|
||||
return frame_url
|
||||
|
||||
# 写入临时文件
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
# 上传到 OSS
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
cover_key = f"covers/{plan_id}/mediakit_frame_{uuid.uuid4().hex[:8]}.jpg"
|
||||
storage.upload_file(
|
||||
file_or_path=tmp_path,
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
|
||||
# 获取公开 URL
|
||||
public_url = storage.get_url(cover_key)
|
||||
logger.info("封面帧图已上传到 OSS: plan_id=%s key=%s url=%s", plan_id, cover_key, public_url[:80])
|
||||
|
||||
# 清理临时文件
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
return public_url
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("封面帧图转存失败,返回原始 URL: %s", str(e))
|
||||
return frame_url
|
||||
|
||||
|
||||
def _call_ai_cover_service(
|
||||
plan_id: str,
|
||||
asset_ids: List[str],
|
||||
@@ -362,7 +306,7 @@ def _call_ai_cover_service(
|
||||
"""调用 AI 封面生成服务.
|
||||
|
||||
当 cover_type 为 ai_frame 或 ai_regenerate 时,调用 MediaKit 视频截帧。
|
||||
失败时抛出 RuntimeError。
|
||||
失败或未配置时降级为 stub 行为。
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
@@ -379,16 +323,9 @@ def _call_ai_cover_service(
|
||||
}
|
||||
|
||||
if cover_type == "manual" and frame_time is not None:
|
||||
svg_placeholder = (
|
||||
"data:image/svg+xml,"
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='1080' height='1920'>"
|
||||
"<rect width='1080' height='1920' fill='#1a1a2e'/>"
|
||||
"<text x='540' y='960' text-anchor='middle' fill='#e0e0e0' font-size='48' font-family='sans-serif'>手动选帧</text>"
|
||||
"</svg>"
|
||||
)
|
||||
return {
|
||||
"type": "manual",
|
||||
"image_url": svg_placeholder,
|
||||
"image_url": f"/api/v1/assets/placeholder/cover?time={frame_time}",
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
@@ -404,8 +341,6 @@ def _call_ai_cover_service(
|
||||
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:
|
||||
@@ -421,12 +356,9 @@ def _call_ai_cover_service(
|
||||
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,
|
||||
"image_url": image_url,
|
||||
"frame_time": round(timestamp, 1),
|
||||
"confidence": 0.85,
|
||||
}
|
||||
@@ -434,12 +366,17 @@ def _call_ai_cover_service(
|
||||
logger.warning("MediaKit 返回的帧无 image_url")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 抽帧失败: %s", str(e))
|
||||
logger.exception("MediaKit 抽帧失败,降级到 stub: %s", str(e))
|
||||
|
||||
# 封面生成失败 - 不再降级到 stub,直接报错
|
||||
raise RuntimeError(
|
||||
f"封面生成失败: plan_id={plan_id}, MediaKit 不可用或抽帧失败。" f"请检查 primary_video_url 是否可访问。"
|
||||
)
|
||||
# 降级:stub 行为
|
||||
logger.info("使用 stub 封面: plan_id=%s", plan_id)
|
||||
time.sleep(0.3)
|
||||
return {
|
||||
"type": "ai_frame",
|
||||
"image_url": f"/api/v1/assets/placeholder/cover?plan={plan_id}",
|
||||
"frame_time": round(random.uniform(1.0, 10.0), 1),
|
||||
"confidence": round(random.uniform(0.80, 0.98), 2),
|
||||
}
|
||||
|
||||
|
||||
# ── 公共入口 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,7 +13,3 @@ pytest-cov==6.0.0
|
||||
pytest-timeout==2.3.1
|
||||
pytest-xdist==3.6.1
|
||||
diff-cover==8.0.3
|
||||
|
||||
# 资产质量评分依赖(与 requirements-worker.txt 保持一致)
|
||||
scipy==1.13.1
|
||||
Pillow==10.4.0
|
||||
|
||||
@@ -75,7 +75,7 @@ def check_required_contexts(token, repo, sha, contexts):
|
||||
|
||||
for ctx in contexts:
|
||||
state = statuses.get(ctx, "pending")
|
||||
if state not in ("success", "skipped"):
|
||||
if state != "success":
|
||||
all_success = False
|
||||
if state == "pending":
|
||||
any_pending = True
|
||||
|
||||
@@ -3,10 +3,18 @@
|
||||
# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁
|
||||
set -eu
|
||||
|
||||
# 测试环境必须的密钥变量
|
||||
export JWT_SECRET_KEY=${JWT_SECRET_KEY:-test-jwt-secret-for-ci-only-2026}
|
||||
|
||||
JOB_NAME="${1:-Unit Tests}"
|
||||
|
||||
echo "=== CI Unit Tests 开始 ==="
|
||||
|
||||
# --- 配置 pip 国内源(加速下载,减少网络失败)---
|
||||
python3 -m pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
|
||||
python3 -m pip config set global.timeout 120
|
||||
python3 -m pip config set global.retries 5
|
||||
|
||||
# --- 安装依赖 ---
|
||||
echo ""
|
||||
echo "=== 安装 Python 依赖 ==="
|
||||
@@ -23,6 +31,12 @@ for i in 1 2 3; do
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-worker.txt && break
|
||||
echo "pip install requirements-worker.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
@@ -31,22 +45,11 @@ for i in 1 2 3; do
|
||||
done
|
||||
pytest --version
|
||||
|
||||
# --- 安装 ffmpeg(视频处理相关测试依赖)---
|
||||
bash scripts/ci/step_install_ffmpeg.sh
|
||||
|
||||
# 双保险:确保numpy已安装
|
||||
echo "=== 验证 numpy 安装 ==="
|
||||
SKIP_NUMPY_TESTS=0
|
||||
python3 -m pip install numpy==1.26.4 || {
|
||||
echo "❌ numpy 首次安装失败,尝试不使用缓存重新安装..."
|
||||
python3 -m pip install --no-cache-dir numpy==1.26.4 || {
|
||||
echo "⚠️ numpy 安装失败,跳过需要 numpy 的测试"
|
||||
SKIP_NUMPY_TESTS=1
|
||||
}
|
||||
}
|
||||
if [ "$SKIP_NUMPY_TESTS" = "0" ]; then
|
||||
python3 -c "import numpy; print(f'✅ numpy {numpy.__version__} 安装成功')" || {
|
||||
echo "⚠️ numpy 导入失败,跳过需要 numpy 的测试"
|
||||
SKIP_NUMPY_TESTS=1
|
||||
}
|
||||
fi
|
||||
python3 -m pip install -q numpy==1.26.4 || true
|
||||
|
||||
# --- 增量测试选择(仅PR) ---
|
||||
UNIT_TEST_MODE="full"
|
||||
@@ -79,32 +82,25 @@ fi
|
||||
echo ""
|
||||
echo "=== 运行单元测试 (模式: $UNIT_TEST_MODE) ==="
|
||||
|
||||
# 如果 numpy 不可用,跳过依赖 numpy 的测试文件
|
||||
NUMPY_IGNORE=""
|
||||
if [ "${SKIP_NUMPY_TESTS:-0}" = "1" ]; then
|
||||
echo "⚠️ SKIP_NUMPY_TESTS=1,将跳过依赖 numpy 的测试"
|
||||
NUMPY_IGNORE="--ignore=tests/unit/test_dedup_engine.py"
|
||||
fi
|
||||
|
||||
if [ "$UNIT_TEST_MODE" = "incremental" ]; then
|
||||
echo "=== 增量测试模式 ==="
|
||||
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD/packages:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--source=apps/api/app,apps/worker/worker_app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest $SELECTED_TEST_FILES $NUMPY_IGNORE -q
|
||||
-m pytest $SELECTED_TEST_FILES -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=10 > /dev/null || true
|
||||
else
|
||||
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD/packages:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--source=apps/api/app,apps/worker/worker_app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/unit $NUMPY_IGNORE -q
|
||||
-m pytest tests/unit -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=65 > /dev/null
|
||||
python3 -m coverage report --fail-under=55 > /dev/null
|
||||
fi
|
||||
|
||||
# --- Diff 覆盖率检查(仅PR) ---
|
||||
|
||||
@@ -311,12 +311,9 @@ else
|
||||
fi
|
||||
|
||||
echo "Stopping old containers..."
|
||||
# 优雅关闭:先 stop(发 SIGTERM,等待),再 rm
|
||||
# Worker 需要更长时间(视频任务最长可能5分钟)
|
||||
docker stop -t 300 xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker stop -t 30 xiaoxia-api-staging 2>/dev/null || true
|
||||
docker stop -t 10 xiaoxia-web-staging 2>/dev/null || true
|
||||
docker rm xiaoxia-worker-staging xiaoxia-api-staging xiaoxia-web-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""统一渲染管线 — 预览与确认生成使用相同品质参数。
|
||||
"""#1280 预览视频生成加速 — 单元测试。
|
||||
|
||||
验证点:
|
||||
1. UnifiedRenderService 不再有 is_preview 参数
|
||||
2. 所有渲染统一使用 medium preset + CRF 23
|
||||
3. RenderAdapter 统一执行校验和缩略图生成
|
||||
4. generation.py 并行下载逻辑(保留)
|
||||
1. UnifiedRenderService.is_preview 参数正确传递
|
||||
2. 预览模式使用 ultrafast preset + crf 28
|
||||
3. RenderAdapter.render_from_memory 正确传递 is_preview
|
||||
4. 预览模式跳过 ASR 初始化
|
||||
5. 预览模式跳过输出校验和缩略图
|
||||
6. generation.py 并行下载逻辑
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,24 +18,13 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── 1. UnifiedRenderService 无 is_preview 参数 ──
|
||||
# ── 1. UnifiedRenderService is_preview 参数 ──
|
||||
|
||||
|
||||
class TestUnifiedRenderServiceNoPreviewParam:
|
||||
"""UnifiedRenderService 构造函数不再接受 is_preview 参数。"""
|
||||
class TestUnifiedRenderServicePreviewFlag:
|
||||
"""is_preview 参数正确传递和存储。"""
|
||||
|
||||
def test_constructor_has_no_is_preview(self):
|
||||
import inspect
|
||||
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
sig = inspect.signature(UnifiedRenderService.__init__)
|
||||
param_names = list(sig.parameters.keys())
|
||||
assert (
|
||||
"is_preview" not in param_names
|
||||
), f"is_preview should be removed from UnifiedRenderService.__init__, found params: {param_names}"
|
||||
|
||||
def test_no_is_preview_attribute(self):
|
||||
def test_default_is_preview_false(self):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
svc = UnifiedRenderService(
|
||||
@@ -42,16 +33,38 @@ class TestUnifiedRenderServiceNoPreviewParam:
|
||||
asset_path_map={},
|
||||
work_dir=Path(tempfile.mkdtemp()),
|
||||
)
|
||||
assert not hasattr(
|
||||
svc, "is_preview"
|
||||
), "UnifiedRenderService should not have is_preview attribute after unification"
|
||||
assert svc.is_preview is False
|
||||
|
||||
def test_is_preview_true(self):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
svc = UnifiedRenderService(
|
||||
plan=MagicMock(id="test"),
|
||||
clips=[],
|
||||
asset_path_map={},
|
||||
work_dir=Path(tempfile.mkdtemp()),
|
||||
is_preview=True,
|
||||
)
|
||||
assert svc.is_preview is True
|
||||
|
||||
def test_is_preview_false_explicit(self):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
svc = UnifiedRenderService(
|
||||
plan=MagicMock(id="test"),
|
||||
clips=[],
|
||||
asset_path_map={},
|
||||
work_dir=Path(tempfile.mkdtemp()),
|
||||
is_preview=False,
|
||||
)
|
||||
assert svc.is_preview is False
|
||||
|
||||
|
||||
# ── 2. FFmpeg 参数统一为 medium + CRF 23 ──
|
||||
# ── 2. 预览模式 FFmpeg 参数 ──
|
||||
|
||||
|
||||
class TestUnifiedFFmpegPreset:
|
||||
"""所有渲染统一使用 medium preset + CRF 23。"""
|
||||
class TestPreviewFFmpegPreset:
|
||||
"""预览模式使用 ultrafast preset + crf 28。"""
|
||||
|
||||
def _make_clip(self):
|
||||
from video_processing.unified_render_service import ResolvedClip
|
||||
@@ -71,7 +84,46 @@ class TestUnifiedFFmpegPreset:
|
||||
)
|
||||
|
||||
@patch("video_processing.unified_render_service.run_ffmpeg")
|
||||
def test_execute_ffmpeg_uses_medium_crf23(self, mock_run):
|
||||
def test_execute_ffmpeg_preview_uses_ultrafast(self, mock_run):
|
||||
from video_processing.unified_render_service import (
|
||||
RenderLayer,
|
||||
UnifiedRenderService,
|
||||
)
|
||||
|
||||
plan = MagicMock()
|
||||
plan.id = "test_plan"
|
||||
plan.config = {"export": {"resolution": "854x480"}}
|
||||
|
||||
clip = self._make_clip()
|
||||
|
||||
svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=[clip],
|
||||
asset_path_map={"a1": Path("/tmp/fake.mp4")},
|
||||
work_dir=Path(tempfile.mkdtemp()),
|
||||
output_width=854,
|
||||
output_height=480,
|
||||
is_preview=True,
|
||||
)
|
||||
|
||||
layers = [RenderLayer(role="main", clips=[clip])]
|
||||
filter_complex, input_args = svc._build_filter_complex(layers)
|
||||
output_path = Path(tempfile.mkdtemp()) / "out.mp4"
|
||||
svc._execute_ffmpeg(filter_complex, input_args, output_path)
|
||||
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# Check preset is ultrafast
|
||||
preset_idx = cmd.index("-preset")
|
||||
assert cmd[preset_idx + 1] == "ultrafast", f"Expected ultrafast, got {cmd[preset_idx + 1]}"
|
||||
|
||||
# Check crf is 28
|
||||
crf_idx = cmd.index("-crf")
|
||||
assert cmd[crf_idx + 1] == "28", f"Expected crf 28, got {cmd[crf_idx + 1]}"
|
||||
|
||||
@patch("video_processing.unified_render_service.run_ffmpeg")
|
||||
def test_execute_ffmpeg_normal_uses_medium(self, mock_run):
|
||||
from video_processing.unified_render_service import (
|
||||
RenderLayer,
|
||||
UnifiedRenderService,
|
||||
@@ -88,8 +140,9 @@ class TestUnifiedFFmpegPreset:
|
||||
clips=[clip],
|
||||
asset_path_map={"a1": Path("/tmp/fake.mp4")},
|
||||
work_dir=Path(tempfile.mkdtemp()),
|
||||
output_width=1920,
|
||||
output_height=1080,
|
||||
output_width=1280,
|
||||
output_height=720,
|
||||
is_preview=False,
|
||||
)
|
||||
|
||||
layers = [RenderLayer(role="main", clips=[clip])]
|
||||
@@ -100,73 +153,137 @@ class TestUnifiedFFmpegPreset:
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# Check preset is medium (no conditional)
|
||||
preset_idx = cmd.index("-preset")
|
||||
assert cmd[preset_idx + 1] == "medium", f"Expected medium, got {cmd[preset_idx + 1]}"
|
||||
assert cmd[preset_idx + 1] == "medium"
|
||||
|
||||
# Check crf is 23 (no conditional)
|
||||
crf_idx = cmd.index("-crf")
|
||||
assert cmd[crf_idx + 1] == "23", f"Expected crf 23, got {cmd[crf_idx + 1]}"
|
||||
assert cmd[crf_idx + 1] == "23"
|
||||
|
||||
|
||||
# ── 3. RenderAdapter 统一执行校验和缩略图 ──
|
||||
# ── 3. RenderAdapter passes is_preview ──
|
||||
|
||||
|
||||
class TestRenderAdapterUnifiedPostProcess:
|
||||
"""RenderAdapter 不再跳过校验和缩略图。"""
|
||||
|
||||
def test_render_adapter_no_is_preview_param(self):
|
||||
import inspect
|
||||
class TestRenderAdapterPreviewPassthrough:
|
||||
"""RenderAdapter 正确传递 is_preview 参数。"""
|
||||
|
||||
def test_render_from_memory_passes_is_preview(self):
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
|
||||
# Check render_from_memory signature
|
||||
sig = inspect.signature(RenderAdapter.render_from_memory)
|
||||
param_names = list(sig.parameters.keys())
|
||||
assert (
|
||||
"is_preview" not in param_names
|
||||
), f"is_preview should be removed from render_from_memory, found params: {param_names}"
|
||||
db = MagicMock()
|
||||
adapter = RenderAdapter(db)
|
||||
|
||||
def test_no_preview_skip_validation_in_source(self):
|
||||
"""渲染适配器源码中不再包含预览跳过校验的逻辑。"""
|
||||
plan = MagicMock()
|
||||
plan.id = "test_plan"
|
||||
plan.config = {"export": {"resolution": "854x480"}}
|
||||
|
||||
clip = MagicMock()
|
||||
clip.id = "c1"
|
||||
|
||||
with patch.object(adapter, "_do_render") as mock_do_render:
|
||||
mock_do_render.return_value = MagicMock(
|
||||
success=True,
|
||||
output_path=Path("/tmp/out.mp4"),
|
||||
thumbnail_url="",
|
||||
duration=5.0,
|
||||
file_size=1000,
|
||||
width=854,
|
||||
height=480,
|
||||
output_url="https://oss/test.mp4",
|
||||
rendered_clip_ids=["c1"],
|
||||
failed_clip_ids=[],
|
||||
)
|
||||
|
||||
adapter.render_from_memory(
|
||||
plan=plan,
|
||||
clips=[clip],
|
||||
asset_path_map={"a1": Path("/tmp/fake.mp4")},
|
||||
is_preview=True,
|
||||
)
|
||||
|
||||
mock_do_render.assert_called_once()
|
||||
_, kwargs = mock_do_render.call_args
|
||||
assert kwargs.get("is_preview") is True
|
||||
|
||||
|
||||
# ── 4. 预览模式跳过 ASR ──
|
||||
|
||||
|
||||
class TestPreviewSkipsASR:
|
||||
"""预览模式跳过 ASR 初始化。"""
|
||||
|
||||
def test_render_method_source_has_asr_skip(self):
|
||||
"""_do_render 在 is_preview=True 时不调用 _get_asr_service。"""
|
||||
with open("apps/worker/video_processing/render_adapter.py") as f:
|
||||
source = f.read()
|
||||
|
||||
assert "预览模式:跳过输出校验" not in source, "Should not skip validation in any mode"
|
||||
assert "if not is_preview:" not in source, "Thumbnail should always be generated"
|
||||
assert (
|
||||
"None if is_preview else self._get_asr_service()" in source
|
||||
), "Should skip ASR initialization in preview mode"
|
||||
|
||||
|
||||
# ── 4. generation.py 不再有 is_preview 覆盖逻辑 ──
|
||||
# ── 5. 并行下载逻辑 ──
|
||||
|
||||
|
||||
class TestWorkerGenerationNoPreviewOverride:
|
||||
"""Worker generation.py 不再覆盖预览分辨率为 480p。"""
|
||||
class TestParallelDownload:
|
||||
"""generation.py 并行下载素材。"""
|
||||
|
||||
def test_no_480p_override(self):
|
||||
with open("apps/worker/worker_app/tasks/generation.py") as f:
|
||||
source = f.read()
|
||||
|
||||
assert 'resolution = "854x480"' not in source, "Should not override resolution to 480p in preview mode"
|
||||
assert 'bitrate = "1M"' not in source, "Should not override bitrate to 1M in preview mode"
|
||||
|
||||
def test_parallel_download_still_works(self):
|
||||
"""并行下载逻辑保留。"""
|
||||
def test_parallel_download_uses_thread_pool(self):
|
||||
with open("apps/worker/worker_app/tasks/generation.py") as f:
|
||||
source = f.read()
|
||||
|
||||
assert "ThreadPoolExecutor" in source, "Should use ThreadPoolExecutor for parallel downloads"
|
||||
assert "as_completed" in source, "Should use as_completed for result collection"
|
||||
|
||||
|
||||
# ── 5. generation_preview.py 不再有 PREVIEW_RESOLUTION ──
|
||||
|
||||
|
||||
class TestPreviewNoLowQualityConstants:
|
||||
"""预览 API 不再定义低质量常量。"""
|
||||
|
||||
def test_no_preview_resolution_constant(self):
|
||||
with open("apps/api/app/api/routes/generation_preview.py") as f:
|
||||
def test_parallel_download_preserves_order(self):
|
||||
with open("apps/worker/worker_app/tasks/generation.py") as f:
|
||||
source = f.read()
|
||||
|
||||
assert "PREVIEW_RESOLUTION" not in source, "PREVIEW_RESOLUTION constant should be removed"
|
||||
assert "_calc_preview_resolution" not in source, "_calc_preview_resolution function should be removed"
|
||||
assert "sorted(results_map.keys())" in source, "Should sort results by original index"
|
||||
|
||||
|
||||
# ── 6. generation.py _render_video passes is_preview ──
|
||||
|
||||
|
||||
class TestRenderVideoPassesPreview:
|
||||
"""_render_video 正确传递 is_preview 到 render_from_memory。"""
|
||||
|
||||
def test_render_video_passes_is_preview(self):
|
||||
with open("apps/worker/worker_app/tasks/generation.py") as f:
|
||||
source = f.read()
|
||||
|
||||
assert "is_preview=is_preview" in source, "Should pass is_preview to render_from_memory"
|
||||
|
||||
|
||||
# ── 7. Preview mode skips thumbnail and validation ──
|
||||
|
||||
|
||||
class TestPreviewSkipsThumbnailAndValidation:
|
||||
"""预览模式跳过缩略图生成和输出校验。"""
|
||||
|
||||
def test_render_adapter_skips_thumbnail_in_preview(self):
|
||||
with open("apps/worker/video_processing/render_adapter.py") as f:
|
||||
source = f.read()
|
||||
|
||||
assert "if not is_preview:" in source, "Thumbnail should be conditional on is_preview"
|
||||
|
||||
def test_render_adapter_skips_validation_in_preview(self):
|
||||
with open("apps/worker/video_processing/render_adapter.py") as f:
|
||||
source = f.read()
|
||||
|
||||
assert "预览模式:跳过输出校验" in source, "Should skip validation in preview mode"
|
||||
|
||||
|
||||
# ── 8. Pass-through rendering uses ultrafast in preview ──
|
||||
|
||||
|
||||
class TestPassThroughPreviewPreset:
|
||||
"""直通渲染在预览模式也使用 ultrafast。"""
|
||||
|
||||
def test_pass_through_has_preview_preset(self):
|
||||
with open("apps/worker/video_processing/unified_render_service.py") as f:
|
||||
source = f.read()
|
||||
|
||||
# The pass_through method should also use ultrafast for preview
|
||||
# Count occurrences of "ultrafast" - should be at least 2 (execute_ffmpeg + pass_through)
|
||||
count = source.count('"ultrafast" if self.is_preview')
|
||||
assert count >= 2, f"Expected at least 2 ultrafast preset usages, found {count}"
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""测试 #1294 修复:预览视频配音注入。
|
||||
|
||||
验证:
|
||||
1. _load_task_info 正确加载 voice_ids
|
||||
2. _render_video 接受 voice_ids 参数
|
||||
3. voice_ids 正确注入到 plan config 中(实际执行代码路径,diff-cover 可达)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLoadTaskInfoVoiceIds:
|
||||
"""验证 _load_task_info 包含 voice_ids"""
|
||||
|
||||
def test_voice_ids_loaded_from_task(self):
|
||||
"""voice_ids 从 gen_task 正确加载"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.project_id = "proj_1"
|
||||
mock_task.asset_library_id = "lib_1"
|
||||
mock_task.voice_library_id = "voice_lib_1"
|
||||
mock_task.template_id = "tmpl_1"
|
||||
mock_task.strategy_id = "one_take"
|
||||
mock_task.asset_ids = ["a1", "a2"]
|
||||
mock_task.batch_id = "batch_1"
|
||||
mock_task.created_by_user_id = "user_1"
|
||||
mock_task.video_title = "test"
|
||||
mock_task.resolution = "854x480"
|
||||
mock_task.bgm_config = {}
|
||||
mock_task.is_preview = True
|
||||
mock_task.voice_ids = ["voice_1", "voice_2"]
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
|
||||
assert result is not None
|
||||
assert result["voice_ids"] == ["voice_1", "voice_2"]
|
||||
|
||||
def test_voice_ids_empty_when_none(self):
|
||||
"""voice_ids 为 None 时返回空列表"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.project_id = "proj_1"
|
||||
mock_task.asset_library_id = "lib_1"
|
||||
mock_task.voice_library_id = ""
|
||||
mock_task.template_id = "tmpl_1"
|
||||
mock_task.strategy_id = "one_take"
|
||||
mock_task.asset_ids = ["a1"]
|
||||
mock_task.batch_id = ""
|
||||
mock_task.created_by_user_id = "user_1"
|
||||
mock_task.video_title = ""
|
||||
mock_task.resolution = ""
|
||||
mock_task.bgm_config = {}
|
||||
mock_task.is_preview = False
|
||||
mock_task.voice_ids = None
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
assert result["voice_ids"] == []
|
||||
|
||||
|
||||
class TestRenderVideoVoiceInjection:
|
||||
"""验证 _render_video 正确注入 voice_id 到 plan config(实际执行代码路径)"""
|
||||
|
||||
def test_render_video_accepts_voice_ids(self):
|
||||
"""_render_video 签名包含 voice_ids 参数"""
|
||||
import inspect
|
||||
|
||||
from worker_app.tasks.generation import _render_video
|
||||
|
||||
sig = inspect.signature(_render_video)
|
||||
assert "voice_ids" in sig.parameters
|
||||
|
||||
def test_voice_ids_default_none(self):
|
||||
"""voice_ids 参数默认为 None"""
|
||||
import inspect
|
||||
|
||||
from worker_app.tasks.generation import _render_video
|
||||
|
||||
sig = inspect.signature(_render_video)
|
||||
param = sig.parameters["voice_ids"]
|
||||
assert param.default is None
|
||||
|
||||
def test_voice_ids_injected_into_plan_config(self):
|
||||
"""voice_ids 非空时,voice_id 和 subtitle.auto_generated 被注入到 plan config。
|
||||
|
||||
此测试实际执行 _render_video 的配音注入代码路径,确保 diff-cover 覆盖新增行。
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class MockClip:
|
||||
"""模拟 VirtualClip,至少需要 duration 属性。"""
|
||||
|
||||
id: str = "clip_1"
|
||||
duration: float = 5.0
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class MockPlan:
|
||||
"""模拟 VirtualPlan,至少需要 config 属性。"""
|
||||
|
||||
id: str = "test_plan"
|
||||
name: str = "test"
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
mock_plan = MockPlan(config={"some_key": "some_value"})
|
||||
mock_clips = [MockClip(duration=5.0), MockClip(duration=3.0)]
|
||||
mock_asset_path_map = {"asset_1": Path("/tmp/video1.mp4")}
|
||||
|
||||
# Mock RenderAdapter 和 render 结果
|
||||
mock_render_result = MagicMock()
|
||||
mock_render_result.success = True
|
||||
mock_render_result.output_path = Path("/tmp/output.mp4")
|
||||
mock_render_result.duration = 8.0
|
||||
|
||||
mock_adapter_cls = MagicMock(return_value=MagicMock())
|
||||
mock_adapter_cls.return_value.render_from_memory.return_value = mock_render_result
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"worker_app.tasks.generation._build_plan_and_clips_from_task",
|
||||
return_value=(mock_plan, mock_clips, mock_asset_path_map),
|
||||
),
|
||||
patch(
|
||||
"worker_app.tasks.generation._load_template_plan_config",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"video_processing.render_adapter.RenderAdapter",
|
||||
mock_adapter_cls,
|
||||
),
|
||||
patch(
|
||||
"worker_app.tasks.generation.SessionLocal",
|
||||
return_value=mock_db,
|
||||
),
|
||||
):
|
||||
from worker_app.tasks.generation import _render_video
|
||||
|
||||
from packages.domain import EditingMode
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
task_id="test_task_123",
|
||||
downloaded_videos=[Path("/tmp/video1.mp4")],
|
||||
voice_path=None,
|
||||
editing_mode=EditingMode.ONE_TAKE,
|
||||
project_id="proj_1",
|
||||
template_id="tmpl_1",
|
||||
user_id="user_1",
|
||||
temp_path=Path("/tmp"),
|
||||
output_name="test_output.mp4",
|
||||
resolution="854x480",
|
||||
voice_ids=["voice_abc"],
|
||||
)
|
||||
|
||||
# 验证 voice_id 被注入到 plan config(覆盖新增代码行)
|
||||
assert mock_plan.config.get("voice_id") == "voice_abc"
|
||||
# 验证 subtitle.auto_generated 被设置为 True
|
||||
assert mock_plan.config.get("subtitle", {}).get("auto_generated") is True
|
||||
# 验证 RenderAdapter 被调用
|
||||
mock_adapter_cls.return_value.render_from_memory.assert_called_once()
|
||||
# 验证返回值
|
||||
assert output_path == Path("/tmp/output.mp4")
|
||||
assert render_duration == 8.0
|
||||
|
||||
def test_voice_ids_empty_skips_injection(self):
|
||||
"""voice_ids 为空时,不注入 voice_id 到 plan config"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class MockClip:
|
||||
id: str = "clip_1"
|
||||
duration: float = 5.0
|
||||
|
||||
@dataclass
|
||||
class MockPlan:
|
||||
id: str = "test_plan"
|
||||
name: str = "test"
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
mock_plan = MockPlan(config={"export": {"resolution": "854x480"}})
|
||||
mock_clips = [MockClip(duration=5.0)]
|
||||
|
||||
mock_render_result = MagicMock()
|
||||
mock_render_result.success = True
|
||||
mock_render_result.output_path = Path("/tmp/output.mp4")
|
||||
mock_render_result.duration = 5.0
|
||||
|
||||
mock_adapter_cls = MagicMock(return_value=MagicMock())
|
||||
mock_adapter_cls.return_value.render_from_memory.return_value = mock_render_result
|
||||
|
||||
with (
|
||||
patch(
|
||||
"worker_app.tasks.generation._build_plan_and_clips_from_task",
|
||||
return_value=(mock_plan, mock_clips, {}),
|
||||
),
|
||||
patch(
|
||||
"worker_app.tasks.generation._load_template_plan_config",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"video_processing.render_adapter.RenderAdapter",
|
||||
mock_adapter_cls,
|
||||
),
|
||||
patch(
|
||||
"worker_app.tasks.generation.SessionLocal",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
from worker_app.tasks.generation import _render_video
|
||||
|
||||
from packages.domain import EditingMode
|
||||
|
||||
_render_video(
|
||||
task_id="test_task_456",
|
||||
downloaded_videos=[Path("/tmp/video1.mp4")],
|
||||
voice_path=None,
|
||||
editing_mode=EditingMode.ONE_TAKE,
|
||||
project_id="proj_1",
|
||||
template_id="",
|
||||
user_id="user_1",
|
||||
temp_path=Path("/tmp"),
|
||||
output_name="test_output.mp4",
|
||||
voice_ids=[],
|
||||
)
|
||||
|
||||
# 验证 voice_id 没有被注入
|
||||
assert "voice_id" not in mock_plan.config
|
||||
|
||||
|
||||
class TestGenerateVideoPassesVoiceIds:
|
||||
"""验证 generate_video 调用 _render_video 时传递 voice_ids"""
|
||||
|
||||
def test_generate_video_passes_voice_ids(self):
|
||||
"""generate_video 中 _render_video 调用包含 voice_ids 参数"""
|
||||
with open("apps/worker/worker_app/tasks/generation.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'voice_ids=task_info.get("voice_ids", [])' in content
|
||||
@@ -399,16 +399,18 @@ class TestRunAIRecommend(unittest.TestCase):
|
||||
|
||||
|
||||
class TestGenerateCover(unittest.TestCase):
|
||||
"""封面生成测试."""
|
||||
"""封面生成测试(降级路径)."""
|
||||
|
||||
def test_ai_frame_type_raises_without_mediakit(self):
|
||||
"""AI封面模式在MediaKit不可用时抛出RuntimeError."""
|
||||
with self.assertRaises(RuntimeError):
|
||||
run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
def test_ai_frame_type(self):
|
||||
"""AI封面模式返回预期结构."""
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
self.assertIn("type", result)
|
||||
self.assertEqual(result["type"], "ai_frame")
|
||||
self.assertIn("image_url", result)
|
||||
|
||||
def test_manual_type(self):
|
||||
"""手动选帧模式."""
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
"""测试 _build_filter_complex 正确排除纯音频 clips.
|
||||
|
||||
Bug: voice.mp3(纯音频文件)被错误地加入视频 filter_complex,
|
||||
导致 FFmpeg 尝试访问 [N:v] 视频流时报错 "Stream specifier ':v' matches no streams".
|
||||
|
||||
修复:_build_filter_complex 在收集 clips 时跳过 clip_type="audio" 的 clips,
|
||||
因为音频 clips 由 mix_audio() 独立处理。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_render_service(tmp_path):
|
||||
"""创建一个最小化的 UnifiedRenderService 实例."""
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
plan = MagicMock()
|
||||
plan.id = "test_plan"
|
||||
plan.config = {}
|
||||
plan.strategy_id = "test_strategy"
|
||||
|
||||
clips = []
|
||||
asset_path_map = {}
|
||||
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=tmp_path,
|
||||
output_width=480,
|
||||
output_height=854,
|
||||
output_fps=30,
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def _make_video_clip(clip_id: str, local_path: Path, duration: float = 5.0):
|
||||
"""创建一个视频 clip."""
|
||||
from video_processing.unified_render_service import ResolvedClip
|
||||
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=f"asset_{clip_id}",
|
||||
local_path=local_path,
|
||||
clip_type="video",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=duration,
|
||||
config={},
|
||||
actual_duration=duration,
|
||||
)
|
||||
|
||||
|
||||
def _make_audio_clip(clip_id: str, local_path: Path, duration: float = 5.0):
|
||||
"""创建一个纯音频 clip."""
|
||||
from video_processing.unified_render_service import ResolvedClip
|
||||
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=f"asset_{clip_id}",
|
||||
local_path=local_path,
|
||||
clip_type="audio",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=duration,
|
||||
config={"volume": 1.0},
|
||||
actual_duration=duration,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildFilterComplexExcludesAudioClips:
|
||||
"""_build_filter_complex 应该排除 clip_type='audio' 的 clips."""
|
||||
|
||||
def test_audio_clip_not_in_filter_complex(self, tmp_path):
|
||||
"""纯音频 clip 不应出现在 filter_complex 中."""
|
||||
render_service = _make_render_service(tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer
|
||||
|
||||
# 准备视频和音频文件
|
||||
video_path = tmp_path / "video.mp4"
|
||||
video_path.write_bytes(b"\x00")
|
||||
audio_path = tmp_path / "voice.mp3"
|
||||
audio_path.write_bytes(b"\x00")
|
||||
|
||||
video_clip = _make_video_clip("clip_0", video_path, duration=5.0)
|
||||
audio_clip = _make_audio_clip("voice_library_main", audio_path, duration=5.0)
|
||||
|
||||
video_layer = RenderLayer(role="main", z_index=1)
|
||||
video_layer.clips.append(video_clip)
|
||||
|
||||
audio_layer = RenderLayer(role="audio", z_index=2)
|
||||
audio_layer.clips.append(audio_clip)
|
||||
|
||||
layers = [video_layer, audio_layer]
|
||||
|
||||
# 执行
|
||||
filter_complex, input_args = render_service._build_filter_complex(layers)
|
||||
|
||||
# 验证:filter_complex 只包含视频 clip 的处理([0:v]),不包含音频 clip([1:v])
|
||||
assert "[0:v]" in filter_complex
|
||||
assert "[1:v]" not in filter_complex # voice.mp3 不应该有视频滤镜
|
||||
|
||||
# 验证:input_args 只包含视频文件,不包含音频文件
|
||||
assert str(video_path) in " ".join(input_args)
|
||||
assert str(audio_path) not in " ".join(input_args)
|
||||
|
||||
def test_multiple_video_clips_with_audio(self, tmp_path):
|
||||
"""多个视频 clips + 音频 clip 时,filter_complex 只处理视频."""
|
||||
render_service = _make_render_service(tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer
|
||||
|
||||
# 准备文件
|
||||
video_paths = [tmp_path / f"video_{i}.mp4" for i in range(3)]
|
||||
for p in video_paths:
|
||||
p.write_bytes(b"\x00")
|
||||
audio_path = tmp_path / "voice.mp3"
|
||||
audio_path.write_bytes(b"\x00")
|
||||
|
||||
video_layer = RenderLayer(role="main", z_index=1)
|
||||
for i, vp in enumerate(video_paths):
|
||||
clip = _make_video_clip(f"clip_{i}", vp, duration=3.0)
|
||||
clip.order = i
|
||||
video_layer.clips.append(clip)
|
||||
|
||||
audio_layer = RenderLayer(role="audio", z_index=2)
|
||||
audio_clip = _make_audio_clip("voice_main", audio_path, duration=9.0)
|
||||
audio_layer.clips.append(audio_clip)
|
||||
|
||||
layers = [video_layer, audio_layer]
|
||||
|
||||
# 执行
|
||||
filter_complex, input_args = render_service._build_filter_complex(layers)
|
||||
|
||||
# 验证:只有 3 个视频输入
|
||||
assert "[0:v]" in filter_complex
|
||||
assert "[1:v]" in filter_complex
|
||||
assert "[2:v]" in filter_complex
|
||||
assert "[3:v]" not in filter_complex # 音频不应该出现
|
||||
|
||||
# 验证:input_args 只有 3 个 -i
|
||||
input_files = [arg for arg in input_args if not arg.startswith("-")]
|
||||
assert len(input_files) == 3
|
||||
assert str(audio_path) not in input_files
|
||||
|
||||
def test_only_audio_clips_raises_error(self, tmp_path):
|
||||
"""只有音频 clips 时应该抛出 ValueError."""
|
||||
render_service = _make_render_service(tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer
|
||||
|
||||
audio_path = tmp_path / "voice.mp3"
|
||||
audio_path.write_bytes(b"\x00")
|
||||
|
||||
audio_layer = RenderLayer(role="audio", z_index=2)
|
||||
audio_clip = _make_audio_clip("voice_main", audio_path, duration=5.0)
|
||||
audio_layer.clips.append(audio_clip)
|
||||
|
||||
layers = [audio_layer]
|
||||
|
||||
with pytest.raises(ValueError, match="没有可渲染的视频片段"):
|
||||
render_service._build_filter_complex(layers)
|
||||
|
||||
def test_tts_audio_clip_excluded(self, tmp_path):
|
||||
"""TTS 配音 clip(clip_type='audio', config.tts=True)也不应出现在 filter_complex."""
|
||||
render_service = _make_render_service(tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer, ResolvedClip
|
||||
|
||||
video_path = tmp_path / "video.mp4"
|
||||
video_path.write_bytes(b"\x00")
|
||||
tts_audio_path = tmp_path / "tts_segment.wav"
|
||||
tts_audio_path.write_bytes(b"\x00")
|
||||
|
||||
video_clip = _make_video_clip("clip_0", video_path, duration=10.0)
|
||||
tts_clip = ResolvedClip(
|
||||
clip_id="tts_0.000",
|
||||
asset_id="tts_voiceover",
|
||||
local_path=tts_audio_path,
|
||||
clip_type="audio",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=3.0,
|
||||
config={"volume": 1.0, "tts": True},
|
||||
actual_duration=3.0,
|
||||
)
|
||||
|
||||
video_layer = RenderLayer(role="main", z_index=1)
|
||||
video_layer.clips.append(video_clip)
|
||||
|
||||
audio_layer = RenderLayer(role="audio", z_index=2)
|
||||
audio_layer.clips.append(tts_clip)
|
||||
|
||||
layers = [video_layer, audio_layer]
|
||||
|
||||
filter_complex, input_args = render_service._build_filter_complex(layers)
|
||||
|
||||
# TTS 音频不应出现在 filter_complex
|
||||
assert "[1:v]" not in filter_complex
|
||||
input_files = [arg for arg in input_args if not arg.startswith("-")]
|
||||
assert str(tts_audio_path) not in input_files
|
||||
|
||||
def test_video_clip_with_audio_config_not_excluded(self, tmp_path):
|
||||
"""clip_type='video' 的 clip 不应被排除(即使它有音频流)."""
|
||||
render_service = _make_render_service(tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer
|
||||
|
||||
video_path = tmp_path / "video.mp4"
|
||||
video_path.write_bytes(b"\x00")
|
||||
|
||||
video_clip = _make_video_clip("clip_0", video_path, duration=5.0)
|
||||
video_layer = RenderLayer(role="main", z_index=1)
|
||||
video_layer.clips.append(video_clip)
|
||||
|
||||
layers = [video_layer]
|
||||
|
||||
filter_complex, input_args = render_service._build_filter_complex(layers)
|
||||
|
||||
# 视频 clip 应该被处理
|
||||
assert "[0:v]" in filter_complex
|
||||
input_files = [arg for arg in input_args if not arg.startswith("-")]
|
||||
assert str(video_path) in input_files
|
||||
|
||||
def test_voice_library_clip_with_voice_library_flag(self, tmp_path):
|
||||
"""voice_library=True 的 clip(来自 _maybe_add_voice_library_layer)应被排除."""
|
||||
render_service = _make_render_service(tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer, ResolvedClip
|
||||
|
||||
video_path = tmp_path / "video.mp4"
|
||||
video_path.write_bytes(b"\x00")
|
||||
voice_path = tmp_path / "voice.mp3"
|
||||
voice_path.write_bytes(b"\x00")
|
||||
|
||||
video_clip = _make_video_clip("clip_0", video_path, duration=14.0)
|
||||
# 模拟 _maybe_add_voice_library_layer 创建的 clip
|
||||
voice_clip = ResolvedClip(
|
||||
clip_id="voice_library_main",
|
||||
asset_id="voice_library",
|
||||
local_path=voice_path,
|
||||
clip_type="audio",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=14.0,
|
||||
config={"volume": 1.0, "voice_library": True},
|
||||
actual_duration=14.0,
|
||||
)
|
||||
|
||||
video_layer = RenderLayer(role="main", z_index=1)
|
||||
video_layer.clips.append(video_clip)
|
||||
|
||||
audio_layer = RenderLayer(role="audio", z_index=2)
|
||||
audio_layer.clips.append(voice_clip)
|
||||
|
||||
layers = [video_layer, audio_layer]
|
||||
|
||||
filter_complex, input_args = render_service._build_filter_complex(layers)
|
||||
|
||||
# 只有视频 clip 被处理
|
||||
assert "[0:v]" in filter_complex
|
||||
assert "[1:v]" not in filter_complex
|
||||
# voice.mp3 不在输入中
|
||||
assert str(voice_path) not in " ".join(input_args)
|
||||
@@ -231,18 +231,18 @@ class TestAIRunTasks:
|
||||
# 即使没有素材,也应该有 intro + outro
|
||||
assert len(result["clips"]) >= 2
|
||||
|
||||
def test_run_generate_cover_ai_frame_raises_without_mediakit(self):
|
||||
"""ai_frame cover raises RuntimeError when MediaKit is unavailable."""
|
||||
import pytest
|
||||
|
||||
def test_run_generate_cover_ai_frame(self):
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
run_generate_cover(
|
||||
plan_id="plan-001",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-001",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
assert result["type"] == "ai_frame"
|
||||
assert "image_url" in result
|
||||
assert "frame_time" in result
|
||||
assert "confidence" in result
|
||||
|
||||
def test_run_generate_cover_manual(self):
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
"""确认生成 API 单元测试.
|
||||
|
||||
覆盖 POST /tasks/{task_id}/confirm 端点:
|
||||
- 预览任务已完成 → 直接复用(mark_confirmed),秒出
|
||||
- 预览任务未完成 → 创建新任务走渲染流程
|
||||
- 预览任务不存在 → 404
|
||||
- 权限不足 → 403
|
||||
- cover_url 和 custom_title 正确传递
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
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")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
|
||||
|
||||
# ── Stub Repository ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StubGenerationTaskRepository:
|
||||
"""内存中模拟 GenerationTask 仓储"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, Any] = {}
|
||||
|
||||
def create(self, task: Any) -> Any:
|
||||
self._store[task.id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> Optional[Any]:
|
||||
return self._store.get(task_id)
|
||||
|
||||
def update(self, task: Any) -> Any:
|
||||
if task.id not in self._store:
|
||||
raise ValueError(f"GenerationTask {task.id} not found")
|
||||
self._store[task.id] = task
|
||||
return task
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[Any]:
|
||||
return [t for t in self._store.values() if t.project_id == project_id]
|
||||
|
||||
def list_by_user(self, user_id: str) -> list[Any]:
|
||||
return [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return len(
|
||||
[
|
||||
t
|
||||
for t in self._store.values()
|
||||
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
|
||||
]
|
||||
)
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return len([t for t in self._store.values() if t.status == GenerationTaskStatus.PENDING])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
|
||||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[Any]:
|
||||
return [t for t in self._store.values() if (t.source_edit_plan_id or "") == plan_id]
|
||||
|
||||
|
||||
# ── Stub Project Repository ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProject:
|
||||
id: str = "project-001"
|
||||
owner_user_id: str = "user-001"
|
||||
shared_users: list[str] = field(default_factory=list)
|
||||
name: str = "Test Project"
|
||||
|
||||
def can_access(self, user_id: str) -> bool:
|
||||
return user_id == self.owner_user_id or user_id in self.shared_users
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self) -> None:
|
||||
self._projects: dict[str, FakeProject] = {}
|
||||
|
||||
def add(self, project: FakeProject) -> None:
|
||||
self._projects[project.id] = project
|
||||
|
||||
def find_by_id(self, project_id: str) -> Optional[FakeProject]:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUser:
|
||||
id: str = "user-001"
|
||||
email: str = "test@example.com"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAuthenticatedUser:
|
||||
user: FakeUser = field(default_factory=FakeUser)
|
||||
session_id: str | None = None
|
||||
token_type: str | None = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gen_task_repo() -> StubGenerationTaskRepository:
|
||||
return StubGenerationTaskRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_repo() -> StubProjectRepository:
|
||||
repo = StubProjectRepository()
|
||||
repo.add(FakeProject())
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
project_repo: StubProjectRepository,
|
||||
) -> FastAPI:
|
||||
"""构建测试 FastAPI 应用,注入 Stub Repository"""
|
||||
from app.api.routes.generation_tasks import router
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/api/v1")
|
||||
|
||||
def override_get_current_user():
|
||||
return FakeAuthenticatedUser()
|
||||
|
||||
def override_get_generation_task_repository():
|
||||
return gen_task_repo
|
||||
|
||||
def override_get_project_repository():
|
||||
return project_repo
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
test_app.dependency_overrides[get_generation_task_repository] = override_get_generation_task_repository
|
||||
test_app.dependency_overrides[get_project_repository] = override_get_project_repository
|
||||
test_app.dependency_overrides[get_asset_library_repository] = lambda: MagicMock()
|
||||
test_app.dependency_overrides[get_asset_repository] = lambda: MagicMock()
|
||||
test_app.dependency_overrides[get_generated_video_repository] = lambda: MagicMock()
|
||||
|
||||
yield test_app
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app: FastAPI) -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_preview_task(**kwargs: Any) -> GenerationTask:
|
||||
"""创建预览任务"""
|
||||
defaults = dict(
|
||||
id="preview-task-001",
|
||||
project_id="project-001",
|
||||
asset_library_id="library-001",
|
||||
strategy_id="one_take",
|
||||
voice_library_id="",
|
||||
template_id="",
|
||||
asset_ids=["asset-1"],
|
||||
title_ids=[],
|
||||
voice_ids=[],
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
progress=100.0,
|
||||
result_count=1,
|
||||
error_message="",
|
||||
created_by_user_id="user-001",
|
||||
source_edit_plan_id="",
|
||||
asset_select_mode="all",
|
||||
is_preview=True,
|
||||
source_task_id="",
|
||||
output_width=1080,
|
||||
output_height=1920,
|
||||
cover_url="",
|
||||
custom_title="",
|
||||
video_title="",
|
||||
resolution="",
|
||||
bgm_config={},
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return GenerationTask(**defaults)
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConfirmGenerationReuse:
|
||||
"""确认生成复用预览产物。"""
|
||||
|
||||
def test_confirm_reuses_completed_preview(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""预览已完成 → 直接复用,返回同一个任务,不创建新任务"""
|
||||
preview = _make_preview_task()
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
initial_count = len(gen_task_repo._store)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={
|
||||
"output_width": 1080,
|
||||
"output_height": 1920,
|
||||
"cover_url": "https://example.com/cover.jpg",
|
||||
"custom_title": "我的视频",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
item = data["items"][0]
|
||||
|
||||
# 返回的是同一个任务(复用)
|
||||
assert item["id"] == preview.id
|
||||
# is_preview 变为 False
|
||||
assert item["is_preview"] is False
|
||||
# 分辨率更新
|
||||
assert item["output_width"] == 1080
|
||||
assert item["output_height"] == 1920
|
||||
# 封面和标题更新
|
||||
assert item["cover_url"] == "https://example.com/cover.jpg"
|
||||
assert item["custom_title"] == "我的视频"
|
||||
|
||||
# 没有创建新任务
|
||||
assert len(gen_task_repo._store) == initial_count
|
||||
|
||||
def test_confirm_updates_task_in_repo(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""确认后的任务在 repo 中被更新"""
|
||||
preview = _make_preview_task()
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={"cover_url": "https://cdn.example.com/cover.png", "custom_title": "测试标题"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 验证 repo 中的任务已被更新
|
||||
updated = gen_task_repo.get(preview.id)
|
||||
assert updated is not None
|
||||
assert updated.is_preview is False
|
||||
assert updated.cover_url == "https://cdn.example.com/cover.png"
|
||||
assert updated.custom_title == "测试标题"
|
||||
|
||||
def test_confirm_creates_new_task_when_preview_not_completed(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""预览任务未完成 → 创建新任务走渲染流程"""
|
||||
preview = _make_preview_task(status=GenerationTaskStatus.RUNNING, progress=50.0)
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 1080, "output_height": 1920},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
# 创建了新任务
|
||||
assert item["id"] != preview.id
|
||||
assert item["is_preview"] is False
|
||||
assert item["source_task_id"] == preview.id
|
||||
|
||||
|
||||
class TestConfirmGenerationErrors:
|
||||
"""确认生成的错误处理。"""
|
||||
|
||||
def test_confirm_not_found(self, client: TestClient) -> None:
|
||||
"""预览任务不存在 → 404"""
|
||||
resp = client.post(
|
||||
"/api/v1/tasks/nonexistent-task/confirm",
|
||||
json={"output_width": 1080, "output_height": 1920},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"]
|
||||
|
||||
def test_confirm_access_denied(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""权限不足 → 403"""
|
||||
preview = _make_preview_task(created_by_user_id="other-user-999")
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 1080, "output_height": 1920},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "denied" in resp.json()["detail"].lower() or "Access" in resp.json()["detail"]
|
||||
|
||||
def test_confirm_preserves_config(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""确认后任务保留预览任务的全部配置"""
|
||||
preview = _make_preview_task(
|
||||
voice_library_id="voice-001",
|
||||
template_id="tmpl-001",
|
||||
title_ids=["title-1", "title-2"],
|
||||
voice_ids=["voice-a"],
|
||||
)
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 1920, "output_height": 1080},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert item["is_preview"] is False
|
||||
assert item["output_width"] == 1920
|
||||
assert item["output_height"] == 1080
|
||||
# 默认封面和标题为空
|
||||
assert item["cover_url"] == ""
|
||||
assert item["custom_title"] == ""
|
||||
# 配置保留
|
||||
assert item["voice_library_id"] == "voice-001"
|
||||
assert item["template_id"] == "tmpl-001"
|
||||
assert item["title_ids"] == ["title-1", "title-2"]
|
||||
assert item["voice_ids"] == ["voice-a"]
|
||||
|
||||
def test_confirm_cover_and_title(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""cover_url 和 custom_title 正确传递"""
|
||||
preview = _make_preview_task()
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={
|
||||
"output_width": 1080,
|
||||
"output_height": 1920,
|
||||
"cover_url": "https://cdn.example.com/my-cover.png",
|
||||
"custom_title": "测试视频标题",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert item["cover_url"] == "https://cdn.example.com/my-cover.png"
|
||||
assert item["custom_title"] == "测试视频标题"
|
||||
|
||||
def test_confirm_default_resolution(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""不传分辨率时使用 ConfirmGenerationRequest 默认值 1080x1920"""
|
||||
preview = _make_preview_task()
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert item["output_width"] == 1080
|
||||
assert item["output_height"] == 1920
|
||||
|
||||
def test_confirm_skips_reuse_when_resolution_mismatch(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""请求的分辨率与预览渲染的分辨率不一致时,跳过复用,创建新任务"""
|
||||
preview = _make_preview_task(output_width=1080, output_height=1920)
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
initial_count = len(gen_task_repo._store)
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 720, "output_height": 1280},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
# 创建了新任务(而非复用)
|
||||
assert item["id"] != preview.id
|
||||
assert item["is_preview"] is False
|
||||
assert item["source_task_id"] == preview.id
|
||||
assert len(gen_task_repo._store) == initial_count + 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user