Compare commits

...

9 Commits

Author SHA1 Message Date
xiaoxia cb3bc04c83 fix: 部署脚本 worker health-cmd 中 $pid 未转义导致 staging 部署失败回滚
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m6s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m5s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m30s
AI Code Review / AI Code Review (pull_request) Successful in 6m34s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m59s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 23m1s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m9s
ci_staging_deploy.sh 开启 set -eu,worker 容器 --health-cmd 双引号内的
$pid 被外层部署脚本提前展开(pid 未定义)→ 'pid: parameter not set' →
worker 容器 docker run 失败 → 判定部署失败并自动回滚。

将 health-cmd 内 $pid 转义为 \$pid,使其透传到容器内由 healthcheck shell
展开(for pid in /proc/[0-9]*/cmdline)。回滚段(195行)与主部署段(504行)
两处一并修复。

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-08 22:16:37 +08:00
xiaoxia bf7c8b8a08 feat: #1797 AI数字人前端页面v3 — 素材库选择/配音库复用/TitleStylePanel直接import (#1805)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / Validate - Style (push) Successful in 1m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m54s
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m50s
CI/CD Pipeline / Unit Tests (push) Successful in 8m50s
CI/CD Pipeline / Validate - Security (push) Successful in 26m4s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-08 21:46:35 +08:00
CI Bot e4ac398397 fix: Worker 健康检查彻底修复 - 移除不可用的 pgrep 和 PID1 检查
根因:#1714 队列隔离后 entrypoint 启动两个 celery worker 子进程,
PID 1 变为 bash。旧健康检查依赖 pgrep(容器内不存在)或
/proc/1/cmdline(PID 1 是 bash 不是 celery),永远返回失败。
此前靠 cron 脚本 fix-worker-healthcheck.sh 每 30 分钟擦屁股。

修复:统一改为扫描 /proc/[0-9]*/cmdline 查找 celery 进程,
覆盖所有部署/回滚/CI 脚本(10 处替换)。已移除 cron 补丁。
2026-09-08 19:07:37 +08:00
xiaoxia a0ca13d463 fix: AI数字人渲染 OSS 上传集成 SharedStorageService (#1804)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Validate - Style (push) Successful in 2m40s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m23s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Unit Tests (push) Successful in 8m39s
CI/CD Pipeline / Validate - Security (push) Successful in 22m54s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-08 19:04:34 +08:00
xiaoxia e45a8fe775 feat: #1798 AI数字人前端页面 — 5列水平面板布局 (#1803)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Integration Tests (push) Successful in 1m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m9s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m50s
CI/CD Pipeline / Validate - Security (push) Successful in 5m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Unit Tests (push) Successful in 7m46s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-08 18:31:02 +08:00
xiaoxia 800f90d8c6 feat: #1798 AI数字人渲染合成管线 (#1802)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m15s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Style (push) Successful in 2m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m49s
CI/CD Pipeline / Validate - Security (push) Successful in 8m27s
CI/CD Pipeline / Unit Tests (push) Successful in 8m34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 18:03:06 +08:00
xiaoxia f7825e3956 feat: #1796 MediaKit 对口型后端对接 (#1801)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 17s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m26s
CI/CD Pipeline / Validate - Style (push) Successful in 2m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m47s
CI/CD Pipeline / Unit Tests (push) Successful in 8m7s
CI/CD Pipeline / Validate - Security (push) Successful in 9m43s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 16:47:57 +08:00
xiaoxia 2cca03f680 Merge pull request 'feat: #1795 文案库 CRUD(Script 模型 + Service + API + 迁移 + 35 测试)' (#1799) from feat/scripts-crud-1795 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 6s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m3s
CI/CD Pipeline / Integration Tests (push) Successful in 2m14s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m40s
CI/CD Pipeline / Validate - Style (push) Successful in 2m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m4s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m52s
CI/CD Pipeline / Validate - Security (push) Successful in 10m38s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-09-08 15:42:54 +08:00
xiaoxia a4c008e829 fix: #1799 批量生成进度卡片 grid 列宽过窄导致标题和进度条重叠
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m31s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m15s
CI/CD Pipeline / Validate - Style (push) Successful in 2m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m36s
CI/CD Pipeline / Unit Tests (push) Failing after 6m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m27s
CI/CD Pipeline / Validate - Security (push) Successful in 9m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-08 15:27:21 +08:00
42 changed files with 6293 additions and 13 deletions
@@ -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")
+12
View File
@@ -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,6 +16,7 @@ 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
@@ -39,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",
@@ -177,3 +184,8 @@ api_router.include_router(
prefix="/scripts",
tags=["ScriptLibrary"],
)
api_router.include_router(
ai_avatar_render_router,
prefix="/ai-avatar/render",
tags=["AI Avatar Render"],
)
+175
View File
@@ -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
+145
View File
@@ -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
+111
View File
@@ -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
+70
View File
@@ -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="驱动音频 URLmp3/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,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
+181
View File
@@ -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
+243
View File
@@ -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: 人物视频 URLMP4,≤30min,单人真人)
audio_url: 驱动音频 URLmp3/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
+1
View File
@@ -0,0 +1 @@
"""Celery 异步任务模块."""
+48
View File
@@ -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
+13
View File
@@ -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.5B-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>
+121
View File
@@ -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,
}
@@ -6455,7 +6455,9 @@
border-radius: 4px;
border: 2px solid transparent;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
transition:
border-color 0.15s,
transform 0.1s;
}
.ep-color-swatch:hover {
@@ -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}>
+4
View File
@@ -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")),
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 \
+1 -1
View File
@@ -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 \
@@ -668,3 +668,68 @@ class ScriptModel(Base):
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))
+168
View File
@@ -563,3 +563,171 @@ def build_title_drawtext_filter(
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
+2 -2
View File
@@ -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 \
+1 -1
View File
@@ -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 \
+2 -2
View File
@@ -192,7 +192,7 @@ rollback() {
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--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 \
@@ -501,7 +501,7 @@ docker run -d \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--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 \
+1 -1
View File
@@ -305,7 +305,7 @@ docker run -d \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--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 \
+1 -1
View File
@@ -201,7 +201,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 \
+317
View File
@@ -0,0 +1,317 @@
from datetime import datetime, timezone
"""AI数字人渲染 API 路由测试 — #1798.
至少 10 个测试覆盖路由层逻辑。
"""
import os
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
def _make_mock_user(user_id="user-1"):
"""创建 mock 认证用户."""
user = MagicMock()
user.id = user_id
return user
def _make_mock_render_job(
job_id="render-1",
user_id="user-1",
status="pending",
progress=0,
output_video_url="",
output_cover_url="",
output_duration=0.0,
error_message="",
):
"""创建 mock 渲染任务."""
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.project_id = ""
m.lipsync_job_id = "lipsync-1"
m.script_id = "script-1"
m.b_roll_segments = []
m.title_config = {}
m.cover_config = {}
m.status = status
m.progress = progress
m.output_video_url = output_video_url
m.output_cover_url = output_cover_url
m.output_duration = output_duration
m.error_message = error_message
m.submitted_at = None
m.started_at = None
m.completed_at = None
m.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
m.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
return m
class TestRenderRoutes:
"""路由层测试(通过 mock service 测试路由逻辑)."""
def _get_client(self):
"""获取测试客户端."""
from app.main import app
from fastapi.testclient import TestClient
return TestClient(app)
def test_create_render_job_success(self):
from app.api.routes.ai_avatar_render import router
from app.schemas.ai_avatar_render import AiAvatarRenderJobResponse
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job()
mock_service.create_render_job.return_value = mock_job
# 直接测试路由函数
from app.api.routes.ai_avatar_render import create_render_job
mock_user = _make_mock_user()
body = MagicMock()
body.lipsync_job_id = "lipsync-1"
body.script_id = "script-1"
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.project_id = ""
result = create_render_job(
body=body,
current_user=mock_user,
svc=mock_service,
)
assert result.id == "render-1"
mock_service.create_render_job.assert_called_once()
def test_create_render_job_lipsync_not_found(self):
from app.api.routes.ai_avatar_render import create_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.create_render_job.side_effect = AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
mock_user = _make_mock_user()
body = MagicMock()
body.lipsync_job_id = "nonexistent"
body.script_id = "script-1"
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.project_id = ""
with pytest.raises(HTTPException) as exc_info:
create_render_job(body=body, current_user=mock_user, svc=mock_service)
assert exc_info.value.status_code == 404
def test_create_render_job_lipsync_not_completed(self):
from app.api.routes.ai_avatar_render import create_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.create_render_job.side_effect = AiAvatarRenderError(
"对口型任务状态为 processing", code="LipsyncJobNotCompleted"
)
mock_user = _make_mock_user()
body = MagicMock()
body.lipsync_job_id = "lipsync-1"
body.script_id = "script-1"
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.project_id = ""
with pytest.raises(HTTPException) as exc_info:
create_render_job(body=body, current_user=mock_user, svc=mock_service)
assert exc_info.value.status_code == 400
def test_get_render_job_success(self):
from app.api.routes.ai_avatar_render import get_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job()
mock_service.get_render_job.return_value = mock_job
result = get_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert result.id == "render-1"
def test_get_render_job_not_found(self):
from app.api.routes.ai_avatar_render import get_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.get_render_job.return_value = None
with pytest.raises(HTTPException) as exc_info:
get_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 404
def test_list_render_jobs(self):
from app.api.routes.ai_avatar_render import list_render_jobs
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
mock_service.list_render_jobs.return_value = (mock_jobs, 3)
result = list_render_jobs(
project_id="",
status="",
offset=0,
limit=20,
current_user=_make_mock_user(),
svc=mock_service,
)
assert result["total"] == 3
assert len(result["items"]) == 3
def test_cancel_render_job_success(self):
from app.api.routes.ai_avatar_render import cancel_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job(status="cancelled")
mock_service.cancel_render_job.return_value = mock_job
result = cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert result.status == "cancelled"
def test_cancel_render_job_not_found(self):
from app.api.routes.ai_avatar_render import cancel_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.cancel_render_job.return_value = None
with pytest.raises(HTTPException) as exc_info:
cancel_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 404
def test_cancel_render_job_not_cancellable(self):
from app.api.routes.ai_avatar_render import cancel_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job(status="completed")
mock_service.cancel_render_job.return_value = mock_job
with pytest.raises(HTTPException) as exc_info:
cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 400
def test_retry_render_job_success(self):
from app.api.routes.ai_avatar_render import retry_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job(status="pending")
mock_service.retry_render_job.return_value = mock_job
result = retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert result.status == "pending"
def test_retry_render_job_not_failed(self):
from app.api.routes.ai_avatar_render import retry_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job(status="completed")
mock_service.retry_render_job.return_value = mock_job
with pytest.raises(HTTPException) as exc_info:
retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 400
def test_retry_render_job_not_found(self):
from app.api.routes.ai_avatar_render import retry_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.retry_render_job.return_value = None
with pytest.raises(HTTPException) as exc_info:
retry_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 404
class TestBrollOverlayFilter:
"""FFmpeg B-roll 滤镜构建测试."""
def test_empty_segments_returns_empty(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
result = build_broll_overlay_filter([], 30.0)
assert result == ""
def test_pip_mode_generates_overlay(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
segments = [
{
"script_segment_index": 0,
"asset_url": "https://example.com/broll.mp4",
"mode": "pip",
"start_time": 5.0,
"end_time": 10.0,
"pip_position": "bottom_right",
"pip_scale": 0.3,
}
]
result = build_broll_overlay_filter(segments, 30.0)
assert "overlay" in result or "scale=" in result
def test_fullscreen_mode_generates_concat(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
segments = [
{
"script_segment_index": 0,
"asset_url": "https://example.com/broll.mp4",
"mode": "fullscreen",
"start_time": 5.0,
"end_time": 10.0,
}
]
result = build_broll_overlay_filter(segments, 30.0)
assert "trim" in result or "concat" in result
def test_cover_extract_command(self):
from packages.domain.video_filter_builder import build_cover_extract_command
cmd = build_cover_extract_command({"timestamp": 5.0}, "/tmp/cover.jpg")
assert "ffmpeg" in cmd
assert "5.0" in cmd
assert "/tmp/cover.jpg" in cmd
def test_cover_extract_empty_config(self):
from packages.domain.video_filter_builder import build_cover_extract_command
cmd = build_cover_extract_command({}, "/tmp/cover.jpg")
assert "ffmpeg" in cmd
def test_cover_extract_with_size(self):
from packages.domain.video_filter_builder import build_cover_extract_command
cmd = build_cover_extract_command(
{"timestamp": 3.0, "width": 1280, "height": 720},
"/tmp/cover.jpg",
)
assert "scale=" in cmd
+517
View File
@@ -0,0 +1,517 @@
"""AI数字人渲染 Service 单元测试 — #1798.
至少 15 个测试覆盖 Service 层核心逻辑。
"""
import os
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
def _make_mock_db():
"""创建 mock 数据库 session."""
mock_db = MagicMock()
mock_db.add = MagicMock()
mock_db.flush = MagicMock()
mock_db.commit = MagicMock()
mock_db.refresh = MagicMock()
return mock_db
def _make_mock_render_job(
job_id="render-1",
user_id="user-1",
status="pending",
progress=0,
output_video_url="",
output_cover_url="",
output_duration=0.0,
error_message="",
lipsync_job_id="lipsync-1",
script_id="script-1",
):
"""创建 mock 渲染任务."""
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.project_id = ""
m.lipsync_job_id = lipsync_job_id
m.script_id = script_id
m.b_roll_segments = []
m.title_config = {}
m.cover_config = {}
m.status = status
m.progress = progress
m.output_video_url = output_video_url
m.output_cover_url = output_cover_url
m.output_duration = output_duration
m.error_message = error_message
m.submitted_at = None
m.started_at = None
m.completed_at = None
m.created_at = None
m.updated_at = None
return m
def _make_mock_lipsync_job(
job_id="lipsync-1",
user_id="user-1",
status="completed",
output_video_url="https://output.mp4",
output_duration=30.0,
):
"""创建 mock 对口型任务."""
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.status = status
m.output_video_url = output_video_url
m.output_duration = output_duration
return m
def _make_mock_script(script_id="script-1", user_id="user-1"):
"""创建 mock 文案."""
m = MagicMock()
m.id = script_id
m.user_id = user_id
m.title = "测试文案"
return m
class TestSchemaValidation:
"""Schema 验证测试."""
def test_valid_broll_segment(self):
from app.schemas.ai_avatar_render import BRollSegment
seg = BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
assert seg.mode == "fullscreen"
assert seg.start_time == 5.0
def test_invalid_mode(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="fullscreen 或 pip"):
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="invalid",
start_time=5.0,
end_time=10.0,
)
def test_end_time_must_exceed_start_time(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="end_time 必须大于 start_time"):
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="fullscreen",
start_time=10.0,
end_time=5.0,
)
def test_asset_url_must_be_http(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="HTTP"):
BRollSegment(
script_segment_index=0,
asset_url="ftp://example.com/broll.mp4",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
def test_asset_url_empty(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="不能为空"):
BRollSegment(
script_segment_index=0,
asset_url=" ",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
def test_create_request_valid(self):
from app.schemas.ai_avatar_render import BRollSegment, CreateAiAvatarRenderRequest
req = CreateAiAvatarRenderRequest(
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="pip",
start_time=5.0,
end_time=10.0,
)
],
)
assert req.lipsync_job_id == "lipsync-1"
assert len(req.b_roll_segments) == 1
def test_create_request_empty_lipsync_job_id(self):
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
with pytest.raises(ValueError, match="lipsync_job_id 不能为空"):
CreateAiAvatarRenderRequest(
lipsync_job_id=" ",
script_id="script-1",
)
def test_create_request_empty_script_id(self):
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
with pytest.raises(ValueError, match="script_id 不能为空"):
CreateAiAvatarRenderRequest(
lipsync_job_id="lipsync-1",
script_id=" ",
)
class TestAiAvatarRenderService:
"""Service 层单元测试(纯 mock,不依赖数据库)."""
def test_create_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
# 模拟 query 链式调用
mock_query = MagicMock()
# 第一次 query: LipsyncJobModel
mock_lipsync_filter = MagicMock()
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
mock_lipsync_query = MagicMock()
mock_lipsync_query.filter.return_value = mock_lipsync_filter
# 第二次 query: ScriptModel
mock_script_filter = MagicMock()
mock_script_filter.first.return_value = _make_mock_script()
mock_script_query = MagicMock()
mock_script_query.filter.return_value = mock_script_filter
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
svc = AiAvatarRenderService(mock_db)
job = svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
assert job.status == "pending"
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
def test_create_job_lipsync_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="对口型任务不存在"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="nonexistent",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_lipsync_not_completed(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(status="processing")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_lipsync_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="仅 completed 状态可渲染"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_lipsync_no_output(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_video_url="")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_lipsync_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="输出视频 URL 为空"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_script_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_query = MagicMock()
mock_lipsync_filter = MagicMock()
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
mock_lipsync_query.filter.return_value = mock_lipsync_filter
mock_script_query = MagicMock()
mock_script_filter = MagicMock()
mock_script_filter.first.return_value = None
mock_script_query.filter.return_value = mock_script_filter
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="文案不存在或无权访问"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="nonexistent",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_get_render_job_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.get_render_job("render-1", "user-1")
assert result is mock_job
def test_get_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.get_render_job("nonexistent", "user-1")
assert result is None
def test_list_render_jobs(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 3
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = mock_jobs
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
items, total = svc.list_render_jobs(user_id="user-1")
assert total == 3
assert len(items) == 3
def test_list_render_jobs_with_project_filter(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 1
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = [_make_mock_render_job()]
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
items, total = svc.list_render_jobs(user_id="user-1", project_id="proj-1")
assert total == 1
# filter should be called for user_id and project_id
assert mock_query.filter.call_count >= 2
def test_cancel_render_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="pending")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("render-1", "user-1")
assert result is mock_job
assert mock_job.status == "cancelled"
def test_cancel_render_job_not_pending(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="completed")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("render-1", "user-1")
# 非 pending 状态不可取消,状态不变
assert result.status == "completed"
def test_cancel_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("nonexistent", "user-1")
assert result is None
def test_retry_render_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="failed", error_message="渲染失败")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("render-1", "user-1")
assert result.status == "pending"
assert result.progress == 0
assert result.error_message == ""
def test_retry_render_job_not_failed(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="completed")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("render-1", "user-1")
assert result is None
def test_retry_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("nonexistent", "user-1")
assert result is None
def test_execute_render_job_not_found(self):
"""execute_render 在任务不存在时应静默返回."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
# 不应抛异常
svc.execute_render("nonexistent")
def test_execute_render_cancelled_job(self):
"""execute_render 在任务已取消时应静默返回."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="cancelled")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
svc.execute_render("render-1")
# 不应执行渲染逻辑
mock_db.commit.assert_not_called()
def test_error_exception_has_code(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError
err = AiAvatarRenderError("测试错误", code="TestCode")
assert err.code == "TestCode"
assert str(err) == "测试错误"
+310
View File
@@ -0,0 +1,310 @@
"""对口型 API 路由 + Service 单元测试 — #1796.
CI 增量映射: lipsync.py (route) + lipsync_service.py → test_lipsync_routes.py
"""
import os
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
@pytest.fixture
def mock_mediakit():
"""Mock MediaKit 客户端."""
client = MagicMock()
client.is_available = True
client.submit_lipsync.return_value = {
"success": True,
"task_id": "mk-task-123",
"request_id": "mk-req-456",
}
client.get_task_status.return_value = {
"success": True,
"task_id": "mk-task-123",
"status": "completed",
"result": {"video_url": "https://output.mp4", "duration": 30.0},
"created_at": 1777291767,
"finished_at": 1777291851,
"expires_at": 1777464650,
}
return client
def _make_mock_job(
job_id="job-1",
user_id="user-1",
status="submitted",
mediakit_task_id="mk-task-123",
output_video_url="",
output_duration=0.0,
error_message="",
error_code="",
):
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.project_id = ""
m.video_url = "https://example.com/video.mp4"
m.audio_url = "https://example.com/audio.mp3"
m.enable_video_loop = False
m.mediakit_task_id = mediakit_task_id
m.status = status
m.output_video_url = output_video_url
m.output_duration = output_duration
m.error_message = error_message
m.error_code = error_code
m.submitted_at = None
m.completed_at = None
m.created_at = None
m.updated_at = None
return m
class TestSchemaValidation:
"""Schema 验证测试."""
def test_valid_video_url(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert req.video_url == "https://example.com/video.mp4"
def test_invalid_video_url_not_mp4(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="MP4"):
CreateLipsyncJobRequest(
video_url="https://example.com/video.mov",
audio_url="https://example.com/audio.mp3",
)
def test_invalid_video_url_empty(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="不能为空"):
CreateLipsyncJobRequest(
video_url=" ",
audio_url="https://example.com/audio.mp3",
)
def test_invalid_video_url_not_http(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="HTTP"):
CreateLipsyncJobRequest(
video_url="ftp://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
def test_valid_audio_formats(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
for ext in [".mp3", ".aac", ".wav", ".m4a", ".flac"]:
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url=f"https://example.com/audio{ext}",
)
assert req.audio_url.endswith(ext)
def test_invalid_audio_format(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="格式不支持"):
CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.ogg",
)
def test_enable_video_loop_default(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert req.enable_video_loop is False
def test_video_url_strip_query_params(self):
"""视频 URL 含查询参数时,扩展名检查应忽略 ? 后面的部分."""
from app.schemas.lipsync import CreateLipsyncJobRequest
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4?token=abc",
audio_url="https://example.com/audio.mp3?sign=xyz",
)
assert "?token=" in req.video_url
class TestLipsyncServiceUnit:
"""Service 层单元测试(纯 mock,不依赖数据库)."""
def test_create_job_success(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit)
# 模拟 db.add + db.flush 不报错
mock_db.add = MagicMock()
mock_db.flush = MagicMock()
mock_db.commit = MagicMock()
mock_db.refresh = MagicMock()
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-task-123"
mock_mediakit.submit_lipsync.assert_called_once()
def test_create_job_api_failure(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed")
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit)
with pytest.raises(MediaKitError, match="API 调用失败"):
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
def test_get_job_delegates_to_db(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job()
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.get_job("job-1", "user-1")
assert result is mock_job
mock_db.query.assert_called_once()
def test_get_job_not_found(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.get_job("nonexistent", "user-1")
assert result is None
def test_refresh_job_completed(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="submitted")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.refresh_job_status("job-1", "user-1")
assert result.status == "completed"
assert result.output_video_url == "https://output.mp4"
assert result.output_duration == 30.0
def test_refresh_job_failed(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_mediakit.get_task_status.return_value = {
"success": True,
"task_id": "mk-task-123",
"status": "failed",
"error": {"code": "DownloadFailed", "message": "无法下载"},
"created_at": 1777291767,
"finished_at": 1777291851,
}
mock_job = _make_mock_job(status="submitted")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.refresh_job_status("job-1", "user-1")
assert result.status == "failed"
assert result.error_code == "DownloadFailed"
def test_refresh_job_already_completed(self, mock_mediakit):
"""已完成的任务不轮询."""
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="completed")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.refresh_job_status("job-1", "user-1")
# 不应调用 MediaKit
mock_mediakit.get_task_status.assert_not_called()
assert result.status == "completed"
def test_cancel_job_pending(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="pending")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
def test_cancel_job_completed_not_allowed(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="completed")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.cancel_job("job-1", "user-1")
# 已完成不可取消
assert result.status == "completed"
+278
View File
@@ -0,0 +1,278 @@
"""MediaKit 客户端单元测试 — #1796."""
import os
from unittest.mock import MagicMock, patch
import httpx
import pytest
# 确保测试环境有 JWT_SECRET_KEY
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
from app.services.mediakit_client import (
MediaKitClient,
MediaKitError,
get_mediakit_client,
reset_mediakit_client,
)
@pytest.fixture(autouse=True)
def _reset_client():
"""每个测试前后重置单例."""
reset_mediakit_client()
yield
reset_mediakit_client()
@pytest.fixture
def mock_settings():
with patch("app.services.mediakit_client.get_api_settings") as m:
settings = MagicMock()
settings.mediakit_api_key = "test-api-key"
settings.mediakit_base_url = "https://mediakit.cn-beijing.volces.com/api/v1"
settings.mediakit_timeout = 30
m.return_value = settings
yield settings
@pytest.fixture
def mock_settings_no_key():
with patch("app.services.mediakit_client.get_api_settings") as m:
settings = MagicMock()
settings.mediakit_api_key = ""
settings.mediakit_base_url = "https://mediakit.cn-beijing.volces.com/api/v1"
settings.mediakit_timeout = 30
m.return_value = settings
yield settings
class TestMediaKitClientInit:
"""客户端初始化测试."""
def test_is_available_with_key(self, mock_settings):
client = MediaKitClient()
assert client.is_available is True
def test_is_available_without_key(self, mock_settings_no_key):
client = MediaKitClient()
assert client.is_available is False
def test_get_client_singleton(self, mock_settings):
c1 = get_mediakit_client()
c2 = get_mediakit_client()
assert c1 is c2
class TestSubmitLipsync:
"""提交对口型任务测试."""
def test_submit_success(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "amk-tool-lip-sync-123",
"request_id": "req-456",
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.post.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert result["success"] is True
assert result["task_id"] == "amk-tool-lip-sync-123"
assert result["request_id"] == "req-456"
def test_submit_without_api_key(self, mock_settings_no_key):
client = MediaKitClient()
with pytest.raises(MediaKitError, match="未配置"):
client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
def test_submit_api_error(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": False,
"task_id": "",
"request_id": "req-789",
"error": {
"code": "InvalidParameter",
"message": "must specify audio_url",
"param": "audio_url",
"type": "BadRequest",
},
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.post.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
with pytest.raises(MediaKitError) as exc_info:
client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert exc_info.value.code == "InvalidParameter"
assert "audio_url" in str(exc_info.value)
def test_submit_timeout(self, mock_settings):
client = MediaKitClient()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.post.side_effect = httpx.TimeoutException("timeout")
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
with pytest.raises(MediaKitError, match="超时"):
client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
def test_submit_with_all_params(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "task-1",
"request_id": "req-1",
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.post.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
enable_video_loop=True,
callback_url="https://callback.example.com",
callback_args="my_args",
client_token="token-123",
)
assert result["success"] is True
# 验证请求参数
call_args = mock_client.post.call_args
payload = call_args.kwargs["json"]
assert payload["enable_video_loop"] is True
assert payload["callback_url"] == "https://callback.example.com"
assert payload["callback_args"] == "my_args"
assert payload["client_token"] == "token-123"
class TestGetTaskStatus:
"""查询任务状态测试."""
def test_get_status_running(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "task-123",
"status": "running",
"created_at": 1777291767,
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.get.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.get_task_status("task-123")
assert result["status"] == "running"
assert result["result"] is None
def test_get_status_completed(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "task-123",
"status": "completed",
"result": {"video_url": "https://output.mp4", "duration": 60.5},
"created_at": 1777291767,
"finished_at": 1777291851,
"expires_at": 1777464650,
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.get.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.get_task_status("task-123")
assert result["status"] == "completed"
assert result["result"]["video_url"] == "https://output.mp4"
assert result["result"]["duration"] == 60.5
def test_get_status_failed(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "task-123",
"status": "failed",
"error": {"code": "DownloadFailed", "message": "无法下载视频"},
"created_at": 1777291767,
"finished_at": 1777291851,
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.get.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.get_task_status("task-123")
assert result["status"] == "failed"
assert result["error"]["code"] == "DownloadFailed"
def test_get_status_without_api_key(self, mock_settings_no_key):
client = MediaKitClient()
with pytest.raises(MediaKitError, match="未配置"):
client.get_task_status("task-123")
def test_get_status_network_error(self, mock_settings):
client = MediaKitClient()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.get.side_effect = httpx.RequestError("connection refused")
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
with pytest.raises(MediaKitError, match="网络错误"):
client.get_task_status("task-123")