Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99c5777514 | |||
| 9121341f1b | |||
| c3023cc11b | |||
| 547561f473 | |||
| a10e05654c | |||
| c954e334e6 | |||
| 7c738ee64d | |||
| 7209b7d481 | |||
| 165574f826 | |||
| a7e6145bb1 | |||
| 6ee99a652c | |||
| bf7c8b8a08 | |||
| e4ac398397 | |||
| a0ca13d463 | |||
| e45a8fe775 | |||
| 800f90d8c6 | |||
| f7825e3956 | |||
| 2cca03f680 | |||
| a4c008e829 |
@@ -0,0 +1,47 @@
|
||||
"""add lipsync jobs table
|
||||
|
||||
Revision ID: 071_add_lipsync_jobs
|
||||
Revises: 070_add_scripts
|
||||
Create Date: 2026-09-08
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "071_add_lipsync_jobs"
|
||||
down_revision = "070_add_scripts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lipsync_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("video_url", sa.Text(), nullable=False),
|
||||
sa.Column("audio_url", sa.Text(), nullable=False),
|
||||
sa.Column("enable_video_loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("mediakit_task_id", sa.String(200), nullable=False, server_default="", index=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("error_code", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
# 复合索引:用户 + 状态(列表查询常用)
|
||||
op.create_index("ix_lipsync_jobs_user_status", "lipsync_jobs", ["user_id", "status"])
|
||||
# 项目 + 用户(项目维度查询)
|
||||
op.create_index("ix_lipsync_jobs_project_user", "lipsync_jobs", ["project_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_lipsync_jobs_project_user", table_name="lipsync_jobs")
|
||||
op.drop_index("ix_lipsync_jobs_user_status", table_name="lipsync_jobs")
|
||||
op.drop_table("lipsync_jobs")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add ai avatar render jobs table
|
||||
|
||||
Revision ID: 072_add_ai_avatar_render
|
||||
Revises: 071_add_lipsync_jobs
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "072_add_ai_avatar_render"
|
||||
down_revision = "071_add_lipsync_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ai_avatar_render_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("lipsync_job_id", sa.String(36), nullable=False),
|
||||
sa.Column("script_id", sa.String(36), nullable=False),
|
||||
sa.Column("b_roll_segments", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("cover_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("progress", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_cover_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_ai_avatar_render_user_status", "ai_avatar_render_jobs", ["user_id", "status"])
|
||||
op.create_index("ix_ai_avatar_render_project_user", "ai_avatar_render_jobs", ["project_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ai_avatar_render_project_user", table_name="ai_avatar_render_jobs")
|
||||
op.drop_index("ix_ai_avatar_render_user_status", table_name="ai_avatar_render_jobs")
|
||||
op.drop_table("ai_avatar_render_jobs")
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
@@ -15,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"],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,197 @@
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整.
|
||||
|
||||
接口:
|
||||
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_cosyvoice_service, get_db_session, get_voice_clone_profile_repository
|
||||
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
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(
|
||||
db: Session = Depends(get_db_session),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> LipsyncService:
|
||||
return LipsyncService(db, cosyvoice_service=cosyvoice_service)
|
||||
|
||||
|
||||
def _resolve_voice_id(
|
||||
raw_voice_id: str,
|
||||
user_id: str,
|
||||
voice_clone_repo,
|
||||
) -> str:
|
||||
"""解析 voice_id:支持预设音色 ID 或克隆音色 profile UUID.
|
||||
|
||||
与 TTS 路由保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id。
|
||||
"""
|
||||
try:
|
||||
profile = voice_clone_repo.get(raw_voice_id)
|
||||
except Exception as exc:
|
||||
logger.error("查询克隆音色失败: voice_id=%s, error=%s", raw_voice_id, exc)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"voice_id 无效: {raw_voice_id}",
|
||||
) from exc
|
||||
if profile is not None:
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该音色")
|
||||
if not profile.voice_id:
|
||||
raise HTTPException(status_code=400, detail="音色克隆尚未完成,请稍后再试")
|
||||
return profile.voice_id
|
||||
return raw_voice_id
|
||||
|
||||
|
||||
# ── 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),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
):
|
||||
"""提交对口型任务.
|
||||
|
||||
#1809: 前端传 {voice_id, script_text, video_url},
|
||||
后端内部调 TTS 合成音频,再提交 MediaKit。
|
||||
"""
|
||||
# 解析 voice_id(支持克隆音色 profile UUID)
|
||||
actual_voice_id = _resolve_voice_id(body.voice_id, current_user.id, voice_clone_repo)
|
||||
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=current_user.id,
|
||||
video_url=body.video_url,
|
||||
voice_id=actual_voice_id,
|
||||
script_text=body.script_text,
|
||||
enable_video_loop=body.enable_video_loop,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
# 参数无效(如 voice_id 格式不对、文本过长等)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except CosyVoiceError as exc:
|
||||
# TTS 合成基础设施失败(API/网络/认证)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"code": "TTSSynthesisFailed", "message": str(exc)},
|
||||
) from exc
|
||||
except MediaKitError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": str(exc),
|
||||
"request_id": exc.request_id,
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
# 兜底:任何未预期的错误返回 400 而非 500
|
||||
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"创建对口型任务失败: {exc}",
|
||||
) 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
|
||||
@@ -0,0 +1,111 @@
|
||||
"""AI数字人渲染合成管线 API Schema — #1798."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class BRollSegment(BaseModel):
|
||||
"""B-roll 片段配置."""
|
||||
|
||||
script_segment_index: int = Field(..., ge=0, description="对应文案片段索引")
|
||||
asset_url: str = Field(..., description="B-roll 素材 URL")
|
||||
mode: str = Field(..., description="插入模式: fullscreen 或 pip")
|
||||
start_time: float = Field(..., ge=0.0, description="在对口型视频中的起始时间(秒)")
|
||||
end_time: float = Field(..., ge=0.0, description="在对口型视频中的结束时间(秒)")
|
||||
pip_position: Optional[str] = Field("bottom_right", description="pip 模式位置")
|
||||
pip_scale: Optional[float] = Field(0.3, ge=0.05, le=1.0, description="pip 模式缩放比例")
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if v not in ("fullscreen", "pip"):
|
||||
raise ValueError("mode 必须为 fullscreen 或 pip")
|
||||
return v
|
||||
|
||||
@field_validator("asset_url")
|
||||
@classmethod
|
||||
def validate_asset_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("asset_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("asset_url 必须是 HTTP/HTTPS URL")
|
||||
return v
|
||||
|
||||
@field_validator("end_time")
|
||||
@classmethod
|
||||
def validate_end_time(cls, v: float, info: Any) -> float:
|
||||
start = info.data.get("start_time", 0.0)
|
||||
if v <= start:
|
||||
raise ValueError("end_time 必须大于 start_time")
|
||||
return v
|
||||
|
||||
|
||||
class CreateAiAvatarRenderRequest(BaseModel):
|
||||
"""创建渲染任务请求."""
|
||||
|
||||
lipsync_job_id: str = Field(..., description="对口型任务 ID")
|
||||
script_id: str = Field(..., description="文案 ID")
|
||||
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
|
||||
title_config: dict[str, Any] = Field(default_factory=dict, description="标题配置")
|
||||
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
|
||||
project_id: str = Field("", description="项目 ID")
|
||||
|
||||
@field_validator("lipsync_job_id")
|
||||
@classmethod
|
||||
def validate_lipsync_job_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("lipsync_job_id 不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("script_id")
|
||||
@classmethod
|
||||
def validate_script_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("script_id 不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class AiAvatarRenderJobResponse(BaseModel):
|
||||
"""渲染任务响应."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
project_id: str
|
||||
lipsync_job_id: str
|
||||
script_id: str
|
||||
b_roll_segments: list[dict[str, Any]]
|
||||
title_config: dict[str, Any]
|
||||
cover_config: dict[str, Any]
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
submitted_at: Optional[datetime] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AiAvatarRenderProgressResponse(BaseModel):
|
||||
"""渲染进度响应."""
|
||||
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
@@ -0,0 +1,77 @@
|
||||
"""对口型 API Schema 定义 — #1796, #1809 参数调整."""
|
||||
|
||||
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):
|
||||
"""创建对口型任务请求 — #1809.
|
||||
|
||||
前端传 {voice_id, script_text, video_url},
|
||||
后端内部调 TTS 生成 audio_url 再提交 MediaKit。
|
||||
"""
|
||||
|
||||
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
|
||||
voice_id: str = Field(..., description="音色 ID(预设音色或克隆音色 profile ID)")
|
||||
script_text: str = Field(..., description="要合成的脚本文本")
|
||||
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")
|
||||
lower = v.lower().split("?")[0]
|
||||
if not lower.endswith(".mp4"):
|
||||
raise ValueError("video_url 仅支持 MP4 格式")
|
||||
return v
|
||||
|
||||
@field_validator("voice_id")
|
||||
@classmethod
|
||||
def validate_voice_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("voice_id 不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("script_text")
|
||||
@classmethod
|
||||
def validate_script_text(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("script_text 不能为空")
|
||||
if len(v) > 5000:
|
||||
raise ValueError("script_text 最长 5000 字符")
|
||||
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
|
||||
@@ -0,0 +1,228 @@
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
|
||||
|
||||
职责:
|
||||
- 创建/查询/取消对口型任务
|
||||
- 调用 TTS 合成音频(#1809:前端不再传 audio_url)
|
||||
- 调用 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
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LipsyncService:
|
||||
"""对口型任务 Service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
client: Optional[MediaKitClient] = None,
|
||||
cosyvoice_service: Optional[CosyVoiceService] = None,
|
||||
):
|
||||
self.db = db
|
||||
self.client = client or get_mediakit_client()
|
||||
self._cosyvoice_service = cosyvoice_service
|
||||
|
||||
@property
|
||||
def cosyvoice_service(self) -> CosyVoiceService:
|
||||
if self._cosyvoice_service is None:
|
||||
from app.dependencies import get_cosyvoice_service
|
||||
|
||||
self._cosyvoice_service = get_cosyvoice_service()
|
||||
return self._cosyvoice_service
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
video_url: str,
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
enable_video_loop: bool = False,
|
||||
project_id: str = "",
|
||||
) -> LipsyncJobModel:
|
||||
"""创建对口型任务并提交到 MediaKit.
|
||||
|
||||
#1809: 内部调 TTS 合成音频,不再由前端传 audio_url。
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: TTS 合成失败
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
# 1. 调 TTS 合成音频
|
||||
try:
|
||||
tts_result = self.cosyvoice_service.synthesize_speech(
|
||||
text=script_text,
|
||||
voice_id=voice_id,
|
||||
)
|
||||
audio_url = tts_result.audio_url
|
||||
except CosyVoiceError as exc:
|
||||
logger.error("TTS 合成失败: voice_id=%s, error=%s", voice_id, exc)
|
||||
# 创建失败记录
|
||||
job_id = str(uuid.uuid4())
|
||||
job = LipsyncJobModel(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
video_url=video_url,
|
||||
audio_url="",
|
||||
enable_video_loop=enable_video_loop,
|
||||
status="failed",
|
||||
error_message=f"TTS 合成失败: {exc}",
|
||||
error_code="TTSSynthesisFailed",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
raise
|
||||
|
||||
# 2. 创建数据库记录
|
||||
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()
|
||||
|
||||
# 3. 提交到 MediaKit
|
||||
try:
|
||||
result = self.client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
client_token=job_id, # 幂等控制
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
logger.error("提交对口型任务失败: %s", exc)
|
||||
raise
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""获取任务详情(用户隔离)."""
|
||||
return (
|
||||
self.db.query(LipsyncJobModel)
|
||||
.filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_jobs(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[LipsyncJobModel], int]:
|
||||
"""获取任务列表(分页 + 用户隔离)."""
|
||||
query = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.user_id == user_id)
|
||||
if project_id:
|
||||
query = query.filter(LipsyncJobModel.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(LipsyncJobModel.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(LipsyncJobModel.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── 更新任务状态(轮询) ──────────────────────────────────────────────
|
||||
|
||||
def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""从 MediaKit 拉取最新状态并更新本地记录.
|
||||
|
||||
Returns:
|
||||
更新后的 Job,或 None(任务不存在/不属于该用户)
|
||||
"""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
# 终态不需要再轮询
|
||||
if job.status in (STATUS_COMPLETED, "failed"):
|
||||
return job
|
||||
|
||||
# 未提交的任务不轮询
|
||||
if not job.mediakit_task_id:
|
||||
return job
|
||||
|
||||
try:
|
||||
status_data = self.client.get_task_status(job.mediakit_task_id)
|
||||
except MediaKitError as exc:
|
||||
logger.error("轮询对口型任务状态失败 [%s]: %s", job_id, exc)
|
||||
return job
|
||||
|
||||
mk_status = status_data.get("status", STATUS_RUNNING)
|
||||
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = result.get("video_url", "")
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
# running 状态只更新时间戳
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""取消任务(仅 pending/submitted 状态可取消)."""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
|
||||
return job
|
||||
@@ -0,0 +1,243 @@
|
||||
"""MediaKit 客户端 — 封装火山引擎 AI MediaKit 对口型 API.
|
||||
|
||||
接口文档:https://docs.volcengine.com/docs/6448/2656064
|
||||
|
||||
异步任务流程:
|
||||
1. POST /api/v1/tools/lip-sync 提交对口型任务 → 返回 task_id
|
||||
2. GET /api/v1/tasks/{task_id} 轮询任务状态 → running/completed/failed
|
||||
3. completed 时 result.video_url 为口型对齐视频(临时链接 24h 有效)
|
||||
|
||||
设计原则:
|
||||
- API Key 从配置读取(settings.mediakit_api_key)
|
||||
- 未配置 API Key 时所有方法返回降级响应,不阻塞主流程
|
||||
- HTTP 超时/网络异常统一包装为 MediaKitError
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.config import get_api_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 任务状态常量 ──────────────────────────────────────────────────────────
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
|
||||
class MediaKitError(Exception):
|
||||
"""MediaKit API 调用异常."""
|
||||
|
||||
def __init__(self, message: str, code: str = "", request_id: str = ""):
|
||||
self.code = code
|
||||
self.request_id = request_id
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MediaKitClient:
|
||||
"""火山引擎 AI MediaKit 对口型 API 客户端.
|
||||
|
||||
用法:
|
||||
client = get_mediakit_client()
|
||||
result = client.submit_lipsync(video_url="...", audio_url="...")
|
||||
task_id = result["task_id"]
|
||||
|
||||
status = client.get_task_status(task_id)
|
||||
# {"status": "completed", "result": {"video_url": "...", "duration": 60.5}}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_api_settings()
|
||||
self._api_key = settings.mediakit_api_key
|
||||
self._base_url = settings.mediakit_base_url.rstrip("/")
|
||||
self._timeout = settings.mediakit_timeout
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否已配置 API Key(未配置时自动降级)."""
|
||||
return bool(self._api_key)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── 提交对口型任务 ────────────────────────────────────────────────────
|
||||
|
||||
def submit_lipsync(
|
||||
self,
|
||||
*,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = False,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_args: Optional[str] = None,
|
||||
client_token: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""提交视频口型对齐任务.
|
||||
|
||||
Args:
|
||||
video_url: 人物视频 URL(MP4,≤30min,单人真人)
|
||||
audio_url: 驱动音频 URL(mp3/aac/wav/m4a/flac)
|
||||
enable_video_loop: 音频长于视频时是否循环画面
|
||||
callback_url: 任务完成回调 URL
|
||||
callback_args: 回调时原样返回的自定义参数
|
||||
client_token: 幂等控制 token
|
||||
|
||||
Returns:
|
||||
{"success": True, "task_id": "...", "request_id": "..."}
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"video_url": video_url,
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
if enable_video_loop:
|
||||
payload["enable_video_loop"] = True
|
||||
if callback_url:
|
||||
payload["callback_url"] = callback_url
|
||||
if callback_args:
|
||||
payload["callback_args"] = callback_args[:512] # API 限制 512 字节
|
||||
if client_token:
|
||||
payload["client_token"] = client_token[:64] # API 限制 64 字符
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(
|
||||
f"{self._base_url}/tools/lip-sync",
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text[:500]
|
||||
raise MediaKitError(
|
||||
f"MediaKit API HTTP {exc.response.status_code}: {body}",
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
|
||||
except Exception as exc:
|
||||
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
|
||||
|
||||
if not data.get("success"):
|
||||
error = data.get("error", {})
|
||||
raise MediaKitError(
|
||||
error.get("message", "提交任务失败"),
|
||||
code=error.get("code", "SubmitFailed"),
|
||||
request_id=data.get("request_id", ""),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": data["task_id"],
|
||||
"request_id": data.get("request_id", ""),
|
||||
}
|
||||
|
||||
# ── 查询任务状态 ──────────────────────────────────────────────────────
|
||||
|
||||
def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
"""查询异步任务状态和结果.
|
||||
|
||||
Args:
|
||||
task_id: 提交任务时返回的任务 ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True,
|
||||
"task_id": "...",
|
||||
"status": "running" | "completed" | "failed",
|
||||
"result": {"video_url": "...", "duration": 60.5} | None,
|
||||
"error": {"code": "...", "message": "..."} | None,
|
||||
"created_at": 1777291767,
|
||||
"finished_at": 1777291851 | None,
|
||||
"expires_at": 1777464650 | None,
|
||||
}
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.get(
|
||||
f"{self._base_url}/tasks/{task_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text[:500]
|
||||
raise MediaKitError(
|
||||
f"MediaKit API HTTP {exc.response.status_code}: {body}",
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
|
||||
except Exception as exc:
|
||||
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
|
||||
|
||||
if not data.get("success"):
|
||||
error = data.get("error", {})
|
||||
raise MediaKitError(
|
||||
error.get("message", "查询任务失败"),
|
||||
code=error.get("code", "QueryFailed"),
|
||||
request_id=data.get("request_id", ""),
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"task_id": data.get("task_id", task_id),
|
||||
"status": data.get("status", STATUS_RUNNING),
|
||||
"result": data.get("result"),
|
||||
"created_at": data.get("created_at"),
|
||||
"finished_at": data.get("finished_at"),
|
||||
"expires_at": data.get("expires_at"),
|
||||
}
|
||||
|
||||
# 失败时提取错误信息
|
||||
if data.get("status") == STATUS_FAILED:
|
||||
error_obj = data.get("error", {})
|
||||
result["error"] = {
|
||||
"code": error_obj.get("code", "TaskFailed"),
|
||||
"message": error_obj.get("message", "任务执行失败"),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 单例 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_client: Optional[MediaKitClient] = None
|
||||
|
||||
|
||||
def get_mediakit_client() -> MediaKitClient:
|
||||
"""获取 MediaKit 客户端单例."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = MediaKitClient()
|
||||
return _client
|
||||
|
||||
|
||||
def reset_mediakit_client() -> None:
|
||||
"""重置客户端(测试用)."""
|
||||
global _client
|
||||
_client = None
|
||||
@@ -0,0 +1 @@
|
||||
"""Celery 异步任务模块."""
|
||||
@@ -0,0 +1,48 @@
|
||||
"""AI数字人渲染 Celery 异步任务 — #1798."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="ai_avatar_render.execute", max_retries=2)
|
||||
def execute_ai_avatar_render(self, job_id: str) -> dict:
|
||||
"""执行 AI 数字人渲染管线.
|
||||
|
||||
进度更新:
|
||||
- 0%: 任务开始
|
||||
- 20%: 下载对口型视频完成
|
||||
- 40%: 滤镜链构建完成
|
||||
- 80%: FFmpeg 渲染完成
|
||||
- 95%: 上传 OSS 完成
|
||||
- 100%: 任务完成
|
||||
"""
|
||||
logger.info("开始执行渲染任务: %s", job_id)
|
||||
self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id})
|
||||
|
||||
try:
|
||||
# 获取数据库 session
|
||||
db_gen = get_db_session()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
service = AiAvatarRenderService(db)
|
||||
service.execute_render(job_id)
|
||||
finally:
|
||||
try:
|
||||
next(db_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
return {"status": "completed", "job_id": job_id}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("渲染任务执行异常 [%s]: %s", job_id, exc)
|
||||
self.update_state(state="FAILED", meta={"progress": 0, "error": str(exc)})
|
||||
raise
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./scripts"
|
||||
export * from "./types"
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 文案库 API
|
||||
* 对接后端 /api/v1/scripts(CRUD + 列表解包)
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
ScriptItem,
|
||||
ScriptListResponse,
|
||||
CreateScriptRequest,
|
||||
UpdateScriptRequest,
|
||||
} from "./types"
|
||||
|
||||
/** 获取文案列表 — 必须解包 items(后端返回 {items,total})*/
|
||||
export const getScripts = async (): Promise<ScriptItem[]> => {
|
||||
const response = await apiClient.get<ScriptListResponse | ScriptItem[]>("/scripts")
|
||||
const data = response.data as unknown
|
||||
if (Array.isArray(data)) return data
|
||||
const items = (data as { items?: ScriptItem[] })?.items
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
/** 新建文案 */
|
||||
export const createScript = async (data: CreateScriptRequest): Promise<ScriptItem> => {
|
||||
const response = await apiClient.post<ScriptItem>("/scripts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新文案 */
|
||||
export const updateScript = async (id: string, data: UpdateScriptRequest): Promise<ScriptItem> => {
|
||||
const response = await apiClient.put<ScriptItem>(`/scripts/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除文案 */
|
||||
export const deleteScript = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 文案库 API — 类型定义
|
||||
* 对接后端 /api/v1/scripts
|
||||
*/
|
||||
export interface ScriptItem {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
char_count: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface ScriptListResponse {
|
||||
items: ScriptItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CreateScriptRequest {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type UpdateScriptRequest = Partial<CreateScriptRequest>
|
||||
@@ -5,7 +5,7 @@
|
||||
.xx-app-shell 全屏 flex 容器
|
||||
├── header (xx-top-nav) 顶部导航(Header.tsx 管理)
|
||||
└── .xx-app-body 水平 flex 行
|
||||
├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠)
|
||||
├── .xx-app-sidebar 左侧侧边栏(128px / 64px 折叠)
|
||||
└── .xx-app-content 主内容区(自适应)
|
||||
|
||||
所有尺寸/颜色均使用 global.css 设计系统变量
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
/* ── 侧边栏 ───────────────────────────────────────────────── */
|
||||
.xx-app-sidebar {
|
||||
width: 240px;
|
||||
width: 128px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -103,6 +103,12 @@
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 展开态(侧边栏 128px)水平 padding 收窄,为菜单文字留出完整一行空间 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-content {
|
||||
padding-left: var(--space-xs);
|
||||
padding-right: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ── 主内容区 ─────────────────────────────────────────────── */
|
||||
.xx-app-content {
|
||||
flex: 1;
|
||||
@@ -136,7 +142,7 @@
|
||||
|
||||
/* 展开态恢复完整宽度 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) {
|
||||
width: 240px;
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle {
|
||||
@@ -159,7 +165,7 @@
|
||||
top: 56px; /* 移动端 Header 高度 */
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
width: 128px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-slow);
|
||||
box-shadow: none;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MainLayout - 主布局组件(Task 1.2)
|
||||
*
|
||||
* 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区
|
||||
* - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 侧边栏:128px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 顶部导航:复用 Header 组件(68px 固定高度)
|
||||
* - 主内容区:自适应填充剩余空间
|
||||
* - 响应式:移动端(<768px)隐藏侧边栏
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
/* 分组标题 */
|
||||
.xx-sidebar-group-title {
|
||||
padding: var(--space-sm) var(--space-md) var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
@@ -56,9 +56,9 @@
|
||||
.xx-sidebar-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin: 0 var(--space-xs);
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-xs);
|
||||
margin: 0 var(--space-xxs);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
@@ -97,12 +97,12 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
@@ -119,7 +119,9 @@
|
||||
/* ── 菜单项文字 ───────────────────────────────────────────── */
|
||||
.xx-sidebar-menu-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -133,8 +135,16 @@
|
||||
/* 折叠时菜单项居中,仅图标 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-item {
|
||||
justify-content: center;
|
||||
padding: var(--space-sm);
|
||||
margin: 0 var(--space-xxs);
|
||||
padding: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠态图标恢复更大尺寸居中 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 折叠时隐藏分组标题 */
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
|
||||
/** 导航项类型 */
|
||||
@@ -57,6 +58,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "scripts",
|
||||
label: "文案库",
|
||||
path: "/app/scripts",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
@@ -88,6 +95,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 +144,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: "剪辑模板",
|
||||
@@ -160,6 +179,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "scripts",
|
||||
label: "文案库",
|
||||
path: "/app/scripts",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.admin-coming-soon-page {
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* AI数字人 — 主页面(v3)
|
||||
* 5列水平面板布局
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
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,
|
||||
getAssetById,
|
||||
createLipsyncJob,
|
||||
getLipsyncJob,
|
||||
submitRender,
|
||||
} from "./api/aiAvatar"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
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 lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const togglePanel = useCallback((key: PanelKey) => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
/* ── 对口型 ── */
|
||||
const handleGenerateLipsync = useCallback(async () => {
|
||||
// ② 缺项明确提示(#1809):不再静默 return
|
||||
const video = state.selectedVideo
|
||||
const voice = state.selectedVoice
|
||||
const text = state.scriptText.trim()
|
||||
const missing: string[] = []
|
||||
if (!video) missing.push("出镜视频")
|
||||
if (!voice) missing.push("音色")
|
||||
if (!text) missing.push("文案")
|
||||
if (missing.length > 0 || !video || !voice) {
|
||||
message.warning(`请先选择${missing.join("、")}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
// ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url)
|
||||
const asset = await getAssetById(video.id)
|
||||
const videoUrl = asset?.file_url
|
||||
if (!videoUrl) {
|
||||
message.error("获取出镜视频播放地址失败,请重新选择素材")
|
||||
return
|
||||
}
|
||||
// ② voice_id(预设/克隆 UUID 均由后端内部调 TTS)+ script_text + video_url
|
||||
const job = await createLipsyncJob({
|
||||
voice_id: voice.voice_id,
|
||||
script_text: state.scriptText,
|
||||
video_url: videoUrl,
|
||||
})
|
||||
state.setLipsyncJob(job)
|
||||
message.success("对口型任务已提交,生成中…")
|
||||
// 开始轮询
|
||||
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)
|
||||
if (updated.status === "completed") {
|
||||
message.success("对口型视频生成完成")
|
||||
} else {
|
||||
message.error(updated.error_message || "对口型生成失败")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误(轮询期间不打扰用户)
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
// ② 接口失败弹错误提示,不只 console
|
||||
console.error("对口型任务创建失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const handleGenerate = useCallback(async () => {
|
||||
// ② 前置条件提示(#1809)
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
|
||||
message.warning("请先生成对口型视频,待对口型完成后再提交渲染")
|
||||
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,
|
||||
})
|
||||
message.success("渲染任务已提交,可在视频管理中查看进度")
|
||||
} catch (err) {
|
||||
console.error("渲染任务提交失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试")
|
||||
} 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}
|
||||
scriptText={state.scriptText}
|
||||
outputDuration={state.lipsyncJob?.output_duration ?? 0}
|
||||
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((items) => setScripts(Array.isArray(items) ? items : []))
|
||||
.catch(() => setScripts([]))
|
||||
.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,73 @@
|
||||
/**
|
||||
* 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<{ items?: Script[] } | Script[]>("/scripts")
|
||||
// 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复)
|
||||
const data = response.data as unknown
|
||||
if (Array.isArray(data)) return data
|
||||
const items = (data as { items?: Script[] })?.items
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
/* ── 素材单查(用于拿到 file_url 传给对口型等新接口) ── */
|
||||
export const getAssetById = async (id: string): Promise<{ file_url?: string; id: string }> => {
|
||||
const response = await apiClient.get<{ file_url?: string; id: string }>(`/assets/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 对口型 ── */
|
||||
export const createLipsyncJob = async (data: {
|
||||
voice_id: string
|
||||
script_text: string
|
||||
video_url: 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,209 @@
|
||||
/**
|
||||
* AI数字人 — 出镜视频选择弹窗(#1809 ③)
|
||||
* 交互对齐智能剪辑 Step2:先选素材库(video 库)→ 再选该库内视频。
|
||||
* 搜索框 + 素材库下拉 + 竖屏 9:16 视频缩略图网格 + 底部确认选择。
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
|
||||
export interface ModalAssetPickerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (asset: AssetItem) => void
|
||||
/** 已选中的素材 ID(用于高亮) */
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalAssetPickerProps) {
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [pickedId, setPickedId] = useState<string | null>(null)
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
/* 弹窗打开:重置状态 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setKeyword("")
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setAssets([])
|
||||
setError("")
|
||||
setPickedId(selectedId ?? null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* 第一步:加载视频素材库列表(仅 kind=video,对齐智能剪辑 #1777) */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
setLoadingLibs(true)
|
||||
getAssetLibraries("video")
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(libs) ? libs : []
|
||||
setLibraries(list)
|
||||
// 默认选中第一个视频库
|
||||
if (list.length > 0) setLibraryId((prev) => prev || list[0].id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("素材库加载失败,请重试")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLibs(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 第二步:选中库后拉取该库视频素材(关键字防抖) */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
const load = async () => {
|
||||
try {
|
||||
// getAssets 返回 { items, total };拉满一页(数字人口播视频库通常不大)
|
||||
const { items } = await getAssets(libraryId, { page_size: 100 })
|
||||
if (cancelled) return
|
||||
let list = Array.isArray(items) ? items : []
|
||||
// 仅保留视频素材(出镜视频要求)
|
||||
list = list.filter((a) => a.mime_type?.includes("video"))
|
||||
const kw = keyword.trim()
|
||||
if (kw) list = list.filter((a) => a.name?.includes(kw))
|
||||
setAssets(list)
|
||||
setError("")
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("素材加载失败,请重试")
|
||||
setAssets([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(load, 300)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [open, libraryId, 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">
|
||||
<select
|
||||
className="aa-select"
|
||||
style={{ width: 160, flex: "0 0 auto" }}
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
|
||||
) : (
|
||||
libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
📁 {lib.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="text"
|
||||
placeholder="搜索素材名称…"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{libraries.length === 0 && !loadingLibs ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无视频素材库,请先在「素材库」中创建视频库并上传视频
|
||||
</div>
|
||||
) : loadingAssets ? (
|
||||
<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,421 @@
|
||||
/**
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗(#1809 ④⑤⑥)
|
||||
*
|
||||
* 布局:
|
||||
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
|
||||
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
|
||||
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
|
||||
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算)
|
||||
* - 底部:已配置的画面插入列表(可删除)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
|
||||
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
|
||||
|
||||
interface ModalBRollEditorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前已有的 B-roll segments(用于标灰已选素材) */
|
||||
existingSegments: BRollSegment[]
|
||||
/** 当前文案全文(用于分句) */
|
||||
scriptText: string
|
||||
/** 对口型成片总时长(秒),用于时间自动估算 */
|
||||
outputDuration: number
|
||||
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,
|
||||
scriptText,
|
||||
outputDuration,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}) => {
|
||||
/* ── 素材库(④ 先选库再选素材) ── */
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [availableAssets, setAvailableAssets] = useState<AssetItem[]>([])
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [assetError, setAssetError] = useState("")
|
||||
|
||||
/* ── 右侧设置本地状态 ── */
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetItem | null>(null)
|
||||
const [selectedSentence, setSelectedSentence] = useState<ScriptSentence | null>(null)
|
||||
const [mode, setMode] = useState<BRollInsertMode>("fullscreen")
|
||||
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
|
||||
const [pipScale, setPipScale] = useState(0.3)
|
||||
|
||||
/** 文案分句(⑤) */
|
||||
const sentences = useMemo(
|
||||
() => splitScriptIntoSentences(scriptText, outputDuration),
|
||||
[scriptText, outputDuration],
|
||||
)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
const usedAssetIds = useMemo(
|
||||
() => new Set(existingSegments.map((seg) => seg.asset.id)),
|
||||
[existingSegments],
|
||||
)
|
||||
|
||||
/* 弹窗打开:重置选择 + 加载视频库列表 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
setMode("fullscreen")
|
||||
setPipPosition("top-right")
|
||||
setPipScale(0.3)
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setAvailableAssets([])
|
||||
setAssetError("")
|
||||
setLoadingLibs(true)
|
||||
let cancelled = false
|
||||
getAssetLibraries("video")
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(libs) ? libs : []
|
||||
setLibraries(list)
|
||||
if (list.length > 0) setLibraryId(list[0].id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAssetError("素材库加载失败,请重试")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLibs(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 选中库后拉取该库视频素材 */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
getAssets(libraryId, { page_size: 100 })
|
||||
.then(({ items }) => {
|
||||
if (cancelled) return
|
||||
const list = (Array.isArray(items) ? items : []).filter((a) =>
|
||||
a.mime_type?.includes("video"),
|
||||
)
|
||||
setAvailableAssets(list)
|
||||
setAssetError("")
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAssetError("素材加载失败,请重试")
|
||||
setAvailableAssets([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, libraryId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
/** 选择素材(已选素材因 pointer-events:none 不会触发) */
|
||||
const handleSelectAsset = (asset: AssetItem) => {
|
||||
if (usedAssetIds.has(asset.id)) return
|
||||
setSelectedAsset(asset)
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止) */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset || !selectedSentence) return
|
||||
const startTime = selectedSentence.startTime
|
||||
const endTime = Math.max(selectedSentence.endTime, startTime + 0.5)
|
||||
const segment: BRollSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
asset: selectedAsset,
|
||||
script_segment_index: selectedSentence.index,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
mode,
|
||||
pip_position: pipPosition,
|
||||
pip_scale: mode === "pip" ? pipScale : 0.3,
|
||||
}
|
||||
onConfirm(segment)
|
||||
// 重置素材/句子选择,保留模式设置便于连续添加
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
}
|
||||
|
||||
const canConfirm = selectedAsset !== null && selectedSentence !== null
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal aa-modal--wide" 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 className="aa-broll-lib-row">
|
||||
<select
|
||||
className="aa-select"
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
|
||||
) : (
|
||||
libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
📁 {lib.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</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 className="aa-broll-asset-placeholder">🎬</div>
|
||||
)}
|
||||
<span className="aa-asset-card__name">{asset.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{loadingAssets ? (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : availableAssets.length === 0 ? (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
{assetError || "该素材库暂无视频素材"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:插入设置 */}
|
||||
<div className="aa-broll-right">
|
||||
<div className="aa-broll-settings">
|
||||
{/* ⑤ 文案句子列表(替代段落索引数字框) */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">对应文案句子(点选)</label>
|
||||
{sentences.length === 0 ? (
|
||||
<div className="aa-sentence-empty">
|
||||
请先在「文案 & 对口型」面板填写或选择文案
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-sentence-list">
|
||||
{sentences.map((sent) => {
|
||||
const active = selectedSentence?.index === sent.index
|
||||
return (
|
||||
<button
|
||||
key={sent.index}
|
||||
type="button"
|
||||
className={`aa-sentence-item${active ? " active" : ""}`}
|
||||
onClick={() => setSelectedSentence(sent)}
|
||||
title={sent.text}
|
||||
>
|
||||
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
|
||||
<span className="aa-sentence-item__text">{sent.text}</span>
|
||||
{outputDuration > 0 && (
|
||||
<span className="aa-sentence-item__time">
|
||||
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<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-broll-hint">
|
||||
{selectedAsset && selectedSentence ? (
|
||||
<>
|
||||
<div>已选素材:{selectedAsset.name}</div>
|
||||
<div>
|
||||
对应第 {selectedSentence.index + 1} 句 · 时间段{" "}
|
||||
{selectedSentence.startTime.toFixed(1)}s -{" "}
|
||||
{Math.max(
|
||||
selectedSentence.endTime,
|
||||
selectedSentence.startTime + 0.5,
|
||||
).toFixed(1)}
|
||||
s (按字数自动估算)
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8c8ca1" }}>
|
||||
{!selectedAsset ? "请从左侧选择一段素材" : "请在上方点选对应的文案句子"}
|
||||
</div>
|
||||
)}
|
||||
</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 + 1} 句 · {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,329 @@
|
||||
/**
|
||||
* AI数字人 — 配音库面板(面板3)
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import { fetchVoices } from "@/api/voices/voices"
|
||||
import { previewTts } from "@/api/tts"
|
||||
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)
|
||||
/** 克隆音色试听合成缓存:voiceId -> url,对齐配音库 useAudioPlayer */
|
||||
const previewCacheRef = useRef<Map<string, string>>(new Map())
|
||||
const VOICE_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的配音效果,希望你喜欢。"
|
||||
|
||||
/* 切换来源时重新获取音色列表 */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadVoices = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchVoices({ type: voiceSource })
|
||||
if (!cancelled) setVoices(Array.isArray(res?.items) ? 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 NO_PREVIEW_TIP = "该音色暂无试听音频,请先用此音色生成一段配音后再试听"
|
||||
|
||||
/** 用指定 URL 真实播放(抽取公共) */
|
||||
const playAudioUrl = (voiceId: string, url: string) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
setPreviewingId(voiceId)
|
||||
audio.onended = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
}
|
||||
}
|
||||
audio.onerror = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
message.error("试听音频加载失败")
|
||||
}
|
||||
}
|
||||
void audio.play().catch(() => {
|
||||
setPreviewingId(null)
|
||||
message.error("试听播放失败")
|
||||
})
|
||||
}
|
||||
|
||||
const handlePreview = async (voice: UnifiedVoiceItem) => {
|
||||
/* 再次点击当前试听音色 → 停止 */
|
||||
if (previewingId === voice.id) {
|
||||
stopPreview()
|
||||
return
|
||||
}
|
||||
|
||||
/* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview
|
||||
* 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */
|
||||
if (voice.type === "clone") {
|
||||
const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id)
|
||||
if (cached) {
|
||||
playAudioUrl(voice.id, cached)
|
||||
return
|
||||
}
|
||||
const targetId = voice.voice_clone_profile_id || voice.id
|
||||
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
|
||||
console.log("[AI数字人-克隆试听] previewTts 请求:", {
|
||||
voice_id: targetId,
|
||||
voice_name: voice.name,
|
||||
voice_type: voice.type,
|
||||
voice_clone_profile_id: voice.voice_clone_profile_id,
|
||||
voice_id_field: voice.voice_id,
|
||||
})
|
||||
setPreviewingId(voice.id)
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: VOICE_PREVIEW_TEXT,
|
||||
voice_id: targetId,
|
||||
speed: 1.0,
|
||||
})
|
||||
console.log("[AI数字人-克隆试听] previewTts 响应:", {
|
||||
audio_url: res.audio_url?.substring(0, 80),
|
||||
duration: res.duration,
|
||||
})
|
||||
if (!res.audio_url) {
|
||||
setPreviewingId(null)
|
||||
message.error("合成试听失败:未返回音频")
|
||||
return
|
||||
}
|
||||
previewCacheRef.current.set(targetId, res.audio_url)
|
||||
playAudioUrl(voice.id, res.audio_url)
|
||||
} catch (err) {
|
||||
setPreviewingId(null)
|
||||
// DEBUG: 打印详细错误信息
|
||||
console.error("[AI数字人-克隆试听] previewTts 失败:", {
|
||||
status: (err as { response?: { status?: number } })?.response?.status,
|
||||
data: (err as { response?: { data?: unknown } })?.response?.data,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
// apiClient 拦截器已统一 toast
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
/* 系统预设音色:沿用 preview_url/audio_url 直链播放 */
|
||||
const url = voice.preview_url || voice.audio_url
|
||||
if (!url) {
|
||||
message.warning(NO_PREVIEW_TIP)
|
||||
return
|
||||
}
|
||||
playAudioUrl(voice.id, url)
|
||||
}
|
||||
|
||||
const handleSpeedChange = (value: string) => {
|
||||
const parsed = parseFloat(value)
|
||||
if (Number.isNaN(parsed)) return
|
||||
const clamped = Math.min(2.0, Math.max(0.5, parsed))
|
||||
onSpeedChange(clamped)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-voice-selector">
|
||||
{/* 音色来源切换 */}
|
||||
<div className="aa-voice-source-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-voice-source-btn${voiceSource === "preset" ? " active" : ""}`}
|
||||
onClick={() => onVoiceSourceChange("preset")}
|
||||
>
|
||||
系统预设
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-voice-source-btn${voiceSource === "clone" ? " active" : ""}`}
|
||||
onClick={() => onVoiceSourceChange("clone")}
|
||||
>
|
||||
我的音色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 音色列表 */}
|
||||
{loading ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
<div>音色加载中…</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⚠️</div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">🎙️</div>
|
||||
<div>{voiceSource === "clone" ? "还没有克隆音色" : "暂无预置音色"}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-voice-list">
|
||||
{voices.map((voice) => {
|
||||
const selected = selectedVoice?.id === voice.id
|
||||
const previewUrl = voice.preview_url || voice.audio_url
|
||||
return (
|
||||
<div
|
||||
key={voice.id}
|
||||
className={`aa-voice-card${selected ? " selected" : ""}`}
|
||||
onClick={() => onSelectVoice(voice)}
|
||||
>
|
||||
<span className="aa-voice-card__radio" />
|
||||
<div className="aa-voice-card__info">
|
||||
<div className="aa-voice-card__name">{voice.name}</div>
|
||||
{voice.description && (
|
||||
<div className="aa-voice-card__desc">{voice.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-voice-card__preview"
|
||||
title={previewingId === voice.id ? "停止试听" : "试听"}
|
||||
disabled={!previewUrl}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreview(voice)
|
||||
}}
|
||||
>
|
||||
{previewingId === voice.id ? "⏸" : "▶"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 我的音色:克隆入口 */}
|
||||
{voiceSource === "clone" && (
|
||||
<div className="aa-clone-entry">
|
||||
<a href="/app/voice-clone">+ 克隆新音色</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配音参数 */}
|
||||
<div className="aa-voice-params">
|
||||
<div className="aa-voice-params__row">
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-emotion">
|
||||
情绪
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-emotion"
|
||||
className="aa-select"
|
||||
value={emotion}
|
||||
onChange={(e) => onEmotionChange(e.target.value as VoiceEmotion)}
|
||||
>
|
||||
{VOICE_EMOTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-language">
|
||||
语言
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-language"
|
||||
className="aa-select"
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value as VoiceLanguage)}
|
||||
>
|
||||
{VOICE_LANGUAGE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-speed">
|
||||
语速({speed.toFixed(1)}x)
|
||||
</label>
|
||||
<input
|
||||
id="aa-voice-speed"
|
||||
type="number"
|
||||
className="aa-input"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={speed}
|
||||
onChange={(e) => handleSpeedChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelVoiceSelector
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* AI数字人 — 页面全局状态管理 hook(v3)
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
type Script,
|
||||
type LipsyncJob,
|
||||
type BRollSegment,
|
||||
type AiAvatarTitleConfig,
|
||||
type AiAvatarCoverConfig,
|
||||
DEFAULT_TITLE_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
|
||||
export function useAiAvatar() {
|
||||
/* ── 面板1:出镜视频 ── */
|
||||
const [selectedVideo, setSelectedVideo] = useState<AssetItem | null>(null)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
|
||||
/* ── 面板2:配音库 ── */
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
|
||||
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("natural")
|
||||
const [speed, setSpeed] = useState(1.0)
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("mandarin")
|
||||
|
||||
/* ── 面板3:文案 & 对口型 ── */
|
||||
const [script, setScript] = useState<Script | null>(null)
|
||||
const [scriptText, setScriptText] = useState("")
|
||||
const [lipsyncJob, setLipsyncJob] = useState<LipsyncJob | null>(null)
|
||||
const [showScriptModal, setShowScriptModal] = useState(false)
|
||||
const [showBRollModal, setShowBRollModal] = useState(false)
|
||||
|
||||
/* ── 面板3.5:B-roll ── */
|
||||
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
|
||||
|
||||
/* ── 面板4:标题配置 ── */
|
||||
const [titleConfig, setTitleConfig] = useState<AiAvatarTitleConfig>(DEFAULT_TITLE_CONFIG)
|
||||
|
||||
/* ── 面板5:封面 & 生成 ── */
|
||||
const [coverConfig, setCoverConfig] = useState<AiAvatarCoverConfig>(DEFAULT_COVER_CONFIG)
|
||||
const [resolution, setResolution] = useState("1080p")
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
/* ── Actions ── */
|
||||
const selectVideo = useCallback((asset: AssetItem) => {
|
||||
setSelectedVideo(asset)
|
||||
setShowAssetPicker(false)
|
||||
}, [])
|
||||
|
||||
const removeVideo = useCallback(() => {
|
||||
setSelectedVideo(null)
|
||||
}, [])
|
||||
|
||||
const selectScript = useCallback((s: Script) => {
|
||||
setScript(s)
|
||||
setScriptText(s.content)
|
||||
setShowScriptModal(false)
|
||||
}, [])
|
||||
|
||||
const addBRollSegment = useCallback((segment: BRollSegment) => {
|
||||
setBRollSegments((prev) => [...prev, segment])
|
||||
}, [])
|
||||
|
||||
const removeBRollSegment = useCallback((id: string) => {
|
||||
setBRollSegments((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
const updateTitleConfig = useCallback((partial: Partial<AiAvatarTitleConfig>) => {
|
||||
setTitleConfig((prev) => ({ ...prev, ...partial }))
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelectedVideo(null)
|
||||
setSelectedVoice(null)
|
||||
setScript(null)
|
||||
setScriptText("")
|
||||
setLipsyncJob(null)
|
||||
setBRollSegments([])
|
||||
setTitleConfig(DEFAULT_TITLE_CONFIG)
|
||||
setCoverConfig(DEFAULT_COVER_CONFIG)
|
||||
setResolution("1080p")
|
||||
setIsGenerating(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 面板1
|
||||
selectedVideo,
|
||||
showAssetPicker,
|
||||
setShowAssetPicker,
|
||||
selectVideo,
|
||||
removeVideo,
|
||||
// 面板2
|
||||
voiceSource,
|
||||
setVoiceSource,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
emotion,
|
||||
setEmotion,
|
||||
speed,
|
||||
setSpeed,
|
||||
language,
|
||||
setLanguage,
|
||||
// 面板3
|
||||
script,
|
||||
setScript,
|
||||
scriptText,
|
||||
setScriptText,
|
||||
lipsyncJob,
|
||||
setLipsyncJob,
|
||||
showScriptModal,
|
||||
setShowScriptModal,
|
||||
showBRollModal,
|
||||
setShowBRollModal,
|
||||
selectScript,
|
||||
// B-roll
|
||||
bRollSegments,
|
||||
addBRollSegment,
|
||||
removeBRollSegment,
|
||||
// 面板4
|
||||
titleConfig,
|
||||
updateTitleConfig,
|
||||
setTitleConfig,
|
||||
// 面板5
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
resolution,
|
||||
setResolution,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
// 全局
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseAiAvatarReturn = ReturnType<typeof useAiAvatar>
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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
|
||||
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
|
||||
output_duration?: number
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
|
||||
*/
|
||||
|
||||
export interface ScriptSentence {
|
||||
/** 句子序号(从 0 开始,对应提交给后端的 script_segment_index) */
|
||||
index: number
|
||||
/** 句子文本(去掉首尾空白) */
|
||||
text: string
|
||||
/** 句子字数(按中文/字符计,去除空白) */
|
||||
charCount: number
|
||||
/** 累计起始字数(用于时间估算) */
|
||||
startChar: number
|
||||
/** 估算的对口型视频内起始时间(秒) */
|
||||
startTime: number
|
||||
/** 估算的对口型视频内结束时间(秒) */
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
|
||||
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
|
||||
*/
|
||||
export function splitScriptIntoSentences(
|
||||
scriptText: string,
|
||||
outputDuration: number,
|
||||
): ScriptSentence[] {
|
||||
const text = (scriptText || "").trim()
|
||||
if (!text) return []
|
||||
|
||||
const rawParts = text
|
||||
.split(/[。!?!?;;\n\r]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
|
||||
const totalChars = rawParts.reduce((sum, part) => sum + part.replace(/\s/g, "").length, 0)
|
||||
const duration = outputDuration > 0 ? outputDuration : 0
|
||||
|
||||
const sentences: ScriptSentence[] = []
|
||||
let accChar = 0
|
||||
rawParts.forEach((part, i) => {
|
||||
const charCount = part.replace(/\s/g, "").length
|
||||
const startTime = duration > 0 && totalChars > 0 ? (accChar / totalChars) * duration : 0
|
||||
const endTime =
|
||||
duration > 0 && totalChars > 0 ? ((accChar + charCount) / totalChars) * duration : 0
|
||||
sentences.push({
|
||||
index: i,
|
||||
text: part,
|
||||
charCount,
|
||||
startChar: accChar,
|
||||
startTime: round1(startTime),
|
||||
endTime: round1(endTime),
|
||||
})
|
||||
accChar += charCount
|
||||
})
|
||||
|
||||
return sentences
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
============================================================ */
|
||||
.dup-page {
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
max-width: 1100px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/* ============================================================
|
||||
TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦)
|
||||
|
||||
从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」
|
||||
两个页面共用。AI数字人页面不引入 generate.css,直接由
|
||||
TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、
|
||||
配色描边、选中态与智能剪辑页面完全一致。
|
||||
|
||||
注意:本文件规则与 generate.css 中同名规则一一对应、取值相同;
|
||||
智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。
|
||||
============================================================ */
|
||||
|
||||
/* ── 区块容器 ── */
|
||||
.xx-title-style-section {
|
||||
margin-top: 22px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.xx-section-subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.xx-title-style-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.xx-half-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-field-label-row label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-field-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ── 共用表单字段(位置/字体下拉) ── */
|
||||
.xx-title-style-section .xx-form-field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field select,
|
||||
.xx-title-style-section .xx-form-field input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
outline: 0;
|
||||
transition: 0.15s ease;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field select:focus,
|
||||
.xx-title-style-section .xx-form-field input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
/* ── 字号滑块 ── */
|
||||
.xx-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.xx-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
/* ── 标题预设卡片网格(24 个 T 格子) ── */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 52px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
background: #404040;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-title-preset-card:hover {
|
||||
border-color: #666;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active {
|
||||
border-color: #409eff;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */
|
||||
.xx-style-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-style-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-style-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-style-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
import React from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import TitlePresetsGrid from "./TitlePresetsGrid"
|
||||
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
|
||||
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
|
||||
import "./TitleStylePanel.css"
|
||||
|
||||
interface PositionOption {
|
||||
value: string
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.xx-generate-page {
|
||||
min-height: 100%;
|
||||
padding: var(--space-xl);
|
||||
max-width: 1400px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.mt-page {
|
||||
padding: var(--space-xl);
|
||||
max-width: 1400px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 文案库页面 — Issue #1811
|
||||
* 风格对齐标题库(同类资源管理页面统一风格),单列卡片列表
|
||||
* 功能:列表 / 新建 / 编辑 / 删除 / 按标题搜索 / 空状态
|
||||
* 对接后端 /api/v1/scripts CRUD
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Modal, message, Empty, Button, Input, Popconfirm } from "antd"
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from "@ant-design/icons"
|
||||
import {
|
||||
getScripts,
|
||||
createScript,
|
||||
updateScript,
|
||||
deleteScript,
|
||||
type ScriptItem,
|
||||
} from "@/api/scripts"
|
||||
import "./scripts.css"
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const ScriptLibrary: React.FC = () => {
|
||||
const [scripts, setScripts] = useState<ScriptItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchText, setSearchText] = useState("")
|
||||
|
||||
// 弹窗状态
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ScriptItem | null>(null)
|
||||
const [formTitle, setFormTitle] = useState("")
|
||||
const [formContent, setFormContent] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await getScripts()
|
||||
setScripts(items)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "加载文案列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const kw = searchText.trim().toLowerCase()
|
||||
if (!kw) return scripts
|
||||
return scripts.filter((s) => s.title.toLowerCase().includes(kw))
|
||||
}, [scripts, searchText])
|
||||
|
||||
const openCreate = () => {
|
||||
setFormTitle("")
|
||||
setFormContent("")
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (item: ScriptItem) => {
|
||||
setEditing(item)
|
||||
setFormTitle(item.title)
|
||||
setFormContent(item.content)
|
||||
}
|
||||
|
||||
const handleCloseCreate = () => {
|
||||
setCreateOpen(false)
|
||||
setFormTitle("")
|
||||
setFormContent("")
|
||||
}
|
||||
|
||||
const handleCloseEdit = () => {
|
||||
setEditing(null)
|
||||
setFormTitle("")
|
||||
setFormContent("")
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const title = formTitle.trim()
|
||||
const content = formContent.trim()
|
||||
if (!title || !content) {
|
||||
message.warning("请填写标题和正文")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createScript({ title, content })
|
||||
message.success("文案已创建")
|
||||
handleCloseCreate()
|
||||
await load()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "创建文案失败")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editing) return
|
||||
const title = formTitle.trim()
|
||||
const content = formContent.trim()
|
||||
if (!title || !content) {
|
||||
message.warning("请填写标题和正文")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateScript(editing.id, { title, content })
|
||||
message.success("文案已更新")
|
||||
handleCloseEdit()
|
||||
await load()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "更新文案失败")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteScript(id)
|
||||
message.success("文案已删除")
|
||||
await load()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "删除文案失败")
|
||||
}
|
||||
}
|
||||
|
||||
const preview = (content: string) => {
|
||||
const text = content.replace(/\s+/g, " ").trim()
|
||||
return text.length > 120 ? `${text.slice(0, 120)}…` : text || "(空)"
|
||||
}
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-scripts-page">
|
||||
<div className="xx-scripts-layout">
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="xx-scripts-filters">
|
||||
<div className="xx-scripts-filters-left">
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="按标题搜索"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-scripts-filters-right">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建文案
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表 / 空状态 */}
|
||||
{loading ? (
|
||||
<div className="xx-scripts-loading">加载中…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Empty
|
||||
description={searchText ? "没有匹配的文案" : "暂无文案,点击右上角「新建文案」开始创作"}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-scripts-list">
|
||||
{filtered.map((s) => (
|
||||
<div key={s.id} className="xx-script-card">
|
||||
<div className="xx-script-card-header">
|
||||
<div className="xx-script-title">{s.title}</div>
|
||||
<div className="xx-script-actions">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(s)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除此文案?"
|
||||
description="删除后不可恢复"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-script-preview">{preview(s.content)}</div>
|
||||
<div className="xx-script-meta">
|
||||
<span>{s.char_count ?? s.content.length} 字</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(s.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建弹窗 */}
|
||||
<Modal
|
||||
title="新建文案"
|
||||
open={createOpen}
|
||||
onCancel={handleCloseCreate}
|
||||
onOk={handleCreate}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-script-form">
|
||||
<Input
|
||||
placeholder="标题"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="正文"
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={8}
|
||||
maxLength={5000}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<Modal
|
||||
title="编辑文案"
|
||||
open={!!editing}
|
||||
onCancel={handleCloseEdit}
|
||||
onOk={handleUpdate}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-script-form">
|
||||
<Input
|
||||
placeholder="标题"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="正文"
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={8}
|
||||
maxLength={5000}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScriptLibrary
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 文案库页面 - V21 设计系统样式
|
||||
* 单列卡片列表,风格对齐标题库(xx-titles-page)
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* ============================================================
|
||||
页面容器
|
||||
============================================================ */
|
||||
.xx-scripts-page {
|
||||
min-height: 100%;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.xx-scripts-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
顶部筛选栏
|
||||
============================================================ */
|
||||
.xx-scripts-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xx-scripts-filters-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-scripts-filters-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
列表
|
||||
============================================================ */
|
||||
.xx-scripts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-scripts-loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-xl);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
文案卡片(对齐标题卡片风格,单列)
|
||||
============================================================ */
|
||||
.xx-script-card {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
transition: var(--transition-all);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.xx-script-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-script-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-script-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-script-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xxs);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: var(--transition-opacity, opacity 0.2s);
|
||||
}
|
||||
|
||||
.xx-script-card:hover .xx-script-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-script-preview {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.xx-script-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
弹窗表单
|
||||
============================================================ */
|
||||
.xx-script-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-script-form textarea.ant-input {
|
||||
resize: vertical;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.task-center {
|
||||
padding: var(--space-lg);
|
||||
max-width: 1400px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ const appChildren: RouteObject[] = [
|
||||
path: "titles",
|
||||
lazy: lazyRoute(() => import("@/pages/titles/TitleLibrary")),
|
||||
},
|
||||
{
|
||||
path: "scripts",
|
||||
lazy: lazyRoute(() => import("@/pages/scripts/ScriptLibrary")),
|
||||
},
|
||||
{
|
||||
path: "voices",
|
||||
lazy: lazyRoute(() => import("@/pages/voices/VoiceLibrary")),
|
||||
@@ -56,6 +60,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")),
|
||||
|
||||
@@ -130,7 +130,7 @@ services:
|
||||
# 健康检查配置
|
||||
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f 'celery.*worker' | head -n1 >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1"]
|
||||
test: ["CMD-SHELL", "for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -155,7 +155,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"pgrep -f 'celery.*worker' >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -116,7 +116,7 @@ docker run -d \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--health-cmd "sh -c \"pgrep -f 'celery.*worker' >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -209,7 +209,7 @@ rollback() {
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
@@ -408,7 +408,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -310,7 +310,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
"""AI数字人渲染 API 路由测试 — #1798.
|
||||
|
||||
至少 10 个测试覆盖路由层逻辑。
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
|
||||
|
||||
|
||||
def _make_mock_user(user_id="user-1"):
|
||||
"""创建 mock 认证用户."""
|
||||
user = MagicMock()
|
||||
user.id = user_id
|
||||
return user
|
||||
|
||||
|
||||
def _make_mock_render_job(
|
||||
job_id="render-1",
|
||||
user_id="user-1",
|
||||
status="pending",
|
||||
progress=0,
|
||||
output_video_url="",
|
||||
output_cover_url="",
|
||||
output_duration=0.0,
|
||||
error_message="",
|
||||
):
|
||||
"""创建 mock 渲染任务."""
|
||||
m = MagicMock()
|
||||
m.id = job_id
|
||||
m.user_id = user_id
|
||||
m.project_id = ""
|
||||
m.lipsync_job_id = "lipsync-1"
|
||||
m.script_id = "script-1"
|
||||
m.b_roll_segments = []
|
||||
m.title_config = {}
|
||||
m.cover_config = {}
|
||||
m.status = status
|
||||
m.progress = progress
|
||||
m.output_video_url = output_video_url
|
||||
m.output_cover_url = output_cover_url
|
||||
m.output_duration = output_duration
|
||||
m.error_message = error_message
|
||||
m.submitted_at = None
|
||||
m.started_at = None
|
||||
m.completed_at = None
|
||||
m.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
m.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
return m
|
||||
|
||||
|
||||
class TestRenderRoutes:
|
||||
"""路由层测试(通过 mock service 测试路由逻辑)."""
|
||||
|
||||
def _get_client(self):
|
||||
"""获取测试客户端."""
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
def test_create_render_job_success(self):
|
||||
from app.api.routes.ai_avatar_render import router
|
||||
from app.schemas.ai_avatar_render import AiAvatarRenderJobResponse
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job()
|
||||
mock_service.create_render_job.return_value = mock_job
|
||||
|
||||
# 直接测试路由函数
|
||||
from app.api.routes.ai_avatar_render import create_render_job
|
||||
|
||||
mock_user = _make_mock_user()
|
||||
body = MagicMock()
|
||||
body.lipsync_job_id = "lipsync-1"
|
||||
body.script_id = "script-1"
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
result = create_render_job(
|
||||
body=body,
|
||||
current_user=mock_user,
|
||||
svc=mock_service,
|
||||
)
|
||||
assert result.id == "render-1"
|
||||
mock_service.create_render_job.assert_called_once()
|
||||
|
||||
def test_create_render_job_lipsync_not_found(self):
|
||||
from app.api.routes.ai_avatar_render import create_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.create_render_job.side_effect = AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
|
||||
|
||||
mock_user = _make_mock_user()
|
||||
body = MagicMock()
|
||||
body.lipsync_job_id = "nonexistent"
|
||||
body.script_id = "script-1"
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_render_job(body=body, current_user=mock_user, svc=mock_service)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
def test_create_render_job_lipsync_not_completed(self):
|
||||
from app.api.routes.ai_avatar_render import create_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.create_render_job.side_effect = AiAvatarRenderError(
|
||||
"对口型任务状态为 processing", code="LipsyncJobNotCompleted"
|
||||
)
|
||||
|
||||
mock_user = _make_mock_user()
|
||||
body = MagicMock()
|
||||
body.lipsync_job_id = "lipsync-1"
|
||||
body.script_id = "script-1"
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_render_job(body=body, current_user=mock_user, svc=mock_service)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_get_render_job_success(self):
|
||||
from app.api.routes.ai_avatar_render import get_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job()
|
||||
mock_service.get_render_job.return_value = mock_job
|
||||
|
||||
result = get_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert result.id == "render-1"
|
||||
|
||||
def test_get_render_job_not_found(self):
|
||||
from app.api.routes.ai_avatar_render import get_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.get_render_job.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
def test_list_render_jobs(self):
|
||||
from app.api.routes.ai_avatar_render import list_render_jobs
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
|
||||
mock_service.list_render_jobs.return_value = (mock_jobs, 3)
|
||||
|
||||
result = list_render_jobs(
|
||||
project_id="",
|
||||
status="",
|
||||
offset=0,
|
||||
limit=20,
|
||||
current_user=_make_mock_user(),
|
||||
svc=mock_service,
|
||||
)
|
||||
assert result["total"] == 3
|
||||
assert len(result["items"]) == 3
|
||||
|
||||
def test_cancel_render_job_success(self):
|
||||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job(status="cancelled")
|
||||
mock_service.cancel_render_job.return_value = mock_job
|
||||
|
||||
result = cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert result.status == "cancelled"
|
||||
|
||||
def test_cancel_render_job_not_found(self):
|
||||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.cancel_render_job.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
cancel_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
def test_cancel_render_job_not_cancellable(self):
|
||||
from app.api.routes.ai_avatar_render import cancel_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job(status="completed")
|
||||
mock_service.cancel_render_job.return_value = mock_job
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_retry_render_job_success(self):
|
||||
from app.api.routes.ai_avatar_render import retry_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job(status="pending")
|
||||
mock_service.retry_render_job.return_value = mock_job
|
||||
|
||||
result = retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert result.status == "pending"
|
||||
|
||||
def test_retry_render_job_not_failed(self):
|
||||
from app.api.routes.ai_avatar_render import retry_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_job = _make_mock_render_job(status="completed")
|
||||
mock_service.retry_render_job.return_value = mock_job
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_retry_render_job_not_found(self):
|
||||
from app.api.routes.ai_avatar_render import retry_render_job
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock(spec=AiAvatarRenderService)
|
||||
mock_service.retry_render_job.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
retry_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
class TestBrollOverlayFilter:
|
||||
"""FFmpeg B-roll 滤镜构建测试."""
|
||||
|
||||
def test_empty_segments_returns_empty(self):
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
result = build_broll_overlay_filter([], 30.0)
|
||||
assert result == ""
|
||||
|
||||
def test_pip_mode_generates_overlay(self):
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
segments = [
|
||||
{
|
||||
"script_segment_index": 0,
|
||||
"asset_url": "https://example.com/broll.mp4",
|
||||
"mode": "pip",
|
||||
"start_time": 5.0,
|
||||
"end_time": 10.0,
|
||||
"pip_position": "bottom_right",
|
||||
"pip_scale": 0.3,
|
||||
}
|
||||
]
|
||||
result = build_broll_overlay_filter(segments, 30.0)
|
||||
assert "overlay" in result or "scale=" in result
|
||||
|
||||
def test_fullscreen_mode_generates_concat(self):
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
segments = [
|
||||
{
|
||||
"script_segment_index": 0,
|
||||
"asset_url": "https://example.com/broll.mp4",
|
||||
"mode": "fullscreen",
|
||||
"start_time": 5.0,
|
||||
"end_time": 10.0,
|
||||
}
|
||||
]
|
||||
result = build_broll_overlay_filter(segments, 30.0)
|
||||
assert "trim" in result or "concat" in result
|
||||
|
||||
def test_cover_extract_command(self):
|
||||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||||
|
||||
cmd = build_cover_extract_command({"timestamp": 5.0}, "/tmp/cover.jpg")
|
||||
assert "ffmpeg" in cmd
|
||||
assert "5.0" in cmd
|
||||
assert "/tmp/cover.jpg" in cmd
|
||||
|
||||
def test_cover_extract_empty_config(self):
|
||||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||||
|
||||
cmd = build_cover_extract_command({}, "/tmp/cover.jpg")
|
||||
assert "ffmpeg" in cmd
|
||||
|
||||
def test_cover_extract_with_size(self):
|
||||
from packages.domain.video_filter_builder import build_cover_extract_command
|
||||
|
||||
cmd = build_cover_extract_command(
|
||||
{"timestamp": 3.0, "width": 1280, "height": 720},
|
||||
"/tmp/cover.jpg",
|
||||
)
|
||||
assert "scale=" in cmd
|
||||
@@ -0,0 +1,517 @@
|
||||
"""AI数字人渲染 Service 单元测试 — #1798.
|
||||
|
||||
至少 15 个测试覆盖 Service 层核心逻辑。
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
|
||||
|
||||
|
||||
def _make_mock_db():
|
||||
"""创建 mock 数据库 session."""
|
||||
mock_db = MagicMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.flush = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
return mock_db
|
||||
|
||||
|
||||
def _make_mock_render_job(
|
||||
job_id="render-1",
|
||||
user_id="user-1",
|
||||
status="pending",
|
||||
progress=0,
|
||||
output_video_url="",
|
||||
output_cover_url="",
|
||||
output_duration=0.0,
|
||||
error_message="",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
):
|
||||
"""创建 mock 渲染任务."""
|
||||
m = MagicMock()
|
||||
m.id = job_id
|
||||
m.user_id = user_id
|
||||
m.project_id = ""
|
||||
m.lipsync_job_id = lipsync_job_id
|
||||
m.script_id = script_id
|
||||
m.b_roll_segments = []
|
||||
m.title_config = {}
|
||||
m.cover_config = {}
|
||||
m.status = status
|
||||
m.progress = progress
|
||||
m.output_video_url = output_video_url
|
||||
m.output_cover_url = output_cover_url
|
||||
m.output_duration = output_duration
|
||||
m.error_message = error_message
|
||||
m.submitted_at = None
|
||||
m.started_at = None
|
||||
m.completed_at = None
|
||||
m.created_at = None
|
||||
m.updated_at = None
|
||||
return m
|
||||
|
||||
|
||||
def _make_mock_lipsync_job(
|
||||
job_id="lipsync-1",
|
||||
user_id="user-1",
|
||||
status="completed",
|
||||
output_video_url="https://output.mp4",
|
||||
output_duration=30.0,
|
||||
):
|
||||
"""创建 mock 对口型任务."""
|
||||
m = MagicMock()
|
||||
m.id = job_id
|
||||
m.user_id = user_id
|
||||
m.status = status
|
||||
m.output_video_url = output_video_url
|
||||
m.output_duration = output_duration
|
||||
return m
|
||||
|
||||
|
||||
def _make_mock_script(script_id="script-1", user_id="user-1"):
|
||||
"""创建 mock 文案."""
|
||||
m = MagicMock()
|
||||
m.id = script_id
|
||||
m.user_id = user_id
|
||||
m.title = "测试文案"
|
||||
return m
|
||||
|
||||
|
||||
class TestSchemaValidation:
|
||||
"""Schema 验证测试."""
|
||||
|
||||
def test_valid_broll_segment(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
seg = BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="https://example.com/broll.mp4",
|
||||
mode="fullscreen",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
assert seg.mode == "fullscreen"
|
||||
assert seg.start_time == 5.0
|
||||
|
||||
def test_invalid_mode(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
with pytest.raises(ValueError, match="fullscreen 或 pip"):
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="https://example.com/broll.mp4",
|
||||
mode="invalid",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
|
||||
def test_end_time_must_exceed_start_time(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
with pytest.raises(ValueError, match="end_time 必须大于 start_time"):
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="https://example.com/broll.mp4",
|
||||
mode="fullscreen",
|
||||
start_time=10.0,
|
||||
end_time=5.0,
|
||||
)
|
||||
|
||||
def test_asset_url_must_be_http(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
with pytest.raises(ValueError, match="HTTP"):
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="ftp://example.com/broll.mp4",
|
||||
mode="fullscreen",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
|
||||
def test_asset_url_empty(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment
|
||||
|
||||
with pytest.raises(ValueError, match="不能为空"):
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url=" ",
|
||||
mode="fullscreen",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
|
||||
def test_create_request_valid(self):
|
||||
from app.schemas.ai_avatar_render import BRollSegment, CreateAiAvatarRenderRequest
|
||||
|
||||
req = CreateAiAvatarRenderRequest(
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[
|
||||
BRollSegment(
|
||||
script_segment_index=0,
|
||||
asset_url="https://example.com/broll.mp4",
|
||||
mode="pip",
|
||||
start_time=5.0,
|
||||
end_time=10.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
assert req.lipsync_job_id == "lipsync-1"
|
||||
assert len(req.b_roll_segments) == 1
|
||||
|
||||
def test_create_request_empty_lipsync_job_id(self):
|
||||
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
|
||||
|
||||
with pytest.raises(ValueError, match="lipsync_job_id 不能为空"):
|
||||
CreateAiAvatarRenderRequest(
|
||||
lipsync_job_id=" ",
|
||||
script_id="script-1",
|
||||
)
|
||||
|
||||
def test_create_request_empty_script_id(self):
|
||||
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
|
||||
|
||||
with pytest.raises(ValueError, match="script_id 不能为空"):
|
||||
CreateAiAvatarRenderRequest(
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id=" ",
|
||||
)
|
||||
|
||||
|
||||
class TestAiAvatarRenderService:
|
||||
"""Service 层单元测试(纯 mock,不依赖数据库)."""
|
||||
|
||||
def test_create_job_success(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
# 模拟 query 链式调用
|
||||
mock_query = MagicMock()
|
||||
|
||||
# 第一次 query: LipsyncJobModel
|
||||
mock_lipsync_filter = MagicMock()
|
||||
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
|
||||
mock_lipsync_query = MagicMock()
|
||||
mock_lipsync_query.filter.return_value = mock_lipsync_filter
|
||||
|
||||
# 第二次 query: ScriptModel
|
||||
mock_script_filter = MagicMock()
|
||||
mock_script_filter.first.return_value = _make_mock_script()
|
||||
mock_script_query = MagicMock()
|
||||
mock_script_query.filter.return_value = mock_script_filter
|
||||
|
||||
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
job = svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
assert job.status == "pending"
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_job_lipsync_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError, match="对口型任务不存在"):
|
||||
svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="nonexistent",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
|
||||
def test_create_job_lipsync_not_completed(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_lipsync_job = _make_mock_lipsync_job(status="processing")
|
||||
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_lipsync_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError, match="仅 completed 状态可渲染"):
|
||||
svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
|
||||
def test_create_job_lipsync_no_output(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_video_url="")
|
||||
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_lipsync_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError, match="输出视频 URL 为空"):
|
||||
svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="script-1",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
|
||||
def test_create_job_script_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_lipsync_query = MagicMock()
|
||||
mock_lipsync_filter = MagicMock()
|
||||
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
|
||||
mock_lipsync_query.filter.return_value = mock_lipsync_filter
|
||||
|
||||
mock_script_query = MagicMock()
|
||||
mock_script_filter = MagicMock()
|
||||
mock_script_filter.first.return_value = None
|
||||
mock_script_query.filter.return_value = mock_script_filter
|
||||
|
||||
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError, match="文案不存在或无权访问"):
|
||||
svc.create_render_job(
|
||||
user_id="user-1",
|
||||
lipsync_job_id="lipsync-1",
|
||||
script_id="nonexistent",
|
||||
b_roll_segments=[],
|
||||
title_config={},
|
||||
cover_config={},
|
||||
)
|
||||
|
||||
def test_get_render_job_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.get_render_job("render-1", "user-1")
|
||||
assert result is mock_job
|
||||
|
||||
def test_get_render_job_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.get_render_job("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_list_render_jobs(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 3
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = mock_jobs
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
items, total = svc.list_render_jobs(user_id="user-1")
|
||||
assert total == 3
|
||||
assert len(items) == 3
|
||||
|
||||
def test_list_render_jobs_with_project_filter(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 1
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = [_make_mock_render_job()]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
items, total = svc.list_render_jobs(user_id="user-1", project_id="proj-1")
|
||||
assert total == 1
|
||||
# filter should be called for user_id and project_id
|
||||
assert mock_query.filter.call_count >= 2
|
||||
|
||||
def test_cancel_render_job_success(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="pending")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.cancel_render_job("render-1", "user-1")
|
||||
assert result is mock_job
|
||||
assert mock_job.status == "cancelled"
|
||||
|
||||
def test_cancel_render_job_not_pending(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="completed")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.cancel_render_job("render-1", "user-1")
|
||||
# 非 pending 状态不可取消,状态不变
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_cancel_render_job_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.cancel_render_job("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_retry_render_job_success(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="failed", error_message="渲染失败")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.retry_render_job("render-1", "user-1")
|
||||
assert result.status == "pending"
|
||||
assert result.progress == 0
|
||||
assert result.error_message == ""
|
||||
|
||||
def test_retry_render_job_not_failed(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="completed")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.retry_render_job("render-1", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_retry_render_job_not_found(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
result = svc.retry_render_job("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_execute_render_job_not_found(self):
|
||||
"""execute_render 在任务不存在时应静默返回."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
# 不应抛异常
|
||||
svc.execute_render("nonexistent")
|
||||
|
||||
def test_execute_render_cancelled_job(self):
|
||||
"""execute_render 在任务已取消时应静默返回."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_job = _make_mock_render_job(status="cancelled")
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
svc.execute_render("render-1")
|
||||
# 不应执行渲染逻辑
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
def test_error_exception_has_code(self):
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError
|
||||
|
||||
err = AiAvatarRenderError("测试错误", code="TestCode")
|
||||
assert err.code == "TestCode"
|
||||
assert str(err) == "测试错误"
|
||||
@@ -0,0 +1,476 @@
|
||||
"""对口型 API 路由 + Service 单元测试 — #1796, #1809 参数调整.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cosyvoice():
|
||||
"""Mock CosyVoice 服务."""
|
||||
service = MagicMock()
|
||||
service.synthesize_speech.return_value = MagicMock(
|
||||
audio_url="https://oss.example.com/tts-output.mp3",
|
||||
duration=15.0,
|
||||
file_size=12345,
|
||||
request_id="tts-req-789",
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
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://oss.example.com/tts-output.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 验证测试 — #1809 新参数结构."""
|
||||
|
||||
def test_valid_request(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
req = CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="大家好,欢迎来到直播间",
|
||||
)
|
||||
assert req.video_url == "https://example.com/video.mp4"
|
||||
assert req.voice_id == "longxiaochun_v3"
|
||||
assert req.script_text == "大家好,欢迎来到直播间"
|
||||
|
||||
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",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_invalid_video_url_empty(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
with pytest.raises(ValueError, match="不能为空"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url=" ",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
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",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_empty_voice_id_rejected(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
with pytest.raises(ValueError, match="voice_id"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id=" ",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_empty_script_text_rejected(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
with pytest.raises(ValueError, match="script_text"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="",
|
||||
)
|
||||
|
||||
def test_script_text_too_long(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
with pytest.raises(ValueError, match="5000"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="x" * 5001,
|
||||
)
|
||||
|
||||
def test_enable_video_loop_default(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
req = CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
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",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
assert "?token=" in req.video_url
|
||||
|
||||
def test_no_audio_url_in_request(self):
|
||||
"""#1809: 请求体不应包含 audio_url 字段."""
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
req = CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
assert not hasattr(req, "audio_url")
|
||||
fields = req.model_fields.keys()
|
||||
assert "audio_url" not in fields
|
||||
assert "voice_id" in fields
|
||||
assert "script_text" in fields
|
||||
|
||||
|
||||
class TestLipsyncServiceUnit:
|
||||
"""Service 层单元测试(纯 mock,不依赖数据库)— #1809 更新."""
|
||||
|
||||
def test_create_job_success(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.flush = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="大家好,欢迎来到直播间",
|
||||
)
|
||||
|
||||
assert job.status == "submitted"
|
||||
assert job.mediakit_task_id == "mk-task-123"
|
||||
# TTS 应该被调用
|
||||
mock_cosyvoice.synthesize_speech.assert_called_once_with(
|
||||
text="大家好,欢迎来到直播间",
|
||||
voice_id="longxiaochun_v3",
|
||||
)
|
||||
# MediaKit 应该用 TTS 生成的 audio_url
|
||||
mock_mediakit.submit_lipsync.assert_called_once()
|
||||
call_kwargs = mock_mediakit.submit_lipsync.call_args
|
||||
assert call_kwargs.kwargs["audio_url"] == "https://oss.example.com/tts-output.mp3"
|
||||
|
||||
def test_create_job_tts_failure(self, mock_mediakit):
|
||||
"""TTS 合成失败时,应创建 failed 记录并抛出 CosyVoiceError."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
|
||||
mock_cosyvoice = MagicMock()
|
||||
mock_cosyvoice.synthesize_speech.side_effect = CosyVoiceError("TTS 服务不可用")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.flush = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
with pytest.raises(CosyVoiceError, match="TTS 服务不可用"):
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
# 不应提交到 MediaKit
|
||||
mock_mediakit.submit_lipsync.assert_not_called()
|
||||
# 应该记录了失败状态
|
||||
added_job = mock_db.add.call_args[0][0]
|
||||
assert added_job.status == "failed"
|
||||
assert "TTS" in added_job.error_message
|
||||
|
||||
def test_create_job_api_failure(self, mock_mediakit, mock_cosyvoice):
|
||||
"""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, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
with pytest.raises(MediaKitError, match="API 调用失败"):
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_get_job_delegates_to_db(self, mock_mediakit, mock_cosyvoice):
|
||||
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, cosyvoice_service=mock_cosyvoice)
|
||||
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, mock_cosyvoice):
|
||||
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, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.get_job("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_refresh_job_completed(self, mock_mediakit, mock_cosyvoice):
|
||||
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, cosyvoice_service=mock_cosyvoice)
|
||||
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, mock_cosyvoice):
|
||||
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, cosyvoice_service=mock_cosyvoice)
|
||||
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, mock_cosyvoice):
|
||||
"""已完成的任务不轮询."""
|
||||
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, cosyvoice_service=mock_cosyvoice)
|
||||
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, mock_cosyvoice):
|
||||
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, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.cancel_job("job-1", "user-1")
|
||||
|
||||
assert result.status == "cancelled"
|
||||
|
||||
def test_cancel_job_completed_not_allowed(self, mock_mediakit, mock_cosyvoice):
|
||||
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, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.cancel_job("job-1", "user-1")
|
||||
|
||||
# 已完成不可取消
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_create_job_stores_tts_audio_url(self, mock_mediakit, mock_cosyvoice):
|
||||
"""#1809: 验证 job 的 audio_url 来自 TTS 合成结果."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.flush = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="my-clone-voice",
|
||||
script_text="这是一段测试文本",
|
||||
)
|
||||
|
||||
# job.audio_url 应该是 TTS 返回的 URL
|
||||
assert job.audio_url == "https://oss.example.com/tts-output.mp3"
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""#1809 补充:错误返回 400 而非 500."""
|
||||
|
||||
def test_voice_id_resolve_failure_returns_400(self, mock_mediakit, mock_cosyvoice):
|
||||
"""voice_clone_repo 查询异常时返回 400 而非 500."""
|
||||
from app.api.routes.lipsync import _resolve_voice_id
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.side_effect = Exception("DB connection error")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_resolve_voice_id("bad-voice-id", "user-1", mock_repo)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "voice_id" in str(exc_info.value.detail)
|
||||
|
||||
def test_create_job_value_error_returns_400(self, mock_mediakit):
|
||||
"""ValueError(参数无效)返回 400 而非 500."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_cosyvoice = MagicMock()
|
||||
mock_cosyvoice.synthesize_speech.side_effect = ValueError("voice_id 为空")
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
# Service 层会 catch CosyVoiceError 但 ValueError 会穿透
|
||||
# 路由层 catch ValueError → 400
|
||||
with pytest.raises(ValueError):
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="",
|
||||
script_text="test",
|
||||
)
|
||||
|
||||
def test_create_job_unexpected_exception_returns_400(self, mock_mediakit):
|
||||
"""未预期的异常应被路由层捕获返回 400 而非 500."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_cosyvoice = MagicMock()
|
||||
mock_cosyvoice.synthesize_speech.side_effect = RuntimeError("unexpected")
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="test-voice",
|
||||
script_text="test",
|
||||
)
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user