Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2b3811277 | |||
| 0c6ec8995f | |||
| f3b98a5ff4 | |||
| ef2f9c2a10 | |||
| d03b44c819 | |||
| 772ea958c2 | |||
| a408ca6679 | |||
| cd3d8730af | |||
| c3f3f83f6e | |||
| 153b38a62f | |||
| 0de7ad3afd | |||
| 1986a9f8a1 | |||
| 41d9713d28 | |||
| a75fa1cd93 | |||
| d9c5281f46 | |||
| 6b5ec0a2f1 | |||
| de30703d41 | |||
| 68e41f6173 | |||
| 2c4fabb585 | |||
| c79022bc2c |
@@ -0,0 +1,84 @@
|
||||
"""封面模板表 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,6 +5,7 @@ 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
|
||||
@@ -45,6 +46,10 @@ 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"],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""封面模板 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)
|
||||
@@ -312,7 +312,7 @@ def create_preview_generation_task(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id=strategy_id,
|
||||
voice_library_id="",
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
title_ids=list(request.title_ids),
|
||||
|
||||
@@ -22,6 +22,9 @@ 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,
|
||||
)
|
||||
@@ -128,6 +131,13 @@ 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:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""封面模板 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)
|
||||
@@ -161,6 +161,9 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
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,为空使用模板默认")
|
||||
|
||||
@@ -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}"
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
@@ -258,6 +258,8 @@ class CoverService:
|
||||
"1",
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 封面模板 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}`)
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
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
|
||||
}
|
||||
Regular → Executable
+4
@@ -3,6 +3,10 @@ export type {
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
ConfirmGenerationResponse,
|
||||
ConfirmGenerationTaskItem,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
Regular → Executable
+46
@@ -7,6 +7,8 @@ export interface CreatePreviewRequest {
|
||||
asset_ids: string[]
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
/** 配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材 */
|
||||
voice_library_id?: string
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
@@ -45,3 +47,47 @@ 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
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ interface ModalComponent extends React.FC<ModalProps> {
|
||||
warning: (config: ModalFuncProps) => ReturnType<typeof AntModal.warning>
|
||||
}
|
||||
|
||||
const Modal: ModalComponent = ({ className, v21 = true, children, ...rest }) => {
|
||||
const Modal: ModalComponent = ({ className, v21 = true, centered = true, children, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-modal", className)
|
||||
return (
|
||||
<AntModal className={v21Class} {...rest}>
|
||||
<AntModal className={v21Class} centered={centered} {...rest}>
|
||||
{children}
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -32,6 +33,7 @@
|
||||
gap: var(--space-sm);
|
||||
position: sticky;
|
||||
top: var(--space-md);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.xx-asset-library-item {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Select as AntSelect } from "antd"
|
||||
import { Select as AntSelect } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
@@ -24,7 +25,7 @@ const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
onCategoryChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
<Modal
|
||||
title={`批量改分类(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -43,7 +44,7 @@ const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
export default BatchClassifyModal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
@@ -12,7 +12,7 @@ export interface PlayModalProps {
|
||||
}
|
||||
|
||||
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
|
||||
<AntModal
|
||||
<Modal
|
||||
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>
|
||||
)}
|
||||
</AntModal>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
export default PlayModal
|
||||
|
||||
@@ -14,7 +14,13 @@ export interface ResultDrawerProps {
|
||||
}
|
||||
|
||||
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
|
||||
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
|
||||
<Drawer
|
||||
title={`${title} — 操作结果`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
>
|
||||
{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: 100vh;
|
||||
height: calc(100vh - 68px);
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
color: var(--text-primary, #1e293b);
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
|
||||
@@ -56,6 +56,7 @@ 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,6 +48,7 @@ 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,6 +63,7 @@ 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"
|
||||
|
||||
@@ -74,6 +74,7 @@ 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,6 +34,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
title="💬 字幕样式配置"
|
||||
placement="right"
|
||||
width={380}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
|
||||
@@ -53,6 +53,7 @@ const TransitionSelector: React.FC<TransitionSelectorProps> = ({
|
||||
title={`🎬 ${title}`}
|
||||
placement="right"
|
||||
width={480}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="transition-selector-drawer"
|
||||
|
||||
@@ -42,6 +42,7 @@ 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"
|
||||
|
||||
@@ -30,3 +30,20 @@ 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 } from "./cover"
|
||||
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG, type CoverTemplate } from "./cover"
|
||||
|
||||
/* 片段数据 */
|
||||
export { type ClipType, type ClipData } from "./clip"
|
||||
|
||||
@@ -106,6 +106,7 @@ const GeneratePage: React.FC = () => {
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
|
||||
import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker"
|
||||
import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
@@ -18,15 +18,21 @@ interface Step6CoverSettingsProps {
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
generateAutoCover,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
selectedTemplateName,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
handleSelectTemplate,
|
||||
handleEditTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
} = useStep6Cover({
|
||||
coverSettings: props.coverSettings,
|
||||
onCoverSettingsChange: props.onCoverSettingsChange,
|
||||
@@ -35,32 +41,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
})
|
||||
|
||||
// 进入 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,
|
||||
])
|
||||
const handleAutoGenerate = () => {
|
||||
generateAutoCover()
|
||||
}
|
||||
|
||||
// 预览图:优先 thumbnail_url,其次 upload_url
|
||||
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
|
||||
@@ -69,70 +52,52 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
<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 className="xx-cover-actions">
|
||||
<Button buttonType="primary" onClick={handleAutoGenerate}>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
⚙️ 封面设置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<CoverModeSelector
|
||||
mode={coverSettings.mode}
|
||||
onModeChange={setMode}
|
||||
modeLabels={COVER_MODE_LABELS}
|
||||
modeIcons={COVER_MODE_ICONS}
|
||||
/>
|
||||
<div className="xx-cover-selected-template">已选模板: {selectedTemplateName}</div>
|
||||
|
||||
{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 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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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
|
||||
@@ -0,0 +1,112 @@
|
||||
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
|
||||
@@ -2383,7 +2383,6 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* ── 加载中状态 ── */
|
||||
.xx-preview-loading {
|
||||
text-align: center;
|
||||
@@ -2889,3 +2888,196 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
Regular → Executable
+2
@@ -19,6 +19,8 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 预览任务的 task_id(用于新确认生成 API) */
|
||||
previewTaskId?: string
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
Regular → Executable
+20
@@ -6,6 +6,7 @@ import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor"
|
||||
import { confirmGeneration } from "@/api/generation"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
@@ -55,6 +56,25 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 新流程:使用确认生成 API(基于预览任务)
|
||||
if (props.previewTaskId) {
|
||||
// 解析分辨率
|
||||
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
|
||||
const outputWidth = parseInt(widthStr, 10) || 1080
|
||||
const outputHeight = parseInt(heightStr, 10) || 1920
|
||||
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
custom_title: props.titleSettings.title || "",
|
||||
})
|
||||
|
||||
startPolling()
|
||||
return
|
||||
}
|
||||
|
||||
// 旧流程:使用 EditPlan API(向后兼容)
|
||||
const payload = buildEditPlanPayload(props)
|
||||
|
||||
// 获取或创建草稿
|
||||
|
||||
@@ -35,6 +35,8 @@ interface UseStep4PreviewProps {
|
||||
videoRatio: string
|
||||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||||
voiceIds?: string[]
|
||||
/** 配音素材库ID(用户选择的上传音频或AI配音素材) */
|
||||
voiceLibraryId?: string
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
}
|
||||
@@ -84,6 +86,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
@@ -332,6 +335,7 @@ 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
|
||||
@@ -355,6 +359,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑
|
||||
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
|
||||
*/
|
||||
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 { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../../editing-planner/types"
|
||||
import { generateCover } from "@/api/template-editor"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
updateCoverTemplate,
|
||||
deleteCoverTemplate,
|
||||
} from "@/api/cover-templates"
|
||||
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
@@ -26,54 +32,56 @@ export function useStep6Cover({
|
||||
}: UseStep6CoverProps) {
|
||||
const generatingRef = useRef(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}`
|
||||
// ── 封面设置弹窗状态 ──
|
||||
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 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],
|
||||
)
|
||||
/** 弹窗打开时加载模板列表 */
|
||||
useEffect(() => {
|
||||
if (showCoverSettings) {
|
||||
loadTemplates()
|
||||
}
|
||||
}, [showCoverSettings, loadTemplates])
|
||||
|
||||
/** 调用后端智能封面 API,生成封面并更新预览 */
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (!selectedTemplate || assetIds.length === 0 || generatingRef.current) return
|
||||
if (generatingRef.current) {
|
||||
message.warning("封面正在生成中,请稍候...")
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedTemplate) {
|
||||
message.error("请先选择模板")
|
||||
return
|
||||
}
|
||||
|
||||
if (assetIds.length === 0) {
|
||||
message.error("请先选择素材")
|
||||
return
|
||||
}
|
||||
|
||||
generatingRef.current = true
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
@@ -87,28 +95,94 @@ export function useStep6Cover({
|
||||
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,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
DEFAULT_COVER_SETTINGS,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
setSelectedTemplateId,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
selectedTemplateName,
|
||||
handleSelectTemplate,
|
||||
handleEditTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
loadTemplates,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -991,19 +991,17 @@
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 1400px) {
|
||||
@media (max-width: 1200px) {
|
||||
.xx-products-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
@media (max-width: 768px) {
|
||||
.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-surface);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.task-status-tabs {
|
||||
@@ -56,10 +56,10 @@
|
||||
|
||||
/* 表格 */
|
||||
.task-table {
|
||||
background: var(--bg-surface);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.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-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
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-hover);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* 任务 ID */
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
/* 操作按钮 */
|
||||
.task-retry-btn {
|
||||
color: var(--primary-500);
|
||||
color: var(--color-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-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.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-500, #ef4444);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.task-error-icon {
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
|
||||
.task-error-message {
|
||||
color: var(--error-500, #ef4444);
|
||||
color: var(--error);
|
||||
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-surface);
|
||||
background: var(--bg-primary);
|
||||
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-500, #ef4444);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.task-error .anticon {
|
||||
|
||||
@@ -439,7 +439,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal);
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
@@ -717,13 +717,13 @@
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1400px) {
|
||||
.xx-templates-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1024px) {
|
||||
.xx-templates-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
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 (
|
||||
<AntModal
|
||||
<Modal
|
||||
title="AI 生成标题"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -116,6 +116,6 @@ export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
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 (
|
||||
<AntModal
|
||||
<Modal
|
||||
title="新建标题"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
@@ -84,6 +84,6 @@ export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -32,6 +33,7 @@
|
||||
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: 1000;
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -310,7 +310,7 @@
|
||||
.vc-edit-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay-bg);
|
||||
|
||||
@@ -351,18 +351,19 @@
|
||||
.vmat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.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-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
@@ -373,6 +374,7 @@
|
||||
.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);
|
||||
@@ -1062,6 +1064,7 @@
|
||||
.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: 1000;
|
||||
z-index: var(--z-modal);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay-bg, rgba(0, 0, 0, 0.4));
|
||||
|
||||
@@ -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}"
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
|
||||
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}"
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
|
||||
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")
|
||||
.output(output_path, vframes=1, format="image2", vcodec="mjpeg", pix_fmt="yuvj420p")
|
||||
.overwrite_output()
|
||||
.run(capture_stdout=True, capture_stderr=True)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
|
||||
@@ -1494,9 +1494,17 @@ 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:
|
||||
all_clips.extend(layer.clips)
|
||||
for clip in layer.clips:
|
||||
if clip.clip_type == "audio":
|
||||
continue
|
||||
all_clips.append(clip)
|
||||
|
||||
if not all_clips:
|
||||
raise ValueError("没有可渲染的视频片段(所有片段均为纯音频)")
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
@@ -1580,10 +1588,14 @@ class UnifiedRenderService:
|
||||
filter_parts.append(filter_str)
|
||||
preprocessed_labels.append(label)
|
||||
|
||||
# Step 2: 同层 clips 用 xfade 串联
|
||||
# Step 2: 同层 clips 用 xfade 串联(跳过纯音频层,由 mix_audio() 独立处理)
|
||||
layer_output_labels: dict[str, str] = {}
|
||||
for layer in layers:
|
||||
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
|
||||
# 音频层不参与视频 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_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]
|
||||
|
||||
@@ -157,6 +157,8 @@ class AssetAnalyzer:
|
||||
"1",
|
||||
"-q:v",
|
||||
"2", # 高质量
|
||||
"-pix_fmt",
|
||||
"yuvj420p", # mjpeg 需要全范围 YUV
|
||||
"-f",
|
||||
"image2",
|
||||
output_path,
|
||||
|
||||
@@ -1058,6 +1058,7 @@ 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,6 +1067,9 @@ 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()
|
||||
@@ -1085,11 +1089,24 @@ def _download_all_assets(
|
||||
)
|
||||
|
||||
audio_path: str | None = None
|
||||
if voice_library_id:
|
||||
# 配音下载:优先 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:
|
||||
local_audio = temp_path / "voice.mp3"
|
||||
if _download_voice_asset(voice_library_id, local_audio):
|
||||
if _download_voice_asset(effective_voice_id, local_audio):
|
||||
audio_path = str(local_audio)
|
||||
logger.info("[task_id=%s] [下载配音] 配音下载成功", task_id)
|
||||
logger.info(
|
||||
"[task_id=%s] [下载配音] 配音下载成功 (source=%s)",
|
||||
task_id,
|
||||
"voice_library_id" if voice_library_id else "voice_ids",
|
||||
)
|
||||
|
||||
return downloaded_videos, audio_path
|
||||
|
||||
@@ -1422,6 +1439,7 @@ 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:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""封面模板 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]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""封面模板 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,
|
||||
)
|
||||
@@ -600,3 +600,18 @@ 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))
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -36,6 +37,7 @@ __all__ = [
|
||||
"AssetLibrary",
|
||||
"AssetLibraryKind",
|
||||
"AssetStatus",
|
||||
"CoverTemplate",
|
||||
"ClassificationJob",
|
||||
"ClassificationJobStatus",
|
||||
"ClassificationStatus",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""封面模板领域实体。"""
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""封面模板仓储接口定义。"""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,262 @@
|
||||
"""测试 _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)
|
||||
@@ -0,0 +1,402 @@
|
||||
"""封面模板 CRUD 单元测试。
|
||||
|
||||
验证:
|
||||
1. 领域实体:创建系统/用户模板、更新、空名称校验
|
||||
2. 仓储接口:list_for_user 返回系统+用户模板
|
||||
3. API 路由:CRUD 权限检查(系统模板不可删/改)
|
||||
4. Schema:请求/响应序列化
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.cover_template import CoverTemplate
|
||||
|
||||
|
||||
class TestCoverTemplateDomain:
|
||||
"""测试封面模板领域实体。"""
|
||||
|
||||
def test_create_system_template(self):
|
||||
"""测试创建系统模板。"""
|
||||
tpl = CoverTemplate.create_system(name="默认模板")
|
||||
assert tpl.is_system is True
|
||||
assert tpl.user_id is None
|
||||
assert tpl.name == "默认模板"
|
||||
assert tpl.config == {}
|
||||
|
||||
def test_create_system_template_with_config(self):
|
||||
"""测试创建带配置的系统模板。"""
|
||||
config = {"background_color": "#ef4444", "title_text": "Hello"}
|
||||
tpl = CoverTemplate.create_system(name="大胆红", config=config)
|
||||
assert tpl.config == config
|
||||
assert tpl.name == "大胆红"
|
||||
|
||||
def test_create_user_template(self):
|
||||
"""测试创建用户自定义模板。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="我的模板")
|
||||
assert tpl.is_system is False
|
||||
assert tpl.user_id == "user-1"
|
||||
assert tpl.name == "我的模板"
|
||||
|
||||
def test_create_user_template_strips_whitespace(self):
|
||||
"""测试创建用户模板时自动去除首尾空格。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name=" 我的模板 ")
|
||||
assert tpl.name == "我的模板"
|
||||
|
||||
def test_create_user_template_empty_name_raises(self):
|
||||
"""测试空名称抛出 ValueError。"""
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
CoverTemplate.create_user(user_id="user-1", name="")
|
||||
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
CoverTemplate.create_user(user_id="user-1", name=" ")
|
||||
|
||||
def test_update_name(self):
|
||||
"""测试更新模板名称。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="旧名称")
|
||||
old_updated_at = tpl.updated_at
|
||||
tpl.update(name="新名称")
|
||||
assert tpl.name == "新名称"
|
||||
assert tpl.updated_at >= old_updated_at
|
||||
|
||||
def test_update_config(self):
|
||||
"""测试更新模板配置。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
|
||||
new_config = {"background_color": "#000", "mask_enabled": True}
|
||||
tpl.update(config=new_config)
|
||||
assert tpl.config == new_config
|
||||
|
||||
def test_update_thumbnail_url(self):
|
||||
"""测试更新缩略图 URL。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
|
||||
tpl.update(thumbnail_url="https://example.com/thumb.jpg")
|
||||
assert tpl.thumbnail_url == "https://example.com/thumb.jpg"
|
||||
|
||||
def test_update_empty_name_raises(self):
|
||||
"""测试更新空名称抛出 ValueError。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
tpl.update(name="")
|
||||
|
||||
|
||||
class TestCoverTemplateRepository:
|
||||
"""测试封面模板仓储(使用 Mock)。"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo(self):
|
||||
"""创建模拟仓储。"""
|
||||
from packages.adapters.sqlalchemy_impl.cover_template_repository import (
|
||||
SQLAlchemyCoverTemplateRepository,
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
return SQLAlchemyCoverTemplateRepository(mock_session)
|
||||
|
||||
def test_create_calls_session_add(self, mock_repo):
|
||||
"""测试 create 方法调用 session.add。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
|
||||
mock_repo.create(tpl)
|
||||
mock_repo.session.add.assert_called_once()
|
||||
mock_repo.session.commit.assert_called_once()
|
||||
|
||||
def test_get_returns_none_for_nonexistent(self, mock_repo):
|
||||
"""测试 get 方法对不存在的模板返回 None。"""
|
||||
mock_repo.session.query.return_value.filter.return_value.first.return_value = None
|
||||
result = mock_repo.get("nonexistent-id")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCoverTemplateSchema:
|
||||
"""测试封面模板 Schema。"""
|
||||
|
||||
def test_create_request_validation(self):
|
||||
"""测试创建请求的字段验证。"""
|
||||
from app.schemas.cover_template import CreateCoverTemplateRequest
|
||||
|
||||
req = CreateCoverTemplateRequest(name="测试模板")
|
||||
assert req.name == "测试模板"
|
||||
assert req.thumbnail_url == ""
|
||||
assert req.config is None
|
||||
|
||||
def test_create_request_with_config(self):
|
||||
"""测试带配置的创建请求。"""
|
||||
from app.schemas.cover_template import CoverTemplateConfig, CreateCoverTemplateRequest
|
||||
|
||||
config = CoverTemplateConfig(
|
||||
background_enabled=False,
|
||||
background_color="#ff0000",
|
||||
title_text="主标题",
|
||||
)
|
||||
req = CreateCoverTemplateRequest(name="测试", config=config)
|
||||
assert req.config.background_enabled is False
|
||||
assert req.config.background_color == "#ff0000"
|
||||
assert req.config.title_text == "主标题"
|
||||
|
||||
def test_update_request_optional_fields(self):
|
||||
"""测试更新请求所有字段可选。"""
|
||||
from app.schemas.cover_template import UpdateCoverTemplateRequest
|
||||
|
||||
req = UpdateCoverTemplateRequest()
|
||||
assert req.name is None
|
||||
assert req.config is None
|
||||
assert req.thumbnail_url is None
|
||||
|
||||
def test_response_serialization(self):
|
||||
"""测试响应序列化。"""
|
||||
from app.schemas.cover_template import CoverTemplateResponse
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
resp = CoverTemplateResponse(
|
||||
id="test-id",
|
||||
name="测试",
|
||||
thumbnail_url="",
|
||||
is_system=False,
|
||||
created_at=now,
|
||||
config={"background_color": "#000"},
|
||||
)
|
||||
assert resp.id == "test-id"
|
||||
assert resp.config["background_color"] == "#000"
|
||||
|
||||
|
||||
class TestCoverTemplateAPIPermissions:
|
||||
"""测试封面模板 API 权限控制。"""
|
||||
|
||||
def test_system_template_cannot_be_deleted(self):
|
||||
"""测试系统模板不可删除。"""
|
||||
tpl = CoverTemplate.create_system(name="系统模板")
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = tpl
|
||||
|
||||
# 模拟 API 路由中的权限检查逻辑
|
||||
template = mock_repo.get("a8b0120fd98e44788f5a6590f983d327")
|
||||
assert template is not None
|
||||
assert template.is_system is True
|
||||
# 权限检查应该阻止删除
|
||||
with pytest.raises(PermissionError):
|
||||
if template.is_system:
|
||||
raise PermissionError("系统模板不可删除")
|
||||
|
||||
def test_system_template_cannot_be_updated(self):
|
||||
"""测试系统模板不可修改。"""
|
||||
tpl = CoverTemplate.create_system(name="系统模板")
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = tpl
|
||||
|
||||
template = mock_repo.get("a8b0120fd98e44788f5a6590f983d327")
|
||||
assert template.is_system is True
|
||||
with pytest.raises(PermissionError):
|
||||
if template.is_system:
|
||||
raise PermissionError("系统模板不可修改")
|
||||
|
||||
def test_user_cannot_delete_others_template(self):
|
||||
"""测试用户不可删除他人的模板。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="别人的模板")
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = tpl
|
||||
|
||||
template = mock_repo.get(tpl.id)
|
||||
current_user_id = "user-2"
|
||||
assert template.user_id != current_user_id
|
||||
with pytest.raises(PermissionError):
|
||||
if template.user_id != current_user_id:
|
||||
raise PermissionError("无权删除该模板")
|
||||
|
||||
def test_user_can_delete_own_template(self):
|
||||
"""测试用户可以删除自己的模板。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="我的模板")
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = tpl
|
||||
|
||||
template = mock_repo.get(tpl.id)
|
||||
current_user_id = "user-1"
|
||||
assert template.user_id == current_user_id
|
||||
assert not template.is_system
|
||||
# 权限检查通过,可以删除
|
||||
mock_repo.delete(template.id)
|
||||
mock_repo.delete.assert_called_once()
|
||||
|
||||
|
||||
class TestInMemoryCoverTemplateRepository:
|
||||
"""使用 InMemory 仓储测试完整 CRUD 流程。"""
|
||||
|
||||
@pytest.fixture
|
||||
def repo(self):
|
||||
from packages.adapters.in_memory.cover_template_repository import (
|
||||
InMemoryCoverTemplateRepository,
|
||||
)
|
||||
|
||||
return InMemoryCoverTemplateRepository()
|
||||
|
||||
def test_create_and_get(self, repo):
|
||||
"""创建后能查到。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
|
||||
repo.create(tpl)
|
||||
found = repo.get(tpl.id)
|
||||
assert found is not None
|
||||
assert found.name == "测试"
|
||||
assert found.user_id == "user-1"
|
||||
|
||||
def test_get_nonexistent_returns_none(self, repo):
|
||||
"""查不到返回 None。"""
|
||||
assert repo.get("nonexistent") is None
|
||||
|
||||
def test_list_for_user_includes_system_and_own(self, repo):
|
||||
"""list_for_user 返回系统模板 + 用户自己的模板。"""
|
||||
sys_tpl = CoverTemplate.create_system(name="系统模板")
|
||||
repo.create(sys_tpl)
|
||||
|
||||
user1_tpl = CoverTemplate.create_user(user_id="user-1", name="用户1的")
|
||||
repo.create(user1_tpl)
|
||||
|
||||
user2_tpl = CoverTemplate.create_user(user_id="user-2", name="用户2的")
|
||||
repo.create(user2_tpl)
|
||||
|
||||
# user-1 应该看到系统模板 + 自己的
|
||||
user1_visible = repo.list_for_user("user-1")
|
||||
assert len(user1_visible) == 2
|
||||
names = {t.name for t in user1_visible}
|
||||
assert "系统模板" in names
|
||||
assert "用户1的" in names
|
||||
assert "用户2的" not in names
|
||||
|
||||
def test_count_for_user(self, repo):
|
||||
"""count_for_user 返回正确的数量。"""
|
||||
repo.create(CoverTemplate.create_system(name="系统1"))
|
||||
repo.create(CoverTemplate.create_system(name="系统2"))
|
||||
repo.create(CoverTemplate.create_user(user_id="user-1", name="用户1的"))
|
||||
|
||||
assert repo.count_for_user("user-1") == 3
|
||||
assert repo.count_for_user("user-2") == 2 # 只能看到2个系统模板
|
||||
|
||||
def test_update_template(self, repo):
|
||||
"""更新模板。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="旧名称")
|
||||
repo.create(tpl)
|
||||
tpl.update(name="新名称", config={"background_color": "#ff0000"})
|
||||
repo.update(tpl)
|
||||
|
||||
found = repo.get(tpl.id)
|
||||
assert found.name == "新名称"
|
||||
assert found.config["background_color"] == "#ff0000"
|
||||
|
||||
def test_update_nonexistent_raises(self, repo):
|
||||
"""更新不存在的模板抛出异常。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
|
||||
with pytest.raises(ValueError, match="不存在"):
|
||||
repo.update(tpl)
|
||||
|
||||
def test_delete_template(self, repo):
|
||||
"""删除模板。"""
|
||||
tpl = CoverTemplate.create_user(user_id="user-1", name="测试")
|
||||
repo.create(tpl)
|
||||
assert repo.delete(tpl.id) is True
|
||||
assert repo.get(tpl.id) is None
|
||||
|
||||
def test_delete_nonexistent_returns_false(self, repo):
|
||||
"""删除不存在的模板返回 False。"""
|
||||
assert repo.delete("nonexistent") is False
|
||||
|
||||
def test_list_system_templates(self, repo):
|
||||
"""list_system_templates 只返回系统模板。"""
|
||||
repo.create(CoverTemplate.create_system(name="系统1"))
|
||||
repo.create(CoverTemplate.create_system(name="系统2"))
|
||||
repo.create(CoverTemplate.create_user(user_id="user-1", name="用户1的"))
|
||||
|
||||
system = repo.list_system_templates()
|
||||
assert len(system) == 2
|
||||
assert all(t.is_system for t in system)
|
||||
|
||||
|
||||
class TestCoverTemplatesErrorHandling:
|
||||
"""测试 API 错误处理加固 - 数据库表不存在时降级处理。"""
|
||||
|
||||
def test_list_raises_operational_error_when_table_missing(self):
|
||||
"""当 cover_templates 表不存在时,repository 应抛出 OperationalError。"""
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.cover_template_repository import SQLAlchemyCoverTemplateRepository
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.side_effect = OperationalError(
|
||||
"statement", {}, Exception('relation "cover_templates" does not exist')
|
||||
)
|
||||
repo = SQLAlchemyCoverTemplateRepository(mock_session)
|
||||
|
||||
with pytest.raises(OperationalError):
|
||||
repo.list_for_user("user-1")
|
||||
|
||||
def test_route_catches_operational_error_on_list(self):
|
||||
"""路由层应捕获 OperationalError 并返回空列表。"""
|
||||
from app.api.routes.cover_templates import list_cover_templates
|
||||
from app.schemas.cover_template import ListCoverTemplatesResponse
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_for_user.side_effect = OperationalError(
|
||||
"statement", {}, Exception('relation "cover_templates" does not exist')
|
||||
)
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.user.id = "user-1"
|
||||
|
||||
# 调用路由函数,验证它不会抛出异常
|
||||
result = list_cover_templates(
|
||||
skip=0,
|
||||
limit=100,
|
||||
authenticated_user=mock_user,
|
||||
repo=mock_repo,
|
||||
)
|
||||
assert isinstance(result, ListCoverTemplatesResponse)
|
||||
assert result.items == []
|
||||
assert result.total == 0
|
||||
|
||||
def test_route_catches_programming_error_on_list(self):
|
||||
"""路由层应捕获 ProgrammingError 并返回空列表。"""
|
||||
from app.api.routes.cover_templates import list_cover_templates
|
||||
from app.schemas.cover_template import ListCoverTemplatesResponse
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_for_user.side_effect = ProgrammingError(
|
||||
"statement", {}, Exception("no such table: cover_templates")
|
||||
)
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.user.id = "user-1"
|
||||
|
||||
result = list_cover_templates(
|
||||
skip=0,
|
||||
limit=100,
|
||||
authenticated_user=mock_user,
|
||||
repo=mock_repo,
|
||||
)
|
||||
assert isinstance(result, ListCoverTemplatesResponse)
|
||||
assert result.items == []
|
||||
assert result.total == 0
|
||||
|
||||
def test_create_returns_503_when_table_missing(self):
|
||||
"""创建模板时,如果表不存在应抛出 HTTPException(503)。"""
|
||||
from app.api.routes.cover_templates import create_cover_template
|
||||
from app.schemas.cover_template import CreateCoverTemplateRequest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.create.side_effect = OperationalError(
|
||||
"statement", {}, Exception('relation "cover_templates" does not exist')
|
||||
)
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.user.id = "user-1"
|
||||
|
||||
request = CreateCoverTemplateRequest(name="test")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_cover_template(
|
||||
request=request,
|
||||
authenticated_user=mock_user,
|
||||
repo=mock_repo,
|
||||
)
|
||||
assert exc_info.value.status_code == 503
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for voice_ids fallback in _download_all_assets.
|
||||
|
||||
When voice_library_id is empty but voice_ids is non-empty, the Worker
|
||||
should fallback to voice_ids[0] as the audio asset_id.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDownloadAllAssetsVoiceIdsFallback:
|
||||
"""_download_all_assets 配音下载 fallback 逻辑测试。"""
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_voice_library_id_takes_priority(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""voice_library_id 存在时优先使用,不 fallback 到 voice_ids。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
mock_download_voice.return_value = True
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="voice-lib-123",
|
||||
task_id="task-1",
|
||||
voice_ids=["voice-ids-456"],
|
||||
)
|
||||
|
||||
assert audio is not None
|
||||
mock_download_voice.assert_called_once()
|
||||
call_args = mock_download_voice.call_args
|
||||
assert call_args[0][0] == "voice-lib-123" # first positional arg
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_fallback_to_voice_ids_when_voice_library_id_empty(
|
||||
self, mock_download_videos, mock_download_voice, tmp_path
|
||||
):
|
||||
"""voice_library_id 为空时 fallback 到 voice_ids[0]。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
mock_download_voice.return_value = True
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="", # 空字符串
|
||||
task_id="task-2",
|
||||
voice_ids=["voice-asset-789"],
|
||||
)
|
||||
|
||||
assert audio is not None
|
||||
mock_download_voice.assert_called_once()
|
||||
call_args = mock_download_voice.call_args
|
||||
assert call_args[0][0] == "voice-asset-789"
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_no_audio_when_both_empty(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""voice_library_id 和 voice_ids 都为空时,不下载音频。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="",
|
||||
task_id="task-3",
|
||||
voice_ids=[],
|
||||
)
|
||||
|
||||
assert audio is None
|
||||
mock_download_voice.assert_not_called()
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_no_audio_when_voice_ids_none(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""voice_ids 为 None 时,不触发 fallback。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="",
|
||||
task_id="task-4",
|
||||
voice_ids=None,
|
||||
)
|
||||
|
||||
assert audio is None
|
||||
mock_download_voice.assert_not_called()
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_voice_library_id_empty_string_fallback(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""voice_library_id 为空字符串且 voice_ids 有多个元素时,取第一个。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
mock_download_voice.return_value = True
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="",
|
||||
task_id="task-5",
|
||||
voice_ids=["first-id", "second-id", "third-id"],
|
||||
)
|
||||
|
||||
assert audio is not None
|
||||
call_args = mock_download_voice.call_args
|
||||
assert call_args[0][0] == "first-id"
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_backward_compat_no_voice_ids_param(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""不传 voice_ids 参数时,行为与之前一致(向后兼容)。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
mock_download_voice.return_value = True
|
||||
|
||||
# 不传 voice_ids
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="voice-lib-999",
|
||||
task_id="task-6",
|
||||
)
|
||||
|
||||
assert audio is not None
|
||||
mock_download_voice.assert_called_once_with("voice-lib-999", tmp_path / "voice.mp3")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""预览生成 voice_library_id 透传修复测试.
|
||||
|
||||
Bug: 预览生成接口硬编码 voice_library_id="",导致用户选择的上传音频
|
||||
在预览渲染时从未下载和混入,预览视频无声。
|
||||
|
||||
Fix: CreatePreviewGenerationTaskRequest 增加 voice_library_id 字段,
|
||||
预览端点透传 request.voice_library_id。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
|
||||
class TestPreviewVoiceLibraryIdSchema:
|
||||
"""CreatePreviewGenerationTaskRequest voice_library_id 字段测试."""
|
||||
|
||||
def test_default_empty_string(self):
|
||||
"""不传 voice_library_id 时默认为空字符串."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_1",
|
||||
asset_ids=["a1"],
|
||||
)
|
||||
assert req.voice_library_id == ""
|
||||
|
||||
def test_accepts_voice_library_id(self):
|
||||
"""传入 voice_library_id 正常接收."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_1",
|
||||
asset_ids=["a1"],
|
||||
voice_library_id="asset_abc123",
|
||||
)
|
||||
assert req.voice_library_id == "asset_abc123"
|
||||
|
||||
def test_accepts_empty_voice_library_id(self):
|
||||
"""显式传空字符串也正常."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_1",
|
||||
asset_ids=["a1"],
|
||||
voice_library_id="",
|
||||
)
|
||||
assert req.voice_library_id == ""
|
||||
|
||||
def test_all_fields_including_voice_library_id(self):
|
||||
"""包含 voice_library_id 的完整请求."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["a1", "a2"],
|
||||
title_ids=["t1"],
|
||||
voice_ids=["v1"],
|
||||
voice_library_id="voice_asset_456",
|
||||
video_title="测试预览",
|
||||
duration=30.0,
|
||||
video_ratio="9:16",
|
||||
bgm_config={"enabled": True, "volume": 0.5},
|
||||
)
|
||||
assert req.voice_library_id == "voice_asset_456"
|
||||
assert req.asset_ids == ["a1", "a2"]
|
||||
assert req.bgm_config["enabled"] is True
|
||||
Reference in New Issue
Block a user