diff --git a/.gitea/workflows/ci-pipeline.yml b/.gitea/workflows/ci-pipeline.yml index 04902aef0..dbae83666 100755 --- a/.gitea/workflows/ci-pipeline.yml +++ b/.gitea/workflows/ci-pipeline.yml @@ -20,6 +20,7 @@ on: default: "手动触发 - CI漏触发补跑" permissions: contents: read + pull-requests: read concurrency: group: ci-pipeline-${{ gitea.ref }} cancel-in-progress: true @@ -88,9 +89,22 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | set -eu + # 优先用 git diff 判断 PR 改动范围(比 API 稳定) PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') - 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)]") + 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 FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true) TOTAL=$(echo "$FILES" | grep -cv '^$' || true) @@ -182,7 +196,7 @@ jobs: - name: Run style checks shell: bash run: bash scripts/ci/validate_style.sh - - name: Auto-fix formatting (black + isort) + - name: Auto-fix formatting (black + isort + ruff) if: failure() shell: sh env: @@ -813,9 +827,6 @@ 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="" @@ -1012,9 +1023,6 @@ 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 @@ -1187,6 +1195,8 @@ jobs: COSYVOICE_API_KEY: ${{ secrets.COSYVOICE_API_KEY }} DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} MEDIAKIT_API_KEY: ${{ secrets.MEDIAKIT_API_KEY }} + WECHAT_APP_ID: ${{ secrets.WECHAT_APP_ID }} + WECHAT_APP_SECRET: ${{ secrets.WECHAT_APP_SECRET }} run: | set -eu echo "Rendering .env from template + secrets..." @@ -1551,9 +1561,6 @@ 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="" diff --git a/.gitea/workflows/debug-web-v2.yml b/.gitea/workflows/debug-web-v2.yml new file mode 100644 index 000000000..315330453 --- /dev/null +++ b/.gitea/workflows/debug-web-v2.yml @@ -0,0 +1,60 @@ +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 diff --git a/alembic/versions/066_asset_client_upload_id_ingest_job_asset_id.py b/alembic/versions/066_asset_client_upload_id_ingest_job_asset_id.py new file mode 100644 index 000000000..6156bbfc1 --- /dev/null +++ b/alembic/versions/066_asset_client_upload_id_ingest_job_asset_id.py @@ -0,0 +1,34 @@ +"""add client_upload_id to assets and asset_id to ingest_jobs + +Issue #1714:上传 complete 幂等 + worker 转码回写关联。 +- assets.client_upload_id:客户端幂等 token(complete 去重) +- ingest_jobs.asset_id:complete 阶段创建的占位 asset id(worker 回写关联, + 防止 HEVC 转码改写 storage_key 后找不到占位而兜底新建 READY 记录) + +Revision ID: 066_upload_idempotency +Revises: 065_dup_record_sim_match +Create Date: 2026-09-05 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "066_upload_idempotency" +down_revision = "065_dup_record_sim_match" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("assets", sa.Column("client_upload_id", sa.String(64), nullable=True)) + op.create_index("ix_assets_client_upload_id", "assets", ["client_upload_id"]) + op.add_column("ingest_jobs", sa.Column("asset_id", sa.String(36), nullable=False, server_default="")) + op.create_index("ix_ingest_jobs_asset_id", "ingest_jobs", ["asset_id"]) + + +def downgrade() -> None: + op.drop_index("ix_ingest_jobs_asset_id", table_name="ingest_jobs") + op.drop_column("ingest_jobs", "asset_id") + op.drop_index("ix_assets_client_upload_id", table_name="assets") + op.drop_column("assets", "client_upload_id") diff --git a/alembic/versions/067_celery_task_id_revoke.py b/alembic/versions/067_celery_task_id_revoke.py new file mode 100644 index 000000000..bd2b68a40 --- /dev/null +++ b/alembic/versions/067_celery_task_id_revoke.py @@ -0,0 +1,35 @@ +"""add celery_task_id to generation_tasks and ingest_jobs + +Issue #1714:孤儿恢复/超时清理撤销队列消息。 +- generation_tasks.celery_task_id:入队时记录的 Celery 消息 ID,清理时 revoke +- ingest_jobs.celery_task_id:同上(素材转码任务) + +Revision ID: 067_celery_task_id +Revises: 066_upload_idempotency +Create Date: 2026-09-05 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "067_celery_task_id" +down_revision = "066_upload_idempotency" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "generation_tasks", + sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""), + ) + op.add_column( + "ingest_jobs", + sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_column("ingest_jobs", "celery_task_id") + op.drop_column("generation_tasks", "celery_task_id") diff --git a/alembic/versions/068_user_profile_completed.py b/alembic/versions/068_user_profile_completed.py new file mode 100644 index 000000000..61d9f6a90 --- /dev/null +++ b/alembic/versions/068_user_profile_completed.py @@ -0,0 +1,26 @@ +"""add profile_completed to users + +Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。 +- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导), + 微信新建用户在应用层置 False。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "068_user_profile_completed" +down_revision = "067_celery_task_id" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")), + ) + + +def downgrade() -> None: + op.drop_column("users", "profile_completed") diff --git a/alembic/versions/069_project_is_default_idempotent.py b/alembic/versions/069_project_is_default_idempotent.py new file mode 100644 index 000000000..17d3817f1 --- /dev/null +++ b/alembic/versions/069_project_is_default_idempotent.py @@ -0,0 +1,72 @@ +"""Projects is_default + partial unique index for idempotent default project (Issue #1775) + +Revision ID: 069_project_is_default +Revises: 068_user_profile_completed +Create Date: 2026-09-08 + +背景: +小程序端 getOrCreateDefaultProject 在重试/并发/前端重复调用下, +仅靠应用层"先查再插"不保证幂等,会给同一用户重复创建默认项目。 + +改动: +1. projects 表新增 is_default 布尔列(默认 false) +2. 部分唯一索引 uq_projects_owner_default:(owner_user_id) WHERE is_default = true + —— 保证每个用户至多一个默认项目 +3. 存量数据回填:把名为"默认项目"的存量项目按创建时间最早者标记为 is_default=true + (只标记不删除;存量重复项目的清理另行确认后单独执行) + +注意:部分唯一索引依赖 PostgreSQL,不支持 downgrade 到其他方言。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "069_project_is_default" +down_revision = "068_user_profile_completed" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. 新增 is_default 列 + op.add_column( + "projects", + sa.Column( + "is_default", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + + # 2. 存量回填:每个拥有"默认项目"的用户,只把最早创建的那一个标记为默认。 + # 用 ROW_NUMBER() 取每组第一条;非"默认项目"命名的项目不标记(保守,不动用户自建项目)。 + op.execute(""" + UPDATE projects p + SET is_default = true + WHERE p.id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY owner_user_id + ORDER BY created_at ASC, id ASC + ) AS rn + FROM projects + WHERE name = '默认项目' + ) t + WHERE t.rn = 1 + ) + """) + + # 3. 部分唯一索引:每用户至多一个默认项目(只约束 is_default = true 的行) + op.execute(""" + CREATE UNIQUE INDEX uq_projects_owner_default + ON projects (owner_user_id) + WHERE is_default = true + """) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS uq_projects_owner_default") + op.drop_column("projects", "is_default") diff --git a/alembic/versions/070_add_scripts_table.py b/alembic/versions/070_add_scripts_table.py new file mode 100644 index 000000000..23dbecd61 --- /dev/null +++ b/alembic/versions/070_add_scripts_table.py @@ -0,0 +1,48 @@ +"""Add scripts table for oral broadcast script library (Issue #1795) + +Revision ID: 070_add_scripts +Revises: 069_project_is_default +Create Date: 2026-09-08 + +新建 scripts 表,支持口播文案 CRUD + 分段存储。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "070_add_scripts" +down_revision = "069_project_is_default" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scripts", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("user_id", sa.String(36), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("content", sa.Text(), nullable=False, server_default=""), + sa.Column("segments", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_scripts_user_id", "scripts", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_scripts_user_id", table_name="scripts") + op.drop_table("scripts") diff --git a/alembic/versions/071_add_lipsync_jobs_table.py b/alembic/versions/071_add_lipsync_jobs_table.py new file mode 100644 index 000000000..0ff2ddb5d --- /dev/null +++ b/alembic/versions/071_add_lipsync_jobs_table.py @@ -0,0 +1,47 @@ +"""add lipsync jobs table + +Revision ID: 071_add_lipsync_jobs +Revises: 070_add_scripts +Create Date: 2026-09-08 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "071_add_lipsync_jobs" +down_revision = "070_add_scripts" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "lipsync_jobs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), nullable=False, index=True), + sa.Column("project_id", sa.String(36), nullable=False, server_default=""), + sa.Column("video_url", sa.Text(), nullable=False), + sa.Column("audio_url", sa.Text(), nullable=False), + sa.Column("enable_video_loop", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("mediakit_task_id", sa.String(200), nullable=False, server_default="", index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""), + sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")), + sa.Column("error_message", sa.Text(), nullable=False, server_default=""), + sa.Column("error_code", sa.String(100), nullable=False, server_default=""), + sa.Column("submitted_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + # 复合索引:用户 + 状态(列表查询常用) + op.create_index("ix_lipsync_jobs_user_status", "lipsync_jobs", ["user_id", "status"]) + # 项目 + 用户(项目维度查询) + op.create_index("ix_lipsync_jobs_project_user", "lipsync_jobs", ["project_id", "user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_lipsync_jobs_project_user", table_name="lipsync_jobs") + op.drop_index("ix_lipsync_jobs_user_status", table_name="lipsync_jobs") + op.drop_table("lipsync_jobs") diff --git a/alembic/versions/072_add_ai_avatar_render_jobs_table.py b/alembic/versions/072_add_ai_avatar_render_jobs_table.py new file mode 100644 index 000000000..e3c5f8e08 --- /dev/null +++ b/alembic/versions/072_add_ai_avatar_render_jobs_table.py @@ -0,0 +1,48 @@ +"""add ai avatar render jobs table + +Revision ID: 072_add_ai_avatar_render +Revises: 071_add_lipsync_jobs +Create Date: 2026-09-09 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "072_add_ai_avatar_render" +down_revision = "071_add_lipsync_jobs" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "ai_avatar_render_jobs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), nullable=False, index=True), + sa.Column("project_id", sa.String(36), nullable=False, server_default=""), + sa.Column("lipsync_job_id", sa.String(36), nullable=False), + sa.Column("script_id", sa.String(36), nullable=False), + sa.Column("b_roll_segments", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("cover_config", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("progress", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""), + sa.Column("output_cover_url", sa.Text(), nullable=False, server_default=""), + sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")), + sa.Column("error_message", sa.Text(), nullable=False, server_default=""), + sa.Column("submitted_at", sa.DateTime(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_ai_avatar_render_user_status", "ai_avatar_render_jobs", ["user_id", "status"]) + op.create_index("ix_ai_avatar_render_project_user", "ai_avatar_render_jobs", ["project_id", "user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_ai_avatar_render_project_user", table_name="ai_avatar_render_jobs") + op.drop_index("ix_ai_avatar_render_user_status", table_name="ai_avatar_render_jobs") + op.drop_table("ai_avatar_render_jobs") diff --git a/alembic/versions/073_add_lipsync_tts_fields.py b/alembic/versions/073_add_lipsync_tts_fields.py new file mode 100644 index 000000000..0edc08e71 --- /dev/null +++ b/alembic/versions/073_add_lipsync_tts_fields.py @@ -0,0 +1,45 @@ +"""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") diff --git a/alembic/versions/074_ai_avatar_render_script_id_optional.py b/alembic/versions/074_ai_avatar_render_script_id_optional.py new file mode 100644 index 000000000..ffd2156a0 --- /dev/null +++ b/alembic/versions/074_ai_avatar_render_script_id_optional.py @@ -0,0 +1,36 @@ +"""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, + ) diff --git a/alembic/versions/075_add_sentence_timings.py b/alembic/versions/075_add_sentence_timings.py new file mode 100644 index 000000000..905e7a3a8 --- /dev/null +++ b/alembic/versions/075_add_sentence_timings.py @@ -0,0 +1,27 @@ +"""add sentence_timings to lipsync_jobs + +Revision ID: 075_add_sentence_timings +Revises: 074_ai_avatar_render_script_id_optional +Create Date: 2026-09-12 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "075_add_sentence_timings" +down_revision = "074_render_script_id_optional" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("lipsync_jobs") as batch: + batch.add_column( + sa.Column("sentence_timings", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + with op.batch_alter_table("lipsync_jobs") as batch: + batch.drop_column("sentence_timings") diff --git a/apps/api/app/api/__init__.py b/apps/api/app/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 53c675d97..3dd8f9afc 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -1,4 +1,5 @@ from app.api.routes.ai import router as ai_router +from app.api.routes.ai_avatar_render import router as ai_avatar_render_router from app.api.routes.asset_diagnosis import router as asset_diagnosis_router from app.api.routes.asset_libraries import router as asset_libraries_router from app.api.routes.assets import router as assets_router @@ -11,10 +12,13 @@ from app.api.routes.feature_flags import router as feature_flags_router from app.api.routes.generation_cover import router as generation_cover_router from app.api.routes.generation_preview import router as generation_preview_router from app.api.routes.generation_tasks import router as generation_tasks_router +from app.api.routes.generation_variant_plans import router as generation_variant_plans_router from app.api.routes.health import router as health_check_router from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.internal_render import router as internal_render_router +from app.api.routes.lipsync import router as lipsync_router from app.api.routes.projects import router as projects_router +from app.api.routes.scripts import router as scripts_router from app.api.routes.share import router as share_router from app.api.routes.subscription import router as subscription_router from app.api.routes.tags import router as tags_router @@ -37,6 +41,11 @@ api_router.include_router( auth_router, tags=["Auth"], ) +api_router.include_router( + lipsync_router, + prefix="/lipsync", + tags=["Lipsync"], +) api_router.include_router( projects_router, prefix="/projects", @@ -99,6 +108,11 @@ api_router.include_router( prefix="/generation", tags=["Generation"], ) +api_router.include_router( + generation_variant_plans_router, + prefix="/generation", + tags=["Generation"], +) api_router.include_router( generation_cover_router, prefix="/generation", @@ -165,3 +179,13 @@ api_router.include_router( internal_render_router, tags=["Internal"], ) +api_router.include_router( + scripts_router, + prefix="/scripts", + tags=["ScriptLibrary"], +) +api_router.include_router( + ai_avatar_render_router, + prefix="/ai-avatar/render", + tags=["AI Avatar Render"], +) diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py new file mode 100644 index 000000000..3277ccb45 --- /dev/null +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -0,0 +1,324 @@ +"""AI数字人渲染合成 API 路由 — #1798. + +接口: + POST /api/v1/ai-avatar/render 提交渲染任务 + GET /api/v1/ai-avatar/render/jobs 任务列表 + GET /api/v1/ai-avatar/render/{job_id} 任务详情 + POST /api/v1/ai-avatar/render/{job_id}/cancel 取消任务 + POST /api/v1/ai-avatar/render/{job_id}/retry 重试失败任务 +""" + +from __future__ import annotations + +import logging +from 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, + FinalizeRenderResponse, + SmartCoverResponse, +) +from app.services.ai_avatar_cover_service import generate_smart_cover +from app.services.ai_avatar_render_service import ( + AiAvatarRenderError, + AiAvatarRenderService, +) +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService: + return AiAvatarRenderService(db) + + +# ── POST / — 提交渲染任务 ──────────────────────────────────────────────── + + +@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201) +def create_render_job( + body: CreateAiAvatarRenderRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: AiAvatarRenderService = Depends(_get_service), +): + """提交 AI 数字人渲染任务. + + 将对口型视频 + B-roll 素材 + 标题叠加 + 封面提取合成最终输出视频。 + """ + try: + job = svc.create_render_job( + user_id=current_user.user.id, + lipsync_job_id=body.lipsync_job_id, + script_id=body.script_id, + b_roll_segments=[s.model_dump() for s in body.b_roll_segments], + title_config=body.title_config, + cover_config=body.cover_config, + project_id=body.project_id, + ) + except AiAvatarRenderError as exc: + status_map = { + "LipsyncJobNotFound": 404, + "LipsyncJobNotCompleted": 400, + "LipsyncJobNoOutput": 400, + "ScriptNotFound": 404, + } + raise HTTPException( + status_code=status_map.get(exc.code, 400), + detail={"code": exc.code, "message": str(exc)}, + ) from exc + + # 异步触发渲染 + try: + from app.tasks.ai_avatar_render import execute_ai_avatar_render + + execute_ai_avatar_render.delay(job.id) + except Exception 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) + + return AiAvatarRenderJobResponse.model_validate(job) + + +# ── GET /jobs — 任务列表 ───────────────────────────────────────────────── + + +@router.get("/jobs", response_model=dict) +def list_render_jobs( + project_id: str = Query("", description="项目 ID 过滤"), + status: str = Query("", description="状态过滤"), + offset: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + current_user: AuthenticatedUser = Depends(get_current_user), + svc: AiAvatarRenderService = Depends(_get_service), +): + """获取 AI 数字人渲染任务列表.""" + items, total = svc.list_render_jobs( + user_id=current_user.user.id, + project_id=project_id, + status=status, + offset=offset, + limit=limit, + ) + return { + "items": [AiAvatarRenderJobResponse.model_validate(j) for j in items], + "total": total, + "offset": offset, + "limit": limit, + } + + +# ── GET /{job_id} — 任务详情 ───────────────────────────────────────────── + + +@router.get("/{job_id}", response_model=AiAvatarRenderJobResponse) +def get_render_job( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: AiAvatarRenderService = Depends(_get_service), +): + """获取渲染任务详情.""" + job = svc.get_render_job(job_id, current_user.user.id) + if job is None: + raise HTTPException(status_code=404, detail="渲染任务不存在") + return job + + +# ── POST /{job_id}/cancel — 取消任务 ───────────────────────────────────── + + +@router.post("/{job_id}/cancel", response_model=AiAvatarRenderJobResponse) +def cancel_render_job( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: AiAvatarRenderService = Depends(_get_service), +): + """取消渲染任务(仅 pending 状态可取消).""" + job = svc.cancel_render_job(job_id, current_user.user.id) + if job is None: + raise HTTPException(status_code=404, detail="渲染任务不存在") + if job.status != "cancelled": + raise HTTPException( + status_code=400, + detail=f"任务状态 {job.status} 不可取消,仅 pending 可取消", + ) + return job + + +# ── POST /{job_id}/retry — 重试失败任务 ────────────────────────────────── + + +@router.post("/{job_id}/retry", response_model=AiAvatarRenderJobResponse) +def retry_render_job( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: AiAvatarRenderService = Depends(_get_service), +): + """重试失败的渲染任务.""" + job = svc.retry_render_job(job_id, current_user.user.id) + if job is None: + raise HTTPException(status_code=404, detail="渲染任务不存在") + if job.status != "pending": + raise HTTPException( + status_code=400, + detail=f"仅 failed 状态的任务可重试,当前状态: {job.status}", + ) + + # 重新触发渲染 + try: + from app.tasks.ai_avatar_render import execute_ai_avatar_render + + execute_ai_avatar_render.delay(job.id) + except Exception 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) + + return AiAvatarRenderJobResponse.model_validate(job) + + +# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)──────── + + +@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse) +def generate_render_smart_cover( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS). + + - 必须等渲染任务 completed 后才可调用(否则返回 400) + - 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url + """ + from app.services.ai_avatar_render_service import AiAvatarRenderService + + svc = AiAvatarRenderService(db) + job = svc.get_render_job(job_id, current_user.user.id) + if job is None: + raise HTTPException(status_code=404, detail="渲染任务不存在") + if job.status != "completed": + raise HTTPException(status_code=400, detail="请先完成视频生成") + video_url = (job.output_video_url or "").strip() + if not video_url: + raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空") + + try: + # 从最终成片抽帧,帧本身已含标题/B-roll,直接转存 OSS + cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5) + except Exception as exc: + logger.error( + "渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s", + current_user.user.id, + job_id, + video_url[:80], + exc, + exc_info=True, + ) + cover_url = "" + + if not cover_url: + return SmartCoverResponse( + cover_url="", + status="fallback_failed", + message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试", + ) + + # 更新 render_job 的封面字段(异步写入 DB;失败不影响返回) + try: + job.cover_config = { + **(job.cover_config if isinstance(job.cover_config, dict) else {}), + "mode": "auto_frame", + "url": cover_url, + } + job.output_cover_url = cover_url + job.updated_at = datetime.now(timezone.utc) + db.commit() + except Exception as exc: + logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc) + + logger.info( + "渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s", + current_user.user.id, + job_id, + cover_url[:120], + ) + return SmartCoverResponse(cover_url=cover_url, status="completed") + +# ── POST /{job_id}/finalize — 封面选定后正式入库成片库 ──────────────────── + + +@router.post("/{job_id}/finalize", response_model=FinalizeRenderResponse) +def finalize_render_job( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """用户完成封面选择后,将视频正式保存到成片库. + + - 必须等渲染任务 completed 后才可调用 + - 如果已通过 smart-cover/custom-cover 设置了封面,会自动带上 + - 返回成片库视频ID + - 幂等:已 finalize 的任务重复调用会返回 existing 记录 + """ + from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService + + svc = AiAvatarRenderService(db) + job = svc.get_render_job(job_id, current_user.user.id) + if job is None: + raise HTTPException(status_code=404, detail="渲染任务不存在") + if job.status != "completed": + raise HTTPException(status_code=400, detail="请先完成视频生成") + + # 幂等检查(通过 generation_task_id=job_id 识别,finalize_job 内部也做了一次,这里提前返回简化) + from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel + + existing = ( + db.query(GeneratedVideoModel) + .filter( + GeneratedVideoModel.user_id == current_user.user.id, + GeneratedVideoModel.generation_task_id == job_id, + ) + .first() + ) + if existing is not None: + return FinalizeRenderResponse( + video_id=existing.id, + cover_url=existing.thumbnail_url or "", + status="already_finalized", + ) + + try: + video = svc.finalize_job(job_id, current_user.user.id) + return FinalizeRenderResponse( + video_id=video.id, + cover_url=video.thumbnail_url or job.output_cover_url or "", + status="success", + ) + except AiAvatarRenderError as exc: + status_map = { + "RenderJobNotFound": 404, + "RenderNotCompleted": 400, + "OutputVideoMissing": 400, + } + raise HTTPException( + status_code=status_map.get(exc.code, 400), + detail=str(exc), + ) from exc + except Exception as exc: + logger.error("渲染任务finalize失败: job_id=%s err=%s", job_id, exc, exc_info=True) + raise HTTPException(status_code=500, detail=f"保存到成片库失败: {str(exc)}") from exc diff --git a/apps/api/app/api/routes/asset_libraries.py b/apps/api/app/api/routes/asset_libraries.py index 7b75e54c2..ba1340450 100755 --- a/apps/api/app/api/routes/asset_libraries.py +++ b/apps/api/app/api/routes/asset_libraries.py @@ -20,7 +20,7 @@ from packages.application import ( GetProjectUseCase, ListAssetLibrariesUseCase, ) -from packages.domain import AssetLibrary, AssetLibraryKind +from packages.domain import AssetLibraryKind from ._helpers import check_project_access @@ -120,30 +120,11 @@ def ensure_default_library( kind = AssetLibraryKind(request.kind) - # 查找该项目下同 kind 的素材库,返回第一个 - existing = asset_library_repository.find_by_project(request.project_id) - for lib in existing: - if lib.kind == kind: - return _to_asset_library_response(lib) - - # 不存在 → 自动创建 - import uuid - from datetime import datetime, timezone - - now = datetime.now(timezone.utc) + # Issue #1775: 幂等获取/创建——依赖唯一约束 uq_asset_libraries_project_kind, + # 并发创建冲突时回滚重查返回已有记录,不再依赖应用层"先查后插",也不会 500。 default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库") - library = AssetLibrary( - id=str(uuid.uuid4()), - project_id=request.project_id, - name=default_name, - kind=kind, - asset_count=0, - total_size=0, - created_at=now, - updated_at=now, - ) - created = asset_library_repository.create(library) - return _to_asset_library_response(created) + library = asset_library_repository.get_or_create_default_library(request.project_id, kind, name=default_name) + return _to_asset_library_response(library) @router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 38438f931..44cdaa6b8 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -1,4 +1,5 @@ """ +from __future__ import annotations Canonical authentication API routes. The route layer is intentionally thin: repository construction lives in @@ -13,9 +14,9 @@ import jwt from app.auth import AuthenticatedUser, blacklist_token, get_current_user from app.config import settings from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository -from fastapi import APIRouter, Depends, Header, HTTPException, status +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator from packages.adapters.redis import NoopSessionStore from packages.adapters.smtp import NoopEmailService @@ -84,6 +85,23 @@ class CurrentUserResponse(BaseModel): phone: str = "" phone_verified: bool = False binding_complete: bool = False + wechat_bound: bool = False + profile_completed: bool = True + + +class UserProfileResponse(BaseModel): + """用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)""" + + user_id: str + email: str + username: str + display_name: str + email_verified: bool + phone: str = "" + phone_verified: bool = False + binding_complete: bool = False + wechat_bound: bool = False + profile_completed: bool = True class PasswordResetRequestModel(BaseModel): @@ -272,9 +290,52 @@ async def get_current_user_info( phone=user.phone or "", phone_verified=user.phone_verified, binding_complete=binding_complete, + wechat_bound=bool(user.wechat_openid), + profile_completed=user.profile_completed, ) +class UpdateProfileRequest(BaseModel): + """更新个人资料请求(当前仅支持昵称)""" + + display_name: str + + @field_validator("display_name") + @classmethod + def _validate_display_name(cls, v: str) -> str: + name = (v or "").strip() + if not name: + raise ValueError("昵称不能为空白") + if len(name) > 20: + raise ValueError("昵称长度需在 1-20 个字符之间") + return name + + +class UpdateProfileResponse(BaseModel): + """更新资料响应:前端 normalizeUser(response.user) 直接消费""" + + user: UserProfileResponse + + +@router.patch("/me", response_model=UpdateProfileResponse) +async def update_current_user_profile( + request: UpdateProfileRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + user_repository: UserRepository = Depends(get_user_repository), +) -> UpdateProfileResponse: + """更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。""" + user = current_user.user + user.display_name = request.display_name # 已 strip(validator) + if not user.profile_completed: + user.profile_completed = True + user_repository.save(user) + + logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed) + # 重新读取,确保返回的是持久化后的最新状态 + fresh = user_repository.find_by_id(user.id) or user + return UpdateProfileResponse(user=_user_profile(fresh)) + + class _NoopSessionStore(NoopSessionStore): pass @@ -426,6 +487,7 @@ async def get_wechat_auth_url() -> WechatAuthUrlResponse: @router.post("/wechat/callback", response_model=WechatLoginResponse) async def wechat_callback( request: WechatCallbackRequest, + http_request: Request, user_repository: UserRepository = Depends(get_user_repository), ) -> WechatLoginResponse: """微信登录回调处理""" @@ -433,11 +495,30 @@ async def wechat_callback( from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase + # 回调可观测性:记录 UA(区分微信内置浏览器 MicroMessenger)与 state, + # 便于排查"停留 open.weixin.qq.com / 回调失败"类问题(#1718) + user_agent = http_request.headers.get("User-Agent", "") + is_wechat_browser = "MicroMessenger" in user_agent + logger.info( + "[微信回调] 收到回调: state=%s code_len=%d UA=%r 微信内置浏览器=%s", + (request.state or "")[:8], + len(request.code or ""), + user_agent[:200], + is_wechat_browser, + ) + # 1. 用 code 换微信用户信息 oauth_service = get_wechat_oauth_service() wechat_user, err = oauth_service.handle_callback(request.code, request.state) if err: + # state 校验失败 / 微信 errcode 等错误原文已在 service 内 log,这里带上 UA 上下文 + logger.warning("[微信回调] 处理失败: err=%s 微信内置浏览器=%s", err, is_wechat_browser) raise HTTPException(status_code=400, detail=err) + logger.info( + "[微信回调] state 校验通过,微信用户信息获取成功: openid=%s unionid=%s", + wechat_user.openid[:8] if wechat_user.openid else "", + bool(wechat_user.unionid), + ) # 2. 同步登录/注册(复用 wechat-sync 逻辑) use_case = WechatSyncUseCase(user_repository=user_repository) @@ -472,6 +553,109 @@ async def wechat_callback( ) +# ==================== 微信账号绑定/解绑(已登录用户) ==================== + + +class WechatBindUrlResponse(BaseModel): + auth_url: str + state: str + + +class WechatBindCompleteRequest(BaseModel): + code: str + state: str = "" + + +class WechatBindCompleteResponse(BaseModel): + success: bool + user: UserProfileResponse + + +class WechatUnbindResponse(BaseModel): + success: bool + + +def _user_profile(user) -> UserProfileResponse: + binding_complete = bool( + user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email + ) + return UserProfileResponse( + user_id=user.id, + email=user.email, + username=user.username, + display_name=user.display_name, + email_verified=user.email_verified, + phone=user.phone or "", + phone_verified=user.phone_verified, + binding_complete=binding_complete, + wechat_bound=bool(user.wechat_openid), + profile_completed=user.profile_completed, + ) + + +@router.get("/wechat/bind/url", response_model=WechatBindUrlResponse) +async def get_wechat_bind_url( + current_user: AuthenticatedUser = Depends(get_current_user), +) -> WechatBindUrlResponse: + """获取微信绑定授权链接(已登录用户场景)。state 经 Redis 存储做 CSRF 校验。""" + from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service + + oauth_service = get_wechat_oauth_service() + auth_url, state = oauth_service.generate_auth_url() + logger.info("[微信绑定] 用户 %s 请求绑定授权链接", current_user.user.id) + return WechatBindUrlResponse(auth_url=auth_url, state=state) + + +@router.post("/wechat/bind", response_model=WechatBindCompleteResponse) +async def wechat_bind( + request: WechatBindCompleteRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + user_repository: UserRepository = Depends(get_user_repository), +) -> WechatBindCompleteResponse: + """微信绑定完成:扫码回调后用 code 换 openid,绑定到当前登录账号(不创建新用户)。""" + from packages.application.auth.wechat_bind_use_case import WechatBindRequest, WechatBindUseCase + from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service + + oauth_service = get_wechat_oauth_service() + wechat_user, err = oauth_service.handle_callback(request.code, request.state) + if err: + logger.warning("[微信绑定] 用户 %s 换取微信信息失败: %s", current_user.user.id, err) + raise HTTPException(status_code=400, detail=err) + + use_case = WechatBindUseCase(user_repository=user_repository) + result, error, http_status = use_case.bind( + WechatBindRequest( + user_id=current_user.user.id, + openid=wechat_user.openid, + unionid=wechat_user.unionid or "", + ) + ) + if error: + logger.warning("[微信绑定] 用户 %s 绑定失败: %s", current_user.user.id, error) + raise HTTPException(status_code=http_status, detail=error) + + logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8]) + return WechatBindCompleteResponse(success=True, user=_user_profile(result.user)) + + +@router.delete("/wechat/bind", response_model=WechatUnbindResponse) +async def wechat_unbind( + current_user: AuthenticatedUser = Depends(get_current_user), + user_repository: UserRepository = Depends(get_user_repository), +) -> WechatUnbindResponse: + """解绑微信:需账号仍有其他登录方式(密码/手机/真实邮箱),否则拒绝。""" + from packages.application.auth.wechat_bind_use_case import WechatUnbindUseCase + + use_case = WechatUnbindUseCase(user_repository=user_repository) + result, error, http_status = use_case.unbind(current_user.user.id) + if error: + logger.warning("[微信解绑] 用户 %s 解绑失败: %s", current_user.user.id, error) + raise HTTPException(status_code=http_status, detail=error) + + logger.info("[微信解绑] 用户 %s 解绑成功", current_user.user.id) + return WechatUnbindResponse(success=True) + + # ==================== 验证码 & 绑定 ==================== diff --git a/apps/api/app/api/routes/chunked_upload.py b/apps/api/app/api/routes/chunked_upload.py index 1db5a1647..8407c842d 100644 --- a/apps/api/app/api/routes/chunked_upload.py +++ b/apps/api/app/api/routes/chunked_upload.py @@ -14,6 +14,7 @@ from typing import Any from uuid import uuid4 from app.api.routes._helpers import require_project_and_library +from app.api.routes.upload import _persist_celery_task_id from app.auth import AuthenticatedUser, get_current_user from app.core.celery_app import celery_app from app.core.storage import OSSStorageService, get_storage_service @@ -176,8 +177,8 @@ def _cleanup_expired_uploads() -> int: meta_file.unlink() cleaned += 1 logger.info(f"Cleaned up expired upload: {upload_id}") - except Exception as e: - logger.warning(f"Failed to cleanup upload metadata {meta_file}: {e}") + except Exception: + logger.exception("Failed to cleanup upload metadata: %s", meta_file) return cleaned @@ -381,7 +382,8 @@ async def complete_chunked_upload( file_hash=request.file_hash, ) ) - celery_app.send_task("worker.ingest_asset", args=[job.id]) + celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id]) + _persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", "")) # Update metadata status meta["status"] = "completed" diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index bcea7df42..766637849 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -75,6 +75,86 @@ class GenerateCoverResponse(BaseModel): # ── Route ──────────────────────────────────────────────────────────────── + +def _select_best_frame_from_snapshots( + snapshots: list[dict], plan_id: str +) -> str: + """从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。 + + 降级策略:cv2 不可用或评分失败时,返回第一帧。 + + Args: + snapshots: MediaKit 返回的帧列表 [{"image_url": str, ...}, ...] + plan_id: 计划 ID(日志用) + + Returns: + 最佳帧的 image_url,或空字符串 + """ + if not snapshots: + return "" + if len(snapshots) == 1: + return snapshots[0].get("image_url") or snapshots[0].get("url") or "" + + try: + import tempfile + + import httpx + + from packages.shared.cover_frame_scorer import score_frames + + scored_candidates = [] + for snap in snapshots: + url = snap.get("image_url") or snap.get("url") or "" + if not url: + continue + # 下载帧到临时文件进行评分 + try: + resp = httpx.get(url, timeout=15, follow_redirects=True) + resp.raise_for_status() + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp.write(resp.content) + tmp_path = tmp.name + scored_candidates.append({"image_path": tmp_path, "url": url}) + except Exception: + # 下载失败的帧跳过,给默认低分 + scored_candidates.append({"image_path": None, "url": url, "score": 0.0}) + + if not scored_candidates: + return snapshots[0].get("image_url") or snapshots[0].get("url") or "" + + scored = score_frames(scored_candidates) + best = scored[0] if scored else None + best_url = best.get("url", "") if best else "" + best_score = best.get("score", 0.0) if best else 0.0 + logger.info( + "[封面生成] 帧质量评分完成: plan_id=%s candidates=%d best_score=%.1f", + plan_id, + len(scored_candidates), + best_score, + ) + + # 清理临时文件 + for c in scored_candidates: + path = c.get("image_path") + if path: + try: + from pathlib import Path + + Path(path).unlink(missing_ok=True) + except Exception: + pass + + return best_url + + except Exception: + logger.warning( + "[封面生成] 帧质量评分失败,使用第一帧: plan_id=%s", + plan_id, + exc_info=True, + ) + return snapshots[0].get("image_url") or snapshots[0].get("url") or "" + + def _persist_cover_frame( frame_url: str, plan_id: str, @@ -652,13 +732,13 @@ def generate_cover( snapshots = mk_client.extract_frames( video_url=primary_video_url, strategy="SpecifiedFrames", - max_frames=1, + max_frames=5, # 抽 5 帧,通过质量评分选最佳 poll_interval=2.0, max_poll_attempts=5, max_retries=0, ) if snapshots: - raw = snapshots[0].get("image_url") or snapshots[0].get("url") or "" + raw = _select_best_frame_from_snapshots(snapshots, plan_id) if raw: cover_url_from_task = _persist_cover_frame(raw, plan_id) logger.info( @@ -715,13 +795,13 @@ def generate_cover( snapshots = mk_client.extract_frames( video_url=src_url, strategy="SpecifiedFrames", - max_frames=1, + max_frames=5, # 抽 5 帧,通过质量评分选最佳 poll_interval=2.0, max_poll_attempts=5, max_retries=0, ) if snapshots: - raw = snapshots[0].get("image_url") or snapshots[0].get("url") or "" + raw = _select_best_frame_from_snapshots(snapshots, plan_id) if raw: cover_url_from_task = _persist_cover_frame( raw, diff --git a/apps/api/app/api/routes/generation_preview.py b/apps/api/app/api/routes/generation_preview.py index c40fdf343..eb2302ce7 100755 --- a/apps/api/app/api/routes/generation_preview.py +++ b/apps/api/app/api/routes/generation_preview.py @@ -14,6 +14,7 @@ from app.core.task_enqueue import ( USER_PENDING_LIMIT, GlobalQueueFull, UserPendingLimitExceeded, + build_rate_limit_detail, safe_enqueue_generation_task, ) from app.dependencies import ( @@ -23,6 +24,7 @@ from app.dependencies import ( get_generation_task_repository, ) from app.schemas.generation_task import ( + BatchPreviewGenerationTaskResponse, CreatePreviewGenerationTaskRequest, PreviewGenerationTaskResponse, ) @@ -193,11 +195,19 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG if started_at and completed_at: generate_duration = (completed_at - started_at).total_seconds() + title_cfg = getattr(task, "title_config", None) + title_cfg = title_cfg if isinstance(title_cfg, dict) else {} + extra_meta = getattr(task, "extra_meta", None) + extra_meta = extra_meta if isinstance(extra_meta, dict) else {} + voice_library_id = getattr(task, "voice_library_id", "") or "" + if not isinstance(voice_library_id, str): + voice_library_id = str(voice_library_id) if voice_library_id else "" return PreviewGenerationTaskResponse( task_id=task.id, status=task.status.value if hasattr(task.status, "value") else str(task.status), progress=float(task.progress or 0.0), is_preview=bool(getattr(task, "is_preview", True)), + variant_index=int(extra_meta.get("variant_index", 0) or 0), resolution=getattr(task, "resolution", "") or "", video_url=video_url, duration=duration, @@ -206,6 +216,8 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG transition_count=transition_count, material_usage=material_usage, error_message=task.error_message or "", + title_text=str(title_cfg.get("text", "") or ""), + voice_library_id=voice_library_id, created_at=task.created_at, started_at=started_at, finished_at=completed_at, @@ -213,50 +225,100 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG ) -@router.post("/preview", response_model=PreviewGenerationTaskResponse, status_code=201) +def _resolve_preview_edit_plan_id( + *, + request: CreatePreviewGenerationTaskRequest, + task, + db: Session, + user_id: str, +) -> str: + """确定任务关联的编辑计划ID:优先前端传入,否则按 template_id+user 兜底查找。""" + if task.source_edit_plan_id: + return task.source_edit_plan_id + if not request.template_id: + return "" + try: + from packages.adapters.sqlalchemy_impl.edit_plan_repository import ( + SQLAlchemyEditPlanRepository, + ) + + _plan_repo = SQLAlchemyEditPlanRepository(db) + _plans = _plan_repo.list_by_template(request.template_id, limit=20) + for _p in _plans: + if (_p.created_by_user_id or "") == user_id: + logger.info( + "[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s", + task.id, + _p.id, + ) + return _p.id + except Exception: + logger.warning( + "[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s", + task.id, + exc_info=True, + ) + return "" + + +def _variant_value(values: list[str], index: int, fallback: str = "") -> str: + """从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。""" + if not values: + return fallback + if len(values) == 1: + return values[0] + return values[index] if index < len(values) else fallback + + +@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201) def create_preview_generation_task( request: CreatePreviewGenerationTaskRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), generation_task_repository=Depends(get_generation_task_repository), db: Session = Depends(get_db_session), asset_repo=Depends(get_asset_repository), -) -> PreviewGenerationTaskResponse: - """创建预览生成任务。 +) -> BatchPreviewGenerationTaskResponse: + """创建预览生成任务(支持批量)。 - 预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。 - - Args: - request: 预览任务创建请求(template_id + asset_ids 等) + preview_count=1 时行为与旧版完全一致(创建 1 个任务); + preview_count=N 时一次创建 N 个独立变体任务: + - 每个变体克隆独立编辑计划(独立 clips、独立随机素材起点),N 个预览内容互不相同 + - 每个变体拥有独立 task_id / 状态 / 预览视频 URL,前端按 task_id 分别轮询 + - 标题样式(font/color/position 等)全局共用;标题文字/配音/封面可按变体独立 + (titles[] / voice_library_ids[] / cover_urls[],长度1=共用,长度N=独立) Returns: - 201 + 预览任务详情 + 201 + 变体任务数组 {items: [...], total: N} """ user_id = authenticated_user.user.id + count = max(1, request.preview_count) logger.info( "[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d", user_id, request.template_id, len(request.asset_ids), - request.preview_count, + count, ) - # 预检查队列限流 + # 预检查队列限流(按变体总数计) try: user_pending = generation_task_repository.count_pending_by_user(user_id) global_pending = generation_task_repository.count_pending_total() - if user_pending + 1 > USER_PENDING_LIMIT: - raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT) - if global_pending + 1 > GLOBAL_PENDING_LIMIT: - raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT) + if user_pending + count > USER_PENDING_LIMIT: + raise UserPendingLimitExceeded( + user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT + ) + if global_pending + count > GLOBAL_PENDING_LIMIT: + raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT) except UserPendingLimitExceeded as e: raise HTTPException( status_code=429, - detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交", + detail=build_rate_limit_detail(e, generation_task_repository, scope="user"), ) from e except GlobalQueueFull as e: raise HTTPException( status_code=503, - detail="系统繁忙,请稍后再试", + detail=build_rate_limit_detail(e, generation_task_repository, scope="global"), ) from e # 确定视频比例:优先前端传入,否则从模板 mode 推断 @@ -273,14 +335,11 @@ def create_preview_generation_task( w, h = int(parts[0]), int(parts[1]) base = 1920 if w < h: - # 竖屏 output_width = round(base * w / h) output_height = base else: - # 横屏 output_width = base output_height = round(base * h / w) - # 对齐到偶数 output_width = output_width - output_width % 2 output_height = output_height - output_height % 2 except (ValueError, ZeroDivisionError): @@ -289,42 +348,70 @@ def create_preview_generation_task( logger.info( "[预览生成] 分辨率: video_ratio=%s → %s (%dx%d)", - video_ratio, resolution, output_width, output_height, + video_ratio, + resolution, + output_width, + output_height, ) - # 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数) strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id) - - title_config = request.title_config or {} + base_title_config = request.title_config or {} use_case = CreateGenerationTaskUseCase(generation_task_repository) + # ── 预创建第一个任务,仅用于解析源编辑计划(不落库为最终任务)── + # 先创建一个临时任务拿到 task 对象上下文,实际 N 个任务在循环中统一创建; + # 为保持与旧版一致的源 plan 解析逻辑,先创建任务0、解析源 plan, + # 再预克隆 N 个变体 plan,最后重建任务关联。 + # 简化实现:直接创建全部任务,plan 关联在创建后、入队前完成。 + + created_tasks: list = [] + variant_plan_ids: list[str] = [] # 每个变体最终关联的 plan_id(按变体顺序) + try: - task = use_case.execute( - CreateGenerationTaskCommand( - project_id="", - asset_library_id="", - strategy_id=strategy_id, - voice_library_id=request.voice_library_id, - template_id=request.template_id, - asset_ids=list(request.asset_ids), - title_ids=list(request.title_ids), - voice_ids=list(request.voice_ids), - created_by_user_id=user_id, - source_edit_plan_id=request.source_edit_plan_id, - asset_select_mode="", - batch_id="", - video_title=request.video_title, - resolution=resolution, - bgm_config=request.bgm_config or {}, - auto_retry_enabled=False, - auto_retry_max=0, - is_preview=True, - title_config=title_config, - output_width=output_width, - output_height=output_height, + for variant_index in range(count): + # 变体独立标题文字:titles[] 覆盖 title_config.text + variant_title_text = _variant_value(request.titles, variant_index, "") + variant_title_config = dict(base_title_config) + if variant_title_text.strip(): + variant_title_config["text"] = variant_title_text.strip() + + # 变体独立配音 + variant_voice_library_id = _variant_value( + request.voice_library_ids, variant_index, request.voice_library_id ) - ) + + task = use_case.execute( + CreateGenerationTaskCommand( + project_id="", + asset_library_id="", + strategy_id=strategy_id, + voice_library_id=variant_voice_library_id, + template_id=request.template_id, + asset_ids=list(request.asset_ids), + title_ids=list(request.title_ids), + created_by_user_id=user_id, + source_edit_plan_id=request.source_edit_plan_id, + asset_select_mode="", + batch_id="", + video_title=request.video_title, + resolution=resolution, + bgm_config=request.bgm_config or {}, + auto_retry_enabled=False, + auto_retry_max=0, + is_preview=True, + title_config=variant_title_config, + output_width=output_width, + output_height=output_height, + ) + ) + task.extra_meta["variant_index"] = variant_index + + # 解析源编辑计划(前端传入或按模板兜底查找) + source_plan_id = _resolve_preview_edit_plan_id(request=request, task=task, db=db, user_id=user_id) + task.source_edit_plan_id = source_plan_id + generation_task_repository.update(task) + created_tasks.append(task) except ValueError as e: logger.warning("[预览生成] 创建失败: %s", e) raise HTTPException(status_code=400, detail=str(e)) from e @@ -332,93 +419,204 @@ def create_preview_generation_task( logger.error("[预览生成] 创建失败: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e - # 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找 - if not task.source_edit_plan_id and request.template_id: + # ── 独立变体 plan(#1743)── + # count=1:克隆源 plan(预览不污染源 plan,仅起点重算),行为与旧版一致; + # count>1:变体 0 保留源 plan,变体 1..N-1 用 reselect_plan_for_variant 完整 + # 重跑单视频选片(素材洗牌+镜头洗牌+起点随机+跨变体避让+批次 20% 重叠重选), + # 所见即所得——预览变体差异即正式成片差异。 + source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else "" + + # #1749:各变体配音解析(严格守卫已在 schema;此处取每变体 voice 查时长)+ 时长分配 + def _preview_voice_durations() -> list[float]: try: - from packages.adapters.sqlalchemy_impl.edit_plan_repository import ( - SQLAlchemyEditPlanRepository, - ) + from packages.domain.variant_voice_resolver import resolve_variant_voice_ids - _plan_repo = SQLAlchemyEditPlanRepository(db) - _plans = _plan_repo.list_by_template(request.template_id, limit=20) - for _p in _plans: - if (_p.created_by_user_id or "") == user_id: - task.source_edit_plan_id = _p.id - generation_task_repository.update(task) - logger.info( - "[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s", - task.id, - _p.id, - ) - break + voices = resolve_variant_voice_ids( + count=count, + voice_library_id=request.voice_library_id, + voice_library_ids=request.voice_library_ids or None, + ) except Exception: - logger.warning( - "[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s", - task.id, - exc_info=True, - ) + logger.warning("[预览生成] 配音解析失败(按无配音处理)", exc_info=True) + return [0.0] * count + try: + from app.api.routes.generation_tasks import _query_voice_durations - # 每条预览都关联独立克隆 plan:多预览前端为 N 次并发调用,若共用同一 plan - # 则 N 条预览片段完全相同;克隆时片段起点按持久化历史区间重算(含受控复用), - # 保证各预览版本内容不同 - if task.source_edit_plan_id: + return _query_voice_durations(db, voices) + except Exception: + return [0.0] * count + + voice_durations = _preview_voice_durations() + + if source_plan_id and count == 1: + # 单预览:克隆一份(原逻辑)+ 配音时长分配 try: from app.services.edit_plan_service import EditPlanService _plan_svc = EditPlanService(db) - _preview_plan = _plan_svc.clone_plan_for_variant( - task.source_edit_plan_id, + variant_plan = _plan_svc.clone_plan_for_variant( + source_plan_id, created_by_user_id=user_id, name_suffix="预览变体", ) - task.source_edit_plan_id = _preview_plan.id - generation_task_repository.update(task) - logger.info( - "[预览生成] 预览关联独立克隆 plan: task_id=%s clone_plan_id=%s", - task.id, - _preview_plan.id, - ) - except Exception as clone_err: - # 不退回共用原 plan(否则多条预览内容相同,违反去重诉求): - # 标记任务失败并中断,前端可重新发起预览 - logger.error( - "[预览生成] 克隆预览变体 plan 失败,任务标记失败: task_id=%s error=%s", - task.id, - clone_err, - exc_info=True, - ) - _mark_task_failed(generation_task_repository, task, "预览变体计划创建失败") + if voice_durations and voice_durations[0] > 0: + try: + _plan_svc.apply_voice_duration_to_plan(variant_plan.id, voice_durations[0]) + except Exception: + logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", variant_plan.id) + variant_plan_ids.append(variant_plan.id) + except Exception as e: + logger.error("[预览生成] 克隆预览 plan 异常: %s", e, exc_info=True) + for t in created_tasks: + _mark_task_failed(generation_task_repository, t, "预览计划创建失败") raise HTTPException( status_code=500, detail="创建预览任务失败:无法生成独立剪辑计划,请重试", - ) from clone_err + ) from e + elif source_plan_id and count > 1: + try: + from app.services.edit_plan_service import EditPlanService - # 入队执行;若入队失败则标记任务为 failed 避免僵尸数据 - try: - if not safe_enqueue_generation_task( - task, - generation_task_repository, - user_id=user_id, - log_prefix="[预览生成]", - log_task_status=True, - ): - logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id) - _mark_task_failed(generation_task_repository, task, "任务入队失败") - raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试") - except UserPendingLimitExceeded as e: - _mark_task_failed(generation_task_repository, task, "待处理任务超限") - raise HTTPException( - status_code=429, - detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交", - ) from None - except GlobalQueueFull: - _mark_task_failed(generation_task_repository, task, "系统队列已满") + _plan_svc = EditPlanService(db) + # #1749:变体 0 也 clone(不污染源 plan)+ 配音分配;变体 1..N-1 独立选片 + _plan0 = _plan_svc.clone_plan_for_variant( + source_plan_id, + created_by_user_id=user_id, + name_suffix="预览变体1", + ) + if voice_durations and voice_durations[0] > 0: + try: + _plan_svc.apply_voice_duration_to_plan(_plan0.id, voice_durations[0]) + except Exception: + logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", _plan0.id) + variant_plan_ids.append(_plan0.id) + batch_asset_pool = list(dict.fromkeys(request.asset_ids or [])) + for variant_index in range(1, count): + last_err: Exception | None = None + variant_plan = None + for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动 + try: + variant_plan = _plan_svc.reselect_plan_for_variant( + source_plan_id, + batch_asset_pool, + created_by_user_id=user_id, + name_suffix=f"预览变体{variant_index + 1}", + voice_duration=( + voice_durations[variant_index] if variant_index < len(voice_durations) else 0.0 + ), + ) + break + except ValueError as ve: + logger.warning("[预览生成] 变体独立选片失败(素材不足): %s", ve) + for t in created_tasks: + _mark_task_failed(generation_task_repository, t, "预览变体选片失败") + raise HTTPException( + status_code=400, + detail=f"批量预览第 {variant_index + 1} 个视频无法独立选片:{ve}。" + "请增加素材库中的视频素材后重试。", + ) from ve + except Exception as reselection_err: # noqa: PERF203 + last_err = reselection_err + logger.warning( + "[预览生成] 变体独立选片失败(尝试%d/2): variant=%d error=%s", + _attempt + 1, + variant_index, + reselection_err, + exc_info=True, + ) + if variant_plan is None: + logger.error( + "[预览生成] 变体独立选片重试仍失败: variant=%d source=%s", + variant_index, + source_plan_id, + exc_info=last_err, + ) + for t in created_tasks: + _mark_task_failed(generation_task_repository, t, "预览变体计划创建失败") + raise HTTPException( + status_code=500, + detail="创建预览任务失败:无法生成独立剪辑计划,请重试", + ) from last_err + variant_plan_ids.append(variant_plan.id) + except HTTPException: + raise + except Exception as e: + logger.error("[预览生成] 变体 plan 生成异常: %s", e, exc_info=True) + for t in created_tasks: + _mark_task_failed(generation_task_repository, t, "预览变体计划创建失败") + raise HTTPException( + status_code=500, + detail="创建预览任务失败:无法生成独立剪辑计划,请重试", + ) from e + + # 关联变体 plan 并回写标题配置 + for variant_index, task in enumerate(created_tasks): + if variant_plan_ids: + task.source_edit_plan_id = variant_plan_ids[variant_index] + generation_task_repository.update(task) + # 回写变体标题到 plan config(worker 渲染时从 plan 读取 title 配置) + if task.source_edit_plan_id and (task.title_config or {}).get("text", "").strip(): + try: + from app.api.routes.generation_tasks import _writeback_edit_plan_config + + _writeback_edit_plan_config( + plan_id=task.source_edit_plan_id, + task_id=task.id, + title_config=task.title_config, + db=db, + ) + except Exception: + logger.warning( + "[预览生成] 回写标题配置失败(不影响主流程): task_id=%s", + task.id, + exc_info=True, + ) + + # ── 入队 ── + responses: list[PreviewGenerationTaskResponse] = [] + rate_limit_exc: Exception | None = None # 记录首个限流异常,全部失败时返回结构化提示 + for variant_index, task in enumerate(created_tasks): + try: + enqueued = safe_enqueue_generation_task( + task, + generation_task_repository, + user_id=user_id, + log_prefix=f"[预览生成][变体{variant_index + 1}]", + log_task_status=True, + ) + if not enqueued: + logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id) + _mark_task_failed(generation_task_repository, task, "任务入队失败") + except UserPendingLimitExceeded as e: + _mark_task_failed(generation_task_repository, task, "待处理任务超限") + rate_limit_exc = rate_limit_exc or e + except GlobalQueueFull as e: + _mark_task_failed(generation_task_repository, task, "系统队列已满") + rate_limit_exc = rate_limit_exc or e + except Exception: + logger.exception("[预览生成] 入队异常: task_id=%s", task.id) + _mark_task_failed(generation_task_repository, task, "任务入队异常") + # enqueue 会原地更新 task 状态/进度,直接用 task 构造响应 + responses.append(_to_preview_response(task)) + + # 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败") + if all(r.status == "failed" for r in responses) and rate_limit_exc is not None: + if isinstance(rate_limit_exc, UserPendingLimitExceeded): + raise HTTPException( + status_code=429, + detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"), + ) raise HTTPException( status_code=503, - detail="系统繁忙,请稍后再试", - ) from None + detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"), + ) - return _to_preview_response(task) + logger.info( + "[预览生成] 创建完成: %d 个变体任务, task_ids=%s", + len(responses), + [r.task_id for r in responses], + ) + return BatchPreviewGenerationTaskResponse(items=responses, total=len(responses)) @router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index d5fbad1f1..d1e301ae1 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -10,6 +10,7 @@ from app.core.task_enqueue import ( USER_PENDING_LIMIT, GlobalQueueFull, UserPendingLimitExceeded, + build_rate_limit_detail, safe_enqueue_generation_task, ) from app.dependencies import ( @@ -47,6 +48,22 @@ logger = logging.getLogger(__name__) router = APIRouter() +def _variant_value(values: list[str], index: int, fallback: str = "") -> str: + """从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。""" + if not values: + return fallback + if len(values) == 1: + return values[0] + return values[index] if index < len(values) else fallback + + +def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]: + """[已下沉] 路由层兼容别名 → app.services.generation_common.query_voice_durations。""" + from app.services.generation_common import query_voice_durations + + return query_voice_durations(db, voice_ids) + + def _to_generation_task_response(task) -> GenerationTaskResponse: return GenerationTaskResponse( id=task.id, @@ -113,6 +130,7 @@ def _select_assets_from_library( assets: list, mode: str, count: int, + rng=None, ) -> list[str]: """根据选取模式从素材库中选取 ready 状态的视频素材 ID。 @@ -120,6 +138,8 @@ def _select_assets_from_library( assets: 素材库中所有素材(Asset 实体列表) mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性) count: 选取数量,0 表示全部(仅 smart 模式有效) + rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机; + 测试可注入固定种子或零噪声随机源获得确定性结果。 Returns: 选中的素材 ID 列表 @@ -132,8 +152,9 @@ def _select_assets_from_library( if mode == "smart": # 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取 # 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%) + # 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重 limit = count if count > 0 else None - results = smart_select_assets(ready_video_assets, limit=limit, kind="video") + results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng) return [r.asset.id for r in results] # 默认 all 模式:返回全部 ready 视频素材 @@ -146,61 +167,10 @@ def _writeback_edit_plan_config( title_config: dict | None, db: Session, ) -> None: - """任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。 + """[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。""" + from app.services.generation_common import writeback_edit_plan_config - 用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。 - 失败只记日志,不影响任务创建。 - """ - if not plan_id: - return - try: - from packages.adapters.sqlalchemy_impl.models import EditPlanModel - - plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first() - if plan_model is None: - logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id) - return - - current_config = plan_model.config if isinstance(plan_model.config, dict) else {} - merged = dict(current_config) - merged["generation_task_id"] = task_id - - # 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面 - if title_config: - old_title_config = merged.get("title_config", {}) or {} - old_title_text = (old_title_config.get("text") or "").strip() - new_title_text = (title_config.get("text") or "").strip() - if old_title_text != new_title_text: - # 标题变化,清除旧封面 - if "cover" in merged: - del merged["cover"] - logger.info( - "[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s", - plan_id, - old_title_text, - new_title_text, - ) - merged["title_config"] = title_config - - plan_model.config = merged - db.commit() - logger.info( - "[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s", - plan_id, - task_id, - list(merged.keys()), - ) - except Exception as e: - logger.warning( - "[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s", - plan_id, - e, - exc_info=True, - ) - try: - db.rollback() - except Exception: - pass + return writeback_edit_plan_config(plan_id, task_id, title_config, db) def _resolve_project_and_library( @@ -408,12 +378,12 @@ def create_generation_task( except UserPendingLimitExceeded as e: raise HTTPException( status_code=429, - detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交", + detail=build_rate_limit_detail(e, generation_task_repository, scope="user"), ) from e except GlobalQueueFull as e: raise HTTPException( status_code=503, - detail="系统繁忙,请稍后再试", + detail=build_rate_limit_detail(e, generation_task_repository, scope="global"), ) from e # 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take @@ -422,66 +392,211 @@ def create_generation_task( logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id) effective_strategy_id = "one_take" - # 批量生成时每个任务关联独立克隆 plan(片段起点重算), - # 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。 - # 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据), - # 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。 + # 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。 + # - 变体 0:clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant + # 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用); + # - #1749:前端可回传 variant-plans 接口预生成的 plan_id(variant_plan_ids),直接复用; + # 回传 plan 仍按各变体配音幂等重分配段长(防 variant-plans 阶段未带配音/占位时长); + # - 配音时长:独立配音各自时长、统一配音同值,逐变体 apply_voice_duration_to_plan, + # 成片总时长=配音时长(素材短→末帧冻结,禁慢放/禁截配音); + # - count>1 但没有源 plan 时,不允许 N 个任务兜底共用同一 plan,直接 4xx 中断。 + # 在创建任何任务【之前】预生成/校验全部变体 plan:失败直接中断(此时无脏数据)。 variant_plan_ids: list[str] = [] - if count > 1 and request.source_edit_plan_id: + if count > 1: from app.services.edit_plan_service import EditPlanService + from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids + _plan_svc = EditPlanService(db) - for task_index in range(1, count): - variant = None - last_err: Exception | None = None - for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动 - try: - variant = _plan_svc.clone_plan_for_variant( - request.source_edit_plan_id, - created_by_user_id=user_id, - name_suffix=f"批量{task_index + 1}", - ) - break - except Exception as clone_err: # noqa: PERF203 - last_err = clone_err - logger.warning( - "[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s", - _attempt + 1, - request.source_edit_plan_id, - clone_err, - exc_info=True, - ) - if variant is None: - logger.error( - "[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s", - request.source_edit_plan_id, - exc_info=last_err, - ) + + # 解析每变体配音(严格守卫:独立配音长度/缺值 → 400,禁静默 fallback) + try: + variant_voices = resolve_variant_voice_ids( + count=count, + voice_library_id=request.voice_library_id, + voice_library_ids=request.voice_library_ids or None, + ) + except VariantVoiceError as ve: + raise HTTPException(status_code=400, detail=str(ve)) from ve + # 各变体配音时长(查询硬化:异常 → 0.0 不阻断) + voice_durations = _query_voice_durations(db, variant_voices) + + # 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(公共函数) + from app.services.generation_common import resolve_latest_plan_by_template + + batch_source_plan_id = ( + request.source_edit_plan_id + or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) + or "" + ) + + if not batch_source_plan_id and not request.variant_plan_ids: + # 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源 + logger.error("[生成任务] 批量 count=%d 但无可编辑计划(无 source_edit_plan_id/template plan)", count) + raise HTTPException( + status_code=400, + detail="批量生成需要先完成预览生成(缺少剪辑计划)。请先生成预览后再批量创建。", + ) + + # 批次素材池:请求显式素材 + 库自动匹配素材(resolved_asset_ids) + batch_asset_pool = list(dict.fromkeys(resolved_asset_ids or [])) + + if request.variant_plan_ids: + # ① 前端回传 variant-plans 预生成结果:直接复用(轻量选片接口已建好 plan) + if len(request.variant_plan_ids) != count: raise HTTPException( - status_code=500, - detail="创建批量任务失败:无法生成独立剪辑计划,请重试", - ) from last_err - variant_plan_ids.append(variant.id) + status_code=400, + detail=f"variant_plan_ids 数量({len(request.variant_plan_ids)})与视频数量({count})不一致", + ) + # 校验归属权 + from packages.adapters.sqlalchemy_impl.models import EditPlanModel + + for _pid in request.variant_plan_ids: + _pm = db.query(EditPlanModel).filter(EditPlanModel.id == _pid).first() + if _pm is None: + raise HTTPException(status_code=400, detail=f"剪辑计划不存在: {_pid}") + if _pm.created_by_user_id and _pm.created_by_user_id != user_id: + raise HTTPException(status_code=403, detail=f"无权使用剪辑计划: {_pid}") + variant_plan_ids = list(request.variant_plan_ids) + else: + # ② 服务端选片:变体 0 clone 源 plan(不污染源 plan) + try: + _plan0 = _plan_svc.clone_plan_for_variant( + batch_source_plan_id, + created_by_user_id=user_id, + name_suffix="批量1", + ) + except Exception as clone_err: + logger.error("[生成任务] 变体0 clone 失败: %s", clone_err, exc_info=True) + raise HTTPException( + status_code=500, detail="创建批量任务失败:无法生成独立剪辑计划,请重试" + ) from clone_err + variant_plan_ids.append(_plan0.id) + + # #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数) + from app.services.generation_common import collect_plan_segments as _collect_segments + + _batch_segments = _collect_segments(_plan0.id, _plan_svc._clip_repo) + + # 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让) + for task_index in range(1, count): + variant = None + last_err: Exception | None = None + for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动 + try: + variant = _plan_svc.reselect_plan_for_variant( + batch_source_plan_id, + batch_asset_pool, + created_by_user_id=user_id, + name_suffix=f"批量{task_index + 1}", + voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0, + batch_segments=_batch_segments, + ) + break + except ValueError as ve: + # 素材不足等可预期错误:不重试,直接中断并给出明确提示 + logger.warning("[生成任务] 变体独立选片失败(素材不足): %s", ve) + raise HTTPException( + status_code=400, + detail=f"批量生成第 {task_index + 1} 个视频无法独立选片:{ve}。" + "请增加素材库中的视频素材后重试。", + ) from ve + except Exception as reselection_err: # noqa: PERF203 + last_err = reselection_err + logger.warning( + "[生成任务] 变体独立选片失败(尝试%d/2): source=%s error=%s", + _attempt + 1, + batch_source_plan_id, + reselection_err, + exc_info=True, + ) + if variant is None: + logger.error( + "[生成任务] 变体独立选片重试仍失败,中断批量创建: source=%s", + batch_source_plan_id, + exc_info=last_err, + ) + raise HTTPException( + status_code=500, + detail="创建批量任务失败:无法生成独立剪辑计划,请重试", + ) from last_err + variant_plan_ids.append(variant.id) + + # #1855 P0:把新变体的clips区间追加到batch_segments,供下一变体避让 + try: + _new_segs = _collect_segments(variant.id, _plan_svc._clip_repo) + for _aid, _ivs in _new_segs.items(): + _batch_segments.setdefault(_aid, []).extend(_ivs) + except Exception: + logger.exception("[生成任务] 变体%d 区间收集失败(不阻断)", task_index) + + # ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配, + # #1855:apply_voice_duration_to_plan 已内置幂等判断,重复调用安全) + for _vi, _pid in enumerate(variant_plan_ids): + _vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0 + if _vd > 0: + try: + _plan_svc.apply_voice_duration_to_plan(_pid, _vd) + except Exception: + logger.exception("[生成任务] 变体%d 配音时长分配失败(不阻断): plan=%s", _vi, _pid) + + # N=1 正式生成:渲染侧全局慢放兜底已删除(#1749),enqueue 前也必须按配音分配段长 + if count == 1 and not request.is_preview: + from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids + + try: + _voices = resolve_variant_voice_ids( + count=1, + voice_library_id=request.voice_library_id, + voice_library_ids=request.voice_library_ids or None, + ) + _single_vd: list[float] = _query_voice_durations(db, _voices) + _single_dur = _single_vd[0] if _single_vd else 0.0 + from app.services.generation_common import resolve_latest_plan_by_template + + _single_plan = ( + request.source_edit_plan_id + or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) + or "" + ) + if _single_dur > 0 and _single_plan: + from app.services.edit_plan_service import EditPlanService + + try: + EditPlanService(db).apply_voice_duration_to_plan(_single_plan, _single_dur) + except Exception: + logger.exception("[生成任务] N=1 配音时长分配失败(不阻断): plan=%s", _single_plan) + except VariantVoiceError as ve: + raise HTTPException(status_code=400, detail=str(ve)) from ve + except Exception: + logger.exception("[生成任务] N=1 配音分配兜底异常(不阻断)") try: for task_index in range(count): - # 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。 - # 无源 plan(source_edit_plan_id 为空)时无可克隆对象,variant_plan_ids - # 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError) - effective_plan_id = request.source_edit_plan_id - if task_index > 0 and variant_plan_ids: - effective_plan_id = variant_plan_ids[task_index - 1] + # #1749:count>1 时每个变体(含变体0)都关联各自独立 plan(clone/reselect/variant-plans)。 + if count > 1 and variant_plan_ids: + effective_plan_id = variant_plan_ids[task_index] + else: + effective_plan_id = request.source_edit_plan_id + + # 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[] + # 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段 + variant_title_text = _variant_value(request.titles, task_index, "") + variant_title_config = dict(request.title_config or {}) + if variant_title_text.strip(): + variant_title_config["text"] = variant_title_text.strip() + variant_voice_library_id = _variant_value(request.voice_library_ids, task_index, request.voice_library_id) + variant_cover_url = _variant_value(request.cover_urls, task_index, request.cover_url) task = use_case.execute( CreateGenerationTaskCommand( project_id=project_id, asset_library_id=asset_library_id, strategy_id=effective_strategy_id, - voice_library_id=request.voice_library_id, + voice_library_id=variant_voice_library_id, template_id=request.template_id, asset_ids=resolved_asset_ids, title_ids=request.title_ids, - voice_ids=request.voice_ids, created_by_user_id=user_id, source_edit_plan_id=effective_plan_id, asset_select_mode=request.asset_select_mode, @@ -495,14 +610,28 @@ def create_generation_task( source_task_id=request.source_task_id, output_width=request.output_width, output_height=request.output_height, - cover_url=request.cover_url, - title_config=request.title_config or {}, + cover_url=variant_cover_url, + title_config=variant_title_config, ) ) + # 变体序号写入 extra_meta(响应/排查时可辨识) + task.extra_meta["variant_index"] = task_index try: # 兜底关联编辑计划:前端未传 source_edit_plan_id 时, # 通过 template_id + user_id 在 DB 层直接查找最新的 plan。 - # 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件) + # 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)。 + # #1743:批量(count>1)场景严禁兜底共用——变体 plan 已在上方预生成, + # 走到这里还缺 plan 说明预生成漏配,直接报错中断,不允许 N 任务关联同一 plan。 + if not task.source_edit_plan_id and count > 1: + logger.error( + "[生成任务] 批量任务缺少独立 plan(禁止共用兜底): task_index=%d task_id=%s", + task_index, + task.id, + ) + raise HTTPException( + status_code=500, + detail="创建批量任务失败:变体剪辑计划缺失,请重新预览后再批量生成。", + ) if not task.source_edit_plan_id and request.template_id: try: from packages.adapters.sqlalchemy_impl.models import EditPlanModel @@ -533,13 +662,13 @@ def create_generation_task( # 回写 plan.config:必须在 enqueue 之前执行, # 确保 worker 读取 plan 时 config 中已包含 generation_task_id。 - # 只在首个任务时回写一次,避免批量生成时循环覆盖。 + # 批量场景下每个变体关联独立 plan,需各自回写自己的变体标题配置。 _effective_plan_id = task.source_edit_plan_id - if _effective_plan_id and len(created_tasks) == 0: + if _effective_plan_id: _writeback_edit_plan_config( plan_id=_effective_plan_id, task_id=task.id, - title_config=request.title_config, + title_config=variant_title_config, db=db, ) @@ -559,7 +688,7 @@ def create_generation_task( if not created_tasks: raise HTTPException( status_code=429, - detail="您的待处理任务过多,请等待完成后再提交", + detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), ) from _e break except GlobalQueueFull as _e: @@ -567,7 +696,7 @@ def create_generation_task( if not created_tasks: raise HTTPException( status_code=503, - detail="系统繁忙,请稍后再试", + detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"), ) from _e break except HTTPException: @@ -669,7 +798,6 @@ def confirm_generation( template_id=source_task.template_id, asset_ids=source_task.asset_ids, title_ids=source_task.title_ids, - voice_ids=source_task.voice_ids, created_by_user_id=authenticated_user.user.id, source_edit_plan_id=source_task.source_edit_plan_id or "", asset_select_mode=source_task.asset_select_mode, @@ -693,15 +821,15 @@ def confirm_generation( log_task_status=True, ): logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id) - except UserPendingLimitExceeded: + except UserPendingLimitExceeded as _e: raise HTTPException( status_code=429, - detail="您的待处理任务过多,请等待完成后再提交", + detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), ) from None - except GlobalQueueFull: + except GlobalQueueFull as _e: raise HTTPException( status_code=503, - detail="系统繁忙,请稍后再试", + detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"), ) from None return BatchGenerationTaskResponse( @@ -783,12 +911,24 @@ def retry_generation_task( if user_pending >= USER_PENDING_LIMIT: raise HTTPException( status_code=429, - detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交", + detail=build_rate_limit_detail( + UserPendingLimitExceeded( + user_id=user_id, + pending_count=user_pending, + limit=USER_PENDING_LIMIT, + ), + generation_task_repository, + scope="user", + ), ) if global_pending >= GLOBAL_PENDING_LIMIT: raise HTTPException( status_code=503, - detail="系统繁忙,请稍后再试", + detail=build_rate_limit_detail( + GlobalQueueFull(pending_count=global_pending, limit=GLOBAL_PENDING_LIMIT), + generation_task_repository, + scope="global", + ), ) use_case = CreateGenerationTaskUseCase(generation_task_repository) @@ -801,7 +941,6 @@ def retry_generation_task( template_id=task.template_id, asset_ids=task.asset_ids, title_ids=task.title_ids, - voice_ids=task.voice_ids, created_by_user_id=user_id, source_edit_plan_id=task.source_edit_plan_id or "", asset_select_mode=getattr(task, "asset_select_mode", ""), @@ -823,15 +962,15 @@ def retry_generation_task( log_task_status=True, ): logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id) - except UserPendingLimitExceeded: + except UserPendingLimitExceeded as _e: raise HTTPException( status_code=429, - detail="您的待处理任务过多,请等待完成后再提交", + detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), ) from None - except GlobalQueueFull: + except GlobalQueueFull as _e: raise HTTPException( status_code=503, - detail="系统繁忙,请稍后再试", + detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"), ) from None return _to_generation_task_response(retried) diff --git a/apps/api/app/api/routes/generation_variant_plans.py b/apps/api/app/api/routes/generation_variant_plans.py new file mode 100644 index 000000000..bd62fd804 --- /dev/null +++ b/apps/api/app/api/routes/generation_variant_plans.py @@ -0,0 +1,165 @@ +"""轻量选片接口 POST /generation/variant-plans(#1749)。 + +与正式生成共用同一套选片函数(EditPlanService.ensure_variant_plans → +clone_plan_for_variant / reselect_plan_for_variant → variant_plan_selector), +但**不建任务、不入队、不渲染**: + +- 仅为 N 个变体创建/选好 EditPlan + clips,返回 plan_id 与片段列表; +- 前端确认后调正式生成接口回传 variant_plan_ids,直接复用这些 plan, + 不再重复选片(回传后仍按各变体配音幂等重分配段长); +- 配音守卫:voice_library_ids 长度/缺值 → 400(variant_voice_resolver), + 禁静默 fallback; +- 素材不足等选片失败 → 400(与正式生成同口径);除此之外不报错打断。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field, model_validator +from sqlalchemy.orm import Session + +from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class VariantPlanRequest(BaseModel): + """轻量选片请求体(与前端 variantPlans.ts 契约一致)。""" + + template_id: str = Field(default="", description="模板 ID(无 source_edit_plan_id 时用于查找骨架 plan)") + asset_ids: list[str] = Field(default_factory=list, description="批次素材池") + count: int = Field(default=1, ge=1, le=50, description="变体数量") + source_edit_plan_id: str = Field(default="", description="源剪辑计划 ID(优先)") + # 配音(可选;传独立配音时严格守卫) + voice_library_id: str = Field(default="", description="统一配音 ID") + voice_library_ids: list[str] = Field(default_factory=list, description="独立配音 ID 列表(长度须=count)") + + @model_validator(mode="after") + def _validate(self) -> "VariantPlanRequest": + if not self.template_id.strip() and not self.source_edit_plan_id.strip(): + raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个") + try: + resolve_variant_voice_ids( + count=self.count, + voice_library_id=self.voice_library_id, + voice_library_ids=self.voice_library_ids or None, + ) + except VariantVoiceError as exc: + raise ValueError(str(exc)) from exc + return self + + +class VariantPlanItem(BaseModel): + variant_index: int + plan_id: str + clips: list[dict[str, Any]] = Field(default_factory=list) + + +class VariantPlanResponse(BaseModel): + items: list[VariantPlanItem] + total: int + + +@router.post("/variant-plans", response_model=VariantPlanResponse) +def create_variant_plans( + request: VariantPlanRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +) -> VariantPlanResponse: + """轻量选片:为 N 个变体创建独立 EditPlan + clips,不建任务/不渲染。 + + Returns: + 200 + {items: [{variant_index, plan_id, clips}], total} + """ + user_id = authenticated_user.user.id + + # 配音严格守卫(schema 已校验,此处复用解析取每变体配音) + try: + voices = resolve_variant_voice_ids( + count=request.count, + voice_library_id=request.voice_library_id, + voice_library_ids=request.voice_library_ids or None, + ) + except VariantVoiceError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # 解析源 plan:显式传入优先;否则按 template_id + user 查最新(公共函数) + from app.services.generation_common import resolve_latest_plan_by_template + + source_plan_id = request.source_edit_plan_id.strip() + if not source_plan_id and request.template_id.strip(): + source_plan_id = resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) or "" + + if not source_plan_id: + raise HTTPException( + status_code=400, + detail="缺少剪辑计划:请先完成一次预览生成(或传入 source_edit_plan_id)后再试。", + ) + + # 配音时长(硬化:异常 → 0.0 不阻断选片) + try: + from app.api.routes.generation_tasks import _query_voice_durations + + voice_durations = _query_voice_durations(db, voices) + except Exception: + logger.exception("[variant-plans] 配音时长查询失败(按占位段长选片)") + voice_durations = [0.0] * request.count + + from app.services.edit_plan_service import EditPlanService + + svc = EditPlanService(db) + try: + plan_ids = svc.ensure_variant_plans( + source_plan_id, + request.count, + list(dict.fromkeys(request.asset_ids or [])), + created_by_user_id=user_id, + voice_durations=voice_durations, + ) + except ValueError as ve: + # 素材池为空/时长全未知等可预期错误 → 400(与正式生成同口径) + logger.warning("[variant-plans] 选片失败: %s", ve) + raise HTTPException(status_code=400, detail=f"变体选片失败:{ve}。请增加素材后重试。") from ve + except HTTPException: + raise + except Exception as e: + logger.exception("[variant-plans] 选片异常") + raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e + + # 组装 clips 响应 + items: list[VariantPlanItem] = [] + for idx, pid in enumerate(plan_ids): + clips = svc.list_clips(pid) + clip_dicts = [ + { + "id": c.id, + "order": c.order, + "asset_id": c.asset_id, + "start_time": float(c.start_time or 0.0), + "duration": float(c.duration or 0.0), + "clip_type": c.clip_type, + "transition_effect": c.transition_effect, + "transition_duration": float(c.transition_duration or 0.0), + "playback_speed": float(c.playback_speed or 1.0), + "text_content": c.text_content or "", + "status": c.status or "ready", + } + for c in clips + ] + items.append(VariantPlanItem(variant_index=idx, plan_id=pid, clips=clip_dicts)) + + logger.info( + "[variant-plans] 轻量选片完成: user=%s source=%s count=%d plans=%d", + user_id, + source_plan_id, + request.count, + len(plan_ids), + ) + return VariantPlanResponse(items=items, total=len(items)) diff --git a/apps/api/app/api/routes/ingest_jobs.py b/apps/api/app/api/routes/ingest_jobs.py index 791be8964..ecb6f6a30 100644 --- a/apps/api/app/api/routes/ingest_jobs.py +++ b/apps/api/app/api/routes/ingest_jobs.py @@ -43,7 +43,13 @@ def submit_ingest_job( ) ) - celery_app.send_task("worker.ingest_asset", args=[job.id]) + celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id]) + if getattr(celery_result, "id", ""): + try: + job.celery_task_id = celery_result.id + ingest_job_repository.update(job) + except Exception: # noqa: BLE001 + pass return IngestJobResponse( id=job.id, diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py new file mode 100644 index 000000000..f4944ec62 --- /dev/null +++ b/apps/api/app/api/routes/lipsync.py @@ -0,0 +1,235 @@ +"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整, #1845 配音前置. + +接口: + POST /api/v1/lipsync/jobs 提交对口型任务(支持 TTS/直传/预合成 三种模式) + GET /api/v1/lipsync/jobs 任务列表 + GET /api/v1/lipsync/jobs/{id} 任务详情 + POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态 + POST /api/v1/lipsync/jobs/{id}/cancel 取消任务 + POST /api/v1/lipsync/tts-preview #1845 步骤1 TTS 预合成(同步 HTTP,~2-3s) +""" + +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.schemas.lipsync import ( + AiAvatarTtsPreviewRequest, + AiAvatarTtsPreviewResponse, + 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 sqlalchemy.orm import Session + +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 解析 + return LipsyncService( + db, + voice_clone_repo=voice_clone_repo, + ) + + +# ── POST /jobs — 提交对口型任务 ─────────────────────────────────────────── + + +@router.post("/jobs", response_model=LipsyncJobResponse, status_code=201) +def create_lipsync_job( + body: CreateLipsyncJobRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: LipsyncService = Depends(_get_service), +): + """提交对口型任务. + + 三种模式: + - TTS 直生(旧版/降级):传 {video_url, voice_id, script_text, speed?, emotion?}, + 后端 dispatch Celery 异步任务。 + - 直接音频:传 {video_url, audio_url},后端同步下载+算timings+提交MediaKit。 + - 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings}, + 后端同步ffprobe+写入timings+直接提交MediaKit(~2-3s)。 + """ + try: + job = svc.create_job( + user_id=current_user.user.id, + video_url=body.video_url, + audio_url=body.audio_url, + audio_duration=body.audio_duration, + sentence_timings=body.sentence_timings, + 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: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except MediaKitError as exc: + status_code = 502 + if exc.code in ("VoiceForbidden",): + status_code = 403 + elif exc.code in ("InvalidInput", "TTSInvalidParam", "VoiceNotReady"): + status_code = 400 + raise HTTPException( + status_code=status_code, + detail={ + "code": exc.code, + "message": str(exc), + "request_id": getattr(exc, "request_id", ""), + }, + ) from exc + except Exception as exc: + logger.error("创建对口型任务异常: %s", exc, exc_info=True) + raise HTTPException( + status_code=400, + detail=f"创建对口型任务失败: {exc}", + ) from exc + + return job + + +# ── POST /tts-preview — #1845 步骤1 TTS 预合成 ────────────────────────── + + +@router.post("/tts-preview", response_model=AiAvatarTtsPreviewResponse) +def preview_tts( + body: AiAvatarTtsPreviewRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: LipsyncService = Depends(_get_service), +): + """步骤1「生成配音」同步 TTS 预合成. + + 同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算, + 不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。 + 耗时约 2-3 秒。 + """ + try: + result = svc.preview_tts( + user_id=current_user.user.id, + voice_id=body.voice_id, + script_text=body.script_text, + speed=body.speed, + emotion=body.emotion, + ) + except MediaKitError as exc: + status_code = 400 + if exc.code in ("VoiceForbidden",): + status_code = 403 + elif exc.code in ("TTSNoAudio",): + status_code = 502 + raise HTTPException( + status_code=status_code, + detail={ + "code": exc.code, + "message": str(exc), + }, + ) from exc + except Exception as exc: + logger.error("TTS 预合成异常: %s", exc, exc_info=True) + raise HTTPException( + status_code=400, + detail=f"TTS 合成失败: {exc}", + ) from exc + + return result + + +# ── GET /jobs — 任务列表 ───────────────────────────────────────────────── + + +@router.get("/jobs", response_model=dict) +def list_lipsync_jobs( + project_id: str = Query("", description="项目 ID 过滤"), + status: str = Query("", description="状态过滤"), + offset: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + current_user: AuthenticatedUser = Depends(get_current_user), + svc: LipsyncService = Depends(_get_service), +): + """获取对口型任务列表.""" + items, total = svc.list_jobs( + user_id=current_user.user.id, + project_id=project_id, + status=status, + offset=offset, + limit=limit, + ) + return { + "items": [LipsyncJobResponse.model_validate(j) for j in items], + "total": total, + "offset": offset, + "limit": limit, + } + + +# ── GET /jobs/{job_id} — 任务详情 ──────────────────────────────────────── + + +@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse) +def get_lipsync_job( + job_id: str, + background: BackgroundTasks, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: LipsyncService = Depends(_get_service), +): + """获取对口型任务详情.""" + job = svc.get_job(job_id, current_user.user.id) + if job is None: + raise HTTPException(status_code=404, detail="任务不存在") + + if job.status not in ("completed", "failed"): + background.add_task(svc.refresh_job_status, job_id, current_user.user.id) + + return job + + +# ── POST /jobs/{job_id}/refresh — 刷新状态 ─────────────────────────────── + + +@router.post("/jobs/{job_id}/refresh", response_model=LipsyncJobResponse) +def refresh_lipsync_job( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: LipsyncService = Depends(_get_service), +): + """从 MediaKit 拉取最新状态并更新.""" + job = svc.refresh_job_status(job_id, current_user.user.id) + if job is None: + raise HTTPException(status_code=404, detail="任务不存在") + return job + + +# ── POST /jobs/{job_id}/cancel — 取消任务 ──────────────────────────────── + + +@router.post("/jobs/{job_id}/cancel", response_model=LipsyncJobResponse) +def cancel_lipsync_job( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + svc: LipsyncService = Depends(_get_service), +): + """取消对口型任务(仅 pending/tts_processing/submitted 状态可取消).""" + job = svc.cancel_job(job_id, current_user.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 可取消", + ) + return job diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py index b17219ed0..e65a6724d 100644 --- a/apps/api/app/api/routes/projects.py +++ b/apps/api/app/api/routes/projects.py @@ -1,13 +1,14 @@ from typing import Any from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_project_repository +from app.dependencies import get_asset_library_repository, get_project_repository from app.schemas.project import ( CreateProjectRequest, ListProjectsResponse, ProjectResponse, ) from fastapi import APIRouter, Depends, HTTPException, Response, status +from pydantic import BaseModel from packages.application import ( CreateProjectCommand, @@ -16,10 +17,20 @@ from packages.application import ( GetProjectUseCase, ListProjectsUseCase, ) +from packages.domain import AssetLibraryKind router = APIRouter() +class DefaultContextResponse(BaseModel): + """幂等默认上下文响应(Issue #1775):默认项目 + 各类型默认素材库 ID。""" + + project_id: str + image_library_id: str + video_library_id: str + voice_library_id: str + + def _to_project_response(item) -> ProjectResponse: return ProjectResponse( id=item.id, @@ -72,6 +83,35 @@ def create_project( return _to_project_response(project) +@router.post("/ensure-default", response_model=DefaultContextResponse) +def ensure_default_project_and_libraries( + authenticated_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), + asset_library_repository: Any = Depends(get_asset_library_repository), +) -> DefaultContextResponse: + """幂等获取/创建当前用户的默认项目和三类默认素材库(Issue #1775)。 + + - 同一用户永远只有一个默认项目(部分唯一索引 uq_projects_owner_default) + - 同一项目同 kind 永远只有一个默认素材库(唯一约束 uq_asset_libraries_project_kind) + - 并发调用/失败重试:唯一约束冲突时返回已存在记录,不报 500 + - 项目和素材库的创建各自在仓储事务内幂等,冲突回滚后重查返回同一条 + """ + user_id = authenticated_user.user.id + project = project_repository.get_or_create_default_project(user_id) + + libraries = {} + for kind in (AssetLibraryKind.VIDEO, AssetLibraryKind.VOICE, AssetLibraryKind.IMAGE): + library = asset_library_repository.get_or_create_default_library(project.id, kind) + libraries[kind] = library.id + + return DefaultContextResponse( + project_id=project.id, + image_library_id=libraries[AssetLibraryKind.IMAGE], + video_library_id=libraries[AssetLibraryKind.VIDEO], + voice_library_id=libraries[AssetLibraryKind.VOICE], + ) + + @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_project( project_id: str, diff --git a/apps/api/app/api/routes/scripts.py b/apps/api/app/api/routes/scripts.py new file mode 100644 index 000000000..6ba326b6e --- /dev/null +++ b/apps/api/app/api/routes/scripts.py @@ -0,0 +1,123 @@ +"""Script (口播文案库) CRUD routes — Issue #1795.""" + +from __future__ import annotations + +from typing import Optional + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session +from app.schemas.script import ( + CreateScriptRequest, + ScriptListResponse, + ScriptResponse, + ScriptSegment, + UpdateScriptRequest, +) +from app.services.script_service import ScriptNotFoundError, ScriptService +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + +router = APIRouter() + + +def _get_service(session: Session = Depends(get_db_session)) -> ScriptService: + return ScriptService(session) + + +def _to_response(script) -> ScriptResponse: + segments = script.segments or [] + return ScriptResponse( + id=script.id, + user_id=script.user_id, + title=script.title, + content=script.content, + segments=[ + ScriptSegment(text=s.get("text", ""), duration=s.get("duration")) if isinstance(s, dict) else s + for s in segments + ], + tags=script.tags or [], + created_at=script.created_at, + updated_at=script.updated_at, + ) + + +@router.get("", response_model=ScriptListResponse) +def list_scripts( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + tag: Optional[str] = Query(None, description="按标签筛选"), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + svc: ScriptService = Depends(_get_service), +) -> ScriptListResponse: + user_id = authenticated_user.user.id + items, total = svc.list_scripts(user_id, skip=skip, limit=limit, tag=tag) + return ScriptListResponse( + items=[_to_response(i) for i in items], + total=total, + ) + + +@router.post("", response_model=ScriptResponse, status_code=status.HTTP_201_CREATED) +def create_script( + request: CreateScriptRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + svc: ScriptService = Depends(_get_service), +) -> ScriptResponse: + user_id = authenticated_user.user.id + script = svc.create_script( + user_id=user_id, + title=request.title, + content=request.content, + segments=[s.model_dump() for s in request.segments], + tags=request.tags, + ) + return _to_response(script) + + +@router.get("/{script_id}", response_model=ScriptResponse) +def get_script( + script_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + svc: ScriptService = Depends(_get_service), +) -> ScriptResponse: + user_id = authenticated_user.user.id + try: + script = svc.get_script(script_id, user_id) + except ScriptNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc + return _to_response(script) + + +@router.put("/{script_id}", response_model=ScriptResponse) +def update_script( + script_id: str, + request: UpdateScriptRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + svc: ScriptService = Depends(_get_service), +) -> ScriptResponse: + user_id = authenticated_user.user.id + try: + script = svc.update_script( + script_id=script_id, + user_id=user_id, + title=request.title, + content=request.content, + segments=[s.model_dump() for s in request.segments] if request.segments is not None else None, + tags=request.tags, + ) + except ScriptNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc + return _to_response(script) + + +@router.delete("/{script_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) +def delete_script( + script_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + svc: ScriptService = Depends(_get_service), +) -> Response: + user_id = authenticated_user.user.id + deleted = svc.delete_script(script_id, user_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") + return diff --git a/apps/api/app/api/routes/task_center.py b/apps/api/app/api/routes/task_center.py index 63a75a77a..cc5188457 100755 --- a/apps/api/app/api/routes/task_center.py +++ b/apps/api/app/api/routes/task_center.py @@ -375,7 +375,13 @@ def retry_project_task( storage_key=job.storage_key, ) ) - celery_app.send_task("worker.ingest_asset", args=[retried.id]) + celery_result = celery_app.send_task("worker.ingest_asset", args=[retried.id]) + if getattr(celery_result, "id", ""): + try: + retried.celery_task_id = celery_result.id + ingest_job_repository.update(retried) + except Exception: # noqa: BLE001 + pass return ProjectTaskResponse( id=f"ingest:{retried.id}", task_type="ingest", diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 3f632d4d5..05bbd8355 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -106,6 +106,10 @@ def list_templates( tag: str | None = Query(None, description="按标签筛选"), keyword: str | None = Query(None, description="按名称关键词搜索"), mode: str | None = Query(None, description="按剪辑模式筛选"), + valid_only: bool = Query( + False, + description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)", + ), authenticated_user: AuthenticatedUser = Depends(get_current_user), template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> ListTemplatesResponse: @@ -116,6 +120,7 @@ def list_templates( tag=tag, keyword=keyword, mode=mode, + valid_only=valid_only, ) use_case = ListTemplatesUseCase(template_repository) templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter) diff --git a/apps/api/app/api/routes/templates_editor/ai_features.py b/apps/api/app/api/routes/templates_editor/ai_features.py index e425a40ee..97efef393 100755 --- a/apps/api/app/api/routes/templates_editor/ai_features.py +++ b/apps/api/app/api/routes/templates_editor/ai_features.py @@ -65,8 +65,8 @@ def _build_asset_analyses( if url: video_urls.append(url) valid_asset_ids.append(aid) - except Exception as e: - logger.warning("获取素材URL失败: asset_id=%s error=%s", aid, str(e)) + except Exception: + logger.exception("获取素材URL失败: asset_id=%s", aid) if not video_urls: logger.info("无可用视频素材,跳过视频理解分析") @@ -108,7 +108,7 @@ def _build_asset_analyses( return analyses except Exception as e: - logger.warning("MediaKit 视频理解异常,将降级到无分析模式: %s", str(e)) + logger.exception("MediaKit 视频理解异常,将降级到无分析模式: %s", e) return {} @@ -177,7 +177,7 @@ def editor_ai_recommend( try: db.rollback() except Exception: - pass + logger.exception("db rollback failed in ai_recommend") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="AI推荐结果保存失败,请稍后重试", diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index fed9a3152..fb774321d 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -36,17 +36,11 @@ from app.services.asset_segment_tracker import ( remove_used_segment, ) from app.services.edit_plan_service import EditPlanService -from app.services.edit_template_service import EditTemplateService +from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository -from packages.adapters.sqlalchemy_impl.template_clip_config_repository import ( - SQLAlchemyTemplateClipConfigRepository, -) -from packages.adapters.sqlalchemy_impl.template_repository import ( - SQLAlchemyTemplateRepository, -) from packages.domain.plan_generator_utils import ( _calc_random_start_time, build_scene_segments, @@ -138,8 +132,8 @@ def _build_asset_url_map( result: dict[str, str | None] = {} try: storage = get_storage_service() - except Exception: - logger.warning("获取存储服务失败,跳过asset_url生成") + except Exception as e: + logger.exception("获取存储服务失败,跳过asset_url生成: %s", e) return {aid: None for aid in asset_ids} # 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1) @@ -147,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.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True) + logger.exception("批量查询素材失败: asset_ids=%s", asset_ids) return {aid: None for aid in asset_ids if aid} for aid in unique_ids: @@ -162,7 +156,7 @@ def _build_asset_url_map( continue result[aid] = storage.get_download_url(storage_key, expires_seconds=3600) except Exception: - logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True) + logger.exception("生成素材签名URL失败: asset_id=%s", aid) result[aid] = None return result @@ -399,68 +393,42 @@ def _safe_segment_duration(value, default: float) -> float: def _get_template_segments( template_id: str, + user_id: str, tpl_svc: EditTemplateService, - db: Session, ) -> list[tuple[int, float, float]]: """获取模板的片段配置(顺序、最短时长、最长时长). - 优先从新模板系统(template_clip_configs)查询, - 若不存在则回退到旧模板系统(template_segments)。 + 单一数据源:模板主表为 ``templates``(用户自建,归属 user_id)/ + ``edit_templates``(全局模板库),片段配置主表为 ``template_clip_configs`` + (由 ``EditTemplateService.list_clip_configs_for_editor`` 统一读取)。 + + 不再使用"新表抛异常 → 降级直查配置表 → 再降级查 segments"的异常控制流, + 也不在正常请求中打印 ``ValueError: 模板不存在`` 堆栈。 + + Args: + template_id: 模板 ID + user_id: 当前登录用户 ID(用于归属校验) + tpl_svc: 模板编辑器服务 Returns: - [(segment_order, duration_min, duration_max), ...] 按 order 排序 + [(segment_order, duration_min, duration_max), ...] 按 order 排序; + 模板存在但未配置片段时返回空列表。 + + Raises: + TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。 """ - # 优先查新模板系统 - try: - clip_configs = tpl_svc.list_clip_configs(template_id) - if clip_configs: - result = [] - for cc in clip_configs: - dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION) - dur_max = _safe_segment_duration( - cc.max_duration or cc.min_duration, - _DEFAULT_EDITOR_CLIP_DURATION, - ) - dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max) - result.append((cc.order, dur_min, dur_max)) - return sorted(result, key=lambda x: x[0]) - except Exception: - logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True) + clip_configs = tpl_svc.list_clip_configs_for_editor(template_id, user_id) - # 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表) - try: - direct_repo = SQLAlchemyTemplateClipConfigRepository(db) - direct_configs = direct_repo.list_by_template(template_id) - if direct_configs: - result = [] - for cc in direct_configs: - dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION) - dur_max = _safe_segment_duration( - cc.max_duration or cc.min_duration, - _DEFAULT_EDITOR_CLIP_DURATION, - ) - dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max) - result.append((cc.order, dur_min, dur_max)) - return sorted(result, key=lambda x: x[0]) - except Exception: - logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True) - - # 回退到旧模板系统(template_segments表) - try: - old_repo = SQLAlchemyTemplateRepository(db) - segments = old_repo.list_segments(template_id) - if segments: - result = [] - for s in segments: - dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION) - dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION) - dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max) - result.append((s.segment_order, dur_min, dur_max)) - return sorted(result, key=lambda x: x[0]) - except Exception: - logger.warning("旧模板系统查询segments失败", exc_info=True) - - return [] + result = [] + for cc in clip_configs: + dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION) + dur_max = _safe_segment_duration( + cc.max_duration or cc.min_duration, + _DEFAULT_EDITOR_CLIP_DURATION, + ) + dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max) + result.append((cc.order, dur_min, dur_max)) + return sorted(result, key=lambda x: x[0]) def _recommended_time_conflicts( @@ -518,8 +486,8 @@ def _get_mediakit_recommendations( if url: video_urls.append(url) valid_asset_ids.append(asset_id) - except Exception as e: - logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e) + except Exception: + logger.exception("获取素材URL失败: asset_id=%s", asset_id) if not video_urls: return {} @@ -595,7 +563,7 @@ def _get_mediakit_recommendations( return recommendations except Exception as e: - logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e) + logger.exception("MediaKit 智能选片异常,降级为随机选择: %s", e) return {} @@ -663,13 +631,21 @@ def create_clips_from_assets_editor( 7. 素材时长为 0 或缺失时报 400,不创建无效片段 """ tpl_svc, plan_svc = services + user_id = str(current_user.user.id) - # 1. 查询模板 segments - segments = _get_template_segments(template_id, tpl_svc, db) + # 1. 查询模板片段配置。模板不存在/已删除/无权限 → 404; + # 模板存在但确实未配置片段 → 422(配置错误,与 404 区分)。 + try: + segments = _get_template_segments(template_id, user_id, tpl_svc) + except TemplateNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="模板不存在或无权访问", + ) from exc if not segments: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="模板没有片段配置,无法创建片段", + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="模板未配置片段", ) # 防御:schema validator 已过滤 null/空串,这里再归一化一次, @@ -884,7 +860,7 @@ def create_clips_from_assets_editor( duplicate_warning = None if dup_rate > 50: duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板" - logger.warning( + logger.exception( "from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%", plan_id, dup_rate, @@ -984,8 +960,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover # 尝试获取存储服务(用于生成视频 URL) try: storage = get_storage_service() - except Exception: - logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新") + except Exception as e: + logger.exception("后台任务: 获取存储服务失败,跳过 SceneChange 更新: %s", e) return # 获取 MediaKit 客户端 @@ -1011,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 as e: - logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e) + except Exception: + logger.exception("后台任务: 获取素材URL失败: asset_id=%s", asset_id) # 构建该素材的占用区间列表(排除已更新片段) def _get_other_segments(asset_id_inner, clip_id_inner): @@ -1063,12 +1039,11 @@ def _update_mediakit_recommendations_async( # pragma: no cover asset_id, len(scene_changes), ) - except Exception as cache_err: + except Exception: # 缓存写入失败不影响本次片段更新 - logger.warning( - "后台任务: 场景点缓存写入失败: asset_id=%s error=%s", + logger.exception( + "后台任务: 场景点缓存写入失败: asset_id=%s", asset_id, - cache_err, ) # SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback @@ -1148,11 +1123,10 @@ def _update_mediakit_recommendations_async( # pragma: no cover recommended_start + clip_duration, plan_id, ) - except Exception as me: - logger.warning( - "后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s", + except Exception: + logger.exception( + "后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s", clip.id, - me, ) db.rollback() continue @@ -1168,8 +1142,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover asset_id, recommended_start, ) - except Exception as ue: - logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue) + except Exception: + logger.exception("后台任务: 单个片段更新失败: clip_id=%s", clip.id) try: db.rollback() except Exception: @@ -1178,9 +1152,9 @@ def _update_mediakit_recommendations_async( # pragma: no cover logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count) - except Exception as e: + except Exception: # 后台任务失败不影响已创建的片段,静默处理 - logger.warning("后台任务异常: plan_id=%s error=%s", plan_id, e, exc_info=True) + logger.exception("后台任务异常: plan_id=%s", plan_id) if db: try: db.rollback() diff --git a/apps/api/app/api/routes/templates_editor/dependencies.py b/apps/api/app/api/routes/templates_editor/dependencies.py index c4388f12e..b23959ce6 100755 --- a/apps/api/app/api/routes/templates_editor/dependencies.py +++ b/apps/api/app/api/routes/templates_editor/dependencies.py @@ -41,29 +41,33 @@ def get_draft_plan_id( 这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里, 确保 template_id → plan_id 的映射始终存在。 - 兼容策略:优先从新模板系统(edit_templates 表)查找, - 若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。 + 模板读取遵循单一数据源、显式判定(不使用异常降级): + - 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True); + - 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。 + 模板不存在、已删除或不归属于当前用户时,一律返回 404。 """ tpl_svc, plan_svc = services user_id = str(current_user.user.id) + # 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验, + # 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。 + old_repo = SQLAlchemyTemplateRepository(db) + old_template = old_repo.get_active(template_id, user_id) + is_global_template = tpl_svc.get_template(template_id) is not None + if old_template is None and not is_global_template: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在") + # 1. 草稿已存在 → 直接返回 draft = tpl_svc.get_template_draft(template_id) if draft is not None: return draft.id - # 2. 新系统有模板 → 用新服务创建草稿 - if tpl_svc.get_template(template_id) is not None: + # 2. 全局模板(新系统)→ 用新服务创建草稿 + if is_global_template: draft = tpl_svc.create_template_draft(template_id, user_id=user_id) return draft.id - # 3. 回退到旧模板系统(templates 表) - old_repo = SQLAlchemyTemplateRepository(db) - old_template = old_repo.get(template_id, user_id=user_id) - if old_template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在") - - # 4. 基于旧模板创建草稿计划 + # 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划 from app.services.plan_generator_service import PlanGeneratorService from packages.domain.edit_template import EditTemplate, EditTemplateStatus diff --git a/apps/api/app/api/routes/templates_editor/draft.py b/apps/api/app/api/routes/templates_editor/draft.py index adbe50e77..2107f1228 100755 --- a/apps/api/app/api/routes/templates_editor/draft.py +++ b/apps/api/app/api/routes/templates_editor/draft.py @@ -150,7 +150,7 @@ def rollback_template( try: tpl = tpl_svc.rollback_to_version(template_id, request.version) except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc clip_configs = tpl_svc.list_clip_configs(template_id) return EditorRollbackResponse( diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index ec000a5b8..400b012e2 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -173,6 +173,14 @@ 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, @@ -180,7 +188,7 @@ def synthesize( voice_id=actual_voice_id, voice_model=request.voice_model, voice_clone_profile_id=voice_clone_profile_id, - metadata=request.metadata_, + metadata=synthesis_meta, ) # 提交 CosyVoice 合成任务 @@ -567,6 +575,7 @@ def preview_tts( text=request.text, voice_id=actual_voice_id, speed=request.speed, + emotion=request.emotion, ) except CosyVoiceError as e: raise HTTPException( diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index 58269c35c..dcf4f9927 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -85,12 +85,22 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str: """从 storage_key 推断 MIME 类型(与 worker 端保持一致)。""" lower_filename = storage_key.rsplit("/", 1)[-1].lower() _MIME_MAP = { - ".mov": "video/quicktime", ".mp4": "video/mp4", ".avi": "video/x-msvideo", - ".mkv": "video/x-matroska", ".webm": "video/webm", - ".png": "image/png", ".gif": "image/gif", ".bmp": "image/bmp", - ".svg": "image/svg+xml", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", - ".flac": "audio/flac", ".m4a": "audio/x-m4a", + ".mov": "video/quicktime", + ".mp4": "video/mp4", + ".avi": "video/x-msvideo", + ".mkv": "video/x-matroska", + ".webm": "video/webm", + ".png": "image/png", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + ".m4a": "audio/x-m4a", } for ext, mime in _MIME_MAP.items(): if lower_filename.endswith(ext): @@ -98,10 +108,139 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str: return "video/mp4" # default +# 兜底去重:无 file_hash / client_upload_id 且大小已知时,同库同名同大小近期活动记录视为重复 +FALLBACK_DEDUP_WINDOW_MINUTES = 30 + + +def _find_duplicate_asset( + asset_repository: Any, + *, + library_id: str, + file_hash: str, + client_upload_id: str, + filename: str, + file_size: int = 0, +) -> Any: + """complete/上传幂等去重,按优先级查找已存在的素材。 + + 1. client_upload_id(客户端幂等 token,同一次上传的重试保持一致) + 2. file_hash(内容哈希,不同上传只要内容相同即去重) + 3. 兜底(严格模式,宁可漏判不可误杀):file_hash 与 client_upload_id + 均缺失、且 file_size > 0 时,同库 + 同文件名 + **同大小** 且 30 分钟内 + 仍处 uploading/processing 的记录才判重。 + - file_hash 非空时跳过兜底(hash 已代表内容;同名但内容全新的视频 + 如 iPhone 的 IMG_xxxx.MOV 绝不能被同名占位误杀) + - file_size=0(未知)时不允许仅凭同名 + processing 判重,直接放行 + + 全部为鸭子类型调用:旧仓储无对应方法时静默跳过,不破坏既有实现。 + """ + if client_upload_id: + find = getattr(asset_repository, "find_by_library_and_client_upload_id", None) + if callable(find): + existing = find(library_id=library_id, client_upload_id=client_upload_id) + if existing is not None: + logger.info( + "素材幂等命中(client_upload_id): library=%s token=%s asset=%s", + library_id, + client_upload_id, + getattr(existing, "id", "?"), + ) + return existing + if file_hash: + existing = asset_repository.find_by_library_and_file_hash( + library_id=library_id, + file_hash=file_hash, + ) + if existing is not None: + logger.info( + "素材去重命中(file_hash): library=%s hash=%s asset=%s", + library_id, + file_hash, + existing.id, + ) + return existing + # 同名兜底去重(最后防线,严格模式): + # - 仅当 file_hash / client_upload_id 均缺失时启用(hash 能代表内容时不靠同名猜) + # - file_size 必须 > 0 且与记录大小严格一致;大小未知(0)直接放行 + # - 只命中近期 UPLOADING/PROCESSING 活动记录(READY 历史素材不拦) + if filename and not file_hash and not client_upload_id and file_size and file_size > 0: + find_recent = getattr(asset_repository, "find_recent_active_by_library_and_name", None) + if callable(find_recent): + existing = find_recent( + library_id=library_id, + name=filename, + within_minutes=FALLBACK_DEDUP_WINDOW_MINUTES, + file_size=file_size, + ) + if existing is not None: + logger.info( + "素材幂等兜底命中(近期同名同大小活动记录): library=%s name=%s asset=%s status=%s size=%s", + library_id, + filename, + getattr(existing, "id", "?"), + getattr(existing, "status", None), + file_size, + ) + return existing + elif filename and not file_hash and not client_upload_id and not file_size: + logger.debug( + "同名兜底去重跳过(file_size 未知,宁可放行不可误杀): library=%s name=%s", + library_id, + filename, + ) + return None + + def _create_pending_asset( - asset_repository, project_id, library_id, storage_key, filename, mime_type, user_id, file_hash="" + asset_repository, + project_id, + library_id, + storage_key, + filename, + mime_type, + user_id, + file_hash="", + client_upload_id="", + file_size: int = 0, ): - """立即创建一条 PROCESSING 状态的 Asset 记录,使前端能马上看到新素材。""" + """立即创建或复用一条 PROCESSING 状态的 Asset 记录。 + + find-or-create:prepare 阶段已按 file_hash/client_upload_id 预建的占位记录 + 会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中, + 直接复用并补齐字段(避免 pre-create + complete 重复建两条)。 + + Issue #1776: 素材库计数由 asset_repository.create() 自动维护。 + """ + # 1. 按 client_upload_id / file_hash 查找现有记录 + existing = None + if client_upload_id: + find_by_cuid = getattr(asset_repository, "find_by_library_and_client_upload_id", None) + if callable(find_by_cuid): + existing = find_by_cuid(library_id=library_id, client_upload_id=client_upload_id) + if existing is None and file_hash: + existing = asset_repository.find_by_library_and_file_hash(library_id=library_id, file_hash=file_hash) + if existing is not None: + # 补齐字段(幂等:避免重复建记录,前端已拿到 asset_id) + changed = False + if file_hash and not existing.file_hash: + existing.file_hash = file_hash + changed = True + if client_upload_id and not existing.client_upload_id: + existing.client_upload_id = client_upload_id + changed = True + if file_size and not existing.file_size: + existing.file_size = file_size + changed = True + if existing.status not in (AssetStatus.PROCESSING, AssetStatus.UPLOADING): + existing.status = AssetStatus.PROCESSING + changed = True + if changed: + try: + asset_repository.update(existing) + except Exception: # noqa: BLE001 — 字段补齐失败不阻塞主流程 + pass + return existing + asset = Asset.create( project_id=project_id, library_id=library_id, @@ -111,16 +250,30 @@ def _create_pending_asset( status=AssetStatus.PROCESSING, uploaded_by_user_id=user_id, file_hash=file_hash, + client_upload_id=client_upload_id, + file_size=file_size, ) return asset_repository.create(asset) +def _persist_celery_task_id(repo: Any, job: Any, celery_task_id: str) -> None: + """记录 celery 消息 ID 到任务行,供孤儿清理时 revoke/清除队列消息(#1714)。""" + if not celery_task_id: + return + try: + job.celery_task_id = celery_task_id + repo.update(job) + except Exception: # noqa: BLE001 — 记录失败不影响主流程(执行前状态守卫兜底) + pass + + def _submit_ingest_job( project_id: str, library_id: str, storage_key: str, ingest_job_repository: Any, file_hash: str = "", + asset_id: str = "", ) -> Any: use_case = SubmitIngestJobUseCase(ingest_job_repository) job = use_case.execute( @@ -129,9 +282,11 @@ def _submit_ingest_job( library_id=library_id, storage_key=storage_key, file_hash=file_hash, + asset_id=asset_id, ) ) - celery_app.send_task("worker.ingest_asset", args=[job.id]) + celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id]) + _persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", "")) return job @@ -141,9 +296,15 @@ async def prepare_direct_upload( authenticated_user: AuthenticatedUser = Depends(get_current_user), project_repository: Any = Depends(get_project_repository), asset_library_repository: Any = Depends(get_asset_library_repository), + asset_repository: Any = Depends(get_asset_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> DirectUploadPrepareResponse: - """创建浏览器直传 OSS 的短期表单签名。""" + """创建浏览器直传 OSS 的短期表单签名,并在签名前按 file_hash/client_upload_id 去重。 + + 命中去重:直接返回 duplicated=True + skip_transfer=True(前端跳过 OSS 直传), + 未命中:正常签名 OSS 并立即预建一条 PROCESSING 状态的 asset 记录占住 + file_hash 闸门,响应带 asset_id 供前端/后续 complete 关联。 + """ settings = get_settings() max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024 if request.file_size > max_size_bytes: @@ -162,8 +323,39 @@ async def prepare_direct_upload( asset_library_repository, ) - file_id = uuid4().hex[:8] safe_filename = request.filename.replace("/", "_").replace("\\", "_") + + # ── prepare 阶段去重:OSS 签名之前先查已存在素材 ── + if request.file_hash or request.client_upload_id: + existing = _find_duplicate_asset( + asset_repository, + library_id=request.library_id, + file_hash=request.file_hash, + client_upload_id=request.client_upload_id, + filename=request.filename, + file_size=request.file_size, + ) + if existing is not None: + logger.info( + "prepare 命中去重: library=%s hash=%s cuid=%s existing_asset=%s", + request.library_id, + request.file_hash, + request.client_upload_id, + existing.id, + ) + return DirectUploadPrepareResponse( + upload_url="", + method="", + storage_key=existing.storage_key, + expires_at="", + fields={}, + max_size_bytes=0, + duplicated=True, + skip_transfer=True, + asset_id=existing.id, + ) + + file_id = uuid4().hex[:8] storage_key = f"uploads/{file_id}/{safe_filename}" try: payload = storage_service.create_direct_upload_post( @@ -182,6 +374,28 @@ async def prepare_direct_upload( detail=f"Failed to prepare upload: {type(error).__name__}", ) from error + # ── 预建 asset 占位:占住 file_hash/client_upload_id 闸门,避免并发重复上传 ── + pending_asset_id = "" + if request.file_hash or request.client_upload_id: + try: + pending = _create_pending_asset( + asset_repository=asset_repository, + project_id=request.project_id, + library_id=request.library_id, + storage_key=storage_key, + filename=safe_filename, + mime_type=validated_content_type, + user_id=authenticated_user.user.id, + file_hash=request.file_hash, + client_upload_id=request.client_upload_id, + file_size=request.file_size, + ) + pending_asset_id = pending.id + # Issue #1776: 计数由 asset_repository.create() 自动维护 + except Exception as error: + # 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重 + logger.warning("预建 asset 占位失败,降级走 old flow: %s", error) + return DirectUploadPrepareResponse( upload_url=str(payload["url"]), method=str(payload["method"]), @@ -189,6 +403,9 @@ async def prepare_direct_upload( expires_at=str(payload["expires_at"]), fields={str(key): str(value) for key, value in dict(payload["fields"]).items()}, max_size_bytes=max_size_bytes, + duplicated=False, + skip_transfer=False, + asset_id=pending_asset_id, ) @@ -202,7 +419,7 @@ async def complete_direct_upload( asset_repository: Any = Depends(get_asset_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> DirectUploadCompleteResponse: - """确认浏览器直传完成并创建导入任务。""" + """确认浏览器直传完成并创建导入任务(幂等:重复 complete 返回同一素材)。""" require_project_and_library( request.project_id, request.library_id, @@ -212,6 +429,29 @@ async def complete_direct_upload( normalized_key = storage_service._normalize_storage_key(request.storage_key) if not normalized_key.startswith("uploads/"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key") + + filename = normalized_key.rsplit("/", 1)[-1] + + # ── 幂等去重(放在 OSS 检查之前):complete 超时后前端重试时, + # 第一次 complete 可能已建好占位记录,此时即使 OSS 检查失败也必须返回 + # 已存在记录,绝不能再建第二条。─ + existing = _find_duplicate_asset( + asset_repository, + library_id=request.library_id, + file_hash=request.file_hash, + client_upload_id=request.client_upload_id, + filename=filename, + file_size=request.file_size, + ) + if existing is not None: + return DirectUploadCompleteResponse( + storage_key=existing.storage_key, + ingest_job_id="", + duplicated=True, + asset_id=existing.id, + url=storage_service.get_url(existing.storage_key), + ) + try: file_exists = storage_service.file_exists(normalized_key) except Exception as error: @@ -223,29 +463,7 @@ async def complete_direct_upload( if not file_exists: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found") - # ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ── - if request.file_hash: - existing = asset_repository.find_by_library_and_file_hash( - library_id=request.library_id, - file_hash=request.file_hash, - ) - if existing is not None: - logger.info( - "素材去重命中: library=%s hash=%s existing_asset=%s", - request.library_id, - request.file_hash, - existing.id, - ) - return DirectUploadCompleteResponse( - storage_key=normalized_key, - ingest_job_id="", - duplicated=True, - asset_id=existing.id, - url=storage_service.get_url(normalized_key), - ) - # 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材 - filename = normalized_key.rsplit("/", 1)[-1] mime_type = _infer_mime_type_from_storage_key(normalized_key) pending_asset = _create_pending_asset( asset_repository=asset_repository, @@ -256,7 +474,10 @@ async def complete_direct_upload( mime_type=mime_type, user_id=authenticated_user.user.id, file_hash=request.file_hash, + client_upload_id=request.client_upload_id, + file_size=request.file_size, ) + # Issue #1776: 计数由 asset_repository.create() 自动维护 job = _submit_ingest_job( project_id=request.project_id, @@ -264,6 +485,7 @@ async def complete_direct_upload( storage_key=normalized_key, ingest_job_repository=ingest_job_repository, file_hash=request.file_hash, + asset_id=pending_asset.id, ) return DirectUploadCompleteResponse( storage_key=normalized_key, @@ -283,7 +505,8 @@ async def upload_asset( project_id: str = Form(..., min_length=1, description="项目 ID"), library_id: str = Form(..., min_length=1, description="素材库 ID"), file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"), - file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"), + file_hash: str = Form(default="", description="文件哈希,用于去重检测"), + client_upload_id: str = Form(default="", description="客户端幂等 token(同一次上传的重试保持一致)"), authenticated_user: AuthenticatedUser = Depends(get_current_user), ingest_job_repository: Any = Depends(get_ingest_job_repository), project_repository: Any = Depends(get_project_repository), @@ -294,32 +517,31 @@ async def upload_asset( """上传素材文件并触发导入流水线。""" require_project_and_library(project_id, library_id, project_repository, asset_library_repository) - # ── 素材去重检测:上传前检查同素材库 + 同 file_hash ── - if file_hash: - existing = asset_repository.find_by_library_and_file_hash( - library_id=library_id, - file_hash=file_hash, - ) - if existing is not None: - logger.info( - "素材去重命中(multipart): library=%s hash=%s existing_asset=%s", - library_id, - file_hash, - existing.id, - ) - return UploadAssetResponse( - storage_key=existing.storage_key, - ingest_job_id="", - url="", - duplicated=True, - asset_id=existing.id, - ) - - # P2-5: 服务端验证 MIME 类型 + # P2-5: 服务端验证 MIME 类型(先验证,再幂等去重,避免非法类型绕过) validated_content_type = _validate_mime_type(file.content_type) - file_id = uuid4().hex[:8] safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown" + + # ── 幂等去重:client_upload_id → file_hash → 近期活动同名记录兜底 ── + # 放在 OSS 上传之前:重复提交直接返回,不占 OSS 流量、不建新记录。 + existing = _find_duplicate_asset( + asset_repository, + library_id=library_id, + file_hash=file_hash, + client_upload_id=client_upload_id, + filename=safe_filename, + file_size=0, + ) + if existing is not None: + return UploadAssetResponse( + storage_key=existing.storage_key, + ingest_job_id="", + url="", + duplicated=True, + asset_id=existing.id, + ) + + file_id = uuid4().hex[:8] storage_key = f"uploads/{file_id}/{safe_filename}" try: @@ -348,7 +570,9 @@ async def upload_asset( mime_type=validated_content_type, user_id=authenticated_user.user.id, file_hash=file_hash, + client_upload_id=client_upload_id, ) + # Issue #1776: 计数由 asset_repository.create() 自动维护 job = _submit_ingest_job( project_id=project_id, @@ -356,6 +580,7 @@ async def upload_asset( storage_key=storage_key, ingest_job_repository=ingest_job_repository, file_hash=file_hash, + asset_id=pending_asset.id, ) return UploadAssetResponse( diff --git a/apps/api/app/api/routes/videos.py b/apps/api/app/api/routes/videos.py index 77be5a3ff..20e623f43 100644 --- a/apps/api/app/api/routes/videos.py +++ b/apps/api/app/api/routes/videos.py @@ -252,6 +252,10 @@ class RecomputeDedupRequest(BaseModel): None, description="指定视频 ID 列表。为空则对当前用户所有缺少查重数据的视频重新计算。", ) + force: bool = Field( + False, + description="强制重算:即使视频已有查重数据也重新入队(#1702 查重算法升级后用于存量视频重算)。", + ) class RecomputeDedupResponse(BaseModel): @@ -291,15 +295,15 @@ def recompute_dedup( skipped = 0 for video in target_videos: - # 已有完整查重数据的跳过 - if video.duplicate_rate is not None and video.video_fingerprint: + # 已有完整查重数据的跳过(force=True 时强制重算,#1702 算法升级后存量视频需要重算指纹/分片) + if not request.force and video.duplicate_rate is not None and video.video_fingerprint: skipped += 1 continue # 触发异步查重任务 celery_app.send_task("worker.check_duplicate", args=[video.id]) enqueued += 1 - logger.info("Enqueued re-dedup for video %s (user=%s)", video.id, user_id) + logger.info("Enqueued re-dedup for video %s (user=%s, force=%s)", video.id, user_id, request.force) return RecomputeDedupResponse( enqueued=enqueued, diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index 39875379e..940e156dd 100755 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -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.error(f"Failed to dispatch Celery task: {e}") + logger.exception("Failed to dispatch Celery task") # P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing try: workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}") - except Exception as inner_e: - logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") + except Exception: + logger.exception("Failed to mark profile as failed after dispatch error") 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.error(f"Failed to dispatch Celery task: {e}") + logger.exception("Failed to dispatch Celery task") # P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing try: workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}") - except Exception as inner_e: - logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") + except Exception: + logger.exception("Failed to mark profile as failed after dispatch error") return _to_response(profile) diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index c6d36ac1f..8da45e69a 100755 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -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 as e: - logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e) + except Exception: + logger.exception("Failed to generate preset voice preview: voice_id=%s", voice_id) return fallback_url @@ -127,6 +127,7 @@ 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 @@ -732,7 +733,7 @@ def _find_or_create_voice_library_for_extract(*, user_id, project_repository, as try: session.rollback() except Exception: - pass + logger.exception("session rollback failed in _find_or_create_voice_library") 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: diff --git a/apps/api/app/core/__init__.py b/apps/api/app/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/core/celery_app.py b/apps/api/app/core/celery_app.py index 52b515335..3d7d7bb2a 100644 --- a/apps/api/app/core/celery_app.py +++ b/apps/api/app/core/celery_app.py @@ -5,3 +5,11 @@ settings = get_settings() celery_app = Celery("xiaoxia-saas-api") celery_app.conf.broker_url = settings.CELERY_BROKER_URL celery_app.conf.result_backend = settings.CELERY_RESULT_BACKEND + +# #1714 队列隔离:视频生成走 generation 队列,素材转码走 transcode 队列 +try: + from packages.shared.celery_queues import apply_queue_settings + + apply_queue_settings(celery_app) +except Exception: # noqa: BLE001 — 队列配置失败不阻断 API 启动 + pass diff --git a/apps/api/app/core/task_enqueue.py b/apps/api/app/core/task_enqueue.py index 31545f177..c3b11329c 100755 --- a/apps/api/app/core/task_enqueue.py +++ b/apps/api/app/core/task_enqueue.py @@ -8,27 +8,145 @@ logger = logging.getLogger(__name__) # ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ── USER_PENDING_LIMIT = 3 # 单用户 pending 上限 GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限 +WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值) + +# 限流错误码:前端据此区分"排队等待"与"创建失败" +ERROR_CODE_USER_QUEUE_FULL = "USER_QUEUE_FULL" # 429:用户自己的任务排队中 +ERROR_CODE_SYSTEM_QUEUE_FULL = "SYSTEM_QUEUE_FULL" # 503:系统整体繁忙 class UserPendingLimitExceeded(Exception): """用户 pending 任务数超限,返回 429。""" - def __init__(self, user_id: str, pending_count: int, limit: int): + def __init__( + self, + user_id: str, + pending_count: int, + limit: int, + *, + running_count: int = 0, + requested_count: int = 1, + queue_ahead: int = 0, + estimated_wait_seconds: int = 0, + ): self.user_id = user_id self.pending_count = pending_count self.limit = limit + # 排队上下文(用于 429 结构化提示,前端展示"排队中"而非"创建失败") + self.running_count = running_count + self.requested_count = requested_count + self.queue_ahead = queue_ahead + self.estimated_wait_seconds = estimated_wait_seconds super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}") class GlobalQueueFull(Exception): """全局限流,返回 503。""" - def __init__(self, pending_count: int, limit: int): + def __init__( + self, + pending_count: int, + limit: int, + *, + running_count: int = 0, + queue_ahead: int = 0, + estimated_wait_seconds: int = 0, + ): self.pending_count = pending_count self.limit = limit + self.running_count = running_count + self.queue_ahead = queue_ahead + self.estimated_wait_seconds = estimated_wait_seconds super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}") +def _estimate_wait_seconds(queue_ahead: int, generation_task_repository: Any) -> int: + """根据排队任务数 + worker 并发数 + 历史平均任务耗时估算等待秒数。 + + 估算公式:ceil(排队任务数 / 并发数) × 平均单任务耗时。 + 拿不到历史数据时仓储层返回默认 120 秒。 + """ + import math + + if queue_ahead <= 0: + return 0 + try: + estimator = getattr(generation_task_repository, "estimate_avg_duration_seconds", None) + avg_seconds = estimator() if estimator is not None else 120.0 + except Exception: + avg_seconds = 120.0 + return int(math.ceil(queue_ahead / WORKER_CONCURRENCY) * avg_seconds) + + +def build_rate_limit_detail( + exc: Exception, + generation_task_repository: Any, + *, + scope: str = "user", +) -> dict: + """构造结构化限流响应体(HTTPException 的 detail)。 + + 前端按 detail.code 判断场景: + - USER_QUEUE_FULL (429):用户自己的任务在排队,应提示"等待/继续排队",不是创建失败 + - SYSTEM_QUEUE_FULL (503):系统繁忙,稍后重试 + + detail 字段: + - code: 错误码 + - message: 可读中文提示(可直接展示) + - queued_count: 当前排队(pending)任务数 + - running_count: 当前渲染中(running)任务数 + - queue_ahead: 前方排队任务数(预计等待批次依据) + - estimated_wait_seconds: 预计等待秒数 + - limit: 对应限流上限 + """ + if scope == "user" and isinstance(exc, UserPendingLimitExceeded): + running = exc.running_count + if not running: + try: + counter = getattr(generation_task_repository, "count_running_by_user", None) + running = counter(exc.user_id) if counter is not None else 0 + except Exception: + running = 0 + queue_ahead = exc.queue_ahead or max(exc.pending_count, 0) + wait = exc.estimated_wait_seconds or _estimate_wait_seconds(queue_ahead, generation_task_repository) + wait_minutes = max(1, round(wait / 60)) + message = ( + f"您有 {exc.pending_count} 个任务正在排队、{running} 个正在渲染," + f"同一时间最多提交 {exc.limit} 个任务。请等待约 {wait_minutes} 分钟后再提交" + ) + return { + "code": ERROR_CODE_USER_QUEUE_FULL, + "message": message, + "queued_count": exc.pending_count, + "running_count": running, + "queue_ahead": queue_ahead, + "estimated_wait_seconds": wait, + "limit": exc.limit, + } + + # 全局繁忙 + pending = getattr(exc, "pending_count", 0) + running = getattr(exc, "running_count", 0) + if not running: + try: + counter = getattr(generation_task_repository, "count_running_total", None) + running = counter() if counter is not None else 0 + except Exception: + running = 0 + queue_ahead = getattr(exc, "queue_ahead", 0) or pending + wait = getattr(exc, "estimated_wait_seconds", 0) or _estimate_wait_seconds(queue_ahead, generation_task_repository) + wait_minutes = max(1, round(wait / 60)) + return { + "code": ERROR_CODE_SYSTEM_QUEUE_FULL, + "message": f"系统繁忙:当前 {pending} 个任务排队中、{running} 个渲染中,预计等待约 {wait_minutes} 分钟,请稍后再试", + "queued_count": pending, + "running_count": running, + "queue_ahead": queue_ahead, + "estimated_wait_seconds": wait, + "limit": getattr(exc, "limit", GLOBAL_PENDING_LIMIT), + } + + def check_queue_limits( user_id: str, generation_task_repository: Any, @@ -161,7 +279,17 @@ def safe_enqueue_generation_task( # ── 发送 Celery 任务 ── try: - celery_app.send_task("worker.generate_video", args=[task.id]) + celery_result = celery_app.send_task("worker.generate_video", args=[task.id]) + # 记录 celery 消息 ID:孤儿清理/超时作废时据此 revoke + 清除队列消息(#1714) + celery_task_id = getattr(celery_result, "id", "") + if celery_task_id: + try: + task.celery_task_id = celery_task_id + generation_task_repository.update(task) + except Exception as persist_err: # noqa: BLE001 + logger.warning( + "%s 持久化 celery_task_id 失败(不影响主流程): task_id=%s err=%s", log_prefix, task.id, persist_err + ) except Exception as e: logger.error( "%s 入队失败,标记为失败: task_id=%s error=%s", diff --git a/apps/api/app/schemas/ai_avatar_render.py b/apps/api/app/schemas/ai_avatar_render.py new file mode 100644 index 000000000..0a6cfa95d --- /dev/null +++ b/apps/api/app/schemas/ai_avatar_render.py @@ -0,0 +1,126 @@ +"""AI数字人渲染合成管线 API Schema — #1798.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field, field_validator + + +class BRollSegment(BaseModel): + """B-roll 片段配置.""" + + script_segment_index: int = Field(..., ge=0, description="对应文案片段索引") + asset_url: str = Field(..., description="B-roll 素材 URL") + mode: str = Field(..., description="插入模式: fullscreen 或 pip") + start_time: float = Field(..., ge=0.0, description="在对口型视频中的起始时间(秒)") + end_time: float = Field(..., ge=0.0, description="在对口型视频中的结束时间(秒)") + pip_position: Optional[str] = Field("bottom_right", description="pip 模式位置") + pip_scale: Optional[float] = Field(0.3, ge=0.05, le=1.0, description="pip 模式缩放比例") + + @field_validator("mode") + @classmethod + def validate_mode(cls, v: str) -> str: + v = v.strip().lower() + if v not in ("fullscreen", "pip"): + raise ValueError("mode 必须为 fullscreen 或 pip") + return v + + @field_validator("asset_url") + @classmethod + def validate_asset_url(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("asset_url 不能为空") + if not v.startswith(("http://", "https://")): + raise ValueError("asset_url 必须是 HTTP/HTTPS URL") + return v + + @field_validator("end_time") + @classmethod + def validate_end_time(cls, v: float, info: Any) -> float: + start = info.data.get("start_time", 0.0) + if v <= start: + raise ValueError("end_time 必须大于 start_time") + return v + + +class CreateAiAvatarRenderRequest(BaseModel): + """创建渲染任务请求.""" + + lipsync_job_id: str = Field(..., description="对口型任务 ID") + script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)") + b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表") + title_config: dict[str, Any] = Field( + default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL)" + ) + cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置") + project_id: str = Field("", description="项目 ID") + + @field_validator("lipsync_job_id") + @classmethod + def validate_lipsync_job_id(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("lipsync_job_id 不能为空") + return v + + @field_validator("script_id") + @classmethod + def validate_script_id(cls, v: str) -> str: + return (v or "").strip() + + +class AiAvatarRenderJobResponse(BaseModel): + """渲染任务响应.""" + + id: str + user_id: str + project_id: str + lipsync_job_id: str + script_id: str = "" + b_roll_segments: list[dict[str, Any]] + title_config: dict[str, Any] + cover_config: dict[str, Any] + status: str + progress: int + output_video_url: str + output_cover_url: str + output_duration: float + error_message: str + submitted_at: Optional[datetime] = None + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class AiAvatarRenderProgressResponse(BaseModel): + """渲染进度响应.""" + + status: str + progress: int + output_video_url: str + output_cover_url: str + output_duration: float + error_message: str + + +class SmartCoverResponse(BaseModel): + """智能封面响应(封面从最终成片抽帧,不再叠加标题).""" + + cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空") + status: str = Field("completed", description="completed / fallback_failed") + message: str = Field("", description="失败原因(如有)") + + +class FinalizeRenderResponse(BaseModel): + """封面选好后点「完成」,正式入库成片库的响应.""" + + video_id: str = Field(..., description="成片库视频ID") + cover_url: str = Field("", description="封面URL") + status: str = Field("success", description="success/already_finalized") diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index bead763e3..b8ce60828 100755 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -25,14 +25,24 @@ class CreateGenerationTaskRequest(BaseModel): asset_library_id: str = "" strategy_id: str = "" voice_library_id: str = "" + # ── 多变体独立配音(批量生成)── + # 长度 1 = 所有变体共用;长度 = count = 每个变体独立配音;空数组 = 回退 voice_library_id + voice_library_ids: list[str] = Field( + default_factory=list, + description="各变体独立配音素材库ID数组:长度1=共用,长度=count=独立。为空时回退 voice_library_id", + ) created_by_user_id: str = "" # ── 模板模式新增字段 ── template_id: str = "" asset_ids: list[str] = Field(default_factory=list) title_ids: list[str] = Field(default_factory=list) - voice_ids: list[str] = Field(default_factory=list) # ── 来源剪辑计划 ── source_edit_plan_id: str = "" + # ── variant-plans 轻量选片回传(#1749):正式生成直接复用,不再重选 ── + variant_plan_ids: list[str] = Field( + default_factory=list, + description="POST /generation/variant-plans 返回的各变体 plan_id(长度须=count);为空则走服务端选片", + ) # ── 标题配置(结构化)── title_config: dict | None = Field( default=None, @@ -75,6 +85,47 @@ class CreateGenerationTaskRequest(BaseModel): output_width: int = Field(default=1280, description="输出视频宽度") output_height: int = Field(default=720, description="输出视频高度") cover_url: str = Field(default="", description="封面图片 URL") + # ── 多变体独立封面(批量生成)── + # 长度 1 = 所有变体共用;长度 = count = 每个变体独立封面;空数组 = 回退 cover_url + cover_urls: list[str] = Field( + default_factory=list, + description="各变体独立封面URL数组:长度1=共用,长度=count=独立。为空时回退 cover_url", + ) + # ── 多变体独立标题文字(批量生成)── + # 长度 1 = 所有变体共用;长度 = count = 每个变体独立标题文字;空数组 = 使用 title_config.text + titles: list[str] = Field( + default_factory=list, + description="各变体独立标题文字数组:长度1=共用,长度=count=独立。为空时使用 title_config.text", + ) + + @model_validator(mode="after") + def _check_variant_arrays(self) -> "CreateGenerationTaskRequest": + """变体数组字段长度校验 + #1749 配音严格守卫。 + + - cover_urls/titles:空(回退单值)、长度 1(共用)或长度 = count(独立); + - voice_library_ids:独立配音长度必须恰好 = count 且逐项非空,禁止静默 fallback + (长度 1 的"共用"场景请用 voice_library_id 单值字段); + - variant_plan_ids:非空时长度必须 = count。 + """ + for name in ("cover_urls", "titles"): + arr = getattr(self, name) + if arr and len(arr) != 1 and len(arr) != self.count: + raise ValueError(f"{name} 长度必须为 1(共用)或 {self.count}(与 count 一致),当前为 {len(arr)}") + + from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids + + try: + resolve_variant_voice_ids( + count=self.count, + voice_library_id=self.voice_library_id, + voice_library_ids=self.voice_library_ids or None, + ) + except VariantVoiceError as exc: + raise ValueError(str(exc)) from exc + + if self.variant_plan_ids and len(self.variant_plan_ids) != self.count: + raise ValueError(f"variant_plan_ids 长度({len(self.variant_plan_ids)})必须与 count({self.count})一致") + return self @model_validator(mode="after") def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": @@ -83,9 +134,9 @@ class CreateGenerationTaskRequest(BaseModel): if not has_project and not has_template: raise ValueError("project_id 或 template_id 至少需要提供一个") has_library = bool(self.asset_library_id.strip()) - has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids) + has_assets = bool(self.asset_ids or self.title_ids) if not has_library and not has_assets: - raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个") + raise ValueError("asset_library_id 或 asset_ids/title_ids 至少需要提供一个") return self @@ -162,7 +213,6 @@ class CreatePreviewGenerationTaskRequest(BaseModel): template_id: str asset_ids: list[str] = Field(default_factory=list) title_ids: list[str] = Field(default_factory=list) - voice_ids: list[str] = Field(default_factory=list) voice_library_id: str = Field( default="", description="配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材" ) @@ -185,8 +235,44 @@ class CreatePreviewGenerationTaskRequest(BaseModel): ) title_config: dict = Field( default_factory=dict, - description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow", + description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow。N个变体时样式全局共用", ) + # ── 多变体独立配置(preview_count > 1)── + # 长度 1 = 所有变体共用;长度 = preview_count = 每个变体独立;空数组 = 回退单值字段 + titles: list[str] = Field( + default_factory=list, + description="各变体独立标题文字数组:长度1=共用,长度=preview_count=独立。为空时使用 title_config.text", + ) + voice_library_ids: list[str] = Field( + default_factory=list, + description="各变体独立配音素材库ID数组:长度1=共用,长度=preview_count=独立。为空时回退 voice_library_id", + ) + cover_urls: list[str] = Field( + default_factory=list, + description="各变体独立封面URL数组:长度1=共用,长度=preview_count=独立(预览阶段通常为空)", + ) + + @model_validator(mode="after") + def _check_variant_arrays(self) -> "CreatePreviewGenerationTaskRequest": + """变体数组字段长度校验 + #1749 配音严格守卫。""" + for name in ("titles", "cover_urls"): + arr = getattr(self, name) + if arr and len(arr) != 1 and len(arr) != self.preview_count: + raise ValueError( + f"{name} 长度必须为 1(共用)或 {self.preview_count}(与 preview_count 一致),当前为 {len(arr)}" + ) + + from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids + + try: + resolve_variant_voice_ids( + count=self.preview_count, + voice_library_id=self.voice_library_id, + voice_library_ids=self.voice_library_ids or None, + ) + except VariantVoiceError as exc: + raise ValueError(str(exc)) from exc + return self @model_validator(mode="after") def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest": @@ -196,13 +282,13 @@ class CreatePreviewGenerationTaskRequest(BaseModel): @model_validator(mode="after") def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest": - if not self.asset_ids and not self.title_ids and not self.voice_ids: - raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个") + if not self.asset_ids and not self.title_ids: + raise ValueError("asset_ids/title_ids 至少需要提供一个") return self class PreviewGenerationTaskResponse(BaseModel): - """预览生成任务响应。 + """单个预览变体任务响应。 包含任务状态、进度、分辨率、生成结果 URL 等关键字段。 """ @@ -211,6 +297,7 @@ class PreviewGenerationTaskResponse(BaseModel): status: str progress: float is_preview: bool = True + variant_index: int = 0 resolution: str = "" video_url: str = "" duration: float = 0.0 @@ -219,7 +306,21 @@ class PreviewGenerationTaskResponse(BaseModel): transition_count: int = 0 material_usage: dict = Field(default_factory=dict) error_message: str = "" + title_text: str = "" + voice_library_id: str = "" created_at: datetime | None = None started_at: datetime | None = None finished_at: datetime | None = None generate_duration: float = 0.0 + + +class BatchPreviewGenerationTaskResponse(BaseModel): + """批量预览任务响应:preview_count=N 时返回 N 个独立变体任务。 + + - items: 变体任务数组,按 variant_index 顺序排列,每个含独立 task_id/状态/预览视频URL + - total: 变体总数(= preview_count) + - 前端按 items[i].task_id 分别轮询 GET /preview/{task_id} 获取进度与结果 + """ + + items: list[PreviewGenerationTaskResponse] + total: int diff --git a/apps/api/app/schemas/lipsync.py b/apps/api/app/schemas/lipsync.py new file mode 100644 index 000000000..3f0e838f0 --- /dev/null +++ b/apps/api/app/schemas/lipsync.py @@ -0,0 +1,131 @@ +"""对口型 API Schema 定义 — #1796 / #1809 / #1822 / #1845(配音前置). + +支持三种输入模式: +1. TTS 直生模式(兼容旧版前端):传 voice_id + script_text(+ speed/emotion), + 后端 Celery 异步做 TTS 合成 + MediaKit 提交。 +2. 直接音频模式:传 video_url + audio_url(音频已由调用方准备好)。 +3. 预合成音频模式(#1845 配音前置新主路径):前端先调 POST /lipsync/tts-preview + 拿到 audio_url + sentence_timings,再在 create_job 时传 audio_url + audio_duration + + sentence_timings,后端跳过 TTS 和时间戳计算,直接 ffprobe 校验后提交 MediaKit。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, model_validator + + +class LipsyncJobResponse(BaseModel): + """对口型任务响应.""" + + id: str + user_id: str + project_id: str + 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 + output_duration: float + error_message: str + error_code: str + sentence_timings: Optional[list] = None + submitted_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class CreateLipsyncJobRequest(BaseModel): + """创建对口型任务请求. + + 三种模式(三选一): + - TTS 直生(旧版/降级):voice_id + script_text 必填;audio_url 留空。 + - 直接音频:video_url + audio_url 必填。 + - 预合成音频(#1845 新主路径):audio_url 必填 + 可选 audio_duration/sentence_timings; + 后端同步 ffprobe 校验时长、写入 timings,直接提交 MediaKit。 + """ + + video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)") + + # 模式 2/3:直接/预合成音频 + audio_url: str = Field("", description="驱动音频 URL(mp3/aac/wav/m4a/flac);直生模式留空") + audio_duration: Optional[float] = Field(None, ge=0, description="预合成音频时长(秒),可选;后端会 ffprobe 校验") + sentence_timings: Optional[list] = Field(None, description="预合成接口返回的句子时间戳,可选;若传入则直接写入 job") + + # 模式 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 或中文 自然/兴奋/沉稳/亲切)") + + enable_video_loop: bool = Field( + True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)" + ) + 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: + raise ValueError("video_url 不能为空") + if not video.startswith(("http://", "https://")): + raise ValueError("video_url 必须是 HTTP/HTTPS URL") + lower = video.lower().split("?")[0] + allowed_video_exts = (".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv", ".3gp") + if not any(lower.endswith(ext) for ext in allowed_video_exts): + raise ValueError("video_url 格式不支持,仅支持: " + ", ".join(allowed_video_exts)) + + 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 + + +# ── #1845 TTS 预合成接口 ──────────────────────────────────────────────── + + +class AiAvatarTtsPreviewRequest(BaseModel): + """步骤1「生成配音」预合成请求(同步 HTTP,~2-3s).""" + + voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID") + script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案") + speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") + emotion: str = Field("natural", max_length=32, description="情绪") + + +class AiAvatarTtsPreviewResponse(BaseModel): + """TTS 预合成响应(临时 URL,24h 内有效,足够当前会话使用).""" + + audio_url: str = Field(..., description="CosyVoice 临时音频 URL") + duration: float = Field(..., ge=0, description="音频总时长(秒),ffprobe 测得") + sentence_timings: list[dict] = Field(..., description="句子级精确时间戳") diff --git a/apps/api/app/schemas/script.py b/apps/api/app/schemas/script.py new file mode 100644 index 000000000..fb06738c8 --- /dev/null +++ b/apps/api/app/schemas/script.py @@ -0,0 +1,45 @@ +"""Script (口播文案库) Pydantic schemas — Issue #1795.""" + +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class ScriptSegment(BaseModel): + """单段文案.""" + + text: str + duration: Optional[float] = None + + +class ScriptResponse(BaseModel): + id: str + user_id: str + title: str + content: str + segments: List[ScriptSegment] = Field(default_factory=list) + tags: List[str] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class ScriptListResponse(BaseModel): + items: list[ScriptResponse] + total: int = 0 + + +class CreateScriptRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + content: str = "" + segments: List[ScriptSegment] = Field(default_factory=list) + tags: List[str] = Field(default_factory=list) + + +class UpdateScriptRequest(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=255) + content: Optional[str] = None + segments: Optional[List[ScriptSegment]] = None + tags: Optional[List[str]] = None diff --git a/apps/api/app/schemas/tts.py b/apps/api/app/schemas/tts.py index 1e6b47513..535f03ba2 100644 --- a/apps/api/app/schemas/tts.py +++ b/apps/api/app/schemas/tts.py @@ -16,6 +16,7 @@ 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)") @@ -109,6 +110,7 @@ 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="音调(预留,当前未使用)") diff --git a/apps/api/app/schemas/upload.py b/apps/api/app/schemas/upload.py index c6d798288..54b6cba6f 100644 --- a/apps/api/app/schemas/upload.py +++ b/apps/api/app/schemas/upload.py @@ -16,6 +16,7 @@ class DirectUploadPrepareRequest(BaseModel): content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100) file_size: int = Field(..., gt=0) file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测") + client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)") class DirectUploadPrepareResponse(BaseModel): @@ -25,20 +26,25 @@ class DirectUploadPrepareResponse(BaseModel): expires_at: str fields: dict[str, str] max_size_bytes: int + duplicated: bool = False + skip_transfer: bool = False + asset_id: str = "" class DirectUploadCompleteRequest(BaseModel): project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) storage_key: str = Field(..., min_length=1, max_length=255) - file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测") + file_hash: str = Field(default="", max_length=64, description="文件哈希,用于去重检测") + client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)") + file_size: int = Field(default=0, ge=0, description="文件大小(字节),用于无 hash 时的兜底去重") class DirectUploadCompleteResponse(BaseModel): storage_key: str ingest_job_id: str - duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)") - asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)") + duplicated: bool = Field(default=False, description="是否为重复素材/重复 complete(命中幂等去重)") + asset_id: str = Field(default="", description="素材 asset_id(重复 complete 时返回已存在记录)") url: str = Field(default="", description="Public URL of uploaded file") @@ -46,5 +52,5 @@ class UploadAssetResponse(BaseModel): storage_key: str ingest_job_id: str url: str = Field(..., description="Public URL of uploaded file") - duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)") - asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)") + duplicated: bool = Field(default=False, description="是否为重复素材/重复提交(命中幂等去重)") + asset_id: str = Field(default="", description="素材 asset_id(重复提交时返回已存在记录)") diff --git a/apps/api/app/services/ai_avatar_cover_service.py b/apps/api/app/services/ai_avatar_cover_service.py new file mode 100644 index 000000000..d1dc22167 --- /dev/null +++ b/apps/api/app/services/ai_avatar_cover_service.py @@ -0,0 +1,215 @@ +"""AI 数字人封面服务 — MediaKit 抽帧 + 质量评分选最佳帧 + 转存 OSS. + +与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧): +1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略) +2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳 +3. 下载最佳帧并转存 OSS,返回公网封面 URL + +设计原则:封面一律从最终成片(已叠加标题/B-roll)抽帧,帧本身已含标题, +本服务**不再叠加标题**。对口型阶段的裸视频封面入口已删除(废弃)。 + +降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。 +""" + +from __future__ import annotations + +import logging +import tempfile +import uuid +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# MediaKit 抽帧轮询参数:poll_interval=2s × max_poll=30 → 最长 60s(与 mediakit_client 默认值/lipsync 轮询保持一致,防止合成视频下载+抽帧超时) +COVER_POLL_INTERVAL = 2.0 +COVER_MAX_POLL_ATTEMPTS = 30 + +# 帧图片下载超时(秒) +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: + 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.""" + if not video_url: + return "" + + 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", + video_url[:80], + max_frames, + ) + + 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 "" + + 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 persist_cover_to_oss( + frame_url: str, + *, + job_id: str = "", + prefix: str = "ai-avatar/covers", +) -> str: + """下载最佳帧图并转存到 OSS,返回公网封面 URL(预签名). + + 封面来自最终成片抽帧,帧本身已含标题,本函数不再做任何文字/图片叠加。 + """ + if not frame_url: + return "" + tmp_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" + + public_url = storage.upload_file( + file_or_path=tmp_path, + storage_key=cover_key, + content_type="image/jpeg", + ) + logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key) + 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: + if tmp_path: + try: + Path(tmp_path).unlink(missing_ok=True) + except Exception: + pass + + +def generate_smart_cover( + video_url: str, + *, + job_id: str = "", + max_frames: int = 5, +) -> str: + """一站式:MediaKit 智能抽帧选最佳 → 转存 OSS。失败返回空字符串。 + + 封面从最终成片抽帧,不再叠加任何标题(帧本身已含)。 + """ + 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) diff --git a/apps/api/app/services/ai_avatar_render_service.py b/apps/api/app/services/ai_avatar_render_service.py new file mode 100644 index 000000000..ca6965b0c --- /dev/null +++ b/apps/api/app/services/ai_avatar_render_service.py @@ -0,0 +1,658 @@ +"""AI数字人渲染合成 Service — #1798. + +职责: +- 创建/查询/取消渲染任务 +- 调用 Celery 异步任务执行渲染 +- B-roll 合成 + 标题叠加 + 封面提取 +- 用户隔离 +""" + +from __future__ import annotations + +import base64 +import binascii +import logging +import os +import subprocess +import tempfile +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import ( + AiAvatarRenderJob, + LipsyncJobModel, + ScriptModel, +) +from packages.domain.video_filter_builder import ( + build_broll_overlay_filter, + build_title_drawtext_filter, + build_title_overlay_filter, +) +from packages.shared.storage import get_shared_storage_service + +logger = logging.getLogger(__name__) + + +class AiAvatarRenderError(Exception): + """渲染服务异常.""" + + def __init__(self, message: str, code: str = "RenderError"): + self.code = code + super().__init__(message) + + +class AiAvatarRenderService: + """AI数字人渲染合成 Service.""" + + def __init__(self, db: Session): + self.db = db + + # ── 创建任务 ────────────────────────────────────────────────────────── + + def create_render_job( + self, + *, + user_id: str, + lipsync_job_id: str, + script_id: str = "", + b_roll_segments: list[dict[str, Any]] | None = None, + title_config: dict[str, Any], + cover_config: dict[str, Any], + project_id: str = "", + ) -> AiAvatarRenderJob: + """创建渲染任务. + + Raises: + AiAvatarRenderError: 校验失败 + """ + # 1. 验证对口型任务 + lipsync_job = ( + self.db.query(LipsyncJobModel) + .filter( + LipsyncJobModel.id == lipsync_job_id, + LipsyncJobModel.user_id == user_id, + ) + .first() + ) + if lipsync_job is None: + raise AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound") + if lipsync_job.status != "completed": + raise AiAvatarRenderError( + f"对口型任务状态为 {lipsync_job.status},仅 completed 状态可渲染", + code="LipsyncJobNotCompleted", + ) + if not lipsync_job.output_video_url: + raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput") + + # 2. 验证文案归属(仅当选了文案库条目时;手动输入文案直生场景 script_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() + ) + if script is None: + raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound") + + # 3. 创建渲染任务 + job_id = str(uuid.uuid4()) + job = AiAvatarRenderJob( + id=job_id, + user_id=user_id, + project_id=project_id, + lipsync_job_id=lipsync_job_id, + script_id=script_id, + b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])], + title_config=title_config, + cover_config=cover_config, + status="pending", + ) + self.db.add(job) + self.db.flush() + + job.submitted_at = datetime.now(timezone.utc) + self.db.commit() + self.db.refresh(job) + return job + + # ── 查询任务 ────────────────────────────────────────────────────────── + + def get_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]: + """获取渲染任务详情(用户隔离).""" + return ( + self.db.query(AiAvatarRenderJob) + .filter( + AiAvatarRenderJob.id == job_id, + AiAvatarRenderJob.user_id == user_id, + ) + .first() + ) + + def list_render_jobs( + self, + *, + user_id: str, + project_id: str = "", + status: str = "", + offset: int = 0, + limit: int = 20, + ) -> tuple[list[AiAvatarRenderJob], int]: + """获取渲染任务列表(分页 + 用户隔离).""" + query = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.user_id == user_id) + if project_id: + query = query.filter(AiAvatarRenderJob.project_id == project_id) + if status: + query = query.filter(AiAvatarRenderJob.status == status) + + total = query.count() + items = query.order_by(AiAvatarRenderJob.created_at.desc()).offset(offset).limit(limit).all() + return items, total + + # ── 取消任务 ────────────────────────────────────────────────────────── + + def cancel_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]: + """取消渲染任务(仅 pending 状态可取消).""" + job = self.get_render_job(job_id, user_id) + if job is None: + return None + if job.status in ("pending", "submitted"): + job.status = "cancelled" + job.updated_at = datetime.now(timezone.utc) + self.db.commit() + self.db.refresh(job) + return job + + # ── 重试任务 ────────────────────────────────────────────────────────── + + def retry_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]: + """重试失败的渲染任务.""" + job = self.get_render_job(job_id, user_id) + if job is None: + return None + if job.status != "failed": + return None + job.status = "pending" + job.progress = 0 + job.error_message = "" + job.output_video_url = "" + job.output_cover_url = "" + job.output_duration = 0.0 + job.started_at = None + job.completed_at = None + job.updated_at = datetime.now(timezone.utc) + self.db.commit() + self.db.refresh(job) + return job + + # ── 执行渲染(Celery 异步调用) ────────────────────────────────────── + + def execute_render(self, job_id: str) -> None: + """执行渲染管线. + + 由 Celery 异步任务调用,流程: + 1. 下载对口型输出视频 (20%) + 2. 构建 FFmpeg 滤镜链 (40%) + 3. 执行 FFmpeg 渲染 (80%) + 4. 上传到 OSS (95%) — 封面不再自动生成,改由前端主动抽帧 + 5. 更新任务状态 (100%) + """ + job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first() + if job is None: + logger.error("渲染任务不存在: %s", job_id) + return + + if job.status == "cancelled": + logger.info("渲染任务已取消: %s", job_id) + return + + try: + # 更新状态为 processing + job.status = "processing" + job.started_at = datetime.now(timezone.utc) + job.progress = 5 + job.updated_at = datetime.now(timezone.utc) + self.db.commit() + + # 获取对口型任务信息 + lipsync_job = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job.lipsync_job_id).first() + if lipsync_job is None: + raise AiAvatarRenderError("关联的对口型任务不存在", code="LipsyncJobNotFound") + + # 1. 下载对口型输出视频 (20%) + input_video_path = self._download_video(lipsync_job.output_video_url) + job.progress = 20 + self.db.commit() + + # 2. 构建 FFmpeg 滤镜链 (40%) + # 用 ffprobe 探测输入视频分辨率,确保 B-roll 缩放与标题位置与实际输出一致。 + # AI 数字人对口型输出为 9:16 竖屏,默认兜底 720x1280;探测失败时使用默认值不阻断渲染。 + output_width, output_height = self._probe_video_resolution(input_video_path) + if output_width <= 0 or output_height <= 0: + output_width, output_height = 720, 1280 + logger.info( + "[数字人渲染] ffprobe 探测分辨率失败或无效,使用默认竖屏尺寸 %sx%s", + output_width, + output_height, + ) + else: + logger.info("[数字人渲染] 探测输入视频分辨率: %sx%s", output_width, output_height) + + broll_filter, broll_label = build_broll_overlay_filter( + b_roll_segments=job.b_roll_segments, + video_duration=lipsync_job.output_duration, + output_width=output_width, + output_height=output_height, + ) + + # 标题叠加路径:优先前端 Canvas 渲染的 PNG 图层(所见即所得), + # 无 title_image_dataurl 时降级到 drawtext 重画文字。 + title_cfg = job.title_config if isinstance(job.title_config, dict) else {} + title_dataurl = (title_cfg or {}).get("title_image_dataurl") if title_cfg else None + use_title_png = isinstance(title_dataurl, str) and title_dataurl.startswith("data:image/") + title_input_index = 1 + len(job.b_roll_segments or []) if use_title_png else None + + job.progress = 40 + self.db.commit() + + # 3. 执行 FFmpeg 渲染 (80%) + with tempfile.TemporaryDirectory() as tmpdir: + output_video_path = os.path.join(tmpdir, "output.mp4") + + # 在临时目录里解码保存标题 PNG(with 退出自动清理) + title_png_path: Optional[str] = None + extra_inputs: list[str] = [] + title_filter = None + if use_title_png: + try: + title_png_path = os.path.join(tmpdir, f"title_{job.id}.png") + self._save_title_dataurl_to_file(title_dataurl, dst_path=title_png_path) + extra_inputs.append(title_png_path) + logger.info( + "[数字人渲染] 标题 PNG 已保存: %s (input index %d)", title_png_path, title_input_index + ) + except Exception as exc: + logger.warning("[数字人渲染] 标题 PNG 解码/保存失败,降级 drawtext: %s", exc) + title_png_path = None + extra_inputs = [] + + # 构建标题滤镜 + final_label = None + if title_png_path and title_input_index is not None: + title_input_label = f"[{title_input_index}:v]" + base_label = f"[{broll_label}]" if broll_label else "[0:v]" + title_filter = build_title_overlay_filter( + title_cfg, + output_width=output_width, + output_height=output_height, + title_png_path=title_png_path, + title_input_label=title_input_label, + base_label=base_label, + output_label="vout_titled", + ) + if not title_filter: + # build 返回 None → 文件不存在(极端并发情况),降级 drawtext + title_png_path = None + extra_inputs = [] + + if title_png_path: + # overlay 路径 + if broll_filter and title_filter: + filter_complex = broll_filter + f";{title_filter}" + elif broll_filter: + filter_complex = broll_filter + final_label = broll_label + elif title_filter: + filter_complex = title_filter + else: + filter_complex = "" + if title_filter: + final_label = "vout_titled" + elif not final_label: + final_label = None + else: + # 降级:drawtext 重画文字 + title_filter = build_title_drawtext_filter( + title_cfg, + output_width=output_width, + output_height=output_height, + ) + if broll_filter and title_filter: + filter_complex = broll_filter + f";[{broll_label}]{title_filter}[vout_titled]" + final_label = "vout_titled" + elif broll_filter: + filter_complex = broll_filter + final_label = broll_label + elif title_filter: + filter_complex = f"[0:v]{title_filter}[vout_titled]" + final_label = "vout_titled" + else: + filter_complex = "" + final_label = None + + cmd_list = self._build_ffmpeg_command( + input_video=input_video_path, + b_roll_segments=job.b_roll_segments, + extra_inputs=extra_inputs, + filter_complex=filter_complex, + final_label=final_label, + 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", + ) + + job.progress = 80 + self.db.commit() + + # 4/5. 上传成片到 OSS (95%) —— 已砍掉自动抽封面逻辑(步骤⑤); + # 封面由前端在渲染完成后通过 /smart-cover 接口主动从成片抽帧,不阻塞渲染链路。 + output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4") + job.output_video_url = output_video_url + + # 封面透传:如果用户已在 cover_config 中选定封面 URL(mode=upload 的自定义上传 或 + # mode=auto_frame 已有的智能封面结果),直接透传到 output_cover_url,不再重新截帧。 + if isinstance(job.cover_config, dict): + _pre_cover_url = ( + job.cover_config.get("url") + or job.cover_config.get("imageUrl") + or job.cover_config.get("cover_url") + or "" + ) + if _pre_cover_url: + job.output_cover_url = _pre_cover_url + logger.info("[数字人渲染] 使用用户已选定封面 URL: job_id=%s", job_id) + + # 获取输出视频时长 + job.output_duration = lipsync_job.output_duration + job.progress = 95 + self.db.commit() + + # 6. 完成 + job.status = "completed" + job.progress = 100 + job.completed_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(timezone.utc) + self.db.commit() + logger.info("渲染任务完成: %s", job_id) + + # 7. 渲染完成,停留在「待选封面」状态:不自动入库。 + # 用户在前端选好封面、点「完成」后,由 /{job_id}/finalize 接口显式入库。 + logger.info("渲染任务完成,等待用户选择封面后入库: job_id=%s", job_id) + + except AiAvatarRenderError as exc: + job.status = "failed" + job.error_message = str(exc) + job.updated_at = datetime.now(timezone.utc) + self.db.commit() + logger.error("渲染任务失败 [%s]: %s", job_id, exc) + 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 _persist_to_library(self, job: AiAvatarRenderJob, cover_url: Optional[str] = None): + """将渲染结果写入成片库,返回 GeneratedVideo 领域对象. + + Args: + job: 渲染任务(必须 status=completed 且 output_video_url 非空) + cover_url: 可选的封面 URL 覆盖(finalize 时传入即优先使用,否则取 job.output_cover_url) + """ + 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.id + effective_cover = (cover_url or "").strip() if cover_url else (job.output_cover_url or "").strip() + 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=effective_cover 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) + return clip + + def finalize_job(self, job_id: str, user_id: str, cover_url: Optional[str] = None): + """用户在前端点「完成」后调用:将已 completed 的渲染任务正式入库到成片库. + + - 必须 status=completed 才可调用 + - cover_url 若传入则优先使用并回写 job.output_cover_url;否则使用 job.output_cover_url(smart-cover/custom-cover 已写入) + - 幂等:已入库则返回已存在的 GeneratedVideo + """ + from packages.adapters.sqlalchemy_impl.generated_video_repository import ( + SQLAlchemyGeneratedVideoRepository, + ) + from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel + + job = self.get_render_job(job_id, user_id) + if job is None: + raise AiAvatarRenderError("渲染任务不存在", code="RenderJobNotFound") + if job.status != "completed": + raise AiAvatarRenderError(f"渲染任务未完成(当前状态: {job.status}),无法入库", code="RenderNotCompleted") + if not (job.output_video_url or "").strip(): + raise AiAvatarRenderError("渲染成片视频 URL 为空,无法入库", code="OutputVideoMissing") + + # 幂等检查:已入库直接返回现有记录(通过 generation_task_id=job_id 识别, + # 因为入库时 generation_task_id 被设置为 render_job_id 自身) + existing = ( + self.db.query(GeneratedVideoModel) + .filter( + GeneratedVideoModel.user_id == user_id, + GeneratedVideoModel.generation_task_id == job_id, + ) + .first() + ) + if existing is not None: + logger.info("[数字人渲染] finalize 幂等命中,返回已存在记录: clip_id=%s job_id=%s", existing.id, job_id) + return SQLAlchemyGeneratedVideoRepository(self.db).get(existing.id) + + # 传入 cover_url 时回写到 job + if cover_url and cover_url.strip(): + job.output_cover_url = cover_url.strip() + # 同步更新 cover_config,保持 smart-cover 路径一致 + if isinstance(job.cover_config, dict): + job.cover_config = {**job.cover_config, "mode": "auto_frame", "url": cover_url.strip()} + job.updated_at = datetime.now(timezone.utc) + self.db.commit() + + return self._persist_to_library(job, cover_url=cover_url) + + def _download_video(self, url: str) -> str: + """下载视频到临时文件.""" + import httpx + + tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) + try: + with httpx.Client(timeout=120) as client: + resp = client.get(url) + resp.raise_for_status() + tmp.write(resp.content) + return tmp.name + except Exception: + if os.path.exists(tmp.name): + os.unlink(tmp.name) + raise + + @staticmethod + def _save_title_dataurl_to_file(dataurl: str, *, dst_path: str | None = None, job_id: str = "") -> str: + """解码前端传来的 data:image/png;base64,... 并保存为本地 PNG 文件。 + + Args: + dataurl: 完整 dataURL 字符串 + dst_path: 指定输出路径;为 None 时创建临时文件并返回路径 + job_id: 仅在 dst_path 为空时用于临时文件命名 + + Returns: + 保存后的本地文件路径 + """ + if not isinstance(dataurl, str) or not dataurl.startswith("data:image/"): + raise ValueError("title_image_dataurl 不是合法的 data:image URL") + # 拆分 data:image/png;base64, + try: + header, b64 = dataurl.split(",", 1) + except ValueError as exc: + raise ValueError("title_image_dataurl 缺少 base64 payload") from exc + if "base64" not in header: + raise ValueError("title_image_dataurl 不是 base64 编码") + try: + png_bytes = base64.b64decode(b64, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError(f"title_image_dataurl base64 解码失败: {exc}") from exc + if not png_bytes: + raise ValueError("title_image_dataurl 解码后为空") + + if dst_path: + out_path = dst_path + with open(out_path, "wb") as f: + f.write(png_bytes) + return out_path + suffix = f"_title_{job_id}.png" if job_id else "_title.png" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(png_bytes) + return tmp.name + + @staticmethod + def _probe_video_resolution(video_path: str) -> tuple[int, int]: + """用 ffprobe 探测视频分辨率,返回 (width, height);失败返回 (0, 0)。""" + try: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=p=0:s=x", + video_path, + ], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode == 0 and result.stdout.strip(): + parts = result.stdout.strip().split("x") + if len(parts) == 2: + w, h = int(parts[0]), int(parts[1]) + if w > 0 and h > 0: + return w, h + except Exception as exc: + logger.warning("[数字人渲染] ffprobe 探测分辨率失败: %s", exc) + return 0, 0 + + def _build_ffmpeg_command( + self, + *, + input_video: str, + b_roll_segments: list[dict[str, Any]], + extra_inputs: list[str] | None = None, + 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] + for seg in b_roll_segments: + asset_url = seg.get("asset_url", "") + if asset_url: + cmd.extend(["-i", asset_url]) + # 额外输入(例如前端 Canvas 渲染的标题 PNG) + for extra in extra_inputs or []: + cmd.extend(["-i", extra]) + + if filter_complex and final_label: + cmd.extend( + [ + "-filter_complex", + filter_complex, + "-map", + f"[{final_label}]", + "-map", + "0:a?", + ] + ) + elif filter_complex: + cmd.extend(["-filter_complex", filter_complex]) + + cmd.extend( + [ + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-c:a", + "aac", + "-b:a", + "128k", + "-y", + output_path, + ] + ) + return cmd + + def _upload_to_oss(self, local_path: str, oss_key: str) -> str: + """上传文件到 OSS,返回 URL. + + 使用 SharedStorageService 统一存储服务。 + """ + storage = get_shared_storage_service() + url = storage.upload_file_smart(local_path, oss_key) + if url is None: + raise AiAvatarRenderError( + f"上传文件到 OSS 失败: {oss_key}", + code="OSSUploadFailed", + ) + logger.info("上传文件到 OSS 成功: %s -> %s", local_path, url) + return url diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 5ed03ae07..f9f752404 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -409,8 +409,13 @@ class EditPlanService: clip_type=clip_item.get("clip_type", "main"), order=order, asset_id=clip_item.get("asset_id", ""), + text_content=clip_item.get("text_content", ""), start_time=clip_item.get("start_time", 0.0), duration=clip_item.get("duration", 0.0), + transition_effect=clip_item.get("transition_effect", "cut"), + transition_duration=clip_item.get("transition_duration", 0.0), + playback_speed=clip_item.get("playback_speed", 1.0), + config=clip_item.get("config") or None, ) model = EditPlanClipModel( id=clip.id, @@ -459,6 +464,178 @@ class EditPlanService: logger.exception("事务性替换片段失败: plan_id=%s", plan_id) raise + def reselect_plan_for_variant( + self, + source_plan_id: str, + candidate_asset_ids: list[str], + *, + created_by_user_id: str = "", + name_suffix: str = "变体", + voice_duration: float = 0.0, + rng=None, + batch_segments: dict[str, list[tuple[float, float]]] | None = None, + ) -> EditPlan: + """为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。 + + 与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,本方法: + - 源 plan 片段骨架(clip_type/order/duration/文案/转场)保留; + - 素材池 shuffle 随机分配 + main 片段顺序洗牌; + - 起点走场景镜头洗牌/随机起点/历史区间避让(与单视频同一入口); + - 批次内同素材区间重叠 >20% 自动重选起点; + - 新片段区间 record_used_segments 写回素材 metadata(跨变体/跨任务避让)。 + + Args: + source_plan_id: 源 plan(任务 0 / 预览源)。 + candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次素材)。 + created_by_user_id: 新 plan 归属用户。 + name_suffix: plan 名后缀。 + rng: 可选随机数(测试注入种子)。 + batch_segments: 可选,外部传入的批次内已使用素材区间(前序变体避让用)。 + 传入时作为初始避让对象;未传则保持原逻辑从源 plan clips 自建(向后兼容)。 + + Raises: + ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。 + """ + from packages.adapters.sqlalchemy_impl.models import AssetModel + from packages.domain.plan_generator_utils import extract_scene_points_from_metadata + from packages.domain.variant_plan_selector import reselect_clips_for_variant + + source = self.get_plan_or_raise(source_plan_id) + + # 分页读取源 plan 全部片段 + clips: List[EditPlanClip] = [] + skip, page = 0, 500 + while True: + batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page) + if not batch: + break + clips.extend(batch) + if len(batch) < page: + break + skip += page + if not clips: + raise ValueError(f"源 plan 无片段,无法生成变体: {source_plan_id}") + + source_clips_data: list[dict[str, Any]] = [ + { + "order": c.order if c.order is not None else i, + "asset_id": c.asset_id, + "start_time": float(c.start_time or 0.0), + "duration": float(c.duration or 0.0), + "clip_type": c.clip_type, + "playback_speed": float(c.playback_speed or 1.0), + "transition_effect": c.transition_effect, + "transition_duration": float(c.transition_duration or 0.0), + "text_content": c.text_content or "", + "config": c.config or {}, + } + for i, c in enumerate(clips) + ] + + db = self._clip_repo.session + + # #1749:配音时长 → 每段目标段长(片段数=模板片段数定死;素材不足由渲染末帧冻结铺满) + target_durations: list[float] | None = None + try: + voice = float(voice_duration or 0.0) + except (TypeError, ValueError): + voice = 0.0 + rhythm_template_for_reselect = None + if source.config: + rhythm_template_for_reselect = source.config.get("rhythm_template") + + if voice > 0 and source_clips_data: + from packages.domain.voice_duration_planner import plan_clip_durations + + _effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data] + _tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data] + # #1855 P0:先占位durations为空dict,真正查durations在后面pool_ids确定后执行; + # plan_clip_durations 的 asset_durations 参数在该函数中仅作最大段长钳制, + # 这里先不依赖它(durations 还没查),传 None 让planner用默认策略; + # 真正的asset_durations会在后面 clips_data 生成时传入 reselect_clips_for_variant + target_durations = plan_clip_durations( + len(source_clips_data), + voice, + transition_effects=_effects, + transition_durations=_tdurs, + rhythm_template=rhythm_template_for_reselect, + asset_durations=None, + ) + if target_durations: + for _c, _d in zip(source_clips_data, target_durations, strict=False): + _c["duration"] = _d + + # 素材池 = 源 plan 素材 ∪ 调用方传入素材(去重保序) + pool_ids: list[str] = [] + seen = set() + for aid in [c.asset_id for c in clips if c.asset_id] + list(candidate_asset_ids or []): + if aid and aid not in seen: + seen.add(aid) + pool_ids.append(aid) + + # 时长 + 场景点 + durations: dict[str, float] = {} + scene_points: dict[str, list[float]] = {} + if pool_ids: + for m in db.query(AssetModel).filter(AssetModel.id.in_(pool_ids)).all(): + durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0) + pts = extract_scene_points_from_metadata(getattr(m, "metadata", None)) + if pts: + scene_points[m.id] = pts + + historical = get_used_segments(db, pool_ids) + + # 创建新 plan(复制模板归属与 config) + new_plan = self.create_plan( + template_id=source.template_id, + name=f"{source.name or '剪辑计划'} · {name_suffix}", + config=dict(source.config or {}), + total_duration=source.total_duration, + project_id=source.project_id or "", + created_by_user_id=created_by_user_id or (source.created_by_user_id or ""), + ) + + # 批次内区间:外部传入时使用外部传入(含前序变体已用区间); + # 否则保持原逻辑从源 plan clips 自建(向后兼容) + if batch_segments is not None: + batch_segments_resolved: dict[str, list[tuple[float, float]]] = { + k: list(v) for k, v in batch_segments.items() + } + else: + batch_segments_resolved = {} + for c in clips: + if c.asset_id and float(c.duration or 0) > 0: + st = float(c.start_time or 0.0) + batch_segments_resolved.setdefault(c.asset_id, []).append((st, st + float(c.duration))) + + clips_data = reselect_clips_for_variant( + source_clips_data, + pool_ids, + asset_durations=durations, + asset_scene_points=scene_points, + historical_used_segments=historical, + batch_segments=batch_segments_resolved, + target_durations=target_durations, + rng=rng, + ) + + # 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit) + for item in clips_data: + aid = item.get("asset_id", "") + if aid: + st = float(item.get("start_time", 0.0)) + record_used_segments(db, aid, st, st + float(item.get("duration", 0.0)), new_plan.id) + + self.replace_all_clips_transactional(new_plan.id, clips_data) + logger.info( + "变体独立选片完成: source=%s new=%s clips=%d assets=%d", + source_plan_id, + new_plan.id, + len(clips_data), + len(pool_ids), + ) + return new_plan + def clone_plan_for_variant( self, source_plan_id: str, @@ -569,6 +746,321 @@ class EditPlanService: ) return new_plan + # ── #1749 配音时长分配 / 素材时长查询 / 批量变体 plan 确保 ────────────── + + def get_asset_durations(self, asset_ids: list[str]) -> dict[str, float]: + """批量查询素材时长(秒),O(N) 单查;缺失/异常返回 0.0。""" + from packages.adapters.sqlalchemy_impl.models import AssetModel + + ids = [a for a in dict.fromkeys(asset_ids or []) if a] + if not ids: + return {} + db = self._clip_repo.session + out: dict[str, float] = {} + for m in db.query(AssetModel).filter(AssetModel.id.in_(ids)).all(): + try: + out[m.id] = float(getattr(m, "duration", 0.0) or 0.0) + except (TypeError, ValueError): + out[m.id] = 0.0 + return out + + def apply_voice_duration_to_plan(self, plan_id: str, voice_duration: float) -> Optional[EditPlan]: + """把配音时长分配到 plan 的每段(#1749)。 + + - 片段数保持不变(= 模板片段数,定死); + - 每段 duration 按 voice_duration_planner 分配(含转场重叠扣减); + - 素材短于段长 → start_time 钳制为 0(末帧冻结由渲染侧 tpad/apad 铺满); + - plan.total_duration 回写为成片净时长(≈ 配音时长); + - 幂等:配音时长相同则分配结果不变,可重复调用。 + + 无配音(<=0)或无片段时直接返回 None,不报错。 + """ + try: + voice = float(voice_duration or 0.0) + except (TypeError, ValueError): + return None + if voice <= 0: + return None + + plan = self.get_plan(plan_id) + if plan is None: + return None + + # #1855 P0:幂等判断——如果已成功分配过且当前 total_duration 已接近 voice_duration,直接返回 + try: + existing_mark = None + if plan.config: + existing_mark = plan.config.get("voice_duration_applied") + cur_total = float(plan.total_duration or 0.0) + if existing_mark is not None and abs(existing_mark - voice) < 1e-6 and abs(cur_total - voice) < 0.5: + return plan + except Exception: + pass + + clips: List[EditPlanClip] = [] + skip, page = 0, 500 + while True: + batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page) + if not batch: + break + clips.extend(batch) + if len(batch) < page: + break + skip += page + if not clips: + return None + + clips.sort(key=lambda c: (c.order if c.order is not None else 0)) + + from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration + + # #1764:从 plan config 读取节奏模板 + rhythm_template = None + if plan and hasattr(plan, "config") and plan.config: + rhythm_template = plan.config.get("rhythm_template") + + # #1768:先获取素材时长,传入 plan_clip_durations 用于最大片段钳制 + asset_ids = [c.asset_id for c in clips if c.asset_id] + durations = self.get_asset_durations(asset_ids) + asset_durations_for_plan = [durations.get(c.asset_id, 0.0) for c in clips] + + target = plan_clip_durations( + len(clips), + voice, + transition_effects=[c.transition_effect for c in clips], + transition_durations=[float(c.transition_duration or 0.0) for c in clips], + rhythm_template=rhythm_template, + asset_durations=asset_durations_for_plan, + ) + if not target: + return None + + clips_data: list[dict] = [] + for i, c in enumerate(clips): + dur = float(target[i]) + total = durations.get(c.asset_id, 0.0) + start = float(c.start_time or 0.0) + if c.asset_id and total > 0: + # 素材短于段长:起点钳 0,段长超出部分渲染侧末帧冻结 + max_start = max(0.0, total - min(dur, total)) + start = min(start, max_start) + clips_data.append( + { + "order": c.order if c.order is not None else i, + "asset_id": c.asset_id or "", + "start_time": round(start, 3), + "duration": dur, + "clip_type": c.clip_type, + "playback_speed": float(c.playback_speed or 1.0), + "transition_effect": c.transition_effect, + "transition_duration": float(c.transition_duration or 0.0), + "text_content": c.text_content or "", + "config": c.config or {}, + } + ) + + self.replace_all_clips_transactional(plan_id, clips_data) + + net = total_output_duration( + target, + transition_effects=[c.transition_effect for c in clips], + transition_durations=[float(c.transition_duration or 0.0) for c in clips], + ) + try: + plan.total_duration = net + # #1855 P0:写入幂等标记,避免二次调用时只重分配 duration 不重算 start_time + new_cfg = dict(plan.config or {}) + new_cfg["voice_duration_applied"] = voice + plan.config = new_cfg + db = self._clip_repo.session + db.commit() + except Exception: + db.rollback() + logger.exception("回写 plan.total_duration 失败(不阻断): plan_id=%s", plan_id) + + logger.info( + "配音时长分配完成: plan=%s clips=%d voice=%.2fs 成片净时长=%.2fs", + plan_id, + len(clips), + voice, + net, + ) + return plan + + def ensure_variant_plans( + self, + source_plan_id: str, + count: int, + candidate_asset_ids: list[str], + *, + created_by_user_id: str = "", + voice_durations: Optional[list[float]] = None, + rng=None, + ) -> list[str]: + """确保批量 N 个变体各自拥有独立 plan(#1749 批量正式生成/预览共用)。 + + - 变体 0:clone 源 plan(不污染源 plan,片段独立可改),并按配音分配段长; + - 变体 1..N-1:reselect_plan_for_variant 完整重跑选片(素材级去重); + - voice_durations:每个变体的配音时长(独立配音各自时长;统一配音同值); + 缺省/为 0 时不分配(段长保持骨架/模板值)。 + + Returns: + plan_id 列表,长度 == count,index 即 variant_index。 + """ + import random as _random + + rng = rng or _random.Random() + plan_ids: list[str] = [] + + # #1855 P0:先确定片段数 clip_count(用于节奏模板生成长度匹配) + from packages.domain.bgm_pool import allocate_bgm_pool_for_variants + from packages.domain.variant_plan_selector import ( + generate_pixel_perturbation, + generate_visual_perturbation, + ) + from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length + + clip_count = 0 + # 从源 plan 获取片段数(分页读,避免关系加载问题) + _sclips: list = [] + _sk, _pg = 0, 500 + while True: + _b = self._clip_repo.list_by_plan(source_plan_id, skip=_sk, limit=_pg) + if not _b: + break + _sclips.extend(_b) + if len(_b) < _pg: + break + _sk += _pg + clip_count = len(_sclips) + + # 预先生成所有 N 个变体的节奏模板/BGM/扰动参数(时机提前到选片前写入config) + rhythm_templates_for_variants: list = [] + for _idx in range(count): + if clip_count > 0: + variant_seed = rng.randint(0, 999999) + _tpl = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count) + rhythm_templates_for_variants.append(_tpl) + else: + rhythm_templates_for_variants.append(None) + + source_bgm_config: dict = {} + source_plan = self.get_plan(source_plan_id) + if source_plan and source_plan.config: + source_bgm_config = source_plan.config.get("bgm", {}) or {} + variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in range(count)] + bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm) + + def _build_variant_config_update(idx: int) -> dict: + """构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。""" + upd: dict = {} + try: + perturbation = generate_visual_perturbation(rng) + if idx == 0: + perturbation["hflip"] = False + upd["visual_perturbation"] = perturbation + except Exception: + logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx) + try: + pixel_pert = generate_pixel_perturbation(rng) + upd["pixel_perturbation"] = pixel_pert + except Exception: + logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx) + rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None + if rt is not None: + upd["rhythm_template"] = rt + if idx < len(bgm_pool_assignments): + existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {}) + existing_bgm.update(bgm_pool_assignments[idx]) + upd["bgm"] = existing_bgm + return upd + + # 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan + plan0 = self.clone_plan_for_variant( + source_plan_id, + created_by_user_id=created_by_user_id, + name_suffix="变体1", + ) + v0_voice = 0.0 + if voice_durations and len(voice_durations) > 0: + try: + v0_voice = float(voice_durations[0] or 0.0) + except (TypeError, ValueError): + v0_voice = 0.0 + + # #1855 P0:在配音分配前先写入变体0的节奏模板/扰动/BGM,确保 apply_voice_duration_to_plan 能读到 rhythm_template + try: + _cfg0 = _build_variant_config_update(0) + if _cfg0: + self.update_plan_config(plan0.id, _cfg0) + except Exception: + logger.exception("变体0 配置写入失败(不阻断): plan=%s", plan0.id) + + if v0_voice > 0: + try: + self.apply_voice_duration_to_plan(plan0.id, v0_voice) + except Exception: + logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id) + plan_ids.append(plan0.id) + + # #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值(公共函数) + from app.services.generation_common import collect_plan_segments as _collect_plan_segments + + batch_segments_acc: dict[str, list[tuple[float, float]]] = _collect_plan_segments(plan0.id, self._clip_repo) + + # 变体 1..N-1:独立选片(传入累积的 batch_segments 做区间避让) + for i in range(1, count): + voice = 0.0 + if voice_durations and i < len(voice_durations): + try: + voice = float(voice_durations[i] or 0.0) + except (TypeError, ValueError): + voice = 0.0 + + # #1855 P0:在reselect前先为"变体i"准备配置更新——但reselect内部复制的是source.config, + # 所以每个变体独立的节奏模板需要在reselect后单独写入config; + # 但 plan_clip_durations 用的是 source.config.rhythm_template(即源plan的节奏模板), + # 为了让每个变体在选片阶段就使用自己的节奏模板分配段长,这里采用: + # - reselect 仍使用源 plan 的 rhythm_template(保持片段骨架一致) + # - 选片完成后立即写入该变体自己的 rhythm_template/扰动/BGM 到config, + # 后续不再二次 apply_voice_duration_to_plan(由幂等标记跳过) + variant = self.reselect_plan_for_variant( + source_plan_id, + candidate_asset_ids, + created_by_user_id=created_by_user_id, + name_suffix=f"变体{i + 1}", + voice_duration=voice, + rng=rng, + batch_segments=batch_segments_acc, + ) + + # 选片完成后写入该变体的独立配置(节奏模板/扰动/BGM) + try: + _cfgi = _build_variant_config_update(i) + if _cfgi: + self.update_plan_config(variant.id, _cfgi) + except Exception: + logger.exception("变体 %d 配置写入失败(不阻断): plan=%s", i, variant.id) + + plan_ids.append(variant.id) + + # #1855 P0:把当前新变体的 clips 区间追加到 batch_segments,供下一变体避让 + try: + _new_segs = _collect_plan_segments(variant.id, self._clip_repo) + for _aid, _ivs in _new_segs.items(): + batch_segments_acc.setdefault(_aid, []).extend(_ivs) + except Exception: + logger.exception("变体 %d 区间收集失败(不阻断): plan=%s", i, variant.id) + + # 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready) + for pid in plan_ids: + try: + self.mark_clips_ready(pid) + except Exception: + logger.exception("标记 clips ready 失败(不阻断): plan=%s", pid) + + return plan_ids + # ── 片段分割与合并 ────────────────────────────────────────────────────── def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]: @@ -792,7 +1284,7 @@ class EditPlanService: config_asset_ids_count = len((plan.config or {}).get("asset_ids", [])) clips_with_asset_count = sum(1 for c in clips if c.asset_id) logger.info( - "can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d", + "can_generate 诊断: plan=%s status=%s total_clips=%d clips_with_asset=%d config_asset_ids_count=%d", plan_id, plan.status, len(clips), @@ -804,7 +1296,7 @@ class EditPlanService: config_asset_ids = (plan.config or {}).get("asset_ids", []) if config_asset_ids: logger.warning( - "can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配", + "can_generate 最后防线触发: plan=%s clips=%d 均无素材,从 config.asset_ids(%d个) 自动分配", plan_id, len(clips), len(config_asset_ids), @@ -836,7 +1328,7 @@ class EditPlanService: return False, "没有可渲染的就绪片段,自动修复后仍未分配素材" else: logger.warning( - "can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复", + "can_generate 失败: plan=%s clips=%d 均无素材,且 config.asset_ids 为空,无法自动修复", plan_id, len(clips), ) diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index 0784cf64f..fe3057c50 100755 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -34,6 +34,17 @@ from packages.domain.template_clip_converter import ( logger = logging.getLogger(__name__) +class TemplateNotFoundError(Exception): + """模板不存在、已删除或当前用户无权访问. + + 与"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。 + """ + + def __init__(self, template_id: str) -> None: + self.template_id = template_id + super().__init__(f"模板不存在: {template_id}") + + class EditTemplateService: """模板管理服务 @@ -217,7 +228,14 @@ class EditTemplateService: skip: int = 0, limit: int = 100, ) -> List[TemplateClipConfig]: - """列出模板的片段配置""" + """列出模板的片段配置 + + 注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库), + 主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表 + ``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用 + :meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表 + ``template_clip_configs``,不依赖新模板主表、也不靠异常降级。 + """ # 确保模板存在 self.get_template_or_raise(template_id) return self._clip_config_repo.list_by_template( @@ -227,6 +245,52 @@ class EditTemplateService: limit=limit, ) + def list_clip_configs_for_editor( + self, + template_id: str, + user_id: str, + *, + clip_type: Optional[ClipType] = None, + skip: int = 0, + limit: int = 100, + ) -> List[TemplateClipConfig]: + """编辑器读取模板片段配置的单一数据源入口. + + 片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。 + 模板主表按双表现状显式判定,不使用 try/except 控制流: + + 1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读; + 2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读; + 3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。 + + Args: + template_id: 模板 ID + user_id: 当前登录用户 ID(用于旧表模板归属校验) + + Raises: + TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。 + """ + # 1) 用户自建模板(旧表 templates,归属 user_id) + if self._clip_config_repo.template_owned_by(template_id, user_id): + return self._clip_config_repo.list_by_template( + template_id, + clip_type=clip_type, + skip=skip, + limit=limit, + ) + + # 2) 全局模板(新表 edit_templates,无 user_id,全局可读) + if self._template_repo.get(template_id) is not None: + return self._clip_config_repo.list_by_template( + template_id, + clip_type=clip_type, + skip=skip, + limit=limit, + ) + + # 3) 两表都没有:不存在 / 已删除 / 无权限 + raise TemplateNotFoundError(template_id) + def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]: """获取片段配置详情""" return self._clip_config_repo.get(config_id) diff --git a/apps/api/app/services/generation_common.py b/apps/api/app/services/generation_common.py new file mode 100644 index 000000000..fd39fbe6b --- /dev/null +++ b/apps/api/app/services/generation_common.py @@ -0,0 +1,175 @@ +"""智能剪辑公共服务辅助函数(从 route 层下沉)。 + +集中管理: +- query_voice_durations:批量查询配音素材时长 +- writeback_edit_plan_config:任务入队后回写 EditPlan.config +- collect_plan_segments:分页读取 plan clips 构建素材区间表(变体避让用) +- resolve_latest_plan_by_template:按 template_id + user_id 查最新 EditPlan + +设计原则: +- 无副作用的纯查询 / 幂等写回;失败一律不阻断主流程(记日志 + 返回安全默认值) +- 不依赖 FastAPI / HTTPException,便于 service 层和 worker 复用 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]: + """批量查询配音素材时长(秒),#1749 配音时长分配用。 + + 逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。 + + #1855 P0修复:不再对 voice_ids 去重,保持与调用方传入顺序/长度一致, + 允许同配音id多次出现时返回相同时长(支持"同配音N变体"的时长对齐)。 + """ + raw_ids = list(voice_ids or []) + if not raw_ids: + return [] + unique_ids: list[str] = [] + _seen: set[str] = set() + for v in raw_ids: + if v and v not in _seen: + _seen.add(v) + unique_ids.append(v) + if not unique_ids: + return [0.0 for _ in raw_ids] + try: + from packages.adapters.sqlalchemy_impl.models import AssetModel + + rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(unique_ids)).all() + dur_map: dict[str, float] = {} + for row in rows: + try: + dur_map[row[0]] = float(row[1] or 0.0) + except (TypeError, ValueError): + dur_map[row[0]] = 0.0 + return [dur_map.get(v, 0.0) if v else 0.0 for v in raw_ids] + except Exception: + logger.warning("[generation_common] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True) + return [0.0 for _ in raw_ids] + + +def writeback_edit_plan_config( + plan_id: str, + task_id: str, + title_config: dict | None, + db: Session, +) -> None: + """任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。 + + 用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。 + 失败只记日志,不影响任务创建。 + """ + if not plan_id: + return + try: + from packages.adapters.sqlalchemy_impl.models import EditPlanModel + + plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first() + if plan_model is None: + logger.warning("[generation_common] 回写plan.config失败: plan不存在 plan_id=%s", plan_id) + return + + current_config = plan_model.config if isinstance(plan_model.config, dict) else {} + merged = dict(current_config) + merged["generation_task_id"] = task_id + + if title_config: + old_title_config = merged.get("title_config", {}) or {} + old_title_text = (old_title_config.get("text") or "").strip() + new_title_text = (title_config.get("text") or "").strip() + if old_title_text != new_title_text: + if "cover" in merged: + del merged["cover"] + logger.info( + "[generation_common] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s", + plan_id, + old_title_text, + new_title_text, + ) + merged["title_config"] = title_config + + plan_model.config = merged + db.commit() + logger.info( + "[generation_common] 回写plan.config成功: plan_id=%s task_id=%s keys=%s", + plan_id, + task_id, + list(merged.keys()), + ) + except Exception as e: + logger.warning( + "[generation_common] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s", + plan_id, + e, + exc_info=True, + ) + try: + db.rollback() + except Exception: + pass + + +def collect_plan_segments( + plan_id: str, + clip_repo: Any, + *, + page_size: int = 500, +) -> dict[str, list[tuple[float, float]]]: + """分页读取 plan 所有 clips,构建 {asset_id: [(start, end), ...]} 素材区间表。 + + 用于 #1855 P0 批次内素材区间避让(变体间素材片段重叠控制)。 + """ + segs: dict[str, list[tuple[float, float]]] = {} + sk, pg = 0, page_size + while True: + batch = clip_repo.list_by_plan(plan_id, skip=sk, limit=pg) + if not batch: + break + for c in batch: + if c.asset_id and float(c.duration or 0) > 0: + st = float(c.start_time or 0.0) + segs.setdefault(c.asset_id, []).append((st, st + float(c.duration))) + if len(batch) < pg: + break + sk += pg + return segs + + +def resolve_latest_plan_by_template( + db: Session, + *, + template_id: str, + user_id: str, +) -> Optional[str]: + """按 template_id + user_id 查找最新的 EditPlan.id(模板兜底用)。找不到返回 None。""" + if not (template_id or "").strip(): + return None + try: + from packages.adapters.sqlalchemy_impl.models import EditPlanModel + + latest = ( + db.query(EditPlanModel) + .filter( + EditPlanModel.template_id == template_id.strip(), + EditPlanModel.created_by_user_id == user_id, + ) + .order_by(EditPlanModel.created_at.desc()) + .first() + ) + return latest.id if latest else None + except Exception: + logger.warning( + "[generation_common] 按template查找最新plan失败: template=%s user=%s", + template_id, + user_id, + exc_info=True, + ) + return None diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py new file mode 100644 index 000000000..a5f3fb12c --- /dev/null +++ b/apps/api/app/services/lipsync_service.py @@ -0,0 +1,581 @@ +"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整, #1845 配音前置. + +职责: +- 创建/查询对口型任务 +- 三输入模式: + 1. TTS 直生(voice_id + script_text)→ 走 Celery 异步(降级路径) + 2. 直接音频(audio_url,前端未传 timings)→ 同步下载 + 算 timings + 提交 MediaKit + 3. 预合成音频(audio_url + sentence_timings,#1845 新主路径)→ 同步 ffprobe 校验时长 + + 写入前端传来的 timings → 直接提交 MediaKit(~2-3s) +- 调用 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, + STATUS_FAILED, + STATUS_RUNNING, + MediaKitClient, + MediaKitError, + get_mediakit_client, +) + +# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径) +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.domain.sentence_timings import ( + compute_sentence_timings, + probe_audio_duration, +) +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_TTL_SECONDS = 7 * 24 * 3600 + + +class LipsyncService: + """对口型任务 Service.""" + + def __init__( + self, + db: Session, + client: Optional[MediaKitClient] = None, + cosyvoice_service=None, + voice_clone_repo=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 + + def _submit_audio_direct( + self, + *, + job: LipsyncJobModel, + supplied_timings: Optional[list] = None, + supplied_duration: Optional[float] = None, + ) -> None: + """音频直传模式(包含 #1845 预合成路径):同步下载 → ffprobe → timings → 提交 MediaKit. + + 直接在 HTTP 请求内完成,不走 Celery。job.status 成功后置为 submitted。 + 失败时把 job 标成 failed 并 commit,然后抛 MediaKitError。 + + Args: + job: 已 commit 的 LipsyncJobModel(audio_url / video_url 已写入) + supplied_timings: 前端传来的预合成 timings(可选,可信时直接用) + supplied_duration: 前端传来的预合成时长(可选,用于优先避免重复探测) + """ + # 1. 下载音频 + audio_data: bytes | None = None + try: + audio_data = safe_download_bytes( + job.audio_url, + purpose="lipsync_direct_audio", + allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES, + timeout=60.0, + ) + logger.info( + "[lipsync] 直传音频下载完成: job_id=%s size=%d", + job.id, + len(audio_data) if audio_data else 0, + ) + except Exception as exc: + logger.warning("[lipsync] 直传音频下载失败,跳过 timings 计算: job_id=%s err=%s", job.id, exc) + + # 2. ffprobe 探测时长(优先用前端传入的预合成时长,但以 ffprobe 为准做兜底校验) + audio_duration = 0.0 + if audio_data: + audio_duration = probe_audio_duration(audio_data) + if audio_duration <= 0 and supplied_duration and supplied_duration > 0: + audio_duration = supplied_duration + logger.info( + "[lipsync] ffprobe 失败,使用前端传入的预合成时长: job_id=%s duration=%.2f", job.id, audio_duration + ) + + # 3. 句子时间戳:优先用前端预合成传入的 timings(后端预合成接口已经算过,可信); + # 否则若音频下载成功则重算;否则不设置(不阻塞主流程) + timings: Optional[list] = None + if supplied_timings: + timings = supplied_timings + logger.info("[lipsync] 使用前端预合成句子时间戳: job_id=%s sentences=%d", job.id, len(timings)) + elif audio_data and audio_duration > 0 and job.script_text: + try: + timings = compute_sentence_timings(audio_data, job.script_text, audio_duration) + logger.info( + "[lipsync] 后端重算句子时间戳: job_id=%s sentences=%d duration=%.2f", + job.id, + len(timings) if timings else 0, + audio_duration, + ) + except Exception as exc: + logger.warning("[lipsync] 句子时间戳计算失败(不阻塞): job_id=%s err=%s", job.id, exc) + + if timings: + job.sentence_timings = timings + + # 4. 签名 URL 并提交 MediaKit + video_url = self._sign_media_url(job.video_url) + signed_audio_url = self._sign_media_url(job.audio_url) + job.audio_url = signed_audio_url + + try: + result = self.client.submit_lipsync( + video_url=video_url, + audio_url=signed_audio_url, + enable_video_loop=job.enable_video_loop, + client_token=job.id, + ) + job.mediakit_task_id = result["task_id"] + job.status = "submitted" + job.submitted_at = datetime.now(timezone.utc) + self.db.commit() + logger.info( + "[lipsync] 直传音频已提交 MediaKit: job_id=%s task_id=%s", + job.id, + result["task_id"], + ) + except MediaKitError as exc: + job.status = "failed" + job.error_message = str(exc) + job.error_code = exc.code + logger.error("[lipsync] 直传音频提交 MediaKit 失败: job_id=%s err=%s", job.id, exc) + self.db.commit() + raise + + # ── 创建任务 ────────────────────────────────────────────────────────── + + def create_job( + self, + *, + user_id: str, + video_url: str, + audio_url: str = "", + audio_duration: Optional[float] = None, + sentence_timings: Optional[list] = None, + voice_id: str = "", + script_text: str = "", + speed: float = 1.0, + emotion: str = "", + enable_video_loop: bool = True, + project_id: str = "", + ) -> LipsyncJobModel: + """创建对口型任务. + + 三种输入模式: + - TTS 直生:voice_id + script_text(audio_url 留空) + → 创建 DB 记录(状态 tts_processing),dispatch Celery 异步任务(降级路径)。 + API 响应 <1s。 + - 直接音频:audio_url 非空 + 无 sentence_timings + → 同步下载音频 + 重算 timings + 提交 MediaKit(几秒完成)。 + - 预合成音频(#1845 新主路径):audio_url 非空 + 传 sentence_timings + → 同步 ffprobe 校验时长 + 写入 timings + 提交 MediaKit(~2-3s)。 + + Raises: + MediaKitError: 参数校验失败或 MediaKit 提交失败 + """ + # 0. 输入校验 + is_pre_synth = bool(audio_url) and bool(sentence_timings) + bool(audio_url) and not is_pre_synth + is_tts_mode = not bool(audio_url) + + if is_tts_mode: + 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) + elif is_pre_synth: + # 预合成模式:script_text 可空(因为 timings 已自带句子文本),但仍建议传 + if not isinstance(sentence_timings, list) or len(sentence_timings) == 0: + raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput") + + # 1. 创建数据库记录 + job_id = str(uuid.uuid4()) + job = LipsyncJobModel( + id=job_id, + user_id=user_id, + project_id=project_id, + video_url=video_url, + audio_url=audio_url, + enable_video_loop=enable_video_loop, + voice_id=voice_id or "", + script_text=script_text or "", + speed=speed, + emotion=normalize_emotion(emotion) if is_tts_mode else (emotion or ""), + # 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing + status="tts_processing" if is_tts_mode else "pending", + ) + self.db.add(job) + self.db.flush() + + # ⚠️ 必须先 commit 再发 Celery 任务 / 后续同步操作,避免事务竞态 + self.db.commit() + self.db.refresh(job) + + 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: + 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) + self.db.commit() + else: + # 2b/2c. 直接音频 / 预合成音频:同步路径 + self._submit_audio_direct( + job=job, + supplied_timings=sentence_timings, + supplied_duration=audio_duration, + ) + self.db.refresh(job) + + return job + + # ── TTS 预合成(#1845 步骤1「生成配音」同步接口使用) ────────────────── + + def preview_tts( + self, + *, + user_id: str, + voice_id: str, + script_text: str, + speed: float = 1.0, + emotion: str = "natural", + ) -> dict: + """同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算. + + 不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效期)。 + 耗时约 2-3 秒,由前端在步骤1点「生成配音」时同步等待。 + + Returns: + {"audio_url": str, "duration": float, "sentence_timings": list[dict]} + + Raises: + MediaKitError: TTS 合成失败 / 下载失败 / ffprobe 失败 + """ + # 1. 音色解析(校验克隆音色归属) + actual_voice_id = self._resolve_voice_id(voice_id, user_id) + cosyvoice = self._get_cosyvoice() + + # 2. TTS 合成(同步,~2-3s) + 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") + + # 3. 下载音频到内存(用于 ffprobe + 静音检测) + try: + audio_data = safe_download_bytes( + temp_url, + purpose="tts_preview_audio", + allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES, + timeout=60.0, + ) + except Exception as exc: + logger.warning("[tts-preview] TTS 音频下载失败,仍返回 audio_url: user_id=%s err=%s", user_id, exc) + return { + "audio_url": temp_url, + "duration": 0.0, + "sentence_timings": [], + } + + # 4. ffprobe 时长 + duration = probe_audio_duration(audio_data) + if duration <= 0: + logger.warning("[tts-preview] ffprobe 未返回有效时长,timings 留空: user_id=%s", user_id) + return { + "audio_url": temp_url, + "duration": 0.0, + "sentence_timings": [], + } + + # 5. 句子时间戳 + timings = compute_sentence_timings(audio_data, script_text, duration) + + logger.info( + "[tts-preview] TTS 预合成完成: user_id=%s duration=%.2f sentences=%d", + user_id, + duration, + len(timings), + ) + return { + "audio_url": temp_url, + "duration": round(duration, 2), + "sentence_timings": timings, + } + + # ── 查询任务 ────────────────────────────────────────────────────────── + + def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]: + """获取任务详情(用户隔离).""" + return ( + self.db.query(LipsyncJobModel) + .filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id) + .first() + ) + + def list_jobs( + self, + *, + user_id: str, + project_id: str = "", + status: str = "", + offset: int = 0, + limit: int = 20, + ) -> tuple[list[LipsyncJobModel], int]: + """获取任务列表(分页 + 用户隔离).""" + query = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.user_id == user_id) + if project_id: + query = query.filter(LipsyncJobModel.project_id == project_id) + if status: + query = query.filter(LipsyncJobModel.status == status) + + total = query.count() + items = query.order_by(LipsyncJobModel.created_at.desc()).offset(offset).limit(limit).all() + return items, total + + # ── 更新任务状态(轮询) ────────────────────────────────────────────── + + def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]: + """从 MediaKit 拉取最新状态并更新本地记录.""" + job = self.get_job(job_id, user_id) + if job is None: + return None + + # 终态不需要再轮询 + if job.status in (STATUS_COMPLETED, "failed"): + return job + + # 未提交的任务不轮询 + if not job.mediakit_task_id: + return job + + try: + status_data = self.client.get_task_status(job.mediakit_task_id) + except MediaKitError as exc: + logger.error("轮询对口型任务状态失败 [%s]: %s", job_id, exc) + return job + + mk_status = status_data.get("status", STATUS_RUNNING) + logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status) + + if mk_status == STATUS_COMPLETED: + result = status_data.get("result", {}) + job.status = STATUS_COMPLETED + temp_url = result.get("video_url", "") + job.output_video_url = temp_url + job.output_duration = result.get("duration", 0.0) + job.completed_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(timezone.utc) + self.db.commit() + # 异步转存自家 OSS + try: + from app.tasks.lipsync_tts import persist_output_video_task + + persist_output_video_task.apply_async(args=(job_id, user_id, temp_url)) + except Exception as exc: + logger.warning( + "提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s", + job_id, + exc, + ) + self.db.refresh(job) + return job + 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: + if isinstance(mk_status, str) and mk_status: + job.status = mk_status + 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 重签长有效期预签名.""" + 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 # 外部临时链接原样透传 + signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS) + return signed or url + except Exception as exc: + 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 状态可取消).""" + job = self.get_job(job_id, user_id) + if job is None: + return None + + if job.status in ("pending", "tts_processing", "submitted"): + job.status = "cancelled" + job.updated_at = datetime.now(timezone.utc) + self.db.commit() + self.db.refresh(job) + + return job diff --git a/apps/api/app/services/mediakit_client.py b/apps/api/app/services/mediakit_client.py new file mode 100644 index 000000000..9999ece9a --- /dev/null +++ b/apps/api/app/services/mediakit_client.py @@ -0,0 +1,242 @@ +"""MediaKit 客户端 — 封装火山引擎 AI MediaKit 对口型 API. + +接口文档:https://docs.volcengine.com/docs/6448/2656064 + +异步任务流程: +1. POST /api/v1/tools/lip-sync 提交对口型任务 → 返回 task_id +2. GET /api/v1/tasks/{task_id} 轮询任务状态 → running/completed/failed +3. completed 时 result.video_url 为口型对齐视频(临时链接 24h 有效) + +设计原则: +- API Key 从配置读取(settings.mediakit_api_key) +- 未配置 API Key 时所有方法返回降级响应,不阻塞主流程 +- HTTP 超时/网络异常统一包装为 MediaKitError +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +import httpx + +from packages.config import get_api_settings + +logger = logging.getLogger(__name__) + +# ── 任务状态常量 ────────────────────────────────────────────────────────── +STATUS_RUNNING = "running" +STATUS_COMPLETED = "completed" +STATUS_FAILED = "failed" + + +class MediaKitError(Exception): + """MediaKit API 调用异常.""" + + def __init__(self, message: str, code: str = "", request_id: str = ""): + self.code = code + self.request_id = request_id + super().__init__(message) + + +class MediaKitClient: + """火山引擎 AI MediaKit 对口型 API 客户端. + + 用法: + client = get_mediakit_client() + result = client.submit_lipsync(video_url="...", audio_url="...") + task_id = result["task_id"] + + status = client.get_task_status(task_id) + # {"status": "completed", "result": {"video_url": "...", "duration": 60.5}} + """ + + def __init__(self) -> None: + settings = get_api_settings() + self._api_key = settings.mediakit_api_key + self._base_url = settings.mediakit_base_url.rstrip("/") + self._timeout = settings.mediakit_timeout + + @property + def is_available(self) -> bool: + """是否已配置 API Key(未配置时自动降级).""" + return bool(self._api_key) + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + # ── 提交对口型任务 ──────────────────────────────────────────────────── + + def submit_lipsync( + self, + *, + video_url: str, + audio_url: str, + enable_video_loop: bool = True, + callback_url: Optional[str] = None, + callback_args: Optional[str] = None, + client_token: Optional[str] = None, + ) -> dict[str, Any]: + """提交视频口型对齐任务. + + Args: + video_url: 人物视频 URL(MP4,≤30min,单人真人) + audio_url: 驱动音频 URL(mp3/aac/wav/m4a/flac) + enable_video_loop: 音频长于视频时是否循环画面 + callback_url: 任务完成回调 URL + callback_args: 回调时原样返回的自定义参数 + client_token: 幂等控制 token + + Returns: + {"success": True, "task_id": "...", "request_id": "..."} + + Raises: + MediaKitError: API 调用失败 + """ + if not self.is_available: + raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured") + + payload: dict[str, Any] = { + "video_url": video_url, + "audio_url": audio_url, + } + payload["enable_video_loop"] = bool(enable_video_loop) + if callback_url: + payload["callback_url"] = callback_url + if callback_args: + payload["callback_args"] = callback_args[:512] # API 限制 512 字节 + if client_token: + payload["client_token"] = client_token[:64] # API 限制 64 字符 + + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.post( + f"{self._base_url}/tools/lip-sync", + headers=self._headers(), + json=payload, + ) + resp.raise_for_status() + data = resp.json() + except httpx.TimeoutException as exc: + raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc + except httpx.HTTPStatusError as exc: + body = exc.response.text[:500] + raise MediaKitError( + f"MediaKit API HTTP {exc.response.status_code}: {body}", + code="HttpError", + ) from exc + except httpx.RequestError as exc: + raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc + except Exception as exc: + raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc + + if not data.get("success"): + error = data.get("error", {}) + raise MediaKitError( + error.get("message", "提交任务失败"), + code=error.get("code", "SubmitFailed"), + request_id=data.get("request_id", ""), + ) + + return { + "success": True, + "task_id": data["task_id"], + "request_id": data.get("request_id", ""), + } + + # ── 查询任务状态 ────────────────────────────────────────────────────── + + def get_task_status(self, task_id: str) -> dict[str, Any]: + """查询异步任务状态和结果. + + Args: + task_id: 提交任务时返回的任务 ID + + Returns: + { + "success": True, + "task_id": "...", + "status": "running" | "completed" | "failed", + "result": {"video_url": "...", "duration": 60.5} | None, + "error": {"code": "...", "message": "..."} | None, + "created_at": 1777291767, + "finished_at": 1777291851 | None, + "expires_at": 1777464650 | None, + } + + Raises: + MediaKitError: API 调用失败 + """ + if not self.is_available: + raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured") + + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.get( + f"{self._base_url}/tasks/{task_id}", + headers=self._headers(), + ) + resp.raise_for_status() + data = resp.json() + except httpx.TimeoutException as exc: + raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc + except httpx.HTTPStatusError as exc: + body = exc.response.text[:500] + raise MediaKitError( + f"MediaKit API HTTP {exc.response.status_code}: {body}", + code="HttpError", + ) from exc + except httpx.RequestError as exc: + raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc + except Exception as exc: + raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc + + if not data.get("success"): + error = data.get("error", {}) + raise MediaKitError( + error.get("message", "查询任务失败"), + code=error.get("code", "QueryFailed"), + request_id=data.get("request_id", ""), + ) + + result: dict[str, Any] = { + "success": True, + "task_id": data.get("task_id", task_id), + "status": data.get("status", STATUS_RUNNING), + "result": data.get("result"), + "created_at": data.get("created_at"), + "finished_at": data.get("finished_at"), + "expires_at": data.get("expires_at"), + } + + # 失败时提取错误信息 + if data.get("status") == STATUS_FAILED: + error_obj = data.get("error", {}) + result["error"] = { + "code": error_obj.get("code", "TaskFailed"), + "message": error_obj.get("message", "任务执行失败"), + } + + return result + + +# ── 单例 ────────────────────────────────────────────────────────────────── + +_client: Optional[MediaKitClient] = None + + +def get_mediakit_client() -> MediaKitClient: + """获取 MediaKit 客户端单例.""" + global _client + if _client is None: + _client = MediaKitClient() + return _client + + +def reset_mediakit_client() -> None: + """重置客户端(测试用).""" + global _client + _client = None diff --git a/apps/api/app/services/script_service.py b/apps/api/app/services/script_service.py new file mode 100644 index 000000000..113281d8d --- /dev/null +++ b/apps/api/app/services/script_service.py @@ -0,0 +1,109 @@ +"""ScriptService — Issue #1795 口播文案库 CRUD. + +纯 Service 层封装,routes 直接调用。 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import ScriptModel + + +class ScriptNotFoundError(Exception): + """文案不存在或不属于当前用户.""" + + +class ScriptService: + """口播文案 CRUD.""" + + def __init__(self, db: Session) -> None: + self.db = db + + # ── list ────────────────────────────────────────────────────────────── + + def list_scripts( + self, + user_id: str, + skip: int = 0, + limit: int = 50, + tag: Optional[str] = None, + ) -> tuple[list[ScriptModel], int]: + """返回 (items, total).""" + q = self.db.query(ScriptModel).filter(ScriptModel.user_id == user_id) + if tag: + # JSON 数组包含查询 + q = q.filter(ScriptModel.tags.contains([tag])) + total = q.count() + items = q.order_by(ScriptModel.created_at.desc()).offset(skip).limit(limit).all() + return items, total + + # ── create ──────────────────────────────────────────────────────────── + + def create_script( + self, + user_id: str, + title: str, + content: str = "", + segments: list | None = None, + tags: list | None = None, + ) -> ScriptModel: + script = ScriptModel( + id=str(uuid.uuid4()), + user_id=user_id, + title=title, + content=content, + segments=segments if segments is not None else [], + tags=tags if tags is not None else [], + ) + self.db.add(script) + self.db.commit() + self.db.refresh(script) + return script + + # ── get ─────────────────────────────────────────────────────────────── + + def get_script(self, script_id: str, user_id: str) -> ScriptModel: + script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first() + if script is None: + raise ScriptNotFoundError(f"Script {script_id} not found") + return script + + # ── update ──────────────────────────────────────────────────────────── + + def update_script( + self, + script_id: str, + user_id: str, + title: Optional[str] = None, + content: Optional[str] = None, + segments: Optional[list] = None, + tags: Optional[list] = None, + ) -> ScriptModel: + script = self.get_script(script_id, user_id) + if title is not None: + script.title = title + if content is not None: + script.content = content + if segments is not None: + script.segments = segments + if tags is not None: + script.tags = tags + script.updated_at = datetime.now(timezone.utc) + self.db.commit() + self.db.refresh(script) + return script + + # ── delete ──────────────────────────────────────────────────────────── + + def delete_script(self, script_id: str, user_id: str) -> bool: + script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first() + if script is None: + return False + self.db.delete(script) + self.db.commit() + return True diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index 7ff2e7559..06b88f2a2 100755 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -39,6 +39,9 @@ from packages.domain.video_filter_builder import ( ) from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex +from packages.domain.video_filter_builder import ( + build_title_drawtext_filter, +) from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func from packages.domain.video_filter_builder import chain_filters as _chain_filters_func from packages.domain.video_filter_builder import has_audio as _has_audio_func @@ -248,6 +251,27 @@ class VideoComposeService: transitions=[c.transition_effect for c in ready_clips], ) + # ── #1789 标题 drawtext 滤镜叠加 ── + # 从 plan.config 读取 title_config,生成 drawtext 滤镜链入 filter_complex + title_cfg = (plan.config or {}).get("title", {}) or {} + if not isinstance(title_cfg, dict): + title_cfg = {} + # 同时兼容 plan.config["title_config"](API 回写路径) + if not title_cfg.get("text") and not title_cfg.get("content"): + title_cfg_alt = (plan.config or {}).get("title_config", {}) or {} + if isinstance(title_cfg_alt, dict) and (title_cfg_alt.get("text") or title_cfg_alt.get("content")): + title_cfg = title_cfg_alt + drawtext_filter = build_title_drawtext_filter(title_cfg, output_width, output_height) + if drawtext_filter: + # 将最终输出标签从 [outv] 改为 [composed],再链入 drawtext → [outv] + filter_complex = filter_complex.replace("[outv]", "[composed]") + filter_complex += f";[composed]{drawtext_filter}[outv]" + logger.info( + "[#1789] 标题 drawtext 滤镜已注入: plan_id=%s text=%s", + plan_id, + (title_cfg.get("text") or title_cfg.get("content") or "")[:30], + ) + # 构建完整命令 command: list[str] = ["ffmpeg", "-y"] diff --git a/apps/api/app/tasks/__init__.py b/apps/api/app/tasks/__init__.py new file mode 100644 index 000000000..64cd61f31 --- /dev/null +++ b/apps/api/app/tasks/__init__.py @@ -0,0 +1 @@ +"""Celery 异步任务模块.""" diff --git a/apps/api/app/tasks/ai_avatar_render.py b/apps/api/app/tasks/ai_avatar_render.py new file mode 100644 index 000000000..9b3cb9d90 --- /dev/null +++ b/apps/api/app/tasks/ai_avatar_render.py @@ -0,0 +1,48 @@ +"""AI数字人渲染 Celery 异步任务 — #1798.""" + +from __future__ import annotations + +import logging + +from app.core.celery_app import celery_app +from app.dependencies import get_db_session + +logger = logging.getLogger(__name__) + + +@celery_app.task(bind=True, name="ai_avatar_render.execute", max_retries=2) +def execute_ai_avatar_render(self, job_id: str) -> dict: + """执行 AI 数字人渲染管线. + + 进度更新: + - 0%: 任务开始 + - 20%: 下载对口型视频完成 + - 40%: 滤镜链构建完成 + - 80%: FFmpeg 渲染完成 + - 95%: 上传 OSS 完成 + - 100%: 任务完成 + """ + logger.info("开始执行渲染任务: %s", job_id) + self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id}) + + try: + # 获取数据库 session + db_gen = get_db_session() + db = next(db_gen) + try: + from app.services.ai_avatar_render_service import AiAvatarRenderService + + service = AiAvatarRenderService(db) + service.execute_render(job_id) + finally: + try: + next(db_gen) + except StopIteration: + pass + + return {"status": "completed", "job_id": job_id} + + except Exception as exc: + logger.exception("渲染任务执行异常 [%s]: %s", job_id, exc) + self.update_state(state="FAILED", meta={"progress": 0, "error": str(exc)}) + raise diff --git a/apps/api/app/tasks/lipsync_tts.py b/apps/api/app/tasks/lipsync_tts.py new file mode 100644 index 000000000..2f75e9b41 --- /dev/null +++ b/apps/api/app/tasks/lipsync_tts.py @@ -0,0 +1,355 @@ +"""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 仍可正常调用。 + +#1845:句子时间戳计算已提取至 packages/domain/sentence_timings.py,本模块保留 +_ 开头别名兼容历史导入,但 _compute_sentence_timings/_split_script_into_sentences/ +_estimate_sentence_timings_by_chars 等内部函数已复用共享实现,避免重复代码。 +""" + +import io +import logging +from datetime import datetime, timezone +from urllib.parse import urlparse + +from celery import shared_task + +# 复用共享的句子时间戳工具(#1845 配音前置) +from packages.domain.sentence_timings import compute_sentence_timings as _compute_sentence_timings +from packages.domain.sentence_timings import ( + probe_audio_duration, +) + +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=5, # 事务竞态重试3次(job not found)+ TTS偶发错误2次 + default_retry_delay=30, + autoretry_for=(OSError, ConnectionError), # 网络/连接错误自动重试 + retry_backoff=True, + retry_backoff_max=30, + soft_time_limit=180, + time_limit=200, +) +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 请求。保留作为降级路径 + (预合成失败 / 旧版前端未传 audio_url 时走此路径)。 + """ + 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: + # 事务竞态防御:API 在 commit 前投递了任务,worker 消费时事务尚未提交。 + retries = getattr(self.request, "retries", 0) + max_retries = 3 + if retries < max_retries: + backoff = (2**retries) + (retries * 1) # 1s, 3s, 7s + logger.warning( + "[lipsync_tts] Job not found yet (retry %d/%d, backoff %ds): job_id=%s", + retries + 1, + max_retries, + backoff, + job_id, + ) + self.db.close() + raise self.retry(countdown=backoff, max_retries=max_retries) + logger.error( + "[lipsync_tts] Job not found after %d retries, giving up: job_id=%s", + max_retries, + job_id, + ) + return + + # 已取消的任务不再处理 + if job.status == "cancelled": + logger.info("[lipsync_tts] Job already cancelled, skipping: job_id=%s", job_id) + return + + # 1. TTS 合成 + logger.info( + "[lipsync_tts] 开始 TTS 合成: job_id=%s voice_id=%s text_len=%d speed=%.2f", + job_id, + voice_id, + len(script_text), + speed, + ) + 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. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL) + audio_data: bytes | None = None + 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/mp4", + "audio/x-m4a", + }, + timeout=60.0, + ) + logger.info( + "[lipsync_tts] TTS 音频已下载到内存: job_id=%s size=%d", + job_id, + len(audio_data) if audio_data else 0, + ) + except Exception as exc: + logger.warning( + "[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 URL 提交: job_id=%s err=%s", + job_id, + exc, + ) + # TTS 音频使用 CosyVoice 临时 URL,跳过自家 OSS 转存(加速,步骤⑥) + job.audio_url = temp_url + logger.info("[lipsync_tts] TTS 音频使用 CosyVoice 临时 URL(跳过 OSS 转存): job_id=%s", job_id) + + db.commit() + + # 2.5 计算精确句子时间戳(基于 TTS 音频静音检测)—— 复用共享工具 + try: + if not audio_data: + logger.warning("[lipsync_tts] 无音频数据,跳过句子时间戳计算: job_id=%s", job_id) + else: + _audio_duration = probe_audio_duration(audio_data) + logger.info( + "[lipsync_tts] 音频时长探测: job_id=%s duration=%.2f", + job_id, + _audio_duration, + ) + + if _audio_duration > 0: + _timings = _compute_sentence_timings(audio_data, script_text, _audio_duration) + if _timings: + job.sentence_timings = _timings + logger.info( + "[lipsync_tts] 句子时间戳已计算: job_id=%s sentences=%d duration=%.1f", + job_id, + len(_timings), + _audio_duration, + ) + else: + logger.warning("[lipsync_tts] 句子时间戳计算返回空结果: job_id=%s", job_id) + else: + logger.warning( + "[lipsync_tts] ffprobe 未获取到有效时长,跳过句子时间戳: job_id=%s", + job_id, + ) + db.commit() + except Exception as _st_err: + logger.warning( + "[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True + ) + + # 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() + + 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( + name="lipsync_tts.persist_output_video", + max_retries=2, + default_retry_delay=30, +) +def persist_output_video_task(job_id: str, user_id: str, temp_url: str): + """异步转存对口型输出视频到自家 OSS(步骤⑦ — 将同步阻塞挪到后台,加速前端响应).""" + + try: + from worker_app.db import SessionLocal # type: ignore + except Exception: # noqa: BLE001 + from app.db import SessionLocal # type: ignore + + from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel + from packages.shared.storage import get_shared_storage_service + + db = 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.persist] Job not found: job_id=%s", job_id) + return + + if not temp_url: + logger.warning("[lipsync_tts.persist] temp_url 为空,跳过转存: job_id=%s", job_id) + 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") + final_url = _sign_media_url(permanent_url) if permanent_url else temp_url + job.output_video_url = final_url + job.updated_at = datetime.now(timezone.utc) + db.commit() + logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key) + except Exception as exc: + logger.warning( + "[lipsync_tts.persist] 输出视频转存失败,保留临时 URL: job_id=%s err=%s", + job_id, + exc, + ) + except Exception: + logger.exception("[lipsync_tts.persist] 未预期异常: job_id=%s", job_id) + finally: + db.close() diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 1e215d69d..c282269f3 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -185,6 +185,13 @@ test.describe("Core generation flow", () => { await expect(page.locator(".xx-choice-item.selected")).toBeVisible() await page.getByRole("button", { name: "下一步" }).click() + // Step1 下一步弹出数量选择弹窗(Issue #1677 固定6步:模板→素材→配音→标题→确认生成→封面) + // 单视频流程:默认 1 个,点击「生成 1 个视频」进入步骤2 + await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({ + timeout: 10_000, + }) + await page.getByRole("button", { name: "生成 1 个视频" }).click() + // Step 2: select material (card grid UI) await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible() const librarySelect = page.locator("select").first() @@ -255,32 +262,19 @@ test.describe("Core generation flow", () => { expect(genData.items.length).toBeGreaterThan(0) expect(genData.items[0].id).toBeTruthy() - // Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度 - await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({ - timeout: 15_000, + // 单视频(N=1):点击「确认生成视频」后跳 Step 5「确认生成」,展示实时渲染进度 + await expect(page.getByRole("heading", { name: "🎬 确认生成" })).toBeVisible({ + timeout: 30_000, }) - // Step 5 → Step 6:等待渲染终态 - // - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面 - // - 失败:出现「生成失败」,停在确认生成页也算向导流程走通 - // - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通 - const renderSucceeded = await page - .getByText("视频生成完成", { exact: false }) - .waitFor({ timeout: 180_000 }) - .then(() => true) - .catch(() => false) - if (renderSucceeded) { - // 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转) - await page.getByRole("button", { name: "下一步" }).click() - // Step 6: 封面(最后一步,无主按钮),仅验证页面渲染 - await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ - timeout: 15_000, - }) - } else { - // 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通 - await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible() - console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)") - } + // 等待渲染完成:进度卡变为「视频生成完成」(最长等待 3 分钟) + await expect(page.getByText("视频生成完成")).toBeVisible({ timeout: 180_000 }) + + // 全部完成后「下一步:选择封面」解锁,点击进入 Step 6 + await page.getByRole("button", { name: /下一步:选择封面/ }).click() + await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ + timeout: 30_000, + }) } else { console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`) // 创建失败时停留在标题页并展示错误提示 diff --git a/apps/web/public/vite.svg b/apps/web/public/vite.svg new file mode 100644 index 000000000..7f7809d44 --- /dev/null +++ b/apps/web/public/vite.svg @@ -0,0 +1,4 @@ + + + 🦐 + diff --git a/apps/web/src/api/assets/libraries.ts b/apps/web/src/api/assets/libraries.ts index 4d3327116..19f04d5b5 100644 --- a/apps/web/src/api/assets/libraries.ts +++ b/apps/web/src/api/assets/libraries.ts @@ -5,10 +5,22 @@ import apiClient from "../client" import { getOrCreateDefaultProject } from "../projects" import type { AssetLibraryItem } from "./types" -/** 获取当前用户的所有素材库 */ -export const getAssetLibraries = async (): Promise => { - const response = await apiClient.get("/asset-libraries") - return response.data.items || [] +/** + * 获取当前用户的素材库 + * + * @param kind 可选,按素材库类型过滤(video/voice/image)。 + * 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的 + * kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库 + * 混进来(#1777:视频选择器只展示视频库)。 + */ +export const getAssetLibraries = async ( + kind?: AssetLibraryItem["kind"], +): Promise => { + const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", { + params: kind ? { kind } : undefined, + }) + const items = response.data.items || [] + return kind ? items.filter((lib) => lib.kind === kind) : items } /** 创建素材库(自动获取或创建默认项目以提供 project_id) */ diff --git a/apps/web/src/api/assets/types.ts b/apps/web/src/api/assets/types.ts index 0efe111ab..77ec261fc 100644 --- a/apps/web/src/api/assets/types.ts +++ b/apps/web/src/api/assets/types.ts @@ -139,6 +139,17 @@ export interface DirectUploadPrepareResult { * 旧后端不返回该字段,前端降级为无预建卡片的原有行为。 */ asset_id?: string + /** + * 后端 file_hash 命中素材库已有相同文件时为 true,前端应跳过 transfer + complete 阶段 + * 直接按「去重命中」处理(不调 transfer、不调 complete、立即刷新素材列表)。 + * 旧后端不返回该字段,前端降级为走老流程。 + */ + duplicated?: boolean + /** + * 与 duplicated 语义一致:true 表示跳过传输,前端据此短路。 + * 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。 + */ + skip_transfer?: boolean } /** 直传完成确认返回 */ diff --git a/apps/web/src/api/assets/upload.ts b/apps/web/src/api/assets/upload.ts index e396b5d77..490b08df7 100644 --- a/apps/web/src/api/assets/upload.ts +++ b/apps/web/src/api/assets/upload.ts @@ -4,6 +4,7 @@ import apiClient from "../client" import { getOrCreateDefaultProject } from "../projects" import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types" +import { computeFileHash, makeClientUploadId } from "./uploadDedup" /** 预签名直传准备 */ export const prepareDirectUpload = async (data: { @@ -12,8 +13,13 @@ export const prepareDirectUpload = async (data: { filename: string content_type: string file_size: number + /** 前端算好的文件内容哈希(SHA-256 hex),打开后端 file_hash 去重闸门 */ + file_hash?: string + /** 前端生成的上传幂等 token,同一次逻辑上传(含重试)保持不变 */ + client_upload_id?: string }): Promise => { - const response = await apiClient.post("/upload/direct/prepare", data) + // prepare 单独放宽到 30s(全局 axios 实例只有 10s,staging 抖动时易超时) + const response = await apiClient.post("/upload/direct/prepare", data, { timeout: 30_000 }) return response.data } @@ -22,8 +28,16 @@ export const completeDirectUpload = async (data: { project_id: string library_id: string storage_key: string + /** 前端算好的文件内容哈希(与 prepare 一致),后端按 hash 幂等去重 */ + file_hash?: string + /** 前端上传幂等 token(与 prepare 一致),同一次上传重发 complete 不重复建记录 */ + client_upload_id?: string + /** 文件字节数;后端同名兜底去重需用它做大小校验,缺失(=0)时同名记录一律不判重 */ + file_size?: number }): Promise => { - const response = await apiClient.post("/upload/direct/complete", data) + // complete 内含 OSS 存在性检查 + 建库 + 派单,放宽到 60s; + // 超时不代表失败(记录可能已建成),调用方禁止超时后盲目重传整个文件 + const response = await apiClient.post("/upload/direct/complete", data, { timeout: 60_000 }) return response.data } @@ -109,8 +123,20 @@ export interface DirectUploadHandle { export const prepareDirectUploadHandle = async (data: { file: File library_id: string + /** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */ + fileHash?: string + /** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */ + clientUploadId?: string }): Promise => { - const project = await getOrCreateDefaultProject() + // 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示, + // 不与 prepare 的签名接口错误混在一起 + let project: Awaited> + try { + project = await getOrCreateDefaultProject() + } catch (err) { + const reason = err instanceof Error ? err.message : "网络异常" + throw new Error(`初始化默认项目失败,无法开始上传:${reason}`) + } const prepared = await prepareDirectUpload({ project_id: project.id, @@ -118,6 +144,8 @@ export const prepareDirectUploadHandle = async (data: { filename: data.file.name, content_type: data.file.type || "application/octet-stream", file_size: data.file.size, + file_hash: data.fileHash, + client_upload_id: data.clientUploadId, }) return { @@ -128,6 +156,10 @@ export const prepareDirectUploadHandle = async (data: { project_id: project.id, library_id: data.library_id, storage_key: prepared.storage_key, + file_hash: data.fileHash, + client_upload_id: data.clientUploadId, + // 透传文件字节数:后端同名兜底去重依赖大小校验,缺省会导致同名新视频被误判重复 + file_size: data.file.size, }), } } @@ -137,8 +169,30 @@ export const uploadAssetDirect = async (data: { file: File library_id: string onProgress?: (percent: number) => void + /** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */ + fileHash?: string + /** 幂等 token;未传时自动生成 */ + clientUploadId?: string }): Promise => { - const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id }) + // 自动补算哈希与幂等 token:确保 file_hash 去重闸门对所有上传链路生效 + const fileHash = data.fileHash ?? (await computeFileHash(data.file)) + const clientUploadId = data.clientUploadId ?? makeClientUploadId() + const handle = await prepareDirectUploadHandle({ + file: data.file, + library_id: data.library_id, + fileHash, + clientUploadId, + }) + // prepare 阶段后端 file_hash 命中素材库已有相同文件:跳过 transfer + complete + if (handle.prepared.skip_transfer || handle.prepared.duplicated) { + return { + storage_key: handle.prepared.storage_key, + ingest_job_id: "", + url: "", + duplicated: true, + asset_id: handle.prepared.asset_id, + } + } await handle.transfer(data.onProgress) return handle.complete() } diff --git a/apps/web/src/api/assets/uploadDedup.ts b/apps/web/src/api/assets/uploadDedup.ts new file mode 100644 index 000000000..f30ad6472 --- /dev/null +++ b/apps/web/src/api/assets/uploadDedup.ts @@ -0,0 +1,143 @@ +/** + * 上传去重 / 幂等工具(Issue #1714) + * + * 背景:同一文件被反复入队、complete 超时后盲目重传,导致后端创建大量重复 + * PROCESSING 素材记录。本模块提供两类纯函数: + * + * 1. 文件指纹: + * - makeFileFingerprint():文件名+大小+lastModified,入队去重用(同步、零开销) + * - computeFileHash():SHA-256 内容哈希(小文件全量、大文件抽样头尾), + * prepare/complete 时发给后端打开 file_hash 去重闸门 + * 2. 队列去重:findDuplicateInQueue() 判断文件是否已在队列中 + * 3. 幂等 token:makeClientUploadId() 生成上传幂等 ID(每次"一次逻辑上传"一个, + * 重试复用同一 ID,重新入队才生成新 ID) + */ + +/** 全量哈希阈值:≤64MB 全量读入计算;超过即走头尾抽样,避免 100~256MB 视频被整文件读进内存卡死页面 */ +export const HASH_FULL_READ_LIMIT = 64 * 1024 * 1024 // 64MB +/** 抽样读取的头尾片段大小(各 16MB) */ +export const HASH_SAMPLE_CHUNK = 16 * 1024 * 1024 + +/** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */ +export type DedupExcludeStatus = "error" | "done" + +/** + * 文件入队指纹:同库 + 文件名 + 大小 + 修改时间。 + * 同一文件(File 对象由 重选或拖拽重复触发时三个字段均一致)稳定复现; + * 不同文件极小概率碰撞时可由后端 file_hash 内容去重兜底。 + */ +export function makeFileFingerprint(file: Pick): string { + return `${file.name}::${file.size}::${file.lastModified}` +} + +/** + * 在现有队列项中查找同一文件的在途记录。 + * 已失败(error)的项允许重试路径复用、已完成(done)的可跳过; + * 处于 preparing/uploading/ingesting 的在途项一律视为重复,禁止重复入队。 + * + * 返回命中的队列项 id(tempId),未命中返回 null。 + */ +export function findDuplicateInQueue( + queue: T[], + fileKey: string, + excludeStatuses: DedupExcludeStatus[] = [], +): T | null { + const exclude = new Set(excludeStatuses) + return queue.find((it) => it.fileKey === fileKey && !exclude.has(it.status)) ?? null +} + +/** 生成上传幂等 token:一次"逻辑上传"一个,重试复用、重新入队换新 */ +export function makeClientUploadId(): string { + const rand = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${Math.random() + .toString(36) + .slice(2, 10)}` + return `up_${Date.now().toString(36)}_${rand.replace(/-/g, "").slice(0, 16)}` +} + +/** 读取 Blob/File 片段为 ArrayBuffer:优先 Blob.arrayBuffer(),老环境回退 FileReader */ +function readAsArrayBuffer(blob: Blob): Promise { + if (typeof blob.arrayBuffer === "function") { + return blob.arrayBuffer() + } + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as ArrayBuffer) + reader.onerror = () => reject(reader.error ?? new Error("FileReader read failed")) + reader.readAsArrayBuffer(blob) + }) +} + +/** + * 把 buffer 复制到当前 JS realm 的 Uint8Array 再哈希。 + * jsdom/测试环境中 Blob.arrayBuffer() 可能返回另一 realm 的 ArrayBuffer, + * Node WebCrypto 的 WebIDL instanceof 校验会拒绝跨 realm 参数。 + */ +async function digestSha256(buffer: ArrayBuffer): Promise { + const subtle = + typeof globalThis !== "undefined" && globalThis.crypto ? globalThis.crypto.subtle : null + if (!subtle) throw new Error("crypto.subtle unavailable") + const local = new Uint8Array(buffer.byteLength) + local.set(new Uint8Array(buffer)) + return subtle.digest("SHA-256", local) +} + +function toHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let hex = "" + for (let i = 0; i < bytes.length; i += 1) { + hex += bytes[i].toString(16).padStart(2, "0") + } + return hex +} + +/** + * 计算文件内容 SHA-256(hex,64 字符,与后端 file_hash 字段长度一致)。 + * - ≤64MB:全量哈希,内容一致必然一致 + * - >64MB:哈希「头部 16MB + 尾部 16MB + 文件大小」,视频素材体积大、 + * 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略, + * 且避免 100~256MB 视频被整文件读进内存导致页面卡死/崩溃 + * + * 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串, + * 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。 + */ +export async function computeFileHash(file: File): Promise { + try { + const subtle = + typeof globalThis !== "undefined" && + globalThis.crypto && + typeof globalThis.crypto.subtle?.digest === "function" + ? globalThis.crypto.subtle + : null + if (!subtle) return "" + + if (file.size <= HASH_FULL_READ_LIMIT) { + const data = await readAsArrayBuffer(file.slice(0, file.size)) + return toHex(await digestSha256(data)) + } + + // 大文件:头 8MB + 尾 8MB + 大小,拼成一段后哈希 + const head = await readAsArrayBuffer(file.slice(0, HASH_SAMPLE_CHUNK)) + const tail = + file.size > HASH_SAMPLE_CHUNK + ? await readAsArrayBuffer(file.slice(Math.max(0, file.size - HASH_SAMPLE_CHUNK), file.size)) + : new ArrayBuffer(0) + const merged = new Uint8Array(head.byteLength + tail.byteLength + 8) + merged.set(new Uint8Array(head), 0) + merged.set(new Uint8Array(tail), head.byteLength) + const sizeView = new DataView(merged.buffer, head.byteLength + tail.byteLength, 8) + // 文件大小以 64 位大端写入(BigInt 最稳;不支持 BigInt64 时手算高低位) + if (typeof sizeView.setBigUint64 === "function") { + sizeView.setBigUint64(0, BigInt(file.size), false) + } else { + sizeView.setUint32(0, Math.floor(file.size / 0x100000000), false) + sizeView.setUint32(4, file.size >>> 0, false) + } + return toHex(await digestSha256(merged.buffer)) + } catch (err) { + console.warn("[uploadDedup] 计算文件哈希失败,降级为不传 file_hash:", err) + return "" + } +} diff --git a/apps/web/src/api/auth/index.ts b/apps/web/src/api/auth/index.ts index 966c92c44..322dbe7c2 100644 --- a/apps/web/src/api/auth/index.ts +++ b/apps/web/src/api/auth/index.ts @@ -12,13 +12,18 @@ export type { UserResponse, WechatAuthUrlResponse, WechatCallbackResponse, + WechatBindUrlResponse, + WechatBindCompleteResponse, + WechatUnbindResponse, + UpdateProfileRequest, + UpdateProfileResponse, SendVerificationCodeRequest, BindContactRequest, BindContactResponse, } from "./types" // 用户工具函数 -export { normalizeUser } from "./user" +export { normalizeUser, updateProfile } from "./user" // 登录/注册/登出/刷新 export { login, refreshAccessToken, register, logout } from "./login" @@ -32,8 +37,14 @@ export { requestPasswordReset, resetPassword } from "./password" // 邮箱验证 export { verifyEmail } from "./email" -// 微信登录 -export { getWechatAuthUrl, wechatCallback } from "./wechat" +// 微信登录 / 绑定 +export { + getWechatAuthUrl, + wechatCallback, + getWechatBindUrl, + bindWechat, + unbindWechat, +} from "./wechat" // 联系方式 export { sendVerificationCode, bindContact } from "./contact" diff --git a/apps/web/src/api/auth/types.ts b/apps/web/src/api/auth/types.ts index 023e0fa32..0ee6e2999 100644 --- a/apps/web/src/api/auth/types.ts +++ b/apps/web/src/api/auth/types.ts @@ -34,6 +34,17 @@ export interface User { is_email_verified: boolean email_verified: boolean created_at?: string + /** 微信是否已绑定 */ + wechat_bound?: boolean + /** 微信昵称(绑定后展示) */ + wechat_nickname?: string + /** 头像 URL(微信头像等) */ + avatar_url?: string + /** 手机号 */ + phone?: string + phone_verified?: boolean + /** 资料是否完善(微信新用户首次登录为 false,需填昵称引导) */ + profile_completed?: boolean } export interface UserResponse { @@ -45,6 +56,12 @@ export interface UserResponse { is_email_verified?: boolean email_verified?: boolean created_at?: string + wechat_bound?: boolean + wechat_nickname?: string + avatar_url?: string + phone?: string + phone_verified?: boolean + profile_completed?: boolean } export interface WechatAuthUrlResponse { @@ -80,3 +97,30 @@ export interface BindContactResponse { success: boolean user: User } + +/** 更新个人资料请求 */ +export interface UpdateProfileRequest { + display_name?: string +} + +/** 更新个人资料响应(返回最新用户信息) */ +export interface UpdateProfileResponse { + user: UserResponse +} + +/** 微信绑定授权链接响应 */ +export interface WechatBindUrlResponse { + auth_url: string + state: string +} + +/** 微信绑定完成响应 */ +export interface WechatBindCompleteResponse { + success: boolean + user: UserResponse +} + +/** 微信解绑响应 */ +export interface WechatUnbindResponse { + success: boolean +} diff --git a/apps/web/src/api/auth/user.ts b/apps/web/src/api/auth/user.ts index 5c345e3b5..395cb2721 100644 --- a/apps/web/src/api/auth/user.ts +++ b/apps/web/src/api/auth/user.ts @@ -1,4 +1,5 @@ -import type { User, UserResponse } from "./types" +import apiClient from "../client" +import type { User, UserResponse, UpdateProfileRequest, UpdateProfileResponse } from "./types" /** * 规范化用户数据,兼容不同后端返回格式 @@ -16,5 +17,19 @@ export const normalizeUser = (data: UserResponse): User => { is_email_verified: emailVerified, email_verified: emailVerified, created_at: data.created_at, + wechat_bound: data.wechat_bound, + wechat_nickname: data.wechat_nickname, + avatar_url: data.avatar_url, + phone: data.phone, + phone_verified: data.phone_verified, + profile_completed: data.profile_completed, } } + +/** + * 更新个人资料(昵称等) + */ +export const updateProfile = async (data: UpdateProfileRequest): Promise => { + const response = await apiClient.patch("/auth/me", data) + return normalizeUser(response.data.user) +} diff --git a/apps/web/src/api/auth/wechat.ts b/apps/web/src/api/auth/wechat.ts index 1e41a8844..38da5f7ce 100644 --- a/apps/web/src/api/auth/wechat.ts +++ b/apps/web/src/api/auth/wechat.ts @@ -1,8 +1,14 @@ import apiClient from "../client" -import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types" +import type { + WechatAuthUrlResponse, + WechatCallbackResponse, + WechatBindUrlResponse, + WechatBindCompleteResponse, + WechatUnbindResponse, +} from "./types" /** - * 获取微信授权链接 + * 获取微信授权链接(登录场景) */ export const getWechatAuthUrl = async (): Promise => { const response = await apiClient.get("/auth/wechat/url") @@ -19,3 +25,30 @@ export const wechatCallback = async ( const response = await apiClient.post("/auth/wechat/callback", { code, state }) return response.data } + +/** + * 获取微信绑定授权链接(已登录用户绑定场景) + */ +export const getWechatBindUrl = async (): Promise => { + const response = await apiClient.get("/auth/wechat/bind/url") + return response.data +} + +/** + * 微信绑定完成(扫码回调后用 code 绑定到当前登录账号) + */ +export const bindWechat = async ( + code: string, + state: string, +): Promise => { + const response = await apiClient.post("/auth/wechat/bind", { code, state }) + return response.data +} + +/** + * 解绑微信 + */ +export const unbindWechat = async (): Promise => { + const response = await apiClient.delete("/auth/wechat/bind") + return response.data +} diff --git a/apps/web/src/api/auth/wxLogin.ts b/apps/web/src/api/auth/wxLogin.ts new file mode 100644 index 000000000..98706076f --- /dev/null +++ b/apps/web/src/api/auth/wxLogin.ts @@ -0,0 +1,112 @@ +/** + * 微信扫码登录 WxLogin JS-SDK 动态加载与授权参数解析 + * + * 微信官网嵌入式二维码方案:页面引入 https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js + * 后挂载全局 window.WxLogin,new WxLogin({...}) 会在指定容器内渲染二维码 iframe。 + * 本模块负责:动态加载该脚本(带超时/失败检测)、从后端返回的 auth_url 中解析 + * WxLogin 所需的 appid / redirect_uri / state。 + */ + +const WX_LOGIN_SRC = "https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js" +/** 脚本加载超时(毫秒):超时视为加载失败,调用方回退整页跳转 */ +const WX_LOGIN_LOAD_TIMEOUT = 8000 + +/** WxLogin 构造参数(微信官方字段,保持原名) */ +export interface WxLoginOptions { + /** 是否内嵌二维码(回调在 iframe 内完成) */ + self_redirect: boolean + /** 二维码容器元素 id */ + id: string + /** 微信开放平台 AppID */ + appid: string + /** 应用授权作用域,网站应用固定 snsapi_login */ + scope: "snsapi_login" + /** 回调地址(需与微信开放平台配置一致,WxLogin 内部会 encodeURIComponent) */ + redirect_uri: string + /** 防 CSRF 随机串,由后端 state store 生成并在回调时一次性消费 */ + state: string + /** 二维码样式:black / white */ + style?: "black" | "white" + /** 自定义样式链接(可选) */ + href?: string +} + +/** 微信脚本挂载到 window 上的全局构造函数类型 */ +export interface WxLoginConstructor { + new (options: WxLoginOptions): unknown +} + +declare global { + interface Window { + WxLogin?: WxLoginConstructor + } +} + +let loadPromise: Promise | null = null + +/** + * 动态加载微信 WxLogin JS(单例:并发调用复用同一个 promise)。 + * 加载失败或超时会 reject,调用方应回退到整页跳转授权方式。 + */ +export function loadWxLoginScript(): Promise { + if (window.WxLogin) return Promise.resolve(window.WxLogin) + if (loadPromise) return loadPromise + + loadPromise = new Promise((resolve, reject) => { + const script = document.createElement("script") + script.src = WX_LOGIN_SRC + script.async = true + script.onload = () => { + if (window.WxLogin) { + resolve(window.WxLogin) + } else { + loadPromise = null + reject(new Error("微信登录脚本加载完成但 WxLogin 未挂载")) + } + } + script.onerror = () => { + loadPromise = null + script.remove() + reject(new Error("微信登录脚本加载失败")) + } + document.head.appendChild(script) + + // 超时兜底:部分网络环境下脚本既不 onload 也不 onerror + window.setTimeout(() => { + if (window.WxLogin) { + resolve(window.WxLogin) + return + } + loadPromise = null + script.remove() + reject(new Error("微信登录脚本加载超时")) + }, WX_LOGIN_LOAD_TIMEOUT) + }) + + return loadPromise +} + +/** 从微信授权链接 query 中解析出的 WxLogin 所需参数 */ +export interface ParsedWxAuthParams { + appid: string + /** 已 URL 解码的回调地址(传给 WxLogin 时由其内部再次编码) */ + redirect_uri: string + state: string +} + +/** + * 从后端返回的微信授权链接(https://open.weixin.qq.com/connect/qrconnect?appid=...&redirect_uri=...&state=...) + * 中解析 appid / redirect_uri / state。解析失败时返回 null,由调用方回退整页跳转。 + */ +export function parseWxAuthUrl(authUrl: string, stateFallback?: string): ParsedWxAuthParams | null { + try { + const url = new URL(authUrl) + const appid = url.searchParams.get("appid") + const redirectUri = url.searchParams.get("redirect_uri") + const state = url.searchParams.get("state") || stateFallback || "" + if (!appid || !redirectUri || !state) return null + return { appid, redirect_uri: redirectUri, state } + } catch { + return null + } +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index df3f52e16..1d3ce3170 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -55,6 +55,19 @@ apiClient.interceptors.response.use( async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean + /** + * 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。 + * 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」, + * 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。 + */ + _silentErrorToast?: boolean + } + + // 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗 + if (originalRequest?._silentErrorToast) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(error as any).__msgShown = true + return Promise.reject(error) } // 401 → 尝试刷新 Token diff --git a/apps/web/src/api/editing-planner/templates.ts b/apps/web/src/api/editing-planner/templates.ts index 2458de451..ea77b1f4f 100644 --- a/apps/web/src/api/editing-planner/templates.ts +++ b/apps/web/src/api/editing-planner/templates.ts @@ -12,17 +12,25 @@ import type { ListCategoriesResponse, } from "./types" -/** 获取模板列表 */ +/** 获取模板列表 + * + * valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用, + * 避免选中无片段配置的模板导致 from-assets 400,#1769/#1772); + * 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。 + * 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。 + */ export const getEditingTemplates = async (params?: { category?: string tag?: string skip?: number limit?: number + validOnly?: boolean }): Promise => { const response = await apiClient.get("/templates", { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 50, + ...(params?.validOnly ? { valid_only: true } : {}), }, }) let list = response.data.items diff --git a/apps/web/src/api/editing-planner/types.ts b/apps/web/src/api/editing-planner/types.ts index 80a4a0180..c29525d92 100644 --- a/apps/web/src/api/editing-planner/types.ts +++ b/apps/web/src/api/editing-planner/types.ts @@ -44,8 +44,10 @@ export interface BgmConfig { export interface TemplateSegment { id?: string segment_order: number - duration_min: number - duration_max: number + /** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取,新模板可不传 */ + duration_min?: number + /** @deprecated 同上 */ + duration_max?: number material_type: string | null } @@ -59,7 +61,8 @@ export interface EditingTemplate { title_config: TitleConfig subtitle_config: SubtitleConfig bgm_config: BgmConfig - estimated_duration: number + /** @deprecated 模板无时长概念(#1750 基线):成片时长由配音时长决定;字段保留兼容旧数据 */ + estimated_duration?: number segments: TemplateSegment[] watermark_config?: WatermarkConfig intro_outro_config?: IntroOutroConfig @@ -89,7 +92,8 @@ export interface SaveTemplatePayload { title_config: TitleConfig subtitle_config: SubtitleConfig bgm_config: BgmConfig - estimated_duration: number + /** @deprecated 模板无时长概念(#1750 基线):保留兼容旧数据 */ + estimated_duration?: number segments: Omit[] watermark_config?: WatermarkConfig intro_outro_config?: IntroOutroConfig diff --git a/apps/web/src/api/errors.ts b/apps/web/src/api/errors.ts new file mode 100644 index 000000000..7eacd4bc9 --- /dev/null +++ b/apps/web/src/api/errors.ts @@ -0,0 +1,132 @@ +/** + * 统一错误信息提取 + * 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、 + * 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。 + * + * 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast, + * 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。 + */ +import type { AxiosError } from "axios" + +/** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg) */ +interface ErrorBody { + detail?: unknown + message?: unknown + msg?: unknown +} + +/** FastAPI 422 校验错误单项 */ +interface ValidationItem { + loc?: (string | number)[] + msg?: string +} + +/** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */ +function extractBodyMessage(data: unknown): string { + if (!data || typeof data !== "object") return "" + const body = data as ErrorBody + + const walk = (val: unknown): string => { + if (typeof val === "string") return val + if (Array.isArray(val)) { + // FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接 + const parts = val + .map((item) => { + if (typeof item === "string") return item + if (item && typeof item === "object") { + const v = item as ValidationItem + if (typeof v.msg === "string") { + const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : "" + return field ? `${field}: ${v.msg}` : v.msg + } + return walk(item) + } + return "" + }) + .filter(Boolean) + return parts.join(";") + } + if (val && typeof val === "object") { + const obj = val as Record + if (typeof obj.message === "string") return obj.message + if (typeof obj.msg === "string") return obj.msg + if (typeof obj.detail === "string") return obj.detail + if (obj.message && typeof obj.message === "object") return walk(obj.message) + if (obj.msg && typeof obj.msg === "object") return walk(obj.msg) + try { + return JSON.stringify(val) + } catch { + return "" + } + } + return "" + } + + return walk(body.detail) || walk(body.message) || walk(body.msg) +} + +/** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */ +function statusFallback(status: number): string { + switch (status) { + case 400: + return "请求参数有误(HTTP 400)" + case 401: + return "登录状态已失效,请重新登录(HTTP 401)" + case 403: + return "没有权限执行该操作(HTTP 403)" + case 404: + return "请求的资源不存在(HTTP 404)" + case 409: + return "操作冲突,资源状态已变化(HTTP 409)" + case 413: + return "文件过大,请缩小后重试(HTTP 413)" + case 415: + return "不支持的文件格式(HTTP 415)" + case 429: + return "操作过于频繁,请稍后再试(HTTP 429)" + case 503: + return "服务暂不可用,请稍后再试(HTTP 503)" + default: + if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status})` + return `请求失败(HTTP ${status})` + } +} + +/** + * 从任意抛出值提取可展示的错误信息。 + * @param fallback 全部提取失败时的兜底文案 + */ +export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string { + if (!err) return fallback + + // axios 错误(后端 JSON 响应 / HTTP 错误状态) + const ax = err as AxiosError + if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) { + // 超时 + if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) { + return "请求超时,请检查网络后重试" + } + const resp = ax.response + if (resp) { + const bodyMsg = extractBodyMessage(resp.data) + if (bodyMsg) return bodyMsg + return statusFallback(resp.status) + } + // 请求已发出但无响应(断网/CORS/DNS) + if (ax.request) return "网络连接异常,请检查网络设置" + return ax.message || fallback + } + + if (err instanceof Error) { + // XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message) + if (err.message) return err.message + } + if (typeof err === "string") return err + + return fallback +} + +/** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */ +export function isErrorMsgShown(err: unknown): boolean { + return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown) +} diff --git a/apps/web/src/api/generation/types.ts b/apps/web/src/api/generation/types.ts index 6b877681e..61ae5749c 100755 --- a/apps/web/src/api/generation/types.ts +++ b/apps/web/src/api/generation/types.ts @@ -33,15 +33,36 @@ export interface CreatePreviewRequest { preset_id?: string volume?: number } + /** 批量预览数量(1~10),默认1。N>1 时返回 N 个独立变体任务 */ + preview_count?: number + /** 各变体独立标题文字:长度1=共用,长度=preview_count=独立,空数组=使用 title_config.text */ + titles?: string[] + /** 各变体独立配音素材库ID:长度1=共用,长度=preview_count=独立,空数组=回退 voice_library_id */ + voice_library_ids?: string[] + /** 各变体独立封面URL:长度1=共用,长度=preview_count=独立(预览阶段通常为空) */ + cover_urls?: string[] } -/** 创建预览任务响应 */ -export interface CreatePreviewResponse { +/** 单个预览变体任务 */ +export interface PreviewVariantItem { task_id: string - status: PreviewStatus + status: string + progress: number is_preview: boolean + variant_index: number resolution: string - created_at: string + video_url: string + duration: number + error_message: string + title_text: string + voice_library_id: string + created_at?: string | null +} + +/** 创建预览任务响应(单变体,preview_count=1 时 items 长度为1) */ +export interface CreatePreviewResponse { + items: PreviewVariantItem[] + total: number /** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id) */ source_edit_plan_id?: string } diff --git a/apps/web/src/api/generation/variantPlans.ts b/apps/web/src/api/generation/variantPlans.ts new file mode 100644 index 000000000..554ad09f1 --- /dev/null +++ b/apps/web/src/api/generation/variantPlans.ts @@ -0,0 +1,67 @@ +/** + * 批量变体剪辑计划 API(#1744) + * + * 批量预览时向后端申请 N 个变体的「独立剪辑计划片段」: + * - 变体 0 保留源 plan(用户在编辑器/智能选片产出的片段,含标题样式编辑结果); + * - 变体 1..N-1 由后端 reselect_plan_for_variant 完整重跑单视频选片流程 + * (素材洗牌 + main 片段顺序洗牌 + 镜头/起点随机 + 跨变体 20% 区间避让 + + * 素材使用区间写回 metadata),与正式批量生成 POST /generation/tasks?count=N + * 使用同一套选片逻辑; + * - 正式生成时把 variant_plan_ids 原样回传,后端直接关联这些 plan 渲染, + * 不再重新选片 —— 预览所见即成片。 + * + * 该接口只做选片/建 plan(秒级),不触发视频渲染,无渲染成本。 + * 后端端点未上线(404)或选片失败(素材不足等)时前端降级为本地 variantSeed + * 模拟预览,不阻塞用户流程。 + */ +import apiClient from "../client" +import type { EditPlanClip } from "../template-editor" + +/** 批量变体计划请求体 */ +export interface BatchVariantPlansRequest { + template_id: string + /** 本批次素材池(手动选择或智能匹配结果) */ + asset_ids: string[] + /** 变体数量(≥1);=1 时只返回源 plan 片段 */ + count: number + /** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */ + source_edit_plan_id?: string + /** 统一配音 ID(共用配音模式);独立配音模式不传,改传 voice_library_ids */ + voice_library_id?: string + /** 独立配音 ID 列表(长度=count,按变体序号一一对应);共用配音模式不传 */ + voice_library_ids?: string[] +} + +/** 单个变体的计划片段 */ +export interface VariantPlan { + /** 变体序号,从 0 开始 */ + variant_index: number + /** 该变体关联的剪辑计划 ID(正式生成时回传,实现预览即成片) */ + plan_id: string + /** 该变体的真实片段(顺序/素材/起点与正式成片一致) */ + clips: EditPlanClip[] + /** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */ + voice_duration?: number +} + +/** 批量变体计划响应 */ +export interface BatchVariantPlansResponse { + items: VariantPlan[] + total: number +} + +/** + * 创建批量变体剪辑计划并返回各变体片段。 + * + * 注意:端点 404(后端未上线)/ 400(素材不足)等失败由调用方 catch 后降级, + * 不要抛 unhandled rejection。 + */ +export async function createBatchVariantPlans( + params: BatchVariantPlansRequest, +): Promise { + const response = await apiClient.post( + "/generation/variant-plans", + params, + ) + return response.data +} diff --git a/apps/web/src/api/products/products.ts b/apps/web/src/api/products/products.ts index 18680df7b..95de308ad 100644 --- a/apps/web/src/api/products/products.ts +++ b/apps/web/src/api/products/products.ts @@ -1,6 +1,6 @@ /** * 成品 / 视频相关 API 函数 - * 后端实际接口:/videos + * 后端实际接口:/videos(分页:page/page_size,返回 {items, total, page, page_size}) */ import apiClient from "../client" import type { @@ -12,16 +12,39 @@ import type { } from "./types" import { mapVideoToProductItem } from "./utils" -/** 获取成品列表(支持分页和筛选) */ -export const getProducts = async (params?: ProductListParams): Promise => { - 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) +/** 分页列表响应(前端消费用) */ +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 => { + 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, + } } /** 获取单个成品详情 */ diff --git a/apps/web/src/api/scripts/index.ts b/apps/web/src/api/scripts/index.ts new file mode 100644 index 000000000..f9d5b5405 --- /dev/null +++ b/apps/web/src/api/scripts/index.ts @@ -0,0 +1,2 @@ +export * from "./scripts" +export * from "./types" diff --git a/apps/web/src/api/scripts/scripts.ts b/apps/web/src/api/scripts/scripts.ts new file mode 100644 index 000000000..4ee28f146 --- /dev/null +++ b/apps/web/src/api/scripts/scripts.ts @@ -0,0 +1,37 @@ +/** + * 文案库 API + * 对接后端 /api/v1/scripts(CRUD + 列表解包) + */ +import apiClient from "../client" +import type { + ScriptItem, + ScriptListResponse, + CreateScriptRequest, + UpdateScriptRequest, +} from "./types" + +/** 获取文案列表 — 必须解包 items(后端返回 {items,total})*/ +export const getScripts = async (): Promise => { + const response = await apiClient.get("/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 => { + const response = await apiClient.post("/scripts", data) + return response.data +} + +/** 更新文案 */ +export const updateScript = async (id: string, data: UpdateScriptRequest): Promise => { + const response = await apiClient.put(`/scripts/${id}`, data) + return response.data +} + +/** 删除文案 */ +export const deleteScript = async (id: string): Promise => { + await apiClient.delete(`/scripts/${id}`) +} diff --git a/apps/web/src/api/scripts/types.ts b/apps/web/src/api/scripts/types.ts new file mode 100644 index 000000000..8fa0f30e4 --- /dev/null +++ b/apps/web/src/api/scripts/types.ts @@ -0,0 +1,24 @@ +/** + * 文案库 API — 类型定义 + * 对接后端 /api/v1/scripts + */ +export interface ScriptItem { + id: string + title: string + content: string + char_count: number + created_at: string + updated_at?: string +} + +export interface ScriptListResponse { + items: ScriptItem[] + total: number +} + +export interface CreateScriptRequest { + title: string + content: string +} + +export type UpdateScriptRequest = Partial diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 42e41995b..4dd381eef 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -92,6 +92,20 @@ export interface CreateGenerationTaskRequest { preset_id?: string volume?: number } + /** 批量生成数量(1~10),默认1。不传=单条旧逻辑 */ + count?: number + /** 各变体独立标题文字:长度1=共用,长度=count=独立,空数组=使用 title_config/custom_title */ + titles?: string[] + /** 各变体独立配音素材库ID:长度1=共用,长度=count=独立,空数组=回退 voice_library_id */ + voice_library_ids?: string[] + /** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */ + cover_urls?: string[] + /** + * 批量变体剪辑计划 ID(#1744):预览阶段后端独立选片产出的 plan id 列表 + * (按变体全量索引,长度=previewCount)。正式生成回传后后端直接关联这些 + * plan 渲染、不再重新选片,保证预览所见即成片。后端未支持时忽略该字段。 + */ + variant_plan_ids?: string[] } /** 单个生成任务详情(对齐后端 GenerationTaskResponse) */ diff --git a/apps/web/src/api/template-editor/clips.ts b/apps/web/src/api/template-editor/clips.ts index b65c23972..7a63ba843 100644 --- a/apps/web/src/api/template-editor/clips.ts +++ b/apps/web/src/api/template-editor/clips.ts @@ -91,7 +91,7 @@ export async function createClipsFromAssets( assetIds: string[], clipType = "main", requiredClipsCount?: number, - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal; silentErrorToast?: boolean }, ): Promise { const body: Record = { asset_ids: assetIds, @@ -104,7 +104,12 @@ export async function createClipsFromAssets( const response = await apiClient.post( `/templates/${templateId}/editor/clips/from-assets`, body, - { timeout: 60000, signal: opts?.signal }, + { + timeout: 60000, + signal: opts?.signal, + // _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777) + ...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record) : {}), + }, ) return response.data } diff --git a/apps/web/src/api/template-editor/editPlans.ts b/apps/web/src/api/template-editor/editPlans.ts index 2ec4acdea..c4d017c16 100644 --- a/apps/web/src/api/template-editor/editPlans.ts +++ b/apps/web/src/api/template-editor/editPlans.ts @@ -43,11 +43,17 @@ export async function updateEditPlanClips( templateId: string, clips: EditPlanClipInput[], signal?: AbortSignal, + /** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */ + silentErrorToast?: boolean, ): Promise<{ count: number }> { const response = await apiClient.put( `/templates/${templateId}/editor/clips`, { clips }, - { signal }, + { + signal, + // _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast) + ...(silentErrorToast ? ({ _silentErrorToast: true } as Record) : {}), + }, ) return response.data } diff --git a/apps/web/src/api/templates/types.ts b/apps/web/src/api/templates/types.ts index 8b0575dd7..5016f4959 100644 --- a/apps/web/src/api/templates/types.ts +++ b/apps/web/src/api/templates/types.ts @@ -42,8 +42,10 @@ export interface TemplateItem { export interface TemplateSegment { id?: string segment_order: number - duration_min: number - duration_max: number + /** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取 */ + duration_min?: number + /** @deprecated 同上 */ + duration_max?: number material_type: string | null description?: string } @@ -55,8 +57,6 @@ export interface TemplateListParams { category?: string tags?: string keyword?: string - /** 时长筛选(秒):short < 30, medium 30-120, long > 120 */ - duration_range?: "short" | "medium" | "long" } /** 模板列表分页响应 */ diff --git a/apps/web/src/api/tts/types.ts b/apps/web/src/api/tts/types.ts index 39e1046d5..c3e686017 100644 --- a/apps/web/src/api/tts/types.ts +++ b/apps/web/src/api/tts/types.ts @@ -103,6 +103,7 @@ export interface TTSPreviewRequest { voice_id: string speed?: number pitch?: number + emotion?: string // 情绪参数:natural/excited/calm/friendly } /** TTS 试听响应 */ diff --git a/apps/web/src/components/auth/WechatQrModal/WechatQrModal.css b/apps/web/src/components/auth/WechatQrModal/WechatQrModal.css new file mode 100644 index 000000000..3f5ea062d --- /dev/null +++ b/apps/web/src/components/auth/WechatQrModal/WechatQrModal.css @@ -0,0 +1,74 @@ +.xx-wechat-qr-modal { + position: relative; + padding: 8px 0 4px; + min-height: 320px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +/* 常驻二维码容器(WxLogin 渲染目标) */ +.xx-wechat-qr-container { + display: flex; + justify-content: center; + min-height: 260px; +} + +/* loading / error 遮罩层,覆盖在二维码容器之上 */ +.xx-wechat-qr-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #fff; + text-align: center; + color: #666; +} + +.xx-wechat-qr-overlay p { + margin-top: 16px; + margin-bottom: 0; +} + +.xx-wechat-qr-container iframe { + border: none; +} + +.xx-wechat-qr-tip { + margin: 12px 0 0; + color: #666; + font-size: 14px; +} + +.xx-wechat-qr-error { + text-align: center; + width: 100%; +} + +.xx-wechat-qr-error-msg { + color: #ef4444; + font-size: 14px; + line-height: 1.6; + margin: 0 0 16px; + word-break: break-word; +} + +.xx-wechat-qr-error-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.xx-wechat-qr-fallback { + background: none; + border: none; + color: var(--primary-color, #3b82f6); + cursor: pointer; + font-size: 13px; + padding: 0; + text-decoration: underline; +} diff --git a/apps/web/src/components/auth/WechatQrModal/index.tsx b/apps/web/src/components/auth/WechatQrModal/index.tsx new file mode 100644 index 000000000..59e254c43 --- /dev/null +++ b/apps/web/src/components/auth/WechatQrModal/index.tsx @@ -0,0 +1,265 @@ +/** + * 微信扫码二维码弹窗(登录 / 绑定复用) + * + * 微信官方嵌入式二维码方案:弹窗内用 new WxLogin({ self_redirect: true }) 渲染二维码, + * 扫码后微信重定向到本站回调页(在二维码 iframe 内加载),回调页通过 postMessage + * 把成功/失败结果通知本弹窗(消息协议见 ./messages)。 + * + * 兜底:获取授权链接成功但 WxLogin JS 加载失败/超时时,自动回退整页跳转授权 + * (与旧流程一致);获取授权链接本身失败时在弹窗内展示错误并提供重试。 + */ +import React, { useEffect, useRef, useState } from "react" +import { Spin } from "antd" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" +import { + getWechatAuthUrl, + getWechatBindUrl, + getCurrentUser, + normalizeUser, + type User, +} from "@/api/auth" +import { useAuthStore } from "@/store/authStore" +import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh" +import { getErrorMessage } from "@/api/errors" +import { loadWxLoginScript, parseWxAuthUrl } from "@/api/auth/wxLogin" +import { isWechatQrMessage, type WechatQrScene } from "./messages" +import "./WechatQrModal.css" + +export interface WechatQrModalProps { + open: boolean + scene: WechatQrScene + onClose: () => void + /** 登录场景成功回调(needOnboarding=true 时调用方应跳昵称引导页) */ + onLoginSuccess?: (needOnboarding: boolean) => void + /** 绑定场景成功回调(调用方刷新用户信息/提示) */ + onBindSuccess?: () => void +} + +type QrStatus = "loading" | "qrcode" | "error" + +const CONTAINER_ID: Record = { + login: "wechat-qr-login-container", + bind: "wechat-qr-bind-container", +} + +const STATE_STORAGE_KEY: Record = { + login: "wechat_state", + bind: "wechat_bind_state", +} + +/** + * 等待二维码容器挂载到 DOM。antd Modal 内容通过 portal 渲染且带进场动画, + * 父组件 effect 首次执行时容器可能尚未出现在 document 中。 + */ +function waitForContainer(id: string, timeoutMs = 3000): Promise { + return new Promise((resolve) => { + const start = Date.now() + const check = () => { + const el = document.getElementById(id) + if (el) { + resolve(el) + return + } + if (Date.now() - start > timeoutMs) { + resolve(null) + return + } + setTimeout(check, 50) + } + check() + }) +} + +const WechatQrModal: React.FC = ({ + open, + scene, + onClose, + onLoginSuccess, + onBindSuccess, +}) => { + const setAuth = useAuthStore((state) => state.setAuth) + const setUser = useAuthStore((state) => state.setUser) + const [status, setStatus] = useState("loading") + const [errorMsg, setErrorMsg] = useState("") + /** 刷新二维码计数:变化时重新请求授权链接并重渲染 */ + const [renderSeq, setRenderSeq] = useState(0) + /** 最新授权链接,用于"整页打开"兜底 */ + const authUrlRef = useRef(null) + + const isLogin = scene === "login" + + // 初始化:获取授权链接 → 加载 WxLogin JS → 内嵌渲染二维码 + useEffect(() => { + if (!open) return + let cancelled = false + authUrlRef.current = null + setStatus("loading") + setErrorMsg("") + + const init = async () => { + try { + const fetchUrl = isLogin ? getWechatAuthUrl : getWechatBindUrl + const result = await fetchUrl() + if (cancelled) return + // 写 state(整页跳转兜底路径的回调页也会清理它) + localStorage.setItem(STATE_STORAGE_KEY[scene], result.state) + authUrlRef.current = result.auth_url + + const params = parseWxAuthUrl(result.auth_url, result.state) + if (!params) { + // 授权链接格式异常:直接整页跳转,由微信侧/回调页兜底 + window.location.href = result.auth_url + return + } + + const WxLogin = await loadWxLoginScript() + if (cancelled) return + // 等 Modal portal 中的容器挂载完成 + const container = await waitForContainer(CONTAINER_ID[scene]) + if (cancelled) return + if (!container) { + window.location.href = result.auth_url + return + } + container.innerHTML = "" + new WxLogin({ + self_redirect: true, + id: CONTAINER_ID[scene], + appid: params.appid, + scope: "snsapi_login", + redirect_uri: params.redirect_uri, + state: params.state, + style: "black", + }) + if (!cancelled) setStatus("qrcode") + } catch (err) { + if (cancelled) return + if (authUrlRef.current) { + // 授权链接已拿到但二维码脚本加载失败/超时:回退整页跳转 + window.location.href = authUrlRef.current + return + } + // 授权链接接口本身失败:弹窗内展示真实原因,允许重试 + setErrorMsg(getErrorMessage(err, "微信服务暂不可用,请稍后重试")) + setStatus("error") + } + } + + init() + return () => { + cancelled = true + } + }, [open, scene, isLogin, renderSeq]) + + // 监听 iframe 内回调页 postMessage 回来的扫码结果 + useEffect(() => { + if (!open) return + + const handleMessage = async (event: MessageEvent) => { + // 只接受同源消息 + if (event.origin !== window.location.origin) return + if (!isWechatQrMessage(event.data, scene)) return + const msg = event.data + + if (msg.success) { + if (isLogin) { + // iframe 内回调页已把 token 写入 localStorage(同源共享), + // 父窗口同步内存登录态后交给调用方跳转 + try { + const userData = await getCurrentUser() + const user = normalizeUser(userData) as User + setAuth( + user, + localStorage.getItem("access_token") || "", + localStorage.getItem("refresh_token"), + ) + scheduleProactiveRefresh() + } catch { + // token 已持久化,即使这里失败路由守卫/刷新也能恢复登录态 + } + onLoginSuccess?.(msg.payload?.needOnboarding ?? false) + } else { + try { + const userData = await getCurrentUser() + setUser(normalizeUser(userData) as User) + } catch { + // 绑定结果以后端为准,调用方 invalidateQueries 会兜底刷新 + } + onBindSuccess?.() + } + return + } + + // 失败:弹窗内展示回调页透传的真实原因,提供刷新/整页跳转 + setErrorMsg(msg.detail || "微信授权失败,请重试") + setStatus("error") + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [open, scene, isLogin, onLoginSuccess, onBindSuccess, setAuth, setUser]) + + const handleRefresh = () => setRenderSeq((seq) => seq + 1) + + const handleFullPageRedirect = () => { + if (authUrlRef.current) { + window.location.href = authUrlRef.current + } + } + + return ( + +
+ {/* 二维码容器常驻:WxLogin 在 loading 阶段就会把 iframe 渲染进来, + 不能按 status 条件渲染,否则 effect 里永远找不到容器 */} +
+ + {status === "loading" && ( +
+ +

正在生成微信二维码...

+
+ )} + + {status === "qrcode" && ( +

请使用微信扫描二维码{isLogin ? "登录" : "绑定账号"}

+ )} + + {status === "error" && ( +
+

{errorMsg}

+
+ + {authUrlRef.current && ( + + )} +
+
+ )} +
+ + ) +} + +export default WechatQrModal diff --git a/apps/web/src/components/auth/WechatQrModal/messages.ts b/apps/web/src/components/auth/WechatQrModal/messages.ts new file mode 100644 index 000000000..365eba137 --- /dev/null +++ b/apps/web/src/components/auth/WechatQrModal/messages.ts @@ -0,0 +1,71 @@ +/** + * 微信扫码弹窗与 iframe 内回调页之间的 postMessage 消息协议 + * + * 流程:弹窗内 WxLogin(self_redirect:true) 渲染的二维码 iframe 扫码后, + * 微信重定向到本站回调页(同源,在 iframe 内加载);回调页完成换 token/绑定后, + * 通过 window.parent.postMessage 把结果通知弹窗,弹窗负责关闭/展示错误/同步登录态。 + */ + +/** 扫码场景:登录 / 绑定 */ +export type WechatQrScene = "login" | "bind" + +export interface WechatQrSuccessPayload { + /** 登录场景:是否需要昵称引导(新用户或资料未完善) */ + needOnboarding?: boolean +} + +export interface WechatQrMessageData { + /** 固定协议标识,父窗口只认该 source */ + source: "xiaoxia-wechat-qr" + /** 场景,需与弹窗发起时一致(login/bind),父窗口据此过滤 */ + scene: WechatQrScene + /** 成功 / 失败 */ + success: boolean + /** 失败时的真实原因(已在回调页拼好,含后端 detail) */ + detail?: string + payload?: WechatQrSuccessPayload +} + +export const WECHAT_QR_MESSAGE_SOURCE = "xiaoxia-wechat-qr" + +/** 判断收到的 message 是否为本协议消息(且场景匹配) */ +export function isWechatQrMessage( + data: unknown, + scene: WechatQrScene, +): data is WechatQrMessageData { + if (!data || typeof data !== "object") return false + const msg = data as Partial + return msg.source === WECHAT_QR_MESSAGE_SOURCE && msg.scene === scene +} + +/** 当前页面是否运行在 iframe(弹窗内嵌二维码)中 */ +export function isInIframe(): boolean { + try { + return window.parent !== window + } catch { + // 跨域访问 window.parent 可能抛异常,按非 iframe 处理 + return false + } +} + +/** + * iframe 内回调页向父窗口上报扫码结果。同源回调页加载,targetOrigin 限定本站 origin。 + */ +export function postWechatQrResult( + scene: WechatQrScene, + success: boolean, + options?: { detail?: string; needOnboarding?: boolean }, +): void { + if (!isInIframe()) return + const data: WechatQrMessageData = { + source: WECHAT_QR_MESSAGE_SOURCE, + scene, + success, + detail: options?.detail, + payload: + success && options?.needOnboarding !== undefined + ? { needOnboarding: options.needOnboarding } + : undefined, + } + window.parent.postMessage(data, window.location.origin) +} diff --git a/apps/web/src/components/common/ChunkErrorBoundary.tsx b/apps/web/src/components/common/ChunkErrorBoundary.tsx new file mode 100644 index 000000000..5403751c6 --- /dev/null +++ b/apps/web/src/components/common/ChunkErrorBoundary.tsx @@ -0,0 +1,85 @@ +/** + * 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏, + * 同时兜住页面级渲染崩溃,避免任何未捕获错误导致整页白屏无反馈。 + * + * 捕获到 ChunkLoadError / Failed to fetch dynamically imported module: + * 1. 首次:自动整页刷新一次(sessionStorage 标记,刷新后 index.html 重新拉取, + * 拿到新 chunk 引用,白屏自愈) + * 2. 刷新后仍失败(标记未过期):不再自动刷新,显示"系统已更新,请点击刷新" + * 兜底界面,由用户手动点击 + * + * 其他非 chunk 错误:显示通用错误页 + "返回首页"按钮(跳首页而非刷新当前 URL, + * 避免刷新后再次命中同一路由崩溃形成死循环)。 + */ +import React from "react" +import { Button, Result } from "antd" +import { + getChunkReloadedAt, + goHomeRecover, + isChunkLoadError, + reloadForChunkError, +} from "@/utils/chunkLoadError" + +interface Props { + children: React.ReactNode +} + +interface State { + error: Error | null + isChunkError: boolean + /** 捕获错误时是否已经自动刷新过(决定显示自动刷新中还是手动兜底) */ + alreadyReloaded: boolean +} + +class ChunkErrorBoundary extends React.Component { + state: State = { error: null, isChunkError: false, alreadyReloaded: false } + + static getDerivedStateFromError(error: Error): State { + const chunk = isChunkLoadError(error) + return { + error, + isChunkError: chunk, + alreadyReloaded: chunk ? getChunkReloadedAt() !== null : false, + } + } + + componentDidCatch(error: Error): void { + // 仅 chunk 错误且本次会话没自动刷新过 → 打标记并整页刷新(自愈) + if (isChunkLoadError(error) && getChunkReloadedAt() === null) { + reloadForChunkError() + } + } + + render(): React.ReactNode { + const { error, isChunkError, alreadyReloaded } = this.state + if (!error) return this.props.children + + if (isChunkError && !alreadyReloaded) { + // 已打标记、componentDidCatch 里已触发 reload;极短瞬间展示加载中 + return ( + + ) + } + + // 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用; + // 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环 + return ( + + {isChunkError ? "刷新并返回首页" : "返回首页"} + + } + /> + ) + } +} + +export default ChunkErrorBoundary diff --git a/apps/web/src/components/layout/MainLayout.css b/apps/web/src/components/layout/MainLayout.css index b2302d8da..890f417b3 100644 --- a/apps/web/src/components/layout/MainLayout.css +++ b/apps/web/src/components/layout/MainLayout.css @@ -5,7 +5,7 @@ .xx-app-shell 全屏 flex 容器 ├── header (xx-top-nav) 顶部导航(Header.tsx 管理) └── .xx-app-body 水平 flex 行 - ├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠) + ├── .xx-app-sidebar 左侧侧边栏(128px / 64px 折叠) └── .xx-app-content 主内容区(自适应) 所有尺寸/颜色均使用 global.css 设计系统变量 @@ -31,7 +31,7 @@ /* ── 侧边栏 ───────────────────────────────────────────────── */ .xx-app-sidebar { - width: 240px; + width: 128px; flex-shrink: 0; position: sticky; top: 0; @@ -103,6 +103,12 @@ padding: var(--space-sm); } +/* 展开态(侧边栏 128px)水平 padding 收窄,为菜单文字留出完整一行空间 */ +.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-content { + padding-left: var(--space-xs); + padding-right: var(--space-xs); +} + /* ── 主内容区 ─────────────────────────────────────────────── */ .xx-app-content { flex: 1; @@ -136,7 +142,7 @@ /* 展开态恢复完整宽度 */ .xx-app-sidebar:not(.xx-collapsed) { - width: 240px; + width: 128px; } .xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle { @@ -159,7 +165,7 @@ top: 56px; /* 移动端 Header 高度 */ left: 0; bottom: 0; - width: 240px; + width: 128px; transform: translateX(-100%); transition: transform var(--transition-slow); box-shadow: none; diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 848da13bd..55dd53928 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -2,7 +2,7 @@ * MainLayout - 主布局组件(Task 1.2) * * 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区 - * - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏 + * - 侧边栏:128px 固定宽度,可折叠至 64px 图标栏 * - 顶部导航:复用 Header 组件(68px 固定高度) * - 主内容区:自适应填充剩余空间 * - 响应式:移动端(<768px)隐藏侧边栏 diff --git a/apps/web/src/components/layout/Sidebar.css b/apps/web/src/components/layout/Sidebar.css index 4a7e957a2..590c23af7 100644 --- a/apps/web/src/components/layout/Sidebar.css +++ b/apps/web/src/components/layout/Sidebar.css @@ -30,7 +30,7 @@ /* 分组标题 */ .xx-sidebar-group-title { - padding: var(--space-sm) var(--space-md) var(--space-xs); + padding: var(--space-sm) var(--space-sm) var(--space-xs); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); color: var(--text-tertiary); @@ -56,9 +56,9 @@ .xx-sidebar-menu-item { display: flex; align-items: center; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); - margin: 0 var(--space-xs); + gap: var(--space-xs); + padding: var(--space-sm) var(--space-xs); + margin: 0 var(--space-xxs); border-radius: var(--radius-sm); cursor: pointer; color: var(--text-secondary); @@ -97,12 +97,12 @@ align-items: center; justify-content: center; flex-shrink: 0; - width: 36px; - height: 36px; - border-radius: 10px; + width: 28px; + height: 28px; + border-radius: 8px; background: #f1f5f9; color: var(--text-secondary); - font-size: 18px; + font-size: 16px; line-height: 1; transition: 0.15s ease; } @@ -119,7 +119,9 @@ /* ── 菜单项文字 ───────────────────────────────────────────── */ .xx-sidebar-menu-label { flex: 1; + min-width: 0; overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; } @@ -133,8 +135,16 @@ /* 折叠时菜单项居中,仅图标 */ .xx-sidebar-nav--collapsed .xx-sidebar-menu-item { justify-content: center; - padding: var(--space-sm); - margin: 0 var(--space-xxs); + padding: var(--space-xs); + margin: 0; +} + +/* 折叠态图标恢复更大尺寸居中 */ +.xx-sidebar-nav--collapsed .xx-sidebar-menu-icon { + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 16px; } /* 折叠时隐藏分组标题 */ diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index dcbf208b2..6279ca871 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -18,6 +18,7 @@ import { ControlOutlined, CrownOutlined, UnorderedListOutlined, + UserOutlined, } from "@ant-design/icons" /** 导航项类型 */ @@ -57,6 +58,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/titles", icon: React.createElement(FileTextOutlined), }, + { + key: "scripts", + label: "文案库", + path: "/app/scripts", + icon: React.createElement(EditOutlined), + }, { key: "voices", label: "配音库", @@ -88,6 +95,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/generate", icon: React.createElement(VideoCameraOutlined), }, + { + key: "ai-avatar", + label: "AI数字人", + path: "/app/ai-avatar", + icon: React.createElement(UserOutlined), + }, { key: "history", label: "任务历史", @@ -131,6 +144,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/generate", icon: React.createElement(VideoCameraOutlined), }, + { + key: "ai-avatar", + label: "AI数字人", + path: "/app/ai-avatar", + icon: React.createElement(UserOutlined), + }, { key: "editing-planner", label: "剪辑模板", @@ -160,6 +179,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/titles", icon: React.createElement(FileTextOutlined), }, + { + key: "scripts", + label: "文案库", + path: "/app/scripts", + icon: React.createElement(EditOutlined), + }, { key: "products", label: "成品库", diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a4253a61a..33cdde527 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ConfigProvider, App as AntApp } from "antd" import zhCN from "antd/locale/zh_CN" import router from "./router" +import ChunkErrorBoundary from "./components/common/ChunkErrorBoundary" import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh" // 应用启动时,如果用户已登录,立即调度主动 token 刷新 @@ -99,7 +100,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + + + diff --git a/apps/web/src/pages/admin/Admin.css b/apps/web/src/pages/admin/Admin.css index 6eb0fc565..cf549b1a2 100644 --- a/apps/web/src/pages/admin/Admin.css +++ b/apps/web/src/pages/admin/Admin.css @@ -11,7 +11,7 @@ .admin-coming-soon-page { padding: 32px; - max-width: 1400px; + max-width: 1680px; margin: 0 auto; } diff --git a/apps/web/src/pages/ai-avatar/AiAvatar.css b/apps/web/src/pages/ai-avatar/AiAvatar.css new file mode 100644 index 000000000..1fb146ddb --- /dev/null +++ b/apps/web/src/pages/ai-avatar/AiAvatar.css @@ -0,0 +1,1284 @@ +/** + * AI数字人 — 页面样式(v3) + * 命名空间: .aa- (ai-avatar 缩写) + */ + +/* ── 页面容器 ── */ +.aa-page { + display: flex; + flex-direction: column; + height: 100%; + min-width: 1280px; + background: #f5f6fa; + overflow-x: auto; +} + +.aa-page-header { + padding: 16px 24px; + background: #fff; + border-bottom: 1px solid #e8e8ec; + flex-shrink: 0; +} + +.aa-page-header h1 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #1a1a2e; +} + +.aa-page-body { + flex: 1; + display: flex; + gap: 12px; + padding: 16px; + overflow-x: auto; + min-height: 0; +} + +/* ── 面板通用 ── */ +.aa-panel { + background: #fff; + border-radius: 12px; + border: 1px solid #e8e8ec; + display: flex; + flex-direction: column; + overflow: hidden; + transition: width 0.2s ease; +} + +.aa-panel--p1 { + width: 20%; + min-width: 220px; +} +.aa-panel--p2 { + width: 20%; + min-width: 220px; +} +.aa-panel--p3 { + width: 25%; + min-width: 280px; +} +.aa-panel--p4 { + width: 17.5%; + min-width: 200px; +} +.aa-panel--p5 { + width: 17.5%; + min-width: 200px; +} + +.aa-panel.collapsed .aa-panel__body { + display: none; +} + +.aa-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #f0f0f5; + cursor: pointer; + user-select: none; + flex-shrink: 0; +} + +.aa-panel__header:hover { + background: #fafafe; +} + +.aa-panel__title { + font-size: 14px; + font-weight: 600; + color: #1a1a2e; +} + +.aa-panel__toggle { + width: 24px; + height: 24px; + border: none; + background: none; + cursor: pointer; + color: #8c8ca1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: transform 0.2s; +} + +.aa-panel.collapsed .aa-panel__toggle { + transform: rotate(-90deg); +} + +.aa-panel__body { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* ── 通用表单元素 ── */ +.aa-label { + display: block; + font-size: 12px; + font-weight: 500; + color: #6b6b80; + margin-bottom: 6px; +} + +.aa-input, +.aa-select { + width: 100%; + padding: 8px 12px; + border: 1px solid #e0e0e8; + border-radius: 8px; + font-size: 13px; + color: #1a1a2e; + background: #fff; + outline: none; + transition: border-color 0.15s; +} + +.aa-input:focus, +.aa-select:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} + +.aa-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid #e0e0e8; + border-radius: 8px; + font-size: 13px; + color: #1a1a2e; + background: #fff; + outline: none; + resize: vertical; + min-height: 100px; + font-family: inherit; + transition: border-color 0.15s; +} + +.aa-textarea:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} + +.aa-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid #e0e0e8; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + background: #fff; + color: #1a1a2e; +} + +.aa-btn:hover { + background: #f5f5fa; + border-color: #c0c0d0; +} +.aa-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.aa-btn--primary { + background: #7c3aed; + color: #fff; + border-color: #7c3aed; +} +.aa-btn--primary:hover { + background: #6d28d9; +} + +.aa-btn--ghost { + background: transparent; + border-color: #d0d0dd; + color: #6b6b80; +} + +.aa-btn--danger { + background: #fee2e2; + border-color: #fca5a5; + color: #dc2626; +} +.aa-btn--danger:hover { + background: #fecaca; +} + +.aa-btn--full { + width: 100%; +} + +.aa-btn--generate { + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: #fff; + border: none; + padding: 12px 20px; + font-size: 15px; + font-weight: 600; + border-radius: 10px; +} +.aa-btn--generate:hover { + background: linear-gradient(135deg, #6d28d9, #9333ea); +} + +/* ── 竖屏视频预览 ── */ +.aa-video-preview { + width: 100%; + aspect-ratio: 9/16; + background: #0f0f1a; + border-radius: 10px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.aa-video-preview video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.aa-video-preview__placeholder { + color: #6b6b80; + font-size: 13px; + text-align: center; +} + +/* ── 上传区域 ── */ +.aa-upload-zone { + border: 2px dashed #d0d0dd; + border-radius: 10px; + padding: 24px 16px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + background: #fafafe; +} + +.aa-upload-zone:hover { + border-color: #7c3aed; + background: #f5f0ff; +} + +.aa-upload-zone__icon { + font-size: 28px; + margin-bottom: 8px; + color: #8c8ca1; +} + +.aa-upload-zone__text { + font-size: 13px; + color: #6b6b80; +} + +/* ── 视频信息卡片 ── */ +.aa-video-info { + margin-top: 10px; + padding: 10px 12px; + background: #f8f8fc; + border-radius: 8px; + font-size: 12px; + color: #6b6b80; +} + +.aa-video-info__row { + display: flex; + justify-content: space-between; + padding: 3px 0; +} + +/* ── 配音面板 ── */ +.aa-voice-source-toggle { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.aa-voice-source-btn { + flex: 1; + padding: 8px; + border: 1px solid #e0e0e8; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + text-align: center; + cursor: pointer; + background: #fff; + color: #6b6b80; + transition: all 0.15s; +} + +.aa-voice-source-btn.active { + background: #f5f0ff; + border-color: #7c3aed; + color: #7c3aed; +} + +.aa-voice-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 240px; + overflow-y: auto; +} + +.aa-voice-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid #e8e8ec; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} + +.aa-voice-card:hover { + border-color: #c0c0d0; + background: #fafafe; +} + +.aa-voice-card.selected { + border-color: #7c3aed; + background: #f5f0ff; +} + +.aa-voice-card__radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #d0d0dd; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.aa-voice-card.selected .aa-voice-card__radio { + border-color: #7c3aed; +} + +.aa-voice-card.selected .aa-voice-card__radio::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: #7c3aed; +} + +.aa-voice-card__info { + flex: 1; + min-width: 0; +} + +.aa-voice-card__name { + font-size: 13px; + font-weight: 500; + color: #1a1a2e; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.aa-voice-card__desc { + font-size: 11px; + color: #8c8ca1; + margin-top: 2px; +} + +.aa-voice-card__preview { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: #f0f0f5; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; +} + +.aa-voice-card__preview:hover { + background: #e0e0ea; +} + +/* ── 配音参数 ── */ +.aa-voice-params { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f5; + display: flex; + flex-direction: column; + gap: 12px; +} + +.aa-voice-params__row { + display: flex; + gap: 8px; +} + +.aa-voice-params__field { + flex: 1; +} + +.aa-clone-entry { + margin-top: 12px; + text-align: center; +} + +/* ── 文案 & 对口型 ── */ +.aa-script-tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.aa-script-tab { + padding: 6px 14px; + border: 1px solid #e0e0e8; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + background: #fff; + color: #6b6b80; + transition: all 0.15s; +} + +.aa-script-tab.active { + background: #7c3aed; + border-color: #7c3aed; + color: #fff; +} + +.aa-char-count { + text-align: right; + font-size: 11px; + color: #8c8ca1; + margin-top: 4px; +} + +.aa-lipsync-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f5; +} + +.aa-lipsync-section__title { + font-size: 13px; + font-weight: 600; + color: #1a1a2e; + margin-bottom: 12px; +} + +.aa-lipsync-preview { + width: 100%; + max-width: 240px; + aspect-ratio: 9/16; + background: #0f0f1a; + border-radius: 10px; + overflow: hidden; + margin: 0 auto 12px auto; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.aa-lipsync-preview video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.aa-lipsync-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.aa-broll-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: #f0fdf4; + border: 1px solid #86efac; + border-radius: 12px; + font-size: 11px; + color: #16a34a; +} + +/* ── 标题配置 ── */ +.aa-title-input { + margin-bottom: 16px; +} + +.aa-title-style-section { + margin-top: 12px; +} + +.aa-subtitle-toggle { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f5; +} + +.aa-checkbox-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #1a1a2e; + cursor: pointer; +} + +/* ── 封面 & 生成 ── */ +.aa-cover-preview { + width: 100%; + max-width: 240px; + aspect-ratio: 9/16; + background: #f0f0f5; + border-radius: 12px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 12px auto; + position: relative; +} + +.aa-cover-preview img { + width: 100%; + height: 100%; + aspect-ratio: 9/16; + object-fit: cover; + display: block; + border-radius: 12px; +} + +.aa-cover-preview__placeholder { + font-size: 12px; + 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; + margin-bottom: 16px; +} + +.aa-generate-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f5; +} + +.aa-config-summary { + margin: 12px 0; + padding: 12px; + background: #f8f8fc; + border-radius: 8px; + font-size: 12px; +} + +.aa-config-summary__row { + display: flex; + justify-content: space-between; + padding: 4px 0; + color: #6b6b80; +} + +.aa-config-summary__value { + color: #1a1a2e; + font-weight: 500; +} + +.aa-config-summary__empty { + color: #c0c0d0; + font-style: italic; +} + +/* ── 状态标签 ── */ +.aa-status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.aa-status-badge--idle { + background: #f0f0f5; + color: #8c8ca1; +} +.aa-status-badge--pending { + background: #fef3c7; + color: #d97706; +} +.aa-status-badge--processing { + background: #dbeafe; + color: #2563eb; +} +.aa-status-badge--completed { + background: #d1fae5; + color: #059669; +} +.aa-status-badge--failed { + background: #fee2e2; + color: #dc2626; +} + +/* ── 进度条 ── */ +.aa-progress { + width: 100%; + height: 6px; + background: #e8e8ec; + border-radius: 3px; + overflow: hidden; + margin: 8px 0; +} + +.aa-progress__bar { + height: 100%; + background: linear-gradient(90deg, #7c3aed, #a855f7); + border-radius: 3px; + transition: width 0.3s; +} + +/* ── 弹窗通用 ── */ +.aa-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.aa-modal { + background: #fff; + border-radius: 14px; + width: 90%; + max-width: 720px; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); +} + +.aa-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #f0f0f5; +} + +.aa-modal__title { + font-size: 16px; + font-weight: 600; + color: #1a1a2e; +} + +.aa-modal__close { + width: 32px; + height: 32px; + border: none; + background: none; + cursor: pointer; + font-size: 18px; + color: #8c8ca1; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.aa-modal__close:hover { + background: #f0f0f5; +} + +.aa-modal__body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.aa-modal__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid #f0f0f5; +} + +/* ── 素材库弹窗 ── */ +.aa-asset-search { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.aa-asset-search .aa-input { + flex: 1; +} + +.aa-asset-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.aa-asset-card { + aspect-ratio: 9/16; + border-radius: 8px; + overflow: hidden; + border: 2px solid transparent; + cursor: pointer; + position: relative; + background: #f0f0f5; + transition: border-color 0.15s; +} + +.aa-asset-card:hover { + border-color: #c0c0d0; +} +.aa-asset-card.selected { + border-color: #7c3aed; +} + +.aa-asset-card img, +.aa-asset-card video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.aa-asset-card__name { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 4px 6px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + color: #fff; + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.aa-asset-card__check { + position: absolute; + top: 6px; + right: 6px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #7c3aed; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +} + +/* ── B-roll 弹窗 ── */ +.aa-broll-modal-body { + display: flex; + gap: 20px; +} + +.aa-broll-left { + flex: 1; +} + +.aa-broll-right { + width: 220px; + flex-shrink: 0; +} + +.aa-broll-asset-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 16px; +} + +.aa-broll-asset-thumb { + aspect-ratio: 9/16; + border-radius: 6px; + overflow: hidden; + border: 2px solid transparent; + cursor: pointer; + position: relative; + background: #f0f0f5; +} + +.aa-broll-asset-thumb.selected { + border-color: #7c3aed; +} + +.aa-broll-asset-thumb.already-selected { + opacity: 0.4; + pointer-events: none; +} + +.aa-broll-asset-thumb.already-selected::after { + content: "已选择"; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; +} + +.aa-broll-asset-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.aa-broll-settings { + display: flex; + flex-direction: column; + gap: 12px; +} + +.aa-broll-mode-toggle { + display: flex; + gap: 6px; +} + +.aa-broll-mode-btn { + flex: 1; + padding: 6px 10px; + border: 1px solid #e0e0e8; + border-radius: 6px; + font-size: 11px; + text-align: center; + cursor: pointer; + background: #fff; + color: #6b6b80; +} + +.aa-broll-mode-btn.active { + background: #f5f0ff; + border-color: #7c3aed; + color: #7c3aed; +} + +.aa-pip-positions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.aa-pip-pos-btn { + padding: 6px; + border: 1px solid #e0e0e8; + border-radius: 6px; + font-size: 11px; + text-align: center; + cursor: pointer; + background: #fff; + color: #6b6b80; +} + +.aa-pip-pos-btn.active { + background: #f5f0ff; + border-color: #7c3aed; + color: #7c3aed; +} + +/* ── 已配置 B-roll 列表 ── */ +.aa-broll-list { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.aa-broll-list__title { + font-size: 12px; + font-weight: 600; + color: #6b6b80; +} + +.aa-broll-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: #f8f8fc; + border-radius: 8px; + font-size: 12px; +} + +.aa-broll-item__thumb { + width: 32px; + height: 32px; + border-radius: 4px; + object-fit: cover; + background: #e8e8ec; +} + +.aa-broll-item__info { + flex: 1; +} + +.aa-broll-item__remove { + width: 24px; + height: 24px; + border: none; + background: none; + cursor: pointer; + color: #dc2626; + font-size: 14px; +} + +/* ── 文案列表(弹窗内) ── */ +.aa-script-list-header { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.aa-script-list-header .aa-input { + flex: 1; +} + +.aa-script-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.aa-script-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid #e8e8ec; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} + +.aa-script-item:hover { + border-color: #c0c0d0; + background: #fafafe; +} +.aa-script-item.selected { + border-color: #7c3aed; + background: #f5f0ff; +} + +.aa-script-item__icon { + font-size: 20px; + color: #8c8ca1; +} + +.aa-script-item__info { + flex: 1; +} + +.aa-script-item__title { + font-size: 13px; + font-weight: 500; + color: #1a1a2e; +} + +.aa-script-item__meta { + font-size: 11px; + color: #8c8ca1; + margin-top: 2px; +} + +.aa-empty { + text-align: center; + padding: 32px 16px; + color: #8c8ca1; + font-size: 13px; +} + +.aa-empty__icon { + 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; +} + +/* 渲染完成后的封面确认区 */ +.aa-finalize-section { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 0 8px; +} + +.aa-finalize-cover { + width: 100%; + max-width: 240px; + aspect-ratio: 9/16; + border-radius: 12px; + overflow: hidden; + position: relative; +} diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx new file mode 100644 index 000000000..e51cff39d --- /dev/null +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -0,0 +1,1210 @@ +/** + * AI数字人 — 主页面(v3 两步骤版 + #1845 配音前置) + * 步骤1:出镜视频 / 配音库 / 文案 → 点击「🎵 生成配音」做 TTS 预合成(同步,~2-3s) + * 步骤2:对口型预览(音频已就绪、B-roll 句子时间戳立即可用)/ 标题配置 / 封面&生成 + */ +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 PanelTitleConfig from "./components/PanelTitleConfig" +import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate" +import { ModalAssetPicker } from "./components/ModalAssetPicker" +import ModalBRollEditor from "./components/ModalBRollEditor" +import ModalCoverSelect from "./components/ModalCoverSelect" +import { + getScripts, + getAssetById, + createLipsyncJob, + getLipsyncJob, + previewTts, + submitRender, + getRenderJob, + generateRenderSmartCover, + finalizeRenderJob, +} from "./api/aiAvatar" +import { getOrCreateDefaultProject } from "@/api/projects" +import type { RenderJob, SentenceTiming } from "./types" +import { + normalizeEmotion, + buildTitleConfigPayload, + buildCoverConfigPayload, +} from "./utils/contract" +import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas" + +/** 面板折叠状态 */ +type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover" + +const AiAvatarPage: React.FC = () => { + const state = useAiAvatar() + const navigate = useNavigate() + const [currentStep, setCurrentStep] = useState<1 | 2>(1) + const [collapsed, setCollapsed] = useState>({ + video: false, + voice: false, + script: false, + lipsync: false, + title: false, + cover: false, + }) + + /* ── #1845 TTS 预合成弹窗 ── */ + const [showTtsModal, setShowTtsModal] = useState(false) + const [ttsProgress, setTtsProgress] = useState(0) + const [ttsErrorMessage, setTtsErrorMessage] = useState("") + const ttsProgressTimerRef = useRef | null>(null) + + /* ── 对口型生成弹窗 ── */ + const [showLipsyncModal, setShowLipsyncModal] = useState(false) + const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">( + "generating", + ) + const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("") + /* ── 渲染进度弹窗 ── */ + const [showRenderModal, setShowRenderModal] = useState(false) + const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">( + "generating", + ) + const [renderProgress, setRenderProgress] = useState(0) + const [renderErrorMessage, setRenderErrorMessage] = useState("") + /* ── 当前渲染任务对象 ── */ + const [currentRenderJob, setCurrentRenderJob] = useState(null) + /* ── 封面选择弹窗 ── */ + const [showCoverModal, setShowCoverModal] = useState(false) + const [selectedCoverUrl, setSelectedCoverUrl] = useState("") + const [finalizeLoading, setFinalizeLoading] = useState(false) + + /* ── 对口型轮询 ── */ + const lipsyncTimerRef = useRef | null>(null) + /* ── 渲染进度轮询 ── */ + const renderTimerRef = useRef | null>(null) + + const togglePanel = useCallback((key: PanelKey) => { + setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })) + }, []) + + /* ── #1845 文案/音色/语速变更时重置 TTS 预合成状态,避免音频与文案不一致 ── */ + useEffect(() => { + if (state.ttsPreview.status !== "idle") { + state.resetTtsPreview() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion]) + + const _clearTtsProgressTimer = useCallback(() => { + if (ttsProgressTimerRef.current) { + clearInterval(ttsProgressTimerRef.current) + ttsProgressTimerRef.current = null + } + }, []) + + useEffect(() => { + return () => { + _clearTtsProgressTimer() + } + }, [_clearTtsProgressTimer]) + + /* ── #1845 步骤1:点击「🎵 生成配音」→ 同步 TTS 预合成 ── */ + const handleGenerateTts = useCallback(async () => { + 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 + } + + // 打开弹窗 & 启动模拟进度条 + setShowTtsModal(true) + setTtsProgress(0) + setTtsErrorMessage("") + state.setTtsPreview({ + audioUrl: null, + duration: 0, + sentenceTimings: [], + status: "generating", + error: null, + }) + + // 模拟进度:每 300ms +10%,到 90% 停住,真完成后瞬间到 100% + _clearTtsProgressTimer() + let fake = 0 + ttsProgressTimerRef.current = setInterval(() => { + fake = Math.min(fake + 10, 90) + setTtsProgress(fake) + if (fake >= 90) { + _clearTtsProgressTimer() + } + }, 300) + + try { + const res = await previewTts({ + voice_id: state.selectedVoice!.voice_id, + script_text: state.scriptText, + speed: state.speed, + emotion: normalizeEmotion(state.emotion), + }) + _clearTtsProgressTimer() + setTtsProgress(100) + state.setTtsPreview({ + audioUrl: res.audio_url, + duration: res.duration, + sentenceTimings: res.sentence_timings as SentenceTiming[], + status: "done", + error: null, + }) + message.success("配音合成完成") + } catch (err) { + _clearTtsProgressTimer() + const errMsg = + (err as { response?: { data?: { message?: string; detail?: unknown } } })?.response?.data + ?.message || (err instanceof Error ? err.message : "配音合成失败,请重试") + setTtsErrorMessage(typeof errMsg === "string" ? errMsg : "配音合成失败,请重试") + state.setTtsPreview({ + audioUrl: null, + duration: 0, + sentenceTimings: [], + status: "failed", + error: typeof errMsg === "string" ? errMsg : "配音合成失败", + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion]) + + const handleRetryTts = useCallback(() => { + handleGenerateTts() + }, [handleGenerateTts]) + + const handleTtsNext = useCallback(() => { + setShowTtsModal(false) + setTtsProgress(0) + setCurrentStep(2) + }, []) + + const handleCancelTts = useCallback(() => { + _clearTtsProgressTimer() + setShowTtsModal(false) + setTtsProgress(0) + setTtsErrorMessage("") + // 若用户在生成中途关闭,把状态重置回 idle,允许重新点击 + if (state.ttsPreview.status === "generating") { + state.resetTtsPreview() + } + }, [_clearTtsProgressTimer, state]) + + /* ── 上一步(返回步骤1,不会丢失 TTS 预合成结果) ── */ + const handlePrevStep = useCallback(() => { + setCurrentStep(1) + }, []) + + /* ── 对口型 ── */ + const handleGenerateLipsync = useCallback(async () => { + const video = state.selectedVideo + const text = state.scriptText.trim() + const missing: string[] = [] + if (!video) missing.push("出镜视频") + if (!text) missing.push("文案") + if (missing.length > 0 || !video) { + message.warning(`请先选择${missing.join("、")}`) + return + } + + // #1845:预合成模式下必须要有 audioUrl(理论上到了步骤2肯定有,兜底防御) + const isPreSynth = state.ttsPreview.status === "done" && !!state.ttsPreview.audioUrl + if (!isPreSynth && !state.selectedVoice) { + message.warning("请先选择音色或完成配音合成") + return + } + + try { + setShowLipsyncModal(true) + setLipsyncStatus("generating") + setLipsyncErrorMessage("") + + const asset = await getAssetById(video.id) + const videoUrl = asset?.file_url + if (!videoUrl) { + console.error("[对口型] file_url 为空,asset:", asset) + setShowLipsyncModal(false) + message.error("获取出镜视频播放地址失败,请重新选择素材") + return + } + + type LipsyncPayload = Parameters[0] + let payload: LipsyncPayload + if (isPreSynth) { + // 预合成模式:传 audio_url + audio_duration + sentence_timings(后端直接提交 MediaKit,~2-3s) + payload = { + video_url: videoUrl, + audio_url: state.ttsPreview.audioUrl!, + audio_duration: state.ttsPreview.duration, + sentence_timings: state.ttsPreview.sentenceTimings, + enable_video_loop: true, + } + } else { + // 降级:TTS 直生(旧路径,前端未预合成时) + payload = { + voice_id: state.selectedVoice!.voice_id, + script_text: state.scriptText, + video_url: videoUrl, + speed: state.speed, + emotion: normalizeEmotion(state.emotion), + } + } + const job = await createLipsyncJob(payload) + state.setLipsyncJob(job) + + // 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running), + // 但仍需轮询等 completed + if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + lipsyncTimerRef.current = setInterval(async () => { + try { + const updated = await getLipsyncJob(job.id) + state.setLipsyncJob(updated) + if (updated.status === "completed") { + 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) + } + }, 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 : "对口型任务提交失败,请重试") + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + state.selectedVideo, + state.selectedVoice, + state.scriptText, + state.speed, + state.emotion, + state.ttsPreview, + ]) + + // 取消对口型生成 + const handleCancelLipsync = useCallback(() => { + if (lipsyncTimerRef.current) { + clearInterval(lipsyncTimerRef.current) + lipsyncTimerRef.current = null + } + setShowLipsyncModal(false) + setLipsyncStatus("generating") + setLipsyncErrorMessage("") + }, []) + + // 清理轮询 + useEffect(() => { + return () => { + if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + if (renderTimerRef.current) clearInterval(renderTimerRef.current) + } + }, []) + + /* ── B-roll 弹窗可用的句子时间戳:优先 lipsyncJob.sentence_timings,否则用 ttsPreview.sentenceTimings ── */ + const bRollSentenceTimings: SentenceTiming[] | undefined = + (state.lipsyncJob?.sentence_timings as SentenceTiming[] | undefined) ?? + (state.ttsPreview.status === "done" ? state.ttsPreview.sentenceTimings : undefined) + + /* ── B-roll 可用的总时长:优先 lipsyncJob.output_duration,否则用 ttsPreview.duration ── */ + const bRollDuration = state.lipsyncJob?.output_duration || state.ttsPreview.duration || 0 + + /* ── 生成视频(含实时进度轮询) ── */ + const handleGenerate = useCallback(async () => { + if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") { + message.warning("请先生成对口型视频,待对口型完成后再提交渲染") + return + } + state.setIsGenerating(true) + try { + const defaultProject = await getOrCreateDefaultProject() + + // 用 Canvas 预渲染标题为 PNG dataURL + let titleImageDataUrl: string | null = null + if (state.titleConfig.title?.trim()) { + try { + const res = await getVideoResolution(state.lipsyncJob.output_video_url || "") + titleImageDataUrl = renderTitleToPngDataUrl({ + titleConfig: state.titleConfig, + videoWidth: res.width, + videoHeight: res.height, + }) + } catch (canvasErr) { + console.warn("[渲染] 标题 Canvas 渲染失败,降级 drawtext:", canvasErr) + titleImageDataUrl = null + } + } + + const job = 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, titleImageDataUrl), + cover_config: + state.coverConfig.smart_cover_url || + (state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:")) + ? buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url) + : {}, + }) + + setShowRenderModal(true) + setRenderStatus("generating") + setRenderProgress(job.progress ?? 0) + setRenderErrorMessage("") + setCurrentRenderJob(job as RenderJob) + // 每次新渲染重置封面状态 + setSelectedCoverUrl("") + setShowCoverModal(false) + + if (renderTimerRef.current) clearInterval(renderTimerRef.current) + renderTimerRef.current = setInterval(async () => { + try { + const updated = await getRenderJob(job.id) + setRenderProgress(updated.progress ?? 0) + setCurrentRenderJob(updated) + if (updated.status === "completed") { + if (renderTimerRef.current) clearInterval(renderTimerRef.current) + renderTimerRef.current = null + setRenderStatus("completed") + // 不再自动入库/自动跳转:渲染完成后停留在主页面,等用户选封面、点「完成」才入库 + // 如果后端在透传时已经带了封面(旧逻辑兜底),同步本地状态 + if (updated.output_cover_url) { + state.setCoverConfig((prev) => ({ + ...prev, + mode: "auto_frame", + smart_cover_url: updated.output_cover_url, + thumbnail_url: updated.output_cover_url, + })) + setSelectedCoverUrl(updated.output_cover_url) + } + 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("") + }, []) + + /* ── 智能封面 ── */ + const handleGenerateRenderSmartCover = useCallback( + async (renderId: string): Promise<{ cover_url: string; message?: string }> => { + try { + const res = await generateRenderSmartCover(renderId) + if (res.cover_url) { + state.setCoverConfig((prev) => ({ + ...prev, + mode: "auto_frame", + smart_cover_url: res.cover_url, + thumbnail_url: res.cover_url, + })) + message.success("智能封面已生成") + return { cover_url: res.cover_url } + } + const errMsg = res.message || "智能封面生成失败,请稍后重试" + message.error(errMsg) + return { cover_url: "", message: errMsg } + } catch (err) { + console.error("智能封面生成失败:", err) + const errMsg = err instanceof Error ? err.message : "智能封面生成失败,请重试" + message.error(errMsg) + return { cover_url: "", message: errMsg } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + + /* ── 封面弹窗回调 ── */ + const handleCoverSelected = useCallback((coverUrl: string) => { + setSelectedCoverUrl(coverUrl || "") + }, []) + + const handleOpenCoverModal = useCallback(() => { + if (currentRenderJob?.status !== "completed") { + message.warning("请先完成视频生成") + return + } + setShowCoverModal(true) + }, [currentRenderJob]) + + const handleCloseCoverModal = useCallback(() => { + setShowCoverModal(false) + }, []) + + /* ── 自定义上传封面(本地预览,不单独上传;点完成时一起入库) ── */ + const handleUploadCover = useCallback( + (file: File) => { + const url = URL.createObjectURL(file) + state.setCoverConfig((prev) => ({ + ...prev, + mode: "upload", + upload_url: url, + thumbnail_url: url, + })) + setSelectedCoverUrl(url) + }, + [state], + ) + + /* ── 点「完成」:调用 finalize 入库成片库,成功后跳转到成片库 ── */ + const handleFinalize = useCallback(async () => { + if (!currentRenderJob?.id) { + message.error("渲染任务不存在") + return + } + if (currentRenderJob.status !== "completed") { + message.warning("请先完成视频生成") + return + } + setFinalizeLoading(true) + try { + const res = await finalizeRenderJob(currentRenderJob.id) + if (res.data?.status === "success" || res.data?.status === "already_finalized") { + message.success("已保存到成片库") + navigate("/app/products") + } else { + message.error("保存失败,请重试") + } + } catch (err) { + console.error("finalize 失败:", err) + const errMsg = + (err as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail || + (err instanceof Error ? err.message : "保存到成片库失败") + message.error(typeof errMsg === "string" ? errMsg : "保存到成片库失败") + } finally { + setFinalizeLoading(false) + } + }, [currentRenderJob, navigate]) + + /* ── 配置汇总 ── */ + const coverStatus: "not_ready" | "pending" | "selected" = (() => { + if ( + state.coverConfig.smart_cover_url || + state.coverConfig.thumbnail_url || + (state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:")) + ) { + return "selected" + } + if (currentRenderJob?.status === "completed") return "pending" + return "not_ready" + })() + const summary = { + videoName: state.selectedVideo?.name || null, + voiceName: state.selectedVoice?.name || null, + scriptLength: state.scriptText.length, + lipsyncStatus: state.lipsyncJob?.status || null, + brollCount: state.bRollSegments.length, + hasTitle: state.titleConfig.title.length > 0, + coverStatus, + } + + return ( +
+
+

AI数字人

+
+ + {/* 步骤切换导航条 */} +
+ + 1. 视频 / 配音 / 文案 + + + 2. 对口型 / 标题 / 封面 / 生成 + +
+ +
+ {/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */} + {currentStep === 1 && ( + <> + {/* 面板1:出镜视频 */} +
+
togglePanel("video")}> + 出镜视频 + ▼ +
+
+ state.setShowAssetPicker(true)} + onRemoveVideo={state.removeVideo} + /> +
+
+ + {/* 面板2:配音库 */} +
+
togglePanel("voice")}> + 配音库 + ▼ +
+
+ +
+
+ + {/* 面板3:文案 */} +
+
togglePanel("script")}> + 文案 & 对口型 + ▼ +
+
+ state.setShowScriptModal(true)} + /> +
+ + {state.ttsPreview.status === "done" && ( + + )} +
+
+
+ + )} + + {/* ════ 步骤 2:对口型预览 / 标题配置 / 封面&生成 ════ */} + {currentStep === 2 && ( + <> +
+
togglePanel("lipsync")}> + 对口型预览 + ▼ +
+
+ state.setShowBRollModal(true)} + onRemoveBRoll={state.removeBRollSegment} + titleConfig={state.titleConfig} + onTitlePositionChange={(pos) => state.updateTitleConfig(pos)} + /> +
+ +
+
+
+ + {/* 面板4:标题配置 */} +
+
togglePanel("title")}> + 标题配置 + ▼ +
+
+ +
+
+ + {/* 面板5:封面 & 生成 + - 渲染未完成:显示分辨率/配置摘要/「开始生成」按钮(PanelCoverAndGenerate setup 变体,无封面区) + - 渲染完成:显示封面预览 + 「🎬 选择封面」/「✅ 完成」按钮,封面选择在弹窗中完成 */} +
+
togglePanel("cover")}> + + {currentRenderJob?.status === "completed" ? "视频已生成" : "封面 & 生成"} + + ▼ +
+
+ {currentRenderJob?.status !== "completed" ? ( + + state.setCoverConfig((prev) => ({ ...prev, ...partial })) + } + renderJob={currentRenderJob} + onGenerateRenderSmartCover={handleGenerateRenderSmartCover} + resolution={state.resolution} + onResolutionChange={state.setResolution} + isGenerating={state.isGenerating} + onGenerate={handleGenerate} + summary={summary} + /> + ) : ( +
+
+ ✅ 视频生成完成,请选择封面后点「完成」入库 +
+
+ {selectedCoverUrl ? ( + 封面预览 + ) : ( +
+ 🎬 + 尚未选择封面 +
+ )} +
+
+ + +
+
+ )} +
+
+ + )} +
+ + {/* 素材库弹窗 */} + {state.showAssetPicker && ( + state.setShowAssetPicker(false)} + onSelect={state.selectVideo} + selectedId={state.selectedVideo?.id} + /> + )} + + {/* 文案选择弹窗 */} + {state.showScriptModal && ( + state.setShowScriptModal(false)} + onSelect={state.selectScript} + /> + )} + + {/* B-roll 编辑器弹窗 — #1845:timings 在对口型完成前就可用(来自 TTS 预合成) */} + {state.showBRollModal && ( + state.setShowBRollModal(false)} + existingSegments={state.bRollSegments} + scriptText={state.lipsyncJob?.script_text || state.scriptText} + outputDuration={bRollDuration} + sentenceTimings={bRollSentenceTimings} + onConfirm={state.addBRollSegment} + onRemove={state.removeBRollSegment} + /> + )} + + {/* #1845 TTS 预合成弹窗 */} + {showTtsModal && ( +
+
e.stopPropagation()}> +
+ 配音合成中 + {state.ttsPreview.status !== "generating" && ( + + )} +
+
+ {state.ttsPreview.status === "generating" && ( + <> +
+
+ 正在合成配音,请稍候… +
+
+ {ttsProgress}% +
+
+
+
+
+ 请勿关闭页面,完成后将自动提示 +
+ + )} + {state.ttsPreview.status === "done" && ( + <> +
✅
+
+ 配音合成完成,点击下一步继续 +
+
+ 音频时长 {state.ttsPreview.duration.toFixed(1)}s,共{" "} + {state.ttsPreview.sentenceTimings.length} 句 +
+ + )} + {state.ttsPreview.status === "failed" && ( + <> +
❌
+
配音合成失败
+ {ttsErrorMessage && ( +
+ {ttsErrorMessage} +
+ )} + + )} +
+
+ {state.ttsPreview.status === "generating" && ( + + )} + {state.ttsPreview.status === "done" && ( + + )} + {state.ttsPreview.status === "failed" && ( + <> + + + + )} +
+
+
+ )} + + {/* 对口型生成弹窗 */} + {showLipsyncModal && ( +
+
e.stopPropagation()}> +
+ 对口型生成 + +
+
+ {lipsyncStatus === "generating" && ( + <> +
+
+ 对口型视频生成中… +
+
+ 请勿关闭页面,完成后将自动提示 +
+ + )} + {lipsyncStatus === "completed" && ( + <> +
✅
+
+ 对口型视频生成完成 +
+ + )} + {lipsyncStatus === "failed" && ( + <> +
❌
+
+ 对口型生成失败 +
+ {lipsyncErrorMessage && ( +
+ {lipsyncErrorMessage} +
+ )} + + )} +
+
+ {lipsyncStatus === "generating" && ( + + )} + {lipsyncStatus !== "generating" && ( + + )} +
+
+
+ )} + + {/* 渲染进度弹窗 */} + {showRenderModal && ( +
+
e.stopPropagation()}> +
+ 视频渲染 + +
+
+ {renderStatus === "generating" && ( + <> +
+
+ 正在生成视频,请稍后 +
+
+ {renderProgress}% +
+
+
+
+
+ 请勿关闭页面,完成后将自动提示 +
+ + )} + {renderStatus === "completed" && ( + <> +
✅
+
+ 视频生成完成,请选择封面 +
+
+ 关闭此窗口后可在下方选择封面并保存到成片库 +
+ + + )} + {renderStatus === "failed" && ( + <> +
❌
+
视频渲染失败
+ {renderErrorMessage && ( +
+ {renderErrorMessage} +
+ )} + + )} +
+
+ {renderStatus === "generating" && ( + + )} + {renderStatus !== "generating" && ( + + )} +
+
+
+ )} + + {/* 封面选择弹窗 */} + state.setCoverConfig((prev) => ({ ...prev, ...partial }))} + onGenerateRenderSmartCover={handleGenerateRenderSmartCover} + onUploadCover={handleUploadCover} + onCoverSelected={handleCoverSelected} + /> +
+ ) +} + +/** 文案选择弹窗(内联实现,轻量版) */ +const ScriptSelectModalLazy: React.FC<{ + open: boolean + onClose: () => void + onSelect: (script: import("./types").Script) => void +}> = ({ open, onClose, onSelect }) => { + const [scripts, setScripts] = useState([]) + const [search, setSearch] = useState("") + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!open) return + setLoading(true) + getScripts() + .then((items) => setScripts(Array.isArray(items) ? items : [])) + .catch(() => setScripts([])) + .finally(() => setLoading(false)) + }, [open]) + + const filtered = scripts.filter( + (s) => !search || s.title.includes(search) || s.content.includes(search), + ) + + return ( +
+
e.stopPropagation()}> +
+ 选择文案 + +
+
+
+ setSearch(e.target.value)} + /> +
+ {loading ? ( +
加载中...
+ ) : filtered.length === 0 ? ( +
+
📝
+ 暂无文案,请手动输入或新建 +
+ ) : ( +
+ {filtered.map((s) => ( +
onSelect(s)}> + 📄 +
+
{s.title}
+
+ {s.char_count}字 · {new Date(s.created_at).toLocaleDateString()} +
+
+
+ ))} +
+ )} +
+
+ +
+
+
+ ) +} + +export default AiAvatarPage diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts new file mode 100644 index 000000000..ae3a4159d --- /dev/null +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -0,0 +1,131 @@ +/** + * AI数字人 — API 封装(#1822 契约对齐) + */ +import apiClient from "@/api/client" +import type { Script, LipsyncJob, RenderJob, BRollSegment, SentenceTiming } from "../types" + +/* ── 文案库 ── */ +export const getScripts = async (): Promise => { + const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts") + // 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复) + const data = response.data as unknown + if (Array.isArray(data)) return data + const items = (data as { items?: Script[] })?.items + return Array.isArray(items) ? items : [] +} + +export const getScriptById = async (id: string): Promise