Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e45a8fe775 | |||
| 800f90d8c6 | |||
| f7825e3956 | |||
| 2cca03f680 | |||
| 277ff5428e | |||
| a4c008e829 | |||
| 24b28bfc89 | |||
| 6904fce511 | |||
| 9dec75c365 | |||
| 7997f083a5 | |||
| cf195685b9 | |||
| ee4636e087 | |||
| 5678779232 | |||
| 44224cfaf6 | |||
| 98cf571ab3 | |||
| 12a9efc65d | |||
| 9c0474ef9c | |||
| 691c811cd4 | |||
| 2114b7e7ae | |||
| 7766ba1479 | |||
| ef686dde8f | |||
| 01026156ae | |||
| 42c0885813 | |||
| cd6d8615e6 | |||
| fcc7863b31 | |||
| 6af2f3c08d | |||
| 410c390cf0 | |||
| 9f85d40855 | |||
| 034eaac695 | |||
| 27dccf6591 | |||
| ac7ab679b7 | |||
| bb98620a8a | |||
| 2a0b75c007 | |||
| 0d9d4e584f | |||
| eb089fa26d | |||
| 1ec274b9f5 | |||
| 62820391c3 | |||
| 322b0a082c | |||
| 07aa03da24 | |||
| add93bb9de | |||
| 6fe3e03d6a | |||
| d73c0a77d8 |
@@ -0,0 +1,72 @@
|
||||
"""Projects is_default + partial unique index for idempotent default project (Issue #1775)
|
||||
|
||||
Revision ID: 069_project_is_default
|
||||
Revises: 068_user_profile_completed
|
||||
Create Date: 2026-09-08
|
||||
|
||||
背景:
|
||||
小程序端 getOrCreateDefaultProject 在重试/并发/前端重复调用下,
|
||||
仅靠应用层"先查再插"不保证幂等,会给同一用户重复创建默认项目。
|
||||
|
||||
改动:
|
||||
1. projects 表新增 is_default 布尔列(默认 false)
|
||||
2. 部分唯一索引 uq_projects_owner_default:(owner_user_id) WHERE is_default = true
|
||||
—— 保证每个用户至多一个默认项目
|
||||
3. 存量数据回填:把名为"默认项目"的存量项目按创建时间最早者标记为 is_default=true
|
||||
(只标记不删除;存量重复项目的清理另行确认后单独执行)
|
||||
|
||||
注意:部分唯一索引依赖 PostgreSQL,不支持 downgrade 到其他方言。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "069_project_is_default"
|
||||
down_revision = "068_user_profile_completed"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 新增 is_default 列
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column(
|
||||
"is_default",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
# 2. 存量回填:每个拥有"默认项目"的用户,只把最早创建的那一个标记为默认。
|
||||
# 用 ROW_NUMBER() 取每组第一条;非"默认项目"命名的项目不标记(保守,不动用户自建项目)。
|
||||
op.execute("""
|
||||
UPDATE projects p
|
||||
SET is_default = true
|
||||
WHERE p.id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY owner_user_id
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS rn
|
||||
FROM projects
|
||||
WHERE name = '默认项目'
|
||||
) t
|
||||
WHERE t.rn = 1
|
||||
)
|
||||
""")
|
||||
|
||||
# 3. 部分唯一索引:每用户至多一个默认项目(只约束 is_default = true 的行)
|
||||
op.execute("""
|
||||
CREATE UNIQUE INDEX uq_projects_owner_default
|
||||
ON projects (owner_user_id)
|
||||
WHERE is_default = true
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_projects_owner_default")
|
||||
op.drop_column("projects", "is_default")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Add scripts table for oral broadcast script library (Issue #1795)
|
||||
|
||||
Revision ID: 070_add_scripts
|
||||
Revises: 069_project_is_default
|
||||
Create Date: 2026-09-08
|
||||
|
||||
新建 scripts 表,支持口播文案 CRUD + 分段存储。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "070_add_scripts"
|
||||
down_revision = "069_project_is_default"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"scripts",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("segments", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("tags", 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(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_scripts_user_id", "scripts", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_scripts_user_id", table_name="scripts")
|
||||
op.drop_table("scripts")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add lipsync jobs table
|
||||
|
||||
Revision ID: 071_add_lipsync_jobs
|
||||
Revises: 070_add_scripts
|
||||
Create Date: 2026-09-08
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "071_add_lipsync_jobs"
|
||||
down_revision = "070_add_scripts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lipsync_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("video_url", sa.Text(), nullable=False),
|
||||
sa.Column("audio_url", sa.Text(), nullable=False),
|
||||
sa.Column("enable_video_loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("mediakit_task_id", sa.String(200), nullable=False, server_default="", index=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("error_code", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
# 复合索引:用户 + 状态(列表查询常用)
|
||||
op.create_index("ix_lipsync_jobs_user_status", "lipsync_jobs", ["user_id", "status"])
|
||||
# 项目 + 用户(项目维度查询)
|
||||
op.create_index("ix_lipsync_jobs_project_user", "lipsync_jobs", ["project_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_lipsync_jobs_project_user", table_name="lipsync_jobs")
|
||||
op.drop_index("ix_lipsync_jobs_user_status", table_name="lipsync_jobs")
|
||||
op.drop_table("lipsync_jobs")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add ai avatar render jobs table
|
||||
|
||||
Revision ID: 072_add_ai_avatar_render
|
||||
Revises: 071_add_lipsync_jobs
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "072_add_ai_avatar_render"
|
||||
down_revision = "071_add_lipsync_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ai_avatar_render_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("lipsync_job_id", sa.String(36), nullable=False),
|
||||
sa.Column("script_id", sa.String(36), nullable=False),
|
||||
sa.Column("b_roll_segments", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("cover_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("progress", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_cover_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_ai_avatar_render_user_status", "ai_avatar_render_jobs", ["user_id", "status"])
|
||||
op.create_index("ix_ai_avatar_render_project_user", "ai_avatar_render_jobs", ["project_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ai_avatar_render_project_user", table_name="ai_avatar_render_jobs")
|
||||
op.drop_index("ix_ai_avatar_render_user_status", table_name="ai_avatar_render_jobs")
|
||||
op.drop_table("ai_avatar_render_jobs")
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
@@ -15,7 +16,9 @@ from app.api.routes.generation_variant_plans import router as generation_variant
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.lipsync import router as lipsync_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.scripts import router as scripts_router
|
||||
from app.api.routes.share import router as share_router
|
||||
from app.api.routes.subscription import router as subscription_router
|
||||
from app.api.routes.tags import router as tags_router
|
||||
@@ -38,6 +41,11 @@ api_router.include_router(
|
||||
auth_router,
|
||||
tags=["Auth"],
|
||||
)
|
||||
api_router.include_router(
|
||||
lipsync_router,
|
||||
prefix="/lipsync",
|
||||
tags=["Lipsync"],
|
||||
)
|
||||
api_router.include_router(
|
||||
projects_router,
|
||||
prefix="/projects",
|
||||
@@ -171,3 +179,13 @@ api_router.include_router(
|
||||
internal_render_router,
|
||||
tags=["Internal"],
|
||||
)
|
||||
api_router.include_router(
|
||||
scripts_router,
|
||||
prefix="/scripts",
|
||||
tags=["ScriptLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ai_avatar_render_router,
|
||||
prefix="/ai-avatar/render",
|
||||
tags=["AI Avatar Render"],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""AI数字人渲染合成 API 路由 — #1798.
|
||||
|
||||
接口:
|
||||
POST /api/v1/ai-avatar/render 提交渲染任务
|
||||
GET /api/v1/ai-avatar/render/jobs 任务列表
|
||||
GET /api/v1/ai-avatar/render/{job_id} 任务详情
|
||||
POST /api/v1/ai-avatar/render/{job_id}/cancel 取消任务
|
||||
POST /api/v1/ai-avatar/render/{job_id}/retry 重试失败任务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
)
|
||||
from app.services.ai_avatar_render_service import (
|
||||
AiAvatarRenderError,
|
||||
AiAvatarRenderService,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService:
|
||||
return AiAvatarRenderService(db)
|
||||
|
||||
|
||||
# ── POST / — 提交渲染任务 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
|
||||
def create_render_job(
|
||||
body: CreateAiAvatarRenderRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""提交 AI 数字人渲染任务.
|
||||
|
||||
将对口型视频 + B-roll 素材 + 标题叠加 + 封面提取合成最终输出视频。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_render_job(
|
||||
user_id=current_user.id,
|
||||
lipsync_job_id=body.lipsync_job_id,
|
||||
script_id=body.script_id,
|
||||
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
|
||||
title_config=body.title_config,
|
||||
cover_config=body.cover_config,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except AiAvatarRenderError as exc:
|
||||
status_map = {
|
||||
"LipsyncJobNotFound": 404,
|
||||
"LipsyncJobNotCompleted": 400,
|
||||
"LipsyncJobNoOutput": 400,
|
||||
"ScriptNotFound": 404,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status_map.get(exc.code, 400),
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
# 异步触发渲染
|
||||
try:
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=dict)
|
||||
def list_render_jobs(
|
||||
project_id: str = Query("", description="项目 ID 过滤"),
|
||||
status: str = Query("", description="状态过滤"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""获取 AI 数字人渲染任务列表."""
|
||||
items, total = svc.list_render_jobs(
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"items": [AiAvatarRenderJobResponse.model_validate(j) for j in items],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /{job_id} — 任务详情 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=AiAvatarRenderJobResponse)
|
||||
def get_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""获取渲染任务详情."""
|
||||
job = svc.get_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /{job_id}/cancel — 取消任务 ─────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel", response_model=AiAvatarRenderJobResponse)
|
||||
def cancel_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""取消渲染任务(仅 pending 状态可取消)."""
|
||||
job = svc.cancel_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "cancelled":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending 可取消",
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /{job_id}/retry — 重试失败任务 ──────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/retry", response_model=AiAvatarRenderJobResponse)
|
||||
def retry_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""重试失败的渲染任务."""
|
||||
job = svc.retry_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"仅 failed 状态的任务可重试,当前状态: {job.status}",
|
||||
)
|
||||
|
||||
# 重新触发渲染
|
||||
try:
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
|
||||
|
||||
return job
|
||||
@@ -20,7 +20,7 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListAssetLibrariesUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
from packages.domain import AssetLibraryKind
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
@@ -120,30 +120,11 @@ def ensure_default_library(
|
||||
|
||||
kind = AssetLibraryKind(request.kind)
|
||||
|
||||
# 查找该项目下同 kind 的素材库,返回第一个
|
||||
existing = asset_library_repository.find_by_project(request.project_id)
|
||||
for lib in existing:
|
||||
if lib.kind == kind:
|
||||
return _to_asset_library_response(lib)
|
||||
|
||||
# 不存在 → 自动创建
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# Issue #1775: 幂等获取/创建——依赖唯一约束 uq_asset_libraries_project_kind,
|
||||
# 并发创建冲突时回滚重查返回已有记录,不再依赖应用层"先查后插",也不会 500。
|
||||
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
|
||||
library = AssetLibrary(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=request.project_id,
|
||||
name=default_name,
|
||||
kind=kind,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
created = asset_library_repository.create(library)
|
||||
return _to_asset_library_response(created)
|
||||
library = asset_library_repository.get_or_create_default_library(request.project_id, kind, name=default_name)
|
||||
return _to_asset_library_response(library)
|
||||
|
||||
|
||||
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型.
|
||||
|
||||
接口:
|
||||
POST /api/v1/lipsync/jobs 提交对口型任务
|
||||
GET /api/v1/lipsync/jobs 任务列表
|
||||
GET /api/v1/lipsync/jobs/{id} 任务详情
|
||||
POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态
|
||||
POST /api/v1/lipsync/jobs/{id}/cancel 取消任务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(db: Session = Depends(get_db_session)) -> LipsyncService:
|
||||
return LipsyncService(db)
|
||||
|
||||
|
||||
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs", response_model=LipsyncJobResponse, status_code=201)
|
||||
def create_lipsync_job(
|
||||
body: CreateLipsyncJobRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""提交对口型任务.
|
||||
|
||||
输入人物视频 + 驱动音频,异步生成口型对齐视频。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=current_user.id,
|
||||
video_url=body.video_url,
|
||||
audio_url=body.audio_url,
|
||||
enable_video_loop=body.enable_video_loop,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
# 创建失败(job 已记录 error),返回 502
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": str(exc),
|
||||
"request_id": exc.request_id,
|
||||
},
|
||||
) from exc
|
||||
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=dict)
|
||||
def list_lipsync_jobs(
|
||||
project_id: str = Query("", description="项目 ID 过滤"),
|
||||
status: str = Query("", description="状态过滤"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务列表."""
|
||||
items, total = svc.list_jobs(
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"items": [LipsyncJobResponse.model_validate(j) for j in items],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /jobs/{job_id} — 任务详情 ────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
|
||||
def get_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务详情."""
|
||||
job = svc.get_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /jobs/{job_id}/refresh — 刷新状态 ───────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/refresh", response_model=LipsyncJobResponse)
|
||||
def refresh_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""从 MediaKit 拉取最新状态并更新."""
|
||||
job = svc.refresh_job_status(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /jobs/{job_id}/cancel — 取消任务 ────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/cancel", response_model=LipsyncJobResponse)
|
||||
def cancel_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""取消对口型任务(仅 pending/submitted 状态可取消)."""
|
||||
job = svc.cancel_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.status != "cancelled":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending/submitted 可取消",
|
||||
)
|
||||
return job
|
||||
@@ -1,13 +1,14 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_project_repository
|
||||
from app.dependencies import get_asset_library_repository, get_project_repository
|
||||
from app.schemas.project import (
|
||||
CreateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from packages.application import (
|
||||
CreateProjectCommand,
|
||||
@@ -16,10 +17,20 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListProjectsUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibraryKind
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DefaultContextResponse(BaseModel):
|
||||
"""幂等默认上下文响应(Issue #1775):默认项目 + 各类型默认素材库 ID。"""
|
||||
|
||||
project_id: str
|
||||
image_library_id: str
|
||||
video_library_id: str
|
||||
voice_library_id: str
|
||||
|
||||
|
||||
def _to_project_response(item) -> ProjectResponse:
|
||||
return ProjectResponse(
|
||||
id=item.id,
|
||||
@@ -72,6 +83,35 @@ def create_project(
|
||||
return _to_project_response(project)
|
||||
|
||||
|
||||
@router.post("/ensure-default", response_model=DefaultContextResponse)
|
||||
def ensure_default_project_and_libraries(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
) -> DefaultContextResponse:
|
||||
"""幂等获取/创建当前用户的默认项目和三类默认素材库(Issue #1775)。
|
||||
|
||||
- 同一用户永远只有一个默认项目(部分唯一索引 uq_projects_owner_default)
|
||||
- 同一项目同 kind 永远只有一个默认素材库(唯一约束 uq_asset_libraries_project_kind)
|
||||
- 并发调用/失败重试:唯一约束冲突时返回已存在记录,不报 500
|
||||
- 项目和素材库的创建各自在仓储事务内幂等,冲突回滚后重查返回同一条
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
project = project_repository.get_or_create_default_project(user_id)
|
||||
|
||||
libraries = {}
|
||||
for kind in (AssetLibraryKind.VIDEO, AssetLibraryKind.VOICE, AssetLibraryKind.IMAGE):
|
||||
library = asset_library_repository.get_or_create_default_library(project.id, kind)
|
||||
libraries[kind] = library.id
|
||||
|
||||
return DefaultContextResponse(
|
||||
project_id=project.id,
|
||||
image_library_id=libraries[AssetLibraryKind.IMAGE],
|
||||
video_library_id=libraries[AssetLibraryKind.VIDEO],
|
||||
voice_library_id=libraries[AssetLibraryKind.VOICE],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_project(
|
||||
project_id: str,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Script (口播文案库) CRUD routes — Issue #1795."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.script import (
|
||||
CreateScriptRequest,
|
||||
ScriptListResponse,
|
||||
ScriptResponse,
|
||||
ScriptSegment,
|
||||
UpdateScriptRequest,
|
||||
)
|
||||
from app.services.script_service import ScriptNotFoundError, ScriptService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(session: Session = Depends(get_db_session)) -> ScriptService:
|
||||
return ScriptService(session)
|
||||
|
||||
|
||||
def _to_response(script) -> ScriptResponse:
|
||||
segments = script.segments or []
|
||||
return ScriptResponse(
|
||||
id=script.id,
|
||||
user_id=script.user_id,
|
||||
title=script.title,
|
||||
content=script.content,
|
||||
segments=[
|
||||
ScriptSegment(text=s.get("text", ""), duration=s.get("duration")) if isinstance(s, dict) else s
|
||||
for s in segments
|
||||
],
|
||||
tags=script.tags or [],
|
||||
created_at=script.created_at,
|
||||
updated_at=script.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ScriptListResponse)
|
||||
def list_scripts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
tag: Optional[str] = Query(None, description="按标签筛选"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptListResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
items, total = svc.list_scripts(user_id, skip=skip, limit=limit, tag=tag)
|
||||
return ScriptListResponse(
|
||||
items=[_to_response(i) for i in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ScriptResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_script(
|
||||
request: CreateScriptRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
script = svc.create_script(
|
||||
user_id=user_id,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
segments=[s.model_dump() for s in request.segments],
|
||||
tags=request.tags,
|
||||
)
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.get("/{script_id}", response_model=ScriptResponse)
|
||||
def get_script(
|
||||
script_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
script = svc.get_script(script_id, user_id)
|
||||
except ScriptNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.put("/{script_id}", response_model=ScriptResponse)
|
||||
def update_script(
|
||||
script_id: str,
|
||||
request: UpdateScriptRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
script = svc.update_script(
|
||||
script_id=script_id,
|
||||
user_id=user_id,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
segments=[s.model_dump() for s in request.segments] if request.segments is not None else None,
|
||||
tags=request.tags,
|
||||
)
|
||||
except ScriptNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.delete("/{script_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_script(
|
||||
script_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> Response:
|
||||
user_id = authenticated_user.user.id
|
||||
deleted = svc.delete_script(script_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
|
||||
return
|
||||
@@ -106,6 +106,10 @@ def list_templates(
|
||||
tag: str | None = Query(None, description="按标签筛选"),
|
||||
keyword: str | None = Query(None, description="按名称关键词搜索"),
|
||||
mode: str | None = Query(None, description="按剪辑模式筛选"),
|
||||
valid_only: bool = Query(
|
||||
False,
|
||||
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTemplatesResponse:
|
||||
@@ -116,6 +120,7 @@ def list_templates(
|
||||
tag=tag,
|
||||
keyword=keyword,
|
||||
mode=mode,
|
||||
valid_only=valid_only,
|
||||
)
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
|
||||
|
||||
@@ -36,17 +36,11 @@ from app.services.asset_segment_tracker import (
|
||||
remove_used_segment,
|
||||
)
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import (
|
||||
_calc_random_start_time,
|
||||
build_scene_segments,
|
||||
@@ -399,68 +393,42 @@ def _safe_segment_duration(value, default: float) -> float:
|
||||
|
||||
def _get_template_segments(
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
tpl_svc: EditTemplateService,
|
||||
db: Session,
|
||||
) -> list[tuple[int, float, float]]:
|
||||
"""获取模板的片段配置(顺序、最短时长、最长时长).
|
||||
|
||||
优先从新模板系统(template_clip_configs)查询,
|
||||
若不存在则回退到旧模板系统(template_segments)。
|
||||
单一数据源:模板主表为 ``templates``(用户自建,归属 user_id)/
|
||||
``edit_templates``(全局模板库),片段配置主表为 ``template_clip_configs``
|
||||
(由 ``EditTemplateService.list_clip_configs_for_editor`` 统一读取)。
|
||||
|
||||
不再使用"新表抛异常 → 降级直查配置表 → 再降级查 segments"的异常控制流,
|
||||
也不在正常请求中打印 ``ValueError: 模板不存在`` 堆栈。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 当前登录用户 ID(用于归属校验)
|
||||
tpl_svc: 模板编辑器服务
|
||||
|
||||
Returns:
|
||||
[(segment_order, duration_min, duration_max), ...] 按 order 排序
|
||||
[(segment_order, duration_min, duration_max), ...] 按 order 排序;
|
||||
模板存在但未配置片段时返回空列表。
|
||||
|
||||
Raises:
|
||||
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
|
||||
"""
|
||||
# 优先查新模板系统
|
||||
try:
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
if clip_configs:
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
|
||||
clip_configs = tpl_svc.list_clip_configs_for_editor(template_id, user_id)
|
||||
|
||||
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
|
||||
try:
|
||||
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
direct_configs = direct_repo.list_by_template(template_id)
|
||||
if direct_configs:
|
||||
result = []
|
||||
for cc in direct_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
|
||||
|
||||
# 回退到旧模板系统(template_segments表)
|
||||
try:
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = old_repo.list_segments(template_id)
|
||||
if segments:
|
||||
result = []
|
||||
for s in segments:
|
||||
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((s.segment_order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("旧模板系统查询segments失败", exc_info=True)
|
||||
|
||||
return []
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
|
||||
|
||||
def _recommended_time_conflicts(
|
||||
@@ -663,13 +631,21 @@ def create_clips_from_assets_editor(
|
||||
7. 素材时长为 0 或缺失时报 400,不创建无效片段
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 1. 查询模板 segments
|
||||
segments = _get_template_segments(template_id, tpl_svc, db)
|
||||
# 1. 查询模板片段配置。模板不存在/已删除/无权限 → 404;
|
||||
# 模板存在但确实未配置片段 → 422(配置错误,与 404 区分)。
|
||||
try:
|
||||
segments = _get_template_segments(template_id, user_id, tpl_svc)
|
||||
except TemplateNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在或无权访问",
|
||||
) from exc
|
||||
if not segments:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="模板没有片段配置,无法创建片段",
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="模板未配置片段",
|
||||
)
|
||||
|
||||
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
|
||||
|
||||
@@ -41,29 +41,33 @@ def get_draft_plan_id(
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
兼容策略:优先从新模板系统(edit_templates 表)查找,
|
||||
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
|
||||
模板读取遵循单一数据源、显式判定(不使用异常降级):
|
||||
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
|
||||
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
|
||||
模板不存在、已删除或不归属于当前用户时,一律返回 404。
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
|
||||
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get_active(template_id, user_id)
|
||||
is_global_template = tpl_svc.get_template(template_id) is not None
|
||||
if old_template is None and not is_global_template:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 2. 新系统有模板 → 用新服务创建草稿
|
||||
if tpl_svc.get_template(template_id) is not None:
|
||||
# 2. 全局模板(新系统)→ 用新服务创建草稿
|
||||
if is_global_template:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 3. 回退到旧模板系统(templates 表)
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id=user_id)
|
||||
if old_template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 4. 基于旧模板创建草稿计划
|
||||
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
|
||||
@@ -150,7 +150,7 @@ def rollback_template(
|
||||
try:
|
||||
tpl = tpl_svc.rollback_to_version(template_id, request.version)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
return EditorRollbackResponse(
|
||||
|
||||
@@ -208,6 +208,8 @@ def _create_pending_asset(
|
||||
find-or-create:prepare 阶段已按 file_hash/client_upload_id 预建的占位记录
|
||||
会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中,
|
||||
直接复用并补齐字段(避免 pre-create + complete 重复建两条)。
|
||||
|
||||
Issue #1776: 素材库计数由 asset_repository.create() 自动维护。
|
||||
"""
|
||||
# 1. 按 client_upload_id / file_hash 查找现有记录
|
||||
existing = None
|
||||
@@ -389,6 +391,7 @@ async def prepare_direct_upload(
|
||||
file_size=request.file_size,
|
||||
)
|
||||
pending_asset_id = pending.id
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
except Exception as error:
|
||||
# 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重
|
||||
logger.warning("预建 asset 占位失败,降级走 old flow: %s", error)
|
||||
@@ -474,6 +477,7 @@ async def complete_direct_upload(
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=request.project_id,
|
||||
@@ -568,6 +572,7 @@ async def upload_asset(
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
)
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=project_id,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""AI数字人渲染合成管线 API Schema — #1798."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class BRollSegment(BaseModel):
|
||||
"""B-roll 片段配置."""
|
||||
|
||||
script_segment_index: int = Field(..., ge=0, description="对应文案片段索引")
|
||||
asset_url: str = Field(..., description="B-roll 素材 URL")
|
||||
mode: str = Field(..., description="插入模式: fullscreen 或 pip")
|
||||
start_time: float = Field(..., ge=0.0, description="在对口型视频中的起始时间(秒)")
|
||||
end_time: float = Field(..., ge=0.0, description="在对口型视频中的结束时间(秒)")
|
||||
pip_position: Optional[str] = Field("bottom_right", description="pip 模式位置")
|
||||
pip_scale: Optional[float] = Field(0.3, ge=0.05, le=1.0, description="pip 模式缩放比例")
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if v not in ("fullscreen", "pip"):
|
||||
raise ValueError("mode 必须为 fullscreen 或 pip")
|
||||
return v
|
||||
|
||||
@field_validator("asset_url")
|
||||
@classmethod
|
||||
def validate_asset_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("asset_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("asset_url 必须是 HTTP/HTTPS URL")
|
||||
return v
|
||||
|
||||
@field_validator("end_time")
|
||||
@classmethod
|
||||
def validate_end_time(cls, v: float, info: Any) -> float:
|
||||
start = info.data.get("start_time", 0.0)
|
||||
if v <= start:
|
||||
raise ValueError("end_time 必须大于 start_time")
|
||||
return v
|
||||
|
||||
|
||||
class CreateAiAvatarRenderRequest(BaseModel):
|
||||
"""创建渲染任务请求."""
|
||||
|
||||
lipsync_job_id: str = Field(..., description="对口型任务 ID")
|
||||
script_id: str = Field(..., description="文案 ID")
|
||||
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
|
||||
title_config: dict[str, Any] = Field(default_factory=dict, description="标题配置")
|
||||
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
|
||||
project_id: str = Field("", description="项目 ID")
|
||||
|
||||
@field_validator("lipsync_job_id")
|
||||
@classmethod
|
||||
def validate_lipsync_job_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("lipsync_job_id 不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("script_id")
|
||||
@classmethod
|
||||
def validate_script_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("script_id 不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class AiAvatarRenderJobResponse(BaseModel):
|
||||
"""渲染任务响应."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
project_id: str
|
||||
lipsync_job_id: str
|
||||
script_id: str
|
||||
b_roll_segments: list[dict[str, Any]]
|
||||
title_config: dict[str, Any]
|
||||
cover_config: dict[str, Any]
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
submitted_at: Optional[datetime] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AiAvatarRenderProgressResponse(BaseModel):
|
||||
"""渲染进度响应."""
|
||||
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
@@ -0,0 +1,70 @@
|
||||
"""对口型 API Schema 定义 — #1796."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class LipsyncJobResponse(BaseModel):
|
||||
"""对口型任务响应."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
project_id: str
|
||||
video_url: str
|
||||
audio_url: str
|
||||
enable_video_loop: bool
|
||||
mediakit_task_id: str
|
||||
status: str
|
||||
output_video_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
error_code: str
|
||||
submitted_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreateLipsyncJobRequest(BaseModel):
|
||||
"""创建对口型任务请求."""
|
||||
|
||||
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
|
||||
audio_url: str = Field(..., description="驱动音频 URL(mp3/aac/wav/m4a/flac)")
|
||||
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
|
||||
project_id: str = Field("", description="项目 ID(可选)")
|
||||
|
||||
@field_validator("video_url")
|
||||
@classmethod
|
||||
def validate_video_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("video_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
|
||||
# 仅支持 MP4
|
||||
lower = v.lower().split("?")[0]
|
||||
if not lower.endswith(".mp4"):
|
||||
raise ValueError("video_url 仅支持 MP4 格式")
|
||||
return v
|
||||
|
||||
@field_validator("audio_url")
|
||||
@classmethod
|
||||
def validate_audio_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("audio_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
|
||||
# 支持的音频格式
|
||||
lower = v.lower().split("?")[0]
|
||||
allowed_exts = (".mp3", ".aac", ".wav", ".m4a", ".flac")
|
||||
if not any(lower.endswith(ext) for ext in allowed_exts):
|
||||
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed_exts)}")
|
||||
return v
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Script (口播文案库) Pydantic schemas — Issue #1795."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScriptSegment(BaseModel):
|
||||
"""单段文案."""
|
||||
|
||||
text: str
|
||||
duration: Optional[float] = None
|
||||
|
||||
|
||||
class ScriptResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ScriptListResponse(BaseModel):
|
||||
items: list[ScriptResponse]
|
||||
total: int = 0
|
||||
|
||||
|
||||
class CreateScriptRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = ""
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateScriptRequest(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
content: Optional[str] = None
|
||||
segments: Optional[List[ScriptSegment]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -0,0 +1,370 @@
|
||||
"""AI数字人渲染合成 Service — #1798.
|
||||
|
||||
职责:
|
||||
- 创建/查询/取消渲染任务
|
||||
- 调用 Celery 异步任务执行渲染
|
||||
- B-roll 合成 + 标题叠加 + 封面提取
|
||||
- 用户隔离
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
AiAvatarRenderJob,
|
||||
LipsyncJobModel,
|
||||
ScriptModel,
|
||||
)
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_cover_extract_command,
|
||||
build_title_drawtext_filter,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiAvatarRenderError(Exception):
|
||||
"""渲染服务异常."""
|
||||
|
||||
def __init__(self, message: str, code: str = "RenderError"):
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AiAvatarRenderService:
|
||||
"""AI数字人渲染合成 Service."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_render_job(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
lipsync_job_id: str,
|
||||
script_id: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
title_config: dict[str, Any],
|
||||
cover_config: dict[str, Any],
|
||||
project_id: str = "",
|
||||
) -> AiAvatarRenderJob:
|
||||
"""创建渲染任务.
|
||||
|
||||
Raises:
|
||||
AiAvatarRenderError: 校验失败
|
||||
"""
|
||||
# 1. 验证对口型任务
|
||||
lipsync_job = (
|
||||
self.db.query(LipsyncJobModel)
|
||||
.filter(
|
||||
LipsyncJobModel.id == lipsync_job_id,
|
||||
LipsyncJobModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if lipsync_job is None:
|
||||
raise AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
|
||||
if lipsync_job.status != "completed":
|
||||
raise AiAvatarRenderError(
|
||||
f"对口型任务状态为 {lipsync_job.status},仅 completed 状态可渲染",
|
||||
code="LipsyncJobNotCompleted",
|
||||
)
|
||||
if not lipsync_job.output_video_url:
|
||||
raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput")
|
||||
|
||||
# 2. 验证文案归属
|
||||
script = (
|
||||
self.db.query(ScriptModel)
|
||||
.filter(
|
||||
ScriptModel.id == script_id,
|
||||
ScriptModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if script is None:
|
||||
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
|
||||
|
||||
# 3. 创建渲染任务
|
||||
job_id = str(uuid.uuid4())
|
||||
job = AiAvatarRenderJob(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
lipsync_job_id=lipsync_job_id,
|
||||
script_id=script_id,
|
||||
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in b_roll_segments],
|
||||
title_config=title_config,
|
||||
cover_config=cover_config,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""获取渲染任务详情(用户隔离)."""
|
||||
return (
|
||||
self.db.query(AiAvatarRenderJob)
|
||||
.filter(
|
||||
AiAvatarRenderJob.id == job_id,
|
||||
AiAvatarRenderJob.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_render_jobs(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[AiAvatarRenderJob], int]:
|
||||
"""获取渲染任务列表(分页 + 用户隔离)."""
|
||||
query = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.user_id == user_id)
|
||||
if project_id:
|
||||
query = query.filter(AiAvatarRenderJob.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(AiAvatarRenderJob.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(AiAvatarRenderJob.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""取消渲染任务(仅 pending 状态可取消)."""
|
||||
job = self.get_render_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 重试任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def retry_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""重试失败的渲染任务."""
|
||||
job = self.get_render_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status != "failed":
|
||||
return None
|
||||
job.status = "pending"
|
||||
job.progress = 0
|
||||
job.error_message = ""
|
||||
job.output_video_url = ""
|
||||
job.output_cover_url = ""
|
||||
job.output_duration = 0.0
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 执行渲染(Celery 异步调用) ──────────────────────────────────────
|
||||
|
||||
def execute_render(self, job_id: str) -> None:
|
||||
"""执行渲染管线.
|
||||
|
||||
由 Celery 异步任务调用,流程:
|
||||
1. 下载对口型输出视频 (20%)
|
||||
2. 构建 FFmpeg 滤镜链 (40%)
|
||||
3. 执行 FFmpeg 渲染 (80%)
|
||||
4. 提取封面 (90%)
|
||||
5. 上传到 OSS (95%)
|
||||
6. 更新任务状态 (100%)
|
||||
"""
|
||||
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
|
||||
if job is None:
|
||||
logger.error("渲染任务不存在: %s", job_id)
|
||||
return
|
||||
|
||||
if job.status == "cancelled":
|
||||
logger.info("渲染任务已取消: %s", job_id)
|
||||
return
|
||||
|
||||
try:
|
||||
# 更新状态为 processing
|
||||
job.status = "processing"
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.progress = 5
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
# 获取对口型任务信息
|
||||
lipsync_job = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job.lipsync_job_id).first()
|
||||
if lipsync_job is None:
|
||||
raise AiAvatarRenderError("关联的对口型任务不存在", code="LipsyncJobNotFound")
|
||||
|
||||
# 1. 下载对口型输出视频 (20%)
|
||||
input_video_path = self._download_video(lipsync_job.output_video_url)
|
||||
job.progress = 20
|
||||
self.db.commit()
|
||||
|
||||
# 2. 构建 FFmpeg 滤镜链 (40%)
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
filter_complex = build_broll_overlay_filter(
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
video_duration=lipsync_job.output_duration,
|
||||
)
|
||||
|
||||
# 标题叠加
|
||||
title_filter = build_title_drawtext_filter(job.title_config)
|
||||
if title_filter:
|
||||
if filter_complex:
|
||||
filter_complex += f"[vout]{title_filter}[vout_titled];"
|
||||
else:
|
||||
filter_complex = f"[0:v]{title_filter}[vout_titled];"
|
||||
|
||||
# 清理末尾分号
|
||||
if filter_complex.endswith(";"):
|
||||
filter_complex = filter_complex[:-1]
|
||||
|
||||
# 最终输出标签
|
||||
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
|
||||
|
||||
job.progress = 40
|
||||
self.db.commit()
|
||||
|
||||
# 3. 执行 FFmpeg 渲染 (80%)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_video_path = os.path.join(tmpdir, "output.mp4")
|
||||
|
||||
cmd = self._build_ffmpeg_command(
|
||||
input_video=input_video_path,
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
filter_complex=filter_complex,
|
||||
final_label=final_label,
|
||||
output_path=output_video_path,
|
||||
)
|
||||
|
||||
exit_code = os.system(cmd)
|
||||
if exit_code != 0:
|
||||
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
|
||||
|
||||
job.progress = 80
|
||||
self.db.commit()
|
||||
|
||||
# 4. 提取封面 (90%)
|
||||
cover_path = ""
|
||||
if job.cover_config:
|
||||
cover_path = os.path.join(tmpdir, "cover.jpg")
|
||||
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
|
||||
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
|
||||
cover_exit = os.system(cover_cmd)
|
||||
if cover_exit != 0:
|
||||
logger.warning("封面提取失败,跳过: %s", cover_cmd)
|
||||
cover_path = ""
|
||||
|
||||
job.progress = 90
|
||||
self.db.commit()
|
||||
|
||||
# 5. 上传到 OSS (95%)
|
||||
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
|
||||
job.output_video_url = output_video_url
|
||||
|
||||
if cover_path:
|
||||
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
|
||||
job.output_cover_url = output_cover_url
|
||||
|
||||
# 获取输出视频时长
|
||||
job.output_duration = lipsync_job.output_duration
|
||||
job.progress = 95
|
||||
self.db.commit()
|
||||
|
||||
# 6. 完成
|
||||
job.status = "completed"
|
||||
job.progress = 100
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.info("渲染任务完成: %s", job_id)
|
||||
|
||||
except AiAvatarRenderError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.error("渲染任务失败 [%s]: %s", job_id, exc)
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"渲染异常: {str(exc)}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.exception("渲染任务异常 [%s]", job_id)
|
||||
|
||||
def _download_video(self, url: str) -> str:
|
||||
"""下载视频到临时文件."""
|
||||
import httpx
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
||||
try:
|
||||
with httpx.Client(timeout=120) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
tmp.write(resp.content)
|
||||
return tmp.name
|
||||
except Exception:
|
||||
if os.path.exists(tmp.name):
|
||||
os.unlink(tmp.name)
|
||||
raise
|
||||
|
||||
def _build_ffmpeg_command(
|
||||
self,
|
||||
*,
|
||||
input_video: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
filter_complex: str,
|
||||
final_label: Optional[str],
|
||||
output_path: str,
|
||||
) -> str:
|
||||
"""构建 FFmpeg 命令."""
|
||||
# 输入文件
|
||||
inputs = f"-i {input_video}"
|
||||
for seg in b_roll_segments:
|
||||
asset_url = seg.get("asset_url", "")
|
||||
if asset_url:
|
||||
inputs += f" -i {asset_url}"
|
||||
|
||||
# 滤镜
|
||||
if filter_complex and final_label:
|
||||
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
|
||||
elif filter_complex:
|
||||
filter_arg = f'-filter_complex "{filter_complex}"'
|
||||
else:
|
||||
filter_arg = ""
|
||||
|
||||
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset fast -crf 23 -y {output_path}"
|
||||
|
||||
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
|
||||
"""上传文件到 OSS,返回 URL.
|
||||
|
||||
简化实现,实际应调用 OSS SDK。
|
||||
"""
|
||||
# TODO: 集成实际 OSS 上传
|
||||
logger.info("上传文件到 OSS: %s -> %s", local_path, oss_key)
|
||||
return f"https://oss.example.com/{oss_key}"
|
||||
@@ -784,19 +784,27 @@ class EditPlanService:
|
||||
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration
|
||||
|
||||
# #1764:从 plan config 读取节奏模板
|
||||
rhythm_template = None
|
||||
if plan and hasattr(plan, "config") and plan.config:
|
||||
rhythm_template = plan.config.get("rhythm_template")
|
||||
|
||||
# #1768:先获取素材时长,传入 plan_clip_durations 用于最大片段钳制
|
||||
asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
durations = self.get_asset_durations(asset_ids)
|
||||
asset_durations_for_plan = [durations.get(c.asset_id, 0.0) for c in clips]
|
||||
|
||||
target = plan_clip_durations(
|
||||
len(clips),
|
||||
voice,
|
||||
transition_effects=[c.transition_effect for c in clips],
|
||||
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
|
||||
rhythm_template=rhythm_template,
|
||||
asset_durations=asset_durations_for_plan,
|
||||
)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
# 素材时长(短素材起点钳 0)
|
||||
asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
durations = self.get_asset_durations(asset_ids)
|
||||
|
||||
clips_data: list[dict] = []
|
||||
for i, c in enumerate(clips):
|
||||
dur = float(target[i])
|
||||
@@ -907,6 +915,36 @@ class EditPlanService:
|
||||
)
|
||||
plan_ids.append(variant.id)
|
||||
|
||||
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
|
||||
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
|
||||
|
||||
clip_count = 0
|
||||
if voice_durations and len(voice_durations) > 0:
|
||||
# 从源 plan 获取片段数
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and hasattr(source_plan, "clips"):
|
||||
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
|
||||
|
||||
rhythm_templates_for_variants = []
|
||||
if clip_count > 0:
|
||||
for idx in range(len(plan_ids)):
|
||||
# 每个变体用不同的 seed 选择节奏模板
|
||||
variant_seed = rng.randint(0, 999999)
|
||||
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
|
||||
rhythm_templates_for_variants.append(template)
|
||||
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
|
||||
|
||||
# #1767:BGM 池差异化分配(让批量变体使用不同 BGM / 段落 / 音量)
|
||||
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
|
||||
|
||||
source_bgm_config = {}
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and source_plan.config:
|
||||
source_bgm_config = source_plan.config.get("bgm", {}) or {}
|
||||
|
||||
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in plan_ids]
|
||||
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
|
||||
|
||||
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
|
||||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||||
|
||||
@@ -916,8 +954,29 @@ class EditPlanService:
|
||||
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
|
||||
if idx == 0:
|
||||
perturbation["hflip"] = False
|
||||
self.update_plan_config(pid, {"visual_perturbation": perturbation})
|
||||
logger.info("变体 %d 视觉扰动: plan=%s perturbation=%s", idx, pid, perturbation)
|
||||
config_update = {"visual_perturbation": perturbation}
|
||||
# #1764:写入节奏模板
|
||||
if idx < len(rhythm_templates_for_variants):
|
||||
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
|
||||
# #1765:写入像素级扰动滤镜
|
||||
from packages.domain.variant_plan_selector import generate_pixel_perturbation
|
||||
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
config_update["pixel_perturbation"] = pixel_pert
|
||||
# #1767:写入 BGM 池分配(覆盖 bgm 配置中的 preset_id / audio_offset / volume_adjust_db)
|
||||
if idx < len(bgm_pool_assignments):
|
||||
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
|
||||
existing_bgm.update(bgm_pool_assignments[idx])
|
||||
config_update["bgm"] = existing_bgm
|
||||
self.update_plan_config(pid, config_update)
|
||||
logger.info(
|
||||
"变体 %d 视觉扰动+像素扰动+BGM池: plan=%s vis=%s pix=%s bgm=%s",
|
||||
idx,
|
||||
pid,
|
||||
perturbation,
|
||||
pixel_pert,
|
||||
bgm_pool_assignments[idx] if idx < len(bgm_pool_assignments) else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
|
||||
|
||||
@@ -1153,7 +1212,7 @@ class EditPlanService:
|
||||
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
|
||||
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
|
||||
logger.info(
|
||||
"can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d",
|
||||
"can_generate 诊断: plan=%s status=%s total_clips=%d clips_with_asset=%d config_asset_ids_count=%d",
|
||||
plan_id,
|
||||
plan.status,
|
||||
len(clips),
|
||||
@@ -1165,7 +1224,7 @@ class EditPlanService:
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
if config_asset_ids:
|
||||
logger.warning(
|
||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配",
|
||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材,从 config.asset_ids(%d个) 自动分配",
|
||||
plan_id,
|
||||
len(clips),
|
||||
len(config_asset_ids),
|
||||
@@ -1197,7 +1256,7 @@ class EditPlanService:
|
||||
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
|
||||
else:
|
||||
logger.warning(
|
||||
"can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复",
|
||||
"can_generate 失败: plan=%s clips=%d 均无素材,且 config.asset_ids 为空,无法自动修复",
|
||||
plan_id,
|
||||
len(clips),
|
||||
)
|
||||
|
||||
@@ -34,6 +34,17 @@ from packages.domain.template_clip_converter import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateNotFoundError(Exception):
|
||||
"""模板不存在、已删除或当前用户无权访问.
|
||||
|
||||
与"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
|
||||
"""
|
||||
|
||||
def __init__(self, template_id: str) -> None:
|
||||
self.template_id = template_id
|
||||
super().__init__(f"模板不存在: {template_id}")
|
||||
|
||||
|
||||
class EditTemplateService:
|
||||
"""模板管理服务
|
||||
|
||||
@@ -217,7 +228,14 @@ class EditTemplateService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""列出模板的片段配置"""
|
||||
"""列出模板的片段配置
|
||||
|
||||
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
|
||||
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
|
||||
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
|
||||
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
|
||||
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
|
||||
"""
|
||||
# 确保模板存在
|
||||
self.get_template_or_raise(template_id)
|
||||
return self._clip_config_repo.list_by_template(
|
||||
@@ -227,6 +245,52 @@ class EditTemplateService:
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def list_clip_configs_for_editor(
|
||||
self,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""编辑器读取模板片段配置的单一数据源入口.
|
||||
|
||||
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
|
||||
模板主表按双表现状显式判定,不使用 try/except 控制流:
|
||||
|
||||
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
|
||||
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
|
||||
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 当前登录用户 ID(用于旧表模板归属校验)
|
||||
|
||||
Raises:
|
||||
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
|
||||
"""
|
||||
# 1) 用户自建模板(旧表 templates,归属 user_id)
|
||||
if self._clip_config_repo.template_owned_by(template_id, user_id):
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
|
||||
if self._template_repo.get(template_id) is not None:
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 3) 两表都没有:不存在 / 已删除 / 无权限
|
||||
raise TemplateNotFoundError(template_id)
|
||||
|
||||
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""获取片段配置详情"""
|
||||
return self._clip_config_repo.get(config_id)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
|
||||
|
||||
职责:
|
||||
- 创建/查询/取消对口型任务
|
||||
- 调用 MediaKit 客户端提交异步任务
|
||||
- 轮询更新任务状态
|
||||
- 用户隔离(每个用户只能操作自己的任务)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.services.mediakit_client import (
|
||||
STATUS_COMPLETED,
|
||||
STATUS_FAILED,
|
||||
STATUS_RUNNING,
|
||||
MediaKitClient,
|
||||
MediaKitError,
|
||||
get_mediakit_client,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LipsyncService:
|
||||
"""对口型任务 Service."""
|
||||
|
||||
def __init__(self, db: Session, client: Optional[MediaKitClient] = None):
|
||||
self.db = db
|
||||
self.client = client or get_mediakit_client()
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = False,
|
||||
project_id: str = "",
|
||||
) -> LipsyncJobModel:
|
||||
"""创建对口型任务并提交到 MediaKit.
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
# 1. 创建数据库记录
|
||||
job_id = str(uuid.uuid4())
|
||||
job = LipsyncJobModel(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
# 2. 提交到 MediaKit
|
||||
try:
|
||||
result = self.client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
client_token=job_id, # 幂等控制
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
logger.error("提交对口型任务失败: %s", exc)
|
||||
raise
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""获取任务详情(用户隔离)."""
|
||||
return (
|
||||
self.db.query(LipsyncJobModel)
|
||||
.filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_jobs(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[LipsyncJobModel], int]:
|
||||
"""获取任务列表(分页 + 用户隔离)."""
|
||||
query = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.user_id == user_id)
|
||||
if project_id:
|
||||
query = query.filter(LipsyncJobModel.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(LipsyncJobModel.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(LipsyncJobModel.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── 更新任务状态(轮询) ──────────────────────────────────────────────
|
||||
|
||||
def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""从 MediaKit 拉取最新状态并更新本地记录.
|
||||
|
||||
Returns:
|
||||
更新后的 Job,或 None(任务不存在/不属于该用户)
|
||||
"""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
# 终态不需要再轮询
|
||||
if job.status in (STATUS_COMPLETED, "failed"):
|
||||
return job
|
||||
|
||||
# 未提交的任务不轮询
|
||||
if not job.mediakit_task_id:
|
||||
return job
|
||||
|
||||
try:
|
||||
status_data = self.client.get_task_status(job.mediakit_task_id)
|
||||
except MediaKitError as exc:
|
||||
logger.error("轮询对口型任务状态失败 [%s]: %s", job_id, exc)
|
||||
return job
|
||||
|
||||
mk_status = status_data.get("status", STATUS_RUNNING)
|
||||
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = result.get("video_url", "")
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
# running 状态只更新时间戳
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""取消任务(仅 pending/submitted 状态可取消)."""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
|
||||
return job
|
||||
@@ -0,0 +1,243 @@
|
||||
"""MediaKit 客户端 — 封装火山引擎 AI MediaKit 对口型 API.
|
||||
|
||||
接口文档:https://docs.volcengine.com/docs/6448/2656064
|
||||
|
||||
异步任务流程:
|
||||
1. POST /api/v1/tools/lip-sync 提交对口型任务 → 返回 task_id
|
||||
2. GET /api/v1/tasks/{task_id} 轮询任务状态 → running/completed/failed
|
||||
3. completed 时 result.video_url 为口型对齐视频(临时链接 24h 有效)
|
||||
|
||||
设计原则:
|
||||
- API Key 从配置读取(settings.mediakit_api_key)
|
||||
- 未配置 API Key 时所有方法返回降级响应,不阻塞主流程
|
||||
- HTTP 超时/网络异常统一包装为 MediaKitError
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.config import get_api_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 任务状态常量 ──────────────────────────────────────────────────────────
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
|
||||
class MediaKitError(Exception):
|
||||
"""MediaKit API 调用异常."""
|
||||
|
||||
def __init__(self, message: str, code: str = "", request_id: str = ""):
|
||||
self.code = code
|
||||
self.request_id = request_id
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MediaKitClient:
|
||||
"""火山引擎 AI MediaKit 对口型 API 客户端.
|
||||
|
||||
用法:
|
||||
client = get_mediakit_client()
|
||||
result = client.submit_lipsync(video_url="...", audio_url="...")
|
||||
task_id = result["task_id"]
|
||||
|
||||
status = client.get_task_status(task_id)
|
||||
# {"status": "completed", "result": {"video_url": "...", "duration": 60.5}}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_api_settings()
|
||||
self._api_key = settings.mediakit_api_key
|
||||
self._base_url = settings.mediakit_base_url.rstrip("/")
|
||||
self._timeout = settings.mediakit_timeout
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否已配置 API Key(未配置时自动降级)."""
|
||||
return bool(self._api_key)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── 提交对口型任务 ────────────────────────────────────────────────────
|
||||
|
||||
def submit_lipsync(
|
||||
self,
|
||||
*,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = False,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_args: Optional[str] = None,
|
||||
client_token: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""提交视频口型对齐任务.
|
||||
|
||||
Args:
|
||||
video_url: 人物视频 URL(MP4,≤30min,单人真人)
|
||||
audio_url: 驱动音频 URL(mp3/aac/wav/m4a/flac)
|
||||
enable_video_loop: 音频长于视频时是否循环画面
|
||||
callback_url: 任务完成回调 URL
|
||||
callback_args: 回调时原样返回的自定义参数
|
||||
client_token: 幂等控制 token
|
||||
|
||||
Returns:
|
||||
{"success": True, "task_id": "...", "request_id": "..."}
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"video_url": video_url,
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
if enable_video_loop:
|
||||
payload["enable_video_loop"] = True
|
||||
if callback_url:
|
||||
payload["callback_url"] = callback_url
|
||||
if callback_args:
|
||||
payload["callback_args"] = callback_args[:512] # API 限制 512 字节
|
||||
if client_token:
|
||||
payload["client_token"] = client_token[:64] # API 限制 64 字符
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(
|
||||
f"{self._base_url}/tools/lip-sync",
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text[:500]
|
||||
raise MediaKitError(
|
||||
f"MediaKit API HTTP {exc.response.status_code}: {body}",
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
|
||||
except Exception as exc:
|
||||
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
|
||||
|
||||
if not data.get("success"):
|
||||
error = data.get("error", {})
|
||||
raise MediaKitError(
|
||||
error.get("message", "提交任务失败"),
|
||||
code=error.get("code", "SubmitFailed"),
|
||||
request_id=data.get("request_id", ""),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": data["task_id"],
|
||||
"request_id": data.get("request_id", ""),
|
||||
}
|
||||
|
||||
# ── 查询任务状态 ──────────────────────────────────────────────────────
|
||||
|
||||
def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
"""查询异步任务状态和结果.
|
||||
|
||||
Args:
|
||||
task_id: 提交任务时返回的任务 ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True,
|
||||
"task_id": "...",
|
||||
"status": "running" | "completed" | "failed",
|
||||
"result": {"video_url": "...", "duration": 60.5} | None,
|
||||
"error": {"code": "...", "message": "..."} | None,
|
||||
"created_at": 1777291767,
|
||||
"finished_at": 1777291851 | None,
|
||||
"expires_at": 1777464650 | None,
|
||||
}
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.get(
|
||||
f"{self._base_url}/tasks/{task_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text[:500]
|
||||
raise MediaKitError(
|
||||
f"MediaKit API HTTP {exc.response.status_code}: {body}",
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
|
||||
except Exception as exc:
|
||||
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
|
||||
|
||||
if not data.get("success"):
|
||||
error = data.get("error", {})
|
||||
raise MediaKitError(
|
||||
error.get("message", "查询任务失败"),
|
||||
code=error.get("code", "QueryFailed"),
|
||||
request_id=data.get("request_id", ""),
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"task_id": data.get("task_id", task_id),
|
||||
"status": data.get("status", STATUS_RUNNING),
|
||||
"result": data.get("result"),
|
||||
"created_at": data.get("created_at"),
|
||||
"finished_at": data.get("finished_at"),
|
||||
"expires_at": data.get("expires_at"),
|
||||
}
|
||||
|
||||
# 失败时提取错误信息
|
||||
if data.get("status") == STATUS_FAILED:
|
||||
error_obj = data.get("error", {})
|
||||
result["error"] = {
|
||||
"code": error_obj.get("code", "TaskFailed"),
|
||||
"message": error_obj.get("message", "任务执行失败"),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 单例 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_client: Optional[MediaKitClient] = None
|
||||
|
||||
|
||||
def get_mediakit_client() -> MediaKitClient:
|
||||
"""获取 MediaKit 客户端单例."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = MediaKitClient()
|
||||
return _client
|
||||
|
||||
|
||||
def reset_mediakit_client() -> None:
|
||||
"""重置客户端(测试用)."""
|
||||
global _client
|
||||
_client = None
|
||||
@@ -0,0 +1,109 @@
|
||||
"""ScriptService — Issue #1795 口播文案库 CRUD.
|
||||
|
||||
纯 Service 层封装,routes 直接调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import ScriptModel
|
||||
|
||||
|
||||
class ScriptNotFoundError(Exception):
|
||||
"""文案不存在或不属于当前用户."""
|
||||
|
||||
|
||||
class ScriptService:
|
||||
"""口播文案 CRUD."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
# ── list ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_scripts(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
tag: Optional[str] = None,
|
||||
) -> tuple[list[ScriptModel], int]:
|
||||
"""返回 (items, total)."""
|
||||
q = self.db.query(ScriptModel).filter(ScriptModel.user_id == user_id)
|
||||
if tag:
|
||||
# JSON 数组包含查询
|
||||
q = q.filter(ScriptModel.tags.contains([tag]))
|
||||
total = q.count()
|
||||
items = q.order_by(ScriptModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── create ────────────────────────────────────────────────────────────
|
||||
|
||||
def create_script(
|
||||
self,
|
||||
user_id: str,
|
||||
title: str,
|
||||
content: str = "",
|
||||
segments: list | None = None,
|
||||
tags: list | None = None,
|
||||
) -> ScriptModel:
|
||||
script = ScriptModel(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
segments=segments if segments is not None else [],
|
||||
tags=tags if tags is not None else [],
|
||||
)
|
||||
self.db.add(script)
|
||||
self.db.commit()
|
||||
self.db.refresh(script)
|
||||
return script
|
||||
|
||||
# ── get ───────────────────────────────────────────────────────────────
|
||||
|
||||
def get_script(self, script_id: str, user_id: str) -> ScriptModel:
|
||||
script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
|
||||
if script is None:
|
||||
raise ScriptNotFoundError(f"Script {script_id} not found")
|
||||
return script
|
||||
|
||||
# ── update ────────────────────────────────────────────────────────────
|
||||
|
||||
def update_script(
|
||||
self,
|
||||
script_id: str,
|
||||
user_id: str,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
segments: Optional[list] = None,
|
||||
tags: Optional[list] = None,
|
||||
) -> ScriptModel:
|
||||
script = self.get_script(script_id, user_id)
|
||||
if title is not None:
|
||||
script.title = title
|
||||
if content is not None:
|
||||
script.content = content
|
||||
if segments is not None:
|
||||
script.segments = segments
|
||||
if tags is not None:
|
||||
script.tags = tags
|
||||
script.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(script)
|
||||
return script
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────
|
||||
|
||||
def delete_script(self, script_id: str, user_id: str) -> bool:
|
||||
script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
|
||||
if script is None:
|
||||
return False
|
||||
self.db.delete(script)
|
||||
self.db.commit()
|
||||
return True
|
||||
@@ -39,6 +39,9 @@ from packages.domain.video_filter_builder import (
|
||||
)
|
||||
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
|
||||
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_title_drawtext_filter,
|
||||
)
|
||||
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
|
||||
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
|
||||
from packages.domain.video_filter_builder import has_audio as _has_audio_func
|
||||
@@ -248,6 +251,27 @@ class VideoComposeService:
|
||||
transitions=[c.transition_effect for c in ready_clips],
|
||||
)
|
||||
|
||||
# ── #1789 标题 drawtext 滤镜叠加 ──
|
||||
# 从 plan.config 读取 title_config,生成 drawtext 滤镜链入 filter_complex
|
||||
title_cfg = (plan.config or {}).get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
# 同时兼容 plan.config["title_config"](API 回写路径)
|
||||
if not title_cfg.get("text") and not title_cfg.get("content"):
|
||||
title_cfg_alt = (plan.config or {}).get("title_config", {}) or {}
|
||||
if isinstance(title_cfg_alt, dict) and (title_cfg_alt.get("text") or title_cfg_alt.get("content")):
|
||||
title_cfg = title_cfg_alt
|
||||
drawtext_filter = build_title_drawtext_filter(title_cfg, output_width, output_height)
|
||||
if drawtext_filter:
|
||||
# 将最终输出标签从 [outv] 改为 [composed],再链入 drawtext → [outv]
|
||||
filter_complex = filter_complex.replace("[outv]", "[composed]")
|
||||
filter_complex += f";[composed]{drawtext_filter}[outv]"
|
||||
logger.info(
|
||||
"[#1789] 标题 drawtext 滤镜已注入: plan_id=%s text=%s",
|
||||
plan_id,
|
||||
(title_cfg.get("text") or title_cfg.get("content") or "")[:30],
|
||||
)
|
||||
|
||||
# 构建完整命令
|
||||
command: list[str] = ["ffmpeg", "-y"]
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Celery 异步任务模块."""
|
||||
@@ -0,0 +1,48 @@
|
||||
"""AI数字人渲染 Celery 异步任务 — #1798."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="ai_avatar_render.execute", max_retries=2)
|
||||
def execute_ai_avatar_render(self, job_id: str) -> dict:
|
||||
"""执行 AI 数字人渲染管线.
|
||||
|
||||
进度更新:
|
||||
- 0%: 任务开始
|
||||
- 20%: 下载对口型视频完成
|
||||
- 40%: 滤镜链构建完成
|
||||
- 80%: FFmpeg 渲染完成
|
||||
- 95%: 上传 OSS 完成
|
||||
- 100%: 任务完成
|
||||
"""
|
||||
logger.info("开始执行渲染任务: %s", job_id)
|
||||
self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id})
|
||||
|
||||
try:
|
||||
# 获取数据库 session
|
||||
db_gen = get_db_session()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
service = AiAvatarRenderService(db)
|
||||
service.execute_render(job_id)
|
||||
finally:
|
||||
try:
|
||||
next(db_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
return {"status": "completed", "job_id": job_id}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("渲染任务执行异常 [%s]: %s", job_id, exc)
|
||||
self.update_state(state="FAILED", meta={"progress": 0, "error": str(exc)})
|
||||
raise
|
||||
@@ -5,10 +5,22 @@ import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { AssetLibraryItem } from "./types"
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get("/asset-libraries")
|
||||
return response.data.items || []
|
||||
/**
|
||||
* 获取当前用户的素材库
|
||||
*
|
||||
* @param kind 可选,按素材库类型过滤(video/voice/image)。
|
||||
* 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的
|
||||
* kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库
|
||||
* 混进来(#1777:视频选择器只展示视频库)。
|
||||
*/
|
||||
export const getAssetLibraries = async (
|
||||
kind?: AssetLibraryItem["kind"],
|
||||
): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", {
|
||||
params: kind ? { kind } : undefined,
|
||||
})
|
||||
const items = response.data.items || []
|
||||
return kind ? items.filter((lib) => lib.kind === kind) : items
|
||||
}
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
|
||||
@@ -55,6 +55,19 @@ apiClient.interceptors.response.use(
|
||||
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean
|
||||
/**
|
||||
* 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。
|
||||
* 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」,
|
||||
* 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。
|
||||
*/
|
||||
_silentErrorToast?: boolean
|
||||
}
|
||||
|
||||
// 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗
|
||||
if (originalRequest?._silentErrorToast) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(error as any).__msgShown = true
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
|
||||
@@ -12,17 +12,25 @@ import type {
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取模板列表 */
|
||||
/** 获取模板列表
|
||||
*
|
||||
* valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用,
|
||||
* 避免选中无片段配置的模板导致 from-assets 400,#1769/#1772);
|
||||
* 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。
|
||||
* 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。
|
||||
*/
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
validOnly?: boolean
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
...(params?.validOnly ? { valid_only: true } : {}),
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function createClipsFromAssets(
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: { signal?: AbortSignal; silentErrorToast?: boolean },
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
asset_ids: assetIds,
|
||||
@@ -104,7 +104,12 @@ export async function createClipsFromAssets(
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
body,
|
||||
{ timeout: 60000, signal: opts?.signal },
|
||||
{
|
||||
timeout: 60000,
|
||||
signal: opts?.signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
|
||||
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -43,11 +43,17 @@ export async function updateEditPlanClips(
|
||||
templateId: string,
|
||||
clips: EditPlanClipInput[],
|
||||
signal?: AbortSignal,
|
||||
/** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */
|
||||
silentErrorToast?: boolean,
|
||||
): Promise<{ count: number }> {
|
||||
const response = await apiClient.put(
|
||||
`/templates/${templateId}/editor/clips`,
|
||||
{ clips },
|
||||
{ signal },
|
||||
{
|
||||
signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast)
|
||||
...(silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
|
||||
/** 导航项类型 */
|
||||
@@ -88,6 +89,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "ai-avatar",
|
||||
label: "AI数字人",
|
||||
path: "/app/ai-avatar",
|
||||
icon: React.createElement(UserOutlined),
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
@@ -131,6 +138,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "ai-avatar",
|
||||
label: "AI数字人",
|
||||
path: "/app/ai-avatar",
|
||||
icon: React.createElement(UserOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑模板",
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* AI数字人页面 — 5 列水平面板布局 (#1798)
|
||||
*/
|
||||
|
||||
/* ── 页面容器 ── */
|
||||
.ai-avatar-page {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
min-width: 1280px;
|
||||
overflow-x: auto;
|
||||
background: var(--bg-primary, #0f0f0f);
|
||||
}
|
||||
|
||||
/* ── 面板通用 ── */
|
||||
.ai-avatar-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color, #2a2a2a);
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-avatar-panel:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.ai-avatar-panel.collapsed {
|
||||
width: 48px !important;
|
||||
min-width: 48px !important;
|
||||
}
|
||||
|
||||
.ai-avatar-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-bottom: 1px solid var(--border-color, #2a2a2a);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-avatar-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ai-avatar-panel-header .collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary, #999);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.ai-avatar-panel.collapsed .collapse-btn {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ai-avatar-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ai-avatar-panel.collapsed .ai-avatar-panel-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-avatar-panel.collapsed .ai-avatar-panel-header h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── 面板宽度 ── */
|
||||
.ai-avatar-panel.panel-avatar-video {
|
||||
width: 20%;
|
||||
}
|
||||
.ai-avatar-panel.panel-voice-clone {
|
||||
width: 20%;
|
||||
}
|
||||
.ai-avatar-panel.panel-script-lipsync {
|
||||
width: 25%;
|
||||
}
|
||||
.ai-avatar-panel.panel-title-config {
|
||||
width: 17.5%;
|
||||
}
|
||||
.ai-avatar-panel.panel-cover-generate {
|
||||
width: 17.5%;
|
||||
}
|
||||
|
||||
/* ── 上传拖拽区 ── */
|
||||
.ai-avatar-upload-zone {
|
||||
border: 2px dashed var(--border-color, #2a2a2a);
|
||||
border-radius: 8px;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ai-avatar-upload-zone:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.ai-avatar-upload-zone .upload-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── 视频/音频预览 ── */
|
||||
.ai-avatar-media-preview {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ai-avatar-media-preview video,
|
||||
.ai-avatar-media-preview audio {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-avatar-media-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
|
||||
/* ── 文案输入 ── */
|
||||
.ai-avatar-script-editor {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
background: var(--bg-tertiary, #222);
|
||||
border: 1px solid var(--border-color, #2a2a2a);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ai-avatar-script-editor:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.ai-avatar-script-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ai-avatar-script-tabs button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color, #2a2a2a);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ai-avatar-script-tabs button.active {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.ai-avatar-script-word-count {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 对口型预览 ── */
|
||||
.ai-avatar-lipsync-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color, #2a2a2a);
|
||||
}
|
||||
|
||||
.ai-avatar-lipsync-section h4 {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #999);
|
||||
margin: 0 0 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ai-avatar-lipsync-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── 声音克隆状态 ── */
|
||||
.ai-avatar-clone-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ai-avatar-clone-status.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.ai-avatar-clone-status.cloning {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.ai-avatar-clone-status.failed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ── 音色列表 ── */
|
||||
.ai-avatar-voice-list {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ai-avatar-voice-list h4 {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #999);
|
||||
margin: 0 0 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ai-avatar-voice-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.ai-avatar-voice-item:hover {
|
||||
background: var(--bg-tertiary, #222);
|
||||
}
|
||||
|
||||
.ai-avatar-voice-item.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
/* ── 字幕设置 ── */
|
||||
.ai-avatar-subtitle-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color, #2a2a2a);
|
||||
}
|
||||
|
||||
.ai-avatar-subtitle-section label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #999);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 封面预览 ── */
|
||||
.ai-avatar-cover-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-tertiary, #222);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ai-avatar-cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── 生成设置 ── */
|
||||
.ai-avatar-generate-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color, #2a2a2a);
|
||||
}
|
||||
|
||||
.ai-avatar-generate-section .field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.ai-avatar-generate-section select {
|
||||
background: var(--bg-tertiary, #222);
|
||||
border: 1px solid var(--border-color, #2a2a2a);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #fff);
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 生成按钮 ── */
|
||||
.ai-avatar-generate-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ai-avatar-generate-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ai-avatar-generate-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── 弹窗通用 ── */
|
||||
.ai-avatar-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ai-avatar-modal {
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 12px;
|
||||
width: 640px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.ai-avatar-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #2a2a2a);
|
||||
}
|
||||
|
||||
.ai-avatar-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.ai-avatar-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ai-avatar-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-color, #2a2a2a);
|
||||
}
|
||||
|
||||
/* ── 文案选择弹窗 ── */
|
||||
.ai-avatar-script-search {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ai-avatar-script-search input {
|
||||
flex: 1;
|
||||
background: var(--bg-tertiary, #222);
|
||||
border: 1px solid var(--border-color, #2a2a2a);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ai-avatar-script-search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.ai-avatar-script-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #2a2a2a);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.ai-avatar-script-item:hover {
|
||||
background: var(--bg-tertiary, #222);
|
||||
}
|
||||
|
||||
.ai-avatar-script-item.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.ai-avatar-script-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-avatar-script-item-info h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ai-avatar-script-item-info span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
|
||||
/* ── B-roll 弹窗 ── */
|
||||
.ai-avatar-broll-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ai-avatar-broll-timeline {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-avatar-broll-settings {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-avatar-broll-thumbnails {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ai-avatar-broll-thumb {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-tertiary, #222);
|
||||
cursor: grab;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ai-avatar-broll-thumb:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.ai-avatar-broll-thumb.selected {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.ai-avatar-pip-positions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.ai-avatar-pip-positions button {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #2a2a2a);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-avatar-pip-positions button.active {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
/* ── 状态指示器 ── */
|
||||
.ai-avatar-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-avatar-status-badge.completed {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.ai-avatar-status-badge.processing {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.ai-avatar-status-badge.failed {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ── 通用按钮 ── */
|
||||
.aa-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color, #2a2a2a);
|
||||
background: transparent;
|
||||
color: var(--text-primary, #fff);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.aa-btn:hover {
|
||||
background: var(--bg-tertiary, #222);
|
||||
}
|
||||
|
||||
.aa-btn-primary {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.aa-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.aa-btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 单选组 ── */
|
||||
.aa-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aa-radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 分割线 ── */
|
||||
.aa-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, #2a2a2a);
|
||||
margin: 16px 0;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* AI数字人 — 主页面(5列水平面板布局)(#1798)
|
||||
*/
|
||||
import React from "react"
|
||||
import { useAiAvatarState } from "./hooks/useAiAvatarState"
|
||||
import AvatarVideoPanel from "./components/AvatarVideoPanel"
|
||||
import VoiceClonePanel from "./components/VoiceClonePanel"
|
||||
import ScriptLipsyncPanel from "./components/ScriptLipsyncPanel"
|
||||
import TitleConfigPanel from "./components/TitleConfigPanel"
|
||||
import CoverGeneratePanel from "./components/CoverGeneratePanel"
|
||||
import ScriptSelectModal from "./components/ScriptSelectModal"
|
||||
import BRollInsertModal from "./components/BRollInsertModal"
|
||||
import "./AiAvatar.css"
|
||||
|
||||
const AiAvatarPage: React.FC = () => {
|
||||
const state = useAiAvatarState()
|
||||
|
||||
const handleSubmitGenerate = React.useCallback(() => {
|
||||
// TODO: 调用 submitRender API
|
||||
state.setIsGenerating(true)
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<div className="ai-avatar-page">
|
||||
{/* 面板1:出镜视频 */}
|
||||
<AvatarVideoPanel
|
||||
video={state.avatarVideo}
|
||||
onVideoChange={state.setAvatarVideo}
|
||||
collapsed={!!state.collapsedPanels["avatar-video"]}
|
||||
onToggleCollapse={() => state.togglePanel("avatar-video")}
|
||||
/>
|
||||
|
||||
{/* 面板2:声音克隆 */}
|
||||
<VoiceClonePanel
|
||||
voiceClone={state.voiceClone}
|
||||
setVoiceClone={state.setVoiceClone}
|
||||
selectedVoiceId={state.selectedVoiceId}
|
||||
setSelectedVoiceId={state.setSelectedVoiceId}
|
||||
collapsed={!!state.collapsedPanels["voice-clone"]}
|
||||
onToggleCollapse={() => state.togglePanel("voice-clone")}
|
||||
/>
|
||||
|
||||
{/* 面板3:文案 & 对口型 */}
|
||||
<ScriptLipsyncPanel
|
||||
selectedScript={state.selectedScript}
|
||||
setSelectedScript={state.setSelectedScript}
|
||||
scriptContent={state.scriptContent}
|
||||
setScriptContent={state.setScriptContent}
|
||||
lipsyncJob={state.lipsyncJob}
|
||||
setLipsyncJob={state.setLipsyncJob}
|
||||
onOpenScriptModal={() => state.setScriptModalOpen(true)}
|
||||
onOpenBRollModal={() => state.setBrollModalOpen(true)}
|
||||
collapsed={!!state.collapsedPanels["script-lipsync"]}
|
||||
onToggleCollapse={() => state.togglePanel("script-lipsync")}
|
||||
/>
|
||||
|
||||
{/* 面板4:标题配置 */}
|
||||
<TitleConfigPanel
|
||||
titleConfig={state.titleConfig}
|
||||
setTitleConfig={state.setTitleConfig}
|
||||
collapsed={!!state.collapsedPanels["title-config"]}
|
||||
onToggleCollapse={() => state.togglePanel("title-config")}
|
||||
/>
|
||||
|
||||
{/* 面板5:封面 & 生成 */}
|
||||
<CoverGeneratePanel
|
||||
coverConfig={state.coverConfig}
|
||||
setCoverConfig={state.setCoverConfig}
|
||||
generateConfig={state.generateConfig}
|
||||
setGenerateConfig={state.setGenerateConfig}
|
||||
isGenerating={state.isGenerating}
|
||||
onSubmitGenerate={handleSubmitGenerate}
|
||||
collapsed={!!state.collapsedPanels["cover-generate"]}
|
||||
onToggleCollapse={() => state.togglePanel("cover-generate")}
|
||||
/>
|
||||
|
||||
{/* 弹窗:文案选择 */}
|
||||
<ScriptSelectModal
|
||||
open={state.scriptModalOpen}
|
||||
onClose={() => state.setScriptModalOpen(false)}
|
||||
onSelect={(script) => {
|
||||
state.setSelectedScript(script)
|
||||
state.setScriptContent(script.content)
|
||||
state.setScriptModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 弹窗:B-roll 插入 */}
|
||||
<BRollInsertModal
|
||||
open={state.brollModalOpen}
|
||||
onClose={() => state.setBrollModalOpen(false)}
|
||||
onConfirm={(segment) => {
|
||||
state.setBRollSegments((prev) => [...prev, segment])
|
||||
state.setBrollModalOpen(false)
|
||||
}}
|
||||
videoDuration={state.lipsyncJob?.output_duration ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiAvatarPage
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* AI数字人 API 调用封装 (#1798)
|
||||
*/
|
||||
import apiClient from "@/api/client"
|
||||
import type {
|
||||
Script,
|
||||
LipsyncJob,
|
||||
AiAvatarRenderRequest,
|
||||
AiAvatarRenderJob,
|
||||
} from "../types/aiAvatar"
|
||||
|
||||
/* ── 文案库 ── */
|
||||
export async function getScripts(params?: { search?: string; offset?: number; limit?: number }) {
|
||||
const { data } = await apiClient.get<{ items: Script[]; total: number }>("/scripts", { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getScript(id: string) {
|
||||
const { data } = await apiClient.get<Script>(`/scripts/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createScript(payload: { title: string; content: string; tags?: string[] }) {
|
||||
const { data } = await apiClient.post<Script>("/scripts", payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteScript(id: string) {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
|
||||
/* ── 对口型 ── */
|
||||
export async function createLipsyncJob(payload: {
|
||||
video_url: string
|
||||
audio_url: string
|
||||
enable_video_loop?: boolean
|
||||
project_id?: string
|
||||
}) {
|
||||
const { data } = await apiClient.post<LipsyncJob>("/lipsync/jobs", payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getLipsyncJob(id: string) {
|
||||
const { data } = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/* ── 渲染合成 ── */
|
||||
export async function submitRender(payload: AiAvatarRenderRequest) {
|
||||
const { data } = await apiClient.post<AiAvatarRenderJob>("/ai-avatar/render", payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRenderJobs(params?: { project_id?: string; status?: string }) {
|
||||
const { data } = await apiClient.get<AiAvatarRenderJob[]>("/ai-avatar/render/jobs", { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRenderJob(jobId: string) {
|
||||
const { data } = await apiClient.get<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function cancelRenderJob(jobId: string) {
|
||||
const { data } = await apiClient.post<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}/cancel`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function retryRenderJob(jobId: string) {
|
||||
const { data } = await apiClient.post<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}/retry`)
|
||||
return data
|
||||
}
|
||||
|
||||
/* ── 素材上传(复用已有 API) ── */
|
||||
export { prepareDirectUpload, completeDirectUpload } from "@/api/assets"
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { AvatarVideo } from "../types/aiAvatar"
|
||||
|
||||
interface AvatarVideoPanelProps {
|
||||
video: AvatarVideo | null
|
||||
onVideoChange: (v: AvatarVideo | null) => void
|
||||
collapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
}
|
||||
|
||||
const MAX_VIDEO_SIZE = 500 * 1024 * 1024 // 500MB
|
||||
|
||||
/** 格式化秒数为 mm:ss */
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 获取视频元信息 */
|
||||
function getVideoMetadata(
|
||||
file: File,
|
||||
): Promise<{ duration: number; width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const video = document.createElement("video")
|
||||
video.preload = "metadata"
|
||||
video.onloadedmetadata = () => {
|
||||
resolve({
|
||||
duration: video.duration,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
})
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error("无法读取视频信息"))
|
||||
}
|
||||
video.src = url
|
||||
})
|
||||
}
|
||||
|
||||
const AvatarVideoPanel: React.FC<AvatarVideoPanelProps> = ({
|
||||
video,
|
||||
onVideoChange,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > MAX_VIDEO_SIZE) {
|
||||
alert("视频文件大小不能超过 500MB")
|
||||
return
|
||||
}
|
||||
if (!file.type.startsWith("video/")) {
|
||||
alert("请上传 MP4 格式的视频文件")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取视频元信息
|
||||
const metadata = await getVideoMetadata(file)
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
|
||||
// 获取或创建默认项目
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
// 获取或创建默认视频库
|
||||
const library = await ensureDefaultLibrary({
|
||||
project_id: project.id,
|
||||
kind: "video",
|
||||
})
|
||||
|
||||
// 上传文件到 OSS
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: library.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 构建视频对象(使用本地预览 URL)
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
onVideoChange({
|
||||
url: previewUrl,
|
||||
name: file.name,
|
||||
duration: metadata.duration,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
size: file.size,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "上传失败,请重试"
|
||||
alert(message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
},
|
||||
[onVideoChange],
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
// 清空 input 以支持重复选择同一文件
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleUpload],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
},
|
||||
[handleUpload],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOver(false)
|
||||
}, [])
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onVideoChange(null)
|
||||
}, [onVideoChange])
|
||||
|
||||
return (
|
||||
<div className={`ai-avatar-panel panel-avatar-video ${collapsed ? "collapsed" : ""}`}>
|
||||
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
|
||||
<h3>出镜视频</h3>
|
||||
<button className="collapse-btn">◀</button>
|
||||
</div>
|
||||
<div className="ai-avatar-panel-body">
|
||||
{video ? (
|
||||
<div>
|
||||
<div className="ai-avatar-media-preview">
|
||||
<video src={video.url} controls />
|
||||
</div>
|
||||
<div className="ai-avatar-media-info">
|
||||
<span>时长:{formatDuration(video.duration)}</span>
|
||||
<span>
|
||||
分辨率:{video.width}×{video.height}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ai-avatar-media-info">
|
||||
<span>{video.name}</span>
|
||||
</div>
|
||||
<button className="aa-btn aa-btn-sm" onClick={handleRemove} style={{ marginTop: 8 }}>
|
||||
移除视频
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`ai-avatar-upload-zone ${dragOver ? "drag-over" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<span className="upload-icon">🎬</span>
|
||||
{uploading ? (
|
||||
<div>
|
||||
<div>上传中... {uploadProgress}%</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 4,
|
||||
background: "#2a2a2a",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: "100%",
|
||||
background: "#3b82f6",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div>点击或拖拽上传视频</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>MP4 格式,不超过 500MB</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AvatarVideoPanel
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* B-roll 画面插入弹窗
|
||||
* 左右布局:左侧素材缩略图 + 右侧设置
|
||||
*/
|
||||
import React, { useCallback, useState } from "react"
|
||||
import type { BRollSegment } from "../types/aiAvatar"
|
||||
|
||||
interface BRollInsertModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
videoDuration: number
|
||||
}
|
||||
|
||||
/** 画中画位置选项 */
|
||||
const PIP_POSITIONS = [
|
||||
{ value: "top-left", label: "左上" },
|
||||
{ value: "top-right", label: "右上" },
|
||||
{ value: "bottom-left", label: "左下" },
|
||||
{ value: "bottom-right", label: "右下" },
|
||||
]
|
||||
|
||||
/** 格式化秒数为 mm:ss */
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const BRollInsertModal: React.FC<BRollInsertModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
videoDuration,
|
||||
}) => {
|
||||
/* 素材列表(示例数据,实际使用时通过 props 或 API 传入) */
|
||||
const [assets, setAssets] = useState<{ id: string; url: string; name: string }[]>([])
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null)
|
||||
|
||||
/* 设置 */
|
||||
const [insertMode, setInsertMode] = useState<"fullscreen" | "pip">("fullscreen")
|
||||
const [pipPosition, setPipPosition] = useState("top-right")
|
||||
const [startTime, setStartTime] = useState(0)
|
||||
const [endTime, setEndTime] = useState(5)
|
||||
const [scriptIndex, setScriptIndex] = useState(0)
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const asset = assets.find((a) => a.id === selectedAssetId)
|
||||
if (!asset) return
|
||||
|
||||
const segment: BRollSegment = {
|
||||
script_segment_index: scriptIndex,
|
||||
asset_url: asset.url,
|
||||
mode: insertMode,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
pip_position: insertMode === "pip" ? pipPosition : undefined,
|
||||
pip_scale: insertMode === "pip" ? 0.3 : undefined,
|
||||
}
|
||||
onConfirm(segment)
|
||||
onClose()
|
||||
}, [
|
||||
assets,
|
||||
selectedAssetId,
|
||||
scriptIndex,
|
||||
insertMode,
|
||||
pipPosition,
|
||||
startTime,
|
||||
endTime,
|
||||
onConfirm,
|
||||
onClose,
|
||||
])
|
||||
|
||||
/* 上传新素材占位 */
|
||||
const handleUploadAsset = useCallback(() => {
|
||||
/* 实际项目中触发文件上传逻辑 */
|
||||
const newAsset = {
|
||||
id: `asset-${Date.now()}`,
|
||||
url: "",
|
||||
name: "新素材",
|
||||
}
|
||||
setAssets((prev) => [...prev, newAsset])
|
||||
setSelectedAssetId(newAsset.id)
|
||||
}, [])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="ai-avatar-modal-overlay" onClick={onClose}>
|
||||
<div className="ai-avatar-modal" style={{ width: 800 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ai-avatar-modal-header">
|
||||
<h3>插入 B-roll 画面</h3>
|
||||
<button className="aa-btn aa-btn-sm" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ai-avatar-modal-body">
|
||||
<div className="ai-avatar-broll-layout">
|
||||
{/* 左侧:素材缩略图 */}
|
||||
<div className="ai-avatar-broll-timeline">
|
||||
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
|
||||
素材列表
|
||||
</h4>
|
||||
<div className="ai-avatar-broll-thumbnails">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`ai-avatar-broll-thumb ${selectedAssetId === asset.id ? "selected" : ""}`}
|
||||
onClick={() => setSelectedAssetId(asset.id)}
|
||||
title={asset.name}
|
||||
>
|
||||
{asset.url ? (
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={asset.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
fontSize: 11,
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
{asset.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="aa-btn" onClick={handleUploadAsset}>
|
||||
+ 上传新素材
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧:设置 */}
|
||||
<div className="ai-avatar-broll-settings">
|
||||
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
|
||||
插入设置
|
||||
</h4>
|
||||
|
||||
{/* 插入位置 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
|
||||
插入位置(文案段落索引)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={scriptIndex}
|
||||
onChange={(e) => setScriptIndex(Number(e.target.value))}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "#222",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: 4,
|
||||
padding: "6px 8px",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 开始时间 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
|
||||
开始时间:{formatTime(startTime)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 持续时间 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
|
||||
结束时间:{formatTime(endTime)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={startTime}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="aa-divider" />
|
||||
|
||||
{/* 插入模式 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 8 }}>
|
||||
插入模式
|
||||
</label>
|
||||
<div className="aa-radio-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="brollInsertMode"
|
||||
value="fullscreen"
|
||||
checked={insertMode === "fullscreen"}
|
||||
onChange={() => setInsertMode("fullscreen")}
|
||||
/>
|
||||
全屏替换
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="brollInsertMode"
|
||||
value="pip"
|
||||
checked={insertMode === "pip"}
|
||||
onChange={() => setInsertMode("pip")}
|
||||
/>
|
||||
画中画
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画中画位置选择 */}
|
||||
{insertMode === "pip" && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 8 }}>
|
||||
画中画位置
|
||||
</label>
|
||||
<div className="ai-avatar-pip-positions">
|
||||
{PIP_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos.value}
|
||||
className={pipPosition === pos.value ? "active" : ""}
|
||||
onClick={() => setPipPosition(pos.value)}
|
||||
>
|
||||
{pos.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-avatar-modal-footer">
|
||||
<button className="aa-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="aa-btn aa-btn-primary"
|
||||
disabled={!selectedAssetId}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
确认插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BRollInsertModal
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 面板5:封面 & 生成
|
||||
* 封面预览 + 生成设置 + 生成按钮
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import type { AiAvatarCoverConfig, AiAvatarGenerateConfig } from "../types/aiAvatar"
|
||||
|
||||
interface CoverGeneratePanelProps {
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
setCoverConfig: (c: AiAvatarCoverConfig) => void
|
||||
generateConfig: AiAvatarGenerateConfig
|
||||
setGenerateConfig: (c: AiAvatarGenerateConfig) => void
|
||||
isGenerating: boolean
|
||||
onSubmitGenerate: () => void
|
||||
collapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
}
|
||||
|
||||
const CoverGeneratePanel: React.FC<CoverGeneratePanelProps> = ({
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
generateConfig,
|
||||
setGenerateConfig,
|
||||
isGenerating,
|
||||
onSubmitGenerate,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const handleFrameCapture = useCallback(() => {
|
||||
setCoverConfig({ ...coverConfig, mode: "frame", enabled: true })
|
||||
}, [coverConfig, setCoverConfig])
|
||||
|
||||
const handleCustomUpload = useCallback(() => {
|
||||
setCoverConfig({ ...coverConfig, mode: "upload", enabled: true })
|
||||
}, [coverConfig, setCoverConfig])
|
||||
|
||||
return (
|
||||
<div className={`ai-avatar-panel panel-cover-generate ${collapsed ? "collapsed" : ""}`}>
|
||||
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
|
||||
<h3>封面 & 生成</h3>
|
||||
<button className="collapse-btn">◀</button>
|
||||
</div>
|
||||
<div className="ai-avatar-panel-body">
|
||||
{/* 封面预览区 */}
|
||||
<div className="ai-avatar-cover-preview">
|
||||
{coverConfig.thumbnail_url ? (
|
||||
<img src={coverConfig.thumbnail_url} alt="封面预览" />
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面操作按钮 */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
<button className="aa-btn" style={{ flex: 1 }} onClick={handleFrameCapture}>
|
||||
从视频截取
|
||||
</button>
|
||||
<button className="aa-btn" style={{ flex: 1 }} onClick={handleCustomUpload}>
|
||||
自定义上传
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分割线 + 生成设置 */}
|
||||
<hr className="aa-divider" />
|
||||
|
||||
<div className="ai-avatar-generate-section">
|
||||
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
|
||||
生成设置
|
||||
</h4>
|
||||
|
||||
{/* 分辨率 */}
|
||||
<div className="field-row">
|
||||
<span>分辨率</span>
|
||||
<select
|
||||
value={generateConfig.resolution}
|
||||
onChange={(e) =>
|
||||
setGenerateConfig({
|
||||
...generateConfig,
|
||||
resolution: e.target.value as "720p" | "1080p",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 画面插入模式 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#fff", marginBottom: 8 }}>
|
||||
画面插入模式
|
||||
</label>
|
||||
<div className="aa-radio-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bRollMode"
|
||||
value="fullscreen"
|
||||
checked={generateConfig.bRollMode === "fullscreen"}
|
||||
onChange={() => setGenerateConfig({ ...generateConfig, bRollMode: "fullscreen" })}
|
||||
/>
|
||||
全屏切换
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bRollMode"
|
||||
value="pip"
|
||||
checked={generateConfig.bRollMode === "pip"}
|
||||
onChange={() => setGenerateConfig({ ...generateConfig, bRollMode: "pip" })}
|
||||
/>
|
||||
画中画
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button
|
||||
className="ai-avatar-generate-btn"
|
||||
disabled={isGenerating}
|
||||
onClick={onSubmitGenerate}
|
||||
>
|
||||
{isGenerating ? "生成中..." : "🚀 开始生成视频"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverGeneratePanel
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import type { Script, LipsyncJob } from "../types/aiAvatar"
|
||||
|
||||
interface ScriptLipsyncPanelProps {
|
||||
selectedScript: Script | null
|
||||
setSelectedScript: (s: Script | null) => void
|
||||
scriptContent: string
|
||||
setScriptContent: (content: string) => void
|
||||
lipsyncJob: LipsyncJob | null
|
||||
setLipsyncJob: (job: LipsyncJob | null) => void
|
||||
onOpenScriptModal: () => void
|
||||
onOpenBRollModal: () => void
|
||||
collapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
}
|
||||
|
||||
type ScriptTab = "library" | "manual"
|
||||
|
||||
/** 对口型状态标签 */
|
||||
const LIPSYNC_STATUS_LABEL: Record<string, string> = {
|
||||
pending: "等待中",
|
||||
processing: "处理中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
const LIPSYNC_STATUS_CLASS: Record<string, string> = {
|
||||
pending: "processing",
|
||||
processing: "processing",
|
||||
completed: "completed",
|
||||
failed: "failed",
|
||||
}
|
||||
|
||||
const ScriptLipsyncPanel: React.FC<ScriptLipsyncPanelProps> = ({
|
||||
selectedScript,
|
||||
setSelectedScript,
|
||||
scriptContent,
|
||||
setScriptContent,
|
||||
lipsyncJob,
|
||||
setLipsyncJob,
|
||||
onOpenScriptModal,
|
||||
onOpenBRollModal,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<ScriptTab>("manual")
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tab: ScriptTab) => {
|
||||
setActiveTab(tab)
|
||||
if (tab === "library") {
|
||||
onOpenScriptModal()
|
||||
}
|
||||
},
|
||||
[onOpenScriptModal],
|
||||
)
|
||||
|
||||
const handleScriptChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setScriptContent(e.target.value)
|
||||
// 清除已选脚本(用户手动输入时)
|
||||
if (selectedScript) {
|
||||
setSelectedScript(null)
|
||||
}
|
||||
},
|
||||
[setScriptContent, selectedScript, setSelectedScript],
|
||||
)
|
||||
|
||||
const handleRegenerateLipsync = useCallback(() => {
|
||||
// TODO: 调用实际的对口型 API
|
||||
if (!scriptContent) {
|
||||
alert("请先输入文案内容")
|
||||
return
|
||||
}
|
||||
// 模拟创建对口型任务
|
||||
const newJob: LipsyncJob = {
|
||||
id: `lipsync-${Date.now()}`,
|
||||
status: "pending",
|
||||
video_url: "",
|
||||
audio_url: "",
|
||||
output_video_url: "",
|
||||
output_duration: 0,
|
||||
error_message: "",
|
||||
submitted_at: new Date().toISOString(),
|
||||
}
|
||||
setLipsyncJob(newJob)
|
||||
|
||||
// 模拟处理流程
|
||||
setTimeout(() => {
|
||||
setLipsyncJob({ ...newJob, status: "processing" })
|
||||
}, 1000)
|
||||
|
||||
setTimeout(() => {
|
||||
setLipsyncJob({
|
||||
...newJob,
|
||||
status: "completed",
|
||||
output_video_url: "",
|
||||
output_duration: 30,
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
}, 5000)
|
||||
}, [scriptContent, setLipsyncJob])
|
||||
|
||||
return (
|
||||
<div className={`ai-avatar-panel panel-script-lipsync ${collapsed ? "collapsed" : ""}`}>
|
||||
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
|
||||
<h3>文案 & 对口型</h3>
|
||||
<button className="collapse-btn">◀</button>
|
||||
</div>
|
||||
<div className="ai-avatar-panel-body">
|
||||
{/* 上半区:文案编辑 */}
|
||||
<div className="ai-avatar-script-tabs">
|
||||
<button
|
||||
className={activeTab === "library" ? "active" : ""}
|
||||
onClick={() => handleTabChange("library")}
|
||||
>
|
||||
从文案库选择
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === "manual" ? "active" : ""}
|
||||
onClick={() => handleTabChange("manual")}
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedScript && activeTab === "library" && (
|
||||
<div style={{ marginBottom: 8, fontSize: 13, color: "#999" }}>
|
||||
已选择:{selectedScript.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className="ai-avatar-script-editor"
|
||||
placeholder="请输入视频文案内容..."
|
||||
value={scriptContent}
|
||||
onChange={handleScriptChange}
|
||||
disabled={activeTab === "library"}
|
||||
/>
|
||||
<div className="ai-avatar-script-word-count">{scriptContent.length} 字</div>
|
||||
|
||||
{/* 下半区:对口型预览 */}
|
||||
<div className="ai-avatar-lipsync-section">
|
||||
<h4>对口型预览</h4>
|
||||
|
||||
{/* 视频预览区域 */}
|
||||
{lipsyncJob?.output_video_url && (
|
||||
<div className="ai-avatar-media-preview">
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态标签 */}
|
||||
{lipsyncJob && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`ai-avatar-status-badge ${LIPSYNC_STATUS_CLASS[lipsyncJob.status] || ""}`}
|
||||
>
|
||||
{LIPSYNC_STATUS_LABEL[lipsyncJob.status] || lipsyncJob.status}
|
||||
</span>
|
||||
{lipsyncJob.error_message && (
|
||||
<span style={{ fontSize: 12, color: "#ef4444" }}>{lipsyncJob.error_message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="ai-avatar-lipsync-actions">
|
||||
<button className="aa-btn aa-btn-sm" onClick={onOpenBRollModal}>
|
||||
🎬 插入画面
|
||||
</button>
|
||||
<button className="aa-btn aa-btn-sm aa-btn-primary" onClick={handleRegenerateLipsync}>
|
||||
重新生成对口型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScriptLipsyncPanel
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 文案选择弹窗
|
||||
* 搜索 + 文案列表 + 选择回调
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import type { Script } from "../types/aiAvatar"
|
||||
import { getScripts } from "../api/aiAvatar"
|
||||
|
||||
interface ScriptSelectModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (script: Script) => void
|
||||
}
|
||||
|
||||
const ScriptSelectModal: React.FC<ScriptSelectModalProps> = ({ open, onClose, onSelect }) => {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [scripts, setScripts] = useState<Script[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
/* 加载文案列表 */
|
||||
const fetchScripts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await getScripts({ search: searchText || undefined })
|
||||
setScripts(result.items)
|
||||
} catch {
|
||||
setScripts([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchText])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchText("")
|
||||
setSelectedId(null)
|
||||
fetchScripts()
|
||||
}
|
||||
}, [open, fetchScripts])
|
||||
|
||||
/* 搜索防抖 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const timer = setTimeout(() => {
|
||||
fetchScripts()
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchText, open, fetchScripts])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(script: Script) => {
|
||||
setSelectedId(script.id)
|
||||
onSelect(script)
|
||||
onClose()
|
||||
},
|
||||
[onSelect, onClose],
|
||||
)
|
||||
|
||||
/* 格式化时间 */
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="ai-avatar-modal-overlay" onClick={onClose}>
|
||||
<div className="ai-avatar-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ai-avatar-modal-header">
|
||||
<h3>选择文案</h3>
|
||||
<button className="aa-btn aa-btn-sm" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ai-avatar-modal-body">
|
||||
{/* 搜索栏 + 新建文案 */}
|
||||
<div className="ai-avatar-script-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索文案标题..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
<button className="aa-btn aa-btn-primary">新建文案</button>
|
||||
</div>
|
||||
|
||||
{/* 文案列表 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: 32, color: "#999", fontSize: 13 }}>
|
||||
加载中...
|
||||
</div>
|
||||
) : scripts.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: 32, color: "#999", fontSize: 13 }}>
|
||||
暂无文案
|
||||
</div>
|
||||
) : (
|
||||
scripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className={`ai-avatar-script-item ${selectedId === script.id ? "selected" : ""}`}
|
||||
onClick={() => handleSelect(script)}
|
||||
>
|
||||
<div className="ai-avatar-script-item-info">
|
||||
<h4>{script.title}</h4>
|
||||
<span>
|
||||
{script.content.length}字 · {formatDate(script.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<button className="aa-btn aa-btn-sm aa-btn-primary">选择</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScriptSelectModal
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 面板4:标题配置
|
||||
* 主标题输入 + 复用 TitleStylePanel + 字幕设置
|
||||
*/
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import type { AiAvatarTitleConfig } from "../types/aiAvatar"
|
||||
import type { TitleSettings, TitlePreset } from "@/pages/generate/types"
|
||||
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
|
||||
|
||||
interface TitleConfigPanelProps {
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
setTitleConfig: (c: AiAvatarTitleConfig) => void
|
||||
collapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
}
|
||||
|
||||
/* ── 位置选项 ── */
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
{ value: "top-left", label: "左上" },
|
||||
{ value: "top-right", label: "右上" },
|
||||
{ value: "bottom-left", label: "左下" },
|
||||
{ value: "bottom-right", label: "右下" },
|
||||
{ value: "custom", label: "自由位置" },
|
||||
]
|
||||
|
||||
/* ── 字体选项 ── */
|
||||
const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"阿里巴巴普惠体",
|
||||
"站酷高端黑",
|
||||
"站酷快乐体",
|
||||
"方正兰亭黑",
|
||||
"方正楷体",
|
||||
"汉仪旗黑",
|
||||
]
|
||||
|
||||
/* ── 标题预设 ── */
|
||||
const titlePresets: TitlePreset[] = [
|
||||
{
|
||||
key: "default",
|
||||
label: "默认",
|
||||
style: { size: 36, color: "#ffffff", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontSize: 16, color: "#ffffff", fontWeight: 400 },
|
||||
},
|
||||
{
|
||||
key: "bold-white",
|
||||
label: "粗体白",
|
||||
style: { size: 48, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: { fontSize: 18, color: "#ffffff", fontWeight: 700 },
|
||||
},
|
||||
{
|
||||
key: "highlight-yellow",
|
||||
label: "高亮黄",
|
||||
style: { size: 44, color: "#FFD700", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: { fontSize: 17, color: "#FFD700", fontWeight: 700 },
|
||||
},
|
||||
{
|
||||
key: "elegant-serif",
|
||||
label: "优雅宋体",
|
||||
style: { size: 40, color: "#f0f0f0", bold: false, italic: true, stroke: false, shadow: true },
|
||||
previewStyle: { fontSize: 16, color: "#f0f0f0", fontStyle: "italic", fontFamily: "serif" },
|
||||
},
|
||||
{
|
||||
key: "impact",
|
||||
label: "冲击力",
|
||||
style: { size: 56, color: "#ff4444", bold: true, italic: false, stroke: true, shadow: true },
|
||||
previewStyle: { fontSize: 20, color: "#ff4444", fontWeight: 900 },
|
||||
},
|
||||
]
|
||||
|
||||
/** 将 AiAvatarTitleConfig 适配为 TitleSettings */
|
||||
function toTitleSettings(config: AiAvatarTitleConfig): TitleSettings {
|
||||
return {
|
||||
aiAutoSelect: false,
|
||||
title: config.title,
|
||||
position: config.position,
|
||||
font: config.font,
|
||||
size: config.size,
|
||||
bold: config.bold,
|
||||
italic: config.italic,
|
||||
stroke: config.stroke,
|
||||
shadow: config.shadow,
|
||||
color: config.color,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据 preset key 找到对应的预设 */
|
||||
function findPresetByKey(key: string): TitlePreset | undefined {
|
||||
return titlePresets.find((p) => p.key === key)
|
||||
}
|
||||
|
||||
const TitleConfigPanel: React.FC<TitleConfigPanelProps> = ({
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
/* 字幕开关 */
|
||||
const [subtitleEnabled, setSubtitleEnabled] = useState(false)
|
||||
const [subtitleFont, setSubtitleFont] = useState("思源黑体")
|
||||
const [subtitleSize, setSubtitleSize] = useState(24)
|
||||
|
||||
const titleSettings = useMemo(() => toTitleSettings(titleConfig), [titleConfig])
|
||||
|
||||
/* 当前激活的预设 */
|
||||
const activePreset = useMemo(() => {
|
||||
const match = titlePresets.find(
|
||||
(p) =>
|
||||
p.style.size === titleConfig.size &&
|
||||
p.style.color === titleConfig.color &&
|
||||
p.style.bold === titleConfig.bold &&
|
||||
p.style.italic === titleConfig.italic &&
|
||||
p.style.stroke === titleConfig.stroke &&
|
||||
p.style.shadow === titleConfig.shadow,
|
||||
)
|
||||
return match ? match.key : null
|
||||
}, [titleConfig])
|
||||
|
||||
/* 将 TitleStylePanel 的预设 key 映射回 titlePresets 项 */
|
||||
const presetItems = useMemo(
|
||||
() =>
|
||||
titlePresets.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
previewStyle: p.previewStyle as React.CSSProperties,
|
||||
})),
|
||||
[],
|
||||
)
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitleConfig({ ...titleConfig, title: e.target.value })
|
||||
},
|
||||
[titleConfig, setTitleConfig],
|
||||
)
|
||||
|
||||
const handleApplyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = findPresetByKey(presetKey)
|
||||
if (!preset) return
|
||||
setTitleConfig({
|
||||
...titleConfig,
|
||||
size: preset.style.size,
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
},
|
||||
[titleConfig, setTitleConfig],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`ai-avatar-panel panel-title-config ${collapsed ? "collapsed" : ""}`}>
|
||||
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
|
||||
<h3>标题配置</h3>
|
||||
<button className="collapse-btn">◀</button>
|
||||
</div>
|
||||
<div className="ai-avatar-panel-body">
|
||||
{/* 主标题输入 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#999", marginBottom: 6 }}>
|
||||
主标题
|
||||
</label>
|
||||
<input
|
||||
className="ai-avatar-script-editor"
|
||||
style={{ minHeight: "auto", padding: "8px 12px", fontSize: 14 }}
|
||||
type="text"
|
||||
placeholder="请输入视频标题"
|
||||
value={titleConfig.title}
|
||||
onChange={handleTitleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 复用标题样式面板 */}
|
||||
<TitleStylePanel
|
||||
settings={titleSettings}
|
||||
onUpdatePosition={(position) => setTitleConfig({ ...titleConfig, position })}
|
||||
onUpdateFont={(font) => setTitleConfig({ ...titleConfig, font })}
|
||||
onUpdateSize={(size) => setTitleConfig({ ...titleConfig, size })}
|
||||
onToggleBold={() => setTitleConfig({ ...titleConfig, bold: !titleConfig.bold })}
|
||||
onToggleItalic={() => setTitleConfig({ ...titleConfig, italic: !titleConfig.italic })}
|
||||
onToggleStroke={() => setTitleConfig({ ...titleConfig, stroke: !titleConfig.stroke })}
|
||||
onToggleShadow={() => setTitleConfig({ ...titleConfig, shadow: !titleConfig.shadow })}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={presetItems}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
|
||||
{/* 字幕区域 */}
|
||||
<div className="ai-avatar-subtitle-section">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitleEnabled}
|
||||
onChange={(e) => setSubtitleEnabled(e.target.checked)}
|
||||
/>
|
||||
显示字幕
|
||||
</label>
|
||||
|
||||
{subtitleEnabled && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
|
||||
字幕字体
|
||||
</label>
|
||||
<select
|
||||
className="ai-avatar-script-editor"
|
||||
style={{ minHeight: "auto", padding: "6px 10px", fontSize: 13 }}
|
||||
value={subtitleFont}
|
||||
onChange={(e) => setSubtitleFont(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
|
||||
字幕大小:{subtitleSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={subtitleSize}
|
||||
onChange={(e) => setSubtitleSize(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleConfigPanel
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { VoiceCloneState, VoiceTone } from "../types/aiAvatar"
|
||||
|
||||
interface VoiceClonePanelProps {
|
||||
voiceClone: VoiceCloneState
|
||||
setVoiceClone: (v: VoiceCloneState) => void
|
||||
selectedVoiceId: string
|
||||
setSelectedVoiceId: (id: string) => void
|
||||
collapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
}
|
||||
|
||||
const MAX_AUDIO_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
/** Mock 音色列表(后续接 API) */
|
||||
const MOCK_VOICES: VoiceTone[] = [
|
||||
{
|
||||
id: "voice-1",
|
||||
name: "温柔女声",
|
||||
description: "适合新闻播报和产品介绍",
|
||||
gender: "女",
|
||||
preview_url: "",
|
||||
},
|
||||
{
|
||||
id: "voice-2",
|
||||
name: "沉稳男声",
|
||||
description: "适合企业宣传和培训视频",
|
||||
gender: "男",
|
||||
preview_url: "",
|
||||
},
|
||||
{
|
||||
id: "voice-3",
|
||||
name: "活泼女声",
|
||||
description: "适合短视频和社交媒体内容",
|
||||
gender: "女",
|
||||
preview_url: "",
|
||||
},
|
||||
]
|
||||
|
||||
/** 状态文案映射 */
|
||||
const STATUS_LABEL: Record<VoiceCloneState["status"], string> = {
|
||||
idle: "",
|
||||
uploading: "正在上传音频...",
|
||||
cloning: "正在克隆声音...",
|
||||
completed: "声音克隆完成",
|
||||
failed: "克隆失败,请重试",
|
||||
}
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
completed: "success",
|
||||
cloning: "cloning",
|
||||
failed: "failed",
|
||||
}
|
||||
|
||||
const VoiceClonePanel: React.FC<VoiceClonePanelProps> = ({
|
||||
voiceClone,
|
||||
setVoiceClone,
|
||||
selectedVoiceId,
|
||||
setSelectedVoiceId,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > MAX_AUDIO_SIZE) {
|
||||
alert("音频文件大小不能超过 50MB")
|
||||
return
|
||||
}
|
||||
const isAudio = file.type.startsWith("audio/")
|
||||
if (!isAudio) {
|
||||
alert("请上传 WAV 或 MP3 格式的音频文件")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setVoiceClone({ ...voiceClone, status: "uploading", progress: 0 })
|
||||
|
||||
// 获取或创建默认项目和配音库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({
|
||||
project_id: project.id,
|
||||
kind: "voice",
|
||||
})
|
||||
|
||||
// 上传音频
|
||||
const audioUrl = URL.createObjectURL(file)
|
||||
setVoiceClone({
|
||||
...voiceClone,
|
||||
status: "uploading",
|
||||
audioUrl,
|
||||
audioName: file.name,
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: library.id,
|
||||
onProgress: (p) => {
|
||||
setVoiceClone({ ...voiceClone, audioUrl, audioName: file.name, progress: p })
|
||||
},
|
||||
})
|
||||
|
||||
// 开始克隆(模拟)
|
||||
setVoiceClone({
|
||||
...voiceClone,
|
||||
status: "cloning",
|
||||
audioUrl,
|
||||
audioName: file.name,
|
||||
progress: 100,
|
||||
})
|
||||
|
||||
// TODO: 调用实际的声音克隆 API
|
||||
// 模拟克隆完成
|
||||
setTimeout(() => {
|
||||
setVoiceClone({
|
||||
...voiceClone,
|
||||
status: "completed",
|
||||
audioUrl,
|
||||
audioName: file.name,
|
||||
cloneJobId: `clone-${Date.now()}`,
|
||||
voiceId: `voice-clone-${Date.now()}`,
|
||||
progress: 100,
|
||||
})
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "上传失败"
|
||||
alert(message)
|
||||
setVoiceClone({ ...voiceClone, status: "failed", progress: 0 })
|
||||
}
|
||||
},
|
||||
[voiceClone, setVoiceClone],
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleUpload],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
},
|
||||
[handleUpload],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOver(false)
|
||||
}, [])
|
||||
|
||||
const handleVoiceSelect = useCallback(
|
||||
(voiceId: string) => {
|
||||
setSelectedVoiceId(voiceId)
|
||||
},
|
||||
[setSelectedVoiceId],
|
||||
)
|
||||
|
||||
const showStatus = voiceClone.status !== "idle" && voiceClone.status !== "uploading"
|
||||
|
||||
return (
|
||||
<div className={`ai-avatar-panel panel-voice-clone ${collapsed ? "collapsed" : ""}`}>
|
||||
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
|
||||
<h3>声音克隆</h3>
|
||||
<button className="collapse-btn">◀</button>
|
||||
</div>
|
||||
<div className="ai-avatar-panel-body">
|
||||
{/* 上传区域 */}
|
||||
{voiceClone.audioUrl ? (
|
||||
<div>
|
||||
<div className="ai-avatar-media-preview">
|
||||
<audio src={voiceClone.audioUrl} controls />
|
||||
</div>
|
||||
<div className="ai-avatar-media-info">
|
||||
<span>{voiceClone.audioName}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`ai-avatar-upload-zone ${dragOver ? "drag-over" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<span className="upload-icon">🎙️</span>
|
||||
<div>点击或拖拽上传参考音频</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>WAV / MP3 格式,不超过 50MB</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/wav,audio/mp3,audio/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆状态 */}
|
||||
{showStatus && (
|
||||
<div className={`ai-avatar-clone-status ${STATUS_CLASS[voiceClone.status] || ""}`}>
|
||||
{voiceClone.status === "cloning" && "⏳ "}
|
||||
{voiceClone.status === "completed" && "✅ "}
|
||||
{voiceClone.status === "failed" && "❌ "}
|
||||
{STATUS_LABEL[voiceClone.status]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传中进度 */}
|
||||
{voiceClone.status === "uploading" && (
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: "#999" }}>
|
||||
上传中... {voiceClone.progress}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已有音色列表 */}
|
||||
<div className="ai-avatar-voice-list">
|
||||
<h4>已有音色</h4>
|
||||
{MOCK_VOICES.map((voice) => (
|
||||
<div
|
||||
key={voice.id}
|
||||
className={`ai-avatar-voice-item ${selectedVoiceId === voice.id ? "selected" : ""}`}
|
||||
onClick={() => handleVoiceSelect(voice.id)}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: "#fff" }}>{voice.name}</div>
|
||||
<div style={{ fontSize: 12, color: "#999", marginTop: 2 }}>{voice.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceClonePanel
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* AI数字人页面全局状态管理 (#1798)
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import type {
|
||||
AvatarVideo,
|
||||
VoiceCloneState,
|
||||
Script,
|
||||
LipsyncJob,
|
||||
BRollSegment,
|
||||
AiAvatarTitleConfig,
|
||||
AiAvatarCoverConfig,
|
||||
AiAvatarGenerateConfig,
|
||||
AiAvatarRenderJob,
|
||||
} from "../types/aiAvatar"
|
||||
|
||||
const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
title: "",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
color: "#ffffff",
|
||||
position: "bottom",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
}
|
||||
|
||||
const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
|
||||
enabled: false,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
thumbnail_url: "",
|
||||
}
|
||||
|
||||
const DEFAULT_VOICE_CLONE: VoiceCloneState = {
|
||||
status: "idle",
|
||||
audioUrl: "",
|
||||
audioName: "",
|
||||
cloneJobId: "",
|
||||
voiceId: "",
|
||||
progress: 0,
|
||||
}
|
||||
|
||||
export function useAiAvatarState() {
|
||||
/* ── 面板折叠状态 ── */
|
||||
const [collapsedPanels, setCollapsedPanels] = useState<Record<string, boolean>>({})
|
||||
|
||||
const togglePanel = useCallback((key: string) => {
|
||||
setCollapsedPanels((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
/* ── 面板1:出镜视频 ── */
|
||||
const [avatarVideo, setAvatarVideo] = useState<AvatarVideo | null>(null)
|
||||
|
||||
/* ── 面板2:声音克隆 ── */
|
||||
const [voiceClone, setVoiceClone] = useState<VoiceCloneState>(DEFAULT_VOICE_CLONE)
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
|
||||
|
||||
/* ── 面板3:文案 & 对口型 ── */
|
||||
const [selectedScript, setSelectedScript] = useState<Script | null>(null)
|
||||
const [scriptContent, setScriptContent] = useState("")
|
||||
const [lipsyncJob, setLipsyncJob] = useState<LipsyncJob | null>(null)
|
||||
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
|
||||
|
||||
/* ── 面板4:标题配置 ── */
|
||||
const [titleConfig, setTitleConfig] = useState<AiAvatarTitleConfig>(DEFAULT_TITLE_CONFIG)
|
||||
|
||||
/* ── 面板5:封面 & 生成 ── */
|
||||
const [coverConfig, setCoverConfig] = useState<AiAvatarCoverConfig>(DEFAULT_COVER_CONFIG)
|
||||
const [generateConfig, setGenerateConfig] = useState<AiAvatarGenerateConfig>({
|
||||
resolution: "1080p",
|
||||
bRollMode: "pip",
|
||||
})
|
||||
|
||||
/* ── 渲染任务 ── */
|
||||
const [renderJob, setRenderJob] = useState<AiAvatarRenderJob | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
/* ── 弹窗状态 ── */
|
||||
const [scriptModalOpen, setScriptModalOpen] = useState(false)
|
||||
const [brollModalOpen, setBrollModalOpen] = useState(false)
|
||||
|
||||
return {
|
||||
// 面板折叠
|
||||
collapsedPanels,
|
||||
togglePanel,
|
||||
|
||||
// 面板1
|
||||
avatarVideo,
|
||||
setAvatarVideo,
|
||||
|
||||
// 面板2
|
||||
voiceClone,
|
||||
setVoiceClone,
|
||||
selectedVoiceId,
|
||||
setSelectedVoiceId,
|
||||
|
||||
// 面板3
|
||||
selectedScript,
|
||||
setSelectedScript,
|
||||
scriptContent,
|
||||
setScriptContent,
|
||||
lipsyncJob,
|
||||
setLipsyncJob,
|
||||
bRollSegments,
|
||||
setBRollSegments,
|
||||
|
||||
// 面板4
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
|
||||
// 面板5
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
generateConfig,
|
||||
setGenerateConfig,
|
||||
|
||||
// 渲染
|
||||
renderJob,
|
||||
setRenderJob,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
|
||||
// 弹窗
|
||||
scriptModalOpen,
|
||||
setScriptModalOpen,
|
||||
brollModalOpen,
|
||||
setBrollModalOpen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AI数字人页面 — 类型定义 (#1798)
|
||||
*/
|
||||
|
||||
/* ── 出镜视频 ── */
|
||||
export interface AvatarVideo {
|
||||
url: string
|
||||
name: string
|
||||
duration: number // 秒
|
||||
width: number
|
||||
height: number
|
||||
size: number // 字节
|
||||
}
|
||||
|
||||
/* ── 声音克隆 ── */
|
||||
export interface VoiceTone {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
gender: string
|
||||
preview_url?: string
|
||||
}
|
||||
|
||||
export interface VoiceCloneState {
|
||||
status: "idle" | "uploading" | "cloning" | "completed" | "failed"
|
||||
audioUrl: string
|
||||
audioName: string
|
||||
cloneJobId: string
|
||||
voiceId: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
/* ── 文案 ── */
|
||||
export interface ScriptSegment {
|
||||
text: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface Script {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
segments: ScriptSegment[]
|
||||
tags: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/* ── 对口型 ── */
|
||||
export interface LipsyncJob {
|
||||
id: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
video_url: string
|
||||
audio_url: string
|
||||
output_video_url: string
|
||||
output_duration: number
|
||||
error_message: string
|
||||
submitted_at?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
/* ── B-roll 插入 ── */
|
||||
export interface BRollSegment {
|
||||
script_segment_index: number
|
||||
asset_url: string
|
||||
mode: "fullscreen" | "pip"
|
||||
start_time: number
|
||||
end_time: number
|
||||
pip_position?: string
|
||||
pip_scale?: number
|
||||
}
|
||||
|
||||
/* ── 渲染任务 ── */
|
||||
export interface AiAvatarRenderRequest {
|
||||
lipsync_job_id: string
|
||||
script_id: string
|
||||
b_roll_segments: BRollSegment[]
|
||||
title_config: Record<string, unknown>
|
||||
cover_config: Record<string, unknown>
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
export interface AiAvatarRenderJob {
|
||||
id: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
progress: number
|
||||
output_video_url: string
|
||||
output_cover_url: string
|
||||
output_duration: number
|
||||
error_message: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/* ── 封面配置 ── */
|
||||
export interface AiAvatarCoverConfig {
|
||||
enabled: boolean
|
||||
mode: "auto" | "frame" | "upload"
|
||||
frame_time: number
|
||||
upload_url: string
|
||||
thumbnail_url: string
|
||||
}
|
||||
|
||||
/* ── 标题配置(复用 generate 的 TitleSettings 结构) ── */
|
||||
export interface AiAvatarTitleConfig {
|
||||
title: string
|
||||
font: string
|
||||
size: number
|
||||
color: string
|
||||
position: string
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
}
|
||||
|
||||
/* ── 生成设置 ── */
|
||||
export interface AiAvatarGenerateConfig {
|
||||
resolution: "720p" | "1080p"
|
||||
bRollMode: "fullscreen" | "pip"
|
||||
}
|
||||
@@ -6440,3 +6440,49 @@
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ═══ 标题设置 — 颜色预设 ═══ */
|
||||
.ep-color-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ep-color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.1s;
|
||||
}
|
||||
|
||||
.ep-color-swatch:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.ep-color-swatch.active {
|
||||
border-color: var(--ep-primary, #4f8cff);
|
||||
}
|
||||
|
||||
.ep-color-picker {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.ep-color-picker::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ep-color-picker::-webkit-color-swatch {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,8 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
{/* 右栏 260px:设置面板 */}
|
||||
<RightPanel
|
||||
titleConfig={titleConfig}
|
||||
onTitleConfigChange={setTitleConfig}
|
||||
rightTab={rightTab}
|
||||
onTabChange={setRightTab}
|
||||
selectedClip={clipOps.selectedClip}
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
import React from "react"
|
||||
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
|
||||
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
|
||||
import TitleSettingsSection from "./clip-properties/TitleSettingsSection"
|
||||
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
|
||||
import ClipDetailSection from "./clip-properties/ClipDetailSection"
|
||||
import StatsSection from "./clip-properties/StatsSection"
|
||||
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
titleConfig,
|
||||
onTitleConfigChange,
|
||||
selectedClip,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
@@ -40,6 +43,11 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 — #1789 ═══ */}
|
||||
{titleConfig && onTitleConfigChange && (
|
||||
<TitleSettingsSection config={titleConfig} onChange={onTitleConfigChange} />
|
||||
)}
|
||||
|
||||
{/* ═══ 字幕设置 ═══ */}
|
||||
<SubtitleSettingsSection
|
||||
settings={subtitleSettings}
|
||||
|
||||
@@ -5,9 +5,12 @@ import type { ClipData } from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface RightPanelProps {
|
||||
titleConfig?: TitleConfig
|
||||
onTitleConfigChange?: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||
rightTab: "properties" | "clips"
|
||||
onTabChange: (tab: "properties" | "clips") => void
|
||||
// 属性 tab
|
||||
@@ -46,6 +49,8 @@ interface RightPanelProps {
|
||||
}
|
||||
|
||||
const RightPanel: React.FC<RightPanelProps> = ({
|
||||
titleConfig,
|
||||
onTitleConfigChange,
|
||||
rightTab,
|
||||
onTabChange,
|
||||
selectedClip,
|
||||
@@ -118,6 +123,8 @@ const RightPanel: React.FC<RightPanelProps> = ({
|
||||
>["onBgmSettingsChange"]
|
||||
return (
|
||||
<ClipPropertiesPanel
|
||||
titleConfig={titleConfig}
|
||||
onTitleConfigChange={onTitleConfigChange}
|
||||
selectedClip={selectedClip}
|
||||
subtitleSettings={sub}
|
||||
bgmSettings={bgm}
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 标题设置区块 — #1789
|
||||
* 提供字号滑块、字体预设、位置、颜色等控制入口
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "@/pages/editing-planner/constants/clipProperties"
|
||||
|
||||
interface TitleSettingsSectionProps {
|
||||
config: TitleConfig
|
||||
onChange: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||
}
|
||||
|
||||
const TITLE_COLOR_PRESETS = [
|
||||
"#ffffff",
|
||||
"#000000",
|
||||
"#ff4444",
|
||||
"#ffaa00",
|
||||
"#44ff44",
|
||||
"#4488ff",
|
||||
"#ff44ff",
|
||||
"#ffff44",
|
||||
]
|
||||
|
||||
const TitleSettingsSection: React.FC<TitleSettingsSectionProps> = ({ config, onChange }) => {
|
||||
const update = (partial: Partial<TitleConfig>) => {
|
||||
onChange((prev: TitleConfig) => ({ ...prev, ...partial }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">📝</span>
|
||||
标题设置
|
||||
</div>
|
||||
|
||||
{/* AI 自动选择开关 */}
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">AI 自动选择</span>
|
||||
<div
|
||||
className={`ep-toggle ${config.ai_auto_select ? "active" : ""}`}
|
||||
onClick={() => update({ ai_auto_select: !config.ai_auto_select })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!config.ai_auto_select && (
|
||||
<>
|
||||
{/* 标题文本 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">标题文本</label>
|
||||
<input
|
||||
className="ep-form-select"
|
||||
type="text"
|
||||
placeholder="输入标题内容"
|
||||
value={config.content}
|
||||
onChange={(e) => update({ content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={config.position}
|
||||
onChange={(e) => update({ position: e.target.value })}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 字体预设 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={config.font_preset}
|
||||
onChange={(e) => update({ font_preset: e.target.value })}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字号</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size}
|
||||
onChange={(e) => update({ font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="ep-slider-value">{config.font_size}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 颜色 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">颜色</label>
|
||||
<div className="ep-color-presets">
|
||||
{TITLE_COLOR_PRESETS.map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
className={`ep-color-swatch${config.font_color === color ? " active" : ""}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => update({ font_color: color })}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
className="ep-color-picker"
|
||||
value={config.font_color}
|
||||
onChange={(e) => update({ font_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleSettingsSection
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { ClipData } from "./clip"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
|
||||
export interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
@@ -28,6 +29,10 @@ export interface BgmSettings {
|
||||
}
|
||||
|
||||
export interface ClipPropertiesPanelProps {
|
||||
/** 标题配置 — #1789 */
|
||||
titleConfig?: TitleConfig
|
||||
/** 标题配置变更 */
|
||||
onTitleConfigChange?: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||
selectedClip: ClipData | null
|
||||
subtitleSettings: SubtitleSettings
|
||||
bgmSettings: BgmSettings
|
||||
|
||||
@@ -13,6 +13,8 @@ export const formatTrimTime = (sec: number): string => {
|
||||
/** 生成时间标尺刻度 */
|
||||
export const generateRulerMarks = (totalDuration: number, step: number): number[] => {
|
||||
const marks: number[] = []
|
||||
// #1790: 无片段时不显示时间刻度
|
||||
if (totalDuration <= 0) return marks
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
marks.push(t)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
handleInvalidTemplate,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
@@ -416,13 +417,11 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 最终成片(单视频右侧播放) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ── 布局 class:步骤4标题页=预览+标题侧栏;步骤5/6批量=整行宽;步骤1~3=整行宽 ── */
|
||||
/* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */
|
||||
const layoutClassName = useMemo(() => {
|
||||
if (currentStep < 4) return "xx-generate-layout full-width"
|
||||
if (currentStep === 4) return "xx-generate-layout step4-layout"
|
||||
// 步骤5/6:批量网格需要整行宽度;单视频保持 表单+右侧成片 两栏
|
||||
return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout"
|
||||
}, [currentStep, isBatch])
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -522,6 +521,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
onTemplateInvalid={handleInvalidTemplate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
@@ -543,6 +543,59 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVariantIds={selectedVariantIds}
|
||||
/>
|
||||
|
||||
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
|
||||
{currentStep === 5 && !isBatch && generated && finalVideo && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
maxWidth: 320,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
|
||||
🔗 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
📁 前往成片库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GenerateStepActions
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
@@ -554,54 +607,6 @@ const GeneratePage: React.FC = () => {
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */}
|
||||
{currentStep >= 5 && !isBatch && generated && finalVideo && (
|
||||
<div className="xx-generate-right-col">
|
||||
<div
|
||||
className="xx-inline-video-player"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay={currentStep === 5}
|
||||
// 竖屏自适应(#1750):成片固定 1080×1920(9:16),元数据到达前按 9:16 占位,
|
||||
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
|
||||
🔗 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
📁 前往成片库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 数量选择弹窗 */}
|
||||
|
||||
@@ -34,12 +34,26 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-batch-gen-grid">
|
||||
<div
|
||||
className="xx-batch-gen-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 320px))",
|
||||
justifyContent: "center",
|
||||
justifyItems: "center",
|
||||
gap: 14,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{sorted.map((task) => {
|
||||
const title = titles[task.variantIndex] || `视频 ${task.variantIndex + 1}`
|
||||
const video = (task.videos?.[0] || null) as GeneratedVideo | null
|
||||
return (
|
||||
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
|
||||
<div
|
||||
key={task.taskId}
|
||||
className={`xx-batch-gen-card status-${task.status}`}
|
||||
style={{ maxWidth: 320 }}
|
||||
>
|
||||
<div className="xx-batch-gen-card-head">
|
||||
<span className="xx-batch-gen-card-title" title={title}>
|
||||
{task.status === "completed" ? (
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface GenerateStepContentProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
/** 当前模板创建片段被判失效(404/400/422)时的自动回退回调(#1777) */
|
||||
onTemplateInvalid?: () => boolean
|
||||
/* 生成 */
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
@@ -108,6 +110,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
onServerClipsChange,
|
||||
onTemplateInvalid,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
@@ -153,6 +156,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedTemplate={selectedTemplate}
|
||||
templateSegments={templateSegments}
|
||||
onServerClipsChange={onServerClipsChange}
|
||||
onTemplateInvalid={onTemplateInvalid}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -189,7 +193,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=进度状态卡(成片播放器在左侧大区域) */
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态(完成后只显示成片播放器,播放器在按钮上方) */
|
||||
if (previewCount > 1) {
|
||||
return (
|
||||
<BatchGenerationGrid
|
||||
@@ -199,10 +203,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
}
|
||||
/* 单视频:渲染进度 / 失败重试 / 完成提示(成片播放器在右侧栏) */
|
||||
/* 单视频:生成中显示进度卡、失败显示重试卡;生成完成后不再渲染提示卡,页面只保留成片播放器+操作按钮 */
|
||||
if (generated && !generating && !generateError) return null
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 确认生成</h3>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
@@ -234,14 +238,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">右侧可预览成片,点击「下一步」选择封面</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 6:
|
||||
|
||||
@@ -23,6 +23,8 @@ interface Step2MaterialSelectProps {
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
|
||||
onTemplateInvalid?: () => boolean
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
@@ -36,16 +38,25 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择视频库</label>
|
||||
<select
|
||||
value={m.selectedLibraryId}
|
||||
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
|
||||
>
|
||||
{m.libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{m.libraries.length === 0 && !m.materialsLoading ? (
|
||||
<div className="xx-empty-state">
|
||||
<p>暂无视频素材库</p>
|
||||
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
|
||||
请先在「素材库」中创建视频素材库并上传视频
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={m.selectedLibraryId}
|
||||
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
|
||||
>
|
||||
{m.libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{m.materialMode === "manual" && (
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
interface GenerationStatusProps {
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
getGenerationPhase: (progress: number) => { icon: string; label: string }
|
||||
onScrollToPreview: () => void
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const GenerationStatus: React.FC<GenerationStatusProps> = ({
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
onScrollToPreview,
|
||||
onRetry,
|
||||
onDismissError,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{!generating && !generated && !generateError && (
|
||||
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">🎬</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">尚未开始生成视频</div>
|
||||
<div className="xx-gen-progress-sub">
|
||||
请返回「选择标题」步骤,点击「确认生成视频」开始渲染最终视频
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationStatus
|
||||
@@ -117,10 +117,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-generate-layout.full-width .xx-generate-right-col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
左侧表单区 generate-form
|
||||
============================================================ */
|
||||
@@ -2323,28 +2319,6 @@
|
||||
生成结果(右侧)
|
||||
================================================================ */
|
||||
|
||||
.xx-generate-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── 内联视频播放器(右侧) ── */
|
||||
.xx-inline-video-player {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.xx-inline-video-player video {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.xx-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2710,11 +2684,12 @@
|
||||
|
||||
/* ── 封面设置区域改造样式 ── */
|
||||
|
||||
/* 封面操作按钮区 */
|
||||
/* 封面操作按钮区(单视频全宽页居中) */
|
||||
.xx-cover-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 已选模板文字 */
|
||||
@@ -3202,10 +3177,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 批量封面网格 ── */
|
||||
/* ── 批量封面网格(单卡/少卡时居中排列,卡片限宽不拉伸) ── */
|
||||
.xx-cover-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 220px));
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -3403,6 +3379,7 @@
|
||||
第5步确认生成:批量渲染进度网格(Issue #1677)
|
||||
============================================================ */
|
||||
.xx-batch-gen-grid {
|
||||
justify-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
|
||||
justify-content: center;
|
||||
@@ -3481,6 +3458,7 @@
|
||||
/* ── 响应式:窄屏批量网格回退单列(.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
|
||||
@media (max-width: 960px) {
|
||||
.xx-batch-gen-grid {
|
||||
justify-items: center;
|
||||
grid-template-columns: minmax(0, 320px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,22 @@ import type { AssetItem } from "@/api/assets"
|
||||
* 管理素材库列表、当前选中库、素材列表加载
|
||||
*/
|
||||
export function useMaterialLibrary() {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
/* ── 素材库数据 API ──
|
||||
* Step2 是视频选片,只拉取 kind=video 的素材库(#1777):
|
||||
* 后端按 kind 查询参数过滤,前端 getAssetLibraries("video") 再兜底过滤一次,
|
||||
* 避免配音库(voice)/图片库(image) 混进「选择视频库」下拉。
|
||||
* queryKey 带 kind,与素材管理页/配音页的 ["asset-libraries"] 全量缓存隔离。
|
||||
*/
|
||||
const { data: allLibraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries", "video"],
|
||||
queryFn: () => getAssetLibraries("video"),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
// 前端兜底过滤:仅保留 kind=video 的素材库(后端按 kind 查询参数过滤)
|
||||
const libraries = useMemo(
|
||||
() => allLibraries.filter((lib) => lib.kind === "video"),
|
||||
[allLibraries],
|
||||
)
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface GenerateFormState {
|
||||
selectedTemplate: string
|
||||
setSelectedTemplate: (id: string) => void
|
||||
userTemplates: EditingTemplate[]
|
||||
/** 当前选中模板在创建片段时被判失效(404/400/422)后的运行时自动回退 */
|
||||
handleInvalidTemplate: () => boolean
|
||||
|
||||
/* 素材 */
|
||||
selectedMaterials: string[]
|
||||
@@ -131,7 +133,8 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
/* ── 模板选择 ── */
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates, handleInvalidTemplate } =
|
||||
useTemplateSelection()
|
||||
|
||||
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
|
||||
// selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆;
|
||||
@@ -228,6 +231,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
handleInvalidTemplate,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 失效模板判定与自动回退工具(#1777)
|
||||
*
|
||||
* 背景:用户进入生成页后,之前选中的模板可能已被删除、或从未配置片段。
|
||||
* 调用片段相关接口(PUT/POST /templates/{id}/editor/clips[...]/from-assets)时:
|
||||
* - 模板不存在 → 后端返回 404(并行工单 #1774 把「模板不存在」统一为该状态码)
|
||||
* - 模板无片段配置 → 当前部分场景返回 400(detail 含「片段配置」),
|
||||
* 参数校验类错误返回 422
|
||||
* 这三类响应都说明「当前选中的模板不可用于生成」,应清除失效选择并自动切换到
|
||||
* 第一个有效模板,同时提示用户,而不是让页面卡死、无任何反馈。
|
||||
*/
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
/** 失效模板相关的 HTTP 状态码 */
|
||||
const INVALID_TEMPLATE_STATUSES = new Set([404, 400, 422])
|
||||
|
||||
/**
|
||||
* 从任意抛出值(axios 错误)提取 HTTP 状态码。
|
||||
* 非 axios 错误 / 无响应时返回 null。
|
||||
*/
|
||||
export function getHttpStatus(err: unknown): number | null {
|
||||
if (!err || typeof err !== "object") return null
|
||||
const status = (err as { response?: { status?: number }; status?: number })?.response?.status
|
||||
return typeof status === "number" ? status : null
|
||||
}
|
||||
|
||||
/** 安全提取后端错误文本(detail/message/msg,422 数组也兜底拼一下) */
|
||||
function extractErrorText(err: unknown): string {
|
||||
if (!err || typeof err !== "object") return ""
|
||||
const data = (err as { response?: { data?: unknown } })?.response?.data
|
||||
if (!data) return ""
|
||||
try {
|
||||
const text = JSON.stringify(data)
|
||||
return typeof text === "string" ? text : ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一次 clips/from-assets 请求失败是否因为「模板失效」。
|
||||
*
|
||||
* 严格判定,避免把无关的 400/422(例如素材参数问题)误判为模板失效:
|
||||
* - 404:模板/编辑计划不存在,一定是模板失效
|
||||
* - 400:仅当后端文本明确提到「片段配置」(无片段配置无法创建片段)才判定
|
||||
* - 422:参数校验类,from-assets 场景下命中「片段/segments」相关字段才判定
|
||||
*/
|
||||
export function isInvalidTemplateError(err: unknown): boolean {
|
||||
const status = getHttpStatus(err)
|
||||
if (status === null || !INVALID_TEMPLATE_STATUSES.has(status)) return false
|
||||
if (status === 404) return true
|
||||
|
||||
const text = extractErrorText(err)
|
||||
if (status === 400) {
|
||||
// 后端当前返回:「模板没有片段配置,无法创建片段」
|
||||
return /片段配置|没有片段|无片段|segments?|clip.*config/i.test(text)
|
||||
}
|
||||
// 422:FastAPI 校验错误,命中模板片段相关字段
|
||||
return /segment|clip|片段|模板/i.test(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模板是否可用于生成(有效模板)。
|
||||
*
|
||||
* 有效 = 处于激活态(is_active !== false,字段缺失视为 true 兼容旧后端)
|
||||
* 且至少配置了一个片段。
|
||||
* 与后端 valid_only 过滤口径保持一致(#1769/#1772),这里是前端双保险。
|
||||
*/
|
||||
export function isValidTemplate(template: EditingTemplate | null | undefined): boolean {
|
||||
if (!template) return false
|
||||
if (template.is_active === false) return false
|
||||
return (template.segments?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
/** 从模板列表中取出第一个有效模板,没有则返回 null */
|
||||
export function findFirstValidTemplate(
|
||||
templates: EditingTemplate[] | null | undefined,
|
||||
): EditingTemplate | null {
|
||||
if (!Array.isArray(templates)) return null
|
||||
return templates.find(isValidTemplate) ?? null
|
||||
}
|
||||
@@ -1,22 +1,89 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { findFirstValidTemplate, isValidTemplate } from "./templateFallback"
|
||||
|
||||
/** 失效模板自动切换的提示文案 */
|
||||
export const INVALID_TEMPLATE_FALLBACK_TOAST = "原模板已失效,已自动切换"
|
||||
|
||||
export function useTemplateSelection() {
|
||||
// selectedTemplate 纯内存状态,绝不写入 localStorage/sessionStorage/URL,
|
||||
// 因此失效模板 ID 不会被持久化、刷新后也不会恢复(#1777 要求 4)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
|
||||
|
||||
const { data: allTemplates = [] } = useQuery<EditingTemplate[]>({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
// valid_only:后端过滤掉没有片段配置的无效模板(#1769/#1772)。
|
||||
// 旧后端忽略该 query 参数时,下方 isValidTemplate 前端兜底再过滤一次。
|
||||
queryFn: () => getEditingTemplates({ validOnly: true }),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id)
|
||||
}
|
||||
}, [userTemplates, selectedTemplate])
|
||||
// 双保险:后端 valid_only 已过滤,前端再按 is_active + segments 兜底,
|
||||
// 保证下拉/自动选择只包含可用于生成的有效模板。
|
||||
// 用 useMemo 缓存引用,避免每次渲染都 .filter 创建新数组,
|
||||
// 导致下游 useTitleCoverSync effect 无限触发、覆盖用户手动修改(#1789)
|
||||
const validTemplates = useMemo(() => allTemplates.filter(isValidTemplate), [allTemplates])
|
||||
const userTemplates = validTemplates
|
||||
|
||||
return { selectedTemplate, setSelectedTemplate, userTemplates }
|
||||
// 用 ref 持有最新值,供稳定回调 handleInvalidTemplate 使用(避免闭包拿到旧值)
|
||||
const templatesRef = useRef(validTemplates)
|
||||
templatesRef.current = validTemplates
|
||||
const selectedRef = useRef(selectedTemplate)
|
||||
selectedRef.current = selectedTemplate
|
||||
// 已提示过失效的模板 ID,避免用户停留在失效模板上时 clips 防抖请求反复弹 toast;
|
||||
// 用户手动切换/成功切换后重置,保证下一个失效模板仍能提示
|
||||
const fallbackNotifiedRef = useRef<string>("")
|
||||
|
||||
/* 自动选择:模板加载完成且当前未选中时,自动选中第一个有效模板。
|
||||
* 用户手动选择(setSelectedTemplate 被显式调用)后 selectedTemplate 非空,
|
||||
* 本 effect 直接 return,绝不覆盖用户的手动选择(#1777 要求 4:手动优先)。 */
|
||||
useEffect(() => {
|
||||
if (selectedTemplate) return
|
||||
const firstValid = validTemplates[0]
|
||||
if (firstValid) {
|
||||
setSelectedTemplate(firstValid.id)
|
||||
}
|
||||
}, [validTemplates, selectedTemplate])
|
||||
|
||||
/** 用户手动选择模板:优先级最高,重置失效提示标记 */
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
fallbackNotifiedRef.current = ""
|
||||
setSelectedTemplate(id)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 运行时失效回退(#1777 要求 3):
|
||||
* 创建片段接口返回 404(模板不存在)/ 400/422(模板无片段配置)时调用。
|
||||
* - 清除失效选择,自动切换到第一个有效模板,并 toast 提示;
|
||||
* - 没有有效模板时清空选择,Step1 展示明确的「暂无可用模板」空状态引导,
|
||||
* 不让用户卡在失效模板上。
|
||||
* 返回 true 表示已按「模板失效」处理(调用方可据此静默原始错误提示)。
|
||||
*/
|
||||
const handleInvalidTemplate = useCallback((): boolean => {
|
||||
const current = selectedRef.current
|
||||
// 同一个失效模板只提示一次(clips 防抖 effect 在素材/模板变化时会反复触发)
|
||||
if (current && fallbackNotifiedRef.current === current) return true
|
||||
|
||||
const fallback = findFirstValidTemplate(templatesRef.current)
|
||||
fallbackNotifiedRef.current = current || "__empty__"
|
||||
if (fallback) {
|
||||
setSelectedTemplate(fallback.id)
|
||||
message.warning(INVALID_TEMPLATE_FALLBACK_TOAST)
|
||||
} else {
|
||||
// 没有任何有效模板:清空选择,交由 Step1 空状态引导用户去模板编辑器创建
|
||||
setSelectedTemplate("")
|
||||
message.warning("当前没有可用模板,请先在「模板编辑器」中创建并配置片段")
|
||||
}
|
||||
return true
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedTemplate,
|
||||
setSelectedTemplate: handleSelectTemplate,
|
||||
userTemplates,
|
||||
handleInvalidTemplate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useRef } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -11,7 +11,11 @@ interface UseTitleCoverSyncOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* 当选中模板变化时,自动同步标题和封面配置
|
||||
* 当选中模板变化时,自动同步标题和封面配置。
|
||||
*
|
||||
* #1789 修复:userTemplates 用 ref 持有最新值,不放入依赖数组。
|
||||
* 否则每次渲染 .filter() 创建的新数组引用都会触发 effect,
|
||||
* 从模板 title_config 覆盖用户手动修改(如字号滑块拖动),导致回弹。
|
||||
*/
|
||||
export function useTitleCoverSync({
|
||||
selectedTemplate,
|
||||
@@ -19,8 +23,12 @@ export function useTitleCoverSync({
|
||||
setTitleSettings,
|
||||
setCoverSettings,
|
||||
}: UseTitleCoverSyncOptions) {
|
||||
// 用 ref 持有最新 userTemplates,避免数组引用变化导致 effect 反复触发
|
||||
const templatesRef = useRef(userTemplates)
|
||||
templatesRef.current = userTemplates
|
||||
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
const tpl = templatesRef.current.find((t) => t.id === selectedTemplate)
|
||||
if (tpl?.title_config) {
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
@@ -43,5 +51,7 @@ export function useTitleCoverSync({
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
}, [selectedTemplate, userTemplates, setTitleSettings, setCoverSettings])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTemplate, setTitleSettings, setCoverSettings])
|
||||
// ↑ 移除 userTemplates,只在 selectedTemplate 真正变化时触发
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
import { isInvalidTemplateError } from "./useGenerateFormState/templateFallback"
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -24,6 +25,8 @@ interface UseStep2MaterialsProps {
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
|
||||
onTemplateInvalid?: () => boolean
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -36,6 +39,7 @@ export function useStep2Materials({
|
||||
selectedTemplate,
|
||||
templateSegments,
|
||||
onServerClipsChange,
|
||||
onTemplateInvalid,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const {
|
||||
libraries,
|
||||
@@ -102,6 +106,8 @@ export function useStep2Materials({
|
||||
selectedTemplateRef.current = selectedTemplate
|
||||
const onServerClipsChangeRef = useRef(onServerClipsChange)
|
||||
onServerClipsChangeRef.current = onServerClipsChange
|
||||
const onTemplateInvalidRef = useRef(onTemplateInvalid)
|
||||
onTemplateInvalidRef.current = onTemplateInvalid
|
||||
|
||||
useEffect(() => {
|
||||
const tid = selectedTemplateRef.current
|
||||
@@ -123,11 +129,12 @@ export function useStep2Materials({
|
||||
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
|
||||
|
||||
try {
|
||||
// 1. 清空旧片段
|
||||
await updateEditPlanClips(tid, [], controller.signal)
|
||||
// 1. 清空旧片段(静默全局 toast:模板失效时由下方回退统一提示)
|
||||
await updateEditPlanClips(tid, [], controller.signal, true)
|
||||
// 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底)
|
||||
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
|
||||
signal: controller.signal,
|
||||
silentErrorToast: true,
|
||||
})
|
||||
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
|
||||
const clipList = await getEditPlanClips(tid, { limit: 500 })
|
||||
@@ -146,6 +153,14 @@ export function useStep2Materials({
|
||||
message.error("智能选片失败,请重试")
|
||||
return
|
||||
}
|
||||
// 模板失效(404 模板不存在 / 400/422 无片段配置):
|
||||
// 清空失效选择并自动切到第一个有效模板 + toast,避免页面卡死无提示(#1777)
|
||||
if (isInvalidTemplateError(err)) {
|
||||
console.warn("[useStep2Materials] 当前模板已失效,触发自动回退:", err)
|
||||
onServerClipsChangeRef.current?.([])
|
||||
onTemplateInvalidRef.current?.()
|
||||
return
|
||||
}
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
}
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
queryFn: () => getAssetLibraries(),
|
||||
})
|
||||
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMateri
|
||||
// ── 获取 voice 类型素材库 ─────────────────────────────────
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
queryFn: () => getAssetLibraries(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
/* 获取或创建默认配音库 */
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
queryFn: () => getAssetLibraries(),
|
||||
})
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
@@ -56,6 +56,10 @@ const appChildren: RouteObject[] = [
|
||||
path: "editing-planner",
|
||||
lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")),
|
||||
},
|
||||
{
|
||||
path: "ai-avatar",
|
||||
lazy: lazyRoute(() => import("@/pages/ai-avatar/AiAvatarPage")),
|
||||
},
|
||||
{
|
||||
path: "my-templates",
|
||||
lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 失效模板判定/回退纯函数单测(#1777)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import {
|
||||
getHttpStatus,
|
||||
isInvalidTemplateError,
|
||||
isValidTemplate,
|
||||
findFirstValidTemplate,
|
||||
} from "@/pages/generate/hooks/useGenerateFormState/templateFallback"
|
||||
|
||||
function makeTemplate(partial: Partial<EditingTemplate> & { id: string }): EditingTemplate {
|
||||
return {
|
||||
name: partial.id,
|
||||
mode: "pip",
|
||||
category: "默认",
|
||||
tags: [],
|
||||
title_config: {
|
||||
ai_auto_select: false,
|
||||
content: "",
|
||||
font_preset: "",
|
||||
font_color: "",
|
||||
font_size: 28,
|
||||
position: "top",
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: true,
|
||||
position: "bottom",
|
||||
font: "",
|
||||
color: "",
|
||||
size: 20,
|
||||
animation: "",
|
||||
},
|
||||
bgm_config: { enabled: false, music_id: "" },
|
||||
segments: [{ segment_order: 0, material_type: null }],
|
||||
is_active: true,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...partial,
|
||||
} as EditingTemplate
|
||||
}
|
||||
|
||||
function axiosError(status: number, data?: unknown) {
|
||||
return { isAxiosError: true, response: { status, data } }
|
||||
}
|
||||
|
||||
describe("getHttpStatus", () => {
|
||||
it("提取 axios 错误的 HTTP 状态码", () => {
|
||||
expect(getHttpStatus(axiosError(404))).toBe(404)
|
||||
expect(getHttpStatus(axiosError(400))).toBe(400)
|
||||
})
|
||||
it("非 axios/无响应错误返回 null", () => {
|
||||
expect(getHttpStatus(new Error("network"))).toBeNull()
|
||||
expect(getHttpStatus(null)).toBeNull()
|
||||
expect(getHttpStatus(undefined)).toBeNull()
|
||||
expect(getHttpStatus({ isAxiosError: true })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isInvalidTemplateError", () => {
|
||||
it("404 始终判定为模板失效(模板不存在)", () => {
|
||||
expect(isInvalidTemplateError(axiosError(404))).toBe(true)
|
||||
expect(isInvalidTemplateError(axiosError(404, { detail: "Not Found" }))).toBe(true)
|
||||
})
|
||||
|
||||
it("400 且后端文案提到「片段配置」判定为模板无片段配置", () => {
|
||||
expect(
|
||||
isInvalidTemplateError(axiosError(400, { detail: "模板没有片段配置,无法创建片段" })),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("400 但文案与片段配置无关 → 不误判", () => {
|
||||
expect(isInvalidTemplateError(axiosError(400, { detail: "素材参数错误" }))).toBe(false)
|
||||
})
|
||||
|
||||
it("422 命中片段/模板字段判定为失效", () => {
|
||||
expect(
|
||||
isInvalidTemplateError(
|
||||
axiosError(422, { detail: [{ loc: ["body", "segments"], msg: "field required" }] }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("其他状态码(401/403/500/超时/网络)不判定为模板失效", () => {
|
||||
expect(isInvalidTemplateError(axiosError(401))).toBe(false)
|
||||
expect(isInvalidTemplateError(axiosError(403))).toBe(false)
|
||||
expect(isInvalidTemplateError(axiosError(500))).toBe(false)
|
||||
expect(isInvalidTemplateError({ code: "ECONNABORTED", message: "timeout of 60000ms" })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isInvalidTemplateError(new Error("Network Error"))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isValidTemplate", () => {
|
||||
it("有片段且未被标记 inactive → 有效", () => {
|
||||
expect(isValidTemplate(makeTemplate({ id: "t1" }))).toBe(true)
|
||||
})
|
||||
it("segments 为空 → 无效(无片段配置)", () => {
|
||||
expect(isValidTemplate(makeTemplate({ id: "t2", segments: [] }))).toBe(false)
|
||||
})
|
||||
it("is_active=false → 无效(已停用/删除)", () => {
|
||||
expect(isValidTemplate(makeTemplate({ id: "t3", is_active: false }))).toBe(false)
|
||||
})
|
||||
it("is_active 字段缺失时视为有效(兼容旧后端)", () => {
|
||||
const t = makeTemplate({ id: "t4" })
|
||||
delete (t as Partial<EditingTemplate>).is_active
|
||||
expect(isValidTemplate(t)).toBe(true)
|
||||
})
|
||||
it("null/undefined → 无效", () => {
|
||||
expect(isValidTemplate(null)).toBe(false)
|
||||
expect(isValidTemplate(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findFirstValidTemplate", () => {
|
||||
it("跳过无效模板,返回第一个有效模板", () => {
|
||||
const list = [
|
||||
makeTemplate({ id: "empty", segments: [] }),
|
||||
makeTemplate({ id: "inactive", is_active: false }),
|
||||
makeTemplate({ id: "valid1" }),
|
||||
makeTemplate({ id: "valid2" }),
|
||||
]
|
||||
expect(findFirstValidTemplate(list)?.id).toBe("valid1")
|
||||
})
|
||||
it("全部无效 → null(用于空状态引导)", () => {
|
||||
expect(
|
||||
findFirstValidTemplate([
|
||||
makeTemplate({ id: "a", segments: [] }),
|
||||
makeTemplate({ id: "b", is_active: false }),
|
||||
]),
|
||||
).toBeNull()
|
||||
})
|
||||
it("空数组/null → null", () => {
|
||||
expect(findFirstValidTemplate([])).toBeNull()
|
||||
expect(findFirstValidTemplate(null)).toBeNull()
|
||||
expect(findFirstValidTemplate(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* useMaterialLibrary Hook 单测(#1777)
|
||||
* - Step2 视频库选择器只拉取 kind=video 的素材库,配音库(voice)/图片库(image) 不混入
|
||||
* - 自动选中第一个视频库
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
import type { AssetItem, AssetLibraryItem } from "@/api/assets"
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn(),
|
||||
getAssets: vi.fn(),
|
||||
isAssetUsable: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
import { getAssetLibraries, getAssets } from "@/api/assets"
|
||||
import { useMaterialLibrary } from "@/pages/generate/hooks/step2-materials/useMaterialLibrary"
|
||||
|
||||
const mockGetLibraries = vi.mocked(getAssetLibraries)
|
||||
const mockGetAssets = vi.mocked(getAssets)
|
||||
|
||||
function lib(id: string, kind: AssetLibraryItem["kind"], name = id): AssetLibraryItem {
|
||||
return { id, name, kind }
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAssets.mockResolvedValue({ items: [] as AssetItem[], total: 0 })
|
||||
})
|
||||
|
||||
describe("useMaterialLibrary (#1777 kind=video 过滤)", () => {
|
||||
it("按 kind=video 拉取素材库(后端参数过滤)", async () => {
|
||||
mockGetLibraries.mockResolvedValueOnce([lib("v1", "video")])
|
||||
renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalledTimes(1))
|
||||
expect(mockGetLibraries).toHaveBeenCalledWith("video")
|
||||
})
|
||||
|
||||
it("下拉库列表只包含视频库(自动选中第一个视频库)", async () => {
|
||||
mockGetLibraries.mockResolvedValueOnce([
|
||||
lib("voice-1", "voice"),
|
||||
lib("img-1", "image"),
|
||||
lib("video-1", "video"),
|
||||
lib("video-2", "video"),
|
||||
])
|
||||
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(result.current.libraries).toHaveLength(2))
|
||||
expect(result.current.libraries.map((l) => l.id)).toEqual(["video-1", "video-2"])
|
||||
expect(result.current.libraries.every((l) => l.kind === "video")).toBe(true)
|
||||
// 自动选中第一个视频库
|
||||
expect(result.current.selectedLibraryId).toBe("video-1")
|
||||
})
|
||||
|
||||
it("没有视频库时库列表为空且不自动选中(UI 展示空状态)", async () => {
|
||||
mockGetLibraries.mockResolvedValueOnce([lib("voice-1", "voice"), lib("img-1", "image")])
|
||||
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalled())
|
||||
expect(result.current.libraries).toEqual([])
|
||||
expect(result.current.selectedLibraryId).toBe("")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* useTemplateSelection Hook 单测(#1777)
|
||||
* - 自动选择跳过无片段/inactive 模板,只选第一个有效模板
|
||||
* - 传 validOnly=true 给后端
|
||||
* - 用户手动选择优先,自动逻辑不覆盖
|
||||
* - handleInvalidTemplate:失效时自动切到第一个有效模板 + toast;无有效模板时清空
|
||||
* - selectedTemplate 仅内存态,不写入 localStorage/sessionStorage
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { renderHook, waitFor, act } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
// antd message mock(拦截 toast)——vi.hoisted 保证 mock 工厂可引用
|
||||
const { messageMock } = vi.hoisted(() => ({
|
||||
messageMock: {
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(() => vi.fn()),
|
||||
},
|
||||
}))
|
||||
vi.mock("antd", () => ({ message: messageMock }))
|
||||
|
||||
vi.mock("@/api/editing-planner", () => ({
|
||||
getEditingTemplates: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useTemplateSelection } from "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
const mockGetTemplates = vi.mocked(getEditingTemplates)
|
||||
|
||||
function tpl(id: string, partial: Partial<EditingTemplate> = {}): EditingTemplate {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
mode: "pip",
|
||||
category: "默认",
|
||||
tags: [],
|
||||
title_config: {
|
||||
ai_auto_select: false,
|
||||
content: "",
|
||||
font_preset: "",
|
||||
font_color: "",
|
||||
font_size: 28,
|
||||
position: "top",
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: true,
|
||||
position: "bottom",
|
||||
font: "",
|
||||
color: "",
|
||||
size: 20,
|
||||
animation: "",
|
||||
},
|
||||
bgm_config: { enabled: false, music_id: "" },
|
||||
segments: [{ segment_order: 0, material_type: null }],
|
||||
is_active: true,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...partial,
|
||||
} as EditingTemplate
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe("useTemplateSelection (#1777)", () => {
|
||||
it("请求模板时传 validOnly=true,并自动选中第一个有片段的有效模板", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([
|
||||
tpl("empty", { segments: [] }),
|
||||
tpl("inactive", { is_active: false }),
|
||||
tpl("valid-a"),
|
||||
tpl("valid-b"),
|
||||
])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("valid-a"))
|
||||
expect(mockGetTemplates).toHaveBeenCalledWith({ validOnly: true })
|
||||
// 暴露给 UI 的 userTemplates 已过滤掉无效模板
|
||||
expect(result.current.userTemplates.map((t) => t.id)).toEqual(["valid-a", "valid-b"])
|
||||
})
|
||||
|
||||
it("列表全部无效时 selectedTemplate 为空(交空状态引导),不选中失效模板", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([
|
||||
tpl("empty", { segments: [] }),
|
||||
tpl("inactive", { is_active: false }),
|
||||
])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
await waitFor(() => expect(mockGetTemplates).toHaveBeenCalled())
|
||||
// 给 effect 一个 tick
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
|
||||
expect(result.current.userTemplates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("用户手动选择优先:自动逻辑不会覆盖手动选择", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([tpl("a"), tpl("b")])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("a"))
|
||||
|
||||
act(() => result.current.setSelectedTemplate("b"))
|
||||
expect(result.current.selectedTemplate).toBe("b")
|
||||
|
||||
// 重新渲染 / refetch 后仍保持用户的手动选择
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("b"))
|
||||
})
|
||||
|
||||
it("handleInvalidTemplate:当前模板失效时自动切到第一个有效模板并 toast", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] }), tpl("good")])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
// 自动选中有效模板 good(bad 无片段不会被自动选中)
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
|
||||
messageMock.warning.mockClear()
|
||||
|
||||
// 模拟运行时用户停留在一个已失效的模板 id(外部/草稿态),触发回退
|
||||
act(() => result.current.setSelectedTemplate("stale-id"))
|
||||
expect(result.current.selectedTemplate).toBe("stale-id")
|
||||
|
||||
act(() => {
|
||||
const handled = result.current.handleInvalidTemplate()
|
||||
expect(handled).toBe(true)
|
||||
})
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
|
||||
expect(messageMock.warning).toHaveBeenCalledWith("原模板已失效,已自动切换")
|
||||
})
|
||||
|
||||
it("handleInvalidTemplate:无有效模板时清空选择并提示去创建", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] })])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
await waitFor(() => expect(result.current.userTemplates).toHaveLength(0))
|
||||
|
||||
act(() => result.current.setSelectedTemplate("stale-id"))
|
||||
act(() => {
|
||||
result.current.handleInvalidTemplate()
|
||||
})
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
|
||||
expect(messageMock.warning).toHaveBeenCalledWith(expect.stringContaining("没有可用模板"))
|
||||
})
|
||||
|
||||
it("失效模板 ID 不写入任何持久化存储", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([tpl("good")])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
|
||||
|
||||
act(() => result.current.setSelectedTemplate("stale-invalid-id"))
|
||||
act(() => result.current.handleInvalidTemplate())
|
||||
|
||||
const ls = JSON.stringify(localStorage)
|
||||
const ss = JSON.stringify(sessionStorage)
|
||||
expect(ls).not.toContain("stale-invalid-id")
|
||||
expect(ss).not.toContain("stale-invalid-id")
|
||||
// URL 也不含
|
||||
expect(window.location.href).not.toContain("stale-invalid-id")
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,8 @@ class BGMConfig:
|
||||
sidechain_attack: float = 0.02 # 攻击时间
|
||||
sidechain_release: float = 0.5 # 释放时间
|
||||
sidechain_threshold: float = -25.0 # 触发阈值(dB)
|
||||
audio_offset: float = 0.0 # BGM 段落起始偏移(秒),#1767 策略二
|
||||
volume_adjust_db: float = 0.0 # 音量微调 dB(-3~+3),#1767 策略三
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, bgm_path: str, config: dict) -> "BGMConfig":
|
||||
@@ -54,6 +56,8 @@ class BGMConfig:
|
||||
sidechain_attack=float(config.get("sidechain_attack", 0.02)),
|
||||
sidechain_release=float(config.get("sidechain_release", 0.5)),
|
||||
sidechain_threshold=float(config.get("sidechain_threshold", -25.0)),
|
||||
audio_offset=float(config.get("audio_offset", 0.0)),
|
||||
volume_adjust_db=float(config.get("volume_adjust_db", 0.0)),
|
||||
)
|
||||
|
||||
|
||||
@@ -84,14 +88,18 @@ def prepare_bgm_track(
|
||||
target_duration = 5.0 # 兜底
|
||||
|
||||
bgm_dur = probe_duration(bgm.bgm_path)
|
||||
needs_loop = bgm.loop_enabled and bgm_dur > 0 and bgm_dur < target_duration * 0.9
|
||||
# #1767:seek 后有效时长 = 总时长 - 偏移
|
||||
effective_dur = (
|
||||
max(1.0, bgm_dur - bgm.audio_offset) if bgm.audio_offset > 0 and bgm_dur > bgm.audio_offset else bgm_dur
|
||||
)
|
||||
needs_loop = bgm.loop_enabled and effective_dur > 0 and effective_dur < target_duration * 0.9
|
||||
|
||||
# 构建滤镜链
|
||||
filter_parts: list[str] = []
|
||||
|
||||
if needs_loop:
|
||||
# 计算需要循环多少次才能铺满
|
||||
loop_count = max(1, int(target_duration / bgm_dur) + 2)
|
||||
# 计算需要循环多少次才能铺满(基于 seek 后有效时长)
|
||||
loop_count = max(1, int(target_duration / effective_dur) + 2)
|
||||
# aloop 滤镜:循环指定次数
|
||||
filter_parts.append(f"aloop=loop={loop_count}:size=0")
|
||||
|
||||
@@ -113,11 +121,30 @@ def prepare_bgm_track(
|
||||
filter_parts.append(f"atrim=0:{target_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB") # 重置时间戳
|
||||
|
||||
filter_str = ",".join(filter_parts)
|
||||
# #1767:BGM 段落差异化 — 使用 -ss 从偏移位置开始(seek 效率高,不读跳过部分)
|
||||
seek_args: list[str] = []
|
||||
if bgm.audio_offset > 0 and bgm_dur > bgm.audio_offset:
|
||||
seek_args = ["-ss", f"{bgm.audio_offset:.3f}"]
|
||||
logger.info("[bgm] #1767 audio_offset=%.1fs(段落差异化)", bgm.audio_offset)
|
||||
|
||||
# #1767:音量微调 — dB 转线性系数(10^(dB/20))
|
||||
db_adjust_filter = ""
|
||||
if abs(bgm.volume_adjust_db) > 0.01:
|
||||
linear_factor = 10.0 ** (bgm.volume_adjust_db / 20.0)
|
||||
db_adjust_filter = f",volume={linear_factor:.4f}"
|
||||
logger.info("[bgm] #1767 volume_adjust=%.0fdB → linear=%.4f", bgm.volume_adjust_db, linear_factor)
|
||||
|
||||
# #1767:追加 dB 微调到滤镜链末尾
|
||||
if db_adjust_filter:
|
||||
filter_str_base = ",".join(filter_parts)
|
||||
filter_str = filter_str_base + db_adjust_filter
|
||||
else:
|
||||
filter_str = ",".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*seek_args,
|
||||
"-i",
|
||||
bgm.bgm_path,
|
||||
"-filter:a",
|
||||
|
||||
@@ -7,6 +7,7 @@ import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
@@ -56,8 +57,13 @@ MAX_GAP = 2 # 允许的最大间隙帧数
|
||||
NEIGHBOR_WINDOW = 1 # 分片时序对齐:允许 ±1 邻接偏移(1s 密集采样下即 ±1s,缓解切点不一致)
|
||||
|
||||
# ── 融合判定常量 ────────────────────────────────────────────────
|
||||
PHASH_WEIGHT = 0.7 # pHash 权重
|
||||
HISTOGRAM_WEIGHT = 0.3 # 直方图权重
|
||||
PHASH_WEIGHT = 0.7 # pHash 权重(视觉内部)
|
||||
HISTOGRAM_WEIGHT = 0.3 # 直方图权重(视觉内部)
|
||||
|
||||
# ── 多维度查重融合权重(Issue #P2-后端3) ────────────────────────
|
||||
VISUAL_WEIGHT = 0.5 # 视觉相似度权重(pHash+直方图)
|
||||
TEXT_WEIGHT = 0.25 # 文案相似度权重(配音文本)
|
||||
STRUCTURE_WEIGHT = 0.25 # 结构相似度权重(片段序列)
|
||||
MATCH_RATIO_THRESHOLD = 0.7 # 全片重复(is_duplicate)至少 70% 帧匹配
|
||||
PARTIAL_COVERAGE_THRESHOLD = 0.5 # 局部复用覆盖率 >=50% 也判全片重复
|
||||
DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值
|
||||
@@ -1057,12 +1063,11 @@ class VideoDeduplicator:
|
||||
) -> dict:
|
||||
"""计算当前视频与已有视频的查重率百分比。
|
||||
|
||||
新公式(双指标加权):
|
||||
- frame_match_rate = 汉明距离 < PHASH_THRESHOLD 的帧数 / 总帧数
|
||||
- temporal_coverage_rate = 连续匹配片段总时长 / 视频总时长
|
||||
- duplicate_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100
|
||||
|
||||
visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim(归一化到 0~1)
|
||||
多维度融合公式(Issue #P2-后端3):
|
||||
- visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim(视觉维度)
|
||||
- text_similarity = 文案 Jaccard 相似度(文案维度)
|
||||
- structure_similarity = 片段序列相似度(结构维度)
|
||||
- duplicate_rate = (visual*0.5 + text*0.25 + structure*0.25) * 100
|
||||
|
||||
对每个匹配视频都算,取最高 duplicate_rate。
|
||||
|
||||
@@ -1093,6 +1098,29 @@ class VideoDeduplicator:
|
||||
match_count = 0
|
||||
evaluated = 0
|
||||
|
||||
# Issue #P2-后端3: 加载当前视频的文案+结构数据
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, GeneratedVideoModel
|
||||
|
||||
current_video_obj = (
|
||||
session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == current_video_id).first()
|
||||
if current_video_id
|
||||
else None
|
||||
)
|
||||
current_plan_id = getattr(current_video_obj, "edit_plan_id", "") or ""
|
||||
current_clips_data = []
|
||||
current_text_content = ""
|
||||
|
||||
if current_plan_id:
|
||||
current_clips = (
|
||||
session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.plan_id == current_plan_id)
|
||||
.order_by(EditPlanClipModel.order)
|
||||
.all()
|
||||
)
|
||||
current_clips_data = [{"clip_type": c.clip_type, "duration": c.duration} for c in current_clips]
|
||||
# 拼接所有片段的文本内容
|
||||
current_text_content = " ".join(c.text_content for c in current_clips if c.text_content)
|
||||
|
||||
for existing in existing_videos:
|
||||
if current_video_id and existing.id == current_video_id:
|
||||
continue
|
||||
@@ -1160,8 +1188,47 @@ class VideoDeduplicator:
|
||||
|
||||
# Issue #1702: 去掉 "frame_match_rate<0.3 整条跳过" 硬门槛——
|
||||
# 局部片段复用帧比例天然低;coverage 为主指标,0 匹配自然得 0 分。
|
||||
# duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage
|
||||
dup_rate = (min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6) * 100
|
||||
# 视觉维度:0.4 * frame_match_rate + 0.6 * temporal_coverage
|
||||
visual_sim = min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6
|
||||
|
||||
# Issue #P2-后端3: 文案+结构维度
|
||||
existing_plan_id = getattr(existing, "edit_plan_id", "") or ""
|
||||
existing_clips_data = []
|
||||
existing_text_content = ""
|
||||
|
||||
if existing_plan_id:
|
||||
existing_clips = (
|
||||
session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.plan_id == existing_plan_id)
|
||||
.order_by(EditPlanClipModel.order)
|
||||
.all()
|
||||
)
|
||||
existing_clips_data = [{"clip_type": c.clip_type, "duration": c.duration} for c in existing_clips]
|
||||
existing_text_content = " ".join(c.text_content for c in existing_clips if c.text_content)
|
||||
|
||||
# 计算文案相似度(有文案才算)
|
||||
text_sim = (
|
||||
compute_text_similarity(current_text_content, existing_text_content)
|
||||
if (current_text_content and existing_text_content)
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# 计算结构相似度(有片段才算)
|
||||
structure_sim = (
|
||||
compute_structure_similarity(current_clips_data, existing_clips_data)
|
||||
if (current_clips_data and existing_clips_data)
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# 多维度融合:visual*0.5 + text*0.25 + structure*0.25
|
||||
# 如果文案/结构数据缺失,只用视觉维度(visual 权重提升到 1.0)
|
||||
if current_text_content and existing_text_content and current_clips_data and existing_clips_data:
|
||||
dup_rate = (
|
||||
visual_sim * VISUAL_WEIGHT + text_sim * TEXT_WEIGHT + structure_sim * STRUCTURE_WEIGHT
|
||||
) * 100
|
||||
else:
|
||||
# 降级:只有视觉维度
|
||||
dup_rate = visual_sim * 100
|
||||
|
||||
# 全片重复计数与 check_duplicate 判定口径一致
|
||||
if ev["fusion"] >= DUPLICATE_THRESHOLD and (
|
||||
@@ -1190,6 +1257,110 @@ class VideoDeduplicator:
|
||||
}
|
||||
|
||||
|
||||
# ── 文案 & 结构维度查重(Issue #P2-后端3) ────────────────────────
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
"""文本标准化:去空白、转小写、去标点。"""
|
||||
if not text:
|
||||
return ""
|
||||
# 去空白字符
|
||||
text = re.sub(r"\s+", "", text)
|
||||
# 转小写
|
||||
text = text.lower()
|
||||
# 去标点(只保留中文、字母、数字)
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff]", "", text)
|
||||
return text
|
||||
|
||||
|
||||
def compute_text_similarity(text1: str, text2: str) -> float:
|
||||
"""计算两段文本的相似度(0~1)。
|
||||
|
||||
使用字符级 Jaccard 相似度:交集 / 并集。
|
||||
适合短文本(配音脚本)的相似度比对。
|
||||
|
||||
Args:
|
||||
text1: 第一段文本
|
||||
text2: 第二段文本
|
||||
|
||||
Returns:
|
||||
0~1 之间的相似度
|
||||
"""
|
||||
t1 = _normalize_text(text1)
|
||||
t2 = _normalize_text(text2)
|
||||
|
||||
if not t1 and not t2:
|
||||
return 1.0 # 都为空,视为完全相同
|
||||
if not t1 or not t2:
|
||||
return 0.0 # 一个为空,完全不同
|
||||
|
||||
# 字符级 Jaccard
|
||||
set1 = set(t1)
|
||||
set2 = set(t2)
|
||||
intersection = set1 & set2
|
||||
union = set1 | set2
|
||||
|
||||
if not union:
|
||||
return 0.0
|
||||
|
||||
return len(intersection) / len(union)
|
||||
|
||||
|
||||
def compute_structure_similarity(clips1: list[dict], clips2: list[dict]) -> float:
|
||||
"""计算两个视频的结构相似度(0~1)。
|
||||
|
||||
结构维度包括:
|
||||
1. 片段数差异(数量越接近越相似)
|
||||
2. 片段类型序列(相同位置的片段类型是否一致)
|
||||
3. 时长分布(各片段时长占比是否相似)
|
||||
|
||||
Args:
|
||||
clips1: 第一个视频的片段列表,每项包含 {clip_type, duration}
|
||||
clips2: 第二个视频的片段列表
|
||||
|
||||
Returns:
|
||||
0~1 之间的相似度
|
||||
"""
|
||||
if not clips1 and not clips2:
|
||||
return 1.0
|
||||
if not clips1 or not clips2:
|
||||
return 0.0
|
||||
|
||||
# 1. 片段数相似度(数量差异越大越低)
|
||||
n1, n2 = len(clips1), len(clips2)
|
||||
count_sim = min(n1, n2) / max(n1, n2)
|
||||
|
||||
# 2. 类型序列相似度(逐位比较,相同位置类型是否一致)
|
||||
min_len = min(n1, n2)
|
||||
type_matches = sum(1 for i in range(min_len) if clips1[i].get("clip_type") == clips2[i].get("clip_type"))
|
||||
type_sim = type_matches / min_len if min_len > 0 else 0.0
|
||||
|
||||
# 3. 时长分布相似度(归一化后比较分布)
|
||||
total1 = sum(c.get("duration", 0) for c in clips1)
|
||||
total2 = sum(c.get("duration", 0) for c in clips2)
|
||||
|
||||
if total1 > 0 and total2 > 0:
|
||||
# 归一化为占比
|
||||
dist1 = [c.get("duration", 0) / total1 for c in clips1]
|
||||
dist2 = [c.get("duration", 0) / total2 for c in clips2]
|
||||
|
||||
# 比较前 min_len 个片段的占比差异(L1 距离转相似度)
|
||||
l1_dist = sum(abs(dist1[i] - dist2[i]) for i in range(min_len))
|
||||
# 加上多出的片段占比
|
||||
if n1 > n2:
|
||||
l1_dist += sum(dist1[i] for i in range(n2, n1))
|
||||
elif n2 > n1:
|
||||
l1_dist += sum(dist2[i] for i in range(n1, n2))
|
||||
|
||||
# L1 距离范围 [0, 2],转为相似度 [0, 1]
|
||||
duration_sim = 1.0 - (l1_dist / 2.0)
|
||||
else:
|
||||
duration_sim = 0.0
|
||||
|
||||
# 三维度加权:数量 0.3 + 类型 0.4 + 时长 0.3
|
||||
return count_sim * 0.3 + type_sim * 0.4 + duration_sim * 0.3
|
||||
|
||||
|
||||
def _save_fingerprint_chunks(
|
||||
fingerprint: VideoFingerprint,
|
||||
video_id: str,
|
||||
|
||||
@@ -59,13 +59,18 @@ def build_xfade_filter_chain(
|
||||
transitions: list[str],
|
||||
*,
|
||||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||||
transition_durations: list[float] | None = None,
|
||||
jitters: list[float] | None = None,
|
||||
output_label: str = "outv",
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链(re-export,#1766 增加逐转场时长与 jitter 支持)."""
|
||||
return _build_xfade_filter_chain_base(
|
||||
clip_durations,
|
||||
clip_video_labels,
|
||||
transitions,
|
||||
transition_duration=transition_duration,
|
||||
transition_durations=transition_durations,
|
||||
jitters=jitters,
|
||||
output_label=output_label,
|
||||
)
|
||||
|
||||
|
||||
@@ -105,17 +105,24 @@ class TransitionEngine:
|
||||
transitions: list[str],
|
||||
*,
|
||||
transition_duration: float | None = None,
|
||||
transition_durations: list[float] | None = None,
|
||||
jitters: list[float] | None = None,
|
||||
output_label: str = "outv",
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链.
|
||||
|
||||
对每步转场应用验证和降级,然后调用底层 ffmpeg_utils 构建。
|
||||
|
||||
#1766 增强:支持逐转场独立时长(transition_durations)和位置微调(jitters)。
|
||||
|
||||
Args:
|
||||
clip_durations: 每个片段的时长
|
||||
clip_video_labels: 每个片段的视频流标签
|
||||
transitions: 每个片段对应的转场效果
|
||||
transition_duration: 统一转场时长,None 则使用引擎默认值
|
||||
transition_duration: 全局默认转场时长,None 则使用引擎默认值
|
||||
transition_durations: #1766 逐转场时长列表,与 transitions 等长;
|
||||
None 时使用各 resolved config 的 duration
|
||||
jitters: #1766 逐转场位置偏移列表(秒)
|
||||
output_label: 最终输出标签
|
||||
|
||||
Returns:
|
||||
@@ -127,6 +134,8 @@ class TransitionEngine:
|
||||
clip_video_labels=clip_video_labels,
|
||||
transitions=transitions,
|
||||
transition_duration=transition_duration or self._default_duration,
|
||||
transition_durations=transition_durations,
|
||||
jitters=jitters,
|
||||
output_label=output_label,
|
||||
)
|
||||
|
||||
@@ -134,17 +143,20 @@ class TransitionEngine:
|
||||
resolved = self.resolve_clip_transitions(transitions, clip_durations)
|
||||
resolved_effects = [c.effect for c in resolved]
|
||||
|
||||
# 使用统一的时长(取各转场中最大的时长作为基准,底层会做每步钳制)
|
||||
dur = transition_duration or self._default_duration
|
||||
if not dur:
|
||||
dur = max(c.duration for c in resolved) if resolved else DEFAULT_TRANSITION_DURATION
|
||||
# #1766: 逐转场时长(优先使用传入的 transition_durations,否则用 resolved config)
|
||||
if transition_durations is not None:
|
||||
resolved_durations = list(transition_durations)
|
||||
else:
|
||||
resolved_durations = [c.duration for c in resolved]
|
||||
|
||||
# 调用底层构建
|
||||
return build_xfade_filter_chain(
|
||||
clip_durations=clip_durations,
|
||||
clip_video_labels=clip_video_labels,
|
||||
transitions=resolved_effects,
|
||||
transition_duration=dur,
|
||||
transition_duration=transition_duration or self._default_duration,
|
||||
transition_durations=resolved_durations,
|
||||
jitters=jitters,
|
||||
output_label=output_label,
|
||||
)
|
||||
|
||||
|
||||
@@ -1870,16 +1870,27 @@ class UnifiedRenderService:
|
||||
)
|
||||
else:
|
||||
# 有转场效果:用 TransitionEngine 构建 xfade 链
|
||||
layer_dur = 0.0
|
||||
for d in layer_transition_durations:
|
||||
if d > 0:
|
||||
layer_dur = d
|
||||
break
|
||||
# #1766: 提取逐转场时长(每个 clip 的 transition_duration,跳过第一个)
|
||||
# layer_transition_durations[i] 对应 clip i 的转场,第 0 个忽略
|
||||
per_transition_durations = [d for idx, d in enumerate(layer_transition_durations) if idx > 0]
|
||||
# #1766: 提取每个 clip 的 jitter(存在 config 中),跳过第一个
|
||||
layer_jitters = [
|
||||
(
|
||||
all_clips[layer_clip_indices[idx]].config.get("transition_jitter", 0.0)
|
||||
if isinstance(all_clips[layer_clip_indices[idx]].config, dict)
|
||||
else 0.0
|
||||
)
|
||||
for idx in range(len(layer_clip_indices))
|
||||
]
|
||||
per_transition_jitters = [j for idx, j in enumerate(layer_jitters) if idx > 0]
|
||||
xfade_filter, xfade_estimated_dur = self._transition_engine.build_xfade_chain(
|
||||
clip_durations=layer_durations,
|
||||
clip_video_labels=layer_labels,
|
||||
transitions=layer_transitions,
|
||||
transition_duration=layer_dur if layer_dur > 0 else None,
|
||||
transition_durations=(
|
||||
per_transition_durations if any(d > 0 for d in per_transition_durations) else None
|
||||
),
|
||||
jitters=per_transition_jitters if any(j != 0.0 for j in per_transition_jitters) else None,
|
||||
output_label=out_label,
|
||||
)
|
||||
if xfade_filter:
|
||||
@@ -2227,12 +2238,17 @@ class UnifiedRenderService:
|
||||
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
|
||||
if not perturbation:
|
||||
return {}
|
||||
return {
|
||||
result = {
|
||||
"hflip": bool(perturbation.get("hflip", False)),
|
||||
"zoom_ratio": max(1.0, min(1.2, float(perturbation.get("zoom_ratio", 1.0) or 1.0))),
|
||||
"speed_factor": max(0.8, min(1.2, float(perturbation.get("speed_factor", 1.0) or 1.0))),
|
||||
"brightness_shift": max(-30, min(30, int(perturbation.get("brightness_shift", 0) or 0))),
|
||||
}
|
||||
# #1765:同时读取像素级扰动滤镜
|
||||
pixel_pert = (self.plan.config or {}).get("pixel_perturbation") or {}
|
||||
if pixel_pert:
|
||||
result["pixel_perturbation"] = pixel_pert
|
||||
return result
|
||||
|
||||
def _apply_visual_perturbation_pre_scale(self, filters: list[str], perturbation: dict) -> None:
|
||||
# scale+pad 之前的扰动(hflip),就地修改 filters
|
||||
@@ -2251,6 +2267,48 @@ class UnifiedRenderService:
|
||||
if brightness != 0:
|
||||
filters.append(f"eq=brightness={brightness / 100.0:.3f}")
|
||||
|
||||
# #1765:追加像素级扰动滤镜
|
||||
pixel_pert = perturbation.get("pixel_perturbation") or {}
|
||||
if pixel_pert:
|
||||
self._apply_pixel_perturbation(filters, pixel_pert)
|
||||
|
||||
def _apply_pixel_perturbation(self, filters: list[str], pixel_pert: dict) -> None:
|
||||
"""应用像素级扰动滤镜(Issue #1765)。
|
||||
|
||||
滤镜参数幅度确保肉眼不可见(SSIM > 0.95),但能让同素材不同变体
|
||||
在帧级产生 > 3% 的差异,降低平台查重风险。
|
||||
"""
|
||||
filter_list = pixel_pert.get("filters") or []
|
||||
|
||||
for filt in filter_list:
|
||||
if filt == "noise":
|
||||
# 轻微噪声:noise=alls=0.015:allf=t+u
|
||||
strength = pixel_pert.get("noise_strength", 0.015)
|
||||
filters.append(f"noise=alls={strength}:allf=t+u")
|
||||
|
||||
elif filt == "unsharp":
|
||||
# 锐化/柔化:unsharp=3:3:amount
|
||||
# amount > 0 锐化,< 0 柔化
|
||||
amount = pixel_pert.get("unsharp_amount", 0.0)
|
||||
if abs(amount) > 0.01:
|
||||
filters.append(f"unsharp=3:3:{amount:.2f}")
|
||||
|
||||
elif filt == "curves":
|
||||
# 对比度微调:curves 用 preset 或手动定义
|
||||
# 简单方案:用 eq=contrast 代替(curves 语法复杂)
|
||||
contrast = pixel_pert.get("curves_contrast", 1.0)
|
||||
if abs(contrast - 1.0) > 0.01:
|
||||
filters.append(f"eq=contrast={contrast:.3f}")
|
||||
|
||||
elif filt == "color_balance":
|
||||
# RGB 通道偏移:color_balance=rs=...:gs=...:bs=...
|
||||
r = pixel_pert.get("color_r", 0)
|
||||
g = pixel_pert.get("color_g", 0)
|
||||
b = pixel_pert.get("color_b", 0)
|
||||
if r != 0 or g != 0 or b != 0:
|
||||
# color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换
|
||||
filters.append(f"color_balance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
|
||||
|
||||
@staticmethod
|
||||
def _clip_volume(clip: ResolvedClip) -> float:
|
||||
"""获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。"""
|
||||
|
||||
@@ -2126,6 +2126,14 @@
|
||||
"type": "JSON",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "is_default",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "BOOLEAN",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "created_at",
|
||||
@@ -2142,6 +2150,13 @@
|
||||
],
|
||||
"name": "ix_projects_owner_user_id",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"owner_user_id"
|
||||
],
|
||||
"name": "uq_projects_owner_default",
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"primary_key": [
|
||||
@@ -3570,4 +3585,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,14 +35,37 @@ class InMemoryAssetLibraryRepository:
|
||||
return True
|
||||
return False
|
||||
|
||||
def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
library = self._libraries.get(library_id)
|
||||
if library:
|
||||
library.asset_count += 1
|
||||
library.asset_count += count_delta
|
||||
library.total_size += size_delta
|
||||
|
||||
def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
library = self._libraries.get(library_id)
|
||||
if library:
|
||||
library.asset_count = max(0, library.asset_count - 1)
|
||||
library.asset_count = max(0, library.asset_count - count_delta)
|
||||
library.total_size = max(0, library.total_size - size_delta)
|
||||
|
||||
def recount_assets(self, library_id: str) -> int:
|
||||
"""InMemory 实现无法真正重算(没有 asset 数据源),返回当前计数。"""
|
||||
library = self._libraries.get(library_id)
|
||||
return library.asset_count if library else 0
|
||||
|
||||
def get_or_create_default_library(
|
||||
self,
|
||||
project_id: str,
|
||||
kind: AssetLibraryKind,
|
||||
*,
|
||||
name: str | None = None,
|
||||
) -> AssetLibrary:
|
||||
"""幂等获取/创建默认素材库(Issue #1775,内存实现,模拟唯一约束语义)。"""
|
||||
for lib in self._libraries.values():
|
||||
if lib.project_id == project_id and lib.kind == kind:
|
||||
return lib
|
||||
# 回退到 find_by_project
|
||||
for lib in self.find_by_project(project_id, kind):
|
||||
return lib
|
||||
library_name = name or f"{kind.value}素材库"
|
||||
library = AssetLibrary.create(project_id=project_id, name=library_name, kind=kind)
|
||||
return self.create(library)
|
||||
|
||||
@@ -29,3 +29,30 @@ class InMemoryProjectRepository:
|
||||
del self._items[project_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_default_by_owner(self, owner_user_id: str) -> Project | None:
|
||||
"""查找用户的默认项目(Issue #1775 幂等接口,内存实现)。"""
|
||||
for p in self._items.values():
|
||||
if p.owner_user_id == owner_user_id and getattr(p, "is_default", False):
|
||||
return p
|
||||
return None
|
||||
|
||||
def get_or_create_default_project(
|
||||
self,
|
||||
owner_user_id: str,
|
||||
*,
|
||||
name: str = "默认项目",
|
||||
description: str = "小程序自动创建的默认项目",
|
||||
) -> Project:
|
||||
"""幂等获取/创建默认项目(内存实现,模拟 DB 部分唯一索引语义)。"""
|
||||
existing = self.find_default_by_owner(owner_user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
project = Project.create(
|
||||
owner_user_id=owner_user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
is_default=True,
|
||||
)
|
||||
self._items[project.id] = project
|
||||
return project
|
||||
|
||||
@@ -77,16 +77,148 @@ class SQLAlchemyAssetLibraryRepository:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
|
||||
if model:
|
||||
model.asset_count = (model.asset_count or 0) + 1
|
||||
model.total_size = (model.total_size or 0) + size_delta
|
||||
self.session.commit()
|
||||
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
"""原子递增素材计数(Issue #1776)。
|
||||
|
||||
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
|
||||
if model:
|
||||
model.asset_count = max(0, (model.asset_count or 0) - 1)
|
||||
model.total_size = max(0, (model.total_size or 0) - size_delta)
|
||||
使用 SQL 级 UPDATE 保证并发安全,不单独 commit(由调用方统一事务提交)。
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: func.coalesce(AssetLibraryModel.asset_count, 0) + count_delta,
|
||||
AssetLibraryModel.total_size: func.coalesce(AssetLibraryModel.total_size, 0) + size_delta,
|
||||
}
|
||||
)
|
||||
|
||||
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
"""原子递减素材计数(Issue #1776),下限为 0 防止负数。
|
||||
|
||||
使用 SQL 级 UPDATE 保证并发安全,不单独 commit(由调用方统一事务提交)。
|
||||
使用 CASE WHEN 兼容 SQLite(测试)和 PostgreSQL(生产)。
|
||||
"""
|
||||
from sqlalchemy import case, func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: case(
|
||||
(func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta,
|
||||
),
|
||||
AssetLibraryModel.total_size: case(
|
||||
(func.coalesce(AssetLibraryModel.total_size, 0) - size_delta < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.total_size, 0) - size_delta,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def recount_assets(self, library_id: str) -> int:
|
||||
"""重算素材库计数(Issue #1776)。
|
||||
|
||||
直接查询实际素材数量(排除已删除),更新 asset_count 和 total_size。
|
||||
返回重算后的实际计数。
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
# 查询实际计数(排除 deleted)
|
||||
actual_count = (
|
||||
self.session.query(func.count(AssetModel.id))
|
||||
.filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.status != "deleted",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
# 查询实际总大小
|
||||
actual_size = (
|
||||
self.session.query(func.coalesce(func.sum(AssetModel.file_size), 0))
|
||||
.filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.status != "deleted",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
# 更新素材库记录
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: actual_count,
|
||||
AssetLibraryModel.total_size: actual_size,
|
||||
}
|
||||
)
|
||||
return actual_count
|
||||
|
||||
def get_or_create_default_library(
|
||||
self,
|
||||
project_id: str,
|
||||
kind: AssetLibraryKind,
|
||||
*,
|
||||
name: str | None = None,
|
||||
) -> AssetLibrary:
|
||||
"""幂等获取/创建项目下指定 kind 的默认素材库(Issue #1775)。
|
||||
|
||||
依赖唯一约束 uq_asset_libraries_project_kind(project_id, kind):
|
||||
并发创建只有一个成功,其余 IntegrityError 后回滚重查,
|
||||
保证同一项目同 kind 永远只有一个素材库。
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
default_names = {
|
||||
AssetLibraryKind.VIDEO: "视频素材库",
|
||||
AssetLibraryKind.VOICE: "配音素材库",
|
||||
AssetLibraryKind.IMAGE: "图片素材库",
|
||||
}
|
||||
library_name = name or default_names.get(kind, f"{kind.value}素材库")
|
||||
|
||||
# 快速路径
|
||||
existing = (
|
||||
self.session.query(AssetLibraryModel)
|
||||
.filter(AssetLibraryModel.project_id == project_id, AssetLibraryModel.kind == kind.value)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return self._to_entity(existing)
|
||||
|
||||
library = AssetLibrary.create(project_id=project_id, name=library_name, kind=kind)
|
||||
model = AssetLibraryModel(
|
||||
id=library.id,
|
||||
project_id=library.project_id,
|
||||
name=library.name,
|
||||
kind=library.kind.value,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=library.created_at,
|
||||
updated_at=library.updated_at,
|
||||
)
|
||||
try:
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return library
|
||||
except IntegrityError:
|
||||
self.session.rollback()
|
||||
existing = (
|
||||
self.session.query(AssetLibraryModel)
|
||||
.filter(
|
||||
AssetLibraryModel.project_id == project_id,
|
||||
AssetLibraryModel.kind == kind.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return self._to_entity(existing)
|
||||
raise
|
||||
|
||||
def _to_entity(self, model: AssetLibraryModel) -> AssetLibrary:
|
||||
return AssetLibrary(
|
||||
id=model.id,
|
||||
project_id=model.project_id,
|
||||
name=model.name,
|
||||
kind=AssetLibraryKind(model.kind),
|
||||
asset_count=int(model.asset_count or 0),
|
||||
total_size=int(model.total_size or 0),
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, AssetTagModel
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel, AssetModel, AssetTagModel
|
||||
from packages.domain import Asset, AssetStatus, ClassificationStatus
|
||||
|
||||
|
||||
@@ -141,6 +141,16 @@ class SQLAlchemyAssetRepository:
|
||||
self.session.add(model)
|
||||
self.session.flush()
|
||||
self._sync_asset_tags(asset.id, asset.tag_ids)
|
||||
# Issue #1776: 自动维护素材库计数(同事务内原子更新)
|
||||
if asset.library_id and asset.status.value != "deleted":
|
||||
from sqlalchemy import func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == asset.library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: func.coalesce(AssetLibraryModel.asset_count, 0) + 1,
|
||||
AssetLibraryModel.total_size: func.coalesce(AssetLibraryModel.total_size, 0) + asset.file_size,
|
||||
}
|
||||
)
|
||||
self.session.commit()
|
||||
return asset
|
||||
|
||||
@@ -175,7 +185,27 @@ class SQLAlchemyAssetRepository:
|
||||
def delete(self, asset_id: str) -> bool:
|
||||
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model:
|
||||
library_id = model.asset_library_id
|
||||
file_size = model.file_size or 0
|
||||
# 只统计非 deleted 状态的素材
|
||||
was_counted = model.status != "deleted"
|
||||
self.session.delete(model)
|
||||
# Issue #1776: 自动维护素材库计数
|
||||
if library_id and was_counted:
|
||||
from sqlalchemy import case, func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: case(
|
||||
(func.coalesce(AssetLibraryModel.asset_count, 0) - 1 < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - 1,
|
||||
),
|
||||
AssetLibraryModel.total_size: case(
|
||||
(func.coalesce(AssetLibraryModel.total_size, 0) - file_size < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.total_size, 0) - file_size,
|
||||
),
|
||||
}
|
||||
)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
@@ -187,11 +217,44 @@ class SQLAlchemyAssetRepository:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# 先查询待删除素材的库分布(用于更新计数)
|
||||
to_delete = (
|
||||
self.session.query(AssetModel.asset_library_id, AssetModel.file_size)
|
||||
.filter(AssetModel.id.in_(asset_ids), AssetModel.status != "deleted")
|
||||
.all()
|
||||
)
|
||||
if not to_delete:
|
||||
return 0
|
||||
# 按库分组统计
|
||||
library_deltas: dict[str, tuple[int, int]] = {} # library_id -> (count_delta, size_delta)
|
||||
for lib_id, size in to_delete:
|
||||
if lib_id not in library_deltas:
|
||||
library_deltas[lib_id] = (0, 0)
|
||||
c, s = library_deltas[lib_id]
|
||||
library_deltas[lib_id] = (c + 1, s + (size or 0))
|
||||
# 执行软删除
|
||||
count = (
|
||||
self.session.query(AssetModel)
|
||||
.filter(AssetModel.id.in_(asset_ids), AssetModel.status != "deleted")
|
||||
.update({AssetModel.status: "deleted", AssetModel.updated_at: now}, synchronize_session=False)
|
||||
)
|
||||
# Issue #1776: 自动维护各素材库计数
|
||||
if library_deltas:
|
||||
from sqlalchemy import case, func
|
||||
|
||||
for lib_id, (count_delta, size_delta) in library_deltas.items():
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == lib_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: case(
|
||||
(func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta,
|
||||
),
|
||||
AssetLibraryModel.total_size: case(
|
||||
(func.coalesce(AssetLibraryModel.total_size, 0) - size_delta < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.total_size, 0) - size_delta,
|
||||
),
|
||||
}
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Index, Integer, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base: Any = declarative_base()
|
||||
@@ -44,12 +44,18 @@ class UserModel(Base):
|
||||
|
||||
class ProjectModel(Base):
|
||||
__tablename__ = "projects"
|
||||
__table_args__ = (
|
||||
# Issue #1775: 每个用户至多一个默认项目(部分唯一索引,只约束 is_default=true 的行)。
|
||||
# 注意:不加 UniqueConstraint(那会要求全表唯一),用部分索引表达"每用户一个默认项目"。
|
||||
Index("uq_projects_owner_default", "owner_user_id", unique=True, postgresql_where=text("is_default = true")),
|
||||
)
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
owner_user_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=False, default="")
|
||||
shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表
|
||||
is_default = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -647,3 +653,83 @@ class VideoFingerprintChunkModel(Base):
|
||||
color_histogram = Column(JSON, nullable=False)
|
||||
frame_count = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ScriptModel(Base):
|
||||
"""口播文案库 (Issue #1795)"""
|
||||
|
||||
__tablename__ = "scripts"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
content = Column(Text, nullable=False, default="")
|
||||
segments = Column(JSON, nullable=False, default=list)
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
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 LipsyncJobModel(Base):
|
||||
"""对口型任务 ORM 模型 — #1796 MediaKit 对口型.
|
||||
|
||||
记录用户提交的对口型任务,跟踪 MediaKit 异步任务状态。
|
||||
"""
|
||||
|
||||
__tablename__ = "lipsync_jobs"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||
|
||||
# 输入参数
|
||||
video_url = Column(Text, nullable=False)
|
||||
audio_url = Column(Text, nullable=False)
|
||||
enable_video_loop = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# MediaKit 任务状态
|
||||
mediakit_task_id = Column(String(200), nullable=False, default="", index=True)
|
||||
status = Column(
|
||||
String(20), nullable=False, default="pending", index=True
|
||||
) # pending → submitted → processing → completed → failed
|
||||
output_video_url = Column(Text, nullable=False, default="")
|
||||
output_duration = Column(Float, nullable=False, default=0.0)
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
error_code = Column(String(100), nullable=False, default="")
|
||||
|
||||
# 时间戳
|
||||
submitted_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AiAvatarRenderJob(Base):
|
||||
"""AI数字人渲染任务 — #1798"""
|
||||
|
||||
__tablename__ = "ai_avatar_render_jobs"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||
|
||||
# 输入参数
|
||||
lipsync_job_id = Column(String(36), nullable=False)
|
||||
script_id = Column(String(36), nullable=False)
|
||||
b_roll_segments = Column(JSON, nullable=False, default=list)
|
||||
# b_roll_segments 格式: [{"script_segment_index": 0, "asset_url": "...", "mode": "fullscreen|pip", "start_time": 5.0, "end_time": 10.0}, ...]
|
||||
title_config = Column(JSON, nullable=False, default=dict)
|
||||
cover_config = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
# 任务状态
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
progress = Column(Integer, nullable=False, default=0)
|
||||
output_video_url = Column(Text, nullable=False, default="")
|
||||
output_cover_url = Column(Text, nullable=False, default="")
|
||||
output_duration = Column(Float, nullable=False, default=0.0)
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
submitted_at = Column(DateTime, nullable=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=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))
|
||||
|
||||
@@ -15,6 +15,7 @@ class SQLAlchemyProjectRepository:
|
||||
name=model.name,
|
||||
description=model.description,
|
||||
shared_users=model.shared_users or [],
|
||||
is_default=bool(getattr(model, "is_default", False)),
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -33,9 +34,12 @@ class SQLAlchemyProjectRepository:
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
shared_users=project.shared_users,
|
||||
is_default=project.is_default,
|
||||
created_at=project.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
if existing:
|
||||
existing.is_default = project.is_default
|
||||
self.session.commit()
|
||||
return project
|
||||
|
||||
@@ -76,3 +80,59 @@ class SQLAlchemyProjectRepository:
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def find_default_by_owner(self, owner_user_id: str) -> Project | None:
|
||||
"""查找用户的默认项目(is_default=true)。"""
|
||||
model = (
|
||||
self.session.query(ProjectModel)
|
||||
.filter(ProjectModel.owner_user_id == owner_user_id, ProjectModel.is_default.is_(True))
|
||||
.first()
|
||||
)
|
||||
return self._to_entity(model) if model else None
|
||||
|
||||
def get_or_create_default_project(
|
||||
self,
|
||||
owner_user_id: str,
|
||||
*,
|
||||
name: str = "默认项目",
|
||||
description: str = "小程序自动创建的默认项目",
|
||||
) -> Project:
|
||||
"""幂等获取/创建用户的默认项目(Issue #1775)。
|
||||
|
||||
依赖部分唯一索引 uq_projects_owner_default(每用户至多一条 is_default=true):
|
||||
并发创建时只有一个 INSERT 成功,其余触发 IntegrityError 后回滚重查,
|
||||
保证同一用户永远只有一个默认项目。
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
# 快速路径:已有默认项目
|
||||
existing = self.find_default_by_owner(owner_user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
project = Project.create(
|
||||
owner_user_id=owner_user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
is_default=True,
|
||||
)
|
||||
model = ProjectModel(
|
||||
id=project.id,
|
||||
owner_user_id=project.owner_user_id,
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
shared_users=project.shared_users,
|
||||
is_default=True,
|
||||
created_at=project.created_at,
|
||||
)
|
||||
try:
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return project
|
||||
except IntegrityError:
|
||||
# 并发:另一个请求已插入默认项目,回滚后重查
|
||||
self.session.rollback()
|
||||
existing = self.find_default_by_owner(owner_user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
|
||||
@@ -6,7 +6,10 @@ from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
)
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
@@ -38,6 +41,24 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
models = query.offset(skip).limit(limit).all()
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def template_owned_by(self, template_id: str, user_id: str) -> bool:
|
||||
"""校验旧模板主表 ``templates`` 中模板归属当前用户且未删除(is_active=True).
|
||||
|
||||
片段配置主表 ``template_clip_configs`` 本身没有 user_id 列,
|
||||
归属关系通过模板主表 ``templates.user_id`` 确定。
|
||||
新表 ``edit_templates`` 为全局模板库(无 user_id 列),不走此校验。
|
||||
"""
|
||||
return (
|
||||
self.session.query(TemplateModel.id)
|
||||
.filter(
|
||||
TemplateModel.id == template_id,
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""根据 ID 获取配置"""
|
||||
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first()
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
@@ -28,6 +29,20 @@ class SQLAlchemyTemplateRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def _filter_with_segment_configs(self, query):
|
||||
"""只保留在 template_clip_configs 或 template_segments 中存在片段配置的模板。
|
||||
|
||||
两张表都没有记录的模板无法用于生成(from-assets 会 400),
|
||||
剪辑页选模板时应排除;模板编辑器不传 valid_only,仍可见全部模板。
|
||||
"""
|
||||
has_clip_config = self.session.query(TemplateClipConfigModel.id).filter(
|
||||
TemplateClipConfigModel.template_id == TemplateModel.id,
|
||||
)
|
||||
has_segment = self.session.query(TemplateSegmentModel.id).filter(
|
||||
TemplateSegmentModel.template_id == TemplateModel.id,
|
||||
)
|
||||
return query.filter(or_(has_clip_config.exists(), has_segment.exists()))
|
||||
|
||||
# ── Template CRUD ──
|
||||
|
||||
def list_by_user(
|
||||
@@ -40,11 +55,14 @@ class SQLAlchemyTemplateRepository:
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> List[Template]:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
if valid_only:
|
||||
query = self._filter_with_segment_configs(query)
|
||||
if category:
|
||||
query = query.filter(TemplateModel.category == category)
|
||||
if mode:
|
||||
@@ -102,6 +120,27 @@ class SQLAlchemyTemplateRepository:
|
||||
template.segments = self.list_segments(template.id)
|
||||
return template
|
||||
|
||||
def get_active(self, template_id: str, user_id: str) -> Optional[Template]:
|
||||
"""获取归属当前用户且未删除(is_active=True)的模板,否则返回 None.
|
||||
|
||||
用于编辑器访问门禁:模板不存在、已软删除或不属于当前用户时返回 None,
|
||||
由调用方映射为 404。与 :meth:`get` 的区别是额外过滤 is_active。
|
||||
"""
|
||||
model = (
|
||||
self.session.query(TemplateModel)
|
||||
.filter(
|
||||
TemplateModel.id == template_id,
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
template = self._model_to_entity(model)
|
||||
template.segments = self.list_segments(template.id)
|
||||
return template
|
||||
|
||||
def create(self, template: Template) -> Template:
|
||||
model = TemplateModel(
|
||||
id=template.id,
|
||||
@@ -173,11 +212,14 @@ class SQLAlchemyTemplateRepository:
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> int:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
if valid_only:
|
||||
query = self._filter_with_segment_configs(query)
|
||||
if category:
|
||||
query = query.filter(TemplateModel.category == category)
|
||||
if mode:
|
||||
|
||||
@@ -62,6 +62,7 @@ class ListTemplatesFilter:
|
||||
tag: Optional[str] = None
|
||||
keyword: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
valid_only: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -106,6 +106,7 @@ class ListTemplatesUseCase:
|
||||
tag=filter.tag,
|
||||
keyword=filter.keyword,
|
||||
mode=filter.mode,
|
||||
valid_only=filter.valid_only,
|
||||
)
|
||||
|
||||
|
||||
@@ -127,6 +128,7 @@ class CountTemplatesUseCase:
|
||||
tag=filter.tag,
|
||||
keyword=filter.keyword,
|
||||
mode=filter.mode,
|
||||
valid_only=filter.valid_only,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""BGM 池差异化分配 — 打破变体间音频指纹一致性 (Issue #1767).
|
||||
|
||||
三层递进策略:
|
||||
1. **BGM 池分配(核心)**:维护风格匹配的 BGM 池,每个变体基于 variant_seed
|
||||
随机分配一首不同 BGM,保证变体间音频指纹不同。
|
||||
2. **段落差异化(池不够时的补充)**:同一首 BGM 做差异化裁剪,不同变体使用
|
||||
不同起始点/段落,进一步降低音频相似度。
|
||||
3. **音量微调**:不同变体 BGM 音量 ±3dB 微调,混音比例有微小差异。
|
||||
|
||||
约束:
|
||||
- 不破坏现有单视频 BGM 选择逻辑(单视频不走池分配)
|
||||
- BGM 情绪/风格与视频内容匹配(基于源 plan 的 BGM style 做风格筛选)
|
||||
- 分配可复现(同 seed 同结果)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── BGM 池条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BGMPoolEntry:
|
||||
"""BGM 池条目"""
|
||||
|
||||
id: str
|
||||
preset_id: str # 关联 PRESET_BGM_LIBRARY 中的 ID(用于渲染侧解析音频路径)
|
||||
mood: str # 情绪/风格:upbeat / relax / tech / commerce / emotional / cinematic
|
||||
duration: float # 时长(秒)
|
||||
audio_url: str = "" # CDN/OSS 直链(优先级高于 preset_id)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── BGM 池(10 首,覆盖 6 种风格) ──────────────────────────────────────────
|
||||
|
||||
BGM_POOL: list[BGMPoolEntry] = [
|
||||
# upbeat (轻快)
|
||||
BGMPoolEntry(
|
||||
id="pool_upbeat_001", preset_id="bgm_upbeat_001", mood="upbeat", duration=120.0, tags=["轻快", "阳光", "vlog"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_upbeat_002", preset_id="bgm_upbeat_002", mood="upbeat", duration=95.0, tags=["轻快", "电子", "运动"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_upbeat_003", preset_id="bgm_upbeat_003", mood="upbeat", duration=110.0, tags=["轻快", "夏日", "旅行"]
|
||||
),
|
||||
# relax (治愈)
|
||||
BGMPoolEntry(
|
||||
id="pool_relax_001", preset_id="bgm_relax_001", mood="relax", duration=180.0, tags=["治愈", "钢琴", "冥想"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_relax_002", preset_id="bgm_relax_002", mood="relax", duration=150.0, tags=["治愈", "自然", "放松"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_relax_003", preset_id="bgm_relax_003", mood="relax", duration=200.0, tags=["治愈", "古典", "钢琴"]
|
||||
),
|
||||
# tech (科技)
|
||||
BGMPoolEntry(
|
||||
id="pool_tech_001", preset_id="bgm_tech_001", mood="tech", duration=85.0, tags=["科技", "电子", "数码"]
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_tech_002", preset_id="bgm_tech_002", mood="tech", duration=100.0, tags=["科技", "极简", "AI"]
|
||||
),
|
||||
# commerce (电商)
|
||||
BGMPoolEntry(
|
||||
id="pool_commerce_001",
|
||||
preset_id="bgm_commerce_001",
|
||||
mood="commerce",
|
||||
duration=75.0,
|
||||
tags=["电商", "时尚", "带货"],
|
||||
),
|
||||
BGMPoolEntry(
|
||||
id="pool_commerce_002",
|
||||
preset_id="bgm_commerce_002",
|
||||
mood="commerce",
|
||||
duration=90.0,
|
||||
tags=["电商", "品牌", "品质"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── 风格 → 情绪映射 ────────────────────────────────────────────────────────
|
||||
# preset_bgm.py 中 style 字段 → bgm_pool.py 中 mood 字段
|
||||
|
||||
STYLE_TO_MOOD: dict[str, str] = {
|
||||
"upbeat": "upbeat",
|
||||
"relax": "relax",
|
||||
"tech": "tech",
|
||||
"commerce": "commerce",
|
||||
"emotional": "emotional",
|
||||
"cinematic": "cinematic",
|
||||
}
|
||||
|
||||
|
||||
# ── 策略一:BGM 池分配 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_bgm_pool_candidates(source_mood: str | None = None) -> list[BGMPoolEntry]:
|
||||
"""获取 BGM 池候选列表。
|
||||
|
||||
如果指定了 source_mood,优先返回同 mood 的条目;
|
||||
如果同 mood 条目不足 2 个,降级返回全池(保证有足够候选)。
|
||||
|
||||
Args:
|
||||
source_mood: 源 BGM 的情绪/风格(来自 preset_bgm.py 的 style 字段)
|
||||
|
||||
Returns:
|
||||
候选 BGM 列表(至少 2 个条目)
|
||||
"""
|
||||
if not source_mood:
|
||||
return list(BGM_POOL)
|
||||
|
||||
mood = STYLE_TO_MOOD.get(source_mood, source_mood)
|
||||
matched = [e for e in BGM_POOL if e.mood == mood]
|
||||
|
||||
# 同 mood 至少要有 2 首,否则无法"差异化",降级全池
|
||||
if len(matched) >= 2:
|
||||
return matched
|
||||
return list(BGM_POOL)
|
||||
|
||||
|
||||
def select_bgm_from_pool(
|
||||
variant_seed: int,
|
||||
candidates: list[BGMPoolEntry] | None = None,
|
||||
) -> BGMPoolEntry:
|
||||
"""基于 variant_seed 从候选池中选一首 BGM(可复现)。
|
||||
|
||||
Args:
|
||||
variant_seed: 变体随机种子
|
||||
candidates: 候选池(None 时使用全池)
|
||||
|
||||
Returns:
|
||||
选中的 BGM 条目
|
||||
"""
|
||||
pool = candidates if candidates is not None else list(BGM_POOL)
|
||||
if not pool:
|
||||
pool = list(BGM_POOL)
|
||||
rng = random.Random(variant_seed)
|
||||
return rng.choice(pool)
|
||||
|
||||
|
||||
# ── 策略二:段落差异化 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_bgm_segment_offset(variant_seed: int, bgm_duration: float) -> float:
|
||||
"""为变体生成 BGM 段落起始偏移(策略二)。
|
||||
|
||||
不同变体从同一首 BGM 的不同位置开始播放,进一步降低音频指纹相似度。
|
||||
|
||||
偏移范围 [0, max_offset],max_offset = min(30s, bgm_duration * 0.3)。
|
||||
量化到 5 秒整数倍,便于复现和调试。
|
||||
|
||||
Args:
|
||||
variant_seed: 变体随机种子
|
||||
bgm_duration: BGM 总时长(秒)
|
||||
|
||||
Returns:
|
||||
起始偏移(秒),0 ~ max_offset 之间,5s 步长
|
||||
"""
|
||||
rng = random.Random(variant_seed + 7919) # 加素数偏移,避免与 BGM 选择 seed 序列重合
|
||||
max_offset = min(30.0, bgm_duration * 0.3)
|
||||
steps = int(max_offset // 5.0)
|
||||
if steps <= 0:
|
||||
return 0.0
|
||||
return float(rng.randint(0, steps) * 5)
|
||||
|
||||
|
||||
# ── 策略三:音量微调 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_bgm_volume_adjust(variant_seed: int) -> float:
|
||||
"""为变体生成 BGM 音量微调值(策略三)。
|
||||
|
||||
±3dB 微调,让不同变体的 BGM/配音混音比例有微小差异。
|
||||
离散步长:-3, -2, -1, 0, 1, 2, 3 dB。
|
||||
|
||||
Args:
|
||||
variant_seed: 变体随机种子
|
||||
|
||||
Returns:
|
||||
音量调整值(dB),-3.0 ~ 3.0
|
||||
"""
|
||||
rng = random.Random(variant_seed + 104729) # 另一个素数偏移
|
||||
return float(rng.choice([-3, -2, -1, 0, 1, 2, 3]))
|
||||
|
||||
|
||||
# ── 批量分配入口 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def allocate_bgm_pool_for_variants(
|
||||
source_bgm_config: dict,
|
||||
variant_seeds: list[int],
|
||||
) -> list[dict]:
|
||||
"""为批量变体分配不同的 BGM 池配置。
|
||||
|
||||
整合三层策略:池分配 + 段落偏移 + 音量微调。
|
||||
每个变体得到一个 dict,可直接合并到 plan.config["bgm"] 中。
|
||||
|
||||
Args:
|
||||
source_bgm_config: 源 plan 的 BGM 配置(用于风格匹配)
|
||||
variant_seeds: 每个变体的随机种子列表
|
||||
|
||||
Returns:
|
||||
每个变体的 BGM 池配置 dict 列表(与 variant_seeds 等长),
|
||||
每项包含 preset_id / audio_url / audio_offset / volume_adjust_db。
|
||||
如果源 BGM 未启用,返回空列表。
|
||||
"""
|
||||
if not source_bgm_config or not source_bgm_config.get("enabled", False):
|
||||
return []
|
||||
if not variant_seeds:
|
||||
return []
|
||||
|
||||
# 从源 BGM 配置中推断风格
|
||||
source_mood = _infer_source_mood(source_bgm_config)
|
||||
|
||||
# 策略一:获取候选池
|
||||
candidates = get_bgm_pool_candidates(source_mood)
|
||||
|
||||
# 为每个变体分配不同的 BGM(尽量不重复)
|
||||
assignments = _assign_unique_bgm(candidates, variant_seeds)
|
||||
|
||||
results = []
|
||||
for i, (entry, seed) in enumerate(zip(assignments, variant_seeds, strict=False)):
|
||||
# 策略二:段落偏移
|
||||
offset = generate_bgm_segment_offset(seed, entry.duration)
|
||||
|
||||
# 策略三:音量微调
|
||||
volume_adj = generate_bgm_volume_adjust(seed)
|
||||
|
||||
result = {
|
||||
"preset_id": entry.preset_id,
|
||||
"audio_url": entry.audio_url,
|
||||
"audio_offset": offset,
|
||||
"volume_adjust_db": volume_adj,
|
||||
"bgm_pool_entry_id": entry.id,
|
||||
"bgm_pool_mood": entry.mood,
|
||||
}
|
||||
results.append(result)
|
||||
logger.info(
|
||||
"变体 %d BGM 池分配: seed=%d bgm=%s mood=%s offset=%.1fs vol_adj=%+.0fdB",
|
||||
i,
|
||||
seed,
|
||||
entry.id,
|
||||
entry.mood,
|
||||
offset,
|
||||
volume_adj,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _infer_source_mood(source_bgm_config: dict) -> str | None:
|
||||
"""从源 BGM 配置推断风格/情绪。
|
||||
|
||||
优先级:
|
||||
1. preset_id → 查 preset_bgm 库获取 style
|
||||
2. bgm_pool_mood → 上游已设置过(二次分配场景)
|
||||
3. 无法推断 → None(返回全池候选)
|
||||
"""
|
||||
preset_id = source_bgm_config.get("preset_id", "")
|
||||
if preset_id:
|
||||
from packages.domain.preset_bgm import get_preset_bgm
|
||||
|
||||
preset = get_preset_bgm(preset_id)
|
||||
if preset:
|
||||
return STYLE_TO_MOOD.get(preset.style, preset.style)
|
||||
|
||||
# 如果之前已经分配过 BGM 池,直接用 mood
|
||||
pool_mood = source_bgm_config.get("bgm_pool_mood", "")
|
||||
if pool_mood:
|
||||
return pool_mood
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _assign_unique_bgm(
|
||||
candidates: list[BGMPoolEntry],
|
||||
variant_seeds: list[int],
|
||||
) -> list[BGMPoolEntry]:
|
||||
"""尽量让每个变体选到不同的 BGM。
|
||||
|
||||
策略:用 seed 选 BGM,如果与前面变体重复,用递增 seed 重试。
|
||||
如果候选池大小 < 变体数,允许重复但不连续。
|
||||
"""
|
||||
if not candidates or not variant_seeds:
|
||||
return []
|
||||
|
||||
assignments: list[BGMPoolEntry] = []
|
||||
used_ids: set[str] = set()
|
||||
|
||||
for i, seed in enumerate(variant_seeds):
|
||||
rng = random.Random(seed)
|
||||
# 先尝试选一个没用过的
|
||||
chosen = None
|
||||
for _attempt in range(len(candidates)):
|
||||
candidate = rng.choice(candidates)
|
||||
if candidate.id not in used_ids:
|
||||
chosen = candidate
|
||||
break
|
||||
if chosen is None:
|
||||
# 候选池已用完,允许重复但取下一个(循环)
|
||||
idx = i % len(candidates)
|
||||
chosen = candidates[idx]
|
||||
|
||||
assignments.append(chosen)
|
||||
used_ids.add(chosen.id)
|
||||
|
||||
return assignments
|
||||
@@ -70,10 +70,11 @@ class Project:
|
||||
name: str
|
||||
description: str = ""
|
||||
shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表
|
||||
is_default: bool = False # 是否为用户的默认项目(小程序自动创建),DB 部分唯一索引保证每人至多一个
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(cls, owner_user_id: str, name: str, description: str = "") -> "Project":
|
||||
def create(cls, owner_user_id: str, name: str, description: str = "", is_default: bool = False) -> "Project":
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("项目名称不能为空")
|
||||
@@ -83,6 +84,7 @@ class Project:
|
||||
name=clean_name,
|
||||
description=description.strip(),
|
||||
shared_users=[],
|
||||
is_default=is_default,
|
||||
)
|
||||
|
||||
def is_owner(self, user_id: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""转场位置与类型随机化(Issue #1766).
|
||||
|
||||
为不同变体生成不同的转场序列(类型 + 时长 + 位置微调),
|
||||
打破"所有变体转场节奏完全一致"的结构相似性,
|
||||
降低平台查重识别为结构相似视频的风险。
|
||||
|
||||
设计要点:
|
||||
1. 转场类型池:5 种效果(dissolve / zoom / slideleft / wipeleft / fade)
|
||||
2. 硬切概率:保证 30%-50% 的转场是硬切(保持节奏感)
|
||||
3. 转场时长随机:0.3s ~ 0.8s
|
||||
4. 转场位置微调:±0.5s 偏移(通过 xfade jitter 实现)
|
||||
5. 与 #1764 节奏模板协同:长片段之间的转场倾向于更长时长
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 转场类型池 ──────────────────────────────────────────────────────────────
|
||||
|
||||
#: 非硬切转场类型池(5 种效果,均为 FFmpeg xfade 支持的 transition 名称)
|
||||
#: 选择标准:视觉效果差异大、FFmpeg 渲染稳定、肉眼可区分
|
||||
TRANSITION_POOL: list[str] = [
|
||||
"dissolve", # 溶解
|
||||
"zoomin", # 缩放(放大进入)
|
||||
"slideleft", # 左滑入
|
||||
"wipeleft", # 左擦除
|
||||
"fade", # 淡入淡出
|
||||
]
|
||||
|
||||
#: 硬切(无转场效果),由 build_xfade_filter_chain 特殊处理(concat filter)
|
||||
HARD_CUT = "cut"
|
||||
|
||||
# ── 时长约束 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#: 随机转场时长下限(秒)
|
||||
TRANSITION_DURATION_MIN = 0.3
|
||||
|
||||
#: 随机转场时长上限(秒)
|
||||
TRANSITION_DURATION_MAX = 0.8
|
||||
|
||||
# ── 硬切比例 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#: 硬切概率下限(至少 30% 硬切,保持节奏感)
|
||||
CUT_RATIO_MIN = 0.3
|
||||
|
||||
#: 硬切概率上限(最多 50% 硬切,保证足够视觉变化)
|
||||
CUT_RATIO_MAX = 0.5
|
||||
|
||||
# ── 位置微调 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#: 转场位置最大偏移(秒),实际偏移在 [-MAX, +MAX] 均匀分布
|
||||
#: 正 = 转场推迟(多留一点前一片段),负 = 转场提前
|
||||
TIMING_JITTER_MAX = 0.5
|
||||
|
||||
|
||||
# ── 内部辅助 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cut_probability_for_pair(
|
||||
prev_duration: float,
|
||||
next_duration: float,
|
||||
) -> float:
|
||||
"""根据相邻片段时长计算硬切概率。
|
||||
|
||||
与 #1764 节奏模板协同:
|
||||
- 两片段都较短(<3s,快节奏)→ 硬切概率更高(节奏更紧凑)
|
||||
- 两片段都较长(>6s,慢节奏)→ 硬切概率稍低(留出过渡空间)
|
||||
- 混合场景 → 基准概率(CUT_RATIO_MIN + CUT_RATIO_MAX)/ 2
|
||||
|
||||
返回概率始终在 [CUT_RATIO_MIN, CUT_RATIO_MAX] 范围内。
|
||||
"""
|
||||
base = (CUT_RATIO_MIN + CUT_RATIO_MAX) / 2 # 0.4
|
||||
avg_dur = (prev_duration + next_duration) / 2
|
||||
|
||||
if avg_dur < 3.0:
|
||||
# 快节奏:硬切概率偏高
|
||||
return min(CUT_RATIO_MAX, base + 0.1)
|
||||
elif avg_dur > 6.0:
|
||||
# 慢节奏:硬切概率偏低(更多视觉过渡)
|
||||
return max(CUT_RATIO_MIN, base - 0.1)
|
||||
return base
|
||||
|
||||
|
||||
def _apply_jitter(
|
||||
base_duration: float,
|
||||
jitter: float,
|
||||
clip_duration: float,
|
||||
) -> float:
|
||||
"""给转场时长应用微调偏移,钳制到安全范围。
|
||||
|
||||
Args:
|
||||
base_duration: 基础转场时长
|
||||
jitter: 偏移量(可正可负)
|
||||
clip_duration: 较短的相邻片段时长(转场不能超过此值)
|
||||
|
||||
Returns:
|
||||
钳制后的实际转场时长
|
||||
"""
|
||||
effective = base_duration + jitter
|
||||
# 上界:不超过相邻片段时长的 40%(留足内容时间),也不超过 MAX
|
||||
upper = min(TRANSITION_DURATION_MAX, clip_duration * 0.4)
|
||||
lower = TRANSITION_DURATION_MIN if jitter < 0 else max(TRANSITION_DURATION_MIN, base_duration)
|
||||
return max(lower, min(upper, effective))
|
||||
|
||||
|
||||
# ── 核心函数 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_transition_plan(
|
||||
num_transitions: int,
|
||||
*,
|
||||
clip_durations: list[float] | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> list[dict]:
|
||||
"""为变体生成一组随机化的转场计划。
|
||||
|
||||
每个转场点独立随机选择类型和时长,硬切比例保持在 30%-50%。
|
||||
|
||||
Args:
|
||||
num_transitions: 转场点数量(= 主片段数 - 1)
|
||||
clip_durations: 各片段时长(用于协同节奏:长片段间转场更长),
|
||||
长度应 >= num_transitions + 1;不足时用默认值
|
||||
rng: 可选随机数生成器(测试可注入固定种子)
|
||||
|
||||
Returns:
|
||||
转场计划列表,每项:
|
||||
- effect: str — "cut" 或 TRANSITION_POOL 中某一效果
|
||||
- duration: float — 转场时长(cut 为 0.0)
|
||||
- jitter: float — 位置偏移量(秒,-0.5 ~ +0.5)
|
||||
"""
|
||||
if num_transitions <= 0:
|
||||
return []
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
|
||||
if clip_durations is None:
|
||||
clip_durations = [5.0] * (num_transitions + 1)
|
||||
|
||||
plan: list[dict] = []
|
||||
for i in range(num_transitions):
|
||||
prev_dur = clip_durations[i] if i < len(clip_durations) else 5.0
|
||||
next_dur = clip_durations[i + 1] if (i + 1) < len(clip_durations) else 5.0
|
||||
|
||||
# 计算硬切概率(协同节奏)
|
||||
cut_prob = _cut_probability_for_pair(prev_dur, next_dur)
|
||||
|
||||
# 随机决定是否硬切
|
||||
if rng.random() < cut_prob:
|
||||
effect = HARD_CUT
|
||||
duration = 0.0
|
||||
else:
|
||||
effect = rng.choice(TRANSITION_POOL)
|
||||
base_dur = rng.uniform(TRANSITION_DURATION_MIN, TRANSITION_DURATION_MAX)
|
||||
# 协同节奏:长片段间转场基础时长更长
|
||||
avg_dur = (prev_dur + next_dur) / 2
|
||||
if avg_dur > 6.0:
|
||||
base_dur = min(TRANSITION_DURATION_MAX, base_dur * 1.15)
|
||||
# 应用位置微调
|
||||
jitter = rng.uniform(-TIMING_JITTER_MAX, TIMING_JITTER_MAX)
|
||||
shorter_clip = min(prev_dur, next_dur)
|
||||
duration = _apply_jitter(base_dur, jitter, shorter_clip)
|
||||
|
||||
jitter_val = rng.uniform(-TIMING_JITTER_MAX, TIMING_JITTER_MAX) if effect != HARD_CUT else 0.0
|
||||
|
||||
plan.append(
|
||||
{
|
||||
"effect": effect,
|
||||
"duration": round(duration, 3),
|
||||
"jitter": round(jitter_val, 3),
|
||||
}
|
||||
)
|
||||
|
||||
return plan
|
||||
@@ -29,6 +29,7 @@ import logging
|
||||
import random
|
||||
|
||||
from packages.domain.plan_generator_utils import _resolve_start_time
|
||||
from packages.domain.transition_randomizer import generate_transition_plan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -129,7 +130,10 @@ def reselect_clips_for_variant(
|
||||
Raises:
|
||||
ValueError: 源片段为空 / 素材池为空 / 素材时长全为 0(无法差异化选片)。
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
elif isinstance(rng, int):
|
||||
rng = random.Random(rng)
|
||||
if not source_clips:
|
||||
raise ValueError("源 plan 无片段,无法为变体重新选片")
|
||||
if not candidate_asset_ids:
|
||||
@@ -222,6 +226,9 @@ def reselect_clips_for_variant(
|
||||
batch_segments.setdefault(aid, []).append(interval)
|
||||
result[idx] = _base_clip_data(src, asset_id=aid, start=start, duration=target_dur)
|
||||
|
||||
# ── 4. #1766 转场随机化:为相邻 main 片段对生成随机转场序列 ────────
|
||||
_apply_transition_randomization(result, rng)
|
||||
|
||||
return [c for c in result if c is not None]
|
||||
|
||||
|
||||
@@ -312,7 +319,10 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
|
||||
- speed_factor: 0.95~1.05 速度微调(±5%,肉眼不太敏感但时间轴不同)
|
||||
- brightness_shift: -10~+10 亮度偏移(eq=brightness,画面明暗差异)
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
elif isinstance(rng, int):
|
||||
rng = random.Random(rng)
|
||||
return {
|
||||
"hflip": rng.random() < 0.3,
|
||||
"zoom_ratio": round(1.0 + rng.uniform(0, 0.08), 4),
|
||||
@@ -321,6 +331,103 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def generate_pixel_perturbation(rng: random.Random | int | None = None) -> dict:
|
||||
"""为一个变体生成像素级扰动滤镜参数(Issue #1765)。
|
||||
|
||||
在现有视觉扰动(hflip/zoom/brightness)基础上,额外叠加 2-3 种
|
||||
像素级滤镜,让同素材不同变体在帧级 SSIM 差异 > 3%,肉眼看不出差异。
|
||||
|
||||
滤镜选项(随机选 2-3 种叠加):
|
||||
- noise: 轻微噪声 (noise=alls=0.015:allf=t+u)
|
||||
- unsharp: 锐化或柔化 (unsharp=3:3:-0.5 ~ 3:3:0.5)
|
||||
- curves: 对比度微调 (curves 轻微调整)
|
||||
- color_balance: RGB 通道偏移 (color_balance 微调)
|
||||
|
||||
返回 dict,可直接存入 plan.config["pixel_perturbation"]。
|
||||
渲染侧读取后追加到 ffmpeg filter chain。
|
||||
"""
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
elif isinstance(rng, int):
|
||||
rng = random.Random(rng)
|
||||
|
||||
# 可用滤镜池
|
||||
filter_options = ["noise", "unsharp", "curves", "color_balance"]
|
||||
|
||||
# 随机选 2-3 种
|
||||
num_filters = rng.choice([2, 2, 3])
|
||||
selected = rng.sample(filter_options, num_filters)
|
||||
|
||||
result: dict = {"filters": selected}
|
||||
|
||||
# 为每种滤镜生成具体参数
|
||||
if "noise" in selected:
|
||||
# 噪声强度 0.01~0.02(肉眼不可见)
|
||||
result["noise_strength"] = round(rng.uniform(0.01, 0.02), 4)
|
||||
|
||||
if "unsharp" in selected:
|
||||
# 锐化/柔化:-0.5 ~ +0.5(正值锐化,负值柔化)
|
||||
result["unsharp_amount"] = round(rng.uniform(-0.5, 0.5), 2)
|
||||
|
||||
if "curves" in selected:
|
||||
# 对比度微调:0.95 ~ 1.05
|
||||
result["curves_contrast"] = round(rng.uniform(0.95, 1.05), 3)
|
||||
|
||||
if "color_balance" in selected:
|
||||
# RGB 通道偏移:-5 ~ +5(极轻微色偏)
|
||||
result["color_r"] = rng.choice([-5, -3, 0, 0, 3, 5])
|
||||
result["color_g"] = rng.choice([-5, -3, 0, 0, 3, 5])
|
||||
result["color_b"] = rng.choice([-5, -3, 0, 0, 3, 5])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _apply_transition_randomization(
|
||||
result: list[dict | None],
|
||||
rng: random.Random,
|
||||
) -> None:
|
||||
"""#1766 对 result 中相邻 main 片段应用转场随机化(就地修改)。
|
||||
|
||||
为每对相邻 main 片段独立选择:
|
||||
- 转场类型(TRANSITION_POOL 中随机,或硬切)
|
||||
- 转场时长(0.3s ~ 0.8s,协同片段时长)
|
||||
- 位置微调 jitter(±0.5s,存入 config["transition_jitter"])
|
||||
|
||||
硬切比例保证在 30%-50%。intro/outro 等非 main 片段的转场保持源值不变。
|
||||
"""
|
||||
# 收集 main 片段的索引(按 order 排序)
|
||||
main_indices = [i for i, c in enumerate(result) if c is not None and c.get("clip_type", "main") == "main"]
|
||||
|
||||
if len(main_indices) < 2:
|
||||
# 不足 2 个 main 片段,无转场点可随机化
|
||||
return
|
||||
|
||||
num_transitions = len(main_indices) - 1
|
||||
# 用 main 片段的 duration 作为协同节奏的输入
|
||||
clip_durations = [result[i]["duration"] for i in main_indices]
|
||||
|
||||
plan = generate_transition_plan(
|
||||
num_transitions,
|
||||
clip_durations=clip_durations,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
# 将转场计划应用到每对相邻 main 片段
|
||||
# plan[k] 是 main_indices[k] → main_indices[k+1] 之间的转场
|
||||
# 转场信息存储在"目标 clip"(即每对的第二个)的 transition_effect/duration
|
||||
for k, transition_info in enumerate(plan):
|
||||
target_idx = main_indices[k + 1]
|
||||
if result[target_idx] is None:
|
||||
continue
|
||||
clip = result[target_idx]
|
||||
clip["transition_effect"] = transition_info["effect"]
|
||||
clip["transition_duration"] = transition_info["duration"]
|
||||
# jitter 存入 config,供渲染侧 xfade_builder 读取
|
||||
cfg = clip.get("config") or {}
|
||||
cfg["transition_jitter"] = transition_info["jitter"]
|
||||
clip["config"] = cfg
|
||||
|
||||
|
||||
def _base_clip_data(src: dict, *, asset_id: str, start: float, duration: float | None = None) -> dict:
|
||||
"""从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。"""
|
||||
return {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
|
||||
@@ -373,3 +373,361 @@ def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) -
|
||||
# concat 滤镜(使用 audio_label 作为输入)
|
||||
audio_inputs = "".join(f"[{c.audio_label}]" for c in audio_chains)
|
||||
parts.append(f"{audio_inputs}concat=n={len(audio_chains)}:v=0:a=1[outa]")
|
||||
|
||||
|
||||
# ── 标题 drawtext 滤镜构建(#1789)─────────────────────────────────────────────
|
||||
|
||||
# drawtext 字体搜索路径:按优先级列出常见安装位置
|
||||
# 服务器使用 Noto Sans SC(思源黑体)作为默认字体
|
||||
DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
|
||||
"/usr/share/fonts/noto/NotoSansSC-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
]
|
||||
|
||||
# 前端字体名 → drawtext 字体搜索关键字
|
||||
DRAWTEXT_FONT_MAP: dict[str, str] = {
|
||||
"思源黑体": "NotoSansCJK",
|
||||
"思源宋体": "NotoSerifCJK",
|
||||
"苹方": "NotoSansCJK",
|
||||
"PingFang": "NotoSansCJK",
|
||||
"微软雅黑": "NotoSansCJK",
|
||||
"楷体": "NotoSerifCJK",
|
||||
"华康俪金黑": "NotoSansCJK",
|
||||
}
|
||||
|
||||
|
||||
def _escape_drawtext_text(text: str) -> str:
|
||||
"""转义 drawtext 特殊字符。
|
||||
|
||||
FFmpeg drawtext 要求转义:
|
||||
- \\ → \\\\
|
||||
- ' → \\\\'
|
||||
- : → \\\\:
|
||||
- % → %%(drawtext 中 % 是时间码特殊字符)
|
||||
"""
|
||||
result = text.replace("\\", "\\\\\\\\")
|
||||
result = result.replace("'", "\\\\'")
|
||||
result = result.replace(":", "\\\\:")
|
||||
result = result.replace("%", "%%")
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_font_path(font_name: str) -> str:
|
||||
"""解析字体名到服务器实际字体文件路径。
|
||||
|
||||
查找策略:
|
||||
1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字
|
||||
2. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径
|
||||
3. 未找到则返回空字符串(drawtext 使用内置默认字体)
|
||||
"""
|
||||
keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name)
|
||||
import os
|
||||
|
||||
for path in DRAWTEXT_FONT_SEARCH_PATHS:
|
||||
if keyword.lower() in path.lower() and os.path.isfile(path):
|
||||
return path
|
||||
# fallback:遍历搜索任意可用字体
|
||||
for path in DRAWTEXT_FONT_SEARCH_PATHS:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return ""
|
||||
|
||||
|
||||
def build_title_drawtext_filter(
|
||||
title_config: dict[str, Any],
|
||||
output_width: int = DEFAULT_OUTPUT_WIDTH,
|
||||
output_height: int = DEFAULT_OUTPUT_HEIGHT,
|
||||
) -> str | None:
|
||||
"""从 title_config 生成 FFmpeg drawtext 滤镜字符串。
|
||||
|
||||
支持前端 TitleSettings 的全部参数:
|
||||
- text / 标题文字
|
||||
- font / 字体名
|
||||
- font_size / 字号
|
||||
- font_color / 颜色(#RRGGBB)
|
||||
- position / 位置(top / center / bottom / custom)
|
||||
- bold / 粗体
|
||||
- stroke / 描边
|
||||
- shadow / 阴影
|
||||
- pos_x, pos_y / 自由位置坐标
|
||||
|
||||
Args:
|
||||
title_config: 标题配置 dict(来自 plan.config["title"])
|
||||
output_width: 输出视频宽度
|
||||
output_height: 输出视频高度
|
||||
|
||||
Returns:
|
||||
drawtext 滤镜字符串;标题为空或 disabled 时返回 None
|
||||
"""
|
||||
if not title_config or not isinstance(title_config, dict):
|
||||
return None
|
||||
|
||||
# 字段名归一化:兼容 content/text、font_preset/font 两套命名
|
||||
text = (title_config.get("text") or title_config.get("content") or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
enabled = title_config.get("enabled", True)
|
||||
if not enabled:
|
||||
return None
|
||||
|
||||
# ── 样式参数 ──
|
||||
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
|
||||
font_size = int(title_config.get("font_size") or title_config.get("size") or 36)
|
||||
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
|
||||
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
|
||||
if font_color.startswith("#"):
|
||||
font_color = font_color[1:]
|
||||
|
||||
position = title_config.get("position", "top")
|
||||
bold = bool(title_config.get("bold", True))
|
||||
stroke = title_config.get("stroke")
|
||||
shadow = title_config.get("shadow")
|
||||
|
||||
# ── 构建 drawtext 参数 ──
|
||||
params: list[str] = []
|
||||
|
||||
# 字体文件
|
||||
font_path = _resolve_font_path(font_name)
|
||||
if font_path:
|
||||
escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'")
|
||||
params.append(f"fontfile='{escaped_path}'")
|
||||
|
||||
# 文字内容
|
||||
params.append(f"text='{_escape_drawtext_text(text)}'")
|
||||
|
||||
# 字号 & 颜色
|
||||
params.append(f"fontsize={font_size}")
|
||||
params.append(f"fontcolor={font_color}")
|
||||
|
||||
# 粗体:bold 在 drawtext 中通过 font 的 Bold 变体实现
|
||||
# 若字体有 Bold 变体可用 fontfont=bold;否则通过 borderw 模拟
|
||||
if bold:
|
||||
# 使用 font 参数尝试加载 Bold 变体(Noto Sans SC 有 Bold 变体文件)
|
||||
params.append("font=bold")
|
||||
|
||||
# 描边(borderw 需要 libfreetype 支持)
|
||||
if stroke:
|
||||
if isinstance(stroke, bool):
|
||||
border_width = 2
|
||||
border_color = "black"
|
||||
elif isinstance(stroke, dict):
|
||||
border_width = int(stroke.get("width", 2)) if stroke.get("enabled", True) else 0
|
||||
border_color = (stroke.get("color") or "#000000").lstrip("#")
|
||||
else:
|
||||
border_width = 0
|
||||
border_color = "black"
|
||||
if border_width > 0:
|
||||
params.append(f"borderw={border_width}")
|
||||
params.append(f"bordercolor={border_color}")
|
||||
|
||||
# 阴影(shadowcolor + shadowx/y)
|
||||
if shadow:
|
||||
if isinstance(shadow, bool):
|
||||
params.append("shadowcolor=black")
|
||||
params.append("shadowx=2")
|
||||
params.append("shadowy=2")
|
||||
elif isinstance(shadow, dict):
|
||||
if shadow.get("enabled", True):
|
||||
params.append(f"shadowcolor={(shadow.get('color') or '#000000').lstrip('#')}")
|
||||
params.append(f"shadowx={int(shadow.get('offset_x', 2))}")
|
||||
params.append(f"shadowy={int(shadow.get('offset_y', 2))}")
|
||||
|
||||
# ── 位置计算 ──
|
||||
# 优先使用自定义坐标 pos_x / pos_y
|
||||
pos_x = title_config.get("pos_x")
|
||||
pos_y = title_config.get("pos_y")
|
||||
if (
|
||||
position == "custom"
|
||||
and isinstance(pos_x, (int, float))
|
||||
and isinstance(pos_y, (int, float))
|
||||
and not isinstance(pos_x, bool)
|
||||
and not isinstance(pos_y, bool)
|
||||
):
|
||||
params.append(f"x={int(pos_x)}")
|
||||
params.append(f"y={int(pos_y)}")
|
||||
else:
|
||||
# 三档预设位置:top / center / bottom
|
||||
# x 始终水平居中:(w-text_w)/2
|
||||
params.append("x=(w-text_w)/2")
|
||||
if position == "center":
|
||||
params.append("y=(h-text_h)/2")
|
||||
elif position == "bottom":
|
||||
params.append("y=h-text_h-50")
|
||||
else:
|
||||
# top(默认)
|
||||
params.append("y=50")
|
||||
|
||||
return "drawtext=" + ":".join(params)
|
||||
|
||||
|
||||
# ── B-roll 叠加滤镜 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_broll_overlay_filter(
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
video_duration: float,
|
||||
output_width: int = DEFAULT_OUTPUT_WIDTH,
|
||||
output_height: int = DEFAULT_OUTPUT_HEIGHT,
|
||||
) -> str:
|
||||
"""构建 B-roll 叠加滤镜链。
|
||||
|
||||
支持两种模式:
|
||||
- fullscreen: 在对口型视频中按时间段替换为全屏 B-roll 画面
|
||||
- pip: 在对口型视频上叠加画中画 B-roll
|
||||
|
||||
Args:
|
||||
b_roll_segments: B-roll 片段配置列表
|
||||
video_duration: 对口型视频总时长(秒)
|
||||
output_width: 输出宽度
|
||||
output_height: 输出高度
|
||||
|
||||
Returns:
|
||||
FFmpeg filter_complex 滤镜字符串片段
|
||||
"""
|
||||
if not b_roll_segments:
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
sorted_segments = sorted(b_roll_segments, key=lambda s: s.get("start_time", 0))
|
||||
|
||||
# 按模式分组处理
|
||||
fullscreen_segments = [s for s in sorted_segments if s.get("mode") == "fullscreen"]
|
||||
pip_segments = [s for s in sorted_segments if s.get("mode") == "pip"]
|
||||
|
||||
# ── fullscreen 模式: 切分 + concat ──
|
||||
if fullscreen_segments:
|
||||
parts.append(_build_fullscreen_filters(fullscreen_segments, video_duration, output_width, output_height))
|
||||
|
||||
# ── pip 模式: overlay 滤镜 ──
|
||||
if pip_segments:
|
||||
for idx, seg in enumerate(pip_segments):
|
||||
start = seg.get("start_time", 0)
|
||||
end = seg.get("end_time", video_duration)
|
||||
scale = seg.get("pip_scale", 0.3)
|
||||
position = seg.get("pip_position", "bottom_right")
|
||||
|
||||
pip_w = int(output_width * scale)
|
||||
pip_h = int(output_height * scale)
|
||||
|
||||
# 位置映射
|
||||
pos_map = {
|
||||
"top_left": "10:10",
|
||||
"top_right": "W-w-10:10",
|
||||
"bottom_left": "10:H-h-10",
|
||||
"bottom_right": "W-w-10:H-h-10",
|
||||
"center": "(W-w)/2:(H-h)/2",
|
||||
}
|
||||
pos_expr = pos_map.get(position, pos_map["bottom_right"])
|
||||
|
||||
broll_input_idx = len(sorted_segments) # placeholder for input index
|
||||
parts.append(
|
||||
f"[{broll_input_idx + idx}:v]scale={pip_w}:{pip_h}," f"enable='between(t,{start},{end})'[pip{idx}];"
|
||||
)
|
||||
# overlay onto main stream
|
||||
if idx == 0:
|
||||
base_label = "[vout]" if fullscreen_segments else "[0:v]"
|
||||
else:
|
||||
base_label = f"[pip{idx - 1}]"
|
||||
parts.append(f"{base_label}[pip{idx}]overlay={pos_expr}:enable='between(t,{start},{end})'[vout{idx}];")
|
||||
|
||||
result = "".join(parts)
|
||||
# 清理末尾多余分号
|
||||
if result.endswith(";"):
|
||||
result = result[:-1]
|
||||
return result
|
||||
|
||||
|
||||
def _build_fullscreen_filters(
|
||||
segments: list[dict[str, Any]],
|
||||
video_duration: float,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
) -> str:
|
||||
"""构建 fullscreen 模式的切分 + concat 滤镜.
|
||||
|
||||
将对口型视频按 B-roll 时间段切分,然后用 concat 拼接 B-roll 片段。
|
||||
"""
|
||||
parts: list[str] = []
|
||||
prev_end = 0.0
|
||||
|
||||
for idx, seg in enumerate(segments):
|
||||
start = seg.get("start_time", 0)
|
||||
end = seg.get("end_time", video_duration)
|
||||
|
||||
# 保持原视频片段(B-roll 之前的部分)
|
||||
if prev_end < start:
|
||||
parts.append(f"[0:v]trim=start={prev_end}:end={start},setpts=PTS-STARTPTS[main{idx}];")
|
||||
|
||||
# B-roll 片段:缩放至目标分辨率
|
||||
parts.append(
|
||||
f"[{idx + 1}:v]scale={output_width}:{output_height}"
|
||||
f":force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,"
|
||||
f"trim=start=0:end={end - start},setpts=PTS-STARTPTS[br{idx}];"
|
||||
)
|
||||
prev_end = end
|
||||
|
||||
# 尾部片段
|
||||
if prev_end < video_duration:
|
||||
last_idx = len(segments)
|
||||
parts.append(f"[0:v]trim=start={prev_end}:end={video_duration},setpts=PTS-STARTPTS[main{last_idx}];")
|
||||
|
||||
# concat 所有片段
|
||||
segment_labels = []
|
||||
for idx in range(len(segments)):
|
||||
start = segments[idx].get("start_time", 0)
|
||||
if (idx == 0 and segments[0].get("start_time", 0) > 0) or idx > 0:
|
||||
prev_end_prev = segments[idx - 1].get("end_time", 0) if idx > 0 else 0
|
||||
if prev_end_prev < start:
|
||||
segment_labels.append(f"[main{idx}]")
|
||||
segment_labels.append(f"[br{idx}]")
|
||||
|
||||
if prev_end < video_duration:
|
||||
segment_labels.append(f"[main{len(segments)}]")
|
||||
|
||||
n = len(segment_labels)
|
||||
if n > 0:
|
||||
concat_inputs = "".join(segment_labels)
|
||||
parts.append(f"{concat_inputs}concat=n={n}:v=1:a=0[vout];")
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def build_cover_extract_command(
|
||||
cover_config: dict[str, Any],
|
||||
output_path: str,
|
||||
) -> str:
|
||||
"""根据封面配置生成 FFmpeg 截帧命令。
|
||||
|
||||
Args:
|
||||
cover_config: 封面配置,支持:
|
||||
- timestamp: 截取时间点(秒),默认 0
|
||||
- width: 封面宽度(可选)
|
||||
- height: 封面高度(可选)
|
||||
output_path: 输出封面文件路径
|
||||
|
||||
Returns:
|
||||
FFmpeg 命令行字符串
|
||||
"""
|
||||
if not cover_config or not isinstance(cover_config, dict):
|
||||
timestamp = 0.0
|
||||
else:
|
||||
timestamp = cover_config.get("timestamp", 0.0)
|
||||
|
||||
width = cover_config.get("width", 0) if isinstance(cover_config, dict) else 0
|
||||
height = cover_config.get("height", 0) if isinstance(cover_config, dict) else 0
|
||||
|
||||
scale_filter = ""
|
||||
if width > 0 and height > 0:
|
||||
scale_filter = (
|
||||
f"-vf scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
||||
)
|
||||
|
||||
cmd = f"ffmpeg -ss {timestamp} -i INPUT_VIDEO -frames:v 1 {scale_filter} -y {output_path}"
|
||||
return cmd
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""配音时长 → 片段时长分配纯函数(#1749)。
|
||||
"""配音时长 → 片段时长分配纯函数(#1749 + #1764 节奏模板)。
|
||||
|
||||
定稿规则(工单 #1749):
|
||||
1. 片段数 = 模板片段数,定死,不因素材增减;
|
||||
@@ -8,6 +8,14 @@
|
||||
禁止慢放、禁止截断配音;
|
||||
4. 任何情况下不得因素材时长/数量报错打断用户。
|
||||
|
||||
#1764 节奏模板 + #1768 多样化增强:
|
||||
- 预设 8 种权重序列,不同变体用不同节奏模板
|
||||
- 片段时长 = 配音总时长 × 该片段权重 / 权重总和
|
||||
- 平均分配作为权重全 1 的特例保留
|
||||
- 每个片段 >= MIN_CLIP_DURATION(2秒)
|
||||
- #1768:最大片段时长 <= 素材可用时长 × 90%
|
||||
- #1768:成片总时长与配音时长误差 <= TOTAL_DURATION_TOLERANCE(0.5s)
|
||||
|
||||
本模块为纯函数:输入片段骨架(每段转场效果/时长)与配音总时长,
|
||||
输出每段目标时长(target duration)与成片总时长。不碰 DB、不碰素材。
|
||||
"""
|
||||
@@ -15,16 +23,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 单段最小时长(秒):低于此值播放器/渲染链路易出问题
|
||||
MIN_CLIP_DURATION = 1.0
|
||||
MIN_CLIP_DURATION = 2.0
|
||||
|
||||
#: 成片总时长与配音时长的可接受误差(秒)
|
||||
TOTAL_DURATION_TOLERANCE = 0.5
|
||||
|
||||
# ── #1764 节奏模板池 ──────────────────────────────────────────────────────
|
||||
# 每种模板是权重序列,权重值代表相对时长比例
|
||||
# 变体基于 variant_seed 随机选一个模板,实现不同变体时长结构不同
|
||||
RHYTHM_TEMPLATES: list[list[int]] = [
|
||||
[1, 1, 1, 1, 1], # 平均(基准)
|
||||
[2, 1, 3, 1, 2], # 中间长,两端短
|
||||
[1, 2, 1, 2, 1], # 偶数段长
|
||||
[3, 1, 1, 1, 3], # 两端长,中间短
|
||||
[1, 1, 3, 2, 1], # 后段渐长
|
||||
[2, 1, 1, 3, 1], # 前段较长 + 第4段最长
|
||||
[2, 2, 1, 1, 2], # #1768 前重后轻
|
||||
[3, 2, 1, 2, 1], # #1768 渐弱节奏
|
||||
]
|
||||
|
||||
|
||||
def get_rhythm_template(variant_seed: int | None = None) -> list[int]:
|
||||
"""根据 variant_seed 选择一个节奏模板。
|
||||
|
||||
Args:
|
||||
variant_seed: 变体随机种子;None 时返回平均模板
|
||||
|
||||
Returns:
|
||||
权重序列(list[int])
|
||||
"""
|
||||
if variant_seed is None:
|
||||
return RHYTHM_TEMPLATES[0] # 默认平均
|
||||
rng = random.Random(variant_seed)
|
||||
return rng.choice(RHYTHM_TEMPLATES)
|
||||
|
||||
|
||||
def adapt_template_length(template: list[int], clip_count: int) -> list[int]:
|
||||
"""将节奏模板适配到实际片段数。
|
||||
|
||||
片段数 != 模板长度时:
|
||||
- clip_count < len(template): 截断
|
||||
- clip_count > len(template): 循环填充
|
||||
|
||||
Args:
|
||||
template: 原始权重序列
|
||||
clip_count: 实际片段数
|
||||
|
||||
Returns:
|
||||
适配后的权重序列(长度 == clip_count)
|
||||
"""
|
||||
if clip_count <= 0:
|
||||
return []
|
||||
if clip_count == len(template):
|
||||
return template[:]
|
||||
if clip_count < len(template):
|
||||
return template[:clip_count]
|
||||
# clip_count > len(template): 循环填充
|
||||
result = []
|
||||
for i in range(clip_count):
|
||||
result.append(template[i % len(template)])
|
||||
return result
|
||||
|
||||
|
||||
def transition_overlap_seconds(transition_effect: Optional[str], transition_duration: float) -> float:
|
||||
"""转场导致的相邻片段重叠时长。
|
||||
@@ -43,9 +108,13 @@ def plan_clip_durations(
|
||||
voice_duration: float,
|
||||
transition_effects: Optional[list[Optional[str]]] = None,
|
||||
transition_durations: Optional[list[float]] = None,
|
||||
rhythm_template: Optional[list[int]] = None,
|
||||
asset_durations: Optional[list[float]] = None,
|
||||
) -> list[float]:
|
||||
"""把配音总时长分配到 clip_count 段,返回每段目标时长(秒)。
|
||||
|
||||
#1764:支持节奏模板,按权重比例分配时长;无模板时平均分配(向后兼容)。
|
||||
|
||||
分配口径:Σ段长 − Σ转场重叠 = 配音时长(成片净时长 = 配音)。
|
||||
转场重叠发生在相邻片段之间,共 clip_count-1 处;第 i 处重叠取
|
||||
**后一段(i+1)** 的转场设置(与 xfade 构建口径一致:转场挂在后段)。
|
||||
@@ -58,6 +127,9 @@ def plan_clip_durations(
|
||||
transition_effects: 每段转场效果(长度 clip_count,index 0 的转场无效)。
|
||||
transition_durations: 每段转场时长(长度 clip_count)。
|
||||
|
||||
asset_durations: #1768 每段可用素材时长(秒),用于钳制最大片段时长
|
||||
<= 素材可用时长 × 90%。长度 clip_count;None 或空则不钳制上限。
|
||||
|
||||
Returns:
|
||||
每段目标时长列表(长度 clip_count);无配音/非法输入返回 []。
|
||||
"""
|
||||
@@ -92,13 +164,82 @@ def plan_clip_durations(
|
||||
MIN_CLIP_DURATION,
|
||||
)
|
||||
|
||||
per_clip = gross / clip_count
|
||||
result = [round(per_clip, 3) for _ in range(clip_count)]
|
||||
# 末段吸收舍入误差:直接用 gross - 前段之和
|
||||
result[-1] = round(gross - sum(result[:-1]), 3)
|
||||
# #1764:按节奏模板权重分配(无模板时全 1 = 平均分配)
|
||||
weights = rhythm_template if rhythm_template and len(rhythm_template) == clip_count else [1] * clip_count
|
||||
|
||||
# 确保每个片段 >= MIN_CLIP_DURATION
|
||||
# 先按权重分配,再检查最小值
|
||||
total_weight = sum(weights)
|
||||
raw_durations = [(w / total_weight) * gross for w in weights]
|
||||
|
||||
# 保底检查:如果有片段 < MIN_CLIP_DURATION,提升它并从最长片段扣
|
||||
result = [round(d, 3) for d in raw_durations]
|
||||
for _ in range(3): # 最多迭代 3 次
|
||||
min_idx = min(range(len(result)), key=lambda i: result[i])
|
||||
if result[min_idx] >= MIN_CLIP_DURATION:
|
||||
break
|
||||
# 从最长片段借时长
|
||||
max_idx = max(range(len(result)), key=lambda i: result[i])
|
||||
if max_idx == min_idx or result[max_idx] <= MIN_CLIP_DURATION:
|
||||
# 无法再调整,强制保底
|
||||
result[min_idx] = MIN_CLIP_DURATION
|
||||
break
|
||||
deficit = MIN_CLIP_DURATION - result[min_idx]
|
||||
result[min_idx] = MIN_CLIP_DURATION
|
||||
result[max_idx] = round(result[max_idx] - deficit, 3)
|
||||
|
||||
# #1768:最大片段时长钳制(<= 素材可用时长 × 90%)
|
||||
if asset_durations and len(asset_durations) == clip_count:
|
||||
for _ in range(3): # 迭代收敛
|
||||
clamped = False
|
||||
for i in range(len(result)):
|
||||
try:
|
||||
max_dur = float(asset_durations[i]) * 0.9
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
if result[i] > max_dur and max_dur >= MIN_CLIP_DURATION:
|
||||
excess = result[i] - max_dur
|
||||
result[i] = round(max_dur, 3)
|
||||
# 将多余时长分配给最短的未超限片段
|
||||
candidates = [
|
||||
j
|
||||
for j in range(len(result))
|
||||
if j != i
|
||||
and (
|
||||
not asset_durations
|
||||
or j >= len(asset_durations)
|
||||
or result[j] < float(asset_durations[j]) * 0.9
|
||||
)
|
||||
]
|
||||
if candidates:
|
||||
shortest = min(candidates, key=lambda j: result[j])
|
||||
result[shortest] = round(result[shortest] + excess, 3)
|
||||
clamped = True
|
||||
if not clamped:
|
||||
break
|
||||
|
||||
# 末段吸收舍入误差
|
||||
total_assigned = sum(result[:-1])
|
||||
result[-1] = round(gross - total_assigned, 3)
|
||||
if result[-1] < MIN_CLIP_DURATION:
|
||||
# 极端情况下末段被舍入压得过小,摊平
|
||||
result[-1] = MIN_CLIP_DURATION
|
||||
|
||||
# #1768:时长总和误差校验(成片净时长 ≈ 配音时长)
|
||||
net_total = total_output_duration(result, transition_effects, transition_durations)
|
||||
deviation = abs(net_total - voice)
|
||||
if deviation > TOTAL_DURATION_TOLERANCE:
|
||||
logger.warning(
|
||||
"#1768 时长总和误差 %.3fs 超过阈值 %.1fs(voice=%.2fs, net=%.2fs),末段补偿修正",
|
||||
deviation,
|
||||
TOTAL_DURATION_TOLERANCE,
|
||||
voice,
|
||||
net_total,
|
||||
)
|
||||
# 修正末段使净时长回归配音时长
|
||||
result[-1] = round(result[-1] + (voice - net_total), 3)
|
||||
if result[-1] < MIN_CLIP_DURATION:
|
||||
result[-1] = MIN_CLIP_DURATION
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ def build_xfade_filter_chain(
|
||||
transitions: list[str],
|
||||
*,
|
||||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||||
transition_durations: list[float] | None = None,
|
||||
jitters: list[float] | None = None,
|
||||
output_label: str = "outv",
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链.
|
||||
@@ -116,11 +118,19 @@ def build_xfade_filter_chain(
|
||||
对每步 xfade 自动钳制 transition duration,确保
|
||||
``offset + td ≤ first_input_duration``,避免 FFmpeg exit 234。
|
||||
|
||||
#1766 增强:支持逐转场独立时长(transition_durations)和位置微调(jitters)。
|
||||
传入 transition_durations 时,每个转场点使用各自的时长,而非全局统一值。
|
||||
jitters 用于在 offset 上做 ±N 秒微调,实现转场位置随机化。
|
||||
|
||||
Args:
|
||||
clip_durations: 每个片段的时长(必须与 trim 后的实际时长一致)
|
||||
clip_video_labels: 每个片段的视频流标签(如 "v0", "v1")
|
||||
transitions: 每个片段对应的转场效果(第一个片段的转场被忽略)
|
||||
transition_duration: 转场时长(秒)
|
||||
transition_duration: 全局默认转场时长(秒),transition_durations 缺失时 fallback
|
||||
transition_durations: #1766 逐转场时长列表(与 transitions 等长),
|
||||
第 i 项对应 transitions[i] 的时长;None 时使用 transition_duration
|
||||
jitters: #1766 逐转场位置偏移列表(秒),与 transitions 等长,
|
||||
正值推迟转场、负值提前转场;None 时不做微调
|
||||
output_label: 最终输出标签
|
||||
|
||||
Returns:
|
||||
@@ -150,15 +160,28 @@ def build_xfade_filter_chain(
|
||||
else:
|
||||
first_input_dur = cumulative - total_transition
|
||||
|
||||
# 正确的 offset 计算:offset 应相对于累积输出时长
|
||||
# offset = 累积输出中,转场开始的时间点
|
||||
# = first_input_dur - transition_duration
|
||||
# 这样每个转场之间的"纯内容"时长等于原始 clip 时长
|
||||
offset = max(0.0, first_input_dur - transition_duration)
|
||||
# #1766: 逐转场时长(优先)或全局默认
|
||||
step_duration = (
|
||||
transition_durations[i - 1]
|
||||
if transition_durations and (i - 1) < len(transition_durations)
|
||||
else transition_duration
|
||||
)
|
||||
|
||||
# 安全钳制:offset + td 不能超过第一个输入的时长
|
||||
# #1766: 位置微调 jitter
|
||||
jitter = jitters[i - 1] if jitters and (i - 1) < len(jitters) else 0.0
|
||||
|
||||
# offset = 转场开始点(相对于累积输出起点)
|
||||
# 基础 offset = first_input_dur - step_duration
|
||||
# jitter > 0 推迟转场(offset 增大);jitter < 0 提前转场(offset 减小)
|
||||
offset = max(0.0, first_input_dur - step_duration) + jitter
|
||||
|
||||
# 安全钳制:offset 不能超出可用范围
|
||||
max_offset = max(0.0, first_input_dur - 0.001)
|
||||
offset = max(0.0, min(offset, max_offset))
|
||||
|
||||
# 安全钳制 td:offset + td 不能超过第一个输入的时长
|
||||
available = max(0.0, first_input_dur - offset)
|
||||
safe_td = min(transition_duration, available)
|
||||
safe_td = min(step_duration, available)
|
||||
|
||||
# 同时不能超过剩余总时长
|
||||
remaining = max(0.0, sum(clip_durations) - cumulative)
|
||||
|
||||
@@ -30,9 +30,16 @@ class AssetLibraryRepository(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
"""原子递增素材计数(Issue #1776)。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
"""原子递减素材计数(Issue #1776)。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def recount_assets(self, library_id: str) -> int:
|
||||
"""重算素材库计数(Issue #1776)。"""
|
||||
pass
|
||||
|
||||
@@ -18,6 +18,7 @@ class TemplateRepositoryPort(Protocol):
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> List[Template]: ...
|
||||
def get(self, template_id: str, user_id: str) -> Optional[Template]: ...
|
||||
def create(self, template: Template) -> Template: ...
|
||||
@@ -31,6 +32,7 @@ class TemplateRepositoryPort(Protocol):
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> int: ...
|
||||
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template: ...
|
||||
def list_segments(self, template_id: str) -> List[TemplateSegment]: ...
|
||||
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""素材库计数重算脚本(Issue #1776)。
|
||||
|
||||
用法:
|
||||
# Dry-run: 输出差异清单,不执行修改
|
||||
python scripts/recount_asset_counts.py --dry-run
|
||||
|
||||
# 执行修正
|
||||
python scripts/recount_asset_counts.py
|
||||
|
||||
# 只处理指定项目
|
||||
python scripts/recount_asset_counts.py --project-id <project_id>
|
||||
|
||||
# 只处理指定素材库
|
||||
python scripts/recount_asset_counts.py --library-id <library_id>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import create_engine, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel, AssetModel
|
||||
|
||||
|
||||
def get_db_session() -> Session:
|
||||
"""创建数据库 session。"""
|
||||
import os
|
||||
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
print("ERROR: DATABASE_URL environment variable not set")
|
||||
sys.exit(1)
|
||||
engine = create_engine(database_url)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def check_discrepancies(session: Session, project_id: str | None = None, library_id: str | None = None) -> list[dict]:
|
||||
"""检查素材库计数差异。
|
||||
|
||||
返回列表,每项包含:
|
||||
- library_id: 素材库 ID
|
||||
- library_name: 素材库名称
|
||||
- recorded_count: 记录的计数
|
||||
- actual_count: 实际计数
|
||||
- delta: 差异 (actual - recorded)
|
||||
"""
|
||||
query = session.query(AssetLibraryModel)
|
||||
if project_id:
|
||||
query = query.filter(AssetLibraryModel.project_id == project_id)
|
||||
if library_id:
|
||||
query = query.filter(AssetLibraryModel.id == library_id)
|
||||
|
||||
libraries = query.all()
|
||||
discrepancies = []
|
||||
|
||||
for lib in libraries:
|
||||
# 查询实际计数(排除 deleted)
|
||||
actual_count = (
|
||||
session.query(func.count(AssetModel.id))
|
||||
.filter(
|
||||
AssetModel.asset_library_id == lib.id,
|
||||
AssetModel.status != "deleted",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
actual_size = (
|
||||
session.query(func.coalesce(func.sum(AssetModel.file_size), 0))
|
||||
.filter(
|
||||
AssetModel.asset_library_id == lib.id,
|
||||
AssetModel.status != "deleted",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
recorded_count = int(lib.asset_count or 0)
|
||||
recorded_size = int(lib.total_size or 0)
|
||||
|
||||
if actual_count != recorded_count or actual_size != recorded_size:
|
||||
discrepancies.append(
|
||||
{
|
||||
"library_id": lib.id,
|
||||
"library_name": lib.name,
|
||||
"project_id": lib.project_id,
|
||||
"kind": lib.kind,
|
||||
"recorded_count": recorded_count,
|
||||
"actual_count": actual_count,
|
||||
"count_delta": actual_count - recorded_count,
|
||||
"recorded_size": recorded_size,
|
||||
"actual_size": actual_size,
|
||||
"size_delta": actual_size - recorded_size,
|
||||
}
|
||||
)
|
||||
|
||||
return discrepancies
|
||||
|
||||
|
||||
def fix_discrepancies(session: Session, discrepancies: list[dict]) -> int:
|
||||
"""修正素材库计数。返回修正数量。"""
|
||||
fixed = 0
|
||||
for d in discrepancies:
|
||||
session.query(AssetLibraryModel).filter(AssetLibraryModel.id == d["library_id"]).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: d["actual_count"],
|
||||
AssetLibraryModel.total_size: d["actual_size"],
|
||||
}
|
||||
)
|
||||
fixed += 1
|
||||
session.commit()
|
||||
return fixed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="素材库计数重算脚本(Issue #1776)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只输出差异清单,不执行修正")
|
||||
parser.add_argument("--project-id", type=str, help="只处理指定项目")
|
||||
parser.add_argument("--library-id", type=str, help="只处理指定素材库")
|
||||
args = parser.parse_args()
|
||||
|
||||
session = get_db_session()
|
||||
|
||||
try:
|
||||
discrepancies = check_discrepancies(session, args.project_id, args.library_id)
|
||||
|
||||
if not discrepancies:
|
||||
print("✅ 所有素材库计数一致,无需修正")
|
||||
return
|
||||
|
||||
# 输出差异清单
|
||||
print(f"发现 {len(discrepancies)} 个素材库计数不一致:\n")
|
||||
print(f"{'Library ID':<40} {'Name':<20} {'Recorded':<10} {'Actual':<10} {'Delta':<10}")
|
||||
print("-" * 90)
|
||||
for d in discrepancies:
|
||||
print(
|
||||
f"{d['library_id']:<40} {d['library_name'][:20]:<20} {d['recorded_count']:<10} {d['actual_count']:<10} {d['count_delta']:+<10}"
|
||||
)
|
||||
|
||||
total_delta = sum(d["count_delta"] for d in discrepancies)
|
||||
print(f"\n总计差异: {total_delta:+d}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n[DRY-RUN] 未执行修正。移除 --dry-run 参数以执行修正。")
|
||||
else:
|
||||
print("\n正在执行修正...")
|
||||
fixed = fix_discrepancies(session, discrepancies)
|
||||
print(f"✅ 已修正 {fixed} 个素材库计数")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""#1768 节奏模板多样化增强 — 单元测试。
|
||||
|
||||
覆盖:
|
||||
- 8 种预设模板完整性
|
||||
- MIN_CLIP_DURATION = 2.0(修复旧 1.0 覆盖 bug)
|
||||
- 最大片段时长钳制(<= 素材可用时长 × 90%)
|
||||
- 时长总和误差校验(<= 0.5s)
|
||||
- asset_durations 参数向后兼容(None/空 = 不钳制)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.voice_duration_planner import (
|
||||
MIN_CLIP_DURATION,
|
||||
RHYTHM_TEMPLATES,
|
||||
TOTAL_DURATION_TOLERANCE,
|
||||
adapt_template_length,
|
||||
get_rhythm_template,
|
||||
plan_clip_durations,
|
||||
total_output_duration,
|
||||
)
|
||||
|
||||
# ── 模板池 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRhythmTemplatesPool:
|
||||
"""#1768 模板池扩展到 8 种。"""
|
||||
|
||||
def test_template_count_is_8(self):
|
||||
assert len(RHYTHM_TEMPLATES) == 8
|
||||
|
||||
def test_all_templates_have_5_segments(self):
|
||||
for tpl in RHYTHM_TEMPLATES:
|
||||
assert len(tpl) == 5
|
||||
|
||||
def test_new_template_22112_exists(self):
|
||||
assert [2, 2, 1, 1, 2] in RHYTHM_TEMPLATES
|
||||
|
||||
def test_new_template_32121_exists(self):
|
||||
assert [3, 2, 1, 2, 1] in RHYTHM_TEMPLATES
|
||||
|
||||
def test_original_6_templates_preserved(self):
|
||||
originals = [
|
||||
[1, 1, 1, 1, 1],
|
||||
[2, 1, 3, 1, 2],
|
||||
[1, 2, 1, 2, 1],
|
||||
[3, 1, 1, 1, 3],
|
||||
[1, 1, 3, 2, 1],
|
||||
[2, 1, 1, 3, 1],
|
||||
]
|
||||
for orig in originals:
|
||||
assert orig in RHYTHM_TEMPLATES
|
||||
|
||||
def test_all_weights_positive(self):
|
||||
for tpl in RHYTHM_TEMPLATES:
|
||||
assert all(w > 0 for w in tpl)
|
||||
|
||||
def test_weight_sum_variety(self):
|
||||
"""不同模板权重和应不完全相同,确保节奏有差异。"""
|
||||
sums = {sum(t) for t in RHYTHM_TEMPLATES}
|
||||
assert len(sums) >= 3 # 至少有 3 种不同的权重和
|
||||
|
||||
|
||||
# ── 常量修复 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstantsFixed:
|
||||
"""#1768 修复 MIN_CLIP_DURATION 从 1.0 回到 2.0。"""
|
||||
|
||||
def test_min_clip_duration_is_2(self):
|
||||
assert MIN_CLIP_DURATION == 2.0
|
||||
|
||||
def test_total_duration_tolerance_is_05(self):
|
||||
assert TOTAL_DURATION_TOLERANCE == 0.5
|
||||
|
||||
|
||||
# ── get_rhythm_template ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetRhythmTemplate:
|
||||
def test_none_seed_returns_average(self):
|
||||
assert get_rhythm_template(None) == [1, 1, 1, 1, 1]
|
||||
|
||||
def test_same_seed_returns_same_template(self):
|
||||
for seed in [0, 42, 999, 123456]:
|
||||
t1 = get_rhythm_template(seed)
|
||||
t2 = get_rhythm_template(seed)
|
||||
assert t1 == t2
|
||||
|
||||
def test_different_seeds_can_yield_different_templates(self):
|
||||
"""大量 seed 应能命中多个不同模板。"""
|
||||
results = {tuple(get_rhythm_template(s)) for s in range(200)}
|
||||
assert len(results) >= 5 # 200 个 seed 至少命中 5 种模板
|
||||
|
||||
|
||||
# ── adapt_template_length ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAdaptTemplateLength:
|
||||
def test_exact_match(self):
|
||||
tpl = [2, 2, 1, 1, 2]
|
||||
assert adapt_template_length(tpl, 5) == tpl
|
||||
|
||||
def test_truncate(self):
|
||||
tpl = [2, 2, 1, 1, 2]
|
||||
assert adapt_template_length(tpl, 3) == [2, 2, 1]
|
||||
|
||||
def test_extend_cycles(self):
|
||||
tpl = [2, 2, 1, 1, 2]
|
||||
result = adapt_template_length(tpl, 8)
|
||||
assert len(result) == 8
|
||||
assert result == [2, 2, 1, 1, 2, 2, 2, 1]
|
||||
|
||||
def test_zero_clips(self):
|
||||
assert adapt_template_length([1, 1, 1], 0) == []
|
||||
|
||||
def test_negative_clips(self):
|
||||
assert adapt_template_length([1, 1, 1], -1) == []
|
||||
|
||||
|
||||
# ── plan_clip_durations 基础行为 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPlanClipDurationsBasic:
|
||||
def test_invalid_inputs(self):
|
||||
assert plan_clip_durations(0, 30.0) == []
|
||||
assert plan_clip_durations(-1, 30.0) == []
|
||||
assert plan_clip_durations(5, 0.0) == []
|
||||
assert plan_clip_durations(5, -10.0) == []
|
||||
assert plan_clip_durations(5, "abc") == []
|
||||
|
||||
def test_average_distribution_no_transitions(self):
|
||||
result = plan_clip_durations(5, 30.0)
|
||||
assert len(result) == 5
|
||||
assert abs(sum(result) - 30.0) < 0.01
|
||||
|
||||
def test_all_segments_above_min(self):
|
||||
result = plan_clip_durations(5, 30.0, rhythm_template=[3, 1, 1, 1, 3])
|
||||
for dur in result:
|
||||
assert dur >= MIN_CLIP_DURATION
|
||||
|
||||
def test_with_rhythm_template(self):
|
||||
tpl = [2, 2, 1, 1, 2]
|
||||
result = plan_clip_durations(5, 30.0, rhythm_template=tpl)
|
||||
assert len(result) == 5
|
||||
# 权重和 = 8,每段应大致为 7.5, 7.5, 3.75, 3.75, 7.5
|
||||
assert result[0] > result[2] # 权重 2 > 权重 1
|
||||
assert abs(sum(result) - 30.0) < 0.5
|
||||
|
||||
def test_total_duration_matches_voice(self):
|
||||
"""成片净时长 ≈ 配音时长(无转场时完全等于)。"""
|
||||
for voice in [15.0, 30.0, 60.0, 120.0]:
|
||||
result = plan_clip_durations(5, voice)
|
||||
net = total_output_duration(result)
|
||||
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE
|
||||
|
||||
def test_with_transitions(self):
|
||||
"""有转场时成片净时长也应 ≈ 配音时长。"""
|
||||
effects = [None, "xfade", "xfade", "xfade", "xfade"]
|
||||
durations = [0.0, 1.0, 1.0, 1.0, 1.0]
|
||||
result = plan_clip_durations(5, 30.0, transition_effects=effects, transition_durations=durations)
|
||||
net = total_output_duration(result, effects, durations)
|
||||
assert abs(net - 30.0) <= TOTAL_DURATION_TOLERANCE
|
||||
|
||||
|
||||
# ── #1768 最小片段时长钳制 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMinClipDurationClamp:
|
||||
def test_min_duration_2s_enforced(self):
|
||||
"""极端权重下,所有片段仍 >= 2.0s。"""
|
||||
tpl = [10, 1, 1, 1, 1]
|
||||
result = plan_clip_durations(5, 20.0, rhythm_template=tpl)
|
||||
for dur in result:
|
||||
assert dur >= 2.0, f"片段时长 {dur} < MIN_CLIP_DURATION(2.0)"
|
||||
|
||||
def test_short_voice_still_meets_minimum(self):
|
||||
"""配音极短时保底每段 MIN_CLIP_DURATION。"""
|
||||
result = plan_clip_durations(5, 3.0)
|
||||
for dur in result:
|
||||
assert dur >= MIN_CLIP_DURATION
|
||||
|
||||
|
||||
# ── #1768 最大片段时长钳制 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMaxClipDurationClamp:
|
||||
def test_no_clamp_without_asset_durations(self):
|
||||
"""不传 asset_durations 时不做上限钳制(向后兼容)。"""
|
||||
tpl = [5, 1, 1, 1, 1]
|
||||
result = plan_clip_durations(5, 30.0, rhythm_template=tpl)
|
||||
# 第一段权重 5/9 * 30 = 16.67,不应被钳制
|
||||
assert result[0] > 10.0
|
||||
|
||||
def test_no_clamp_with_empty_asset_durations(self):
|
||||
"""asset_durations 为空列表时不做上限钳制。"""
|
||||
tpl = [5, 1, 1, 1, 1]
|
||||
result = plan_clip_durations(5, 30.0, rhythm_template=tpl, asset_durations=[])
|
||||
assert result[0] > 10.0
|
||||
|
||||
def test_clamp_respects_90_percent(self):
|
||||
"""有素材时长时,片段时长 <= 素材可用时长 × 90%。"""
|
||||
tpl = [5, 1, 1, 1, 1]
|
||||
# 素材只有第一段短(12s),90% = 10.8s
|
||||
asset_durs = [12.0, 60.0, 60.0, 60.0, 60.0]
|
||||
result = plan_clip_durations(5, 30.0, rhythm_template=tpl, asset_durations=asset_durs)
|
||||
max_allowed = 12.0 * 0.9
|
||||
assert result[0] <= max_allowed + 0.01, f"第一段 {result[0]} 超过 90% 上限 {max_allowed}"
|
||||
|
||||
def test_clamp_does_not_violate_min(self):
|
||||
"""素材极短时钳制不违反 MIN_CLIP_DURATION。"""
|
||||
# 素材 2.0s,90% = 1.8s < MIN(2.0),不应钳制到 1.8
|
||||
asset_durs = [2.0, 60.0, 60.0, 60.0, 60.0]
|
||||
result = plan_clip_durations(5, 30.0, asset_durations=asset_durs)
|
||||
for dur in result:
|
||||
assert dur >= MIN_CLIP_DURATION
|
||||
|
||||
def test_clamp_preserves_total(self):
|
||||
"""钳制后总时长仍应接近配音时长。"""
|
||||
asset_durs = [10.0, 60.0, 60.0, 60.0, 60.0]
|
||||
voice = 30.0
|
||||
result = plan_clip_durations(5, voice, asset_durations=asset_durs)
|
||||
net = total_output_duration(result)
|
||||
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE + 0.5 # 允许略多误差
|
||||
|
||||
def test_all_assets_short(self):
|
||||
"""所有素材都短时,钳制全部生效但不违反最小值。"""
|
||||
asset_durs = [8.0, 8.0, 8.0, 8.0, 8.0]
|
||||
result = plan_clip_durations(5, 30.0, asset_durations=asset_durs)
|
||||
for dur in result:
|
||||
assert dur >= MIN_CLIP_DURATION
|
||||
max_allowed = 8.0 * 0.9
|
||||
# 如果 max_allowed >= MIN_CLIP_DURATION 才钳制
|
||||
if max_allowed >= MIN_CLIP_DURATION:
|
||||
assert dur <= max_allowed + 0.1
|
||||
|
||||
|
||||
# ── #1768 时长总和误差校验 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTotalDurationTolerance:
|
||||
def test_no_transition_exact_match(self):
|
||||
"""无转场时总时长精确等于配音。"""
|
||||
result = plan_clip_durations(5, 25.0)
|
||||
assert abs(sum(result) - 25.0) < 0.01
|
||||
|
||||
def test_with_transition_within_tolerance(self):
|
||||
"""有转场时净时长在 0.5s 以内。"""
|
||||
effects = [None, "xfade", "fade", "xfade", "fade"]
|
||||
durations = [0.0, 0.8, 1.2, 0.5, 1.0]
|
||||
result = plan_clip_durations(5, 45.0, transition_effects=effects, transition_durations=durations)
|
||||
net = total_output_duration(result, effects, durations)
|
||||
assert abs(net - 45.0) <= TOTAL_DURATION_TOLERANCE
|
||||
|
||||
@pytest.mark.parametrize("voice", [10.0, 20.0, 30.0, 60.0, 120.0])
|
||||
def test_various_voice_durations(self, voice):
|
||||
result = plan_clip_durations(5, voice)
|
||||
net = total_output_duration(result)
|
||||
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE
|
||||
|
||||
@pytest.mark.parametrize("tpl", RHYTHM_TEMPLATES)
|
||||
def test_each_template_within_tolerance(self, tpl):
|
||||
"""每种模板分配的总时长都应在误差范围内。"""
|
||||
adapted = adapt_template_length(tpl, 5)
|
||||
result = plan_clip_durations(5, 30.0, rhythm_template=adapted)
|
||||
net = total_output_duration(result)
|
||||
assert (
|
||||
abs(net - 30.0) <= TOTAL_DURATION_TOLERANCE
|
||||
), f"模板 {tpl} 总时长误差 {abs(net - 30.0):.3f}s > {TOTAL_DURATION_TOLERANCE}s"
|
||||
|
||||
|
||||
# ── #1768 组合场景 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCombinedScenarios:
|
||||
def test_rhythm_plus_clamp_plus_tolerance(self):
|
||||
"""节奏模板 + 素材钳制 + 误差校验 同时生效。"""
|
||||
tpl = [3, 2, 1, 2, 1]
|
||||
effects = [None, "xfade", None, "xfade", None]
|
||||
tdurs = [0.0, 1.0, 0.0, 1.0, 0.0]
|
||||
asset_durs = [15.0, 60.0, 60.0, 60.0, 60.0]
|
||||
voice = 30.0
|
||||
|
||||
adapted = adapt_template_length(tpl, 5)
|
||||
result = plan_clip_durations(
|
||||
5,
|
||||
voice,
|
||||
transition_effects=effects,
|
||||
transition_durations=tdurs,
|
||||
rhythm_template=adapted,
|
||||
asset_durations=asset_durs,
|
||||
)
|
||||
|
||||
# 最小值保证
|
||||
for dur in result:
|
||||
assert dur >= MIN_CLIP_DURATION
|
||||
|
||||
# 最大值钳制(第一段 90% = 13.5)
|
||||
assert result[0] <= 15.0 * 0.9 + 0.1
|
||||
|
||||
# 总时长误差
|
||||
net = total_output_duration(result, effects, tdurs)
|
||||
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE + 0.5
|
||||
|
||||
def test_many_clips_with_cycling_template(self):
|
||||
"""片段数 > 模板长度时循环填充 + 钳制。"""
|
||||
tpl = [2, 2, 1, 1, 2]
|
||||
adapted = adapt_template_length(tpl, 8)
|
||||
assert len(adapted) == 8
|
||||
|
||||
asset_durs = [20.0] * 8
|
||||
result = plan_clip_durations(8, 40.0, rhythm_template=adapted, asset_durations=asset_durs)
|
||||
assert len(result) == 8
|
||||
for dur in result:
|
||||
assert dur >= MIN_CLIP_DURATION
|
||||
@@ -0,0 +1,317 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
"""AI数字人渲染 API 路由测试 — #1798.
|
||||
|
||||
至少 10 个测试覆盖路由层逻辑。
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
|
||||
|
||||
|
||||
def _make_mock_user(user_id="user-1"):
|
||||
"""创建 mock 认证用户."""
|
||||
user = MagicMock()
|
||||
user.id = user_id
|
||||
return user
|
||||
|
||||
|
||||
def _make_mock_render_job(
|
||||
job_id="render-1",
|
||||
user_id="user-1",
|
||||
status="pending",
|
||||
progress=0,
|
||||
output_video_url="",
|
||||
output_cover_url="",
|
||||
output_duration=0.0,
|
||||
error_message="",
|
||||
):
|
||||
"""创建 mock 渲染任务."""
|
||||
m = MagicMock()
|
||||
m.id = job_id
|
||||
m.user_id = user_id
|
||||
m.project_id = ""
|
||||
m.lipsync_job_id = "lipsync-1"
|
||||
m.script_id = "script-1"
|
||||
m.b_roll_segments = []
|
||||
m.title_config = {}
|
||||
m.cover_config = {}
|
||||
m.status = status
|
||||
m.progress = progress
|
||||
m.output_video_url = output_video_url
|
||||
m.output_cover_url = output_cover_url
|
||||
m.output_duration = output_duration
|
||||
m.error_message = error_message
|
||||
m.submitted_at = None
|
||||
m.started_at = None
|
||||
m.completed_at = None
|
||||
m.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
m.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
return m
|
||||
|
||||
|
||||
class TestRenderRoutes:
|
||||
"""路由层测试(通过 mock service 测试路由逻辑)."""
|
||||
|
||||
def _get_client(self):
|
||||
"""获取测试客户端."""
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
def test_create_render_job_success(self):
|
||||
from app.api.routes.ai_avatar_render import router
|
||||
from app.schemas.ai_avatar_render import AiAvatarRenderJobResponse
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job()
|
||||
mock_service.create_render_job.return_value = mock_job
|
||||
|
||||
# 直接测试路由函数
|
||||
from app.api.routes.ai_avatar_render import create_render_job
|
||||
|
||||
mock_user = _make_mock_user()
|
||||
body = MagicMock()
|
||||
body.lipsync_job_id = "lipsync-1"
|
||||
body.script_id = "script-1"
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
result = create_render_job(
|
||||
body=body,
|
||||
current_user=mock_user,
|
||||
svc=mock_service,
|
||||
)
|
||||
assert result.id == "render-1"
|
||||
mock_service.create_render_job.assert_called_once()
|
||||
|
||||
def test_create_render_job_lipsync_not_found(self):
|
||||
from app.api.routes.ai_avatar_render import create_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.create_render_job.side_effect = AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
|
||||
|
||||
mock_user = _make_mock_user()
|
||||
body = MagicMock()
|
||||
body.lipsync_job_id = "nonexistent"
|
||||
body.script_id = "script-1"
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_render_job(body=body, current_user=mock_user, svc=mock_service)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
def test_create_render_job_lipsync_not_completed(self):
|
||||
from app.api.routes.ai_avatar_render import create_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.create_render_job.side_effect = AiAvatarRenderError(
|
||||
"对口型任务状态为 processing", code="LipsyncJobNotCompleted"
|
||||
)
|
||||
|
||||
mock_user = _make_mock_user()
|
||||
body = MagicMock()
|
||||
body.lipsync_job_id = "lipsync-1"
|
||||
body.script_id = "script-1"
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_render_job(body=body, current_user=mock_user, svc=mock_service)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_get_render_job_success(self):
|
||||
from app.api.routes.ai_avatar_render import get_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job()
|
||||
mock_service.get_render_job.return_value = mock_job
|
||||
|
||||
result = get_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert result.id == "render-1"
|
||||
|
||||
def test_get_render_job_not_found(self):
|
||||
from app.api.routes.ai_avatar_render import get_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.get_render_job.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
def test_list_render_jobs(self):
|
||||
from app.api.routes.ai_avatar_render import list_render_jobs
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
|
||||
mock_service.list_render_jobs.return_value = (mock_jobs, 3)
|
||||
|
||||
result = list_render_jobs(
|
||||
project_id="",
|
||||
status="",
|
||||
offset=0,
|
||||
limit=20,
|
||||
current_user=_make_mock_user(),
|
||||
svc=mock_service,
|
||||
)
|
||||
assert result["total"] == 3
|
||||
assert len(result["items"]) == 3
|
||||
|
||||
def test_cancel_render_job_success(self):
|
||||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job(status="cancelled")
|
||||
mock_service.cancel_render_job.return_value = mock_job
|
||||
|
||||
result = cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert result.status == "cancelled"
|
||||
|
||||
def test_cancel_render_job_not_found(self):
|
||||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.cancel_render_job.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
cancel_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
def test_cancel_render_job_not_cancellable(self):
|
||||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job(status="completed")
|
||||
mock_service.cancel_render_job.return_value = mock_job
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_retry_render_job_success(self):
|
||||
from app.api.routes.ai_avatar_render import retry_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job(status="pending")
|
||||
mock_service.retry_render_job.return_value = mock_job
|
||||
|
||||
result = retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert result.status == "pending"
|
||||
|
||||
def test_retry_render_job_not_failed(self):
|
||||
from app.api.routes.ai_avatar_render import retry_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job(status="completed")
|
||||
mock_service.retry_render_job.return_value = mock_job
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_retry_render_job_not_found(self):
|
||||
from app.api.routes.ai_avatar_render import retry_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.retry_render_job.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
retry_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
class TestBrollOverlayFilter:
|
||||
"""FFmpeg B-roll 滤镜构建测试."""
|
||||
|
||||
def test_empty_segments_returns_empty(self):
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
result = build_broll_overlay_filter([], 30.0)
|
||||
assert result == ""
|
||||
|
||||
def test_pip_mode_generates_overlay(self):
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
segments = [
|
||||
{
|
||||
"script_segment_index": 0,
|
||||
"asset_url": "https://example.com/broll.mp4",
|
||||
"mode": "pip",
|
||||
"start_time": 5.0,
|
||||
"end_time": 10.0,
|
||||
"pip_position": "bottom_right",
|
||||
"pip_scale": 0.3,
|
||||
}
|
||||
]
|
||||
result = build_broll_overlay_filter(segments, 30.0)
|
||||
assert "overlay" in result or "scale=" in result
|
||||
|
||||
def test_fullscreen_mode_generates_concat(self):
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
segments = [
|
||||
{
|
||||
"script_segment_index": 0,
|
||||
"asset_url": "https://example.com/broll.mp4",
|
||||
"mode": "fullscreen",
|
||||
"start_time": 5.0,
|
||||
"end_time": 10.0,
|
||||
}
|
||||
]
|
||||
result = build_broll_overlay_filter(segments, 30.0)
|
||||
assert "trim" in result or "concat" in result
|
||||
|
||||
def test_cover_extract_command(self):
|
||||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||||
|
||||
cmd = build_cover_extract_command({"timestamp": 5.0}, "/tmp/cover.jpg")
|
||||
assert "ffmpeg" in cmd
|
||||
assert "5.0" in cmd
|
||||
assert "/tmp/cover.jpg" in cmd
|
||||
|
||||
def test_cover_extract_empty_config(self):
|
||||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||||
|
||||
cmd = build_cover_extract_command({}, "/tmp/cover.jpg")
|
||||
assert "ffmpeg" in cmd
|
||||
|
||||
def test_cover_extract_with_size(self):
|
||||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||||
|
||||
cmd = build_cover_extract_command(
|
||||
{"timestamp": 3.0, "width": 1280, "height": 720},
|
||||
"/tmp/cover.jpg",
|
||||
)
|
||||
assert "scale=" in cmd
|
||||
@@ -0,0 +1,517 @@
|
||||
"""AI数字人渲染 Service 单元测试 — #1798.
|
||||
|
||||
至少 15 个测试覆盖 Service 层核心逻辑。
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
|
||||
|
||||
|
||||
def _make_mock_db():
|
||||
"""创建 mock 数据库 session."""
|
||||
mock_db = MagicMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.flush = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
return mock_db
|
||||
|
||||
|
||||
def _make_mock_render_job(
|
||||
job_id="render-1",
|
||||
user_id="user-1",
|
||||
status="pending",
|
||||
progress=0,
|
||||
output_video_url="",
|
||||
output_cover_url="",
|
||||
output_duration=0.0,
|
||||
error_message="",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
):
|
||||
"""创建 mock 渲染任务."""
|
||||
m = MagicMock()
|
||||
m.id = job_id
|
||||
m.user_id = user_id
|
||||
m.project_id = ""
|
||||
m.lipsync_job_id = lipsync_job_id
|
||||
m.script_id = script_id
|
||||
m.b_roll_segments = []
|
||||
m.title_config = {}
|
||||
m.cover_config = {}
|
||||
m.status = status
|
||||
m.progress = progress
|
||||
m.output_video_url = output_video_url
|
||||
m.output_cover_url = output_cover_url
|
||||
m.output_duration = output_duration
|
||||
m.error_message = error_message
|
||||
m.submitted_at = None
|
||||
m.started_at = None
|
||||
m.completed_at = None
|
||||
m.created_at = None
|
||||
m.updated_at = None
|
||||
return m
|
||||
|
||||
|
||||
def _make_mock_lipsync_job(
|
||||
job_id="lipsync-1",
|
||||
user_id="user-1",
|
||||
status="completed",
|
||||
output_video_url="https://output.mp4",
|
||||
output_duration=30.0,
|
||||
):
|
||||
"""创建 mock 对口型任务."""
|
||||
m = MagicMock()
|
||||
m.id = job_id
|
||||
m.user_id = user_id
|
||||
m.status = status
|
||||
m.output_video_url = output_video_url
|
||||
m.output_duration = output_duration
|
||||
return m
|
||||
|
||||
|
||||
def _make_mock_script(script_id="script-1", user_id="user-1"):
|
||||
"""创建 mock 文案."""
|
||||
m = MagicMock()
|
||||
m.id = script_id
|
||||
m.user_id = user_id
|
||||
m.title = "测试文案"
|
||||
return m
|
||||
|
||||
|
||||
class TestSchemaValidation:
|
||||
"""Schema 验证测试."""
|
||||
|
||||
def test_valid_broll_segment(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
seg = BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="https://example.com/broll.mp4",
|
||||
mode="fullscreen",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
assert seg.mode == "fullscreen"
|
||||
assert seg.start_time == 5.0
|
||||
|
||||
def test_invalid_mode(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
with pytest.raises(ValueError, match="fullscreen 或 pip"):
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="https://example.com/broll.mp4",
|
||||
mode="invalid",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
|
||||
def test_end_time_must_exceed_start_time(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
with pytest.raises(ValueError, match="end_time 必须大于 start_time"):
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="https://example.com/broll.mp4",
|
||||
mode="fullscreen",
|
||||
start_time=10.0,
|
||||
end_time=5.0,
|
||||
)
|
||||
|
||||
def test_asset_url_must_be_http(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
with pytest.raises(ValueError, match="HTTP"):
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="ftp://example.com/broll.mp4",
|
||||
mode="fullscreen",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
|
||||
def test_asset_url_empty(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
with pytest.raises(ValueError, match="不能为空"):
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url=" ",
|
||||
mode="fullscreen",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
|
||||
def test_create_request_valid(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment, CreateAiAvatarRenderRequest
|
||||
|
||||
req = CreateAiAvatarRenderRequest(
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="https://example.com/broll.mp4",
|
||||
mode="pip",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
assert req.lipsync_job_id == "lipsync-1"
|
||||
assert len(req.b_roll_segments) == 1
|
||||
|
||||
def test_create_request_empty_lipsync_job_id(self):
|
||||
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
|
||||
|
||||
with pytest.raises(ValueError, match="lipsync_job_id 不能为空"):
|
||||
CreateAiAvatarRenderRequest(
|
||||
lipsync_job_id=" ",
|
||||
script_id="script-1",
|
||||
)
|
||||
|
||||
def test_create_request_empty_script_id(self):
|
||||
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
|
||||
|
||||
with pytest.raises(ValueError, match="script_id 不能为空"):
|
||||
CreateAiAvatarRenderRequest(
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id=" ",
|
||||
)
|
||||
|
||||
|
||||
class TestAiAvatarRenderService:
|
||||
"""Service 层单元测试(纯 mock,不依赖数据库)."""
|
||||
|
||||
def test_create_job_success(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
# 模拟 query 链式调用
|
||||
mock_query = MagicMock()
|
||||
|
||||
# 第一次 query: LipsyncJobModel
|
||||
mock_lipsync_filter = MagicMock()
|
||||
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
|
||||
mock_lipsync_query = MagicMock()
|
||||
mock_lipsync_query.filter.return_value = mock_lipsync_filter
|
||||
|
||||
# 第二次 query: ScriptModel
|
||||
mock_script_filter = MagicMock()
|
||||
mock_script_filter.first.return_value = _make_mock_script()
|
||||
mock_script_query = MagicMock()
|
||||
mock_script_query.filter.return_value = mock_script_filter
|
||||
|
||||
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
job = svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
assert job.status == "pending"
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_job_lipsync_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError, match="对口型任务不存在"):
|
||||
svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="nonexistent",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
|
||||
def test_create_job_lipsync_not_completed(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_lipsync_job = _make_mock_lipsync_job(status="processing")
|
||||
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_lipsync_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError, match="仅 completed 状态可渲染"):
|
||||
svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
|
||||
def test_create_job_lipsync_no_output(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_video_url="")
|
||||
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_lipsync_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError, match="输出视频 URL 为空"):
|
||||
svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
|
||||
def test_create_job_script_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_lipsync_query = MagicMock()
|
||||
mock_lipsync_filter = MagicMock()
|
||||
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
|
||||
mock_lipsync_query.filter.return_value = mock_lipsync_filter
|
||||
|
||||
mock_script_query = MagicMock()
|
||||
mock_script_filter = MagicMock()
|
||||
mock_script_filter.first.return_value = None
|
||||
mock_script_query.filter.return_value = mock_script_filter
|
||||
|
||||
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError, match="文案不存在或无权访问"):
|
||||
svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="nonexistent",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
|
||||
def test_get_render_job_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.get_render_job("render-1", "user-1")
|
||||
assert result is mock_job
|
||||
|
||||
def test_get_render_job_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.get_render_job("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_list_render_jobs(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 3
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = mock_jobs
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
items, total = svc.list_render_jobs(user_id="user-1")
|
||||
assert total == 3
|
||||
assert len(items) == 3
|
||||
|
||||
def test_list_render_jobs_with_project_filter(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 1
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = [_make_mock_render_job()]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
items, total = svc.list_render_jobs(user_id="user-1", project_id="proj-1")
|
||||
assert total == 1
|
||||
# filter should be called for user_id and project_id
|
||||
assert mock_query.filter.call_count >= 2
|
||||
|
||||
def test_cancel_render_job_success(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="pending")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.cancel_render_job("render-1", "user-1")
|
||||
assert result is mock_job
|
||||
assert mock_job.status == "cancelled"
|
||||
|
||||
def test_cancel_render_job_not_pending(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="completed")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.cancel_render_job("render-1", "user-1")
|
||||
# 非 pending 状态不可取消,状态不变
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_cancel_render_job_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.cancel_render_job("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_retry_render_job_success(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="failed", error_message="渲染失败")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.retry_render_job("render-1", "user-1")
|
||||
assert result.status == "pending"
|
||||
assert result.progress == 0
|
||||
assert result.error_message == ""
|
||||
|
||||
def test_retry_render_job_not_failed(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="completed")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.retry_render_job("render-1", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_retry_render_job_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.retry_render_job("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_execute_render_job_not_found(self):
|
||||
"""execute_render 在任务不存在时应静默返回."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
# 不应抛异常
|
||||
svc.execute_render("nonexistent")
|
||||
|
||||
def test_execute_render_cancelled_job(self):
|
||||
"""execute_render 在任务已取消时应静默返回."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="cancelled")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
svc.execute_render("render-1")
|
||||
# 不应执行渲染逻辑
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
def test_error_exception_has_code(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError
|
||||
|
||||
err = AiAvatarRenderError("测试错误", code="TestCode")
|
||||
assert err.code == "TestCode"
|
||||
assert str(err) == "测试错误"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user