Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb3bc04c83 | |||
| bf7c8b8a08 | |||
| e4ac398397 | |||
| a0ca13d463 | |||
| 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 |
@@ -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,73 +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)
|
||||
|
||||
# 所有途径都失败:模板没有片段配置(可能是无效测试模板)
|
||||
logger.error(
|
||||
"模板无片段配置:template_id=%s(可能是 is_active=false 的无效模板)",
|
||||
template_id,
|
||||
)
|
||||
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(
|
||||
@@ -668,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,377 @@
|
||||
"""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,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
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.
|
||||
|
||||
使用 SharedStorageService 统一存储服务。
|
||||
"""
|
||||
storage = get_shared_storage_service()
|
||||
url = storage.upload_file_smart(local_path, oss_key)
|
||||
if url is None:
|
||||
raise AiAvatarRenderError(
|
||||
f"上传文件到 OSS 失败: {oss_key}",
|
||||
code="OSSUploadFailed",
|
||||
)
|
||||
logger.info("上传文件到 OSS 成功: %s -> %s", local_path, url)
|
||||
return url
|
||||
@@ -789,20 +789,22 @@ class EditPlanService:
|
||||
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])
|
||||
@@ -932,6 +934,17 @@ class EditPlanService:
|
||||
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
|
||||
|
||||
@@ -950,8 +963,20 @@ class EditPlanService:
|
||||
|
||||
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 视觉扰动+像素扰动: plan=%s vis=%s pix=%s", idx, pid, perturbation, pixel_pert)
|
||||
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)
|
||||
|
||||
@@ -1187,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),
|
||||
@@ -1199,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),
|
||||
@@ -1231,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: "剪辑模板",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* AI数字人 — 主页面(v3)
|
||||
* 5列水平面板布局
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import "./AiAvatar.css"
|
||||
import { useAiAvatar } from "./hooks/useAiAvatar"
|
||||
import { PanelVideoSelector } from "./components/PanelVideoSelector"
|
||||
import PanelVoiceSelector from "./components/PanelVoiceSelector"
|
||||
import PanelScriptAndLipsync from "./components/PanelScriptAndLipsync"
|
||||
import PanelTitleConfig from "./components/PanelTitleConfig"
|
||||
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
|
||||
import { ModalAssetPicker } from "./components/ModalAssetPicker"
|
||||
import ModalBRollEditor from "./components/ModalBRollEditor"
|
||||
import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
type PanelKey = "video" | "voice" | "script" | "title" | "cover"
|
||||
|
||||
const AiAvatarPage: React.FC = () => {
|
||||
const state = useAiAvatar()
|
||||
const [collapsed, setCollapsed] = useState<Record<PanelKey, boolean>>({
|
||||
video: false,
|
||||
voice: false,
|
||||
script: false,
|
||||
title: false,
|
||||
cover: false,
|
||||
})
|
||||
|
||||
/* ── 素材库弹窗 ── */
|
||||
const [bRollAssets, setBRollAssets] = useState<import("@/api/assets").AssetItem[]>([])
|
||||
|
||||
/* ── 对口型轮询 ── */
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const togglePanel = useCallback((key: PanelKey) => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
/* ── 对口型 ── */
|
||||
const handleGenerateLipsync = useCallback(async () => {
|
||||
if (!state.selectedVideo || !state.selectedVoice || !state.scriptText) return
|
||||
try {
|
||||
const job = await createLipsyncJob({
|
||||
voice_id: state.selectedVoice.voice_id,
|
||||
script_text: state.scriptText,
|
||||
video_asset_id: state.selectedVideo.id,
|
||||
})
|
||||
state.setLipsyncJob(job)
|
||||
// 开始轮询
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getLipsyncJob(job.id)
|
||||
state.setLipsyncJob(updated)
|
||||
if (updated.status === "completed" || updated.status === "failed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
console.error("对口型任务创建失败:", err)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 加载 B-roll 素材 ── */
|
||||
useEffect(() => {
|
||||
getAssetsByKind("video", { limit: 50 })
|
||||
.then(setBRollAssets)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") return
|
||||
state.setIsGenerating(true)
|
||||
try {
|
||||
await submitRender({
|
||||
lipsync_job_id: state.lipsyncJob.id,
|
||||
script_id: state.script?.id,
|
||||
b_roll_segments: state.bRollSegments as never,
|
||||
title_config: state.titleConfig as unknown as Record<string, unknown>,
|
||||
cover_config: state.coverConfig as unknown as Record<string, unknown>,
|
||||
resolution: state.resolution,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("渲染任务提交失败:", err)
|
||||
} finally {
|
||||
state.setIsGenerating(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
state.lipsyncJob,
|
||||
state.script,
|
||||
state.bRollSegments,
|
||||
state.titleConfig,
|
||||
state.coverConfig,
|
||||
state.resolution,
|
||||
])
|
||||
|
||||
/* ── 配置汇总 ── */
|
||||
const summary = {
|
||||
videoName: state.selectedVideo?.name || null,
|
||||
voiceName: state.selectedVoice?.name || null,
|
||||
scriptLength: state.scriptText.length,
|
||||
lipsyncStatus: state.lipsyncJob?.status || null,
|
||||
brollCount: state.bRollSegments.length,
|
||||
hasTitle: state.titleConfig.title.length > 0,
|
||||
hasCover: state.coverConfig.enabled,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-page">
|
||||
<div className="aa-page-header">
|
||||
<h1>AI数字人</h1>
|
||||
</div>
|
||||
<div className="aa-page-body">
|
||||
{/* 面板1:出镜视频 */}
|
||||
<div className={`aa-panel aa-panel--p1${collapsed.video ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
|
||||
<span className="aa-panel__title">出镜视频</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelVideoSelector
|
||||
selectedVideo={state.selectedVideo}
|
||||
onSelectVideo={() => state.setShowAssetPicker(true)}
|
||||
onRemoveVideo={state.removeVideo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板2:配音库 */}
|
||||
<div className={`aa-panel aa-panel--p2${collapsed.voice ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
|
||||
<span className="aa-panel__title">配音库</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelVoiceSelector
|
||||
voiceSource={state.voiceSource}
|
||||
onVoiceSourceChange={state.setVoiceSource}
|
||||
selectedVoice={state.selectedVoice}
|
||||
onSelectVoice={state.setSelectedVoice}
|
||||
emotion={state.emotion}
|
||||
onEmotionChange={state.setEmotion}
|
||||
speed={state.speed}
|
||||
onSpeedChange={state.setSpeed}
|
||||
language={state.language}
|
||||
onLanguageChange={state.setLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板3:文案 & 对口型 */}
|
||||
<div className={`aa-panel aa-panel--p3${collapsed.script ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
|
||||
<span className="aa-panel__title">文案 & 对口型</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelScriptAndLipsync
|
||||
scriptText={state.scriptText}
|
||||
onScriptTextChange={state.setScriptText}
|
||||
onOpenScriptModal={() => state.setShowScriptModal(true)}
|
||||
lipsyncJob={state.lipsyncJob}
|
||||
onGenerateLipsync={handleGenerateLipsync}
|
||||
bRollSegments={state.bRollSegments}
|
||||
onOpenBRollModal={() => state.setShowBRollModal(true)}
|
||||
onRemoveBRoll={state.removeBRollSegment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板4:标题配置 */}
|
||||
<div className={`aa-panel aa-panel--p4${collapsed.title ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
|
||||
<span className="aa-panel__title">标题配置</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelTitleConfig titleConfig={state.titleConfig} onUpdate={state.updateTitleConfig} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板5:封面 & 生成 */}
|
||||
<div className={`aa-panel aa-panel--p5${collapsed.cover ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
|
||||
<span className="aa-panel__title">封面 & 生成</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelCoverAndGenerate
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
summary={summary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材库弹窗 */}
|
||||
{state.showAssetPicker && (
|
||||
<ModalAssetPicker
|
||||
open={state.showAssetPicker}
|
||||
onClose={() => state.setShowAssetPicker(false)}
|
||||
onSelect={state.selectVideo}
|
||||
selectedId={state.selectedVideo?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文案选择弹窗 */}
|
||||
{state.showScriptModal && (
|
||||
<ScriptSelectModalLazy
|
||||
open={state.showScriptModal}
|
||||
onClose={() => state.setShowScriptModal(false)}
|
||||
onSelect={state.selectScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* B-roll 编辑器弹窗 */}
|
||||
{state.showBRollModal && (
|
||||
<ModalBRollEditor
|
||||
open={state.showBRollModal}
|
||||
onClose={() => state.setShowBRollModal(false)}
|
||||
existingSegments={state.bRollSegments}
|
||||
availableAssets={bRollAssets}
|
||||
onConfirm={state.addBRollSegment}
|
||||
onRemove={state.removeBRollSegment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 文案选择弹窗(内联实现,轻量版) */
|
||||
const ScriptSelectModalLazy: React.FC<{
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (script: import("./types").Script) => void
|
||||
}> = ({ open, onClose, onSelect }) => {
|
||||
const [scripts, setScripts] = useState<import("./types").Script[]>([])
|
||||
const [search, setSearch] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getScripts()
|
||||
.then(setScripts)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
const filtered = scripts.filter(
|
||||
(s) => !search || s.title.includes(search) || s.content.includes(search),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择文案</span>
|
||||
<button className="aa-modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-script-list-header">
|
||||
<input
|
||||
className="aa-input"
|
||||
placeholder="搜索文案..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="aa-empty">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📝</div>
|
||||
暂无文案,请手动输入或新建
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-script-list">
|
||||
{filtered.map((s) => (
|
||||
<div key={s.id} className="aa-script-item" onClick={() => onSelect(s)}>
|
||||
<span className="aa-script-item__icon">📄</span>
|
||||
<div className="aa-script-item__info">
|
||||
<div className="aa-script-item__title">{s.title}</div>
|
||||
<div className="aa-script-item__meta">
|
||||
{s.char_count}字 · {new Date(s.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-modal__footer">
|
||||
<button className="aa-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiAvatarPage
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* AI数字人 — API 封装
|
||||
*/
|
||||
import apiClient from "@/api/client"
|
||||
import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types"
|
||||
|
||||
/* ── 文案库 ── */
|
||||
export const getScripts = async (): Promise<Script[]> => {
|
||||
const response = await apiClient.get<Script[]>("/scripts")
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getScriptById = async (id: string): Promise<Script> => {
|
||||
const response = await apiClient.get<Script>(`/scripts/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createScript = async (data: { title: string; content: string }): Promise<Script> => {
|
||||
const response = await apiClient.post<Script>("/scripts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const deleteScript = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
|
||||
/* ── 对口型 ── */
|
||||
export const createLipsyncJob = async (data: {
|
||||
voice_id: string
|
||||
script_text: string
|
||||
video_asset_id: string
|
||||
}): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 渲染 ── */
|
||||
export const submitRender = async (data: {
|
||||
lipsync_job_id: string
|
||||
script_id?: string
|
||||
b_roll_segments?: BRollSegment[]
|
||||
title_config?: Record<string, unknown>
|
||||
cover_config?: Record<string, unknown>
|
||||
project_id?: string
|
||||
resolution?: string
|
||||
}): Promise<RenderJob> => {
|
||||
const response = await apiClient.post<RenderJob>("/ai-avatar/render", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* AI数字人 — 素材库弹窗
|
||||
* 搜索框 + 类型筛选(全部/视频/图片)+ 4 列竖屏 9:16 缩略图网格 + 底部确认选择
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
|
||||
/** 素材类型筛选 */
|
||||
type AssetKindFilter = "all" | "video" | "image"
|
||||
|
||||
export interface ModalAssetPickerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (asset: AssetItem) => void
|
||||
/** 已选中的素材 ID(用于高亮) */
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
const KIND_OPTIONS: { value: AssetKindFilter; label: string }[] = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]
|
||||
|
||||
export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalAssetPickerProps) {
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [kindFilter, setKindFilter] = useState<AssetKindFilter>("video")
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [pickedId, setPickedId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
/* 弹窗打开:重置筛选 / 关键字,并定位高亮到已选素材 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setKeyword("")
|
||||
setKindFilter("video")
|
||||
setAssets([])
|
||||
setError("")
|
||||
setPickedId(selectedId ?? null)
|
||||
setReady(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* 确保默认素材库存在(视频 + 图片,供类型筛选),仅在弹窗打开时执行一次 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
const ensureLibraries = async () => {
|
||||
try {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
await Promise.all([
|
||||
ensureDefaultLibrary({ project_id: project.id, kind: "video" }),
|
||||
ensureDefaultLibrary({ project_id: project.id, kind: "image" }),
|
||||
])
|
||||
if (!cancelled) setReady(true)
|
||||
} catch {
|
||||
if (!cancelled) setError("素材库初始化失败,请重试")
|
||||
}
|
||||
}
|
||||
ensureLibraries()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 拉取素材:类型 / 关键字变化时防抖重新请求 */
|
||||
useEffect(() => {
|
||||
if (!open || !ready) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
const load = async () => {
|
||||
try {
|
||||
const kw = keyword.trim() || undefined
|
||||
let items: AssetItem[] = []
|
||||
if (kindFilter === "all") {
|
||||
const [videos, images] = await Promise.all([
|
||||
getAssetsByKind("video", { keyword: kw }),
|
||||
getAssetsByKind("image", { keyword: kw }),
|
||||
])
|
||||
const seen = new Set<string>()
|
||||
items = [...videos, ...images].filter((a) => {
|
||||
if (seen.has(a.id)) return false
|
||||
seen.add(a.id)
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
items = await getAssetsByKind(kindFilter, { keyword: kw })
|
||||
}
|
||||
if (!cancelled) setAssets(items)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("素材加载失败,请重试")
|
||||
setAssets([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(load, 300)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [open, ready, kindFilter, keyword])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pickedId) return
|
||||
const asset = assets.find((a) => a.id === pickedId)
|
||||
if (asset) onSelect(asset)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择素材</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体:搜索 + 筛选 + 网格 */}
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-asset-search">
|
||||
<input
|
||||
className="aa-input"
|
||||
type="text"
|
||||
placeholder="搜索素材名称…"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="aa-select"
|
||||
style={{ width: 110, flex: "0 0 auto" }}
|
||||
value={kindFilter}
|
||||
onChange={(e) => setKindFilter(e.target.value as AssetKindFilter)}
|
||||
>
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⚠️</div>
|
||||
{error}
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无素材
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-asset-grid">
|
||||
{assets.map((asset) => {
|
||||
const isActive = asset.id === pickedId
|
||||
const thumb = asset.thumbnail_url || asset.file_url
|
||||
const isVideo = asset.mime_type?.includes("video")
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`aa-asset-card${isActive ? " selected" : ""}`}
|
||||
onClick={() => setPickedId(asset.id)}
|
||||
>
|
||||
{isVideo && !asset.thumbnail_url ? (
|
||||
<video src={asset.file_url} muted preload="metadata" />
|
||||
) : (
|
||||
<img src={thumb} alt={asset.name} />
|
||||
)}
|
||||
{isActive && <div className="aa-asset-card__check">✓</div>}
|
||||
<div className="aa-asset-card__name">{asset.name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:取消 + 确认选择 */}
|
||||
<div className="aa-modal__footer">
|
||||
<button type="button" className="aa-btn aa-btn--ghost" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!pickedId}
|
||||
>
|
||||
确认选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗
|
||||
*
|
||||
* 布局:
|
||||
* - 左侧:可用素材网格(已被其他 segment 使用的素材标灰 + "已选择" 遮罩,
|
||||
* pointer-events: none 防止重复选择同一段素材)
|
||||
* - 右侧:插入设置(文案段落索引 / 全屏 or 画中画 / 画中画四角位置 + 大小 / 起止时间)
|
||||
* - 底部:已配置的画面插入列表(可删除)+ 上传新素材入口
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
|
||||
|
||||
interface ModalBRollEditorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前已有的 B-roll segments(用于标灰已选素材) */
|
||||
existingSegments: BRollSegment[]
|
||||
/** 所有可用素材 */
|
||||
availableAssets: AssetItem[]
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
const PIP_POSITION_OPTIONS: { value: PipPosition; label: string }[] = [
|
||||
{ value: "top-left", label: "左上" },
|
||||
{ value: "top-right", label: "右上" },
|
||||
{ value: "bottom-left", label: "左下" },
|
||||
{ value: "bottom-right", label: "右下" },
|
||||
]
|
||||
|
||||
const MODE_LABEL: Record<BRollInsertMode, string> = {
|
||||
fullscreen: "全屏切换",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
existingSegments,
|
||||
availableAssets,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}) => {
|
||||
/* ── 右侧设置本地状态 ── */
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetItem | null>(null)
|
||||
const [scriptSegmentIndex, setScriptSegmentIndex] = useState(0)
|
||||
const [mode, setMode] = useState<BRollInsertMode>("fullscreen")
|
||||
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
|
||||
const [pipScale, setPipScale] = useState(0.3)
|
||||
const [startTime, setStartTime] = useState(0)
|
||||
const [endTime, setEndTime] = useState(3)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
const usedAssetIds = useMemo(
|
||||
() => new Set(existingSegments.map((seg) => seg.asset.id)),
|
||||
[existingSegments],
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
/** 选择素材(已选素材因 pointer-events:none 不会触发) */
|
||||
const handleSelectAsset = (asset: AssetItem) => {
|
||||
if (usedAssetIds.has(asset.id)) return
|
||||
setSelectedAsset(asset)
|
||||
// 默认起止时间:素材时长的前 3 秒(或整段)
|
||||
const dur = asset.duration ?? 3
|
||||
setEndTime(Math.min(3, dur))
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset) return
|
||||
if (endTime <= startTime) return
|
||||
const segment: BRollSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
asset: selectedAsset,
|
||||
script_segment_index: scriptSegmentIndex,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
mode,
|
||||
pip_position: pipPosition,
|
||||
pip_scale: mode === "pip" ? pipScale : 0.3,
|
||||
}
|
||||
onConfirm(segment)
|
||||
// 重置选择,保留设置便于连续添加
|
||||
setSelectedAsset(null)
|
||||
}
|
||||
|
||||
const canConfirm = selectedAsset !== null && endTime > startTime
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">🎞️ 画面插入(B-roll)</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体:左素材 + 右设置 */}
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-broll-modal-body">
|
||||
{/* 左侧:素材网格 */}
|
||||
<div className="aa-broll-left">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#1a1a2e" }}>
|
||||
选择素材({availableAssets.length})
|
||||
</span>
|
||||
<button type="button" className="aa-btn aa-btn--ghost aa-btn-sm">
|
||||
⬆️ 上传新素材
|
||||
</button>
|
||||
</div>
|
||||
<div className="aa-broll-asset-grid">
|
||||
{availableAssets.map((asset) => {
|
||||
const alreadySelected = usedAssetIds.has(asset.id)
|
||||
const isCurrent = selectedAsset?.id === asset.id
|
||||
const classNames = [
|
||||
"aa-broll-asset-thumb",
|
||||
isCurrent ? "selected" : "",
|
||||
alreadySelected ? "already-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={classNames}
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
title={asset.name}
|
||||
>
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
)}
|
||||
<span className="aa-asset-card__name">{asset.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{availableAssets.length === 0 && (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
暂无可用素材,请先上传视频素材
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:插入设置 */}
|
||||
<div className="aa-broll-right">
|
||||
<div className="aa-broll-settings">
|
||||
{/* 文案段落索引 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">文案段落索引</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={scriptSegmentIndex}
|
||||
onChange={(e) => setScriptSegmentIndex(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 插入模式 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">插入模式</label>
|
||||
<div className="aa-broll-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-broll-mode-btn${mode === "fullscreen" ? " active" : ""}`}
|
||||
onClick={() => setMode("fullscreen")}
|
||||
>
|
||||
全屏切换
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-broll-mode-btn${mode === "pip" ? " active" : ""}`}
|
||||
onClick={() => setMode("pip")}
|
||||
>
|
||||
画中画
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画中画:四角位置 + 大小 */}
|
||||
{mode === "pip" && (
|
||||
<>
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">画中画位置</label>
|
||||
<div className="aa-pip-positions">
|
||||
{PIP_POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`aa-pip-pos-btn${
|
||||
pipPosition === opt.value ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setPipPosition(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<div
|
||||
className="aa-field-label-row"
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<label className="aa-label">画中画大小</label>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1" }}>
|
||||
{Math.round(pipScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={0.6}
|
||||
step={0.05}
|
||||
value={pipScale}
|
||||
onChange={(e) => setPipScale(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 起止时间 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">开始时间(秒)</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">结束时间(秒)</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前选中素材提示 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: selectedAsset ? "#059669" : "#8c8ca1",
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
}}
|
||||
>
|
||||
{selectedAsset ? `已选素材:${selectedAsset.name}` : "请从左侧选择一段素材"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:已配置的画面插入列表 */}
|
||||
<div className="aa-broll-list">
|
||||
<div className="aa-broll-list__title">已配置画面插入({existingSegments.length})</div>
|
||||
{existingSegments.length === 0 ? (
|
||||
<div className="aa-empty" style={{ padding: 12 }}>
|
||||
尚未配置画面插入
|
||||
</div>
|
||||
) : (
|
||||
existingSegments.map((seg) => (
|
||||
<div key={seg.id} className="aa-broll-item">
|
||||
{seg.asset.thumbnail_url ? (
|
||||
<img className="aa-broll-item__thumb" src={seg.asset.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="aa-broll-item__thumb" />
|
||||
)}
|
||||
<div className="aa-broll-item__info">
|
||||
<div style={{ fontWeight: 500, color: "#1a1a2e" }}>{seg.asset.name}</div>
|
||||
<div style={{ color: "#8c8ca1", fontSize: 11 }}>
|
||||
段落 {seg.script_segment_index} · {MODE_LABEL[seg.mode]}
|
||||
{seg.mode === "pip" ? ` · ${seg.pip_position}` : ""} ·{" "}
|
||||
{seg.start_time.toFixed(1)}s - {seg.end_time.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-broll-item__remove"
|
||||
title="删除"
|
||||
onClick={() => onRemove(seg.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="aa-modal__footer">
|
||||
<button type="button" className="aa-btn" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
disabled={!canConfirm}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
✅ 添加画面插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalBRollEditor
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* AI数字人 — 面板5:封面 & 生成
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
|
||||
* - 分辨率选择(720p / 1080p / 4K)
|
||||
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
|
||||
* - 渐变紫色生成按钮
|
||||
*
|
||||
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import type { AiAvatarCoverConfig } from "../types"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution: string
|
||||
onResolutionChange: (r: string) => void
|
||||
isGenerating: boolean
|
||||
onGenerate: () => void
|
||||
/** 配置汇总信息 */
|
||||
summary: {
|
||||
videoName: string | null
|
||||
voiceName: string | null
|
||||
scriptLength: number
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
hasCover: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const RESOLUTION_OPTIONS = [
|
||||
{ value: "720p", label: "720p(高清)" },
|
||||
{ value: "1080p", label: "1080p(全高清)" },
|
||||
{ value: "4k", label: "4K(超清)" },
|
||||
]
|
||||
|
||||
const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
idle: { text: "未开始", cls: "aa-status-badge--idle" },
|
||||
pending: { text: "排队中", cls: "aa-status-badge--pending" },
|
||||
processing: { text: "生成中", cls: "aa-status-badge--processing" },
|
||||
completed: { text: "已完成", cls: "aa-status-badge--completed" },
|
||||
failed: { text: "失败", cls: "aa-status-badge--failed" },
|
||||
}
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
resolution,
|
||||
onResolutionChange,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** 自定义上传封面 */
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
// 本地预览:生成 object URL(实际上传由父级/后端链路处理)
|
||||
const url = URL.createObjectURL(file)
|
||||
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
|
||||
// 允许重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 从视频截取(使用配置的帧时间,默认首帧) */
|
||||
const handleCaptureFromVideo = () => {
|
||||
onCoverConfigChange({ mode: "auto_frame" })
|
||||
}
|
||||
|
||||
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
|
||||
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
|
||||
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 封面预览(竖屏 9:16) */}
|
||||
<div className="aa-cover-preview">
|
||||
{coverConfig.thumbnail_url ? (
|
||||
<img src={coverConfig.thumbnail_url} alt="封面预览" />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleCaptureFromVideo}
|
||||
>
|
||||
🎬 从视频截取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分辨率选择 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">分辨率</label>
|
||||
<select
|
||||
className="aa-select"
|
||||
value={resolution}
|
||||
onChange={(e) => onResolutionChange(e.target.value)}
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 配置汇总 */}
|
||||
<div className="aa-generate-section">
|
||||
<div className="aa-config-summary">
|
||||
<div className="aa-config-summary__row">
|
||||
<span>出镜视频</span>
|
||||
{summary.videoName ? (
|
||||
<span className="aa-config-summary__value">{summary.videoName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>音色</span>
|
||||
{summary.voiceName ? (
|
||||
<span className="aa-config-summary__value">{summary.voiceName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>文案</span>
|
||||
{summary.scriptLength > 0 ? (
|
||||
<span className="aa-config-summary__value">{summary.scriptLength} 字</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>对口型</span>
|
||||
{lipsync ? (
|
||||
<span className={`aa-status-badge ${lipsync.cls}`}>{lipsync.text}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>B-roll 画面插入</span>
|
||||
<span className="aa-config-summary__value">
|
||||
{summary.brollCount > 0 ? `${summary.brollCount} 段` : "无"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>标题</span>
|
||||
{summary.hasTitle ? (
|
||||
<span className="aa-config-summary__value">已设置</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>封面</span>
|
||||
{summary.hasCover ? (
|
||||
<span className="aa-config-summary__value">已开启</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--generate aa-btn--full"
|
||||
disabled={!canGenerate}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
{isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"}
|
||||
</button>
|
||||
{summary.lipsyncStatus !== "completed" && !isGenerating && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
|
||||
请先完成对口型生成
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelCoverAndGenerate
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* AI数字人 — 文案 & 对口型面板(面板4)
|
||||
* 上半区:文案(文案库选择 / 手动输入);下半区:对口型视频预览(9:16)+ B-roll 画面
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import type { LipsyncJob, BRollSegment } from "../types"
|
||||
|
||||
interface PanelScriptAndLipsyncProps {
|
||||
scriptText: string
|
||||
onScriptTextChange: (text: string) => void
|
||||
onOpenScriptModal: () => void
|
||||
lipsyncJob: LipsyncJob | null
|
||||
onGenerateLipsync: () => void
|
||||
bRollSegments: BRollSegment[]
|
||||
onOpenBRollModal: () => void
|
||||
onRemoveBRoll: (id: string) => void
|
||||
}
|
||||
|
||||
type ScriptTab = "library" | "manual"
|
||||
|
||||
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
|
||||
fullscreen: "全屏",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function PanelScriptAndLipsync({
|
||||
scriptText,
|
||||
onScriptTextChange,
|
||||
onOpenScriptModal,
|
||||
lipsyncJob,
|
||||
onGenerateLipsync,
|
||||
bRollSegments,
|
||||
onOpenBRollModal,
|
||||
onRemoveBRoll,
|
||||
}: PanelScriptAndLipsyncProps) {
|
||||
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
|
||||
|
||||
/* 对口型状态判断 */
|
||||
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
|
||||
const isDone = lipsyncJob?.status === "completed"
|
||||
const isFailed = lipsyncJob?.status === "failed"
|
||||
|
||||
const statusText =
|
||||
lipsyncJob?.status === "processing"
|
||||
? "对口型生成中…"
|
||||
: lipsyncJob?.status === "pending"
|
||||
? "排队中…"
|
||||
: "对口型生成中…"
|
||||
|
||||
return (
|
||||
<div className="aa-script-lipsync">
|
||||
{/* ── 上半区:文案 ── */}
|
||||
<div className="aa-script-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-script-tab${scriptTab === "library" ? " active" : ""}`}
|
||||
onClick={() => setScriptTab("library")}
|
||||
>
|
||||
从文案库选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-script-tab${scriptTab === "manual" ? " active" : ""}`}
|
||||
onClick={() => setScriptTab("manual")}
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scriptTab === "library" && (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--ghost aa-btn--full"
|
||||
style={{ marginBottom: 8 }}
|
||||
onClick={onOpenScriptModal}
|
||||
>
|
||||
📚 从文案库选择文案
|
||||
</button>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className="aa-textarea"
|
||||
value={scriptText}
|
||||
readOnly={scriptTab === "library"}
|
||||
placeholder={
|
||||
scriptTab === "library" ? "点击上方按钮,从文案库选择文案…" : "请输入数字人口播文案…"
|
||||
}
|
||||
onChange={(e) => onScriptTextChange(e.target.value)}
|
||||
/>
|
||||
<div className="aa-char-count">{scriptText.length} 字</div>
|
||||
|
||||
{/* ── B-roll 画面 ── */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">
|
||||
<span style={{ marginRight: 8 }}>🎞️ 插入画面</span>
|
||||
{bRollSegments.length > 0 && (
|
||||
<span className="aa-broll-badge">🎬 {bRollSegments.length} 个画面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-lipsync-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary aa-btn--full"
|
||||
onClick={onOpenBRollModal}
|
||||
>
|
||||
🎬 插入画面
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bRollSegments.length > 0 && (
|
||||
<div className="aa-broll-list">
|
||||
{bRollSegments.map((seg) => (
|
||||
<div key={seg.id} className="aa-broll-item">
|
||||
{seg.asset.thumbnail_url || seg.asset.file_url ? (
|
||||
<img
|
||||
className="aa-broll-item__thumb"
|
||||
src={seg.asset.thumbnail_url || seg.asset.file_url}
|
||||
alt={seg.asset.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="aa-broll-item__thumb" style={{ padding: "6px 4px" }}>
|
||||
🎬
|
||||
</span>
|
||||
)}
|
||||
<div className="aa-broll-item__info">
|
||||
<div
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{seg.asset.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#8c8ca1", marginTop: 2 }}>
|
||||
{BROLL_MODE_LABEL[seg.mode]} · {formatTime(seg.start_time)}-
|
||||
{formatTime(seg.end_time)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-broll-item__remove"
|
||||
title="删除"
|
||||
onClick={() => onRemoveBRoll(seg.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 下半区:对口型预览(竖屏 9:16) ── */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">对口型预览</div>
|
||||
|
||||
<div className="aa-lipsync-preview">
|
||||
{isDone && lipsyncJob?.output_video_url ? (
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
) : isGenerating ? (
|
||||
<div style={{ width: "80%", textAlign: "center", color: "#fff" }}>
|
||||
<div style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
{statusText} {Math.round(lipsyncJob?.progress ?? 0)}%
|
||||
</div>
|
||||
<div className="aa-progress">
|
||||
<div
|
||||
className="aa-progress__bar"
|
||||
style={{ width: `${lipsyncJob?.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-video-preview__placeholder">
|
||||
{isFailed ? (
|
||||
<>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>❌</div>
|
||||
<div>对口型生成失败</div>
|
||||
{lipsyncJob?.error_message && (
|
||||
<div style={{ fontSize: 11, marginTop: 4, color: "#fca5a5" }}>
|
||||
{lipsyncJob.error_message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"生成对口型视频后在此预览"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-lipsync-actions">
|
||||
{isDone ? (
|
||||
<button type="button" className="aa-btn aa-btn--full" onClick={onGenerateLipsync}>
|
||||
🔄 重新生成对口型
|
||||
</button>
|
||||
) : isGenerating ? (
|
||||
<button type="button" className="aa-btn aa-btn--full" disabled>
|
||||
对口型生成中…
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary aa-btn--full"
|
||||
onClick={onGenerateLipsync}
|
||||
>
|
||||
🎬 生成对口型视频
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelScriptAndLipsync
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* AI数字人 — 面板4:标题配置
|
||||
*
|
||||
* 关键:直接复用智能剪辑(generate)模块的 TitleStylePanel 标题样式面板,
|
||||
* 不重新开发标题预设/字体/位置等样式能力。本组件只负责:
|
||||
* - 主标题文字输入
|
||||
* - AiAvatarTitleConfig ↔ TitleSettings 的双向适配
|
||||
* - 自动生成字幕开关
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
|
||||
import type { TitleSettings } from "@/pages/generate/types"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
TITLE_PRESETS,
|
||||
getFontFamily,
|
||||
} from "@/pages/generate/constants"
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
|
||||
interface PanelTitleConfigProps {
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
onUpdate: (partial: Partial<AiAvatarTitleConfig>) => void
|
||||
}
|
||||
|
||||
const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpdate }) => {
|
||||
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
|
||||
const [activePreset, setActivePreset] = useState<string | null>(null)
|
||||
|
||||
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
|
||||
const titleSettings: TitleSettings = useMemo(
|
||||
() => ({
|
||||
aiAutoSelect: false,
|
||||
title: titleConfig.title,
|
||||
position: titleConfig.position,
|
||||
font: titleConfig.font,
|
||||
size: titleConfig.size,
|
||||
bold: titleConfig.bold,
|
||||
italic: titleConfig.italic,
|
||||
stroke: titleConfig.stroke,
|
||||
shadow: titleConfig.shadow,
|
||||
color: titleConfig.color,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}),
|
||||
[titleConfig],
|
||||
)
|
||||
|
||||
/** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
|
||||
const handleApplyPreset = (presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
setActivePreset(presetKey)
|
||||
onUpdate({
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-title-config">
|
||||
{/* 主标题输入 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">主标题</label>
|
||||
<input
|
||||
className="aa-input aa-title-input"
|
||||
type="text"
|
||||
placeholder="输入视频标题(留空则不显示标题)"
|
||||
value={titleConfig.title}
|
||||
maxLength={30}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
/>
|
||||
{titleConfig.title && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: "6px 8px",
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 6,
|
||||
fontFamily: getFontFamily(titleConfig.font),
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
color: titleConfig.color,
|
||||
textShadow: titleConfig.shadow ? "1px 1px 3px rgba(0,0,0,0.6)" : undefined,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题样式:直接复用智能剪辑 TitleStylePanel(位置/字体/字号/样式/预设) */}
|
||||
<TitleStylePanel
|
||||
settings={titleSettings}
|
||||
onUpdatePosition={(position) => onUpdate({ position })}
|
||||
onUpdateFont={(font) => onUpdate({ font })}
|
||||
onUpdateSize={(size) => onUpdate({ size: Math.min(128, Math.max(16, size)) })}
|
||||
onToggleBold={() => onUpdate({ bold: !titleConfig.bold })}
|
||||
onToggleItalic={() => onUpdate({ italic: !titleConfig.italic })}
|
||||
onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })}
|
||||
onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={TITLE_PRESETS}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
|
||||
{/* 自动生成字幕 */}
|
||||
<div className="aa-subtitle-toggle">
|
||||
<label className="aa-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={titleConfig.auto_subtitle}
|
||||
onChange={(e) => onUpdate({ auto_subtitle: e.target.checked })}
|
||||
/>
|
||||
自动生成字幕
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelTitleConfig
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* AI数字人 — 出镜视频选择面板
|
||||
* - 未选视频:虚线上传区,点击打开素材库弹窗
|
||||
* - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
export interface PanelVideoSelectorProps {
|
||||
selectedVideo: AssetItem | null
|
||||
/** 触发打开素材库弹窗 */
|
||||
onSelectVideo: () => void
|
||||
onRemoveVideo: () => void
|
||||
}
|
||||
|
||||
/** 格式化时长(秒 → mm:ss) */
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
|
||||
return "00:00"
|
||||
}
|
||||
return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function PanelVideoSelector({
|
||||
selectedVideo,
|
||||
onSelectVideo,
|
||||
onRemoveVideo,
|
||||
}: PanelVideoSelectorProps) {
|
||||
/* 未选视频:虚线上传区,点击打开素材库弹窗 */
|
||||
if (!selectedVideo) {
|
||||
return (
|
||||
<div
|
||||
className="aa-upload-zone"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelectVideo}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelectVideo()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="aa-upload-zone__icon">🎬</div>
|
||||
<div className="aa-upload-zone__text">从素材库选择视频</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const width = selectedVideo.metadata?.width
|
||||
const height = selectedVideo.metadata?.height
|
||||
const duration = selectedVideo.duration ?? selectedVideo.metadata?.duration
|
||||
const fileUrl = selectedVideo.file_url ?? ""
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 竖屏 9:16 视频预览播放器 */}
|
||||
<div className="aa-video-preview">
|
||||
{fileUrl ? (
|
||||
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
|
||||
) : (
|
||||
<div className="aa-video-preview__placeholder">视频暂不可预览</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
|
||||
<div className="aa-video-info">
|
||||
<div className="aa-video-info__row">
|
||||
<span>文件名</span>
|
||||
<span title={selectedVideo.name}>{selectedVideo.name}</span>
|
||||
</div>
|
||||
<div className="aa-video-info__row">
|
||||
<span>时长</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
</div>
|
||||
<div className="aa-video-info__row">
|
||||
<span>分辨率</span>
|
||||
<span>{width && height ? `${width}×${height}` : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--danger aa-btn--full"
|
||||
style={{ marginTop: 10 }}
|
||||
onClick={onRemoveVideo}
|
||||
>
|
||||
移除视频
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* AI数字人 — 配音库面板(面板3)
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { fetchVoices } from "@/api/voices/voices"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
VOICE_EMOTION_OPTIONS,
|
||||
VOICE_LANGUAGE_OPTIONS,
|
||||
} from "../types"
|
||||
|
||||
interface PanelVoiceSelectorProps {
|
||||
voiceSource: VoiceSource
|
||||
onVoiceSourceChange: (source: VoiceSource) => void
|
||||
selectedVoice: UnifiedVoiceItem | null
|
||||
onSelectVoice: (voice: UnifiedVoiceItem) => void
|
||||
emotion: VoiceEmotion
|
||||
onEmotionChange: (e: VoiceEmotion) => void
|
||||
speed: number
|
||||
onSpeedChange: (s: number) => void
|
||||
language: VoiceLanguage
|
||||
onLanguageChange: (l: VoiceLanguage) => void
|
||||
}
|
||||
|
||||
export function PanelVoiceSelector({
|
||||
voiceSource,
|
||||
onVoiceSourceChange,
|
||||
selectedVoice,
|
||||
onSelectVoice,
|
||||
emotion,
|
||||
onEmotionChange,
|
||||
speed,
|
||||
onSpeedChange,
|
||||
language,
|
||||
onLanguageChange,
|
||||
}: PanelVoiceSelectorProps) {
|
||||
const [voices, setVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/* 切换来源时重新获取音色列表 */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadVoices = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchVoices({ type: voiceSource })
|
||||
if (!cancelled) setVoices(res.items || [])
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "音色加载失败")
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
loadVoices()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [voiceSource])
|
||||
|
||||
/* 卸载时停止试听 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopPreview = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setPreviewingId(null)
|
||||
}
|
||||
|
||||
const handlePreview = (voice: UnifiedVoiceItem) => {
|
||||
const url = voice.preview_url || voice.audio_url
|
||||
if (!url) return
|
||||
/* 再次点击当前试听音色 → 停止 */
|
||||
if (previewingId === voice.id) {
|
||||
stopPreview()
|
||||
return
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
setPreviewingId(voice.id)
|
||||
audio.onended = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
}
|
||||
}
|
||||
audio.onerror = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
setError("试听音频加载失败")
|
||||
}
|
||||
}
|
||||
void audio.play().catch(() => {
|
||||
setPreviewingId(null)
|
||||
setError("试听播放失败")
|
||||
})
|
||||
}
|
||||
|
||||
const handleSpeedChange = (value: string) => {
|
||||
const parsed = parseFloat(value)
|
||||
if (Number.isNaN(parsed)) return
|
||||
const clamped = Math.min(2.0, Math.max(0.5, parsed))
|
||||
onSpeedChange(clamped)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-voice-selector">
|
||||
{/* 音色来源切换 */}
|
||||
<div className="aa-voice-source-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-voice-source-btn${voiceSource === "preset" ? " active" : ""}`}
|
||||
onClick={() => onVoiceSourceChange("preset")}
|
||||
>
|
||||
系统预设
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-voice-source-btn${voiceSource === "clone" ? " active" : ""}`}
|
||||
onClick={() => onVoiceSourceChange("clone")}
|
||||
>
|
||||
我的音色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 音色列表 */}
|
||||
{loading ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
<div>音色加载中…</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⚠️</div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">🎙️</div>
|
||||
<div>{voiceSource === "clone" ? "还没有克隆音色" : "暂无预置音色"}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-voice-list">
|
||||
{voices.map((voice) => {
|
||||
const selected = selectedVoice?.id === voice.id
|
||||
const previewUrl = voice.preview_url || voice.audio_url
|
||||
return (
|
||||
<div
|
||||
key={voice.id}
|
||||
className={`aa-voice-card${selected ? " selected" : ""}`}
|
||||
onClick={() => onSelectVoice(voice)}
|
||||
>
|
||||
<span className="aa-voice-card__radio" />
|
||||
<div className="aa-voice-card__info">
|
||||
<div className="aa-voice-card__name">{voice.name}</div>
|
||||
{voice.description && (
|
||||
<div className="aa-voice-card__desc">{voice.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-voice-card__preview"
|
||||
title={previewingId === voice.id ? "停止试听" : "试听"}
|
||||
disabled={!previewUrl}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreview(voice)
|
||||
}}
|
||||
>
|
||||
{previewingId === voice.id ? "⏸" : "▶"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 我的音色:克隆入口 */}
|
||||
{voiceSource === "clone" && (
|
||||
<div className="aa-clone-entry">
|
||||
<a href="/app/voice-clone">+ 克隆新音色</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配音参数 */}
|
||||
<div className="aa-voice-params">
|
||||
<div className="aa-voice-params__row">
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-emotion">
|
||||
情绪
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-emotion"
|
||||
className="aa-select"
|
||||
value={emotion}
|
||||
onChange={(e) => onEmotionChange(e.target.value as VoiceEmotion)}
|
||||
>
|
||||
{VOICE_EMOTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-language">
|
||||
语言
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-language"
|
||||
className="aa-select"
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value as VoiceLanguage)}
|
||||
>
|
||||
{VOICE_LANGUAGE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-speed">
|
||||
语速({speed.toFixed(1)}x)
|
||||
</label>
|
||||
<input
|
||||
id="aa-voice-speed"
|
||||
type="number"
|
||||
className="aa-input"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={speed}
|
||||
onChange={(e) => handleSpeedChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelVoiceSelector
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* AI数字人 — 页面全局状态管理 hook(v3)
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
type Script,
|
||||
type LipsyncJob,
|
||||
type BRollSegment,
|
||||
type AiAvatarTitleConfig,
|
||||
type AiAvatarCoverConfig,
|
||||
DEFAULT_TITLE_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
|
||||
export function useAiAvatar() {
|
||||
/* ── 面板1:出镜视频 ── */
|
||||
const [selectedVideo, setSelectedVideo] = useState<AssetItem | null>(null)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
|
||||
/* ── 面板2:配音库 ── */
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
|
||||
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("natural")
|
||||
const [speed, setSpeed] = useState(1.0)
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("mandarin")
|
||||
|
||||
/* ── 面板3:文案 & 对口型 ── */
|
||||
const [script, setScript] = useState<Script | null>(null)
|
||||
const [scriptText, setScriptText] = useState("")
|
||||
const [lipsyncJob, setLipsyncJob] = useState<LipsyncJob | null>(null)
|
||||
const [showScriptModal, setShowScriptModal] = useState(false)
|
||||
const [showBRollModal, setShowBRollModal] = useState(false)
|
||||
|
||||
/* ── 面板3.5:B-roll ── */
|
||||
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
|
||||
|
||||
/* ── 面板4:标题配置 ── */
|
||||
const [titleConfig, setTitleConfig] = useState<AiAvatarTitleConfig>(DEFAULT_TITLE_CONFIG)
|
||||
|
||||
/* ── 面板5:封面 & 生成 ── */
|
||||
const [coverConfig, setCoverConfig] = useState<AiAvatarCoverConfig>(DEFAULT_COVER_CONFIG)
|
||||
const [resolution, setResolution] = useState("1080p")
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
/* ── Actions ── */
|
||||
const selectVideo = useCallback((asset: AssetItem) => {
|
||||
setSelectedVideo(asset)
|
||||
setShowAssetPicker(false)
|
||||
}, [])
|
||||
|
||||
const removeVideo = useCallback(() => {
|
||||
setSelectedVideo(null)
|
||||
}, [])
|
||||
|
||||
const selectScript = useCallback((s: Script) => {
|
||||
setScript(s)
|
||||
setScriptText(s.content)
|
||||
setShowScriptModal(false)
|
||||
}, [])
|
||||
|
||||
const addBRollSegment = useCallback((segment: BRollSegment) => {
|
||||
setBRollSegments((prev) => [...prev, segment])
|
||||
}, [])
|
||||
|
||||
const removeBRollSegment = useCallback((id: string) => {
|
||||
setBRollSegments((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
const updateTitleConfig = useCallback((partial: Partial<AiAvatarTitleConfig>) => {
|
||||
setTitleConfig((prev) => ({ ...prev, ...partial }))
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelectedVideo(null)
|
||||
setSelectedVoice(null)
|
||||
setScript(null)
|
||||
setScriptText("")
|
||||
setLipsyncJob(null)
|
||||
setBRollSegments([])
|
||||
setTitleConfig(DEFAULT_TITLE_CONFIG)
|
||||
setCoverConfig(DEFAULT_COVER_CONFIG)
|
||||
setResolution("1080p")
|
||||
setIsGenerating(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 面板1
|
||||
selectedVideo,
|
||||
showAssetPicker,
|
||||
setShowAssetPicker,
|
||||
selectVideo,
|
||||
removeVideo,
|
||||
// 面板2
|
||||
voiceSource,
|
||||
setVoiceSource,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
emotion,
|
||||
setEmotion,
|
||||
speed,
|
||||
setSpeed,
|
||||
language,
|
||||
setLanguage,
|
||||
// 面板3
|
||||
script,
|
||||
setScript,
|
||||
scriptText,
|
||||
setScriptText,
|
||||
lipsyncJob,
|
||||
setLipsyncJob,
|
||||
showScriptModal,
|
||||
setShowScriptModal,
|
||||
showBRollModal,
|
||||
setShowBRollModal,
|
||||
selectScript,
|
||||
// B-roll
|
||||
bRollSegments,
|
||||
addBRollSegment,
|
||||
removeBRollSegment,
|
||||
// 面板4
|
||||
titleConfig,
|
||||
updateTitleConfig,
|
||||
setTitleConfig,
|
||||
// 面板5
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
resolution,
|
||||
setResolution,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
// 全局
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseAiAvatarReturn = ReturnType<typeof useAiAvatar>
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AI数字人 — TypeScript 类型定义(v3)
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
/* ── 音色来源切换 ── */
|
||||
export type VoiceSource = "preset" | "clone"
|
||||
|
||||
/* ── 情绪 ── */
|
||||
export type VoiceEmotion = "natural" | "excited" | "calm" | "friendly"
|
||||
|
||||
export const VOICE_EMOTION_OPTIONS: { value: VoiceEmotion; label: string }[] = [
|
||||
{ value: "natural", label: "自然" },
|
||||
{ value: "excited", label: "兴奋" },
|
||||
{ value: "calm", label: "沉稳" },
|
||||
{ value: "friendly", label: "亲切" },
|
||||
]
|
||||
|
||||
/* ── 语言 ── */
|
||||
export type VoiceLanguage = "mandarin" | "english" | "cantonese"
|
||||
|
||||
export const VOICE_LANGUAGE_OPTIONS: { value: VoiceLanguage; label: string }[] = [
|
||||
{ value: "mandarin", label: "普通话" },
|
||||
{ value: "english", label: "English" },
|
||||
{ value: "cantonese", label: "粤语" },
|
||||
]
|
||||
|
||||
/* ── 对口型任务状态 ── */
|
||||
export type LipsyncStatus = "idle" | "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
/* ── 文案 ── */
|
||||
export interface Script {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
char_count: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/* ── 对口型任务 ── */
|
||||
export interface LipsyncJob {
|
||||
id: string
|
||||
status: LipsyncStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/* ── B-roll 画面插入 ── */
|
||||
export type BRollInsertMode = "fullscreen" | "pip"
|
||||
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
|
||||
export interface BRollSegment {
|
||||
id: string
|
||||
asset: AssetItem
|
||||
script_segment_index: number
|
||||
start_time: number
|
||||
end_time: number
|
||||
mode: BRollInsertMode
|
||||
pip_position: PipPosition
|
||||
pip_scale: number
|
||||
}
|
||||
|
||||
/* ── 标题配置 ── */
|
||||
export interface AiAvatarTitleConfig {
|
||||
title: string
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
auto_subtitle: boolean
|
||||
}
|
||||
|
||||
/* ── 封面配置 ── */
|
||||
export interface AiAvatarCoverConfig {
|
||||
enabled: boolean
|
||||
mode: "auto_frame" | "upload"
|
||||
frame_time: number
|
||||
upload_url: string | null
|
||||
thumbnail_url: string | null
|
||||
}
|
||||
|
||||
/* ── 渲染任务 ── */
|
||||
export type RenderStatus = "pending" | "processing" | "completed" | "failed" | "cancelled"
|
||||
|
||||
export interface RenderJob {
|
||||
id: string
|
||||
status: RenderStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/* ── 默认值 ── */
|
||||
export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
title: "",
|
||||
position: "top",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
auto_subtitle: true,
|
||||
}
|
||||
|
||||
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
|
||||
enabled: true,
|
||||
mode: "auto_frame",
|
||||
frame_time: 0,
|
||||
upload_url: null,
|
||||
thumbnail_url: null,
|
||||
}
|
||||
@@ -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,15 +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:全宽+内容居中(单视频视频播放器居中,批量网格居中)
|
||||
if (currentStep === 5) return "xx-generate-layout full-width"
|
||||
// 步骤6:封面选择保持两栏布局
|
||||
return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout"
|
||||
}, [currentStep, isBatch])
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -524,6 +521,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
onTemplateInvalid={handleInvalidTemplate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
@@ -545,18 +543,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVariantIds={selectedVariantIds}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
/>
|
||||
|
||||
{/* ════ 步骤5(单视频):成片播放器内联居中(#1761) ════ */}
|
||||
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
|
||||
{currentStep === 5 && !isBatch && generated && finalVideo && (
|
||||
<div
|
||||
style={{
|
||||
@@ -580,6 +567,7 @@ const GeneratePage: React.FC = () => {
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
@@ -607,55 +595,18 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ════ 步骤6(单视频):右侧成片播放器 ════ */}
|
||||
{currentStep >= 6 && !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>
|
||||
)}
|
||||
<GenerateStepActions
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数量选择弹窗 */}
|
||||
|
||||
@@ -38,7 +38,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
className="xx-batch-gen-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 180px))",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 320px))",
|
||||
justifyContent: "center",
|
||||
justifyItems: "center",
|
||||
gap: 14,
|
||||
@@ -52,7 +52,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div
|
||||
key={task.taskId}
|
||||
className={`xx-batch-gen-card status-${task.status}`}
|
||||
style={{ maxWidth: 240 }}
|
||||
style={{ maxWidth: 320 }}
|
||||
>
|
||||
<div className="xx-batch-gen-card-head">
|
||||
<span className="xx-batch-gen-card-title" title={title}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ services:
|
||||
# 健康检查配置
|
||||
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f 'celery.*worker' | head -n1 >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1"]
|
||||
test: ["CMD-SHELL", "for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -155,7 +155,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"pgrep -f 'celery.*worker' >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -116,7 +116,7 @@ docker run -d \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--health-cmd "sh -c \"pgrep -f 'celery.*worker' >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -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,7 +331,7 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def generate_pixel_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 种
|
||||
@@ -336,7 +346,10 @@ def generate_pixel_perturbation(rng: random.Random | None = None) -> dict:
|
||||
返回 dict,可直接存入 plan.config["pixel_perturbation"]。
|
||||
渲染侧读取后追加到 ffmpeg filter chain。
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
elif isinstance(rng, int):
|
||||
rng = random.Random(rng)
|
||||
|
||||
# 可用滤镜池
|
||||
filter_options = ["noise", "unsharp", "curves", "color_balance"]
|
||||
@@ -369,6 +382,52 @@ def generate_pixel_perturbation(rng: random.Random | None = None) -> dict:
|
||||
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
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
禁止慢放、禁止截断配音;
|
||||
4. 任何情况下不得因素材时长/数量报错打断用户。
|
||||
|
||||
#1764 节奏模板:
|
||||
- 预设 6 种权重序列,不同变体用不同节奏模板
|
||||
#1764 节奏模板 + #1768 多样化增强:
|
||||
- 预设 8 种权重序列,不同变体用不同节奏模板
|
||||
- 片段时长 = 配音总时长 × 该片段权重 / 权重总和
|
||||
- 平均分配作为权重全 1 的特例保留
|
||||
- 每个片段 >= MIN_CLIP_DURATION(2秒)
|
||||
- #1768:最大片段时长 <= 素材可用时长 × 90%
|
||||
- #1768:成片总时长与配音时长误差 <= TOTAL_DURATION_TOLERANCE(0.5s)
|
||||
|
||||
本模块为纯函数:输入片段骨架(每段转场效果/时长)与配音总时长,
|
||||
输出每段目标时长(target duration)与成片总时长。不碰 DB、不碰素材。
|
||||
@@ -42,6 +44,8 @@ RHYTHM_TEMPLATES: list[list[int]] = [
|
||||
[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 渐弱节奏
|
||||
]
|
||||
|
||||
|
||||
@@ -87,13 +91,6 @@ def adapt_template_length(template: list[int], clip_count: int) -> list[int]:
|
||||
return result
|
||||
|
||||
|
||||
#: 单段最小时长(秒):低于此值播放器/渲染链路易出问题
|
||||
MIN_CLIP_DURATION = 1.0
|
||||
|
||||
#: 成片总时长与配音时长的可接受误差(秒)
|
||||
TOTAL_DURATION_TOLERANCE = 0.5
|
||||
|
||||
|
||||
def transition_overlap_seconds(transition_effect: Optional[str], transition_duration: float) -> float:
|
||||
"""转场导致的相邻片段重叠时长。
|
||||
|
||||
@@ -112,6 +109,7 @@ def plan_clip_durations(
|
||||
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 段,返回每段目标时长(秒)。
|
||||
|
||||
@@ -129,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);无配音/非法输入返回 []。
|
||||
"""
|
||||
@@ -187,12 +188,58 @@ def plan_clip_durations(
|
||||
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]: ...
|
||||
|
||||
@@ -209,7 +209,7 @@ rollback() {
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
@@ -408,7 +408,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -310,7 +310,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user