Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb3bc04c83 | |||
| bf7c8b8a08 | |||
| e4ac398397 | |||
| a0ca13d463 | |||
| e45a8fe775 | |||
| 800f90d8c6 | |||
| f7825e3956 | |||
| 2cca03f680 | |||
| 277ff5428e | |||
| a4c008e829 | |||
| 24b28bfc89 | |||
| 6904fce511 | |||
| 9dec75c365 | |||
| 7997f083a5 | |||
| cf195685b9 | |||
| ee4636e087 | |||
| 5678779232 | |||
| 44224cfaf6 | |||
| 98cf571ab3 | |||
| 12a9efc65d | |||
| 9c0474ef9c | |||
| 691c811cd4 | |||
| 2114b7e7ae | |||
| 7766ba1479 | |||
| ef686dde8f | |||
| 01026156ae | |||
| 42c0885813 | |||
| cd6d8615e6 | |||
| fcc7863b31 | |||
| 6af2f3c08d | |||
| 410c390cf0 | |||
| 9f85d40855 | |||
| 034eaac695 | |||
| 27dccf6591 | |||
| ac7ab679b7 | |||
| bb98620a8a | |||
| 2a0b75c007 | |||
| 0d9d4e584f | |||
| eb089fa26d | |||
| 1ec274b9f5 | |||
| 62820391c3 | |||
| 322b0a082c | |||
| 07aa03da24 | |||
| add93bb9de | |||
| 6fe3e03d6a | |||
| d73c0a77d8 | |||
| 1087ec181b | |||
| 04463fbdce | |||
| fadca408d4 | |||
| c6a3137949 | |||
| 3447ab2582 | |||
| 37ebf42278 | |||
| d6a2715a26 | |||
| b8073b6c38 | |||
| debb3a5d67 | |||
| 87a61b7f2b | |||
| 4b3feb2d88 | |||
| d8da69970b | |||
| 3557635f93 | |||
| e83a0b86f8 | |||
| 478c9c2bc3 | |||
| bcbcb41750 | |||
| 4c03e05e6c | |||
| 6cdb70bb61 | |||
| ed09794f4d | |||
| 52664f7168 | |||
| 0469272bd6 | |||
| 6638f8b29e | |||
| 794793f992 | |||
| 3fba310b9e | |||
| 0fa5b31f4f | |||
| 68d2319234 | |||
| e148f995a8 | |||
| 873008dde8 | |||
| dddc1cd081 | |||
| 2ce3a5efd3 | |||
| 54916aff86 | |||
| 9a289e1e1f | |||
| 9bca7e53e3 | |||
| 70dde8cbfb | |||
| 528f56254d | |||
| ff60fdf956 | |||
| 06b0bacce1 | |||
| a83ed58864 | |||
| cdcb032e45 | |||
| c8b1c4b8ff | |||
| 5ca64898b7 | |||
| 5575d98512 | |||
| f11b71361d | |||
| e7d6e69396 | |||
| 6a1ec20e68 | |||
| df99305dd6 | |||
| 9840d5d778 | |||
| c8cfed0add | |||
| 8a6d51f6c3 | |||
| 6521be5426 | |||
| 0a7ae8db4f | |||
| 04b4fab131 | |||
| fbfd19fbb9 | |||
| c321ac3af8 | |||
| 21c26b5b26 | |||
| cf83c0df9f | |||
| ca7f875224 | |||
| 1c5060c224 | |||
| 15909a92e5 | |||
| af25045123 | |||
| 7a4aa27f71 | |||
| 28b3010668 | |||
| c1763b995c |
@@ -1187,6 +1187,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..."
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Projects is_default + partial unique index for idempotent default project (Issue #1775)
|
||||
|
||||
Revision ID: 069_project_is_default
|
||||
Revises: 068_user_profile_completed
|
||||
Create Date: 2026-09-08
|
||||
|
||||
背景:
|
||||
小程序端 getOrCreateDefaultProject 在重试/并发/前端重复调用下,
|
||||
仅靠应用层"先查再插"不保证幂等,会给同一用户重复创建默认项目。
|
||||
|
||||
改动:
|
||||
1. projects 表新增 is_default 布尔列(默认 false)
|
||||
2. 部分唯一索引 uq_projects_owner_default:(owner_user_id) WHERE is_default = true
|
||||
—— 保证每个用户至多一个默认项目
|
||||
3. 存量数据回填:把名为"默认项目"的存量项目按创建时间最早者标记为 is_default=true
|
||||
(只标记不删除;存量重复项目的清理另行确认后单独执行)
|
||||
|
||||
注意:部分唯一索引依赖 PostgreSQL,不支持 downgrade 到其他方言。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "069_project_is_default"
|
||||
down_revision = "068_user_profile_completed"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 新增 is_default 列
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column(
|
||||
"is_default",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
# 2. 存量回填:每个拥有"默认项目"的用户,只把最早创建的那一个标记为默认。
|
||||
# 用 ROW_NUMBER() 取每组第一条;非"默认项目"命名的项目不标记(保守,不动用户自建项目)。
|
||||
op.execute("""
|
||||
UPDATE projects p
|
||||
SET is_default = true
|
||||
WHERE p.id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY owner_user_id
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS rn
|
||||
FROM projects
|
||||
WHERE name = '默认项目'
|
||||
) t
|
||||
WHERE t.rn = 1
|
||||
)
|
||||
""")
|
||||
|
||||
# 3. 部分唯一索引:每用户至多一个默认项目(只约束 is_default = true 的行)
|
||||
op.execute("""
|
||||
CREATE UNIQUE INDEX uq_projects_owner_default
|
||||
ON projects (owner_user_id)
|
||||
WHERE is_default = true
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_projects_owner_default")
|
||||
op.drop_column("projects", "is_default")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Add scripts table for oral broadcast script library (Issue #1795)
|
||||
|
||||
Revision ID: 070_add_scripts
|
||||
Revises: 069_project_is_default
|
||||
Create Date: 2026-09-08
|
||||
|
||||
新建 scripts 表,支持口播文案 CRUD + 分段存储。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "070_add_scripts"
|
||||
down_revision = "069_project_is_default"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"scripts",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("segments", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_scripts_user_id", "scripts", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_scripts_user_id", table_name="scripts")
|
||||
op.drop_table("scripts")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add lipsync jobs table
|
||||
|
||||
Revision ID: 071_add_lipsync_jobs
|
||||
Revises: 070_add_scripts
|
||||
Create Date: 2026-09-08
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "071_add_lipsync_jobs"
|
||||
down_revision = "070_add_scripts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lipsync_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("video_url", sa.Text(), nullable=False),
|
||||
sa.Column("audio_url", sa.Text(), nullable=False),
|
||||
sa.Column("enable_video_loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("mediakit_task_id", sa.String(200), nullable=False, server_default="", index=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("error_code", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
# 复合索引:用户 + 状态(列表查询常用)
|
||||
op.create_index("ix_lipsync_jobs_user_status", "lipsync_jobs", ["user_id", "status"])
|
||||
# 项目 + 用户(项目维度查询)
|
||||
op.create_index("ix_lipsync_jobs_project_user", "lipsync_jobs", ["project_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_lipsync_jobs_project_user", table_name="lipsync_jobs")
|
||||
op.drop_index("ix_lipsync_jobs_user_status", table_name="lipsync_jobs")
|
||||
op.drop_table("lipsync_jobs")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add ai avatar render jobs table
|
||||
|
||||
Revision ID: 072_add_ai_avatar_render
|
||||
Revises: 071_add_lipsync_jobs
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "072_add_ai_avatar_render"
|
||||
down_revision = "071_add_lipsync_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ai_avatar_render_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("lipsync_job_id", sa.String(36), nullable=False),
|
||||
sa.Column("script_id", sa.String(36), nullable=False),
|
||||
sa.Column("b_roll_segments", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("cover_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("progress", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_cover_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_ai_avatar_render_user_status", "ai_avatar_render_jobs", ["user_id", "status"])
|
||||
op.create_index("ix_ai_avatar_render_project_user", "ai_avatar_render_jobs", ["project_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ai_avatar_render_project_user", table_name="ai_avatar_render_jobs")
|
||||
op.drop_index("ix_ai_avatar_render_user_status", table_name="ai_avatar_render_jobs")
|
||||
op.drop_table("ai_avatar_render_jobs")
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""AI数字人渲染合成 API 路由 — #1798.
|
||||
|
||||
接口:
|
||||
POST /api/v1/ai-avatar/render 提交渲染任务
|
||||
GET /api/v1/ai-avatar/render/jobs 任务列表
|
||||
GET /api/v1/ai-avatar/render/{job_id} 任务详情
|
||||
POST /api/v1/ai-avatar/render/{job_id}/cancel 取消任务
|
||||
POST /api/v1/ai-avatar/render/{job_id}/retry 重试失败任务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
)
|
||||
from app.services.ai_avatar_render_service import (
|
||||
AiAvatarRenderError,
|
||||
AiAvatarRenderService,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService:
|
||||
return AiAvatarRenderService(db)
|
||||
|
||||
|
||||
# ── POST / — 提交渲染任务 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
|
||||
def create_render_job(
|
||||
body: CreateAiAvatarRenderRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""提交 AI 数字人渲染任务.
|
||||
|
||||
将对口型视频 + B-roll 素材 + 标题叠加 + 封面提取合成最终输出视频。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_render_job(
|
||||
user_id=current_user.id,
|
||||
lipsync_job_id=body.lipsync_job_id,
|
||||
script_id=body.script_id,
|
||||
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
|
||||
title_config=body.title_config,
|
||||
cover_config=body.cover_config,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except AiAvatarRenderError as exc:
|
||||
status_map = {
|
||||
"LipsyncJobNotFound": 404,
|
||||
"LipsyncJobNotCompleted": 400,
|
||||
"LipsyncJobNoOutput": 400,
|
||||
"ScriptNotFound": 404,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status_map.get(exc.code, 400),
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
# 异步触发渲染
|
||||
try:
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=dict)
|
||||
def list_render_jobs(
|
||||
project_id: str = Query("", description="项目 ID 过滤"),
|
||||
status: str = Query("", description="状态过滤"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""获取 AI 数字人渲染任务列表."""
|
||||
items, total = svc.list_render_jobs(
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"items": [AiAvatarRenderJobResponse.model_validate(j) for j in items],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /{job_id} — 任务详情 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=AiAvatarRenderJobResponse)
|
||||
def get_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""获取渲染任务详情."""
|
||||
job = svc.get_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /{job_id}/cancel — 取消任务 ─────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel", response_model=AiAvatarRenderJobResponse)
|
||||
def cancel_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""取消渲染任务(仅 pending 状态可取消)."""
|
||||
job = svc.cancel_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "cancelled":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending 可取消",
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /{job_id}/retry — 重试失败任务 ──────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/retry", response_model=AiAvatarRenderJobResponse)
|
||||
def retry_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""重试失败的渲染任务."""
|
||||
job = svc.retry_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"仅 failed 状态的任务可重试,当前状态: {job.status}",
|
||||
)
|
||||
|
||||
# 重新触发渲染
|
||||
try:
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
|
||||
|
||||
return job
|
||||
@@ -20,7 +20,7 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListAssetLibrariesUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
from packages.domain import AssetLibraryKind
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
@@ -120,30 +120,11 @@ def ensure_default_library(
|
||||
|
||||
kind = AssetLibraryKind(request.kind)
|
||||
|
||||
# 查找该项目下同 kind 的素材库,返回第一个
|
||||
existing = asset_library_repository.find_by_project(request.project_id)
|
||||
for lib in existing:
|
||||
if lib.kind == kind:
|
||||
return _to_asset_library_response(lib)
|
||||
|
||||
# 不存在 → 自动创建
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# Issue #1775: 幂等获取/创建——依赖唯一约束 uq_asset_libraries_project_kind,
|
||||
# 并发创建冲突时回滚重查返回已有记录,不再依赖应用层"先查后插",也不会 500。
|
||||
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
|
||||
library = AssetLibrary(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=request.project_id,
|
||||
name=default_name,
|
||||
kind=kind,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
created = asset_library_repository.create(library)
|
||||
return _to_asset_library_response(created)
|
||||
library = asset_library_repository.get_or_create_default_library(request.project_id, kind, name=default_name)
|
||||
return _to_asset_library_response(library)
|
||||
|
||||
|
||||
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ==================== 验证码 & 绑定 ====================
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,39 @@ 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]:
|
||||
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
|
||||
|
||||
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
|
||||
"""
|
||||
ids = [v for v in dict.fromkeys(voice_ids or []) if v]
|
||||
if not ids:
|
||||
return []
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(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) for v in ids]
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
|
||||
return [0.0 for _ in ids]
|
||||
|
||||
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
@@ -113,6 +147,7 @@ def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
rng=None,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
@@ -120,6 +155,8 @@ def _select_assets_from_library(
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||||
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
|
||||
测试可注入固定种子或零噪声随机源获得确定性结果。
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
@@ -132,8 +169,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 视频素材
|
||||
@@ -408,12 +446,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 +460,218 @@ 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}",
|
||||
|
||||
# 解析每变体配音(严格守卫:独立配音长度/缺值 → 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 查最新(与单任务兜底同源)
|
||||
batch_source_plan_id = request.source_edit_plan_id
|
||||
if not batch_source_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id,
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
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,
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
batch_source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
|
||||
|
||||
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)
|
||||
|
||||
# 变体 1..N-1 独立选片
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配)
|
||||
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
|
||||
_single_plan = request.source_edit_plan_id
|
||||
if not _single_plan and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id,
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
_single_plan = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True)
|
||||
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 +685,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 +737,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 +763,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 +771,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 +873,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 +896,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 +986,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 +1016,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 +1037,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)
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""轻量选片接口 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 查最新
|
||||
source_plan_id = request.source_edit_plan_id.strip()
|
||||
if not source_plan_id and request.template_id.strip():
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id.strip(),
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
|
||||
|
||||
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.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
|
||||
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.error("[variant-plans] 选片异常: %s", e, exc_info=True)
|
||||
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))
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型.
|
||||
|
||||
接口:
|
||||
POST /api/v1/lipsync/jobs 提交对口型任务
|
||||
GET /api/v1/lipsync/jobs 任务列表
|
||||
GET /api/v1/lipsync/jobs/{id} 任务详情
|
||||
POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态
|
||||
POST /api/v1/lipsync/jobs/{id}/cancel 取消任务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(db: Session = Depends(get_db_session)) -> LipsyncService:
|
||||
return LipsyncService(db)
|
||||
|
||||
|
||||
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs", response_model=LipsyncJobResponse, status_code=201)
|
||||
def create_lipsync_job(
|
||||
body: CreateLipsyncJobRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""提交对口型任务.
|
||||
|
||||
输入人物视频 + 驱动音频,异步生成口型对齐视频。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=current_user.id,
|
||||
video_url=body.video_url,
|
||||
audio_url=body.audio_url,
|
||||
enable_video_loop=body.enable_video_loop,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
# 创建失败(job 已记录 error),返回 502
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": str(exc),
|
||||
"request_id": exc.request_id,
|
||||
},
|
||||
) from exc
|
||||
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=dict)
|
||||
def list_lipsync_jobs(
|
||||
project_id: str = Query("", description="项目 ID 过滤"),
|
||||
status: str = Query("", description="状态过滤"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务列表."""
|
||||
items, total = svc.list_jobs(
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"items": [LipsyncJobResponse.model_validate(j) for j in items],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /jobs/{job_id} — 任务详情 ────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
|
||||
def get_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务详情."""
|
||||
job = svc.get_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /jobs/{job_id}/refresh — 刷新状态 ───────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/refresh", response_model=LipsyncJobResponse)
|
||||
def refresh_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""从 MediaKit 拉取最新状态并更新."""
|
||||
job = svc.refresh_job_status(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /jobs/{job_id}/cancel — 取消任务 ────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/cancel", response_model=LipsyncJobResponse)
|
||||
def cancel_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""取消对口型任务(仅 pending/submitted 状态可取消)."""
|
||||
job = svc.cancel_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.status != "cancelled":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending/submitted 可取消",
|
||||
)
|
||||
return job
|
||||
@@ -1,13 +1,14 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_project_repository
|
||||
from app.dependencies import get_asset_library_repository, get_project_repository
|
||||
from app.schemas.project import (
|
||||
CreateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from packages.application import (
|
||||
CreateProjectCommand,
|
||||
@@ -16,10 +17,20 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListProjectsUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibraryKind
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DefaultContextResponse(BaseModel):
|
||||
"""幂等默认上下文响应(Issue #1775):默认项目 + 各类型默认素材库 ID。"""
|
||||
|
||||
project_id: str
|
||||
image_library_id: str
|
||||
video_library_id: str
|
||||
voice_library_id: str
|
||||
|
||||
|
||||
def _to_project_response(item) -> ProjectResponse:
|
||||
return ProjectResponse(
|
||||
id=item.id,
|
||||
@@ -72,6 +83,35 @@ def create_project(
|
||||
return _to_project_response(project)
|
||||
|
||||
|
||||
@router.post("/ensure-default", response_model=DefaultContextResponse)
|
||||
def ensure_default_project_and_libraries(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
) -> DefaultContextResponse:
|
||||
"""幂等获取/创建当前用户的默认项目和三类默认素材库(Issue #1775)。
|
||||
|
||||
- 同一用户永远只有一个默认项目(部分唯一索引 uq_projects_owner_default)
|
||||
- 同一项目同 kind 永远只有一个默认素材库(唯一约束 uq_asset_libraries_project_kind)
|
||||
- 并发调用/失败重试:唯一约束冲突时返回已存在记录,不报 500
|
||||
- 项目和素材库的创建各自在仓储事务内幂等,冲突回滚后重查返回同一条
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
project = project_repository.get_or_create_default_project(user_id)
|
||||
|
||||
libraries = {}
|
||||
for kind in (AssetLibraryKind.VIDEO, AssetLibraryKind.VOICE, AssetLibraryKind.IMAGE):
|
||||
library = asset_library_repository.get_or_create_default_library(project.id, kind)
|
||||
libraries[kind] = library.id
|
||||
|
||||
return DefaultContextResponse(
|
||||
project_id=project.id,
|
||||
image_library_id=libraries[AssetLibraryKind.IMAGE],
|
||||
video_library_id=libraries[AssetLibraryKind.VIDEO],
|
||||
voice_library_id=libraries[AssetLibraryKind.VOICE],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_project(
|
||||
project_id: str,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Script (口播文案库) CRUD routes — Issue #1795."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.script import (
|
||||
CreateScriptRequest,
|
||||
ScriptListResponse,
|
||||
ScriptResponse,
|
||||
ScriptSegment,
|
||||
UpdateScriptRequest,
|
||||
)
|
||||
from app.services.script_service import ScriptNotFoundError, ScriptService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(session: Session = Depends(get_db_session)) -> ScriptService:
|
||||
return ScriptService(session)
|
||||
|
||||
|
||||
def _to_response(script) -> ScriptResponse:
|
||||
segments = script.segments or []
|
||||
return ScriptResponse(
|
||||
id=script.id,
|
||||
user_id=script.user_id,
|
||||
title=script.title,
|
||||
content=script.content,
|
||||
segments=[
|
||||
ScriptSegment(text=s.get("text", ""), duration=s.get("duration")) if isinstance(s, dict) else s
|
||||
for s in segments
|
||||
],
|
||||
tags=script.tags or [],
|
||||
created_at=script.created_at,
|
||||
updated_at=script.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ScriptListResponse)
|
||||
def list_scripts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
tag: Optional[str] = Query(None, description="按标签筛选"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptListResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
items, total = svc.list_scripts(user_id, skip=skip, limit=limit, tag=tag)
|
||||
return ScriptListResponse(
|
||||
items=[_to_response(i) for i in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ScriptResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_script(
|
||||
request: CreateScriptRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
script = svc.create_script(
|
||||
user_id=user_id,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
segments=[s.model_dump() for s in request.segments],
|
||||
tags=request.tags,
|
||||
)
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.get("/{script_id}", response_model=ScriptResponse)
|
||||
def get_script(
|
||||
script_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
script = svc.get_script(script_id, user_id)
|
||||
except ScriptNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.put("/{script_id}", response_model=ScriptResponse)
|
||||
def update_script(
|
||||
script_id: str,
|
||||
request: UpdateScriptRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
script = svc.update_script(
|
||||
script_id=script_id,
|
||||
user_id=user_id,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
segments=[s.model_dump() for s in request.segments] if request.segments is not None else None,
|
||||
tags=request.tags,
|
||||
)
|
||||
except ScriptNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.delete("/{script_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_script(
|
||||
script_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> Response:
|
||||
user_id = authenticated_user.user.id
|
||||
deleted = svc.delete_script(script_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
|
||||
return
|
||||
@@ -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",
|
||||
|
||||
@@ -106,6 +106,10 @@ def list_templates(
|
||||
tag: str | None = Query(None, description="按标签筛选"),
|
||||
keyword: str | None = Query(None, description="按名称关键词搜索"),
|
||||
mode: str | None = Query(None, description="按剪辑模式筛选"),
|
||||
valid_only: bool = Query(
|
||||
False,
|
||||
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTemplatesResponse:
|
||||
@@ -116,6 +120,7 @@ def list_templates(
|
||||
tag=tag,
|
||||
keyword=keyword,
|
||||
mode=mode,
|
||||
valid_only=valid_only,
|
||||
)
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
|
||||
|
||||
@@ -36,17 +36,11 @@ from app.services.asset_segment_tracker import (
|
||||
remove_used_segment,
|
||||
)
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import (
|
||||
_calc_random_start_time,
|
||||
build_scene_segments,
|
||||
@@ -399,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(
|
||||
@@ -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/空串,这里再归一化一次,
|
||||
|
||||
@@ -41,29 +41,33 @@ def get_draft_plan_id(
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
兼容策略:优先从新模板系统(edit_templates 表)查找,
|
||||
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
|
||||
模板读取遵循单一数据源、显式判定(不使用异常降级):
|
||||
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
|
||||
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
|
||||
模板不存在、已删除或不归属于当前用户时,一律返回 404。
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
|
||||
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get_active(template_id, user_id)
|
||||
is_global_template = tpl_svc.get_template(template_id) is not None
|
||||
if old_template is None and not is_global_template:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 2. 新系统有模板 → 用新服务创建草稿
|
||||
if tpl_svc.get_template(template_id) is not None:
|
||||
# 2. 全局模板(新系统)→ 用新服务创建草稿
|
||||
if is_global_template:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 3. 回退到旧模板系统(templates 表)
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id=user_id)
|
||||
if old_template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 4. 基于旧模板创建草稿计划
|
||||
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
|
||||
@@ -150,7 +150,7 @@ def rollback_template(
|
||||
try:
|
||||
tpl = tpl_svc.rollback_to_version(template_id, request.version)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
return EditorRollbackResponse(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""AI数字人渲染合成管线 API Schema — #1798."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class BRollSegment(BaseModel):
|
||||
"""B-roll 片段配置."""
|
||||
|
||||
script_segment_index: int = Field(..., ge=0, description="对应文案片段索引")
|
||||
asset_url: str = Field(..., description="B-roll 素材 URL")
|
||||
mode: str = Field(..., description="插入模式: fullscreen 或 pip")
|
||||
start_time: float = Field(..., ge=0.0, description="在对口型视频中的起始时间(秒)")
|
||||
end_time: float = Field(..., ge=0.0, description="在对口型视频中的结束时间(秒)")
|
||||
pip_position: Optional[str] = Field("bottom_right", description="pip 模式位置")
|
||||
pip_scale: Optional[float] = Field(0.3, ge=0.05, le=1.0, description="pip 模式缩放比例")
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if v not in ("fullscreen", "pip"):
|
||||
raise ValueError("mode 必须为 fullscreen 或 pip")
|
||||
return v
|
||||
|
||||
@field_validator("asset_url")
|
||||
@classmethod
|
||||
def validate_asset_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("asset_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("asset_url 必须是 HTTP/HTTPS URL")
|
||||
return v
|
||||
|
||||
@field_validator("end_time")
|
||||
@classmethod
|
||||
def validate_end_time(cls, v: float, info: Any) -> float:
|
||||
start = info.data.get("start_time", 0.0)
|
||||
if v <= start:
|
||||
raise ValueError("end_time 必须大于 start_time")
|
||||
return v
|
||||
|
||||
|
||||
class CreateAiAvatarRenderRequest(BaseModel):
|
||||
"""创建渲染任务请求."""
|
||||
|
||||
lipsync_job_id: str = Field(..., description="对口型任务 ID")
|
||||
script_id: str = Field(..., description="文案 ID")
|
||||
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
|
||||
title_config: dict[str, Any] = Field(default_factory=dict, description="标题配置")
|
||||
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
|
||||
project_id: str = Field("", description="项目 ID")
|
||||
|
||||
@field_validator("lipsync_job_id")
|
||||
@classmethod
|
||||
def validate_lipsync_job_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("lipsync_job_id 不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("script_id")
|
||||
@classmethod
|
||||
def validate_script_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("script_id 不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class AiAvatarRenderJobResponse(BaseModel):
|
||||
"""渲染任务响应."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
project_id: str
|
||||
lipsync_job_id: str
|
||||
script_id: str
|
||||
b_roll_segments: list[dict[str, Any]]
|
||||
title_config: dict[str, Any]
|
||||
cover_config: dict[str, Any]
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
submitted_at: Optional[datetime] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AiAvatarRenderProgressResponse(BaseModel):
|
||||
"""渲染进度响应."""
|
||||
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""对口型 API Schema 定义 — #1796."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class LipsyncJobResponse(BaseModel):
|
||||
"""对口型任务响应."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
project_id: str
|
||||
video_url: str
|
||||
audio_url: str
|
||||
enable_video_loop: bool
|
||||
mediakit_task_id: str
|
||||
status: str
|
||||
output_video_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
error_code: str
|
||||
submitted_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreateLipsyncJobRequest(BaseModel):
|
||||
"""创建对口型任务请求."""
|
||||
|
||||
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
|
||||
audio_url: str = Field(..., description="驱动音频 URL(mp3/aac/wav/m4a/flac)")
|
||||
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
|
||||
project_id: str = Field("", description="项目 ID(可选)")
|
||||
|
||||
@field_validator("video_url")
|
||||
@classmethod
|
||||
def validate_video_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("video_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
|
||||
# 仅支持 MP4
|
||||
lower = v.lower().split("?")[0]
|
||||
if not lower.endswith(".mp4"):
|
||||
raise ValueError("video_url 仅支持 MP4 格式")
|
||||
return v
|
||||
|
||||
@field_validator("audio_url")
|
||||
@classmethod
|
||||
def validate_audio_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("audio_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
|
||||
# 支持的音频格式
|
||||
lower = v.lower().split("?")[0]
|
||||
allowed_exts = (".mp3", ".aac", ".wav", ".m4a", ".flac")
|
||||
if not any(lower.endswith(ext) for ext in allowed_exts):
|
||||
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed_exts)}")
|
||||
return v
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Script (口播文案库) Pydantic schemas — Issue #1795."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScriptSegment(BaseModel):
|
||||
"""单段文案."""
|
||||
|
||||
text: str
|
||||
duration: Optional[float] = None
|
||||
|
||||
|
||||
class ScriptResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ScriptListResponse(BaseModel):
|
||||
items: list[ScriptResponse]
|
||||
total: int = 0
|
||||
|
||||
|
||||
class CreateScriptRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = ""
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateScriptRequest(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
content: Optional[str] = None
|
||||
segments: Optional[List[ScriptSegment]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -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(重复提交时返回已存在记录)")
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""AI数字人渲染合成 Service — #1798.
|
||||
|
||||
职责:
|
||||
- 创建/查询/取消渲染任务
|
||||
- 调用 Celery 异步任务执行渲染
|
||||
- B-roll 合成 + 标题叠加 + 封面提取
|
||||
- 用户隔离
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
AiAvatarRenderJob,
|
||||
LipsyncJobModel,
|
||||
ScriptModel,
|
||||
)
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_cover_extract_command,
|
||||
build_title_drawtext_filter,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiAvatarRenderError(Exception):
|
||||
"""渲染服务异常."""
|
||||
|
||||
def __init__(self, message: str, code: str = "RenderError"):
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AiAvatarRenderService:
|
||||
"""AI数字人渲染合成 Service."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_render_job(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
lipsync_job_id: str,
|
||||
script_id: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
title_config: dict[str, Any],
|
||||
cover_config: dict[str, Any],
|
||||
project_id: str = "",
|
||||
) -> AiAvatarRenderJob:
|
||||
"""创建渲染任务.
|
||||
|
||||
Raises:
|
||||
AiAvatarRenderError: 校验失败
|
||||
"""
|
||||
# 1. 验证对口型任务
|
||||
lipsync_job = (
|
||||
self.db.query(LipsyncJobModel)
|
||||
.filter(
|
||||
LipsyncJobModel.id == lipsync_job_id,
|
||||
LipsyncJobModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if lipsync_job is None:
|
||||
raise AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
|
||||
if lipsync_job.status != "completed":
|
||||
raise AiAvatarRenderError(
|
||||
f"对口型任务状态为 {lipsync_job.status},仅 completed 状态可渲染",
|
||||
code="LipsyncJobNotCompleted",
|
||||
)
|
||||
if not lipsync_job.output_video_url:
|
||||
raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput")
|
||||
|
||||
# 2. 验证文案归属
|
||||
script = (
|
||||
self.db.query(ScriptModel)
|
||||
.filter(
|
||||
ScriptModel.id == script_id,
|
||||
ScriptModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if script is None:
|
||||
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
|
||||
|
||||
# 3. 创建渲染任务
|
||||
job_id = str(uuid.uuid4())
|
||||
job = AiAvatarRenderJob(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
lipsync_job_id=lipsync_job_id,
|
||||
script_id=script_id,
|
||||
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in b_roll_segments],
|
||||
title_config=title_config,
|
||||
cover_config=cover_config,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""获取渲染任务详情(用户隔离)."""
|
||||
return (
|
||||
self.db.query(AiAvatarRenderJob)
|
||||
.filter(
|
||||
AiAvatarRenderJob.id == job_id,
|
||||
AiAvatarRenderJob.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_render_jobs(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[AiAvatarRenderJob], int]:
|
||||
"""获取渲染任务列表(分页 + 用户隔离)."""
|
||||
query = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.user_id == user_id)
|
||||
if project_id:
|
||||
query = query.filter(AiAvatarRenderJob.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(AiAvatarRenderJob.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(AiAvatarRenderJob.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""取消渲染任务(仅 pending 状态可取消)."""
|
||||
job = self.get_render_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 重试任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def retry_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""重试失败的渲染任务."""
|
||||
job = self.get_render_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status != "failed":
|
||||
return None
|
||||
job.status = "pending"
|
||||
job.progress = 0
|
||||
job.error_message = ""
|
||||
job.output_video_url = ""
|
||||
job.output_cover_url = ""
|
||||
job.output_duration = 0.0
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 执行渲染(Celery 异步调用) ──────────────────────────────────────
|
||||
|
||||
def execute_render(self, job_id: str) -> None:
|
||||
"""执行渲染管线.
|
||||
|
||||
由 Celery 异步任务调用,流程:
|
||||
1. 下载对口型输出视频 (20%)
|
||||
2. 构建 FFmpeg 滤镜链 (40%)
|
||||
3. 执行 FFmpeg 渲染 (80%)
|
||||
4. 提取封面 (90%)
|
||||
5. 上传到 OSS (95%)
|
||||
6. 更新任务状态 (100%)
|
||||
"""
|
||||
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
|
||||
if job is None:
|
||||
logger.error("渲染任务不存在: %s", job_id)
|
||||
return
|
||||
|
||||
if job.status == "cancelled":
|
||||
logger.info("渲染任务已取消: %s", job_id)
|
||||
return
|
||||
|
||||
try:
|
||||
# 更新状态为 processing
|
||||
job.status = "processing"
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.progress = 5
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
# 获取对口型任务信息
|
||||
lipsync_job = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job.lipsync_job_id).first()
|
||||
if lipsync_job is None:
|
||||
raise AiAvatarRenderError("关联的对口型任务不存在", code="LipsyncJobNotFound")
|
||||
|
||||
# 1. 下载对口型输出视频 (20%)
|
||||
input_video_path = self._download_video(lipsync_job.output_video_url)
|
||||
job.progress = 20
|
||||
self.db.commit()
|
||||
|
||||
# 2. 构建 FFmpeg 滤镜链 (40%)
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
filter_complex = build_broll_overlay_filter(
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
video_duration=lipsync_job.output_duration,
|
||||
)
|
||||
|
||||
# 标题叠加
|
||||
title_filter = build_title_drawtext_filter(job.title_config)
|
||||
if title_filter:
|
||||
if filter_complex:
|
||||
filter_complex += f"[vout]{title_filter}[vout_titled];"
|
||||
else:
|
||||
filter_complex = f"[0:v]{title_filter}[vout_titled];"
|
||||
|
||||
# 清理末尾分号
|
||||
if filter_complex.endswith(";"):
|
||||
filter_complex = filter_complex[:-1]
|
||||
|
||||
# 最终输出标签
|
||||
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
|
||||
|
||||
job.progress = 40
|
||||
self.db.commit()
|
||||
|
||||
# 3. 执行 FFmpeg 渲染 (80%)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_video_path = os.path.join(tmpdir, "output.mp4")
|
||||
|
||||
cmd = self._build_ffmpeg_command(
|
||||
input_video=input_video_path,
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
filter_complex=filter_complex,
|
||||
final_label=final_label,
|
||||
output_path=output_video_path,
|
||||
)
|
||||
|
||||
exit_code = os.system(cmd)
|
||||
if exit_code != 0:
|
||||
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
|
||||
|
||||
job.progress = 80
|
||||
self.db.commit()
|
||||
|
||||
# 4. 提取封面 (90%)
|
||||
cover_path = ""
|
||||
if job.cover_config:
|
||||
cover_path = os.path.join(tmpdir, "cover.jpg")
|
||||
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
|
||||
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
|
||||
cover_exit = os.system(cover_cmd)
|
||||
if cover_exit != 0:
|
||||
logger.warning("封面提取失败,跳过: %s", cover_cmd)
|
||||
cover_path = ""
|
||||
|
||||
job.progress = 90
|
||||
self.db.commit()
|
||||
|
||||
# 5. 上传到 OSS (95%)
|
||||
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
|
||||
job.output_video_url = output_video_url
|
||||
|
||||
if cover_path:
|
||||
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
|
||||
job.output_cover_url = output_cover_url
|
||||
|
||||
# 获取输出视频时长
|
||||
job.output_duration = lipsync_job.output_duration
|
||||
job.progress = 95
|
||||
self.db.commit()
|
||||
|
||||
# 6. 完成
|
||||
job.status = "completed"
|
||||
job.progress = 100
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.info("渲染任务完成: %s", job_id)
|
||||
|
||||
except AiAvatarRenderError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.error("渲染任务失败 [%s]: %s", job_id, exc)
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"渲染异常: {str(exc)}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.exception("渲染任务异常 [%s]", job_id)
|
||||
|
||||
def _download_video(self, url: str) -> str:
|
||||
"""下载视频到临时文件."""
|
||||
import httpx
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
||||
try:
|
||||
with httpx.Client(timeout=120) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
tmp.write(resp.content)
|
||||
return tmp.name
|
||||
except Exception:
|
||||
if os.path.exists(tmp.name):
|
||||
os.unlink(tmp.name)
|
||||
raise
|
||||
|
||||
def _build_ffmpeg_command(
|
||||
self,
|
||||
*,
|
||||
input_video: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
filter_complex: str,
|
||||
final_label: Optional[str],
|
||||
output_path: str,
|
||||
) -> str:
|
||||
"""构建 FFmpeg 命令."""
|
||||
# 输入文件
|
||||
inputs = f"-i {input_video}"
|
||||
for seg in b_roll_segments:
|
||||
asset_url = seg.get("asset_url", "")
|
||||
if asset_url:
|
||||
inputs += f" -i {asset_url}"
|
||||
|
||||
# 滤镜
|
||||
if filter_complex and final_label:
|
||||
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
|
||||
elif filter_complex:
|
||||
filter_arg = f'-filter_complex "{filter_complex}"'
|
||||
else:
|
||||
filter_arg = ""
|
||||
|
||||
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset fast -crf 23 -y {output_path}"
|
||||
|
||||
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
|
||||
"""上传文件到 OSS,返回 URL.
|
||||
|
||||
使用 SharedStorageService 统一存储服务。
|
||||
"""
|
||||
storage = get_shared_storage_service()
|
||||
url = storage.upload_file_smart(local_path, oss_key)
|
||||
if url is None:
|
||||
raise AiAvatarRenderError(
|
||||
f"上传文件到 OSS 失败: {oss_key}",
|
||||
code="OSSUploadFailed",
|
||||
)
|
||||
logger.info("上传文件到 OSS 成功: %s -> %s", local_path, url)
|
||||
return url
|
||||
@@ -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,159 @@ 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,
|
||||
) -> 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: 可选随机数(测试注入种子)。
|
||||
|
||||
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
|
||||
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]
|
||||
target_durations = plan_clip_durations(
|
||||
len(source_clips_data),
|
||||
voice,
|
||||
transition_effects=_effects,
|
||||
transition_durations=_tdurs,
|
||||
)
|
||||
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(变体 0)片段为初始避让对象
|
||||
batch_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
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.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,
|
||||
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 +727,268 @@ 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
|
||||
|
||||
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
|
||||
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] = []
|
||||
|
||||
# 变体 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
|
||||
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)
|
||||
|
||||
# 变体 1..N-1:独立选片
|
||||
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
|
||||
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,
|
||||
)
|
||||
plan_ids.append(variant.id)
|
||||
|
||||
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
|
||||
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
|
||||
|
||||
clip_count = 0
|
||||
if voice_durations and len(voice_durations) > 0:
|
||||
# 从源 plan 获取片段数
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and hasattr(source_plan, "clips"):
|
||||
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
|
||||
|
||||
rhythm_templates_for_variants = []
|
||||
if clip_count > 0:
|
||||
for idx in range(len(plan_ids)):
|
||||
# 每个变体用不同的 seed 选择节奏模板
|
||||
variant_seed = rng.randint(0, 999999)
|
||||
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
|
||||
rhythm_templates_for_variants.append(template)
|
||||
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
|
||||
|
||||
# #1767:BGM 池差异化分配(让批量变体使用不同 BGM / 段落 / 音量)
|
||||
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
|
||||
|
||||
source_bgm_config = {}
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and source_plan.config:
|
||||
source_bgm_config = source_plan.config.get("bgm", {}) or {}
|
||||
|
||||
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in plan_ids]
|
||||
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
|
||||
|
||||
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
|
||||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||||
|
||||
for idx, pid in enumerate(plan_ids):
|
||||
try:
|
||||
perturbation = generate_visual_perturbation(rng)
|
||||
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
|
||||
if idx == 0:
|
||||
perturbation["hflip"] = False
|
||||
config_update = {"visual_perturbation": perturbation}
|
||||
# #1764:写入节奏模板
|
||||
if idx < len(rhythm_templates_for_variants):
|
||||
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
|
||||
# #1765:写入像素级扰动滤镜
|
||||
from packages.domain.variant_plan_selector import generate_pixel_perturbation
|
||||
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
config_update["pixel_perturbation"] = pixel_pert
|
||||
# #1767:写入 BGM 池分配(覆盖 bgm 配置中的 preset_id / audio_offset / volume_adjust_db)
|
||||
if idx < len(bgm_pool_assignments):
|
||||
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
|
||||
existing_bgm.update(bgm_pool_assignments[idx])
|
||||
config_update["bgm"] = existing_bgm
|
||||
self.update_plan_config(pid, config_update)
|
||||
logger.info(
|
||||
"变体 %d 视觉扰动+像素扰动+BGM池: plan=%s vis=%s pix=%s bgm=%s",
|
||||
idx,
|
||||
pid,
|
||||
perturbation,
|
||||
pixel_pert,
|
||||
bgm_pool_assignments[idx] if idx < len(bgm_pool_assignments) else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
|
||||
|
||||
# 标记所有变体 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 +1212,7 @@ class EditPlanService:
|
||||
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
|
||||
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
|
||||
logger.info(
|
||||
"can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d",
|
||||
"can_generate 诊断: plan=%s status=%s total_clips=%d clips_with_asset=%d config_asset_ids_count=%d",
|
||||
plan_id,
|
||||
plan.status,
|
||||
len(clips),
|
||||
@@ -804,7 +1224,7 @@ class EditPlanService:
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
if config_asset_ids:
|
||||
logger.warning(
|
||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配",
|
||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材,从 config.asset_ids(%d个) 自动分配",
|
||||
plan_id,
|
||||
len(clips),
|
||||
len(config_asset_ids),
|
||||
@@ -836,7 +1256,7 @@ class EditPlanService:
|
||||
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
|
||||
else:
|
||||
logger.warning(
|
||||
"can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复",
|
||||
"can_generate 失败: plan=%s clips=%d 均无素材,且 config.asset_ids 为空,无法自动修复",
|
||||
plan_id,
|
||||
len(clips),
|
||||
)
|
||||
|
||||
@@ -34,6 +34,17 @@ from packages.domain.template_clip_converter import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateNotFoundError(Exception):
|
||||
"""模板不存在、已删除或当前用户无权访问.
|
||||
|
||||
与"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
|
||||
"""
|
||||
|
||||
def __init__(self, template_id: str) -> None:
|
||||
self.template_id = template_id
|
||||
super().__init__(f"模板不存在: {template_id}")
|
||||
|
||||
|
||||
class EditTemplateService:
|
||||
"""模板管理服务
|
||||
|
||||
@@ -217,7 +228,14 @@ class EditTemplateService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""列出模板的片段配置"""
|
||||
"""列出模板的片段配置
|
||||
|
||||
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
|
||||
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
|
||||
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
|
||||
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
|
||||
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
|
||||
"""
|
||||
# 确保模板存在
|
||||
self.get_template_or_raise(template_id)
|
||||
return self._clip_config_repo.list_by_template(
|
||||
@@ -227,6 +245,52 @@ class EditTemplateService:
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def list_clip_configs_for_editor(
|
||||
self,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""编辑器读取模板片段配置的单一数据源入口.
|
||||
|
||||
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
|
||||
模板主表按双表现状显式判定,不使用 try/except 控制流:
|
||||
|
||||
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
|
||||
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
|
||||
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 当前登录用户 ID(用于旧表模板归属校验)
|
||||
|
||||
Raises:
|
||||
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
|
||||
"""
|
||||
# 1) 用户自建模板(旧表 templates,归属 user_id)
|
||||
if self._clip_config_repo.template_owned_by(template_id, user_id):
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
|
||||
if self._template_repo.get(template_id) is not None:
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 3) 两表都没有:不存在 / 已删除 / 无权限
|
||||
raise TemplateNotFoundError(template_id)
|
||||
|
||||
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""获取片段配置详情"""
|
||||
return self._clip_config_repo.get(config_id)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
|
||||
|
||||
职责:
|
||||
- 创建/查询/取消对口型任务
|
||||
- 调用 MediaKit 客户端提交异步任务
|
||||
- 轮询更新任务状态
|
||||
- 用户隔离(每个用户只能操作自己的任务)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.services.mediakit_client import (
|
||||
STATUS_COMPLETED,
|
||||
STATUS_FAILED,
|
||||
STATUS_RUNNING,
|
||||
MediaKitClient,
|
||||
MediaKitError,
|
||||
get_mediakit_client,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LipsyncService:
|
||||
"""对口型任务 Service."""
|
||||
|
||||
def __init__(self, db: Session, client: Optional[MediaKitClient] = None):
|
||||
self.db = db
|
||||
self.client = client or get_mediakit_client()
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = False,
|
||||
project_id: str = "",
|
||||
) -> LipsyncJobModel:
|
||||
"""创建对口型任务并提交到 MediaKit.
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
# 1. 创建数据库记录
|
||||
job_id = str(uuid.uuid4())
|
||||
job = LipsyncJobModel(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
# 2. 提交到 MediaKit
|
||||
try:
|
||||
result = self.client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
client_token=job_id, # 幂等控制
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
logger.error("提交对口型任务失败: %s", exc)
|
||||
raise
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""获取任务详情(用户隔离)."""
|
||||
return (
|
||||
self.db.query(LipsyncJobModel)
|
||||
.filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_jobs(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[LipsyncJobModel], int]:
|
||||
"""获取任务列表(分页 + 用户隔离)."""
|
||||
query = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.user_id == user_id)
|
||||
if project_id:
|
||||
query = query.filter(LipsyncJobModel.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(LipsyncJobModel.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(LipsyncJobModel.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── 更新任务状态(轮询) ──────────────────────────────────────────────
|
||||
|
||||
def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""从 MediaKit 拉取最新状态并更新本地记录.
|
||||
|
||||
Returns:
|
||||
更新后的 Job,或 None(任务不存在/不属于该用户)
|
||||
"""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
# 终态不需要再轮询
|
||||
if job.status in (STATUS_COMPLETED, "failed"):
|
||||
return job
|
||||
|
||||
# 未提交的任务不轮询
|
||||
if not job.mediakit_task_id:
|
||||
return job
|
||||
|
||||
try:
|
||||
status_data = self.client.get_task_status(job.mediakit_task_id)
|
||||
except MediaKitError as exc:
|
||||
logger.error("轮询对口型任务状态失败 [%s]: %s", job_id, exc)
|
||||
return job
|
||||
|
||||
mk_status = status_data.get("status", STATUS_RUNNING)
|
||||
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = result.get("video_url", "")
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
# running 状态只更新时间戳
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""取消任务(仅 pending/submitted 状态可取消)."""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
|
||||
return job
|
||||
@@ -0,0 +1,243 @@
|
||||
"""MediaKit 客户端 — 封装火山引擎 AI MediaKit 对口型 API.
|
||||
|
||||
接口文档:https://docs.volcengine.com/docs/6448/2656064
|
||||
|
||||
异步任务流程:
|
||||
1. POST /api/v1/tools/lip-sync 提交对口型任务 → 返回 task_id
|
||||
2. GET /api/v1/tasks/{task_id} 轮询任务状态 → running/completed/failed
|
||||
3. completed 时 result.video_url 为口型对齐视频(临时链接 24h 有效)
|
||||
|
||||
设计原则:
|
||||
- API Key 从配置读取(settings.mediakit_api_key)
|
||||
- 未配置 API Key 时所有方法返回降级响应,不阻塞主流程
|
||||
- HTTP 超时/网络异常统一包装为 MediaKitError
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.config import get_api_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 任务状态常量 ──────────────────────────────────────────────────────────
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
|
||||
class MediaKitError(Exception):
|
||||
"""MediaKit API 调用异常."""
|
||||
|
||||
def __init__(self, message: str, code: str = "", request_id: str = ""):
|
||||
self.code = code
|
||||
self.request_id = request_id
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MediaKitClient:
|
||||
"""火山引擎 AI MediaKit 对口型 API 客户端.
|
||||
|
||||
用法:
|
||||
client = get_mediakit_client()
|
||||
result = client.submit_lipsync(video_url="...", audio_url="...")
|
||||
task_id = result["task_id"]
|
||||
|
||||
status = client.get_task_status(task_id)
|
||||
# {"status": "completed", "result": {"video_url": "...", "duration": 60.5}}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_api_settings()
|
||||
self._api_key = settings.mediakit_api_key
|
||||
self._base_url = settings.mediakit_base_url.rstrip("/")
|
||||
self._timeout = settings.mediakit_timeout
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否已配置 API Key(未配置时自动降级)."""
|
||||
return bool(self._api_key)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── 提交对口型任务 ────────────────────────────────────────────────────
|
||||
|
||||
def submit_lipsync(
|
||||
self,
|
||||
*,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = False,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_args: Optional[str] = None,
|
||||
client_token: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""提交视频口型对齐任务.
|
||||
|
||||
Args:
|
||||
video_url: 人物视频 URL(MP4,≤30min,单人真人)
|
||||
audio_url: 驱动音频 URL(mp3/aac/wav/m4a/flac)
|
||||
enable_video_loop: 音频长于视频时是否循环画面
|
||||
callback_url: 任务完成回调 URL
|
||||
callback_args: 回调时原样返回的自定义参数
|
||||
client_token: 幂等控制 token
|
||||
|
||||
Returns:
|
||||
{"success": True, "task_id": "...", "request_id": "..."}
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"video_url": video_url,
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
if enable_video_loop:
|
||||
payload["enable_video_loop"] = True
|
||||
if callback_url:
|
||||
payload["callback_url"] = callback_url
|
||||
if callback_args:
|
||||
payload["callback_args"] = callback_args[:512] # API 限制 512 字节
|
||||
if client_token:
|
||||
payload["client_token"] = client_token[:64] # API 限制 64 字符
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(
|
||||
f"{self._base_url}/tools/lip-sync",
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text[:500]
|
||||
raise MediaKitError(
|
||||
f"MediaKit API HTTP {exc.response.status_code}: {body}",
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
|
||||
except Exception as exc:
|
||||
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
|
||||
|
||||
if not data.get("success"):
|
||||
error = data.get("error", {})
|
||||
raise MediaKitError(
|
||||
error.get("message", "提交任务失败"),
|
||||
code=error.get("code", "SubmitFailed"),
|
||||
request_id=data.get("request_id", ""),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": data["task_id"],
|
||||
"request_id": data.get("request_id", ""),
|
||||
}
|
||||
|
||||
# ── 查询任务状态 ──────────────────────────────────────────────────────
|
||||
|
||||
def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
"""查询异步任务状态和结果.
|
||||
|
||||
Args:
|
||||
task_id: 提交任务时返回的任务 ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True,
|
||||
"task_id": "...",
|
||||
"status": "running" | "completed" | "failed",
|
||||
"result": {"video_url": "...", "duration": 60.5} | None,
|
||||
"error": {"code": "...", "message": "..."} | None,
|
||||
"created_at": 1777291767,
|
||||
"finished_at": 1777291851 | None,
|
||||
"expires_at": 1777464650 | None,
|
||||
}
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.get(
|
||||
f"{self._base_url}/tasks/{task_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text[:500]
|
||||
raise MediaKitError(
|
||||
f"MediaKit API HTTP {exc.response.status_code}: {body}",
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
|
||||
except Exception as exc:
|
||||
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
|
||||
|
||||
if not data.get("success"):
|
||||
error = data.get("error", {})
|
||||
raise MediaKitError(
|
||||
error.get("message", "查询任务失败"),
|
||||
code=error.get("code", "QueryFailed"),
|
||||
request_id=data.get("request_id", ""),
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"task_id": data.get("task_id", task_id),
|
||||
"status": data.get("status", STATUS_RUNNING),
|
||||
"result": data.get("result"),
|
||||
"created_at": data.get("created_at"),
|
||||
"finished_at": data.get("finished_at"),
|
||||
"expires_at": data.get("expires_at"),
|
||||
}
|
||||
|
||||
# 失败时提取错误信息
|
||||
if data.get("status") == STATUS_FAILED:
|
||||
error_obj = data.get("error", {})
|
||||
result["error"] = {
|
||||
"code": error_obj.get("code", "TaskFailed"),
|
||||
"message": error_obj.get("message", "任务执行失败"),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 单例 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_client: Optional[MediaKitClient] = None
|
||||
|
||||
|
||||
def get_mediakit_client() -> MediaKitClient:
|
||||
"""获取 MediaKit 客户端单例."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = MediaKitClient()
|
||||
return _client
|
||||
|
||||
|
||||
def reset_mediakit_client() -> None:
|
||||
"""重置客户端(测试用)."""
|
||||
global _client
|
||||
_client = None
|
||||
@@ -0,0 +1,109 @@
|
||||
"""ScriptService — Issue #1795 口播文案库 CRUD.
|
||||
|
||||
纯 Service 层封装,routes 直接调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import ScriptModel
|
||||
|
||||
|
||||
class ScriptNotFoundError(Exception):
|
||||
"""文案不存在或不属于当前用户."""
|
||||
|
||||
|
||||
class ScriptService:
|
||||
"""口播文案 CRUD."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
# ── list ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_scripts(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
tag: Optional[str] = None,
|
||||
) -> tuple[list[ScriptModel], int]:
|
||||
"""返回 (items, total)."""
|
||||
q = self.db.query(ScriptModel).filter(ScriptModel.user_id == user_id)
|
||||
if tag:
|
||||
# JSON 数组包含查询
|
||||
q = q.filter(ScriptModel.tags.contains([tag]))
|
||||
total = q.count()
|
||||
items = q.order_by(ScriptModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── create ────────────────────────────────────────────────────────────
|
||||
|
||||
def create_script(
|
||||
self,
|
||||
user_id: str,
|
||||
title: str,
|
||||
content: str = "",
|
||||
segments: list | None = None,
|
||||
tags: list | None = None,
|
||||
) -> ScriptModel:
|
||||
script = ScriptModel(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
segments=segments if segments is not None else [],
|
||||
tags=tags if tags is not None else [],
|
||||
)
|
||||
self.db.add(script)
|
||||
self.db.commit()
|
||||
self.db.refresh(script)
|
||||
return script
|
||||
|
||||
# ── get ───────────────────────────────────────────────────────────────
|
||||
|
||||
def get_script(self, script_id: str, user_id: str) -> ScriptModel:
|
||||
script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
|
||||
if script is None:
|
||||
raise ScriptNotFoundError(f"Script {script_id} not found")
|
||||
return script
|
||||
|
||||
# ── update ────────────────────────────────────────────────────────────
|
||||
|
||||
def update_script(
|
||||
self,
|
||||
script_id: str,
|
||||
user_id: str,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
segments: Optional[list] = None,
|
||||
tags: Optional[list] = None,
|
||||
) -> ScriptModel:
|
||||
script = self.get_script(script_id, user_id)
|
||||
if title is not None:
|
||||
script.title = title
|
||||
if content is not None:
|
||||
script.content = content
|
||||
if segments is not None:
|
||||
script.segments = segments
|
||||
if tags is not None:
|
||||
script.tags = tags
|
||||
script.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(script)
|
||||
return script
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────
|
||||
|
||||
def delete_script(self, script_id: str, user_id: str) -> bool:
|
||||
script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
|
||||
if script is None:
|
||||
return False
|
||||
self.db.delete(script)
|
||||
self.db.commit()
|
||||
return True
|
||||
@@ -39,6 +39,9 @@ from packages.domain.video_filter_builder import (
|
||||
)
|
||||
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
|
||||
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_title_drawtext_filter,
|
||||
)
|
||||
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
|
||||
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
|
||||
from packages.domain.video_filter_builder import has_audio as _has_audio_func
|
||||
@@ -248,6 +251,27 @@ class VideoComposeService:
|
||||
transitions=[c.transition_effect for c in ready_clips],
|
||||
)
|
||||
|
||||
# ── #1789 标题 drawtext 滤镜叠加 ──
|
||||
# 从 plan.config 读取 title_config,生成 drawtext 滤镜链入 filter_complex
|
||||
title_cfg = (plan.config or {}).get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
# 同时兼容 plan.config["title_config"](API 回写路径)
|
||||
if not title_cfg.get("text") and not title_cfg.get("content"):
|
||||
title_cfg_alt = (plan.config or {}).get("title_config", {}) or {}
|
||||
if isinstance(title_cfg_alt, dict) and (title_cfg_alt.get("text") or title_cfg_alt.get("content")):
|
||||
title_cfg = title_cfg_alt
|
||||
drawtext_filter = build_title_drawtext_filter(title_cfg, output_width, output_height)
|
||||
if drawtext_filter:
|
||||
# 将最终输出标签从 [outv] 改为 [composed],再链入 drawtext → [outv]
|
||||
filter_complex = filter_complex.replace("[outv]", "[composed]")
|
||||
filter_complex += f";[composed]{drawtext_filter}[outv]"
|
||||
logger.info(
|
||||
"[#1789] 标题 drawtext 滤镜已注入: plan_id=%s text=%s",
|
||||
plan_id,
|
||||
(title_cfg.get("text") or title_cfg.get("content") or "")[:30],
|
||||
)
|
||||
|
||||
# 构建完整命令
|
||||
command: list[str] = ["ffmpeg", "-y"]
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Celery 异步任务模块."""
|
||||
@@ -0,0 +1,48 @@
|
||||
"""AI数字人渲染 Celery 异步任务 — #1798."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="ai_avatar_render.execute", max_retries=2)
|
||||
def execute_ai_avatar_render(self, job_id: str) -> dict:
|
||||
"""执行 AI 数字人渲染管线.
|
||||
|
||||
进度更新:
|
||||
- 0%: 任务开始
|
||||
- 20%: 下载对口型视频完成
|
||||
- 40%: 滤镜链构建完成
|
||||
- 80%: FFmpeg 渲染完成
|
||||
- 95%: 上传 OSS 完成
|
||||
- 100%: 任务完成
|
||||
"""
|
||||
logger.info("开始执行渲染任务: %s", job_id)
|
||||
self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id})
|
||||
|
||||
try:
|
||||
# 获取数据库 session
|
||||
db_gen = get_db_session()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
service = AiAvatarRenderService(db)
|
||||
service.execute_render(job_id)
|
||||
finally:
|
||||
try:
|
||||
next(db_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
return {"status": "completed", "job_id": job_id}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("渲染任务执行异常 [%s]: %s", job_id, exc)
|
||||
self.update_state(state="FAILED", meta={"progress": 0, "error": str(exc)})
|
||||
raise
|
||||
@@ -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`)
|
||||
// 创建失败时停留在标题页并展示错误提示
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#3b82f6"/>
|
||||
<text x="32" y="44" font-size="34" text-anchor="middle">🦐</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 194 B |
@@ -5,10 +5,22 @@ import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { AssetLibraryItem } from "./types"
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get("/asset-libraries")
|
||||
return response.data.items || []
|
||||
/**
|
||||
* 获取当前用户的素材库
|
||||
*
|
||||
* @param kind 可选,按素材库类型过滤(video/voice/image)。
|
||||
* 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的
|
||||
* kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库
|
||||
* 混进来(#1777:视频选择器只展示视频库)。
|
||||
*/
|
||||
export const getAssetLibraries = async (
|
||||
kind?: AssetLibraryItem["kind"],
|
||||
): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", {
|
||||
params: kind ? { kind } : undefined,
|
||||
})
|
||||
const items = response.data.items || []
|
||||
return kind ? items.filter((lib) => lib.kind === kind) : items
|
||||
}
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
|
||||
@@ -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<DirectUploadPrepareResult> => {
|
||||
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<DirectUploadCompleteResult> => {
|
||||
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<DirectUploadHandle> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
// 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示,
|
||||
// 不与 prepare 的签名接口错误混在一起
|
||||
let project: Awaited<ReturnType<typeof getOrCreateDefaultProject>>
|
||||
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<DirectUploadCompleteResult> => {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 对象由 <input> 重选或拖拽重复触发时三个字段均一致)稳定复现;
|
||||
* 不同文件极小概率碰撞时可由后端 file_hash 内容去重兜底。
|
||||
*/
|
||||
export function makeFileFingerprint(file: Pick<File, "name" | "size" | "lastModified">): string {
|
||||
return `${file.name}::${file.size}::${file.lastModified}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 在现有队列项中查找同一文件的在途记录。
|
||||
* 已失败(error)的项允许重试路径复用、已完成(done)的可跳过;
|
||||
* 处于 preparing/uploading/ingesting 的在途项一律视为重复,禁止重复入队。
|
||||
*
|
||||
* 返回命中的队列项 id(tempId),未命中返回 null。
|
||||
*/
|
||||
export function findDuplicateInQueue<T extends { fileKey: string; status: string }>(
|
||||
queue: T[],
|
||||
fileKey: string,
|
||||
excludeStatuses: DedupExcludeStatus[] = [],
|
||||
): T | null {
|
||||
const exclude = new Set<string>(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<ArrayBuffer> {
|
||||
if (typeof blob.arrayBuffer === "function") {
|
||||
return blob.arrayBuffer()
|
||||
}
|
||||
return new Promise<ArrayBuffer>((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<ArrayBuffer> {
|
||||
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<string> {
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<User> => {
|
||||
const response = await apiClient.patch<UpdateProfileResponse>("/auth/me", data)
|
||||
return normalizeUser(response.data.user)
|
||||
}
|
||||
|
||||
@@ -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<WechatAuthUrlResponse> => {
|
||||
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<WechatBindUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/bind/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信绑定完成(扫码回调后用 code 绑定到当前登录账号)
|
||||
*/
|
||||
export const bindWechat = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatBindCompleteResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/bind", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑微信
|
||||
*/
|
||||
export const unbindWechat = async (): Promise<WechatUnbindResponse> => {
|
||||
const response = await apiClient.delete("/auth/wechat/bind")
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -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<WxLoginConstructor> | null = null
|
||||
|
||||
/**
|
||||
* 动态加载微信 WxLogin JS(单例:并发调用复用同一个 promise)。
|
||||
* 加载失败或超时会 reject,调用方应回退到整页跳转授权方式。
|
||||
*/
|
||||
export function loadWxLoginScript(): Promise<WxLoginConstructor> {
|
||||
if (window.WxLogin) return Promise.resolve(window.WxLogin)
|
||||
if (loadPromise) return loadPromise
|
||||
|
||||
loadPromise = new Promise<WxLoginConstructor>((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
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,19 @@ apiClient.interceptors.response.use(
|
||||
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean
|
||||
/**
|
||||
* 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。
|
||||
* 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」,
|
||||
* 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。
|
||||
*/
|
||||
_silentErrorToast?: boolean
|
||||
}
|
||||
|
||||
// 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗
|
||||
if (originalRequest?._silentErrorToast) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(error as any).__msgShown = true
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
|
||||
@@ -12,17 +12,25 @@ import type {
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取模板列表 */
|
||||
/** 获取模板列表
|
||||
*
|
||||
* valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用,
|
||||
* 避免选中无片段配置的模板导致 from-assets 400,#1769/#1772);
|
||||
* 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。
|
||||
* 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。
|
||||
*/
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
validOnly?: boolean
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
...(params?.validOnly ? { valid_only: true } : {}),
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
|
||||
@@ -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<TemplateSegment, "id">[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<ErrorBody>
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 批量变体剪辑计划 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
|
||||
}
|
||||
|
||||
/** 单个变体的计划片段 */
|
||||
export interface VariantPlan {
|
||||
/** 变体序号,从 0 开始 */
|
||||
variant_index: number
|
||||
/** 该变体关联的剪辑计划 ID(正式生成时回传,实现预览即成片) */
|
||||
plan_id: string
|
||||
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
|
||||
clips: EditPlanClip[]
|
||||
}
|
||||
|
||||
/** 批量变体计划响应 */
|
||||
export interface BatchVariantPlansResponse {
|
||||
items: VariantPlan[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量变体剪辑计划并返回各变体片段。
|
||||
*
|
||||
* 注意:端点 404(后端未上线)/ 400(素材不足)等失败由调用方 catch 后降级,
|
||||
* 不要抛 unhandled rejection。
|
||||
*/
|
||||
export async function createBatchVariantPlans(
|
||||
params: BatchVariantPlansRequest,
|
||||
): Promise<BatchVariantPlansResponse> {
|
||||
const response = await apiClient.post<BatchVariantPlansResponse>(
|
||||
"/generation/variant-plans",
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function createClipsFromAssets(
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: { signal?: AbortSignal; silentErrorToast?: boolean },
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
asset_ids: assetIds,
|
||||
@@ -104,7 +104,12 @@ export async function createClipsFromAssets(
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
body,
|
||||
{ timeout: 60000, signal: opts?.signal },
|
||||
{
|
||||
timeout: 60000,
|
||||
signal: opts?.signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
|
||||
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -43,11 +43,17 @@ export async function updateEditPlanClips(
|
||||
templateId: string,
|
||||
clips: EditPlanClipInput[],
|
||||
signal?: AbortSignal,
|
||||
/** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */
|
||||
silentErrorToast?: boolean,
|
||||
): Promise<{ count: number }> {
|
||||
const response = await apiClient.put(
|
||||
`/templates/${templateId}/editor/clips`,
|
||||
{ clips },
|
||||
{ signal },
|
||||
{
|
||||
signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast)
|
||||
...(silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
/** 模板列表分页响应 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<WechatQrScene, string> = {
|
||||
login: "wechat-qr-login-container",
|
||||
bind: "wechat-qr-bind-container",
|
||||
}
|
||||
|
||||
const STATE_STORAGE_KEY: Record<WechatQrScene, string> = {
|
||||
login: "wechat_state",
|
||||
bind: "wechat_bind_state",
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待二维码容器挂载到 DOM。antd Modal 内容通过 portal 渲染且带进场动画,
|
||||
* 父组件 effect 首次执行时容器可能尚未出现在 document 中。
|
||||
*/
|
||||
function waitForContainer(id: string, timeoutMs = 3000): Promise<HTMLElement | null> {
|
||||
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<WechatQrModalProps> = ({
|
||||
open,
|
||||
scene,
|
||||
onClose,
|
||||
onLoginSuccess,
|
||||
onBindSuccess,
|
||||
}) => {
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [status, setStatus] = useState<QrStatus>("loading")
|
||||
const [errorMsg, setErrorMsg] = useState("")
|
||||
/** 刷新二维码计数:变化时重新请求授权链接并重渲染 */
|
||||
const [renderSeq, setRenderSeq] = useState(0)
|
||||
/** 最新授权链接,用于"整页打开"兜底 */
|
||||
const authUrlRef = useRef<string | null>(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 (
|
||||
<Modal
|
||||
title={isLogin ? "微信扫码登录" : "绑定微信"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={380}
|
||||
maskClosable={false}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="xx-wechat-qr-modal">
|
||||
{/* 二维码容器常驻:WxLogin 在 loading 阶段就会把 iframe 渲染进来,
|
||||
不能按 status 条件渲染,否则 effect 里永远找不到容器 */}
|
||||
<div
|
||||
id={CONTAINER_ID[scene]}
|
||||
className="xx-wechat-qr-container"
|
||||
style={{ visibility: status === "qrcode" ? "visible" : "hidden" }}
|
||||
/>
|
||||
|
||||
{status === "loading" && (
|
||||
<div className="xx-wechat-qr-overlay">
|
||||
<Spin size="large" />
|
||||
<p>正在生成微信二维码...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "qrcode" && (
|
||||
<p className="xx-wechat-qr-tip">请使用微信扫描二维码{isLogin ? "登录" : "绑定账号"}</p>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="xx-wechat-qr-overlay xx-wechat-qr-error">
|
||||
<p className="xx-wechat-qr-error-msg">{errorMsg}</p>
|
||||
<div className="xx-wechat-qr-error-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleRefresh}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
{authUrlRef.current && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-wechat-qr-fallback"
|
||||
onClick={handleFullPageRedirect}
|
||||
>
|
||||
使用整页方式打开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatQrModal
|
||||
@@ -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<WechatQrMessageData>
|
||||
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)
|
||||
}
|
||||
@@ -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<Props, State> {
|
||||
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 (
|
||||
<Result status="info" title="系统正在更新" subTitle="检测到新版本,正在自动刷新页面…" />
|
||||
)
|
||||
}
|
||||
|
||||
// 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用;
|
||||
// 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环
|
||||
return (
|
||||
<Result
|
||||
status="warning"
|
||||
title={isChunkError ? "系统已更新" : "页面出现异常"}
|
||||
subTitle={
|
||||
isChunkError
|
||||
? "检测到新版本,请点击下方按钮回到首页加载最新内容。"
|
||||
: "页面加载遇到问题,点击返回首页通常可以恢复,未保存的内容可能丢失。"
|
||||
}
|
||||
extra={
|
||||
<Button type="primary" onClick={goHomeRecover}>
|
||||
{isChunkError ? "刷新并返回首页" : "返回首页"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ChunkErrorBoundary
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
|
||||
/** 导航项类型 */
|
||||
@@ -88,6 +89,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "ai-avatar",
|
||||
label: "AI数字人",
|
||||
path: "/app/ai-avatar",
|
||||
icon: React.createElement(UserOutlined),
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
@@ -131,6 +138,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "ai-avatar",
|
||||
label: "AI数字人",
|
||||
path: "/app/ai-avatar",
|
||||
icon: React.createElement(UserOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑模板",
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN} theme={theme}>
|
||||
<AntApp>
|
||||
<RouterProvider router={router} />
|
||||
<ChunkErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
</ChunkErrorBoundary>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* AI数字人 — 主页面(v3)
|
||||
* 5列水平面板布局
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import "./AiAvatar.css"
|
||||
import { useAiAvatar } from "./hooks/useAiAvatar"
|
||||
import { PanelVideoSelector } from "./components/PanelVideoSelector"
|
||||
import PanelVoiceSelector from "./components/PanelVoiceSelector"
|
||||
import PanelScriptAndLipsync from "./components/PanelScriptAndLipsync"
|
||||
import PanelTitleConfig from "./components/PanelTitleConfig"
|
||||
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
|
||||
import { ModalAssetPicker } from "./components/ModalAssetPicker"
|
||||
import ModalBRollEditor from "./components/ModalBRollEditor"
|
||||
import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
type PanelKey = "video" | "voice" | "script" | "title" | "cover"
|
||||
|
||||
const AiAvatarPage: React.FC = () => {
|
||||
const state = useAiAvatar()
|
||||
const [collapsed, setCollapsed] = useState<Record<PanelKey, boolean>>({
|
||||
video: false,
|
||||
voice: false,
|
||||
script: false,
|
||||
title: false,
|
||||
cover: false,
|
||||
})
|
||||
|
||||
/* ── 素材库弹窗 ── */
|
||||
const [bRollAssets, setBRollAssets] = useState<import("@/api/assets").AssetItem[]>([])
|
||||
|
||||
/* ── 对口型轮询 ── */
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const togglePanel = useCallback((key: PanelKey) => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
/* ── 对口型 ── */
|
||||
const handleGenerateLipsync = useCallback(async () => {
|
||||
if (!state.selectedVideo || !state.selectedVoice || !state.scriptText) return
|
||||
try {
|
||||
const job = await createLipsyncJob({
|
||||
voice_id: state.selectedVoice.voice_id,
|
||||
script_text: state.scriptText,
|
||||
video_asset_id: state.selectedVideo.id,
|
||||
})
|
||||
state.setLipsyncJob(job)
|
||||
// 开始轮询
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getLipsyncJob(job.id)
|
||||
state.setLipsyncJob(updated)
|
||||
if (updated.status === "completed" || updated.status === "failed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
console.error("对口型任务创建失败:", err)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 加载 B-roll 素材 ── */
|
||||
useEffect(() => {
|
||||
getAssetsByKind("video", { limit: 50 })
|
||||
.then(setBRollAssets)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") return
|
||||
state.setIsGenerating(true)
|
||||
try {
|
||||
await submitRender({
|
||||
lipsync_job_id: state.lipsyncJob.id,
|
||||
script_id: state.script?.id,
|
||||
b_roll_segments: state.bRollSegments as never,
|
||||
title_config: state.titleConfig as unknown as Record<string, unknown>,
|
||||
cover_config: state.coverConfig as unknown as Record<string, unknown>,
|
||||
resolution: state.resolution,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("渲染任务提交失败:", err)
|
||||
} finally {
|
||||
state.setIsGenerating(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
state.lipsyncJob,
|
||||
state.script,
|
||||
state.bRollSegments,
|
||||
state.titleConfig,
|
||||
state.coverConfig,
|
||||
state.resolution,
|
||||
])
|
||||
|
||||
/* ── 配置汇总 ── */
|
||||
const summary = {
|
||||
videoName: state.selectedVideo?.name || null,
|
||||
voiceName: state.selectedVoice?.name || null,
|
||||
scriptLength: state.scriptText.length,
|
||||
lipsyncStatus: state.lipsyncJob?.status || null,
|
||||
brollCount: state.bRollSegments.length,
|
||||
hasTitle: state.titleConfig.title.length > 0,
|
||||
hasCover: state.coverConfig.enabled,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-page">
|
||||
<div className="aa-page-header">
|
||||
<h1>AI数字人</h1>
|
||||
</div>
|
||||
<div className="aa-page-body">
|
||||
{/* 面板1:出镜视频 */}
|
||||
<div className={`aa-panel aa-panel--p1${collapsed.video ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
|
||||
<span className="aa-panel__title">出镜视频</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelVideoSelector
|
||||
selectedVideo={state.selectedVideo}
|
||||
onSelectVideo={() => state.setShowAssetPicker(true)}
|
||||
onRemoveVideo={state.removeVideo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板2:配音库 */}
|
||||
<div className={`aa-panel aa-panel--p2${collapsed.voice ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
|
||||
<span className="aa-panel__title">配音库</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelVoiceSelector
|
||||
voiceSource={state.voiceSource}
|
||||
onVoiceSourceChange={state.setVoiceSource}
|
||||
selectedVoice={state.selectedVoice}
|
||||
onSelectVoice={state.setSelectedVoice}
|
||||
emotion={state.emotion}
|
||||
onEmotionChange={state.setEmotion}
|
||||
speed={state.speed}
|
||||
onSpeedChange={state.setSpeed}
|
||||
language={state.language}
|
||||
onLanguageChange={state.setLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板3:文案 & 对口型 */}
|
||||
<div className={`aa-panel aa-panel--p3${collapsed.script ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
|
||||
<span className="aa-panel__title">文案 & 对口型</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelScriptAndLipsync
|
||||
scriptText={state.scriptText}
|
||||
onScriptTextChange={state.setScriptText}
|
||||
onOpenScriptModal={() => state.setShowScriptModal(true)}
|
||||
lipsyncJob={state.lipsyncJob}
|
||||
onGenerateLipsync={handleGenerateLipsync}
|
||||
bRollSegments={state.bRollSegments}
|
||||
onOpenBRollModal={() => state.setShowBRollModal(true)}
|
||||
onRemoveBRoll={state.removeBRollSegment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板4:标题配置 */}
|
||||
<div className={`aa-panel aa-panel--p4${collapsed.title ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
|
||||
<span className="aa-panel__title">标题配置</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelTitleConfig titleConfig={state.titleConfig} onUpdate={state.updateTitleConfig} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板5:封面 & 生成 */}
|
||||
<div className={`aa-panel aa-panel--p5${collapsed.cover ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
|
||||
<span className="aa-panel__title">封面 & 生成</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelCoverAndGenerate
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
summary={summary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材库弹窗 */}
|
||||
{state.showAssetPicker && (
|
||||
<ModalAssetPicker
|
||||
open={state.showAssetPicker}
|
||||
onClose={() => state.setShowAssetPicker(false)}
|
||||
onSelect={state.selectVideo}
|
||||
selectedId={state.selectedVideo?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文案选择弹窗 */}
|
||||
{state.showScriptModal && (
|
||||
<ScriptSelectModalLazy
|
||||
open={state.showScriptModal}
|
||||
onClose={() => state.setShowScriptModal(false)}
|
||||
onSelect={state.selectScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* B-roll 编辑器弹窗 */}
|
||||
{state.showBRollModal && (
|
||||
<ModalBRollEditor
|
||||
open={state.showBRollModal}
|
||||
onClose={() => state.setShowBRollModal(false)}
|
||||
existingSegments={state.bRollSegments}
|
||||
availableAssets={bRollAssets}
|
||||
onConfirm={state.addBRollSegment}
|
||||
onRemove={state.removeBRollSegment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 文案选择弹窗(内联实现,轻量版) */
|
||||
const ScriptSelectModalLazy: React.FC<{
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (script: import("./types").Script) => void
|
||||
}> = ({ open, onClose, onSelect }) => {
|
||||
const [scripts, setScripts] = useState<import("./types").Script[]>([])
|
||||
const [search, setSearch] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getScripts()
|
||||
.then(setScripts)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
const filtered = scripts.filter(
|
||||
(s) => !search || s.title.includes(search) || s.content.includes(search),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择文案</span>
|
||||
<button className="aa-modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-script-list-header">
|
||||
<input
|
||||
className="aa-input"
|
||||
placeholder="搜索文案..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="aa-empty">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📝</div>
|
||||
暂无文案,请手动输入或新建
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-script-list">
|
||||
{filtered.map((s) => (
|
||||
<div key={s.id} className="aa-script-item" onClick={() => onSelect(s)}>
|
||||
<span className="aa-script-item__icon">📄</span>
|
||||
<div className="aa-script-item__info">
|
||||
<div className="aa-script-item__title">{s.title}</div>
|
||||
<div className="aa-script-item__meta">
|
||||
{s.char_count}字 · {new Date(s.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-modal__footer">
|
||||
<button className="aa-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiAvatarPage
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* AI数字人 — API 封装
|
||||
*/
|
||||
import apiClient from "@/api/client"
|
||||
import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types"
|
||||
|
||||
/* ── 文案库 ── */
|
||||
export const getScripts = async (): Promise<Script[]> => {
|
||||
const response = await apiClient.get<Script[]>("/scripts")
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getScriptById = async (id: string): Promise<Script> => {
|
||||
const response = await apiClient.get<Script>(`/scripts/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createScript = async (data: { title: string; content: string }): Promise<Script> => {
|
||||
const response = await apiClient.post<Script>("/scripts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const deleteScript = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
|
||||
/* ── 对口型 ── */
|
||||
export const createLipsyncJob = async (data: {
|
||||
voice_id: string
|
||||
script_text: string
|
||||
video_asset_id: string
|
||||
}): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 渲染 ── */
|
||||
export const submitRender = async (data: {
|
||||
lipsync_job_id: string
|
||||
script_id?: string
|
||||
b_roll_segments?: BRollSegment[]
|
||||
title_config?: Record<string, unknown>
|
||||
cover_config?: Record<string, unknown>
|
||||
project_id?: string
|
||||
resolution?: string
|
||||
}): Promise<RenderJob> => {
|
||||
const response = await apiClient.post<RenderJob>("/ai-avatar/render", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* AI数字人 — 素材库弹窗
|
||||
* 搜索框 + 类型筛选(全部/视频/图片)+ 4 列竖屏 9:16 缩略图网格 + 底部确认选择
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
|
||||
/** 素材类型筛选 */
|
||||
type AssetKindFilter = "all" | "video" | "image"
|
||||
|
||||
export interface ModalAssetPickerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (asset: AssetItem) => void
|
||||
/** 已选中的素材 ID(用于高亮) */
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
const KIND_OPTIONS: { value: AssetKindFilter; label: string }[] = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]
|
||||
|
||||
export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalAssetPickerProps) {
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [kindFilter, setKindFilter] = useState<AssetKindFilter>("video")
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [pickedId, setPickedId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
/* 弹窗打开:重置筛选 / 关键字,并定位高亮到已选素材 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setKeyword("")
|
||||
setKindFilter("video")
|
||||
setAssets([])
|
||||
setError("")
|
||||
setPickedId(selectedId ?? null)
|
||||
setReady(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* 确保默认素材库存在(视频 + 图片,供类型筛选),仅在弹窗打开时执行一次 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
const ensureLibraries = async () => {
|
||||
try {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
await Promise.all([
|
||||
ensureDefaultLibrary({ project_id: project.id, kind: "video" }),
|
||||
ensureDefaultLibrary({ project_id: project.id, kind: "image" }),
|
||||
])
|
||||
if (!cancelled) setReady(true)
|
||||
} catch {
|
||||
if (!cancelled) setError("素材库初始化失败,请重试")
|
||||
}
|
||||
}
|
||||
ensureLibraries()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 拉取素材:类型 / 关键字变化时防抖重新请求 */
|
||||
useEffect(() => {
|
||||
if (!open || !ready) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
const load = async () => {
|
||||
try {
|
||||
const kw = keyword.trim() || undefined
|
||||
let items: AssetItem[] = []
|
||||
if (kindFilter === "all") {
|
||||
const [videos, images] = await Promise.all([
|
||||
getAssetsByKind("video", { keyword: kw }),
|
||||
getAssetsByKind("image", { keyword: kw }),
|
||||
])
|
||||
const seen = new Set<string>()
|
||||
items = [...videos, ...images].filter((a) => {
|
||||
if (seen.has(a.id)) return false
|
||||
seen.add(a.id)
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
items = await getAssetsByKind(kindFilter, { keyword: kw })
|
||||
}
|
||||
if (!cancelled) setAssets(items)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("素材加载失败,请重试")
|
||||
setAssets([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(load, 300)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [open, ready, kindFilter, keyword])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pickedId) return
|
||||
const asset = assets.find((a) => a.id === pickedId)
|
||||
if (asset) onSelect(asset)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择素材</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体:搜索 + 筛选 + 网格 */}
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-asset-search">
|
||||
<input
|
||||
className="aa-input"
|
||||
type="text"
|
||||
placeholder="搜索素材名称…"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="aa-select"
|
||||
style={{ width: 110, flex: "0 0 auto" }}
|
||||
value={kindFilter}
|
||||
onChange={(e) => setKindFilter(e.target.value as AssetKindFilter)}
|
||||
>
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⚠️</div>
|
||||
{error}
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无素材
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-asset-grid">
|
||||
{assets.map((asset) => {
|
||||
const isActive = asset.id === pickedId
|
||||
const thumb = asset.thumbnail_url || asset.file_url
|
||||
const isVideo = asset.mime_type?.includes("video")
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`aa-asset-card${isActive ? " selected" : ""}`}
|
||||
onClick={() => setPickedId(asset.id)}
|
||||
>
|
||||
{isVideo && !asset.thumbnail_url ? (
|
||||
<video src={asset.file_url} muted preload="metadata" />
|
||||
) : (
|
||||
<img src={thumb} alt={asset.name} />
|
||||
)}
|
||||
{isActive && <div className="aa-asset-card__check">✓</div>}
|
||||
<div className="aa-asset-card__name">{asset.name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:取消 + 确认选择 */}
|
||||
<div className="aa-modal__footer">
|
||||
<button type="button" className="aa-btn aa-btn--ghost" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!pickedId}
|
||||
>
|
||||
确认选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗
|
||||
*
|
||||
* 布局:
|
||||
* - 左侧:可用素材网格(已被其他 segment 使用的素材标灰 + "已选择" 遮罩,
|
||||
* pointer-events: none 防止重复选择同一段素材)
|
||||
* - 右侧:插入设置(文案段落索引 / 全屏 or 画中画 / 画中画四角位置 + 大小 / 起止时间)
|
||||
* - 底部:已配置的画面插入列表(可删除)+ 上传新素材入口
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
|
||||
|
||||
interface ModalBRollEditorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前已有的 B-roll segments(用于标灰已选素材) */
|
||||
existingSegments: BRollSegment[]
|
||||
/** 所有可用素材 */
|
||||
availableAssets: AssetItem[]
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
const PIP_POSITION_OPTIONS: { value: PipPosition; label: string }[] = [
|
||||
{ value: "top-left", label: "左上" },
|
||||
{ value: "top-right", label: "右上" },
|
||||
{ value: "bottom-left", label: "左下" },
|
||||
{ value: "bottom-right", label: "右下" },
|
||||
]
|
||||
|
||||
const MODE_LABEL: Record<BRollInsertMode, string> = {
|
||||
fullscreen: "全屏切换",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
existingSegments,
|
||||
availableAssets,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}) => {
|
||||
/* ── 右侧设置本地状态 ── */
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetItem | null>(null)
|
||||
const [scriptSegmentIndex, setScriptSegmentIndex] = useState(0)
|
||||
const [mode, setMode] = useState<BRollInsertMode>("fullscreen")
|
||||
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
|
||||
const [pipScale, setPipScale] = useState(0.3)
|
||||
const [startTime, setStartTime] = useState(0)
|
||||
const [endTime, setEndTime] = useState(3)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
const usedAssetIds = useMemo(
|
||||
() => new Set(existingSegments.map((seg) => seg.asset.id)),
|
||||
[existingSegments],
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
/** 选择素材(已选素材因 pointer-events:none 不会触发) */
|
||||
const handleSelectAsset = (asset: AssetItem) => {
|
||||
if (usedAssetIds.has(asset.id)) return
|
||||
setSelectedAsset(asset)
|
||||
// 默认起止时间:素材时长的前 3 秒(或整段)
|
||||
const dur = asset.duration ?? 3
|
||||
setEndTime(Math.min(3, dur))
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset) return
|
||||
if (endTime <= startTime) return
|
||||
const segment: BRollSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
asset: selectedAsset,
|
||||
script_segment_index: scriptSegmentIndex,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
mode,
|
||||
pip_position: pipPosition,
|
||||
pip_scale: mode === "pip" ? pipScale : 0.3,
|
||||
}
|
||||
onConfirm(segment)
|
||||
// 重置选择,保留设置便于连续添加
|
||||
setSelectedAsset(null)
|
||||
}
|
||||
|
||||
const canConfirm = selectedAsset !== null && endTime > startTime
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">🎞️ 画面插入(B-roll)</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体:左素材 + 右设置 */}
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-broll-modal-body">
|
||||
{/* 左侧:素材网格 */}
|
||||
<div className="aa-broll-left">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#1a1a2e" }}>
|
||||
选择素材({availableAssets.length})
|
||||
</span>
|
||||
<button type="button" className="aa-btn aa-btn--ghost aa-btn-sm">
|
||||
⬆️ 上传新素材
|
||||
</button>
|
||||
</div>
|
||||
<div className="aa-broll-asset-grid">
|
||||
{availableAssets.map((asset) => {
|
||||
const alreadySelected = usedAssetIds.has(asset.id)
|
||||
const isCurrent = selectedAsset?.id === asset.id
|
||||
const classNames = [
|
||||
"aa-broll-asset-thumb",
|
||||
isCurrent ? "selected" : "",
|
||||
alreadySelected ? "already-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={classNames}
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
title={asset.name}
|
||||
>
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
)}
|
||||
<span className="aa-asset-card__name">{asset.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{availableAssets.length === 0 && (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
暂无可用素材,请先上传视频素材
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:插入设置 */}
|
||||
<div className="aa-broll-right">
|
||||
<div className="aa-broll-settings">
|
||||
{/* 文案段落索引 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">文案段落索引</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={scriptSegmentIndex}
|
||||
onChange={(e) => setScriptSegmentIndex(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 插入模式 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">插入模式</label>
|
||||
<div className="aa-broll-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-broll-mode-btn${mode === "fullscreen" ? " active" : ""}`}
|
||||
onClick={() => setMode("fullscreen")}
|
||||
>
|
||||
全屏切换
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-broll-mode-btn${mode === "pip" ? " active" : ""}`}
|
||||
onClick={() => setMode("pip")}
|
||||
>
|
||||
画中画
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画中画:四角位置 + 大小 */}
|
||||
{mode === "pip" && (
|
||||
<>
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">画中画位置</label>
|
||||
<div className="aa-pip-positions">
|
||||
{PIP_POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`aa-pip-pos-btn${
|
||||
pipPosition === opt.value ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setPipPosition(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<div
|
||||
className="aa-field-label-row"
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<label className="aa-label">画中画大小</label>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1" }}>
|
||||
{Math.round(pipScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={0.6}
|
||||
step={0.05}
|
||||
value={pipScale}
|
||||
onChange={(e) => setPipScale(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 起止时间 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">开始时间(秒)</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">结束时间(秒)</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前选中素材提示 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: selectedAsset ? "#059669" : "#8c8ca1",
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
}}
|
||||
>
|
||||
{selectedAsset ? `已选素材:${selectedAsset.name}` : "请从左侧选择一段素材"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:已配置的画面插入列表 */}
|
||||
<div className="aa-broll-list">
|
||||
<div className="aa-broll-list__title">已配置画面插入({existingSegments.length})</div>
|
||||
{existingSegments.length === 0 ? (
|
||||
<div className="aa-empty" style={{ padding: 12 }}>
|
||||
尚未配置画面插入
|
||||
</div>
|
||||
) : (
|
||||
existingSegments.map((seg) => (
|
||||
<div key={seg.id} className="aa-broll-item">
|
||||
{seg.asset.thumbnail_url ? (
|
||||
<img className="aa-broll-item__thumb" src={seg.asset.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="aa-broll-item__thumb" />
|
||||
)}
|
||||
<div className="aa-broll-item__info">
|
||||
<div style={{ fontWeight: 500, color: "#1a1a2e" }}>{seg.asset.name}</div>
|
||||
<div style={{ color: "#8c8ca1", fontSize: 11 }}>
|
||||
段落 {seg.script_segment_index} · {MODE_LABEL[seg.mode]}
|
||||
{seg.mode === "pip" ? ` · ${seg.pip_position}` : ""} ·{" "}
|
||||
{seg.start_time.toFixed(1)}s - {seg.end_time.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-broll-item__remove"
|
||||
title="删除"
|
||||
onClick={() => onRemove(seg.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="aa-modal__footer">
|
||||
<button type="button" className="aa-btn" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
disabled={!canConfirm}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
✅ 添加画面插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalBRollEditor
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* AI数字人 — 面板5:封面 & 生成
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
|
||||
* - 分辨率选择(720p / 1080p / 4K)
|
||||
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
|
||||
* - 渐变紫色生成按钮
|
||||
*
|
||||
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import type { AiAvatarCoverConfig } from "../types"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution: string
|
||||
onResolutionChange: (r: string) => void
|
||||
isGenerating: boolean
|
||||
onGenerate: () => void
|
||||
/** 配置汇总信息 */
|
||||
summary: {
|
||||
videoName: string | null
|
||||
voiceName: string | null
|
||||
scriptLength: number
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
hasCover: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const RESOLUTION_OPTIONS = [
|
||||
{ value: "720p", label: "720p(高清)" },
|
||||
{ value: "1080p", label: "1080p(全高清)" },
|
||||
{ value: "4k", label: "4K(超清)" },
|
||||
]
|
||||
|
||||
const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
idle: { text: "未开始", cls: "aa-status-badge--idle" },
|
||||
pending: { text: "排队中", cls: "aa-status-badge--pending" },
|
||||
processing: { text: "生成中", cls: "aa-status-badge--processing" },
|
||||
completed: { text: "已完成", cls: "aa-status-badge--completed" },
|
||||
failed: { text: "失败", cls: "aa-status-badge--failed" },
|
||||
}
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
resolution,
|
||||
onResolutionChange,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** 自定义上传封面 */
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
// 本地预览:生成 object URL(实际上传由父级/后端链路处理)
|
||||
const url = URL.createObjectURL(file)
|
||||
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
|
||||
// 允许重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 从视频截取(使用配置的帧时间,默认首帧) */
|
||||
const handleCaptureFromVideo = () => {
|
||||
onCoverConfigChange({ mode: "auto_frame" })
|
||||
}
|
||||
|
||||
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
|
||||
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
|
||||
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 封面预览(竖屏 9:16) */}
|
||||
<div className="aa-cover-preview">
|
||||
{coverConfig.thumbnail_url ? (
|
||||
<img src={coverConfig.thumbnail_url} alt="封面预览" />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleCaptureFromVideo}
|
||||
>
|
||||
🎬 从视频截取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分辨率选择 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">分辨率</label>
|
||||
<select
|
||||
className="aa-select"
|
||||
value={resolution}
|
||||
onChange={(e) => onResolutionChange(e.target.value)}
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 配置汇总 */}
|
||||
<div className="aa-generate-section">
|
||||
<div className="aa-config-summary">
|
||||
<div className="aa-config-summary__row">
|
||||
<span>出镜视频</span>
|
||||
{summary.videoName ? (
|
||||
<span className="aa-config-summary__value">{summary.videoName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>音色</span>
|
||||
{summary.voiceName ? (
|
||||
<span className="aa-config-summary__value">{summary.voiceName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>文案</span>
|
||||
{summary.scriptLength > 0 ? (
|
||||
<span className="aa-config-summary__value">{summary.scriptLength} 字</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>对口型</span>
|
||||
{lipsync ? (
|
||||
<span className={`aa-status-badge ${lipsync.cls}`}>{lipsync.text}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>B-roll 画面插入</span>
|
||||
<span className="aa-config-summary__value">
|
||||
{summary.brollCount > 0 ? `${summary.brollCount} 段` : "无"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>标题</span>
|
||||
{summary.hasTitle ? (
|
||||
<span className="aa-config-summary__value">已设置</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>封面</span>
|
||||
{summary.hasCover ? (
|
||||
<span className="aa-config-summary__value">已开启</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--generate aa-btn--full"
|
||||
disabled={!canGenerate}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
{isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"}
|
||||
</button>
|
||||
{summary.lipsyncStatus !== "completed" && !isGenerating && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
|
||||
请先完成对口型生成
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelCoverAndGenerate
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* AI数字人 — 文案 & 对口型面板(面板4)
|
||||
* 上半区:文案(文案库选择 / 手动输入);下半区:对口型视频预览(9:16)+ B-roll 画面
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import type { LipsyncJob, BRollSegment } from "../types"
|
||||
|
||||
interface PanelScriptAndLipsyncProps {
|
||||
scriptText: string
|
||||
onScriptTextChange: (text: string) => void
|
||||
onOpenScriptModal: () => void
|
||||
lipsyncJob: LipsyncJob | null
|
||||
onGenerateLipsync: () => void
|
||||
bRollSegments: BRollSegment[]
|
||||
onOpenBRollModal: () => void
|
||||
onRemoveBRoll: (id: string) => void
|
||||
}
|
||||
|
||||
type ScriptTab = "library" | "manual"
|
||||
|
||||
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
|
||||
fullscreen: "全屏",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function PanelScriptAndLipsync({
|
||||
scriptText,
|
||||
onScriptTextChange,
|
||||
onOpenScriptModal,
|
||||
lipsyncJob,
|
||||
onGenerateLipsync,
|
||||
bRollSegments,
|
||||
onOpenBRollModal,
|
||||
onRemoveBRoll,
|
||||
}: PanelScriptAndLipsyncProps) {
|
||||
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
|
||||
|
||||
/* 对口型状态判断 */
|
||||
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
|
||||
const isDone = lipsyncJob?.status === "completed"
|
||||
const isFailed = lipsyncJob?.status === "failed"
|
||||
|
||||
const statusText =
|
||||
lipsyncJob?.status === "processing"
|
||||
? "对口型生成中…"
|
||||
: lipsyncJob?.status === "pending"
|
||||
? "排队中…"
|
||||
: "对口型生成中…"
|
||||
|
||||
return (
|
||||
<div className="aa-script-lipsync">
|
||||
{/* ── 上半区:文案 ── */}
|
||||
<div className="aa-script-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-script-tab${scriptTab === "library" ? " active" : ""}`}
|
||||
onClick={() => setScriptTab("library")}
|
||||
>
|
||||
从文案库选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-script-tab${scriptTab === "manual" ? " active" : ""}`}
|
||||
onClick={() => setScriptTab("manual")}
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scriptTab === "library" && (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--ghost aa-btn--full"
|
||||
style={{ marginBottom: 8 }}
|
||||
onClick={onOpenScriptModal}
|
||||
>
|
||||
📚 从文案库选择文案
|
||||
</button>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className="aa-textarea"
|
||||
value={scriptText}
|
||||
readOnly={scriptTab === "library"}
|
||||
placeholder={
|
||||
scriptTab === "library" ? "点击上方按钮,从文案库选择文案…" : "请输入数字人口播文案…"
|
||||
}
|
||||
onChange={(e) => onScriptTextChange(e.target.value)}
|
||||
/>
|
||||
<div className="aa-char-count">{scriptText.length} 字</div>
|
||||
|
||||
{/* ── B-roll 画面 ── */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">
|
||||
<span style={{ marginRight: 8 }}>🎞️ 插入画面</span>
|
||||
{bRollSegments.length > 0 && (
|
||||
<span className="aa-broll-badge">🎬 {bRollSegments.length} 个画面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-lipsync-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary aa-btn--full"
|
||||
onClick={onOpenBRollModal}
|
||||
>
|
||||
🎬 插入画面
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bRollSegments.length > 0 && (
|
||||
<div className="aa-broll-list">
|
||||
{bRollSegments.map((seg) => (
|
||||
<div key={seg.id} className="aa-broll-item">
|
||||
{seg.asset.thumbnail_url || seg.asset.file_url ? (
|
||||
<img
|
||||
className="aa-broll-item__thumb"
|
||||
src={seg.asset.thumbnail_url || seg.asset.file_url}
|
||||
alt={seg.asset.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="aa-broll-item__thumb" style={{ padding: "6px 4px" }}>
|
||||
🎬
|
||||
</span>
|
||||
)}
|
||||
<div className="aa-broll-item__info">
|
||||
<div
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{seg.asset.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#8c8ca1", marginTop: 2 }}>
|
||||
{BROLL_MODE_LABEL[seg.mode]} · {formatTime(seg.start_time)}-
|
||||
{formatTime(seg.end_time)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-broll-item__remove"
|
||||
title="删除"
|
||||
onClick={() => onRemoveBRoll(seg.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 下半区:对口型预览(竖屏 9:16) ── */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">对口型预览</div>
|
||||
|
||||
<div className="aa-lipsync-preview">
|
||||
{isDone && lipsyncJob?.output_video_url ? (
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
) : isGenerating ? (
|
||||
<div style={{ width: "80%", textAlign: "center", color: "#fff" }}>
|
||||
<div style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
{statusText} {Math.round(lipsyncJob?.progress ?? 0)}%
|
||||
</div>
|
||||
<div className="aa-progress">
|
||||
<div
|
||||
className="aa-progress__bar"
|
||||
style={{ width: `${lipsyncJob?.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-video-preview__placeholder">
|
||||
{isFailed ? (
|
||||
<>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>❌</div>
|
||||
<div>对口型生成失败</div>
|
||||
{lipsyncJob?.error_message && (
|
||||
<div style={{ fontSize: 11, marginTop: 4, color: "#fca5a5" }}>
|
||||
{lipsyncJob.error_message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"生成对口型视频后在此预览"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-lipsync-actions">
|
||||
{isDone ? (
|
||||
<button type="button" className="aa-btn aa-btn--full" onClick={onGenerateLipsync}>
|
||||
🔄 重新生成对口型
|
||||
</button>
|
||||
) : isGenerating ? (
|
||||
<button type="button" className="aa-btn aa-btn--full" disabled>
|
||||
对口型生成中…
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary aa-btn--full"
|
||||
onClick={onGenerateLipsync}
|
||||
>
|
||||
🎬 生成对口型视频
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelScriptAndLipsync
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* AI数字人 — 面板4:标题配置
|
||||
*
|
||||
* 关键:直接复用智能剪辑(generate)模块的 TitleStylePanel 标题样式面板,
|
||||
* 不重新开发标题预设/字体/位置等样式能力。本组件只负责:
|
||||
* - 主标题文字输入
|
||||
* - AiAvatarTitleConfig ↔ TitleSettings 的双向适配
|
||||
* - 自动生成字幕开关
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
|
||||
import type { TitleSettings } from "@/pages/generate/types"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
TITLE_PRESETS,
|
||||
getFontFamily,
|
||||
} from "@/pages/generate/constants"
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
|
||||
interface PanelTitleConfigProps {
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
onUpdate: (partial: Partial<AiAvatarTitleConfig>) => void
|
||||
}
|
||||
|
||||
const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpdate }) => {
|
||||
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
|
||||
const [activePreset, setActivePreset] = useState<string | null>(null)
|
||||
|
||||
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
|
||||
const titleSettings: TitleSettings = useMemo(
|
||||
() => ({
|
||||
aiAutoSelect: false,
|
||||
title: titleConfig.title,
|
||||
position: titleConfig.position,
|
||||
font: titleConfig.font,
|
||||
size: titleConfig.size,
|
||||
bold: titleConfig.bold,
|
||||
italic: titleConfig.italic,
|
||||
stroke: titleConfig.stroke,
|
||||
shadow: titleConfig.shadow,
|
||||
color: titleConfig.color,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}),
|
||||
[titleConfig],
|
||||
)
|
||||
|
||||
/** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
|
||||
const handleApplyPreset = (presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
setActivePreset(presetKey)
|
||||
onUpdate({
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-title-config">
|
||||
{/* 主标题输入 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">主标题</label>
|
||||
<input
|
||||
className="aa-input aa-title-input"
|
||||
type="text"
|
||||
placeholder="输入视频标题(留空则不显示标题)"
|
||||
value={titleConfig.title}
|
||||
maxLength={30}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
/>
|
||||
{titleConfig.title && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: "6px 8px",
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 6,
|
||||
fontFamily: getFontFamily(titleConfig.font),
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
color: titleConfig.color,
|
||||
textShadow: titleConfig.shadow ? "1px 1px 3px rgba(0,0,0,0.6)" : undefined,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题样式:直接复用智能剪辑 TitleStylePanel(位置/字体/字号/样式/预设) */}
|
||||
<TitleStylePanel
|
||||
settings={titleSettings}
|
||||
onUpdatePosition={(position) => onUpdate({ position })}
|
||||
onUpdateFont={(font) => onUpdate({ font })}
|
||||
onUpdateSize={(size) => onUpdate({ size: Math.min(128, Math.max(16, size)) })}
|
||||
onToggleBold={() => onUpdate({ bold: !titleConfig.bold })}
|
||||
onToggleItalic={() => onUpdate({ italic: !titleConfig.italic })}
|
||||
onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })}
|
||||
onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={TITLE_PRESETS}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
|
||||
{/* 自动生成字幕 */}
|
||||
<div className="aa-subtitle-toggle">
|
||||
<label className="aa-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={titleConfig.auto_subtitle}
|
||||
onChange={(e) => onUpdate({ auto_subtitle: e.target.checked })}
|
||||
/>
|
||||
自动生成字幕
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelTitleConfig
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* AI数字人 — 出镜视频选择面板
|
||||
* - 未选视频:虚线上传区,点击打开素材库弹窗
|
||||
* - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
export interface PanelVideoSelectorProps {
|
||||
selectedVideo: AssetItem | null
|
||||
/** 触发打开素材库弹窗 */
|
||||
onSelectVideo: () => void
|
||||
onRemoveVideo: () => void
|
||||
}
|
||||
|
||||
/** 格式化时长(秒 → mm:ss) */
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
|
||||
return "00:00"
|
||||
}
|
||||
return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function PanelVideoSelector({
|
||||
selectedVideo,
|
||||
onSelectVideo,
|
||||
onRemoveVideo,
|
||||
}: PanelVideoSelectorProps) {
|
||||
/* 未选视频:虚线上传区,点击打开素材库弹窗 */
|
||||
if (!selectedVideo) {
|
||||
return (
|
||||
<div
|
||||
className="aa-upload-zone"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelectVideo}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelectVideo()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="aa-upload-zone__icon">🎬</div>
|
||||
<div className="aa-upload-zone__text">从素材库选择视频</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const width = selectedVideo.metadata?.width
|
||||
const height = selectedVideo.metadata?.height
|
||||
const duration = selectedVideo.duration ?? selectedVideo.metadata?.duration
|
||||
const fileUrl = selectedVideo.file_url ?? ""
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 竖屏 9:16 视频预览播放器 */}
|
||||
<div className="aa-video-preview">
|
||||
{fileUrl ? (
|
||||
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
|
||||
) : (
|
||||
<div className="aa-video-preview__placeholder">视频暂不可预览</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
|
||||
<div className="aa-video-info">
|
||||
<div className="aa-video-info__row">
|
||||
<span>文件名</span>
|
||||
<span title={selectedVideo.name}>{selectedVideo.name}</span>
|
||||
</div>
|
||||
<div className="aa-video-info__row">
|
||||
<span>时长</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
</div>
|
||||
<div className="aa-video-info__row">
|
||||
<span>分辨率</span>
|
||||
<span>{width && height ? `${width}×${height}` : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--danger aa-btn--full"
|
||||
style={{ marginTop: 10 }}
|
||||
onClick={onRemoveVideo}
|
||||
>
|
||||
移除视频
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* AI数字人 — 配音库面板(面板3)
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { fetchVoices } from "@/api/voices/voices"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
VOICE_EMOTION_OPTIONS,
|
||||
VOICE_LANGUAGE_OPTIONS,
|
||||
} from "../types"
|
||||
|
||||
interface PanelVoiceSelectorProps {
|
||||
voiceSource: VoiceSource
|
||||
onVoiceSourceChange: (source: VoiceSource) => void
|
||||
selectedVoice: UnifiedVoiceItem | null
|
||||
onSelectVoice: (voice: UnifiedVoiceItem) => void
|
||||
emotion: VoiceEmotion
|
||||
onEmotionChange: (e: VoiceEmotion) => void
|
||||
speed: number
|
||||
onSpeedChange: (s: number) => void
|
||||
language: VoiceLanguage
|
||||
onLanguageChange: (l: VoiceLanguage) => void
|
||||
}
|
||||
|
||||
export function PanelVoiceSelector({
|
||||
voiceSource,
|
||||
onVoiceSourceChange,
|
||||
selectedVoice,
|
||||
onSelectVoice,
|
||||
emotion,
|
||||
onEmotionChange,
|
||||
speed,
|
||||
onSpeedChange,
|
||||
language,
|
||||
onLanguageChange,
|
||||
}: PanelVoiceSelectorProps) {
|
||||
const [voices, setVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/* 切换来源时重新获取音色列表 */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadVoices = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchVoices({ type: voiceSource })
|
||||
if (!cancelled) setVoices(res.items || [])
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "音色加载失败")
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
loadVoices()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [voiceSource])
|
||||
|
||||
/* 卸载时停止试听 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopPreview = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setPreviewingId(null)
|
||||
}
|
||||
|
||||
const handlePreview = (voice: UnifiedVoiceItem) => {
|
||||
const url = voice.preview_url || voice.audio_url
|
||||
if (!url) return
|
||||
/* 再次点击当前试听音色 → 停止 */
|
||||
if (previewingId === voice.id) {
|
||||
stopPreview()
|
||||
return
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
setPreviewingId(voice.id)
|
||||
audio.onended = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
}
|
||||
}
|
||||
audio.onerror = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
setError("试听音频加载失败")
|
||||
}
|
||||
}
|
||||
void audio.play().catch(() => {
|
||||
setPreviewingId(null)
|
||||
setError("试听播放失败")
|
||||
})
|
||||
}
|
||||
|
||||
const handleSpeedChange = (value: string) => {
|
||||
const parsed = parseFloat(value)
|
||||
if (Number.isNaN(parsed)) return
|
||||
const clamped = Math.min(2.0, Math.max(0.5, parsed))
|
||||
onSpeedChange(clamped)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-voice-selector">
|
||||
{/* 音色来源切换 */}
|
||||
<div className="aa-voice-source-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-voice-source-btn${voiceSource === "preset" ? " active" : ""}`}
|
||||
onClick={() => onVoiceSourceChange("preset")}
|
||||
>
|
||||
系统预设
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-voice-source-btn${voiceSource === "clone" ? " active" : ""}`}
|
||||
onClick={() => onVoiceSourceChange("clone")}
|
||||
>
|
||||
我的音色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 音色列表 */}
|
||||
{loading ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
<div>音色加载中…</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⚠️</div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">🎙️</div>
|
||||
<div>{voiceSource === "clone" ? "还没有克隆音色" : "暂无预置音色"}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-voice-list">
|
||||
{voices.map((voice) => {
|
||||
const selected = selectedVoice?.id === voice.id
|
||||
const previewUrl = voice.preview_url || voice.audio_url
|
||||
return (
|
||||
<div
|
||||
key={voice.id}
|
||||
className={`aa-voice-card${selected ? " selected" : ""}`}
|
||||
onClick={() => onSelectVoice(voice)}
|
||||
>
|
||||
<span className="aa-voice-card__radio" />
|
||||
<div className="aa-voice-card__info">
|
||||
<div className="aa-voice-card__name">{voice.name}</div>
|
||||
{voice.description && (
|
||||
<div className="aa-voice-card__desc">{voice.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-voice-card__preview"
|
||||
title={previewingId === voice.id ? "停止试听" : "试听"}
|
||||
disabled={!previewUrl}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreview(voice)
|
||||
}}
|
||||
>
|
||||
{previewingId === voice.id ? "⏸" : "▶"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 我的音色:克隆入口 */}
|
||||
{voiceSource === "clone" && (
|
||||
<div className="aa-clone-entry">
|
||||
<a href="/app/voice-clone">+ 克隆新音色</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配音参数 */}
|
||||
<div className="aa-voice-params">
|
||||
<div className="aa-voice-params__row">
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-emotion">
|
||||
情绪
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-emotion"
|
||||
className="aa-select"
|
||||
value={emotion}
|
||||
onChange={(e) => onEmotionChange(e.target.value as VoiceEmotion)}
|
||||
>
|
||||
{VOICE_EMOTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-language">
|
||||
语言
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-language"
|
||||
className="aa-select"
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value as VoiceLanguage)}
|
||||
>
|
||||
{VOICE_LANGUAGE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-speed">
|
||||
语速({speed.toFixed(1)}x)
|
||||
</label>
|
||||
<input
|
||||
id="aa-voice-speed"
|
||||
type="number"
|
||||
className="aa-input"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={speed}
|
||||
onChange={(e) => handleSpeedChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelVoiceSelector
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* AI数字人 — 页面全局状态管理 hook(v3)
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
type Script,
|
||||
type LipsyncJob,
|
||||
type BRollSegment,
|
||||
type AiAvatarTitleConfig,
|
||||
type AiAvatarCoverConfig,
|
||||
DEFAULT_TITLE_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
|
||||
export function useAiAvatar() {
|
||||
/* ── 面板1:出镜视频 ── */
|
||||
const [selectedVideo, setSelectedVideo] = useState<AssetItem | null>(null)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
|
||||
/* ── 面板2:配音库 ── */
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
|
||||
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("natural")
|
||||
const [speed, setSpeed] = useState(1.0)
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("mandarin")
|
||||
|
||||
/* ── 面板3:文案 & 对口型 ── */
|
||||
const [script, setScript] = useState<Script | null>(null)
|
||||
const [scriptText, setScriptText] = useState("")
|
||||
const [lipsyncJob, setLipsyncJob] = useState<LipsyncJob | null>(null)
|
||||
const [showScriptModal, setShowScriptModal] = useState(false)
|
||||
const [showBRollModal, setShowBRollModal] = useState(false)
|
||||
|
||||
/* ── 面板3.5:B-roll ── */
|
||||
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
|
||||
|
||||
/* ── 面板4:标题配置 ── */
|
||||
const [titleConfig, setTitleConfig] = useState<AiAvatarTitleConfig>(DEFAULT_TITLE_CONFIG)
|
||||
|
||||
/* ── 面板5:封面 & 生成 ── */
|
||||
const [coverConfig, setCoverConfig] = useState<AiAvatarCoverConfig>(DEFAULT_COVER_CONFIG)
|
||||
const [resolution, setResolution] = useState("1080p")
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
/* ── Actions ── */
|
||||
const selectVideo = useCallback((asset: AssetItem) => {
|
||||
setSelectedVideo(asset)
|
||||
setShowAssetPicker(false)
|
||||
}, [])
|
||||
|
||||
const removeVideo = useCallback(() => {
|
||||
setSelectedVideo(null)
|
||||
}, [])
|
||||
|
||||
const selectScript = useCallback((s: Script) => {
|
||||
setScript(s)
|
||||
setScriptText(s.content)
|
||||
setShowScriptModal(false)
|
||||
}, [])
|
||||
|
||||
const addBRollSegment = useCallback((segment: BRollSegment) => {
|
||||
setBRollSegments((prev) => [...prev, segment])
|
||||
}, [])
|
||||
|
||||
const removeBRollSegment = useCallback((id: string) => {
|
||||
setBRollSegments((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
const updateTitleConfig = useCallback((partial: Partial<AiAvatarTitleConfig>) => {
|
||||
setTitleConfig((prev) => ({ ...prev, ...partial }))
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelectedVideo(null)
|
||||
setSelectedVoice(null)
|
||||
setScript(null)
|
||||
setScriptText("")
|
||||
setLipsyncJob(null)
|
||||
setBRollSegments([])
|
||||
setTitleConfig(DEFAULT_TITLE_CONFIG)
|
||||
setCoverConfig(DEFAULT_COVER_CONFIG)
|
||||
setResolution("1080p")
|
||||
setIsGenerating(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 面板1
|
||||
selectedVideo,
|
||||
showAssetPicker,
|
||||
setShowAssetPicker,
|
||||
selectVideo,
|
||||
removeVideo,
|
||||
// 面板2
|
||||
voiceSource,
|
||||
setVoiceSource,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
emotion,
|
||||
setEmotion,
|
||||
speed,
|
||||
setSpeed,
|
||||
language,
|
||||
setLanguage,
|
||||
// 面板3
|
||||
script,
|
||||
setScript,
|
||||
scriptText,
|
||||
setScriptText,
|
||||
lipsyncJob,
|
||||
setLipsyncJob,
|
||||
showScriptModal,
|
||||
setShowScriptModal,
|
||||
showBRollModal,
|
||||
setShowBRollModal,
|
||||
selectScript,
|
||||
// B-roll
|
||||
bRollSegments,
|
||||
addBRollSegment,
|
||||
removeBRollSegment,
|
||||
// 面板4
|
||||
titleConfig,
|
||||
updateTitleConfig,
|
||||
setTitleConfig,
|
||||
// 面板5
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
resolution,
|
||||
setResolution,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
// 全局
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseAiAvatarReturn = ReturnType<typeof useAiAvatar>
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AI数字人 — TypeScript 类型定义(v3)
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
/* ── 音色来源切换 ── */
|
||||
export type VoiceSource = "preset" | "clone"
|
||||
|
||||
/* ── 情绪 ── */
|
||||
export type VoiceEmotion = "natural" | "excited" | "calm" | "friendly"
|
||||
|
||||
export const VOICE_EMOTION_OPTIONS: { value: VoiceEmotion; label: string }[] = [
|
||||
{ value: "natural", label: "自然" },
|
||||
{ value: "excited", label: "兴奋" },
|
||||
{ value: "calm", label: "沉稳" },
|
||||
{ value: "friendly", label: "亲切" },
|
||||
]
|
||||
|
||||
/* ── 语言 ── */
|
||||
export type VoiceLanguage = "mandarin" | "english" | "cantonese"
|
||||
|
||||
export const VOICE_LANGUAGE_OPTIONS: { value: VoiceLanguage; label: string }[] = [
|
||||
{ value: "mandarin", label: "普通话" },
|
||||
{ value: "english", label: "English" },
|
||||
{ value: "cantonese", label: "粤语" },
|
||||
]
|
||||
|
||||
/* ── 对口型任务状态 ── */
|
||||
export type LipsyncStatus = "idle" | "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
/* ── 文案 ── */
|
||||
export interface Script {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
char_count: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/* ── 对口型任务 ── */
|
||||
export interface LipsyncJob {
|
||||
id: string
|
||||
status: LipsyncStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/* ── B-roll 画面插入 ── */
|
||||
export type BRollInsertMode = "fullscreen" | "pip"
|
||||
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
|
||||
export interface BRollSegment {
|
||||
id: string
|
||||
asset: AssetItem
|
||||
script_segment_index: number
|
||||
start_time: number
|
||||
end_time: number
|
||||
mode: BRollInsertMode
|
||||
pip_position: PipPosition
|
||||
pip_scale: number
|
||||
}
|
||||
|
||||
/* ── 标题配置 ── */
|
||||
export interface AiAvatarTitleConfig {
|
||||
title: string
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
auto_subtitle: boolean
|
||||
}
|
||||
|
||||
/* ── 封面配置 ── */
|
||||
export interface AiAvatarCoverConfig {
|
||||
enabled: boolean
|
||||
mode: "auto_frame" | "upload"
|
||||
frame_time: number
|
||||
upload_url: string | null
|
||||
thumbnail_url: string | null
|
||||
}
|
||||
|
||||
/* ── 渲染任务 ── */
|
||||
export type RenderStatus = "pending" | "processing" | "completed" | "failed" | "cancelled"
|
||||
|
||||
export interface RenderJob {
|
||||
id: string
|
||||
status: RenderStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/* ── 默认值 ── */
|
||||
export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
title: "",
|
||||
position: "top",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
auto_subtitle: true,
|
||||
}
|
||||
|
||||
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
|
||||
enabled: true,
|
||||
mode: "auto_frame",
|
||||
frame_time: 0,
|
||||
upload_url: null,
|
||||
thumbnail_url: null,
|
||||
}
|
||||
@@ -42,6 +42,7 @@ const AssetLibrary: React.FC = () => {
|
||||
assetsError,
|
||||
assetsErrorObj,
|
||||
refetchAssets,
|
||||
stalledAssetIds,
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterType,
|
||||
@@ -77,6 +78,7 @@ const AssetLibrary: React.FC = () => {
|
||||
removeUpload,
|
||||
clearFinished,
|
||||
uploading,
|
||||
transferActive,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
} = useAssetUpload({ effectiveLibId })
|
||||
@@ -168,6 +170,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{/* 上传区域 */}
|
||||
<AssetUploadZone
|
||||
uploading={uploading}
|
||||
transferActive={transferActive}
|
||||
activeCount={activeCount}
|
||||
pendingCount={pendingCount}
|
||||
onUpload={enqueueUploads}
|
||||
@@ -214,6 +217,7 @@ const AssetLibrary: React.FC = () => {
|
||||
selectedIds={selectedIds}
|
||||
diagnosingId={diagnosingId}
|
||||
uploadProgressMap={uploadProgressMap}
|
||||
stalledAssetIds={stalledAssetIds}
|
||||
onRetry={refetchAssets}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDiagnose={handleDiagnose}
|
||||
|
||||
@@ -831,6 +831,20 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-upload-queue-error-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #ef4444;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xx-upload-queue-error-hint {
|
||||
margin-top: 2px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.xx-upload-queue-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -1041,3 +1055,25 @@
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 上传入口禁用态(直传进行中,防重复提交,Issue #1714) */
|
||||
.xx-asset-upload-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.xx-asset-upload-btn:disabled:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.xx-asset-upload-btn:disabled:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 处理超时遮罩:创建超过 10 分钟仍在处理中(疑似后端卡住),停止转圈并警示 */
|
||||
.xx-asset-thumb-stalled {
|
||||
background: rgba(217, 119, 6, 0.28);
|
||||
color: #fde68a;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.xx-asset-thumb-stalled :first-child {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface AssetCardProps {
|
||||
diagnosing?: boolean
|
||||
/** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */
|
||||
uploadProgress?: { progress: number; uploading: boolean }
|
||||
/** 处理超过 10 分钟仍未就绪(疑似后端卡住):停止转圈并提示处理超时 */
|
||||
stalled?: boolean
|
||||
onToggle: () => void
|
||||
onDiagnose: () => void
|
||||
onPlay: () => void
|
||||
@@ -33,6 +35,7 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
selected,
|
||||
diagnosing,
|
||||
uploadProgress,
|
||||
stalled,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
@@ -67,11 +70,15 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 转码/处理中遮罩 */}
|
||||
{/* 转码/处理中遮罩(卡死超过 10 分钟时停止转圈,提示超时) */}
|
||||
{asset.loading && !isUploading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>转码处理中</span>
|
||||
<div
|
||||
className={`xx-asset-thumb-overlay ${
|
||||
stalled ? "xx-asset-thumb-stalled" : "xx-asset-thumb-processing"
|
||||
}`}
|
||||
>
|
||||
{stalled ? <CloseCircleOutlined /> : <LoadingOutlined />}
|
||||
<span>{stalled ? "处理超时,可重试上传" : "转码处理中"}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -129,7 +136,7 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<span className="xx-asset-meta-status">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
<StatusPill status={asset.status} label={stalled ? "处理超时" : asset.statusLabel} />
|
||||
</span>
|
||||
{asset.duration && <span className="xx-asset-meta-duration">{asset.duration}</span>}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface AssetGridSectionProps {
|
||||
selectedIds: Set<string>
|
||||
diagnosingId: string | null
|
||||
uploadProgressMap?: UploadProgressMap
|
||||
/** 创建超过 10 分钟仍在处理中的素材 id(疑似后端卡住),卡片提示处理超时 */
|
||||
stalledAssetIds?: Set<string>
|
||||
onRetry?: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onDiagnose: (asset: AssetItem) => void
|
||||
@@ -34,6 +36,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
selectedIds,
|
||||
diagnosingId,
|
||||
uploadProgressMap,
|
||||
stalledAssetIds,
|
||||
onRetry,
|
||||
onToggleSelect,
|
||||
onDiagnose,
|
||||
@@ -76,6 +79,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
uploadProgress={uploadProgressMap?.get(asset.id)}
|
||||
stalled={stalledAssetIds?.has(asset.id)}
|
||||
onToggle={() => onToggleSelect(asset.id)}
|
||||
onDiagnose={() => onDiagnose(asset)}
|
||||
onPlay={() => onPlay(asset)}
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
* - 拖拽文件到内容区任意位置同样触发上传(不再占用大面积虚线框)
|
||||
*/
|
||||
import React, { useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import { PlusOutlined, CloudUploadOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface AssetUploadZoneProps {
|
||||
uploading: boolean
|
||||
/** 有文件正在本地指纹/prepare/直传(非服务端转码),此时禁用入口防重复提交 */
|
||||
transferActive: boolean
|
||||
activeCount: number
|
||||
pendingCount: number
|
||||
onUpload: (files: File[]) => void
|
||||
@@ -15,6 +18,7 @@ export interface AssetUploadZoneProps {
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
transferActive,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
onUpload,
|
||||
@@ -27,6 +31,11 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
|
||||
const pickFiles = (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return
|
||||
// 直传进行中拦截重复触发:相同文件仍由入队去重兜底,这里先给明确反馈
|
||||
if (transferActive) {
|
||||
message.warning("文件正在上传中,请等待当前上传完成后再添加")
|
||||
return
|
||||
}
|
||||
onUpload(Array.from(list))
|
||||
}
|
||||
|
||||
@@ -58,10 +67,18 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="xx-asset-upload-btn"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={transferActive}
|
||||
title={transferActive ? "文件上传中,暂不能添加新文件" : undefined}
|
||||
onClick={() => {
|
||||
if (transferActive) {
|
||||
message.warning("文件正在上传中,请等待当前上传完成后再添加")
|
||||
return
|
||||
}
|
||||
inputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />
|
||||
上传素材
|
||||
{transferActive ? "上传中…" : "上传素材"}
|
||||
</button>
|
||||
<span className="xx-asset-upload-status">
|
||||
{uploading ? (
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { UploadItem } from "../hooks/useAssetUpload"
|
||||
import type { UploadItem, UploadFailStage } from "../hooks/useAssetUpload"
|
||||
import { COMPLETE_RETRY_HINT } from "../hooks/useAssetUpload"
|
||||
|
||||
export interface UploadQueuePanelProps {
|
||||
items: UploadItem[]
|
||||
@@ -29,6 +30,13 @@ const STATUS_TEXT: Record<UploadItem["status"], string> = {
|
||||
error: "上传失败",
|
||||
}
|
||||
|
||||
/** 失败阶段中文名:让用户一眼看到失败发生在哪一步 */
|
||||
const FAIL_STAGE_TEXT: Record<UploadFailStage, string> = {
|
||||
prepare: "准备上传阶段",
|
||||
transfer: "文件传输阶段",
|
||||
complete: "确认入库阶段",
|
||||
}
|
||||
|
||||
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
items,
|
||||
onRetry,
|
||||
@@ -80,16 +88,34 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
) : null}
|
||||
<div className="xx-upload-queue-status">
|
||||
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
|
||||
{it.status === "preparing" && it.hint ? `(${it.hint})` : ""}
|
||||
{it.status === "uploading" ? ` ${it.progress}%` : ""}
|
||||
{it.status === "error" && it.error ? `:${it.error}` : ""}
|
||||
{it.status === "error" && it.failedStage
|
||||
? `(${FAIL_STAGE_TEXT[it.failedStage]})`
|
||||
: ""}
|
||||
</div>
|
||||
{it.status === "error" && it.error ? (
|
||||
<div className="xx-upload-queue-error-detail" title={it.error}>
|
||||
{it.error.split("\n").map((line, idx) =>
|
||||
line === COMPLETE_RETRY_HINT ? (
|
||||
<div key={idx} className="xx-upload-queue-error-hint">
|
||||
{line}
|
||||
</div>
|
||||
) : (
|
||||
<div key={idx}>{line}</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="xx-upload-queue-actions">
|
||||
{it.status === "error" && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-upload-queue-btn"
|
||||
title="重试"
|
||||
title={
|
||||
it.failedStage === "complete" ? "安全重试(只确认,不重新上传)" : "重试上传"
|
||||
}
|
||||
onClick={() => onRetry(it.tempId)}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
|
||||
@@ -2,34 +2,65 @@ import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
import { MAX_FILE_SIZE } from "../constants"
|
||||
import {
|
||||
computeFileHash,
|
||||
findDuplicateInQueue,
|
||||
makeClientUploadId,
|
||||
makeFileFingerprint,
|
||||
} from "@/api/assets/uploadDedup"
|
||||
|
||||
/** 单文件上传状态机 */
|
||||
export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error"
|
||||
|
||||
/** 失败发生的阶段:complete 阶段失败时记录可能已在后端建成,禁止盲目重传整个文件 */
|
||||
export type UploadFailStage = "prepare" | "transfer" | "complete"
|
||||
|
||||
export interface UploadItem {
|
||||
/** 前端临时 id(prepare 前无 asset_id 时用) */
|
||||
/** 前端临时 id(prepare 前无 asset_id 时用),同时作为队列项 key */
|
||||
tempId: string
|
||||
file: File
|
||||
fileName: string
|
||||
/** 进度 0~100(仅直传阶段有真实进度) */
|
||||
progress: number
|
||||
status: UploadItemStatus
|
||||
/** 文件指纹(name+size+lastModified),入队去重用 */
|
||||
fileKey: string
|
||||
/** 本次逻辑上传的幂等 token:重试复用、重新入队才换新 */
|
||||
clientUploadId: string
|
||||
/** 上传前算好的文件内容哈希(SHA-256),prepare/complete 都带上 */
|
||||
fileHash?: string
|
||||
/** 后端 prepare 预建的 asset id(旧后端可能为空) */
|
||||
assetId?: string
|
||||
/** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */
|
||||
duplicated?: boolean
|
||||
/** 失败发生的阶段;complete 阶段失败点重试只重发 complete,不重新上传文件 */
|
||||
failedStage?: UploadFailStage
|
||||
/** 状态行补充提示(如"正在计算文件指纹…") */
|
||||
hint?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
|
||||
const MAX_CONCURRENT = 3
|
||||
|
||||
/** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */
|
||||
export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件"
|
||||
|
||||
/** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */
|
||||
const STAGE_LABEL: Record<UploadFailStage, string> = {
|
||||
prepare: "准备上传",
|
||||
transfer: "文件传输",
|
||||
complete: "确认入库",
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材批量上传 Hook
|
||||
* - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表
|
||||
* - 入队按文件指纹(name+size+lastModified)去重:同一文件已在队列/上传中/处理中时不重复入队
|
||||
* - 上传前计算文件 SHA-256,prepare/complete 携带 file_hash + 幂等 token(clientUploadId)
|
||||
* - complete 超时/失败不盲目重传:复用 handle 只重发 complete(幂等),prepare/transfer 失败才全量重跑
|
||||
* - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态
|
||||
* - complete 后素材进入转码(ingesting/processing),由列表轮询反映
|
||||
* - 失败卡片支持重试/移除
|
||||
*/
|
||||
export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
@@ -39,6 +70,13 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
const itemsRef = useRef<UploadItem[]>([])
|
||||
itemsRef.current = items
|
||||
|
||||
/**
|
||||
* prepare 成功后的 handle 按 tempId 留存:
|
||||
* complete 阶段失败(超时/网络)时 OSS 文件已存在、后端记录也可能已建成,
|
||||
* 重试必须复用同一 handle 只重发 complete,绝不能重新 prepare+直传。
|
||||
*/
|
||||
const handlesRef = useRef<Map<string, DirectUploadHandle>>(new Map())
|
||||
|
||||
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
|
||||
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it)))
|
||||
}, [])
|
||||
@@ -52,14 +90,71 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient, effectiveLibId])
|
||||
|
||||
/** 执行单个文件的完整上传流程(prepare→transfer→complete) */
|
||||
/**
|
||||
* 执行单个文件的完整上传流程。
|
||||
* @param completeOnly complete 阶段失败后的重试:跳过 hash/prepare/transfer,只重发 complete
|
||||
* (OSS 文件已传完,重发由 file_hash + clientUploadId 保证幂等)
|
||||
*/
|
||||
const runUpload = useCallback(
|
||||
async (item: UploadItem, handle?: DirectUploadHandle) => {
|
||||
async (item: UploadItem, handle?: DirectUploadHandle, completeOnly = false) => {
|
||||
let stage: UploadFailStage = "prepare"
|
||||
try {
|
||||
// 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳)
|
||||
const h =
|
||||
handle ??
|
||||
(await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId }))
|
||||
let h = handle
|
||||
|
||||
if (completeOnly && h) {
|
||||
// ── complete 重试:文件已在 OSS,直接幂等重发确认 ──
|
||||
stage = "complete"
|
||||
updateItem(item.tempId, {
|
||||
status: "ingesting",
|
||||
progress: 100,
|
||||
error: undefined,
|
||||
failedStage: undefined,
|
||||
hint: undefined,
|
||||
})
|
||||
const result = await h.complete()
|
||||
refreshList()
|
||||
handlesRef.current.delete(item.tempId)
|
||||
if (result.duplicated) {
|
||||
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
} else {
|
||||
updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId })
|
||||
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 计算文件内容哈希(失败不阻塞,降级为不传 hash;后端仍有幂等 token 兜底)
|
||||
updateItem(item.tempId, { hint: "正在计算文件指纹…" })
|
||||
const fileHash = item.fileHash || (await computeFileHash(item.file))
|
||||
updateItem(item.tempId, { fileHash, hint: undefined })
|
||||
|
||||
// 2. prepare(携带 file_hash + 幂等 token;重试时复用同一 clientUploadId)
|
||||
stage = "prepare"
|
||||
h =
|
||||
h ??
|
||||
(await prepareDirectUploadHandle({
|
||||
file: item.file,
|
||||
library_id: effectiveLibId,
|
||||
fileHash,
|
||||
clientUploadId: item.clientUploadId,
|
||||
}))
|
||||
handlesRef.current.set(item.tempId, h)
|
||||
|
||||
// prepare 阶段后端 file_hash 命中素材库已有相同文件(skip_transfer / duplicated):
|
||||
// 立即标记 done、调一次 refreshList 让已存在素材立即显示,跳过 transfer + complete
|
||||
if (h.prepared.skip_transfer || h.prepared.duplicated) {
|
||||
updateItem(item.tempId, {
|
||||
status: "done",
|
||||
duplicated: true,
|
||||
assetId: h.prepared.asset_id,
|
||||
})
|
||||
handlesRef.current.delete(item.tempId)
|
||||
refreshList()
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
if (h.prepared.asset_id) {
|
||||
updateItem(item.tempId, {
|
||||
status: "uploading",
|
||||
@@ -72,26 +167,59 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
updateItem(item.tempId, { status: "uploading", progress: 0 })
|
||||
}
|
||||
|
||||
// 2. OSS 直传(真实进度)
|
||||
// 3. OSS 直传(真实进度)
|
||||
stage = "transfer"
|
||||
await h.transfer((pct) => updateItem(item.tempId, { progress: pct }))
|
||||
|
||||
// 3. complete:后端创建 ingest job,素材进入转码
|
||||
// 4. complete:后端确认入库并创建 ingest job(file_hash + 幂等 token 已在 handle 闭包中)
|
||||
stage = "complete"
|
||||
updateItem(item.tempId, { status: "ingesting", progress: 100 })
|
||||
const result = await h.complete()
|
||||
refreshList()
|
||||
handlesRef.current.delete(item.tempId)
|
||||
|
||||
if (result.duplicated) {
|
||||
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
} else {
|
||||
updateItem(item.tempId, { status: "done" })
|
||||
updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId })
|
||||
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "上传失败"
|
||||
console.error("[useAssetUpload] 上传失败:", item.fileName, err)
|
||||
updateItem(item.tempId, { status: "error", error: detail })
|
||||
message.error(`"${item.fileName}" 上传失败:${detail}`)
|
||||
// 完整失败原因:HTTP 状态码 / OSS XML 的 Code+Message / 后端 detail,
|
||||
// 由 getErrorMessage 统一提取(OSS XHR 错误自带「OSS 直传失败: HTTP xxx ...」明细)
|
||||
const detail = getErrorMessage(err, "未知错误")
|
||||
console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err)
|
||||
|
||||
if (stage === "complete") {
|
||||
// complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试;
|
||||
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作。
|
||||
// 卡片同时展示真实错误原因 + 安全重试提示(重试只重发 complete,不重新上传)
|
||||
refreshList()
|
||||
updateItem(item.tempId, {
|
||||
status: "error",
|
||||
failedStage: "complete",
|
||||
error: `${detail}\n${COMPLETE_RETRY_HINT}`,
|
||||
hint: undefined,
|
||||
})
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`"${item.fileName}" 确认入库失败:${detail}`)
|
||||
}
|
||||
} else {
|
||||
// prepare / transfer 失败:后端尚无素材记录,可安全全量重跑
|
||||
handlesRef.current.delete(item.tempId)
|
||||
updateItem(item.tempId, {
|
||||
status: "error",
|
||||
failedStage: stage === "transfer" ? "transfer" : "prepare",
|
||||
error: detail,
|
||||
hint: undefined,
|
||||
})
|
||||
// 拦截器已对后端错误弹过 toast(含真实 detail)时不重复弹;
|
||||
// OSS XHR 直传错误不走 axios,必须在这里弹
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`"${item.fileName}" ${STAGE_LABEL[stage]}失败:${detail}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[effectiveLibId, refreshList, updateItem],
|
||||
@@ -113,7 +241,9 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
if (!next) return
|
||||
claimedRef.current.add(next.tempId)
|
||||
inFlightRef.current += 1
|
||||
void runUpload(next).finally(() => {
|
||||
// complete 阶段失败的重试:复用留存的 handle,只重发 complete
|
||||
const existingHandle = handlesRef.current.get(next.tempId)
|
||||
void runUpload(next, existingHandle, existingHandle !== undefined).finally(() => {
|
||||
inFlightRef.current -= 1
|
||||
claimedRef.current.delete(next.tempId)
|
||||
// 一个任务结束(成功/失败)后继续拉起排队任务
|
||||
@@ -126,7 +256,11 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
pumpRef.current()
|
||||
}, [items])
|
||||
|
||||
/** 入队一个或多个文件 */
|
||||
/**
|
||||
* 入队一个或多个文件(按文件指纹去重):
|
||||
* - 同一文件已在队列且 preparing/uploading/ingesting/done → 跳过,不重复入队
|
||||
* - 同一文件此前失败(error)→ 重新激活原队列项(复用 clientUploadId,保持幂等语义)
|
||||
*/
|
||||
const enqueueUploads = useCallback(
|
||||
(files: File[]) => {
|
||||
if (!effectiveLibId) {
|
||||
@@ -143,37 +277,86 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
}
|
||||
if (valid.length === 0) return
|
||||
|
||||
const newItems: UploadItem[] = valid.map((file, idx) => ({
|
||||
tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: "preparing",
|
||||
}))
|
||||
setItems((prev) => [...prev, ...newItems])
|
||||
let skipped = 0
|
||||
let rearmed = 0
|
||||
const newItems: UploadItem[] = []
|
||||
|
||||
for (const file of valid) {
|
||||
const fileKey = makeFileFingerprint(file)
|
||||
// error 项允许重新激活;其余状态(preparing/uploading/ingesting/done)都算重复
|
||||
const dup = findDuplicateInQueue([...itemsRef.current, ...newItems], fileKey, ["error"])
|
||||
if (dup) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
// 失败项重新激活:复用 tempId/clientUploadId,由 pump 按留存 handle 决定重试方式
|
||||
const failed = itemsRef.current.find(
|
||||
(it) => it.fileKey === fileKey && it.status === "error",
|
||||
)
|
||||
if (failed) {
|
||||
rearmed += 1
|
||||
updateItem(failed.tempId, {
|
||||
status: "preparing",
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
failedStage: undefined,
|
||||
hint: undefined,
|
||||
})
|
||||
continue
|
||||
}
|
||||
newItems.push({
|
||||
tempId: `${Date.now()}-${newItems.length}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: "preparing",
|
||||
fileKey,
|
||||
clientUploadId: makeClientUploadId(),
|
||||
})
|
||||
}
|
||||
|
||||
if (newItems.length > 0) {
|
||||
setItems((prev) => [...prev, ...newItems])
|
||||
}
|
||||
if (skipped > 0) {
|
||||
message.warning(`已跳过 ${skipped} 个重复文件(已在上传队列、处理中或本页已上传)`)
|
||||
}
|
||||
if (rearmed > 0) {
|
||||
message.info(`已重新加入 ${rearmed} 个此前失败的文件`)
|
||||
}
|
||||
},
|
||||
[effectiveLibId],
|
||||
[effectiveLibId, updateItem],
|
||||
)
|
||||
|
||||
/** 重试失败任务 */
|
||||
/**
|
||||
* 重试失败任务(仅限 status=error):
|
||||
* - complete 阶段失败:复用留存 handle 只重发 complete(幂等,不重新上传)
|
||||
* - prepare/transfer 阶段失败:全量重跑(后端尚无记录,安全)
|
||||
*/
|
||||
const retryUpload = useCallback(
|
||||
(tempId: string) => {
|
||||
const target = itemsRef.current.find((it) => it.tempId === tempId)
|
||||
if (!target) return
|
||||
updateItem(tempId, { status: "preparing", progress: 0, error: undefined })
|
||||
// 状态更新后由 useEffect 触发 pump
|
||||
if (!target || target.status !== "error") return
|
||||
updateItem(tempId, { status: "preparing", progress: 0, error: undefined, hint: undefined })
|
||||
// 状态更新后由 useEffect 触发 pump;pump 会按 handlesRef 自动选择 completeOnly / 全量
|
||||
},
|
||||
[updateItem],
|
||||
)
|
||||
|
||||
/** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */
|
||||
const removeUpload = useCallback((tempId: string) => {
|
||||
handlesRef.current.delete(tempId)
|
||||
setItems((prev) => prev.filter((it) => it.tempId !== tempId))
|
||||
}, [])
|
||||
|
||||
/** 清空已完成/去重记录 */
|
||||
const clearFinished = useCallback(() => {
|
||||
setItems((prev) => prev.filter((it) => it.status !== "done"))
|
||||
setItems((prev) => {
|
||||
for (const it of prev) {
|
||||
if (it.status === "done") handlesRef.current.delete(it.tempId)
|
||||
}
|
||||
return prev.filter((it) => it.status !== "done")
|
||||
})
|
||||
}, [])
|
||||
|
||||
const activeCount = items.filter(
|
||||
@@ -188,8 +371,10 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
retryUpload,
|
||||
removeUpload,
|
||||
clearFinished,
|
||||
/** 是否有进行中的上传(用于上传区文案) */
|
||||
/** 是否有进行中的上传(用于上传区文案/禁用入口) */
|
||||
uploading: hasActive,
|
||||
/** 是否有文件正在本地处理或直传(用于禁用上传入口,防重复提交) */
|
||||
transferActive: activeCount > 0,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,26 @@ import {
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
|
||||
|
||||
/** 处理中素材快速轮询(3s)的最大持续时间:超过后停止快轮询,避免孤儿任务永久转圈 */
|
||||
const PROCESSING_POLL_MAX_MS = 10 * 60 * 1000 // 10 分钟
|
||||
|
||||
const isProcessingStatus = (st?: string | null): boolean =>
|
||||
st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
|
||||
|
||||
/** 判断列表是否存在「创建超过 maxMs 仍在处理中」的卡死素材 */
|
||||
const hasStalledProcessing = (
|
||||
list: ApiAssetItem[],
|
||||
maxMs: number = PROCESSING_POLL_MAX_MS,
|
||||
): boolean => {
|
||||
const now = Date.now()
|
||||
return list.some((a) => {
|
||||
if (!isProcessingStatus(a.status ?? "")) return false
|
||||
if (!a.created_at) return false
|
||||
const created = new Date(a.created_at).getTime()
|
||||
return Number.isFinite(created) && now - created > maxMs
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材库数据 Hook
|
||||
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
|
||||
@@ -63,15 +83,15 @@ export function useAssetsData() {
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
|
||||
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止。
|
||||
// 但若处理中素材创建已超过 10 分钟仍未就绪(疑似后端卡住/孤儿任务),
|
||||
// 停止快轮询避免无限转圈——卡死素材在网格中显示「处理超时」提示。
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as { items: ApiAssetItem[] } | undefined
|
||||
const items = data?.items ?? []
|
||||
const processing = items.some((a) => {
|
||||
const st = a.status ?? ""
|
||||
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
|
||||
})
|
||||
return processing ? 3000 : false
|
||||
const processing = items.some((a) => isProcessingStatus(a.status ?? ""))
|
||||
if (!processing) return false
|
||||
return hasStalledProcessing(items) ? false : 3000
|
||||
},
|
||||
})
|
||||
|
||||
@@ -80,6 +100,21 @@ export function useAssetsData() {
|
||||
[apiAssets],
|
||||
)
|
||||
|
||||
/** 创建超过 10 分钟仍在处理中的素材(后端可能卡住),网格提示「处理超时」 */
|
||||
const stalledAssetIds = useMemo(() => {
|
||||
const list = Array.isArray(apiAssets?.items) ? apiAssets.items : []
|
||||
const ids = new Set<string>()
|
||||
const now = Date.now()
|
||||
for (const a of list) {
|
||||
if (!isProcessingStatus(a.status ?? "") || !a.created_at) continue
|
||||
const created = new Date(a.created_at).getTime()
|
||||
if (Number.isFinite(created) && now - created > PROCESSING_POLL_MAX_MS) {
|
||||
ids.add(a.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [apiAssets])
|
||||
|
||||
/* ── 筛选状态 ── */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
@@ -129,6 +164,8 @@ export function useAssetsData() {
|
||||
assetsError,
|
||||
assetsErrorObj,
|
||||
refetchAssets,
|
||||
stalledAssetIds,
|
||||
hasStalledAssets: stalledAssetIds.size > 0,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
|
||||
@@ -5,8 +5,8 @@ import React, { useState } from "react"
|
||||
import { Form, Input, Checkbox, message } from "antd"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { useLogin } from "@/hooks/useAuth"
|
||||
import { getWechatAuthUrl } from "@/api/auth"
|
||||
import Button from "@/components/ui/Button"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
import "./Login.css"
|
||||
|
||||
interface LoginFormValues {
|
||||
@@ -19,7 +19,7 @@ const Login: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const loginMutation = useLogin()
|
||||
const [form] = Form.useForm()
|
||||
const [wechatLoading, setWechatLoading] = useState(false)
|
||||
const [wechatQrOpen, setWechatQrOpen] = useState(false)
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
@@ -35,27 +35,28 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleWechatLogin = async () => {
|
||||
try {
|
||||
setWechatLoading(true)
|
||||
const result = await getWechatAuthUrl()
|
||||
// 保存 state 到 localStorage 用于回调时验证
|
||||
localStorage.setItem("wechat_state", result.state)
|
||||
// 记录登录前的来源页,登录成功后跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
}
|
||||
// 跳转到微信授权页
|
||||
window.location.href = result.auth_url
|
||||
} catch (error) {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("微信登录暂不可用,请稍后重试")
|
||||
} finally {
|
||||
setWechatLoading(false)
|
||||
const handleWechatLogin = () => {
|
||||
// 记录登录前的来源页,登录成功后(弹窗回调)跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
}
|
||||
setWechatQrOpen(true)
|
||||
// 弹窗打开期间按钮 disabled;WxLogin 脚本加载失败/超时时弹窗内会自动回退整页跳转
|
||||
}
|
||||
|
||||
// 弹窗扫码登录成功:登录态已由弹窗同步,按用户类型跳转
|
||||
const handleWechatQrSuccess = (needOnboarding: boolean) => {
|
||||
setWechatQrOpen(false)
|
||||
if (needOnboarding) {
|
||||
navigate("/welcome/wechat", { replace: true })
|
||||
return
|
||||
}
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -126,10 +127,10 @@ const Login: React.FC = () => {
|
||||
type="button"
|
||||
className="xx-btn-wechat"
|
||||
onClick={handleWechatLogin}
|
||||
disabled={wechatLoading}
|
||||
disabled={wechatQrOpen}
|
||||
>
|
||||
<span className="xx-wechat-icon">💬</span>
|
||||
{wechatLoading ? "加载中..." : "微信登录"}
|
||||
微信登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +138,13 @@ const Login: React.FC = () => {
|
||||
还没有账号? <Link to="/register">立即注册</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatQrModal
|
||||
open={wechatQrOpen}
|
||||
scene="login"
|
||||
onClose={() => setWechatQrOpen(false)}
|
||||
onLoginSuccess={handleWechatQrSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
|
||||
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
|
||||
*
|
||||
* 两种运行环境:
|
||||
* - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页
|
||||
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
|
||||
* 结果通过 postMessage 通知父窗口弹窗,不做页面导航
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { Spin } from "antd"
|
||||
import { bindWechat, normalizeUser } from "@/api/auth"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
|
||||
|
||||
const WechatBindCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inIframe = isInIframe()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (inIframe) {
|
||||
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示
|
||||
postWechatQrResult("bind", false, { detail: message })
|
||||
return
|
||||
}
|
||||
setError(message)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
fail("无效的回调参数,请回到设置页重新扫码绑定")
|
||||
return
|
||||
}
|
||||
|
||||
const handleBind = async () => {
|
||||
// state 校验由后端 state store 一次性消费兜底(前端不再比对 localStorage,
|
||||
// 微信内打开/跨浏览器场景本地无 state 会误杀);清理绑定前写入的 state
|
||||
localStorage.removeItem("wechat_bind_state")
|
||||
|
||||
try {
|
||||
const result = await bindWechat(code, state)
|
||||
setUser(normalizeUser(result.user))
|
||||
|
||||
if (inIframe) {
|
||||
// 弹窗模式:通知父窗口关闭弹窗并刷新绑定状态
|
||||
postWechatQrResult("bind", true)
|
||||
return
|
||||
}
|
||||
|
||||
// 用 replace 回设置页,query 携带成功标记由设置页提示
|
||||
navigate("/app/profile?wechat_bind=success", { replace: true })
|
||||
} catch (err) {
|
||||
// 绑定失败直接在本页展示/上报真实原因(如微信已被其他账号绑定),不静默跳走
|
||||
fail(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
|
||||
}
|
||||
}
|
||||
|
||||
handleBind()
|
||||
}, [searchParams, navigate, setUser, inIframe])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
|
||||
<button
|
||||
onClick={() => navigate("/app/profile")}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "var(--primary-color, #3b82f6)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
返回设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, color: "#666" }}>正在绑定微信...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatBindCallback
|
||||
@@ -1,40 +1,63 @@
|
||||
/**
|
||||
* 微信登录回调页
|
||||
* 扫码授权后由微信重定向回来:用 code 换登录态,
|
||||
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
|
||||
*
|
||||
* 两种运行环境:
|
||||
* - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航
|
||||
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
|
||||
* 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { Spin, message } from "antd"
|
||||
import { Spin } from "antd"
|
||||
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import BindContactModal from "@/components/auth/BindContactModal"
|
||||
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
|
||||
|
||||
const WechatCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showBindModal, setShowBindModal] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inIframe = isInIframe()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
if (!code || !state) {
|
||||
setError("无效的回调参数")
|
||||
const fail = (message: string) => {
|
||||
if (inIframe) {
|
||||
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示,本页保持"处理中"即可
|
||||
postWechatQrResult("login", false, { detail: message })
|
||||
return
|
||||
}
|
||||
setError(message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示/上报原因
|
||||
const wxErrorCode = searchParams.get("error")
|
||||
const wxErrDesc = searchParams.get("error_description")
|
||||
if (wxErrorCode || wxErrDesc) {
|
||||
const reason = [wxErrorCode, wxErrDesc].filter(Boolean).join(":")
|
||||
fail(`微信授权失败:${reason}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
fail("无效的回调参数,请重新扫码登录")
|
||||
return
|
||||
}
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// 校验 state,防止 CSRF
|
||||
const savedState = localStorage.getItem("wechat_state")
|
||||
if (!savedState || savedState !== state) {
|
||||
setError("安全校验失败,请重新登录")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// state 的 CSRF 校验由后端 state store 一次性消费兜底(前端不再比对
|
||||
// localStorage——微信内打开、跨浏览器等场景本地没有 state,会误杀正常回调);
|
||||
// 清理登录前写入的 state,避免残留
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
@@ -49,41 +72,34 @@ const WechatCallback: React.FC = () => {
|
||||
const userData = await getCurrentUser()
|
||||
const user: User = normalizeUser(userData)
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
if (result.binding_complete) {
|
||||
// 已绑定,跳转到登录前页面或首页
|
||||
message.success("登录成功")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} else {
|
||||
// 未绑定,显示绑定弹窗
|
||||
setLoading(false)
|
||||
setShowBindModal(true)
|
||||
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
|
||||
const needOnboarding = result.is_new_user || user.profile_completed === false
|
||||
|
||||
if (inIframe) {
|
||||
// 弹窗模式:token 已写入同源 localStorage,通知父窗口同步登录态并跳转
|
||||
postWechatQrResult("login", true, { needOnboarding })
|
||||
return
|
||||
}
|
||||
|
||||
if (needOnboarding) {
|
||||
navigate("/welcome/wechat", { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// 老用户:回登录前页面或首页
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} catch (err) {
|
||||
setError("登录失败,请重试")
|
||||
setLoading(false)
|
||||
// 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示
|
||||
fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [searchParams, navigate, setAuth])
|
||||
|
||||
const handleBindSuccess = (user: User) => {
|
||||
const setUser = useAuthStore.getState().setUser
|
||||
setUser(user)
|
||||
setShowBindModal(false)
|
||||
message.success("绑定成功")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
|
||||
const handleBindCancel = () => {
|
||||
setShowBindModal(false)
|
||||
navigate("/login")
|
||||
}
|
||||
}, [searchParams, navigate, setAuth, inIframe])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -98,49 +114,39 @@ const WechatCallback: React.FC = () => {
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, color: "#666" }}>正在登录...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "var(--primary-color, #3b82f6)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
<p style={{ marginTop: 16, color: "#666" }}>微信登录中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BindContactModal
|
||||
open={showBindModal}
|
||||
onSuccess={handleBindSuccess}
|
||||
onCancel={handleBindCancel}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "var(--primary-color, #3b82f6)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 微信新用户昵称引导页
|
||||
* 新微信用户首次登录后强制填写昵称,完成后才进入主界面
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import { Form, Input, message } from "antd"
|
||||
import { Navigate, useNavigate } from "react-router-dom"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { updateProfile } from "@/api/auth"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "./Login.css"
|
||||
|
||||
interface OnboardingFormValues {
|
||||
display_name: string
|
||||
}
|
||||
|
||||
const WechatOnboarding: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
const [form] = Form.useForm<OnboardingFormValues>()
|
||||
// 同步防连点守卫:antd loading 要等 React 重渲染后才禁用按钮,
|
||||
// 连点两次时第一次的 mutation 刚触发、重渲染未发生,第二次 click 仍会进来
|
||||
// (截图里 PATCH /me 405 出现两次就是连点导致的重复提交)
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (displayName: string) => updateProfile({ display_name: displayName }),
|
||||
})
|
||||
|
||||
// 已登录且资料已完善的用户不该停留在引导页
|
||||
if (isAuthenticated && hasAccessToken && user?.profile_completed === true) {
|
||||
return <Navigate to="/app/dashboard" replace />
|
||||
}
|
||||
// 未登录(如手动输入 URL)回登录页
|
||||
if (!isAuthenticated || !hasAccessToken) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const onFinish = async (values: OnboardingFormValues) => {
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
try {
|
||||
const updated = await saveMutation.mutateAsync(values.display_name.trim())
|
||||
// 后端返回的 profile_completed 以最新资料为准,前端同步标记完善
|
||||
setUser({ ...updated, profile_completed: true })
|
||||
message.success("欢迎加入小虾智剪!")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} catch (err) {
|
||||
// 透传后端真实原因(如接口异常/校验失败);拦截器已弹过的不重复弹
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`昵称保存失败:${getErrorMessage(err, "请稍后重试")}`)
|
||||
}
|
||||
submittingRef.current = false
|
||||
}
|
||||
// 成功时页面跳走,不复位
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-auth-page">
|
||||
<div className="xx-auth-card">
|
||||
<div className="xx-auth-header">
|
||||
<div className="xx-auth-brand">
|
||||
<span className="xx-auth-logo">🦐</span>
|
||||
<span className="xx-auth-brand-name">小虾智剪</span>
|
||||
</div>
|
||||
<p>欢迎使用微信登录,请先设置您的昵称</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="wechat-onboarding"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
// 不预填:新微信用户必须自己输入昵称(user.display_name 可能是微信昵称/系统占位)
|
||||
initialValues={{ display_name: "" }}
|
||||
>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label="昵称"
|
||||
rules={[
|
||||
{ required: true, message: "请输入昵称" },
|
||||
{ whitespace: true, message: "昵称不能为空白" },
|
||||
{ min: 1, max: 20, message: "昵称长度需在 1-20 个字符之间" },
|
||||
]}
|
||||
extra="昵称将展示在您的作品和账户中,之后可在个人设置中修改"
|
||||
>
|
||||
<Input placeholder="请输入您的昵称" size="large" maxLength={20} showCount />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="lg"
|
||||
htmlType="submit"
|
||||
loading={saveMutation.isPending}
|
||||
disabled={saveMutation.isPending}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{saveMutation.isPending ? "保存中..." : "进入小虾智剪"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatOnboarding
|
||||
@@ -6440,3 +6440,49 @@
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ═══ 标题设置 — 颜色预设 ═══ */
|
||||
.ep-color-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ep-color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.1s;
|
||||
}
|
||||
|
||||
.ep-color-swatch:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.ep-color-swatch.active {
|
||||
border-color: var(--ep-primary, #4f8cff);
|
||||
}
|
||||
|
||||
.ep-color-picker {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.ep-color-picker::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ep-color-picker::-webkit-color-swatch {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,8 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
{/* 右栏 260px:设置面板 */}
|
||||
<RightPanel
|
||||
titleConfig={titleConfig}
|
||||
onTitleConfigChange={setTitleConfig}
|
||||
rightTab={rightTab}
|
||||
onTabChange={setRightTab}
|
||||
selectedClip={clipOps.selectedClip}
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
import React from "react"
|
||||
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
|
||||
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
|
||||
import TitleSettingsSection from "./clip-properties/TitleSettingsSection"
|
||||
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
|
||||
import ClipDetailSection from "./clip-properties/ClipDetailSection"
|
||||
import StatsSection from "./clip-properties/StatsSection"
|
||||
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
titleConfig,
|
||||
onTitleConfigChange,
|
||||
selectedClip,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
@@ -40,6 +43,11 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 — #1789 ═══ */}
|
||||
{titleConfig && onTitleConfigChange && (
|
||||
<TitleSettingsSection config={titleConfig} onChange={onTitleConfigChange} />
|
||||
)}
|
||||
|
||||
{/* ═══ 字幕设置 ═══ */}
|
||||
<SubtitleSettingsSection
|
||||
settings={subtitleSettings}
|
||||
|
||||
@@ -5,9 +5,12 @@ import type { ClipData } from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface RightPanelProps {
|
||||
titleConfig?: TitleConfig
|
||||
onTitleConfigChange?: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||
rightTab: "properties" | "clips"
|
||||
onTabChange: (tab: "properties" | "clips") => void
|
||||
// 属性 tab
|
||||
@@ -46,6 +49,8 @@ interface RightPanelProps {
|
||||
}
|
||||
|
||||
const RightPanel: React.FC<RightPanelProps> = ({
|
||||
titleConfig,
|
||||
onTitleConfigChange,
|
||||
rightTab,
|
||||
onTabChange,
|
||||
selectedClip,
|
||||
@@ -118,6 +123,8 @@ const RightPanel: React.FC<RightPanelProps> = ({
|
||||
>["onBgmSettingsChange"]
|
||||
return (
|
||||
<ClipPropertiesPanel
|
||||
titleConfig={titleConfig}
|
||||
onTitleConfigChange={onTitleConfigChange}
|
||||
selectedClip={selectedClip}
|
||||
subtitleSettings={sub}
|
||||
bgmSettings={bgm}
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 标题设置区块 — #1789
|
||||
* 提供字号滑块、字体预设、位置、颜色等控制入口
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "@/pages/editing-planner/constants/clipProperties"
|
||||
|
||||
interface TitleSettingsSectionProps {
|
||||
config: TitleConfig
|
||||
onChange: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||
}
|
||||
|
||||
const TITLE_COLOR_PRESETS = [
|
||||
"#ffffff",
|
||||
"#000000",
|
||||
"#ff4444",
|
||||
"#ffaa00",
|
||||
"#44ff44",
|
||||
"#4488ff",
|
||||
"#ff44ff",
|
||||
"#ffff44",
|
||||
]
|
||||
|
||||
const TitleSettingsSection: React.FC<TitleSettingsSectionProps> = ({ config, onChange }) => {
|
||||
const update = (partial: Partial<TitleConfig>) => {
|
||||
onChange((prev: TitleConfig) => ({ ...prev, ...partial }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">📝</span>
|
||||
标题设置
|
||||
</div>
|
||||
|
||||
{/* AI 自动选择开关 */}
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">AI 自动选择</span>
|
||||
<div
|
||||
className={`ep-toggle ${config.ai_auto_select ? "active" : ""}`}
|
||||
onClick={() => update({ ai_auto_select: !config.ai_auto_select })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!config.ai_auto_select && (
|
||||
<>
|
||||
{/* 标题文本 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">标题文本</label>
|
||||
<input
|
||||
className="ep-form-select"
|
||||
type="text"
|
||||
placeholder="输入标题内容"
|
||||
value={config.content}
|
||||
onChange={(e) => update({ content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={config.position}
|
||||
onChange={(e) => update({ position: e.target.value })}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 字体预设 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={config.font_preset}
|
||||
onChange={(e) => update({ font_preset: e.target.value })}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字号</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size}
|
||||
onChange={(e) => update({ font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="ep-slider-value">{config.font_size}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 颜色 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">颜色</label>
|
||||
<div className="ep-color-presets">
|
||||
{TITLE_COLOR_PRESETS.map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
className={`ep-color-swatch${config.font_color === color ? " active" : ""}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => update({ font_color: color })}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
className="ep-color-picker"
|
||||
value={config.font_color}
|
||||
onChange={(e) => update({ font_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleSettingsSection
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user