Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb3bc04c83 |
@@ -20,7 +20,6 @@ on:
|
||||
default: "手动触发 - CI漏触发补跑"
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: ci-pipeline-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -89,22 +88,9 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
# 优先用 git diff 判断 PR 改动范围(比 API 稳定)
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
if command -v git >/dev/null 2>&1 && [ -d .git ]; then
|
||||
FILES=$(git diff --name-only origin/develop...HEAD 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "${FILES:-}" ]; then
|
||||
# fallback 到 API
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "${FILES:-}" ]; then
|
||||
echo "⚠️ 无法获取变更文件列表,保守运行完整 CI"
|
||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
@@ -196,7 +182,7 @@ jobs:
|
||||
- name: Run style checks
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_style.sh
|
||||
- name: Auto-fix formatting (black + isort + ruff)
|
||||
- name: Auto-fix formatting (black + isort)
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
@@ -827,6 +813,9 @@ jobs:
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker 与 API/Web 统一走持久 builder(ci-builder-persist),共享宿主机层缓存
|
||||
NO_CACHE_FLAG=""
|
||||
@@ -1023,6 +1012,9 @@ jobs:
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
@@ -1561,6 +1553,9 @@ jobs:
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
|
||||
fi
|
||||
|
||||
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
|
||||
NO_CACHE_FLAG=""
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: "Debug: Web container v2 (mount conflict)"
|
||||
on:
|
||||
push:
|
||||
branches: [debug/web-crash-v2]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
web-diag:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Setup SSH and diagnose
|
||||
shell: bash
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -x
|
||||
which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
H=47.98.113.167; P=22222
|
||||
ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh -p $P -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@$H 'bash -s' <<'REMOTE'
|
||||
set -x
|
||||
echo "=== Current staging containers ==="
|
||||
docker ps -a --filter name=xiaoxia-*-staging --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"
|
||||
echo ""
|
||||
echo "=== Web container logs (current/current-rolledback) ==="
|
||||
docker logs xiaoxia-web-staging 2>&1 | tail -40
|
||||
echo ""
|
||||
echo "=== Web inspect: env & mounts ==="
|
||||
docker inspect xiaoxia-web-staging --format 'Entrypoint: {{.Config.Entrypoint}} Cmd: {{.Config.Cmd}}'
|
||||
docker inspect xiaoxia-web-staging --format '{{range .Config.Env}}{{.}}{{"\n"}}{{end}}' | grep -E "APP_ENV|VERSION"
|
||||
echo "Mounts:"
|
||||
docker inspect xiaoxia-web-staging --format '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} (rw={{.RW}}){{"\n"}}{{end}}'
|
||||
echo ""
|
||||
echo "=== Reproduce: rm on read-only bind mount ==="
|
||||
docker run --rm --name nginx-ro-test \
|
||||
-v /var/lib/xiaoxia-saas-staging/nginx-staging.conf:/etc/nginx/conf.d/default.conf:ro \
|
||||
git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/xiaoxia-saas-web:387514c \
|
||||
sh -c '
|
||||
set -x
|
||||
echo "Before:"
|
||||
ls -la /etc/nginx/conf.d/
|
||||
echo "Try rm (as entrypoint does):"
|
||||
rm -f /etc/nginx/conf.d/default.conf
|
||||
echo "rm exitcode=$?"
|
||||
echo "After rm:"
|
||||
ls -la /etc/nginx/conf.d/
|
||||
echo "Test ln:"
|
||||
ln -s /etc/nginx/nginx-staging.conf /etc/nginx/conf.d/default.conf
|
||||
echo "ln exitcode=$?"
|
||||
ls -la /etc/nginx/conf.d/
|
||||
echo "nginx -t:"
|
||||
nginx -t 2>&1
|
||||
' 2>&1
|
||||
echo ""
|
||||
echo "=== Also test with NEW fixed image (9c0d4b1 if present) ==="
|
||||
docker images | grep xiaoxia-saas-web | head -5
|
||||
REMOTE
|
||||
@@ -1,45 +0,0 @@
|
||||
"""lipsync_jobs 增加 TTS 直生字段(voice_id/script_text/speed/emotion)
|
||||
|
||||
Revision ID: 073_add_lipsync_tts_fields
|
||||
Revises: 072_add_ai_avatar_render
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "073_add_lipsync_tts_fields"
|
||||
down_revision = "072_add_ai_avatar_render"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 对口型支持「传音色 + 文案直接生成」:后端内部先 TTS 合成音频再提交对口型
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("voice_id", sa.String(200), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("script_text", sa.Text(), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("speed", sa.Float(), nullable=False, server_default=sa.text("1.0")),
|
||||
)
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("emotion", sa.String(20), nullable=False, server_default=""),
|
||||
)
|
||||
# audio_url 改为可空:直生模式下音频由后端 TTS 合成后回填
|
||||
op.alter_column("lipsync_jobs", "audio_url", existing_type=sa.Text(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("lipsync_jobs", "audio_url", existing_type=sa.Text(), nullable=False)
|
||||
op.drop_column("lipsync_jobs", "emotion")
|
||||
op.drop_column("lipsync_jobs", "speed")
|
||||
op.drop_column("lipsync_jobs", "script_text")
|
||||
op.drop_column("lipsync_jobs", "voice_id")
|
||||
@@ -1,36 +0,0 @@
|
||||
"""ai_avatar_render_jobs.script_id 放宽为可空串(手动文案直生场景不关联文案库)
|
||||
|
||||
Revision ID: 074_render_script_id_optional
|
||||
Revises: 073_add_lipsync_tts_fields
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "074_render_script_id_optional"
|
||||
down_revision = "073_add_lipsync_tts_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 列保持 NOT NULL(空串占位),仅应用层允许不传;这里显式补 server_default 防止历史约束歧义
|
||||
with op.batch_alter_table("ai_avatar_render_jobs") as batch:
|
||||
batch.alter_column(
|
||||
"script_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("ai_avatar_render_jobs") as batch:
|
||||
batch.alter_column(
|
||||
"script_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=False,
|
||||
server_default=None,
|
||||
)
|
||||
@@ -11,17 +11,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
SmartCoverRequest,
|
||||
SmartCoverResponse,
|
||||
)
|
||||
from app.services.ai_avatar_cover_service import generate_smart_cover
|
||||
from app.services.ai_avatar_render_service import (
|
||||
AiAvatarRenderError,
|
||||
AiAvatarRenderService,
|
||||
@@ -53,7 +49,7 @@ def create_render_job(
|
||||
"""
|
||||
try:
|
||||
job = svc.create_render_job(
|
||||
user_id=current_user.user.id,
|
||||
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],
|
||||
@@ -78,16 +74,10 @@ def create_render_job(
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception as exc:
|
||||
logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
|
||||
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
@@ -104,7 +94,7 @@ def list_render_jobs(
|
||||
):
|
||||
"""获取 AI 数字人渲染任务列表."""
|
||||
items, total = svc.list_render_jobs(
|
||||
user_id=current_user.user.id,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
@@ -128,7 +118,7 @@ def get_render_job(
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""获取渲染任务详情."""
|
||||
job = svc.get_render_job(job_id, current_user.user.id)
|
||||
job = svc.get_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
return job
|
||||
@@ -144,7 +134,7 @@ def cancel_render_job(
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""取消渲染任务(仅 pending 状态可取消)."""
|
||||
job = svc.cancel_render_job(job_id, current_user.user.id)
|
||||
job = svc.cancel_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "cancelled":
|
||||
@@ -165,7 +155,7 @@ def retry_render_job(
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""重试失败的渲染任务."""
|
||||
job = svc.retry_render_job(job_id, current_user.user.id)
|
||||
job = svc.retry_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "pending":
|
||||
@@ -179,57 +169,7 @@ def retry_render_job(
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception as exc:
|
||||
logger.exception("Celery 任务投递失败(重试): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
|
||||
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
|
||||
|
||||
|
||||
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
|
||||
|
||||
|
||||
@router.post("/smart-cover", response_model=SmartCoverResponse)
|
||||
def generate_avatar_smart_cover(
|
||||
body: SmartCoverRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> SmartCoverResponse:
|
||||
"""智能获取数字人视频封面.
|
||||
|
||||
复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧逻辑(非 FFmpeg 简单截帧),
|
||||
并将选中帧转存到自家 OSS,返回非临时的封面公网 URL。
|
||||
|
||||
前端「智能获取封面」按钮可直接调用本接口;不依赖渲染任务完成。
|
||||
"""
|
||||
video_url = (body.video_url or "").strip()
|
||||
if not video_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL")
|
||||
|
||||
try:
|
||||
cover_url = generate_smart_cover(
|
||||
video_url,
|
||||
max_frames=body.max_frames,
|
||||
title_config=getattr(body, "title_config", None),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"智能封面生成异常: user=%s video_url=%s err=%s",
|
||||
current_user.user.id, video_url[:80], exc,
|
||||
exc_info=True,
|
||||
)
|
||||
cover_url = ""
|
||||
|
||||
if not cover_url:
|
||||
return SmartCoverResponse(
|
||||
cover_url="",
|
||||
status="fallback_failed",
|
||||
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
|
||||
)
|
||||
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
|
||||
return SmartCoverResponse(cover_url=cover_url, status="completed")
|
||||
return job
|
||||
|
||||
@@ -177,8 +177,8 @@ def _cleanup_expired_uploads() -> int:
|
||||
meta_file.unlink()
|
||||
cleaned += 1
|
||||
logger.info(f"Cleaned up expired upload: {upload_id}")
|
||||
except Exception:
|
||||
logger.exception("Failed to cleanup upload metadata: %s", meta_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup upload metadata {meta_file}: {e}")
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ def create_variant_plans(
|
||||
if _latest:
|
||||
source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.exception("[variant-plans] 源 plan 解析失败")
|
||||
logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
|
||||
|
||||
if not source_plan_id:
|
||||
raise HTTPException(
|
||||
@@ -122,7 +122,7 @@ def create_variant_plans(
|
||||
|
||||
voice_durations = _query_voice_durations(db, voices)
|
||||
except Exception:
|
||||
logger.exception("[variant-plans] 配音时长查询失败(按占位段长选片)")
|
||||
logger.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
|
||||
voice_durations = [0.0] * request.count
|
||||
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
@@ -143,7 +143,7 @@ def create_variant_plans(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[variant-plans] 选片异常")
|
||||
logger.error("[variant-plans] 选片异常: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e
|
||||
|
||||
# 组装 clips 响应
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整.
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型.
|
||||
|
||||
接口:
|
||||
POST /api/v1/lipsync/jobs 提交对口型任务
|
||||
@@ -13,14 +13,11 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_db_session,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,16 +25,8 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(
|
||||
db: Session = Depends(get_db_session),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> LipsyncService:
|
||||
# voice_clone_repo 用于克隆音色 profile 解析
|
||||
# TTS 合成已移至 Celery 异步任务,无需同步注入 cosyvoice_service
|
||||
return LipsyncService(
|
||||
db,
|
||||
voice_clone_repo=voice_clone_repo,
|
||||
)
|
||||
def _get_service(db: Session = Depends(get_db_session)) -> LipsyncService:
|
||||
return LipsyncService(db)
|
||||
|
||||
|
||||
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
|
||||
@@ -51,47 +40,26 @@ def create_lipsync_job(
|
||||
):
|
||||
"""提交对口型任务.
|
||||
|
||||
#1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?},
|
||||
后端创建任务记录(状态 tts_processing),dispatch Celery 异步任务执行 TTS 合成 + MediaKit 提交;
|
||||
也支持直接传 {video_url, audio_url}(同步提交 MediaKit)。
|
||||
输入人物视频 + 驱动音频,异步生成口型对齐视频。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=current_user.user.id,
|
||||
user_id=current_user.id,
|
||||
video_url=body.video_url,
|
||||
audio_url=body.audio_url,
|
||||
voice_id=body.voice_id,
|
||||
script_text=body.script_text,
|
||||
speed=body.speed,
|
||||
emotion=body.emotion,
|
||||
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 MediaKitError as exc:
|
||||
# 音色无权访问 → 403;参数无效 → 400;MediaKit 提交失败 → 502
|
||||
status_code = 502
|
||||
if exc.code in ("VoiceForbidden",):
|
||||
status_code = 403
|
||||
elif exc.code in ("InvalidInput", "TTSInvalidParam", "VoiceNotReady"):
|
||||
status_code = 400
|
||||
# 创建失败(job 已记录 error),返回 502
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
status_code=502,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": str(exc),
|
||||
"request_id": getattr(exc, "request_id", ""),
|
||||
"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
|
||||
|
||||
@@ -110,7 +78,7 @@ def list_lipsync_jobs(
|
||||
):
|
||||
"""获取对口型任务列表."""
|
||||
items, total = svc.list_jobs(
|
||||
user_id=current_user.user.id,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
@@ -130,37 +98,13 @@ def list_lipsync_jobs(
|
||||
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
|
||||
def get_lipsync_job(
|
||||
job_id: str,
|
||||
background: BackgroundTasks,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务详情.
|
||||
|
||||
非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态),
|
||||
避免 MediaKit 慢响应阻塞前端轮询。
|
||||
"""
|
||||
job = svc.get_job(job_id, current_user.user.id)
|
||||
"""获取对口型任务详情."""
|
||||
job = svc.get_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if job.status not in ("completed", "failed"):
|
||||
# 如果距上次更新超过 30 秒,同步刷新一次(避免 background task 静默失败导致永久卡 running);
|
||||
# 否则挂后台异步刷新(避免阻塞前端轮询)。
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
stale = (
|
||||
job.updated_at is None
|
||||
or (now - job.updated_at).total_seconds() > 30
|
||||
)
|
||||
if stale:
|
||||
try:
|
||||
job = svc.refresh_job_status(job_id, current_user.user.id) or job
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("同步刷新对口型状态失败 job_id=%s err=%s", job_id, exc, exc_info=True)
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
else:
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@@ -174,7 +118,7 @@ def refresh_lipsync_job(
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""从 MediaKit 拉取最新状态并更新."""
|
||||
job = svc.refresh_job_status(job_id, current_user.user.id)
|
||||
job = svc.refresh_job_status(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return job
|
||||
@@ -189,13 +133,13 @@ def cancel_lipsync_job(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""取消对口型任务(仅 pending/tts_processing/submitted 状态可取消)."""
|
||||
job = svc.cancel_job(job_id, current_user.user.id)
|
||||
"""取消对口型任务(仅 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/tts_processing/submitted 可取消",
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending/submitted 可取消",
|
||||
)
|
||||
return job
|
||||
|
||||
@@ -65,8 +65,8 @@ def _build_asset_analyses(
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(aid)
|
||||
except Exception:
|
||||
logger.exception("获取素材URL失败: asset_id=%s", aid)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", aid, str(e))
|
||||
|
||||
if not video_urls:
|
||||
logger.info("无可用视频素材,跳过视频理解分析")
|
||||
@@ -108,7 +108,7 @@ def _build_asset_analyses(
|
||||
return analyses
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 视频理解异常,将降级到无分析模式: %s", e)
|
||||
logger.warning("MediaKit 视频理解异常,将降级到无分析模式: %s", str(e))
|
||||
return {}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ def editor_ai_recommend(
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
logger.exception("db rollback failed in ai_recommend")
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI推荐结果保存失败,请稍后重试",
|
||||
|
||||
@@ -132,8 +132,8 @@ def _build_asset_url_map(
|
||||
result: dict[str, str | None] = {}
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception as e:
|
||||
logger.exception("获取存储服务失败,跳过asset_url生成: %s", e)
|
||||
except Exception:
|
||||
logger.warning("获取存储服务失败,跳过asset_url生成")
|
||||
return {aid: None for aid in asset_ids}
|
||||
|
||||
# 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1)
|
||||
@@ -141,7 +141,7 @@ def _build_asset_url_map(
|
||||
assets = asset_repo.find_by_ids(unique_ids)
|
||||
asset_map = {a.id: a for a in assets}
|
||||
except Exception:
|
||||
logger.exception("批量查询素材失败: asset_ids=%s", asset_ids)
|
||||
logger.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True)
|
||||
return {aid: None for aid in asset_ids if aid}
|
||||
|
||||
for aid in unique_ids:
|
||||
@@ -156,7 +156,7 @@ def _build_asset_url_map(
|
||||
continue
|
||||
result[aid] = storage.get_download_url(storage_key, expires_seconds=3600)
|
||||
except Exception:
|
||||
logger.exception("生成素材签名URL失败: asset_id=%s", aid)
|
||||
logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True)
|
||||
result[aid] = None
|
||||
|
||||
return result
|
||||
@@ -486,8 +486,8 @@ def _get_mediakit_recommendations(
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(asset_id)
|
||||
except Exception:
|
||||
logger.exception("获取素材URL失败: asset_id=%s", asset_id)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
if not video_urls:
|
||||
return {}
|
||||
@@ -563,7 +563,7 @@ def _get_mediakit_recommendations(
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 智能选片异常,降级为随机选择: %s", e)
|
||||
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -860,7 +860,7 @@ def create_clips_from_assets_editor(
|
||||
duplicate_warning = None
|
||||
if dup_rate > 50:
|
||||
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
|
||||
logger.exception(
|
||||
logger.warning(
|
||||
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
|
||||
plan_id,
|
||||
dup_rate,
|
||||
@@ -960,8 +960,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
# 尝试获取存储服务(用于生成视频 URL)
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception as e:
|
||||
logger.exception("后台任务: 获取存储服务失败,跳过 SceneChange 更新: %s", e)
|
||||
except Exception:
|
||||
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
|
||||
return
|
||||
|
||||
# 获取 MediaKit 客户端
|
||||
@@ -987,8 +987,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if storage_key and mime.startswith("video/"):
|
||||
try:
|
||||
video_url = storage.get_download_url(storage_key)
|
||||
except Exception:
|
||||
logger.exception("后台任务: 获取素材URL失败: asset_id=%s", asset_id)
|
||||
except Exception as e:
|
||||
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
# 构建该素材的占用区间列表(排除已更新片段)
|
||||
def _get_other_segments(asset_id_inner, clip_id_inner):
|
||||
@@ -1039,11 +1039,12 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
asset_id,
|
||||
len(scene_changes),
|
||||
)
|
||||
except Exception:
|
||||
except Exception as cache_err:
|
||||
# 缓存写入失败不影响本次片段更新
|
||||
logger.exception(
|
||||
"后台任务: 场景点缓存写入失败: asset_id=%s",
|
||||
logger.warning(
|
||||
"后台任务: 场景点缓存写入失败: asset_id=%s error=%s",
|
||||
asset_id,
|
||||
cache_err,
|
||||
)
|
||||
|
||||
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
|
||||
@@ -1123,10 +1124,11 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s",
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
@@ -1142,8 +1144,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("后台任务: 单个片段更新失败: clip_id=%s", clip.id)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
@@ -1152,9 +1154,9 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
|
||||
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
|
||||
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# 后台任务失败不影响已创建的片段,静默处理
|
||||
logger.exception("后台任务异常: plan_id=%s", plan_id)
|
||||
logger.warning("后台任务异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
|
||||
if db:
|
||||
try:
|
||||
db.rollback()
|
||||
|
||||
@@ -173,14 +173,6 @@ def synthesize(
|
||||
# job.voice_id 统一存解析后的 CosyVoice voice_id
|
||||
actual_voice_id = resolved_profile.voice_id
|
||||
|
||||
# 语速/情绪等合成参数随 metadata 落库,workflow 提交 CosyVoice 时读取透传
|
||||
synthesis_meta = {
|
||||
"speed": request.speed,
|
||||
"emotion": request.emotion or "",
|
||||
}
|
||||
if request.metadata_:
|
||||
synthesis_meta.update(request.metadata_)
|
||||
|
||||
use_case = CreateTTSJobUseCase(repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
@@ -188,7 +180,7 @@ def synthesize(
|
||||
voice_id=actual_voice_id,
|
||||
voice_model=request.voice_model,
|
||||
voice_clone_profile_id=voice_clone_profile_id,
|
||||
metadata=synthesis_meta,
|
||||
metadata=request.metadata_,
|
||||
)
|
||||
|
||||
# 提交 CosyVoice 合成任务
|
||||
@@ -575,7 +567,6 @@ def preview_tts(
|
||||
text=request.text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=request.speed,
|
||||
emotion=request.emotion,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -163,12 +163,12 @@ def create_voice_clone(
|
||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
||||
logger.info(f"Celery task dispatched for voice clone {profile.id}")
|
||||
except Exception as e:
|
||||
logger.exception("Failed to dispatch Celery task")
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
|
||||
try:
|
||||
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to mark profile as failed after dispatch error")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||
|
||||
return _to_response(profile)
|
||||
|
||||
@@ -277,12 +277,12 @@ def retry_voice_clone(
|
||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
||||
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
|
||||
except Exception as e:
|
||||
logger.exception("Failed to dispatch Celery task")
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
|
||||
try:
|
||||
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to mark profile as failed after dispatch error")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
@@ -105,8 +105,8 @@ def _resolve_preset_preview_url(
|
||||
_preset_preview_cache[voice_id] = (audio_url, time.time())
|
||||
logger.info("Preset voice preview generated: %s", voice_id)
|
||||
return audio_url
|
||||
except Exception:
|
||||
logger.exception("Failed to generate preset voice preview: voice_id=%s", voice_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e)
|
||||
return fallback_url
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@ def _resolve_all_preset_preview_urls(
|
||||
try:
|
||||
result_map[p.voice_id] = _resolve_preset_preview_url(p.voice_id, p.preview_url, cosyvoice)
|
||||
except Exception:
|
||||
logger.exception("Failed to resolve preset preview URL: voice_id=%s", p.voice_id)
|
||||
result_map[p.voice_id] = p.preview_url
|
||||
return result_map
|
||||
|
||||
@@ -733,7 +732,7 @@ def _find_or_create_voice_library_for_extract(*, user_id, project_repository, as
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception:
|
||||
logger.exception("session rollback failed in _find_or_create_voice_library")
|
||||
pass
|
||||
for lib in asset_library_repository.find_by_project(project.id):
|
||||
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if kind == AssetLibraryKind.VOICE.value:
|
||||
|
||||
@@ -50,7 +50,7 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
"""创建渲染任务请求."""
|
||||
|
||||
lipsync_job_id: str = Field(..., description="对口型任务 ID")
|
||||
script_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="封面配置")
|
||||
@@ -67,8 +67,10 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
@field_validator("script_id")
|
||||
@classmethod
|
||||
def validate_script_id(cls, v: str) -> str:
|
||||
# script_id 可选:手动输入文案(TTS 直生)场景不关联文案库条目
|
||||
return (v or "").strip()
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("script_id 不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class AiAvatarRenderJobResponse(BaseModel):
|
||||
@@ -78,7 +80,7 @@ class AiAvatarRenderJobResponse(BaseModel):
|
||||
user_id: str
|
||||
project_id: str
|
||||
lipsync_job_id: str
|
||||
script_id: str = ""
|
||||
script_id: str
|
||||
b_roll_segments: list[dict[str, Any]]
|
||||
title_config: dict[str, Any]
|
||||
cover_config: dict[str, Any]
|
||||
@@ -107,21 +109,3 @@ class AiAvatarRenderProgressResponse(BaseModel):
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
|
||||
|
||||
class SmartCoverRequest(BaseModel):
|
||||
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧 + 可选标题 drawtext 叠加."""
|
||||
|
||||
video_url: str = Field(..., description="数字人视频 URL(对口型/渲染成片)")
|
||||
max_frames: int = Field(5, ge=1, le=10, description="抽帧数量(默认 5)")
|
||||
title_config: Optional[dict[str, Any]] = Field(
|
||||
None, description="标题配置;传入时在封面上用 drawtext 叠加标题(竖屏 720x1280)"
|
||||
)
|
||||
|
||||
|
||||
class SmartCoverResponse(BaseModel):
|
||||
"""智能封面响应."""
|
||||
|
||||
cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空")
|
||||
status: str = Field("completed", description="completed / fallback_failed")
|
||||
message: str = Field("", description="失败原因(如有)")
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
"""对口型 API Schema 定义 — #1796 / #1809 / #1822.
|
||||
|
||||
支持两种输入模式(二选一):
|
||||
1. TTS 直生模式(推荐):传 voice_id + script_text(+ speed/emotion),
|
||||
后端内部先调 CosyVoice 合成音频,再提交 MediaKit 对口型。
|
||||
2. 直接音频模式:传 video_url + audio_url(音频已由调用方准备好)。
|
||||
"""
|
||||
"""对口型 API Schema 定义 — #1796."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class LipsyncJobResponse(BaseModel):
|
||||
@@ -23,10 +17,6 @@ class LipsyncJobResponse(BaseModel):
|
||||
video_url: str
|
||||
audio_url: str
|
||||
enable_video_loop: bool
|
||||
voice_id: str = ""
|
||||
script_text: str = ""
|
||||
speed: float = 1.0
|
||||
emotion: str = ""
|
||||
mediakit_task_id: str
|
||||
status: str
|
||||
output_video_url: str
|
||||
@@ -43,58 +33,38 @@ class LipsyncJobResponse(BaseModel):
|
||||
|
||||
|
||||
class CreateLipsyncJobRequest(BaseModel):
|
||||
"""创建对口型任务请求.
|
||||
|
||||
两种模式(二选一):
|
||||
- TTS 直生:voice_id + script_text 必填(+ 可选 speed/emotion);audio_url 留空。
|
||||
- 直接音频:video_url + audio_url 必填。
|
||||
"""
|
||||
"""创建对口型任务请求."""
|
||||
|
||||
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
|
||||
|
||||
# 模式 2:直接音频
|
||||
audio_url: str = Field("", description="驱动音频 URL(mp3/aac/wav/m4a/flac);直生模式留空")
|
||||
|
||||
# 模式 1:TTS 直生
|
||||
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
|
||||
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
|
||||
|
||||
audio_url: str = Field(..., description="驱动音频 URL(mp3/aac/wav/m4a/flac)")
|
||||
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
|
||||
project_id: str = Field("", description="项目 ID(可选)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_input_mode(self) -> "CreateLipsyncJobRequest":
|
||||
video = (self.video_url or "").strip()
|
||||
if not video:
|
||||
@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 video.startswith(("http://", "https://")):
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
|
||||
lower = video.lower().split("?")[0]
|
||||
# 仅支持 MP4
|
||||
lower = v.lower().split("?")[0]
|
||||
if not lower.endswith(".mp4"):
|
||||
raise ValueError("video_url 仅支持 MP4 格式")
|
||||
return v
|
||||
|
||||
has_audio = bool((self.audio_url or "").strip())
|
||||
has_tts = bool((self.voice_id or "").strip()) and bool((self.script_text or "").strip())
|
||||
|
||||
if not has_audio and not has_tts:
|
||||
raise ValueError(
|
||||
"必须提供驱动音频:要么传 audio_url(直接音频模式),"
|
||||
"要么同时传 voice_id + script_text(TTS 直生模式)"
|
||||
)
|
||||
|
||||
if has_tts and len(self.script_text) > 5000:
|
||||
raise ValueError("script_text 最长 5000 字符")
|
||||
|
||||
if has_audio:
|
||||
au = self.audio_url.strip()
|
||||
if not au.startswith(("http://", "https://")):
|
||||
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
|
||||
au_lower = au.lower().split("?")[0]
|
||||
allowed = (".mp3", ".aac", ".wav", ".m4a", ".flac")
|
||||
if not any(au_lower.endswith(ext) for ext in allowed):
|
||||
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed)}")
|
||||
self.audio_url = au
|
||||
|
||||
return self
|
||||
@field_validator("audio_url")
|
||||
@classmethod
|
||||
def validate_audio_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("audio_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
|
||||
# 支持的音频格式
|
||||
lower = v.lower().split("?")[0]
|
||||
allowed_exts = (".mp3", ".aac", ".wav", ".m4a", ".flac")
|
||||
if not any(lower.endswith(ext) for ext in allowed_exts):
|
||||
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed_exts)}")
|
||||
return v
|
||||
|
||||
@@ -16,7 +16,6 @@ class TTSSynthesizeRequest(BaseModel):
|
||||
output_name: str = Field("", description="输出文件名")
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文 自然/兴奋/沉稳/亲切)")
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
|
||||
format: str = Field("mp3", description="输出格式(mp3/wav/pcm)")
|
||||
@@ -110,7 +109,6 @@ class TTSPreviewRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
|
||||
voice_id: str = Field(..., min_length=1, description="音色 ID")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文)")
|
||||
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
|
||||
|
||||
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
"""AI 数字人封面服务 — 复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧.
|
||||
|
||||
与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧):
|
||||
1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略)
|
||||
2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳
|
||||
3. 下载最佳帧并转存 OSS,返回公网封面 URL
|
||||
|
||||
降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MediaKit 抽帧轮询参数:poll_interval=1s × max_poll=15 → 最长 15s,配合前端 120s 超时足够
|
||||
COVER_POLL_INTERVAL = 1.0
|
||||
COVER_MAX_POLL_ATTEMPTS = 15
|
||||
|
||||
# 帧图片下载超时(秒)
|
||||
FRAME_DOWNLOAD_TIMEOUT = 20
|
||||
# 最佳帧下载超时(用于 persist)
|
||||
BEST_FRAME_DOWNLOAD_TIMEOUT = 30
|
||||
|
||||
# 自家 OSS 私有桶 URL 重签有效期(供 MediaKit GPU worker 拉取)
|
||||
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
def _sign_video_url_for_mediakit(video_url: str) -> str:
|
||||
"""如果 video_url 是自家 OSS 私有桶 URL,重新签名为长有效期预签名 URL。
|
||||
|
||||
MediaKit GPU worker 需要能公网访问 video_url,裸 public_url 在私有桶下会 403。
|
||||
"""
|
||||
if not video_url:
|
||||
return video_url
|
||||
try:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return video_url
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
url_host = urlparse(video_url).netloc.lower()
|
||||
if own_host and url_host == own_host:
|
||||
# 是自家 OSS URL,重签 7 天有效期供 MediaKit 拉取
|
||||
signed = storage.get_download_url(video_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
|
||||
if signed:
|
||||
logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)")
|
||||
return signed
|
||||
except Exception:
|
||||
logger.warning("[数字人封面] video_url 重签失败,使用原始 URL", exc_info=True)
|
||||
return video_url
|
||||
|
||||
|
||||
def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL.
|
||||
|
||||
Args:
|
||||
video_url: 可公网访问的视频 URL
|
||||
max_frames: 抽帧数量
|
||||
|
||||
Returns:
|
||||
最佳帧图片 URL;失败返回空字符串
|
||||
"""
|
||||
if not video_url:
|
||||
return ""
|
||||
|
||||
# 确保 MediaKit 能访问 video_url(自家 OSS 私有桶需重签)
|
||||
video_url = _sign_video_url_for_mediakit(video_url)
|
||||
|
||||
try:
|
||||
from packages.shared.cover_frame_scorer import score_frames
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
mk = get_mediakit_client()
|
||||
if not mk.is_available:
|
||||
logger.warning("[数字人封面] MediaKit 未配置,无法智能抽帧")
|
||||
return ""
|
||||
|
||||
logger.info(
|
||||
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d poll_interval=%.1f max_poll=%d",
|
||||
video_url[:80],
|
||||
max_frames,
|
||||
COVER_POLL_INTERVAL,
|
||||
COVER_MAX_POLL_ATTEMPTS,
|
||||
)
|
||||
|
||||
snapshots = mk.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=max_frames,
|
||||
poll_interval=COVER_POLL_INTERVAL,
|
||||
max_poll_attempts=COVER_MAX_POLL_ATTEMPTS,
|
||||
max_retries=1,
|
||||
)
|
||||
if not snapshots:
|
||||
logger.warning("[数字人封面] MediaKit 未返回帧: %s", video_url[:80])
|
||||
return ""
|
||||
|
||||
if len(snapshots) == 1:
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
# 使用连接池下载各帧(复用 TCP 连接,减少延迟)
|
||||
import httpx
|
||||
|
||||
candidates = []
|
||||
with httpx.Client(timeout=FRAME_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
|
||||
for snap in snapshots:
|
||||
url = snap.get("image_url") or snap.get("url") or ""
|
||||
if not url:
|
||||
continue
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp_path = tmp.name
|
||||
candidates.append({"image_path": tmp_path, "url": url})
|
||||
except Exception as e:
|
||||
logger.warning("[数字人封面] 帧下载失败,跳过: url=%s err=%s", url[:80], e)
|
||||
candidates.append({"image_path": None, "url": url, "score": 0.0})
|
||||
|
||||
if not candidates:
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
scored = score_frames(candidates)
|
||||
best = scored[0] if scored else None
|
||||
best_url = best.get("url", "") if best else ""
|
||||
|
||||
# 清理临时文件
|
||||
for c in candidates:
|
||||
p = c.get("image_path")
|
||||
if p:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"[数字人封面] 智能选帧完成: candidates=%d best_score=%s",
|
||||
len(candidates),
|
||||
best.get("score") if best else "n/a",
|
||||
)
|
||||
return best_url
|
||||
|
||||
except Exception:
|
||||
logger.warning("[数字人封面] 智能选帧失败", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def apply_title_to_cover(local_frame: str, *, title_config: dict | None) -> str:
|
||||
"""用 ffmpeg drawtext 在封面图上叠加标题,返回叠加后图片的本地路径.
|
||||
|
||||
ffmpeg 失败时回退返回原始 local_frame。竖屏封面按 720x1280 计算位置。
|
||||
"""
|
||||
if not title_config or not isinstance(title_config, dict):
|
||||
return local_frame
|
||||
text = (title_config.get("text") or title_config.get("content") or "").strip()
|
||||
if not text:
|
||||
return local_frame
|
||||
enabled = title_config.get("enabled", True)
|
||||
if not enabled:
|
||||
return local_frame
|
||||
|
||||
try:
|
||||
from packages.domain.video_filter_builder import build_title_drawtext_filter
|
||||
|
||||
drawtext_filter = build_title_drawtext_filter(
|
||||
title_config,
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
)
|
||||
if not drawtext_filter:
|
||||
return local_frame
|
||||
|
||||
base, ext = os.path.splitext(local_frame)
|
||||
titled_path = f"{base}_titled{ext or '.jpg'}"
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
local_frame,
|
||||
"-vf",
|
||||
drawtext_filter,
|
||||
"-y",
|
||||
titled_path,
|
||||
]
|
||||
logger.info("[数字人封面] 叠加标题: text=%s", text[:30])
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"[数字人封面] drawtext 失败,回退无标题: exit=%s stderr=%s",
|
||||
result.returncode,
|
||||
(result.stderr or "")[-300:],
|
||||
)
|
||||
return local_frame
|
||||
if not os.path.exists(titled_path) or os.path.getsize(titled_path) == 0:
|
||||
logger.warning("[数字人封面] drawtext 输出为空,回退无标题")
|
||||
return local_frame
|
||||
return titled_path
|
||||
except Exception as exc:
|
||||
logger.warning("[数字人封面] 标题叠加异常,回退无标题: %s", exc, exc_info=True)
|
||||
return local_frame
|
||||
|
||||
|
||||
def persist_cover_to_oss(
|
||||
frame_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
prefix: str = "ai-avatar/covers",
|
||||
title_config: dict | None = None,
|
||||
) -> str:
|
||||
"""下载帧图并转存到 OSS,返回公网封面 URL.
|
||||
|
||||
Args:
|
||||
frame_url: MediaKit 返回的临时帧图 URL
|
||||
job_id: 关联任务 ID(用于 OSS key 命名)
|
||||
prefix: OSS key 前缀
|
||||
title_config: 可选标题配置;传入时用 drawtext 叠加标题(竖屏 720x1280)
|
||||
|
||||
Returns:
|
||||
OSS 公网 URL;失败回退原始 frame_url
|
||||
"""
|
||||
if not frame_url:
|
||||
return ""
|
||||
tmp_path: Optional[str] = None
|
||||
titled_path: Optional[str] = None
|
||||
try:
|
||||
import httpx
|
||||
|
||||
with httpx.Client(timeout=BEST_FRAME_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
|
||||
resp = client.get(frame_url)
|
||||
resp.raise_for_status()
|
||||
if not resp.content:
|
||||
logger.warning("[数字人封面] 帧图内容为空: %s", frame_url[:80])
|
||||
return frame_url
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
token = job_id or uuid.uuid4().hex[:12]
|
||||
cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg"
|
||||
|
||||
upload_path = apply_title_to_cover(tmp_path, title_config=title_config)
|
||||
if upload_path != tmp_path:
|
||||
titled_path = upload_path
|
||||
|
||||
public_url = storage.upload_file(
|
||||
file_or_path=upload_path,
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
logger.info(
|
||||
"[数字人封面] 封面已转存 OSS: key=%s titled=%s",
|
||||
cover_key,
|
||||
bool(titled_path),
|
||||
)
|
||||
# 私有桶:返回预签名 URL(前端才能加载)
|
||||
if public_url:
|
||||
signed = storage.get_download_url(cover_key, expires_seconds=86400)
|
||||
return signed
|
||||
return frame_url
|
||||
except Exception:
|
||||
logger.warning("[数字人封面] 封面转存 OSS 失败,返回原始 URL", exc_info=True)
|
||||
return frame_url
|
||||
finally:
|
||||
for p in (tmp_path, titled_path):
|
||||
if p:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def generate_smart_cover(
|
||||
video_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
max_frames: int = 5,
|
||||
title_config: dict | None = None,
|
||||
) -> str:
|
||||
"""一站式:MediaKit 智能抽帧选最佳 → (可选)drawtext 叠加标题 → 转存 OSS.
|
||||
|
||||
供独立封面接口与渲染管线复用。失败返回空字符串。
|
||||
|
||||
Args:
|
||||
video_url: 可公网访问的视频 URL
|
||||
job_id: 关联任务 ID
|
||||
max_frames: 抽帧数量
|
||||
title_config: 可选标题配置;传入时在封面上叠加 drawtext 标题(竖屏 720x1280)
|
||||
"""
|
||||
best_frame = select_best_cover_frame(video_url, max_frames=max_frames)
|
||||
if not best_frame:
|
||||
return ""
|
||||
return persist_cover_to_oss(best_frame, job_id=job_id, title_config=title_config)
|
||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -25,6 +24,7 @@ from packages.adapters.sqlalchemy_impl.models import (
|
||||
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
|
||||
@@ -53,8 +53,8 @@ class AiAvatarRenderService:
|
||||
*,
|
||||
user_id: str,
|
||||
lipsync_job_id: str,
|
||||
script_id: str = "",
|
||||
b_roll_segments: list[dict[str, Any]] | None = None,
|
||||
script_id: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
title_config: dict[str, Any],
|
||||
cover_config: dict[str, Any],
|
||||
project_id: str = "",
|
||||
@@ -83,19 +83,17 @@ class AiAvatarRenderService:
|
||||
if not lipsync_job.output_video_url:
|
||||
raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput")
|
||||
|
||||
# 2. 验证文案归属(仅当选了文案库条目时;手动输入文案直生场景 script_id 可空)
|
||||
script_id = (script_id or "").strip()
|
||||
if script_id:
|
||||
script = (
|
||||
self.db.query(ScriptModel)
|
||||
.filter(
|
||||
ScriptModel.id == script_id,
|
||||
ScriptModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
# 2. 验证文案归属
|
||||
script = (
|
||||
self.db.query(ScriptModel)
|
||||
.filter(
|
||||
ScriptModel.id == script_id,
|
||||
ScriptModel.user_id == user_id,
|
||||
)
|
||||
if script is None:
|
||||
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
|
||||
.first()
|
||||
)
|
||||
if script is None:
|
||||
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
|
||||
|
||||
# 3. 创建渲染任务
|
||||
job_id = str(uuid.uuid4())
|
||||
@@ -105,7 +103,7 @@ class AiAvatarRenderService:
|
||||
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 or [])],
|
||||
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",
|
||||
@@ -257,7 +255,7 @@ class AiAvatarRenderService:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_video_path = os.path.join(tmpdir, "output.mp4")
|
||||
|
||||
cmd_list = self._build_ffmpeg_command(
|
||||
cmd = self._build_ffmpeg_command(
|
||||
input_video=input_video_path,
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
filter_complex=filter_complex,
|
||||
@@ -265,25 +263,9 @@ class AiAvatarRenderService:
|
||||
output_path=output_video_path,
|
||||
)
|
||||
|
||||
try:
|
||||
render_result = subprocess.run(
|
||||
cmd_list,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AiAvatarRenderError(
|
||||
"FFmpeg 渲染超时(600s)",
|
||||
code="FFmpegTimeout",
|
||||
) from exc
|
||||
|
||||
if render_result.returncode != 0:
|
||||
stderr_tail = (render_result.stderr or "").strip()[-800:]
|
||||
raise AiAvatarRenderError(
|
||||
f"FFmpeg 渲染失败,退出码: {render_result.returncode}, stderr: {stderr_tail}",
|
||||
code="FFmpegFailed",
|
||||
)
|
||||
exit_code = os.system(cmd)
|
||||
if exit_code != 0:
|
||||
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
|
||||
|
||||
job.progress = 80
|
||||
self.db.commit()
|
||||
@@ -292,27 +274,11 @@ class AiAvatarRenderService:
|
||||
cover_path = ""
|
||||
if job.cover_config:
|
||||
cover_path = os.path.join(tmpdir, "cover.jpg")
|
||||
cover_cmd = self._build_cover_extract_cmd(
|
||||
cover_config=job.cover_config,
|
||||
input_video=output_video_path,
|
||||
output_path=cover_path,
|
||||
)
|
||||
try:
|
||||
cover_result = subprocess.run(
|
||||
cover_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if cover_result.returncode != 0:
|
||||
logger.warning(
|
||||
"封面提取失败(非致命),跳过: exit=%s stderr=%s",
|
||||
cover_result.returncode,
|
||||
(cover_result.stderr or "")[-300:],
|
||||
)
|
||||
cover_path = ""
|
||||
except Exception as cover_err:
|
||||
logger.warning("封面提取异常(非致命),跳过: %s", cover_err)
|
||||
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
|
||||
@@ -322,28 +288,7 @@ class AiAvatarRenderService:
|
||||
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
|
||||
job.output_video_url = output_video_url
|
||||
|
||||
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加);
|
||||
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
|
||||
smart_cover_url = ""
|
||||
if output_video_url:
|
||||
try:
|
||||
from app.services.ai_avatar_cover_service import (
|
||||
generate_smart_cover,
|
||||
)
|
||||
|
||||
smart_cover_url = generate_smart_cover(
|
||||
output_video_url,
|
||||
job_id=job_id,
|
||||
max_frames=5,
|
||||
# 注意:不传 title_config —— 最终输出视频已经通过 drawtext 叠加了标题,
|
||||
# 再传会导致封面标题双重叠加
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
|
||||
|
||||
if smart_cover_url:
|
||||
job.output_cover_url = smart_cover_url
|
||||
elif cover_path:
|
||||
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
|
||||
|
||||
@@ -360,56 +305,18 @@ class AiAvatarRenderService:
|
||||
self.db.commit()
|
||||
logger.info("渲染任务完成: %s", job_id)
|
||||
|
||||
# 7. 自动保存成片记录到成片库
|
||||
if job.output_video_url:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
clip_name = f"AI数字人_{job_id[:8]}"
|
||||
# AI数字人入口是独立页面,前端可能不传 project_id(无项目概念),
|
||||
# 兜底为 "ai_avatar" 避免 DB 非空约束/查询问题;generation_task_id 同样兜底用 render_job_id
|
||||
clip_project_id = (job.project_id or "").strip() or "ai_avatar"
|
||||
clip_generation_task_id = (job.lipsync_job_id or "").strip() or job_id
|
||||
clip = GeneratedVideo.create(
|
||||
project_id=clip_project_id,
|
||||
generation_task_id=clip_generation_task_id,
|
||||
name=clip_name,
|
||||
file_url=job.output_video_url,
|
||||
user_id=job.user_id,
|
||||
duration=job.output_duration or 0.0,
|
||||
thumbnail_url=job.output_cover_url or None,
|
||||
generation_params={
|
||||
"source": "ai_avatar_render",
|
||||
"render_job_id": job.id,
|
||||
},
|
||||
)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
|
||||
video_repo.create(clip)
|
||||
logger.info("成片记录已保存到成片库: clip_id=%s, render_job=%s", clip.id, job_id)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"自动保存成片记录失败(不影响渲染任务状态): render_job=%s",
|
||||
job_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
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)
|
||||
raise
|
||||
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)
|
||||
raise
|
||||
|
||||
def _download_video(self, url: str) -> str:
|
||||
"""下载视频到临时文件."""
|
||||
@@ -435,73 +342,24 @@ class AiAvatarRenderService:
|
||||
filter_complex: str,
|
||||
final_label: Optional[str],
|
||||
output_path: str,
|
||||
) -> list[str]:
|
||||
"""构建 FFmpeg 命令(list 形式,shell=False).
|
||||
|
||||
根因修复 #1798 P0:OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符,
|
||||
os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被
|
||||
当成独立命令报 sh: -filter_complex: not found(exit 127 → Python 32512)。
|
||||
list + shell=False 彻底规避 shell 转义问题。
|
||||
"""
|
||||
cmd: list[str] = ["ffmpeg", "-i", input_video]
|
||||
) -> str:
|
||||
"""构建 FFmpeg 命令."""
|
||||
# 输入文件
|
||||
inputs = f"-i {input_video}"
|
||||
for seg in b_roll_segments:
|
||||
asset_url = seg.get("asset_url", "")
|
||||
if asset_url:
|
||||
cmd.extend(["-i", asset_url])
|
||||
inputs += f" -i {asset_url}"
|
||||
|
||||
# 滤镜
|
||||
if filter_complex and final_label:
|
||||
cmd.extend(["-filter_complex", filter_complex, "-map", f"[{final_label}]"])
|
||||
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
|
||||
elif filter_complex:
|
||||
cmd.extend(["-filter_complex", filter_complex])
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-y",
|
||||
output_path,
|
||||
]
|
||||
)
|
||||
return cmd
|
||||
|
||||
def _build_cover_extract_cmd(
|
||||
self,
|
||||
*,
|
||||
cover_config: dict[str, Any],
|
||||
input_video: str,
|
||||
output_path: str,
|
||||
) -> list[str]:
|
||||
"""构建封面截帧 FFmpeg 命令(list 形式,shell=False)."""
|
||||
if not cover_config or not isinstance(cover_config, dict):
|
||||
timestamp = 0.0
|
||||
width = 0
|
||||
height = 0
|
||||
filter_arg = f'-filter_complex "{filter_complex}"'
|
||||
else:
|
||||
timestamp = cover_config.get("timestamp", 0.0)
|
||||
width = cover_config.get("width", 0)
|
||||
height = cover_config.get("height", 0)
|
||||
filter_arg = ""
|
||||
|
||||
cmd: list[str] = [
|
||||
"ffmpeg",
|
||||
"-ss",
|
||||
str(timestamp),
|
||||
"-i",
|
||||
input_video,
|
||||
"-frames:v",
|
||||
"1",
|
||||
]
|
||||
if width > 0 and height > 0:
|
||||
vf = (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
||||
)
|
||||
cmd.extend(["-vf", vf])
|
||||
cmd.extend(["-y", output_path])
|
||||
return cmd
|
||||
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.
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
|
||||
|
||||
职责:
|
||||
- 创建/查询对口型任务
|
||||
- 双输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url)
|
||||
- 创建/查询/取消对口型任务
|
||||
- 调用 MediaKit 客户端提交异步任务
|
||||
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
|
||||
- 轮询更新任务状态
|
||||
- 用户隔离(每个用户只能操作自己的任务)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.services.mediakit_client import (
|
||||
STATUS_COMPLETED,
|
||||
@@ -25,122 +22,19 @@ from app.services.mediakit_client import (
|
||||
MediaKitError,
|
||||
get_mediakit_client,
|
||||
)
|
||||
|
||||
# Celery 异步任务:TTS 合成 + MediaKit 提交(#lipsync-speed-optimization)
|
||||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, normalize_emotion
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 传给 MediaKit GPU worker / 回给前端播放的 OSS 预签名有效期:7 天。
|
||||
# MediaKit 排队 + 拉取可能延迟,私有桶裸 URL 或 1 小时短预签名都会 403,故统一重签长有效期。
|
||||
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
class LipsyncService:
|
||||
"""对口型任务 Service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
client: Optional[MediaKitClient] = None,
|
||||
cosyvoice_service=None,
|
||||
voice_clone_repo=None,
|
||||
):
|
||||
def __init__(self, db: Session, client: Optional[MediaKitClient] = None):
|
||||
self.db = db
|
||||
self.client = client or get_mediakit_client()
|
||||
self._cosyvoice = cosyvoice_service
|
||||
self._voice_clone_repo = voice_clone_repo
|
||||
|
||||
def _get_cosyvoice(self):
|
||||
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
|
||||
if self._cosyvoice is None:
|
||||
from app.dependencies import get_cosyvoice_service
|
||||
|
||||
self._cosyvoice = get_cosyvoice_service()
|
||||
return self._cosyvoice
|
||||
|
||||
def _resolve_voice_id(self, voice_id: str, user_id: str) -> str:
|
||||
"""将克隆音色 profile UUID 解析为 CosyVoice voice_id。
|
||||
|
||||
与 /tts/synthesize 保持一致:命中 profile → 校验归属 → 返回其 voice_id;
|
||||
未命中(预置音色 ID 或克隆 CosyVoice voice_id)原样返回。
|
||||
"""
|
||||
if not voice_id:
|
||||
return ""
|
||||
if self._voice_clone_repo is None:
|
||||
try:
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
self._voice_clone_repo = get_voice_clone_profile_repository(self.db)
|
||||
except Exception:
|
||||
return voice_id
|
||||
try:
|
||||
profile = self._voice_clone_repo.get(voice_id)
|
||||
except Exception:
|
||||
return voice_id
|
||||
if profile is None:
|
||||
return voice_id
|
||||
if getattr(profile, "user_id", "") != user_id:
|
||||
raise MediaKitError("无权访问该音色", code="VoiceForbidden")
|
||||
if not getattr(profile, "voice_id", ""):
|
||||
raise MediaKitError("音色克隆尚未完成,请稍后再试", code="VoiceNotReady")
|
||||
return profile.voice_id
|
||||
|
||||
def _synthesize_and_persist_audio(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
job_id: str,
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
) -> str:
|
||||
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
|
||||
|
||||
Raises:
|
||||
MediaKitError: 合成失败
|
||||
"""
|
||||
actual_voice_id = self._resolve_voice_id(voice_id, user_id)
|
||||
cosyvoice = self._get_cosyvoice()
|
||||
try:
|
||||
result = cosyvoice.submit_synthesize_task(
|
||||
text=script_text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=speed,
|
||||
emotion=normalize_emotion(emotion),
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
|
||||
except ValueError as exc:
|
||||
raise MediaKitError(f"TTS 参数错误: {exc}", code="TTSInvalidParam") from exc
|
||||
|
||||
temp_url = result.get("audio_url", "")
|
||||
if not temp_url:
|
||||
raise MediaKitError("TTS 未返回音频 URL", code="TTSNoAudio")
|
||||
|
||||
# 转存到自家 OSS,避免临时 URL 过期导致 MediaKit 拉取失败
|
||||
try:
|
||||
audio_data = safe_download_bytes(
|
||||
temp_url,
|
||||
purpose="lipsync_tts_audio",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
|
||||
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
|
||||
logger.info("对口型 TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
return permanent_url
|
||||
except Exception as exc:
|
||||
logger.warning("TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
|
||||
return temp_url
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -149,39 +43,17 @@ class LipsyncService:
|
||||
*,
|
||||
user_id: str,
|
||||
video_url: str,
|
||||
audio_url: str = "",
|
||||
voice_id: str = "",
|
||||
script_text: str = "",
|
||||
speed: float = 1.0,
|
||||
emotion: str = "",
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = False,
|
||||
project_id: str = "",
|
||||
) -> LipsyncJobModel:
|
||||
"""创建对口型任务.
|
||||
|
||||
两种输入模式:
|
||||
- TTS 直生:voice_id + script_text(audio_url 留空)
|
||||
→ 先创建 DB 记录(状态 tts_processing),再 dispatch Celery 异步任务
|
||||
执行 TTS 合成 + MediaKit 提交。API 响应 <1s。
|
||||
- 直接音频:提供 audio_url
|
||||
→ 同步提交 MediaKit,状态直接设为 submitted。
|
||||
"""创建对口型任务并提交到 MediaKit.
|
||||
|
||||
Raises:
|
||||
MediaKitError: 参数校验失败或 MediaKit 提交失败(仅直接音频模式)
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
# 0. 输入校验
|
||||
if not audio_url:
|
||||
if not (voice_id and script_text):
|
||||
raise MediaKitError(
|
||||
"必须提供 audio_url 或 voice_id+script_text",
|
||||
code="InvalidInput",
|
||||
)
|
||||
# TTS 模式:在 HTTP 请求中同步校验音色归属,快速失败
|
||||
self._resolve_voice_id(voice_id, user_id)
|
||||
|
||||
# 1. 创建数据库记录
|
||||
job_id = str(uuid.uuid4())
|
||||
is_tts_mode = not bool(audio_url)
|
||||
job = LipsyncJobModel(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
@@ -189,63 +61,28 @@ class LipsyncService:
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
voice_id=voice_id or "",
|
||||
script_text=script_text or "",
|
||||
speed=speed,
|
||||
emotion=normalize_emotion(emotion),
|
||||
status="tts_processing" if is_tts_mode else "pending",
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
if is_tts_mode:
|
||||
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交
|
||||
try:
|
||||
tts_synthesize_and_submit.apply_async(
|
||||
args=(
|
||||
job_id,
|
||||
user_id,
|
||||
voice_id,
|
||||
script_text,
|
||||
speed,
|
||||
normalize_emotion(emotion),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
# 投递失败时立即把 job 标成 failed 并写入 error_message,
|
||||
# 前端轮询时能直接看到失败原因,不会无限卡在 tts_processing。
|
||||
logger.exception(
|
||||
"Celery 任务提交失败,TTS 任务已创建但未触发执行: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
job.status = "failed"
|
||||
job.error_message = f"Celery 任务投递失败: {exc}"
|
||||
job.error_code = "AsyncDispatchFailed"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# 2b. 直接音频模式:同步签名并提交 MediaKit
|
||||
video_url = self._sign_media_url(video_url)
|
||||
if audio_url:
|
||||
audio_url = self._sign_media_url(audio_url)
|
||||
job.audio_url = audio_url
|
||||
|
||||
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
|
||||
# 2. 提交到 MediaKit
|
||||
try:
|
||||
result = self.client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
client_token=job_id, # 幂等控制
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
logger.error("提交对口型任务失败: %s", exc)
|
||||
raise
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
@@ -308,102 +145,34 @@ class LipsyncService:
|
||||
return job
|
||||
|
||||
mk_status = status_data.get("status", STATUS_RUNNING)
|
||||
logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status)
|
||||
|
||||
try:
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
output_url = result.get("video_url", "")
|
||||
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL)
|
||||
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
|
||||
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)
|
||||
else:
|
||||
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
except Exception as exc: # noqa: BLE001 - DB 提交失败必须记录日志并重试,否则后台任务静默失败
|
||||
logger.error(
|
||||
"refresh_job_status 提交 DB 失败 job_id=%s mk_status=%s err=%s",
|
||||
job_id,
|
||||
mk_status,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
self.db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
# DB commit 失败不 raise,返回当前 job 对象让下次轮询再试
|
||||
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 _persist_output_video(self, temp_url: str, job_id: str, user_id: str) -> str:
|
||||
"""将 MediaKit 输出的临时视频 URL 转存到自家 OSS.
|
||||
|
||||
失败时回退返回原始临时 URL,不影响任务完成。
|
||||
"""
|
||||
if not temp_url:
|
||||
return ""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
with httpx.Client(timeout=180.0, follow_redirects=True) as client:
|
||||
resp = client.get(temp_url)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
|
||||
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
|
||||
logger.info("对口型输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
return self._sign_media_url(permanent_url) or temp_url
|
||||
except Exception as exc:
|
||||
logger.warning("对口型输出视频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
|
||||
return temp_url
|
||||
|
||||
def _sign_media_url(self, url: str) -> str:
|
||||
"""对自家 OSS 私有桶 URL 重签长有效期预签名,供 MediaKit 拉取 / 前端播放。
|
||||
|
||||
- 裸 public_url(upload_file 返回,不带签名)→ 私有桶匿名访问 403,重签。
|
||||
- 已带签名但即将过期的 URL(如前端 1h 预签名)→ 抽 storage_key 后重签。
|
||||
- 外部 URL(CosyVoice/MediaKit 临时链接,非本桶 host)→ 原样透传。
|
||||
- 任何异常都降级原样返回,不阻断主流程。
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
try:
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return url # 无法判定归属,保守透传
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
host = urlparse(url).netloc.lower()
|
||||
if not own_host or host != own_host:
|
||||
return url # 非自家 OSS(外部临时链接),不处理
|
||||
signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
|
||||
return signed or url
|
||||
except Exception as exc: # noqa: BLE001 - 签名失败不阻断,降级原 URL
|
||||
logger.warning("对口型 URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
|
||||
return url
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""取消任务(仅 pending/tts_processing/submitted 状态可取消)."""
|
||||
"""取消任务(仅 pending/submitted 状态可取消)."""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
if job.status in ("pending", "tts_processing", "submitted"):
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
"""AI 数字人对口型 TTS 异步任务 — 将 TTS 合成从 HTTP 请求移至 Celery 后台执行.
|
||||
|
||||
优化目标:将 create_job 的 API 响应时间从 6~35s 降到 <1s。
|
||||
任务流程:
|
||||
1. 创建新 DB session,加载 job 记录
|
||||
2. 调用 CosyVoice 合成音频
|
||||
3. 下载音频并转存到自家 OSS
|
||||
4. 更新 job 的 audio_url
|
||||
5. 签名 URL 并提交到 MediaKit
|
||||
6. 更新 job 状态为 submitted
|
||||
7. 异常时标记 job 为 failed
|
||||
|
||||
注意:使用 @shared_task 而非绑定到某个 celery_app 实例,
|
||||
确保任务能被 Worker 侧 celery_app 正确注册,同时 API 侧 send_task/apply_async 仍可正常调用。
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MediaKit 预签名 URL 有效期(7天,秒),与 LipsyncService._sign_media_url 保持一致
|
||||
_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
def _sign_media_url(url: str) -> str:
|
||||
"""对自家 OSS 私有桶 URL 重签长有效期预签名.
|
||||
|
||||
- 自家 OSS URL → 重签 7 天有效期
|
||||
- 外部临时 URL → 原样透传
|
||||
- 任何异常降级原样返回,不阻断主流程
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
try:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return url
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
host = urlparse(url).netloc.lower()
|
||||
if not own_host or host != own_host:
|
||||
return url
|
||||
signed = storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
|
||||
return signed or url
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("[lipsync_tts] URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
|
||||
return url
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="lipsync_tts.synthesize_and_submit",
|
||||
max_retries=2,
|
||||
default_retry_delay=30,
|
||||
)
|
||||
def tts_synthesize_and_submit(
|
||||
self,
|
||||
job_id: str,
|
||||
user_id: str,
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
):
|
||||
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
|
||||
|
||||
在 Celery worker 中运行,不阻塞 HTTP 请求。
|
||||
"""
|
||||
from app.services.mediakit_client import MediaKitError, get_mediakit_client
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.shared.url_security import safe_download_bytes
|
||||
|
||||
# SessionLocal 获取:
|
||||
# - API 容器:app.db.SessionLocal(环境变量完整,导入即建引擎)
|
||||
# - Worker 容器:worker_app.db.SessionLocal(Worker 自己的 settings 初始化引擎)
|
||||
# API 侧没有 worker_app 模块 → ImportError 直接回退;
|
||||
# Worker 侧 app.db 会因缺少 API 专有环境变量抛 pydantic ValidationError,
|
||||
# 此时也要回退到 worker_app.db。
|
||||
try:
|
||||
from worker_app.db import SessionLocal # type: ignore
|
||||
except Exception: # noqa: BLE001
|
||||
from app.db import SessionLocal # type: ignore
|
||||
|
||||
db: DBSession = SessionLocal()
|
||||
try:
|
||||
job = (
|
||||
db.query(LipsyncJobModel)
|
||||
.filter(
|
||||
LipsyncJobModel.id == job_id,
|
||||
LipsyncJobModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if job is None:
|
||||
logger.error("[lipsync_tts] Job not found: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
# 已取消的任务不再处理
|
||||
if job.status == "cancelled":
|
||||
logger.info("[lipsync_tts] Job already cancelled, skipping: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
# 1. TTS 合成
|
||||
try:
|
||||
cosyvoice = CosyVoiceService()
|
||||
result = cosyvoice.submit_synthesize_task(
|
||||
text=script_text,
|
||||
voice_id=voice_id,
|
||||
speed=speed,
|
||||
emotion=emotion,
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
logger.error("[lipsync_tts] TTS 合成失败: job_id=%s err=%s", job_id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"TTS 合成失败: {exc}"
|
||||
job.error_code = "TTSSynthesisFailed"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
except ValueError as exc:
|
||||
logger.error("[lipsync_tts] TTS 参数错误: job_id=%s err=%s", job_id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"TTS 参数错误: {exc}"
|
||||
job.error_code = "TTSInvalidParam"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
temp_url = result.get("audio_url", "")
|
||||
if not temp_url:
|
||||
logger.error("[lipsync_tts] TTS 未返回音频 URL: job_id=%s", job_id)
|
||||
job.status = "failed"
|
||||
job.error_message = "TTS 未返回音频 URL"
|
||||
job.error_code = "TTSNoAudio"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# 2. 下载并转存到自家 OSS
|
||||
try:
|
||||
audio_data = safe_download_bytes(
|
||||
temp_url,
|
||||
purpose="lipsync_tts_audio",
|
||||
allowed_mime_types=(
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav,与 audio/wav 等价(RIFF/WAVE)
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
),
|
||||
timeout=60.0,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
|
||||
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
|
||||
logger.info("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
job.audio_url = permanent_url
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
job.audio_url = temp_url
|
||||
|
||||
db.commit()
|
||||
|
||||
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
|
||||
audio_url = _sign_media_url(job.audio_url)
|
||||
video_url = _sign_media_url(job.video_url)
|
||||
|
||||
client = get_mediakit_client()
|
||||
try:
|
||||
mk_result = client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=job.enable_video_loop,
|
||||
client_token=job_id,
|
||||
)
|
||||
job.mediakit_task_id = mk_result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
logger.info(
|
||||
"[lipsync_tts] 已提交 MediaKit: job_id=%s task_id=%s",
|
||||
job_id,
|
||||
mk_result["task_id"],
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
logger.error("[lipsync_tts] 提交 MediaKit 失败: job_id=%s err=%s", job_id, exc)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 4. 链式触发 Celery 兜底轮询:MediaKit 提交成功后由 worker 主动拉取状态到终态,
|
||||
# 不依赖前端轮询触发的 FastAPI background task(后台任务可能静默失败导致永久卡 running)
|
||||
if job.status == "submitted" and job.mediakit_task_id:
|
||||
poll_mediakit_status.apply_async(
|
||||
kwargs={"job_id": job_id, "user_id": user_id},
|
||||
countdown=10, # 10 秒后开始轮询(给 MediaKit 一点处理时间)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("[lipsync_tts] 未预期的异常: job_id=%s", job_id)
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id).first()
|
||||
if job and job.status not in ("cancelled", "failed", "completed"):
|
||||
job.status = "failed"
|
||||
job.error_message = "TTS 异步任务执行异常"
|
||||
job.error_code = "AsyncTaskError"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="lipsync_tts.poll_mediakit_status",
|
||||
max_retries=60, # 最多轮询 60 次
|
||||
default_retry_delay=10, # 每次间隔 10 秒(总兜底时长 10 分钟)
|
||||
)
|
||||
def poll_mediakit_status(self, job_id: str, user_id: str):
|
||||
"""Celery 兜底轮询:TTS 提交 MediaKit 后,由 worker 主动拉取状态直到终态。
|
||||
|
||||
不依赖前端轮询,避免 background task 静默失败导致任务永久卡 running/submitted。
|
||||
"""
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
try:
|
||||
from worker_app.db import SessionLocal # type: ignore
|
||||
except Exception: # noqa: BLE001
|
||||
from app.db import SessionLocal # type: ignore
|
||||
|
||||
db: DBSession = SessionLocal()
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
|
||||
if job is None:
|
||||
logger.warning("[lipsync_poll] Job not found: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
# 已终态,不需要再轮询
|
||||
if job.status in ("completed", "failed", "cancelled"):
|
||||
return
|
||||
|
||||
if not job.mediakit_task_id:
|
||||
logger.warning("[lipsync_poll] Job has no mediakit_task_id: job_id=%s status=%s", job_id, job.status)
|
||||
return
|
||||
|
||||
from app.services.mediakit_client import MediaKitError, get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
try:
|
||||
status_data = client.get_task_status(job.mediakit_task_id)
|
||||
except MediaKitError as exc:
|
||||
logger.warning("[lipsync_poll] 拉取 MediaKit 状态失败,将重试: job_id=%s err=%s", job_id, exc)
|
||||
raise self.retry(exc=exc)
|
||||
|
||||
mk_status = status_data.get("status", "running")
|
||||
|
||||
if mk_status == "succeeded":
|
||||
# 复用 LipsyncService 的持久化逻辑
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db)
|
||||
result = status_data.get("result", {})
|
||||
job.status = "completed"
|
||||
output_url = result.get("video_url", "")
|
||||
job.output_video_url = svc._persist_output_video(output_url, job_id, user_id)
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("[lipsync_poll] 任务完成: job_id=%s", job_id)
|
||||
elif mk_status in ("failed", "error"):
|
||||
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)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("[lipsync_poll] 任务失败: job_id=%s err=%s", job_id, job.error_message)
|
||||
else:
|
||||
# 中间状态,更新时间戳,继续重试
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
db.commit()
|
||||
logger.debug("[lipsync_poll] 任务仍在 %s,继续轮询: job_id=%s", mk_status, job_id)
|
||||
raise self.retry()
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync_poll] 未预期异常: job_id=%s", job_id)
|
||||
db.rollback()
|
||||
raise self.retry(exc=exc)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API 函数
|
||||
* 后端实际接口:/videos(分页:page/page_size,返回 {items, total, page, page_size})
|
||||
* 后端实际接口:/videos
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
@@ -12,39 +12,16 @@ import type {
|
||||
} from "./types"
|
||||
import { mapVideoToProductItem } from "./utils"
|
||||
|
||||
/** 分页列表响应(前端消费用) */
|
||||
export interface ProductListResult {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成品列表(分页)
|
||||
* @param params 分页与筛选参数:page 默认 1,page_size 默认 20
|
||||
*/
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductListResult> => {
|
||||
const response = await apiClient.get("/videos", {
|
||||
params: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
const data = response.data as {
|
||||
items?: VideoItem[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
const items: VideoItem[] = Array.isArray(data?.items) ? data.items : []
|
||||
return {
|
||||
items: items.map(mapVideoToProductItem),
|
||||
total: data.total ?? items.length,
|
||||
page: data.page ?? params?.page ?? 1,
|
||||
page_size: data.page_size ?? params?.page_size ?? 20,
|
||||
}
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/videos", { params })
|
||||
const data = response.data
|
||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
return videos.map(mapVideoToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./scripts"
|
||||
export * from "./types"
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 文案库 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}`)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* 文案库 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>
|
||||
@@ -103,7 +103,6 @@ export interface TTSPreviewRequest {
|
||||
voice_id: string
|
||||
speed?: number
|
||||
pitch?: number
|
||||
emotion?: string // 情绪参数:natural/excited/calm/friendly
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.xx-app-shell 全屏 flex 容器
|
||||
├── header (xx-top-nav) 顶部导航(Header.tsx 管理)
|
||||
└── .xx-app-body 水平 flex 行
|
||||
├── .xx-app-sidebar 左侧侧边栏(128px / 64px 折叠)
|
||||
├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠)
|
||||
└── .xx-app-content 主内容区(自适应)
|
||||
|
||||
所有尺寸/颜色均使用 global.css 设计系统变量
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
/* ── 侧边栏 ───────────────────────────────────────────────── */
|
||||
.xx-app-sidebar {
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -103,12 +103,6 @@
|
||||
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;
|
||||
@@ -142,7 +136,7 @@
|
||||
|
||||
/* 展开态恢复完整宽度 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) {
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle {
|
||||
@@ -165,7 +159,7 @@
|
||||
top: 56px; /* 移动端 Header 高度 */
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-slow);
|
||||
box-shadow: none;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MainLayout - 主布局组件(Task 1.2)
|
||||
*
|
||||
* 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区
|
||||
* - 侧边栏:128px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 顶部导航:复用 Header 组件(68px 固定高度)
|
||||
* - 主内容区:自适应填充剩余空间
|
||||
* - 响应式:移动端(<768px)隐藏侧边栏
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
/* 分组标题 */
|
||||
.xx-sidebar-group-title {
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md) 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-xs);
|
||||
padding: var(--space-sm) var(--space-xs);
|
||||
margin: 0 var(--space-xxs);
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin: 0 var(--space-xs);
|
||||
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: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
@@ -119,9 +119,7 @@
|
||||
/* ── 菜单项文字 ───────────────────────────────────────────── */
|
||||
.xx-sidebar-menu-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -135,16 +133,8 @@
|
||||
/* 折叠时菜单项居中,仅图标 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-item {
|
||||
justify-content: center;
|
||||
padding: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠态图标恢复更大尺寸居中 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
padding: var(--space-sm);
|
||||
margin: 0 var(--space-xxs);
|
||||
}
|
||||
|
||||
/* 折叠时隐藏分组标题 */
|
||||
|
||||
@@ -58,12 +58,6 @@ 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: "配音库",
|
||||
@@ -179,12 +173,6 @@ 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: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -486,16 +486,14 @@
|
||||
|
||||
.aa-lipsync-preview {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
aspect-ratio: 9/16;
|
||||
background: #0f0f1a;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 12px auto;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.aa-lipsync-preview video {
|
||||
@@ -549,25 +547,21 @@
|
||||
/* ── 封面 & 生成 ── */
|
||||
.aa-cover-preview {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
aspect-ratio: 9/16;
|
||||
max-height: 160px;
|
||||
background: #f0f0f5;
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 12px auto;
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aa-cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 9/16;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.aa-cover-preview__placeholder {
|
||||
@@ -575,19 +569,6 @@
|
||||
color: #8c8ca1;
|
||||
}
|
||||
|
||||
.aa-cover-preview__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.aa-cover-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -1040,228 +1021,3 @@
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
#1809 ④⑤⑥ B-roll 弹窗:选库行 + 文案句子列表 + 自动估算提示
|
||||
============================================================ */
|
||||
|
||||
/* 宽弹窗:左右两栏 + 句子列表需要更大空间 */
|
||||
.aa-modal--wide {
|
||||
max-width: 960px;
|
||||
width: 94%;
|
||||
}
|
||||
|
||||
.aa-broll-modal-body .aa-broll-right {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
/* 左侧素材库选择行 */
|
||||
.aa-broll-lib-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aa-broll-lib-row .aa-select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border-color, #e2e2ea);
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #1a1a2e;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 无缩略图时的素材占位 */
|
||||
.aa-broll-asset-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 文案句子列表 */
|
||||
.aa-sentence-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.aa-sentence-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border-color, #e2e2ea);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.aa-sentence-item:hover {
|
||||
border-color: #c0c0d0;
|
||||
background: #f8f8fc;
|
||||
}
|
||||
|
||||
.aa-sentence-item.active {
|
||||
border-color: #7c3aed;
|
||||
background: #f3edff;
|
||||
}
|
||||
|
||||
.aa-sentence-item__idx {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f5;
|
||||
color: #6b6b80;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.aa-sentence-item.active .aa-sentence-item__idx {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.aa-sentence-item__text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #1a1a2e;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.aa-sentence-item__time {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: #8c8ca1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.aa-sentence-empty {
|
||||
font-size: 12px;
|
||||
color: #8c8ca1;
|
||||
background: #f8f8fc;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* 选择/估算提示 */
|
||||
.aa-broll-hint {
|
||||
font-size: 12px;
|
||||
color: #059669;
|
||||
background: #f8f8fc;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ─ 对口型生成弹窗 Spinner ── */
|
||||
.aa-lipsync-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #f0f0f5;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: aa-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes aa-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.aa-btn--danger {
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.aa-btn--danger:hover {
|
||||
background: #ff7875;
|
||||
}
|
||||
|
||||
/* ── v3.1 两步骤导航(仅追加,不改动上方任何原有样式) ── */
|
||||
.aa-step-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 10px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8ec;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aa-step-nav__item {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #8c8ca1;
|
||||
position: relative;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.aa-step-nav__item.active {
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aa-step-nav__item.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -11px;
|
||||
height: 2px;
|
||||
background: #4f46e5;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 步骤切换按钮行 */
|
||||
.aa-step-btn-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #f0f0f5;
|
||||
}
|
||||
|
||||
.aa-step-btn-row .aa-btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 分步布局:每个步骤 3 个横向面板等宽撑满(沿用原 .aa-panel 外观,仅改宽度) */
|
||||
.aa-page-body > .aa-panel--s1,
|
||||
.aa-page-body > .aa-panel--s2 {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* 文案 / 对口型预览面板内容较多,给更宽的弹性比例 */
|
||||
.aa-page-body > .aa-panel--s1-wide {
|
||||
flex: 1.4 1 0;
|
||||
width: auto;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.aa-page-body > .aa-panel--s2-wide {
|
||||
flex: 1.4 1 0;
|
||||
width: auto;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
@@ -1,145 +1,52 @@
|
||||
/**
|
||||
* AI数字人 — 主页面(v3 两步骤版)
|
||||
* 步骤1:出镜视频 / 配音库 / 文案
|
||||
* 步骤2:对口型预览(含插入画面)/ 标题配置 / 封面&生成
|
||||
* AI数字人 — 主页面(v3)
|
||||
* 5列水平面板布局
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import "./AiAvatar.css"
|
||||
import { useAiAvatar } from "./hooks/useAiAvatar"
|
||||
import { PanelVideoSelector } from "./components/PanelVideoSelector"
|
||||
import PanelVoiceSelector from "./components/PanelVoiceSelector"
|
||||
import PanelScript from "./components/PanelScript"
|
||||
import PanelLipsyncPreview from "./components/PanelLipsyncPreview"
|
||||
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,
|
||||
getRenderJob,
|
||||
generateSmartCover,
|
||||
} from "./api/aiAvatar"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import {
|
||||
normalizeEmotion,
|
||||
buildTitleConfigPayload,
|
||||
buildCoverConfigPayload,
|
||||
} from "./utils/contract"
|
||||
import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover"
|
||||
type PanelKey = "video" | "voice" | "script" | "title" | "cover"
|
||||
|
||||
const AiAvatarPage: React.FC = () => {
|
||||
const state = useAiAvatar()
|
||||
const navigate = useNavigate()
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1)
|
||||
const [collapsed, setCollapsed] = useState<Record<PanelKey, boolean>>({
|
||||
video: false,
|
||||
voice: false,
|
||||
script: false,
|
||||
lipsync: false,
|
||||
title: false,
|
||||
cover: false,
|
||||
})
|
||||
|
||||
/* ── 对口型生成弹窗 ── */
|
||||
const [showLipsyncModal, setShowLipsyncModal] = useState(false)
|
||||
const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">(
|
||||
"generating",
|
||||
)
|
||||
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
|
||||
/* ── 智能封面加载态 ── */
|
||||
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
|
||||
/* ── 渲染进度弹窗 ── */
|
||||
const [showRenderModal, setShowRenderModal] = useState(false)
|
||||
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
|
||||
"generating",
|
||||
)
|
||||
const [renderProgress, setRenderProgress] = useState(0)
|
||||
const [renderErrorMessage, setRenderErrorMessage] = useState("")
|
||||
/* ── 素材库弹窗 ── */
|
||||
const [bRollAssets, setBRollAssets] = useState<import("@/api/assets").AssetItem[]>([])
|
||||
|
||||
/* ── 对口型轮询 ── */
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
/* ── 渲染进度轮询 ── */
|
||||
const renderTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const togglePanel = useCallback((key: PanelKey) => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
/* ── 步骤切换 ── */
|
||||
const handleNextStep = useCallback(() => {
|
||||
const missing: string[] = []
|
||||
if (!state.selectedVideo) missing.push("出镜视频")
|
||||
if (!state.selectedVoice) missing.push("配音")
|
||||
if (!state.scriptText.trim()) missing.push("文案")
|
||||
if (missing.length > 0) {
|
||||
message.warning(`请先完成${missing.join("、")}`)
|
||||
return
|
||||
}
|
||||
setCurrentStep(2)
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
|
||||
|
||||
const handlePrevStep = useCallback(() => {
|
||||
setCurrentStep(1)
|
||||
}, [])
|
||||
|
||||
/* ── 对口型 ── */
|
||||
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
|
||||
}
|
||||
if (!state.selectedVideo || !state.selectedVoice || !state.scriptText) return
|
||||
try {
|
||||
// 显示生成弹窗
|
||||
setShowLipsyncModal(true)
|
||||
setLipsyncStatus("generating")
|
||||
setLipsyncErrorMessage("")
|
||||
|
||||
// ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url)
|
||||
console.log("[对口型] 开始生成:", {
|
||||
videoId: video.id,
|
||||
voiceId: voice.voice_id,
|
||||
voiceType: voice.type,
|
||||
textLen: state.scriptText.length,
|
||||
})
|
||||
const asset = await getAssetById(video.id)
|
||||
console.log("[对口型] getAssetById 响应:", {
|
||||
id: asset?.id,
|
||||
file_url: asset?.file_url?.substring(0, 100),
|
||||
})
|
||||
const videoUrl = asset?.file_url
|
||||
if (!videoUrl) {
|
||||
console.error("[对口型] file_url 为空,asset:", asset)
|
||||
setShowLipsyncModal(false)
|
||||
message.error("获取出镜视频播放地址失败,请重新选择素材")
|
||||
return
|
||||
}
|
||||
// ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822)
|
||||
const payload = {
|
||||
voice_id: voice.voice_id,
|
||||
const job = await createLipsyncJob({
|
||||
voice_id: state.selectedVoice.voice_id,
|
||||
script_text: state.scriptText,
|
||||
video_url: videoUrl,
|
||||
speed: state.speed, // 语速 0.5~2.0
|
||||
emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly
|
||||
}
|
||||
console.log("[对口型] createLipsyncJob 请求:", payload)
|
||||
const job = await createLipsyncJob(payload)
|
||||
console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status })
|
||||
video_asset_id: state.selectedVideo.id,
|
||||
})
|
||||
state.setLipsyncJob(job)
|
||||
// 开始轮询
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
@@ -147,162 +54,60 @@ const AiAvatarPage: React.FC = () => {
|
||||
try {
|
||||
const updated = await getLipsyncJob(job.id)
|
||||
state.setLipsyncJob(updated)
|
||||
console.log("[对口型] 轮询状态:", {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
error: updated.error_message,
|
||||
})
|
||||
if (updated.status === "completed") {
|
||||
if (updated.status === "completed" || updated.status === "failed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
setLipsyncStatus("completed")
|
||||
setTimeout(() => {
|
||||
setShowLipsyncModal(false)
|
||||
message.success("对口型视频生成完成")
|
||||
}, 1000)
|
||||
} else if (updated.status === "failed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
setLipsyncStatus("failed")
|
||||
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[对口型] 轮询错误:", err)
|
||||
} catch {
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
console.error("[对口型] 创建失败:", {
|
||||
status: (err as { response?: { status?: number } })?.response?.status,
|
||||
data: (err as { response?: { data?: unknown } })?.response?.data,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
setShowLipsyncModal(false)
|
||||
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
|
||||
console.error("对口型任务创建失败:", err)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
|
||||
|
||||
// 取消对口型生成
|
||||
const handleCancelLipsync = useCallback(() => {
|
||||
if (lipsyncTimerRef.current) {
|
||||
clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = null
|
||||
}
|
||||
setShowLipsyncModal(false)
|
||||
setLipsyncStatus("generating")
|
||||
setLipsyncErrorMessage("")
|
||||
}, [])
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频(含实时进度轮询) ── */
|
||||
/* ── 加载 B-roll 素材 ── */
|
||||
useEffect(() => {
|
||||
getAssetsByKind("video", { limit: 50 })
|
||||
.then(setBRollAssets)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
|
||||
message.warning("请先生成对口型视频,待对口型完成后再提交渲染")
|
||||
return
|
||||
}
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") return
|
||||
state.setIsGenerating(true)
|
||||
try {
|
||||
// 确保有 project_id(AI数字人入口独立,不在项目内,自动取默认项目;#1860 P0 bugfix)
|
||||
const defaultProject = await getOrCreateDefaultProject()
|
||||
const job = await submitRender({
|
||||
await submitRender({
|
||||
lipsync_job_id: state.lipsyncJob.id,
|
||||
script_id: state.script?.id,
|
||||
project_id: defaultProject.id,
|
||||
b_roll_segments: state.bRollSegments.map((seg) => ({
|
||||
script_segment_index: seg.script_segment_index,
|
||||
asset_url: seg.asset.file_url || "",
|
||||
mode: seg.mode,
|
||||
start_time: seg.start_time,
|
||||
end_time: seg.end_time,
|
||||
pip_position: seg.pip_position,
|
||||
pip_scale: seg.pip_scale,
|
||||
})) as never,
|
||||
title_config: buildTitleConfigPayload(state.titleConfig),
|
||||
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
|
||||
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,
|
||||
})
|
||||
|
||||
// 打开渲染进度弹窗,启动轮询
|
||||
setShowRenderModal(true)
|
||||
setRenderStatus("generating")
|
||||
setRenderProgress(job.progress ?? 0)
|
||||
setRenderErrorMessage("")
|
||||
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getRenderJob(job.id)
|
||||
setRenderProgress(updated.progress ?? 0)
|
||||
if (updated.status === "completed") {
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = null
|
||||
setRenderStatus("completed")
|
||||
message.success("视频已生成并保存到成片库")
|
||||
} else if (updated.status === "failed") {
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = null
|
||||
setRenderStatus("failed")
|
||||
setRenderErrorMessage(updated.error_message || "渲染失败,请重试")
|
||||
}
|
||||
} catch (pollErr) {
|
||||
console.error("[渲染] 轮询失败:", pollErr)
|
||||
}
|
||||
}, 3000)
|
||||
} 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])
|
||||
|
||||
/* ── 关闭渲染进度弹窗 ── */
|
||||
const handleCancelRender = useCallback(() => {
|
||||
if (renderTimerRef.current) {
|
||||
clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = null
|
||||
}
|
||||
setShowRenderModal(false)
|
||||
setRenderStatus("generating")
|
||||
setRenderProgress(0)
|
||||
setRenderErrorMessage("")
|
||||
}, [])
|
||||
|
||||
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822) ── */
|
||||
const handleSmartCover = useCallback(async () => {
|
||||
// 基于对口型成片抽帧,必须先完成对口型
|
||||
const videoUrl = state.lipsyncJob?.output_video_url
|
||||
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
|
||||
message.warning("请先生成对口型视频,完成后再智能获取封面")
|
||||
return
|
||||
}
|
||||
setSmartCoverLoading(true)
|
||||
try {
|
||||
const res = await generateSmartCover(videoUrl, buildTitleConfigPayload(state.titleConfig), 5)
|
||||
if (res.cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
mode: "auto_frame",
|
||||
smart_cover_url: res.cover_url,
|
||||
thumbnail_url: res.cover_url,
|
||||
}))
|
||||
message.success("智能封面已生成")
|
||||
} else {
|
||||
message.error(res.message || "智能封面生成失败,请稍后重试")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("智能封面生成失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
|
||||
} finally {
|
||||
setSmartCoverLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.lipsyncJob])
|
||||
}, [
|
||||
state.lipsyncJob,
|
||||
state.script,
|
||||
state.bRollSegments,
|
||||
state.titleConfig,
|
||||
state.coverConfig,
|
||||
state.resolution,
|
||||
])
|
||||
|
||||
/* ── 配置汇总 ── */
|
||||
const summary = {
|
||||
@@ -320,147 +125,95 @@ const AiAvatarPage: React.FC = () => {
|
||||
<div className="aa-page-header">
|
||||
<h1>AI数字人</h1>
|
||||
</div>
|
||||
|
||||
{/* 步骤切换导航条 */}
|
||||
<div className="aa-step-nav">
|
||||
<span className={`aa-step-nav__item${currentStep === 1 ? " active" : ""}`}>
|
||||
1. 视频 / 配音 / 文案
|
||||
</span>
|
||||
<span className={`aa-step-nav__item${currentStep === 2 ? " active" : ""}`}>
|
||||
2. 对口型 / 标题 / 封面 / 生成
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="aa-page-body">
|
||||
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */}
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
{/* 面板1:出镜视频 */}
|
||||
<div className={`aa-panel aa-panel--s1${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>
|
||||
{/* 面板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--s1${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>
|
||||
{/* 面板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--s1-wide${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">
|
||||
<PanelScript
|
||||
scriptText={state.scriptText}
|
||||
onScriptTextChange={state.setScriptText}
|
||||
onOpenScriptModal={() => state.setShowScriptModal(true)}
|
||||
/>
|
||||
<div className="aa-step-btn-row">
|
||||
<button type="button" className="aa-btn aa-btn--primary" onClick={handleNextStep}>
|
||||
下一步 →
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* ════ 步骤 2:对口型预览(含插入画面)/ 标题配置 / 封面&生成 ════ */}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
{/* 面板:对口型预览 + 插入画面 */}
|
||||
<div className={`aa-panel aa-panel--s2-wide${collapsed.lipsync ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("lipsync")}>
|
||||
<span className="aa-panel__title">对口型预览</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelLipsyncPreview
|
||||
lipsyncJob={state.lipsyncJob}
|
||||
onGenerateLipsync={handleGenerateLipsync}
|
||||
bRollSegments={state.bRollSegments}
|
||||
onOpenBRollModal={() => state.setShowBRollModal(true)}
|
||||
onRemoveBRoll={state.removeBRollSegment}
|
||||
titleConfig={state.titleConfig}
|
||||
onTitlePositionChange={(pos) => state.updateTitleConfig(pos)}
|
||||
/>
|
||||
<div className="aa-step-btn-row">
|
||||
<button type="button" className="aa-btn" onClick={handlePrevStep}>
|
||||
← 上一步
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 面板4:标题配置 */}
|
||||
<div className={`aa-panel aa-panel--s2${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--s2${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 }))
|
||||
}
|
||||
titleConfig={state.titleConfig}
|
||||
onSmartCover={handleSmartCover}
|
||||
smartCoverLoading={smartCoverLoading}
|
||||
canSmartCover={state.lipsyncJob?.status === "completed"}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
summary={summary}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 素材库弹窗 */}
|
||||
@@ -488,186 +241,11 @@ const AiAvatarPage: React.FC = () => {
|
||||
open={state.showBRollModal}
|
||||
onClose={() => state.setShowBRollModal(false)}
|
||||
existingSegments={state.bRollSegments}
|
||||
scriptText={state.scriptText}
|
||||
outputDuration={state.lipsyncJob?.output_duration ?? 0}
|
||||
availableAssets={bRollAssets}
|
||||
onConfirm={state.addBRollSegment}
|
||||
onRemove={state.removeBRollSegment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 对口型生成弹窗 */}
|
||||
{showLipsyncModal && (
|
||||
<div className="aa-modal-overlay">
|
||||
<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={handleCancelLipsync}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="aa-modal__body"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
padding: "40px 20px",
|
||||
}}
|
||||
>
|
||||
{lipsyncStatus === "generating" && (
|
||||
<>
|
||||
<div className="aa-lipsync-spinner" />
|
||||
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
|
||||
对口型视频生成中…
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
|
||||
请勿关闭页面,完成后将自动提示
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{lipsyncStatus === "completed" && (
|
||||
<>
|
||||
<div style={{ fontSize: 48 }}>✅</div>
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
|
||||
对口型视频生成完成
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{lipsyncStatus === "failed" && (
|
||||
<>
|
||||
<div style={{ fontSize: 48 }}>❌</div>
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
|
||||
对口型生成失败
|
||||
</div>
|
||||
{lipsyncErrorMessage && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: "#ff4d4f" }}>
|
||||
{lipsyncErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-modal__footer">
|
||||
{lipsyncStatus === "generating" && (
|
||||
<button className="aa-btn aa-btn--danger" onClick={handleCancelLipsync}>
|
||||
取消生成
|
||||
</button>
|
||||
)}
|
||||
{lipsyncStatus !== "generating" && (
|
||||
<button className="aa-btn" onClick={handleCancelLipsync}>
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 渲染进度弹窗 */}
|
||||
{showRenderModal && (
|
||||
<div className="aa-modal-overlay">
|
||||
<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={handleCancelRender}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="aa-modal__body"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
padding: "40px 20px",
|
||||
}}
|
||||
>
|
||||
{renderStatus === "generating" && (
|
||||
<>
|
||||
<div className="aa-lipsync-spinner" />
|
||||
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
|
||||
正在生成视频,请稍后
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: "#1890ff",
|
||||
}}
|
||||
>
|
||||
{renderProgress}%
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
width: "80%",
|
||||
height: 8,
|
||||
backgroundColor: "#f0f0f0",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${renderProgress}%`,
|
||||
height: "100%",
|
||||
backgroundColor: "#1890ff",
|
||||
borderRadius: 4,
|
||||
transition: "width 0.5s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: "#8c8ca1" }}>
|
||||
请勿关闭页面,完成后将自动提示
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{renderStatus === "completed" && (
|
||||
<>
|
||||
<div style={{ fontSize: 48 }}>✅</div>
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
|
||||
视频已保存到成片库
|
||||
</div>
|
||||
<button
|
||||
className="aa-btn"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => {
|
||||
setShowRenderModal(false)
|
||||
navigate("/app/products")
|
||||
}}
|
||||
>
|
||||
📁 查看成片
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{renderStatus === "failed" && (
|
||||
<>
|
||||
<div style={{ fontSize: 48 }}>❌</div>
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>视频渲染失败</div>
|
||||
{renderErrorMessage && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: "#ff4d4f" }}>
|
||||
{renderErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-modal__footer">
|
||||
{renderStatus === "generating" && (
|
||||
<button className="aa-btn aa-btn--danger" onClick={handleCancelRender}>
|
||||
关闭窗口
|
||||
</button>
|
||||
)}
|
||||
{renderStatus !== "generating" && (
|
||||
<button className="aa-btn" onClick={handleCancelRender}>
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -686,8 +264,8 @@ const ScriptSelectModalLazy: React.FC<{
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getScripts()
|
||||
.then((items) => setScripts(Array.isArray(items) ? items : []))
|
||||
.catch(() => setScripts([]))
|
||||
.then(setScripts)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
/**
|
||||
* AI数字人 — API 封装(#1822 契约对齐)
|
||||
* 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 : []
|
||||
const response = await apiClient.get<Script[]>("/scripts")
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getScriptById = async (id: string): Promise<Script> => {
|
||||
@@ -28,48 +24,18 @@ export const deleteScript = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
|
||||
/* ── 素材单查(拿到 file_url 作为对口型的 video_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
|
||||
}
|
||||
|
||||
/* ── 对口型(模式A:TTS 直生,后端内部合成音频;不要先调 TTS 拿 audio_url) ── */
|
||||
/* ── 对口型 ── */
|
||||
export const createLipsyncJob = async (data: {
|
||||
/** 人物视频 URL(MP4);由素材 id 经 getAssetById 拿 file_url,禁止传 video_asset_id */
|
||||
video_url: string
|
||||
/** 音色 ID(预置音色 或 克隆音色 profile UUID,后端会解析) */
|
||||
voice_id: string
|
||||
/** 要合成的文案(手动输入或文案库内容) */
|
||||
script_text: string
|
||||
/** 语速 0.5~2.0,默认 1.0 */
|
||||
speed?: number
|
||||
/** 情绪英文枚举:natural/excited/calm/friendly */
|
||||
emotion?: string
|
||||
enable_video_loop?: boolean
|
||||
project_id?: string
|
||||
video_asset_id: string
|
||||
}): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧 + 可选 drawtext 标题叠加) ── */
|
||||
export const generateSmartCover = async (
|
||||
video_url: string,
|
||||
title_config?: Record<string, unknown> | null,
|
||||
max_frames = 5,
|
||||
): Promise<{ cover_url: string; status: string; message: string }> => {
|
||||
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
|
||||
"/ai-avatar/render/smart-cover",
|
||||
{ video_url, max_frames, title_config: title_config ?? null },
|
||||
// smart-cover 链路:下载视频+抽帧+drawtext 加标题+上传 OSS,需要较长时间,120s 超时
|
||||
{ timeout: 120000 },
|
||||
)
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -81,13 +47,14 @@ export const submitRender = async (data: {
|
||||
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}`, { timeout: 60000 })
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* AI数字人 — 出镜视频选择弹窗(#1809 ③)
|
||||
* 交互对齐智能剪辑 Step2:先选素材库(video 库)→ 再选该库内视频。
|
||||
* 搜索框 + 素材库下拉 + 竖屏 9:16 视频缩略图网格 + 底部确认选择。
|
||||
* AI数字人 — 素材库弹窗
|
||||
* 搜索框 + 类型筛选(全部/视频/图片)+ 4 列竖屏 9:16 缩略图网格 + 底部确认选择
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
|
||||
/** 素材类型筛选 */
|
||||
type AssetKindFilter = "all" | "video" | "image"
|
||||
|
||||
export interface ModalAssetPickerProps {
|
||||
open: boolean
|
||||
@@ -14,76 +17,86 @@ export interface ModalAssetPickerProps {
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
const KIND_OPTIONS: { value: AssetKindFilter; label: string }[] = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]
|
||||
|
||||
export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalAssetPickerProps) {
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [kindFilter, setKindFilter] = useState<AssetKindFilter>("video")
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [pickedId, setPickedId] = useState<string | null>(null)
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
/* 弹窗打开:重置状态 */
|
||||
/* 弹窗打开:重置筛选 / 关键字,并定位高亮到已选素材 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setKeyword("")
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setKindFilter("video")
|
||||
setAssets([])
|
||||
setError("")
|
||||
setPickedId(selectedId ?? null)
|
||||
setReady(false)
|
||||
// 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)
|
||||
})
|
||||
const ensureLibraries = async () => {
|
||||
try {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
await Promise.all([
|
||||
ensureDefaultLibrary({ project_id: project.id, kind: "video" }),
|
||||
ensureDefaultLibrary({ project_id: project.id, kind: "image" }),
|
||||
])
|
||||
if (!cancelled) setReady(true)
|
||||
} catch {
|
||||
if (!cancelled) setError("素材库初始化失败,请重试")
|
||||
}
|
||||
}
|
||||
ensureLibraries()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 第二步:选中库后拉取该库视频素材(关键字防抖) */
|
||||
/* 拉取素材:类型 / 关键字变化时防抖重新请求 */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
if (!open || !ready) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
setLoading(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("")
|
||||
const kw = keyword.trim() || undefined
|
||||
let items: AssetItem[] = []
|
||||
if (kindFilter === "all") {
|
||||
const [videos, images] = await Promise.all([
|
||||
getAssetsByKind("video", { keyword: kw }),
|
||||
getAssetsByKind("image", { keyword: kw }),
|
||||
])
|
||||
const seen = new Set<string>()
|
||||
items = [...videos, ...images].filter((a) => {
|
||||
if (seen.has(a.id)) return false
|
||||
seen.add(a.id)
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
items = await getAssetsByKind(kindFilter, { keyword: kw })
|
||||
}
|
||||
if (!cancelled) setAssets(items)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("素材加载失败,请重试")
|
||||
setAssets([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(load, 300)
|
||||
@@ -91,7 +104,7 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [open, libraryId, keyword])
|
||||
}, [open, ready, kindFilter, keyword])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -107,33 +120,15 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择出镜视频</span>
|
||||
<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"
|
||||
@@ -141,14 +136,21 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="aa-select"
|
||||
style={{ width: 110, flex: "0 0 auto" }}
|
||||
value={kindFilter}
|
||||
onChange={(e) => setKindFilter(e.target.value as AssetKindFilter)}
|
||||
>
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{libraries.length === 0 && !loadingLibs ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无视频素材库,请先在「素材库」中创建视频库并上传视频
|
||||
</div>
|
||||
) : loadingAssets ? (
|
||||
{loading ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
@@ -160,8 +162,8 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
该素材库暂无视频素材
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无素材
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-asset-grid">
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
/**
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗(#1809 ④⑤⑥)
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗
|
||||
*
|
||||
* 布局:
|
||||
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
|
||||
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
|
||||
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
|
||||
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算)
|
||||
* - 底部:已配置的画面插入列表(可删除)
|
||||
* - 左侧:可用素材网格(已被其他 segment 使用的素材标灰 + "已选择" 遮罩,
|
||||
* pointer-events: none 防止重复选择同一段素材)
|
||||
* - 右侧:插入设置(文案段落索引 / 全屏 or 画中画 / 画中画四角位置 + 大小 / 起止时间)
|
||||
* - 底部:已配置的画面插入列表(可删除)+ 上传新素材入口
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import type { AssetItem } 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
|
||||
/** 所有可用素材 */
|
||||
availableAssets: AssetItem[]
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
@@ -42,31 +38,18 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
existingSegments,
|
||||
scriptText,
|
||||
outputDuration,
|
||||
availableAssets,
|
||||
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 [scriptSegmentIndex, setScriptSegmentIndex] = useState(0)
|
||||
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],
|
||||
)
|
||||
const [startTime, setStartTime] = useState(0)
|
||||
const [endTime, setEndTime] = useState(3)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
const usedAssetIds = useMemo(
|
||||
@@ -74,83 +57,25 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
[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)
|
||||
// 默认起止时间:素材时长的前 3 秒(或整段)
|
||||
const dur = asset.duration ?? 3
|
||||
setEndTime(Math.min(3, dur))
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止) */
|
||||
/** 确认添加一段 B-roll */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset || !selectedSentence) return
|
||||
const startTime = selectedSentence.startTime
|
||||
const endTime = Math.max(selectedSentence.endTime, startTime + 0.5)
|
||||
if (!selectedAsset) return
|
||||
if (endTime <= startTime) return
|
||||
const segment: BRollSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
asset: selectedAsset,
|
||||
script_segment_index: selectedSentence.index,
|
||||
script_segment_index: scriptSegmentIndex,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
mode,
|
||||
@@ -158,16 +83,15 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
pip_scale: mode === "pip" ? pipScale : 0.3,
|
||||
}
|
||||
onConfirm(segment)
|
||||
// 重置素材/句子选择,保留模式设置便于连续添加
|
||||
// 重置选择,保留设置便于连续添加
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
}
|
||||
|
||||
const canConfirm = selectedAsset !== null && selectedSentence !== null
|
||||
const canConfirm = selectedAsset !== null && endTime > startTime
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal aa-modal--wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">🎞️ 画面插入(B-roll)</span>
|
||||
@@ -179,25 +103,22 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
{/* 主体:左素材 + 右设置 */}
|
||||
<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
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#1a1a2e" }}>
|
||||
选择素材({availableAssets.length})
|
||||
</span>
|
||||
<button type="button" className="aa-btn aa-btn--ghost aa-btn-sm">
|
||||
⬆️ 上传新素材
|
||||
</button>
|
||||
</div>
|
||||
<div className="aa-broll-asset-grid">
|
||||
{availableAssets.map((asset) => {
|
||||
@@ -220,60 +141,45 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="aa-broll-asset-placeholder">🎬</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
)}
|
||||
<span className="aa-asset-card__name">{asset.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{loadingAssets ? (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : availableAssets.length === 0 ? (
|
||||
{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>
|
||||
)}
|
||||
<label className="aa-label">文案段落索引</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={scriptSegmentIndex}
|
||||
onChange={(e) => setScriptSegmentIndex(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 插入模式 */}
|
||||
@@ -318,7 +224,10 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<div className="aa-field-label-row">
|
||||
<div
|
||||
className="aa-field-label-row"
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<label className="aa-label">画中画大小</label>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1" }}>
|
||||
{Math.round(pipScale * 100)}%
|
||||
@@ -337,26 +246,41 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 当前选择提示(⑥ 自动估算时间在这里展示) */}
|
||||
<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 className="aa-form-field">
|
||||
<label className="aa-label">开始时间(秒)</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">结束时间(秒)</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前选中素材提示 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: selectedAsset ? "#059669" : "#8c8ca1",
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
}}
|
||||
>
|
||||
{selectedAsset ? `已选素材:${selectedAsset.name}` : "请从左侧选择一段素材"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,7 +304,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
<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.script_segment_index} · {MODE_LABEL[seg.mode]}
|
||||
{seg.mode === "pip" ? ` · ${seg.pip_position}` : ""} ·{" "}
|
||||
{seg.start_time.toFixed(1)}s - {seg.end_time.toFixed(1)}s
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
/**
|
||||
* AI数字人 — 面板5:封面 & 生成
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)+ 标题文字实时叠加预览
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
|
||||
* - 分辨率选择(720p / 1080p / 4K)
|
||||
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
|
||||
* - 渐变紫色生成按钮
|
||||
*
|
||||
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
|
||||
*/
|
||||
import React, { useMemo, useRef } from "react"
|
||||
import type { AiAvatarCoverConfig, AiAvatarTitleConfig } from "../types"
|
||||
import React, { useRef } from "react"
|
||||
import type { AiAvatarCoverConfig } from "../types"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution: string
|
||||
onResolutionChange: (r: string) => void
|
||||
isGenerating: boolean
|
||||
onGenerate: () => void
|
||||
/** 智能获取封面(MediaKit 选帧) */
|
||||
onSmartCover: () => void
|
||||
smartCoverLoading: boolean
|
||||
canSmartCover: boolean
|
||||
/** 配置汇总信息 */
|
||||
summary: {
|
||||
videoName: string | null
|
||||
@@ -48,27 +43,13 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
failed: { text: "失败", cls: "aa-status-badge--failed" },
|
||||
}
|
||||
|
||||
/** 字体名 → CSS font-family 映射(与后端 drawtext 对齐) */
|
||||
const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
思源黑体: "'Noto Sans SC', 'Source Han Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||
思源宋体: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
|
||||
楷体: "KaiTi, 'STKaiti', serif",
|
||||
黑体: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
|
||||
}
|
||||
|
||||
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
coverConfig,
|
||||
titleConfig,
|
||||
onCoverConfigChange,
|
||||
resolution,
|
||||
onResolutionChange,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
onSmartCover,
|
||||
smartCoverLoading,
|
||||
canSmartCover,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -88,106 +69,33 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822) */
|
||||
const handleSmartCover = () => {
|
||||
/** 从视频截取(使用配置的帧时间,默认首帧) */
|
||||
const handleCaptureFromVideo = () => {
|
||||
onCoverConfigChange({ mode: "auto_frame" })
|
||||
onSmartCover()
|
||||
}
|
||||
|
||||
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
|
||||
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
|
||||
|
||||
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
|
||||
const coverUrl =
|
||||
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
|
||||
const hasCoverImage = Boolean(coverUrl)
|
||||
|
||||
/** 是否显示标题叠加层:有图、有文字、非加载中 */
|
||||
const showTitleOverlay =
|
||||
hasCoverImage && !smartCoverLoading && titleConfig.title.trim().length > 0
|
||||
|
||||
/** 计算标题叠加层的 inline 样式 */
|
||||
const titleOverlayStyle = useMemo<React.CSSProperties>(() => {
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
width: "90%",
|
||||
transform: "translateX(-50%)",
|
||||
textAlign: "center",
|
||||
boxSizing: "border-box",
|
||||
padding: "0 4px",
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
color: titleConfig.color || "#ffffff",
|
||||
fontSize: `${titleConfig.size}px`,
|
||||
fontFamily: getFontFamily(titleConfig.font),
|
||||
fontWeight: titleConfig.bold ? "bold" : "normal",
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
lineHeight: 1.3,
|
||||
pointerEvents: "none",
|
||||
}
|
||||
|
||||
// 位置
|
||||
const pos = titleConfig.position || "bottom"
|
||||
if (pos === "top") {
|
||||
style.top = "40px"
|
||||
} else if (pos === "center") {
|
||||
style.top = "50%"
|
||||
style.transform = "translate(-50%, -50%)"
|
||||
} else if (pos === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null) {
|
||||
// pos_x/pos_y 是相对预览容器的百分比坐标
|
||||
style.left = `${titleConfig.pos_x}%`
|
||||
style.top = `${titleConfig.pos_y}%`
|
||||
style.transform = "translate(-50%, -50%)"
|
||||
} else {
|
||||
style.bottom = "40px"
|
||||
}
|
||||
|
||||
// 描边优先于阴影(二者互斥,与 drawtext 对齐)
|
||||
if (titleConfig.stroke) {
|
||||
// 描边宽度按字号估算,保证视觉一致
|
||||
const strokeWidth = Math.max(1, Math.round(titleConfig.size / 18))
|
||||
;(style as React.CSSProperties)["WebkitTextStroke"] = `${strokeWidth}px rgba(0,0,0,0.75)`
|
||||
style.textShadow = "none"
|
||||
} else if (titleConfig.shadow) {
|
||||
style.textShadow = "0 2px 8px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.5)"
|
||||
} else {
|
||||
// 默认给轻微阴影保证白字在亮背景可读
|
||||
style.textShadow = "0 2px 6px rgba(0,0,0,0.6)"
|
||||
}
|
||||
|
||||
return style
|
||||
}, [titleConfig])
|
||||
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 封面预览(竖屏 9:16) */}
|
||||
<div className="aa-cover-preview">
|
||||
{hasCoverImage ? (
|
||||
<img src={coverUrl!} alt="封面预览" draggable={false} />
|
||||
{coverConfig.thumbnail_url ? (
|
||||
<img src={coverConfig.thumbnail_url} alt="封面预览" />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">暂无封面</span>
|
||||
)}
|
||||
{/* 智能封面加载遮罩 */}
|
||||
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
|
||||
{/* 标题文字叠加层(实时预览,仅前端视觉参考,最终由后端 ffmpeg drawtext 叠加) */}
|
||||
{showTitleOverlay && (
|
||||
<div style={titleOverlayStyle} aria-hidden="true">
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleSmartCover}
|
||||
disabled={smartCoverLoading || !canSmartCover}
|
||||
title={canSmartCover ? "基于对口型成片智能选帧" : "请先完成对口型生成"}
|
||||
onClick={handleCaptureFromVideo}
|
||||
>
|
||||
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
|
||||
🎬 从视频截取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — 文案面板(步骤1用)
|
||||
* 文案库选择 / 手动输入 + 字数统计
|
||||
*/
|
||||
import { useState } from "react"
|
||||
|
||||
interface PanelScriptProps {
|
||||
scriptText: string
|
||||
onScriptTextChange: (text: string) => void
|
||||
onOpenScriptModal: () => void
|
||||
}
|
||||
|
||||
type ScriptTab = "library" | "manual"
|
||||
|
||||
export function PanelScript({
|
||||
scriptText,
|
||||
onScriptTextChange,
|
||||
onOpenScriptModal,
|
||||
}: PanelScriptProps) {
|
||||
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
|
||||
|
||||
return (
|
||||
<div className="aa-script-lipsync">
|
||||
{/* ── Tab 切换 ── */}
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelScript
|
||||
Executable → Regular
+63
-109
@@ -1,23 +1,23 @@
|
||||
/**
|
||||
* AI数字人 — 对口型预览面板(步骤2用)
|
||||
* B-roll 画面插入 + 对口型视频预览 + 生成/重新生成按钮
|
||||
* v3.1: 预览容器按 1/2 缩放、标题实时叠加预览
|
||||
* AI数字人 — 文案 & 对口型面板(面板4)
|
||||
* 上半区:文案(文案库选择 / 手动输入);下半区:对口型视频预览(9:16)+ B-roll 画面
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import type { LipsyncJob, BRollSegment, AiAvatarTitleConfig } from "../types"
|
||||
import { useState } from "react"
|
||||
import type { LipsyncJob, BRollSegment } from "../types"
|
||||
|
||||
interface PanelLipsyncPreviewProps {
|
||||
interface PanelScriptAndLipsyncProps {
|
||||
scriptText: string
|
||||
onScriptTextChange: (text: string) => void
|
||||
onOpenScriptModal: () => void
|
||||
lipsyncJob: LipsyncJob | null
|
||||
onGenerateLipsync: () => void
|
||||
bRollSegments: BRollSegment[]
|
||||
onOpenBRollModal: () => void
|
||||
onRemoveBRoll: (id: string) => void
|
||||
/** 标题配置(实时叠加预览用) */
|
||||
titleConfig?: AiAvatarTitleConfig
|
||||
/** 标题位置变更回调(拖拽结束时调用) */
|
||||
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number }) => void
|
||||
}
|
||||
|
||||
type ScriptTab = "library" | "manual"
|
||||
|
||||
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
|
||||
fullscreen: "全屏",
|
||||
pip: "画中画",
|
||||
@@ -29,18 +29,19 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function PanelLipsyncPreview({
|
||||
export function PanelScriptAndLipsync({
|
||||
scriptText,
|
||||
onScriptTextChange,
|
||||
onOpenScriptModal,
|
||||
lipsyncJob,
|
||||
onGenerateLipsync,
|
||||
bRollSegments,
|
||||
onOpenBRollModal,
|
||||
onRemoveBRoll,
|
||||
titleConfig,
|
||||
onTitlePositionChange,
|
||||
}: PanelLipsyncPreviewProps) {
|
||||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||||
const draggingTitleRef = useRef(false)
|
||||
const previewContainerRef = useRef<HTMLDivElement>(null)
|
||||
}: 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"
|
||||
@@ -52,66 +53,48 @@ export function PanelLipsyncPreview({
|
||||
? "排队中…"
|
||||
: "对口型生成中…"
|
||||
|
||||
/** 标题叠加样式 */
|
||||
const titleOverlayStyle: React.CSSProperties | null = titleConfig?.title
|
||||
? {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
color: titleConfig.color || "#ffffff",
|
||||
fontFamily: titleConfig.font || "思源黑体",
|
||||
fontSize: `${(titleConfig.size || 36) * 0.55}px`, // 预览等比缩
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
textAlign: "center",
|
||||
width: "90%",
|
||||
padding: "4px 8px",
|
||||
textShadow: titleConfig.shadow ? "0 2px 4px rgba(0,0,0,0.8)" : undefined,
|
||||
WebkitTextStroke: titleConfig.stroke ? "1.5px #000" : undefined,
|
||||
...(titleConfig.position === "top"
|
||||
? { top: 8 }
|
||||
: titleConfig.position === "bottom"
|
||||
? { bottom: 8 }
|
||||
: { top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
|
||||
}
|
||||
: null
|
||||
|
||||
const handleTitlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !previewContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
;(e.target as Element).setPointerCapture(e.pointerId)
|
||||
draggingTitleRef.current = true
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
|
||||
}
|
||||
const handleTitlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current || !previewContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (titleDragRef.current) {
|
||||
const rect = previewContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const xpct = (relX / rect.width) * 100
|
||||
const ypct = (relY / rect.height) * 100
|
||||
titleDragRef.current.style.left = `${xpct}%`
|
||||
titleDragRef.current.style.top = `${ypct}%`
|
||||
}
|
||||
}
|
||||
const handleTitlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current) return
|
||||
draggingTitleRef.current = false
|
||||
if (onTitlePositionChange && previewContainerRef.current) {
|
||||
const rect = previewContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
onTitlePositionChange({ pos_x: relX, pos_y: relY })
|
||||
}
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -174,36 +157,13 @@ export function PanelLipsyncPreview({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 对口型预览(v3.1: 缩放1/2 + 标题叠加) ─ */}
|
||||
{/* ── 下半区:对口型预览(竖屏 9:16) ── */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">对口型预览</div>
|
||||
|
||||
<div className="aa-lipsync-preview" ref={previewContainerRef}>
|
||||
<div className="aa-lipsync-preview">
|
||||
{isDone && lipsyncJob?.output_video_url ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
{titleOverlayStyle && (
|
||||
<div
|
||||
ref={titleDragRef}
|
||||
style={{
|
||||
...titleOverlayStyle,
|
||||
cursor: onTitlePositionChange ? "grab" : "default",
|
||||
pointerEvents: onTitlePositionChange ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
onPointerCancel={handleTitlePointerUp}
|
||||
>
|
||||
{titleConfig!.title.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
) : isGenerating ? (
|
||||
<div style={{ width: "80%", textAlign: "center", color: "#fff" }}>
|
||||
<div style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
@@ -223,13 +183,7 @@ export function PanelLipsyncPreview({
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>❌</div>
|
||||
<div>对口型生成失败</div>
|
||||
{lipsyncJob?.error_message && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
color: "#fca5a5",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, marginTop: 4, color: "#fca5a5" }}>
|
||||
{lipsyncJob.error_message}
|
||||
</div>
|
||||
)}
|
||||
@@ -265,4 +219,4 @@ export function PanelLipsyncPreview({
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelLipsyncPreview
|
||||
export default PanelScriptAndLipsync
|
||||
@@ -7,17 +7,16 @@
|
||||
* - AiAvatarTitleConfig ↔ TitleSettings 的双向适配
|
||||
* - 自动生成字幕开关
|
||||
*/
|
||||
import React, { useMemo, useState, useEffect } from "react"
|
||||
import { Input } from "antd"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
|
||||
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
|
||||
import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete"
|
||||
import type { TitleSettings } from "@/pages/generate/types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
TITLE_PRESETS,
|
||||
getFontFamily,
|
||||
} from "@/pages/generate/constants"
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
import { getTitles } from "@/api/titles"
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
interface PanelTitleConfigProps {
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
@@ -28,14 +27,6 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
|
||||
const [activePreset, setActivePreset] = useState<string | null>(null)
|
||||
|
||||
/** 标题库选项(复用智能剪辑的标题库) */
|
||||
const [titleOptions, setTitleOptions] = useState<TitleOption[]>([])
|
||||
useEffect(() => {
|
||||
getTitles()
|
||||
.then((items) => setTitleOptions(items.map((t) => ({ label: t.content, value: t.content }))))
|
||||
.catch(() => setTitleOptions([]))
|
||||
}, [])
|
||||
|
||||
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
|
||||
const titleSettings: TitleSettings = useMemo(
|
||||
() => ({
|
||||
@@ -71,32 +62,37 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
|
||||
return (
|
||||
<div className="aa-title-config">
|
||||
{/* 主标题输入 — TextArea 多行 + 标题库选择 */}
|
||||
{/* 主标题输入 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">主标题</label>
|
||||
<TextArea
|
||||
className="aa-title-input"
|
||||
placeholder="输入视频标题(支持 / 分行)"
|
||||
<input
|
||||
className="aa-input aa-title-input"
|
||||
type="text"
|
||||
placeholder="输入视频标题(留空则不显示标题)"
|
||||
value={titleConfig.title}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
maxLength={200}
|
||||
maxLength={30}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
style={{ fontSize: 15 }}
|
||||
/>
|
||||
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1", whiteSpace: "nowrap" }}>📚 标题库</span>
|
||||
<TitleLibraryAutoComplete
|
||||
key={titleConfig.title}
|
||||
placeholder="选择标题填入上方"
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
if (val) onUpdate({ title: val })
|
||||
{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",
|
||||
}}
|
||||
options={titleOptions}
|
||||
maxLength={200}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题样式:直接复用智能剪辑 TitleStylePanel(位置/字体/字号/样式/预设) */}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
* AI数字人 — 出镜视频选择面板
|
||||
* - 未选视频:虚线上传区,点击打开素材库弹窗
|
||||
* - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮
|
||||
*
|
||||
* 注意:本面板只展示原始素材视频,不叠加标题(标题在对口型预览和最终成片上展示)
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
@@ -55,7 +53,7 @@ export function PanelVideoSelector({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 竖屏 9:16 视频预览播放器(纯素材预览,不叠加标题) */}
|
||||
{/* 竖屏 9:16 视频预览播放器 */}
|
||||
<div className="aa-video-preview">
|
||||
{fileUrl ? (
|
||||
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import { fetchVoices } from "@/api/voices/voices"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { normalizeEmotion } from "../utils/contract"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
@@ -46,9 +43,6 @@ export function PanelVoiceSelector({
|
||||
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(() => {
|
||||
@@ -58,7 +52,7 @@ export function PanelVoiceSelector({
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchVoices({ type: voiceSource })
|
||||
if (!cancelled) setVoices(Array.isArray(res?.items) ? res.items : [])
|
||||
if (!cancelled) setVoices(res.items || [])
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "音色加载失败")
|
||||
} finally {
|
||||
@@ -89,20 +83,21 @@ export function PanelVoiceSelector({
|
||||
setPreviewingId(null)
|
||||
}
|
||||
|
||||
const NO_PREVIEW_TIP = "该音色暂无试听音频,请先用此音色生成一段配音后再试听"
|
||||
|
||||
/** 用指定 URL 真实播放(抽取公共) */
|
||||
const playAudioUrl = (voiceId: string, url: string) => {
|
||||
// 临时兼容:后端 /tts/preview 返回 HTTP URL,staging 是 HTTPS,Mixed Content 会阻止加载
|
||||
// OSS 同时支持 HTTP/HTTPS,直接替换协议即可
|
||||
const safeUrl = url.startsWith("http://") ? url.replace("http://", "https://") : url
|
||||
const handlePreview = (voice: UnifiedVoiceItem) => {
|
||||
const url = voice.preview_url || voice.audio_url
|
||||
if (!url) return
|
||||
/* 再次点击当前试听音色 → 停止 */
|
||||
if (previewingId === voice.id) {
|
||||
stopPreview()
|
||||
return
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(safeUrl)
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
setPreviewingId(voiceId)
|
||||
setPreviewingId(voice.id)
|
||||
audio.onended = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
@@ -113,80 +108,15 @@ export function PanelVoiceSelector({
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
message.error("试听音频加载失败")
|
||||
setError("试听音频加载失败")
|
||||
}
|
||||
}
|
||||
void audio.play().catch(() => {
|
||||
setPreviewingId(null)
|
||||
message.error("试听播放失败")
|
||||
setError("试听播放失败")
|
||||
})
|
||||
}
|
||||
|
||||
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: speed, // 透传用户选择的语速(#1822)
|
||||
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
|
||||
})
|
||||
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
|
||||
@@ -252,7 +182,7 @@ export function PanelVoiceSelector({
|
||||
type="button"
|
||||
className="aa-voice-card__preview"
|
||||
title={previewingId === voice.id ? "停止试听" : "试听"}
|
||||
disabled={voice.type === "preset" && !previewUrl}
|
||||
disabled={!previewUrl}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreview(voice)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — 标题库选择弹窗
|
||||
* 复用智能剪辑的标题库 API,选择标题后填入输入框
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import type { TitleItem } from "@/api/titles/types"
|
||||
|
||||
interface TitleLibraryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (title: string) => void
|
||||
}
|
||||
|
||||
const TitleLibraryModal: React.FC<TitleLibraryModalProps> = ({ open, onClose, onSelect }) => {
|
||||
const [titles, setTitles] = useState<TitleItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getTitles()
|
||||
.then((items) => setTitles(items))
|
||||
.catch(() => setTitles([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
const filtered = titles.filter(
|
||||
(t) => !search || t.content.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 600 }}>
|
||||
<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 style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
className="aa-input"
|
||||
placeholder="搜索标题..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: 40, color: "#8c8ca1" }}>加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: 40, color: "#8c8ca1" }}>
|
||||
暂无标题,请先在标题库创建
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 400, overflowY: "auto" }}>
|
||||
{filtered.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
marginBottom: 8,
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "#eef0ff")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "#f8f8fc")}
|
||||
onClick={() => {
|
||||
onSelect(t.content)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, color: "#1a1a2e", marginBottom: 4 }}>{t.content}</div>
|
||||
<div style={{ fontSize: 12, color: "#8c8ca1" }}>
|
||||
{t.word_count ?? t.content.length}字 ·{" "}
|
||||
{t.created_at ? new Date(t.created_at).toLocaleDateString() : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-modal__footer">
|
||||
<button className="aa-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleLibraryModal
|
||||
@@ -44,8 +44,6 @@ export interface LipsyncJob {
|
||||
status: LipsyncStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
|
||||
output_duration?: number
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -77,9 +75,6 @@ export interface AiAvatarTitleConfig {
|
||||
shadow: boolean
|
||||
color: string
|
||||
auto_subtitle: boolean
|
||||
/** 自定义位置坐标(position=custom 时生效,像素) */
|
||||
pos_x?: number
|
||||
pos_y?: number
|
||||
}
|
||||
|
||||
/* ── 封面配置 ── */
|
||||
@@ -89,8 +84,6 @@ export interface AiAvatarCoverConfig {
|
||||
frame_time: number
|
||||
upload_url: string | null
|
||||
thumbnail_url: string | null
|
||||
/** 智能封面(MediaKit 选帧)返回的 OSS 非临时 URL(#1822) */
|
||||
smart_cover_url: string | null
|
||||
}
|
||||
|
||||
/* ── 渲染任务 ── */
|
||||
@@ -108,7 +101,7 @@ export interface RenderJob {
|
||||
/* ── 默认值 ── */
|
||||
export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
title: "",
|
||||
position: "bottom",
|
||||
position: "top",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
@@ -117,8 +110,6 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
auto_subtitle: true,
|
||||
pos_x: undefined,
|
||||
pos_y: undefined,
|
||||
}
|
||||
|
||||
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
|
||||
@@ -127,5 +118,4 @@ export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
|
||||
frame_time: 0,
|
||||
upload_url: null,
|
||||
thumbnail_url: null,
|
||||
smart_cover_url: null,
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — 前后端接口契约转换工具(#1822)
|
||||
*
|
||||
* 以 packages/domain/video_filter_builder.py 的 build_title_drawtext_filter() 为唯一口径
|
||||
* (契约文档第 5 节的 titles[]/fontSize/frame/start/end 为误写,后端不认,禁止使用)。
|
||||
*/
|
||||
import type { AiAvatarTitleConfig, AiAvatarCoverConfig, VoiceEmotion } from "../types"
|
||||
|
||||
/* ── 情绪:中文 → 英文(防御性映射;state 默认已是英文) ── */
|
||||
const EMOTION_ZH_TO_EN: Record<string, VoiceEmotion> = {
|
||||
自然: "natural",
|
||||
兴奋: "excited",
|
||||
沉稳: "calm",
|
||||
亲切: "friendly",
|
||||
}
|
||||
const VALID_EMOTIONS: VoiceEmotion[] = ["natural", "excited", "calm", "friendly"]
|
||||
|
||||
/** 归一化为后端英文枚举 natural/excited/calm/friendly;非法/空值回退 natural。 */
|
||||
export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
|
||||
if (!raw) return "natural"
|
||||
const v = raw.trim()
|
||||
if ((VALID_EMOTIONS as string[]).includes(v)) return v as VoiceEmotion
|
||||
return EMOTION_ZH_TO_EN[v] ?? "natural"
|
||||
}
|
||||
|
||||
/* ── 标题:前端 state → 后端 build_title_drawtext_filter 字段(单个 title_config dict) ── */
|
||||
/**
|
||||
* 后端真实字段:text(或content)、font(或font_preset)、font_size(或size)、
|
||||
* font_color(或color,可传 #RRGGBB)、position(top/center/bottom/custom)、
|
||||
* enabled、bold、stroke{enabled,width,color}、shadow{enabled,color,offset_x,offset_y}、
|
||||
* pos_x/pos_y(custom 时)。
|
||||
* 口播标题默认 position=bottom(不传后端会默认 top 跑到画面顶部)。
|
||||
*/
|
||||
export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string, unknown> {
|
||||
const text = (cfg.title || "").trim()
|
||||
if (!text) return {}
|
||||
const position = cfg.position || "bottom"
|
||||
const payload: Record<string, unknown> = {
|
||||
text,
|
||||
enabled: true,
|
||||
font: cfg.font || "思源黑体",
|
||||
font_size: Math.round(cfg.size) || 36,
|
||||
font_color: cfg.color || "#ffffff",
|
||||
position,
|
||||
bold: !!cfg.bold,
|
||||
stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false },
|
||||
shadow: cfg.shadow
|
||||
? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 }
|
||||
: { enabled: false },
|
||||
}
|
||||
// 自定义坐标(custom 位置)
|
||||
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
|
||||
payload.pos_x = cfg.pos_x
|
||||
payload.pos_y = cfg.pos_y
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/* ── 封面:前端 state → 后端 render cover_config ── */
|
||||
export function buildCoverConfigPayload(
|
||||
cfg: AiAvatarCoverConfig,
|
||||
smartCoverUrl: string | null,
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
enabled: !!cfg.enabled,
|
||||
mode: cfg.mode,
|
||||
// build_cover_extract_command 读取 timestamp(截帧秒数)
|
||||
timestamp: cfg.frame_time || 0,
|
||||
}
|
||||
if (smartCoverUrl) payload.cover_url = smartCoverUrl
|
||||
// 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用
|
||||
if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) {
|
||||
payload.upload_url = cfg.upload_url
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @deprecated 请从 ./batch/ 目录导入子模块
|
||||
* 保持向后兼容,re-export 所有批量操作 Hook
|
||||
*/
|
||||
export { useBatchDelete } from "./batch/useBatchDelete"
|
||||
export { useBatchTag } from "./batch/useBatchTag"
|
||||
export { useBatchClassify } from "./batch/useBatchClassify"
|
||||
export { useBatchMark } from "./batch/useBatchMark"
|
||||
@@ -10,7 +10,7 @@
|
||||
============================================================ */
|
||||
.dup-page {
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
max-width: 1400px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* LayerConfig 入口(向后兼容)
|
||||
* 实际实现位于 ./layer-config/ 目录
|
||||
*/
|
||||
export { default } from "./layer-config"
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 混剪图层列表
|
||||
*/
|
||||
import React from "react"
|
||||
import type { PipLayer } from "@/pages/editing-planner/types"
|
||||
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerListProps {
|
||||
layers: PipLayer[]
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
onAdd: () => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onAdd, onDelete }) => {
|
||||
return (
|
||||
<div className="pip-layer-list">
|
||||
<div className="pip-toolbar" style={{ marginBottom: 8 }}>
|
||||
<button className="pip-add-btn" onClick={onAdd}>
|
||||
+ 添加图层
|
||||
</button>
|
||||
</div>
|
||||
{layers.length === 0 ? (
|
||||
<div className="pip-layer-empty">暂无图层,点击上方添加</div>
|
||||
) : (
|
||||
layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
|
||||
onClick={() => onSelect(layer.id)}
|
||||
>
|
||||
{layer.thumbnail_url || layer.material_url ? (
|
||||
<img
|
||||
className="pip-layer-thumb"
|
||||
src={layer.thumbnail_url || layer.material_url}
|
||||
alt={layer.name}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="pip-layer-thumb"
|
||||
style={{
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="pip-layer-name">{layer.name}</span>
|
||||
<button
|
||||
className="pip-layer-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(layer.id)
|
||||
}}
|
||||
title="删除图层"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayerList
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import React from "react"
|
||||
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
|
||||
import { GRID_POSITIONS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerPositionSizeProps {
|
||||
layer: PipLayer
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层位置与尺寸配置面板
|
||||
*/
|
||||
export const LayerPositionSize: React.FC<LayerPositionSizeProps> = ({
|
||||
layer,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => (
|
||||
<>
|
||||
{/* 素材类型 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "image" })}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "video" })}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材 URL */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{layer.material_type === "image" ? "图片" : "视频"} URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
layer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={layer.material_url}
|
||||
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 位置:九宫格 + 坐标 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => onGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.x}
|
||||
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.y}
|
||||
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>宽</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.width}
|
||||
onChange={(e) => onWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.width}%</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>高</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.height}
|
||||
onChange={(e) => onHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.height}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
|
||||
>
|
||||
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
|
||||
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 圆角 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={layer.border_radius}
|
||||
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.border_radius}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.opacity}
|
||||
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.opacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import React from "react"
|
||||
import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
|
||||
import { ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerTimingAnimationProps {
|
||||
layer: PipLayer
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层时间与动画配置面板
|
||||
*/
|
||||
export const LayerTimingAnimation: React.FC<LayerTimingAnimationProps> = ({
|
||||
layer,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
}) => (
|
||||
<>
|
||||
{/* 时间 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.start_time}
|
||||
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.duration}
|
||||
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 入场动画 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.animation}
|
||||
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{layer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.slide_direction}
|
||||
onChange={(e) =>
|
||||
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react"
|
||||
import type { PipLayer } from "@/pages/editing-planner/types"
|
||||
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface PipPreviewProps {
|
||||
layers: PipLayer[]
|
||||
selectedId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PIP 图层迷你预览组件
|
||||
*/
|
||||
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => (
|
||||
<div className="pip-preview-box">
|
||||
{layers.map((l, idx) => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`pip-preview-layer${selectedId === l.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${l.x}%`,
|
||||
top: `${l.y}%`,
|
||||
width: `${l.width}%`,
|
||||
height: `${l.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: l.opacity / 100,
|
||||
borderRadius: `${l.border_radius}%`,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{l.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 混剪单图层配置区
|
||||
*/
|
||||
import React from "react"
|
||||
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
|
||||
import { PipPreview } from "./PipPreview"
|
||||
import { LayerPositionSize } from "./LayerPositionSize"
|
||||
import { LayerTimingAnimation } from "./LayerTimingAnimation"
|
||||
|
||||
interface LayerConfigProps {
|
||||
layer: PipLayer | null
|
||||
layers: PipLayer[]
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
const LayerConfig: React.FC<LayerConfigProps> = ({
|
||||
layer,
|
||||
layers,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => {
|
||||
if (!layer) {
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
{/* 迷你预览 */}
|
||||
<PipPreview layers={layers} selectedId={layer.id} />
|
||||
|
||||
{/* 位置与尺寸 */}
|
||||
<LayerPositionSize
|
||||
layer={layer}
|
||||
onUpdate={onUpdate}
|
||||
onGridClick={onGridClick}
|
||||
onWidthChange={onWidthChange}
|
||||
onHeightChange={onHeightChange}
|
||||
/>
|
||||
|
||||
{/* 时间与动画 */}
|
||||
<LayerTimingAnimation layer={layer} totalDuration={totalDuration} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayerConfig
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 贴纸素材库(emoji / 图片 / 文字花字)
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import type { StickerType, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import {
|
||||
EMOJI_LIST,
|
||||
STICKER_TYPE_TABS,
|
||||
TEXT_PRESET_STYLES,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface StickerLibraryProps {
|
||||
activeTab: StickerType
|
||||
onTabChange: (tab: StickerType) => void
|
||||
onAddSticker: (type: StickerType, content: string) => void
|
||||
}
|
||||
|
||||
const StickerLibrary: React.FC<StickerLibraryProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onAddSticker,
|
||||
}) => {
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const imageInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAddImage = () => {
|
||||
const val = imageInputRef.current?.value.trim()
|
||||
if (val) {
|
||||
onAddSticker("image", val)
|
||||
if (imageInputRef.current) imageInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddText = () => {
|
||||
if (textInput.trim()) {
|
||||
onAddSticker("text", textInput.trim())
|
||||
setTextInput("")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 类型 Tab */}
|
||||
<div className="sticker-tabs">
|
||||
{STICKER_TYPE_TABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
className={`sticker-tab${activeTab === t.value ? " active" : ""}`}
|
||||
onClick={() => onTabChange(t.value)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容区 */}
|
||||
<div className="sticker-tab-content">
|
||||
{/* Emoji 素材库 */}
|
||||
{activeTab === "emoji" && (
|
||||
<div className="sticker-emoji-grid">
|
||||
{EMOJI_LIST.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
className="sticker-emoji-btn"
|
||||
onClick={() => onAddSticker("emoji", emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片贴纸 */}
|
||||
{activeTab === "image" && (
|
||||
<div className="sticker-image-input">
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="text"
|
||||
className="sticker-url-input"
|
||||
placeholder="输入图片 URL 添加贴纸..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value.trim()) {
|
||||
handleAddImage()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="sticker-url-add-btn" onClick={handleAddImage}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文字花字 */}
|
||||
{activeTab === "text" && (
|
||||
<div className="sticker-text-section">
|
||||
<div className="sticker-text-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-text-input"
|
||||
placeholder="输入文字内容..."
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="sticker-text-add-btn"
|
||||
disabled={!textInput.trim()}
|
||||
onClick={handleAddText}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="sticker-text-presets">
|
||||
<div className="sticker-preset-title">花字预设预览</div>
|
||||
<div className="sticker-preset-grid">
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="sticker-preset-preview"
|
||||
style={{
|
||||
background:
|
||||
p === "bubble"
|
||||
? "rgba(0,0,0,0.5)"
|
||||
: p === "gradient"
|
||||
? "linear-gradient(90deg,#f093fb,#f5576c)"
|
||||
: "#1a1a2e",
|
||||
}}
|
||||
>
|
||||
<span style={TEXT_PRESET_STYLES[p]}>示例</span>
|
||||
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StickerLibrary
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 已添加贴纸列表
|
||||
*/
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
|
||||
interface StickerListProps {
|
||||
items: StickerItem[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const StickerList: React.FC<StickerListProps> = ({ items, selectedId, onSelect, onDelete }) => {
|
||||
if (items.length === 0) return null
|
||||
|
||||
const getDisplayContent = (item: StickerItem) => {
|
||||
if (item.type === "emoji") return { icon: item.content, name: "表情贴纸" }
|
||||
if (item.type === "text") return { icon: "T", name: item.content.slice(0, 10) }
|
||||
return { icon: "🖼", name: "图片贴纸" }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticker-list-section">
|
||||
<div className="sticker-section-title">已添加贴纸 ({items.length})</div>
|
||||
<div className="sticker-list">
|
||||
{items.map((item) => {
|
||||
const { icon, name } = getDisplayContent(item)
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="sticker-list-icon">{icon}</span>
|
||||
<span className="sticker-list-name">{name}</span>
|
||||
<button
|
||||
className="sticker-list-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(item.id)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StickerList
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { TEXT_PRESET_STYLES } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface StickerPreviewProps {
|
||||
sticker: StickerItem
|
||||
}
|
||||
|
||||
export const StickerPreview: React.FC<StickerPreviewProps> = ({ sticker }) => (
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.height}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 选中贴纸的属性编辑器
|
||||
*/
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { StickerPreview } from "./StickerPreview"
|
||||
import { TextStickerPropsEditor } from "./TextStickerPropsEditor"
|
||||
|
||||
interface StickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sticker-props-section">
|
||||
<div className="sticker-section-title">属性调整</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 X</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.x}
|
||||
onChange={(e) => onUpdate(sticker.id, { x: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.x}%</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 Y</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.y}
|
||||
onChange={(e) => onUpdate(sticker.id, { y: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.y}%</span>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">大小</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={5}
|
||||
max={50}
|
||||
value={sticker.width}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, {
|
||||
width: Number(e.target.value),
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.width}%</span>
|
||||
</div>
|
||||
|
||||
{/* 旋转 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">旋转</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={sticker.rotation}
|
||||
onChange={(e) => onUpdate(sticker.id, { rotation: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.rotation}°</span>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">透明度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.opacity}
|
||||
onChange={(e) => onUpdate(sticker.id, { opacity: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.opacity}%</span>
|
||||
</div>
|
||||
|
||||
{/* 时间 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">开始</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={sticker.start_time}
|
||||
onChange={(e) => onUpdate(sticker.id, { start_time: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-label">时长</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={sticker.duration}
|
||||
onChange={(e) => onUpdate(sticker.id, { duration: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
<TextStickerPropsEditor sticker={sticker} onUpdate={onUpdate} />
|
||||
|
||||
{/* 预览 */}
|
||||
<StickerPreview sticker={sticker} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StickerPropsEditor
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface TextStickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
onUpdate: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
export const TextStickerPropsEditor: React.FC<TextStickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
onUpdate,
|
||||
}) => {
|
||||
if (sticker.type !== "text") return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* TTS 滑块组件(语速/语调/音量)
|
||||
*/
|
||||
import React from "react"
|
||||
import { Slider } from "antd"
|
||||
|
||||
interface TtsSliderProps {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
unit?: string
|
||||
onChange: (val: number) => void
|
||||
marks?: string[]
|
||||
tooltipFormatter?: (v: number) => string
|
||||
}
|
||||
|
||||
const TtsSlider: React.FC<TtsSliderProps> = ({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
unit = "",
|
||||
onChange,
|
||||
marks,
|
||||
tooltipFormatter,
|
||||
}) => {
|
||||
const displayValue =
|
||||
unit === "x"
|
||||
? `${value.toFixed(2)}x`
|
||||
: label === "语调"
|
||||
? `${value > 0 ? "+" : ""}${value} 半音`
|
||||
: `${value}${unit}`
|
||||
|
||||
return (
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">{label}</span>
|
||||
<span className="tts-slider-value">{displayValue}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as number)}
|
||||
tooltip={tooltipFormatter ? { formatter: (v) => tooltipFormatter(v as number) } : undefined}
|
||||
/>
|
||||
{marks && (
|
||||
<div className="tts-slider-marks">
|
||||
{marks.map((m, i) => (
|
||||
<span key={i}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TtsSlider
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* TTS 音色选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TTSVoice } from "@/api/tts"
|
||||
import { VOICE_CATEGORY_MAP } from "../../constants/tts"
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
voices: TTSVoice[]
|
||||
voicesLoading: boolean
|
||||
selectedVoiceId: string
|
||||
onVoiceSelect: (voiceId: string) => void
|
||||
}
|
||||
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
voices,
|
||||
voicesLoading,
|
||||
selectedVoiceId,
|
||||
onVoiceSelect,
|
||||
}) => {
|
||||
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
|
||||
|
||||
return (
|
||||
<div className="tts-voice-section">
|
||||
<div className="tts-voice-label">
|
||||
选择音色
|
||||
{voicesLoading && <span className="tts-voice-loading">加载中...</span>}
|
||||
</div>
|
||||
<div className="tts-voice-grid">
|
||||
{voiceCategories.map(([cat, info]) => {
|
||||
const voice = voices.find((v) => v.category === cat)
|
||||
const isSelected = voice && selectedVoiceId === voice.id
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
className={`tts-voice-card${isSelected ? " active" : ""}`}
|
||||
onClick={() => voice && onVoiceSelect(voice.id)}
|
||||
disabled={!voice || voicesLoading}
|
||||
>
|
||||
<span className="tts-voice-card-icon">{info.icon}</span>
|
||||
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceSelector
|
||||
@@ -548,21 +548,10 @@ const GeneratePage: React.FC = () => {
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginBottom: 12,
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
🎬 确认生成
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
/* ============================================================
|
||||
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,9 +5,6 @@
|
||||
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: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.mt-page {
|
||||
padding: var(--space-xl);
|
||||
max-width: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
/**
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选、无限滚动分页
|
||||
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 列表查询 → hooks/useProductList(useInfiniteQuery 分页)
|
||||
* 列表查询 → hooks/useProductList
|
||||
* 操作逻辑 → hooks/useProductActions
|
||||
* 筛选栏 → components/ProductFilterBar
|
||||
* 批量操作栏 → components/ProductBatchBar
|
||||
* 空状态 → components/ProductEmptyState
|
||||
* 产品卡片 → components/ProductCard(内联视频播放)
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import React from "react"
|
||||
import { VideoCameraOutlined, DownloadOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import { ProductCard } from "./components/ProductCard"
|
||||
import { ProductFilterBar } from "./components/ProductFilterBar"
|
||||
@@ -29,13 +24,11 @@ import "./products.css"
|
||||
|
||||
const ProductLibrary: React.FC = () => {
|
||||
const {
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
searchText,
|
||||
setSearchText,
|
||||
@@ -71,40 +64,19 @@ const ProductLibrary: React.FC = () => {
|
||||
} = useProductActions({
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
products: filteredProducts,
|
||||
products,
|
||||
setPlayingProduct: () => {}, // 不再使用弹窗播放
|
||||
})
|
||||
|
||||
const { recomputeDedup, isRecomputing } = useRecomputeDedup()
|
||||
|
||||
/* ── 无限滚动:IntersectionObserver 监听底部哨兵元素 ── */
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
// 已有数据但正在加载中/没有更多页时不触发
|
||||
if (isFetchingNextPage || !hasNextPage) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
void fetchNextPage()
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
|
||||
|
||||
// ── Loading 状态(仅首次加载)──
|
||||
if (isLoading && filteredProducts.length === 0) {
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
return <ProductEmptyState type="loading" />
|
||||
}
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError && filteredProducts.length === 0) {
|
||||
if (isError) {
|
||||
console.error("[ProductLibrary] 加载失败:", error)
|
||||
const errorMsg = error?.message || "加载失败"
|
||||
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found")
|
||||
@@ -171,46 +143,22 @@ const ProductLibrary: React.FC = () => {
|
||||
|
||||
{/* 卡片网格 */}
|
||||
{filteredProducts.length > 0 ? (
|
||||
<>
|
||||
<div className="xx-products-grid">
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
isSelected={selectedIds.has(product.id)}
|
||||
batchMode={batchMode}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onReviewStatusChange={handleReviewStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部哨兵 + 状态提示 */}
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
style={{
|
||||
gridColumn: "1 / -1",
|
||||
textAlign: "center",
|
||||
padding: "24px 0",
|
||||
fontSize: 13,
|
||||
color: "#8c8ca1",
|
||||
}}
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<LoadingOutlined /> 加载中…
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<span style={{ opacity: 0 }}>加载更多</span>
|
||||
) : (
|
||||
<span>—— 已加载全部 ——</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="xx-products-grid">
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
isSelected={selectedIds.has(product.id)}
|
||||
batchMode={batchMode}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onReviewStatusChange={handleReviewStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ProductEmptyState type="empty" />
|
||||
)}
|
||||
|
||||
@@ -1,53 +1,28 @@
|
||||
import { useMemo } from "react"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
|
||||
import { mapApiProduct } from "../../utils"
|
||||
import type { ProductItem } from "../../types"
|
||||
import { useProductFiltering } from "./useProductFiltering"
|
||||
import { useBatchSelection } from "./useBatchSelection"
|
||||
|
||||
export type { Filters } from "./useProductFiltering"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const useProductList = () => {
|
||||
/* ── 无限滚动获取成品列表(每页 20 条) ── */
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data,
|
||||
data: apiProducts = [],
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery<
|
||||
{
|
||||
items: ApiProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
},
|
||||
Error
|
||||
>({
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: async ({ pageParam = 1 }) =>
|
||||
getProducts({ page: pageParam as number, page_size: PAGE_SIZE }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const loadedCount = lastPage.page * lastPage.page_size
|
||||
return loadedCount < lastPage.total ? lastPage.page + 1 : undefined
|
||||
},
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 将所有页拼接为一维数组,再做前端映射+排序
|
||||
const apiProducts = useMemo<ApiProductItem[]>(() => {
|
||||
if (!data?.pages) return []
|
||||
return data.pages.flatMap((p) => p.items)
|
||||
}, [data])
|
||||
|
||||
const products = useMemo<ProductItem[]>(
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(
|
||||
() =>
|
||||
(Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
@@ -90,11 +65,8 @@ export const useProductList = () => {
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
// 筛选
|
||||
searchText,
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* 文案库页面 — 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
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 文案库页面 - 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: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,6 @@ 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")),
|
||||
|
||||
@@ -36,10 +36,10 @@ import "@/pages/assets/hooks/useLibraryManagement"
|
||||
import "@/pages/assets/hooks/useAssetUpload"
|
||||
import "@/pages/assets/hooks/useAssetSelection"
|
||||
import "@/pages/assets/hooks/useAssetOperations"
|
||||
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchDelete"
|
||||
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchTag"
|
||||
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchClassify"
|
||||
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchMark"
|
||||
import "@/pages/assets/hooks/asset-operations/batch/useBatchDelete"
|
||||
import "@/pages/assets/hooks/asset-operations/batch/useBatchTag"
|
||||
import "@/pages/assets/hooks/asset-operations/batch/useBatchClassify"
|
||||
import "@/pages/assets/hooks/asset-operations/batch/useBatchMark"
|
||||
|
||||
describe("AssetLibrary module smoke test", () => {
|
||||
it("should load all asset modules", () => {
|
||||
|
||||
@@ -53,6 +53,13 @@ import "@/pages/editing-planner/components/clip-properties/SubtitleSettingsSecti
|
||||
import "@/pages/editing-planner/components/clip-properties/BgmSettingsSection"
|
||||
import "@/pages/editing-planner/components/clip-properties/ClipDetailSection"
|
||||
import "@/pages/editing-planner/components/clip-properties/StatsSection"
|
||||
import "@/pages/editing-planner/components/pip-config/LayerList"
|
||||
import "@/pages/editing-planner/components/pip-config/LayerConfig"
|
||||
import "@/pages/editing-planner/components/sticker/StickerLibrary"
|
||||
import "@/pages/editing-planner/components/sticker/StickerList"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPreview"
|
||||
import "@/pages/editing-planner/components/sticker/TextStickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/filter/FilterPresetGrid"
|
||||
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
|
||||
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
|
||||
@@ -60,6 +67,8 @@ import "@/pages/editing-planner/components/subtitle-style/SubtitlePreview"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitleModeSwitch"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitlePositionSelector"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitleEffectButtons"
|
||||
import "@/pages/editing-planner/components/tts/VoiceSelector"
|
||||
import "@/pages/editing-planner/components/tts/TtsSlider"
|
||||
import "@/pages/editing-planner/components/watermark/WatermarkTypeTabs"
|
||||
import "@/pages/editing-planner/components/watermark/ImageWatermarkSection"
|
||||
import "@/pages/editing-planner/components/watermark/TextWatermarkSection"
|
||||
|
||||
@@ -34,15 +34,9 @@ celery_app.conf.imports = (
|
||||
"worker_app.tasks.tts_synthesis",
|
||||
"worker_app.tasks.batch_download",
|
||||
"worker_app.tasks.duplication_check",
|
||||
# #1798 AI 数字人渲染:必须在 Worker 实例上注册同名任务,否则消息无人消费(渲染卡 0%)
|
||||
"worker_app.tasks.ai_avatar_render",
|
||||
"worker_app.tasks._startup",
|
||||
"apps.worker.video_processing.dedup",
|
||||
"worker_app.tasks.cleanup",
|
||||
# 注意:必须用 app.* 路径,不能用 apps.api.app.* 路径!
|
||||
# PYTHONPATH=/app/apps/api 下,app.tasks.lipsync_tts 可直接导入且不触发 apps/api/__init__.py
|
||||
# (apps/api/__init__.py 会 from .main import app,级联加载整个 FastAPI 栈,Worker 中不需要且会导致注册失败)
|
||||
"app.tasks.lipsync_tts",
|
||||
)
|
||||
|
||||
# Celery Beat 定时任务调度
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""AI 数字人渲染任务 — Worker 侧 Celery 任务注册.
|
||||
|
||||
#1798 渲染进度卡在 0% 的根因:渲染任务定义在 API 侧(`app.tasks.ai_avatar_render`),
|
||||
装饰在 API 自己的 Celery 实例(`xiaoxia-saas-api`)上;而 Worker 用的是
|
||||
`worker_app.celery_app` 实例,`conf.imports` 从未导入该任务,Worker 的任务
|
||||
注册表里没有 `ai_avatar_render.execute`,消息被路由到默认 `celery` 队列后
|
||||
无人消费,任务永远停在 0%。
|
||||
|
||||
修复:在 Worker 侧用 `worker_app.celery_app` 注册同名任务,直接调用与 API
|
||||
服务一致的 `AiAvatarRenderService.execute_render` 核心管线(业务逻辑在
|
||||
`apps.api.app.services`,worker 镜像已复制 `apps/api/app`)。任务名保持
|
||||
`ai_avatar_render.execute`,与 API 生产端 `.delay()` 的消息路由一致;未在
|
||||
task_routes 显式配置,走默认 `celery` 队列,由 transcode worker 消费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
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 数字人渲染管线(Worker 侧入口).
|
||||
|
||||
进度由 service 直接写入 DB(AiAvatarRenderJob.progress:
|
||||
0→5→20→40→80→90→95→100),API 通过轮询 progress 字段展示。
|
||||
"""
|
||||
logger.info("开始执行渲染任务: %s", job_id)
|
||||
self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id})
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
service = AiAvatarRenderService(session)
|
||||
service.execute_render(job_id)
|
||||
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
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1,169 +0,0 @@
|
||||
# AI 数字人前后端接口契约(#1797 / #1822)
|
||||
|
||||
> 分支:`fix/ai-avatar-v3-1797`
|
||||
> 范围:TTS→对口型链路打通、语速/情绪透传、封面智能选帧、标题字段对齐
|
||||
> 本文档为前后端联调的唯一字段口径。
|
||||
|
||||
---
|
||||
|
||||
## 1. 对口型创建接口 `POST /api/v1/lipsync/jobs`
|
||||
|
||||
支持两种输入模式,**二选一**:
|
||||
|
||||
### 模式 A(推荐):TTS 直生 —— 传音色 + 文案,后端内部合成音频
|
||||
|
||||
前端无需先调 TTS。后端收到请求后:先调 CosyVoice 合成音频 → 转存 OSS → 再提交 MediaKit 对口型。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"video_url": "https://oss.../person.mp4", // 必填,人物视频(MP4)
|
||||
"voice_id": "cosyvoice-v3-flash-99-xxxx", // 必填,音色 ID(预置音色 或 克隆 profile UUID)
|
||||
"script_text": "省是浙江省,市是永康市……", // 必填,要合成的文案
|
||||
"speed": 1.0, // 可选,语速 0.5~2.0,默认 1.0
|
||||
"emotion": "excited", // 可选,情绪,见 §3
|
||||
"enable_video_loop": false, // 可选,音频长于视频时是否循环画面
|
||||
"project_id": "" // 可选
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 B:直接音频 —— 前端已准备好音频
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"video_url": "https://oss.../person.mp4", // 必填
|
||||
"audio_url": "https://oss.../voice.mp3", // 必填,mp3/aac/wav/m4a/flac
|
||||
"enable_video_loop": false
|
||||
}
|
||||
```
|
||||
|
||||
### 校验与错误码
|
||||
|
||||
| 场景 | HTTP | detail.code |
|
||||
|------|------|-------------|
|
||||
| 既无 audio_url 又无 voice_id+script_text | 422 | (schema 校验) |
|
||||
| video_url 非 MP4 / audio_url 格式不支持 | 422 | (schema 校验) |
|
||||
| 克隆音色不属于当前用户 | 403 | `VoiceForbidden` |
|
||||
| 克隆音色尚未合成完成 | 400 | `VoiceNotReady` |
|
||||
| TTS 合成失败(如 CosyVoice 欠费) | 502 | `TTSSynthesisFailed` |
|
||||
| MediaKit 提交失败 | 502 | `*`(透传 MediaKit code) |
|
||||
|
||||
### 轮询
|
||||
|
||||
- `GET /api/v1/lipsync/jobs/{id}`:非终态任务先返回 DB 缓存,**后台异步刷新 MediaKit**(不会阻塞轮询)。
|
||||
- `status` 流转:`pending` → `submitted` → `running`/`processing`(MediaKit 中间态同步)→ `completed` / `failed`。
|
||||
- `completed` 时 `output_video_url` 为**已转存自家 OSS 的非临时 URL**(不会过期)。
|
||||
- 前端每 3s 轮询,命中 `completed`/`failed` 即停。
|
||||
|
||||
---
|
||||
|
||||
## 2. TTS 合成接口语速/情绪透传
|
||||
|
||||
- `POST /api/v1/tts/synthesize`(异步任务)与 `POST /api/v1/tts/preview`(即时试听)均新增:
|
||||
- `speed`:float,0.5~2.0,默认 1.0 → 透传 CosyVoice payload 的 `rate`
|
||||
- `emotion`:string,见 §3 映射 → 透传 `emotion`
|
||||
- 透传链路:`route → CreateTTSJobUseCase(metadata) → TTSJobWorkflow.start_synthesis / 分段合成 → CosyVoiceService.submit_synthesize_task(rate/emotion)`。
|
||||
- 分段合成(长文案)与失败重合成路径同样透传 speed/emotion。
|
||||
|
||||
---
|
||||
|
||||
## 3. 情绪枚举(前后端统一)
|
||||
|
||||
前端把中文选项映射成英文后传后端;后端同时接受中文/英文,非法值忽略(走默认自然)。
|
||||
|
||||
| 前端选项 | 传参值 | CosyVoice 枚举 |
|
||||
|---------|--------|---------------|
|
||||
| 自然 | `natural` | natural |
|
||||
| 兴奋 | `excited` | excited |
|
||||
| 沉稳 | `calm` | calm |
|
||||
| 亲切 | `friendly` | friendly |
|
||||
|
||||
后端 `normalize_emotion()` 也接受中文(自然/兴奋/沉稳/亲切)做兜底映射。
|
||||
|
||||
---
|
||||
|
||||
## 4. 智能封面接口 `POST /api/v1/ai-avatar/render/smart-cover`
|
||||
|
||||
独立接口,**不依赖渲染任务**,前端「智能获取封面」按钮直接调用。
|
||||
|
||||
**请求**
|
||||
```jsonc
|
||||
{
|
||||
"video_url": "https://oss.../avatar_output.mp4", // 必填,数字人视频
|
||||
"max_frames": 5 // 可选,抽帧数量 1~10,默认 5
|
||||
}
|
||||
```
|
||||
|
||||
**响应**
|
||||
```jsonc
|
||||
{
|
||||
"cover_url": "https://oss.../ai-avatar/covers/xxx/cover_yy.jpg", // OSS 非临时 URL
|
||||
"status": "completed", // completed / fallback_failed
|
||||
"message": "" // 失败原因
|
||||
}
|
||||
```
|
||||
|
||||
**实现**:复用智能剪辑同款能力 —— MediaKit `extract_frames(SpecifiedFrames)` 抽 5 帧 → `cover_frame_scorer.score_frames`(清晰度+亮度+色彩)评分选最佳 → 转存 OSS。
|
||||
**不再使用 FFmpeg 简单首帧**。渲染管线最终封面也优先走该智能选帧,MediaKit 不可用时才回退 FFmpeg。
|
||||
|
||||
---
|
||||
|
||||
## 5. 渲染接口 `POST /api/v1/ai-avatar/render`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"lipsync_job_id": "7c29a3b2-...", // 必填,已 completed 的对口型任务
|
||||
"script_id": "", // 可选!见下方说明
|
||||
"b_roll_segments": [], // 可选,B-roll 片段
|
||||
"title_config": { ... }, // 可选,单个标题配置 dict(见 §6)
|
||||
"cover_config": { ... }, // 可选,封面配置(建议改用 smart-cover)
|
||||
"project_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
**`script_id` 是否必填:可选。**
|
||||
- 从文案库选了文案时传对应文案 ID(后端做归属校验)。
|
||||
- **手动输入文案、走 TTS 直生模式时不传(留空)即可**——渲染管线不依赖文案内容,`script_id` 仅用于归属校验。留空不会卡手动文案用户。
|
||||
|
||||
---
|
||||
|
||||
## 6. 标题配置 `title_config` 字段清单(以 build_title_drawtext_filter 为准)
|
||||
|
||||
渲染请求收的是**单个 `title_config` dict**(不是 `titles[]` 数组),字段与 `packages/domain/video_filter_builder.py` 的 `build_title_drawtext_filter()` 完全对齐:
|
||||
|
||||
| 字段 | 别名 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| `text` | `content` | string | ✅ | — | 标题文字;为空或 `enabled=false` 时不渲染标题 |
|
||||
| `enabled` | — | bool | ❌ | `true` | 是否启用标题;false 跳过 |
|
||||
| `font` | `font_preset` | string | ❌ | 思源黑体 | 字体名(后端按名字解析字体文件) |
|
||||
| `font_size` | `size` | int | ❌ | 36 | 字号(像素) |
|
||||
| `font_color` | `color` | string | ❌ | `#ffffff` | 文字颜色,`#RRGGBB`;后端自动去掉 `#`,也可传 `RRGGBB` 或颜色名 |
|
||||
| `position` | — | string | ❌ | `top` | 预设位置:`top`(y=50) / `center`(垂直居中) / `bottom`(底部上移50px) / `custom` |
|
||||
| `pos_x` | — | int/float | ❌ | — | 自定义 X 坐标(像素),仅 `position=custom` 生效 |
|
||||
| `pos_y` | — | int/float | ❌ | — | 自定义 Y 坐标(像素),仅 `position=custom` 生效 |
|
||||
| `bold` | — | bool | ❌ | `true` | 粗体(Bold 字体变体,回退 borderw 模拟) |
|
||||
| `stroke` | — | bool/object | ❌ | — | 描边。`true`=黑描边宽2;object 见下 |
|
||||
| `stroke.enabled` | — | bool | ❌ | true | 是否描边 |
|
||||
| `stroke.width` | — | int | ❌ | 2 | 描边宽度 |
|
||||
| `stroke.color` | — | string | ❌ | `#000000` | 描边颜色 |
|
||||
| `shadow` | — | bool/object | ❌ | — | 阴影。`true`=黑色阴影偏移2px;object 见下 |
|
||||
| `shadow.enabled` | — | bool | ❌ | true | 是否阴影 |
|
||||
| `shadow.color` | — | string | ❌ | `#000000` | 阴影颜色 |
|
||||
| `shadow.offset_x` | — | int | ❌ | 2 | 阴影 X 偏移 |
|
||||
| `shadow.offset_y` | — | int | ❌ | 2 | 阴影 Y 偏移 |
|
||||
|
||||
**前端注意事项**
|
||||
- 标题是**整条成片一个标题**(单个 dict),不是按时间段的标题数组;没有 `start`/`end`/`frame`/`fontSize` 这些字段。
|
||||
- 位置用 `position` 四档枚举;自由摆放用 `position="custom"` + `pos_x`/`pos_y`(像素坐标,非比例)。
|
||||
- 颜色统一传 `#RRGGBB` 即可,后端会处理 `#`;三档预设位置下标题始终水平居中。
|
||||
- `stroke`/`shadow` 传 `true` 用默认样式,或传 object 精细控制颜色/宽度/偏移。
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端对接清单
|
||||
|
||||
1. 对口型:改用**模式 A**(voice_id + script_text + speed + emotion),不要再先调 TTS 拿 audio_url。
|
||||
2. 音色 ID:`voice_id` 可直接传克隆音色的 profile UUID,后端会解析为 CosyVoice voice_id(与 /tts 一致)。
|
||||
3. 情绪下拉:自然/兴奋/沉稳/亲切 → natural/excited/calm/friendly。
|
||||
4. 封面:点「智能获取封面」→ POST `/ai-avatar/render/smart-cover`,用返回的 `cover_url`。
|
||||
5. 渲染:手动文案直生场景 `script_id` 留空;标题传**单个** `title_config` dict(字段见 §6)。
|
||||
6. 轮询:识别 `running` 等中间态,不要只认 `submitted`。
|
||||
@@ -128,9 +128,9 @@ services:
|
||||
- xiaoxia-net
|
||||
|
||||
# 健康检查配置
|
||||
# 注:容器内无 pgrep/ps,扫描 /proc 所有进程的 cmdline 查找 celery 进程
|
||||
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || 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
|
||||
@@ -163,7 +163,7 @@ services:
|
||||
# context: ../..
|
||||
# dockerfile: ${WEB_DOCKERFILE:-infra/docker/web.Dockerfile}
|
||||
# args:
|
||||
# (NGINX_CONF no longer needed - all configs baked into image)
|
||||
# NGINX_CONF: ${WEB_NGINX_CONF:-infra/docker/nginx.conf}
|
||||
|
||||
container_name: xiaoxia-web-${ENV:-staging}
|
||||
restart: unless-stopped
|
||||
@@ -178,12 +178,12 @@ services:
|
||||
- xiaoxia-net
|
||||
|
||||
# =========================================
|
||||
# Nginx 配置运行时覆盖(双保险:entrypoint 也按 APP_ENV 选择配置)
|
||||
# Nginx 配置运行时覆盖
|
||||
# 确保容器使用正确环境的 nginx 配置,即使镜像构建时使用了默认配置
|
||||
# 注意: 只覆盖 /etc/nginx/conf.d/default.conf,不挂载 /usr/share/nginx/html
|
||||
# =========================================
|
||||
environment:
|
||||
- APP_ENV=${ENV:-staging}
|
||||
- NGINX_ENV=${ENV:-staging}
|
||||
volumes:
|
||||
- ./nginx-${ENV:-staging}.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
|
||||
REPO_DIR="${REPO_DIR:-/var/lib/xiaoxia-saas-production/repo}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
@@ -31,6 +31,7 @@ if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 三镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
@@ -65,14 +66,17 @@ if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
|
||||
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
|
||||
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
|
||||
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
|
||||
fi
|
||||
rm -rf "$_tmpdir"
|
||||
else
|
||||
echo "No existing web container, skipping legacy assets backup"
|
||||
fi
|
||||
|
||||
# 清理超过 7 天的旧 assets 文件(避免无限增长)
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ]; then
|
||||
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
echo "Legacy assets cleanup done (retain 7 days)"
|
||||
fi
|
||||
|
||||
# ---- 确保基础设施容器在运行 ----
|
||||
echo "Checking infrastructure containers..."
|
||||
@@ -80,10 +84,12 @@ for c in xiaoxia-postgres-production xiaoxia-redis-production; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 确保生产网络存在 ----
|
||||
@@ -102,6 +108,7 @@ echo "Migrations completed."
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
|
||||
# ---- 日志配置(所有容器共用) ----
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
@@ -148,7 +155,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || 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 \
|
||||
@@ -159,16 +166,15 @@ docker run -d \
|
||||
# ---- 启动 Web ----
|
||||
# Legacy assets 挂载到 /usr/share/nginx/html/assets-legacy/assets/
|
||||
# nginx 配置中 assets location 有 fallback 逻辑
|
||||
WEB_VOLUMES=""
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
WEB_VOLUMES="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
echo "Web container: legacy assets mounted (fallback)"
|
||||
else
|
||||
echo "Web container: no legacy assets to mount"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
@@ -176,8 +182,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
-e APP_ENV=production \
|
||||
$WEB_VOLUMES \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
@@ -192,6 +197,7 @@ while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
@@ -201,6 +207,7 @@ if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
@@ -209,6 +216,7 @@ while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
@@ -218,6 +226,7 @@ if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
|
||||
@@ -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 \"grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || 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 \
|
||||
@@ -124,15 +124,23 @@ docker run -d \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web ----
|
||||
# Web 镜像默认打包 production nginx.conf,staging 需要挂载 staging 配置
|
||||
NGINX_CONF="${NGINX_CONF:-${COMPOSE_DIR}/nginx-staging.conf}"
|
||||
if [ ! -f "$NGINX_CONF" ]; then
|
||||
echo "WARN: nginx config not found at $NGINX_CONF, using image default"
|
||||
NGINX_VOLUME=""
|
||||
else
|
||||
NGINX_VOLUME="-v ${NGINX_CONF}:/etc/nginx/conf.d/default.conf:ro"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
-e APP_ENV=staging \
|
||||
$NGINX_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
|
||||
@@ -53,6 +53,7 @@ export API_IMAGE="${API_IMAGE:-${REGISTRY}/xiaoxia-saas-api:dev}"
|
||||
export WORKER_IMAGE="${WORKER_IMAGE:-${REGISTRY}/xiaoxia-saas-worker:dev}"
|
||||
|
||||
# Use staging-specific nginx config (proxy_pass → xiaoxia-api-staging:8000)
|
||||
export WEB_NGINX_CONF=infra/docker/nginx-staging.conf
|
||||
|
||||
if [ "${REBUILD_BACKEND:-0}" = "1" ] || [ "${BUILD_WEB:-0}" = "1" ]; then
|
||||
if [ "${ALLOW_STAGING_BUILDS:-false}" != "true" ]; then
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Select nginx config based on APP_ENV (staging/production).
|
||||
#
|
||||
# 两种运行模式:
|
||||
# 1. CI/CD 部署(staging/production):部署脚本通过 `-v 宿主机文件:/etc/nginx/conf.d/default.conf:ro`
|
||||
# 把宿主机生成的带 resolver/docker upstream 的配置 bind mount 进来,entrypoint 不应改动。
|
||||
# bind mount 的文件是 readonly 的,rm 会报 EBUSY ("Resource busy"),直接 exec nginx 即可。
|
||||
# 2. 本地 docker-compose / 直接 `docker run`(无外部挂载):镜像烤入了 nginx-staging.conf 与
|
||||
# nginx-production.conf 到 /etc/nginx/,entrypoint 根据 APP_ENV 把 default.conf 换成正确的 symlink。
|
||||
#
|
||||
# 策略:
|
||||
# - 如果 /etc/nginx/conf.d/default.conf 已经是指向目标 conf 的 symlink,什么都不做;
|
||||
# - 否则尝试 rm -f 再 ln -s;rm 失败说明是外部 bind mount(已有正确配置),不阻塞启动;
|
||||
# - 兜底:只要 conf.d 目录里有 .conf 文件(含 bind mount 来的),就直接启动 nginx。
|
||||
set -e
|
||||
|
||||
NGINX_CONF_DIR="/etc/nginx/conf.d"
|
||||
TARGET_CONF=""
|
||||
|
||||
case "${APP_ENV:-production}" in
|
||||
staging)
|
||||
TARGET_CONF="/etc/nginx/nginx-staging.conf"
|
||||
;;
|
||||
*)
|
||||
TARGET_CONF="/etc/nginx/nginx-production.conf"
|
||||
;;
|
||||
esac
|
||||
|
||||
DEFAULT_CONF="$NGINX_CONF_DIR/default.conf"
|
||||
|
||||
# 1. 已经是正确的 symlink:直接启动
|
||||
if [ -L "$DEFAULT_CONF" ] && [ "$(readlink "$DEFAULT_CONF" 2>/dev/null)" = "$TARGET_CONF" ]; then
|
||||
exec nginx -g "daemon off;"
|
||||
fi
|
||||
|
||||
# 2. 尝试替换为目标 symlink(无 bind mount 的场景)
|
||||
# 若 rm 失败(bind mount readonly,EBUSY/EPERM),则认为外部已注入配置,不阻塞。
|
||||
rm -f "$DEFAULT_CONF" 2>/dev/null || true
|
||||
if [ -f "$TARGET_CONF" ] && [ ! -e "$DEFAULT_CONF" ]; then
|
||||
ln -s "$TARGET_CONF" "$DEFAULT_CONF" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 3. 兜底:至少要有一个 .conf 文件,否则 nginx 起不来
|
||||
if ! ls "$NGINX_CONF_DIR"/*.conf >/dev/null 2>&1; then
|
||||
echo "ERROR: no nginx config found in $NGINX_CONF_DIR (tried $TARGET_CONF and external bind mount)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -1,11 +1,7 @@
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
|
||||
ARG NGINX_CONF=infra/docker/nginx.conf
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY apps/web/dist ./
|
||||
# 将所有 nginx 配置烤入镜像,entrypoint 按 APP_ENV 选择
|
||||
COPY infra/docker/nginx.conf /etc/nginx/nginx-production.conf
|
||||
COPY infra/docker/nginx-staging.conf /etc/nginx/nginx-staging.conf
|
||||
COPY infra/docker/nginx-production.conf /etc/nginx/nginx-production.conf
|
||||
COPY infra/docker/nginx-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -28,13 +28,9 @@ RUN --mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
|
||||
|
||||
# Production stage with nginx
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
|
||||
ARG NGINX_CONF=infra/docker/nginx.conf
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY --from=builder /app/apps/web/dist ./
|
||||
# 将所有 nginx 配置烤入镜像,entrypoint 按 APP_ENV 选择
|
||||
COPY infra/docker/nginx.conf /etc/nginx/nginx-production.conf
|
||||
COPY infra/docker/nginx-staging.conf /etc/nginx/nginx-staging.conf
|
||||
COPY infra/docker/nginx-production.conf /etc/nginx/nginx-production.conf
|
||||
COPY infra/docker/nginx-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -20,7 +20,7 @@ WORKDIR /app
|
||||
|
||||
# 设置 Python 环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app:/app/apps/api:/app/packages
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
@@ -28,10 +28,8 @@ ENV APP_VERSION=$APP_VERSION
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY packages/ /app/packages/
|
||||
# PR #1844 起,worker 还需要加载 apps.api.app.tasks.lipsync_tts,
|
||||
# 该 task 依赖 app.services.* 与 app.core.celery_app(PYTHONPATH=/app/apps/api 下解析)。
|
||||
# 为避免后续新增 task 再次漏 COPY,直接把整个 apps/api/app/ 复制进 worker 镜像。
|
||||
COPY apps/api/app/ /app/apps/api/app/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
COPY apps/api/app/core/ /app/apps/api/app/core/
|
||||
|
||||
# Worker 启动脚本
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
@@ -40,10 +38,6 @@ RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
# 业务代码(变化最频繁,放最后)
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
|
||||
# 健康检查:扫描所有进程的 cmdline 查找 celery 进程
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1
|
||||
|
||||
USER celery
|
||||
WORKDIR /app/apps/worker
|
||||
CMD ["/usr/local/bin/entrypoint-worker.sh"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# xiaoxia-saas shared packages namespace
|
||||
@@ -1 +0,0 @@
|
||||
# adapter implementations namespace
|
||||
@@ -684,15 +684,9 @@ class LipsyncJobModel(Base):
|
||||
|
||||
# 输入参数
|
||||
video_url = Column(Text, nullable=False)
|
||||
audio_url = Column(Text, nullable=True) # 直生模式(voice_id+script_text)下 TTS 合成后回填
|
||||
audio_url = Column(Text, nullable=False)
|
||||
enable_video_loop = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# TTS 直生字段:传音色 + 文案,由后端先合成音频再对口型
|
||||
voice_id = Column(String(200), nullable=False, default="")
|
||||
script_text = Column(Text, nullable=False, default="")
|
||||
speed = Column(Float, nullable=False, default=1.0)
|
||||
emotion = Column(String(20), nullable=False, default="")
|
||||
|
||||
# MediaKit 任务状态
|
||||
mediakit_task_id = Column(String(200), nullable=False, default="", index=True)
|
||||
status = Column(
|
||||
@@ -721,8 +715,7 @@ class AiAvatarRenderJob(Base):
|
||||
|
||||
# 输入参数
|
||||
lipsync_job_id = Column(String(36), nullable=False)
|
||||
# 文案 ID 可选:手动输入文案(TTS 直生)场景不关联文案库条目
|
||||
script_id = Column(String(36), nullable=False, default="")
|
||||
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)
|
||||
|
||||
@@ -25,35 +25,6 @@ from packages.shared.config import get_shared_settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# CosyVoice 支持的情绪:中文标签 → API 英文值
|
||||
EMOTION_MAP = {
|
||||
"自然": "natural",
|
||||
"兴奋": "excited",
|
||||
"沉稳": "calm",
|
||||
"亲切": "friendly",
|
||||
"natural": "natural",
|
||||
"excited": "excited",
|
||||
"calm": "calm",
|
||||
"friendly": "friendly",
|
||||
}
|
||||
VALID_EMOTIONS = {"natural", "excited", "calm", "friendly"}
|
||||
|
||||
|
||||
def normalize_emotion(emotion: str) -> str:
|
||||
"""将前端情绪值归一化为 CosyVoice 英文枚举。
|
||||
|
||||
支持中文(自然/兴奋/沉稳/亲切)和英文;非法值返回空串(不传,走默认)。
|
||||
"""
|
||||
if not emotion:
|
||||
return ""
|
||||
key = emotion.strip().lower()
|
||||
mapped = EMOTION_MAP.get(emotion.strip()) or EMOTION_MAP.get(key)
|
||||
if mapped and mapped in VALID_EMOTIONS:
|
||||
return mapped
|
||||
logger.warning("未知的 emotion 值,忽略: %r", emotion)
|
||||
return ""
|
||||
|
||||
|
||||
class CosyVoiceError(Exception):
|
||||
"""CosyVoice API 调用异常。"""
|
||||
|
||||
@@ -460,7 +431,6 @@ class CosyVoiceService:
|
||||
format: str = "",
|
||||
speed: float = 1.0,
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
) -> dict:
|
||||
"""提交语音合成任务(同步非流式,直接返回结果).
|
||||
|
||||
@@ -474,7 +444,6 @@ class CosyVoiceService:
|
||||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||||
speed: 语速(0.5-2.0),1.0 为正常速度
|
||||
volume: 音量(0-100),默认 50
|
||||
emotion: 情绪(natural/excited/calm/friendly),空串不传
|
||||
|
||||
Returns:
|
||||
dict: {"audio_url": str, "request_id": str,
|
||||
@@ -494,20 +463,17 @@ class CosyVoiceService:
|
||||
|
||||
settings = get_shared_settings()
|
||||
|
||||
input_payload: dict[str, Any] = {
|
||||
"text": text,
|
||||
"voice": voice_id,
|
||||
"format": format or settings.cosyvoice_format,
|
||||
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
|
||||
"rate": speed,
|
||||
"volume": volume,
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"input": {
|
||||
"text": text,
|
||||
"voice": voice_id,
|
||||
"format": format or settings.cosyvoice_format,
|
||||
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
|
||||
"rate": speed,
|
||||
"volume": volume,
|
||||
},
|
||||
}
|
||||
# 情绪:归一化(中文→英文)后透传;空/非法则不传,走 CosyVoice 默认
|
||||
norm_emotion = normalize_emotion(emotion)
|
||||
if norm_emotion:
|
||||
input_payload["emotion"] = norm_emotion
|
||||
|
||||
payload = {"model": self._model, "input": input_payload}
|
||||
|
||||
response = self._call_api(
|
||||
method="POST",
|
||||
@@ -524,10 +490,6 @@ class CosyVoiceService:
|
||||
if not audio_url:
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
|
||||
|
||||
# DashScope 返回 http://,统一升级为 https://
|
||||
if audio_url.startswith("http://"):
|
||||
audio_url = audio_url.replace("http://", "https://", 1)
|
||||
|
||||
return {
|
||||
"task_id": "", # 同步接口无 task_id,兼容旧接口
|
||||
"audio_url": audio_url,
|
||||
@@ -555,7 +517,6 @@ class CosyVoiceService:
|
||||
format: str = "",
|
||||
speed: float = 1.0,
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
timeout: float = 120.0,
|
||||
) -> SynthesizeResult:
|
||||
"""语音合成(同步非流式).
|
||||
@@ -587,7 +548,6 @@ class CosyVoiceService:
|
||||
format=format,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
)
|
||||
|
||||
return SynthesizeResult(
|
||||
|
||||
@@ -143,16 +143,11 @@ class TTSWorkflowService:
|
||||
return self._start_segment_synthesis(job)
|
||||
|
||||
try:
|
||||
_meta = dict(job.metadata)
|
||||
_speed = float(_meta.get("speed", 1.0) or 1.0)
|
||||
_emotion = str(_meta.get("emotion", "") or "")
|
||||
submit_result = self.cosyvoice_service.submit_synthesize_task(
|
||||
text=job.input_text,
|
||||
voice_id=job.voice_id,
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=_speed,
|
||||
emotion=_emotion,
|
||||
)
|
||||
|
||||
# 保存 task_id / request_id 到 metadata
|
||||
@@ -288,7 +283,6 @@ class TTSWorkflowService:
|
||||
job_metadata = job.metadata or {}
|
||||
speed = float(job_metadata.get("speed", 1.0))
|
||||
volume = int(job_metadata.get("volume", 50))
|
||||
emotion = str(job_metadata.get("emotion", "") or "")
|
||||
|
||||
result = self.cosyvoice_service.submit_synthesize_task(
|
||||
text=job.input_text,
|
||||
@@ -297,7 +291,6 @@ class TTSWorkflowService:
|
||||
format=job.format,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
)
|
||||
audio_url = result.get("audio_url", "")
|
||||
if not audio_url:
|
||||
@@ -408,9 +401,6 @@ class TTSWorkflowService:
|
||||
"""
|
||||
max_workers = min(len(segments), _MAX_SEGMENT_WORKERS)
|
||||
results: list[dict | None] = [None] * len(segments)
|
||||
_seg_meta = job.metadata or {}
|
||||
_seg_speed = float(_seg_meta.get("speed", 1.0) or 1.0)
|
||||
_seg_emotion = str(_seg_meta.get("emotion", "") or "")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_idx = {}
|
||||
@@ -421,8 +411,6 @@ class TTSWorkflowService:
|
||||
voice_id=job.voice_id,
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=_seg_speed,
|
||||
emotion=_seg_emotion,
|
||||
)
|
||||
future_to_idx[future] = idx
|
||||
|
||||
@@ -512,7 +500,6 @@ class TTSWorkflowService:
|
||||
job_metadata = job.metadata or {}
|
||||
speed = float(job_metadata.get("speed", 1.0))
|
||||
volume = int(job_metadata.get("volume", 50))
|
||||
emotion = str(job_metadata.get("emotion", "") or "")
|
||||
|
||||
# 分段文本(用于缺失段重新合成)
|
||||
segments = split_text(job.input_text, max_chars=_SEGMENT_THRESHOLD)
|
||||
@@ -547,7 +534,6 @@ class TTSWorkflowService:
|
||||
format=job.format,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
)
|
||||
future_to_idx[future] = idx
|
||||
|
||||
|
||||
@@ -49,17 +49,19 @@ class GeneratedVideo:
|
||||
thumbnail_url: str | None = None,
|
||||
generation_params: dict[str, Any] | None = None,
|
||||
) -> "GeneratedVideo":
|
||||
# project_id / generation_task_id 允许为空:AI数字人等无项目场景下,前端可能不传 project_id;
|
||||
# lipsync 路径下 generation_task_id 也可能暂时为空。空串会被下面统一兜底为 "" 入库。
|
||||
if not name or not name.strip():
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id cannot be empty")
|
||||
if not generation_task_id.strip():
|
||||
raise ValueError("generation_task_id cannot be empty")
|
||||
if not name.strip():
|
||||
raise ValueError("name cannot be empty")
|
||||
if not file_url or not file_url.strip():
|
||||
if not file_url.strip():
|
||||
raise ValueError("file_url cannot be empty")
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
project_id=(project_id or "").strip(),
|
||||
user_id=(user_id or "").strip(),
|
||||
generation_task_id=(generation_task_id or "").strip(),
|
||||
project_id=project_id.strip(),
|
||||
user_id=user_id.strip(),
|
||||
generation_task_id=generation_task_id.strip(),
|
||||
name=name.strip(),
|
||||
file_url=file_url.strip(),
|
||||
file_size=file_size,
|
||||
|
||||
@@ -13,7 +13,3 @@ pytest-cov==6.0.0
|
||||
|
||||
# 工具
|
||||
python-dotenv==1.0.1
|
||||
|
||||
# AI 数字人封面智能选帧(cover_frame_scorer 用 cv2/numpy 做清晰度/亮度/色彩评分)
|
||||
numpy==1.26.4
|
||||
opencv-python-headless==4.10.0.84
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI中自动修复代码格式(Python: black + isort + ruff | Frontend: prettier),并推送回原分支。
|
||||
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
|
||||
|
||||
- PR事件:所有PR只要Code Quality因格式问题失败,自动修复并push回源分支
|
||||
- Push事件(develop/main):自动修复并push回原分支,保持主干格式永远正确
|
||||
- 防循环:修复commit带 [skip ci-format-check] 标记,检测到该标记则跳过修复
|
||||
- black/isort/prettier 修格式;ruff check --fix --unsafe-fixes 自动修复
|
||||
ruff 可修复的 lint 规则(含 F401 未使用 import 等 unsafe fix)
|
||||
- ruff 目标范围与 validate_style.sh 的检查范围对齐:apps packages tests
|
||||
(alembic/scripts 不在 ruff 检查范围内,不做修复)
|
||||
- 只修格式(black/isort/prettier),ruff逻辑类错误不动
|
||||
当code quality检查因格式问题失败时触发。
|
||||
"""
|
||||
|
||||
@@ -138,7 +135,7 @@ def get_pr_head_branch(pr_number, api_url, token):
|
||||
|
||||
|
||||
def fix_python(target_py_files, scan_mode):
|
||||
"""修复 Python 文件 (black 格式化 + isort 排序 + ruff lint 自动修复)"""
|
||||
"""修复 Python 文件格式 (black + isort)"""
|
||||
if not target_py_files:
|
||||
print("没有需要修复的 Python 文件,跳过")
|
||||
return
|
||||
@@ -149,48 +146,14 @@ def fix_python(target_py_files, scan_mode):
|
||||
result = run(f"python3 -m black {target_str}", check=False)
|
||||
print(result.stdout[-500:] if result.stdout else "")
|
||||
if result.returncode != 0:
|
||||
print("black执行失败,但继续尝试isort/ruff", file=sys.stderr)
|
||||
print("black执行失败,但继续尝试isort", file=sys.stderr)
|
||||
|
||||
print()
|
||||
print("--- isort 排序 ---")
|
||||
result = run(f"python3 -m isort {target_str}", check=False)
|
||||
print(result.stdout[-500:] if result.stdout else "")
|
||||
if result.returncode != 0:
|
||||
print("isort执行失败,继续尝试ruff", file=sys.stderr)
|
||||
|
||||
# ruff lint 自动修复
|
||||
# 与 validate_style.sh 的检查范围对齐:只修 apps/packages/tests
|
||||
# (alembic 在 pyproject.toml 中被 exclude,scripts 不在 ruff 检查范围内)
|
||||
ruff_scopes = ("apps/", "packages/", "tests/")
|
||||
ruff_files = [f for f in target_py_files if f.startswith(ruff_scopes)]
|
||||
if scan_mode != "incremental":
|
||||
ruff_targets = "apps packages tests"
|
||||
elif ruff_files:
|
||||
ruff_targets = " ".join(ruff_files)
|
||||
else:
|
||||
ruff_targets = ""
|
||||
|
||||
if ruff_targets:
|
||||
# ruff 由 style job 的 requirements-dev.txt 安装;不可用时跳过(不阻断 black/isort 的修复)
|
||||
avail = run("python3 -m ruff --version", check=False)
|
||||
if avail.returncode != 0:
|
||||
print("ruff 不可用,跳过 ruff 自动修复", file=sys.stderr)
|
||||
else:
|
||||
print()
|
||||
print("--- ruff lint 自动修复 (--fix --unsafe-fixes) ---")
|
||||
# --unsafe-fixes 用于启用 F401(未使用 import)等 ruff 归类为 unsafe 的自动修复;
|
||||
# 安全性由修复后重跑的完整 CI(单测/构建/staging 健康检查)兜底
|
||||
result = run(
|
||||
f"python3 -m ruff check {ruff_targets} --fix --unsafe-fixes",
|
||||
check=False,
|
||||
)
|
||||
print(result.stdout[-1500:] if result.stdout else "")
|
||||
if result.returncode != 0:
|
||||
# 可能是仍有不可自动修复的 lint 错误(留待 style check 再次拦截),或修复过程出错
|
||||
print("ruff 自动修复后仍有未修复项或执行失败,剩余问题由 style check 继续拦截", file=sys.stderr)
|
||||
else:
|
||||
print()
|
||||
print("增量模式且无 apps/packages/tests 范围内的 Python 变更,跳过 ruff 自动修复")
|
||||
print("isort执行失败", file=sys.stderr)
|
||||
|
||||
|
||||
def fix_frontend(target_fe_files, scan_mode, repo_root):
|
||||
@@ -371,7 +334,7 @@ def main():
|
||||
# 提交修复
|
||||
run("git clean -fd")
|
||||
run("git add -u")
|
||||
run('git commit -m "style: auto-format with black + isort + ruff + prettier [skip ci-format-check]"')
|
||||
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
|
||||
|
||||
# 推送(head_branch已从ensure_git_repo获取)
|
||||
print(f"\nPR来源分支: {head_branch}")
|
||||
|
||||
@@ -87,19 +87,28 @@ class TestGeneratedVideoCreate:
|
||||
assert v.file_url == "http://x/v"
|
||||
|
||||
def test_create_empty_project_id(self):
|
||||
"""空 project_id 允许(AI数字人无项目场景)."""
|
||||
v = GeneratedVideo.create("", "t1", "v", "http://x/v")
|
||||
assert v.project_id == ""
|
||||
"""空 project_id 无效."""
|
||||
try:
|
||||
GeneratedVideo.create("", "t1", "v", "http://x/v")
|
||||
assert False
|
||||
except ValueError as e:
|
||||
assert "project_id" in str(e)
|
||||
|
||||
def test_create_whitespace_project_id(self):
|
||||
"""纯空白 project_id 归一化为空串."""
|
||||
v = GeneratedVideo.create(" ", "t1", "v", "http://x/v")
|
||||
assert v.project_id == ""
|
||||
"""纯空白 project_id 无效."""
|
||||
try:
|
||||
GeneratedVideo.create(" ", "t1", "v", "http://x/v")
|
||||
assert False
|
||||
except ValueError as e:
|
||||
assert "project_id" in str(e)
|
||||
|
||||
def test_create_empty_task_id(self):
|
||||
"""空 generation_task_id 允许."""
|
||||
v = GeneratedVideo.create("p1", "", "v", "http://x/v")
|
||||
assert v.generation_task_id == ""
|
||||
"""空 generation_task_id 无效."""
|
||||
try:
|
||||
GeneratedVideo.create("p1", "", "v", "http://x/v")
|
||||
assert False
|
||||
except ValueError as e:
|
||||
assert "generation_task_id" in str(e)
|
||||
|
||||
def test_create_empty_name(self):
|
||||
"""空 name 无效."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user