diff --git a/.env.example b/.env.example index 3f3eac461..aa6063c5c 100755 --- a/.env.example +++ b/.env.example @@ -79,14 +79,33 @@ CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_RESULT_BACKEND=redis://localhost:6379/1 -# ==================== Worker 配置 ==================== +# ==================== Worker 配置(#2073 队列分流) ==================== +# +# 容器内跑三个独立进程:beat(只发定时任务)+ generation worker(实时高优) +# + transcode worker(后台批量/清理)。三个进程的并发与开关独立配置。 # Worker 进程名称 WORKER_NAME=xiaoxia-saas-worker -# Worker 并发数(同时执行的任务数) +# 总并发参考(兼容旧变量): +# - 若 GENERATION_CONCURRENCY 与 TRANSCODE_CONCURRENCY 都未显式设置, +# entrypoint 会按此总数对半分配(gen=ceil(total/2), trans=剩余,各至少 1); +# - 任一个 *_CONCURRENCY 显式设置后,按显式值生效,忽略此变量对应部分。 WORKER_CONCURRENCY=4 +# Generation worker 并发数(用户实时任务:视频生成/TTS/音色克隆/lipsync/数字人) +# 实时链路对延迟敏感,建议 2C 以上机器设为 2;高负载场景可加到 4。 +GENERATION_CONCURRENCY=2 + +# Transcode worker 并发数(后台批量:素材入库转码/AI 分类打标/质量评分/查重/批量下载) +# 后台任务可排队,独立伸缩;素材入库量大时可加到 4。 +TRANSCODE_CONCURRENCY=2 + +# 是否在本容器启动 celery beat 进程(默认 1)。 +# 默认 beat 与 worker 同容器部署;若要独立 beat 容器部署,worker 容器设为 0、 +# beat 容器单独跑 `celery -A worker_app.celery_app beat` 并设 BEAT_ENABLED=1。 +BEAT_ENABLED=1 + # 每个子进程最多处理多少任务后重启(防止内存泄漏) WORKER_MAX_TASKS_PER_CHILD=1000 @@ -198,10 +217,13 @@ DOUBAO_TIMEOUT=30 DOUBAO_MAX_RETRIES=2 # ==================== 积分/会员系统 (#1895) ==================== -# 积分扣点总开关:默认 false(对现有用户零影响)。 -# P2 阶段各业务路由逐个接入 @points_gate 时,用 -# `if settings.points_enabled: ...` -# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。 +# 积分系统总开关:默认 false(暂停积分系统)。 +# - false:生成视频/口型同步/数字人/AI标题/TTS/克隆音色等所有功能对登录 +# 用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员状态查询接口 +# 保留可用,但数据不再变动。积分相关的表、代码、接口均保留不删除。 +# - 恢复积分:设置 ENABLE_CREDIT_SYSTEM=true 即可,无需改代码。 +ENABLE_CREDIT_SYSTEM=false +# 旧开关名(兼容别名):与 ENABLE_CREDIT_SYSTEM 任一为 true 即启用。 POINTS_ENABLED=false # ==================== 抖音解析多源轮询 (#1963) ==================== @@ -213,3 +235,20 @@ TIKHUB_API_KEY= # apizero.cn API Key (https://v1.apizero.cn) — 国内抖音解析服务 APIZERO_API_KEY= +# ==================== GPU MuseTalk Worker(反向轮询口型同步)==================== +# GPU Worker 长期鉴权 Token,Worker 端 .env 的 GPU_WORKER_TOKEN 必须与此一致 +# 留空时 development 环境允许匿名访问(仅本地调试),staging/production 必须配置 +GPU_WORKER_TOKEN= +# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed +# #1970:RTX2060 6G 推理 720p 长视频需 5 分钟以上,默认 900 +GPU_TASK_TIMEOUT_SECONDS=900 +# 是否启用 GPU 口型同步(开关)。开启后需同时有 Worker 在心跳窗口内(5分钟)才会走 GPU 路径; +# 开关关闭 / 无可用 Worker / GPU 任务失败或超时 → 自动回退现有 MediaKit 云端 lipsync +USE_GPU_LIPSYNC=false +# 业务侧轮询 GPU 任务结果的间隔(秒) +GPU_LIPSYNC_POLL_INTERVAL=5 +# 业务侧等待 GPU 任务总超时(秒);超时回退 MediaKit +GPU_LIPSYNC_WAIT_TIMEOUT=1200 +# Worker 心跳新鲜度窗口(秒),last_heartbeat_at 在此窗口内视为在线 +GPU_WORKER_STALE_SECONDS=300 + diff --git a/.gitea/workflows/ci-pipeline.yml b/.gitea/workflows/ci-pipeline.yml index 95564ae3b..55bba5d05 100755 --- a/.gitea/workflows/ci-pipeline.yml +++ b/.gitea/workflows/ci-pipeline.yml @@ -813,7 +813,7 @@ jobs: IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}" CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop" - EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\"" + EXTRA_BUILD_ARGS="APP_VERSION=${GITHUB_SHA}" # Worker 与 API/Web 统一走持久 builder(ci-builder-persist),共享宿主机层缓存 NO_CACHE_FLAG="" @@ -1014,7 +1014,7 @@ jobs: PUSHED_TAGS_SUMMARY="${BRANCH_TAG}" fi - EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\"" + EXTRA_BUILD_ARGS="APP_VERSION=${GITHUB_SHA}" NO_CACHE_FLAG="" for i in 1 2 3; do @@ -1186,10 +1186,12 @@ jobs: DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}" DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}" DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}" + DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}" WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}" WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}" TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}" APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}" + GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}" run: | set -eu echo "Rendering .env from template + secrets..." @@ -1236,9 +1238,11 @@ jobs: ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}" run: | set -eux - # CI runner (act_runner) 部署在 116 staging 本机(116.62.226.203 公网 22 未开放), - # 默认走 127.0.0.1:22 本机 SSH,避免跨机网络依赖;可通过 secrets 覆盖。 - staging_host="${STAGING_SSH_HOST:-127.0.0.1}" + # Staging 业务机 = 116.62.226.203(公网 sshd 端口 22)。 + # 47.98.113.167 现为生产机(sshd 端口 22222),不承载 staging 容器。 + # CI job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败, + # 故默认目标必须是 staging 业务机;仍可通过 secrets 覆盖。 + staging_host="${STAGING_SSH_HOST:-116.62.226.203}" staging_user="${STAGING_SSH_USER:-root}" staging_port="${STAGING_SSH_PORT:-22}" echo "Host: $staging_host" @@ -1298,8 +1302,16 @@ jobs: "${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt" echo "✅ Douyin cookies uploaded" + # 上传 infra/docker 配置到服务器(compose 单一事实来源) + echo "Uploading infra/docker configs to staging server..." + ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \ + "mkdir -p /var/lib/xiaoxia-saas-staging/infra/docker" + scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no infra/docker/compose.yml \ + "${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/infra/docker/compose.yml" + echo "✅ infra/docker/compose.yml uploaded" + # 通过环境变量传递凭证,避免命令行引号转义问题 - cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh" + cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} COMPOSE_SYNC=0 sh" # 清理 CI runner 上的渲染文件 rm -f .env.rendered @@ -1550,7 +1562,7 @@ jobs: IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}" CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main" - EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\"" + EXTRA_BUILD_ARGS="APP_VERSION=${TAG_NAME}" # Docker build 带重试:失败自动重试2次,第2次重试加--no-cache NO_CACHE_FLAG="" @@ -1640,10 +1652,12 @@ jobs: DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}" DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}" DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}" + DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}" WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}" WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}" TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}" APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}" + GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}" run: | set -eu echo "Rendering .env from template + secrets..." diff --git a/alembic/versions/081_add_gpu_lipsync_tasks.py b/alembic/versions/081_add_gpu_lipsync_tasks.py new file mode 100644 index 000000000..f15e0234d --- /dev/null +++ b/alembic/versions/081_add_gpu_lipsync_tasks.py @@ -0,0 +1,58 @@ +"""add gpu_lipsync_tasks and gpu_workers tables for MuseTalk reverse-poll worker + +Revision ID: 081_add_gpu_lipsync +Revises: 080_edit_plan_clips_atom_clip_id +Create Date: 2026-09-18 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "081_add_gpu_lipsync" +down_revision = "080_edit_plan_clips_atom_clip_id" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # GPU Worker 注册表 + op.create_table( + "gpu_workers", + sa.Column("worker_id", sa.String(100), primary_key=True), + sa.Column("hostname", sa.String(200), nullable=False, server_default=""), + sa.Column("gpu_name", sa.String(200), nullable=False, server_default=""), + sa.Column("free_vram_mb", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("capabilities", sa.String(500), nullable=False, server_default=""), + sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True, index=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + + # GPU 口型同步任务表 + op.create_table( + "gpu_lipsync_tasks", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("lipsync_job_id", sa.String(36), nullable=False, server_default="", index=True), + sa.Column("user_id", sa.String(36), nullable=False, server_default="", index=True), + sa.Column("project_id", sa.String(36), nullable=False, server_default="", index=True), + sa.Column("video_url", sa.Text(), nullable=False), + sa.Column("audio_url", sa.Text(), nullable=False), + sa.Column("result_url", sa.Text(), nullable=False, server_default=""), + sa.Column("result_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("worker_id", sa.String(100), nullable=False, server_default="", index=True), + sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("error_msg", sa.Text(), nullable=False, server_default=""), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True), + ) + op.create_index("ix_gpu_lipsync_status_created", "gpu_lipsync_tasks", ["status", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_gpu_lipsync_status_created", table_name="gpu_lipsync_tasks") + op.drop_table("gpu_lipsync_tasks") + op.drop_table("gpu_workers") diff --git a/alembic/versions/082_atom_clip_ai_tags.py b/alembic/versions/082_atom_clip_ai_tags.py new file mode 100644 index 000000000..1a1670c6d --- /dev/null +++ b/alembic/versions/082_atom_clip_ai_tags.py @@ -0,0 +1,26 @@ +"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging + +Revision ID: 082_atom_clip_ai_tags +Revises: 081_add_gpu_lipsync +Create Date: 2026-09-18 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "082_atom_clip_ai_tags" +down_revision = "081_add_gpu_lipsync" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "asset_atom_clips", + sa.Column("ai_tags", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("asset_atom_clips", "ai_tags") diff --git a/alembic/versions/083_cover_title_config.py b/alembic/versions/083_cover_title_config.py new file mode 100644 index 000000000..341f1d728 --- /dev/null +++ b/alembic/versions/083_cover_title_config.py @@ -0,0 +1,26 @@ +"""#2001 爆款标题样式面板升级: ai_avatar_render_jobs 新增 cover_title_config + +Revision ID: 083_cover_title_config +Revises: 082_atom_clip_ai_tags +Create Date: 2026-09-20 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "083_cover_title_config" +down_revision = "082_atom_clip_ai_tags" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ai_avatar_render_jobs", + sa.Column("cover_title_config", sa.JSON(), nullable=False, server_default=sa.text("'{}'")), + ) + + +def downgrade() -> None: + op.drop_column("ai_avatar_render_jobs", "cover_title_config") diff --git a/alembic/versions/084_lipsync_jobs_style.py b/alembic/versions/084_lipsync_jobs_style.py new file mode 100644 index 000000000..08575bdf3 --- /dev/null +++ b/alembic/versions/084_lipsync_jobs_style.py @@ -0,0 +1,26 @@ +"""lipsync_jobs 新增 style 字段(TTS 语气风格) + +Revision ID: 084_lipsync_jobs_style +Revises: 083_cover_title_config +Create Date: 2026-09-21 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "084_lipsync_jobs_style" +down_revision = "083_cover_title_config" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "lipsync_jobs", + sa.Column("style", sa.String(length=32), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_column("lipsync_jobs", "style") diff --git a/alembic/versions/085_atom_clip_caption_embedding.py b/alembic/versions/085_atom_clip_caption_embedding.py new file mode 100644 index 000000000..f3348d565 --- /dev/null +++ b/alembic/versions/085_atom_clip_caption_embedding.py @@ -0,0 +1,33 @@ +"""asset_atom_clips 新增 caption/embedding 字段(#2035 语义标签增强) + +Revision ID: 085_atom_clip_caption_embedding +Revises: 084_lipsync_jobs_style +Create Date: 2026-09-25 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "085_atom_clip_caption_embedding" +down_revision = "084_lipsync_jobs_style" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # caption: 中文画面描述(10-30字) + op.add_column( + "asset_atom_clips", + sa.Column("caption", sa.Text(), nullable=True), + ) + # embedding: caption 对应的向量(豆包 embedding 接口返回,JSON 存 float 数组) + op.add_column( + "asset_atom_clips", + sa.Column("embedding", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("asset_atom_clips", "embedding") + op.drop_column("asset_atom_clips", "caption") diff --git a/alembic/versions/086_add_viral_video_tables.py b/alembic/versions/086_add_viral_video_tables.py new file mode 100755 index 000000000..8f22364e7 --- /dev/null +++ b/alembic/versions/086_add_viral_video_tables.py @@ -0,0 +1,100 @@ +"""add viral video tables + +Revision ID: 086_add_viral_video_tables +Revises: 085_atom_clip_caption_embedding +Create Date: 2026-09-28 + +新增爆款视频相关表: +- viral_video_jobs: 爆款视频任务 +- viral_video_style_templates: 风格模板配置 +- viral_video_prompt_templates: Prompt 模板(由 #2040 seed) +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "086_add_viral_video_tables" +down_revision = "085_atom_clip_caption_embedding" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # viral_video_jobs + op.create_table( + "viral_video_jobs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), nullable=False, index=True), + sa.Column("images", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("industry", sa.String(100), nullable=False, server_default=""), + sa.Column("target_customer", sa.String(500), nullable=False, server_default=""), + sa.Column("persona_id", sa.String(36), nullable=False, server_default=""), + sa.Column("viral_structure", sa.String(50), nullable=False, server_default=""), + sa.Column("marketing_purpose", sa.String(100), nullable=False, server_default=""), + sa.Column("bgm_preference", sa.String(50), nullable=False, server_default=""), + sa.Column("duration", sa.Integer(), nullable=False, server_default="30"), + sa.Column("user_copy_text", sa.Text(), nullable=False, server_default=""), + sa.Column("fusion_level", sa.String(20), nullable=False, server_default="ai_polish"), + sa.Column("reference_audio_path", sa.String(1000), nullable=False, server_default=""), + # v1.3 新增 + sa.Column("reference_video_url", sa.String(1000), nullable=False, server_default=""), + sa.Column("style_strength", sa.String(20), nullable=False, server_default="medium"), + sa.Column("style_guide", sa.JSON(), nullable=True), + sa.Column("style_template_id", sa.String(36), nullable=False, server_default="", index=True), + # 状态与结果 + sa.Column("status", sa.String(30), nullable=False, server_default="pending", index=True), + sa.Column("intent_result", sa.JSON(), nullable=True), + sa.Column("result_video_url", sa.String(1000), nullable=False, server_default=""), + sa.Column("credits_cost", sa.Integer(), nullable=False, server_default="0"), + sa.Column("error_msg", sa.Text(), nullable=False, server_default=""), + sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + 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()), + ) + + # viral_video_style_templates + op.create_table( + "viral_video_style_templates", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.Column("thumbnail_url", sa.String(1000), nullable=False, server_default=""), + sa.Column("style_config", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + 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()), + ) + + # viral_video_prompt_templates + op.create_table( + "viral_video_prompt_templates", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("prompt_type", sa.String(50), nullable=False, index=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("content", sa.Text(), nullable=False, server_default=""), + sa.Column("variables", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True), + 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()), + ) + + # Seed 默认风格模板 + op.execute(""" + INSERT INTO viral_video_style_templates (id, name, description, style_config, is_system, sort_order) + VALUES + ('style-tpl-001', '快节奏冲击', '高频切镜+动感BGM,适合食品饮料等快消品', '{"cut_speed": "fast", "transition": "jump_cut", "energy": "high"}', true, 1), + ('style-tpl-002', '质感慢镜', '慢节奏+电影感调色,适合美妆护肤珠宝', '{"cut_speed": "slow", "transition": "dissolve", "energy": "low", "color_grade": "cinematic"}', true, 2), + ('style-tpl-003', '口播种草', '数字人口播+产品特写穿插', '{"cut_speed": "medium", "transition": "cross_dissolve", "has_talking_head": true}', true, 3), + ('style-tpl-004', '场景叙事', '多场景切换+故事线叙述', '{"cut_speed": "medium", "transition": "wipe", "narrative": true}', true, 4) + """) + + +def downgrade() -> None: + op.drop_table("viral_video_prompt_templates") + op.drop_table("viral_video_style_templates") + op.drop_table("viral_video_jobs") diff --git a/alembic/versions/087_viral_video_image_analysis.py b/alembic/versions/087_viral_video_image_analysis.py new file mode 100644 index 000000000..df1a6db92 --- /dev/null +++ b/alembic/versions/087_viral_video_image_analysis.py @@ -0,0 +1,25 @@ +"""viral video add image_analysis column + +Revision ID: 087_viral_video_image_analysis +Revises: 086_add_viral_video_tables +Create Date: 2026-09-30 + +#2106 爆款视频 P0:持久化图片分析结果(image_analysis JSON),供 resume 阶段使用。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "087_viral_video_image_analysis" +down_revision = "086_add_viral_video_tables" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("viral_video_jobs", sa.Column("image_analysis", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("viral_video_jobs", "image_analysis") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index ab8df9da4..7764a83e6 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -8,17 +8,21 @@ from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.classification_jobs import router as classification_jobs_router from app.api.routes.clips_standalone import router as clips_standalone_router from app.api.routes.cover_templates import router as cover_templates_router +from app.api.routes.drafts_standalone import router as drafts_standalone_router from app.api.routes.duplication import router as duplication_router 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.gpu_lipsync import router as gpu_lipsync_router +from app.api.routes.gpu_relay import router as gpu_relay_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.points import points_router, usage_router +from app.api.routes.points import router as points_router +from app.api.routes.points import usage_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.scripts_ai import router as scripts_ai_router @@ -32,6 +36,7 @@ from app.api.routes.titles import router as titles_router from app.api.routes.tts import router as tts_router from app.api.routes.upload import router as upload_router from app.api.routes.videos import router as videos_router +from app.api.routes.viral_video import router as viral_video_router from app.api.routes.voice_clones import router as voice_clones_router from app.api.routes.voices import router as voices_router from fastapi import APIRouter @@ -40,6 +45,19 @@ api_router = APIRouter(prefix="/api/v1") health_router = APIRouter() health_router.include_router(health_check_router) +# ── /api/health 别名:部分前端/探针把 health 放在 /api 前缀下 ────────────── +# 原来 /health 在根路径;额外加一个 /api/health 别名避免 404。 +api_health_router = APIRouter(prefix="/api") +api_health_router.include_router(health_check_router) +health_router.include_router(api_health_router) + +# ── 旧前端路径别名(无需 template_id 路径参数)──────────────────────────── +# /api/v1/clips/from-assets 已有 clips_standalone;此处额外挂 /api/v1/editor/*, +# 解决前端调 /api/v1/editor/clips/from-assets 和 /api/v1/editor/drafts 的 404。 +editor_legacy_router = APIRouter(prefix="/editor", tags=["Editor Legacy Alias"]) +editor_legacy_router.include_router(clips_standalone_router) +editor_legacy_router.include_router(drafts_standalone_router) + api_router.include_router( auth_router, tags=["Auth"], @@ -168,6 +186,9 @@ api_router.include_router( prefix="/templates/{template_id}/editor", tags=["TemplateEditor"], ) +api_router.include_router( + editor_legacy_router, +) api_router.include_router( tts_router, prefix="/tts", @@ -186,6 +207,10 @@ api_router.include_router( internal_render_router, tags=["Internal"], ) +api_router.include_router( + gpu_relay_router, + tags=["GpuRelay"], +) api_router.include_router( scripts_router, prefix="/scripts", @@ -211,3 +236,9 @@ api_router.include_router( prefix="/usage", tags=["Usage"], ) +api_router.include_router( + gpu_lipsync_router, + prefix="/gpu", + tags=["GPU Worker"], +) +api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"]) diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index 52fc98abb..d921d7928 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -63,6 +63,7 @@ def create_render_job( b_roll_segments=[s.model_dump() for s in body.b_roll_segments], title_config=body.title_config, cover_config=body.cover_config, + cover_title_config=body.cover_title_config, project_id=body.project_id, ) except AiAvatarRenderError as exc: diff --git a/apps/api/app/api/routes/drafts_standalone.py b/apps/api/app/api/routes/drafts_standalone.py new file mode 100644 index 000000000..302e3b36d --- /dev/null +++ b/apps/api/app/api/routes/drafts_standalone.py @@ -0,0 +1,102 @@ +"""独立的草稿端点(不依赖 template_id 路径参数,兼容旧前端路径). + +提供以下别名端点,与 /api/v1/templates/{template_id}/editor/draft 功能一致: +- GET /api/v1/editor/drafts 获取草稿详情(template_id 从 query/body/默认模板兜底) +- PUT /api/v1/editor/drafts 更新草稿(兼容前端 useDraftAutoSave 调用) + +根因:前端 useDraftAutoSave 调用 /api/v1/editor/drafts(复数、无 template_id), +与后端以 template_id 为路径参数的设计不一致,导致 404 并触发 10s timeout。 +本模块参照 clips_standalone.py 的模式,通过默认模板兜底复用 draft.py 的核心逻辑。 +""" + +from __future__ import annotations + +import logging + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session +from app.services.edit_plan_service import EditPlanService +from app.services.edit_template_service import EditTemplateService +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from ._default_template import get_or_create_default_template_id +from .templates_editor.dependencies import resolve_draft_plan_id +from .templates_editor.draft import get_editor_draft, update_editor_draft +from .templates_editor.schemas import EditorDraftResponse, EditorUpdateRequest + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["Editor Legacy Alias"]) + + +def _resolve_editor_services(db: Session) -> tuple[EditTemplateService, EditPlanService]: + return EditTemplateService(db), EditPlanService(db) + + +def _resolve_template_id( + template_id: str | None, + db: Session, + current_user: AuthenticatedUser, +) -> str: + """解析 template_id:query/body 优先,否则兜底默认模板。""" + tid = (template_id or "").strip() + if tid: + return tid + user_id = str(current_user.user.id) + tid = get_or_create_default_template_id(db, user_id) + if not tid: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="无法自动创建默认模板,请刷新页面重试", + ) + return tid + + +@router.get("/drafts", response_model=EditorDraftResponse) +def get_editor_drafts_alias( + template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"), + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditorDraftResponse: + """获取草稿详情(复数路径别名,兼容旧前端调用)。""" + tid = _resolve_template_id(template_id, db, current_user) + services = _resolve_editor_services(db) + plan_id = resolve_draft_plan_id( + template_id=tid, + services=services, + current_user=current_user, + db=db, + auto_create_default=False, + ) + return get_editor_draft( + template_id=tid, + plan_id=plan_id, + services=services, + _=current_user, + ) + + +@router.put("/drafts", response_model=EditorDraftResponse) +def update_editor_drafts_alias( + req: EditorUpdateRequest, + template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"), + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditorDraftResponse: + """更新草稿(复数路径别名,兼容前端 useDraftAutoSave 调用)。""" + tid = _resolve_template_id(template_id, db, current_user) + services = _resolve_editor_services(db) + plan_id = resolve_draft_plan_id( + template_id=tid, + services=services, + current_user=current_user, + db=db, + auto_create_default=False, + ) + return update_editor_draft( + template_id=tid, + req=req, + plan_id=plan_id, + services=services, + _=current_user, + ) diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index 658189880..c147c0f93 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -76,10 +76,7 @@ class GenerateCoverResponse(BaseModel): # ── Route ──────────────────────────────────────────────────────────────── - -def _select_best_frame_from_snapshots( - snapshots: list[dict], plan_id: str -) -> str: +def _select_best_frame_from_snapshots(snapshots: list[dict], plan_id: str) -> str: """从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。 降级策略:cv2 不可用或评分失败时,返回第一帧。 @@ -232,7 +229,10 @@ def _persist_cover_frame( def _get_task_video_url(db: Session, task_id: str) -> Optional[str]: - """从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.""" + """从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL. + + #2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。 + """ try: video_repo = get_generated_video_repository(db) use_case = ListGeneratedVideosByTaskUseCase(video_repo) @@ -241,6 +241,20 @@ def _get_task_video_url(db: Session, task_id: str) -> Optional[str]: return getattr(videos[0], "file_url", "") or "" except Exception: logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True) + # awaiting_cover 兜底:从 extra_meta.rendered_output 取 + try: + task_repo = SQLAlchemyGenerationTaskRepository(db) + task = task_repo.get(task_id) + if task is not None: + _status = task.status.value if hasattr(task.status, "value") else str(task.status) + if _status == "awaiting_cover": + _meta = getattr(task, "extra_meta", {}) or {} + _ro = _meta.get("rendered_output") or {} + _url = _ro.get("file_url") or "" + if _url: + return _url + except Exception: + logger.warning("[封面生成] awaiting_cover 兜底读取失败: task_id=%s", task_id, exc_info=True) return None diff --git a/apps/api/app/api/routes/generation_preview.py b/apps/api/app/api/routes/generation_preview.py index b518b16d4..ec828daed 100755 --- a/apps/api/app/api/routes/generation_preview.py +++ b/apps/api/app/api/routes/generation_preview.py @@ -11,9 +11,7 @@ from app.auth import AuthenticatedUser, get_current_user from app.core.storage import get_storage_service from app.core.task_enqueue import ( GLOBAL_PENDING_LIMIT, - USER_PENDING_LIMIT, GlobalQueueFull, - UserPendingLimitExceeded, build_rate_limit_detail, safe_enqueue_generation_task, ) @@ -302,26 +300,17 @@ def create_preview_generation_task( count, ) - # 预检查队列限流(按变体总数计) - try: - user_pending = generation_task_repository.count_pending_by_user(user_id) - global_pending = generation_task_repository.count_pending_total() - 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=build_rate_limit_detail(e, generation_task_repository, scope="user"), - ) from e - except GlobalQueueFull as e: + # 预检查队列限流(按变体总数计)——仅保留全局硬上限,用户上限改为软 warning 在 safe_enqueue 内处理(#2098) + global_pending = generation_task_repository.count_pending_total() + if global_pending + count > GLOBAL_PENDING_LIMIT: raise HTTPException( status_code=503, - detail=build_rate_limit_detail(e, generation_task_repository, scope="global"), - ) from e + detail=build_rate_limit_detail( + GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT), + generation_task_repository, + scope="global", + ), + ) # 确定视频比例:优先前端传入,否则从模板 mode 推断 video_ratio = request.video_ratio or "" @@ -589,9 +578,6 @@ def create_preview_generation_task( 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 @@ -603,11 +589,6 @@ def create_preview_generation_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=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"), @@ -650,11 +631,26 @@ def get_preview_generation_task( if not getattr(task, "is_preview", False): raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在") - # 查询生成的视频(取第一个) + # 查询生成的视频(取第一个)。 + # #2024: 渲染完成后先进入 awaiting_cover(未入成品库),此时预览也应可见, + # 从 extra_meta["rendered_output"] 读取视频 URL。 generated_videos = [] status_val = task.status.value if hasattr(task.status, "value") else str(task.status) if status_val == "completed": list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository) generated_videos = list_use_case.execute(task_id) + elif status_val == "awaiting_cover": + # 用 extra_meta 中的渲染信息组装一个轻量视频对象给前端预览播放 + _meta = getattr(task, "extra_meta", {}) or {} + _ro = _meta.get("rendered_output") or {} + if _ro.get("file_url"): + + class _PreviewVideo: + def __init__(self, ro): + self.file_url = ro.get("file_url", "") + self.duration = float(ro.get("duration") or 0.0) + self.file_size = int(ro.get("file_size") or 0) + + generated_videos = [_PreviewVideo(_ro)] return _to_preview_response(task, generated_videos=generated_videos) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 560a81750..0f0a2daac 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -7,7 +7,6 @@ from app.auth import AuthenticatedUser, get_current_user from app.core.storage import OSSStorageService, get_storage_service from app.core.task_enqueue import ( GLOBAL_PENDING_LIMIT, - USER_PENDING_LIMIT, GlobalQueueFull, UserPendingLimitExceeded, build_rate_limit_detail, @@ -31,6 +30,8 @@ from app.schemas.generation_task import ( BatchGenerationTaskResponse, ConfirmGenerationRequest, CreateGenerationTaskRequest, + FinalizeGenerationRequest, + FinalizeGenerationResponse, GenerationTaskResponse, ListGenerationTasksResponse, ) @@ -44,6 +45,124 @@ from packages.application import ( ListGeneratedVideosByTaskUseCase, ) from packages.domain.smart_match import smart_select_assets + +# #2035:文案关键词 → 素材分类 映射表(用于 smart_match category_match 维度) +# AssetClassification 枚举: scenic / product / person / animal / food / tech / sport / music / other +_CATEGORY_KEYWORDS: dict[str, set[str]] = { + "scenic": { + "风景", + "自然", + "山水", + "大海", + "天空", + "日落", + "日出", + "森林", + "城市", + "建筑", + "夜景", + "街道", + "公园", + "景区", + "旅行", + "旅游", + "户外", + }, + "product": { + "产品", + "商品", + "展示", + "演示", + "开箱", + "评测", + "好物", + "推荐", + "种草", + "购物", + "电商", + "带货", + "品牌", + "广告", + "包装", + }, + "person": { + "人物", + "人物采访", + "对话", + "说话", + "讲解", + "演讲", + "采访", + "聊天", + "开会", + "工作", + "办公室", + "团队", + "员工", + "老板", + "女性", + "男性", + "美女", + "帅哥", + }, + "animal": {"动物", "宠物", "狗", "猫", "鸟", "鱼", "马", "牛", "羊", "野生动物", "动物园"}, + "food": { + "美食", + "食物", + "餐饮", + "餐厅", + "做饭", + "烹饪", + "厨房", + "菜品", + "饮料", + "水果", + "甜点", + "蛋糕", + "咖啡", + "茶", + "零食", + "吃", + }, + "tech": { + "科技", + "数码", + "电脑", + "手机", + "屏幕", + "软件", + "APP", + "互联网", + "AI", + "人工智能", + "机器人", + "办公", + "程序员", + "代码", + "屏幕录制", + }, + "sport": {"运动", "健身", "跑步", "篮球", "足球", "游泳", "瑜伽", "户外", "锻炼", "体育", "比赛", "球场"}, + "music": {"音乐", "歌曲", "演唱会", "乐器", "唱歌", "跳舞", "舞蹈", "MV", "演出", "乐队", "钢琴", "吉他", "节奏"}, +} + + +def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None: + """从文案标签集合推断期望的素材分类(可能命中多个)。标签为空返回 None。""" + if not script_tags: + return None + matched: set[str] = set() + for cat, kws in _CATEGORY_KEYWORDS.items(): + for tag in script_tags: + tag.lower() + for kw in kws: + if kw in tag or tag in kw: + matched.add(cat) + break + if cat in matched: + break + return matched or None + + from packages.middleware.points_gate import points_gate logger = logging.getLogger(__name__) @@ -132,10 +251,11 @@ def _ensure_library_has_ready_video_assets(assets) -> None: def _select_assets_from_library( assets: list, mode: str, - count: int, + count: int = 0, rng=None, script_tags: list | None = None, tag_names_by_id: dict | None = None, + db=None, ) -> list[str]: """根据选取模式从素材库中选取 ready 状态的视频素材 ID。 @@ -156,6 +276,45 @@ def _select_assets_from_library( if not ready_video_assets: return [] + # #2035:加载片段级 AI 标签,供叙事模式 AI 加权和 smart 模式语义匹配使用。 + # 失败降级为空(不影响选片主流程)。 + clip_ai_tags_by_asset: dict[str, list[dict]] = {} + ai_tags_by_asset: dict[ + str, dict + ] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重) + try: + if db is not None: + from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel + + ready_ids = [a.id for a in ready_video_assets] + clip_rows = ( + db.query(AssetAtomClipModel.asset_id, AssetAtomClipModel.ai_tags) + .filter(AssetAtomClipModel.asset_id.in_(ready_ids)) + .filter(AssetAtomClipModel.ai_tags.isnot(None)) + .all() + ) + agg: dict[str, dict] = {} + for asset_id, ai_tags in clip_rows: + if not isinstance(ai_tags, dict): + continue + clip_ai_tags_by_asset.setdefault(asset_id, []).append(ai_tags) + # 聚合:合并 scene/objects/action 去重 + agg.setdefault(asset_id, {"scene": [], "objects": [], "action": [], "shot": "", "has_text": False}) + for key in ("scene", "objects", "action"): + for v in ai_tags.get(key) or []: + v = str(v).strip() + if v and v not in agg[asset_id][key]: + agg[asset_id][key].append(v) + if ai_tags.get("has_text") is True: + agg[asset_id]["has_text"] = True + if not agg[asset_id]["shot"] and ai_tags.get("shot"): + agg[asset_id]["shot"] = ai_tags["shot"] + ai_tags_by_asset = agg + except Exception: # noqa: BLE001 + logger.warning("[选片] 加载片段 AI 标签失败,降级不使用语义匹配", exc_info=True) + clip_ai_tags_by_asset = {} + ai_tags_by_asset = {} + # 叙事模式(#1970 PR3):文案标签命中池优先;无任何命中时完全降级为现有随机逻辑。 if script_tags: from packages.domain.narrative_match import pick_narrative_assets @@ -165,6 +324,7 @@ def _select_assets_from_library( ready_video_assets, script_tags=script_tags, tag_names_by_id=tag_names_by_id, + clip_ai_tags_by_asset=clip_ai_tags_by_asset, limit=limit, rng=rng, ) @@ -175,7 +335,18 @@ def _select_assets_from_library( # 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%) # 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重 limit = count if count > 0 else None - results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng) + # #2035:给 smart_select_assets 传入文案标签和 AI 标签映射,启用语义维度 + norm_script = {t.strip().lower() for t in (script_tags or []) if t and t.strip()} + expected_categories = _infer_expected_categories(norm_script) + results = smart_select_assets( + ready_video_assets, + limit=limit, + kind="video", + rng=rng, + script_tags=norm_script if norm_script else None, + ai_tags_by_asset=ai_tags_by_asset or None, + expected_categories=expected_categories, + ) return [r.asset.id for r in results] # 默认 all 模式:返回全部 ready 视频素材 @@ -394,6 +565,7 @@ def create_generation_task( count=request.asset_select_count, script_tags=narrative_script_tags or None, tag_names_by_id=_tag_index, + db=db, ) elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags): # 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取 @@ -408,6 +580,7 @@ def create_generation_task( count=request.asset_select_count, script_tags=narrative_script_tags or None, tag_names_by_id=_tag_index, + db=db, ) if not resolved_asset_ids: raise HTTPException( @@ -510,21 +683,13 @@ def create_generation_task( # 同批次任务共享 batch_id,用于视频查重时批次内比对 batch_id = uuid.uuid4().hex if count > 1 else "" - # 预检查:批量提交前先看会不会超限,避免建一半才拒 + # 预检查(Bug B #2098):只保留全局 503 保护,用户级不再硬拒 429; + # 超额任务直接入队等待 worker 自然消费,前端展示排队位置而非阻止提交。 + # USER_PENDING_LIMIT 作为软上限(safe_enqueue 兜底),提高到 20 支持批量提交。 try: - user_pending = generation_task_repository.count_pending_by_user(user_id) global_pending = generation_task_repository.count_pending_total() - 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=build_rate_limit_detail(e, generation_task_repository, scope="user"), - ) from e except GlobalQueueFull as e: raise HTTPException( status_code=503, @@ -839,14 +1004,10 @@ def create_generation_task( else: failed_tasks.append(task) except UserPendingLimitExceeded as _e: - # 兜底:如果预检查后又并发提交了,在这里也拦住 - failed_tasks.append(task) - if not created_tasks: - raise HTTPException( - status_code=429, - detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), - ) from _e - break + # Bug B #2098: 用户级限流已改为软限制,此分支理论上不再触发; + # 极端并发兜底仍入队(safe_enqueue 内部会打 warning 日志),不 429 拒绝 + logger.warning("[生成任务] 用户 pending 超软限制,仍允许入队: task_id=%s", task.id) + created_tasks.append(task) except GlobalQueueFull as _e: failed_tasks.append(task) if not created_tasks: @@ -891,8 +1052,14 @@ def confirm_generation( if source_task.project_id: check_project_access(source_task.project_id, authenticated_user.user.id, project_repository) - # 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出) - if source_task.is_completed and getattr(source_task, "is_preview", False): + # 3. 如果预览任务已完成渲染(completed 或 awaiting_cover),检查分辨率一致性后复用产物(秒出)。 + # #2024: 渲染完成先进入 awaiting_cover(等 Step5 finalize 入库), + # confirm 时不再直接 finalize——仍创建 is_preview=False 的正式任务,复用预览渲染产物。 + _preview_done = getattr(source_task, "is_preview", False) and source_task.status.value in ( + "completed", + "awaiting_cover", + ) + if _preview_done: # 校验请求的分辨率是否与预览实际渲染的分辨率一致 req_w = request.output_width or 0 req_h = request.output_height or 0 @@ -907,13 +1074,25 @@ def confirm_generation( confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {}) confirmed_title_config["text"] = request.custom_title.strip() + # #2024: mark_confirmed 会把 is_preview 翻转为 False、同步标题/分辨率/封面, + # 但不再自动 mark_completed——任务停留在 awaiting_cover,等待用户 Step5 选封面后调 finalize。 source_task.mark_confirmed( cover_url=request.cover_url, output_width=request.output_width, output_height=request.output_height, title_config=confirmed_title_config, ) + # 若预览任务此时是 completed(历史数据/旧 worker),回退到 awaiting_cover 统一流程 + if source_task.status.value == "completed": + try: + from packages.domain.generation_task import GenerationTaskStatus + + source_task.status = GenerationTaskStatus.AWAITING_COVER + source_task.completed_at = None + except Exception: + pass generation_task_repository.update(source_task) + db.commit() # 同步标题到 EditPlan.config # #1970:确认生成复用预览计划,dedup_enabled 沿用计划已有值,不在此覆盖 @@ -926,7 +1105,7 @@ def confirm_generation( ) logger.info( - "[确认生成] 复用预览产物: task_id=%s, user_id=%s", + "[确认生成] 复用预览产物(等待 finalize): task_id=%s, user_id=%s", task_id, authenticated_user.user.id, ) @@ -979,10 +1158,8 @@ def confirm_generation( ): logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id) except UserPendingLimitExceeded as _e: - raise HTTPException( - status_code=429, - detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), - ) from None + # Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行 + logger.warning("[任务] 用户 pending 超软限制,任务已入队") except GlobalQueueFull as _e: raise HTTPException( status_code=503, @@ -995,6 +1172,67 @@ def confirm_generation( ) +@router.post("/tasks/{task_id}/finalize", response_model=FinalizeGenerationResponse) +def finalize_generation_task( + task_id: str, + request: FinalizeGenerationRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + generation_task_repository: Any = Depends(get_generation_task_repository), + generated_video_repository: Any = Depends(get_generated_video_repository), + project_repository: Any = Depends(get_project_repository), + storage_service: OSSStorageService = Depends(get_storage_service), + db: Session = Depends(get_db_session), +) -> FinalizeGenerationResponse: + """#2024: Step5 点「完成」时调用——将 awaiting_cover 状态的任务正式入库+绑定封面。 + + - 任务必须处于 awaiting_cover 状态(渲染+上传已完成、封面候选已就绪)。 + - cover_url 为空则使用任务自动截帧/智能封面;非空则绑定为最终封面。 + - 幂等:已 finalize 的任务直接返回已有视频记录。 + - 成功后任务推进到 completed,返回成品视频 ID + 可播放 URL。 + """ + from app.services.generation_finalize_service import ( + GenerationFinalizeError, + GenerationFinalizeService, + ) + + task = generation_task_repository.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found") + if task.project_id: + check_project_access(task.project_id, authenticated_user.user.id, project_repository) + + service = GenerationFinalizeService(db) + try: + video = service.finalize_task( + task_id=task_id, + user_id=authenticated_user.user.id, + cover_url=request.cover_url or None, + custom_title=(request.custom_title or "").strip() or None, + ) + except GenerationFinalizeError as e: + raise HTTPException(status_code=e.status_code, detail=str(e)) from e + + try: + download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400) + except Exception: + download_url = video.file_url + return FinalizeGenerationResponse( + video_id=video.id, + project_id=getattr(video, "project_id", "") or "", + name=getattr(video, "name", "") or "", + file_size=int(getattr(video, "file_size", 0) or 0), + duration=float(getattr(video, "duration", 0.0) or 0.0), + thumbnail_url=video.thumbnail_url or "", + cover_url=video.thumbnail_url or "", + file_url=download_url, + width=int(getattr(video, "width", 0) or 0), + height=int(getattr(video, "height", 0) or 0), + fps=float(getattr(video, "fps", 0.0) or 0.0), + status="success", + is_duplicate=bool(getattr(video, "is_duplicate", False)), + ) + + @router.get("/tasks", response_model=ListGenerationTasksResponse) def list_generation_tasks( authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -1042,6 +1280,44 @@ def list_generation_results( for item in items: download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400) responses.append(_to_generated_video_response(item, download_url=download_url)) + + # #2024/#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库, + # 从 extra_meta["rendered_output"] 合成一条轻量视频响应,供前端预览与智能封面使用。 + status_val = task.status.value if hasattr(task.status, "value") else str(task.status) + if not responses and status_val == "awaiting_cover": + _meta = getattr(task, "extra_meta", {}) or {} + _ro = _meta.get("rendered_output") or {} + _file_url = _ro.get("file_url") or "" + if _file_url: + if _file_url.startswith("http"): + _download = _file_url + else: + try: + _download = storage_service.get_download_url(_file_url, expires_seconds=86400) + except Exception: + _download = _file_url + _name = _ro.get("name") or "" + if not _name: + _name = f"generated-{task_id[:8]}" + responses.append( + GeneratedVideoResponse( + id=f"preview-{task_id}", + project_id=getattr(task, "project_id", "") or "", + generation_task_id=task_id, + name=_name, + file_url=_file_url, + file_size=int(_ro.get("file_size") or 0), + duration=float(_ro.get("duration") or 0.0), + thumbnail_url=_ro.get("thumbnail_url") or getattr(task, "cover_url", "") or "", + width=int(_ro.get("width") or 0), + height=int(_ro.get("height") or 0), + fps=float(_ro.get("fps") or 0.0), + mode=_ro.get("mode", ""), + download_url=_download, + created_at=getattr(task, "updated_at", None) or getattr(task, "created_at", None), + ) + ) + return ListGeneratedVideosResponse(items=responses) @@ -1062,22 +1338,8 @@ def retry_generation_task( raise HTTPException(status_code=409, detail="Only failed tasks can be retried") user_id = authenticated_user.user.id - # 预检查:创建前判断,>= 上限就拒绝 - user_pending = generation_task_repository.count_pending_by_user(user_id) + # 预检查(Bug B #2098):只保留全局 503,用户级不再硬拒 global_pending = generation_task_repository.count_pending_total() - if user_pending >= USER_PENDING_LIMIT: - raise HTTPException( - status_code=429, - 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, @@ -1120,10 +1382,8 @@ def retry_generation_task( ): logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id) except UserPendingLimitExceeded as _e: - raise HTTPException( - status_code=429, - detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"), - ) from None + # Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行 + logger.warning("[任务] 用户 pending 超软限制,任务已入队") except GlobalQueueFull as _e: raise HTTPException( status_code=503, diff --git a/apps/api/app/api/routes/gpu_lipsync.py b/apps/api/app/api/routes/gpu_lipsync.py new file mode 100644 index 000000000..85a7c97a9 --- /dev/null +++ b/apps/api/app/api/routes/gpu_lipsync.py @@ -0,0 +1,231 @@ +"""GPU MuseTalk Worker 反向轮询路由 — /api/v1/gpu/lipsync/*. + +仅面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。 +鉴权方式:长期 API Token(`Authorization: Bearer `),不走用户 JWT。 + +接口: + POST /api/v1/gpu/register Worker 注册/心跳 + GET /api/v1/gpu/lipsync/poll Worker 轮询拉任务(无任务返回 204) + POST /api/v1/gpu/lipsync/result Worker multipart 上传结果视频/上报失败 + GET /api/v1/gpu/lipsync/status/{id} 业务侧查询任务状态(内部接口,暂开放给登录用户) +""" + +from __future__ import annotations + +import logging +import tempfile +from datetime import UTC, datetime +from pathlib import Path +from typing import Optional + +import requests +from app.core.storage import get_storage_service +from app.dependencies import get_db_session +from app.schemas.gpu_lipsync import ( + GpuLipsyncPollResponse, + GpuLipsyncResultResponse, + GpuLipsyncStatusResponse, + GpuLipsyncTaskPayload, + GpuWorkerRegisterRequest, + GpuWorkerRegisterResponse, +) +from app.services.gpu_lipsync_service import GpuLipsyncService +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Query, + Request, + UploadFile, + status, +) +from fastapi.responses import Response +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from packages.config import get_api_settings + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# 复用 bearer scheme 抽 Token,但不校验用户 JWT +_gpu_bearer = HTTPBearer(auto_error=False) + + +def _verify_gpu_token( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_gpu_bearer), +) -> str: + """校验 GPU Worker Token,返回 worker 提供的 token 串(仅用于日志,不做身份识别). + + - development 且未配置 token → 直接放行(方便本地调试)。 + - production/staging 未配置 token → 拒绝(避免裸奔)。 + - token 不匹配 → 401。 + """ + settings = get_api_settings() + expected = (settings.gpu_worker_token or "").strip() + is_dev = settings.environment == "development" + if not expected: + if is_dev: + return credentials.credentials if credentials else "" + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="GPU_WORKER_TOKEN not configured on server", + ) + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") + if credentials.credentials != expected: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid GPU worker token") + return credentials.credentials + + +def _get_svc(db=Depends(get_db_session)) -> GpuLipsyncService: + return GpuLipsyncService(db) + + +# ── POST /register — Worker 注册/心跳 ────────────────────────────── + + +@router.post("/register", response_model=GpuWorkerRegisterResponse) +def register_worker( + body: GpuWorkerRegisterRequest, + svc: GpuLipsyncService = Depends(_get_svc), + _token: str = Depends(_verify_gpu_token), +): + worker, cancel_task = svc.register_worker( + worker_id=body.worker_id, + hostname=body.hostname, + gpu_name=body.gpu_name, + free_vram_mb=body.free_vram_mb, + capabilities=body.capabilities, + task_id=body.task_id, + ) + return GpuWorkerRegisterResponse(ok=True, server_time=datetime.now(UTC), message="ok", cancel_task=cancel_task) + + +# ── GET /lipsync/poll — Worker 轮询拉任务 ───────────────────────── + + +@router.get("/lipsync/poll") +def poll_task( + worker_id: str = Query(..., min_length=1, max_length=100, description="Worker 唯一 ID"), + svc: GpuLipsyncService = Depends(_get_svc), + _token: str = Depends(_verify_gpu_token), +): + task = svc.poll_task(worker_id=worker_id) + if task is None: + return Response(status_code=status.HTTP_204_NO_CONTENT) + payload = GpuLipsyncTaskPayload( + task_id=task.id, + video_url=getattr(task, "_signed_video_url", task.video_url), + audio_url=getattr(task, "_signed_audio_url", task.audio_url), + lipsync_job_id=task.lipsync_job_id or "", + user_id=task.user_id or "", + project_id=task.project_id or "", + created_at=task.created_at, + upload_url=getattr(task, "_signed_upload_url", ""), + upload_method="PUT", + expires_at=getattr(task, "_upload_expires_at", datetime.now(UTC)), + ) + return GpuLipsyncPollResponse(task=payload) + + +# ── POST /lipsync/result — Worker 上报结果(multipart) ───────────── + + +@router.post("/lipsync/result", response_model=GpuLipsyncResultResponse) +async def report_result( + request: Request, + task_id: str = Form(...), + worker_id: str = Form(...), + success: bool = Form(True), + duration_seconds: float = Form(0.0), + error_msg: str = Form(""), + result: Optional[UploadFile] = File(None), + svc: GpuLipsyncService = Depends(_get_svc), + _token: str = Depends(_verify_gpu_token), +): + # 参数校验: + # - success=true + result 文件 → API 代为上传到 OSS(方便 Worker 端实现) + # - success=true + 无文件 → Worker 已经自己 PUT 到预签名 upload_url,直接确认 + # - success=false → 不上传文件,错误信息通过 error_msg 传递 + if success and result is not None: + # 把文件落盘到临时目录,然后 PUT 到预签名 URL + storage = get_storage_service() + result_key = svc._result_key(task_id) + upload_url = storage.get_upload_url(result_key, expires_seconds=3600, content_type="video/mp4") + try: + with tempfile.TemporaryDirectory(prefix="gpu_result_") as tmpdir: + tmp_path = Path(tmpdir) / "result.mp4" + content = await result.read() + if not content: + raise HTTPException(status_code=400, detail="上传的 result 文件为空") + tmp_path.write_bytes(content) + headers = {"Content-Type": "video/mp4"} + with open(tmp_path, "rb") as f: + resp = requests.put(upload_url, data=f, headers=headers, timeout=300) + if resp.status_code >= 400: + logger.error( + "上传 GPU 结果到 OSS 失败: status=%d body=%s", + resp.status_code, + resp.text[:500], + ) + raise HTTPException( + status_code=502, + detail=f"上传结果视频到 OSS 失败 (HTTP {resp.status_code})", + ) + except HTTPException: + raise + except Exception as exc: + logger.exception("上传 GPU 结果视频异常: %s", exc) + raise HTTPException(status_code=500, detail=f"上传结果视频异常: {exc}") from exc + elif not success: + # 失败时忽略 result 文件(即便传了也没用) + pass + # 其他情况:success=true 且无文件 → Worker 已自行 PUT 到预签名 URL,直接标记完成 + + try: + task = svc.report_result( + task_id=task_id, + worker_id=worker_id, + success=success, + duration_seconds=duration_seconds, + error_msg=error_msg, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return GpuLipsyncResultResponse( + ok=True, + task_id=task.id, + status=task.status, + message="ok", + ) + + +# ── GET /lipsync/status/{task_id} — 业务侧查询状态 ───────────────── +# 说明:此接口会被 lipsync_service 内部在业务流程里直接读 DB,不通过 HTTP。 +# 但仍暴露一个简单查询接口,方便调试和前端轮询(如后续需要)。暂不做用户权限校验, +# task_id 本身是 UUID,不可枚举。 + + +@router.get("/lipsync/status/{task_id}", response_model=GpuLipsyncStatusResponse) +def get_task_status( + task_id: str, + svc: GpuLipsyncService = Depends(_get_svc), +): + task = svc.get_task(task_id) + if task is None: + raise HTTPException(status_code=404, detail="task not found") + return GpuLipsyncStatusResponse( + task_id=task.id, + status=task.status, + result_url=task.result_url, + result_duration=task.result_duration, + error_msg=task.error_msg, + worker_id=task.worker_id, + attempt=task.attempt, + created_at=task.created_at, + started_at=task.started_at, + finished_at=task.finished_at, + ) diff --git a/apps/api/app/api/routes/gpu_relay.py b/apps/api/app/api/routes/gpu_relay.py new file mode 100644 index 000000000..34f6a8de0 --- /dev/null +++ b/apps/api/app/api/routes/gpu_relay.py @@ -0,0 +1,215 @@ +"""GPU 编码回传 relay 端点。 + +两个用途: +1. 结果回传(原):P4000 编码完成后通过 HTTP PUT 把结果 mp4 写到 /{key};Worker 用同 URL GET 回本地。 +2. Mezzanine 中转(新):Worker 先把 CPU ultrafast 编码出的 mezzanine 通过 PUT 到 /mezzanine/{key}, + P4000 通过 Tailscale 内网直接 GET 下载,跳过公网 OSS 中转,节省 18-20s 固定延迟。 + 编码完成后 DELETE 清理。 + +安全: +- 生产环境必须配置 GPU_ENCODE_RELAY_SECRET;token=xxx 查询参数必须匹配。 +- key 为随机 hex,无法被枚举。 +- 写入/读取后 worker 会调用 DELETE 主动清理;文件落地在 generated-files/gpu_relay/。 +""" +from __future__ import annotations + +import logging +import os +import secrets +import time +import uuid +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import FileResponse, Response + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/internal/gpu-relay", tags=["Internal-GpuRelay"]) + +_DEFAULT_SECRET_LOGGED = False + + +def _relay_dir() -> Path: + base = os.getenv("GENERATED_FILES_DIR", "/app/generated") + sub = os.getenv("GPU_ENCODE_RELAY_DIR", "gpu_relay") + p = Path(base) / sub + p.mkdir(parents=True, exist_ok=True) + return p + + +def _mezzanine_dir() -> Path: + p = _relay_dir() / "mezzanine" + p.mkdir(parents=True, exist_ok=True) + return p + + +def _secret() -> str: + global _DEFAULT_SECRET_LOGGED + secret = (os.getenv("GPU_ENCODE_RELAY_SECRET", "") or "").strip() + if not secret: + env = (os.getenv("APP_ENV", os.getenv("ENV", "development"))).lower() + if env in ("production", "prod"): + raise RuntimeError("GPU_ENCODE_RELAY_SECRET must be set in production") + secret = os.environ.setdefault("GPU_ENCODE_RELAY_SECRET", secrets.token_urlsafe(32)) + if not _DEFAULT_SECRET_LOGGED: + logger.warning( + "[gpu-relay] GPU_ENCODE_RELAY_SECRET not set; using ephemeral dev token (%s...)", + secret[:8], + ) + _DEFAULT_SECRET_LOGGED = True + return secret + + +def _safe_key(key: str) -> str: + """只允许合法文件名字符,防 path traversal。""" + k = key.strip() + if not k or "/" in k or "\\" in k or k in (".", "..") or not all( + c.isalnum() or c in "-_" for c in k + ): + raise HTTPException(status_code=400, detail="invalid key") + return k + + +def _check_token(tok: Optional[str]) -> None: + if not tok or tok != _secret(): + raise HTTPException(status_code=401, detail="unauthorized") + + +async def _atomic_write(request: Request, dst: Path, log_prefix: str, key_for_log: str) -> int: + """通用原子写入(流式 → .part → replace)。返回字节数。""" + tmp = dst.with_suffix(dst.suffix + ".part") + size = 0 + t0 = time.time() + try: + with open(tmp, "wb") as f: + async for chunk in request.stream(): + f.write(chunk) + size += len(chunk) + os.replace(tmp, dst) + except Exception as e: # noqa: BLE001 + if tmp.exists(): + try: + tmp.unlink() + except OSError: + pass + logger.exception("[gpu-relay] %s PUT failed key=%s", log_prefix, key_for_log) + raise HTTPException(status_code=500, detail=f"write failed: {e}") from e + logger.info( + "[gpu-relay] %s PUT key=%s size=%d took=%.2fs", + log_prefix, key_for_log, size, time.time() - t0, + ) + return size + + +def _file_response(path: Path, download_name: str) -> FileResponse: + if not path.exists(): + raise HTTPException(status_code=404, detail="not found") + return FileResponse(path=path, media_type="video/mp4", filename=f"{download_name}.mp4") + + +def _head_response(path: Path) -> Response: + if not path.exists(): + return Response(status_code=404) + return Response( + status_code=200, + media_type="video/mp4", + headers={"Content-Length": str(path.stat().st_size)}, + ) + + +def _safe_delete(path: Path, err_detail: str) -> dict: + try: + if path.exists(): + path.unlink() + except OSError as e: + raise HTTPException(status_code=500, detail=f"{err_detail}: {e}") from e + return {"ok": True} + + +# ── Worker 侧 URL 构造 ───────────────────────────────────────────────── +def build_relay_put_url(base_url: str, key: str, secret: str) -> str: + """给 P4000 回传结果用的 PUT URL(外部/Tailscale 可达)。""" + return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/{key}?token={secret}" + + +def build_relay_get_url(base_url: str, key: str, secret: str) -> str: + """Worker 取回结果用的 GET URL。""" + return build_relay_put_url(base_url, key, secret) + + +def build_mezzanine_put_url(base_url: str, key: str, secret: str) -> str: + """Worker 上传 mezzanine 用的 PUT URL(Docker 内网或 Tailscale)。""" + return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/mezzanine/{key}?token={secret}" + + +def build_mezzanine_get_url(base_url: str, key: str, secret: str) -> str: + """P4000 下载 mezzanine 用的 GET URL(必须是 P4000 可达地址,通常是 Tailscale host:8092)。""" + return build_mezzanine_put_url(base_url, key, secret) + + +def generate_key() -> str: + return uuid.uuid4().hex + + +# ── 编码结果:PUT/GET/HEAD/DELETE /{key} ────────────────────────────── +@router.put("/{key}") +async def put_object(key: str, request: Request, token: Optional[str] = Query(None)): + _check_token(token) + safe = _safe_key(key) + size = await _atomic_write(request, _relay_dir() / safe, "result", safe) + return {"ok": True, "key": safe, "size": size} + + +@router.get("/{key}") +async def get_object(key: str, token: Optional[str] = Query(None)): + _check_token(token) + safe = _safe_key(key) + return _file_response(_relay_dir() / safe, safe) + + +@router.head("/{key}") +async def head_object(key: str, token: Optional[str] = Query(None)): + _check_token(token) + safe = _safe_key(key) + return _head_response(_relay_dir() / safe) + + +@router.delete("/{key}") +async def delete_object(key: str, token: Optional[str] = Query(None)): + _check_token(token) + safe = _safe_key(key) + return _safe_delete(_relay_dir() / safe, "delete failed") + + +# ── Mezzanine 中转:PUT/GET/HEAD/DELETE /mezzanine/{key} ───────────── +# Worker 上传 mezzanine 用;P4000 通过 Tailscale 直接 GET 下载。 +@router.put("/mezzanine/{key}") +async def put_mezzanine(key: str, request: Request, token: Optional[str] = Query(None)): + _check_token(token) + safe = _safe_key(key) + dst = _mezzanine_dir() / f"{safe}.mp4" + size = await _atomic_write(request, dst, "mezzanine", safe) + return {"ok": True, "key": safe, "size": size} + + +@router.get("/mezzanine/{key}") +async def get_mezzanine(key: str, token: Optional[str] = Query(None)): + _check_token(token) + safe = _safe_key(key) + return _file_response(_mezzanine_dir() / f"{safe}.mp4", f"{safe}-mezzanine") + + +@router.head("/mezzanine/{key}") +async def head_mezzanine(key: str, token: Optional[str] = Query(None)): + _check_token(token) + safe = _safe_key(key) + return _head_response(_mezzanine_dir() / f"{safe}.mp4") + + +@router.delete("/mezzanine/{key}") +async def delete_mezzanine(key: str, token: Optional[str] = Query(None)): + _check_token(token) + safe = _safe_key(key) + return _safe_delete(_mezzanine_dir() / f"{safe}.mp4", "mezzanine delete failed") diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py index ef8359f6a..6e10608d3 100644 --- a/apps/api/app/api/routes/lipsync.py +++ b/apps/api/app/api/routes/lipsync.py @@ -111,6 +111,8 @@ def create_lipsync_job( voice_id=body.voice_id, script_text=body.script_text, speed=body.speed, + style=body.style or "", + volume=body.volume if body.volume is not None else 50, emotion=body.emotion, enable_video_loop=body.enable_video_loop, project_id=body.project_id, @@ -211,6 +213,8 @@ def preview_tts( voice_id=body.voice_id, script_text=body.script_text, speed=body.speed, + style=body.style or "", + volume=body.volume if body.volume is not None else 50, emotion=body.emotion, ) except MediaKitError as exc: @@ -342,13 +346,13 @@ def cancel_lipsync_job( current_user: AuthenticatedUser = Depends(get_current_user), svc: LipsyncService = Depends(_get_service), ): - """取消对口型任务(仅 pending/tts_processing/submitted 状态可取消).""" + """取消对口型任务(仅 pending/tts_processing/submitted/processing 状态可取消).""" job = svc.cancel_job(job_id, current_user.user.id) if job is None: raise HTTPException(status_code=404, detail="任务不存在") if job.status != "cancelled": raise HTTPException( status_code=400, - detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted 可取消", + detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted/processing 可取消", ) return job diff --git a/apps/api/app/api/routes/points.py b/apps/api/app/api/routes/points.py index e54924812..ecf1657d8 100644 --- a/apps/api/app/api/routes/points.py +++ b/apps/api/app/api/routes/points.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone from typing import Optional from app.auth import AuthenticatedUser, get_current_user +from app.config import settings from app.dependencies import get_db_session from app.schemas.points import ( DailyUsageResponse, @@ -44,6 +45,12 @@ from packages.domain.points_service import PointsService logger = logging.getLogger(__name__) + +def _credits_enabled() -> bool: + """积分系统总开关(ENABLE_CREDIT_SYSTEM),关闭时全部功能免费放行。""" + return bool(getattr(settings, "points_enabled", False)) + + # ── 两个 router ── points_router = APIRouter() usage_router = APIRouter() @@ -172,6 +179,19 @@ def check_points( "valid_scenes": sorted(POINTS_SCENES.keys()), }, ) + + # 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分 + if not _credits_enabled(): + svc = _get_service() + account = svc.get_or_create_account(current_user.user.id, db) + return PointsCheckResponse( + allowed=True, + required_points=0, + current_balance=account["balance"], + remaining_after=account["balance"], + is_free_quota=False, + ) + is_mem = _is_member(current_user) mt = _member_type(current_user) @@ -209,8 +229,19 @@ def deduct_points( current_user: AuthenticatedUser = Depends(get_current_user), db: Session = Depends(get_db_session), ): - """积分扣减(内部服务调用)。""" + """积分扣减(内部服务调用)。 + + 积分系统暂停(ENABLE_CREDIT_SYSTEM=false)时为 no-op:不扣分、余额不变, + 直接返回成功,保证内部调用方拿到 success=True 继续业务流程。 + """ svc = _get_service() + if not _credits_enabled(): + account = svc.get_or_create_account(current_user.user.id, db) + return SimpleMessageResponse( + success=True, + message="积分系统已暂停,未扣减积分", + data={"transaction_id": "", "balance": account["balance"]}, + ) result = svc.deduct_points( user_id=current_user.user.id, amount=body.amount, @@ -243,11 +274,7 @@ def refund_points( """积分退还(内部服务调用)。""" from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel - txn = ( - db.query(PointsTransactionModel) - .filter(PointsTransactionModel.id == body.transaction_id) - .first() - ) + txn = db.query(PointsTransactionModel).filter(PointsTransactionModel.id == body.transaction_id).first() if txn is None: raise HTTPException(status_code=404, detail="交易记录不存在") if txn.user_id != current_user.user.id: diff --git a/apps/api/app/api/routes/task_center.py b/apps/api/app/api/routes/task_center.py index cc5188457..a6edeb189 100755 --- a/apps/api/app/api/routes/task_center.py +++ b/apps/api/app/api/routes/task_center.py @@ -63,6 +63,8 @@ def _generation_step(task) -> str: return "等待 Worker 执行" if s == "running": return "正在生成成片" + if s == "awaiting_cover": + return "等待确认封面" if s == "completed": return "生成完成" if s == "failed": @@ -129,7 +131,7 @@ def _validate_status(status: str | None) -> str | None: """校验状态值合法性。""" if status is None: return None - valid = {"pending", "running", "completed", "failed", "cancelled"} + valid = {"pending", "running", "awaiting_cover", "completed", "failed", "cancelled"} if status not in valid: raise HTTPException( status_code=400, @@ -151,7 +153,9 @@ def _clamp_page_size(page_size: int) -> int: @router.get("/tasks", response_model=ListTasksResponse) def list_user_tasks( - status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"), + status: str | None = Query( + None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled" + ), task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"), page: int = Query(1, ge=1, description="页码,从1开始"), page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"), @@ -248,7 +252,9 @@ def retry_task_by_id( @router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse) def list_project_tasks( project_id: str, - status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"), + status: str | None = Query( + None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled" + ), task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"), page: int = Query(1, ge=1, description="页码,从1开始"), page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"), diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index c3513ff08..f01c1e028 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -682,17 +682,50 @@ def create_clips_from_assets_editor( # 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入): # 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点 asset_scene_points: dict[str, list[float]] = {} + invalid_asset_ids: list[str] = [] + valid_asset_ids: list[str] = [] for asset_id in unique_asset_ids: asset = asset_repo.get(asset_id) - if asset and hasattr(asset, "duration"): - asset_durations[asset_id] = float(asset.duration or 0.0) - # 计算 smart_match 综合评分,用于候选排序 + if asset is None: + logger.warning("from-assets 素材不存在或已删除,跳过: asset_id=%s", asset_id) + invalid_asset_ids.append(asset_id) + continue + _dur = float(getattr(asset, "duration", 0.0) or 0.0) + if _dur <= 0: + # 素材时长缺失(刚上传/分析未完成)或为0,跳过该素材——避免按兜底时长分配无效片段。 + # 若所有素材都无效,在下面统一抛 400。 + logger.warning("from-assets 素材时长缺失或为0,跳过: asset_id=%s", asset_id) + invalid_asset_ids.append(asset_id) + continue + valid_asset_ids.append(asset_id) + asset_durations[asset_id] = _dur + # 计算 smart_match 综合评分,用于候选排序 + try: smart_score, _ = score_asset(asset) asset_smart_scores[asset_id] = smart_score - # 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底) + except Exception: + asset_smart_scores[asset_id] = 0.0 + # 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底) + try: cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None)) if cached_points: asset_scene_points[asset_id] = cached_points + except Exception: + pass + if invalid_asset_ids: + logger.info( + "from-assets %d 个素材无效(时长缺失/不存在,已跳过): %s", + len(invalid_asset_ids), + ",".join(invalid_asset_ids[:5]), + ) + # 所有素材都无效(刚上传未分析完)→ 400 让前端稍后重试,而不是用兜底时长产生错乱片段 + if not valid_asset_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="素材尚未完成分析,请稍后重试", + ) + # 后续分配素材时只在 valid_asset_ids 里挑选 + unique_asset_ids = valid_asset_ids logger.info( "from-assets 场景缓存命中: %d/%d 个素材有场景切换点", len(asset_scene_points), diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index a01c33d12..6eca8baea 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -207,6 +207,9 @@ def synthesize( synthesis_meta = { "speed": request.speed, "emotion": request.emotion or "", + "style": request.style or "", + "volume": request.volume if request.volume is not None else 50, + "pitch": request.pitch if request.pitch is not None else 1.0, "language": request.language or "zh-CN", } if request.metadata_: @@ -654,6 +657,9 @@ def preview_tts( text=request.text, voice_id=actual_voice_id, speed=request.speed, + style=request.style or "", + volume=request.volume if request.volume is not None else 50, + pitch=request.pitch, emotion=request.emotion, language=getattr(request, "language", "zh-CN"), ) diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index dcf4f9927..135bf8d87 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -191,6 +191,23 @@ def _find_duplicate_asset( return None + +def _get_existing_asset_url(existing: Any, storage_service: Any) -> str: + """安全获取已存在素材的公网 URL,兼容 domain Asset(无 file_url 字段)和 ORM model。""" + # Domain Asset 只有 storage_key 字段;ORM model 有 file_url 但存的也是 storage_key + key = "" + for attr in ("storage_key", "file_url"): + v = getattr(existing, attr, None) + if v: + key = v + break + if not key: + return "" + try: + return storage_service.get_url(key) or "" + except Exception: + return "" + def _create_pending_asset( asset_repository, project_id, @@ -290,6 +307,43 @@ def _submit_ingest_job( return job +def _find_active_ingest_job(ingest_job_repository: Any, asset_id: str) -> Any | None: + """查询 asset 上是否存在"仍在跑或已成功"的 ingest job(FAILED 视为不存在,需重提)。""" + if not asset_id: + return None + find = getattr(ingest_job_repository, "find_by_asset_id", None) + if not callable(find): + # 旧仓储未实现 find_by_asset_id,无法判断 → 保守返回 None(走正常流程, + # _submit_ingest_job 自身有数据库唯一约束/幂等兜底,不会重复建 job) + return None + try: + return find(asset_id) + except Exception: # noqa: BLE001 + logger.warning("[upload] find_by_asset_id 查询失败,按无 job 处理: asset=%s", asset_id, exc_info=True) + return None + + +def _is_true_duplicate(existing_asset: Asset, ingest_job_repository: Any) -> tuple[bool, Any | None]: + """判断 `existing_asset` 是真重复(应短路返 duplicated)还是占位(应补提 ingest)。 + + 返回 (is_duplicate, existing_job): + - READY 素材:真重复,job 可能为 None(已就绪不需要 job_id) + - PROCESSING/UPLOADING 且已有在跑/已完成 ingest job:幂等重试,真重复,job 返回给前端轮询 + - PROCESSING/UPLOADING 且无 job:prepare 建的占位 / 之前 ingest 创建失败 → 非重复,需补提 ingest + - ERROR/DELETED:非重复(允许重新上传覆盖) + """ + status = getattr(existing_asset, "status", None) + if status == AssetStatus.READY: + return True, None + if status in (AssetStatus.PROCESSING, AssetStatus.UPLOADING): + job = _find_active_ingest_job(ingest_job_repository, existing_asset.id) + if job is not None: + return True, job + return False, None + # ERROR / DELETED / 其它:走正常流程重新 ingest + return False, None + + @router.post("/direct/prepare", response_model=DirectUploadPrepareResponse) async def prepare_direct_upload( request: DirectUploadPrepareRequest, @@ -353,6 +407,7 @@ async def prepare_direct_upload( duplicated=True, skip_transfer=True, asset_id=existing.id, + url=_get_existing_asset_url(existing, storage_service), ) file_id = uuid4().hex[:8] @@ -406,6 +461,7 @@ async def prepare_direct_upload( duplicated=False, skip_transfer=False, asset_id=pending_asset_id, + url="", ) @@ -444,12 +500,25 @@ async def complete_direct_upload( 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), + is_dup, existing_job = _is_true_duplicate(existing, ingest_job_repository) + if is_dup: + logger.info( + "[upload] complete 幂等命中真重复: asset=%s status=%s job=%s", + existing.id, + getattr(existing, "status", None), + getattr(existing_job, "id", None), + ) + return DirectUploadCompleteResponse( + storage_key=existing.storage_key, + ingest_job_id=getattr(existing_job, "id", "") or "", + duplicated=True, + asset_id=existing.id, + url=storage_service.get_url(existing.storage_key), + ) + logger.info( + "[upload] complete 命中占位 asset(status=%s 无 ingest job),继续补提 ingest: asset=%s", + getattr(existing, "status", None), + existing.id, ) try: @@ -479,14 +548,24 @@ async def complete_direct_upload( ) # Issue #1776: 计数由 asset_repository.create() 自动维护 - job = _submit_ingest_job( - project_id=request.project_id, - library_id=request.library_id, - storage_key=normalized_key, - ingest_job_repository=ingest_job_repository, - file_hash=request.file_hash, - asset_id=pending_asset.id, - ) + # 幂等保护:补提占位场景下可能已有 job(极端竞态),先查一次 + existing_job = _find_active_ingest_job(ingest_job_repository, pending_asset.id) + if existing_job is not None: + logger.info( + "[upload] complete 补提时发现 job 已存在(竞态/并发重试),复用: asset=%s job=%s", + pending_asset.id, + existing_job.id, + ) + job = existing_job + else: + job = _submit_ingest_job( + project_id=request.project_id, + library_id=request.library_id, + 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, ingest_job_id=job.id, @@ -533,12 +612,27 @@ async def upload_asset( 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, + is_dup, existing_job = _is_true_duplicate(existing, ingest_job_repository) + if is_dup: + logger.info( + "[upload] multipart 幂等命中真重复: asset=%s status=%s job=%s", + existing.id, + getattr(existing, "status", None), + getattr(existing_job, "id", None), + ) + return UploadAssetResponse( + storage_key=existing.storage_key, + ingest_job_id=getattr(existing_job, "id", "") or "", + url=storage_service.get_url(existing.storage_key) + if getattr(existing, "status", None) == AssetStatus.READY + else "", + duplicated=True, + asset_id=existing.id, + ) + logger.info( + "[upload] multipart 命中占位 asset(status=%s 无 ingest job),继续补提 ingest: asset=%s", + getattr(existing, "status", None), + existing.id, ) file_id = uuid4().hex[:8] @@ -574,14 +668,23 @@ async def upload_asset( ) # Issue #1776: 计数由 asset_repository.create() 自动维护 - job = _submit_ingest_job( - project_id=project_id, - library_id=library_id, - storage_key=storage_key, - ingest_job_repository=ingest_job_repository, - file_hash=file_hash, - asset_id=pending_asset.id, - ) + existing_job = _find_active_ingest_job(ingest_job_repository, pending_asset.id) + if existing_job is not None: + logger.info( + "[upload] multipart 补提时发现 job 已存在(竞态/并发重试),复用: asset=%s job=%s", + pending_asset.id, + existing_job.id, + ) + job = existing_job + else: + job = _submit_ingest_job( + project_id=project_id, + library_id=library_id, + storage_key=storage_key, + ingest_job_repository=ingest_job_repository, + file_hash=file_hash, + asset_id=pending_asset.id, + ) return UploadAssetResponse( storage_key=storage_key, diff --git a/apps/api/app/api/routes/viral_video.py b/apps/api/app/api/routes/viral_video.py new file mode 100644 index 000000000..2ebae48ee --- /dev/null +++ b/apps/api/app/api/routes/viral_video.py @@ -0,0 +1,549 @@ +"""爆款视频 API 路由。 + +端点: + POST /api/v1/viral-video/generate 创建爆款视频任务 + GET /api/v1/viral-video/{job_id} 查询任务状态 + GET /api/v1/viral-video/history 历史记录 + POST /api/v1/viral-video/{job_id}/retry 重试失败任务 + POST /api/v1/viral-video/{job_id}/confirm-intent 确认意图文案 + POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析 + GET /api/v1/viral-video/style-templates 获取风格模板列表 + WS /api/v1/viral-video/ws/{job_id}?token= WebSocket 进度推送(订阅 Redis pub/sub) +""" + +from __future__ import annotations + +import logging + +from app.auth import AuthenticatedUser, get_current_user +from app.core.celery_app import celery_app +from app.dependencies import get_db_session +from app.schemas.viral_video import ( + AnalyzeStyleRequest, + AnalyzeStyleResponse, + ConfirmIntentRequest, + CreateViralVideoRequest, + StyleTemplateListResponse, + StyleTemplateResponse, + ViralVideoHistoryResponse, + ViralVideoJobResponse, +) +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.viral_video_repository import ( + SQLAlchemyViralVideoJobRepository, + SQLAlchemyViralVideoStyleTemplateRepository, +) +from packages.domain.viral_video import ViralVideoStatus + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── Helpers ────────────────────────────────────────────────────────────── + + +def _to_response(job) -> ViralVideoJobResponse: + return ViralVideoJobResponse( + id=job.id, + user_id=job.user_id, + images=job.images, + industry=job.industry, + target_customer=job.target_customer, + persona_id=job.persona_id, + viral_structure=job.viral_structure, + marketing_purpose=job.marketing_purpose, + bgm_preference=job.bgm_preference, + duration=job.duration, + user_copy_text=job.user_copy_text, + fusion_level=job.fusion_level, + reference_audio_path=job.reference_audio_path, + reference_video_url=job.reference_video_url, + style_strength=job.style_strength, + style_guide=job.style_guide, + style_template_id=job.style_template_id, + status=job.status, + intent_result=job.intent_result, + result_video_url=job.result_video_url, + credits_cost=job.credits_cost, + error_msg=job.error_msg, + retry_count=job.retry_count, + started_at=job.started_at, + completed_at=job.completed_at, + created_at=job.created_at, + updated_at=job.updated_at, + ) + + +def _get_job_repo(session: Session) -> SQLAlchemyViralVideoJobRepository: + return SQLAlchemyViralVideoJobRepository(session) + + +def _get_style_repo(session: Session) -> SQLAlchemyViralVideoStyleTemplateRepository: + return SQLAlchemyViralVideoStyleTemplateRepository(session) + + +# ── Endpoints ──────────────────────────────────────────────────────────── + + +@router.post("/generate", response_model=ViralVideoJobResponse) +def create_viral_video( + request: CreateViralVideoRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + session: Session = Depends(get_db_session), +) -> ViralVideoJobResponse: + """创建爆款视频任务,入队 Celery 编排器。""" + from packages.domain.viral_video import ViralVideoJob + + repo = _get_job_repo(session) + + # 创建领域实体 + job = ViralVideoJob( + user_id=authenticated_user.user.id, + images=list(request.images), + industry=request.industry, + target_customer=request.target_customer, + persona_id=request.persona_id, + viral_structure=request.viral_structure, + marketing_purpose=request.marketing_purpose, + bgm_preference=request.bgm_preference, + duration=request.duration, + user_copy_text=request.user_copy_text, + fusion_level=request.fusion_level, + reference_audio_path=request.reference_audio_path, + reference_video_url=request.reference_video_url, + style_strength=request.style_strength, + style_template_id=request.style_template_id, + ) + + # 持久化 + repo.save(job) + + # 入队 Celery 任务 + try: + celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id]) + logger.info("[爆款视频] 任务已入队: job_id=%s user_id=%s", job.id, job.user_id) + except Exception as e: + logger.error("[爆款视频] 入队失败: %s", e, exc_info=True) + job.mark_failed(f"任务入队失败: {e}") + repo.update(job) + + return _to_response(job) + + +@router.get("/history", response_model=ViralVideoHistoryResponse) +def list_viral_video_history( + limit: int = 50, + offset: int = 0, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + session: Session = Depends(get_db_session), +) -> ViralVideoHistoryResponse: + """获取用户的爆款视频历史列表。""" + repo = _get_job_repo(session) + jobs = repo.list_by_user(authenticated_user.user.id, limit=limit, offset=offset) + items = [_to_response(j) for j in jobs] + return ViralVideoHistoryResponse(items=items, total=len(items)) + + +@router.get("/style-templates", response_model=StyleTemplateListResponse) +def list_style_templates( + session: Session = Depends(get_db_session), +) -> StyleTemplateListResponse: + """获取风格模板列表。""" + repo = _get_style_repo(session) + templates = repo.list_all() + items = [ + StyleTemplateResponse( + id=t["id"], + name=t["name"], + description=t["description"], + thumbnail_url=t["thumbnail_url"], + style_config=t["style_config"], + ) + for t in templates + ] + return StyleTemplateListResponse(items=items) + + +@router.get("/{job_id}", response_model=ViralVideoJobResponse) +def get_viral_video_job( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + session: Session = Depends(get_db_session), +) -> ViralVideoJobResponse: + """查询爆款视频任务状态。""" + repo = _get_job_repo(session) + job = repo.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="任务不存在") + if job.user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="无权查看此任务") + return _to_response(job) + + +@router.post("/{job_id}/retry", response_model=ViralVideoJobResponse) +def retry_viral_video_job( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + session: Session = Depends(get_db_session), +) -> ViralVideoJobResponse: + """重试失败的爆款视频任务。""" + repo = _get_job_repo(session) + job = repo.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="任务不存在") + if job.user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="无权操作此任务") + if job.status != ViralVideoStatus.FAILED: + raise HTTPException(status_code=409, detail="只有失败的任务可以重试") + + # 重置状态 + job.retry_count += 1 + job.status = ViralVideoStatus.PENDING + job.error_msg = "" + job.started_at = None + job.completed_at = None + repo.update(job) + + # 重新入队 + try: + celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id]) + logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d", job.id, job.retry_count) + except Exception as e: + logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True) + job.mark_failed(f"重试入队失败: {e}") + repo.update(job) + + return _to_response(job) + + +@router.post("/{job_id}/confirm-intent", response_model=ViralVideoJobResponse) +def confirm_intent( + job_id: str, + request: ConfirmIntentRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + session: Session = Depends(get_db_session), +) -> ViralVideoJobResponse: + """用户确认/修改 AI 生成的意图文案,恢复流水线。""" + repo = _get_job_repo(session) + job = repo.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="任务不存在") + if job.user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="无权操作此任务") + if job.status != ViralVideoStatus.WAIT_USER_CONFIRM: + raise HTTPException(status_code=409, detail="任务当前不在等待确认状态") + + # 更新文案 + if request.confirmed_copy: + job.user_copy_text = request.confirmed_copy + + # 恢复流水线 + job.resume_from_confirm() + repo.update(job) + + # 从断点恢复 Celery 任务 + try: + celery_app.send_task("worker.resume_viral_video_pipeline", args=[job.id]) + logger.info("[爆款视频] 意图确认,恢复流水线: job_id=%s", job.id) + except Exception as e: + logger.error("[爆款视频] 恢复流水线失败: %s", e, exc_info=True) + job.mark_failed(f"恢复流水线失败: {e}") + repo.update(job) + + return _to_response(job) + + +@router.post("/{job_id}/analyze-style", response_model=AnalyzeStyleResponse) +def analyze_style( + job_id: str, + request: AnalyzeStyleRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + session: Session = Depends(get_db_session), +) -> AnalyzeStyleResponse: + """触发参考视频风格分析(独立步骤,可在生成前单独调用)。""" + repo = _get_job_repo(session) + job = repo.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="任务不存在") + if job.user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="无权操作此任务") + + # 更新参考视频 URL + job.reference_video_url = request.reference_video_url + if request.style_template_id: + job.style_template_id = request.style_template_id + repo.update(job) + + # 入队风格分析任务 + try: + celery_app.send_task("worker.run_video_style_analysis", args=[job.id]) + logger.info("[爆款视频] 风格分析入队: job_id=%s", job.id) + except Exception as e: + logger.error("[爆款视频] 风格分析入队失败: %s", e, exc_info=True) + + return AnalyzeStyleResponse( + job_id=job.id, + status="analyzing", + style_guide=None, + ) + + +# ── WebSocket 进度推送 ────────────────────────────────────────────────── + + +def _ws_authenticate_user(token: str): + """从 token 字符串解析用户(复用 HTTP Bearer 的解码 + 黑名单逻辑)。 + + WebSocket 握手阶段不能发自定义 Authorization header, + 因此统一通过 query 参数 ``?token=...`` 传 JWT。 + """ + from app.auth import _decode_user_token + from app.dependencies import get_user_repository + + if not token: + return None + try: + payload = _decode_user_token(token) + except Exception: + return None + user_id = payload.get("sub") + if not isinstance(user_id, str) or not user_id: + return None + # 同步场景下手动拉 repository 实例 + from app.db import SessionLocal + + session = SessionLocal() + try: + user_repo = get_user_repository(session) + user = user_repo.find_by_id(user_id) + return user + finally: + session.close() + + +async def _run_pubsub_forwarder( + websocket, redis_lib, settings, job_id: str +) -> None: # pragma: no cover - integration tested (real Redis + thread) + """订阅 Redis 频道并把消息桥接到 WebSocket,终态消息后自动关闭。 + + 该函数封装了线程 + asyncio.Queue 桥接逻辑,在单测中可被整体替换为桩, + 避免引入真实 Redis 与线程调度的不确定性。 + """ + import asyncio + import json + import threading + + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + pubsub = r.pubsub(ignore_subscribe_messages=True) + channel = f"viral_video:{job_id}" + pubsub.subscribe(channel) + + loop = asyncio.get_running_loop() + queue: asyncio.Queue = asyncio.Queue(maxsize=64) + stop_event = asyncio.Event() + + def _reader() -> None: + try: + while not stop_event.is_set(): + msg = pubsub.get_message(timeout=0.5) + if msg is None or msg.get("type") != "message": + continue + raw = msg.get("data") + if not isinstance(raw, str): + continue + try: + payload = json.loads(raw) + except Exception: + payload = {"type": "viral_video:progress", "data": {"raw": raw}} + loop.call_soon_threadsafe(queue.put_nowait, payload) + if payload.get("type") in ("viral_video:completed", "viral_video:failed"): + loop.call_soon_threadsafe(stop_event.set) + break + except Exception as e: + logger.warning("[爆款视频WS] pubsub reader 异常退出: %s", e) + loop.call_soon_threadsafe(stop_event.set) + + try: + reader_thread = threading.Thread(target=_reader, name=f"viral-video-ws-{job_id}", daemon=True) + reader_thread.start() + + while not stop_event.is_set(): + try: + payload = await asyncio.wait_for(queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + try: + await websocket.send_json(payload) + except Exception: + break + if payload.get("type") in ("viral_video:completed", "viral_video:failed"): + break + except WebSocketDisconnect: + logger.info("[爆款视频WS] 客户端断开: job_id=%s", job_id) + except Exception as e: + logger.error("[爆款视频WS] 转发异常: %s", e, exc_info=True) + try: + await websocket.send_json({"type": "viral_video:error", "message": f"服务异常: {e}"}) + except Exception: + pass + finally: + stop_event.set() + try: + pubsub.unsubscribe(channel) + pubsub.close() + except Exception: + pass + try: + r.close() + except Exception: + pass + try: + await websocket.close() + except Exception: + pass + + +@router.websocket("/ws/{job_id}") +async def viral_video_websocket(websocket: WebSocket, job_id: str) -> None: + """WebSocket 桥接:订阅 Redis `viral_video:{job_id}` 频道并转发给前端。 + + 认证:通过 ``?token=`` query 参数传 JWT(浏览器 WS 握手不支持自定义 header)。 + 事件类型: + - viral_video:progress 中间进度(progress: 0-100) + - viral_video:wait_user 等待用户确认意图文案 + - viral_video:completed 任务完成(data.video_url) + - viral_video:failed 任务失败(data.error) + - viral_video:error 服务端错误(如鉴权失败 / job 不存在 / 无权限) + """ + + import redis as redis_lib + from app.config import settings + + # ── 1. 鉴权 ────────────────────────────────────────────────────── + token = websocket.query_params.get("token", "") + user = _ws_authenticate_user(token) + if user is None: + await websocket.close(code=4401, reason="Unauthorized") + return + + # ── 2. 校验 job 归属 ───────────────────────────────────────────── + from app.db import SessionLocal + + session = SessionLocal() + try: + job_repo = SQLAlchemyViralVideoJobRepository(session) + job = job_repo.get(job_id) + if job is None: + await websocket.close(code=4404, reason="Job not found") + return + if job.user_id != user.id: + await websocket.close(code=4403, reason="Forbidden") + return + finally: + session.close() + + await websocket.accept() + + # ── 3. 发送一条初始状态(前端连接后立即拿到当前进度) ──────────── + try: + session = SessionLocal() + job_repo = SQLAlchemyViralVideoJobRepository(session) + job = job_repo.get(job_id) + if job is not None: + status_val = job.status.value if hasattr(job.status, "value") else str(job.status) + initial = { + "type": "viral_video:progress", + "job_id": job_id, + "stage": _stage_from_status(job), + "progress": _estimate_progress(job), + "message": _initial_message(job), + "data": {"status": status_val}, + } + await websocket.send_json(initial) + # 已经终态 → 再发一条终态事件后立即关闭,避免占连接 + if job.is_terminal: + is_completed = status_val == "completed" + terminal_type = "viral_video:completed" if is_completed else "viral_video:failed" + terminal_data = ( + {"video_url": job.result_video_url or ""} if is_completed else {"error": job.error_msg or ""} + ) + await websocket.send_json( + { + "type": terminal_type, + "job_id": job_id, + "stage": "", + "progress": 100 if is_completed else 0, + "message": "视频生成完成" if is_completed else "任务失败", + "data": terminal_data, + } + ) + await websocket.close() + return + session.close() + except Exception as e: + logger.warning("[爆款视频WS] 发送初始状态失败: %s", e) + try: + session.close() + except Exception: + pass + + # ── 4. 订阅 Redis 频道并转发 ───────────────────────────────────── + # redis-py 的 pubsub 是同步阻塞的,放到线程里跑,通过 asyncio.Queue 桥接到 event loop。 + # 该段依赖真实 Redis + 线程调度,属于集成测试范围,单测通过桩替换。 + await _run_pubsub_forwarder(websocket, redis_lib, settings, job_id) + + +def _job_status(job) -> str: + return job.status.value if hasattr(job.status, "value") else str(job.status) + + +# 初始快照的 stage 推断:领域对象不持久化 stage, +# 只能根据 status 给一个占位,后续 worker 推送的真实进度事件会覆盖。 +_STATUS_STAGE = { + "pending": "", + "running": "", + "wait_user_confirm": "intent_parsing", + "completed": "uploading", + "failed": "", + "cancelled": "", +} + +_STATUS_PROGRESS = { + "pending": 0.0, + "running": 5.0, + "wait_user_confirm": 35.0, + "completed": 100.0, + "failed": 0.0, + "cancelled": 0.0, +} + +_STATUS_MESSAGE = { + "pending": "任务已创建,等待执行", + "running": "任务执行中", + "wait_user_confirm": "等待用户确认意图文案", + "completed": "视频生成完成", + "failed": "任务失败", + "cancelled": "任务已取消", +} + + +def _stage_from_status(job) -> str: + return _STATUS_STAGE.get(_job_status(job), "") + + +def _estimate_progress(job) -> float: + """根据 status 粗略估算百分比(0-100),用于连接初始快照; + 连接建立后由 Redis 推送的真实事件持续更新。 + """ + return _STATUS_PROGRESS.get(_job_status(job), 5.0) + + +def _initial_message(job) -> str: + """给新连接的前端一个可读的初始状态文案。""" + status_val = _job_status(job) + if status_val == "failed" and job.error_msg: + return f"任务失败: {job.error_msg}" + return _STATUS_MESSAGE.get(status_val, "任务准备中") diff --git a/apps/api/app/core/task_enqueue.py b/apps/api/app/core/task_enqueue.py index c3b11329c..3675fe9de 100755 --- a/apps/api/app/core/task_enqueue.py +++ b/apps/api/app/core/task_enqueue.py @@ -6,7 +6,7 @@ from app.core.celery_app import celery_app logger = logging.getLogger(__name__) # ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ── -USER_PENDING_LIMIT = 3 # 单用户 pending 上限 +USER_PENDING_LIMIT = 20 # 单用户 pending 上限(#2098: 从 3 提到 20,支持批量任务自动排队) GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限 WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值) @@ -154,19 +154,18 @@ def check_queue_limits( user_pending_limit: int = USER_PENDING_LIMIT, global_pending_limit: int = GLOBAL_PENDING_LIMIT, ) -> None: - """检查队列限流(预检查用,任务创建前调用),超限抛对应异常。 + """检查队列限流(预检查用,任务创建前调用)。 - 边界语义:>= 上限即拒绝(达到上限就不能再加新任务)。 + #2098 语义变更:用户级限流改为软提示,不再抛异常拒绝;仅全局硬上限抛 GlobalQueueFull。 Args: - user_id: 用户 ID + user_id: 用户 ID(保留参数,当前不做用户级硬拒) generation_task_repository: 任务仓储 - user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT + user_pending_limit: 单用户 pending 上限(保留,当前未硬拒) global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT Raises: - GlobalQueueFull: 全局超限时抛出(优先级更高,先查全局) - UserPendingLimitExceeded: 用户超限时抛出 + GlobalQueueFull: 全局超限时抛出 """ # 先查全局(系统级保护优先级更高) global_pending = generation_task_repository.count_pending_total() @@ -179,17 +178,9 @@ def check_queue_limits( ) raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit) - # 再查用户级 - if user_id: - user_pending = generation_task_repository.count_pending_by_user(user_id) - if user_pending >= user_pending_limit: - logger.warning( - "[队列限流] 用户 pending 任务数超限: user_id=%s, count=%d/%d", - user_id, - user_pending, - user_pending_limit, - ) - raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit) + # #2098: 用户级限流改为软提示,不在预检查阶段拒绝(超额任务仍入队排队)。 + # 真正的系统保护由全局 GLOBAL_PENDING_LIMIT 硬上限承担。 + # UserPendingLimitExceeded 保留以兼容历史 import/except,但预检查与 safe_enqueue 均不再 raise。 def _mark_task_failed_safely( @@ -246,7 +237,6 @@ def safe_enqueue_generation_task( Raises: GlobalQueueFull: 全局 pending 超限时抛出,任务会被标记为 failed - UserPendingLimitExceeded: 用户 pending 超限时抛出,任务会被标记为 failed """ # ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ── @@ -263,19 +253,18 @@ def safe_enqueue_generation_task( _mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc)) raise exc - # 用户级限流检查(传了 user_id 才做) + # Bug B #2098: 用户级限流改为软提示,不再硬拒;所有任务都入队等待 worker 自然消费。 + # user_pending_limit 作为兜底阈值保留(默认 20),达到时打 warning 日志但仍入队, + # 避免极端情况下恶意用户无限堆积任务。真正的系统保护由全局 GLOBAL_PENDING_LIMIT 承担。 if user_id: user_pending = generation_task_repository.count_pending_by_user(user_id) if user_pending > user_pending_limit: logger.warning( - "[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d", + "[队列限流] 用户 pending 任务数超过软上限(入队): user_id=%s, count=%d/%d, 仍允许入队排队", user_id, user_pending, user_pending_limit, ) - exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit) - _mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc)) - raise exc # ── 发送 Celery 任务 ── try: @@ -317,16 +306,18 @@ def safe_enqueue_generation_task( user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0 global_over = global_after > global_pending_limit - user_over = bool(user_id and user_after > user_pending_limit) - - if global_over or user_over: - if global_over: - reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}" - exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit) - else: - reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}" - exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit) + # Bug B #2098: 用户超限仅日志警告,不回滚任务 + if user_id and user_after > user_pending_limit: + logger.warning( + "[队列限流] 用户 pending 超软上限(入队后): user_id=%s, count=%d/%d", + user_id, + user_after, + user_pending_limit, + ) + if global_over: + reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}" + exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit) logger.warning( "[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed", reason, diff --git a/apps/api/app/db.py b/apps/api/app/db.py index c9a879910..3819b672c 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -7,9 +7,9 @@ from packages.adapters.sqlalchemy_impl import ( ) from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed -ensure_database_exists(settings.DATABASE_URL) +ensure_database_exists(settings.effective_database_url) engine, SessionLocal = build_session_factory( - settings.DATABASE_URL, + settings.effective_database_url, pool_size=settings.DATABASE_POOL_SIZE, max_overflow=settings.DATABASE_MAX_OVERFLOW, pool_timeout=settings.DATABASE_POOL_TIMEOUT, diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index ce622df26..12b2d0b43 100644 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -56,7 +56,7 @@ from packages.adapters.sqlalchemy_impl.voice_library_repository import ( from packages.ports.tag_repository import TagRepository from packages.ports.user_repository import UserRepository -_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL) +_engine, _SessionLocal = build_session_factory(settings.effective_database_url) def get_db_session() -> Generator[Session, None, None]: diff --git a/apps/api/app/schemas/ai_avatar_render.py b/apps/api/app/schemas/ai_avatar_render.py index 0a6cfa95d..921269cb4 100644 --- a/apps/api/app/schemas/ai_avatar_render.py +++ b/apps/api/app/schemas/ai_avatar_render.py @@ -53,9 +53,14 @@ class CreateAiAvatarRenderRequest(BaseModel): script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)") b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表") title_config: dict[str, Any] = Field( - default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL)" + default_factory=dict, + description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL;含 line_overrides 逐行样式)", ) cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置") + cover_title_config: dict[str, Any] = Field( + default_factory=dict, + description="封面独立标题配置(#2001),结构同 title_config;为空时封面不叠标题", + ) project_id: str = Field("", description="项目 ID") @field_validator("lipsync_job_id") @@ -83,6 +88,7 @@ class AiAvatarRenderJobResponse(BaseModel): b_roll_segments: list[dict[str, Any]] title_config: dict[str, Any] cover_config: dict[str, Any] + cover_title_config: dict[str, Any] = Field(default_factory=dict, description="封面独立标题配置") status: str progress: int output_video_url: str diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index afab39fa8..111b3ea32 100755 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -13,6 +13,33 @@ class ConfirmGenerationRequest(BaseModel): custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划") +class FinalizeGenerationRequest(BaseModel): + """Step5 点「完成」请求体:用户选定封面后,正式将视频入成品库。""" + + cover_url: str = Field( + default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)" + ) + custom_title: str = Field(default="", description="用户自定义成片标题,非空时覆盖 rendered_output.name") + + +class FinalizeGenerationResponse(BaseModel): + """finalize 响应:返回新创建的成品库视频信息。""" + + video_id: str = Field(description="新创建的成品视频 ID") + project_id: str = Field(default="", description="成品所属项目 ID") + name: str = Field(default="", description="成片名称") + file_size: int = Field(default=0, description="文件大小(字节)") + duration: float = Field(default=0.0, description="时长(秒)") + thumbnail_url: str = Field(default="", description="最终绑定的缩略图/封面 URL") + cover_url: str = Field(default="", description="最终绑定的封面 URL") + file_url: str = Field(default="", description="成品视频下载 URL") + width: int = Field(default=0) + height: int = Field(default=0) + fps: float = Field(default=0.0) + status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录") + is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复") + + class CreateGenerationTaskRequest(BaseModel): """创建生成任务请求。 diff --git a/apps/api/app/schemas/gpu_lipsync.py b/apps/api/app/schemas/gpu_lipsync.py new file mode 100644 index 000000000..b9d391ef9 --- /dev/null +++ b/apps/api/app/schemas/gpu_lipsync.py @@ -0,0 +1,112 @@ +"""GPU MuseTalk 反向轮询 API Schema 定义. + +面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。 +Worker 用长期 GPU_WORKER_TOKEN 鉴权(不是用户 JWT)。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +# ── Worker 注册/心跳 ────────────────────────────────────────────── + + +class GpuWorkerRegisterRequest(BaseModel): + """Worker 启动/心跳时上报自身信息.""" + + worker_id: str = Field(..., min_length=1, max_length=100, description="Worker 唯一 ID(机器名+UUID 等)") + hostname: str = Field("", max_length=200, description="主机名,用于运维排查") + gpu_name: str = Field("", max_length=200, description="GPU 型号,如 'NVIDIA GeForce RTX 2060'") + free_vram_mb: int = Field(0, ge=0, description="当前空闲显存(MB)") + capabilities: str = Field("musetalk", max_length=500, description="能力列表,逗号分隔,如 'musetalk'") + task_id: Optional[str] = Field( + None, + max_length=64, + description=( + "当前正在处理的任务 ID。Worker 推理期间定期心跳时携带," + "服务端同步刷新该任务 last_heartbeat_at,防止长推理被误判超时;空闲时不传" + ), + ) + + +class GpuWorkerRegisterResponse(BaseModel): + ok: bool = True + server_time: datetime + message: str = "ok" + cancel_task: bool = Field(False, description="当前心跳任务是否已被用户取消;为 true 时 Worker 应终止推理") + + +# ── 轮询任务 ──────────────────────────────────────────────────── + + +class GpuLipsyncTaskPayload(BaseModel): + """下发给 Worker 的任务载荷(含预签名下载 URL).""" + + task_id: str + video_url: str = Field(..., description="人物视频预签名下载 URL(GET)") + audio_url: str = Field(..., description="驱动音频预签名下载 URL(GET)") + lipsync_job_id: str = "" + user_id: str = "" + project_id: str = "" + created_at: datetime + upload_url: str = Field(..., description="结果视频预签名上传 URL(PUT, video/mp4)") + upload_method: str = Field("PUT", description="上传方式,目前只支持 PUT") + expires_at: datetime + + +class GpuLipsyncPollResponse(BaseModel): + """Worker poll 的返回:200 带任务,204 无任务.""" + + task: Optional[GpuLipsyncTaskPayload] = None + + +# ── Worker 上报结果 ────────────────────────────────────────────── + + +class GpuLipsyncResultRequest(BaseModel): + """Worker 通过 multipart 上传结果时携带的字段(非文件字段).""" + + task_id: str = Field(..., min_length=1, max_length=64) + worker_id: str = Field(..., min_length=1, max_length=100) + success: bool = Field(True, description="true=成功(此时必须上传 result 视频文件);false=失败") + duration_seconds: float = Field(0.0, ge=0, description="合成后视频时长(秒),成功时应填入") + error_msg: str = Field("", max_length=2000, description="失败原因,success=false 时必填") + + +class GpuLipsyncResultResponse(BaseModel): + ok: bool = True + task_id: str + status: str # done / failed + message: str = "ok" + + +# ── 业务侧查询任务状态 ──────────────────────────────────────────── + + +class GpuLipsyncStatusResponse(BaseModel): + task_id: str + status: str + result_url: str = "" + result_duration: float = 0.0 + error_msg: str = "" + worker_id: str = "" + attempt: int = 0 + created_at: datetime + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + + +# ── 创建任务(内部服务调用) ────────────────────────────────────── + + +class GpuLipsyncCreateRequest(BaseModel): + """服务层内部创建 GPU 任务用(不通过 HTTP 暴露给 Worker/前端).""" + + video_url: str # 已可访问的 OSS key 或公网 URL(API 侧会转预签名) + audio_url: str + lipsync_job_id: str = "" + user_id: str = "" + project_id: str = "" diff --git a/apps/api/app/schemas/lipsync.py b/apps/api/app/schemas/lipsync.py index 8bd9ef9bc..d73611913 100644 --- a/apps/api/app/schemas/lipsync.py +++ b/apps/api/app/schemas/lipsync.py @@ -29,6 +29,7 @@ class LipsyncJobResponse(BaseModel): voice_id: str = "" script_text: str = "" speed: float = 1.0 + style: str = "" emotion: str = "" mediakit_task_id: str status: str @@ -67,9 +68,14 @@ class CreateLipsyncJobRequest(BaseModel): voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)") script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") + style: Optional[str] = Field( + None, + description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion", + ) + volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50") emotion: str = Field( "", - description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)", + description="[deprecated] 旧情绪参数,内部映射为 style", ) enable_video_loop: bool = Field( @@ -123,10 +129,15 @@ class AiAvatarTtsPreviewRequest(BaseModel): voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID") script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") + style: Optional[str] = Field( + None, + description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion", + ) + volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50") emotion: str = Field( "neutral", max_length=32, - description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral)", + description="[deprecated] 旧情绪参数,内部映射为 style;默认 neutral", ) diff --git a/apps/api/app/schemas/tts.py b/apps/api/app/schemas/tts.py index 39dd4f380..8cb92351a 100644 --- a/apps/api/app/schemas/tts.py +++ b/apps/api/app/schemas/tts.py @@ -16,9 +16,15 @@ class TTSSynthesizeRequest(BaseModel): output_name: str = Field("", description="输出文件名") language: str = Field("zh-CN", description="语言") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速") + style: Optional[str] = Field( + None, + description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion", + ) + volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50") + pitch: Optional[float] = Field(None, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0") emotion: str = Field( "", - description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)", + description="[deprecated] 旧情绪参数,内部映射为 style;新接入请使用 style", ) voice_model: str = Field("", description="语音模型名称") voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID") @@ -113,9 +119,14 @@ class TTSPreviewRequest(BaseModel): text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字") voice_id: str = Field(..., min_length=1, description="音色 ID") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速") - emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)") + style: Optional[str] = Field( + None, + description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion", + ) + volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50") + emotion: str = Field("", description="[deprecated] 旧情绪参数,内部映射为 style") language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)") - pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)") + pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0") class TTSPreviewResponse(BaseModel): diff --git a/apps/api/app/schemas/upload.py b/apps/api/app/schemas/upload.py index 54b6cba6f..25b6d9eb5 100644 --- a/apps/api/app/schemas/upload.py +++ b/apps/api/app/schemas/upload.py @@ -29,6 +29,8 @@ class DirectUploadPrepareResponse(BaseModel): duplicated: bool = False skip_transfer: bool = False asset_id: str = "" + # duplicated=true 时填充已存在素材的公网 URL,前端可直接用而不必再调 complete + url: str = Field(default="", description="duplicated=true 时已存在素材的公网 URL") class DirectUploadCompleteRequest(BaseModel): diff --git a/apps/api/app/schemas/viral_video.py b/apps/api/app/schemas/viral_video.py new file mode 100755 index 000000000..359aeae18 --- /dev/null +++ b/apps/api/app/schemas/viral_video.py @@ -0,0 +1,159 @@ +"""爆款视频 API schemas。""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator + +# ── 枚举常量 ───────────────────────────────────────────────────────────── + +VALID_FUSION_LEVELS = ("ai_full", "full_ai", "ai_polish", "user_primary") +VALID_STYLE_STRENGTHS = ("light", "medium", "strict") +VALID_STAGES = ( + "image_analysis", + "video_analysis", + "intent_parsing", + "copy_fusion", + "storyboard", + "review", + "tts", + "bgm_select", + "rendering", + "musetalk", + "uploading", +) + + +# ── Request Schemas ──────────────────────────────────────────────────────── + + +class CreateViralVideoRequest(BaseModel): + """创建爆款视频任务请求。""" + + images: list[str] = Field(..., min_length=1, max_length=20, description="产品图片 URL 列表") + industry: str = Field(default="", description="行业") + target_customer: str = Field(default="", description="目标客户描述") + persona_id: str = Field(default="", description="人设 ID") + viral_structure: str = Field(default="", description="爆款结构类型") + marketing_purpose: str = Field(default="", description="营销目的") + bgm_preference: str = Field(default="", description="BGM 偏好") + duration: int = Field(default=30, ge=5, le=180, description="视频时长(秒)") + user_copy_text: str = Field(default="", description="用户原始文案(我说你写)") + fusion_level: str = Field(default="ai_polish", description="文案融合级别: ai_full/ai_polish/user_primary") + reference_audio_path: str = Field(default="", description="参考音频路径") + # v1.3 新增 + reference_video_url: str = Field(default="", description="参考爆款视频 URL") + style_strength: str = Field(default="medium", description="风格强度: light/medium/strict") + style_template_id: str = Field(default="", description="风格模板 ID") + + @field_validator("fusion_level") + @classmethod + def _validate_fusion_level(cls, v: str) -> str: + # 兼容前端历史写法 full_ai(等价 ai_full) + if v == "full_ai": + return "ai_full" + if v not in VALID_FUSION_LEVELS: + raise ValueError(f"fusion_level 必须是 {VALID_FUSION_LEVELS} 之一") + return v + + @field_validator("style_strength") + @classmethod + def _validate_style_strength(cls, v: str) -> str: + if v not in VALID_STYLE_STRENGTHS: + raise ValueError(f"style_strength 必须是 {VALID_STYLE_STRENGTHS} 之一") + return v + + +class ConfirmIntentRequest(BaseModel): + """确认意图请求(confirm-intent)。""" + + confirmed_copy: str = Field(default="", description="用户确认/修改后的文案,为空表示使用 AI 生成的文案") + adjustments: str = Field(default="", description="用户对 AI 文案的调整意见") + + +class AnalyzeStyleRequest(BaseModel): + """触发参考视频风格分析请求。""" + + reference_video_url: str = Field(..., description="参考视频 URL") + style_template_id: str = Field(default="", description="风格模板 ID(可选覆盖)") + + +# ── Response Schemas ─────────────────────────────────────────────────────── + + +class ViralVideoJobResponse(BaseModel): + """爆款视频任务响应。""" + + id: str + user_id: str + images: list[str] = Field(default_factory=list) + industry: str = "" + target_customer: str = "" + persona_id: str = "" + viral_structure: str = "" + marketing_purpose: str = "" + bgm_preference: str = "" + duration: int = 30 + user_copy_text: str = "" + fusion_level: str = "ai_polish" + reference_audio_path: str = "" + reference_video_url: str = "" + style_strength: str = "medium" + style_guide: dict | None = None + style_template_id: str = "" + status: str + intent_result: dict | None = None + result_video_url: str = "" + credits_cost: int = 0 + error_msg: str = "" + retry_count: int = 0 + started_at: datetime | None = None + completed_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class ViralVideoHistoryResponse(BaseModel): + """历史记录列表响应。""" + + items: list[ViralVideoJobResponse] + total: int + + +class StyleTemplateResponse(BaseModel): + """风格模板响应。""" + + id: str + name: str + description: str = "" + thumbnail_url: str = "" + style_config: dict = Field(default_factory=dict) + + +class StyleTemplateListResponse(BaseModel): + """风格模板列表响应。""" + + items: list[StyleTemplateResponse] + + +class AnalyzeStyleResponse(BaseModel): + """风格分析结果响应。""" + + job_id: str + status: str + style_guide: dict | None = None + + +# ── WebSocket 事件 Schema ────────────────────────────────────────────────── + + +class WSProgressEvent(BaseModel): + """WebSocket 进度推送事件。""" + + type: str = "viral_video:progress" + job_id: str + stage: str + progress: float = Field(ge=0.0, le=100.0) + message: str = "" + data: dict = Field(default_factory=dict) diff --git a/apps/api/app/services/ai_avatar_render_service.py b/apps/api/app/services/ai_avatar_render_service.py index 0e84914e5..57dc07845 100644 --- a/apps/api/app/services/ai_avatar_render_service.py +++ b/apps/api/app/services/ai_avatar_render_service.py @@ -61,6 +61,7 @@ class AiAvatarRenderService: b_roll_segments: list[dict[str, Any]] | None = None, title_config: dict[str, Any], cover_config: dict[str, Any], + cover_title_config: dict[str, Any] | None = None, project_id: str = "", ) -> AiAvatarRenderJob: """创建渲染任务. @@ -112,6 +113,7 @@ class AiAvatarRenderService: b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])], title_config=title_config, cover_config=cover_config, + cover_title_config=cover_title_config or {}, status="pending", ) self.db.add(job) diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 3d795a195..7858098e4 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -999,26 +999,35 @@ class EditPlanService: source_bgm_config: dict = {} source_plan = self.get_plan(source_plan_id) + # #2034:读取源 plan 的 dedup_enabled 决定变体是否注入视觉/像素扰动 + # 默认 True;关了则保留节奏模板+BGM差异化,但跳过 visual/pixel 扰动 + _dedup_enabled = True if source_plan and source_plan.config: source_bgm_config = source_plan.config.get("bgm", {}) or {} + _dedup_enabled = bool(source_plan.config.get("dedup_enabled", True)) variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in range(count)] bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm) def _build_variant_config_update(idx: int) -> dict: - """构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。""" + """构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。 + + #2034:dedup_enabled=False 时跳过 visual_perturbation/pixel_perturbation, + 保留 rhythm_template 和 BGM 池分配(合理的多变体差异,不属于降重扰动)。 + """ upd: dict = {} - try: - perturbation = generate_visual_perturbation(rng) - if idx == 0: - perturbation["hflip"] = False - upd["visual_perturbation"] = perturbation - except Exception: - logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx) - try: - pixel_pert = generate_pixel_perturbation(rng) - upd["pixel_perturbation"] = pixel_pert - except Exception: - logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx) + if _dedup_enabled: + try: + perturbation = generate_visual_perturbation(rng) + if idx == 0: + perturbation["hflip"] = False + upd["visual_perturbation"] = perturbation + except Exception: + logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx) + try: + pixel_pert = generate_pixel_perturbation(rng) + upd["pixel_perturbation"] = pixel_pert + except Exception: + logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx) rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None if rt is not None: upd["rhythm_template"] = rt diff --git a/apps/api/app/services/generation_finalize_service.py b/apps/api/app/services/generation_finalize_service.py new file mode 100644 index 000000000..8849d6554 --- /dev/null +++ b/apps/api/app/services/generation_finalize_service.py @@ -0,0 +1,142 @@ +"""视频生成任务 finalize 服务(#2024)。 + +Worker 渲染+上传完成后不再自动入库,标记为 awaiting_cover;用户在 Step5 选好封面 +点「完成」时由 API 调用本服务:创建 GeneratedVideo 成品库记录(复用 worker 预计算 +的查重结果)、绑定封面、推进任务到 completed。 + +与 AI 数字人 ``ai_avatar_render_service.finalize_job`` 模式一致, +只是走 GenerationTask 而非 AiAvatarRenderJob。 +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Optional + +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +class GenerationFinalizeError(Exception): + """finalize 业务错误,code 供 API 层映射 HTTP 状态码。""" + + def __init__(self, message: str, code: str = "FinalizeError", status_code: int = 400): + super().__init__(message) + self.code = code + self.status_code = status_code + + +class GenerationFinalizeService: + def __init__(self, db: Session): + self.db = db + + def finalize_task( + self, + task_id: str, + user_id: str, + cover_url: Optional[str] = None, + custom_title: Optional[str] = None, + ): + """执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。 + + Returns: + GeneratedVideo 领域对象 + """ + from packages.adapters.sqlalchemy_impl.generated_video_repository import ( + SQLAlchemyGeneratedVideoRepository, + ) + from packages.adapters.sqlalchemy_impl.generation_task_repository import ( + SQLAlchemyGenerationTaskRepository, + ) + from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel + from packages.application.generated_video_finalize import finalize_generated_video + + task_repo = SQLAlchemyGenerationTaskRepository(self.db) + video_repo = SQLAlchemyGeneratedVideoRepository(self.db) + + task = task_repo.get(task_id) + if task is None: + raise GenerationFinalizeError(f"任务 {task_id} 不存在", "TaskNotFound", 404) + + # ── 幂等:已入库直接返回 ───────────────────────────────── + existing = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == task_id).first() + if existing is not None: + logger.info("[finalize] 幂等命中 task=%s video=%s", task_id, existing.id) + _changed = False + if cover_url and cover_url.strip() and existing.thumbnail_url != cover_url.strip(): + existing.thumbnail_url = cover_url.strip() + task.cover_url = cover_url.strip() + _changed = True + if custom_title and custom_title.strip() and (getattr(existing, "name", "") or "") != custom_title.strip(): + existing.name = custom_title.strip() + _changed = True + if _changed: + self.db.commit() + if task.status.value != "completed": + try: + task.mark_completed(result_count=1) + if cover_url and cover_url.strip(): + task.cover_url = cover_url.strip() + task_repo.update(task) + self.db.commit() + except Exception as e: + logger.warning("[finalize] 幂等补 mark_completed 失败: %s", e) + self.db.rollback() + return video_repo.get(existing.id) + + # ── 状态校验 ───────────────────────────────────────────── + if task.status.value != "awaiting_cover": + raise GenerationFinalizeError( + f"任务当前状态 {task.status.value},无法 finalize(需 awaiting_cover)", + "InvalidTaskStatus", + 400, + ) + + # ── 封面 ───────────────────────────────────────────────── + effective_cover = (cover_url or "").strip() if cover_url else (task.cover_url or "").strip() + + # ── 入库+查重(复用 worker 预计算结果) ────────────────── + try: + result = finalize_generated_video( + task=task, + session=self.db, + effective_cover_url=effective_cover, + custom_name=custom_title, + ) + except ValueError as e: + raise GenerationFinalizeError(str(e), "RenderedOutputMissing", 400) from e + + video_id = result["video_id"] + + # 应用自定义标题 + if custom_title and custom_title.strip(): + try: + _v = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first() + if _v is not None: + _v.name = custom_title.strip() + self.db.flush() + except Exception: + logger.warning("[finalize] 更新标题失败: video_id=%s", video_id, exc_info=True) + + # ── 推进任务 ───────────────────────────────────────────── + task.mark_completed(result_count=1) + task.cover_url = effective_cover + # 清理 rendered_output(体积较大,入库后不再需要) + meta = dict(task.extra_meta or {}) + meta.pop("rendered_output", None) + task.extra_meta = meta + task.updated_at = datetime.now(UTC) + task_repo.update(task) + self.db.commit() + + video = video_repo.get(video_id) + logger.info( + "[finalize] task=%s finalized -> video=%s cover=%s dup=%s", + task_id, + video_id, + bool(effective_cover), + result.get("is_duplicate", False), + ) + return video diff --git a/apps/api/app/services/gpu_lipsync_service.py b/apps/api/app/services/gpu_lipsync_service.py new file mode 100644 index 000000000..7c8286231 --- /dev/null +++ b/apps/api/app/services/gpu_lipsync_service.py @@ -0,0 +1,396 @@ +"""GPU MuseTalk 口型同步服务 — 反向轮询模式. + +职责: +1. 创建任务(由 lipsync 业务流程调用),为输入/输出生成预签名 URL,任务入队; +2. Worker 心跳注册(register):登记/刷新 worker 状态; +3. Worker 轮询拉任务(poll):原子地 CLAIM 一条 pending 任务,返回预签名 URL; +4. Worker 上报结果(report_result):标记 done/failed,失败可重试; +5. 业务侧查询状态(get_status)。 +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime, timedelta +from typing import Optional + +from app.core.storage import get_storage_service +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel, GpuWorkerModel +from packages.config import get_api_settings + +logger = logging.getLogger(__name__) + +# 任务在 processing 超过此时长仍未完成 → 超时回退 pending 或置 failed +MAX_ATTEMPTS = 3 + + +class GpuLipsyncService: + """GPU 口型同步服务(无状态方法,每次调用从 DI 拿 db/storage).""" + + RESULT_PREFIX = "gpu-lipsync/results/" + INPUT_SIGN_EXPIRES_PAD = 600 # 输入预签名 URL 在任务超时基础上再加 10min 余量 + + # ── 公共入口 ──────────────────────────────────────────────────── + + def __init__(self, db: Session): + self.db = db + self.settings = get_api_settings() + self.storage = get_storage_service() + + # ── Worker 注册/心跳 ──────────────────────────────────────────── + + def register_worker( + self, + worker_id: str, + hostname: str = "", + gpu_name: str = "", + free_vram_mb: int = 0, + capabilities: str = "musetalk", + task_id: Optional[str] = None, + ) -> tuple[GpuWorkerModel, bool]: + """Worker 注册/心跳。 + + task_id 非空时(Worker 推理期间的任务级心跳),同步把对应 processing + 任务的 last_heartbeat_at 续到当前时间,使长推理不会被 + ``_recover_timed_out_tasks`` 误回退。任务已结束 / 不属于该 worker + (如已被超时回收重新派发)时忽略,不报错。 + + 返回 ``(worker, cancel_task)``:当心跳任务已被用户取消时 + ``cancel_task=True``,Worker 应尽快终止推理并释放 GPU。 + """ + now = datetime.now(UTC) + worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none() + if worker is None: + worker = GpuWorkerModel( + worker_id=worker_id, + hostname=hostname, + gpu_name=gpu_name, + free_vram_mb=free_vram_mb, + capabilities=capabilities, + last_heartbeat_at=now, + created_at=now, + ) + self.db.add(worker) + else: + worker.hostname = hostname or worker.hostname + worker.gpu_name = gpu_name or worker.gpu_name + worker.free_vram_mb = free_vram_mb + worker.capabilities = capabilities or worker.capabilities + worker.last_heartbeat_at = now + cancel_task = False + if task_id: + cancel_task = self._touch_task_heartbeat(task_id, worker_id, now) + self.db.commit() + return worker, cancel_task + + # ── 轮询拉任务(Worker 调用) ────────────────────────────────── + + def poll_task(self, worker_id: str) -> Optional[GpuLipsyncTaskModel]: + """原子地认领一条最早的 pending 任务,返回给 worker;无任务返回 None. + + 同时会: + - 把 processing 状态且真正超时(任务心跳停滞超过 + gpu_task_timeout_seconds;Worker 推理期会通过 register(task_id=...) + 续心跳,长推理不会误判)的任务回退为 pending(attempt++,超过 + MAX_ATTEMPTS 置 failed),让其它 worker 认领。 + - 刷新 worker 心跳。 + """ + now = datetime.now(UTC) + self._recover_timed_out_tasks(now) + # 更新 worker 心跳 + self._touch_worker(worker_id, now) + + # 选一条最早 pending 任务(FOR UPDATE SKIP LOCKED 语义:简单起见先查再锁状态) + task = ( + self.db.query(GpuLipsyncTaskModel) + .filter(GpuLipsyncTaskModel.status == "pending") + .order_by(GpuLipsyncTaskModel.created_at.asc()) + .first() + ) + if task is None: + self.db.commit() + return None + + # 原子 claim:用 UPDATE WHERE status=pending 避免并发 + upd_rows = ( + self.db.query(GpuLipsyncTaskModel) + .filter( + GpuLipsyncTaskModel.id == task.id, + GpuLipsyncTaskModel.status == "pending", + ) + .update( + { + GpuLipsyncTaskModel.status: "processing", + GpuLipsyncTaskModel.worker_id: worker_id, + GpuLipsyncTaskModel.started_at: now, + GpuLipsyncTaskModel.last_heartbeat_at: now, + GpuLipsyncTaskModel.attempt: GpuLipsyncTaskModel.attempt + 1, + GpuLipsyncTaskModel.updated_at: now, + }, + synchronize_session=False, + ) + ) + self.db.commit() + if upd_rows == 0: + # 被其它 worker 抢先了 + return None + self.db.refresh(task) + # 生成预签名输入/输出 URL(在 claim 时动态生成,避免长时间过期) + expires = self.settings.gpu_task_timeout_seconds + self.INPUT_SIGN_EXPIRES_PAD + task._signed_video_url = self.storage.get_download_url(task.video_url, expires_seconds=expires) + task._signed_audio_url = self.storage.get_download_url(task.audio_url, expires_seconds=expires) + task._signed_upload_url = self.storage.get_upload_url( + self._result_key(task.id), + expires_seconds=expires, + content_type="video/mp4", + ) + task._upload_expires_at = now + timedelta(seconds=expires) + return task + + # ── 上报结果 ────────────────────────────────────────────────── + + def report_result( + self, + task_id: str, + worker_id: str, + success: bool, + duration_seconds: float = 0.0, + error_msg: str = "", + ) -> GpuLipsyncTaskModel: + task = self.db.get(GpuLipsyncTaskModel, task_id) + if task is None: + raise KeyError(f"task {task_id} not found") + now = datetime.now(UTC) + if success: + task.status = "done" + task.result_url = self._result_key(task_id) + task.result_duration = duration_seconds or 0.0 + task.error_msg = "" + task.finished_at = now + elif task.status == "cancelled": + # 用户已取消的任务,Worker 终止后上报失败,保持 cancelled 状态不回退 + task.finished_at = now + task.error_msg = (error_msg or "用户取消")[:2000] + logger.info("GPU 任务 %s 已被用户取消,保持 cancelled 状态", task_id) + else: + # 失败:若仍可重试(已尝试次数 < MAX_ATTEMPTS)→ 回退 pending;否则 → failed + if task.attempt < MAX_ATTEMPTS: + task.status = "pending" + task.worker_id = "" + task.started_at = None + task.error_msg = error_msg[:2000] + logger.warning( + "GPU 任务 %s 在 worker %s 上失败,回退 pending 等待重试(attempt=%d): %s", + task_id, + worker_id, + task.attempt, + error_msg[:200], + ) + else: + task.status = "failed" + task.error_msg = error_msg[:2000] + task.finished_at = now + logger.error( + "GPU 任务 %s 失败达到最大重试次数 %d,置为 failed: %s", + task_id, + MAX_ATTEMPTS, + error_msg[:200], + ) + task.updated_at = now + task.last_heartbeat_at = now + self._touch_worker(worker_id, now) + self.db.commit() + self.db.refresh(task) + return task + + # ── 业务侧查询 ──────────────────────────────────────────────── + + def get_task(self, task_id: str) -> Optional[GpuLipsyncTaskModel]: + return self.db.get(GpuLipsyncTaskModel, task_id) + + def get_by_lipsync_job(self, lipsync_job_id: str) -> Optional[GpuLipsyncTaskModel]: + return ( + self.db.query(GpuLipsyncTaskModel) + .filter(GpuLipsyncTaskModel.lipsync_job_id == lipsync_job_id) + .order_by(GpuLipsyncTaskModel.created_at.desc()) + .first() + ) + + # ── 创建任务(业务侧调用) ──────────────────────────────────── + + def create_task( + self, + video_url: str, + audio_url: str, + lipsync_job_id: str = "", + user_id: str = "", + project_id: str = "", + ) -> GpuLipsyncTaskModel: + task_id = str(uuid.uuid4()) + now = datetime.now(UTC) + task = GpuLipsyncTaskModel( + id=task_id, + lipsync_job_id=lipsync_job_id, + user_id=user_id, + project_id=project_id, + video_url=video_url, + audio_url=audio_url, + status="pending", + attempt=0, + created_at=now, + updated_at=now, + ) + self.db.add(task) + self.db.commit() + self.db.refresh(task) + logger.info( + "创建 GPU 口型任务 %s (lipsync_job=%s, user=%s)", + task_id, + lipsync_job_id, + user_id, + ) + return task + + # ── 内部辅助 ────────────────────────────────────────────────── + + def _result_key(self, task_id: str) -> str: + return f"{self.RESULT_PREFIX}{task_id}.mp4" + + def _touch_task_heartbeat(self, task_id: str, worker_id: str, now: datetime) -> bool: + """Worker 推理期间的任务级心跳:只刷新属于该 worker 且仍在 processing 的任务。 + + 任务不存在 / 已被超时回收重新派发 / 已完成 → 静默忽略(此时旧 worker 的 + 结果上报会被结果接口按最终态处理)。 + + 返回 ``cancel_task``:任务已被用户取消时为 True,Worker 应终止推理。 + """ + task = self.db.get(GpuLipsyncTaskModel, task_id) + if task is None: + return False + # 任务已被用户取消 → 通知 Worker 终止推理 + if task.status == "cancelled": + logger.info("任务心跳检测到已取消 task=%s worker=%s,通知 Worker 终止", task_id, worker_id) + return True + if task.status != "processing" or task.worker_id != worker_id: + logger.info( + "忽略过期任务心跳 task=%s worker=%s(status=%s owner=%s)", + task_id, + worker_id, + task.status, + task.worker_id, + ) + return False + task.last_heartbeat_at = now + task.updated_at = now + self.db.flush() + return False + + def _touch_worker(self, worker_id: str, now: datetime) -> None: + if not worker_id: + return + worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none() + if worker is not None: + worker.last_heartbeat_at = now + self.db.flush() + else: + # 自注册(poll 时允许自动建一个空 worker 记录,运维可见) + worker = GpuWorkerModel( + worker_id=worker_id, + hostname="", + gpu_name="", + free_vram_mb=0, + capabilities="musetalk", + last_heartbeat_at=now, + created_at=now, + ) + self.db.add(worker) + self.db.flush() + + def _recover_timed_out_tasks(self, now: datetime) -> None: + """扫描 processing 状态且真正超时的任务,回退 pending 或失败。 + + 判定只看任务自身 last_heartbeat_at:claim 时写入,Worker 推理期间通过 + /gpu/register(task_id=...) 每 30s 续期。因此仅在 Worker 崩溃/断网 + (任务心跳停滞超过 gpu_task_timeout_seconds)时才回收, + 不会因 Worker 主循环忙于推理而误回退。 + """ + timeout = self.settings.gpu_task_timeout_seconds + cutoff = now - timedelta(seconds=timeout) + stuck_tasks = ( + self.db.query(GpuLipsyncTaskModel) + .filter( + GpuLipsyncTaskModel.status == "processing", + GpuLipsyncTaskModel.last_heartbeat_at < cutoff, + ) + .all() + ) + for t in stuck_tasks: + if t.attempt >= MAX_ATTEMPTS: + t.status = "failed" + t.error_msg = f"worker 心跳超时({timeout}s),重试次数已耗尽" + t.finished_at = now + else: + t.status = "pending" + t.worker_id = "" + t.started_at = None + t.error_msg = f"worker 心跳超时({timeout}s),等待重试" + logger.warning("GPU 任务 %s 心跳超时,回退 pending(attempt=%d)", t.id, t.attempt) + t.updated_at = now + if stuck_tasks: + self.db.flush() + + # ── 业务侧辅助 ────────────────────────────────────────────────── + + def has_available_worker(self) -> bool: + """判断是否有 Worker 在心跳新鲜窗口内可用.""" + stale_cutoff = datetime.now(UTC) - timedelta(seconds=self.settings.gpu_worker_stale_seconds) + return ( + self.db.query(GpuWorkerModel).filter(GpuWorkerModel.last_heartbeat_at >= stale_cutoff).first() is not None + ) + + def wait_for_result( + self, + task_id: str, + timeout_seconds: Optional[int] = None, + poll_interval: Optional[float] = None, + ) -> Optional[GpuLipsyncTaskModel]: + """同步轮询等待 GPU 任务完成。 + + Args: + task_id: 任务 ID(由 create_task 返回) + timeout_seconds: 总超时,默认取 settings.gpu_lipsync_wait_timeout + poll_interval: 轮询间隔秒,默认取 settings.gpu_lipsync_poll_interval + + Returns: + 终态 task(status=done/failed);超时返回 None(此时调用方应回退 MediaKit)。 + 等待期间会自动调用 _recover_timed_out_tasks 做超时回收。 + """ + import time + + timeout = timeout_seconds if timeout_seconds is not None else self.settings.gpu_lipsync_wait_timeout + interval = poll_interval if poll_interval is not None else self.settings.gpu_lipsync_poll_interval + deadline = time.monotonic() + timeout + + while True: + now = datetime.now(UTC) + # 顺手回收超时任务 + try: + self._recover_timed_out_tasks(now) + self.db.commit() + except Exception as exc: # noqa: BLE001 - 回收失败不阻塞主流程 + logger.warning("wait_for_result 回收超时任务异常: %s", exc) + self.db.rollback() + + task = self.db.get(GpuLipsyncTaskModel, task_id) + if task is None: + return None + if task.status in ("done", "failed", "cancelled"): + return task + # pending/processing 继续等 + if time.monotonic() >= deadline: + logger.warning("GPU 任务 %s 等待超时(%ds),回退 MediaKit", task_id, timeout) + return None + time.sleep(interval) diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py index 10110d27d..f4a248514 100644 --- a/apps/api/app/services/lipsync_service.py +++ b/apps/api/app/services/lipsync_service.py @@ -29,6 +29,7 @@ from app.services.mediakit_client import ( MediaKitError, get_mediakit_client, ) +from app.tasks.lipsync_gpu import lipsync_gpu_process_async # Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径) from app.tasks.lipsync_tts import tts_synthesize_and_submit @@ -36,6 +37,7 @@ from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel from packages.application.cosyvoice_service import CosyVoiceError +from packages.config import get_api_settings from packages.domain.sentence_timings import ( compute_sentence_timings, probe_audio_duration, @@ -63,6 +65,7 @@ class LipsyncService: self.client = client or get_mediakit_client() self._cosyvoice = cosyvoice_service self._voice_clone_repo = voice_clone_repo + self.settings = get_api_settings() def _get_cosyvoice(self): """延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置).""" @@ -108,6 +111,8 @@ class LipsyncService: script_text: str, speed: float, emotion: str, + style: str = "", + volume: int = 50, ) -> str: """TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL. @@ -121,7 +126,9 @@ class LipsyncService: text=script_text, voice_id=actual_voice_id, speed=speed, - emotion=emotion, # normalize 在 CosyVoiceService 内部完成 + style=style, + volume=volume, + emotion=emotion, language="zh", ) except CosyVoiceError as exc: @@ -215,7 +222,57 @@ class LipsyncService: if timings: job.sentence_timings = timings - # 4. 签名 URL 并提交 MediaKit + # 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker + use_gpu = False + if self.settings.use_gpu_lipsync: + try: + from app.services.gpu_lipsync_service import GpuLipsyncService + + gpu_svc = GpuLipsyncService(self.db) + if gpu_svc.has_available_worker(): + use_gpu = True + logger.info("[lipsync] 检测到可用 GPU Worker,优先走 MuseTalk 本地推理: job_id=%s", job.id) + else: + logger.info("[lipsync] GPU 开关已开但无可用 Worker(心跳过期),回退 MediaKit: job_id=%s", job.id) + except Exception as exc: + logger.warning("[lipsync] GPU 服务初始化失败,回退 MediaKit: job_id=%s err=%s", job.id, exc) + + if use_gpu: + try: + gpu_task = self._submit_to_gpu_create(job=job, gpu_svc=gpu_svc) + if gpu_task is not None: + # GPU 任务已创建,设为 processing 并异步等待结果 + job.mediakit_task_id = f"gpu:{gpu_task.id}" + job.status = "processing" + job.updated_at = datetime.now(UTC) + self.db.commit() + # 派发 Celery 异步任务处理 GPU 等待+结果回写 + try: + lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id)) + logger.info( + "[lipsync] GPU 任务已异步派发: job_id=%s gpu_task=%s", + job.id, + gpu_task.id, + ) + except Exception as celery_exc: + logger.warning( + "[lipsync] Celery 派发失败,降级同步等待: job_id=%s err=%s", + job.id, + celery_exc, + ) + self._submit_to_gpu_wait(job=job, gpu_svc=gpu_svc, gpu_task=gpu_task) + return + # create 失败 → 回退 MediaKit + logger.warning("[lipsync] GPU 任务创建失败,回退 MediaKit: job_id=%s", job.id) + self.db.rollback() + except Exception as exc: + logger.exception("[lipsync] GPU 路径异常,回退 MediaKit: job_id=%s err=%s", job.id, exc) + try: + self.db.rollback() + except Exception: + pass + + # 5. 签名 URL 并提交 MediaKit(兜底路径) video_url = self._sign_media_url(job.video_url) signed_audio_url = self._sign_media_url(job.audio_url) job.audio_url = signed_audio_url @@ -244,6 +301,120 @@ class LipsyncService: self.db.commit() raise + # ── GPU MuseTalk 路径 ──────────────────────────────────────────────── + + def _is_own_oss_url(self, url: str, storage) -> bool: + """判断 URL / 存储 key 是否属于自家 OSS。 + + - 裸存储 key(无 scheme):自家对象 + - host 与 storage.public_url host 一致:自家对象 + - 其余 http(s) 公网链接(如 dashscope-result 临时地址):外部对象 + """ + if not url: + return False + parsed = urlparse(url) + if not parsed.scheme: + return True # 裸存储 key + public_base = getattr(storage, "public_url", "") + own_host = urlparse(public_base).netloc.lower() if public_base else "" + return bool(own_host) and parsed.netloc.lower() == own_host + + def _persist_external_audio_for_gpu(self, *, job, storage) -> Optional[str]: + """GPU 任务创建前,把外部域名的预合成 TTS 音频转存到自家 OSS。 + + Worker 部署在用户家庭网络,dashscope-result 等第三方临时 OSS 地址 + 可能无法访问;转存后 gpu_svc 在 poll 时会签自家预签名 URL 给 Worker。 + 已是自家 OSS 对象(含裸 key)直接返回 None(无需转存); + 转存失败返回 None,调用方回退使用原始 URL(最坏情况是 Worker 拉取失败, + 服务端重试耗尽后回退 MediaKit,不阻断业务)。 + """ + if self._is_own_oss_url(job.audio_url, storage): + return None + try: + audio_data = safe_download_bytes( + job.audio_url, + purpose="lipsync_gpu_tts_audio", + allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES, + timeout=60.0, + ) + storage_key = f"lipsync-tts/{job.user_id}/{job.id}.mp3" + permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg") + logger.info( + "[lipsync] GPU 任务外部音频已转存自家 OSS: job_id=%s key=%s", + job.id, + storage_key, + ) + return permanent_url + except Exception as exc: + logger.warning( + "[lipsync] GPU 任务外部音频转存 OSS 失败,回退原始 URL: job_id=%s err=%s", + job.id, + exc, + ) + return None + + def _submit_to_gpu_create(self, *, job, gpu_svc) -> Optional[object]: + """创建 GPU 任务并立即返回(异步模式)。 + + 成功返回 gpu_task 对象;创建失败返回 None。 + 不再同步等待结果,结果由 Celery 异步任务 lipsync_gpu_process_async 回写。 + """ + storage = get_shared_storage_service() + persisted_audio_url = self._persist_external_audio_for_gpu(job=job, storage=storage) + audio_url_for_task = persisted_audio_url or job.audio_url + gpu_task = gpu_svc.create_task( + video_url=job.video_url, + audio_url=audio_url_for_task, + lipsync_job_id=job.id, + user_id=job.user_id, + project_id=job.project_id, + ) + logger.info( + "[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s", + job.id, + gpu_task.id, + ) + return gpu_task + + def _submit_to_gpu_wait(self, *, job, gpu_svc, gpu_task) -> None: + """同步等待 GPU 结果(Celery 派发失败时的降级路径)。""" + final_task = gpu_svc.wait_for_result(gpu_task.id) + if final_task is None: + logger.warning("[lipsync] GPU 同步等待超时,回退 MediaKit: gpu_task=%s", gpu_task.id) + return + if final_task.status != "done": + logger.warning( + "[lipsync] GPU 同步等待失败: gpu_task=%s status=%s", + gpu_task.id, + final_task.status, + ) + return + try: + storage = get_shared_storage_service() + signed_result_url = storage.get_download_url( + final_task.result_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS + ) + if signed_result_url: + final_task.result_url = signed_result_url + except Exception as exc: + logger.warning( + "[lipsync] GPU 结果签名失败: gpu_task=%s err=%s", + gpu_task.id, + exc, + ) + job.mediakit_task_id = "" + job.status = STATUS_COMPLETED + job.output_video_url = final_task.result_url + job.output_duration = final_task.result_duration or 0.0 + job.completed_at = datetime.now(UTC) + job.updated_at = datetime.now(UTC) + self.db.commit() + logger.info( + "[lipsync] GPU 同步等待完成: job_id=%s duration=%.2f", + job.id, + job.output_duration, + ) + # ── 创建任务 ────────────────────────────────────────────────────────── def create_job( @@ -257,6 +428,8 @@ class LipsyncService: voice_id: str = "", script_text: str = "", speed: float = 1.0, + style: str = "", + volume: int = 50, emotion: str = "", enable_video_loop: bool = True, project_id: str = "", @@ -305,6 +478,7 @@ class LipsyncService: voice_id=voice_id or "", script_text=script_text or "", speed=speed, + style=style or "", emotion=emotion or "", # 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing status="tts_processing" if is_tts_mode else "pending", @@ -326,6 +500,8 @@ class LipsyncService: voice_id, script_text, speed, + style or "", + volume, emotion or "", ) ) @@ -360,6 +536,8 @@ class LipsyncService: voice_id: str, script_text: str, speed: float = 1.0, + style: str = "", + volume: int = 50, emotion: str = "neutral", ) -> dict: """同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算. @@ -478,6 +656,29 @@ class LipsyncService: if job.status in (STATUS_COMPLETED, "failed"): return job + # GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新 + # 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败 + if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"): + if job.status in ("processing", "gpu_processing"): + _now = datetime.now(UTC) + _upd = job.updated_at + if _upd is not None and _upd.tzinfo is None: + _upd = _upd.replace(tzinfo=UTC) + stale_minutes = 30 + if _upd and (_now - _upd).total_seconds() > stale_minutes * 60: + logger.warning( + "GPU 异步任务超时(>%d 分钟),标记失败: job_id=%s", + stale_minutes, + job_id, + ) + job.status = "failed" + job.error_message = f"GPU 处理超时(>{stale_minutes} 分钟)" + job.error_code = "GpuTimeout" + job.completed_at = _now + job.updated_at = _now + self.db.commit() + return job + # 未提交的任务不轮询 if not job.mediakit_task_id: return job @@ -582,12 +783,32 @@ class LipsyncService: # ── 取消任务 ────────────────────────────────────────────────────────── def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]: - """取消任务(仅 pending/tts_processing/submitted 状态可取消).""" + """取消任务(pending/tts_processing/submitted/processing 状态可取消). + + 当 job 走 GPU 路径(mediakit_task_id 以 "gpu:" 开头)且状态为 processing 时, + 同步将关联的 GpuLipsyncTask 标记为 cancelled,以便 Worker 心跳时检测到取消信号。 + """ job = self.get_job(job_id, user_id) if job is None: return None - if job.status in ("pending", "tts_processing", "submitted"): + if job.status in ("pending", "tts_processing", "submitted", "processing"): + # GPU 路径:同步标记关联的 GPU 任务为 cancelled + if job.status == "processing" and job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"): + gpu_task_id = job.mediakit_task_id[4:] # 去掉 "gpu:" 前缀 + try: + from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel + + gpu_task = self.db.get(GpuLipsyncTaskModel, gpu_task_id) + if gpu_task and gpu_task.status == "processing": + gpu_task.status = "cancelled" + gpu_task.error_msg = "用户取消" + gpu_task.updated_at = datetime.now(UTC) + gpu_task.finished_at = datetime.now(UTC) + logger.info("GPU 任务 %s 已被用户取消(通过 job_id=%s)", gpu_task_id, job_id) + except Exception as exc: + logger.warning("标记 GPU 任务取消失败(不影响 job 取消): %s", exc) + job.status = "cancelled" job.updated_at = datetime.now(UTC) self.db.commit() diff --git a/apps/api/app/tasks/lipsync_gpu.py b/apps/api/app/tasks/lipsync_gpu.py new file mode 100644 index 000000000..afbb89028 --- /dev/null +++ b/apps/api/app/tasks/lipsync_gpu.py @@ -0,0 +1,198 @@ +"""GPU MuseTalk 异步推理任务 — 将 GPU 推理等待从 HTTP 请求移至 Celery 后台执行. + +优化目标:将 POST /lipsync/jobs 的 API 响应时间从 >200s 降到 <1s。 +任务流程: +1. 加载 LipsyncJob,获取 gpu_task_id +2. 调用 GpuLipsyncService.wait_for_result 轮询等待 GPU 完成 +3. 签名结果 URL(7 天),更新 job 为 completed +4. 失败/超时时:尝试 MediaKit 兜底,若仍失败则标记 job 为 failed + +使用 @shared_task 确保被 Worker 侧 celery_app 正确注册。 +""" + +import logging +from datetime import UTC, datetime + +from celery import shared_task +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel +from packages.shared.storage import get_shared_storage_service + +logger = logging.getLogger(__name__) + +# 与 LipsyncService 保持一致 +_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600 + + +def _get_db_session() -> Session: + """获取 DB session(兼容 API 和 Worker 两种运行时).""" + try: + from worker_app.db import SessionLocal # type: ignore + except ImportError: + from app.db import SessionLocal # type: ignore + return SessionLocal() + + +def _sign_media_url(url: str) -> str: + """对自家 OSS URL 签 7 天预签名。""" + if not url: + return url + try: + from urllib.parse import urlparse + + storage = get_shared_storage_service() + public_base = getattr(storage, "public_url", "") + if not isinstance(public_base, str) or not public_base: + return url + own_host = urlparse(public_base).netloc.lower() + host = urlparse(url).netloc.lower() + if not own_host or host != own_host: + return url + return storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS) + except Exception: + return url + + +@shared_task( + name="lipsync_gpu_process_async", + bind=True, + max_retries=0, + acks_late=True, +) +def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str) -> None: + """异步处理 GPU MuseTalk 推理。 + + Args: + job_id: LipsyncJob 的 ID + user_id: 用户 ID + gpu_task_id: GpuLipsyncTask 的 ID + """ + db: Session = _get_db_session() + try: + job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first() + if job is None: + logger.error("[lipsync_gpu_async] job 不存在: job_id=%s", job_id) + return + + # 确保状态为 processing + if job.status not in ("processing", "gpu_processing"): + logger.warning( + "[lipsync_gpu_async] job 状态异常,跳过: job_id=%s status=%s", + job_id, + job.status, + ) + return + + from app.services.gpu_lipsync_service import GpuLipsyncService + + gpu_svc = GpuLipsyncService(db) + final_task = gpu_svc.wait_for_result(gpu_task_id) + + if final_task is None: + logger.warning( + "[lipsync_gpu_async] GPU 超时,回退 MediaKit: job_id=%s gpu_task=%s", + job_id, + gpu_task_id, + ) + _fallback_to_mediakit(db, job) + return + + if final_task.status == "cancelled": + # 用户已取消任务,不回退 MediaKit,直接标记 job 为 cancelled + job.status = "cancelled" + job.updated_at = datetime.now(UTC) + db.commit() + logger.info("[lipsync_gpu_async] GPU 任务已被用户取消: job_id=%s", job_id) + return + + if final_task.status != "done": + logger.warning( + "[lipsync_gpu_async] GPU 失败,回退 MediaKit: job_id=%s gpu_task=%s status=%s", + job_id, + gpu_task_id, + final_task.status, + ) + _fallback_to_mediakit(db, job) + return + + # 签名结果 URL + result_url = final_task.result_url or "" + try: + storage = get_shared_storage_service() + signed = storage.get_download_url(result_url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS) + if signed: + result_url = signed + except Exception as exc: + logger.warning( + "[lipsync_gpu_async] 签名失败,用原 URL: job_id=%s err=%s", + job_id, + exc, + ) + + job.status = "completed" + job.output_video_url = result_url + job.output_duration = final_task.result_duration or 0.0 + job.completed_at = datetime.now(UTC) + job.updated_at = datetime.now(UTC) + db.commit() + logger.info( + "[lipsync_gpu_async] GPU 完成: job_id=%s duration=%.2f", + job_id, + job.output_duration, + ) + except Exception as exc: + logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc) + try: + job = db.query(LipsyncJobModel).filter_by(id=job_id).first() + if job: + job.status = "failed" + job.error_message = f"GPU 异步处理异常: {exc}" + job.error_code = "GpuAsyncError" + job.updated_at = datetime.now(UTC) + db.commit() + except Exception: + pass + finally: + db.close() + + +def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None: + """GPU 失败时回退到 MediaKit 云端渲染。""" + try: + from app.services.mediakit_client import MediaKitError, get_mediakit_client + + client = get_mediakit_client() + video_url = _sign_media_url(job.video_url) + audio_url = _sign_media_url(job.audio_url) + + result = client.submit_lipsync( + video_url=video_url, + audio_url=audio_url, + enable_video_loop=job.enable_video_loop, + client_token=job.id, + ) + job.mediakit_task_id = result["task_id"] + job.status = "submitted" + job.submitted_at = datetime.now(UTC) + job.updated_at = datetime.now(UTC) + db.commit() + logger.info( + "[lipsync_gpu_async] 已回退 MediaKit: job_id=%s task_id=%s", + job.id, + result["task_id"], + ) + except MediaKitError as exc: + job.status = "failed" + job.error_message = str(exc) + job.error_code = exc.code + job.updated_at = datetime.now(UTC) + db.commit() + logger.error("[lipsync_gpu_async] MediaKit 也失败: job_id=%s err=%s", job.id, exc) + except Exception as exc: + job.status = "failed" + job.error_message = f"GPU+MediaKit 均失败: {exc}" + job.error_code = "FallbackFailed" + job.updated_at = datetime.now(UTC) + db.commit() + logger.error("[lipsync_gpu_async] 兜底异常: job_id=%s err=%s", job.id, exc) diff --git a/apps/api/app/tasks/lipsync_tts.py b/apps/api/app/tasks/lipsync_tts.py index dce535485..bc58127ae 100644 --- a/apps/api/app/tasks/lipsync_tts.py +++ b/apps/api/app/tasks/lipsync_tts.py @@ -82,7 +82,9 @@ def tts_synthesize_and_submit( voice_id: str, script_text: str, speed: float, - emotion: str, + style: str = "", + volume: int = 50, + emotion: str = "", ): """异步执行 TTS 合成 + OSS 转存 + MediaKit 提交. @@ -159,6 +161,8 @@ def tts_synthesize_and_submit( text=script_text, voice_id=voice_id, speed=speed, + style=style, + volume=volume, emotion=emotion, language="zh", ) diff --git a/apps/web/e2e/core-douyin-extract.spec.ts b/apps/web/e2e/core-douyin-extract.spec.ts new file mode 100755 index 000000000..2fee156c0 --- /dev/null +++ b/apps/web/e2e/core-douyin-extract.spec.ts @@ -0,0 +1,117 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" + +const PASSWORD = "SmokePass123!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" + +async function routeBrowserApiToTestApi(page: Page) { + if (!apiOrigin) return + await page.route("**/api/v1/**", async (route) => { + const sourceUrl = new URL(route.request().url()) + const response = await route.fetch({ + url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, + }) + await route.fulfill({ response }) + }) +} + +async function loginWithRetry(request: APIRequestContext, email: string, password: string) { + for (let i = 0; i <= 2; i++) { + const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } }) + if (r.status() !== 429) { + expect(r.ok(), `login: ${await r.text()}`).toBeTruthy() + return (await r.json()).access_token as string + } + console.log(`[douyin] 429 retry ${i + 1}/2`) + await new Promise((res) => setTimeout(res, 65000)) + } + throw new Error("Login retries exhausted") +} + +/** + * #1972 抖音文案提取冒烟 + * + * 路径:文案库页面 → 点「🎬 从抖音提取」→ 粘贴分享文案 → 点「开始提取」 + * → mock /api/v1/scripts/extract-from-douyin 返回稳定文案 → 断言「新建文案」弹窗中预填了非空文案 + */ +test.describe("Douyin Script Extraction (#1972)", () => { + test("extract flow: open modal, paste link, text prefilled in create modal", async ({ + page, + request, + }) => { + test.setTimeout(180_000) + await page.setViewportSize({ width: 1440, height: 900 }) + + const suffix = Math.random().toString(36).slice(2, 8) + const email = `e2e-douyin-${suffix}@example.com` + await request.post(`${apiBase}/auth/register`, { + data: { email, password: PASSWORD, username: `e2e_dy_${suffix}` }, + }) + const token = await loginWithRetry(request, email, PASSWORD) + const authHeader = { Authorization: `Bearer ${token}` } + + const proj = await request.post(`${apiBase}/projects`, { + headers: authHeader, + data: { name: `Smoke Douyin ${suffix}` }, + }) + const projectId = (await proj.json()).id ?? (await proj.json()).project_id + await request.post(`${apiBase}/asset-libraries`, { + headers: authHeader, + data: { project_id: projectId, name: "Smoke", kind: "video" }, + }) + + await page.addInitScript((t: string) => { + window.localStorage.setItem("access_token", t) + window.localStorage.setItem( + "auth-storage", + JSON.stringify({ state: { token: t, user: null } }), + ) + }, token) + await routeBrowserApiToTestApi(page) + + // Mock 抖音提取接口返回稳定文案 + const extractedText = "大家好,今天给大家推荐一款超好用的产品,性价比非常高,快来看看吧!" + await page.route("**/api/v1/scripts/extract-from-douyin", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ text: extractedText, duration_seconds: 15 }), + }), + ) + // 文案列表空态 + await page.route( + (url) => url.pathname.endsWith("/scripts") && !url.pathname.includes("extract-from-douyin"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [], total: 0, page: 1, page_size: 20 }), + }), + ) + + await page.goto("/app/scripts") + // 文案库页面加载 + await expect(page.getByText(/文案库|文案/).first()).toBeVisible({ timeout: 30000 }) + + // 点「🎬 从抖音提取」按钮 + await page.getByRole("button", { name: /从抖音提取/ }).click() + await expect(page.getByText("从抖音视频提取文案")).toBeVisible({ timeout: 5000 }) + + // 在 TextArea 粘贴"抖音分享文案" + const textarea = page.locator(".ant-modal textarea").first() + await expect(textarea).toBeVisible() + await textarea.fill("8.88 复制打开抖音,看看【推荐视频】https://v.douyin.com/abcDEF/") + + // 点「开始提取」 + await page.getByRole("button", { name: "开始提取" }).click() + await expect(page.getByText(/提取中/)).toBeVisible({ timeout: 3000 }) + + // 等待抖音弹窗关闭,「新建文案」弹窗打开并预填提取文案 + await expect(page.getByText("从抖音视频提取文案")).not.toBeVisible({ timeout: 15000 }) + await expect(page.getByText("新建文案")).toBeVisible({ timeout: 5000 }) + const createTextarea = page.locator(".ant-modal textarea").first() + await expect(createTextarea).toBeVisible() + await expect(createTextarea).toHaveValue(new RegExp(extractedText.slice(0, 10))) + console.log("[douyin] Extraction flow completed ✓, text length:", extractedText.length) + }) +}) diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index da26cb324..459c72cc2 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type APIRequestContext } from "@playwright/test" +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import * as fs from "node:fs" import * as path from "node:path" import { fileURLToPath } from "node:url" @@ -8,7 +8,8 @@ const PASSWORD = "SmokePass123!" const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" -const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { +/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */ +async function routeBrowserApiToTestApi(page: Page) { if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { const sourceUrl = new URL(route.request().url()) @@ -24,276 +25,354 @@ async function loginWithRetry( email: string, password: string, maxRetries = 2, -) { +): Promise { for (let i = 0; i <= maxRetries; i++) { - const response = await request.post(`${apiBase}/auth/login`, { - data: { email, password }, - }) - if (response.status() !== 429) return response - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + const resp = await request.post(`${apiBase}/auth/login`, { data: { email, password } }) + if (resp.status() !== 429) { + expect(resp.ok(), `Login should succeed: ${await resp.text()}`).toBeTruthy() + const data = await resp.json() + return data.access_token + } + console.log(`[login] 429 rate limited, retry ${i + 1}/${maxRetries} after 65s`) await new Promise((r) => setTimeout(r, 65000)) } - return request.post(`${apiBase}/auth/login`, { - data: { email, password }, + throw new Error("Login failed after retries") +} + +/** + * 注册新用户 + 建项目/视频库/上传 sample.mp4,等素材 ready。返回 { token, projectId, libraryId, assetId }。 + */ +async function setupFreshUser( + request: APIRequestContext, + label: string, +): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> { + const suffix = Math.random().toString(36).slice(2, 8) + const email = `e2e-${label}-${suffix}@example.com` + await request.post(`${apiBase}/auth/register`, { + data: { email, password: PASSWORD, username: `e2e_${label}_${suffix}` }, }) + const token = await loginWithRetry(request, email, PASSWORD) + const auth = { Authorization: `Bearer ${token}` } + + const proj = await request.post(`${apiBase}/projects`, { + headers: auth, + data: { name: `Smoke ${label} ${suffix}` }, + }) + expect(proj.ok(), `create project: ${await proj.text()}`).toBeTruthy() + const projectId = (await proj.json()).id ?? (await proj.json()).project_id + + const lib = await request.post(`${apiBase}/asset-libraries`, { + headers: auth, + data: { project_id: projectId, name: "Smoke", kind: "video" }, + }) + expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy() + const libraryId = (await lib.json()).id + + const samplePath = path.join(__dirname, "fixtures", "sample.mp4") + const sampleBuf = fs.readFileSync(samplePath) + const up = await request.post(`${apiBase}/upload`, { + headers: auth, + multipart: { + project_id: projectId, + library_id: libraryId, + file: { + name: "sample.mp4", + mimeType: "video/mp4", + buffer: sampleBuf, + }, + }, + }) + expect(up.ok(), `upload sample: ${await up.text()}`).toBeTruthy() + const assetId = (await up.json()).asset_id + await expect + .poll( + async () => { + const r = await request.get(`${apiBase}/assets/${assetId}`, { headers: auth }) + return r.ok() ? (await r.json()).status : "pending" + }, + { timeout: 90_000, intervals: [3000, 3000, 5000] }, + ) + .toBe("ready") + return { token, libraryId, assetId, suffix } } -type ProjectResponse = { id: string } -type LibraryResponse = { id: string } -type AssetListResponse = { - items: Array<{ - id: string - name: string - status: string - }> -} +/** + * #1970 智能剪辑核心冒烟(新 5 步向导) + * + * 新流程:选择模式 → 选择素材 → 选择标题 → 确认生成 → 选择封面 + * + * 两条路径: + * 1) 随机混剪(默认)→ Step1 下一步 → 配音选择弹窗 → Step2 选素材 → 数量弹窗 + * → Step3 标题 → Step4 确认生成 → 断言任务创建 + * 2) 叙事剪辑 → Step1 切模式 → 下一步 → 文案选择弹窗 → TTS 弹窗选音色(mock 合成) + * → Step2 AI 提示卡可见 + 选素材 → 数量弹窗 → Step3 标题 → Step4 确认生成 + * → 断言任务创建 + */ +test.describe("Core Smart-Edit Flow (#1970)", () => { + test("random mode: 5-step wizard creates generation task", async ({ page, request }) => { + test.setTimeout(600_000) + await page.setViewportSize({ width: 1440, height: 1000 }) + const { token, suffix } = await setupFreshUser(request, "random") + const authHeader = { Authorization: `Bearer ${token}` } -test.describe("Core generation flow", () => { - test.describe.configure({ timeout: 360_000 }) - - test("walks through wizard with count modal and starts generation", async ({ page, request }) => { - test.setTimeout(360_000) + // 确保默认模板存在(智能剪辑页依赖模板) + const tmpls = await request.get(`${apiBase}/templates`, { headers: authHeader }) + const tmplsJson = await tmpls.json() + const templates = Array.isArray(tmplsJson) + ? tmplsJson + : Array.isArray(tmplsJson.items) + ? tmplsJson.items + : [] + expect(templates.length).toBeGreaterThan(0) + // 注入登录态 + 路由 API + await page.addInitScript((t: string) => { + window.localStorage.setItem("access_token", t) + window.localStorage.setItem( + "auth-storage", + JSON.stringify({ state: { token: t, user: null } }), + ) + }, token) await routeBrowserApiToTestApi(page) - const suffix = Date.now().toString(36) - const email = `e2e-gen-${suffix}@example.com` - const username = `e2e_gen_${suffix}` - const libraryName = `E2E Gen Lib ${suffix}` - // Register - const register = await request.post(`${apiBase}/auth/register`, { - data: { email, username, password: PASSWORD, display_name: username }, - }) - expect(register.status()).toBe(201) - const registerData = (await register.json()) as { user_id: string } - - // Login - const login = await loginWithRetry(request, email, PASSWORD) - expect(login.status()).toBe(200) - const loginData = (await login.json()) as { access_token: string } - const headers = { Authorization: `Bearer ${loginData.access_token}` } - - // Create project - const project = await request.post(`${apiBase}/projects`, { - headers, - data: { name: `E2E Gen Proj ${suffix}` }, - }) - expect(project.status()).toBe(200) - const projectData = (await project.json()) as ProjectResponse - - // Create asset library - const library = await request.post(`${apiBase}/asset-libraries`, { - headers, - data: { project_id: projectData.id, name: libraryName, kind: "video" }, - }) - expect(library.status()).toBe(200) - const libraryData = (await library.json()) as LibraryResponse - - // Upload source video - const sourceFileName = "e2e-gen-source.mp4" - const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4") - const sampleVideoBuffer = fs.readFileSync(sampleVideoPath) - const upload = await request.post(`${apiBase}/upload`, { - headers, - multipart: { - project_id: projectData.id, - library_id: libraryData.id, - file: { - name: sourceFileName, - mimeType: "video/mp4", - buffer: sampleVideoBuffer, - }, - }, - }) - expect(upload.status()).toBe(200) - - // Wait for asset to be ready - await expect - .poll( - async () => { - const assets = await request.get(`${apiBase}/assets`, { - headers, - params: { library_id: libraryData.id }, - }) - if (!assets.ok()) return `http_${assets.status()}` - const data = (await assets.json()) as AssetListResponse - const asset = data.items.find((a) => a.name === sourceFileName) - if (!asset) return "missing" - return asset.status - }, - { timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, - ) - .toBe("ready") - - // GET /templates auto-creates a default template for new users - const templatesResp = await request.get(`${apiBase}/templates`, { headers }) - expect(templatesResp.status(), await templatesResp.text()).toBe(200) - const templatesData = (await templatesResp.json()) as { - items: Array<{ id: string }> - } - expect(Array.isArray(templatesData.items)).toBe(true) - expect(templatesData.items.length).toBeGreaterThan(0) - const templateId = templatesData.items[0].id - expect(templateId).toBeTruthy() - - // Set auth in localStorage - await page.addInitScript( - ({ token, user }) => { - localStorage.setItem("access_token", token) - localStorage.setItem( - "auth-storage", - JSON.stringify({ - state: { user, isAuthenticated: true }, - version: 0, + // ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ── + await page.route( + (url) => url.pathname.endsWith("/assets") && url.searchParams.get("kind") === "voice", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + id: `asset-voice-${suffix}`, + name: "测试配音.mp3", + file_url: "data:audio/mpeg;base64,", + duration: 10, + file_size: 1024, + kind: "voice", + status: "ready", + }, + ], + total: 1, }), - ) - }, - { - token: loginData.access_token, - user: { - id: registerData.user_id, - user_id: registerData.user_id, - email, - username, - display_name: username, - is_email_verified: true, - email_verified: true, - }, - }, + }), ) - // Navigate to generate page await page.goto("/app/generate") - await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({ - timeout: 20_000, - }) + // ── 页面标题 ───────────────────────────────────────────────── + // GenerateHeader:

智能剪辑

+ // SVG icon 可能干扰 role=heading 的 accessible name,用文本包含兜底 + await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 }) - // 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5) + // ── Step 1:默认随机混剪选中,点下一步 ────────────────────────── + // h3 实际文案: "🎬 选择剪辑模式"(非 "选择模式"),用正则包含匹配 + await expect(page.getByText(/选择剪辑模式/)).toBeVisible() + await expect(page.getByText("随机混剪")).toBeVisible() + await page.getByRole("button", { name: /下一步/ }).click() - // ── Step 1: 素材选择 ── - await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible() - const librarySelect = page.locator("select").first() - await librarySelect.selectOption({ label: libraryName }) - const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName }) - await expect(materialCard).toBeVisible({ timeout: 10_000 }) - await materialCard.click({ position: { x: 15, y: 15 } }) - await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 }) - await page.getByRole("button", { name: "下一步" }).click() + // ── 配音选择弹窗:选第一个配音 → 确认 ───────────────────────── + await expect(page.getByText("🎙️ 选择配音")).toBeVisible({ timeout: 5000 }) + await page.getByText("测试配音.mp3").first().click() + await page.getByRole("button", { name: "确认选择" }).click() + await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible() - // ── 数量弹窗(PreviewCountModal) ── - await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({ - timeout: 5_000, - }) - await page.getByRole("button", { name: "生成 1 个视频" }).click() + // ── Step 2:选择素材 ────────────────────────────────────────── + await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 }) + await page.getByTestId("material-card").first().click() + await page.getByRole("button", { name: /下一步/ }).click() - // ── Step 2: 配音(新注册用户无配音素材,跳过) ── - await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 }) - await page.getByRole("button", { name: "下一步" }).click() - - // ── Step 3: 标题设置 ── - await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 }) - await page.waitForTimeout(2000) - - const titleInput = page.locator(".ant-select-auto-complete input") + // ── Step 3:填写标题 ────────────────────────────────────────── + // (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置) + await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 }) + const titleInput = page.getByPlaceholder("输入或从标题库选择") await expect(titleInput).toBeVisible({ timeout: 5000 }) - await titleInput.fill(`E2E Test ${suffix}`) + await titleInput.fill(`测试随机剪辑 ${suffix}`) + await page.getByRole("button", { name: /下一步/ }).click() - // Step 3 底部是「下一步 →」,点击进入 Step 4(确认生成) - await page.getByRole("button", { name: "下一步" }).click() + // ── Step 4:确认生成 ────────────────────────────────────────── + // (#2024: Step4 不再显示"📋 生成配置"卡片,内容区仅显示进度/错误) + // 等待底部操作栏的「✨ 确认生成视频」按钮可见即可 + const confirmBtn = page.getByRole("button", { name: /确认生成视频/ }) + await expect(confirmBtn).toBeVisible({ timeout: 10000 }) + await expect(confirmBtn).toBeEnabled({ timeout: 5000 }) - // ── Step 4: 确认生成 ── - // 等待实时预览就绪(占位消失) - await page - .getByText("准备预览素材") - .waitFor({ state: "detached", timeout: 30_000 }) - .catch(() => {}) - - // Step 4 底部是「✨ 确认生成视频」 - const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first() - await expect(confirmBtn).toBeVisible({ timeout: 15_000 }) - - // 先挂 API 监听再点击 - const generatePromise = page.waitForResponse( - (response) => { - const url = response.url() - const path = new URL(url).pathname - return response.request().method() === "POST" && path.endsWith("/generation/tasks") - }, - { timeout: 30_000 }, + const createTask = page.waitForResponse( + (r) => r.url().includes("/generation/tasks") && r.request().method() === "POST", + { timeout: 30000 }, ) - await confirmBtn.click() - - // 验证生成 API 被调用 - const genResp = await generatePromise.catch(() => null) - if (!genResp) { - // staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过 - console.log( - "[E2E] Generation API not triggered (preview not ready) — wizard navigation verified", - ) - } else if (genResp.ok()) { - const genData = (await genResp.json()) as { - items: Array<{ id: string; status: string }> - total: number - } - expect(genData.items.length).toBeGreaterThan(0) - - // race:渲染完成 vs 生成失败/超时 - const downloadReady = page - .getByText("视频生成完成") - .isVisible({ timeout: 180_000 }) - .then((v) => (v ? "completed" : null)) - const generationFailed = page - .getByText(/生成失败|重新生成/) - .isVisible({ timeout: 180_000 }) - .then((v) => (v ? "failed" : null)) - - const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout") - - if (outcome === "completed") { - await page.getByRole("button", { name: /下一步:选择封面/ }).click() - await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ - timeout: 30_000, - }) - } else { - console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`) - } - } else { - console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`) - } - - // 验证成品库页面加载 - await page.goto("/app/products") - await expect(page).toHaveURL(/\/app\/products/) - await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 }) - - await page.unrouteAll({ behavior: "ignoreErrors" }) + const taskResp = await createTask + expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy() + const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id + console.log("[random] Generation task created:", taskId) + await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 }) + console.log("[random] Wizard flow completed ✓") }) - test("generation task API creates and lists tasks", async ({ request }) => { - const suffix = Date.now().toString(36) - const email = `e2e-gen-api-${suffix}@example.com` - const username = `e2e_gen_api_${suffix}` + test("narrative mode: select script + mock TTS, create generation task", async ({ + page, + request, + }) => { + test.setTimeout(600_000) + await page.setViewportSize({ width: 1440, height: 1000 }) + const { token, suffix } = await setupFreshUser(request, "narrative") - const register = await request.post(`${apiBase}/auth/register`, { - data: { email, username, password: PASSWORD, display_name: username }, + await page.addInitScript((t: string) => { + window.localStorage.setItem("access_token", t) + window.localStorage.setItem( + "auth-storage", + JSON.stringify({ state: { token: t, user: null } }), + ) + }, token) + await routeBrowserApiToTestApi(page) + + // ── Mock 文案列表、音色、TTS 合成(避免真实合成) ────────────── + const mockScriptId = `script-mock-${suffix}` + const mockVoiceId = `preset-voice-${suffix}` + const mockJobId = `tts-job-${suffix}` + + // 文案列表(ScriptSelectModal 查询 /scripts) + await page.route("**/api/v1/scripts**", (route) => { + const url = new URL(route.request().url()) + if (url.pathname.includes("/extract-from-douyin")) { + route.continue() + return + } + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + id: mockScriptId, + title: "测试带货文案", + content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。", + tags: ["带货"], + title_category: "daihuo", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + total: 1, + page: 1, + page_size: 200, + }), + }) }) - expect(register.status()).toBe(201) - const login = await loginWithRetry(request, email, PASSWORD) - expect(login.status()).toBe(200) - const loginData = (await login.json()) as { access_token: string } - const headers = { Authorization: `Bearer ${loginData.access_token}` } + // 预设音色(TtsVoiceModal 查询 GET /voices/presets) + await page.route("**/api/v1/voices/presets**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + voice_id: mockVoiceId, + name: "晓晓(女声)", + description: "温柔女声", + gender: "female", + language: "zh-CN", + preview_url: null, + tags: ["温柔"], + }, + ], + total: 1, + }), + }), + ) - const project = await request.post(`${apiBase}/projects`, { - headers, - data: { name: `E2E API Proj ${suffix}` }, - }) - expect(project.status()).toBe(200) + // 克隆音色:空列表 + await page.route( + (url) => url.pathname.endsWith("/voice-clones"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [] }), + }), + ) - const tasks = await request.get(`${apiBase}/tasks`, { headers }) - expect(tasks.status()).toBe(200) - const tasksData = await tasks.json() - expect(Array.isArray(tasksData.items)).toBe(true) + // TTS 合成:直接返回 completed 任务 + await page.route("**/api/v1/tts/synthesize", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ job_id: mockJobId, status: "queued" }), + }), + ) + await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + job_id: mockJobId, + status: "completed", + progress: 100, + audio_url: "data:audio/mpeg;base64,", + duration: 5, + }), + }), + ) + await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }), + }), + ) + + await page.goto("/app/generate") + // ── 页面标题 ───────────────────────────────────────────────── + await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 }) + + // ── Step 1:切到叙事剪辑 → 下一步 ──────────────────────────── + await expect(page.getByText(/选择剪辑模式/)).toBeVisible() + await page.getByText("叙事剪辑").click() + await page.getByRole("button", { name: /下一步/ }).click() + + // ── 文案选择弹窗:选第一条 → 确认 ───────────────────────────── + await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 }) + await page.getByText("测试带货文案").first().click() + await page.getByRole("button", { name: "确认选择" }).click() + await expect(page.getByText("📝 选择文案")).not.toBeVisible() + + // ── TTS 音色弹窗:选系统音色 → 合成 ───────────────────────── + await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 }) + await page.getByText("晓晓(女声)").first().click() + await page.getByRole("button", { name: "🎧 合成配音" }).click() + await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 }) + + // ── Step 2:AI 匹配提示卡可见 + 选素材 ──────────────────────── + await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(page.getByText(/AI智能匹配/)).toBeVisible() + await page.getByTestId("material-card").first().click() + await page.getByRole("button", { name: /下一步/ }).click() + + // ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─ + // (#2048: PreviewCountModal 已移除) + await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 }) + const titleInput2 = page.getByPlaceholder("输入或从标题库选择") + await expect(titleInput2).toBeVisible({ timeout: 5000 }) + await titleInput2.fill(`测试叙事剪辑 ${suffix}`) + await page.getByRole("button", { name: /下一步/ }).click() + + // ── Step 4:确认生成 ────────────────────────────────────────── + // (#2024: Step4 不再显示"📋 生成配置"卡片) + const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ }) + await expect(confirmBtn2).toBeVisible({ timeout: 10000 }) + await expect(confirmBtn2).toBeEnabled({ timeout: 5000 }) + + const createTask2 = page.waitForResponse( + (r) => r.url().includes("/generation/tasks") && r.request().method() === "POST", + { timeout: 30000 }, + ) + await confirmBtn2.click() + const taskResp2 = await createTask2 + expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy() + console.log("[narrative] Generation task created:", (await taskResp2.json()).id) + await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 }) + console.log("[narrative] Wizard flow completed ✓") }) }) diff --git a/apps/web/e2e/core-navigation.spec.ts b/apps/web/e2e/core-navigation.spec.ts new file mode 100755 index 000000000..fe392b485 --- /dev/null +++ b/apps/web/e2e/core-navigation.spec.ts @@ -0,0 +1,105 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" + +const PASSWORD = "SmokePass123!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" + +async function routeBrowserApiToTestApi(page: Page) { + if (!apiOrigin) return + await page.route("**/api/v1/**", async (route) => { + const sourceUrl = new URL(route.request().url()) + const response = await route.fetch({ + url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, + }) + await route.fulfill({ response }) + }) +} + +async function loginWithRetry(request: APIRequestContext, email: string, password: string) { + for (let i = 0; i <= 2; i++) { + const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } }) + if (r.status() !== 429) { + expect(r.ok(), `login: ${await r.text()}`).toBeTruthy() + return (await r.json()).access_token as string + } + console.log(`[nav] 429 retry ${i + 1}/2`) + await new Promise((res) => setTimeout(res, 65000)) + } + throw new Error("Login retries exhausted") +} + +/** + * 核心页面导航冒烟:侧边栏主要入口能访问、文案库/配音库页面能正常加载(不出白屏/无致命 js error) + */ +test.describe("Core Navigation", () => { + let authToken: string + + test.beforeAll(async ({ request }) => { + const suffix = Math.random().toString(36).slice(2, 8) + const email = `e2e-nav-${suffix}@example.com` + await request.post(`${apiBase}/auth/register`, { + data: { email, password: PASSWORD, username: `e2e_nav_${suffix}` }, + }) + authToken = await loginWithRetry(request, email, PASSWORD) + const authHeader = { Authorization: `Bearer ${authToken}` } + const proj = await request.post(`${apiBase}/projects`, { + headers: authHeader, + data: { name: `Smoke Nav ${suffix}` }, + }) + if (proj.ok()) { + const projectId = (await proj.json()).id ?? (await proj.json()).project_id + await request.post(`${apiBase}/asset-libraries`, { + headers: authHeader, + data: { project_id: projectId, name: "Nav Lib", kind: "video" }, + }) + } + }) + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.addInitScript((t: string) => { + window.localStorage.setItem("access_token", t) + window.localStorage.setItem( + "auth-storage", + JSON.stringify({ state: { token: t, user: null } }), + ) + }, authToken) + await routeBrowserApiToTestApi(page) + }) + + const navCases = [ + { path: "/app/dashboard", marker: /概览|工作台|最近/i, name: "概览" }, + { path: "/app/generate", marker: /智能剪辑|剪辑/, name: "智能剪辑" }, + { path: "/app/assets", marker: /视频库|素材/, name: "视频库" }, + { path: "/app/scripts", marker: /文案/, name: "文案库" }, + { path: "/app/voices", marker: /配音|我的音色|配音库/, name: "配音库" }, + { path: "/app/products", marker: /成品|作品/, name: "成品库" }, + { path: "/app/history", marker: /历史|任务/, name: "任务历史" }, + { path: "/app/tasks", marker: /任务中心|任务列表/, name: "任务中心" }, + { path: "/app/points", marker: /积分|我的积分/, name: "积分中心" }, + ] + + for (const c of navCases) { + test(`visit ${c.name} (${c.path}) loads without fatal pageerror`, async ({ page }) => { + const errors: Error[] = [] + page.on("pageerror", (e) => errors.push(e)) + await page.goto(c.path) + await expect(page.locator("body")).not.toBeEmpty({ timeout: 20000 }) + // 过滤掉常见第三方/非致命错误 + const fatal = errors.filter( + (e) => + !/ResizeObserver|Loading chunk|network error|Failed to fetch|chunkLoadError/i.test( + e.message, + ), + ) + expect(fatal, `${c.name} pageerrors: ${fatal.map((e) => e.message).join("; ")}`).toHaveLength( + 0, + ) + await expect( + page.getByText(c.marker).first(), + `${c.name} should show relevant text`, + ).toBeVisible({ timeout: 15000 }) + console.log(`[nav] ${c.name} loaded ✓`) + }) + } +}) diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index 9c036632d..16ed06549 100755 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -161,7 +161,7 @@ test.describe("Core media upload flow", () => { const asset = data.items.find((item) => item.name === "e2e-sample.mp4") return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing" }, - { timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, + { timeout: 90_000, intervals: [3_000, 5_000, 10_000] }, ) .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/) diff --git a/apps/web/index.html b/apps/web/index.html index 8805a8961..e793c168c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,13 @@ + + + + 小虾 SaaS - 自动化视频剪辑平台 diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index dcaf42a7b..103e4e845 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -12,6 +12,8 @@ "@tanstack/react-query": "^5.45.0", "antd": "^5.18.0", "axios": "^1.7.2", + "classnames": "^2.5.1", + "dayjs": "^1.11.23", "mp4box": "^2.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3005,9 +3007,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.21", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", - "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { diff --git a/apps/web/package.json b/apps/web/package.json index 01c638ec9..651fa5a18 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,8 @@ "@tanstack/react-query": "^5.45.0", "antd": "^5.18.0", "axios": "^1.7.2", + "classnames": "^2.5.1", + "dayjs": "^1.11.23", "mp4box": "^2.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index ff790131f..ccf2735a0 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -18,7 +18,16 @@ importers: version: 5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) axios: specifier: ^1.7.2 - version: 1.18.1 + version: 1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) + classnames: + specifier: ^2.5.1 + version: 2.5.1 + dayjs: + specifier: ^1.11.23 + version: 1.11.23 + mp4box: + specifier: ^2.4.1 + version: 2.4.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -43,7 +52,7 @@ importers: version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) "@testing-library/user-event": specifier: ^14.5.2 - version: 14.6.1(@testing-library/dom@10.4.1) + version: 14.6.7(@testing-library/dom@10.4.1) "@types/node": specifier: ^20.14.9 version: 20.19.43 @@ -55,34 +64,34 @@ importers: version: 18.3.7(@types/react@18.3.31) "@typescript-eslint/eslint-plugin": specifier: ^7.13.1 - version: 7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3) + version: 7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) "@typescript-eslint/parser": specifier: ^7.13.1 - version: 7.13.1(eslint@8.57.0)(typescript@5.5.3) + version: 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) "@vitejs/plugin-react": specifier: ^4.3.1 - version: 4.3.1(vite@5.3.1(@types/node@20.19.43)) + version: 4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43)) "@vitest/coverage-v8": specifier: ^1.6.1 - version: 1.6.1(vitest@1.6.0) + version: 1.6.1(supports-color@7.2.0)(vitest@1.6.0) "@vitest/ui": specifier: ^1.6.0 version: 1.6.0(vitest@1.6.0) eslint: specifier: ^8.57.0 - version: 8.57.0 + version: 8.57.0(supports-color@7.2.0) eslint-config-prettier: specifier: ^9.1.2 - version: 9.1.2(eslint@8.57.0) + version: 9.1.2(eslint@8.57.0(supports-color@7.2.0)) eslint-plugin-react-hooks: specifier: ^4.6.2 - version: 4.6.2(eslint@8.57.0) + version: 4.6.2(eslint@8.57.0(supports-color@7.2.0)) eslint-plugin-react-refresh: specifier: ^0.4.7 - version: 0.4.26(eslint@8.57.0) + version: 0.4.26(eslint@8.57.0(supports-color@7.2.0)) jsdom: specifier: ^24.1.0 - version: 24.1.0 + version: 24.1.0(supports-color@7.2.0) prettier: specifier: ^3.9.5 version: 3.9.5 @@ -94,7 +103,7 @@ importers: version: 5.3.1(@types/node@20.19.43) vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0) + version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0) packages: "@adobe/css-tools@4.5.0": @@ -1127,10 +1136,10 @@ packages: "@types/react-dom": optional: true - "@testing-library/user-event@14.6.1": + "@testing-library/user-event@14.6.7": resolution: { - integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==, + integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==, } engines: { node: ">=12", npm: ">=6" } peerDependencies: @@ -1672,10 +1681,10 @@ packages: } engines: { node: ">=18" } - dayjs@1.11.21: + dayjs@1.11.23: resolution: { - integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==, + integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==, } debug@4.4.3: @@ -2546,6 +2555,13 @@ packages: integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==, } + mp4box@2.4.1: + resolution: + { + integrity: sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==, + } + engines: { node: ">=20.8.1" } + mrmime@2.0.1: resolution: { @@ -3861,20 +3877,20 @@ snapshots: "@babel/compat-data@7.29.7": {} - "@babel/core@7.29.7": + "@babel/core@7.29.7(supports-color@7.2.0)": dependencies: "@babel/code-frame": 7.29.7 "@babel/generator": 7.29.7 "@babel/helper-compilation-targets": 7.29.7 - "@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7) + "@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) "@babel/helpers": 7.29.7 "@babel/parser": 7.29.7 "@babel/template": 7.29.7 - "@babel/traverse": 7.29.7 + "@babel/traverse": 7.29.7(supports-color@7.2.0) "@babel/types": 7.29.7 "@jridgewell/remapping": 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3899,19 +3915,19 @@ snapshots: "@babel/helper-globals@7.29.7": {} - "@babel/helper-module-imports@7.29.7": + "@babel/helper-module-imports@7.29.7(supports-color@7.2.0)": dependencies: - "@babel/traverse": 7.29.7 + "@babel/traverse": 7.29.7(supports-color@7.2.0) "@babel/types": 7.29.7 transitivePeerDependencies: - supports-color - "@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)": + "@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)": dependencies: - "@babel/core": 7.29.7 - "@babel/helper-module-imports": 7.29.7 + "@babel/core": 7.29.7(supports-color@7.2.0) + "@babel/helper-module-imports": 7.29.7(supports-color@7.2.0) "@babel/helper-validator-identifier": 7.29.7 - "@babel/traverse": 7.29.7 + "@babel/traverse": 7.29.7(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -3932,14 +3948,14 @@ snapshots: dependencies: "@babel/types": 7.29.7 - "@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)": + "@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))": dependencies: - "@babel/core": 7.29.7 + "@babel/core": 7.29.7(supports-color@7.2.0) "@babel/helper-plugin-utils": 7.29.7 - "@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)": + "@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))": dependencies: - "@babel/core": 7.29.7 + "@babel/core": 7.29.7(supports-color@7.2.0) "@babel/helper-plugin-utils": 7.29.7 "@babel/runtime@7.29.7": {} @@ -3950,7 +3966,7 @@ snapshots: "@babel/parser": 7.29.7 "@babel/types": 7.29.7 - "@babel/traverse@7.29.7": + "@babel/traverse@7.29.7(supports-color@7.2.0)": dependencies: "@babel/code-frame": 7.29.7 "@babel/generator": 7.29.7 @@ -3958,7 +3974,7 @@ snapshots: "@babel/parser": 7.29.7 "@babel/template": 7.29.7 "@babel/types": 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4044,17 +4060,17 @@ snapshots: "@esbuild/win32-x64@0.21.3": optional: true - "@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)": + "@eslint-community/eslint-utils@4.9.1(eslint@8.57.0(supports-color@7.2.0))": dependencies: - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 "@eslint-community/regexpp@4.12.2": {} - "@eslint/eslintrc@2.1.4": + "@eslint/eslintrc@2.1.4(supports-color@7.2.0)": dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) espree: 9.6.1 globals: 13.19.0 ignore: 5.2.0 @@ -4067,10 +4083,10 @@ snapshots: "@eslint/js@8.57.0": {} - "@humanwhocodes/config-array@0.11.14": + "@humanwhocodes/config-array@0.11.14(supports-color@7.2.0)": dependencies: "@humanwhocodes/object-schema": 2.0.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4360,7 +4376,7 @@ snapshots: "@types/react": 18.3.31 "@types/react-dom": 18.3.7(@types/react@18.3.31) - "@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)": + "@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)": dependencies: "@testing-library/dom": 10.4.1 @@ -4420,15 +4436,15 @@ snapshots: dependencies: "@types/yargs-parser": 21.0.3 - "@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)": + "@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": dependencies: "@eslint-community/regexpp": 4.12.2 - "@typescript-eslint/parser": 7.13.1(eslint@8.57.0)(typescript@5.5.3) + "@typescript-eslint/parser": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) "@typescript-eslint/scope-manager": 7.13.1 - "@typescript-eslint/type-utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3) - "@typescript-eslint/utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3) + "@typescript-eslint/type-utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + "@typescript-eslint/utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) "@typescript-eslint/visitor-keys": 7.13.1 - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 @@ -4438,14 +4454,14 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3)": + "@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": dependencies: "@typescript-eslint/scope-manager": 7.13.1 "@typescript-eslint/types": 7.13.1 - "@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3) + "@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3) "@typescript-eslint/visitor-keys": 7.13.1 - debug: 4.4.3 - eslint: 8.57.0 + debug: 4.4.3(supports-color@7.2.0) + eslint: 8.57.0(supports-color@7.2.0) optionalDependencies: typescript: 5.5.3 transitivePeerDependencies: @@ -4456,12 +4472,12 @@ snapshots: "@typescript-eslint/types": 7.13.1 "@typescript-eslint/visitor-keys": 7.13.1 - "@typescript-eslint/type-utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)": + "@typescript-eslint/type-utils@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": dependencies: - "@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3) - "@typescript-eslint/utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3) - debug: 4.4.3 - eslint: 8.57.0 + "@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3) + "@typescript-eslint/utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 8.57.0(supports-color@7.2.0) ts-api-utils: 1.3.0(typescript@5.5.3) optionalDependencies: typescript: 5.5.3 @@ -4470,11 +4486,11 @@ snapshots: "@typescript-eslint/types@7.13.1": {} - "@typescript-eslint/typescript-estree@7.13.1(typescript@5.5.3)": + "@typescript-eslint/typescript-estree@7.13.1(supports-color@7.2.0)(typescript@5.5.3)": dependencies: "@typescript-eslint/types": 7.13.1 "@typescript-eslint/visitor-keys": 7.13.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.9 @@ -4485,13 +4501,13 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)": + "@typescript-eslint/utils@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)": dependencies: - "@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0) + "@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0)) "@typescript-eslint/scope-manager": 7.13.1 "@typescript-eslint/types": 7.13.1 - "@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3) - eslint: 8.57.0 + "@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3) + eslint: 8.57.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color - typescript @@ -4503,25 +4519,25 @@ snapshots: "@ungap/structured-clone@1.3.3": {} - "@vitejs/plugin-react@4.3.1(vite@5.3.1(@types/node@20.19.43))": + "@vitejs/plugin-react@4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))": dependencies: - "@babel/core": 7.29.7 - "@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7) - "@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7) + "@babel/core": 7.29.7(supports-color@7.2.0) + "@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + "@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) "@types/babel__core": 7.20.5 react-refresh: 0.14.2 vite: 5.3.1(@types/node@20.19.43) transitivePeerDependencies: - supports-color - "@vitest/coverage-v8@1.6.1(vitest@1.6.0)": + "@vitest/coverage-v8@1.6.1(supports-color@7.2.0)(vitest@1.6.0)": dependencies: "@ampproject/remapping": 2.3.0 "@bcoe/v8-coverage": 0.2.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 + istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0) istanbul-reports: 3.2.0 magic-string: 0.30.5 magicast: 0.3.3 @@ -4529,7 +4545,7 @@ snapshots: std-env: 3.5.0 strip-literal: 2.1.1 test-exclude: 6.0.0 - vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0) + vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4564,7 +4580,7 @@ snapshots: pathe: 1.1.2 picocolors: 1.1.1 sirv: 2.0.4 - vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0) + vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0) "@vitest/utils@1.6.0": dependencies: @@ -4583,21 +4599,21 @@ snapshots: acorn@8.17.0: {} - agent-base@6.0.0: + agent-base@6.0.0(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - agent-base@7.0.2: + agent-base@7.0.2(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - agent-base@7.1.0: + agent-base@7.1.0(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4632,7 +4648,7 @@ snapshots: "@rc-component/trigger": 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 copy-to-clipboard: 3.3.3 - dayjs: 1.11.21 + dayjs: 1.11.23 rc-cascader: 3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-checkbox: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-collapse: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4648,7 +4664,7 @@ snapshots: rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - rc-picker: 4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-picker: 4.11.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4688,11 +4704,11 @@ snapshots: asynckit@0.4.0: {} - axios@1.18.1: + axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) form-data: 4.0.6 - https-proxy-agent: 5.0.1 + https-proxy-agent: 5.0.1(supports-color@7.2.0) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -4796,11 +4812,13 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.0.0 - dayjs@1.11.21: {} + dayjs@1.11.23: {} - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decimal.js@10.6.0: {} @@ -4883,17 +4901,17 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@9.1.2(eslint@8.57.0): + eslint-config-prettier@9.1.2(eslint@8.57.0(supports-color@7.2.0)): dependencies: - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) - eslint-plugin-react-hooks@4.6.2(eslint@8.57.0): + eslint-plugin-react-hooks@4.6.2(eslint@8.57.0(supports-color@7.2.0)): dependencies: - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) - eslint-plugin-react-refresh@0.4.26(eslint@8.57.0): + eslint-plugin-react-refresh@0.4.26(eslint@8.57.0(supports-color@7.2.0)): dependencies: - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) eslint-scope@7.2.2: dependencies: @@ -4902,20 +4920,20 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint@8.57.0: + eslint@8.57.0(supports-color@7.2.0): dependencies: - "@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0) + "@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0)) "@eslint-community/regexpp": 4.12.2 - "@eslint/eslintrc": 2.1.4 + "@eslint/eslintrc": 2.1.4(supports-color@7.2.0) "@eslint/js": 8.57.0 - "@humanwhocodes/config-array": 0.11.14 + "@humanwhocodes/config-array": 0.11.14(supports-color@7.2.0) "@humanwhocodes/module-importer": 1.0.1 "@nodelib/fs.walk": 1.2.8 "@ungap/structured-clone": 1.3.3 ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -5019,7 +5037,9 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): + optionalDependencies: + debug: 4.4.3(supports-color@7.2.0) form-data@4.0.6: dependencies: @@ -5115,24 +5135,24 @@ snapshots: html-escaper@2.0.2: {} - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: - agent-base: 7.1.0 - debug: 4.4.3 + agent-base: 7.1.0(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@7.2.0): dependencies: - agent-base: 6.0.0 - debug: 4.4.3 + agent-base: 6.0.0(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.4: + https-proxy-agent@7.0.4(supports-color@7.2.0): dependencies: - agent-base: 7.0.2 - debug: 4.4.3 + agent-base: 7.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -5186,10 +5206,10 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: + istanbul-lib-source-maps@5.0.6(supports-color@7.2.0): dependencies: "@jridgewell/trace-mapping": 0.3.31 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -5209,15 +5229,15 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@24.1.0: + jsdom@24.1.0(supports-color@7.2.0): dependencies: cssstyle: 4.0.1 data-urls: 5.0.0 decimal.js: 10.6.0 form-data: 4.0.6 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.4 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.4(supports-color@7.2.0) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.24 parse5: 7.1.2 @@ -5331,6 +5351,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + mp4box@2.4.1: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -5611,7 +5633,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - rc-picker@4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + rc-picker@4.11.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: "@babel/runtime": 7.29.7 "@rc-component/trigger": 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5622,7 +5644,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - dayjs: 1.11.21 + dayjs: 1.11.23 rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -6008,10 +6030,10 @@ snapshots: dependencies: react: 18.3.1 - vite-node@1.6.0(@types/node@20.19.43): + vite-node@1.6.0(@types/node@20.19.43)(supports-color@7.2.0): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) pathe: 1.1.2 picocolors: 1.1.1 vite: 5.3.1(@types/node@20.19.43) @@ -6034,7 +6056,7 @@ snapshots: "@types/node": 20.19.43 fsevents: 2.3.3 - vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0): + vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0): dependencies: "@vitest/expect": 1.6.0 "@vitest/runner": 1.6.0 @@ -6043,7 +6065,7 @@ snapshots: "@vitest/utils": 1.6.0 acorn-walk: 8.3.5 chai: 4.3.10 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.5 @@ -6054,12 +6076,12 @@ snapshots: tinybench: 2.5.1 tinypool: 0.8.3 vite: 5.3.1(@types/node@20.19.43) - vite-node: 1.6.0(@types/node@20.19.43) + vite-node: 1.6.0(@types/node@20.19.43)(supports-color@7.2.0) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 20.19.43 "@vitest/ui": 1.6.0(vitest@1.6.0) - jsdom: 24.1.0 + jsdom: 24.1.0(supports-color@7.2.0) transitivePeerDependencies: - less - lightningcss diff --git a/apps/web/public/title-templates/portrait1.jpg b/apps/web/public/title-templates/portrait1.jpg new file mode 100644 index 000000000..10ff584b5 Binary files /dev/null and b/apps/web/public/title-templates/portrait1.jpg differ diff --git a/apps/web/public/title-templates/portrait2.jpg b/apps/web/public/title-templates/portrait2.jpg new file mode 100644 index 000000000..07838a010 Binary files /dev/null and b/apps/web/public/title-templates/portrait2.jpg differ diff --git a/apps/web/public/title-templates/scene1.jpg b/apps/web/public/title-templates/scene1.jpg new file mode 100644 index 000000000..cba869f7b Binary files /dev/null and b/apps/web/public/title-templates/scene1.jpg differ diff --git a/apps/web/src/api/assets/types.ts b/apps/web/src/api/assets/types.ts index 77ec261fc..e8419d285 100644 --- a/apps/web/src/api/assets/types.ts +++ b/apps/web/src/api/assets/types.ts @@ -150,6 +150,8 @@ export interface DirectUploadPrepareResult { * 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。 */ skip_transfer?: boolean + /** duplicated=true 时后端返回已存在素材的公网 URL,前端直接用而不必再调 complete */ + url?: string } /** 直传完成确认返回 */ diff --git a/apps/web/src/api/assets/upload.ts b/apps/web/src/api/assets/upload.ts index 490b08df7..2f22673c4 100644 --- a/apps/web/src/api/assets/upload.ts +++ b/apps/web/src/api/assets/upload.ts @@ -3,9 +3,24 @@ */ import apiClient from "../client" import { getOrCreateDefaultProject } from "../projects" +import { ensureDefaultLibrary } from "./libraries" import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types" import { computeFileHash, makeClientUploadId } from "./uploadDedup" +/** 根据 File.type 推断素材库 kind(image/video/voice);无法推断时默认 image */ +function inferKindFromFile(file: File): "image" | "video" | "voice" { + const t = (file.type || "").toLowerCase() + if (t.startsWith("image/")) return "image" + if (t.startsWith("video/")) return "video" + if (t.startsWith("audio/")) return "voice" + // 兜底:按扩展名再判一次 + const name = file.name.toLowerCase() + if (/\.(png|jpe?g|gif|webp|bmp|svg|avif)$/.test(name)) return "image" + if (/\.(mp4|mov|webm|avi|mkv|flv|wmv|m4v)$/.test(name)) return "video" + if (/\.(mp3|wav|m4a|aac|ogg|flac|opus|webm)$/.test(name)) return "voice" + return "image" +} + /** 预签名直传准备 */ export const prepareDirectUpload = async (data: { project_id: string @@ -108,6 +123,8 @@ const putToOSS = ( /** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */ export interface DirectUploadHandle { + /** 实际使用的素材库(内部解析出来,便于调用方做后续 UI/缓存操作) */ + library: { id: string; kind: "image" | "video" | "voice" } /** prepare 返回(含可能的预建 asset_id) */ prepared: DirectUploadPrepareResult /** 直传 OSS(可重复调用用于重试) */ @@ -119,10 +136,17 @@ export interface DirectUploadHandle { /** * 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset), * 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。 + * + * 修复 P0 404:library_id 改为可选;未传时自动根据文件类型在默认项目下确保对应素材库存在, + * 避免调用方从「全部素材库列表」里挑一个 library_id、但与默认项目 project_id 不匹配, + * 导致后端返回 "Asset library not found" 404。 */ export const prepareDirectUploadHandle = async (data: { file: File - library_id: string + /** 素材库 ID;未传时按文件类型自动在默认项目下 ensure-default */ + library_id?: string + /** 显式指定素材库 kind;未传时按 MIME/扩展名推断 */ + kind?: "image" | "video" | "voice" /** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */ fileHash?: string /** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */ @@ -138,9 +162,17 @@ export const prepareDirectUploadHandle = async (data: { throw new Error(`初始化默认项目失败,无法开始上传:${reason}`) } + // 解析 library_id:调用方传了就用,没传就按 kind 自动 ensure-default + let resolvedLibraryId = data.library_id + const resolvedKind = data.kind ?? inferKindFromFile(data.file) + if (!resolvedLibraryId) { + const lib = await ensureDefaultLibrary({ project_id: project.id, kind: resolvedKind }) + resolvedLibraryId = lib.id + } + const prepared = await prepareDirectUpload({ project_id: project.id, - library_id: data.library_id, + library_id: resolvedLibraryId, filename: data.file.name, content_type: data.file.type || "application/octet-stream", file_size: data.file.size, @@ -149,12 +181,13 @@ export const prepareDirectUploadHandle = async (data: { }) return { + library: { id: resolvedLibraryId, kind: resolvedKind }, prepared, transfer: (onProgress) => putToOSS(prepared, data.file, onProgress), complete: () => completeDirectUpload({ project_id: project.id, - library_id: data.library_id, + library_id: resolvedLibraryId, storage_key: prepared.storage_key, file_hash: data.fileHash, client_upload_id: data.clientUploadId, @@ -164,10 +197,17 @@ export const prepareDirectUploadHandle = async (data: { } } -/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */ +/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete + * + * P0 404 修复:library_id 可选;不传时内部按文件类型自动匹配正确项目下的素材库, + * 保证 project_id 与 library_id 必然一致。 + */ export const uploadAssetDirect = async (data: { file: File - library_id: string + /** 素材库 ID;可选,不传按文件类型自动解析默认项目下的对应素材库(推荐用法) */ + library_id?: string + /** 显式指定素材库 kind;未传时按文件 MIME/扩展名推断 */ + kind?: "image" | "video" | "voice" onProgress?: (percent: number) => void /** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */ fileHash?: string @@ -180,6 +220,7 @@ export const uploadAssetDirect = async (data: { const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id, + kind: data.kind, fileHash, clientUploadId, }) @@ -188,7 +229,7 @@ export const uploadAssetDirect = async (data: { return { storage_key: handle.prepared.storage_key, ingest_job_id: "", - url: "", + url: handle.prepared.url || "", duplicated: true, asset_id: handle.prepared.asset_id, } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 1d3ce3170..caeb38bba 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -11,7 +11,7 @@ import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh // 创建 Axios 实例 const apiClient = axios.create({ baseURL: "/api/v1", - timeout: 10000, + timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时 headers: { "Content-Type": "application/json", }, diff --git a/apps/web/src/api/cover-templates.ts b/apps/web/src/api/cover-templates.ts index 69d2f4d7e..dd34da733 100644 --- a/apps/web/src/api/cover-templates.ts +++ b/apps/web/src/api/cover-templates.ts @@ -3,7 +3,7 @@ * 后端路由: /api/v1/cover-templates */ import apiClient from "./client" -import type { CoverTemplate } from "@/pages/generate/types/cover" +import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover" export interface CoverTemplateListResponse { items: CoverTemplate[] @@ -12,14 +12,7 @@ export interface CoverTemplateListResponse { export interface CoverTemplateCreateRequest { name: string - config?: { - background_enabled?: boolean - background_color?: string - portrait_enabled?: boolean - title_text?: string - subtitle_text?: string - mask_enabled?: boolean - } + config?: CoverEditorConfig } export type CoverTemplateUpdateRequest = Partial diff --git a/apps/web/src/api/generation/cover.ts b/apps/web/src/api/generation/cover.ts index 8bf308d1e..948d4b3df 100644 --- a/apps/web/src/api/generation/cover.ts +++ b/apps/web/src/api/generation/cover.ts @@ -7,8 +7,19 @@ export interface GenerateCoverTitleConfig { font_color?: string position?: string bold?: boolean - stroke?: boolean - shadow?: boolean + italic?: boolean + stroke?: boolean | { enabled?: boolean; width?: number; color?: string } + shadow?: + | boolean + | { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string } + line_height?: number + margin_top?: number + max_chars_per_line?: number + background?: { enabled?: boolean; color?: string; padding?: number; radius?: number } + line_overrides?: Array> + cover_title_config?: Record + pos_x?: number + pos_y?: number } export interface GenerateCoverRequest { @@ -40,12 +51,18 @@ export interface GenerateCoverResponse { /** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */ export async function generateCover( - templateId: string, + templateId: string | undefined | null, data: GenerateCoverRequest, ): Promise { + // templateId 为空时不传该参数,让后端使用默认模板配置 + // (前端此前用 "default" 作为占位符,该 id 不存在于后端模板库会 404) + const params: Record = {} + if (templateId && templateId !== "default") { + params.template_id = templateId + } const response = await apiClient.post("/generation/generate-cover", data, { timeout: 300000, - params: { template_id: templateId }, + params, }) return response.data } diff --git a/apps/web/src/api/generation/finalize.ts b/apps/web/src/api/generation/finalize.ts new file mode 100644 index 000000000..0d347dcbb --- /dev/null +++ b/apps/web/src/api/generation/finalize.ts @@ -0,0 +1,37 @@ +import apiClient from "../client" + +/** #2024 Step5 「完成」入库 —— 将 awaiting_cover 任务正式写入成品库 */ +export interface FinalizeGenerationRequest { + /** 用户选定的封面图片 URL;为空则使用任务默认封面(自动截帧/智能封面) */ + cover_url?: string + /** 用户自定义成片标题,非空时覆盖 rendered_output.name */ + custom_title?: string +} + +export interface FinalizeGenerationResponse { + video_id: string + project_id: string + name: string + file_size: number + duration: number + thumbnail_url: string + cover_url: string + file_url: string + width: number + height: number + fps: number + /** success=新建成功;already_finalized=幂等返回已有记录 */ + status: string + is_duplicate: boolean +} + +export const finalizeGeneration = async ( + taskId: string, + params: FinalizeGenerationRequest = {}, +): Promise => { + const response = await apiClient.post( + `/generation/tasks/${taskId}/finalize`, + params, + ) + return response.data +} diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 685d7fea1..a69fa03aa 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -3,7 +3,8 @@ */ /** 任务状态 */ -export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled" +export type TaskStatus = + "pending" | "waiting" | "running" | "awaiting_cover" | "completed" | "failed" | "cancelled" /** 任务类型 */ export type TaskType = "ingest" | "generation" | string @@ -81,7 +82,7 @@ export interface CreateGenerationTaskRequest { tts_voice_source?: "preset" | "clone" /** #1970:智能降重开关(默认 true) */ dedup_enabled?: boolean - /** 标题烧录配置 */ + /** 标题烧录配置(#2001 扩展:描边/阴影参数/行距/自动换行/背景/逐行/封面) */ title_config?: { text?: string font?: string @@ -89,8 +90,34 @@ export interface CreateGenerationTaskRequest { font_color?: string position?: string bold?: boolean - stroke?: boolean - shadow?: boolean + italic?: boolean + stroke?: boolean | { enabled?: boolean; width?: number; color?: string } + shadow?: + | boolean + | { + enabled?: boolean + offset_x?: number + offset_y?: number + blur?: number + color?: string + } + line_height?: number + margin_top?: number + max_chars_per_line?: number + background?: { enabled?: boolean; color?: string; padding?: number; radius?: number } + line_overrides?: Array<{ + line_index: number + text?: string + size?: number + color?: string + bold?: boolean + italic?: boolean + stroke?: boolean + highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }> + }> + cover_title_config?: Record + pos_x?: number + pos_y?: number } /** 关联的草稿 ID(编辑流程数据链路用) */ source_edit_plan_id?: string diff --git a/apps/web/src/api/template-editor/editPlans.ts b/apps/web/src/api/template-editor/editPlans.ts index c4d017c16..3533f9e4b 100644 --- a/apps/web/src/api/template-editor/editPlans.ts +++ b/apps/web/src/api/template-editor/editPlans.ts @@ -6,17 +6,20 @@ import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types" /** 获取单个模板草稿 */ export async function getEditPlan(templateId: string): Promise { - const response = await apiClient.get(`/templates/${templateId}/editor`) + const response = await apiClient.get(`/templates/${templateId}/editor`, { timeout: 30_000 }) return response.data } -/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */ +/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消;超时 60s 防止大 config 写入失败) */ export async function updateEditPlan( templateId: string, data: UpdateEditPlanRequest, signal?: AbortSignal, ): Promise { - const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal }) + const response = await apiClient.put(`/templates/${templateId}/editor`, data, { + signal, + timeout: 60_000, + }) return response.data } diff --git a/apps/web/src/api/template-editor/types.ts b/apps/web/src/api/template-editor/types.ts index 40c806a21..9d80f144f 100644 --- a/apps/web/src/api/template-editor/types.ts +++ b/apps/web/src/api/template-editor/types.ts @@ -54,6 +54,8 @@ export interface SegmentTtsConfig { pitch: number volume: number subtitle_sync: boolean + /** 配音风格预设(natural/excited/professional/sweet/news/livestream) */ + style?: string } /** 片段裁剪配置 */ diff --git a/apps/web/src/api/tts/index.ts b/apps/web/src/api/tts/index.ts index 0c006f6d0..4817839c0 100644 --- a/apps/web/src/api/tts/index.ts +++ b/apps/web/src/api/tts/index.ts @@ -18,6 +18,9 @@ export type { TTSPreviewResponse, } from "./types" +export type { TtsStyle, TtsStyleOption } from "./styles" +export { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, getTtsStyle } from "./styles" + // API 函数 export { synthesizeSpeech, diff --git a/apps/web/src/api/tts/styles.ts b/apps/web/src/api/tts/styles.ts new file mode 100644 index 000000000..9af9c6190 --- /dev/null +++ b/apps/web/src/api/tts/styles.ts @@ -0,0 +1,71 @@ +/** + * TTS 配音风格预设(情感/语气风格) + * - key:传给后端的 style 标识,便于后端按策略合成 + * - 未传 style 时后端默认自然亲切 + * + * 注:与原 emotion(CosyVoice 7 种基础情绪枚举)解耦; + * style 是更高层的"说话风格预设",后端可能映射到 emotion + speed + prompt 组合。 + */ + +export interface TtsStyleOption { + /** 传给后端的风格标识 */ + value: string + /** 展示名 */ + label: string + /** emoji 图标 */ + emoji: string + /** 给用户/后端的风格描述(prompt 风格) */ + description: string +} + +export const TTS_STYLE_OPTIONS: readonly TtsStyleOption[] = [ + { + value: "natural", + label: "自然亲切", + emoji: "😊", + description: "亲切自然,像朋友聊天", + }, + { + value: "excited", + label: "激动兴奋", + emoji: "🤩", + description: "激动兴奋,语速稍快,充满活力", + }, + { + value: "professional", + label: "沉稳专业", + emoji: "🧑‍💼", + description: "沉稳专业,语速适中,正式可靠", + }, + { + value: "sweet", + label: "温柔甜美", + emoji: "🌸", + description: "温柔甜美,语速轻柔", + }, + { + value: "news", + label: "新闻播报", + emoji: "📰", + description: "字正腔圆,严肃正式", + }, + { + value: "livestream", + label: "直播带货", + emoji: "🎤", + description: "热情有感染力,有节奏感", + }, +] as const + +export type TtsStyle = (typeof TTS_STYLE_OPTIONS)[number]["value"] + +/** 默认风格:自然亲切 */ +export const DEFAULT_TTS_STYLE: TtsStyle = "natural" + +/** 根据 value 查找风格选项(容错:找不到回退 natural) */ +export function getTtsStyle(value: string | null | undefined): TtsStyleOption { + return ( + (TTS_STYLE_OPTIONS as readonly TtsStyleOption[]).find((o) => o.value === value) ?? + (TTS_STYLE_OPTIONS as readonly TtsStyleOption[])[0] + ) +} diff --git a/apps/web/src/api/tts/types.ts b/apps/web/src/api/tts/types.ts index 9bfec5498..6b394d4fd 100644 --- a/apps/web/src/api/tts/types.ts +++ b/apps/web/src/api/tts/types.ts @@ -17,6 +17,8 @@ export interface TTSSynthesizeRequest { output_name?: string language?: string emotion?: string + /** 配音风格预设(自然亲切/激动兴奋/沉稳专业/温柔甜美/新闻播报/直播带货),不传默认 natural */ + style?: string speed?: number voice_model?: string voice_clone_profile_id?: string @@ -106,6 +108,8 @@ export interface TTSPreviewRequest { pitch?: number language?: string emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签) + /** 配音风格预设 */ + style?: string } /** TTS 试听响应 */ diff --git a/apps/web/src/api/viral-video/index.ts b/apps/web/src/api/viral-video/index.ts new file mode 100644 index 000000000..b6d3b20a9 --- /dev/null +++ b/apps/web/src/api/viral-video/index.ts @@ -0,0 +1,47 @@ +import apiClient from "@/api/client" +import type { + GenerateViralVideoRequest, + HistoryResponse, + StyleTemplate, + ViralVideoJob, +} from "./types" + +/** 创建爆款视频任务 */ +export function generateViralVideo(payload: GenerateViralVideoRequest) { + return apiClient.post("/viral-video/generate", payload).then((r) => r.data) +} + +/** 查询单个任务 */ +export function getViralVideoJob(id: string) { + return apiClient.get(`/viral-video/${id}`).then((r) => r.data) +} + +/** 用户确认/修改 AI 理解的意图后继续 */ +export function confirmViralVideoIntent( + id: string, + payload: { confirmed_copy?: string; edits?: Record }, +) { + return apiClient + .post(`/viral-video/${id}/confirm-intent`, payload) + .then((r) => r.data) +} + +/** 重试失败任务 */ +export function retryViralVideo(id: string) { + return apiClient.post(`/viral-video/${id}/retry`).then((r) => r.data) +} + +/** 历史记录(分页) */ +export function getViralVideoHistory(params?: { page?: number; page_size?: number }) { + return apiClient.get("/viral-video/history", { params }).then((r) => r.data) +} + +/** 预设风格模板 */ +export function getViralStyleTemplates() { + return apiClient.get("/viral-video/style-templates").then((r) => r.data) +} + +/** 上传参考视频后触发风格分析(返回带 style_guide 的任务详情) */ +export function analyzeViralStyle(id: string) { + return apiClient.post(`/viral-video/${id}/analyze-style`).then((r) => r.data) +} diff --git a/apps/web/src/api/viral-video/types.ts b/apps/web/src/api/viral-video/types.ts new file mode 100644 index 000000000..01d7c894e --- /dev/null +++ b/apps/web/src/api/viral-video/types.ts @@ -0,0 +1,116 @@ +export type FusionLevel = "ai_full" | "ai_polish" | "user_primary" +export const FUSION_LEVELS: { value: FusionLevel; label: string; desc: string }[] = [ + { value: "ai_full", label: "AI 全写", desc: "给我方向,全由AI创作" }, + { value: "ai_polish", label: "AI润色", desc: "我写草稿,AI帮我润色" }, + { value: "user_primary", label: "按我写的来", desc: "几乎不改我的文案" }, +] + +export type StyleStrength = "light" | "medium" | "strict" +export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [ + { value: "light", label: "轻度借鉴" }, + { value: "medium", label: "中度参考" }, + { value: "strict", label: "深度模仿" }, +] + +export type ViralVideoStatus = + "pending" | "running" | "wait_user_confirm" | "completed" | "failed" | "cancelled" + +/** + * 后端流水线阶段字符串。前端不展示逐阶段进度列表,仅保留类型 + * 用于轮询时判断当前在哪个大阶段(分析中 vs 视频生成中)以选择轮询间隔/文案。 + */ +export type ViralVideoStage = + | "image_analysis" + | "video_analysis" + | "intent_parsing" + | "copy_fusion" + | "storyboard" + | "review" + | "tts" + | "bgm_select" + | "rendering" + | "musetalk" + | "uploading" + +/** 分析类阶段(image_analysis / video_analysis / intent_parsing):属于「开始分析」阶段 */ +const ANALYSIS_STAGES = new Set([ + "image_analysis", + "video_analysis", + "intent_parsing", +]) + +export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean { + return !!stage && ANALYSIS_STAGES.has(stage) +} + +export interface StyleTemplate { + id: string + name: string + description?: string + preview_url?: string + tags?: string[] +} + +export interface IntentResult { + product: string + selling_points: string[] + target_audience: string + tone: string + structure: string + duration: number + suggested_title?: string + suggested_copy?: string +} + +export interface ViralVideoJob { + id: string + status: ViralVideoStatus + images: string[] + reference_video_url?: string + style_strength?: StyleStrength + style_template_id?: string + style_guide?: string + user_copy_text?: string + final_copy_text?: string + fusion_level?: FusionLevel + voice_id?: string + voice_mode?: "global" | "per_video" + bgm_preference?: string + intent_result?: IntentResult + intent_text?: string + progress_stage?: ViralVideoStage + progress_percent?: number + progress_message?: string + output_url?: string + error_message?: string + credits_cost?: number + created_at?: string + updated_at?: string +} + +export interface GenerateViralVideoRequest { + images: string[] + reference_video_url?: string + style_strength?: StyleStrength + style_template_id?: string + user_copy_text?: string + fusion_level?: FusionLevel + voice_id?: string + bgm_preference?: string + industry?: string + target_customer?: string + language?: string + persona_id?: string + viral_structure?: string + marketing_purpose?: string + duration?: number + video_model?: string + video_ratio?: string +} + +export interface HistoryResponse { + items: ViralVideoJob[] + total: number + page: number + page_size: number +} diff --git a/apps/web/src/components/common/PointsBadge/index.tsx b/apps/web/src/components/common/PointsBadge/index.tsx index 3ac88e25f..fd493bae4 100644 --- a/apps/web/src/components/common/PointsBadge/index.tsx +++ b/apps/web/src/components/common/PointsBadge/index.tsx @@ -17,6 +17,7 @@ import { } from "@ant-design/icons" import { useNavigate } from "react-router-dom" import { usePointsStore } from "@/store/pointsStore" +import { ENABLE_CREDIT_SYSTEM } from "@/config/features" import "./PointsBadge.css" const { Text, Paragraph } = Typography @@ -32,9 +33,13 @@ const PointsBadge: React.FC = () => { const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore() useEffect(() => { + if (!ENABLE_CREDIT_SYSTEM) return if (!balance) init() }, [balance, init]) + // 功能开关:积分系统关闭时直接隐藏徽章 + if (!ENABLE_CREDIT_SYSTEM) return null + // 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance const bal = membership?.points_balance ?? balance?.balance ?? 0 const lowBalance = bal > 0 && bal < 10 diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx index e9ec2edba..95af32a7d 100644 --- a/apps/web/src/components/common/PointsCost/index.tsx +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -15,6 +15,7 @@ import React, { useMemo } from "react" import { Tooltip } from "antd" import { WarningOutlined } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" +import { ENABLE_CREDIT_SYSTEM } from "@/config/features" import type { PointsSource } from "@/api/points/types" import "./PointsCost.css" @@ -53,7 +54,7 @@ const PointsCost: React.FC = ({ compact = false, showRechargeHint = true, className = "", -}) => { +}: Props) => { const { balance, dailyUsage, rules, membership } = usePointsStore() const qty = quantity ?? units ?? 1 @@ -118,6 +119,9 @@ const PointsCost: React.FC = ({ } }, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes]) + // 积分系统关闭时不展示消耗提示(组件保留,hooks 必须在 return 前调用) + if (!ENABLE_CREDIT_SYSTEM) return null + if (!rule || !balance) { return } diff --git a/apps/web/src/components/cover/cover.css b/apps/web/src/components/cover/cover.css new file mode 100644 index 000000000..9bd4d08c0 --- /dev/null +++ b/apps/web/src/components/cover/cover.css @@ -0,0 +1,563 @@ +/** + * 共享封面编辑器样式(智能剪辑 generate + AI数字人 ai-avatar 共用) + * #2033:从 generate.css 抽取 xx-ce-* / xx-cover-template-* / xx-cover-modal-* 规则 + */ + +.xx-cover-modal-toolbar { + display: flex; + gap: 8px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.xx-cover-template-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.xx-cover-template-card { + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + transition: border-color 0.2s; +} + +.xx-cover-template-card:hover { + border-color: var(--primary-color); +} + +.xx-cover-template-card.selected { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.xx-cover-template-thumb { + aspect-ratio: 9/16; + background: linear-gradient(135deg, #f0f0f0, #e0e0e0); + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + color: #ccc; + position: relative; +} + +.xx-cover-template-info { + padding: 8px; +} + +.xx-cover-template-name { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + font-weight: 500; + margin-bottom: 2px; +} + +.xx-cover-template-badge { + font-size: 11px; + color: #7c3aed; + background: rgba(124, 58, 237, 0.1); + padding: 1px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.xx-cover-template-date { + font-size: 11px; + color: var(--text-tertiary); + margin-bottom: 6px; +} + +.xx-cover-template-actions { + display: flex; + gap: 4px; +} + +.xx-ce-header { + margin-bottom: 16px; +} + +.xx-ce-name-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: var(--radius-sm, 6px); + font-size: 14px; + margin-bottom: 12px; + outline: none; +} + +.xx-ce-name-input:focus { + border-color: #7c3aed; +} + +.xx-ce-header-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.xx-ce-layout { + display: flex; + gap: 20px; + min-height: 500px; +} + +.xx-ce-left { + width: 300px; + flex-shrink: 0; + max-height: 70vh; + overflow-y: auto; +} + +.xx-ce-right { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + border-radius: 8px; + min-height: 480px; +} + +.xx-ce-section { + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + margin-bottom: 8px; +} + +.xx-ce-section-header { + padding: 10px 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + background: #f0f4ff; + user-select: none; +} + +.xx-ce-section-header:hover { + background: #e8edf8; +} + +.xx-ce-section-body { + padding: 12px; + font-size: 12px; + color: var(--text-secondary, #666); +} + +.xx-ce-header-right { + display: flex; + align-items: center; + gap: 6px; +} + +.xx-ce-status-text { + font-size: 11px; + font-weight: 400; + color: #3b82f6; +} + +.xx-ce-row { + margin: 12px 0; +} + +.xx-ce-label { + display: block; + font-size: 12px; + color: #374151; + margin-bottom: 4px; +} + +.xx-ce-hint { + font-size: 11px; + color: #9ca3af; + margin-top: 4px; +} + +.xx-ce-sub-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.xx-ce-switch-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.xx-ce-switch-item { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #f3f4f6; +} + +.xx-ce-switch-item:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.xx-ce-color-picker { + display: flex; + align-items: center; + gap: 6px; +} + +.xx-ce-color-picker input[type="color"] { + width: 32px; + height: 24px; + padding: 0; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + background: none; +} + +.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper { + padding: 1px; +} + +.xx-ce-color-picker input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.xx-ce-color-hex { + width: 70px; + padding: 2px 6px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 12px; + font-family: monospace; +} + +.xx-ce-position { + display: flex; + gap: 8px; +} + +.xx-ce-position .ant-input-number { + flex: 1; +} + +.xx-ce-radio-group { + display: flex; + gap: 0; +} + +.xx-ce-radio-btn { + padding: 4px 14px; + font-size: 12px; + border: 1px solid #d1d5db; + background: #fff; + color: #374151; + cursor: pointer; + transition: all 0.15s; +} + +.xx-ce-radio-btn:first-child { + border-radius: 4px 0 0 4px; +} + +.xx-ce-radio-btn:last-child { + border-radius: 0 4px 4px 0; +} + +.xx-ce-radio-btn + .xx-ce-radio-btn { + border-left: none; +} + +.xx-ce-radio-btn.active { + background: #7c3aed; + color: #fff; + border-color: #7c3aed; +} + +.xx-ce-radio-btn.active + .xx-ce-radio-btn { + border-left: 1px solid #d1d5db; +} + +.xx-ce-font-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +.xx-ce-font-dot--preset { + background: #10b981; +} + +.xx-ce-font-dot--system { + background: #3b82f6; +} + +.xx-ce-shadow-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.xx-ce-add-shadow-btn { + padding: 4px 10px; + font-size: 12px; + background: #7c3aed; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.xx-ce-add-shadow-btn:hover { + background: #6d28d9; +} + +.xx-ce-preset-shadow-btn { + padding: 4px 10px; + font-size: 12px; + background: #fff; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; +} + +.xx-ce-text-bg-section { + margin-top: 8px; + padding: 8px; + background: #fafafa; + border-radius: 4px; + border: 1px solid #f0f0f0; +} + +.xx-ce-readonly-text { + padding: 6px 10px; + background: #eff6ff; + border-radius: 4px; + font-size: 13px; + color: #1e40af; + margin-bottom: 4px; +} + +.xx-ce-file-row { + display: flex; + gap: 6px; + align-items: center; +} + +.xx-ce-file-name { + flex: 1; + padding: 4px 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 12px; + background: #f9fafb; + color: #6b7280; +} + +.xx-ce-file-btn { + padding: 4px 10px; + font-size: 12px; + background: #fff; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.xx-ce-file-btn:hover { + border-color: #7c3aed; + color: #7c3aed; +} + +.xx-ce-canvas-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.xx-ce-canvas { + width: 225px; + height: 400px; + background: #ddd; + position: relative; + border-radius: 8px; + overflow: hidden; +} + +.xx-ce-anchor-dot { + position: absolute; + width: 8px; + height: 8px; + background: #ef4444; + border-radius: 50%; + z-index: 5; +} + +.xx-ce-el-portrait { + position: absolute; + background: #a8d4f0; + border: 2px solid #333; + z-index: 2; +} + +.xx-ce-handle { + position: absolute; + width: 8px; + height: 8px; + background: #3b82f6; + border: 1px solid #fff; + z-index: 10; +} + +.xx-ce-handle--0 { + top: -4px; + left: -4px; +} + +.xx-ce-handle--1 { + top: -4px; + left: 50%; + margin-left: -4px; +} + +.xx-ce-handle--2 { + top: -4px; + right: -4px; +} + +.xx-ce-handle--3 { + top: 50%; + right: -4px; + margin-top: -4px; +} + +.xx-ce-handle--4 { + bottom: -4px; + right: -4px; +} + +.xx-ce-handle--5 { + bottom: -4px; + left: 50%; + margin-left: -4px; +} + +.xx-ce-handle--6 { + bottom: -4px; + left: -4px; +} + +.xx-ce-handle--7 { + top: 50%; + left: -4px; + margin-top: -4px; +} + +.xx-ce-el-bg { + position: absolute; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + z-index: 1; +} + +.xx-ce-el-mask { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +.xx-ce-text-bg { + position: absolute; + z-index: -1; +} + +.xx-cover-template-check { + position: absolute; + top: 8px; + right: 8px; + width: 22px; + height: 22px; + background: #7c3aed; + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + z-index: 2; + box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4); +} + +.xx-cover-template-thumb { + position: relative; +} + +.xx-ce-preview-tip { + text-align: center; + margin-top: 12px; + font-size: 12px; + color: #6b7280; +} + +.xx-ce-canvas { + background: #1a1a2e; +} + +.xx-ce-section-body .ant-slider { + margin: 4px 0 8px; +} + +.xx-ce-section-body .ant-slider-rail { + background: #e5e7eb; +} + +.xx-ce-section-body .ant-slider-track { + background: #3b82f6; +} + +.xx-ce-section-body .ant-slider-handle::after { + box-shadow: 0 0 0 2px #3b82f6; +} + +.xx-ce-section-body .ant-slider-mark-text { + font-size: 10px; +} + +.xx-ce-font-select-dropdown .ant-select-item-option-content { + display: flex; + align-items: center; +} + +.xx-ce-canvas > div { + pointer-events: none; +} + +/* Text panel wrapper */ +.xx-ce-text-panel { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Canvas base gradient layer (behind all elements) */ +.xx-ce-canvas-base { + position: absolute; + inset: 0; + z-index: 0; + background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); +} diff --git a/apps/web/src/components/cover/index.ts b/apps/web/src/components/cover/index.ts new file mode 100644 index 000000000..ec1c48ffa --- /dev/null +++ b/apps/web/src/components/cover/index.ts @@ -0,0 +1,2 @@ +export { useSharedCover } from "./useSharedCover" +export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover" diff --git a/apps/web/src/components/cover/useSharedCover.ts b/apps/web/src/components/cover/useSharedCover.ts new file mode 100644 index 000000000..574c5bddd --- /dev/null +++ b/apps/web/src/components/cover/useSharedCover.ts @@ -0,0 +1,315 @@ +/** + * 共享封面选择 Hook(供智能剪辑 generate 与 AI 数字人 ai-avatar 共同使用) + * + * 能力: + * - 封面模板列表加载 / 选择 / 创建 / 编辑 / 删除(调用 /cover-templates 接口) + * - 自动生成封面按钮点击 → 调用调用方传入的 generateFn + * - 封面编辑器弹窗状态 + * - 本地封面上传文件选择 + */ +import type React from "react" +import { useCallback, useEffect, useRef, useState } from "react" +import { message } from "antd" +import type { CoverTemplate } from "@/pages/generate/types/cover" +import { + fetchCoverTemplates, + createCoverTemplate, + updateCoverTemplate, + deleteCoverTemplate, +} from "@/api/cover-templates" + +export interface UseSharedCoverOptions { + canGenerate: boolean + disabledHint?: string + generateFn: (templateId: string) => Promise + initialTemplateId?: string +} + +export interface UseSharedCoverReturn { + templates: CoverTemplate[] + templatesLoading: boolean + templatesError: string | null + selectedTemplateId: string + selectedTemplateName: string + handleSelectTemplate: (id: string) => void + reloadTemplates: () => void + showCoverSettings: boolean + setShowCoverSettings: (v: boolean) => void + showCoverEditor: boolean + setShowCoverEditor: (v: boolean) => void + editingTemplate: CoverTemplate | null + handleEditTemplate: (tpl: CoverTemplate) => void + handleCreateTemplate: () => void + handleSaveTemplate: (tpl: CoverTemplate) => Promise + handleDeleteTemplate: (id: string) => Promise + generating: boolean + generateAutoCover: () => Promise + uploadInputRef: React.RefObject + handleUploadClick: () => void + handleFileInputChange: (e: React.ChangeEvent) => void + setOnUploadFile: (fn: (file: File) => Promise | string | null) => void +} + +export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverReturn { + const { canGenerate, disabledHint, generateFn, initialTemplateId = "default" } = opts + const [generating, setGenerating] = useState(false) + const [showCoverSettings, setShowCoverSettings] = useState(false) + const [showCoverEditor, setShowCoverEditor] = useState(false) + const [selectedTemplateId, setSelectedTemplateId] = useState(initialTemplateId) + const [editingTemplate, setEditingTemplate] = useState(null) + const [templates, setTemplates] = useState([]) + const [templatesLoading, setTemplatesLoading] = useState(false) + const [templatesError, setTemplatesError] = useState(null) + const uploadInputRef = useRef(null) + const onUploadFileRef = useRef< + ((file: File) => Promise | string | null) | undefined + >(undefined) + + const setOnUploadFile = useCallback( + (fn: (file: File) => Promise | string | null) => { + onUploadFileRef.current = fn + }, + [], + ) + + const reloadTemplates = useCallback(async () => { + setTemplatesLoading(true) + setTemplatesError(null) + try { + const res = await fetchCoverTemplates() + // 兼容两种响应:{items:[...]} 或直接数组 + const rawList = (res as unknown as { items?: CoverTemplate[] }).items ?? [] + // 确保每个模板都有 config 字段(避免编辑器打开时访问 cfg.title.text 崩溃) + const list: CoverTemplate[] = rawList.map((t) => ({ + ...t, + config: t.config, + })) + setTemplates(list) + // 若当前选中 "default"(初始占位),自动解析为第一个系统模板的真实 id + // ("default" 不是后端真实模板 id,传过去会 404) + setSelectedTemplateId((prev) => { + if (prev !== "default") return prev + const firstSys = list.find((t) => t.is_system) + return firstSys?.id || list[0]?.id || "default" + }) + } catch (err) { + const axiosErr = err as { + response?: { + status?: number + data?: { detail?: string; message?: string; error?: { message?: string } } + } + message?: string + } + const status = axiosErr?.response?.status + const detail = + axiosErr?.response?.data?.detail || + axiosErr?.response?.data?.message || + axiosErr?.response?.data?.error?.message || + axiosErr?.message + console.error("[SharedCover] 加载封面模板失败:", err, "status=", status, "detail=", detail) + if (status === 401) { + setTemplatesError("登录已过期,请刷新页面重新登录") + } else if (status === 403) { + setTemplatesError(detail ? "权限不足:" + detail : "无权限访问封面模板") + } else { + setTemplatesError("加载模板失败:" + (detail || "请稍后重试")) + } + } finally { + setTemplatesLoading(false) + } + }, []) + + useEffect(() => { + // 挂载时拉一次模板列表,用于把 "default" 占位符解析成真实模板 id + void reloadTemplates() + }, [reloadTemplates]) + + useEffect(() => { + if (showCoverSettings) { + void reloadTemplates() + } + }, [showCoverSettings, reloadTemplates]) + + const handleSelectTemplate = useCallback((id: string) => { + setSelectedTemplateId(id) + }, []) + + const handleEditTemplate = useCallback((tpl: CoverTemplate) => { + // 系统模板不可修改:复制为新模板草稿,走另存为流程 + if (tpl.is_system) { + setEditingTemplate({ + ...tpl, + id: "", + name: tpl.name + " 副本", + is_system: false, + created_at: "", + }) + } else { + setEditingTemplate(tpl) + } + setShowCoverEditor(true) + }, []) + + const handleCreateTemplate = useCallback(() => { + setEditingTemplate(null) + setShowCoverEditor(true) + }, []) + + const handleSaveTemplate = useCallback( + async (tpl: CoverTemplate) => { + try { + // 系统模板或无 id(新建/副本)→ 走创建分支;否则走更新 + const isSystem = templates.find((t) => t.id === tpl.id)?.is_system === true + const shouldCreate = !tpl.id || isSystem + if (shouldCreate) { + const created = await createCoverTemplate({ + name: tpl.name || "我的封面模板", + config: tpl.config, + }) + setTemplates((prev) => [...prev, created]) + setSelectedTemplateId(created.id || tpl.id) + } else { + const updated = await updateCoverTemplate(tpl.id, { name: tpl.name, config: tpl.config }) + setTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t))) + } + setShowCoverEditor(false) + setEditingTemplate(null) + } catch (err) { + const axiosErr = err as { + response?: { + status?: number + data?: { detail?: string; message?: string; error?: { message?: string } } + } + message?: string + } + const status = axiosErr?.response?.status + const detail = + axiosErr?.response?.data?.detail || + axiosErr?.response?.data?.message || + axiosErr?.response?.data?.error?.message || + axiosErr?.message + console.error("[SharedCover] 保存模板失败:", err, "status=", status, "detail=", detail) + if (status === 403) { + message.error("保存失败(权限不足):" + (detail || "无权操作该模板")) + } else { + message.error("保存模板失败:" + (detail || "请稍后重试")) + } + } + }, + [templates], + ) + + const handleDeleteTemplate = useCallback( + async (id: string) => { + try { + await deleteCoverTemplate(id) + setTemplates((prev) => prev.filter((t) => t.id !== id)) + if (selectedTemplateId === id) { + // 删除后选中第一个系统模板作为兜底,避免 magic string "default" 传后端 404 + setTemplates((prevAfter) => { + const firstSys = prevAfter.find((t) => t.is_system) + setSelectedTemplateId(firstSys?.id || prevAfter[0]?.id || "") + return prevAfter + }) + } + } catch (err) { + const axiosErr = err as { + response?: { + status?: number + data?: { detail?: string; message?: string; error?: { message?: string } } + } + message?: string + } + const status = axiosErr?.response?.status + const detail = + axiosErr?.response?.data?.detail || + axiosErr?.response?.data?.message || + axiosErr?.response?.data?.error?.message || + axiosErr?.message + console.error("[SharedCover] 删除模板失败:", err, "status=", status, "detail=", detail) + if (status === 403) { + message.error("删除失败(权限不足):" + (detail || "无权操作该模板")) + } else { + message.error("删除模板失败:" + (detail || "请稍后重试")) + } + } + }, + [selectedTemplateId], + ) + + const generateAutoCover = useCallback(async () => { + if (generating) { + message.warning("封面正在生成中,请稍候…") + return + } + if (!canGenerate) { + if (disabledHint) message.warning(disabledHint) + return + } + setGenerating(true) + try { + const tplId = selectedTemplateId && selectedTemplateId !== "default" ? selectedTemplateId : "" + const url = await generateFn(tplId) + if (!url) { + message.warning("封面生成未返回图片,请重试") + } + } catch (err) { + console.error("[SharedCover] 自动生成封面失败:", err) + const anyErr = err as { __msgShown?: boolean; message?: string } + if (!anyErr?.__msgShown) { + message.error(anyErr?.message || "封面生成失败") + } + } finally { + setGenerating(false) + } + }, [generating, canGenerate, disabledHint, generateFn, selectedTemplateId]) + + const handleUploadClick = useCallback(() => { + uploadInputRef.current?.click() + }, []) + + const handleFileInputChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = "" + if (!file) return + if (onUploadFileRef.current) { + const ret = onUploadFileRef.current(file) + if (ret instanceof Promise) { + ret.catch((err) => { + console.error("[SharedCover] 上传封面失败:", err) + }) + } + } + }, []) + + const selectedTemplateName = + templates.find((t) => t.id === selectedTemplateId)?.name || + (selectedTemplateId === "default" || !selectedTemplateId ? "默认模板" : "自定义") + + return { + templates, + templatesLoading, + templatesError, + selectedTemplateId, + selectedTemplateName, + handleSelectTemplate, + reloadTemplates, + showCoverSettings, + setShowCoverSettings, + showCoverEditor, + setShowCoverEditor, + editingTemplate, + handleEditTemplate, + handleCreateTemplate, + handleSaveTemplate, + handleDeleteTemplate, + generating, + generateAutoCover, + uploadInputRef, + handleUploadClick, + handleFileInputChange, + setOnUploadFile, + } +} + +export default useSharedCover diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 301ee1a7c..72665734d 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -21,6 +21,7 @@ import { useLogout } from "@/hooks/useAuth" import type { MenuProps } from "antd" import { NAV_ITEMS } from "@/config/navigation" import PointsBadge from "@/components/common/PointsBadge" +import { ENABLE_CREDIT_SYSTEM } from "@/config/features" import { usePointsStore } from "@/store/pointsStore" import "./Header.css" @@ -57,30 +58,36 @@ const Header: React.FC = () => { label: "订阅管理", onClick: () => navigate("/app/subscription"), }, - // v2: 我的积分入口 - { - key: "points-center", - icon: , - label: ( - - 我的积分 - {balance && {balance.balance}} - - ), - onClick: () => navigate("/app/points"), - }, - { - key: "points-history", - icon: , - label: "积分明细", - onClick: () => navigate("/app/points/transactions"), - }, - { - key: "recharge", - icon: , - label: "充值积分", - onClick: () => navigate("/app/points/recharge"), - }, + // 积分系统开关关闭时隐藏积分相关菜单项(代码保留不删除) + ...(ENABLE_CREDIT_SYSTEM + ? [ + { + key: "points-center", + icon: , + label: ( + + 我的积分 + {balance && ( + {balance.balance} + )} + + ), + onClick: () => navigate("/app/points"), + }, + { + key: "points-history", + icon: , + label: "积分明细", + onClick: () => navigate("/app/points/transactions"), + }, + { + key: "recharge", + icon: , + label: "充值积分", + onClick: () => navigate("/app/points/recharge"), + }, + ] + : []), { type: "divider" }, { key: "logout", @@ -130,7 +137,13 @@ const Header: React.FC = () => { {/* v2: 升级会员入口(仅免费用户显示) */} {!isMember && ( - + + { + onChange(e.target.value) + setCustomOpen(false) + }} + style={{ + opacity: customOpen ? 1 : 0, + position: customOpen ? "static" : "absolute", + pointerEvents: customOpen ? "auto" : "none", + width: customOpen ? 28 : 0, + height: customOpen ? 28 : 0, + border: "none", + padding: 0, + cursor: "pointer", + background: "transparent", + }} + /> + +
+ 当前:{value} +
+ + ) +} + +const TitleStyleParamsTab: React.FC = ({ + settings, + onUpdatePosition, + onUpdateFont, + onUpdateSize, + onToggleBold, + onToggleItalic, + onToggleStroke, + onToggleShadow, + onUpdatePatch, + positionOptions, + fontOptions = FONT_OPTIONS, + showCoverToggle = false, + coverEnabled = false, + onToggleCover, +}) => { + const upd = onUpdatePatch + return ( + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + +
+
+ upd({ color: c })} + /> + + ), + }, + { + key: "stroke", + label: "描边", + children: ( + <> +
+ +
+ {settings.stroke && ( + <> + upd({ strokeWidth: v })} + /> + upd({ strokeColor: c })} + /> + + )} + + ), + }, + { + key: "shadow", + label: "阴影", + children: ( + <> +
+ +
+ {settings.shadow && ( + <> + upd({ shadowOffsetX: v })} + /> + upd({ shadowOffsetY: v })} + /> + upd({ shadowBlur: v })} + /> +
+ + upd({ shadowColor: e.target.value })} + placeholder="rgba(0,0,0,0.8)" + /> +
+ + )} + + ), + }, + { + key: "bg", + label: "背景", + children: ( + <> +
+ +
+ {settings.bgEnabled && ( + <> + upd({ bgColor: c })} + /> + upd({ bgPadding: v })} + /> + upd({ bgRadius: v })} + /> + + )} + + ), + }, + { + key: "layout", + label: "排版", + children: ( + <> + upd({ maxCharsPerLine: v })} + /> +
+ 0 = 不自动换行(按 / 手动分行) +
+ upd({ lineHeight: Number(v.toFixed(2)) })} + /> + upd({ marginTop: v })} + /> + + ), + }, + ...(showCoverToggle + ? [ + { + key: "cover", + label: "封面", + children: ( +
+ +
+ ), + }, + ] + : []), + ]} + /> + ) +} + +export default TitleStyleParamsTab diff --git a/apps/web/src/components/title/TitleTemplate.css b/apps/web/src/components/title/TitleTemplate.css new file mode 100644 index 000000000..6cf273837 --- /dev/null +++ b/apps/web/src/components/title/TitleTemplate.css @@ -0,0 +1,391 @@ +/* ============================================================ + 标题模板系统 v3(按 sketch 重构) + - 大卡片网格(图片背景 + 透明 Canvas 叠字 + 始终可见操作按钮) + - 编辑器弹窗(左竖屏预览 + 右参数 Tab) + ============================================================ */ + +/* ── 面板容器(模板模式) ── */ +.ttv3-panel { + display: flex; + flex-direction: column; + gap: 14px; +} +.ttv3-header { + display: flex; + justify-content: space-between; + align-items: center; +} +.ttv3-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary, #1f2937); +} +.ttv3-new-btn.ant-btn { + background: linear-gradient(135deg, #6c5ce7, #a29bfe); + border: none; + border-radius: 20px; + font-size: 12px; + height: 28px; + padding: 0 14px; + font-weight: 500; + box-shadow: 0 2px 8px rgba(108, 92, 231, 0.25); +} +.ttv3-new-btn.ant-btn:hover { + background: linear-gradient(135deg, #5b4cdb, #8c83f5) !important; + color: #fff !important; +} +.ttv3-section-label { + font-size: 12px; + color: #888; + font-weight: 500; + margin-bottom: 8px; +} +.ttv3-section { + margin-bottom: 6px; +} + +/* ── 空状态 ── */ +.ttv3-empty { + background: #f8f8fc; + border-radius: 12px; + padding: 28px 16px; + text-align: center; + color: #aaa; +} +.ttv3-empty-icon { + font-size: 28px; + margin-bottom: 6px; +} +.ttv3-empty-text { + font-size: 12px; +} + +/* ── 卡片网格:minmax(180px,1fr) 自适应 ── */ +.ttv3-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} + +/* ── 卡片:3:4 竖版,圆角 14px ── */ +.ttv3-card { + background: #fff; + border-radius: 14px; + border: 3px solid #e8e8ed; + cursor: pointer; + transition: all 0.15s ease; + overflow: hidden; + padding: 0; + font-family: inherit; + text-align: left; +} +.ttv3-card:hover { + border-color: #c5c0f0; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); +} +.ttv3-card.selected { + border-color: #6c5ce7; + box-shadow: 0 4px 16px rgba(108, 92, 231, 0.25); +} + +/* ── 卡片预览区(3:4) ── */ +.ttv3-preview { + width: 100%; + aspect-ratio: 3/4; + position: relative; + overflow: hidden; + border-radius: 11px 11px 0 0; +} +.ttv3-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +/* 暗色渐变遮罩:顶部15%半透明黑 + 中部透明 + 底部45%黑 */ +.ttv3-vignette { + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.45) 0%, + rgba(0, 0, 0, 0.15) 15%, + transparent 30%, + transparent 55%, + rgba(0, 0, 0, 0.6) 100% + ); + z-index: 1; + pointer-events: none; +} +/* 透明 Canvas 标题填充整个预览区 */ +.ttv3-preview .tt-fill-canvas-wrap { + position: absolute; + inset: 0; + z-index: 2; +} +.ttv3-preview .tt-fill-canvas-wrap canvas { + width: 100% !important; + height: 100% !important; + border-radius: 0; + display: block; +} + +/* ── 左上角角标(系统/我的) ── */ +.ttv3-badge { + position: absolute; + top: 8px; + left: 8px; + font-size: 10px; + padding: 2px 8px; + border-radius: 8px; + font-weight: 500; + color: #fff; + z-index: 3; + backdrop-filter: blur(4px); +} +.ttv3-badge--sys { + background: rgba(108, 92, 231, 0.88); +} +.ttv3-badge--mine { + background: rgba(0, 184, 148, 0.88); +} + +/* ── 右上角勾选圆圈 ── */ +.ttv3-check { + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.3); + border: 2px solid rgba(255, 255, 255, 0.7); + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: transparent; + transition: all 0.15s; +} +.ttv3-check.on { + background: #6c5ce7; + border-color: #fff; + color: #fff; +} + +/* ── 卡片底栏(名称 + 操作按钮) ── */ +.ttv3-footer { + padding: 10px 10px 12px; +} +.ttv3-name-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + color: #1f2937; +} +.ttv3-emoji { + font-size: 14px; + flex-shrink: 0; +} +.ttv3-name { + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} +.ttv3-tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 6px; + font-weight: 500; + flex-shrink: 0; +} +.ttv3-tag--sys { + background: #f0ecff; + color: #6c5ce7; +} +.ttv3-tag--mine { + background: #e6f9f4; + color: #00b894; +} + +/* ── 操作按钮:始终可见,等宽排列 ── */ +.ttv3-actions { + display: flex; + gap: 5px; +} +.ttv3-act { + flex: 1; + padding: 5px 0; + border: 1px solid #e8e8ed; + background: #fff; + border-radius: 7px; + font-size: 11px; + cursor: pointer; + color: #666; + font-weight: 500; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + transition: all 0.15s; + font-family: inherit; +} +.ttv3-act:hover:not(:disabled) { + background: #f5f5fa; + border-color: #d5d3e8; +} +.ttv3-act--primary { + background: #6c5ce7; + color: #fff; + border-color: #6c5ce7; +} +.ttv3-act--primary:hover:not(:disabled) { + background: #5b4cdb; + border-color: #5b4cdb; +} +.ttv3-act--danger { + color: #e74c3c; +} +.ttv3-act--danger:hover:not(:disabled) { + background: #fef2f2; +} +.ttv3-act:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* ── FillPreview 公共容器 ── */ +.tt-fill-canvas-wrap { + position: relative; +} + +/* ── params-only(编辑器右侧)去掉多余 margin ── */ +.ttv3-params-only { + padding: 0; +} +.ttv3-params-only .ant-tabs { + margin: 0; +} + +/* ============================================================ + 编辑器 Modal(v3) + ============================================================ */ +.ttv3-modal .ant-modal-content { + border-radius: 16px; + overflow: hidden; + padding: 0; +} +.ttv3-modal .ant-modal-header { + padding: 16px 20px; + margin: 0; + border-bottom: 1px solid #f0f0f0; +} +.ttv3-modal .ant-modal-body { + padding: 0; + max-height: 75vh; +} +.ttv3-modal .ant-modal-footer { + padding: 14px 20px; + margin: 0; + border-top: 1px solid #f0f0f0; +} + +/* 编辑器两栏布局 */ +.ttv3-editor { + display: flex; + min-height: 500px; +} +.ttv3-editor-left { + width: 300px; + padding: 20px; + background: #f8f8fc; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + border-right: 1px solid #f0f0f0; +} +.ttv3-editor-canvas { + width: 200px; + aspect-ratio: 9/16; + border-radius: 12px; + overflow: hidden; + position: relative; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + flex-shrink: 0; +} +.ttv3-editor-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} +.ttv3-editor-canvas-inner { + position: absolute; + inset: 0; + z-index: 2; +} +.ttv3-editor-canvas-inner canvas { + width: 100% !important; + height: 100% !important; + border-radius: 0; + display: block; +} +.ttv3-editor-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} +.ttv3-form-row { + display: flex; + align-items: center; + gap: 10px; +} +.ttv3-form-row label { + font-size: 12px; + font-weight: 600; + color: #333; + white-space: nowrap; + min-width: 44px; +} +.ttv3-form-row--grow { + flex: 1; +} +.ttv3-form-row--grow .ant-input { + flex: 1; +} + +.ttv3-editor-right { + flex: 1; + padding: 16px 20px; + overflow-y: auto; + min-width: 0; +} + +/* 侧栏较窄时(380px 侧栏):强制 2 列,卡片稍微紧凑 */ +@media (max-width: 540px) { + .ttv3-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + .ttv3-act { + font-size: 10px; + padding: 4px 0; + } + .ttv3-act .anticon { + display: none; + } +} diff --git a/apps/web/src/components/title/TitleTemplateEditor.tsx b/apps/web/src/components/title/TitleTemplateEditor.tsx new file mode 100644 index 000000000..4c19002b1 --- /dev/null +++ b/apps/web/src/components/title/TitleTemplateEditor.tsx @@ -0,0 +1,167 @@ +/** + * 标题模板编辑器(公共组件) + * + * - Modal 弹窗 860px 宽 + * - 左侧:300px 竖屏预览区(图片背景+暗角+透明 Canvas 叠字)+ 模板名称输入 + * - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStyleParamsTab + * - 底部:取消 / 保存模板 按钮 + * - 内置模板编辑时保存会创建副本(带"副本"逻辑由 onSave 的调用方处理) + */ +import React, { useEffect, useMemo, useState } from "react" +import { Modal, Button, Input, message } from "antd" +import type { TitleStyleSettings } from "./settings" +import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings" +import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils" +import type { TitleTemplate } from "./template-types" +import type { TitleStyleConfig } from "./types" +import { POSITION_OPTIONS } from "./position-options" +import { FONT_OPTIONS } from "./constants" +import TitleMiniPreview from "./TitleMiniPreview" +import TitleStyleParamsTab from "./TitleStyleParamsTab" +import "./TitleTemplate.css" +import "./TitleStylePanel.css" + +interface Props { + open: boolean + template: TitleTemplate + onClose: () => void + onSave: (data: { name: string; emoji: string; style: Partial }) => void +} + +/** 编辑器预览用的背景图(复用卡片池第一张) */ +const EDITOR_BG = "/title-templates/portrait1.jpg" + +const TitleTemplateEditor: React.FC = ({ open, template, onClose, onSave }) => { + const [settings, setSettings] = useState(() => ({ + ...DEFAULT_TITLE_STYLE_SETTINGS, + ...titleStyleConfigToCamel(template.style || {}), + })) + const [formName, setFormName] = useState(template.name || "") + const [formEmoji, setFormEmoji] = useState(template.emoji || "✨") + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (open) { + setSettings({ + ...DEFAULT_TITLE_STYLE_SETTINGS, + ...titleStyleConfigToCamel(template.style || {}), + }) + setFormName(template.name || "") + setFormEmoji(template.emoji || "✨") + } + }, [open, template]) + + const upd = (patch: Partial) => setSettings((s) => ({ ...s, ...patch })) + + const handleSave = () => { + const name = formName.trim() + if (!name) { + message.warning("请填写模板名称") + return + } + setSaving(true) + try { + const snake = camelToTitleStyleConfig(settings) + onSave({ name, emoji: formEmoji, style: snake }) + } finally { + setSaving(false) + } + } + + // 编辑器预览 settings:竖屏宽度 200px,字号按比例缩放 + const previewSettings = useMemo( + () => ({ ...settings, size: Math.round(settings.size * 0.55) }), + [settings], + ) + + return ( + + + + + } + destroyOnClose + className="ttv3-modal" + styles={{ body: { padding: 0 } }} + > +
+ {/* 左侧:竖屏预览 + 名称 */} +
+
+ +
+
+ +
+
+
+
+ + setFormEmoji(e.target.value)} + /> +
+
+ + setFormName(e.target.value)} + /> +
+
+
+ {/* 右侧:参数 Tab */} +
+ upd({ position: p, posX: null, posY: null })} + onUpdateFont={(f) => upd({ font: f })} + onUpdateSize={(v) => upd({ size: v })} + onToggleBold={() => upd({ bold: !settings.bold })} + onToggleItalic={() => upd({ italic: !settings.italic })} + onToggleStroke={() => + upd({ + stroke: !settings.stroke, + strokeWidth: + !settings.stroke && (settings.strokeWidth ?? 0) < 2 ? 4 : settings.strokeWidth, + }) + } + onToggleShadow={() => upd({ shadow: !settings.shadow })} + onUpdatePatch={upd} + positionOptions={POSITION_OPTIONS} + fontOptions={FONT_OPTIONS} + /> +
+
+ + ) +} + +export default TitleTemplateEditor diff --git a/apps/web/src/components/title/TitleTemplateSelector.tsx b/apps/web/src/components/title/TitleTemplateSelector.tsx new file mode 100644 index 000000000..27d8f46a5 --- /dev/null +++ b/apps/web/src/components/title/TitleTemplateSelector.tsx @@ -0,0 +1,343 @@ +/** + * 标题模板选择器 — 大卡片网格(共享组件) + * + * 渲染「我的模板」+「系统模板」两个分组的 3:4 竖版大圆角卡片: + * - 卡片上半:示例背景图 + vignette 暗角 + 透明 Canvas 大字预览 + * - 卡片下半:emoji + 名称 + 系统/我的标签 + 始终可见的编辑/复制/导出/删除按钮 + * - 选中紫色边框;右上角「新建模板」按钮;点编辑/新建弹 TitleTemplateEditor + * + * Props 通用化,不耦合业务 state。 + */ +import React, { useCallback, useMemo, useState } from "react" +import { Button, message, Popconfirm } from "antd" +import { + PlusOutlined, + EditOutlined, + CopyOutlined, + DeleteOutlined, + ExportOutlined, + CheckOutlined, +} from "@ant-design/icons" +import type { TitleTemplate } from "./template-types" +import type { TitleStyleSettings } from "./settings" +import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings" +import { + titleStyleConfigToCamel, + camelToTitleStyleConfig, + templateToPreviewSettings, +} from "./utils" +import { useTitleTemplates } from "./useTitleTemplates" +import TitleMiniPreview from "./TitleMiniPreview" +import TitleTemplateEditor from "./TitleTemplateEditor" +import "./TitleTemplate.css" +import "./TitleStylePanel.css" + +export interface TitleTemplateSelectorProps { + /** 当前选中模板 id(受控) */ + value?: string | null + /** 选中模板时回调(templateId, fullStyleSettings, template) */ + onChange?: (templateId: string, style: TitleStyleSettings, template: TitleTemplate) => void + /** 是否显示编辑器入口(新建/编辑按钮),默认 true */ + showEditor?: boolean + /** 显示哪些分组,默认全部 */ + categories?: Array<"system" | "custom"> + /** 使用场景标识(仅作 data-attr,不影响样式) */ + context?: string +} + +/* ── 卡片预览背景图池(按 index 轮换) ── */ +const PREVIEW_BG_IMAGES = [ + "/title-templates/portrait1.jpg", + "/title-templates/portrait2.jpg", + "/title-templates/scene1.jpg", +] + +/* ── 预览容器:用 ref 测量宽度后再渲染透明 Canvas,保证文字清晰 ── */ +const FillPreview: React.FC<{ + settings: TitleStyleSettings + sampleText: string + portrait?: boolean +}> = ({ settings, sampleText, portrait }) => { + const [w, setW] = useState(0) + // 首次挂载后测量一次 + const setRef = useCallback((el: HTMLDivElement | null) => { + if (el) setW(Math.floor(el.clientWidth)) + }, []) + return ( +
+ {w > 0 && ( + + )} +
+ ) +} + +const TitleTemplateSelector: React.FC = ({ + value, + onChange, + showEditor = true, + categories = ["system", "custom"], + context, +}) => { + const { + templates, + createTemplate, + duplicateTemplate, + updateTemplate, + deleteTemplate, + exportTemplate, + } = useTitleTemplates() + + const [editingTemplate, setEditingTemplate] = useState(null) + const [editorOpen, setEditorOpen] = useState(false) + + const grouped = useMemo( + () => ({ + builtin: templates.filter((t) => t.isBuiltin), + custom: templates.filter((t) => !t.isBuiltin), + }), + [templates], + ) + + const showSys = categories.includes("system") + const showMine = categories.includes("custom") + + /* ── 选中模板:合成完整 TitleStyleSettings 回调给父组件 ── */ + const handleSelectTemplate = useCallback( + (tpl: TitleTemplate) => { + const full: TitleStyleSettings = { + ...DEFAULT_TITLE_STYLE_SETTINGS, + ...titleStyleConfigToCamel(tpl.style), + } + onChange?.(tpl.id, full, tpl) + }, + [onChange], + ) + + const handleRequestCreate = useCallback(() => { + // 新建:以当前选中模板样式为起点,否则用默认样式 + let base: TitleStyleSettings = DEFAULT_TITLE_STYLE_SETTINGS + if (value) { + const sel = templates.find((t) => t.id === value) + if (sel) { + base = { ...DEFAULT_TITLE_STYLE_SETTINGS, ...titleStyleConfigToCamel(sel.style) } + } + } + const draft: TitleTemplate = { + id: "", + name: "我的标题模板", + emoji: "✨", + isBuiltin: false, + style: camelToTitleStyleConfig({ + ...base, + position: base.position === "custom" ? "bottom" : base.position, + }), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + setEditingTemplate(draft) + setEditorOpen(true) + }, [value, templates]) + + const handleRequestEdit = useCallback((tpl: TitleTemplate) => { + setEditingTemplate(tpl) + setEditorOpen(true) + }, []) + + const handleDuplicate = useCallback( + (t: TitleTemplate) => { + const dup = duplicateTemplate(t.id) + if (dup) message.success(`已复制:${dup.name}`) + }, + [duplicateTemplate], + ) + const handleDelete = useCallback( + (t: TitleTemplate) => { + deleteTemplate(t.id) + message.success("已删除模板") + }, + [deleteTemplate], + ) + const handleExport = useCallback( + (t: TitleTemplate) => { + const json = exportTemplate(t.id) + if (!json) return + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${t.name}.title-template.json` + a.click() + URL.revokeObjectURL(url) + }, + [exportTemplate], + ) + + const handleEditorSave = useCallback( + (data: { name: string; emoji: string; style: Partial }) => { + if (!editingTemplate) return + let saved: TitleTemplate + if (editingTemplate.isBuiltin || !editingTemplate.id) { + saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style }) + } else { + updateTemplate(editingTemplate.id, { + name: data.name, + emoji: data.emoji, + style: data.style, + }) + saved = { + ...editingTemplate, + name: data.name, + emoji: data.emoji, + style: data.style, + updatedAt: new Date().toISOString(), + } + } + setEditorOpen(false) + setEditingTemplate(null) + message.success(`已保存:${data.name}`) + handleSelectTemplate(saved) + }, + [editingTemplate, createTemplate, updateTemplate, handleSelectTemplate], + ) + + /* ── 渲染单张大卡片 ── */ + const renderCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => { + const isSelected = value === t.id + const bgIdx = idx % PREVIEW_BG_IMAGES.length + const bgImg = PREVIEW_BG_IMAGES[bgIdx] + const preview = templateToPreviewSettings(t, 42) + return ( +
handleSelectTemplate(t)} + data-context={context} + > +
+ +
+ + + {section === "sys" ? "系统" : "我的"} + + + {isSelected && } + +
+
+
+ {t.emoji || "✨"} + + {t.name} + + + {section === "sys" ? "系统" : "我的"} + +
+ {showEditor && ( +
e.stopPropagation()}> + + + + handleDelete(t)}> + + +
+ )} +
+
+ ) + } + + return ( +
+
+ 标题模板 + {showEditor && ( + + )} +
+ + {showMine && ( +
+
我的模板
+ {grouped.custom.length === 0 ? ( +
+
✨
+
还没有自定义模板,点右上角「新建模板」创建
+
+ ) : ( +
+ {grouped.custom.map((t, i) => renderCard(t, i, "mine"))} +
+ )} +
+ )} + + {showSys && ( +
+
系统模板
+
{grouped.builtin.map((t, i) => renderCard(t, i, "sys"))}
+
+ )} + + {showEditor && editorOpen && editingTemplate && ( + { + setEditorOpen(false) + setEditingTemplate(null) + }} + onSave={handleEditorSave} + /> + )} +
+ ) +} + +export default TitleTemplateSelector diff --git a/apps/web/src/components/title/constants.ts b/apps/web/src/components/title/constants.ts new file mode 100644 index 000000000..37368e464 --- /dev/null +++ b/apps/web/src/components/title/constants.ts @@ -0,0 +1,392 @@ +/** + * 标题样式相关常量(#2001) + * - 字体列表(新增4款爆款字体) + * - 色板(常用标题字色/描边色/背景色) + * - 预设样式方案(10 个,含抖音爆款黄) + */ +import type { TitleStyleConfig } from "./types" + +/* ── 字体选项(#2001:新增优设标题黑/阿里普惠体Bold/抖音美好体/思源黑体Heavy) ── */ +export interface FontOption { + value: string + label: string + /** CSS font-family 栈 */ + family: string + /** 爆款/常用标签 */ + tag?: "hot" | "new" +} + +export const FONT_OPTIONS: FontOption[] = [ + { + value: "优设标题黑", + label: "优设标题黑", + // 原版"优设标题黑"为商用字体非开源;优先本地已安装字体,兜底用 Noto Sans SC(Google Fonts 已加载 wght@900,保证 bold 字重可用),再用 ZCOOL 庆科黄油体作风格兜底 + family: + '"YouSheBiaoTiHei","YouShe Title Black","Noto Sans SC","ZCOOL QingKe HuangYou","PingFang SC","Microsoft YaHei",sans-serif', + tag: "hot", + }, + { + value: "阿里普惠体Bold", + label: "阿里普惠体Bold", + // 阿里普惠体需从阿里官网下载;兜底用 Noto Sans SC 900(同等字重,已在 Google Fonts wght@400;500;700;900 加载) + family: + '"Alibaba PuHuiTi","Alibaba PuHuiTi Bold","Alibaba Sans","Noto Sans SC",system-ui,"PingFang SC","Microsoft YaHei",sans-serif', + tag: "hot", + }, + { + value: "抖音美好体", + label: "抖音美好体", + // 抖音美好体为版权字体;兜底用 Noto Sans SC(确保 bold 字重可用),再用 ZCOOL KuaiLe(站酷快乐体,圆润卡通风格近似) + family: + '"Douyin Sans","DouyinSans","Noto Sans SC","ZCOOL KuaiLe","PingFang SC","Microsoft YaHei",sans-serif', + tag: "hot", + }, + { + value: "思源黑体Heavy", + label: "思源黑体Heavy", + family: + '"Noto Sans SC","Source Han Sans SC","Source Han Sans CN Heavy","PingFang SC","Microsoft YaHei",sans-serif', + tag: "new", + }, + { + value: "思源黑体", + label: "思源黑体", + family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + }, + { + value: "思源宋体", + label: "思源宋体", + family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif', + }, + { + value: "苹方", + label: "苹方", + family: + '"PingFang SC",-apple-system,blinkmacsystemfont,"Helvetica Neue","Noto Sans SC",sans-serif', + }, + { + value: "微软雅黑", + label: "微软雅黑", + family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif', + }, + { + value: "楷体", + label: "楷体", + family: '"KaiTi","STKaiti","DFKai-SB","Kaiti SC",serif', + }, + { + value: "站酷小薇体", + label: "站酷小薇体", + family: '"ZCOOL XiaoWei","Noto Serif SC",serif', + }, + { + value: "马善政毛笔", + label: "马善政毛笔", + family: '"Ma Shan Zheng","STXingkai","KaiTi",cursive', + }, + { + value: "龙藏体", + label: "龙藏体", + family: '"Long Cang","STXingkai","KaiTi",cursive', + }, + { + value: "流江毛笔草", + label: "流江毛笔草", + family: '"Liu Jian Mao Cao","STXingkai",cursive', + }, + { + value: "志莽行书", + label: "志莽行书", + family: '"Zhi Mang Xing","STXingkai",cursive', + }, +] + +/** 根据中文名取 font-family 栈(找不到回退思源黑体) */ +export function getFontFamily(font: string): string { + const f = FONT_OPTIONS.find((x) => x.value === font) + if (f) return f.family + return FONT_OPTIONS[4].family // 思源黑体 +} + +/* ── 色板 ── */ +/** 标题字色(常用爆款色) */ +export const TITLE_COLOR_PALETTE: string[] = [ + "#ffffff", + "#000000", + "#ffd700", // 抖音黄 + "#ff2d55", // 抖音红 + "#ff4081", + "#00e5ff", + "#d4a843", + "#ffa500", + "#52c41a", + "#1890ff", + "#7c3aed", + "#ff6b35", +] + +/** 描边色(黑/白/灰为主) */ +export const STROKE_COLOR_PALETTE: string[] = [ + "#000000", + "#ffffff", + "#333333", + "#555555", + "#8b0000", + "#001f3f", +] + +/** 背景色(带透明度) */ +export const BG_COLOR_PALETTE: string[] = [ + "rgba(0,0,0,0.5)", + "rgba(0,0,0,0.7)", + "rgba(0,0,0,0.3)", + "rgba(255,215,0,0.9)", + "rgba(255,45,85,0.85)", + "rgba(124,58,237,0.85)", + "rgba(24,144,255,0.85)", + "rgba(82,196,26,0.85)", +] + +/* ── 预设样式方案(10 个,含抖音爆款黄) ── */ +export interface TitlePreset { + key: string + label: string + emoji: string + /** 应用时覆盖到 TitleStyleConfig 的字段(其他字段保持当前值) */ + style: Partial +} + +const BASE: Partial = { + position: "bottom", + line_overrides: [], + cover_title_config: null, +} + +export const TITLE_PRESETS: TitlePreset[] = [ + { + key: "douyin_hot", + label: "抖音爆款黄", + emoji: "🔥", + style: { + ...BASE, + font: "优设标题黑", + size: 80, + color: "#ffd700", + bold: true, + italic: false, + stroke: true, + stroke_width: 8, + stroke_color: "#000000", + shadow: true, + shadow_offset_x: 3, + shadow_offset_y: 3, + shadow_blur: 6, + shadow_color: "rgba(0,0,0,0.6)", + bg_enabled: false, + line_height: 1.25, + max_chars_per_line: 8, + }, + }, + { + key: "classic_white", + label: "经典白字黑描边", + emoji: "⚪", + style: { + ...BASE, + font: "思源黑体Heavy", + size: 56, + color: "#ffffff", + bold: true, + italic: false, + stroke: true, + stroke_width: 5, + stroke_color: "#000000", + shadow: false, + bg_enabled: false, + line_height: 1.2, + max_chars_per_line: 10, + }, + }, + { + key: "red_bold", + label: "醒目红字", + emoji: "🔴", + style: { + ...BASE, + font: "优设标题黑", + size: 72, + color: "#ff2d55", + bold: true, + italic: false, + stroke: true, + stroke_width: 6, + stroke_color: "#ffffff", + shadow: true, + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 5, + shadow_color: "rgba(0,0,0,0.5)", + bg_enabled: false, + line_height: 1.2, + max_chars_per_line: 9, + }, + }, + { + key: "black_gold", + label: "黑金质感", + emoji: "🟡", + style: { + ...BASE, + font: "思源宋体", + size: 52, + color: "#d4a843", + bold: true, + italic: false, + stroke: false, + shadow: true, + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 8, + shadow_color: "rgba(0,0,0,0.8)", + bg_enabled: false, + line_height: 1.25, + max_chars_per_line: 10, + }, + }, + { + key: "neon_blue", + label: "霓虹发光", + emoji: "💙", + style: { + ...BASE, + font: "阿里普惠体Bold", + size: 60, + color: "#00e5ff", + bold: true, + italic: false, + stroke: false, + shadow: true, + shadow_offset_x: 0, + shadow_offset_y: 0, + shadow_blur: 16, + shadow_color: "#00e5ff", + bg_enabled: false, + line_height: 1.2, + max_chars_per_line: 10, + }, + }, + { + key: "bg_black", + label: "黑底白字", + emoji: "⬛", + style: { + ...BASE, + font: "思源黑体Heavy", + size: 52, + color: "#ffffff", + bold: true, + italic: false, + stroke: false, + shadow: false, + bg_enabled: true, + bg_color: "rgba(0,0,0,0.7)", + bg_padding: 16, + bg_radius: 8, + line_height: 1.3, + max_chars_per_line: 10, + }, + }, + { + key: "bg_yellow", + label: "黄底黑字", + emoji: "🟨", + style: { + ...BASE, + font: "抖音美好体", + size: 56, + color: "#000000", + bold: true, + italic: false, + stroke: false, + shadow: false, + bg_enabled: true, + bg_color: "rgba(255,215,0,0.95)", + bg_padding: 14, + bg_radius: 6, + line_height: 1.2, + max_chars_per_line: 9, + }, + }, + { + key: "sweet_pink", + label: "温柔甜美粉", + emoji: "🌸", + style: { + ...BASE, + font: "阿里普惠体Bold", + size: 50, + color: "#ff4081", + bold: false, + italic: false, + stroke: true, + stroke_width: 4, + stroke_color: "#ffffff", + shadow: true, + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 4, + shadow_color: "rgba(255,64,129,0.4)", + bg_enabled: false, + line_height: 1.3, + max_chars_per_line: 11, + }, + }, + { + key: "business_dark", + label: "商务深色", + emoji: "💼", + style: { + ...BASE, + font: "思源黑体", + size: 44, + color: "#ffffff", + bold: false, + italic: false, + stroke: false, + shadow: true, + shadow_offset_x: 1, + shadow_offset_y: 1, + shadow_blur: 3, + shadow_color: "rgba(0,0,0,0.8)", + bg_enabled: true, + bg_color: "rgba(24,144,255,0.85)", + bg_padding: 12, + bg_radius: 4, + line_height: 1.3, + max_chars_per_line: 12, + }, + }, + { + key: "minimal_clean", + label: "极简无描边", + emoji: "✨", + style: { + ...BASE, + font: "苹方", + size: 48, + color: "#ffffff", + bold: true, + italic: false, + stroke: false, + shadow: false, + bg_enabled: false, + line_height: 1.3, + max_chars_per_line: 10, + }, + }, +] + +/** 根据 key 获取预设 */ +export function getTitlePreset(key: string): TitlePreset | undefined { + return TITLE_PRESETS.find((p) => p.key === key) +} diff --git a/apps/web/src/components/title/index.ts b/apps/web/src/components/title/index.ts new file mode 100644 index 000000000..9c53c2d54 --- /dev/null +++ b/apps/web/src/components/title/index.ts @@ -0,0 +1,19 @@ +/** + * 公共标题模板/样式组件统一导出 + * + * 任何页面需要标题样式配置/模板选择/模板编辑,从这里 import, + * 不要直接 import pages/generate/components/title/* 下的内部组件。 + */ +export { default as TitleTemplateSelector } from "./TitleTemplateSelector" +export { default as TitleTemplateEditor } from "./TitleTemplateEditor" +export { default as TitleStyleParamsTab } from "./TitleStyleParamsTab" +export { default as TitleMiniPreview } from "./TitleMiniPreview" +export { useTitleTemplates } from "./useTitleTemplates" +export * from "./constants" +export * from "./types" +export * from "./template-types" +export * from "./settings" +export * from "./utils" +export { POSITION_OPTIONS } from "./position-options" +export type { PositionOption, FontOption, TitleStyleParamsTabProps } from "./TitleStyleParamsTab" +export type { TitleTemplateSelectorProps } from "./TitleTemplateSelector" diff --git a/apps/web/src/components/title/position-options.ts b/apps/web/src/components/title/position-options.ts new file mode 100644 index 000000000..e43c982b3 --- /dev/null +++ b/apps/web/src/components/title/position-options.ts @@ -0,0 +1,14 @@ +/** + * 标题位置选项(公共常量) + */ +export interface PositionOption { + value: string + label: string +} + +export const POSITION_OPTIONS: PositionOption[] = [ + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, + { value: "custom", label: "自定义" }, +] diff --git a/apps/web/src/components/title/settings.ts b/apps/web/src/components/title/settings.ts new file mode 100644 index 000000000..9b194203a --- /dev/null +++ b/apps/web/src/components/title/settings.ts @@ -0,0 +1,64 @@ +/** + * 标题样式设置 — 公共 camelCase 类型与默认值 + * + * 本文件是 @/components/title 公共包的唯一样式类型出口,不依赖任何业务页面(generate/ai-avatar)的私有类型。 + * - 字段与后端 snake_case TitleStyleConfig 一一对应(camelCase 版本) + * - DEFAULT_TITLE_STYLE_SETTINGS 用于组件内部补全默认值 + * - aiAutoSelect / title / coverTitle 等业务状态不在本类型中——它们属于页面业务 state + */ +import type { TitleLineOverride } from "./types" + +export interface TitleStyleSettings { + position: string + font: string + size: number + bold: boolean + italic: boolean + stroke: boolean + shadow: boolean + color: string + posX: number | null + posY: number | null + lineHeight: number + marginTop: number + maxCharsPerLine: number + strokeWidth: number + strokeColor: string + shadowOffsetX: number + shadowOffsetY: number + shadowBlur: number + shadowColor: string + bgEnabled: boolean + bgColor: string + bgPadding: number + bgRadius: number + lineOverrides: TitleLineOverride[] +} + +/** 公共默认样式(经典白字黑描边) */ +export const DEFAULT_TITLE_STYLE_SETTINGS: TitleStyleSettings = { + position: "bottom", + font: "思源黑体", + size: 56, + bold: true, + italic: false, + stroke: true, + shadow: false, + color: "#ffffff", + posX: null, + posY: null, + lineHeight: 1.2, + marginTop: 24, + maxCharsPerLine: 10, + strokeWidth: 5, + strokeColor: "#000000", + shadowOffsetX: 2, + shadowOffsetY: 2, + shadowBlur: 4, + shadowColor: "rgba(0,0,0,0.8)", + bgEnabled: false, + bgColor: "rgba(0,0,0,0.5)", + bgPadding: 12, + bgRadius: 8, + lineOverrides: [], +} diff --git a/apps/web/src/components/title/template-types.ts b/apps/web/src/components/title/template-types.ts new file mode 100644 index 000000000..51c3e8459 --- /dev/null +++ b/apps/web/src/components/title/template-types.ts @@ -0,0 +1,28 @@ +/** + * 标题模板系统类型(#2003) + * + * 设计: + * - 内置系统模板(从 TITLE_PRESETS 自动派生,不可编辑/删除,可"复制为我的") + * - 用户自定义模板(保存在 localStorage,可编辑/复制/导出/删除) + * - 模板存完整 TitleStyleConfig(snake_case),与后端契约一致 + */ +import type { TitleStyleConfig } from "./types" + +export interface TitleTemplate { + /** 唯一 ID:系统模板 `sys:`,用户模板 `usr:` */ + id: string + /** 模板名称(必填,保存时校验) */ + name: string + /** 模板描述(可选) */ + description?: string + /** 是否为系统内置(不可删除/编辑源) */ + isBuiltin: boolean + /** emoji(展示用,可选) */ + emoji?: string + /** 创建时间(ISO 字符串,系统模板=固定值) */ + createdAt: string + /** 更新时间 */ + updatedAt: string + /** 完整样式配置(snake_case,与后端 title_config 对齐) */ + style: Partial +} diff --git a/apps/web/src/components/title/types.ts b/apps/web/src/components/title/types.ts new file mode 100644 index 000000000..098e9d275 --- /dev/null +++ b/apps/web/src/components/title/types.ts @@ -0,0 +1,118 @@ +/** + * 共享标题样式配置类型(#2001 爆款标题样式配置面板升级) + * + * 设计原则: + * 1. 向后兼容:保留旧的 bold/stroke/shadow 布尔字段,新增细粒度字段 + * (stroke_width/stroke_color/shadow_offset_x-y-blur-color/bg_enabled-color-padding-radius/line_height/margin_top/max_chars_per_line)。 + * 2. 后端契约:字段名使用 snake_case,与 title_config dict 直接对齐。 + * 3. line_overrides 支持逐行覆盖(选中某行单独设置颜色/字号/关键词高亮/加粗/斜体)。 + * 4. cover_title_config 为封面独立标题样式,null 表示封面沿用主标题样式。 + */ + +/** 关键词高亮配置 */ +export interface TitleKeywordHighlight { + /** 要高亮的词 */ + word: string + /** 高亮颜色(可选,默认主色反转) */ + color?: string + /** 是否加粗(默认 true) */ + bold?: boolean + /** 额外字号放大倍数(1.0=不变,1.3=放大 30%) */ + scale?: number +} + +/** 单行覆盖配置 */ +export interface TitleLineOverride { + /** 行索引(0-based,按 / 或自动换行后的行序) */ + line_index: number + /** 覆盖后的文字(可选,默认沿用原行) */ + text?: string + /** 覆盖字号(可选) */ + size?: number + /** 覆盖字色(可选) */ + color?: string + /** 覆盖加粗(可选) */ + bold?: boolean + /** 覆盖斜体(可选) */ + italic?: boolean + /** 覆盖描边开关(可选) */ + stroke?: boolean + /** 关键词高亮列表 */ + highlights?: TitleKeywordHighlight[] +} + +/** + * 标题样式配置(不含 title 文字本身,不含 auto_subtitle)。 + * + * cover_title_config 使用 Partial> 递归避免无限类型。 + */ +export interface TitleStyleConfig { + /* ── 基础 ── */ + font: string + size: number + color: string + bold: boolean + italic: boolean + position: "top" | "center" | "bottom" | "custom" + pos_x?: number + pos_y?: number + + /* ── 排版(P0) ── */ + /** 行距倍数(默认 1.2) */ + line_height: number + /** 顶部边距(position=top 时距画面顶部距离,px @720p,默认 24) */ + margin_top: number + /** 每行最大字符数(4-20,超出自动换行;0=不自动换行,使用 / 手动分行) */ + max_chars_per_line: number + + /* ── 描边参数化(P0) ── */ + stroke: boolean + stroke_width: number + stroke_color: string + + /* ── 阴影参数化(P1) ── */ + shadow: boolean + shadow_offset_x: number + shadow_offset_y: number + shadow_blur: number + shadow_color: string + + /* ── 背景色块(P1) ── */ + bg_enabled: boolean + bg_color: string + bg_padding: number + bg_radius: number + + /* ── 逐行独立样式(P1) ── */ + line_overrides: TitleLineOverride[] + + /* ── 封面独立标题配置(P1):null=沿用主标题样式 ── */ + cover_title_config: null | Partial> +} + +/** 默认样式(经典白字黑描边,保持老版本观感) */ +export const DEFAULT_TITLE_STYLE: TitleStyleConfig = { + font: "思源黑体", + size: 48, + color: "#ffffff", + bold: true, + italic: false, + position: "bottom", + line_height: 1.2, + margin_top: 24, + max_chars_per_line: 0, + stroke: true, + stroke_width: 4, + stroke_color: "#000000", + shadow: false, + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 4, + shadow_color: "rgba(0,0,0,0.8)", + bg_enabled: false, + bg_color: "rgba(0,0,0,0.5)", + bg_padding: 12, + bg_radius: 8, + line_overrides: [], + cover_title_config: null, +} diff --git a/apps/web/src/components/title/useTitleTemplates.ts b/apps/web/src/components/title/useTitleTemplates.ts new file mode 100644 index 000000000..c8a26acac --- /dev/null +++ b/apps/web/src/components/title/useTitleTemplates.ts @@ -0,0 +1,213 @@ +/** + * 标题模板 CRUD Hook(#2003) + * + * - 内存态 + localStorage 持久化 + * - 系统模板(来自 TITLE_PRESETS)始终前置、不可删除/编辑源 + * - 用户模板 CRUD:新增/复制/更新/删除/导出/导入 + */ +import { useCallback, useEffect, useMemo, useState } from "react" +import { TITLE_PRESETS } from "./constants" +import type { TitleStyleConfig } from "./types" +import type { TitleTemplate } from "./template-types" + +const STORAGE_KEY = "xiaoxia.title.templates.v1" + +function uid(): string { + return "usr:" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36) +} + +/** 把 TITLE_PRESETS 转为内置 TitleTemplate 列表 */ +function buildBuiltinTemplates(): TitleTemplate[] { + const now = "2026-09-22T00:00:00+08:00" + return TITLE_PRESETS.map((p) => ({ + id: `sys:${p.key}`, + name: p.label, + description: "系统内置模板", + isBuiltin: true, + emoji: p.emoji, + createdAt: now, + updatedAt: now, + style: { ...p.style }, + })) +} + +function loadUserTemplates(): TitleTemplate[] { + if (typeof window === "undefined") return [] + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter((t) => t && !t.isBuiltin && t.id.startsWith("usr:")) + } catch { + return [] + } +} + +function saveUserTemplates(list: TitleTemplate[]) { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(list)) + } catch { + /* ignore quota */ + } +} + +export interface UseTitleTemplatesResult { + templates: TitleTemplate[] + builtin: TitleTemplate[] + custom: TitleTemplate[] + /** 新建自定义模板:传入样式(snake_case),返回新建模板 id */ + createTemplate: (input: { + name: string + description?: string + emoji?: string + style: Partial + }) => TitleTemplate + /** 复制模板(内置模板也可复制,自动加"副本"后缀) */ + duplicateTemplate: (id: string) => TitleTemplate | null + /** 更新自定义模板(系统模板不可改) */ + updateTemplate: (id: string, patch: Partial>) => void + /** 删除自定义模板 */ + deleteTemplate: (id: string) => void + /** 导出单个模板为 JSON 字符串 */ + exportTemplate: (id: string) => string | null + /** 导入 JSON 字符串作为新模板,返回新建模板 */ + importTemplate: (json: string) => TitleTemplate | null + /** 根据 id 查询模板 */ + getById: (id: string) => TitleTemplate | undefined +} + +export function useTitleTemplates(): UseTitleTemplatesResult { + const [custom, setCustom] = useState(() => loadUserTemplates()) + + // 跨 tab 同步 + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY) setCustom(loadUserTemplates()) + } + window.addEventListener("storage", onStorage) + return () => window.removeEventListener("storage", onStorage) + }, []) + + const builtin = useMemo(() => buildBuiltinTemplates(), []) + const templates = useMemo(() => [...builtin, ...custom], [builtin, custom]) + + const persist = useCallback((next: TitleTemplate[]) => { + setCustom(next) + saveUserTemplates(next) + }, []) + + const getById = useCallback((id: string) => templates.find((t) => t.id === id), [templates]) + + const createTemplate = useCallback( + ({ name, description, emoji, style }) => { + const now = new Date().toISOString() + const t: TitleTemplate = { + id: uid(), + name: name.trim() || "未命名模板", + description: description?.trim() || undefined, + emoji: emoji || "✨", + isBuiltin: false, + createdAt: now, + updatedAt: now, + style: { ...style }, + } + persist([...custom, t]) + return t + }, + [custom, persist], + ) + + const duplicateTemplate = useCallback( + (id) => { + const src = templates.find((t) => t.id === id) + if (!src) return null + const now = new Date().toISOString() + const t: TitleTemplate = { + id: uid(), + name: `${src.name} 副本`, + description: src.description, + emoji: src.emoji, + isBuiltin: false, + createdAt: now, + updatedAt: now, + style: { ...src.style }, + } + persist([...custom, t]) + return t + }, + [templates, custom, persist], + ) + + const updateTemplate = useCallback( + (id, patch) => { + if (id.startsWith("sys:")) return + const next = custom.map((t) => + t.id === id + ? { ...t, ...patch, id: t.id, isBuiltin: false, updatedAt: new Date().toISOString() } + : t, + ) + persist(next) + }, + [custom, persist], + ) + + const deleteTemplate = useCallback( + (id) => { + if (id.startsWith("sys:")) return + persist(custom.filter((t) => t.id !== id)) + }, + [custom, persist], + ) + + const exportTemplate = useCallback( + (id) => { + const t = templates.find((x) => x.id === id) + if (!t) return null + return JSON.stringify( + { + name: t.name, + description: t.description, + emoji: t.emoji, + style: t.style, + exportedAt: new Date().toISOString(), + }, + null, + 2, + ) + }, + [templates], + ) + + const importTemplate = useCallback( + (json) => { + try { + const data = JSON.parse(json) + if (!data || typeof data !== "object" || !data.style) return null + return createTemplate({ + name: data.name || "导入模板", + description: data.description, + emoji: data.emoji || "✨", + style: data.style, + }) + } catch { + return null + } + }, + [createTemplate], + ) + + return { + templates, + builtin, + custom, + createTemplate, + duplicateTemplate, + updateTemplate, + deleteTemplate, + exportTemplate, + importTemplate, + getById, + } +} diff --git a/apps/web/src/components/title/utils.ts b/apps/web/src/components/title/utils.ts new file mode 100644 index 000000000..95c25df2f --- /dev/null +++ b/apps/web/src/components/title/utils.ts @@ -0,0 +1,126 @@ +/** + * 标题样式工具(#2001 / 模板系统 #2003) + * + * - snake_case TitleStyleConfig <-> camelCase TitleStyleSettings 互转 + * - preset 归一化预览(修复"标题"两字大小不一) + * - template -> preview settings 转换 + */ +import type { TitleStyleConfig } from "./types" +import type { TitleStyleSettings } from "./settings" +import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings" +import { TITLE_PRESETS } from "./constants" +import type { TitleTemplate } from "./template-types" + +/** snake_case TitleStyleConfig -> camelCase TitleStyleSettings(仅覆盖已知字段) */ +export function titleStyleConfigToCamel(s: Partial): Partial { + const out: Partial = {} + if (s.font != null) out.font = s.font + if (s.size != null) out.size = s.size + if (s.color != null) out.color = s.color + if (s.bold != null) out.bold = s.bold + if (s.italic != null) out.italic = s.italic + if (s.position != null) out.position = s.position + if (s.pos_x != null) out.posX = s.pos_x + if (s.pos_y != null) out.posY = s.pos_y + if (s.line_height != null) out.lineHeight = s.line_height + if (s.margin_top != null) out.marginTop = s.margin_top + if (s.max_chars_per_line != null) out.maxCharsPerLine = s.max_chars_per_line + if (s.stroke != null) out.stroke = s.stroke + if (s.stroke_width != null) out.strokeWidth = s.stroke_width + if (s.stroke_color != null) out.strokeColor = s.stroke_color + if (s.shadow != null) out.shadow = s.shadow + if (s.shadow_offset_x != null) out.shadowOffsetX = s.shadow_offset_x + if (s.shadow_offset_y != null) out.shadowOffsetY = s.shadow_offset_y + if (s.shadow_blur != null) out.shadowBlur = s.shadow_blur + if (s.shadow_color != null) out.shadowColor = s.shadow_color + if (s.bg_enabled != null) out.bgEnabled = s.bg_enabled + if (s.bg_color != null) out.bgColor = s.bg_color + if (s.bg_padding != null) out.bgPadding = s.bg_padding + if (s.bg_radius != null) out.bgRadius = s.bg_radius + if (s.line_overrides != null) out.lineOverrides = s.line_overrides + return out +} + +/** camelCase TitleStyleSettings patch -> snake_case TitleStyleConfig patch */ +export function camelToTitleStyleConfig(p: Partial): Partial { + const out: Partial = {} + if (p.font != null) out.font = p.font + if (p.size != null) out.size = p.size + if (p.color != null) out.color = p.color + if (p.bold != null) out.bold = p.bold + if (p.italic != null) out.italic = p.italic + if (p.position != null) out.position = p.position as TitleStyleConfig["position"] + if (p.posX != null) out.pos_x = p.posX + if (p.posY != null) out.pos_y = p.posY + if (p.lineHeight != null) out.line_height = p.lineHeight + if (p.marginTop != null) out.margin_top = p.marginTop + if (p.maxCharsPerLine != null) out.max_chars_per_line = p.maxCharsPerLine + if (p.stroke != null) out.stroke = p.stroke + if (p.strokeWidth != null) out.stroke_width = p.strokeWidth + if (p.strokeColor != null) out.stroke_color = p.strokeColor + if (p.shadow != null) out.shadow = p.shadow + if (p.shadowOffsetX != null) out.shadow_offset_x = p.shadowOffsetX + if (p.shadowOffsetY != null) out.shadow_offset_y = p.shadowOffsetY + if (p.shadowBlur != null) out.shadow_blur = p.shadowBlur + if (p.shadowColor != null) out.shadow_color = p.shadowColor + if (p.bgEnabled != null) out.bg_enabled = p.bgEnabled + if (p.bgColor != null) out.bg_color = p.bgColor + if (p.bgPadding != null) out.bg_padding = p.bgPadding + if (p.bgRadius != null) out.bg_radius = p.bgRadius + if (p.lineOverrides != null) out.line_overrides = p.lineOverrides + return out +} + +/** + * 把 preset style(snake_case)归一化为固定字号的 TitleStyleSettings, + * 用于"预设卡片"缩略预览——所有卡片视觉上"标题"两字大小一致,便于辨识。 + * 描边/阴影/背景padding 按 fixedSize / 原始 size 比例缩放,避免粗描边爆框。 + */ +export function buildPresetPreviewSettings( + base: TitleStyleSettings, + presetKey: string, + fixedSize = 56, +): TitleStyleSettings { + const preset = TITLE_PRESETS.find((p) => p.key === presetKey) + if (!preset) return base + const origSize = preset.style.size ?? fixedSize + const ratio = fixedSize / origSize + const scale = (v: number | undefined, fallback: number): number => + v != null ? Math.round(v * ratio) : fallback + return { + ...DEFAULT_TITLE_STYLE_SETTINGS, + ...base, + ...titleStyleConfigToCamel(preset.style), + size: fixedSize, + strokeWidth: scale(preset.style.stroke_width, base.strokeWidth), + shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX), + shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY), + shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur), + bgPadding: scale(preset.style.bg_padding, base.bgPadding), + lineOverrides: [], + } +} + +/** + * 把 TitleTemplate 渲染为完整 TitleStyleSettings(带默认值),用于卡片预览。 + */ +export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleStyleSettings { + const base: TitleStyleSettings = { + ...DEFAULT_TITLE_STYLE_SETTINGS, + ...titleStyleConfigToCamel(t.style), + } + // 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放 + const origSize = t.style.size ?? base.size + if (origSize && origSize !== fixedSize) { + const ratio = fixedSize / origSize + base.size = fixedSize + if (base.strokeWidth != null) + base.strokeWidth = Math.max(1, Math.round(base.strokeWidth * ratio)) + if (base.shadowOffsetX != null) base.shadowOffsetX = Math.round(base.shadowOffsetX * ratio) + if (base.shadowOffsetY != null) base.shadowOffsetY = Math.round(base.shadowOffsetY * ratio) + if (base.shadowBlur != null) base.shadowBlur = Math.round(base.shadowBlur * ratio) + if (base.bgPadding != null) base.bgPadding = Math.round(base.bgPadding * ratio) + } + base.lineOverrides = [] + return base +} diff --git a/apps/web/src/components/voice/TtsStyleSelector/index.tsx b/apps/web/src/components/voice/TtsStyleSelector/index.tsx new file mode 100644 index 000000000..56e023f88 --- /dev/null +++ b/apps/web/src/components/voice/TtsStyleSelector/index.tsx @@ -0,0 +1,151 @@ +/** + * TTS 配音风格选择器 + * - 6 种预设风格卡片(自然亲切 / 激动兴奋 / 沉稳专业 / 温柔甜美 / 新闻播报 / 直播带货) + * - 卡片单选,选中高亮紫色 + * - 默认 natural + * + * 复用方式: + * + * // 紧凑模式(小尺寸) + */ +import React from "react" +import { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles" + +export interface TtsStyleSelectorProps { + value?: TtsStyle | string + onChange: (style: TtsStyle) => void + /** 紧凑模式(小卡片),适合与其他参数并排 */ + compact?: boolean + /** 是否显示"配音风格"标签 */ + showLabel?: boolean +} + +const TtsStyleSelector: React.FC = ({ + value, + onChange, + compact = false, + showLabel = true, +}) => { + const current = value || DEFAULT_TTS_STYLE + + if (compact) { + return ( +
+ {showLabel && ( +
+ 配音风格 +
+ )} +
+ {TTS_STYLE_OPTIONS.map((opt) => { + const selected = current === opt.value + return ( + + ) + })} +
+
+ ) + } + + return ( +
+ {showLabel && ( +
+ 配音风格 +
+ )} +
+ {TTS_STYLE_OPTIONS.map((opt) => { + const selected = current === opt.value + return ( + + ) + })} +
+
+ ) +} + +export default TtsStyleSelector diff --git a/apps/web/src/config/features.ts b/apps/web/src/config/features.ts new file mode 100644 index 000000000..c208821f0 --- /dev/null +++ b/apps/web/src/config/features.ts @@ -0,0 +1,13 @@ +/** + * 功能开关配置 + * 集中管理前端特性的启用/隐藏,便于灰度与回滚。 + * 注意:仅控制 UI 展示与前端校验,后端扣减逻辑由后端对应开关控制。 + */ + +/** + * 积分系统 UI 开关(默认 false = 隐藏) + * - false:隐藏所有积分相关入口/余额/消耗提示/不足弹窗/充值入口;会员标识保留; + * 功能流程不做积分预校验,直接走生成。 + * - true:展示完整积分系统 UI。 + */ +export const ENABLE_CREDIT_SYSTEM = false diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index 846a246a7..a54a8873b 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -3,6 +3,7 @@ * Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复 */ import React from "react" +import { ENABLE_CREDIT_SYSTEM } from "./features" import { DashboardOutlined, FileOutlined, @@ -17,6 +18,7 @@ import { ThunderboltOutlined, UnorderedListOutlined, UserOutlined, + FireOutlined, } from "@ant-design/icons" /** 导航项类型 */ @@ -75,6 +77,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/ai-avatar", icon: React.createElement(UserOutlined), }, + { + key: "viral-video", + label: "爆款视频", + path: "/app/viral-video", + icon: React.createElement(FireOutlined), + }, { key: "history", label: "任务历史", @@ -105,12 +113,17 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/subscription", icon: React.createElement(CrownOutlined), }, - { - key: "points", - label: "积分中心", - path: "/app/points", - icon: React.createElement(ThunderboltOutlined), - }, + // 积分系统开关关闭时隐藏积分中心入口(代码保留不删除) + ...(ENABLE_CREDIT_SYSTEM + ? [ + { + key: "points", + label: "积分中心", + path: "/app/points", + icon: React.createElement(ThunderboltOutlined), + }, + ] + : []), ] /** 侧边栏导航分组(Sidebar 分组列表使用) */ @@ -136,6 +149,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/ai-avatar", icon: React.createElement(UserOutlined), }, + { + key: "viral-video", + label: "爆款视频", + path: "/app/viral-video", + icon: React.createElement(FireOutlined), + }, ], }, { @@ -200,12 +219,17 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/subscription", icon: React.createElement(CrownOutlined), }, - { - key: "points", - label: "积分中心", - path: "/app/points", - icon: React.createElement(ThunderboltOutlined), - }, + // 积分系统开关关闭时隐藏积分中心入口(代码保留不删除) + ...(ENABLE_CREDIT_SYSTEM + ? [ + { + key: "points", + label: "积分中心", + path: "/app/points", + icon: React.createElement(ThunderboltOutlined), + }, + ] + : []), ], }, ] diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index a9a10e723..84c7017cd 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -30,11 +30,7 @@ import { } from "./api/aiAvatar" import { getOrCreateDefaultProject } from "@/api/projects" import type { RenderJob, SentenceTiming } from "./types" -import { - normalizeEmotion, - buildTitleConfigPayload, - buildCoverConfigPayload, -} from "./utils/contract" +import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract" import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas" /** 面板折叠状态 */ @@ -65,6 +61,10 @@ const AiAvatarPage: React.FC = () => { "generating", ) const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("") + /* ── 对口型耗时计时(秒) ── */ + const [lipsyncElapsed, setLipsyncElapsed] = useState(0) + const lipsyncStartAtRef = useRef(0) + const lipsyncTickRef = useRef | null>(null) /* ── 渲染进度弹窗 ── */ const [showRenderModal, setShowRenderModal] = useState(false) const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">( @@ -94,7 +94,7 @@ const AiAvatarPage: React.FC = () => { state.resetTtsPreview() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion]) + }, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.style]) const _clearTtsProgressTimer = useCallback(() => { if (ttsProgressTimerRef.current) { @@ -148,7 +148,7 @@ const AiAvatarPage: React.FC = () => { voice_id: state.selectedVoice!.voice_id, script_text: state.scriptText, speed: state.speed, - emotion: normalizeEmotion(state.emotion), + style: state.style, }) _clearTtsProgressTimer() setTtsProgress(100) @@ -175,7 +175,7 @@ const AiAvatarPage: React.FC = () => { }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion]) + }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.style]) const handleRetryTts = useCallback(() => { handleGenerateTts() @@ -226,6 +226,13 @@ const AiAvatarPage: React.FC = () => { setShowLipsyncModal(true) setLipsyncStatus("generating") setLipsyncErrorMessage("") + // 启动计时器 + lipsyncStartAtRef.current = Date.now() + setLipsyncElapsed(0) + if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current) + lipsyncTickRef.current = setInterval(() => { + setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000)) + }, 1000) const asset = await getAssetById(video.id) const videoUrl = asset?.file_url @@ -254,7 +261,7 @@ const AiAvatarPage: React.FC = () => { script_text: state.scriptText, video_url: videoUrl, speed: state.speed, - emotion: normalizeEmotion(state.emotion), + style: state.style, } } const job = await createLipsyncJob(payload) @@ -269,6 +276,11 @@ const AiAvatarPage: React.FC = () => { state.setLipsyncJob(updated) if (updated.status === "completed") { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + if (lipsyncTickRef.current) { + clearInterval(lipsyncTickRef.current) + lipsyncTickRef.current = null + } + setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000)) setLipsyncStatus("completed") setTimeout(() => { setShowLipsyncModal(false) @@ -276,6 +288,10 @@ const AiAvatarPage: React.FC = () => { }, 1000) } else if (updated.status === "failed") { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + if (lipsyncTickRef.current) { + clearInterval(lipsyncTickRef.current) + lipsyncTickRef.current = null + } setLipsyncStatus("failed") setLipsyncErrorMessage(updated.error_message || "对口型生成失败") } @@ -289,6 +305,10 @@ const AiAvatarPage: React.FC = () => { data: (err as { response?: { data?: unknown } })?.response?.data, message: err instanceof Error ? err.message : String(err), }) + if (lipsyncTickRef.current) { + clearInterval(lipsyncTickRef.current) + lipsyncTickRef.current = null + } setShowLipsyncModal(false) message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试") } @@ -298,7 +318,8 @@ const AiAvatarPage: React.FC = () => { state.selectedVoice, state.scriptText, state.speed, - state.emotion, + + state.style, state.ttsPreview, ]) @@ -308,15 +329,21 @@ const AiAvatarPage: React.FC = () => { clearInterval(lipsyncTimerRef.current) lipsyncTimerRef.current = null } + if (lipsyncTickRef.current) { + clearInterval(lipsyncTickRef.current) + lipsyncTickRef.current = null + } setShowLipsyncModal(false) setLipsyncStatus("generating") setLipsyncErrorMessage("") + setLipsyncElapsed(0) }, []) // 清理轮询 useEffect(() => { return () => { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current) if (renderTimerRef.current) clearInterval(renderTimerRef.current) } }, []) @@ -598,8 +625,8 @@ const AiAvatarPage: React.FC = () => { onVoiceSourceChange={state.setVoiceSource} selectedVoice={state.selectedVoice} onSelectVoice={state.setSelectedVoice} - emotion={state.emotion} - onEmotionChange={state.setEmotion} + style={state.style} + onStyleChange={state.setStyle} speed={state.speed} onSpeedChange={state.setSpeed} language={state.language} @@ -699,17 +726,11 @@ const AiAvatarPage: React.FC = () => {
{currentRenderJob?.status !== "completed" ? ( - state.setCoverConfig((prev) => ({ ...prev, ...partial })) - } - renderJob={currentRenderJob} - onGenerateRenderSmartCover={handleGenerateRenderSmartCover} resolution={state.resolution} onResolutionChange={state.setResolution} isGenerating={state.isGenerating} onGenerate={handleGenerate} + renderJob={currentRenderJob} summary={summary} /> ) : ( @@ -966,6 +987,19 @@ const AiAvatarPage: React.FC = () => {
对口型视频生成中…
+
+ {`${Math.floor(lipsyncElapsed / 60) + .toString() + .padStart(2, "0")}:${(lipsyncElapsed % 60).toString().padStart(2, "0")}`} +
请勿关闭页面,完成后将自动提示
@@ -977,6 +1011,20 @@ const AiAvatarPage: React.FC = () => {
对口型视频生成完成
+
+ 总耗时{" "} + {Math.floor(lipsyncElapsed / 60) + .toString() + .padStart(2, "0")} + :{(lipsyncElapsed % 60).toString().padStart(2, "0")} +
)} {lipsyncStatus === "failed" && ( diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index 1671b7e30..d8dbc2037 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -41,10 +41,13 @@ export const createLipsyncJob = async (data: { speed?: number /** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgusted(TTS 直生模式用;前端经 normalizeEmotion 归一化) */ emotion?: string + /** 配音风格预设(natural/excited/professional/sweet/news/livestream) */ + style?: string enable_video_loop?: boolean project_id?: string }): Promise => { - const response = await apiClient.post("/lipsync/jobs", data) + // GPU 口型同步推理约 20s,留足余量到 120s 防止 10s 默认超时 + const response = await apiClient.post("/lipsync/jobs", data, { timeout: 120_000 }) return response.data } @@ -54,6 +57,7 @@ export const previewTts = async (data: { script_text: string speed?: number emotion?: string + style?: string }): Promise<{ audio_url: string duration: number @@ -95,15 +99,18 @@ export const cancelRenderJob = async (jobId: string): Promise => { await apiClient.post(`/ai-avatar/render/${jobId}/cancel`) } -/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */ +/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ── + * #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧) + */ export const generateRenderSmartCover = async ( jobId: string, + templateId: string = "default", ): Promise<{ cover_url: string; status: string; message: string }> => { const response = await apiClient.post<{ cover_url: string; status: string; message: string }>( `/ai-avatar/render/${jobId}/smart-cover`, - {}, - // 抽帧+评分+转存 OSS 链路较长,120s 超时 - { timeout: 120000 }, + templateId && templateId !== "default" ? { template_id: templateId } : {}, + // 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s + { timeout: templateId && templateId !== "default" ? 180000 : 120000 }, ) return response.data } diff --git a/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx b/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx index 55654476a..126af59dc 100644 --- a/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx +++ b/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx @@ -1,11 +1,25 @@ /** - * AI数字人 — 封面选择弹窗 - * 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供 - * 智能抽帧 + 自定义上传 + 预览 + 确定按钮。 + * AI数字人 — 封面选择弹窗(#2033 共享封面组件重构) + * + * 复用智能剪辑的 CoverSettingsModal(模板选择)+ CoverEditorModal(7 面板自定义编辑器) + * + 智能生成 / 本地上传 / 封面预览,与智能剪辑侧 UI 一致。 + * + * 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构: + * - 智能生成封面:mode="auto_frame",thumbnail_url/smart_cover_url 指向后端返回的 cover_url + * - 本地上传封面:mode="upload",upload_url/thumbnail_url 指向 blob 预览 URL + * + * 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。 */ -import React from "react" +import React, { useCallback, useEffect, useMemo } from "react" +import { Modal as AntModal, Spin, message } from "antd" +import { LoadingOutlined } from "@ant-design/icons" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" +import CoverSettingsModal from "@/pages/generate/components/cover-settings/CoverSettingsModal" +import CoverEditorModal from "@/pages/generate/components/cover-settings/CoverEditorModal" +import { useSharedCover } from "@/components/cover/useSharedCover" +import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar" import type { AiAvatarCoverConfig, RenderJob } from "../types" -import PanelCoverAndGenerate from "./PanelCoverAndGenerate" interface ModalCoverSelectProps { open: boolean @@ -13,7 +27,13 @@ interface ModalCoverSelectProps { renderJob: RenderJob | null coverConfig: AiAvatarCoverConfig onCoverConfigChange: (partial: Partial) => void - onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }> + /** + * 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入 + * 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。 + */ + onGenerateRenderSmartCover?: ( + renderId: string, + ) => Promise<{ cover_url: string; message?: string }> onUploadCover?: (file: File) => void onCoverSelected: (coverUrl: string) => void } @@ -28,31 +48,302 @@ const ModalCoverSelect: React.FC = ({ onUploadCover, onCoverSelected, }) => { + const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id + + const generateFn = useCallback( + async (templateId: string): Promise => { + if (!renderJob || !isRenderCompleted) return null + try { + let coverUrl = "" + if (onGenerateRenderSmartCover) { + const res = await onGenerateRenderSmartCover(renderJob.id) + coverUrl = res.cover_url + } else { + const res = await apiGenerateSmartCover(renderJob.id, templateId) + coverUrl = res.cover_url + if (!coverUrl && res.message) { + const err = new Error(res.message) as Error & { __msgShown?: boolean } + err.__msgShown = true + message.error(res.message) + throw err + } + } + if (coverUrl) { + onCoverConfigChange({ + mode: "auto_frame", + thumbnail_url: coverUrl, + smart_cover_url: coverUrl, + }) + onCoverSelected(coverUrl) + message.success("智能封面已生成") + } + return coverUrl || null + } catch (err) { + const anyErr = err as { __msgShown?: boolean; message?: string } + if (!anyErr?.__msgShown) { + message.error(anyErr?.message || "智能封面生成失败") + } + throw err + } + }, + [ + renderJob, + isRenderCompleted, + onGenerateRenderSmartCover, + onCoverConfigChange, + onCoverSelected, + ], + ) + + const shared = useSharedCover({ + canGenerate: isRenderCompleted, + disabledHint: "请先完成视频生成再选择封面", + initialTemplateId: "default", + generateFn, + }) + + // 父层 onUploadCover 走 onUploadFile 回调(兼容老父组件) + useEffect(() => { + shared.setOnUploadFile((file: File) => { + if (onUploadCover) { + onUploadCover(file) + } else { + const url = URL.createObjectURL(file) + onCoverConfigChange({ + mode: "upload", + upload_url: url, + thumbnail_url: url, + }) + onCoverSelected(url) + } + return null + }) + }, [shared, onUploadCover, onCoverConfigChange, onCoverSelected]) + + // 打开时同步刷新模板列表 + useEffect(() => { + if (open) void shared.reloadTemplates() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + /** 当前预览 URL:智能封面 > 自定义上传 */ + const previewUrl = useMemo( + () => coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "", + [coverConfig.smart_cover_url, coverConfig.thumbnail_url, coverConfig.upload_url], + ) + if (!open) return null return ( -
-
e.stopPropagation()} style={{ maxWidth: 480 }}> -
- 选择封面 - + + +
-
- + } + > +
+ {renderJob && ( +
+ 🎬 从渲染成片中智能选帧 + {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( + <> + {" "} + · 当前模板:{shared.selectedTemplateName} + + )} +
+ )} + +
+ {/* 左:封面预览 */} +
+
+ {previewUrl ? ( + 封面预览 + ) : ( +
+ 🖼️ + + {isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"} + +
+ )} + {shared.generating && ( +
+ } /> + AI 选帧中… +
+ )} +
+
+ 9:16 竖版封面 +
+
+ + {/* 右:操作按钮 */} +
+ + + + +
+ 💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。 +
+
-
+ + {/* 模板选择弹窗 */} + shared.setShowCoverSettings(false)} + templates={shared.templates} + loading={shared.templatesLoading} + error={shared.templatesError} + selectedTemplateId={shared.selectedTemplateId} + onSelectTemplate={shared.handleSelectTemplate} + onEditTemplate={shared.handleEditTemplate} + onDeleteTemplate={shared.handleDeleteTemplate} + onCreateNew={shared.handleCreateTemplate} + /> + + {/* 自定义编辑器弹窗 */} + shared.setShowCoverEditor(false)} + template={shared.editingTemplate} + onSave={shared.handleSaveTemplate} + /> + + {/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */} + +
+ +

+ AI 正在从最终成片选帧,请稍候... +

+
+
+ ) } diff --git a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx index 72a6c62f3..feda83d02 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx @@ -1,36 +1,19 @@ /** - * AI数字人 — 面板5 / 封面选择弹窗内容: - * - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段; - * 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。 - * - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览), - * 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。 - * - * 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。 + * AI数字人 — 面板5 / 生成配置面板(渲染前) + * #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮) + * 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件) */ -import React, { useRef, useState } from "react" -import type { AiAvatarCoverConfig, RenderJob } from "../types" - -type PanelVariant = "setup" | "select-cover" +import React from "react" +import type { RenderJob } from "../types" interface PanelCoverAndGenerateProps { - variant?: PanelVariant - coverConfig: AiAvatarCoverConfig - onCoverConfigChange: (partial: Partial) => void resolution?: string onResolutionChange?: (r: string) => void isGenerating?: boolean onGenerate?: () => void - /** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */ + /** 当前渲染任务 */ renderJob: RenderJob | null - /** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */ - onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }> - /** 自定义上传封面(选择本地文件后由父组件处理实际上传) */ - onUploadCover?: (file: File) => void - /** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */ - onClose?: () => void - /** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */ - onCoverSelected?: (coverUrl: string) => void - /** 配置汇总信息(仅 variant="setup" 使用) */ + /** 配置汇总信息 */ summary?: { videoName: string | null voiceName: string | null @@ -38,7 +21,6 @@ interface PanelCoverAndGenerateProps { lipsyncStatus: string | null brollCount: number hasTitle: boolean - /** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */ coverStatus: "not_ready" | "pending" | "selected" } } @@ -58,89 +40,15 @@ const LIPSYNC_STATUS_LABEL: Record = { } const PanelCoverAndGenerate: React.FC = ({ - variant = "setup", - coverConfig, - onCoverConfigChange, resolution = "720p", onResolutionChange, isGenerating = false, onGenerate, - renderJob, - onGenerateRenderSmartCover, - onUploadCover, - onClose, - onCoverSelected, + renderJob: _renderJob, summary, }) => { - const uploadInputRef = useRef(null) - // 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序) - const [smartCoverLoading, setSmartCoverLoading] = useState(false) - - /** 自定义上传封面 */ - const handleUploadClick = () => { - uploadInputRef.current?.click() - } - - const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => { - const partial: Partial = { - mode, - thumbnail_url: url, - } - if (mode === "auto_frame") { - partial.smart_cover_url = url - } else { - partial.upload_url = url - } - onCoverConfigChange(partial) - onCoverSelected?.(url) - } - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - if (onUploadCover) { - onUploadCover(file) - e.target.value = "" - return - } - // 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示) - const url = URL.createObjectURL(file) - _applyCoverUrl(url, "upload") - e.target.value = "" - } - - /** 智能获取封面(从最终成片抽帧;必须等 render 完成) */ - const handleSmartCover = async () => { - if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return - setSmartCoverLoading(true) - try { - const res = await onGenerateRenderSmartCover(renderJob.id) - if (res.cover_url) { - _applyCoverUrl(res.cover_url, "auto_frame") - } else { - // 失败由父组件 message 提示,这里不重复弹窗 - console.warn("[智能封面] 返回空 cover_url:", res.message) - } - } catch (err) { - console.error("[智能封面] 调用失败:", err) - } finally { - setSmartCoverLoading(false) - } - } - const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating - // 渲染已完成 → 封面区可用 - const isRenderCompleted = renderJob?.status === "completed" - const canSmartCover = isRenderCompleted && !smartCoverLoading - - /** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */ - const coverUrl = - coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url - const hasCoverImage = Boolean(coverUrl) - - /** 封面区占位文字 */ - const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面" /** 配置摘要中的封面状态标签 */ const coverSummaryNode = (() => { @@ -154,69 +62,6 @@ const PanelCoverAndGenerate: React.FC = ({ return 生成视频后可选 })() - // ── 封面选择区(两种 variant 共用) ───────────────────────────────── - const coverSection = ( -
-
- {variant === "select-cover" ? "选择封面" : "封面"} -
- {/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */} -
- {hasCoverImage ? ( - 封面预览 - ) : ( - {coverPlaceholder} - )} - {smartCoverLoading &&
⏳ 智能选帧中…
} -
- -
- - - -
-
- ) - - // ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ── - if (variant === "select-cover") { - return ( -
- {coverSection} - {onClose && ( -
- -
- )} -
- ) - } - - // ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ── return (
{/* 分辨率选择 */} diff --git a/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx b/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx index c7d0ba19c..98cbd04ae 100755 --- a/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx @@ -29,15 +29,7 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, "0")}` } -/** 字体名 → CSS font-family 映射(与 titleCanvas 字体链对齐) */ -const FONT_FAMILY_MAP: Record = { - 思源黑体: - "'Noto Sans CJK SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif", - 思源宋体: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif", - 楷体: "KaiTi, 'STKaiti', serif", - 黑体: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif", -} -const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"] +import { getFontFamily as getFontFamilyByKey } from "@/components/title/constants" export function PanelLipsyncPreview({ lipsyncJob, @@ -83,63 +75,105 @@ export function PanelLipsyncPreview({ const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35 const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale]) - /** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */ - const titleOverlayStyle: React.CSSProperties | null = + /** 标题叠加样式(新字段全支持:描边宽色/阴影参数化/背景块/行距/顶部边距/自动换行) */ + const titleOverlayData = titleConfig?.title && containerWidth > 0 ? (() => { + const c = titleConfig as AiAvatarTitleConfig & { + stroke_width?: number + stroke_color?: string + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + line_height?: number + margin_top?: number + max_chars_per_line?: number + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + cover_title_config?: Record | null + line_overrides?: unknown[] + } const baseSize = titleConfig.size || 48 const fontSize = ps(baseSize) - // 描边宽度基准 ≈ size * 0.06,最小 1.5px @720p - const strokeW = Math.max(ps(1.5), +(baseSize * 0.06 * previewScale).toFixed(2)) - // 阴影按比例缩放 - const shadowBlur = ps(4) - const shadowOffsetY = ps(2) - // padding / top 边距按比例(基准 8px 对应预览小窗,成片基准 16px,这里 8px 对应约 0.33 缩放) - const padV = ps(16) * 0.5 // ≈ 8px in ~240px container - const padH = ps(24) * 0.5 - + const strokeW = c.stroke ? ps(c.stroke_width ?? 4) : 0 + const strokeC = c.stroke_color || "#000000" + const shBlur = ps(c.shadow_blur ?? 4) + const shOffX = ps(c.shadow_offset_x ?? 2) + const shOffY = ps(c.shadow_offset_y ?? 2) + const shColor = c.shadow_color || "rgba(0,0,0,0.8)" + const lh = c.line_height ?? 1.2 + const mTop = ps(c.margin_top ?? 24) + const bgPad = ps(c.bg_padding ?? 12) + const bgR = ps(c.bg_radius ?? 8) + const maxChars = c.max_chars_per_line ?? 0 + const rawText = titleConfig.title || "" + const lines = (() => { + const manual = rawText + .split(/[//]/) + .map((l) => l.trim()) + .filter(Boolean) + if (!maxChars || maxChars <= 0) return manual + const out: string[] = [] + manual.forEach((seg) => { + for (let i = 0; i < seg.length; i += maxChars) out.push(seg.slice(i, i + maxChars)) + }) + return out + })() + const padV = ps(16) * 0.5 + const textShadow = titleConfig.shadow + ? `${shOffX}px ${shOffY}px ${shBlur}px ${shColor}` + : undefined const style: React.CSSProperties = { position: "absolute", color: titleConfig.color || "#ffffff", - fontFamily: getFontFamily(titleConfig.font || "思源黑体"), + fontFamily: getFontFamilyByKey(titleConfig.font || "source_sans_sc"), fontSize: `${fontSize}px`, fontWeight: titleConfig.bold ? 700 : 400, fontStyle: titleConfig.italic ? "italic" : "normal", textAlign: "center", - width: "90%", - lineHeight: 1.2, - padding: `${ps(4)}px ${padH}px`, - textShadow: titleConfig.shadow - ? `0 ${shadowOffsetY}px ${shadowBlur}px rgba(0,0,0,0.8), 0 0 ${ps(2)}px rgba(0,0,0,0.5)` - : undefined, - WebkitTextStroke: titleConfig.stroke ? `${strokeW}px #000` : undefined, - boxSizing: "border-box", - wordBreak: "break-word", + lineHeight: lh, + WebkitTextStroke: + titleConfig.stroke && strokeW > 0 ? `${strokeW}px ${strokeC}` : undefined, + paintOrder: "stroke fill", + textShadow, whiteSpace: "pre-wrap", + padding: c.bg_enabled ? `${bgPad}px ${bgPad}px` : 0, + background: c.bg_enabled ? c.bg_color || "rgba(0,0,0,0.5)" : "transparent", + borderRadius: c.bg_enabled ? `${bgR}px` : 0, + boxSizing: "border-box", + display: "inline-block", + maxWidth: "94%", + } + const wrap: React.CSSProperties = { + position: "absolute", + left: "50%", + width: "100%", + display: "flex", + justifyContent: "center", + pointerEvents: onTitlePositionChange ? "auto" : "none", } - if ( titleConfig.position === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null ) { - style.left = `${titleConfig.pos_x}%` - style.top = `${titleConfig.pos_y}%` - style.transform = "translateX(-50%) translateY(-50%)" + wrap.left = `${titleConfig.pos_x}%` + wrap.top = `${titleConfig.pos_y}%` + wrap.transform = "translate(-50%, -50%)" } else if (titleConfig.position === "top") { - style.left = "50%" - style.top = padV - style.transform = "translateX(-50%)" + wrap.top = `${padV + mTop}px` + wrap.transform = "translateX(-50%)" } else if (titleConfig.position === "bottom") { - style.left = "50%" - style.bottom = padV - style.transform = "translateX(-50%)" + wrap.bottom = `${padV}px` + wrap.transform = "translateX(-50%)" } else { - style.left = "50%" - style.top = "50%" - style.transform = "translateX(-50%) translateY(-50%)" + wrap.top = "50%" + wrap.transform = "translate(-50%, -50%)" } - return style + return { style, wrap, lines } })() : null @@ -252,25 +286,23 @@ export function PanelLipsyncPreview({ {isDone && lipsyncJob?.output_video_url ? (
diff --git a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx index 170b80a5a..9eed01c6a 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelTitleConfig.tsx @@ -10,10 +10,13 @@ import React, { useMemo, useState, useEffect } from "react" import { Input } from "antd" import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel" +import type { TitleTemplate } from "@/components/title/template-types" + import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete" import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete" import type { TitleSettings } from "@/pages/generate/types" -import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants" +import { POSITION_OPTIONS } from "@/pages/generate/constants" +import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants" import type { AiAvatarTitleConfig } from "../types" // #1894: 标题数据源切换到文案库,取 script.title 作为候选 import { getScripts } from "@/api/scripts" @@ -28,6 +31,7 @@ interface PanelTitleConfigProps { const PanelTitleConfig: React.FC = ({ titleConfig, onUpdate }) => { /** TitleStylePanel 内部高亮的预设 key(面板本地状态) */ const [activePreset, setActivePreset] = useState(null) + const [selectedTemplateId, setSelectedTemplateId] = useState(null) /** 标题库选项(#1894:从文案库 scripts[].title 取候选) */ const [titleOptions, setTitleOptions] = useState([]) @@ -49,9 +53,60 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda .catch(() => setTitleOptions([])) }, []) - /** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */ - const titleSettings: TitleSettings = useMemo( - () => ({ + /** AiAvatarTitleConfig (snake_case) → TitleSettings (camelCase) */ + const titleSettings: TitleSettings = useMemo(() => { + const c = titleConfig as AiAvatarTitleConfig & { + stroke_width?: number + stroke_color?: string + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + line_height?: number + margin_top?: number + max_chars_per_line?: number + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + cover_title_config?: { + title?: string + font?: string + size?: number + font_size?: number + color?: string + font_color?: string + bold?: boolean + italic?: boolean + position?: string + stroke?: { enabled: boolean; width?: number; color?: string } | boolean + stroke_width?: number + stroke_color?: string + shadow?: + | { + enabled: boolean + offset_x?: number + offset_y?: number + blur?: number + color?: string + } + | boolean + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + background?: { enabled: boolean; color?: string; padding?: number; radius?: number } + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + line_height?: number + margin_top?: number + max_chars_per_line?: number + } | null + line_overrides?: unknown[] + } + return { aiAutoSelect: false, title: titleConfig.title, position: titleConfig.position, @@ -64,21 +119,250 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda color: titleConfig.color, posX: null, posY: null, - }), - [titleConfig], - ) + lineHeight: c.line_height ?? 1.2, + marginTop: c.margin_top ?? 24, + maxCharsPerLine: c.max_chars_per_line ?? 0, + strokeWidth: c.stroke_width ?? 4, + strokeColor: c.stroke_color ?? "#000000", + shadowOffsetX: c.shadow_offset_x ?? 2, + shadowOffsetY: c.shadow_offset_y ?? 2, + shadowBlur: c.shadow_blur ?? 4, + shadowColor: c.shadow_color ?? "rgba(0,0,0,0.8)", + bgEnabled: !!c.bg_enabled, + bgColor: c.bg_color ?? "rgba(0,0,0,0.5)", + bgPadding: c.bg_padding ?? 12, + bgRadius: c.bg_radius ?? 8, + lineOverrides: Array.isArray(c.line_overrides) ? c.line_overrides : [], + coverTitle: (() => { + const ct = c.cover_title_config as + | null + | (AiAvatarTitleConfig & { + font_size?: number + font_color?: string + stroke?: { enabled?: boolean; width?: number; color?: string } | boolean + stroke_width?: number + stroke_color?: string + shadow?: + | { + enabled?: boolean + offset_x?: number + offset_y?: number + blur?: number + color?: string + } + | boolean + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + background?: { enabled?: boolean; color?: string; padding?: number; radius?: number } + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + }) + if (!ct) return null + const ctStroke = ct.stroke as + { enabled?: boolean; width?: number; color?: string } | boolean | undefined + const ctShadow = ct.shadow as + | { + enabled?: boolean + offset_x?: number + offset_y?: number + blur?: number + color?: string + } + | boolean + | undefined + const ctBg = ct.background as + { enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined + return { + title: ct.title, + font: ct.font, + size: ct.font_size ?? ct.size, + color: ct.font_color ?? ct.color, + bold: ct.bold, + italic: ct.italic, + position: ct.position, + stroke: + typeof ctStroke === "object" && ctStroke ? ctStroke.enabled !== false : !!ctStroke, + strokeWidth: + (typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ?? + ct.stroke_width ?? + 4, + strokeColor: + (typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ?? + ct.stroke_color ?? + "#000000", + shadow: + typeof ctShadow === "object" && ctShadow ? ctShadow.enabled !== false : !!ctShadow, + shadowOffsetX: + (typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ?? + ct.shadow_offset_x ?? + 2, + shadowOffsetY: + (typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ?? + ct.shadow_offset_y ?? + 2, + shadowBlur: + (typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ?? + ct.shadow_blur ?? + 4, + shadowColor: + (typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ?? + ct.shadow_color ?? + "rgba(0,0,0,0.8)", + bgEnabled: ctBg?.enabled ?? !!ct.bg_enabled, + bgColor: ctBg?.color ?? ct.bg_color ?? "rgba(0,0,0,0.5)", + bgPadding: ctBg?.padding ?? ct.bg_padding ?? 12, + bgRadius: ctBg?.radius ?? ct.bg_radius ?? 8, + } + })(), + } + }, [titleConfig]) - /** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */ + /** 应用预设:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */ const handleApplyPreset = (presetKey: string) => { const preset = TITLE_PRESETS.find((p) => p.key === presetKey) if (!preset) return setActivePreset(presetKey) + const st = preset.style || {} onUpdate({ - color: preset.style.color, - bold: preset.style.bold, - italic: preset.style.italic, - stroke: preset.style.stroke, - shadow: preset.style.shadow, + font: st.font, + size: st.size, + color: st.color, + bold: st.bold, + italic: st.italic, + stroke: st.stroke, + stroke_width: st.stroke_width, + stroke_color: st.stroke_color, + shadow: st.shadow, + shadow_offset_x: st.shadow_offset_x, + shadow_offset_y: st.shadow_offset_y, + shadow_blur: st.shadow_blur, + shadow_color: st.shadow_color, + bg_enabled: st.bg_enabled, + bg_color: st.bg_color, + bg_padding: st.bg_padding, + bg_radius: st.bg_radius, + line_overrides: [], + cover_title_config: null, + }) + } + + /** 字段 patch 透传:TitleStylePanel 的 onUpdateStyle(camelCase → snake_case) */ + const handleUpdateStyle = (patch: Partial) => { + const snake: Record = {} + const map: Record = { + lineHeight: "line_height", + marginTop: "margin_top", + maxCharsPerLine: "max_chars_per_line", + strokeWidth: "stroke_width", + strokeColor: "stroke_color", + shadowOffsetX: "shadow_offset_x", + shadowOffsetY: "shadow_offset_y", + shadowBlur: "shadow_blur", + shadowColor: "shadow_color", + bgEnabled: "bg_enabled", + bgColor: "bg_color", + bgPadding: "bg_padding", + bgRadius: "bg_radius", + lineOverrides: "line_overrides", + coverTitle: "cover_title_config", + } + Object.entries(patch).forEach(([k, v]) => { + if (k === "coverTitle" && v && typeof v === "object") { + const ct = v as { + title?: string + font?: string + size?: number + color?: string + bold?: boolean + italic?: boolean + position?: string + stroke?: boolean + strokeWidth?: number + strokeColor?: string + shadow?: boolean + shadowOffsetX?: number + shadowOffsetY?: number + shadowBlur?: number + shadowColor?: string + bgEnabled?: boolean + bgColor?: string + bgPadding?: number + bgRadius?: number + lineHeight?: number + marginTop?: number + maxCharsPerLine?: number + } + snake.cover_title_config = { + title: ct.title, + font: ct.font, + font_size: ct.size, + font_color: ct.color, + bold: ct.bold, + italic: ct.italic, + position: ct.position, + stroke: ct.stroke + ? { enabled: true, width: ct.strokeWidth ?? 4, color: ct.strokeColor ?? "#000" } + : { enabled: false }, + shadow: ct.shadow + ? { + enabled: true, + offset_x: ct.shadowOffsetX ?? 2, + offset_y: ct.shadowOffsetY ?? 2, + blur: ct.shadowBlur ?? 4, + color: ct.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + background: ct.bgEnabled + ? { enabled: true, color: ct.bgColor, padding: ct.bgPadding, radius: ct.bgRadius } + : { enabled: false }, + line_height: ct.lineHeight, + margin_top: ct.marginTop, + max_chars_per_line: ct.maxCharsPerLine, + } + } else if (map[k]) { + snake[map[k]] = v + } else { + snake[k] = v + } + }) + onUpdate(snake) + } + + /** 应用模板(来自内联网格 onApplyTemplate):camelCase TitleSettings → snake_case AiAvatarTitleConfig */ + const handleApplyTemplate = (settings: TitleSettings, tpl: TitleTemplate) => { + setActivePreset(null) + setSelectedTemplateId(tpl.id) + onUpdate({ + title: titleConfig.title, + font: settings.font, + size: settings.size, + color: settings.color, + bold: settings.bold, + italic: settings.italic, + position: settings.position, + stroke: settings.stroke, + stroke_width: settings.strokeWidth, + stroke_color: settings.strokeColor, + shadow: settings.shadow, + shadow_offset_x: settings.shadowOffsetX, + shadow_offset_y: settings.shadowOffsetY, + shadow_blur: settings.shadowBlur, + shadow_color: settings.shadowColor, + bg_enabled: settings.bgEnabled, + bg_color: settings.bgColor, + bg_padding: settings.bgPadding, + bg_radius: settings.bgRadius, + line_height: settings.lineHeight, + margin_top: settings.marginTop, + max_chars_per_line: settings.maxCharsPerLine, + line_overrides: [], + pos_x: settings.posX ?? undefined, + pos_y: settings.posY ?? undefined, + cover_title_config: null, }) } @@ -125,8 +409,16 @@ const PanelTitleConfig: React.FC = ({ titleConfig, onUpda onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })} onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })} onApplyPreset={handleApplyPreset} + onUpdateStyle={handleUpdateStyle} + showCoverToggle + previewWidth={280} + enableTemplates={true} + selectedTemplateId={selectedTemplateId} + onApplyTemplate={handleApplyTemplate} activePreset={activePreset} - titlePresets={TITLE_PRESETS} + titlePresets={ + TITLE_PRESETS as unknown as React.ComponentProps["titlePresets"] + } POSITION_OPTIONS={POSITION_OPTIONS} FONT_OPTIONS={FONT_OPTIONS} /> diff --git a/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx b/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx index 58cd92f33..3c9ebf42a 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx @@ -1,18 +1,17 @@ /** * AI数字人 — 配音库面板(面板3) - * 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数 + * 音色来源切换(系统预设 / 我的音色)、音色选择与试听、风格/语速/语言参数 */ import { useEffect, useRef, useState } from "react" import { message } from "antd" import { fetchVoices } from "@/api/voices/voices" import { previewTts } from "@/api/tts" -import { normalizeEmotion } from "../utils/contract" +import TtsStyleSelector from "@/components/voice/TtsStyleSelector" +import type { TtsStyle } from "@/api/tts/styles" import type { UnifiedVoiceItem } from "@/api/voices/types" import { type VoiceSource, - type VoiceEmotion, type VoiceLanguage, - VOICE_EMOTION_OPTIONS, PRESET_VOICE_LANGUAGE_OPTIONS, CLONE_VOICE_LANGUAGE_OPTIONS, } from "../types" @@ -22,8 +21,8 @@ interface PanelVoiceSelectorProps { onVoiceSourceChange: (source: VoiceSource) => void selectedVoice: UnifiedVoiceItem | null onSelectVoice: (voice: UnifiedVoiceItem) => void - emotion: VoiceEmotion - onEmotionChange: (e: VoiceEmotion) => void + style: TtsStyle + onStyleChange: (s: TtsStyle) => void speed: number onSpeedChange: (s: number) => void language: VoiceLanguage @@ -35,8 +34,8 @@ export function PanelVoiceSelector({ onVoiceSourceChange, selectedVoice, onSelectVoice, - emotion, - onEmotionChange, + style, + onStyleChange, speed, onSpeedChange, language, @@ -139,31 +138,30 @@ export function PanelVoiceSelector({ /* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview * 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */ if (voice.type === "clone") { - const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id) + const cacheKey = `${voice.voice_clone_profile_id || voice.id}::${style}` + const cached = previewCacheRef.current.get(cacheKey) if (cached) { playAudioUrl(voice.id, cached) return } const targetId = voice.voice_clone_profile_id || voice.id - // DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因 setPreviewingId(voice.id) try { const res = await previewTts({ text: VOICE_PREVIEW_TEXT, voice_id: targetId, speed: speed, // 透传用户选择的语速(#1822) - emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举 + style, }) if (!res.audio_url) { setPreviewingId(null) message.error("合成试听失败:未返回音频") return } - previewCacheRef.current.set(targetId, res.audio_url) + previewCacheRef.current.set(cacheKey, res.audio_url) playAudioUrl(voice.id, res.audio_url) } catch (err) { setPreviewingId(null) - // DEBUG: 打印详细错误信息 console.error("[AI数字人-克隆试听] previewTts 失败:", { status: (err as { response?: { status?: number } })?.response?.status, data: (err as { response?: { data?: unknown } })?.response?.data, @@ -272,23 +270,6 @@ export function PanelVoiceSelector({ {/* 配音参数 */}
-
- - -
+
+ +
) diff --git a/apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts b/apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts index fb8f86660..6a28f730f 100644 --- a/apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts @@ -6,7 +6,6 @@ import type { AssetItem } from "@/api/assets" import type { UnifiedVoiceItem } from "@/api/voices/types" import { type VoiceSource, - type VoiceEmotion, type VoiceLanguage, type Script, type LipsyncJob, @@ -17,6 +16,7 @@ import { DEFAULT_TITLE_CONFIG, DEFAULT_COVER_CONFIG, } from "../types" +import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles" const DEFAULT_TTS_PREVIEW: TtsPreviewResult = { audioUrl: null, @@ -34,7 +34,7 @@ export function useAiAvatar() { /* ── 面板2:配音库 ── */ const [voiceSource, setVoiceSource] = useState("preset") const [selectedVoice, setSelectedVoice] = useState(null) - const [emotion, setEmotion] = useState("neutral") + const [style, setStyle] = useState(DEFAULT_TTS_STYLE) const [speed, setSpeed] = useState(1.0) const [language, setLanguage] = useState("zh") @@ -113,8 +113,8 @@ export function useAiAvatar() { setVoiceSource, selectedVoice, setSelectedVoice, - emotion, - setEmotion, + style, + setStyle, speed, setSpeed, language, diff --git a/apps/web/src/pages/ai-avatar/types.ts b/apps/web/src/pages/ai-avatar/types.ts index ac13d76a8..e7c044a8d 100644 --- a/apps/web/src/pages/ai-avatar/types.ts +++ b/apps/web/src/pages/ai-avatar/types.ts @@ -100,7 +100,7 @@ export interface BRollSegment { pip_scale: number } -/* ── 标题配置 ── */ +/* ── 标题配置(#2001 升级:细粒度描边/阴影/背景/排版/逐行/封面独立标题) ── */ export interface AiAvatarTitleConfig { title: string position: string @@ -115,6 +115,42 @@ export interface AiAvatarTitleConfig { /** 自定义位置坐标(position=custom 时生效,百分比 0-100) */ pos_x?: number pos_y?: number + + /* ── 排版 ── */ + line_height: number + margin_top: number + max_chars_per_line: number + + /* ── 描边参数化 ── */ + stroke_width: number + stroke_color: string + + /* ── 阴影参数化 ── */ + shadow_offset_x: number + shadow_offset_y: number + shadow_blur: number + shadow_color: string + + /* ── 背景色块 ── */ + bg_enabled: boolean + bg_color: string + bg_padding: number + bg_radius: number + + /* ── 逐行覆盖 ── */ + line_overrides: Array<{ + line_index: number + text?: string + size?: number + color?: string + bold?: boolean + italic?: boolean + stroke?: boolean + highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }> + }> + + /* ── 封面独立标题(null=沿用主标题) ── */ + cover_title_config: null | Partial } /* ── 封面配置 ── */ @@ -149,12 +185,27 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = { size: 48, bold: true, italic: false, - stroke: false, + stroke: true, shadow: false, color: "#ffffff", auto_subtitle: true, pos_x: undefined, pos_y: undefined, + line_height: 1.2, + margin_top: 24, + max_chars_per_line: 0, + stroke_width: 4, + stroke_color: "#000000", + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 4, + shadow_color: "rgba(0,0,0,0.8)", + bg_enabled: false, + bg_color: "rgba(0,0,0,0.5)", + bg_padding: 12, + bg_radius: 8, + line_overrides: [], + cover_title_config: null, } export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = { diff --git a/apps/web/src/pages/ai-avatar/utils/contract.ts b/apps/web/src/pages/ai-avatar/utils/contract.ts index f660f07d6..31266caca 100644 --- a/apps/web/src/pages/ai-avatar/utils/contract.ts +++ b/apps/web/src/pages/ai-avatar/utils/contract.ts @@ -67,6 +67,37 @@ export function buildTitleConfigPayload( const text = (cfg.title || "").trim() if (!text) return {} const position = cfg.position || "bottom" + const anyCfg = cfg as AiAvatarTitleConfig & { + stroke_width?: number + stroke_color?: string + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + line_height?: number + margin_top?: number + max_chars_per_line?: number + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + line_overrides?: unknown[] + cover_title_config?: Record | null + } + const strokeWidth = anyCfg.stroke_width != null ? anyCfg.stroke_width : 4 + const strokeColor = anyCfg.stroke_color || "#000000" + const shadowOffsetX = anyCfg.shadow_offset_x != null ? anyCfg.shadow_offset_x : 2 + const shadowOffsetY = anyCfg.shadow_offset_y != null ? anyCfg.shadow_offset_y : 2 + const shadowBlur = anyCfg.shadow_blur != null ? anyCfg.shadow_blur : 4 + const shadowColor = anyCfg.shadow_color || "rgba(0,0,0,0.8)" + const lineHeight = anyCfg.line_height != null ? anyCfg.line_height : 1.2 + const marginTop = anyCfg.margin_top != null ? anyCfg.margin_top : 24 + const maxCharsPerLine = anyCfg.max_chars_per_line ?? 0 + const bgEnabled = !!anyCfg.bg_enabled + const bgColor = anyCfg.bg_color || "rgba(0,0,0,0.5)" + const bgPadding = anyCfg.bg_padding != null ? anyCfg.bg_padding : 12 + const bgRadius = anyCfg.bg_radius != null ? anyCfg.bg_radius : 8 + const payload: Record = { text, enabled: true, @@ -75,16 +106,68 @@ export function buildTitleConfigPayload( font_color: cfg.color || "#ffffff", position, bold: !!cfg.bold, - stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false }, - shadow: cfg.shadow - ? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 } + italic: !!cfg.italic, + stroke: cfg.stroke + ? { enabled: true, width: strokeWidth, color: strokeColor } : { enabled: false }, + shadow: cfg.shadow + ? { + enabled: true, + color: shadowColor, + offset_x: shadowOffsetX, + offset_y: shadowOffsetY, + blur: shadowBlur, + } + : { enabled: false }, + line_height: lineHeight, + margin_top: marginTop, + max_chars_per_line: maxCharsPerLine, + background: bgEnabled + ? { enabled: true, color: bgColor, padding: bgPadding, radius: bgRadius } + : { enabled: false }, + line_overrides: Array.isArray(anyCfg.line_overrides) ? anyCfg.line_overrides : [], } // 自定义坐标(custom 位置) if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") { payload.pos_x = cfg.pos_x payload.pos_y = cfg.pos_y } + // 封面独立标题配置 + if (anyCfg.cover_title_config) { + const ctc = anyCfg.cover_title_config + payload.cover_title_config = { + title: ctc.title, + font: ctc.font, + font_size: ctc.size, + font_color: ctc.color, + position: ctc.position, + bold: ctc.bold, + italic: ctc.italic, + stroke: ctc.stroke + ? { enabled: true, width: ctc.stroke_width ?? 4, color: ctc.stroke_color ?? "#000000" } + : { enabled: false }, + shadow: ctc.shadow + ? { + enabled: true, + color: ctc.shadow_color ?? shadowColor, + offset_x: ctc.shadow_offset_x ?? 2, + offset_y: ctc.shadow_offset_y ?? 2, + blur: ctc.shadow_blur ?? 4, + } + : { enabled: false }, + line_height: ctc.line_height ?? lineHeight, + margin_top: ctc.margin_top ?? marginTop, + max_chars_per_line: ctc.max_chars_per_line ?? maxCharsPerLine, + background: ctc.bg_enabled + ? { + enabled: true, + color: ctc.bg_color ?? bgColor, + padding: ctc.bg_padding ?? bgPadding, + radius: ctc.bg_radius ?? bgRadius, + } + : { enabled: false }, + } + } // 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层) if (titleImageDataUrl) { payload.title_image_dataurl = titleImageDataUrl diff --git a/apps/web/src/pages/ai-avatar/utils/titleCanvas.ts b/apps/web/src/pages/ai-avatar/utils/titleCanvas.ts index cbdbc8426..9c4e03456 100644 --- a/apps/web/src/pages/ai-avatar/utils/titleCanvas.ts +++ b/apps/web/src/pages/ai-avatar/utils/titleCanvas.ts @@ -9,37 +9,78 @@ * 按 videoWidth / 720 得到 scale,所有长度类参数乘以 scale, * 保证 1080p / 4K 成片里标题视觉大小与预览一致。 */ +import { getFontFamily } from "@/components/title/constants" import type { AiAvatarTitleConfig } from "../types" export interface RenderTitlePngOptions { - /** 标题配置 */ titleConfig: AiAvatarTitleConfig - /** 视频宽度(像素),默认 720 */ videoWidth?: number - /** 视频高度(像素),默认 1280 */ videoHeight?: number + useCoverTitle?: boolean } -/** - * 将标题渲染为透明背景 PNG 的 dataURL(data:image/png;base64,...) - * Canvas 尺寸与视频一致,保证叠加时 1:1 像素对齐。 - * - * 标题为空时返回 null。 - */ -export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null { - const { titleConfig, videoWidth = 720, videoHeight = 1280 } = opts - if (!titleConfig) return null - const rawTitle = (titleConfig.title || "").trim() - if (!rawTitle) return null - - // 按 / 或 / 分割为多行 - const lines = rawTitle +function autoWrapLines(rawTitle: string, maxCharsPerLine: number): string[] { + const manual = rawTitle .split(/[//]/) .map((l) => l.trim()) .filter((l) => l.length > 0) + if (!maxCharsPerLine || maxCharsPerLine <= 0) return manual + const out: string[] = [] + manual.forEach((seg) => { + for (let i = 0; i < seg.length; i += maxCharsPerLine) { + out.push(seg.slice(i, i + maxCharsPerLine)) + } + }) + return out +} + +export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null { + const { titleConfig, videoWidth = 720, videoHeight = 1280, useCoverTitle } = opts + if (!titleConfig) return null + + type TitleCfgExt = AiAvatarTitleConfig & { + stroke_width?: number + stroke_color?: string + shadow_offset_x?: number + shadow_offset_y?: number + shadow_blur?: number + shadow_color?: string + line_height?: number + margin_top?: number + max_chars_per_line?: number + bg_enabled?: boolean + bg_color?: string + bg_padding?: number + bg_radius?: number + line_overrides?: Array<{ + line_index: number + text?: string + size?: number + color?: string + bold?: boolean + italic?: boolean + stroke?: boolean + highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }> + }> + cover_title_config?: Partial | null + pos_x?: number + pos_y?: number + } + const cfg: TitleCfgExt = + useCoverTitle && titleConfig.cover_title_config + ? ({ + ...(titleConfig as TitleCfgExt), + ...(titleConfig.cover_title_config as object), + } as TitleCfgExt) + : (titleConfig as TitleCfgExt) + + const rawTitle = (cfg.title || "").trim() + if (!rawTitle) return null + + const maxCharsPerLine = cfg.max_chars_per_line ?? 0 + const lines = autoWrapLines(rawTitle, maxCharsPerLine) if (lines.length === 0) return null - // 分辨率缩放系数:基准 720p,所有长度类参数乘以 scale const scale = videoWidth / 720 const r = (v: number) => Math.round(v * scale) @@ -49,86 +90,183 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n const ctx = canvas.getContext("2d") if (!ctx) return null - const baseSize = Math.max(12, Math.round(titleConfig.size || 48)) + const baseSize = Math.max(12, Math.round(cfg.size || 48)) const size = r(baseSize) - const bold = !!titleConfig.bold - const italic = !!titleConfig.italic - const color = titleConfig.color || "#ffffff" - const stroke = !!titleConfig.stroke - const shadow = !!titleConfig.shadow + const bold = !!cfg.bold + const italic = !!cfg.italic + const color = cfg.color || "#ffffff" + const stroke = !!cfg.stroke + const shadow = !!cfg.shadow - // 字体族 fallback 链:优先中文字体 - const fontFamily = - '"Noto Sans CJK SC","Source Han Sans CN","PingFang SC","Microsoft YaHei",sans-serif' - const fontParts: string[] = [] - if (italic) fontParts.push("italic") - if (bold) fontParts.push("bold") - fontParts.push(`${size}px`, fontFamily) - ctx.font = fontParts.join(" ") + const strokeWidthBase = cfg.stroke_width != null ? cfg.stroke_width : 4 + const strokeColor = cfg.stroke_color || "#000000" + const shadowOffsetXBase = cfg.shadow_offset_x != null ? cfg.shadow_offset_x : 2 + const shadowOffsetYBase = cfg.shadow_offset_y != null ? cfg.shadow_offset_y : 2 + const shadowBlurBase = cfg.shadow_blur != null ? cfg.shadow_blur : 4 + const shadowColor = cfg.shadow_color || "rgba(0,0,0,0.8)" + const lineHeightScale = cfg.line_height != null ? cfg.line_height : 1.2 + const marginTopBase = cfg.margin_top != null ? cfg.margin_top : 24 + const bgEnabled = !!cfg.bg_enabled + const bgColor = cfg.bg_color || "rgba(0,0,0,0.5)" + const bgPaddingBase = cfg.bg_padding != null ? cfg.bg_padding : 12 + const bgRadiusBase = cfg.bg_radius != null ? cfg.bg_radius : 8 + + const fontKey = cfg.font || "思源黑体" + const fontFamily = getFontFamily(fontKey) + const setFont = (sz: number, bd: boolean, it: boolean) => { + const parts: string[] = [] + if (it) parts.push("italic") + if (bd) parts.push("bold") + parts.push(`${sz}px`, fontFamily) + ctx.font = parts.join(" ") + } + setFont(size, bold, italic) ctx.fillStyle = color ctx.textAlign = "center" ctx.textBaseline = "middle" - // 阴影(shadow=true 时开启)——按 scale 缩放 - if (shadow) { - ctx.shadowColor = "rgba(0,0,0,0.8)" - ctx.shadowBlur = r(4) - ctx.shadowOffsetX = 0 - ctx.shadowOffsetY = r(2) - } + const lineGap = size * lineHeightScale + const totalTextH = lines.length * lineGap - (lineGap - size) + let maxLineW = 0 + lines.forEach((l: string) => { + const m = ctx.measureText(l).width + if (m > maxLineW) maxLineW = m + }) - // 位置计算:与 PanelLipsyncPreview 的 CSS 对齐(按 scale 缩放 PAD) const PAD = r(16) let centerX = videoWidth / 2 - const position = titleConfig.position || "bottom" - const lineGap = size * 1.2 - const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度 - // 文本块顶部 y(textBaseline=middle 时首行基线) + const position = cfg.position || "bottom" let firstLineY: number - if ( - position === "custom" && - typeof titleConfig.pos_x === "number" && - typeof titleConfig.pos_y === "number" - ) { - centerX = (Math.max(0, Math.min(100, titleConfig.pos_x)) / 100) * videoWidth - const centerY = (Math.max(0, Math.min(100, titleConfig.pos_y)) / 100) * videoHeight + if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") { + centerX = (Math.max(0, Math.min(100, cfg.pos_x)) / 100) * videoWidth + const centerY = (Math.max(0, Math.min(100, cfg.pos_y)) / 100) * videoHeight firstLineY = centerY - totalTextH / 2 + size / 2 } else if (position === "top") { - // 顶部:y = size/2 + PAD - firstLineY = size / 2 + PAD + firstLineY = size / 2 + PAD + r(marginTopBase) } else if (position === "center") { firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2 } else { - // bottom(默认) firstLineY = videoHeight - totalTextH - PAD + size / 2 } - // 描边参数:描边 lineWidth 按 scale 缩放(基准 size * 0.06,最小 2px @720p) - const doStroke = stroke - const strokeWidth = Math.max(r(2), Math.round(size * 0.06)) - // 逐行绘制 - lines.forEach((line, idx) => { + if (shadow) { + ctx.shadowColor = shadowColor + ctx.shadowBlur = r(shadowBlurBase) + ctx.shadowOffsetX = r(shadowOffsetXBase) + ctx.shadowOffsetY = r(shadowOffsetYBase) + } else { + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + const bgPad = r(bgPaddingBase) + const bgR = r(bgRadiusBase) + const bgW = maxLineW + bgPad * 2 + const bgH = totalTextH + bgPad * 2 + const bgX = centerX - bgW / 2 + const bgY = firstLineY - size / 2 - bgPad + + if (bgEnabled) { + ctx.save() + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + ctx.fillStyle = bgColor + if ( + bgR > 0 && + ( + ctx as CanvasRenderingContext2D & { + roundRect?: (x: number, y: number, w: number, h: number, r: number) => void + } + ).roundRect + ) { + ;( + ctx as CanvasRenderingContext2D & { + roundRect?: (x: number, y: number, w: number, h: number, r: number) => void + } + ).roundRect(bgX, bgY, bgW, bgH, bgR) + ctx.fill() + } else { + ctx.fillRect(bgX, bgY, bgW, bgH) + } + ctx.restore() + } + + const sw = stroke ? Math.max(r(1), r(strokeWidthBase)) : 0 + const lineOverrides = cfg.line_overrides || [] + lines.forEach((line: string, idx: number) => { const y = firstLineY + idx * lineGap - if (doStroke) { - const prevShadowColor = ctx.shadowColor - const prevShadowBlur = ctx.shadowBlur - // 描边不要带阴影(避免黑色描边发虚) + const override = lineOverrides.find((lo) => lo.line_index === idx) + const lineSize = override?.size ? r(Math.max(12, Math.round(override.size))) : size + const lineColor = override?.color || color + const lineBold = override?.bold != null ? !!override.bold : bold + const lineItalic = override?.italic != null ? !!override.italic : italic + const lineStroke = override?.stroke != null ? !!override.stroke : stroke + + setFont(lineSize, lineBold, lineItalic) + ctx.fillStyle = lineColor + + if (shadow) { + ctx.shadowColor = shadowColor + ctx.shadowBlur = r(shadowBlurBase) + ctx.shadowOffsetX = r(shadowOffsetXBase) + ctx.shadowOffsetY = r(shadowOffsetYBase) + } else { ctx.shadowColor = "rgba(0,0,0,0)" ctx.shadowBlur = 0 - ctx.lineWidth = strokeWidth - ctx.strokeStyle = "#000000" + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + const lineSw = override?.size + ? Math.max(r(1), Math.round(lineSize * (strokeWidthBase / baseSize))) + : sw + + if (lineStroke && lineSw > 0) { + ctx.save() + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + ctx.lineWidth = lineSw + ctx.strokeStyle = strokeColor ctx.lineJoin = "round" ctx.strokeText(line, centerX, y) - // 恢复阴影 - if (shadow) { - ctx.shadowColor = "rgba(0,0,0,0.8)" - ctx.shadowBlur = r(4) - } else { - ctx.shadowColor = prevShadowColor - ctx.shadowBlur = prevShadowBlur - } + ctx.restore() } + ctx.fillText(line, centerX, y) + + if (override?.highlights?.length) { + const fullW = ctx.measureText(line).width + const charW = line.length > 0 ? fullW / line.length : lineSize + override.highlights.forEach((hl) => { + if (!hl.word) return + const pos = line.indexOf(hl.word) + if (pos < 0) return + const hlX = centerX - fullW / 2 + pos * charW + (charW * hl.word.length) / 2 + const hlColor = hl.color || "#ffd700" + const hlScale = hl.scale || 1 + const hlSize = lineSize * hlScale + const hlBold = hl.bold != null ? !!hl.bold : true + ctx.save() + ctx.shadowColor = "rgba(0,0,0,0)" + ctx.shadowBlur = 0 + setFont(hlSize, hlBold, lineItalic) + ctx.fillStyle = hlColor + if (lineStroke && lineSw > 0) { + ctx.lineWidth = Math.max(r(1), Math.round(hlSize * (strokeWidthBase / baseSize))) + ctx.strokeStyle = strokeColor + ctx.lineJoin = "round" + ctx.strokeText(hl.word, hlX, y) + } + ctx.fillText(hl.word, hlX, y) + ctx.restore() + }) + } }) try { @@ -138,9 +276,6 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n } } -/** - * 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。 - */ export function getVideoResolution( videoUrl: string, timeoutMs = 3000, diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 1e7cc474c..bb57eba68 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -8,31 +8,25 @@ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react" import { message } from "antd" import { useNavigate } from "react-router-dom" import type { VoiceClone } from "@/api/voice-clone" -import { useQuery } from "@tanstack/react-query" import { useCloneProgress } from "@/hooks/useCloneProgress" import CloneModal from "@/components/voice/CloneModal" import VoiceSelectModal from "./components/VoiceSelectModal" import ScriptSelectModal from "./components/ScriptSelectModal" import TtsVoiceModal from "./components/TtsVoiceModal" import GenerateHeader from "./components/GenerateHeader" -import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer" -import CanvasPreviewGrid from "./components/CanvasPreviewGrid" -import PreviewCountModal from "./components/PreviewCountModal" import GenerateStepsBar from "./components/GenerateStepsBar" import GenerateStepContent from "./components/GenerateStepContent" import GenerateStepActions from "./components/GenerateStepActions" import { useGenerateFormState } from "./hooks/useGenerateFormState" import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" +import { finalizeGeneration } from "@/api/generation/finalize" -import { usePreviewAssets } from "./hooks/usePreviewAssets" import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans" -import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" -import { getAssetsByKind } from "@/api/assets" -import { previewTts } from "@/api/tts" import { usePointsStore } from "@/store/pointsStore" import { hasEnoughPoints } from "./hooks/pointsCost" +import { ENABLE_CREDIT_SYSTEM } from "@/config/features" import "./generate.css" import "./generate-points.css" @@ -41,6 +35,9 @@ const GeneratePage: React.FC = () => { /* ── 表单状态 ── */ const formState = useGenerateFormState() + /* ── 标题模板(#2003)当前选中模板 id ── */ + const [selectedTitleTemplateId, setSelectedTitleTemplateId] = useState(null) + /* ── 积分状态 ── */ const { balance, dailyUsage, rules, init: initPoints } = usePointsStore() useEffect(() => { @@ -76,6 +73,8 @@ const GeneratePage: React.FC = () => { setTtsVoiceId, ttsVoiceSource, setTtsVoiceSource, + ttsStyle, + setTtsStyle, ttsVoiceAssetId, setTtsVoiceAssetId, dedupEnabled, @@ -95,8 +94,6 @@ const GeneratePage: React.FC = () => { setPreviewTaskId, storedSourceEditPlanId, setStoredSourceEditPlanId, - serverClips, - setServerClips, previewCount, setPreviewCount, previewTitles, @@ -109,6 +106,7 @@ const GeneratePage: React.FC = () => { setPreviewCovers, selectedVariantIds, setSelectedVariantIds, + setSelectedTemplate, } = formState const isBatch = previewCount > 1 @@ -139,8 +137,11 @@ const GeneratePage: React.FC = () => { } }, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds]) - /* ── 数量选择弹窗 ── */ - const [countModalOpen, setCountModalOpen] = useState(false) + /* ── 标题面板模式:true = 内联大卡片模板网格(默认),false = 旧预设+参数 Tab ── */ + const enableTemplates = true + + /* ── Step5 保存中状态 ── */ + const [finishing, setFinishing] = useState(false) /* ── #1970 流程重构:分支弹窗 ── */ const [voiceModalOpen, setVoiceModalOpen] = useState(false) @@ -153,62 +154,6 @@ const GeneratePage: React.FC = () => { onTitleSettingsChange: setTitleSettings, }) - /* ── 配音素材库(TTS 试听)── */ - const { data: voiceMaterials = [] } = useQuery({ - queryKey: ["assets", "voice"], - queryFn: () => getAssetsByKind("voice", { limit: 50 }), - }) - - const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState(null) - const ttsAbortRef = useRef(null) - const variant0Title = isBatch ? previewTitles?.[0] || "" : "" - - useEffect(() => { - if (isBatch) return - const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice) - if (voiceAsset?.file_url) { - setPreviewVoiceAudioUrl(voiceAsset.file_url) - return - } - - const ttsTitle = titleSettings.title - const voiceId = selectedClonedVoice || selectedVoice - if (!voiceId || !ttsTitle) { - setPreviewVoiceAudioUrl(null) - return - } - - ttsAbortRef.current?.abort() - const controller = new AbortController() - ttsAbortRef.current = controller - let cancelled = false - - previewTts({ text: ttsTitle, voice_id: voiceId }) - .then((res) => { - if (!cancelled && res.audio_url) { - setPreviewVoiceAudioUrl(res.audio_url) - } - }) - .catch((err) => { - if (!cancelled) { - console.warn("[预览配音生成失败]", err) - setPreviewVoiceAudioUrl(null) - } - }) - - return () => { - cancelled = true - controller.abort() - } - }, [ - selectedVoice, - selectedClonedVoice, - titleSettings.title, - variant0Title, - isBatch, - voiceMaterials, - ]) - /* ── 克隆声音 ── */ const { addClone } = useCloneProgress() @@ -233,30 +178,11 @@ const GeneratePage: React.FC = () => { [bgm], ) - /* ── 加载素材详情 ── */ - const previewAssetsEnabled = previewAssetIds.length > 0 - const { - assets: previewAssets, - ready: previewAssetsReady, - ensureAssets, - } = usePreviewAssets(previewAssetIds, previewAssetsEnabled) - - /* ── 预览就绪 ── */ - // #1899: 不再依赖 currentTemplate,素材加载完即可预览 - const previewReady = previewAssetsReady - /* ── 批量变体真实片段(#1744) ── */ const batchVoiceLibraryId = voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || "" - const { - clipsByVariant: variantClips, - planIdsByVariant: variantPlanIds, - voiceDurationsByVariant: variantVoiceDurations, - loading: variantClipsLoading, - error: variantClipsError, - retry: retryVariantClips, - } = useBatchVariantPlans({ - enabled: isBatch && currentStep === 3 && previewAssetsReady, + const { planIdsByVariant: variantPlanIds } = useBatchVariantPlans({ + enabled: isBatch && currentStep === 3, count: previewCount, templateId: selectedTemplate || "", assetIds: previewAssetIds, @@ -266,39 +192,6 @@ const GeneratePage: React.FC = () => { voiceModePerVideo, }) - /* ── 批量变体配音预览 URL ── */ - const variantVoiceAudioUrls = useVariantVoicePreview({ - enabled: isBatch, - count: previewCount, - perVideo: voiceModePerVideo, - sharedVoiceId: selectedVoice || "", - clonedVoiceId: selectedClonedVoice || "", - variantVoiceIds: voiceLibraryIds || [], - titles: previewTitles || [], - }) - - /* ── 变体 clips 引用素材补拉 ── */ - const clipAssetIds = useMemo(() => { - if (!isBatch || !variantClips?.length) return [] - const ids = new Set() - variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id))) - return Array.from(ids) - }, [isBatch, variantClips]) - useEffect(() => { - if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds) - }, [clipAssetIds, ensureAssets]) - - /* ── 勾选变体 ── */ - const toggleVariantSelect = useCallback( - (index: number) => { - setSelectedVariantIds((prev) => { - const list = prev || [] - return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort() - }) - }, - [setSelectedVariantIds], - ) - /* ── 视频生成核心逻辑 ── */ const { generating, @@ -306,6 +199,7 @@ const GeneratePage: React.FC = () => { generated, generateError, generatedVideos, + currentTaskId, batchTasks, generate: handleGenerate, retry: handleRetryGenerate, @@ -328,6 +222,7 @@ const GeneratePage: React.FC = () => { selectedScript, ttsVoiceId, ttsVoiceSource, + ttsStyle, ttsVoiceAssetId, dedupEnabled, style, @@ -344,44 +239,48 @@ const GeneratePage: React.FC = () => { voiceModePerVideo, variantCoverUrls: previewCovers, selectedVariantIndexes: isBatch ? selectedVariantIds : undefined, - onGenerationSuccess: () => { + onGenerationSuccess: (status?: "completed" | "awaiting_cover") => { setPreviewTaskId(null) setStoredSourceEditPlanId(null) + // #2088:渲染完成后自动跳到封面选择页(step 5),不再等用户手动点「下一步」 + // awaiting_cover 和 completed 都走封面页(completed 是旧 worker 或 finalize 后状态,仍支持选封面) + if (status === "awaiting_cover" || status === "completed" || !status) { + setCurrentStep(5) + } }, }) - /* ── 数量弹窗确认 ── */ - const handleCountConfirm = useCallback( - (count: number) => { - setPreviewCount(count) - setCountModalOpen(false) - setPreviewTitles((prev) => { - const list = prev || [] - const base = list[0] || titleSettings.title || "" - return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : "")) - }) - setVoiceLibraryIds((prev) => { - const list = prev || [] - return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "") - }) - setPreviewCovers((prev) => { - const list = prev || [] - return Array.from({ length: count }, (_, i) => list[i] ?? "") - }) - setSelectedVariantIds(Array.from({ length: count }, (_, i) => i)) - setCurrentStep(3) - }, - [ - setPreviewCount, - setPreviewTitles, - setVoiceLibraryIds, - setPreviewCovers, - setSelectedVariantIds, - setCurrentStep, - titleSettings.title, - selectedVoice, - ], - ) + /* ── 对齐批量数组长度到 previewCount(用于进入 Step3 时) ── */ + const ensureArraysAligned = useCallback(() => { + setPreviewTitles((prev) => { + const list = prev || [] + if (list.length === previewCount) return list + const base = list[0] || titleSettings.title || "" + return Array.from({ length: previewCount }, (_, i) => list[i] ?? (i === 0 ? base : "")) + }) + setVoiceLibraryIds((prev) => { + const list = prev || [] + if (list.length === previewCount) return list + return Array.from({ length: previewCount }, (_, i) => list[i] ?? selectedVoice ?? "") + }) + setPreviewCovers((prev) => { + const list = prev || [] + if (list.length === previewCount) return list + return Array.from({ length: previewCount }, (_, i) => list[i] ?? "") + }) + setSelectedVariantIds((prev) => { + if (prev && prev.length === previewCount) return prev + return Array.from({ length: previewCount }, (_, i) => i) + }) + }, [ + previewCount, + setPreviewTitles, + setVoiceLibraryIds, + setPreviewCovers, + setSelectedVariantIds, + titleSettings.title, + selectedVoice, + ]) /* ── #1970:Step1 弹窗回调 ── */ const handleVoiceModalConfirm = useCallback( @@ -409,9 +308,15 @@ const GeneratePage: React.FC = () => { ) const handleTtsSynthesized = useCallback( - (payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => { + (payload: { + voiceAssetId: string + ttsVoiceId: string + ttsVoiceSource: "preset" | "clone" + ttsStyle?: string + }) => { setTtsVoiceId(payload.ttsVoiceId) setTtsVoiceSource(payload.ttsVoiceSource) + if (payload.ttsStyle) setTtsStyle(payload.ttsStyle) setTtsVoiceAssetId(payload.voiceAssetId) if (payload.ttsVoiceSource === "clone") { setSelectedClonedVoice(payload.ttsVoiceId) @@ -427,6 +332,7 @@ const GeneratePage: React.FC = () => { [ setTtsVoiceId, setTtsVoiceSource, + setTtsStyle, setTtsVoiceAssetId, setSelectedVoice, setSelectedClonedVoice, @@ -437,19 +343,22 @@ const GeneratePage: React.FC = () => { /* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */ const handleConfirmGenerate = useCallback(async () => { - // 积分预检查 - const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1 - const check = hasEnoughPoints( - balance ?? null, - units, - dailyUsage ?? null, - [], - "free", - rules?.free_user_multiplier ?? 1.15, - ) - if (!check.sufficient) { - message.error(check.reason ?? "积分不足,请充值") - return + // 积分预检查(积分系统关闭时跳过,直接走生成流程) + let check: ReturnType = { sufficient: true, cost: 0 } + if (ENABLE_CREDIT_SYSTEM) { + const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1 + check = hasEnoughPoints( + balance ?? null, + units, + dailyUsage ?? null, + [], + "free", + rules?.free_user_multiplier ?? 1.15, + ) + if (!check.sufficient) { + message.error(check.reason ?? "积分不足,请充值") + return + } } if (isBatch) { if (selectedVariantIds.length === 0) { @@ -467,10 +376,6 @@ const GeneratePage: React.FC = () => { return } } - if (!previewReady) { - message.warning("预览素材正在加载,请稍候") - return - } const ok = await handleGenerate() if (ok) { setCurrentStep(4) @@ -481,7 +386,6 @@ const GeneratePage: React.FC = () => { previewTitles, titleSettings.aiAutoSelect, titleSettings.title, - previewReady, handleGenerate, setCurrentStep, balance, @@ -499,7 +403,7 @@ const GeneratePage: React.FC = () => { smartSelectedIds, titleSettings, generated, - onOpenCountModal: () => setCountModalOpen(true), + onBeforeEnterStep3: ensureArraysAligned, onOpenStep1Modal: () => { if (editMode === "random") { setVoiceModalOpen(true) @@ -509,30 +413,109 @@ const GeneratePage: React.FC = () => { }, }) - /* ── 最终成片 ── */ + /* ── 最终成片(单视频) ── */ const finalVideo = generatedVideos[0] + /* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */ + const handleFinish = useCallback(async () => { + if (finishing) return + // 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过 + if (isBatch) { + if (generatedVideos.length === 0) { + message.warning("请等待视频生成完成") + return + } + } else { + // 单视频:finalVideo 可能因 /results 接口在 awaiting_cover 阶段暂未返回 + // GeneratedVideo 记录而为 undefined;此时 currentTaskId 已在创建任务时保存, + // 下面 singleTaskId 兜底逻辑会用 currentTaskId 调 finalize,不应拦截 + if (!finalVideo && !currentTaskId) { + message.warning("请等待视频生成完成") + return + } + } + setFinishing(true) + const hide = message.loading("正在保存到视频库...", 0) + try { + // 收集需要 finalize 的任务 ID:批量用 batchTasks;单视频优先用 finalVideo.generation_task_id,兜底 currentTaskId + const singleTaskId = finalVideo?.generation_task_id || currentTaskId || "" + + // 单视频/批量:为每个任务调用 finalize(入库 + 绑定封面 + 自定义标题) + // 批量时必须按 batchTasks[i].variantIndex 对齐 previewCovers/previewTitles(taskIds 顺序不一定按变体序号) + if (isBatch && batchTasks.length > 0) { + await Promise.all( + batchTasks.map(async (task) => { + const vi = task.variantIndex + const rawCoverUrl = previewCovers[vi] || "" + const coverUrl = rawCoverUrl.startsWith("blob:") ? "" : rawCoverUrl + const title = previewTitles[vi] || titleSettings.title || "" + return finalizeGeneration(task.taskId, { + cover_url: coverUrl || undefined, + custom_title: title || undefined, + }) + }), + ) + } else if (singleTaskId) { + // 单视频:cover_url 仅在非 blob: 本地预览地址时才传;blob: URL 浏览器本地临时地址, + // 后端无法下载,此时不传让后端回退自动截帧封面(避免 400 保存失败)。 + // 正常流程本地上传完成后 uploadLocalCover 会把 URL 替换为 OSS 真实 URL,这里仅兜底异常场景。 + const rawCoverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || "" + const coverUrl = rawCoverUrl.startsWith("blob:") ? "" : rawCoverUrl + await finalizeGeneration(singleTaskId, { + cover_url: coverUrl || undefined, + custom_title: titleSettings.title || undefined, + }) + } else { + console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转") + } + + hide() + message.success("已保存到视频库") + navigate("/app/products") + } catch (err) { + hide() + console.error("[保存失败]", err) + const detail = + (err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data + ?.detail || + (err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data + ?.message || + "保存失败,请稍后在任务历史查看" + message.error(detail) + } finally { + setFinishing(false) + } + }, [ + finishing, + isBatch, + finalVideo, + generatedVideos, + batchTasks, + previewCovers, + previewTitles, + titleSettings.title, + coverSettings, + currentTaskId, + navigate, + ]) + /* ── 布局 class ── */ - const layoutClassName = useMemo(() => { - if (currentStep === 3) return "xx-generate-layout step4-layout" - return "xx-generate-layout full-width" - }, [currentStep]) + const layoutClassName = "xx-generate-layout full-width" /* ── 积分消耗估算(步骤3确认生成展示用) ── */ const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1 - const pointsEstimate = useMemo( - () => - hasEnoughPoints( - balance ?? null, - unitsForCost, - dailyUsage ?? null, - [], - "free", - rules?.free_user_multiplier ?? 1.15, - ), - [unitsForCost, balance, dailyUsage, rules], - ) - const insufficientPoints = !pointsEstimate.sufficient + const pointsEstimate = useMemo(() => { + if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 } + return hasEnoughPoints( + balance ?? null, + unitsForCost, + dailyUsage ?? null, + [], + "free", + rules?.free_user_multiplier ?? 1.15, + ) + }, [unitsForCost, balance, dailyUsage, rules]) + const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient /* ================================================================ 渲染 @@ -545,66 +528,7 @@ const GeneratePage: React.FC = () => {
- {/* ════ 步骤3:左侧预览大区域 ════ */} - {currentStep === 3 && ( -
- {!isBatch ? ( - 0} - serverClips={serverClips} - voiceAudioUrl={previewVoiceAudioUrl || undefined} - titleSettings={{ - title: titleSettings.title, - size: titleSettings.size, - font: titleSettings.font, - color: titleSettings.color, - position: titleSettings.position as "top" | "center" | "bottom" | "custom", - bold: titleSettings.bold, - italic: titleSettings.italic, - stroke: titleSettings.stroke, - shadow: titleSettings.shadow, - posX: titleSettings.posX, - posY: titleSettings.posY, - }} - onTitlePositionChange={styleUpdaters.updateTitlePosition} - /> - ) : ( -
-
-

🎬 {previewCount} 个视频预览

- - 实时预览,勾选要生成的视频 - -
- -
- )} -
- )} - - {/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */} + {/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
{ onToggleStroke={styleUpdaters.toggleStroke} onToggleShadow={styleUpdaters.toggleShadow} onApplyPreset={styleUpdaters.applyPreset} + onUpdateStyle={styleUpdaters.updateStyle} activePreset={styleUpdaters.activePreset} titlePresets={styleUpdaters.titlePresets} + enableTemplates={enableTemplates} + selectedTemplateId={selectedTitleTemplateId} + onApplyTemplate={(settings, tpl) => { + styleUpdaters.applyTemplate(settings) + setSelectedTitleTemplateId(tpl.id) + }} bgm={bgm} bgmConfig={bgmConfig} coverSettings={coverSettings} @@ -641,16 +572,14 @@ const GeneratePage: React.FC = () => { onPreviewCountChange={setPreviewCount} videoRatio={videoRatio as "9:16" | "16:9"} onVideoRatioChange={(r) => setVideoRatio(r)} - selectedScript={selectedScript} - ttsVoiceId={ttsVoiceId} - ttsVoiceSource={ttsVoiceSource} onSelectedVoiceChange={setSelectedVoice} - onServerClipsChange={setServerClips} generating={generating} generated={generated} generateError={generateError} progress={progress} generatedVideos={generatedVideos} + + currentTaskId={currentTaskId} onRetry={handleRetryGenerate} onRetryBatchTask={handleRetryBatchTask} onDismissError={handleDismissError} @@ -665,6 +594,9 @@ const GeneratePage: React.FC = () => { previewCovers={previewCovers} onPreviewCoversChange={setPreviewCovers} selectedVariantIds={selectedVariantIds} + selectedCoverTemplate={selectedTemplate} + onSelectedCoverTemplateChange={setSelectedTemplate} + onConfirmGenerate={handleConfirmGenerate} /> {/* ════ 步骤4(单视频):成片播放器 ════ */} @@ -727,12 +659,6 @@ const GeneratePage: React.FC = () => { -
@@ -752,18 +678,12 @@ const GeneratePage: React.FC = () => { pointsInsufficient={insufficientPoints} insufficientReason={pointsEstimate.reason} onRecharge={() => navigate("/app/points/recharge")} + onFinish={handleFinish} + finishing={finishing} />
- {/* 数量选择弹窗 */} - setCountModalOpen(false)} - /> - {/* 音色克隆弹窗 */} { open={ttsModalOpen} scriptText={selectedScript?.content ?? ""} scriptTitle={selectedScript?.title ?? ""} + style={ttsStyle} + onStyleChange={setTtsStyle} onCancel={() => setTtsModalOpen(false)} onSynthesized={handleTtsSynthesized} /> diff --git a/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx b/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx index 001187adb..84544eca2 100644 --- a/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx +++ b/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx @@ -11,7 +11,12 @@ * 防止长标题在窄列里溢出导致与相邻卡片进度条视觉重叠。 */ import React from "react" -import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons" +import { + LoadingOutlined, + CheckCircleFilled, + CloseCircleOutlined, + ClockCircleOutlined, +} from "@ant-design/icons" import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling" import type { GeneratedVideo } from "@/api/template-editor" @@ -37,7 +42,9 @@ const BatchGenerationGrid: React.FC = ({

🎬 正在生成 {tasks.length} 个视频

- 完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length} + 完成{" "} + {tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "} + {tasks.length}
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */} @@ -49,7 +56,7 @@ const BatchGenerationGrid: React.FC = ({
- {task.status === "completed" ? ( + {task.status === "completed" || task.status === "awaiting_cover" ? ( = ({ className="xx-batch-gen-card-icon" style={{ color: "#ef4444" }} /> + ) : task.status === "queued" ? ( + ) : ( = ({
{Math.round(task.progress)}%
)} - {task.status === "completed" && video && ( + {task.status === "queued" && ( +
+ + 排队等待中,前面任务完成后自动开始渲染 +
+ )} + {(task.status === "completed" || task.status === "awaiting_cover") && video && ( // 竖屏自适应容器(#1750):成片固定 1080×1920(9:16), // 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
= ({ />
)} - {task.status === "completed" && !video && ( + {(task.status === "completed" || task.status === "awaiting_cover") && !video && (
✅ 已完成(成片可在下一步选择封面)
)} {task.status === "failed" && ( diff --git a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx deleted file mode 100644 index 20a9891ef..000000000 --- a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx +++ /dev/null @@ -1,156 +0,0 @@ -/** - * 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划) - * - * N 个 FrontendPreviewPlayer 网格排列: - * - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口; - * - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实 - * clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片; - * 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览); - * 加载中 → 9:16 占位防塌陷; - * - 各自叠加独立标题浮层(variantTitle),标题样式全局共用; - * - 勾选框决定提交时生成哪些变体; - * - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥: - * 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。 - */ -import React, { useState } from "react" -import type { AssetItem } from "@/api/assets" -import type { EditPlanClip } from "@/api/template-editor" -import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons" -import { Button } from "antd" -import type { TitleSettings } from "../types" -import FrontendPreviewPlayer from "./FrontendPreviewPlayer" - -interface CanvasPreviewGridProps { - count: number - assets: AssetItem[] - videoRatio: string - titles: string[] - titleSettings: TitleSettings - /** - * 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条; - * 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中)) - */ - voiceAudioUrls?: (string | null)[] - /** - * 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined, - * 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。 - * 长度=count,undefined 项表示该变体未提供后端时长。 - */ - voiceDurations?: (number | undefined)[] - /** - * 各变体的后端真实片段(#1744/#1750):长度=count。 - * 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。 - */ - variantClips?: EditPlanClip[][] - /** 是否正在向后端申请变体计划 */ - clipsLoading?: boolean - /** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */ - clipsError?: boolean - /** 用户点击错误态「重试」 */ - onRetryClips?: () => void - /** 勾选的变体序号 */ - selectedIds: number[] - onToggleSelect: (index: number) => void - /** 生成中禁止勾选 */ - selectable?: boolean -} - -const CanvasPreviewGrid: React.FC = ({ - count, - assets, - videoRatio, - titles, - titleSettings, - voiceAudioUrls, - voiceDurations, - variantClips, - clipsLoading = false, - clipsError = false, - onRetryClips, - selectedIds, - onToggleSelect, - selectable = true, -}) => { - // ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750) ── - const [activePlayToken, setActivePlayToken] = useState(null) - - // count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp, - // 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节 - return ( -
- {Array.from({ length: count }, (_, i) => { - const checked = selectedIds.includes(i) - return ( -
-
- -
- {clipsError ? ( - // ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ── -
- 预览加载失败,请重试 - {i === 0 && onRetryClips ? ( - - ) : null} -
- ) : clipsLoading || !variantClips?.[i]?.length ? ( - // ── 加载态:9:16 占位防塌陷 ── -
- - 独立选片中… -
- ) : ( - 0} - playToken={i} - serverClips={variantClips[i]} - variantTitle={titles[i] || ""} - voiceAudioUrl={voiceAudioUrls?.[i] || undefined} - voiceDurationHint={voiceDurations?.[i]} - activePlayToken={activePlayToken} - onPlayTokenChange={setActivePlayToken} - compact - titleSettings={{ - title: titles[i] || "", - size: titleSettings.size, - font: titleSettings.font, - color: titleSettings.color, - position: titleSettings.position as "top" | "center" | "bottom" | "custom", - bold: titleSettings.bold, - italic: titleSettings.italic, - stroke: titleSettings.stroke, - shadow: titleSettings.shadow, - posX: titleSettings.posX, - posY: titleSettings.posY, - }} - /> - )} -
- ) - })} -
- ) -} - -export default CanvasPreviewGrid diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx deleted file mode 100644 index c0ca9a43a..000000000 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ /dev/null @@ -1,602 +0,0 @@ -/** - * 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI) - * - * 架构: - * - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度), - * 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。 - * UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。 - * - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除), - * 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。 - * - * 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。 - */ -import React, { useMemo, useCallback, useState, useRef, useEffect } from "react" -import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons" -import type { AssetItem } from "@/api/assets" -import type { EditPlanClip } from "@/api/template-editor" -import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler" -import { usePreviewAudio } from "../hooks/usePreviewAudio" -import { PreviewControls } from "./PreviewControls" -import { getFontFamily } from "../constants" - -interface FrontendPreviewPlayerProps { - assets: AssetItem[] - videoRatio: string - ready: boolean - /** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */ - serverClips?: EditPlanClip[] - voiceAudioUrl?: string - titleSettings?: { - title: string - size: number - font: string - color: string - position: "top" | "center" | "bottom" | "custom" - bold?: boolean - italic?: boolean - stroke?: boolean - shadow?: boolean - posX?: number | null - posY?: number | null - } - onTitlePositionChange?: (posX: number, posY: number) => void - /** - * 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。 - * 持有播放权的实例播放,其余自动暂停。 - */ - playToken?: number - /** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */ - variantTitle?: string - /** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */ - compact?: boolean - /** - * 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。 - * 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。 - */ - activePlayToken?: number | null - /** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */ - onPlayTokenChange?: (token: number | null) => void - /** - * 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点; - * 未提供则在 audio loadedmetadata 后自测兜底。 - */ - voiceDurationHint?: number -} - -/** - * 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback) - * - * 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。 - * 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。 - */ -function buildPlaybackSegments( - assets: AssetItem[], - serverClips?: EditPlanClip[], - /** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */ - speedFactor = 1, -): PlaybackSegment[] { - if (!assets.length || !serverClips || serverClips.length === 0) return [] - - const assetMap = new Map(assets.map((a) => [a.id, a])) - const segments: PlaybackSegment[] = [] - // speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速 - const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1 - for (const clip of serverClips) { - const asset = assetMap.get(clip.asset_id) - if (!asset) continue - const assetDuration = asset.duration || asset.metadata?.duration || 0 - const startTime = clip.start_time || 0 - // 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界 - const rawClipDuration = clip.duration || 0 - // #1754:按 speedFactor 缩放片段时长,使总时长匹配配音 - const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration - const endTime = - assetDuration > 0 - ? Math.min(startTime + clipDuration, assetDuration) - : startTime + clipDuration - const videoUrl = asset.file_url || asset.storage_key - segments.push({ - assetId: asset.id, - videoUrl, - startTime, - endTime, - order: clip.order, - playbackRate: invSpeed !== 1 ? speedFactor : undefined, - }) - } - return segments.sort((a, b) => a.order - b.order) -} - -const FrontendPreviewPlayer: React.FC = ({ - assets, - videoRatio, - ready, - serverClips, - voiceAudioUrl, - voiceDurationHint, - titleSettings, - onTitlePositionChange, - playToken, - variantTitle, - compact = false, - activePlayToken = null, - onPlayTokenChange, -}) => { - // #1754→P0:配音时长作为音画时长锚点。 - // 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。 - const [voiceDuration, setVoiceDuration] = useState(() => - voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0, - ) - - // #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位) - const rawClipsDuration = useMemo(() => { - if (!serverClips?.length) return 0 - return serverClips.reduce((sum, c) => sum + (c.duration || 0) + (c.transition_duration || 0), 0) - }, [serverClips]) - - // #1754→#1756:配音时长可用且与 clips+转场 总时长偏差 > 5% 时,按比例调速 - const speedFactor = useMemo(() => { - if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1 - const ratio = rawClipsDuration / voiceDuration - return Math.abs(ratio - 1) > 0.05 ? ratio : 1 - }, [voiceDuration, rawClipsDuration]) - - const segments = useMemo( - () => buildPlaybackSegments(assets, serverClips, speedFactor), - [assets, serverClips, speedFactor], - ) - // 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制 - const effectiveTitle = variantTitle ?? titleSettings?.title - - // ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── - const TITLE_MARGIN_TOP = 180 - const TITLE_MARGIN_BOTTOM = 100 - const TITLE_MARGIN_SIDE = 40 - const playRes = (() => { - switch (videoRatio) { - case "16:9": - return { width: 1920, height: 1080 } - case "1:1": - return { width: 1080, height: 1080 } - case "9:16": - default: - return { width: 1080, height: 1920 } - } - })() - const customTitleXPct = - titleSettings?.posX != null && playRes.width > 0 - ? (titleSettings.posX / playRes.width) * 100 - : null - const customTitleYPct = - titleSettings?.posY != null && playRes.height > 0 - ? (titleSettings.posY / playRes.height) * 100 - : null - - // ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)── - const draggingTitleRef = useRef(false) - const titleDragRef = useRef(null) - const playerContainerRef = useRef(null) - const handleTitlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (!onTitlePositionChange || !playerContainerRef.current) return - e.preventDefault() - e.stopPropagation() - ;(e.target as Element).setPointerCapture(e.pointerId) - draggingTitleRef.current = true - ;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing" - }, - [onTitlePositionChange], - ) - const handleTitlePointerMove = useCallback((e: React.PointerEvent) => { - if (!draggingTitleRef.current || !playerContainerRef.current) return - e.preventDefault() - e.stopPropagation() - if (titleDragRef.current) { - const rect = playerContainerRef.current.getBoundingClientRect() - const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) - const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)) - const xpct = (relX / rect.width) * 100 - const ypct = (relY / rect.height) * 100 - titleDragRef.current.style.left = `${xpct}%` - titleDragRef.current.style.top = `${ypct}%` - } - }, []) - const handleTitlePointerUp = useCallback( - (e: React.PointerEvent) => { - if (!draggingTitleRef.current) return - draggingTitleRef.current = false - if (onTitlePositionChange && playerContainerRef.current) { - const rect = playerContainerRef.current.getBoundingClientRect() - const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) - const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)) - const posX = Math.round((relX / rect.width) * playRes.width) - const posY = Math.round((relY / rect.height) * playRes.height) - onTitlePositionChange(posX, posY) - } - ;(e.currentTarget as HTMLDivElement).style.cursor = "grab" - try { - if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) { - ;(e.currentTarget as Element).releasePointerCapture(e.pointerId) - } - } catch { - /* ignore */ - } - }, - [onTitlePositionChange, playRes.width, playRes.height], - ) - - const [containerHeight, setContainerHeight] = useState(0) - useEffect(() => { - const el = playerContainerRef.current - if (!el) return - const ro = new ResizeObserver((entries) => { - for (const entry of entries) { - const h = entry.contentRect.height - if (h > 0) setContainerHeight(h) - } - }) - ro.observe(el) - const rect = el.getBoundingClientRect() - if (rect.height > 0) setContainerHeight(rect.height) - return () => ro.disconnect() - }, []) - - // 标题字号按容器高度与 PlayResY 的比例缩放 - const titleFontSizePx = - containerHeight > 0 - ? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight - : (titleSettings?.size ?? 36) - const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100 - const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100 - const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100 - const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1 - const titleStrokeWidth = Math.max(1, 2 * titleScale) - const titleShadowBlur = 4 * titleScale - const titleShadowOffset = 2 * titleScale - - // ── Video 播放器(默认路径,浏览器原生硬件解码) ── - const { - isPlaying, - currentTime, - totalDuration, - currentSegmentIndex, - canPlay, - togglePlayPause, - seekTo, - pause, - videoRefs, - } = useSegmentScheduler(segments) - - // P0 fix:以配音时长为音画同步锚点。 - // 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。 - const effectiveTotalDuration = - !!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration - - // 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片 - const [muted, setMuted] = useState(false) - // 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底 - const hasVoice = !!voiceAudioUrl - - // 音频 ended:兜底触发暂停与释放播放权 - const handleAudioEnded = useCallback(() => { - if (!isPlaying) return - pause() - if (playToken != null) onPlayTokenChange?.(null) - }, [isPlaying, pause, playToken, onPlayTokenChange]) - - const { - seekTo: audioSeekTo, - ensurePlayingAt: audioEnsurePlayingAt, - pause: audioPause, - } = usePreviewAudio({ - voiceAudioUrl, - voiceDurationHint, - muted, - isPlaying, - currentTime, - onVoiceDurationChange: setVoiceDuration, - onEnded: handleAudioEnded, - }) - - // 片段切换时同步音频时间(video fallback) - useEffect(() => { - if (!isPlaying) return - audioSeekTo(currentTime) - // 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSegmentIndex, isPlaying]) - - // P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。 - // 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。 - const [tailCurrentTime, setTailCurrentTime] = useState(null) - const tailStartRef = useRef(0) - const tailBaseRef = useRef(0) - const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }) - tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause } - - useEffect(() => { - const needTail = - !!voiceAudioUrl && - voiceDuration > 0 && - !isPlaying && - typeof currentTime === "number" && - currentTime >= totalDuration - 0.1 && - currentTime < voiceDuration - 0.1 - if (needTail && tailCurrentTime === null) { - tailBaseRef.current = currentTime - tailStartRef.current = performance.now() - setTailCurrentTime(currentTime) - tailAudioRef.current.ensurePlayingAt(currentTime) - return - } - if (!needTail && tailCurrentTime !== null) { - setTailCurrentTime(null) - } - }, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime]) - - useEffect(() => { - if (tailCurrentTime === null) return - let raf = 0 - const tick = () => { - const elapsed = (performance.now() - tailStartRef.current) / 1000 - const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current) - setTailCurrentTime(t) - tailAudioRef.current.ensurePlayingAt(t) - if (t >= (voiceDuration || 0) - 0.05) { - tailAudioRef.current.pause() - if (playToken != null) onPlayTokenChange?.(null) - setTailCurrentTime(null) - return - } - raf = requestAnimationFrame(tick) - } - raf = requestAnimationFrame(tick) - return () => cancelAnimationFrame(raf) - }, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange]) - - // 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间 - const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime - - const handleSeekTo = useCallback( - (time: number) => { - setTailCurrentTime(null) - seekTo(time) - audioSeekTo(time) - }, - [seekTo, audioSeekTo], - ) - - // ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ── - useEffect(() => { - if (activePlayToken == null || playToken == null || activePlayToken === playToken) return - if (isPlaying) { - pause() - } - // isPlaying 不放依赖:只在 token 变化时执行一次暂停 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activePlayToken, playToken]) - - const handleTogglePlay = useCallback(() => { - if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken) - togglePlayPause() - }, [togglePlayPause, isPlaying, playToken, onPlayTokenChange]) - - // P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结) - useEffect(() => { - if (!isPlaying) return - if (!voiceAudioUrl || voiceDuration <= 0) return - if (displayCurrentTime < voiceDuration - 0.08) return - pause() - audioPause() - if (playToken != null) onPlayTokenChange?.(null) - }, [ - isPlaying, - displayCurrentTime, - voiceAudioUrl, - voiceDuration, - pause, - audioPause, - playToken, - onPlayTokenChange, - ]) - - // ── 未就绪 ── - if (!ready || !assets.length) { - return ( -
- -

- 准备预览素材... -

-

- 加载素材后即可预览播放 -

-
- ) - } - - // ── 无播放片段 ── - if (!canPlay) { - return ( -
- -

- 暂无可播放素材 -

-

- 请先在左侧选择素材 -

-
- ) - } - - return ( -
- {/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */} - {segments.map((seg, i) => ( -
- ) -} - -export default FrontendPreviewPlayer diff --git a/apps/web/src/pages/generate/components/GenerateStepActions.tsx b/apps/web/src/pages/generate/components/GenerateStepActions.tsx index 2f6c5660b..42c950c4d 100644 --- a/apps/web/src/pages/generate/components/GenerateStepActions.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepActions.tsx @@ -5,7 +5,7 @@ * 步骤 1~3:上一步 / 下一步 * 步骤 4(确认生成/进度):未开始 →「✨ 确认生成视频」;生成中 →「⏳ 视频渲染中…」; * 失败 →「🔄 重新生成」;全部完成 →「下一步:选择封面 →」 - * 步骤 5(选择封面):仅上一步,无主按钮 + * 步骤 5(选择封面):上一步 + 完成按钮 */ import React from "react" import { Tooltip } from "antd" @@ -32,6 +32,10 @@ export interface GenerateStepActionsProps { freeClipsUsedThisTime?: number /** 前往充值 */ onRecharge?: () => void + /** Step5 点击完成(保存入库并跳转) */ + onFinish?: () => void | Promise + /** Step5 保存中 */ + finishing?: boolean } const GenerateStepActions: React.FC = ({ @@ -48,6 +52,8 @@ const GenerateStepActions: React.FC = ({ insufficientReason, freeClipsUsedThisTime, onRecharge, + onFinish, + finishing, }) => { const renderPrimaryButton = () => { /* 步骤 1~3:上一步 / 下一步 */ @@ -126,8 +132,18 @@ const GenerateStepActions: React.FC = ({ ) } - /* 步骤 5(封面,最后一步):无主按钮 */ - return null + /* 步骤 5(封面,最后一步):完成按钮 */ + return ( + + ) } return ( diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 6a331f980..22af3044f 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -4,18 +4,18 @@ * 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。 */ import React from "react" -import type { EditPlanClip } from "@/api/template-editor" import type { CoverConfig } from "../types/cover" import type { TitleSettings } from "../types" -import type { ScriptItem } from "@/api/scripts" import Step1EditMode from "./Step1EditMode" import type { EditMode } from "./Step1EditMode" import Step2MaterialSelect from "../components/Step2MaterialSelect" import Step4TitleSettings from "../components/Step4TitleSettings" import Step6CoverSettings from "../components/Step6CoverSettings" import BatchGenerationGrid from "./BatchGenerationGrid" +import Step3VoiceWithMode from "./Step3VoiceWithMode" import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling" import type { GeneratedVideo } from "@/api/template-editor" +import type { TitleTemplate } from "@/components/title/template-types" export interface GenerateStepContentProps { currentStep: number @@ -51,15 +51,23 @@ export interface GenerateStepContentProps { onToggleStroke: () => void onToggleShadow: () => void onApplyPreset: (presetKey: string) => void + onUpdateStyle?: (patch: Partial) => void activePreset: string | null - titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] + titlePresets: Array<{ + key: string + label: string + emoji?: string + style: Record + }> + enableTemplates?: boolean + selectedTemplateId?: string | null + onApplyTemplate?: (settings: import("../types").TitleSettings, template: TitleTemplate) => void /* ── 封面 ── */ coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void /* ── 配音 ── */ selectedVoice: string onSelectedVoiceChange: (id: string) => void - onServerClipsChange: (clips: EditPlanClip[]) => void /* ── 生成 ── */ generating: boolean generated: boolean @@ -82,10 +90,12 @@ export interface GenerateStepContentProps { previewCovers: string[] onPreviewCoversChange: (urls: string[]) => void selectedVariantIds?: number[] - /* ── 摘要信息(#1970 Step4 展示用) ── */ - selectedScript: ScriptItem | null - ttsVoiceId: string - ttsVoiceSource: "preset" | "clone" + selectedCoverTemplate?: string + onSelectedCoverTemplateChange?: (templateId: string) => void + /** 单视频任务 ID(兜底,awaiting_cover 状态下 results 接口未入库时用) */ + currentTaskId?: string + /** Step3 右上角确认生成按钮 */ + onConfirmGenerate?: () => void | Promise } export const GenerateStepContent: React.FC = (props) => { @@ -119,11 +129,14 @@ export const GenerateStepContent: React.FC = (props) = onToggleStroke, onToggleShadow, onApplyPreset, + onUpdateStyle, + enableTemplates, + selectedTemplateId, + onApplyTemplate, activePreset, titlePresets, coverSettings, onCoverSettingsChange, - onServerClipsChange, generating, generateError, progress, @@ -136,18 +149,17 @@ export const GenerateStepContent: React.FC = (props) = previewCovers, onPreviewCoversChange, selectedVariantIds, - selectedScript, - ttsVoiceId, - ttsVoiceSource, + selectedCoverTemplate, + onSelectedCoverTemplateChange, + onConfirmGenerate, + selectedVoice, + onSelectedVoiceChange, + voiceModePerVideo, + onVoiceModePerVideoChange, + voiceLibraryIds, + onVoiceLibraryIdsChange, } = props - const handleClipsChange = React.useCallback( - (clips: EditPlanClip[], _templateId?: string) => { - onServerClipsChange(clips) - }, - [onServerClipsChange], - ) - switch (currentStep) { case 1: return ( @@ -175,66 +187,54 @@ export const GenerateStepContent: React.FC = (props) = clipCount={clipCount} onClipCountChange={onClipCountChange} selectedTemplate={selectedTemplate} - onServerClipsChange={handleClipsChange} /> ) case 3: return ( - + <> + 1 + ? props.selectedVariantIds?.length || 1 + : 1 + } + /> + {/* 批量配音选择:共用/独立切换(#2096) */} + + ) case 4: return (
- {/* 配置摘要(#1970) */} -
-
- 📋 生成配置 -
-
🎬 剪辑模式:{editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}
- {editMode === "random" ? ( -
🎙️ 配音来源:配音库音频
- ) : ( - <> -
📝 文案:{selectedScript?.title ?? "未选择"}
-
- 🎙️ 合成配音音色: - {ttsVoiceId - ? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}(${ttsVoiceId.slice(0, 8)}...)` - : "未选择"} -
- - )} -
📱 视频比例:{videoRatio}
-
🎯 智能降重:{dedupEnabled ? "已开启" : "已关闭"}
- {previewCount > 1 &&
📦 生成数量:{previewCount} 个
} -
- {previewCount > 1 ? ( = (props) = previewCovers={previewCovers} onPreviewCoversChange={onPreviewCoversChange} selectedVariantIndexes={selectedVariantIds} + selectedTemplate={selectedCoverTemplate} + onTemplateChange={onSelectedCoverTemplateChange} + currentTaskId={props.currentTaskId} /> ) default: diff --git a/apps/web/src/pages/generate/components/PreviewControls.tsx b/apps/web/src/pages/generate/components/PreviewControls.tsx deleted file mode 100644 index 41d0889e4..000000000 --- a/apps/web/src/pages/generate/components/PreviewControls.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React from "react" -import { - PlayCircleOutlined, - PauseCircleOutlined, - AudioOutlined, - AudioMutedOutlined, -} from "@ant-design/icons" -import { PreviewProgressBar } from "./PreviewProgressBar" - -interface PreviewControlsProps { - isPlaying: boolean - onTogglePlay: () => void - muted: boolean - onToggleMute: () => void - hasSegments: boolean - segmentIndex: number - segmentCount: number - currentTime: number - totalDuration: number - onSeek: (time: number) => void - compact?: boolean -} - -/** - * 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条) - */ -export const PreviewControls: React.FC = ({ - isPlaying, - onTogglePlay, - muted, - onToggleMute, - hasSegments, - segmentIndex, - segmentCount, - currentTime, - totalDuration, - onSeek, - compact = false, -}) => { - return ( - <> - {/* 静音/有声切换(#1741):左上角 */} - {hasSegments && ( - - )} - - {/* 片段指示器 — 右上角胶囊 */} -
- {`${segmentIndex + 1} / ${segmentCount}`} -
- - {/* 中央播放按钮 */} - {!isPlaying && ( - - )} - - {/* 控制条 — 手机风格毛玻璃 */} -
- - - -
- - ) -} diff --git a/apps/web/src/pages/generate/components/PreviewCountModal.tsx b/apps/web/src/pages/generate/components/PreviewCountModal.tsx deleted file mode 100644 index f8a334bf7..000000000 --- a/apps/web/src/pages/generate/components/PreviewCountModal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/** - * 生成数量选择弹窗(Issue #1677) - * Step1 选完模板点「下一步」时弹出:要生成几个视频?(1~10) - * 默认 1,回车 = 1(零额外操作) - */ -import React, { useState, useEffect, useRef } from "react" -import { MAX_PREVIEW_COUNT } from "../constants" - -interface PreviewCountModalProps { - open: boolean - /** 默认值(上次选择,默认1) */ - defaultCount?: number - onConfirm: (count: number) => void - onCancel: () => void -} - -const PreviewCountModal: React.FC = ({ - open, - defaultCount = 1, - onConfirm, - onCancel, -}) => { - const [count, setCount] = useState(defaultCount) - const inputRef = useRef(null) - - useEffect(() => { - if (open) { - setCount(defaultCount) - // 弹窗打开后聚焦并选中,方便直接回车=默认1 - setTimeout(() => inputRef.current?.focus(), 50) - } - }, [open, defaultCount]) - - const clamp = (n: number) => Math.max(1, Math.min(MAX_PREVIEW_COUNT, n || 1)) - - const handleConfirm = () => { - onConfirm(clamp(count)) - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault() - handleConfirm() - } - if (e.key === "Escape") { - onCancel() - } - } - - if (!open) return null - - return ( -
-
e.stopPropagation()}> -

要生成几个视频?

-

- 素材共用,AI 随机剪辑出不同版本,每个视频可独立设置标题、配音和封面 -

- -
- - setCount(clamp(parseInt(e.target.value, 10) || 1))} - onKeyDown={handleKeyDown} - className="xx-count-input" - /> - -
- -
- {[1, 3, 5, 10].map((n) => ( - - ))} -
- -
- - -
-

- 直接按回车 = 生成 1 个 -

-
-
- ) -} - -export default PreviewCountModal diff --git a/apps/web/src/pages/generate/components/PreviewProgressBar.tsx b/apps/web/src/pages/generate/components/PreviewProgressBar.tsx deleted file mode 100644 index f3fe337a7..000000000 --- a/apps/web/src/pages/generate/components/PreviewProgressBar.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react" -import { formatDuration } from "../utils/formatDuration" - -interface PreviewProgressBarProps { - currentTime: number - totalDuration: number - onSeek: (time: number) => void - compact?: boolean -} - -/** - * 进度条组件:点击/拖拽 seek - */ -export const PreviewProgressBar: React.FC = ({ - currentTime, - totalDuration, - onSeek, - compact = false, -}) => { - const progressRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) - - const seekByClientX = useCallback( - (clientX: number) => { - if (!progressRef.current || totalDuration <= 0) return - const rect = progressRef.current.getBoundingClientRect() - const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) - onSeek(ratio * totalDuration) - }, - [totalDuration, onSeek], - ) - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - setIsDragging(true) - seekByClientX(e.clientX) - }, - [seekByClientX], - ) - - useEffect(() => { - if (!isDragging) return - const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX) - const handleMouseUp = () => setIsDragging(false) - window.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - return () => { - window.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - } - }, [isDragging, seekByClientX]) - - const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0 - - return ( - <> - - {formatDuration(currentTime)} / {formatDuration(totalDuration)} - - -
-
-
-
- - ) -} diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 6f8500519..fdd1a1f23 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -1,23 +1,19 @@ /** * Step 4 选择标题(Issue #1677 批量生成) * - * 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格), - * 右侧边栏标题设置。本组件渲染在右侧边栏: - * - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归) - * - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成 - * (一次生成 N 个标题,分别填入各变体,可单独换一个) + * 布局:全宽区域,标题输入 + 标题模板卡片网格。 + * - 单视频:TitleLibraryAutoComplete 标题库输入 + * - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择) * - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一 */ -import React, { useMemo, useState } from "react" -import { Input, message } from "antd" -import { LoadingOutlined } from "@ant-design/icons" +import React, { useMemo } from "react" import type { TitleSettings } from "../types" -import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants" +import { POSITION_OPTIONS } from "../constants" +import { FONT_OPTIONS } from "@/components/title/constants" import { useStep4Title } from "../hooks/useStep4Title" -import AiTitleGenerator from "./title/AiTitleGenerator" import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete" import TitleStylePanel from "./title/TitleStylePanel" -import { AI_TITLE_TEMPLATES } from "../constants" +import type { TitleTemplate } from "@/components/title/template-types" interface Step4TitleSettingsProps { titleSettings: TitleSettings @@ -33,44 +29,30 @@ interface Step4TitleSettingsProps { onToggleStroke: () => void onToggleShadow: () => void onApplyPreset: (presetKey: string) => void + onUpdateStyle?: (patch: Partial) => void activePreset: string | null - titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] + titlePresets: Array<{ + key: string + label: string + emoji?: string + style?: Record + previewStyle?: React.CSSProperties + }> /* ── 批量生成(#1677)── */ /** 生成数量 */ previewCount?: number /** 每个变体的标题文字(长度=previewCount) */ previewTitles?: string[] onPreviewTitlesChange?: (titles: string[]) => void -} - -/** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */ -function buildBatchAiTitles(topic: string, count: number): string[] { - const styles: Array<"catchy" | "emotional" | "informative"> = [ - "catchy", - "emotional", - "informative", - ] - const pool: string[] = [] - styles.forEach((style) => { - const templates = AI_TITLE_TEMPLATES[style] || [] - templates.forEach((tpl) => pool.push(tpl.replace(/\{topic\}/g, topic))) - }) - // 洗牌后取前 count 个;不足则轮转补齐 - const shuffled = [...pool].sort(() => Math.random() - 0.5) - const out: string[] = [] - for (let i = 0; i < count; i++) { - out.push(shuffled[i % shuffled.length] || "") - } - return out -} - -function extractTopic(text: string): string { - const keywords = text - .replace(/[,。!?、,.!?]/g, " ") - .split(/\s+/) - .filter(Boolean) - if (keywords.length === 0) return "这个话题" - return keywords.slice(0, 3).join("") + enableTemplates?: boolean + selectedTemplateId?: string | null + onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void + /** Step3 右上角「🎬 确认生成」主按钮 */ + onConfirmGenerate?: () => void | Promise + /** 是否生成中 */ + generating?: boolean + /** 批量模式下勾选数量 */ + selectedCount?: number } const Step4TitleSettings: React.FC = (props) => { @@ -84,16 +66,21 @@ const Step4TitleSettings: React.FC = (props) => { onToggleStroke, onToggleShadow, onApplyPreset, + onUpdateStyle, activePreset, titlePresets, previewCount = 1, previewTitles, onPreviewTitlesChange, + enableTemplates, + selectedTemplateId, + onApplyTemplate, + onConfirmGenerate, + generating, + selectedCount = 1, } = props const isBatch = previewCount > 1 - const [batchAiLoading, setBatchAiLoading] = useState(false) - const [batchAiTopic, setBatchAiTopic] = useState("") /** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */ const updateVariantTitle = (index: number, val: string) => { @@ -106,117 +93,71 @@ const Step4TitleSettings: React.FC = (props) => { } } - /** 批量 AI 生成:按主题词生成标题,分别填入 N 个变体 */ - const handleBatchAiGenerate = async (onlyEmpty = false) => { - if (!onPreviewTitlesChange || !previewTitles) return - const topic = (batchAiTopic || t.aiTitleInput || "").trim() - if (!topic) { - message.warning("请先输入主题词,例如:萌宠日常、旅行vlog") - return - } - setBatchAiLoading(true) - try { - // 与单视频一致:本地模板模拟 AI 生成(1200ms 体验延迟) - await new Promise((resolve) => setTimeout(resolve, 800)) - const picked = buildBatchAiTitles(extractTopic(topic), previewCount) - const next = [...previewTitles] - for (let i = 0; i < previewCount; i++) { - if (onlyEmpty && next[i]?.trim()) continue - if (picked[i]) next[i] = picked[i] - } - onPreviewTitlesChange(next) - if (next[0]) t.updateTitle(next[0]) - message.success(`已为 ${previewCount} 个视频生成标题,可单独修改`) - } finally { - setBatchAiLoading(false) - } - } - const titleOptions = useMemo( () => t.userTitles.map((ut) => ({ label: ut.content, value: ut.content })), [t.userTitles], ) return ( -
+
+ {/* ── 右上角「🎬 确认生成」主按钮 ── */} + {onConfirmGenerate && ( + + )}

📝 选择标题

{!isBatch ? ( - /* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */ - <> - {t.titleSettings.aiAutoSelect ? ( - <> -
- AI 自动选择标题 -
-
-
-
-
- -
- - {(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"} - - -
-
- - ) : ( - <> - - -
- AI 自动选择标题 -
-
-
-
-
- - { - t.updateTitle(val || "") - onPreviewTitlesChange?.([val || ""]) - }} - options={titleOptions} - /> -
- - )} - + /* ── 单视频:标题输入框 ── */ +
+ + { + t.updateTitle(val || "") + onPreviewTitlesChange?.([val || ""]) + }} + options={titleOptions} + /> +
) : ( - /* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */ + /* ── 批量:N 个独立标题输入框(两列布局 #2096) ── */
= (props) => { lineHeight: 1.6, }} > - 为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。 + 为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
- {/* 批量 AI 标题 */} -
- { - setBatchAiTopic(e.target.value) - t.setAiTitleInput(e.target.value) - }} - maxLength={30} - size="small" - style={{ flex: 1 }} - /> - - +
+ {Array.from({ length: previewCount }, (_, i) => ( +
+ + updateVariantTitle(i, val)} + options={titleOptions} + /> +
+ ))}
- - {Array.from({ length: previewCount }, (_, i) => ( -
- - updateVariantTitle(i, val)} - options={titleOptions} - /> -
- ))}
)} @@ -285,10 +203,15 @@ const Step4TitleSettings: React.FC = (props) => { onToggleStroke={onToggleStroke} onToggleShadow={onToggleShadow} onApplyPreset={onApplyPreset} + onUpdateStyle={onUpdateStyle} + showCoverToggle activePreset={activePreset} titlePresets={titlePresets} POSITION_OPTIONS={POSITION_OPTIONS} FONT_OPTIONS={FONT_OPTIONS} + enableTemplates={enableTemplates} + selectedTemplateId={selectedTemplateId} + onApplyTemplate={onApplyTemplate} />
) diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index bc79ade53..ac088d032 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -1,84 +1,242 @@ /** - * Step 5 选择封面(Issue #1677 批量生成改造) - * - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览) - * - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传 + * Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择) + * - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传) + * - N 个视频:N 张封面卡片,每张带对应视频标题,支持统一选择封面模板、逐个自动生成或上传 + * + * 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover */ -import React, { useRef } from "react" -import { Modal, Spin } from "antd" +import React, { useCallback, useEffect, useMemo, useState } from "react" +import { Modal, Spin, message } from "antd" import { LoadingOutlined } from "@ant-design/icons" import type { CoverConfig } from "../types/cover" import type { GeneratedVideo } from "@/api/template-editor" import type { TitleSettings } from "../types" -import { useStep6Cover } from "../hooks/useStep6Cover" import { useBatchCovers } from "../hooks/useBatchCovers" import Button from "@/components/ui/Button" import CoverSettingsModal from "./cover-settings/CoverSettingsModal" import CoverEditorModal from "./cover-settings/CoverEditorModal" +import { useSharedCover } from "@/components/cover/useSharedCover" +import { generateCover as apiGenerateCover } from "@/api/generation" +import { uploadAssetDirect } from "@/api/assets" interface Step6CoverSettingsProps { coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void - /** 当前选中的模板 ID */ selectedTemplate?: string - /** Step4 标题设置,用于封面叠加标题 */ titleSettings?: TitleSettings - /** 确认生成步骤产出的最终视频列表 */ generatedVideos: GeneratedVideo[] - /* ── 批量生成(#1677)── */ previewCount?: number - /** 每个变体的标题文字 */ previewTitles?: string[] - /** 每个变体的封面URL(按变体索引) */ previewCovers?: string[] onPreviewCoversChange?: (urls: string[]) => void - /** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */ selectedVariantIndexes?: number[] + onTemplateChange?: (templateId: string) => void + /** 单视频任务 ID(awaiting_cover 阶段 results 接口可能返回 preview-xxx 合成对象,兜底用) */ + currentTaskId?: string } const Step6CoverSettings: React.FC = (props) => { - const { - coverSettings, - generating, - generateAutoCover, - finalVideo, - showCoverSettings, - setShowCoverSettings, - showCoverEditor, - setShowCoverEditor, - selectedTemplateId, - editingTemplate, - coverTemplates, - templatesLoading, - templatesError, - handleSelectTemplate, - handleEditTemplate, - handleSaveTemplate, - handleDeleteTemplate, - } = useStep6Cover({ - coverSettings: props.coverSettings, - onCoverSettingsChange: props.onCoverSettingsChange, - selectedTemplate: props.selectedTemplate, - titleSettings: props.titleSettings, - generatedVideos: props.generatedVideos, - }) - const previewCount = props.previewCount || 1 const isBatch = previewCount > 1 const previewTitles = props.previewTitles || [] const previewCovers = props.previewCovers || [] - /** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */ const cardIndexes = isBatch && props.selectedVariantIndexes?.length ? props.selectedVariantIndexes : Array.from({ length: previewCount }, (_, i) => i) - const uploadInputRef = useRef(null) - const uploadTargetRef = useRef(0) - const completedVideos = props.generatedVideos.filter((v) => v.status === "completed") + /** 最终成片:取第一个已完成视频(单视频场景) */ + const finalVideo = + props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") || + props.generatedVideos[0] + + /** + * 兜底任务/视频 ID:awaiting_cover 阶段后端 /results 可能还没有入库 GeneratedVideo, + * 只返回合成的 preview-{taskId} 轻量对象;此时用 currentTaskId 兜底让后端能找到任务。 + * 同时统一抽取 taskId(generation_task_id 优先)用于日志/错误提示。 + */ + const effectiveTaskId = + (finalVideo as { generation_task_id?: string } | undefined)?.generation_task_id || + props.currentTaskId || + "" + const _rawVideoId = + (finalVideo as { id?: string; video_id?: string } | undefined)?.id || + (finalVideo as { video_id?: string } | undefined)?.video_id || + "" + // preview-{taskId} 是后端合成的临时 id,gv_repo.get 查不到 → 不传 generated_video_id, + // 让后端走 plan.config.generation_task_id / rendered_storage_key 兜底路径。 + const effectiveVideoId = _rawVideoId && !_rawVideoId.startsWith("preview-") ? _rawVideoId : "" + const effectiveVideoUrl = finalVideo?.file_url || finalVideo?.download_url || "" + + /** 按钮可用:非批量 且 (有 finalVideo 对象或兜底 taskId) 且 视频状态已完成/等待封面/未设置 */ + const isVideoReady = + !finalVideo || + finalVideo.status === "completed" || + finalVideo.status === "awaiting_cover" || + !finalVideo.status + const canGenerateCover = !isBatch && (!!finalVideo || !!effectiveTaskId) && isVideoReady + + const completedVideos = useMemo( + () => + props.generatedVideos.filter( + (v) => v.status === "completed" || v.status === "awaiting_cover", + ), + [props.generatedVideos], + ) + + /** + * 单视频自动生成(点击"自动生成封面"按钮):使用当前选中的模板 + * 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发 + */ + const shared = useSharedCover({ + canGenerate: canGenerateCover, + disabledHint: isBatch + ? "批量场景请在上方操作卡片" + : !finalVideo && !effectiveTaskId + ? "请先生成视频再选择封面" + : "视频尚未就绪,请稍候", + initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default + generateFn: async (tplId) => { + if (isBatch) return null + if (!finalVideo && !effectiveTaskId) { + console.warn("[Cover] generateAutoCover: no finalVideo and no taskId") + return null + } + // 请求体:generated_video_id 仅在后端已入库(非 preview-xxx 合成id)时传; + // video_url 兜底让后端能直接下载视频抽帧;generation_task_id 后端已从 plan.config 自动读取。 + const requestBody: { + generated_video_id?: string + video_url?: string + cover_type: "ai_frame" + title_config?: Record + } = { + cover_type: "ai_frame", + } + if (effectiveVideoId) { + requestBody.generated_video_id = effectiveVideoId + } + if (effectiveVideoUrl) { + requestBody.video_url = effectiveVideoUrl + } + if (props.titleSettings?.title) { + requestBody.title_config = { + text: props.titleSettings.title, + font: props.titleSettings.font, + font_size: props.titleSettings.size, + font_color: props.titleSettings.color, + position: props.titleSettings.position, + bold: props.titleSettings.bold, + stroke: props.titleSettings.stroke, + shadow: props.titleSettings.shadow, + } + } + console.log("[Cover] auto-generate request:", { tplId, ...requestBody }) + const response = await apiGenerateCover(tplId, requestBody) + const url = response.cover?.image_url || response.cover?.thumbnail_url || "" + if (url) { + props.onCoverSettingsChange({ + ...props.coverSettings, + thumbnail_url: url, + ai_suggested_time: response.cover?.frame_time ?? null, + }) + } else { + console.warn("[Cover] generate returned empty url:", response) + } + return url + }, + }) + + // 选中模板变化时通知父组件(用于批量生成时透传 template_id) + const { onTemplateChange, selectedTemplate: parentSelectedTemplate } = props + // 父组件 selectedTemplate 变化时同步到子(例如从 Step1/Step4 切换到 Step6 时) + useEffect(() => { + if (parentSelectedTemplate && parentSelectedTemplate !== shared.selectedTemplateId) { + shared.handleSelectTemplate(parentSelectedTemplate) + } + }, [parentSelectedTemplate]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (isBatch && onTemplateChange && shared.selectedTemplateId !== parentSelectedTemplate) { + onTemplateChange(shared.selectedTemplateId) + } + }, [isBatch, shared.selectedTemplateId, parentSelectedTemplate, onTemplateChange]) + + /** 单视频本地上传封面:选完文件后上传到素材库 OSS,拿到真实 URL 再 set */ + const [uploadingLocalCover, setUploadingLocalCover] = useState(false) + const { coverSettings: curCoverSettings, onCoverSettingsChange } = props + const uploadLocalCover = useCallback( + async (file: File): Promise => { + const hide = message.loading("正在上传封面...", 0) + setUploadingLocalCover(true) + try { + // 立即创建 blob URL 用于即时预览,同时异步上传 OSS + const previewUrl = URL.createObjectURL(file) + onCoverSettingsChange({ + ...curCoverSettings, + upload_url: previewUrl, + thumbnail_url: previewUrl, + mode: "upload", + }) + // 后端自动在默认项目下确保图片素材库存在(P0 404 修复) + const result = await uploadAssetDirect({ file, kind: "image" }) + const realUrl = result?.url || "" + if (!realUrl) { + hide() + message.warning("上传完成但未获取到URL,将使用本地预览") + return previewUrl + } + hide() + // 替换 blob URL 为真实 OSS URL(blob 用于预览过渡,finalize 时必须用真实 URL) + onCoverSettingsChange({ + ...curCoverSettings, + upload_url: realUrl, + thumbnail_url: realUrl, + mode: "upload", + }) + message.success("封面上传成功") + return realUrl + } catch (err) { + hide() + console.error("[Step6] 封面上传失败:", err) + message.error("封面上传失败,请重试") + return null + } finally { + setUploadingLocalCover(false) + } + }, + [curCoverSettings, onCoverSettingsChange], + ) + useEffect(() => { + // 单视频:注册实际上传函数;批量场景已由 batchCovers.uploadOne 接管, + // 这里不要覆盖(批量时 input ref 绑定到 batchUploadRef,不走 shared.handleFileInputChange) + if (!isBatch) { + shared.setOnUploadFile((file) => uploadLocalCover(file)) + } + }, [shared, isBatch, uploadLocalCover]) + + const batchUploadRef = React.useRef(null) + const [batchUploadCard, setBatchUploadCard] = React.useState(null) + const handleBatchUploadClick = (cardPos: number) => { + setBatchUploadCard(cardPos) + batchUploadRef.current?.click() + } + const handleBatchUploadChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = "" + const cardPos = batchUploadCard + setBatchUploadCard(null) + if (!file || cardPos == null) return + void batchCovers.uploadOne(cardPos, file) + } + const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "") const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "") + + /** + * 批量生成:selectedTemplateId 来自用户在 CoverSettingsModal 中选择的模板, + * 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。 + */ const batchCovers = useBatchCovers({ - selectedTemplate: props.selectedTemplate || "", + selectedTemplate: shared.selectedTemplateId, generatedVideos: props.generatedVideos, titles: batchTitles, titleStyle: { @@ -92,7 +250,6 @@ const Step6CoverSettings: React.FC = (props) => { }, covers: batchCoversList, onCoversChange: (updater) => { - // 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖) const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "") const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater const next = [...(props.previewCovers || [])] @@ -103,22 +260,7 @@ const Step6CoverSettings: React.FC = (props) => { }, }) - const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url - - const handleUploadClick = (variantIndex: number) => { - uploadTargetRef.current = variantIndex - uploadInputRef.current?.click() - } - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - e.target.value = "" - if (file) { - const variantIndex = uploadTargetRef.current - const cardPos = cardIndexes.indexOf(variantIndex) - if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file) - } - } + const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url /* ── 批量封面 ── */ if (isBatch) { @@ -138,17 +280,32 @@ const Step6CoverSettings: React.FC = (props) => { }} > 🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题 + {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( + <> + {" · "}当前模板:{shared.selectedTemplateName} + + )}
-
+
+ +
@@ -209,7 +366,7 @@ const Step6CoverSettings: React.FC = (props) => { type="button" className="xx-btn xx-btn-ghost xx-btn-sm" style={{ flex: 1, fontSize: 12, padding: "4px 8px" }} - onClick={() => handleUploadClick(variantIndex)} + onClick={() => handleBatchUploadClick(cardPos)} disabled={isLoading || isUploading} > 📤 上传 @@ -220,24 +377,44 @@ const Step6CoverSettings: React.FC = (props) => { })}
+ {/* 隐藏的文件选择 input,批量上传复用 */} + + shared.setShowCoverSettings(false)} + templates={shared.templates} + loading={shared.templatesLoading} + error={shared.templatesError} + selectedTemplateId={shared.selectedTemplateId} + onSelectTemplate={shared.handleSelectTemplate} + onEditTemplate={shared.handleEditTemplate} + onDeleteTemplate={shared.handleDeleteTemplate} + onCreateNew={shared.handleCreateTemplate} + /> + + shared.setShowCoverEditor(false)} + template={shared.editingTemplate} + onSave={shared.handleSaveTemplate} />
) } - /* ── 单视频:原有流程保持不变 ── */ + /* ── 单视频 ── */ return (

🖼️ 选择封面

- {/* 最终成片信息 */} - {finalVideo && ( + {(finalVideo || effectiveTaskId) && (
= (props) => { color: "var(--text-secondary, #666)", }} > - 🎬 封面将从最终成片「{finalVideo.name}」中智能选帧 + 🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧 + {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( + <> + {" "} + · 当前模板:{shared.selectedTemplateName} + + )}
)}
- - + + +
封面预览
@@ -276,30 +487,26 @@ const Step6CoverSettings: React.FC = (props) => {
setShowCoverSettings(false)} - templates={coverTemplates} - loading={templatesLoading} - error={templatesError} - selectedTemplateId={selectedTemplateId} - onSelectTemplate={handleSelectTemplate} - onEditTemplate={handleEditTemplate} - onDeleteTemplate={handleDeleteTemplate} - onCreateNew={() => { - setShowCoverSettings(false) - setShowCoverEditor(true) - }} + open={shared.showCoverSettings} + onClose={() => shared.setShowCoverSettings(false)} + templates={shared.templates} + loading={shared.templatesLoading} + error={shared.templatesError} + selectedTemplateId={shared.selectedTemplateId} + onSelectTemplate={shared.handleSelectTemplate} + onEditTemplate={shared.handleEditTemplate} + onDeleteTemplate={shared.handleDeleteTemplate} + onCreateNew={shared.handleCreateTemplate} /> setShowCoverEditor(false)} - template={editingTemplate} - onSave={handleSaveTemplate} + open={shared.showCoverEditor} + onClose={() => shared.setShowCoverEditor(false)} + template={shared.editingTemplate} + onSave={shared.handleSaveTemplate} /> - {/* AI 生成封面进度弹窗 */} - +

@@ -311,4 +518,6 @@ const Step6CoverSettings: React.FC = (props) => { ) } +Step6CoverSettings.displayName = "Step6CoverSettings" + export default Step6CoverSettings diff --git a/apps/web/src/pages/generate/components/TtsVoiceModal.tsx b/apps/web/src/pages/generate/components/TtsVoiceModal.tsx index e99ad89b7..9a5d2c643 100644 --- a/apps/web/src/pages/generate/components/TtsVoiceModal.tsx +++ b/apps/web/src/pages/generate/components/TtsVoiceModal.tsx @@ -19,6 +19,8 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" import type { PresetVoiceItem } from "@/api/voices" import type { VoiceClone } from "@/api/voice-clone" import { VOICE_GENDER_ICON } from "../constants" +import TtsStyleSelector from "@/components/voice/TtsStyleSelector" +import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles" interface TtsVoiceModalProps { open: boolean @@ -31,7 +33,11 @@ interface TtsVoiceModalProps { voiceAssetId: string ttsVoiceId: string ttsVoiceSource: "preset" | "clone" + ttsStyle: TtsStyle }) => void + /** 当前风格 */ + style?: TtsStyle + onStyleChange?: (s: TtsStyle) => void } type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error" @@ -42,7 +48,15 @@ const TtsVoiceModal: React.FC = ({ scriptTitle, onCancel, onSynthesized, + style: externalStyle, + onStyleChange, }) => { + const [internalStyle, setInternalStyle] = useState(DEFAULT_TTS_STYLE) + const currentStyle: TtsStyle = externalStyle ?? internalStyle + const handleStyleChange = (s: TtsStyle) => { + setInternalStyle(s) + onStyleChange?.(s) + } const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset") const [selectedVoiceId, setSelectedVoiceId] = useState("") const [status, setStatus] = useState("idle") @@ -77,6 +91,7 @@ const TtsVoiceModal: React.FC = ({ setStatus("idle") setError(null) setActiveTab("preset") + setInternalStyle(externalStyle ?? DEFAULT_TTS_STYLE) } else { if (timerRef.current) { clearInterval(timerRef.current) @@ -91,6 +106,7 @@ const TtsVoiceModal: React.FC = ({ return () => { if (timerRef.current) clearInterval(timerRef.current) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) const handlePreview = useCallback( @@ -143,6 +159,7 @@ const TtsVoiceModal: React.FC = ({ text: textToSynth, speed: 1.0, language: "zh-CN", + style: currentStyle, } if (isClone) { payload.voice_clone_profile_id = selectedVoiceId @@ -187,13 +204,14 @@ const TtsVoiceModal: React.FC = ({ voiceAssetId: jobId, ttsVoiceId: selectedVoiceId, ttsVoiceSource: isClone ? "clone" : "preset", + ttsStyle: currentStyle, }) } catch (err: unknown) { setStatus("error") const msg = err instanceof Error ? err.message : "合成失败,请稍后重试" setError(msg) } - }, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized]) + }, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized, currentStyle]) const renderVoiceCard = (v: { id: string @@ -393,6 +411,10 @@ const TtsVoiceModal: React.FC = ({ {textToSynth.length} 字

+
+ +
+ { diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx index e4bac6fec..21e320b6e 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx @@ -1,8 +1,59 @@ -import React, { useState } from "react" -import type { CoverTemplate } from "../../types/cover" +import React, { useState, useCallback, useRef, useEffect } from "react" +import { Slider, Switch, Select, InputNumber, Input } from "antd" +import type { + CoverTemplate, + CoverEditorConfig, + TextStyleConfig, + TextDirection, + StrokeStyle, +} from "../../types/cover" +import { DEFAULT_EDITOR_CONFIG, ALL_FONTS } from "../../types/cover" import Modal from "@/components/ui/Modal" import Button from "@/components/ui/Button" +import "@/components/cover/cover.css" +/** 安全合并模板 config 与默认配置(深拷贝 title/subtitle/shadows/background 等嵌套对象,避免共享引用 & 字段缺失) */ +const mergeEditorConfig = (partial?: Partial | null): CoverEditorConfig => { + const def = DEFAULT_EDITOR_CONFIG + const src = partial || {} + /** 兼容老模板:老版本 background 有 posX/posY/rotation,新版改为 offsetY(相对文字位置偏移)。 + * 老模板黑底默认 posY 通常是 50(与文字对齐)或 80(副标题偏下),统一归一为 offsetY=0, + * 因为新版背景位置已自动跟随文字位置,offsetY 仅做相对微调。 */ + const normalizeBg = (bg: Record | undefined) => { + if (!bg) return {} + // 兼容老模板字段:posX/posY/rotation 在新版中已改为 offsetY(背景位置自动跟随文字) + const normalized = { ...bg } + delete (normalized as Record).posX + delete (normalized as Record).posY + delete (normalized as Record).rotation + if (normalized.offsetY == null) normalized.offsetY = 0 + return normalized + } + const mergeText = ( + base: TextStyleConfig, + patch?: Partial | null, + ): TextStyleConfig => ({ + ...base, + ...(patch || {}), + position: { ...base.position, ...(patch?.position || {}) }, + background: { + ...base.background, + ...normalizeBg(patch?.background as Record | undefined), + }, + shadows: Array.isArray(patch?.shadows) ? [...patch!.shadows] : [...base.shadows], + }) + return { + ...def, + ...src, + title: mergeText(def.title, src.title), + subtitle: mergeText(def.subtitle, src.subtitle), + portraitPosition: { ...def.portraitPosition, ...(src.portraitPosition || {}) }, + backgroundPosition: { ...def.backgroundPosition, ...(src.backgroundPosition || {}) }, + maskPosition: { ...def.maskPosition, ...(src.maskPosition || {}) }, + } +} + +/* ── Props ── */ interface CoverEditorModalProps { open: boolean onClose: () => void @@ -10,53 +61,611 @@ interface CoverEditorModalProps { onSave: (template: CoverTemplate) => void } -interface SectionState { - basic: boolean - portrait: boolean - background: boolean - title: boolean - subtitle: boolean - mask: boolean +/* ── Section expand/collapse keys ── */ +type SectionKey = "basic" | "portrait" | "background" | "title" | "subtitle" | "mask" + +/* ── Color picker sub-component ── */ +const ColorPicker: React.FC<{ value: string; onChange: (v: string) => void }> = ({ + value, + onChange, +}) => ( +
+ onChange(e.target.value)} /> + onChange(e.target.value)} + maxLength={7} + className="xx-ce-color-hex" + /> +
+) + +/* ── Position pair sub-component ── */ +const PositionPair: React.FC<{ + x: number + y: number + onChange: (pos: { x: number; y: number }) => void +}> = ({ x, y, onChange }) => ( +
+ onChange({ x: v ?? 0, y })} + style={{ width: "100%" }} + /> + onChange({ x, y: v ?? 0 })} + style={{ width: "100%" }} + /> +
+) + +/* ── Font Select options(合并预设+系统,用圆点颜色区分 tag) ── */ +const fontDotClass = (tag?: string) => { + if (tag === "preset") return "xx-ce-font-dot xx-ce-font-dot--preset" + if (tag === "hand") return "xx-ce-font-dot xx-ce-font-dot--hand" + if (tag === "serif") return "xx-ce-font-dot xx-ce-font-dot--serif" + if (tag === "mono") return "xx-ce-font-dot xx-ce-font-dot--mono" + return "xx-ce-font-dot xx-ce-font-dot--system" +} +const fontOptions = ALL_FONTS.map((f) => ({ + label: ( + + + {f.name} + + ), + value: f.name, +})) + +/* ── Find font family string from name ── */ +const getFontFamily = (name: string): string => { + const found = ALL_FONTS.find((f) => f.name === name) + return found ? found.family : "sans-serif" } +/* ── Text style panel (shared between title & subtitle) ── */ +const TextStylePanel: React.FC<{ + config: TextStyleConfig | undefined | null + onChange: (c: TextStyleConfig) => void + placeholder: string +}> = ({ config, onChange, placeholder }) => { + if (!config) return null + const upd = (key: K, val: TextStyleConfig[K]) => + onChange({ ...config, [key]: val }) + return ( +
+ {/* 文字内容 - 改为可输入 */} +
+ + upd("text", e.target.value)} + placeholder={placeholder} + autoSize={{ minRows: 1, maxRows: 3 }} + size="small" + maxLength={50} + showCount + /> +
+ + {/* 字体 */} +
+ + setName(e.target.value)} - placeholder="输入模板名称" + placeholder="输入模板名称(如:我的爆款封面)" /> -
+
@@ -66,56 +675,488 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp
-
- {/* 左侧折叠面板 */} -
- {[ - { key: "basic" as const, label: "基础设置" }, - { key: "portrait" as const, label: "人像设置" }, - { key: "background" as const, label: "背景设置", toggle: true }, - { key: "title" as const, label: "主标题" }, - { key: "subtitle" as const, label: "副标题" }, - { key: "mask" as const, label: "蒙版", toggle: true }, - ].map((item) => ( -
-
toggleSection(item.key)} - > - {item.label} - {sections[item.key] ? "▾" : "▸"} -
- {sections[item.key] && ( -
- {item.toggle ? ( - - ) : ( - 暂无配置项 + {/* ── Layout ── */} +
+ {/* ── Left Panel ── */} +
+ {/* 1. 基础设置 */} +
+
toggle("basic")}> + 🔧 基础设置 + {sections.basic ? "▾" : "▸"} +
+ {sections.basic && ( +
+
+
+ 文字自动换行 + upd("autoSplitEnabled", v)} + /> +
+ {cfg.autoSplitEnabled && ( + <> +
+ + upd("titleMaxChars", v)} + /> +
+
+ + upd("subtitleMaxChars", v)} + /> +
+ )}
- )} + +
+
+ 背景模糊 + upd("blurEnabled", v)} + /> +
+ {cfg.blurEnabled && ( +
+ + upd("blurAmount", v)} + /> +
+ )} +
+
+ )} +
+ + {/* 2. 背景 */} +
+
toggle("background")}> + 🎨 背景设置 +
e.stopPropagation()}> + upd("backgroundEnabled", v)} + /> +
- ))} + {sections.background && cfg.backgroundEnabled && ( +
+
+ +
+ + + {bgImageUrl && ( + + )} + + handleFile(e.target.files?.[0], setBgImageUrl, "backgroundImage") + } + /> +
+
+
+ + upd("backgroundColor", v)} + /> +
+
+ + upd("backgroundSize", v)} + /> +
+
+ + upd("backgroundPosition", pos)} + /> +
+
+ )} +
+ + {/* 3. 主标题 */} +
+
toggle("title")}> + 🔠 主标题 + {sections.title ? "▾" : "▸"} +
+ {sections.title && ( +
+ +
+ )} +
+ + {/* 4. 副标题 */} +
+
toggle("subtitle")}> + 🔡 副标题 + {sections.subtitle ? "▾" : "▸"} +
+ {sections.subtitle && ( +
+ +
+ )} +
+ + {/* 5. 人像 */} +
+
toggle("portrait")}> + 👤 人像设置 +
e.stopPropagation()}> + upd("portraitEnabled", v)} + /> +
+
+ {sections.portrait && cfg.portraitEnabled && ( +
+
+ +
+ + + {portraitImageUrl && ( + + )} + + handleFile(e.target.files?.[0], setPortraitImageUrl, "portraitImage") + } + /> +
+
+
+ + upd("portraitSize", v)} + /> +
+
+ + upd("portraitPosition", pos)} + /> +
+
+
+ 人物描边 + upd("personStrokeEnabled", v)} + /> +
+ {cfg.personStrokeEnabled && ( + <> +
+
+ {(["solid", "dashed"] as StrokeStyle[]).map((st) => ( + + ))} +
+
+
+ + upd("personStrokeColor", v)} + /> +
+
+ + upd("personStrokeWidth", v)} + /> +
+ + )} +
+
+ )} +
+ + {/* 6. 蒙版 */} +
+
toggle("mask")}> + 🌓 蒙版 +
e.stopPropagation()}> + upd("maskEnabled", v)} + /> +
+
+ {sections.mask && cfg.maskEnabled && ( +
+
+ +
+ + + { + const f = e.target.files?.[0] + if (f) upd("maskImage", f.name) + }} + /> +
+
+
+ + upd("maskColor", v)} /> +
+
+ + upd("maskOpacity", v)} + /> +
+
+ + upd("maskSize", v)} + /> +
+
+ )} +
- {/* 右侧画布预览 */} -
-
- {/* 人像占位 */} -
- {/* 四角拖拽手柄 */} - - - - + {/* ── Right Preview ── */} +
+
+ + + + +
+ {/* base background layer */} +
+ + {/* Background decoration */} + {cfg.backgroundEnabled && !bgImageUrl && ( +
+ )} + + {/* Portrait */} + {cfg.portraitEnabled && ( +
+ )} + + {/* Title bg */} + {cfg.title?.background?.enabled &&
} + {/* Subtitle bg */} + {cfg.subtitle?.background?.enabled &&
} + + {/* Title (draggable) */} + {cfg.title && ( +
handleTextMouseDown(e, "title")} + > + {renderTextLines(cfg.title)} +
+ )} + + {/* Subtitle (draggable) */} + {cfg.subtitle && ( +
handleTextMouseDown(e, "subtitle")} + > + {renderTextLines(cfg.subtitle)} +
+ )} + + {/* Mask overlay */} + {cfg.maskEnabled && ( +
+ )}
- {/* 文字占位 */} -
主标题文字
-
副标题文字
+ +
👁️ 实时预览(9:16 竖版)
diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx index 4359a8f3d..786a5ed5b 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx @@ -1,7 +1,8 @@ -import React from "react" +import React, { useMemo, useState } from "react" import type { CoverTemplate } from "../../types/cover" import Modal from "@/components/ui/Modal" import Button from "@/components/ui/Button" +import "@/components/cover/cover.css" interface CoverSettingsModalProps { open: boolean @@ -16,15 +17,58 @@ interface CoverSettingsModalProps { onCreateNew: () => void } -const GRADIENT_MAP: Record = { - default: "linear-gradient(135deg, #e0e0e0, #c0c0c0)", - "bold-red": "linear-gradient(135deg, #ef4444, #b91c1c)", - "elegant-black": "linear-gradient(135deg, #374151, #111827)", - "gradient-blue": "linear-gradient(135deg, #3b82f6, #1d4ed8)", - "gradient-purple": "linear-gradient(135deg, #8b5cf6, #6d28d9)", - "warm-orange": "linear-gradient(135deg, #f97316, #ea580c)", - "fresh-green": "linear-gradient(135deg, #22c55e, #15803d)", - "tech-blue": "linear-gradient(135deg, #06b6d4, #0e7490)", +/** 模板缩略图:优先渲染 thumbnail_url;加载失败/无图时展示占位 */ +const TemplateThumb: React.FC<{ tpl: CoverTemplate; isSelected: boolean }> = ({ + tpl, + isSelected, +}) => { + const [errored, setErrored] = useState(false) + const url = tpl.thumbnail_url && !errored ? tpl.thumbnail_url : "" + // 随机柔和渐变做占位,保证卡片不会灰成一片 + const placeholderBg = useMemo(() => { + const palettes = [ + ["#e0e0e0", "#c0c0c0"], + ["#ef4444", "#b91c1c"], + ["#374151", "#111827"], + ["#3b82f6", "#1d4ed8"], + ["#8b5cf6", "#6d28d9"], + ["#f97316", "#ea580c"], + ["#22c55e", "#15803d"], + ["#06b6d4", "#0e7490"], + ] + let h = 0 + for (const ch of tpl.id || tpl.name || "") h = (h * 31 + ch.charCodeAt(0)) >>> 0 + const [a, b] = palettes[h % palettes.length] + return `linear-gradient(135deg, ${a}, ${b})` + }, [tpl.id, tpl.name]) + + return ( +
+ {isSelected && ✓} + {url ? ( + {tpl.name} setErrored(true)} + style={{ + width: "100%", + height: "100%", + objectFit: "cover", + display: "block", + }} + /> + ) : ( + 🖼️ + )} +
+ ) } const CoverSettingsModal: React.FC = ({ @@ -40,12 +84,26 @@ const CoverSettingsModal: React.FC = ({ onCreateNew, }) => { return ( - + + + +
+ } + >
- -
@@ -59,52 +117,77 @@ const CoverSettingsModal: React.FC = ({
{error}
)} - {!loading && !error && ( + {!loading && !error && templates.length === 0 && ( +
+ 暂无封面模板,点击右上角「创建新模板」可自定义封面样式 +
+ )} + + {!loading && !error && templates.length > 0 && (
- {templates.map((tpl) => ( -
onSelectTemplate(tpl.id)} - > + {templates.map((tpl) => { + const isSelected = selectedTemplateId === tpl.id + return (
onSelectTemplate(tpl.id)} > - 🖼️ -
-
-
- {tpl.name} - {tpl.is_system && ✨ 系统模板} -
-
{tpl.created_at}
-
e.stopPropagation()}> - - {!tpl.is_system && ( + +
+
+ {tpl.name} + {tpl.is_system && ✨ 系统} +
+
e.stopPropagation()}> - )} - + {!tpl.is_system && ( + + )} +
-
- ))} + ) + })}
)} + +
+ 💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面 +
) } diff --git a/apps/web/src/pages/generate/components/title/AiTitleCard.tsx b/apps/web/src/pages/generate/components/title/AiTitleCard.tsx deleted file mode 100644 index 81059fe49..000000000 --- a/apps/web/src/pages/generate/components/title/AiTitleCard.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 单个 AI 标题卡片 - */ -import React from "react" -import { CheckCircleFilled } from "@ant-design/icons" - -interface AiTitleCardProps { - title: string - highlight: string - style: "catchy" | "emotional" | "informative" - selected: boolean - onClick: () => void -} - -const AiTitleCard: React.FC = ({ - title, - highlight, - style, - selected, - onClick, -}) => { - return ( -
-
{title}
-
{highlight}
- {selected && ( -
- -
- )} -
- ) -} - -export default AiTitleCard diff --git a/apps/web/src/pages/generate/components/title/AiTitleGenerator.tsx b/apps/web/src/pages/generate/components/title/AiTitleGenerator.tsx deleted file mode 100644 index e980f0e7e..000000000 --- a/apps/web/src/pages/generate/components/title/AiTitleGenerator.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * AI 智能生成标题 - * 输入框 + 生成按钮 + 结果列表 + 加载状态 - */ -import React from "react" -import { LoadingOutlined } from "@ant-design/icons" -import AiTitleCard from "./AiTitleCard" - -interface AiTitleItem { - title: string - highlight: string - style: "catchy" | "emotional" | "informative" -} - -interface AiTitleGeneratorProps { - inputValue: string - onInputChange: (value: string) => void - generating: boolean - onGenerate: () => void - results: AiTitleItem[] - hasGenerated: boolean - onSelect: (title: string) => void - selectedTitle: string - onRefresh: () => void -} - -const AiTitleGenerator: React.FC = ({ - inputValue, - onInputChange, - generating, - onGenerate, - results, - hasGenerated, - onSelect, - selectedTitle, - onRefresh, -}) => { - return ( -
-
- ✨ AI 智能生成标题 -
-
- onInputChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") onGenerate() - }} - /> - -
- - {/* 生成结果 */} - {hasGenerated && !generating && results.length > 0 && ( -
-
- 为你生成 {results.length} 个标题 - -
-
- {results.map((item, idx) => ( - onSelect(item.title)} - /> - ))} -
-
- )} - - {/* 生成中 */} - {generating && ( -
- - AI 正在为你创作标题… -
- )} -
- ) -} - -export default AiTitleGenerator diff --git a/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx b/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx new file mode 100644 index 000000000..fc9f6f6ca --- /dev/null +++ b/apps/web/src/pages/generate/components/title/TitleMiniPreview.tsx @@ -0,0 +1 @@ +export { default } from "@/components/title/TitleMiniPreview" diff --git a/apps/web/src/pages/generate/components/title/TitlePresetsGrid.tsx b/apps/web/src/pages/generate/components/title/TitlePresetsGrid.tsx index 6293a8d94..07c1efa57 100644 --- a/apps/web/src/pages/generate/components/title/TitlePresetsGrid.tsx +++ b/apps/web/src/pages/generate/components/title/TitlePresetsGrid.tsx @@ -4,7 +4,7 @@ * 避免 -webkit-text-stroke 在 Chromium 中吞掉填充色的问题 */ import React from "react" -import { getFontFamily } from "../../constants" +import { getFontFamily } from "@/components/title/constants" interface TitlePresetItem { key: string diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.css b/apps/web/src/pages/generate/components/title/TitleStylePanel.css index 30c642941..58d88efcc 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.css +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.css @@ -190,3 +190,269 @@ border-color: var(--primary-color); color: #fff; } + +/* ============================================================ + #2001 爆款标题样式面板升级 — 新增样式(ts- 前缀) + ============================================================ */ + +.ts-panel { + position: relative; +} + +/* 预览 */ +.ts-preview-wrap { + margin-bottom: 14px; + display: flex; + justify-content: center; + padding: 10px; + background: #0f172a; + border-radius: 8px; +} + +/* 表单字段 */ +.ts-form-field { + margin-bottom: 12px; +} +.ts-form-field label { + display: block; + font-weight: 600; + margin-bottom: 6px; + font-size: 12px; + color: var(--text-primary, #1f2937); +} +.ts-field-label-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} +.ts-field-value { + font-size: 12px; + font-weight: 600; + color: var(--primary-color, #7c3aed); +} +.ts-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.ts-half { + margin-bottom: 0; +} + +.ts-select { + width: 100%; + height: 34px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + background: var(--bg-primary, #fff); + padding: 0 10px; + font-size: 13px; + outline: 0; + color: var(--text-primary, #1f2937); +} +.ts-select:focus { + border-color: var(--primary-color, #7c3aed); + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} +.ts-input { + width: 100%; + height: 34px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + outline: 0; +} + +.ts-slider { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: #e5e7eb; + border-radius: 2px; + outline: none; +} +.ts-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #7c3aed; + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} +.ts-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #7c3aed; + cursor: pointer; + border: 2px solid #fff; +} + +/* 样式按钮 B/I/S/☁ */ +.ts-style-btns { + display: flex; + gap: 6px; +} +.ts-style-btn { + width: 34px; + height: 34px; + border-radius: 6px; + border: 1px solid #e5e7eb; + background: #fff; + cursor: pointer; + font-size: 14px; + transition: 0.15s; + color: #374151; + display: inline-flex; + align-items: center; + justify-content: center; +} +.ts-style-btn:hover { + border-color: #7c3aed; + color: #7c3aed; +} +.ts-style-btn.active { + background: #faf5ff; + color: #6d28d9; + border-color: #7c3aed; + font-weight: 700; +} + +/* 色板 */ +.ts-color-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.ts-color-swatch { + width: 24px; + height: 24px; + border-radius: 4px; + border: 2px solid #fff; + box-shadow: 0 0 0 1px #e5e7eb; + cursor: pointer; + padding: 0; + transition: 0.15s; +} +.ts-color-swatch:hover { + transform: scale(1.1); +} +.ts-color-swatch.active { + box-shadow: 0 0 0 2px #7c3aed; + transform: scale(1.1); +} +.ts-color-custom { + background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px; + color: #666; + font-size: 14px; + line-height: 20px; +} +.ts-color-native { + width: 0; + height: 0; + border: 0; + padding: 0; +} + +/* 预设网格 10个 - 5列 */ +.ts-presets-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; +} +.ts-preset-card { + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fff; + padding: 4px; + cursor: pointer; + transition: 0.15s; + display: flex; + flex-direction: column; + gap: 4px; +} +.ts-preset-card:hover { + border-color: #7c3aed; +} +.ts-preset-card.active { + border-color: #7c3aed; + background: #faf5ff; + box-shadow: 0 0 0 1px #7c3aed; +} +.ts-preset-preview { + height: 34px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 4px; + background: #0f172a; +} +.ts-preset-preview canvas { + max-width: 100%; + max-height: 100%; +} +.ts-preset-meta { + display: flex; + align-items: center; + gap: 2px; + font-size: 10px; + color: #4b5563; + justify-content: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 2px 2px; +} +.ts-preset-emoji { + font-size: 11px; +} +.ts-preset-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.ts-toggle-row label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + margin-bottom: 10px; +} +.ts-toggle-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #7c3aed; +} + +/* Tabs 紧凑样式 */ +.xx-title-style-section .ant-tabs-nav { + margin-bottom: 10px; +} +.xx-title-style-section .ant-tabs-tab { + font-size: 12px !important; + padding: 6px 8px !important; +} + +/* 标题模板入口按钮(#2003) */ +.ts-template-btn { + border: none; + background: transparent; + color: var(--primary-color, #7c3aed); + font-size: 12px; + cursor: pointer; + padding: 2px 0; + font-weight: 500; +} +.ts-template-btn:hover { + text-decoration: underline; +} diff --git a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx index 82acbe6f6..de709199a 100644 --- a/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx +++ b/apps/web/src/pages/generate/components/title/TitleStylePanel.tsx @@ -1,23 +1,61 @@ /** - * 标题样式设置区 - * 位置/字体/字号/样式按钮/预设 + * 标题样式设置面板(v3 重构) + * + * 三种模式(mode): + * - 「默认」(不传):顶部实时预览 + 爆款预设网格 + 参数 Tab(基础/描边/阴影/背景/排版/封面) + * - 「模板网格」(enableTemplates=true, paramsOnly=false): + * 仅显示顶部标题栏「标题模板」+ 我的模板/系统模板两个分组的大卡片网格, + * 卡片用图片背景 + 真实标题渲染(Canvas 叠加透明渲染),底部始终可见 + * 编辑/复制/导出/删除 4 个按钮,选中卡片紫边紫影。 + * - 「仅参数」(paramsOnly=true):仅渲染参数 Tab(给编辑器右侧用,不显示预览/预设/模板网格) + * + * 历史:#2001 6 Tab 参数面板;#2003 初版内联模板网格;v3 按 sketch 重写大卡片 + 图片背景 */ -import React from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Tabs, Button, message, Popconfirm } from "antd" +import { + PlusOutlined, + EditOutlined, + CopyOutlined, + DeleteOutlined, + ExportOutlined, + CheckOutlined, +} from "@ant-design/icons" import type { TitleSettings } from "../../types" -import TitlePresetsGrid from "./TitlePresetsGrid" -// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件, -// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css +import { + FONT_OPTIONS as NEW_FONT_OPTIONS, + TITLE_PRESETS, + TITLE_COLOR_PALETTE, + STROKE_COLOR_PALETTE, + BG_COLOR_PALETTE, +} from "@/components/title/constants" +import { buildPresetPreviewSettings } from "@/components/title/utils" + +import TitleMiniPreview from "@/components/title/TitleMiniPreview" +import TitleTemplateEditor from "@/components/title/TitleTemplateEditor" +import { useTitleTemplates } from "@/components/title/useTitleTemplates" +import { + titleStyleConfigToCamel, + templateToPreviewSettings, + camelToTitleStyleConfig, +} from "@/components/title/utils" +import { DEFAULT_TITLE_SETTINGS_FULL } from "../../types" +import type { TitleTemplate } from "@/components/title/template-types" +import type { TitleStyleConfig } from "@/components/title/types" import "./TitleStylePanel.css" +import "@/components/title/TitleTemplate.css" interface PositionOption { value: string label: string } -interface TitlePresetItem { +interface LegacyPreset { key: string label: string - previewStyle: React.CSSProperties + emoji?: string + style?: Record + previewStyle?: React.CSSProperties } interface TitleStylePanelProps { @@ -31,11 +69,151 @@ interface TitleStylePanelProps { onToggleShadow: () => void onApplyPreset: (presetKey: string) => void activePreset: string | null - titlePresets: TitlePresetItem[] + titlePresets: LegacyPreset[] POSITION_OPTIONS: PositionOption[] - FONT_OPTIONS: string[] + FONT_OPTIONS?: Array<{ value: string; label: string; family?: string; tag?: string }> + onUpdateStyle?: (patch: Partial) => void + showCoverToggle?: boolean + previewWidth?: number + /** 启用「标题模板」模式:显示大卡片网格,隐藏参数 Tab 和旧预览 */ + enableTemplates?: boolean + /** 当前选中模板 id */ + selectedTemplateId?: string | null + /** 应用模板回调(父组件负责合并到 state) */ + onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void + /** 仅渲染参数 Tab(编辑器右侧用,不显示预览/预设/模板网格) */ + paramsOnly?: boolean + /** 编辑器模式下,模板编辑器组件不在这里渲染(由父组件承载) */ + hideEditor?: boolean } +/* ── Slider 行 ── */ +const SliderRow: React.FC<{ + label: string + value: number + min: number + max: number + step?: number + unit?: string + onChange: (v: number) => void +}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => ( +
+
+ + + {value} + {unit} + +
+ onChange(Number(e.target.value))} + /> +
+) + +/* ── 色板 ── */ +const ColorPicker: React.FC<{ + label?: string + value: string + palette: string[] + onChange: (c: string) => void +}> = ({ label, value, palette, onChange }) => { + const [customOpen, setCustomOpen] = useState(false) + return ( +
+ {label && } +
+ {palette.map((c) => ( + + { + onChange(e.target.value) + setCustomOpen(false) + }} + style={{ + opacity: customOpen ? 1 : 0, + position: customOpen ? "static" : "absolute", + pointerEvents: customOpen ? "auto" : "none", + width: customOpen ? 28 : 0, + height: customOpen ? 28 : 0, + border: "none", + padding: 0, + cursor: "pointer", + background: "transparent", + }} + /> +
+
+ 当前:{value} +
+
+ ) +} + +/* ── 卡片预览:用 ref 测量容器宽度后再渲染透明 Canvas,保证文字清晰 ── */ +const FillPreview: React.FC<{ + settings: import("@/components/title/settings").TitleStyleSettings + sampleText: string + portrait?: boolean +}> = ({ settings, sampleText, portrait }) => { + const wrapRef = useRef(null) + const [w, setW] = useState(0) + useEffect(() => { + const el = wrapRef.current + if (!el) return + const update = () => setW(Math.floor(el.clientWidth)) + update() + const ro = new ResizeObserver(update) + ro.observe(el) + return () => ro.disconnect() + }, []) + return ( +
+ {w > 0 && ( + + )} +
+ ) +} + +/* ── 模板卡片预览背景图池:按 index 轮换 ── */ +const PREVIEW_BG_IMAGES = [ + "/title-templates/portrait1.jpg", + "/title-templates/portrait2.jpg", + "/title-templates/scene1.jpg", +] + const TitleStylePanel: React.FC = ({ settings, onUpdatePosition, @@ -47,107 +225,658 @@ const TitleStylePanel: React.FC = ({ onToggleShadow, onApplyPreset, activePreset, - titlePresets, + titlePresets: _titlePresets, POSITION_OPTIONS, - FONT_OPTIONS, + showCoverToggle = false, + previewWidth = 220, + enableTemplates = false, + selectedTemplateId, + onApplyTemplate, + onUpdateStyle, + paramsOnly = false, + hideEditor = false, }) => { + const upd = (patch: Partial) => { + onUpdateStyle?.(patch) + } + + const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle) + + /* ── 模板相关 hook + 编辑器 state ── */ + const { + templates, + createTemplate, + duplicateTemplate, + updateTemplate, + deleteTemplate, + exportTemplate, + } = useTitleTemplates() + const [editingTemplate, setEditingTemplate] = useState(null) + const [editorOpen, setEditorOpen] = useState(false) + + const grouped = useMemo( + () => ({ + builtin: templates.filter((t) => t.isBuiltin), + custom: templates.filter((t) => !t.isBuiltin), + }), + [templates], + ) + + const handleRequestCreate = useCallback(() => { + // 新建模板:若已有选中模板,以其样式为起点;否则以当前 settings 为起点 + // 注意:这里只构造「草稿」对象(不写入 localStorage),保存时再由 handleEditorSave 真正 createTemplate + let base: TitleSettings = settings + if (selectedTemplateId) { + const sel = templates.find((t) => t.id === selectedTemplateId) + if (sel) { + base = { + ...DEFAULT_TITLE_SETTINGS_FULL, + ...titleStyleConfigToCamel(sel.style), + title: settings.title, + aiAutoSelect: settings.aiAutoSelect, + coverTitle: settings.coverTitle, + } + } + } + const draft: TitleTemplate = { + id: "", // 空 id 表示新建草稿 + name: "我的标题模板", + emoji: "✨", + isBuiltin: false, + style: camelToTitleStyleConfig({ + ...base, + position: base.position === "custom" ? "bottom" : base.position, + } as TitleSettings), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + setEditingTemplate(draft) + setEditorOpen(true) + }, [settings, selectedTemplateId, templates]) + + const handleRequestEdit = useCallback((tpl: TitleTemplate) => { + setEditingTemplate(tpl) + setEditorOpen(true) + }, []) + + const handleDuplicate = useCallback( + (t: TitleTemplate) => { + const dup = duplicateTemplate(t.id) + if (dup) message.success(`已复制:${dup.name}`) + }, + [duplicateTemplate], + ) + const handleDelete = useCallback( + (t: TitleTemplate) => { + deleteTemplate(t.id) + message.success("已删除模板") + }, + [deleteTemplate], + ) + const handleExport = useCallback( + (t: TitleTemplate) => { + const json = exportTemplate(t.id) + if (!json) return + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${t.name}.title-template.json` + a.click() + URL.revokeObjectURL(url) + }, + [exportTemplate], + ) + + const handleSelectTemplate = useCallback( + (tpl: TitleTemplate) => { + const full: TitleSettings = { + ...DEFAULT_TITLE_SETTINGS_FULL, + ...titleStyleConfigToCamel(tpl.style), + title: settings.title, + aiAutoSelect: settings.aiAutoSelect, + coverTitle: settings.coverTitle, + } + onApplyTemplate?.(full, tpl) + }, + [onApplyTemplate, settings], + ) + + const handleEditorSave = useCallback( + (data: { name: string; emoji: string; style: Partial }) => { + if (!editingTemplate) return + let saved: TitleTemplate + if (editingTemplate.isBuiltin || !editingTemplate.id) { + // 系统模板编辑 → 创建副本;新草稿(空 id)→ 新建 + saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style }) + } else { + // 已有自定义模板 → 更新 + updateTemplate(editingTemplate.id, { + name: data.name, + emoji: data.emoji, + style: data.style, + }) + saved = { + ...editingTemplate, + name: data.name, + emoji: data.emoji, + style: data.style, + updatedAt: new Date().toISOString(), + } + } + setEditorOpen(false) + setEditingTemplate(null) + message.success(`已保存:${data.name}`) + handleSelectTemplate(saved) + }, + [editingTemplate, createTemplate, updateTemplate, handleSelectTemplate], + ) + + /* ── 渲染模板大卡片(v3:图片背景 + 透明 Canvas 叠字 + 底栏始终可见操作按钮) ── */ + const renderTemplateCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => { + const isSelected = selectedTemplateId === t.id + const bgIdx = idx % PREVIEW_BG_IMAGES.length + const bgImg = PREVIEW_BG_IMAGES[bgIdx] + // 卡片用的预览 settings:字号按 3:4 竖版比例缩放 + const preview = templateToPreviewSettings(t, 42) + return ( +
handleSelectTemplate(t)} + > +
+ +
+ + + {section === "sys" ? "系统" : "我的"} + + + {isSelected && } + +
+
+
+ {t.emoji || "✨"} + + {t.name} + + + {section === "sys" ? "系统" : "我的"} + +
+
e.stopPropagation()}> + + + + handleDelete(t)}> + + +
+
+
+ ) + } + + /* ── 旧爆款预设网格(paramsOnly 或 enableTemplates 时不显示) ── */ + const renderPresetGrid = () => ( +
+ {TITLE_PRESETS.map((p) => { + const isActive = activePreset === p.key + const previewStyle = buildPresetPreviewSettings(settings, p.key, 56) + return ( + + ) + })} +
+ ) + + /* ── 参数 Tab 面板(编辑器右侧复用) ── */ + const renderParamsTabs = () => ( + +
+
+ + +
+
+ + +
+
+ + + +
+ +
+ + + + +
+
+ + upd({ color: c })} + /> + + ), + }, + { + key: "stroke", + label: "描边", + children: ( + <> +
+ +
+ {settings.stroke && ( + <> + upd({ strokeWidth: v })} + /> + upd({ strokeColor: c })} + /> + + )} + + ), + }, + { + key: "shadow", + label: "阴影", + children: ( + <> +
+ +
+ {settings.shadow && ( + <> + upd({ shadowOffsetX: v })} + /> + upd({ shadowOffsetY: v })} + /> + upd({ shadowBlur: v })} + /> +
+ + upd({ shadowColor: e.target.value })} + placeholder="rgba(0,0,0,0.8)" + /> +
+ + )} + + ), + }, + { + key: "bg", + label: "背景", + children: ( + <> +
+ +
+ {settings.bgEnabled && ( + <> + upd({ bgColor: c })} + /> + upd({ bgPadding: v })} + /> + upd({ bgRadius: v })} + /> + + )} + + ), + }, + { + key: "layout", + label: "排版", + children: ( + <> + upd({ maxCharsPerLine: v })} + /> +
+ 0 = 不自动换行(按 / 手动分行) +
+ upd({ lineHeight: Number(v.toFixed(2)) })} + /> + upd({ marginTop: v })} + /> + + ), + }, + ...(showCoverToggle + ? [ + { + key: "cover", + label: "封面", + children: ( + <> +
+ +
+ {coverOpen && settings.coverTitle && ( +
+ 封面样式已开启。可在「封面设置」面板单独调整封面标题的字体/字号/颜色。 +
+ )} + + ), + }, + ] + : []), + ]} + /> + ) + + /* ── 模板模式:只显示网格 ── */ + if (enableTemplates && !paramsOnly) { + return ( +
+
+ 标题模板 + +
+ +
+
我的模板
+ {grouped.custom.length === 0 ? ( +
+
✨
+
还没有自定义模板,点右上角「新建模板」创建
+
+ ) : ( +
+ {grouped.custom.map((t, i) => renderTemplateCard(t, i, "mine"))} +
+ )} +
+ +
+
系统模板
+
+ {grouped.builtin.map((t, i) => renderTemplateCard(t, i, "sys"))} +
+
+ + {!hideEditor && editorOpen && editingTemplate && ( + { + setEditorOpen(false) + setEditingTemplate(null) + }} + onSave={handleEditorSave} + /> + )} +
+ ) + } + + /* ── paramsOnly:仅参数 Tab ── */ + if (paramsOnly) { + return
{renderParamsTabs()}
+ } + + /* ── 默认模式:顶部预览 + 预设 + 参数 Tab ── */ return ( -
-

标题样式

- - {/* 位置 + 字体 一行 */} -
-
- - -
-
- - -
-
- - {/* 字号滑块 */} -
-
- - {settings.size}px -
- onUpdateSize(Number(e.target.value))} +
+ {/* 实时迷你预览 */} +
+
- {/* 预设样式 */} -
- - + {/* 爆款预设 */} +
+
+ + {activePreset && 已选} +
+ {renderPresetGrid()}
- {/* 样式按钮:粗体/斜体/描边/阴影 */} -
- -
- - - - -
-
+ {renderParamsTabs()} + + {!hideEditor && editorOpen && editingTemplate && ( + { + setEditorOpen(false) + setEditingTemplate(null) + }} + onSave={handleEditorSave} + /> + )}
) } diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts index 680dbb384..1e17a4c25 100644 --- a/apps/web/src/pages/generate/constants.ts +++ b/apps/web/src/pages/generate/constants.ts @@ -46,30 +46,12 @@ export const CLIP_COUNT_STEP = 1 export const MAX_PREVIEW_COUNT = 10 export const MIN_PREVIEW_COUNT = 1 -/* ── 标题位置选项 ── */ -export const POSITION_OPTIONS = [ - { value: "top", label: "顶部" }, - { value: "center", label: "居中" }, - { value: "bottom", label: "底部" }, - { value: "custom", label: "自定义" }, -] +/* ── 标题位置选项(统一从公共层重导出) ── */ +export { POSITION_OPTIONS } from "@/components/title/position-options" +export type { PositionOption } from "@/components/title/position-options" -/* ── 标题字体选项 ── */ -export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"] - -/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */ -export const FONT_FAMILY_MAP: Record = { - 思源黑体: '"Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif', - 思源宋体: '"Source Han Serif SC", "Noto Serif SC", "Songti SC", "SimSun", serif', - 苹方: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif', - PingFang: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif', - 微软雅黑: '"Microsoft YaHei", "PingFang SC", sans-serif', - 楷体: '"KaiTi", "STKaiti", "DFKai-SB", serif', -} - -export function getFontFamily(font: string): string { - return FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"] -} +/* ── 标题字体:统一使用公共层定义(#2001) ── */ +export { getFontFamily } from "@/components/title/constants" /* ── 标题样式预设 ── */ export const TITLE_PRESETS = [ @@ -549,31 +531,6 @@ export const SMART_MATCH_REASONS = [ "人物表情生动", ] -/* ── AI 标题模板 ── */ -export const AI_TITLE_TEMPLATES: Record = { - catchy: [ - "震惊!{topic}居然还能这样操作", - "99%的人都不知道的{topic}秘诀", - "{topic}的终极指南,看完直接封神", - "别再走弯路了!{topic}看这一篇就够", - "一个视频讲透{topic},建议收藏", - ], - emotional: [ - "致每一个在{topic}路上坚持的人", - "关于{topic},我想说句真心话", - "{topic}背后的故事,看完沉默了", - "为什么我劝你一定要了解{topic}", - "这才是{topic}最动人的样子", - ], - informative: [ - "{topic}完整科普:从入门到精通", - "深度解析{topic}的核心原理", - "{topic}行业趋势报告|2026最新版", - "三分钟带你全面了解{topic}", - "{topic}常见问题与解决方案汇总", - ], -} - /* ── 默认封面设置 ── */ export const DEFAULT_COVER_SETTINGS: CoverConfig = { enabled: true, diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index cb5eff27e..a2fc9e0eb 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -2739,6 +2739,7 @@ justify-content: center; font-size: 32px; color: #ccc; + position: relative; } /* 卡片信息区 */ @@ -2880,27 +2881,6 @@ Issue #1677 多视频批量生成 ================================================================ */ -/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */ -.xx-generate-layout.step4-layout { - grid-template-columns: 1fr 380px; - align-items: start; -} - -.xx-generate-preview-col { - min-width: 0; - position: sticky; - top: 16px; -} - -.xx-generate-preview-col .xx-form-section { - margin: 0; -} - -.xx-title-sidebar { - max-height: calc(100vh - 140px); - overflow-y: auto; -} - /* ── 数量选择弹窗 ── */ .xx-modal-mask { position: fixed; @@ -3240,135 +3220,6 @@ border-radius: 4px; } -/* ── 响应式:窄屏 Step4 回退单列 ── */ -@media (max-width: 960px) { - .xx-generate-layout.step4-layout { - grid-template-columns: 1fr; - } - - .xx-generate-preview-col { - position: static; - } - - .xx-title-sidebar { - max-height: none; - } -} - -/* ============================================================ - 批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览) - ============================================================ */ -/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中), - 中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */ -.xx-canvas-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 180px)); - justify-content: center; - gap: 14px; -} - -/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */ -@media (max-width: 960px) { - .xx-canvas-grid { - grid-template-columns: minmax(0, 320px); - } -} - -.xx-canvas-grid-card { - position: relative; - border: 2px solid var(--border-primary, #e2e8f0); - border-radius: 12px; - overflow: hidden; - background: transparent; - transition: border-color 0.2s ease; - min-width: 0; -} - -.xx-canvas-grid-card.selected { - border-color: var(--primary-color, #1677ff); - box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15); -} - -.xx-canvas-grid-card-bar { - position: relative; - z-index: 2; - display: flex; - align-items: center; - padding: 6px 10px; - background: var(--bg-surface, #fff); - border-bottom: 1px solid var(--border-primary, #e2e8f0); -} - -.xx-canvas-grid-check { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 500; - color: var(--text-primary, #1a1a1a); - cursor: pointer; - user-select: none; -} - -.xx-canvas-grid-check input[type="checkbox"] { - width: 15px; - height: 15px; - cursor: pointer; - accent-color: var(--primary-color, #1677ff); -} - -/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */ -.xx-variant-clips-status { - position: relative; - width: 100%; - aspect-ratio: 9 / 16; - max-height: 70vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - font-size: 13px; - color: rgba(255, 255, 255, 0.75); - background: #0a0a0a; - border-radius: 16px; - overflow: hidden; - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.3), - 0 20px 50px -12px rgba(0, 0, 0, 0.5), - inset 0 0 0 1px rgba(255, 255, 255, 0.06); -} - -.xx-variant-clips-status .anticon { - font-size: 22px; -} - -.xx-variant-clips-error-text { - color: rgba(255, 255, 255, 0.85); - font-size: 13px; -} - -/* ============================================================ - 批量标题:AI 一键生成行(Issue #1677) - ============================================================ */ -.xx-batch-ai-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - padding: 10px 12px; - margin-bottom: 12px; - background: var(--bg-secondary, #f7f8fa); - border: 1px dashed var(--border-primary, #d9d9d9); - border-radius: 10px; -} - -.xx-batch-ai-row .xx-form-field { - margin: 0; - flex: 1; - min-width: 140px; -} - .xx-batch-titles { display: flex; flex-direction: column; @@ -3485,3 +3336,465 @@ grid-template-columns: minmax(0, 360px); } } + +/* ================================================================ + 自定义封面编辑器 (Cover Editor Modal) — xx-ce-* + ================================================================ */ + +/* Header */ +.xx-ce-header { + margin-bottom: 16px; +} +.xx-ce-name-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: var(--radius-sm, 6px); + font-size: 14px; + margin-bottom: 12px; + outline: none; +} +.xx-ce-name-input:focus { + border-color: #7c3aed; +} +.xx-ce-header-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* Layout */ +.xx-ce-layout { + display: flex; + gap: 20px; + min-height: 500px; +} +.xx-ce-left { + width: 300px; + flex-shrink: 0; + max-height: 70vh; + overflow-y: auto; +} +.xx-ce-right { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + border-radius: 8px; + min-height: 480px; +} + +/* Section / collapsible panels */ +.xx-ce-section { + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + margin-bottom: 8px; +} +.xx-ce-section-header { + padding: 10px 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + background: #f0f4ff; + user-select: none; +} +.xx-ce-section-header:hover { + background: #e8edf8; +} +.xx-ce-section-body { + padding: 12px; + font-size: 12px; + color: var(--text-secondary, #666); +} +.xx-ce-header-right { + display: flex; + align-items: center; + gap: 6px; +} +.xx-ce-status-text { + font-size: 11px; + font-weight: 400; + color: #3b82f6; +} + +/* Rows / labels */ +.xx-ce-row { + margin: 12px 0; +} +.xx-ce-label { + display: block; + font-size: 12px; + color: #374151; + margin-bottom: 4px; +} +.xx-ce-hint { + font-size: 11px; + color: #9ca3af; + margin-top: 4px; +} +.xx-ce-sub-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} +.xx-ce-switch-row { + display: flex; + align-items: center; + justify-content: space-between; +} +.xx-ce-switch-item { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #f3f4f6; +} +.xx-ce-switch-item:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +/* Color picker */ +.xx-ce-color-picker { + display: flex; + align-items: center; + gap: 6px; +} +.xx-ce-color-picker input[type="color"] { + width: 32px; + height: 24px; + padding: 0; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + background: none; +} +.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper { + padding: 1px; +} +.xx-ce-color-picker input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 2px; +} +.xx-ce-color-hex { + width: 70px; + padding: 2px 6px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 12px; + font-family: monospace; +} + +/* Position pair */ +.xx-ce-position { + display: flex; + gap: 8px; +} +.xx-ce-position .ant-input-number { + flex: 1; +} + +/* Radio button group */ +.xx-ce-radio-group { + display: flex; + gap: 0; +} +.xx-ce-radio-btn { + padding: 4px 14px; + font-size: 12px; + border: 1px solid #d1d5db; + background: #fff; + color: #374151; + cursor: pointer; + transition: all 0.15s; +} +.xx-ce-radio-btn:first-child { + border-radius: 4px 0 0 4px; +} +.xx-ce-radio-btn:last-child { + border-radius: 0 4px 4px 0; +} +.xx-ce-radio-btn + .xx-ce-radio-btn { + border-left: none; +} +.xx-ce-radio-btn.active { + background: #7c3aed; + color: #fff; + border-color: #7c3aed; +} +.xx-ce-radio-btn.active + .xx-ce-radio-btn { + border-left: 1px solid #d1d5db; +} + +/* Font select dots */ +.xx-ce-font-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} +.xx-ce-font-dot--preset { + background: #10b981; /* 绿:预置爆款中文字体 */ +} +.xx-ce-font-dot--hand { + background: #f59e0b; /* 橙:手写/书法字体 */ +} +.xx-ce-font-dot--serif { + background: #8b5cf6; /* 紫:衬线字体 */ +} +.xx-ce-font-dot--mono { + background: #6b7280; /* 灰:等宽字体 */ +} +.xx-ce-font-dot--system { + background: #3b82f6; /* 蓝:系统无衬线 */ +} + +/* Shadow actions */ +.xx-ce-shadow-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} +.xx-ce-add-shadow-btn { + padding: 4px 10px; + font-size: 12px; + background: #7c3aed; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} +.xx-ce-add-shadow-btn:hover { + background: #6d28d9; +} +.xx-ce-preset-shadow-btn { + padding: 4px 10px; + font-size: 12px; + background: #fff; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; +} + +/* Text background sub-section */ +.xx-ce-text-bg-section { + margin-top: 8px; + padding: 8px; + background: #fafafa; + border-radius: 4px; + border: 1px solid #f0f0f0; +} + +/* Readonly text display */ +.xx-ce-readonly-text { + padding: 6px 10px; + background: #eff6ff; + border-radius: 4px; + font-size: 13px; + color: #1e40af; + margin-bottom: 4px; +} + +/* Mask file row */ +.xx-ce-file-row { + display: flex; + gap: 6px; + align-items: center; +} +.xx-ce-file-name { + flex: 1; + padding: 4px 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 12px; + background: #f9fafb; + color: #6b7280; +} +.xx-ce-file-btn { + padding: 4px 10px; + font-size: 12px; + background: #fff; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} +.xx-ce-file-btn:hover { + border-color: #7c3aed; + color: #7c3aed; +} + +/* ── Canvas / Preview ── */ +.xx-ce-canvas-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} +.xx-ce-canvas { + width: 225px; + height: 400px; + background: #ddd; + position: relative; + border-radius: 8px; + overflow: hidden; +} +.xx-ce-anchor-dot { + position: absolute; + width: 8px; + height: 8px; + background: #ef4444; + border-radius: 50%; + z-index: 5; +} + +/* Portrait element */ +.xx-ce-el-portrait { + position: absolute; + background: #a8d4f0; + border: 2px solid #333; + z-index: 2; +} + +/* 8 handles: 0=TL 1=T 2=TR 3=R 4=BR 5=B 6=BL 7=L */ +.xx-ce-handle { + position: absolute; + width: 8px; + height: 8px; + background: #3b82f6; + border: 1px solid #fff; + z-index: 10; +} +.xx-ce-handle--0 { + top: -4px; + left: -4px; +} +.xx-ce-handle--1 { + top: -4px; + left: 50%; + margin-left: -4px; +} +.xx-ce-handle--2 { + top: -4px; + right: -4px; +} +.xx-ce-handle--3 { + top: 50%; + right: -4px; + margin-top: -4px; +} +.xx-ce-handle--4 { + bottom: -4px; + right: -4px; +} +.xx-ce-handle--5 { + bottom: -4px; + left: 50%; + margin-left: -4px; +} +.xx-ce-handle--6 { + bottom: -4px; + left: -4px; +} +.xx-ce-handle--7 { + top: 50%; + left: -4px; + margin-top: -4px; +} + +/* Background element */ +.xx-ce-el-bg { + position: absolute; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + z-index: 1; +} + +/* Mask overlay */ +.xx-ce-el-mask { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +/* Text background shape in canvas */ +.xx-ce-text-bg { + position: absolute; + z-index: -1; +} + +/* Cover template selected check */ +.xx-cover-template-check { + position: absolute; + top: 8px; + right: 8px; + width: 22px; + height: 22px; + background: #7c3aed; + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + z-index: 2; + box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4); +} +.xx-cover-template-thumb { + position: relative; +} + +/* Preview tip */ +.xx-ce-preview-tip { + text-align: center; + margin-top: 12px; + font-size: 12px; + color: #6b7280; +} + +/* Cover editor modal base gradient */ +.xx-ce-canvas { + background: #1a1a2e; +} + +/* Antd Slider overrides for editor */ +.xx-ce-section-body .ant-slider { + margin: 4px 0 8px; +} +.xx-ce-section-body .ant-slider-rail { + background: #e5e7eb; +} +.xx-ce-section-body .ant-slider-track { + background: #3b82f6; +} +.xx-ce-section-body .ant-slider-handle::after { + box-shadow: 0 0 0 2px #3b82f6; +} +.xx-ce-section-body .ant-slider-mark-text { + font-size: 10px; +} + +/* Antd Select dropdown font dots */ +.xx-ce-font-select-dropdown .ant-select-item-option-content { + display: flex; + align-items: center; +} + +/* Canvas 装饰层(背景/装饰/遮罩/底色/人物/文字背景色块)不接收鼠标事件, + 但拖拽的标题/副标题文字(内联 cursor:grab)需要接收 mousedown。 + 已通过 renderTextStyle 显式设 pointer-events 以外的样式,因此此处只关掉纯装饰层。 */ +.xx-ce-canvas-base, +.xx-ce-el-bg, +.xx-ce-el-portrait, +.xx-ce-el-mask { + pointer-events: none; +} diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts index b0ba92201..a60d2c102 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -22,6 +22,8 @@ export interface UseGenerateVideoProps { ttsVoiceId?: string /** TTS 音色来源 */ ttsVoiceSource?: "preset" | "clone" + /** TTS 配音风格 */ + ttsStyle?: string /** 合成后保存到配音库的 asset id / job id(叙事模式) */ ttsVoiceAssetId?: string /** 智能降重开关(默认 true) */ @@ -39,8 +41,8 @@ export interface UseGenerateVideoProps { enabled: boolean music_id?: string } - /** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */ - onGenerationSuccess?: () => void + /** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态);status=awaiting_cover 表示需进封面选择 */ + onGenerationSuccess?: (status?: "completed" | "awaiting_cover") => void /* ── 批量生成(#1677)── */ /** 生成数量(1=单条旧逻辑,>1=批量) */ previewCount?: number diff --git a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts index cfa5be4fb..272016768 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -1,4 +1,4 @@ -import { useRef, useCallback, useState } from "react" +import { useRef, useCallback, useState, useEffect } from "react" import { message } from "antd" import axios from "axios" import { getGenerationTask, retryTask as retryGenerationTaskApi } from "@/api/tasks/tasks" @@ -10,7 +10,7 @@ export interface BatchTaskState { taskId: string /** 变体序号(0-based,与标题/封面数组对齐) */ variantIndex: number - status: "running" | "completed" | "failed" + status: "running" | "completed" | "awaiting_cover" | "failed" | "queued" progress: number error: string | null /** 完成后的成片视频 */ @@ -19,7 +19,7 @@ export interface BatchTaskState { interface UseGenerationPollingOptions { onProgress: (progress: number) => void - onComplete: (videos: unknown[]) => void + onComplete: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void onFailed: (errorMsg: string) => void /** 批量:单任务状态变化(第5步逐卡片展示) */ onBatchTaskUpdate?: (taskId: string, patch: Partial) => void @@ -31,13 +31,19 @@ const MAX_RETRYABLE_ERRORS = 10 const MAX_RESULTS_RETRIES = 3 /** - * 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试) + * 生成状态轮询 Hook(v5 — awaiting_cover 状态识别 + visibilitychange 恢复 + 状态透传) * * startPolling(taskId) 轮询单个任务; * startPollingBatch(tasks) 并行轮询 N 个任务: * - 每个任务独立进度/状态/失败,通过 onBatchTaskUpdate 实时回传 * - 全部成功才 onComplete(聚合视频按变体顺序);任一失败不影响其他任务继续 * - retryTask(taskId) 单独重试失败任务(重新轮询,后端任务仍在跑则直接接续) + * + * v5 修复(#2088): + * 1. 单任务路径透传 taskStatus(completed / awaiting_cover)到 onComplete,外层据此区分跳转 + * 2. 监听 visibilitychange,页面从后台切回可见时立即补拉一次,解决切后台 setInterval 被浏览器 + * 降频/冻结导致进度卡在 56% 的问题 + * 3. 非 4xx/5xx 网络错误按 3s 退避重试(已有 MAX_RETRYABLE_ERRORS=10 兜底) */ export function useGenerationPolling({ onProgress, @@ -49,12 +55,15 @@ export function useGenerationPolling({ const cancelledRef = useRef(false) /** 批量任务上下文:taskId → 变体序号 */ const batchContextRef = useRef>(new Map()) + /** 当前活跃的「立刻补拉一次」函数(visibilitychange 回调使用) */ + const immediateTickRef = useRef<(() => void) | null>(null) const [, forceTick] = useState(0) const clearTimer = useCallback(() => { cancelledRef.current = true progressTimer.current.forEach((t) => clearTimeout(t)) progressTimer.current = [] + immediateTickRef.current = null }, []) /** 任务完成后拉取结果列表,带重试 */ @@ -96,7 +105,7 @@ export function useGenerationPolling({ runId: number, callbacks?: { onTaskProgress?: (pct: number) => void - onTaskCompleted?: (videos: unknown[]) => void + onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void onTaskFailed?: (msg: string) => void }, ): Promise => { @@ -111,8 +120,9 @@ export function useGenerationPolling({ if (cancelledRef.current || done) return consecutiveErrors = 0 - if (task.status === "completed") { + if (task.status === "completed" || task.status === "awaiting_cover") { done = true + immediateTickRef.current = null const videos = await fetchResultsWithRetry(taskId) if (cancelledRef.current) return if (videos === null) { @@ -121,13 +131,14 @@ export function useGenerationPolling({ reject(new Error(msg)) return } - callbacks?.onTaskCompleted?.(videos) + callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover") resolve(videos) return } if (task.status === "failed" || task.status === "cancelled") { done = true + immediateTickRef.current = null const rawMsg = task.error_info?.error_message || task.error_message || @@ -138,6 +149,7 @@ export function useGenerationPolling({ return } + // running / pending / waiting:更新进度并安排下一次轮询 const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0))) callbacks?.onTaskProgress?.(pct) if (!callbacks && runId === 0) { @@ -149,16 +161,20 @@ export function useGenerationPolling({ if (cancelledRef.current || done) return console.error("[轮询出错] taskId:", taskId, pollErr) const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined + // 4xx 视为不可重试(任务不存在/权限问题等),直接失败 if (status && status >= 400 && status < 500) { done = true + immediateTickRef.current = null const msg = extractErrorMessage(pollErr, status) callbacks?.onTaskFailed?.(msg) reject(new Error(msg)) return } + // 网络错误 / 5xx:3s 退避重试,最多 MAX_RETRYABLE_ERRORS 次 consecutiveErrors += 1 if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) { done = true + immediateTickRef.current = null const msg = "任务状态查询连续失败,请稍后在任务列表查看结果" callbacks?.onTaskFailed?.(msg) reject(new Error(msg)) @@ -169,6 +185,18 @@ export function useGenerationPolling({ } } + // 注册「立刻补拉一次」回调,供 visibilitychange 恢复时调用 + // 注意:必须在 done 后清理,避免切换页面时误触发已结束任务的补拉 + immediateTickRef.current = () => { + if (!done && !cancelledRef.current) { + // 清除未触发的 setTimeout,立即拉一次 + progressTimer.current.forEach((t) => clearTimeout(t)) + progressTimer.current = [] + consecutiveErrors = 0 + void poll() + } + } + const timer = setTimeout(poll, 1500) progressTimer.current.push(timer) }) @@ -181,12 +209,22 @@ export function useGenerationPolling({ (taskId: string) => { cancelledRef.current = false batchContextRef.current.clear() - pollSingleTask(taskId, 0) - .then((videos) => { - if (cancelledRef.current) return + let resolvedStatus: "completed" | "awaiting_cover" = "completed" + pollSingleTask(taskId, 0, { + onTaskProgress: (pct) => onProgress(pct), + onTaskCompleted: (videos, taskStatus) => { + resolvedStatus = taskStatus ?? "completed" onProgress(100) - onComplete(videos) - message.success("视频生成完成!") + onComplete(videos, resolvedStatus) + }, + onTaskFailed: (msg) => onFailed(msg), + }) + .then(() => { + if (cancelledRef.current) return + // awaiting_cover 是中间态(进封面选择页),不弹"完成"toast;completed 才弹 + if (resolvedStatus === "completed") { + message.success("视频生成完成!") + } }) .catch((err: Error) => { if (cancelledRef.current) return @@ -201,7 +239,7 @@ export function useGenerationPolling({ /** * 批量多任务轮询: * - 每个任务独立进度/状态回传 onBatchTaskUpdate - * * 全部完成后按变体顺序聚合视频 onComplete + * - 全部完成后按变体顺序聚合视频 onComplete * - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed */ const startPollingBatch = useCallback( @@ -211,6 +249,7 @@ export function useGenerationPolling({ const progressMap = new Map() const resultMap = new Map() const failureMap = new Map() + const statusMap = new Map() batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex])) const reportAggregateProgress = () => { @@ -225,7 +264,9 @@ export function useGenerationPolling({ if (resultMap.size === tasks.length) { onProgress(100) const ordered = tasks.map((t) => resultMap.get(t.taskId) || []).flat() - onComplete(ordered) + // 批量:任一任务为 awaiting_cover,则整体透传 awaiting_cover(进封面页) + const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover") + onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed") message.success(`全部 ${tasks.length} 个视频生成完成!`) } else if (resultMap.size > 0) { // 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮 @@ -234,7 +275,8 @@ export function useGenerationPolling({ .filter((t) => resultMap.has(t.taskId)) .map((t) => resultMap.get(t.taskId) || []) .flat() - onComplete(ordered) + const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover") + onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed") message.warning( `${failureMap.size} 个视频生成失败,可点击卡片上的「重试此视频」,成功的视频可先进入下一步`, ) @@ -259,10 +301,12 @@ export function useGenerationPolling({ onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }) reportAggregateProgress() }, - onTaskCompleted: (videos) => { + onTaskCompleted: (videos, taskStatus) => { progressMap.set(taskId, 100) resultMap.set(taskId, videos) - onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos }) + const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed" + statusMap.set(taskId, _finalStatus) + onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos }) reportAggregateProgress() checkAllSettled() }, @@ -293,8 +337,9 @@ export function useGenerationPolling({ } pollSingleTask(taskId, Date.now(), { onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }), - onTaskCompleted: (videos) => { - onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos }) + onTaskCompleted: (videos, taskStatus) => { + const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed" + onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos }) message.success(`视频 ${variantIndex + 1} 重试成功`) }, onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }), @@ -307,5 +352,64 @@ export function useGenerationPolling({ [pollSingleTask, onBatchTaskUpdate], ) - return { startPolling, startPollingBatch, retryTask, clearTimer } + /** + * visibilitychange 恢复:页面从后台切回前台时,立刻触发一次补拉。 + * 解决浏览器后台标签页对 setTimeout 的 1Hz 节流/冻结导致的"进度卡 56%"问题。 + */ + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible" && immediateTickRef.current) { + immediateTickRef.current() + } + } + document.addEventListener("visibilitychange", handleVisibilityChange) + // 页面聚焦也兜底一次(部分浏览器 visibilitychange 触发时机不一致) + const handleFocus = () => { + if (immediateTickRef.current) immediateTickRef.current() + } + window.addEventListener("focus", handleFocus) + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange) + window.removeEventListener("focus", handleFocus) + } + }, []) + + /** + * 批量队列模式:逐任务追加到轮询队列(支持串行提交、429 排队重试场景)。 + * 与 startPollingBatch 不同的是: + * - 不会 reset batchContextRef;多次调用会累积 + * - 不触发整体 onComplete / onFailed(完成判定交给外层 useEffect 按状态聚合) + * - 仍通过 onBatchTaskUpdate 回传单任务状态 + */ + const pollBatchTaskQueued = useCallback( + (taskId: string, variantIndex: number) => { + cancelledRef.current = false + batchContextRef.current.set(taskId, variantIndex) + onBatchTaskUpdate?.(taskId, { + taskId, + variantIndex, + status: "running", + progress: 0, + error: null, + videos: [], + }) + pollSingleTask(taskId, Date.now(), { + onTaskProgress: (pct) => { + onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }) + }, + onTaskCompleted: (videos, taskStatus) => { + const finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed" + onBatchTaskUpdate?.(taskId, { status: finalStatus, progress: 100, videos }) + }, + onTaskFailed: (msg) => { + onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }) + }, + }).catch(() => { + /* onTaskFailed 已处理 */ + }) + }, + [pollSingleTask, onBatchTaskUpdate], + ) + + return { startPolling, startPollingBatch, pollBatchTaskQueued, retryTask, clearTimer } } diff --git a/apps/web/src/pages/generate/hooks/useBatchCovers.ts b/apps/web/src/pages/generate/hooks/useBatchCovers.ts index 7a134cca4..f450e4858 100644 --- a/apps/web/src/pages/generate/hooks/useBatchCovers.ts +++ b/apps/web/src/pages/generate/hooks/useBatchCovers.ts @@ -12,7 +12,7 @@ import { useCallback, useState } from "react" import { message } from "antd" import { generateCover } from "@/api/generation" -import { uploadAssetDirect, getAssetLibraries } from "@/api/assets" +import { uploadAssetDirect } from "@/api/assets" import type { GeneratedVideo } from "@/api/template-editor" /** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */ @@ -30,8 +30,23 @@ interface UseBatchCoversOptions { color: string position: string bold: boolean + italic?: boolean stroke: boolean + strokeWidth?: number + strokeColor?: string shadow: boolean + shadowOffsetX?: number + shadowOffsetY?: number + shadowBlur?: number + shadowColor?: string + lineHeight?: number + marginTop?: number + maxCharsPerLine?: number + bgEnabled?: boolean + bgColor?: string + bgPadding?: number + bgRadius?: number + lineOverrides?: unknown[] } covers: string[] onCoversChange: CoversChangeFn @@ -78,7 +93,12 @@ export function useBatchCovers({ /** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */ const generateOne = useCallback( async (index: number): Promise => { - const finalVideos = generatedVideos.filter((v) => v.status === "completed") + const finalVideos = generatedVideos.filter( + (v) => + v.status === "completed" || + v.status === "awaiting_cover" || + v.status === "awaiting_cover", + ) const target = finalVideos[index] || generatedVideos[index] if (!target) { message.warning("该视频尚未生成完成") @@ -87,25 +107,57 @@ export function useBatchCovers({ addBusy(index) try { const titleText = titles[index] || "" - const response = await generateCover(selectedTemplate, { - generated_video_id: target.id, - video_url: target.file_url || target.download_url || "", - cover_type: "ai_frame", - ...(titleText - ? { - title_config: { - text: titleText, - font: titleStyle.font, - font_size: titleStyle.size, - font_color: titleStyle.color, - position: titleStyle.position, - bold: titleStyle.bold, - stroke: titleStyle.stroke, - shadow: titleStyle.shadow, - }, - } - : {}), - }) + const response = await generateCover( + selectedTemplate && selectedTemplate !== "default" ? selectedTemplate : undefined, + { + generated_video_id: target.id, + video_url: target.file_url || target.download_url || "", + cover_type: "ai_frame", + ...(titleText + ? { + title_config: { + text: titleText, + font: titleStyle.font, + font_size: titleStyle.size, + font_color: titleStyle.color, + position: titleStyle.position, + bold: titleStyle.bold, + italic: titleStyle.italic, + stroke: titleStyle.stroke + ? { + enabled: true, + width: titleStyle.strokeWidth ?? 4, + color: titleStyle.strokeColor ?? "#000000", + } + : { enabled: false }, + shadow: titleStyle.shadow + ? { + enabled: true, + offset_x: titleStyle.shadowOffsetX ?? 2, + offset_y: titleStyle.shadowOffsetY ?? 2, + blur: titleStyle.shadowBlur ?? 4, + color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + line_height: titleStyle.lineHeight ?? 1.2, + margin_top: titleStyle.marginTop ?? 24, + max_chars_per_line: titleStyle.maxCharsPerLine ?? 0, + background: titleStyle.bgEnabled + ? { + enabled: true, + color: titleStyle.bgColor, + padding: titleStyle.bgPadding, + radius: titleStyle.bgRadius, + } + : { enabled: false }, + line_overrides: (titleStyle.lineOverrides ?? []) as Array< + Record + >, + }, + } + : {}), + }, + ) const url = response.cover?.image_url || response.cover?.thumbnail_url || "" if (url) { patchCover(index, url) @@ -130,15 +182,9 @@ export function useBatchCovers({ async (index: number, file: File) => { addUploading(index) try { - const libs = await getAssetLibraries() - const imageLib = libs.find((l) => l.kind === "image") || libs[0] - if (!imageLib) { - message.error("未找到素材库,请先创建") - return - } const result = await uploadAssetDirect({ file, - library_id: imageLib.id, + kind: "image", }) const url = result?.url || "" if (url) { @@ -159,7 +205,10 @@ export function useBatchCovers({ /** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */ const generateAll = useCallback(async () => { - const finalVideos = generatedVideos.filter((v) => v.status === "completed") + const finalVideos = generatedVideos.filter( + (v) => + v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover", + ) const total = finalVideos.length // 待处理:基于调用时刻的 covers 快照判断(已有封面跳过); // 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包 diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index a9ea75090..7e6ab6ed0 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -13,6 +13,7 @@ import type { EditPlanClip } from "@/api/template-editor" import type { CoverConfig } from "../../types/cover" import type { PresetVoiceItem } from "@/api/voices" import type { ScriptItem } from "@/api/scripts" +import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles" import { DEFAULT_COVER_SETTINGS, DEFAULT_CLIP_COUNT } from "../../constants" import type { TitleSettings } from "../../types" import { usePlanConfigLoader } from "./usePlanConfigLoader" @@ -33,6 +34,21 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = { color: "#ffffff", posX: null, posY: null, + lineHeight: 1.2, + marginTop: 24, + maxCharsPerLine: 0, + strokeWidth: 4, + strokeColor: "#000000", + shadowOffsetX: 2, + shadowOffsetY: 2, + shadowBlur: 4, + shadowColor: "rgba(0,0,0,0.8)", + bgEnabled: false, + bgColor: "rgba(0,0,0,0.5)", + bgPadding: 12, + bgRadius: 8, + lineOverrides: [], + coverTitle: null, } export interface GenerateFormState { @@ -95,6 +111,9 @@ export interface GenerateFormState { /** TTS 音色来源:preset 系统 / clone 克隆 */ ttsVoiceSource: "preset" | "clone" setTtsVoiceSource: (src: "preset" | "clone") => void + /** TTS 配音风格 */ + ttsStyle: TtsStyle + setTtsStyle: (s: TtsStyle) => void /** 合成后配音库 asset id(叙事模式保存到库后获得;随机模式 = selectedVoice) */ ttsVoiceAssetId: string setTtsVoiceAssetId: (id: string) => void @@ -234,6 +253,7 @@ export const useGenerateFormState = (): GenerateFormState => { const [selectedScript, setSelectedScript] = useState(null) const [ttsVoiceId, setTtsVoiceId] = useState("") const [ttsVoiceSource, setTtsVoiceSource] = useState<"preset" | "clone">("preset") + const [ttsStyle, setTtsStyle] = useState(DEFAULT_TTS_STYLE) const [ttsVoiceAssetId, setTtsVoiceAssetId] = useState("") const [dedupEnabled, setDedupEnabled] = useState(true) @@ -311,6 +331,8 @@ export const useGenerateFormState = (): GenerateFormState => { setTtsVoiceId, ttsVoiceSource, setTtsVoiceSource, + ttsStyle, + setTtsStyle, ttsVoiceAssetId, setTtsVoiceAssetId, dedupEnabled, diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader.ts index 01ed07438..38eeb3311 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader.ts @@ -1,6 +1,7 @@ import { useEffect } from "react" import type { CoverConfig } from "../../types/cover" import type { TitleSettings } from "../../types" +import type { TitleLineOverride } from "@/components/title/types" import type { TitleConfig } from "@/api/template-editor" import { getEditPlan } from "@/api/template-editor" @@ -12,6 +13,156 @@ interface UsePlanConfigLoaderOptions { setSelectedMaterials: (ids: string[]) => void } +/** #2001:统一归一化 title_config snake_case -> camelCase TitleSettings */ +function mapTitleCfgToSettings( + prev: TitleSettings, + tc: TitleConfig & Record, +): TitleSettings { + const stroke = tc.stroke as + boolean | { enabled?: boolean; width?: number; color?: string } | undefined + const strokeEnabled: boolean | undefined = + typeof stroke === "object" && stroke ? stroke.enabled !== false : !!stroke || undefined + const strokeW: number | undefined = + typeof stroke === "object" && stroke + ? (stroke.width ?? (tc.stroke_width as number | undefined)) + : (tc.stroke_width as number | undefined) + const strokeC: string | undefined = + typeof stroke === "object" && stroke + ? (stroke.color ?? (tc.stroke_color as string | undefined)) + : (tc.stroke_color as string | undefined) + + const shadow = tc.shadow as + | boolean + | { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string } + | undefined + const shadowEnabled: boolean | undefined = + typeof shadow === "object" && shadow ? shadow.enabled !== false : !!shadow || undefined + const shOffX: number | undefined = + typeof shadow === "object" && shadow + ? (shadow.offset_x ?? (tc.shadow_offset_x as number | undefined)) + : (tc.shadow_offset_x as number | undefined) + const shOffY: number | undefined = + typeof shadow === "object" && shadow + ? (shadow.offset_y ?? (tc.shadow_offset_y as number | undefined)) + : (tc.shadow_offset_y as number | undefined) + const shBlur: number | undefined = + typeof shadow === "object" && shadow + ? (shadow.blur ?? (tc.shadow_blur as number | undefined)) + : (tc.shadow_blur as number | undefined) + const shColor: string | undefined = + typeof shadow === "object" && shadow + ? (shadow.color ?? (tc.shadow_color as string | undefined)) + : (tc.shadow_color as string | undefined) + + const bg = tc.background as + { enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined + const bgEnabled: boolean | undefined = + (bg && typeof bg === "object" ? bg.enabled : undefined) ?? + (tc.bg_enabled as boolean | undefined) + const bgColor: string | undefined = + (bg && typeof bg === "object" ? bg.color : undefined) ?? (tc.bg_color as string | undefined) + const bgPadding: number | undefined = + (bg && typeof bg === "object" ? bg.padding : undefined) ?? (tc.bg_padding as number | undefined) + const bgRadius: number | undefined = + (bg && typeof bg === "object" ? bg.radius : undefined) ?? (tc.bg_radius as number | undefined) + + const ct = (tc.cover_title_config ?? null) as null | Record + let coverTitle: TitleSettings["coverTitle"] = prev.coverTitle + if (ct) { + const ctStroke = ct.stroke as + boolean | { enabled?: boolean; width?: number; color?: string } | undefined + const ctShadow = ct.shadow as + | boolean + | { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string } + | undefined + const ctBg = ct.background as + { enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined + coverTitle = { + title: (ct.title as string | undefined) ?? prev.coverTitle?.title ?? "", + font: (ct.font as string | undefined) ?? prev.coverTitle?.font, + size: + (ct.font_size as number | undefined) ?? + (ct.size as number | undefined) ?? + prev.coverTitle?.size, + color: + (ct.font_color as string | undefined) ?? + (ct.color as string | undefined) ?? + prev.coverTitle?.color, + bold: (ct.bold as boolean | undefined) ?? prev.coverTitle?.bold, + italic: (ct.italic as boolean | undefined) ?? prev.coverTitle?.italic, + position: (ct.position as string | undefined) ?? prev.coverTitle?.position, + stroke: + typeof ctStroke === "object" && ctStroke + ? ctStroke.enabled !== false + : ((ctStroke as boolean | undefined) ?? prev.coverTitle?.stroke), + strokeWidth: + (typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ?? + (ct.stroke_width as number | undefined) ?? + prev.coverTitle?.strokeWidth, + strokeColor: + (typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ?? + (ct.stroke_color as string | undefined) ?? + prev.coverTitle?.strokeColor, + shadow: + typeof ctShadow === "object" && ctShadow + ? ctShadow.enabled !== false + : ((ctShadow as boolean | undefined) ?? prev.coverTitle?.shadow), + shadowOffsetX: + (typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ?? + (ct.shadow_offset_x as number | undefined) ?? + prev.coverTitle?.shadowOffsetX, + shadowOffsetY: + (typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ?? + (ct.shadow_offset_y as number | undefined) ?? + prev.coverTitle?.shadowOffsetY, + shadowBlur: + (typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ?? + (ct.shadow_blur as number | undefined) ?? + prev.coverTitle?.shadowBlur, + shadowColor: + (typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ?? + (ct.shadow_color as string | undefined) ?? + prev.coverTitle?.shadowColor, + bgEnabled: + ctBg?.enabled ?? (ct.bg_enabled as boolean | undefined) ?? prev.coverTitle?.bgEnabled, + bgColor: ctBg?.color ?? (ct.bg_color as string | undefined) ?? prev.coverTitle?.bgColor, + bgPadding: + ctBg?.padding ?? (ct.bg_padding as number | undefined) ?? prev.coverTitle?.bgPadding, + bgRadius: ctBg?.radius ?? (ct.bg_radius as number | undefined) ?? prev.coverTitle?.bgRadius, + } + } + + const result: TitleSettings = { + ...prev, + title: (tc.content as string | undefined) || prev.title, + aiAutoSelect: (tc.ai_auto_select as boolean | undefined) || false, + position: prev.position, + font: (tc.font_preset as string | undefined) || prev.font, + size: (tc.font_size as number | undefined) || prev.size, + color: (tc.font_color as string | undefined) || prev.color, + bold: (tc.bold as boolean | undefined) ?? prev.bold, + italic: (tc.italic as boolean | undefined) ?? prev.italic, + stroke: strokeEnabled ?? prev.stroke, + strokeWidth: strokeW ?? prev.strokeWidth, + strokeColor: strokeC ?? prev.strokeColor, + shadow: shadowEnabled ?? prev.shadow, + shadowOffsetX: shOffX ?? prev.shadowOffsetX, + shadowOffsetY: shOffY ?? prev.shadowOffsetY, + shadowBlur: shBlur ?? prev.shadowBlur, + shadowColor: shColor ?? prev.shadowColor, + lineHeight: (tc.line_height as number | undefined) ?? prev.lineHeight, + marginTop: (tc.margin_top as number | undefined) ?? prev.marginTop, + maxCharsPerLine: (tc.max_chars_per_line as number | undefined) ?? prev.maxCharsPerLine, + bgEnabled: bgEnabled ?? prev.bgEnabled, + bgColor: bgColor ?? prev.bgColor, + bgPadding: bgPadding ?? prev.bgPadding, + bgRadius: bgRadius ?? prev.bgRadius, + lineOverrides: ((tc.line_overrides as unknown[] | undefined) ?? []) as TitleLineOverride[], + coverTitle, + } + return result +} + /** * 从 URL 参数或编辑计划 ID 加载表单配置 */ @@ -27,14 +178,7 @@ export function usePlanConfigLoader({ if (!planConfigStr) return try { const config = JSON.parse(planConfigStr) as { - title_config?: { - content?: string - ai_auto_select?: boolean - position?: string - font_preset?: string - font_size?: number - font_color?: string - } + title_config?: Record subtitle_config?: { enabled?: boolean } bgm_config?: { enabled?: boolean; music_id?: string } mode?: string @@ -43,16 +187,8 @@ export function usePlanConfigLoader({ } if (config.title_config) { - const tc = config.title_config as TitleConfig - setTitleSettings((prev: TitleSettings) => ({ - ...prev, - title: tc.content || "", - aiAutoSelect: tc.ai_auto_select || false, - position: prev.position, // 强制保留默认/用户选择,不从草稿配置同步位置 - font: tc.font_preset || prev.font, - size: tc.font_size || prev.size, - color: tc.font_color || prev.color, - })) + const tc = config.title_config as TitleConfig & Record + setTitleSettings((prev: TitleSettings) => mapTitleCfgToSettings(prev, tc)) } if (config.segments && config.segments.length > 0) { const assetIds = config.segments @@ -76,15 +212,8 @@ export function usePlanConfigLoader({ if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name })) const cfg = plan.config if (cfg?.title_config) { - setTitleSettings((prev: TitleSettings) => ({ - ...prev, - aiAutoSelect: cfg.title_config!.ai_auto_select, - title: cfg.title_config!.content || prev.title, - position: prev.position, // 强制保留默认/用户选择,不从远程草稿同步位置 - font: cfg.title_config!.font_preset || prev.font, - size: cfg.title_config!.font_size || prev.size, - color: cfg.title_config!.font_color || prev.color, - })) + const tc2 = cfg.title_config as unknown as TitleConfig & Record + setTitleSettings((prev: TitleSettings) => mapTitleCfgToSettings(prev, tc2)) } if (cfg?.cover_config) { const cc = cfg.cover_config as CoverConfig diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index 8f51a2dbf..fda29c22a 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -2,10 +2,12 @@ * 视频生成 Hook * 封装视频生成的核心逻辑、状态管理、轮询等 */ -import { useState, useCallback, useEffect } from "react" +import { useState, useCallback, useEffect, useRef } from "react" import { message } from "antd" +import axios from "axios" import { type GeneratedVideo, getEditPlanClips, createClipsFromAssets } from "@/api/template-editor" import { createGenerationTask } from "@/api/tasks/tasks" +import type { CreateGenerationTaskRequest } from "@/api/tasks/types" import type { UseGenerateVideoProps } from "./generate-video/types" import { getGenerationPhase } from "./generate-video/phase" import { useGenerationPolling, type BatchTaskState } from "./generate-video/useGenerationPolling" @@ -13,6 +15,28 @@ import { validateGenerateInputs } from "./generate-video/buildPayload" import { calculateResolution } from "../utils/calculateResolution" import { extractBackendError, translateError } from "./generate-video/errorUtils" +export type GenerationCompleteStatus = "completed" | "awaiting_cover" | null + +/** 判断是否是用户队列已满 429(需要排队重试而非直接报错) */ +function isUserQueueFullError(err: unknown): { waitMs: number } | null { + if (!axios.isAxiosError(err)) return null + if (err.response?.status !== 429 && err.response?.status !== 503) return null + const detail = (err.response?.data as { detail?: unknown })?.detail + const code = + typeof detail === "object" && detail !== null ? (detail as { code?: string }).code : undefined + if (code === "USER_QUEUE_FULL" || code === "SYSTEM_QUEUE_FULL") { + const waitSec = + typeof detail === "object" && detail !== null + ? Number((detail as { estimated_wait_seconds?: number }).estimated_wait_seconds) || 0 + : 0 + return { waitMs: Math.max(15_000, waitSec * 1000 || 30_000) } + } + return null +} + +/** sleep */ +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + export function useGenerateVideo(props: UseGenerateVideoProps) { const { selectedTemplate, onGenerationSuccess } = props @@ -22,9 +46,29 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const [generated, setGenerated] = useState(false) const [generateError, setGenerateError] = useState(null) const [generatedVideos, setGeneratedVideos] = useState([]) + /** #2088:任务最终状态,区分 awaiting_cover(选封面)/ completed(已完成) */ + const [completionStatus, setCompletionStatus] = useState(null) + /** 单视频模式:当前任务 ID(封面 finalize 需要) */ + const [currentTaskId, setCurrentTaskId] = useState("") /** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */ const [batchTasks, setBatchTasks] = useState([]) + /** 排队中重试的定时器,unmount / 新提交时清理 */ + const queueTimersRef = useRef([]) + const cancelledRef = useRef(false) + + const clearQueueTimers = useCallback(() => { + queueTimersRef.current.forEach((id) => clearTimeout(id)) + queueTimersRef.current = [] + }, []) + + useEffect(() => { + return () => { + cancelledRef.current = true + clearQueueTimers() + } + }, [clearQueueTimers]) + const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial) => { setBatchTasks((prev) => { const list = prev || [] @@ -51,14 +95,15 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const handleProgress = useCallback((p: number) => setProgress(p), []) const handleComplete = useCallback( - (videos: unknown[]) => { - setGenerating(false) + (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => { setGenerated(true) + const finalStatus: GenerationCompleteStatus = taskStatus ?? "completed" + setCompletionStatus(finalStatus) setGeneratedVideos(videos as GeneratedVideo[]) // 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底 setBatchTasks((prev) => (prev || []).map((t) => - t.status === "completed" && t.videos.length === 0 + t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0) ? { ...t, videos: (videos as GeneratedVideo[]).filter( @@ -68,23 +113,35 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { : t, ), ) - onGenerationSuccess?.() + onGenerationSuccess?.(finalStatus) }, [onGenerationSuccess], ) const handleFailed = useCallback((errorMsg: string) => { - setGenerating(false) setGenerateError(errorMsg) }, []) /* 批量:任务状态变化时聚合已完成成片(含失败重试成功后补入), - 按变体索引排序,供步骤6封面按勾选顺序逐个取视频 */ + 按变体索引排序,供步骤6封面按勾选顺序逐个取视频。 + 当全部任务都已结束(completed/awaiting_cover/failed)且无排队/渲染中任务时,关闭 generating。 */ useEffect(() => { if (batchTasks.length === 0) return const byVariant = new Map() + let hasQueued = false + let hasRunning = false + let hasSuccess = false + let allDone = true batchTasks.forEach((t) => { - if (t.status === "completed" && t.videos && t.videos.length > 0) { - byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo) + if (t.status === "queued") hasQueued = true + else if (t.status === "running") hasRunning = true + if (t.status === "completed" || t.status === "awaiting_cover") { + hasSuccess = true + if (t.videos && t.videos.length > 0) { + byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo) + } + } + if (t.status !== "completed" && t.status !== "awaiting_cover" && t.status !== "failed") { + allDone = false } }) const ordered = [...byVariant.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v) @@ -94,17 +151,185 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { } return ordered }) + if (allDone && !hasQueued && !hasRunning) { + setGenerating(false) + if (hasSuccess) { + setGenerated(true) + setCompletionStatus("awaiting_cover") + } + } }, [batchTasks]) - const { startPolling, startPollingBatch, retryTask, clearTimer } = useGenerationPolling({ + const { startPolling, pollBatchTaskQueued, retryTask, clearTimer } = useGenerationPolling({ onProgress: handleProgress, onComplete: handleComplete, onFailed: handleFailed, onBatchTaskUpdate: handleBatchTaskUpdate, }) + /** 根据 props 构造基础 payload(批量/单任务共用的字段) */ + const buildBasePayload = useCallback((): Omit< + CreateGenerationTaskRequest, + "count" | "titles" | "voice_library_ids" | "cover_urls" | "variant_plan_ids" + > => { + const { width: outputWidth, height: outputHeight } = calculateResolution( + props.videoRatio || "9:16", + ) + const editMode = props.editMode ?? "random" + const dedupEnabled = props.dedupEnabled !== false + const assetIds = + props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials + const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || "" + // #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice + const voiceLibraryId = + editMode === "narrative" + ? props.ttsVoiceId || "" + : props.voiceMode === "clone" + ? props.selectedClonedVoice || props.selectedVoice || "" + : props.selectedVoice || "" + + const bgmConfig = { + enabled: props.bgm !== false, + ...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}), + } + + const titleConfig = props.titleSettings?.title + ? { + text: props.titleSettings.title, + font: props.titleSettings.font, + font_size: props.titleSettings.size, + font_color: props.titleSettings.color, + position: props.titleSettings.position, + ...(props.titleSettings.position === "custom" && + props.titleSettings.posX != null && + props.titleSettings.posY != null + ? { + pos_x: Math.round(props.titleSettings.posX), + pos_y: Math.round(props.titleSettings.posY), + } + : {}), + bold: props.titleSettings.bold, + italic: props.titleSettings.italic, + stroke: props.titleSettings.stroke + ? { + enabled: true, + width: props.titleSettings.strokeWidth ?? 4, + color: props.titleSettings.strokeColor ?? "#000000", + } + : { enabled: false }, + shadow: props.titleSettings.shadow + ? { + enabled: true, + offset_x: props.titleSettings.shadowOffsetX ?? 2, + offset_y: props.titleSettings.shadowOffsetY ?? 2, + blur: props.titleSettings.shadowBlur ?? 4, + color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + line_height: props.titleSettings.lineHeight ?? 1.2, + margin_top: props.titleSettings.marginTop ?? 24, + max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0, + ...(props.titleSettings.bgEnabled + ? { + background: { + enabled: true, + color: props.titleSettings.bgColor, + padding: props.titleSettings.bgPadding, + radius: props.titleSettings.bgRadius, + }, + } + : { background: { enabled: false } }), + line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({ + line_index: lo.line_index, + text: lo.text, + size: lo.size, + color: lo.color, + bold: lo.bold, + italic: lo.italic, + stroke: lo.stroke, + highlights: lo.highlights?.map((h) => ({ + word: h.word, + color: h.color, + bold: h.bold, + scale: h.scale, + })), + })), + ...(props.titleSettings.coverTitle + ? { + cover_title_config: { + title: props.titleSettings.coverTitle.title, + font: props.titleSettings.coverTitle.font, + font_size: props.titleSettings.coverTitle.size, + font_color: props.titleSettings.coverTitle.color, + bold: props.titleSettings.coverTitle.bold, + italic: props.titleSettings.coverTitle.italic, + position: props.titleSettings.coverTitle.position, + stroke: props.titleSettings.coverTitle.stroke + ? { + enabled: true, + width: props.titleSettings.coverTitle.strokeWidth ?? 4, + color: props.titleSettings.coverTitle.strokeColor ?? "#000000", + } + : { enabled: false }, + shadow: props.titleSettings.coverTitle.shadow + ? { + enabled: true, + offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2, + offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2, + blur: props.titleSettings.coverTitle.shadowBlur ?? 4, + color: props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)", + } + : { enabled: false }, + ...(props.titleSettings.coverTitle.bgEnabled + ? { + background: { + enabled: true, + color: props.titleSettings.coverTitle.bgColor, + padding: props.titleSettings.coverTitle.bgPadding, + radius: props.titleSettings.coverTitle.bgRadius, + }, + } + : { background: { enabled: false } }), + }, + } + : {}), + } + : undefined + + const payload: Omit< + CreateGenerationTaskRequest, + "count" | "titles" | "voice_library_ids" | "cover_urls" | "variant_plan_ids" + > = { + template_id: selectedTemplate, + asset_ids: assetIds, + output_width: outputWidth, + output_height: outputHeight, + cover_url: coverUrl, + custom_title: props.titleSettings?.title || "", + duration: props.duration || undefined, + video_ratio: props.videoRatio, + assembly_mode: editMode, + ...(editMode === "narrative" && props.selectedScript?.id + ? { + script_id: props.selectedScript.id, + tts_voice_id: props.ttsVoiceId || undefined, + tts_voice_source: props.ttsVoiceSource || undefined, + tts_style: props.ttsStyle || undefined, + } + : {}), + dedup_enabled: dedupEnabled, + voice_library_id: voiceLibraryId, + ...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}), + bgm_config: bgmConfig as CreateGenerationTaskRequest["bgm_config"], + ...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}), + ...(titleConfig ? ({ title_config: titleConfig } as Record) : {}), + } + + return payload + }, [props, selectedTemplate]) + /* ── 生成视频 ── - 返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */ + 返回 true 表示任务创建成功并已开始轮询(含排队中);false 表示校验未通过或创建失败 */ const generate = useCallback(async (): Promise => { const errorMsg = validateGenerateInputs(props) if (errorMsg) { @@ -112,32 +337,28 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { return false } + cancelledRef.current = false + clearQueueTimers() setGenerating(true) setProgress(0) setGenerated(false) setGenerateError(null) + setCompletionStatus(null) setBatchTasks([]) + setGeneratedVideos([]) + setCurrentTaskId("") clearTimer() + const basePayload = buildBasePayload() + const assetIds = basePayload.asset_ids + const isBatch = (props.previewCount || 1) > 1 + try { - const { width: outputWidth, height: outputHeight } = calculateResolution( - props.videoRatio || "9:16", - ) - const editMode = props.editMode ?? "random" - const dedupEnabled = props.dedupEnabled !== false - - const assetIds = - props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials - - // from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用, - // 后端已改为异步秒级返回,这里做一次轻量兜底: - // 单次查 clips,已有则直接放行;没有则再调一次 from-assets。 + // from-assets 兜底:片段不存在则补一次 if (assetIds.length > 0 && selectedTemplate) { try { const clipList = await getEditPlanClips(selectedTemplate, { limit: 500 }) if (clipList.items.length === 0) { - // 片段不存在(极端情况:useStep2Materials 的 debounce 还没触发) - // 手动补一次 from-assets(后端秒级返回) await createClipsFromAssets(selectedTemplate, assetIds, "main") } } catch { @@ -145,135 +366,185 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { } } - const isBatch = (props.previewCount || 1) > 1 - const hide = message.loading( - isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...", - 0, - ) - - const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || "" - - // #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice - const voiceLibraryId = - editMode === "narrative" - ? props.ttsVoiceId || "" - : props.voiceMode === "clone" - ? props.selectedClonedVoice || props.selectedVoice || "" - : props.selectedVoice || "" - - /* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */ - const indexes = - isBatch && props.selectedVariantIndexes?.length - ? props.selectedVariantIndexes - : Array.from({ length: props.previewCount || 1 }, (_, i) => i) - const batchCount = isBatch ? indexes.length : 1 - - // 标题文字数组:批量时按勾选顺序 - const titlesArr = - isBatch && (props.variantTitles?.length || 0) >= batchCount - ? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "") - : [] - // 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id) - const voiceArr = - isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length - ? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId) - : [] - // 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面) - const coversArr = - isBatch && props.variantCoverUrls?.length - ? indexes.map((i) => props.variantCoverUrls![i] || "") - : [] - // #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传, - // 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。 - // 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。 - const variantPlansArr = - isBatch && props.variantPlanIds?.length - ? indexes.map((i) => props.variantPlanIds![i] || "") - : [] - const hasVariantPlans = variantPlansArr.some((id) => !!id) - - try { - const taskResp = await createGenerationTask({ - template_id: selectedTemplate, - asset_ids: assetIds, - output_width: outputWidth, - output_height: outputHeight, - cover_url: coverUrl, - custom_title: props.titleSettings?.title || "", - duration: props.duration || undefined, - video_ratio: props.videoRatio, - assembly_mode: editMode, - ...(editMode === "narrative" && props.selectedScript?.id - ? { - script_id: props.selectedScript.id, - tts_voice_id: props.ttsVoiceId || undefined, - tts_voice_source: props.ttsVoiceSource || undefined, - } - : {}), - dedup_enabled: dedupEnabled, - voice_library_id: voiceLibraryId, - ...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}), - bgm_config: { - enabled: props.bgm !== false, - ...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}), - }, - ...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}), - ...(isBatch ? { count: batchCount } : {}), - ...(titlesArr.length ? { titles: titlesArr } : {}), - ...(voiceArr.length ? { voice_library_ids: voiceArr } : {}), - ...(coversArr.length ? { cover_urls: coversArr } : {}), - ...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}), - ...(props.titleSettings?.title - ? { - title_config: { - text: props.titleSettings.title, - font: props.titleSettings.font, - font_size: props.titleSettings.size, - font_color: props.titleSettings.color, - position: props.titleSettings.position, - ...(props.titleSettings.position === "custom" && - props.titleSettings.posX != null && - props.titleSettings.posY != null - ? { - pos_x: Math.round(props.titleSettings.posX), - pos_y: Math.round(props.titleSettings.posY), - } - : {}), - bold: props.titleSettings.bold, - stroke: props.titleSettings.stroke, - shadow: props.titleSettings.shadow, - }, - } - : {}), - }) - hide() - const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean) - - if (taskIds.length === 0) { - throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看") - } - if (taskIds.length > 1) { - // 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建) - startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i }))) - } else { + if (!isBatch) { + /* ── 单视频:原逻辑(一次提交 count=1) ── */ + const hide = message.loading("正在生成预览视频...", 0) + try { + const taskResp = await createGenerationTask({ ...basePayload, count: 1 }) + hide() + const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean) + if (taskIds.length === 0) { + throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看") + } + setCurrentTaskId(taskIds[0]) startPolling(taskIds[0]) + } catch (err) { + hide() + throw err } - } catch (err) { - hide() - throw err + return true } + + /* ── 批量:支持任意数量视频,按队列容量串行提交,429 自动排队重试 ── */ + const indexes = props.selectedVariantIndexes?.length + ? props.selectedVariantIndexes + : Array.from({ length: props.previewCount || 1 }, (_, i) => i) + const batchCount = indexes.length + + const titlesAll = + (props.variantTitles?.length || 0) >= batchCount + ? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "") + : indexes.map(() => props.titleSettings?.title || "") + const voiceArrAll = + props.voiceModePerVideo && props.variantVoiceLibraryIds?.length + ? indexes.map( + (i) => props.variantVoiceLibraryIds![i] || basePayload.voice_library_id || "", + ) + : [] + const coversAll = props.variantCoverUrls?.length + ? indexes.map((i) => props.variantCoverUrls![i] || "") + : indexes.map(() => "") + const plansAll = props.variantPlanIds?.length + ? indexes.map((i) => props.variantPlanIds![i] || "") + : indexes.map(() => "") + + const hasAnyVoice = voiceArrAll.some((v) => !!v) + const hasAnyCover = coversAll.some((u) => !!u) + const hasAnyPlan = plansAll.some((id) => !!id) + + // 先用占位 ID 把所有变体卡片置为 queued,UI 可见 + const placeholderIds = indexes.map((_, i) => `__queued_${Date.now()}_${i}`) + const initialTasks: BatchTaskState[] = indexes.map((variantIndex, i) => ({ + taskId: placeholderIds[i], + variantIndex, + status: "queued", + progress: 0, + error: null, + videos: [], + })) + setBatchTasks(initialTasks) + + message.loading({ + content: `已提交 ${batchCount} 个视频任务,系统按队列容量依次渲染…`, + key: "batch-gen", + duration: 3, + }) + + /** 将占位 taskId 更新为真实 taskId(卡片引用同一对象) */ + const replacePlaceholder = (placeholderId: string, realTaskId: string) => { + setBatchTasks((prev) => { + const idx = prev.findIndex((t) => t.taskId === placeholderId) + if (idx === -1) return prev + const next = [...prev] + next[idx] = { ...next[idx], taskId: realTaskId } + return next + }) + } + + /** 提交某一索引的单任务(count=1),成功后返回真实 taskId;429/503 则返回 waitMs */ + const submitOne = async ( + i: number, + ): Promise<{ queued: true; waitMs: number } | { queued: false; taskId: string }> => { + const body: CreateGenerationTaskRequest = { + ...basePayload, + count: 1, + titles: [titlesAll[i] || ""], + ...(hasAnyVoice + ? { voice_library_ids: [voiceArrAll[i] || basePayload.voice_library_id || ""] } + : {}), + ...(hasAnyCover ? { cover_urls: [coversAll[i] || ""] } : {}), + ...(hasAnyPlan && plansAll[i] ? { variant_plan_ids: [plansAll[i]] } : {}), + } + try { + const resp = await createGenerationTask(body) + const item = resp.items?.[0] + const tid = item?.id + if (!tid) throw new Error("创建任务成功但未返回任务 ID") + return { queued: false, taskId: tid } + } catch (err) { + const q = isUserQueueFullError(err) + if (q) return { queued: true, waitMs: q.waitMs } + throw err + } + } + + // 串行提交:每次提交一个;429/503 则等待后重试;其它错误立即标记该任务失败 + let fatalErr: unknown = null + for (let i = 0; i < batchCount; i++) { + if (cancelledRef.current) return false + const variantIndex = indexes[i] + const placeholderId = placeholderIds[i] + let attempt = 0 + let submitted = false + while (!submitted) { + if (cancelledRef.current) return false + attempt++ + try { + const result = await submitOne(i) + if (!result.queued) { + replacePlaceholder(placeholderId, result.taskId) + // 先更新到 running,再启动单任务增量轮询(不触发整体 onComplete) + pollBatchTaskQueued(result.taskId, variantIndex) + submitted = true + } else { + // 排队:保持 queued 状态,等待后重试 + handleBatchTaskUpdate(placeholderId, { + taskId: placeholderId, + variantIndex, + status: "queued", + progress: 0, + error: null, + }) + if (attempt === 1) { + message.info({ + content: `队列繁忙,${Math.round(result.waitMs / 1000)} 秒后自动继续提交后续视频…`, + key: "batch-gen", + duration: 4, + }) + } + await sleep(Math.min(result.waitMs, 60_000)) + } + } catch (err) { + // 非限流错误:该任务标记失败,继续后续任务(不阻断整个批量) + console.error("[batch generate] 任务提交失败:", err) + const msg = translateError(extractBackendError(err)) + handleBatchTaskUpdate(placeholderId, { + taskId: placeholderId, + variantIndex, + status: "failed", + error: msg, + progress: 0, + }) + submitted = true + if (!fatalErr) fatalErr = err + } + } + } + + if (fatalErr) { + // 有任务失败但其余已成功,整体不 throw;由 UI 展示单个失败卡片 + } + return true } catch (err: unknown) { console.error("[handleGenerate] 生成失败:", err) - setGenerating(false) const backendMsg = extractBackendError(err) console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err) const finalMsg = translateError(backendMsg) setGenerateError(finalMsg) + setGenerating(false) message.error(finalMsg) return false } - return true - }, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate]) + }, [ + props, + clearTimer, + startPolling, + selectedTemplate, + buildBasePayload, + handleBatchTaskUpdate, + clearQueueTimers, + pollBatchTaskQueued, + ]) const retry = useCallback(() => { setGenerateError(null) @@ -283,9 +554,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { /** 第5步:单独重试某个失败任务 */ const retryBatchTask = useCallback( (taskId: string) => { + handleBatchTaskUpdate(taskId, { status: "running", progress: 0, error: null, videos: [] }) retryTask(taskId) }, - [retryTask], + [retryTask, handleBatchTaskUpdate], ) const dismissError = useCallback(() => { @@ -330,6 +602,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { generated, generateError, generatedVideos, + completionStatus, + currentTaskId, generate, retry, retryBatchTask, diff --git a/apps/web/src/pages/generate/hooks/usePreviewAssets.ts b/apps/web/src/pages/generate/hooks/usePreviewAssets.ts deleted file mode 100644 index c43172d99..000000000 --- a/apps/web/src/pages/generate/hooks/usePreviewAssets.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 预览素材加载 Hook - * 根据选中的素材 ID 列表,逐个获取素材详情(含 file_url、duration 等) - * 供前端预览播放器使用 - * - * 注意:后端没有批量接口(/assets/batch 返回 405), - * 因此直接使用 Promise.allSettled 并发请求单个 GET /assets/{id} - */ -import { useState, useEffect, useCallback, useRef } from "react" -import type { AssetItem } from "@/api/assets" -import type { AxiosResponse } from "axios" - -/** - * 通过 ID 列表逐个获取素材(并发) - * 使用 Promise.allSettled 确保单个失败不影响整体 - */ -async function fetchAssetsByIds(ids: string[]): Promise { - // 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求 - const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0) - if (!validIds.length) return [] - - try { - const { default: apiClient } = await import("@/api/client") - const results = await Promise.allSettled( - validIds.map((id) => apiClient.get(`/assets/${id}`)), - ) - return results - .filter( - (r): r is PromiseFulfilledResult> => - r.status === "fulfilled" && !!r.value?.data, - ) - .map((r) => r.value.data) - } catch { - return [] - } -} - -interface UsePreviewAssetsReturn { - /** 加载后的素材列表 */ - assets: AssetItem[] - /** 是否正在加载 */ - loading: boolean - /** 是否已就绪(加载完成) */ - ready: boolean - /** 手动触发重新加载 */ - reload: () => void - /** - * 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材 - * (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并, - * 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。 - */ - ensureAssets: (ids: string[]) => Promise -} - -/** - * usePreviewAssets — 加载选中素材的视频文件信息 - */ -export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn { - const [assets, setAssets] = useState([]) - const [loading, setLoading] = useState(false) - const [ready, setReady] = useState(false) - const requestIdRef = useRef(0) - - // 稳定化 assetIds:只有内容真正变化时才更新引用 - const stableAssetIds = useStableArray(assetIds) - - const load = useCallback(async () => { - const validIds = stableAssetIds.filter( - (id): id is string => typeof id === "string" && id.length > 0, - ) - if (!validIds.length || !enabled) { - setAssets([]) - setReady(false) - return - } - - const thisRequestId = ++requestIdRef.current - setLoading(true) - setReady(false) - - try { - const result = await fetchAssetsByIds(validIds) - // 防止竞态:只保留最新请求的结果 - if (requestIdRef.current === thisRequestId) { - setAssets(result) - setReady(result.length > 0) - } - } catch { - if (requestIdRef.current === thisRequestId) { - setAssets([]) - setReady(false) - } - } finally { - if (requestIdRef.current === thisRequestId) { - setLoading(false) - } - } - }, [stableAssetIds, enabled]) - - // 补拉用独立序号:不干扰主 load 的竞态守卫 - const ensureSeqRef = useRef(0) - const assetsRef = useRef([]) - useEffect(() => { - assetsRef.current = assets - }, [assets]) - - /** - * 差集补拉(#1750):变体计划 clips 引用的 asset_id 不在当前素材列表时, - * 补拉素材详情并去重合并(不静默丢片段、不用假数据冒充)。 - */ - const ensureAssets = useCallback(async (ids: string[]) => { - const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0) - if (!validIds.length) return - const seq = ++ensureSeqRef.current - const missing = Array.from(new Set(validIds)).filter( - (id) => !assetsRef.current.some((a) => a.id === id), - ) - if (!missing.length) return - const fetched = await fetchAssetsByIds(missing) - if (seq !== ensureSeqRef.current || !fetched.length) return - const existing = new Set(assetsRef.current.map((a) => a.id)) - const additions = fetched.filter((a) => !existing.has(a.id)) - if (!additions.length) return - const merged = [...assetsRef.current, ...additions] - assetsRef.current = merged - setAssets(merged) - setReady(merged.length > 0) - }, []) - - useEffect(() => { - load() - }, [load]) - - return { assets, loading, ready, reload: load, ensureAssets } -} - -/** - * useStableArray — 数组内容稳定化 Hook - * 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新 - */ -function useStableArray(array: T[]): T[] { - const ref = useRef(array) - - // 比较数组内容是否真正变化 - const hasChanged = - array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index]) - - if (hasChanged) { - ref.current = array - } - - return ref.current -} - -export default usePreviewAssets diff --git a/apps/web/src/pages/generate/hooks/usePreviewAudio.ts b/apps/web/src/pages/generate/hooks/usePreviewAudio.ts deleted file mode 100644 index 1de9ad6f1..000000000 --- a/apps/web/src/pages/generate/hooks/usePreviewAudio.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useCallback, useEffect, useRef } from "react" - -interface UsePreviewAudioOptions { - voiceAudioUrl: string | undefined - voiceDurationHint: number | undefined - muted: boolean - isPlaying: boolean - currentTime: number - onVoiceDurationChange: (d: number) => void - onEnded: () => void -} - -interface UsePreviewAudioReturn { - seekTo: (time: number) => void - ensurePlayingAt: (time: number) => void - pause: () => void -} - -/** - * 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、 - * ended 事件回调、seek 同步、末帧冻结期间续播。 - */ -export function usePreviewAudio({ - voiceAudioUrl, - voiceDurationHint, - muted, - isPlaying, - currentTime, - onVoiceDurationChange, - onEnded, -}: UsePreviewAudioOptions): UsePreviewAudioReturn { - const audioRef = useRef(null) - const prevIsPlayingRef = useRef(false) - - // 外部 hint 初始化(自测值前的兜底) - useEffect(() => { - if (voiceDurationHint && voiceDurationHint > 0) { - onVoiceDurationChange(voiceDurationHint) - } - }, [voiceDurationHint, onVoiceDurationChange]) - - // 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended - useEffect(() => { - if (!voiceAudioUrl) { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current.src = "" - audioRef.current = null - } - return - } - if (!audioRef.current) { - audioRef.current = new Audio() - audioRef.current.preload = "auto" - } - if (audioRef.current.src !== voiceAudioUrl) { - audioRef.current.src = voiceAudioUrl - } - audioRef.current.muted = muted - - const audio = audioRef.current - const onLoaded = () => { - if (audio.duration && isFinite(audio.duration) && audio.duration > 0) { - onVoiceDurationChange(audio.duration) - } - } - const onEndedHandler = () => onEnded() - audio.addEventListener("loadedmetadata", onLoaded) - audio.addEventListener("ended", onEndedHandler) - return () => { - audio.removeEventListener("loadedmetadata", onLoaded) - audio.removeEventListener("ended", onEndedHandler) - } - }, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded]) - - // mute 变化即时同步 - useEffect(() => { - if (audioRef.current) audioRef.current.muted = muted - }, [muted]) - - // 播放/暂停同步(跟随视频 isPlaying) - useEffect(() => { - const audio = audioRef.current - if (!audio || !audio.src) return - if (isPlaying && !prevIsPlayingRef.current) { - if (Math.abs(audio.currentTime - currentTime) > 0.3) { - try { - audio.currentTime = currentTime - } catch { - /* ignore */ - } - } - audio.play().catch(() => {}) - } else if (!isPlaying && prevIsPlayingRef.current) { - audio.pause() - } - prevIsPlayingRef.current = isPlaying - }, [isPlaying, currentTime]) - - const seekTo = useCallback((time: number) => { - const audio = audioRef.current - if (audio && audio.src) { - try { - audio.currentTime = time - } catch { - /* ignore */ - } - } - }, []) - - const ensurePlayingAt = useCallback((time: number) => { - const audio = audioRef.current - if (!audio || !audio.src) return - try { - if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time - if (audio.paused) audio.play().catch(() => {}) - } catch { - /* ignore */ - } - }, []) - - const pause = useCallback(() => { - try { - audioRef.current?.pause() - } catch { - /* ignore */ - } - }, []) - - return { seekTo, ensurePlayingAt, pause } -} diff --git a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts deleted file mode 100644 index 5e0676693..000000000 --- a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * 素材片段调度器 Hook(多 video 元素方案 v3) - * - * v3 修复: - * - 所有动态状态存入 ref,tick 为稳定函数,彻底消除 RAF 闭包陷阱 - * - 片段切换时先启动下一个 video 再切可见性,消除冻屏间隔 - * - 进度更新 200ms 节流 - */ - -import { useState, useRef, useCallback, useEffect, useMemo } from "react" - -export interface PlaybackSegment { - assetId: string - videoUrl: string - startTime: number - endTime: number - order: number - /** #1754 兜底:配音时长≠clips 总时长时按比例调速,1.0 = 原速 */ - playbackRate?: number -} - -export interface SegmentSchedulerState { - isPlaying: boolean - currentTime: number - totalDuration: number - currentSegmentIndex: number - segmentLocalTime: number - isEnded: boolean - canPlay: boolean - play: () => void - pause: () => void - togglePlayPause: () => void - seekTo: (time: number) => void - videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]> -} - -function findSegmentAtTime( - segments: PlaybackSegment[], - globalTime: number, -): { index: number; localTime: number } { - let accumulated = 0 - for (let i = 0; i < segments.length; i++) { - const seg = segments[i] - const segDuration = seg.endTime - seg.startTime - if (globalTime < accumulated + segDuration || i === segments.length - 1) { - return { index: i, localTime: seg.startTime + (globalTime - accumulated) } - } - accumulated += segDuration - } - return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime } -} - -function buildTimeline(segments: PlaybackSegment[]): number[] { - const starts: number[] = [] - let acc = 0 - for (const seg of segments) { - starts.push(acc) - acc += seg.endTime - seg.startTime - } - return starts -} - -export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState { - const videoRefs = useRef<(HTMLVideoElement | null)[]>([]) - const [isPlaying, setIsPlaying] = useState(false) - const [currentTime, setCurrentTime] = useState(0) - const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0) - const [isEnded, setIsEnded] = useState(false) - const rafRef = useRef(0) - const isSeekingRef = useRef(false) - const lastTimeUpdateRef = useRef(0) - - // 所有动态值存入 ref,tick 始终读取最新值,不依赖闭包 - const segIdxRef = useRef(0) - const segmentsRef = useRef(segments) - const timelineStartsData = useMemo(() => buildTimeline(segments), [segments]) - const totalDurationData = useMemo( - () => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0), - [segments], - ) - const timelineStartsRef = useRef(timelineStartsData) - const totalDurationRef = useRef(totalDurationData) - const isPlayingRef = useRef(false) - - segmentsRef.current = segments - timelineStartsRef.current = timelineStartsData - totalDurationRef.current = totalDurationData - - const canPlay = segments.length > 0 - - useEffect(() => { - segIdxRef.current = currentSegmentIndex - }, [currentSegmentIndex]) - - useEffect(() => { - isPlayingRef.current = isPlaying - }, [isPlaying]) - - const waitForReady = useCallback((video: HTMLVideoElement, timeout = 3000): Promise => { - if (video.readyState >= 3) return Promise.resolve() - return new Promise((resolve) => { - const onCanPlay = () => { - video.removeEventListener("canplay", onCanPlay) - clearTimeout(timer) - resolve() - } - const timer = setTimeout(() => { - video.removeEventListener("canplay", onCanPlay) - resolve() - }, timeout) - video.addEventListener("canplay", onCanPlay) - }) - }, []) - - const switchToSegment = useCallback( - async (index: number, seekToLocalTime?: number) => { - const segs = segmentsRef.current - const video = videoRefs.current[index] - if (!video || index >= segs.length) return - - const seg = segs[index] - const localTime = seekToLocalTime ?? seg.startTime - const oldIdx = segIdxRef.current - const oldVideo = videoRefs.current[oldIdx] - - if (oldVideo && oldVideo !== video) oldVideo.pause() - - if (!video.src && seg.videoUrl) { - video.src = seg.videoUrl - video.load() - } - - if (Math.abs(video.currentTime - localTime) > 0.05) { - video.currentTime = localTime - } - // #1754:按比例调速(配音时长≠clips 总时长时的前端兜底) - const rate = seg.playbackRate || 1 - if (Math.abs(video.playbackRate - rate) > 0.01) { - video.playbackRate = rate - } - - segIdxRef.current = index - setCurrentSegmentIndex(index) - - await waitForReady(video) - }, - [waitForReady], - ) - - // 稳定的 tick 函数,空依赖,所有值从 ref 读取 - const tick = useCallback(() => { - const segs = segmentsRef.current - const idx = segIdxRef.current - const video = videoRefs.current[idx] - - if (!video || isSeekingRef.current) { - rafRef.current = requestAnimationFrame(tick) - return - } - - const seg = segs[idx] - if (!seg) return - - // 预加载下一个片段 - const nextIndex = idx + 1 - if (nextIndex < segs.length) { - const nextVideo = videoRefs.current[nextIndex] - if (nextVideo) { - const timeToEnd = seg.endTime - video.currentTime - if (timeToEnd <= 2 && nextVideo.readyState < 3) { - const nextSeg = segs[nextIndex] - if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.5) { - nextVideo.currentTime = nextSeg.startTime - } - } - } - } - - // 检测片段边界 - if (video.currentTime >= seg.endTime - 0.1) { - if (nextIndex < segs.length) { - const nextVideo = videoRefs.current[nextIndex] - const nextSeg = segs[nextIndex] - const accumulatedTime = - (timelineStartsRef.current[idx] || 0) + (seg.endTime - seg.startTime) - - if (nextVideo) { - if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.1) { - nextVideo.currentTime = nextSeg.startTime - } - // 先启动下一个视频(muted,可安全同时播放) - nextVideo - .play() - .catch((e) => console.warn("[useSegmentScheduler] next segment play failed:", e)) - } - - // 立即切换可见性 - segIdxRef.current = nextIndex - setCurrentSegmentIndex(nextIndex) - setCurrentTime(accumulatedTime) - lastTimeUpdateRef.current = 0 - setIsPlaying(true) - - // 下一帧暂停旧视频(让新视频先渲染,避免冻屏) - const oldVideo = video - requestAnimationFrame(() => { - oldVideo.pause() - }) - - rafRef.current = requestAnimationFrame(tick) - return - } else { - video.pause() - setIsPlaying(false) - setIsEnded(true) - setCurrentTime(totalDurationRef.current) - return - } - } - - const globalTime = (timelineStartsRef.current[idx] || 0) + (video.currentTime - seg.startTime) - const now = performance.now() - if (now - lastTimeUpdateRef.current >= 200) { - lastTimeUpdateRef.current = now - setCurrentTime(Math.max(0, Math.min(globalTime, totalDurationRef.current))) - } - - rafRef.current = requestAnimationFrame(tick) - }, []) - - const play = useCallback(async () => { - if (!canPlay) return - setIsEnded(false) - const idx = segIdxRef.current - const video = videoRefs.current[idx] - if (!video) return - - if (idx === 0 && video.readyState < 2) { - if (!video.src && segmentsRef.current[0]?.videoUrl) { - video.src = segmentsRef.current[0].videoUrl - video.load() - } - await waitForReady(video) - } - - // 播放前 seek 到片段起始时间,确保 progress 计算正确 - const seg = segmentsRef.current[idx] - if (seg && Math.abs(video.currentTime - seg.startTime) > 0.1) { - video.currentTime = seg.startTime - } - - // #1754:调速 - const rate = seg?.playbackRate || 1 - if (Math.abs(video.playbackRate - rate) > 0.01) { - video.playbackRate = rate - } - - try { - await video.play() - setIsPlaying(true) - cancelAnimationFrame(rafRef.current) - rafRef.current = requestAnimationFrame(tick) - } catch (err) { - console.warn("[useSegmentScheduler] 播放失败:", err) - } - }, [canPlay, waitForReady, tick]) - - const pause = useCallback(() => { - const video = videoRefs.current[segIdxRef.current] - if (video) video.pause() - setIsPlaying(false) - cancelAnimationFrame(rafRef.current) - }, []) - - const togglePlayPause = useCallback(() => { - if (isPlayingRef.current) { - pause() - } else { - if (isEnded) { - setIsEnded(false) - lastTimeUpdateRef.current = 0 - const firstVideo = videoRefs.current[0] - if (firstVideo) { - videoRefs.current.forEach((v, i) => { - if (v && i !== 0) v.pause() - }) - firstVideo.currentTime = segmentsRef.current[0]?.startTime || 0 - segIdxRef.current = 0 - setCurrentSegmentIndex(0) - setCurrentTime(0) - firstVideo - .play() - .then(() => { - setIsPlaying(true) - cancelAnimationFrame(rafRef.current) - rafRef.current = requestAnimationFrame(tick) - }) - .catch((e) => console.warn("[useSegmentScheduler] restart failed:", e)) - } - } else { - play() - } - } - }, [isEnded, pause, play, tick]) - - const seekTo = useCallback( - async (time: number) => { - if (!canPlay) return - const clampedTime = Math.max(0, Math.min(time, totalDurationRef.current)) - const { index, localTime } = findSegmentAtTime(segmentsRef.current, clampedTime) - - isSeekingRef.current = true - cancelAnimationFrame(rafRef.current) - - if (index !== segIdxRef.current) { - await switchToSegment(index, localTime) - } else { - const video = videoRefs.current[index] - if (video) video.currentTime = localTime - } - - setCurrentTime(clampedTime) - setIsEnded(false) - lastTimeUpdateRef.current = 0 - - if (isPlayingRef.current) { - const video = videoRefs.current[index] - if (video) { - video.play().catch(() => {}) - } - rafRef.current = requestAnimationFrame(tick) - } - - setTimeout(() => { - isSeekingRef.current = false - }, 200) - }, - [canPlay, switchToSegment, tick], - ) - - useEffect(() => { - videoRefs.current = videoRefs.current.slice(0, segments.length) - while (videoRefs.current.length < segments.length) { - videoRefs.current.push(null) - } - }, [segments]) - - useEffect(() => { - return () => { - cancelAnimationFrame(rafRef.current) - } - }, []) - - useEffect(() => { - cancelAnimationFrame(rafRef.current) - segIdxRef.current = 0 - setIsPlaying(false) - setCurrentTime(0) - setCurrentSegmentIndex(0) - setIsEnded(false) - }, [segments]) - - const currentSegment = segments[currentSegmentIndex] || null - const segmentLocalTime = currentSegment - ? currentTime - (timelineStartsRef.current[currentSegmentIndex] || 0) + currentSegment.startTime - : 0 - - return { - isPlaying, - currentTime, - totalDuration: totalDurationData, - currentSegmentIndex, - segmentLocalTime, - isEnded, - canPlay, - play, - pause, - togglePlayPause, - seekTo, - videoRefs, - } -} - -export default useSegmentScheduler diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts index ba7baa2f6..3f0e234dc 100644 --- a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts +++ b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts @@ -1,9 +1,8 @@ -import { useEffect, useRef } from "react" +import { useEffect } from "react" import { useQuery } from "@tanstack/react-query" // #1894: 标题候选从文案库 scripts[].title 获取,不再调用废弃的 /api/titles import { getScripts } from "@/api/scripts" import type { TitleSettings } from "../../types" -import { useAiTitleGenerator } from "./useAiTitleGenerator" import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters" import { useDraftAutoSave } from "../useDraftAutoSave" @@ -16,7 +15,7 @@ interface UseStep4TitleProps { /** * Step 4 标题设置 Hook - * 封装 AI 标题生成、标题样式设置等逻辑 + * 封装标题样式设置等逻辑 */ export function useStep4Title({ titleSettings, @@ -34,26 +33,9 @@ export function useStep4Title({ staleTime: 30_000, }) - // AI 标题生成 - const { - aiTitleInput, - setAiTitleInput, - aiTitleGenerating, - aiTitleResults, - hasGeneratedTitles, - handleGenerateAiTitles, - handleSelectAiTitle, - handleRefreshAiTitles, - autoGenerateTitle, - } = useAiTitleGenerator({ titleSettings, onTitleSettingsChange }) - // 样式更新 const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange }) - // 追踪 AI 自动选择开关的上一次值 & 是否首次挂载 - const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect) - const isFirstMount = useRef(true) - /* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */ const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate) useEffect(() => { @@ -86,40 +68,12 @@ export function useStep4Title({ scheduleTitleSave, ]) - // 当 AI 自动选择开关打开时,自动生成/选择一个标题填入 - // 首次挂载时如果开关已经是 true 且无标题,也需要触发 - useEffect(() => { - if (isFirstMount.current) { - isFirstMount.current = false - if (titleSettings.aiAutoSelect && !titleSettings.title) { - autoGenerateTitle() - } - prevAiAutoSelect.current = titleSettings.aiAutoSelect - return - } - if (titleSettings.aiAutoSelect && !prevAiAutoSelect.current && !titleSettings.title) { - autoGenerateTitle() - } - prevAiAutoSelect.current = titleSettings.aiAutoSelect - }, [titleSettings.aiAutoSelect, titleSettings.title, autoGenerateTitle]) - return { // 数据 userTitles, titleSettings, - // AI 标题状态 - aiTitleInput, - setAiTitleInput, - aiTitleGenerating, - aiTitleResults, - hasGeneratedTitles, activePreset: styleUpdaters.activePreset, titlePresets: styleUpdaters.titlePresets, - // AI 标题操作 - handleGenerateAiTitles, - handleSelectAiTitle, - handleRefreshAiTitles, - autoGenerateTitle, // 标题设置操作 updateTitle: styleUpdaters.updateTitle, toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect, diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts b/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts deleted file mode 100644 index b765259a6..000000000 --- a/apps/web/src/pages/generate/hooks/useStep4Title/useAiTitleGenerator.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { useState, useCallback } from "react" -import { message } from "antd" -import { AI_TITLE_TEMPLATES } from "../../constants" -import type { TitleSettings } from "../../types" - -export interface AiTitleItem { - title: string - highlight: string - style: "catchy" | "emotional" | "informative" -} - -interface UseAiTitleGeneratorOptions { - titleSettings: TitleSettings - onTitleSettingsChange: (settings: TitleSettings) => void -} - -/** - * AI 标题生成 Hook - * 封装 AI 标题生成、刷新、选择等逻辑 - */ -export function useAiTitleGenerator({ - titleSettings, - onTitleSettingsChange, -}: UseAiTitleGeneratorOptions) { - const [aiTitleInput, setAiTitleInput] = useState("") - const [aiTitleGenerating, setAiTitleGenerating] = useState(false) - const [aiTitleResults, setAiTitleResults] = useState([]) - const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false) - - const extractTopic = (text: string): string => { - const keywords = text - .replace(/[,。!?、,.!?]/g, " ") - .split(/\s+/) - .filter(Boolean) - if (keywords.length === 0) return "这个话题" - return keywords.slice(0, 3).join("") - } - - const generateTitlesFromTopic = (topic: string): AiTitleItem[] => { - const results: AiTitleItem[] = [] - const styles: Array<"catchy" | "emotional" | "informative"> = [ - "catchy", - "emotional", - "informative", - ] - const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" } - styles.forEach((style) => { - const templates = AI_TITLE_TEMPLATES[style] - const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) - shuffled.forEach((tpl) => { - results.push({ - title: tpl.replace(/\{topic\}/g, topic), - highlight: highlights[style], - style, - }) - }) - }) - results.sort(() => Math.random() - 0.5) - return results - } - - const handleGenerateAiTitles = useCallback(async () => { - if (!aiTitleInput.trim()) { - message.warning("请先输入视频描述或关键词") - return - } - setAiTitleGenerating(true) - setHasGeneratedTitles(true) - await new Promise((resolve) => setTimeout(resolve, 1200)) - const topic = extractTopic(aiTitleInput) - setAiTitleResults(generateTitlesFromTopic(topic)) - setAiTitleGenerating(false) - }, [aiTitleInput]) - - /** - * 自动生成标题(供 AI 自动选择开关使用) - * 如果已有生成结果,直接从中选一个;否则用默认关键词生成 - */ - const autoGenerateTitle = useCallback((): string => { - if (aiTitleResults.length > 0) { - const picked = aiTitleResults[Math.floor(Math.random() * aiTitleResults.length)] - if (!picked) return "" - onTitleSettingsChange({ ...titleSettings, title: picked.title }) - return picked.title - } - // 没有已有结果,用默认关键词生成 - const results = generateTitlesFromTopic("短视频") - setAiTitleResults(results) - setHasGeneratedTitles(true) - const picked = results[Math.floor(Math.random() * results.length)] - if (!picked) return "" - onTitleSettingsChange({ ...titleSettings, title: picked.title }) - return picked.title - }, [aiTitleResults, titleSettings, onTitleSettingsChange]) - - const handleSelectAiTitle = useCallback( - (title: string) => { - onTitleSettingsChange({ ...titleSettings, title }) - message.success("已选用此标题") - }, - [titleSettings, onTitleSettingsChange], - ) - - const handleRefreshAiTitles = useCallback(async () => { - if (!aiTitleInput.trim()) return - setAiTitleGenerating(true) - await new Promise((resolve) => setTimeout(resolve, 800)) - const topic = extractTopic(aiTitleInput) - setAiTitleResults(generateTitlesFromTopic(topic)) - setAiTitleGenerating(false) - }, [aiTitleInput]) - - return { - aiTitleInput, - setAiTitleInput, - aiTitleGenerating, - aiTitleResults, - hasGeneratedTitles, - handleGenerateAiTitles, - handleSelectAiTitle, - handleRefreshAiTitles, - autoGenerateTitle, - } -} diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts index 5a7934c87..c5965bf8a 100644 --- a/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts +++ b/apps/web/src/pages/generate/hooks/useStep4Title/useTitleStyleUpdaters.ts @@ -1,5 +1,7 @@ import { useCallback, useMemo } from "react" import { TITLE_PRESETS } from "../../constants" +import { TITLE_PRESETS as NEW_TITLE_PRESETS } from "@/components/title/constants" +import { titleStyleConfigToCamel } from "@/components/title/utils" import type { TitleSettings } from "../../types" interface UseTitleStyleUpdatersOptions { @@ -15,8 +17,24 @@ export function useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange, }: UseTitleStyleUpdatersOptions) { - /** 匹配预设:只用 color/bold/italic/stroke/shadow,不再匹配 size */ + /** 匹配预设:对新预设(snake→camel 后)全字段比;旧预设只比 color/bold/italic/stroke/shadow */ const getActivePreset = (settings: TitleSettings): string | null => { + // 新预设匹配:font/size/color/bold/stroke/shadow/bg 全部对齐才算命中 + for (const p of NEW_TITLE_PRESETS) { + const camel = titleStyleConfigToCamel(p.style) + if ( + (camel.font ?? null) === (settings.font ?? null) && + (camel.color ?? null) === (settings.color ?? null) && + (camel.bold ?? null) === (settings.bold ?? null) && + (camel.italic ?? null) === (settings.italic ?? null) && + (camel.stroke ?? null) === (settings.stroke ?? null) && + (camel.shadow ?? null) === (settings.shadow ?? null) && + (camel.bgEnabled ?? null) === (settings.bgEnabled ?? null) + ) { + return p.key + } + } + // fallback 旧预设(legacy) for (const p of TITLE_PRESETS) { if ( settings.color === p.style.color && @@ -90,33 +108,95 @@ export function useTitleStyleUpdaters({ }, [titleSettings, onTitleSettingsChange]) const toggleStroke = useCallback(() => { - onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke }) + onTitleSettingsChange({ + ...titleSettings, + stroke: !titleSettings.stroke, + // 开启描边时若宽度过小给个默认值(让滑块可见可调) + strokeWidth: + !titleSettings.stroke && (titleSettings.strokeWidth ?? 0) < 2 + ? 4 + : titleSettings.strokeWidth, + }) }, [titleSettings, onTitleSettingsChange]) const toggleShadow = useCallback(() => { onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow }) }, [titleSettings, onTitleSettingsChange]) - /** 应用预设:只覆盖 color/bold/italic/stroke/shadow,不改变字号 */ + /** 应用预设:正确把 snake_case 的 preset.style 转为 camelCase 再 spread */ const applyPreset = useCallback( (presetKey: string) => { - const preset = TITLE_PRESETS.find((p) => p.key === presetKey) - if (!preset) return + const newPreset = NEW_TITLE_PRESETS.find((p) => p.key === presetKey) + const oldPreset = TITLE_PRESETS.find((p) => p.key === presetKey) + if (newPreset) { + onTitleSettingsChange({ + ...titleSettings, + ...titleStyleConfigToCamel(newPreset.style), + // 封面独立标题保持不变(不清空,避免破坏封面定制) + lineOverrides: [], + }) + return + } + if (!oldPreset) return onTitleSettingsChange({ ...titleSettings, - color: preset.style.color, - bold: preset.style.bold, - italic: preset.style.italic, - stroke: preset.style.stroke, - shadow: preset.style.shadow, + color: oldPreset.style.color as string, + bold: oldPreset.style.bold as boolean, + italic: oldPreset.style.italic as boolean, + stroke: oldPreset.style.stroke as boolean, + shadow: oldPreset.style.shadow as boolean, }) }, [titleSettings, onTitleSettingsChange], ) + /** 应用模板:用模板(camelCase TitleSettings)覆盖样式字段,保留 title/aiAutoSelect */ + const applyTemplate = useCallback( + (tpl: TitleSettings) => { + onTitleSettingsChange({ + ...titleSettings, + // 覆盖样式字段 + position: tpl.position, + posX: tpl.posX, + posY: tpl.posY, + font: tpl.font, + size: tpl.size, + bold: tpl.bold, + italic: tpl.italic, + stroke: tpl.stroke, + shadow: tpl.shadow, + color: tpl.color, + lineHeight: tpl.lineHeight, + marginTop: tpl.marginTop, + maxCharsPerLine: tpl.maxCharsPerLine, + strokeWidth: tpl.strokeWidth, + strokeColor: tpl.strokeColor, + shadowOffsetX: tpl.shadowOffsetX, + shadowOffsetY: tpl.shadowOffsetY, + shadowBlur: tpl.shadowBlur, + shadowColor: tpl.shadowColor, + bgEnabled: tpl.bgEnabled, + bgColor: tpl.bgColor, + bgPadding: tpl.bgPadding, + bgRadius: tpl.bgRadius, + lineOverrides: [], + // coverTitle 保留用户当前值,不强制覆盖 + }) + }, + [titleSettings, onTitleSettingsChange], + ) + + /** 通用字段更新(patch) */ + const updateStyle = useCallback( + (patch: Partial) => { + onTitleSettingsChange({ ...titleSettings, ...patch }) + }, + [titleSettings, onTitleSettingsChange], + ) + return { activePreset, - titlePresets: TITLE_PRESETS, + titlePresets: NEW_TITLE_PRESETS, updateTitle, toggleAiAutoSelect, updatePosition, @@ -129,5 +209,7 @@ export function useTitleStyleUpdaters({ toggleStroke, toggleShadow, applyPreset, + applyTemplate, + updateStyle, } } diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index cf3bda235..c92528af7 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -19,7 +19,7 @@ import { interface UseStep6CoverProps { coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void - /** 当前选中的模板 ID */ + /** 当前选中的视频模板 ID(封面模板由本地 state 管理) */ selectedTemplate?: string /** Step4 标题设置,用于封面叠加标题 */ titleSettings?: TitleSettings @@ -30,7 +30,7 @@ interface UseStep6CoverProps { export function useStep6Cover({ coverSettings, onCoverSettingsChange, - selectedTemplate = "", + selectedTemplate: _selectedTemplate = "", titleSettings, generatedVideos, }: UseStep6CoverProps) { @@ -48,7 +48,11 @@ export function useStep6Cover({ const [templatesError, setTemplatesError] = useState(null) /** 最终成片:取第一个已完成视频 */ - const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0] + const finalVideo = + generatedVideos.find( + (v) => + v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover", + ) || generatedVideos[0] /** 从后端加载封面模板列表 */ const loadTemplates = useCallback(async () => { @@ -79,7 +83,8 @@ export function useStep6Cover({ return } - if (!selectedTemplate) { + const activeCoverTemplateId = selectedTemplateId || "default" + if (!activeCoverTemplateId) { message.error("请先选择模板") return } @@ -95,7 +100,7 @@ export function useStep6Cover({ }, 300000) try { - const response = await generateCover(selectedTemplate, { + const response = await generateCover(activeCoverTemplateId, { generated_video_id: finalVideo.id, video_url: finalVideo.file_url || finalVideo.download_url || "", cover_type: "ai_frame", @@ -156,7 +161,7 @@ export function useStep6Cover({ setGenerating(false) } }, [ - selectedTemplate, + selectedTemplateId, finalVideo, coverSettings, onCoverSettingsChange, @@ -170,7 +175,18 @@ export function useStep6Cover({ }, []) const handleEditTemplate = useCallback((tpl: CoverTemplate) => { - setEditingTemplate(tpl) + // 系统模板不可修改:复制为新模板草稿,走"另存为"流程 + if (tpl.is_system) { + setEditingTemplate({ + ...tpl, + id: "", + name: tpl.name + " 副本", + is_system: false, + created_at: "", + }) + } else { + setEditingTemplate(tpl) + } setShowCoverEditor(true) }, []) @@ -178,20 +194,25 @@ export function useStep6Cover({ const handleSaveTemplate = useCallback( async (tpl: CoverTemplate) => { try { - if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) { + // 系统模板或无 id(新建/副本)→ 走创建分支;否则走更新 + const isSystem = coverTemplates.find((t) => t.id === tpl.id)?.is_system === true + const shouldCreate = !tpl.id || isSystem + if (shouldCreate) { + const created = await createCoverTemplate({ + name: tpl.name || "我的封面模板", + config: tpl.config, + }) + setCoverTemplates((prev) => [...prev, created]) + setSelectedTemplateId(created.id || tpl.id) + } else { const updated = await updateCoverTemplate(tpl.id, { name: tpl.name, config: tpl.config, }) setCoverTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t))) - } else { - const created = await createCoverTemplate({ - name: tpl.name, - config: tpl.config, - }) - setCoverTemplates((prev) => [...prev, created]) } setShowCoverEditor(false) + setEditingTemplate(null) } catch (err) { console.error("[Step6] 保存模板失败:", err) } diff --git a/apps/web/src/pages/generate/hooks/useStepNavigation.ts b/apps/web/src/pages/generate/hooks/useStepNavigation.ts index 9442bc7f1..d33928235 100644 --- a/apps/web/src/pages/generate/hooks/useStepNavigation.ts +++ b/apps/web/src/pages/generate/hooks/useStepNavigation.ts @@ -4,7 +4,7 @@ * * - 步骤1(选择模式):下一步分支由外层弹窗处理(VoiceSelectModal / ScriptSelectModal), * 本 hook 的 goNext 仅在未选模式时拦截;外层 Modal onConfirm 里主动 setCurrentStep(2)。 - * - 步骤2(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3。 + * - 步骤2(选择素材):直接进入步骤3,数组长度对齐由 onBeforeEnterStep3 保证。 * - 步骤3 底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate), * 创建成功后跳步骤4;本 hook 的 goNext 只负责 2→3 和 4→5 的「下一步」。 * - 步骤4(确认生成进度页):全部渲染完成后「下一步」解锁进封面。 @@ -23,10 +23,10 @@ export interface UseStepNavigationOptions { titleSettings: TitleSettings /** 是否已完成视频生成(步骤4全部渲染完成后才能进入封面) */ generated: boolean - /** 点素材下一步时弹出数量选择弹窗 */ - onOpenCountModal: () => void /** 步骤1下一步:根据 editMode 打开对应弹窗(随机→配音 / 叙事→文案) */ onOpenStep1Modal: () => void + /** 进入步骤3前自动对齐数组(previewTitles/voiceLibraryIds/previewCovers/selectedVariantIds)长度到 previewCount */ + onBeforeEnterStep3?: () => void } export interface UseStepNavigationReturn { @@ -42,8 +42,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav selectedMaterials, smartSelectedIds, generated, - onOpenCountModal, onOpenStep1Modal, + onBeforeEnterStep3, } = options const goNext = () => { @@ -62,8 +62,9 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav message.warning("请先进行智能匹配并选择素材") return } - // 弹数量选择弹窗 - onOpenCountModal() + // 直接进入步骤3(生成数量在 Step1 已设置);对齐数组长度 + onBeforeEnterStep3?.() + setCurrentStep(3) return } // 步骤4(确认生成):全部渲染完成后才能下一步进封面 diff --git a/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts b/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts deleted file mode 100644 index 5eb6adc19..000000000 --- a/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * 批量变体配音预览音频解析(#1750) - * - * 独立配音模式下每个变体挂载各自的配音 URL: - * - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL; - * - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成; - * - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。 - * - * N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。 - */ -import { useEffect, useRef, useState } from "react" -import { useQuery } from "@tanstack/react-query" -import { getAssetsByKind } from "@/api/assets" -import type { AssetItem } from "@/api/assets" -import { previewTts } from "@/api/tts" - -interface UseVariantVoicePreviewOptions { - /** 是否批量模式(count>1) */ - enabled: boolean - /** 变体数量 */ - count: number - /** 是否每个视频独立配音 */ - perVideo: boolean - /** 共用配音 ID(独立模式下为变体 0 的配音) */ - sharedVoiceId: string - /** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice) */ - clonedVoiceId?: string - /** 各变体独立配音 ID(独立模式);长度不足时回退共用 */ - variantVoiceIds: string[] - /** 各变体标题(TTS 合成文案源) */ - titles: string[] -} - -/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */ -const EMPTY_VOICE_MATERIALS: AssetItem[] = [] - -/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */ -function findMaterialUrl(id: string, materials: AssetItem[]): string | null { - if (!id) return null - const m = materials.find((x) => x.id === id) - return m?.file_url || null -} - -export function useVariantVoicePreview({ - enabled, - count, - perVideo, - sharedVoiceId, - clonedVoiceId = "", - variantVoiceIds, - titles, -}: UseVariantVoicePreviewOptions): (string | null)[] { - const [urls, setUrls] = useState<(string | null)[]>([]) - // 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发 - const { data: voiceMaterialsData } = useQuery({ - queryKey: ["assets", "voice"], - queryFn: () => getAssetsByKind("voice", { limit: 50 }), - }) - const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS - // 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成 - const ttsCacheRef = useRef>(new Map()) - // 在途请求 AbortController - const abortRef = useRef(null) - const seqRef = useRef(0) - - useEffect(() => { - if (!enabled || count <= 1) { - setUrls((prev) => (prev.length === 0 ? prev : [])) - return - } - - const seq = ++seqRef.current - abortRef.current?.abort() - const controller = new AbortController() - abortRef.current = controller - - // 每个变体实际使用的配音 ID - const voiceIds = Array.from({ length: count }, (_, i) => - perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId, - ) - - // 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成 - const result: (string | null)[] = voiceIds.map((id, i) => { - const materialUrl = findMaterialUrl(id, voiceMaterials) - if (materialUrl) return materialUrl - // 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致) - if (!perVideo && i === 0 && clonedVoiceId) { - return findMaterialUrl(clonedVoiceId, voiceMaterials) - } - return null - }) - - // 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID - const ttsJobs: { index: number; voiceId: string; title: string }[] = [] - voiceIds.forEach((id, i) => { - if (result[i]) return - // 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice - const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id - const title = titles[i] || "" - if (!ttsVoiceId || !title) return - ttsJobs.push({ index: i, voiceId: ttsVoiceId, title }) - }) - - setUrls((prev) => - prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result, - ) - - if (ttsJobs.length === 0) return - - let cancelled = false - void (async () => { - // 串行 TTS,避免瞬间 N 个合成请求打爆后端 - for (const job of ttsJobs) { - const cacheKey = `${job.voiceId}|${job.title}` - const cached = ttsCacheRef.current.get(cacheKey) - if (cached) { - if (seq === seqRef.current) { - setUrls((prev) => { - if (prev[job.index] === cached) return prev - const next = [...prev] - next[job.index] = cached - return next - }) - } - continue - } - try { - const res = await previewTts({ text: job.title, voice_id: job.voiceId }) - if (cancelled || controller.signal.aborted || seq !== seqRef.current) return - const audioUrl = res.audio_url || "" - if (audioUrl) { - ttsCacheRef.current.set(cacheKey, audioUrl) - setUrls((prev) => { - if (prev[job.index] === audioUrl) return prev - const next = [...prev] - next[job.index] = audioUrl - return next - }) - } - } catch (err) { - if (cancelled || controller.signal.aborted || seq !== seqRef.current) return - console.warn(`[变体${job.index + 1}预览配音生成失败]`, err) - } - } - })() - - return () => { - cancelled = true - controller.abort() - } - }, [ - enabled, - count, - perVideo, - sharedVoiceId, - clonedVoiceId, - variantVoiceIds, - titles, - voiceMaterials, - ]) - - return urls -} - -export default useVariantVoicePreview diff --git a/apps/web/src/pages/generate/types.ts b/apps/web/src/pages/generate/types.ts index b279973aa..f44396b92 100644 --- a/apps/web/src/pages/generate/types.ts +++ b/apps/web/src/pages/generate/types.ts @@ -3,8 +3,9 @@ */ import type { AssetItem } from "@/api/assets" +import type { TitleLineOverride } from "@/components/title/types" -/* ── 标题设置 ── */ +/* ── 标题设置(#2001 升级:新增描边/阴影/背景/逐行/封面独立样式/排版字段) ── */ export interface TitleSettings { aiAutoSelect: boolean title: string @@ -19,6 +20,58 @@ export interface TitleSettings { /** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */ posX: number | null posY: number | null + + /* ── 排版(P0) ── */ + /** 行距倍数,默认 1.2 */ + lineHeight: number + /** 顶部边距(position=top,px @720p) */ + marginTop: number + /** 每行最大字符数(4-20),0=不自动换行 */ + maxCharsPerLine: number + + /* ── 描边参数化(P0) ── */ + strokeWidth: number + strokeColor: string + + /* ── 阴影参数化(P1) ── */ + shadowOffsetX: number + shadowOffsetY: number + shadowBlur: number + shadowColor: string + + /* ── 背景色块(P1) ── */ + bgEnabled: boolean + bgColor: string + bgPadding: number + bgRadius: number + + /* ── 逐行独立样式(P1) ── */ + lineOverrides: TitleLineOverride[] + + /* ── 封面独立标题(P1):null=沿用主标题 ── */ + coverTitle: null | { + title?: string + font?: string + size?: number + color?: string + bold?: boolean + italic?: boolean + position?: string + stroke?: boolean + strokeWidth?: number + strokeColor?: string + shadow?: boolean + shadowOffsetX?: number + shadowOffsetY?: number + shadowBlur?: number + shadowColor?: string + bgEnabled?: boolean + bgColor?: string + bgPadding?: number + bgRadius?: number + lineHeight?: number + maxCharsPerLine?: number + } } /* ── 智能匹配结果 ── */ @@ -28,14 +81,6 @@ export interface SmartMatchResult { reasons: string[] } -/* ── AI 标题结果 ── */ -export interface AiTitleResult { - title: string - style: string - styleLabel: string - highlights: string[] -} - /* ── 配音推荐结果 ── */ export interface VoiceRecommendation { voiceId: string @@ -49,28 +94,33 @@ export interface StepDef { label: string } -/* ── 标题预设样式 ── */ -export interface TitlePresetStyle { - size: number - color: string - bold: boolean - italic: boolean - stroke: boolean - shadow: boolean -} - -export interface TitlePreset { - key: string - label: string - style: TitlePresetStyle - previewStyle: Record -} - -/* ── 生成结果视频 ── */ -export interface GeneratedVideoResult { - id: string - url: string - thumbnail: string - duration: number - title: string +/** 旧版 TitleSettings 的默认值字段(P0/P1 新字段补齐默认值) */ +export const DEFAULT_TITLE_SETTINGS_FULL: TitleSettings = { + aiAutoSelect: false, + title: "", + position: "top", + font: "思源黑体", + size: 28, + bold: true, + italic: false, + stroke: true, + shadow: false, + color: "#ffffff", + posX: null, + posY: null, + lineHeight: 1.2, + marginTop: 24, + maxCharsPerLine: 0, + strokeWidth: 4, + strokeColor: "#000000", + shadowOffsetX: 2, + shadowOffsetY: 2, + shadowBlur: 4, + shadowColor: "rgba(0,0,0,0.8)", + bgEnabled: false, + bgColor: "rgba(0,0,0,0.5)", + bgPadding: 12, + bgRadius: 8, + lineOverrides: [], + coverTitle: null, } diff --git a/apps/web/src/pages/generate/types/cover.ts b/apps/web/src/pages/generate/types/cover.ts index 08e10a14b..c52846c85 100644 --- a/apps/web/src/pages/generate/types/cover.ts +++ b/apps/web/src/pages/generate/types/cover.ts @@ -32,6 +32,279 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = { thumbnail_url: "", } +/** 文字方向 */ +export type TextDirection = "horizontal" | "vertical" + +/** 文字背景形状 */ +export type TextBgShape = "rectangle" | "polygon" + +/** 描边样式 */ +export type StrokeStyle = "solid" | "dashed" + +/** 阴影层 */ +export interface ShadowLayer { + color: string + offsetX: number + offsetY: number + blur: number +} + +/** 文字位置 */ +export interface TextPosition { + x: number + y: number +} + +/** 文字背景配置 */ +export interface TextBackground { + enabled: boolean + color: string + opacity: number + shape: TextBgShape + width: number + height: number + /** 相对文字的上下偏移(百分比),背景自动跟随文字位置 */ + offsetY: number +} + +/** 文字样式配置(主标题/副标题共用) */ +export interface TextStyleConfig { + text: string + fontFamily: string + fontSize: number + fontWeight: number + direction: TextDirection + charsPerLine: number + letterSpacing: number + lineHeight: number + color: string + strokeColor: string + strokeWidth: number + shadows: ShadowLayer[] + traditionalShadow: boolean + position: TextPosition + rotation: number + background: TextBackground +} + +/** 编辑器完整配置 */ +export interface CoverEditorConfig { + // 基础设置 + blurEnabled: boolean + blurAmount: number + personStrokeEnabled: boolean + personStrokeStyle: StrokeStyle + personStrokeColor: string + personStrokeWidth: number + autoSplitEnabled: boolean + titleMaxChars: number + subtitleMaxChars: number + + // 人像设置 + portraitEnabled: boolean + portraitSize: number + portraitPosition: TextPosition + portraitImage?: string + + // 背景设置 + backgroundEnabled: boolean + backgroundSize: number + backgroundPosition: TextPosition + backgroundImage?: string + backgroundColor?: string + + // 主标题 + title: TextStyleConfig + + // 副标题 + subtitle: TextStyleConfig + + // 蒙版 + maskEnabled: boolean + maskImage: string + maskSize: number + maskPosition: TextPosition + maskColor: string + maskOpacity: number + maskShape: string +} + +/** 默认主标题配置 */ +export const DEFAULT_TITLE_CONFIG: TextStyleConfig = { + text: "主标题文字", + fontFamily: "思源黑体", + fontSize: 120, + fontWeight: 700, + direction: "horizontal", + charsPerLine: 10, + letterSpacing: 24, + lineHeight: 144, + color: "#FFD700", + strokeColor: "#000000", + strokeWidth: 3, + shadows: [], + traditionalShadow: false, + position: { x: 50, y: 30 }, + rotation: 0, + background: { + enabled: false, + color: "#FFFFFF", + opacity: 25, + shape: "polygon", + width: 30, + height: 10, + offsetY: 0, + }, +} + +/** 默认副标题配置 */ +export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = { + text: "副标题文字", + fontFamily: "思源黑体", + fontSize: 82, + fontWeight: 500, + direction: "horizontal", + charsPerLine: 17, + letterSpacing: 23, + lineHeight: 72, + color: "#FFFFFF", + strokeColor: "#000000", + strokeWidth: 1, + shadows: [], + traditionalShadow: false, + position: { x: 50, y: 70 }, + rotation: 0, + background: { + enabled: true, + color: "#000000", + opacity: 70, + shape: "rectangle", + width: 100, + height: 20, + offsetY: 8, + }, +} + +/** 默认编辑器配置 */ +export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = { + blurEnabled: false, + blurAmount: 10, + personStrokeEnabled: false, + personStrokeStyle: "solid", + personStrokeColor: "#FFFFFF", + personStrokeWidth: 8, + autoSplitEnabled: false, + titleMaxChars: 4, + subtitleMaxChars: 10, + + portraitEnabled: false, + portraitSize: 50, + portraitPosition: { x: 50, y: 70 }, + + backgroundEnabled: true, + backgroundSize: 100, + backgroundPosition: { x: 50, y: 50 }, + + title: DEFAULT_TITLE_CONFIG, + subtitle: DEFAULT_SUBTITLE_CONFIG, + + maskEnabled: false, + maskImage: "", + maskSize: 100, + maskPosition: { x: 50, y: 50 }, + maskColor: "#000000", + maskOpacity: 40, + maskShape: "矩形", +} + +/** 预置字体(已与 @/components/title/constants 字体表保持一致;自定义商业字体兜底 Google Fonts 开源中文字体) */ +// 封面编辑器预置字体:与标题样式字体列表保持一致(从 @/components/title/constants 同步), +// 并补全西文常用系统字体,保证在中英文环境下都有可用字体。 +// 注:需要配合 index.html 引入的 Google Fonts(Noto Sans SC / ZCOOL / Ma Shan Zheng 等)。 +export interface CoverFont { + name: string + family: string + tag?: "preset" | "hand" | "serif" | "sans" | "mono" +} + +/** 预置中文字体(爆款/常用) */ +export const PRESET_FONTS: CoverFont[] = [ + { + name: "优设标题黑", + family: + '"YouSheBiaoTiHei","ZCOOL QingKe HuangYou","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "阿里普惠体Bold", + family: + '"Alibaba PuHuiTi","Alibaba Sans","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "抖音美好体", + family: + '"Douyin Sans","ZCOOL KuaiLe","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "思源黑体Heavy", + family: '"Noto Sans SC","Source Han Sans SC Heavy","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "思源黑体", + family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif', + tag: "preset", + }, + { + name: "思源宋体", + family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif', + tag: "serif", + }, + { name: "站酷小薇体", family: '"ZCOOL XiaoWei","Noto Serif SC",serif', tag: "preset" }, + { name: "马善政毛笔", family: '"Ma Shan Zheng","STXingkai","KaiTi",cursive', tag: "hand" }, + { name: "龙藏体", family: '"Long Cang","STXingkai",cursive', tag: "hand" }, + { name: "楷体", family: '"KaiTi","STKaiti","DFKai-SB",serif', tag: "serif" }, + { + name: "苹方", + family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif', + tag: "sans", + }, + { + name: "微软雅黑", + family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif', + tag: "sans", + }, +] + +/** 系统字体(西文 + 通用中文) */ +export const SYSTEM_FONTS: CoverFont[] = [ + { name: "Arial", family: "Arial, Helvetica, sans-serif", tag: "sans" }, + { name: "Helvetica", family: "Helvetica, Arial, sans-serif", tag: "sans" }, + { name: "Times New Roman", family: '"Times New Roman", Times, serif', tag: "serif" }, + { name: "Georgia", family: "Georgia, serif", tag: "serif" }, + { name: "Verdana", family: "Verdana, Geneva, sans-serif", tag: "sans" }, + { name: "Tahoma", family: "Tahoma, Geneva, sans-serif", tag: "sans" }, + { name: "Impact", family: 'Impact, "Arial Black", sans-serif', tag: "sans" }, + { name: "Comic Sans MS", family: '"Comic Sans MS", cursive', tag: "hand" }, + { name: "Courier New", family: '"Courier New", Courier, monospace', tag: "mono" }, + { name: "宋体", family: "SimSun, 'Noto Serif SC', serif", tag: "serif" }, + { name: "黑体", family: "SimHei, 'Noto Sans SC', sans-serif", tag: "sans" }, + { name: "仿宋", family: "FangSong, 'Noto Serif SC', serif", tag: "serif" }, + { name: "Trebuchet MS", family: '"Trebuchet MS", sans-serif', tag: "sans" }, + { name: "Lucida Console", family: '"Lucida Console", Monaco, monospace', tag: "mono" }, + { name: "Palatino", family: 'Palatino, "Palatino Linotype", serif', tag: "serif" }, + { name: "Garamond", family: "Garamond, serif", tag: "serif" }, + { name: "Calibri", family: "Calibri, sans-serif", tag: "sans" }, + { name: "Cambria", family: "Cambria, serif", tag: "serif" }, + { name: "Candara", family: "Candara, sans-serif", tag: "sans" }, + { name: "Consolas", family: "Consolas, monospace", tag: "mono" }, +] + +/** 所有字体列表 */ +export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS] + /** 封面模板 */ export interface CoverTemplate { id: string @@ -39,12 +312,5 @@ export interface CoverTemplate { thumbnail_url: string is_system: boolean created_at: string - config?: { - background_enabled?: boolean - background_color?: string - portrait_enabled?: boolean - title_text?: string - subtitle_text?: string - mask_enabled?: boolean - } + config?: CoverEditorConfig } diff --git a/apps/web/src/pages/subscription/Plans.tsx b/apps/web/src/pages/subscription/Plans.tsx index 430771bc8..2ea891f2e 100644 --- a/apps/web/src/pages/subscription/Plans.tsx +++ b/apps/web/src/pages/subscription/Plans.tsx @@ -39,6 +39,7 @@ import { getDiscountPriceCents } from "@/api/points/types" import type { SubscriptionPlan } from "@/api/subscription/types" import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types" import "./Plans.css" +import { ENABLE_CREDIT_SYSTEM } from "@/config/features" const { Title, Text, Paragraph } = Typography @@ -249,17 +250,23 @@ const Plans: React.FC = () => { return (
- - + ENABLE_CREDIT_SYSTEM ? ( + + + + ) : null } /> @@ -296,13 +303,15 @@ const Plans: React.FC = () => { )}
-
- 可用积分 -
- - {bal} + {ENABLE_CREDIT_SYSTEM && ( +
+ 可用积分 +
+ + {bal} +
-
+ )} {!isMember && freeLimit > 0 && (
今日免费混剪 @@ -319,18 +328,20 @@ const Plans: React.FC = () => { )} - - - + {ENABLE_CREDIT_SYSTEM && ( + + + + )} @@ -461,69 +472,71 @@ const Plans: React.FC = () => { - {/* 积分充值 */} -
- - <ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} /> - 积分充值 - <Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣"> - <Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}> - (永久有效) - </Text> - </Tooltip> - + {/* 积分充值(积分系统关闭时隐藏,代码保留不删除) */} + {ENABLE_CREDIT_SYSTEM && ( +
+ + <ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} /> + 积分充值 + <Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣"> + <Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}> + (永久有效) + </Text> + </Tooltip> + - - {packages.map((pkg) => { - const priceCents = getDiscountPriceCents(pkg, userDiscount) - const originalCents = pkg.price_cents - const discount = - priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0 - const unit = priceCents / 100 / pkg.points - const isHot = pkg.unit_price < 0.1 - return ( - - 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`} - hoverable - > - {isHot &&
热门
} - {discount > 0 && ( - - {Math.round((priceCents / originalCents) * 10) / 1}折 - - )} -
{pkg.name}
-
- {pkg.points.toLocaleString()} 积分 -
-
- ¥ - - {(priceCents / 100) - .toFixed(priceCents % 100 === 0 ? 0 : 1) - .replace(/\.0$/, "")} - - {discount > 0 && ( - ¥{(originalCents / 100).toFixed(0)} - )} -
-
≈¥{unit.toFixed(3)}/积分
- -
- - ) - })} -
-
+ {isHot &&
热门
} + {discount > 0 && ( + + {Math.round((priceCents / originalCents) * 10) / 1}折 + + )} +
{pkg.name}
+
+ {pkg.points.toLocaleString()} 积分 +
+
+ ¥ + + {(priceCents / 100) + .toFixed(priceCents % 100 === 0 ? 0 : 1) + .replace(/\.0$/, "")} + + {discount > 0 && ( + ¥{(originalCents / 100).toFixed(0)} + )} +
+
≈¥{unit.toFixed(3)}/积分
+ + + + ) + })} + +
+ )}
) } diff --git a/apps/web/src/pages/tasks/constants.tsx b/apps/web/src/pages/tasks/constants.tsx index 23edb6c6c..49af22e51 100644 --- a/apps/web/src/pages/tasks/constants.tsx +++ b/apps/web/src/pages/tasks/constants.tsx @@ -45,6 +45,11 @@ export const STATUS_CONFIG: Record< color: "processing", icon: , }, + awaiting_cover: { + label: "待选封面", + color: "warning", + icon: , + }, completed: { label: "已完成", color: "success", diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css new file mode 100644 index 000000000..074204928 --- /dev/null +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -0,0 +1,817 @@ +/* ============================================================ + 爆款视频创作页 - 浅色紫调(对齐 AI 数字人页视觉规范) + 布局(参考 ui-ref-step-layout.png 三列等宽 STEP 向导): + .vv-tabs 顶栏多任务 Tab(生成1 × / + 新建) + .vv-grid 三列等宽 grid(1fr 1fr 1fr,gap 16) + ├── .vv-col 左:STEP 1 上传素材(图片+参考视频+配音) + ├── .vv-col 中:STEP 2 生成视频文案(融合Tab+参数+文案I/O+AI摘要) + └── .vv-col 右:STEP 3 生成视频(预览+参数+进度+扣点+按钮) + 可折叠模块:.vv-section > .vv-section-head[aria-expanded] + .vv-section-body + ============================================================ */ + +.vv-page { + padding: 16px; + background: #f5f6fa; + min-height: calc(100vh - 56px); +} + +/* ── 顶部任务 Tab 栏 ─────────────────────────────────── */ +.vv-tabs { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; + padding: 6px 8px; + background: #fff; + border-radius: 10px; + border: 1px solid #e5e7eb; + flex-wrap: wrap; +} +.vv-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + color: #6b7280; + cursor: pointer; + border: 1px solid transparent; + background: transparent; + transition: all 0.15s; +} +.vv-tab:hover { + background: #f3f4f6; + color: #374151; +} +.vv-tab.active { + background: #f3f0ff; + color: #7c3aed; + border-color: #d8cafc; + font-weight: 500; +} +.vv-tab .vv-tab-close { + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #9ca3af; +} +.vv-tab .vv-tab-close:hover { + background: rgba(0, 0, 0, 0.08); + color: #374151; +} +.vv-tab-new { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: #7c3aed; + cursor: pointer; + border: 1px dashed #d8cafc; + background: transparent; +} +.vv-tab-new:hover { + background: #f3f0ff; +} +.vv-tabs-right { + margin-left: auto; + display: flex; + gap: 8px; +} + +/* ── 三列等宽网格 ───────────────────────────────────── */ +.vv-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + align-items: start; +} +@media (max-width: 1280px) { + .vv-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 900px) { + .vv-grid { + grid-template-columns: 1fr; + } +} + +.vv-col { + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ── 可折叠模块(对齐 AI 数字人卡块) ────────────────── */ +.vv-section { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; +} +.vv-section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid #f3f4f6; +} +.vv-section.collapsed .vv-section-head { + border-bottom: none; +} +.vv-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + color: #111827; +} +.vv-section-title .vv-step-badge { + width: 22px; + height: 22px; + border-radius: 50%; + background: #7c3aed; + color: #fff; + font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 600; +} +.vv-section-arrow { + color: #9ca3af; + font-size: 12px; + transition: transform 0.2s; +} +.vv-section.collapsed .vv-section-arrow { + transform: rotate(-90deg); +} +.vv-section-body { + padding: 14px 16px 16px; +} +.vv-section.collapsed .vv-section-body { + display: none; +} + +/* ── 通用表单元素(对齐 AI 数字人样式) ─────────────── */ +.vv-label { + display: block; + font-size: 12px; + color: #6b7280; + margin-bottom: 6px; +} +.vv-input, +.vv-select, +.vv-textarea { + width: 100%; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 9px 12px; + font-size: 13px; + color: #111827; + background: #fff; + outline: none; + transition: + border-color 0.15s, + box-shadow 0.15s; + font-family: inherit; +} +.vv-textarea { + line-height: 1.6; + resize: vertical; + min-height: 90px; +} +.vv-input:focus, +.vv-select:focus, +.vv-textarea:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); +} +.vv-input::placeholder, +.vv-textarea::placeholder { + color: #d1d5db; +} +.vv-form-row { + margin-bottom: 12px; +} +.vv-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +/* ── Tab 分段(对齐"系统预设/我的音色"样式) ────────── */ +.vv-seg-tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; + border-radius: 8px; + padding: 3px; + background: #f5f6fa; +} +.vv-seg-tab { + flex: 1; + padding: 7px 10px; + font-size: 13px; + text-align: center; + border-radius: 6px; + cursor: pointer; + color: #6b7280; + background: transparent; + border: 1px solid transparent; + transition: all 0.15s; + font-family: inherit; +} +.vv-seg-tab:hover { + color: #374151; +} +.vv-seg-tab.active { + background: #fff; + color: #7c3aed; + border-color: #7c3aed; + font-weight: 500; + box-shadow: 0 1px 2px rgba(124, 58, 237, 0.06); +} + +/* 融合强度大按钮(选中紫色描边+浅紫底) */ +.vv-fusion-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} +.vv-fusion-btn { + padding: 10px 8px; + border: 1px solid #e5e7eb; + background: #fff; + border-radius: 8px; + font-size: 12px; + cursor: pointer; + color: #6b7280; + text-align: center; + line-height: 1.4; + transition: all 0.15s; + font-family: inherit; +} +.vv-fusion-btn strong { + display: block; + font-size: 13px; + color: #111827; + margin-bottom: 2px; + font-weight: 600; +} +.vv-fusion-btn:hover { + border-color: #d8cafc; +} +.vv-fusion-btn.active { + background: #f3f0ff; + border-color: #7c3aed; + color: #7c3aed; +} +.vv-fusion-btn.active strong { + color: #7c3aed; +} + +/* 风格强度小分段按钮(三档,参考配音风格按钮) */ +.vv-pill-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.vv-pill { + padding: 6px 12px; + border: 1px solid #e5e7eb; + background: #fff; + border-radius: 6px; + font-size: 12px; + color: #6b7280; + cursor: pointer; + font-family: inherit; + transition: all 0.15s; +} +.vv-pill:hover { + border-color: #d8cafc; + color: #7c3aed; +} +.vv-pill.active { + background: #f3f0ff; + border-color: #7c3aed; + color: #7c3aed; + font-weight: 500; +} + +/* ── 上传区(浅灰虚线框) ───────────────────────────── */ +.vv-upload { + border: 1.5px dashed #d1d5db; + border-radius: 10px; + padding: 20px; + text-align: center; + cursor: pointer; + transition: + border-color 0.2s, + background 0.2s; + background: #fafbfc; + color: #9ca3af; +} +.vv-upload:hover, +.vv-upload.dragover { + border-color: #7c3aed; + background: #f9f7ff; + color: #7c3aed; +} +.vv-upload-icon { + font-size: 28px; + margin-bottom: 6px; +} +.vv-upload small { + display: block; + font-size: 11px; + color: #9ca3af; + margin-top: 4px; +} + +/* 图片网格 */ +.vv-img-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 8px; + margin-top: 12px; +} +.vv-img-item { + position: relative; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e5e7eb; + cursor: grab; + background: #f5f6fa; +} +.vv-img-item.dragging { + opacity: 0.4; +} +.vv-img-item img { + width: 100%; + height: 100%; + object-fit: cover; +} +.vv-img-badge { + position: absolute; + top: 4px; + left: 4px; + background: rgba(124, 58, 237, 0.9); + color: #fff; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; +} +.vv-img-del { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(239, 68, 68, 0.9); + color: #fff; + border: none; + cursor: pointer; + font-size: 12px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s; +} +.vv-img-item:hover .vv-img-del { + opacity: 1; +} +.vv-img-add { + width: 100%; + height: 100%; + border: 1.5px dashed #d1d5db; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #9ca3af; + cursor: pointer; + background: #fafbfc; + font-size: 11px; + gap: 2px; + transition: all 0.15s; +} +.vv-img-add:hover { + border-color: #7c3aed; + color: #7c3aed; + background: #f9f7ff; +} +.vv-progress-mini { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.35); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; +} + +/* 参考视频预览 */ +.vv-video-preview { + width: 100%; + aspect-ratio: 16/9; + border-radius: 8px; + overflow: hidden; + background: #000; + margin-top: 10px; + border: 1px solid #e5e7eb; +} +.vv-video-preview video { + width: 100%; + height: 100%; + object-fit: cover; +} +.vv-video-ph { + width: 100%; + aspect-ratio: 16/9; + border: 1.5px dashed #d1d5db; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #9ca3af; + cursor: pointer; + background: #fafbfc; + font-size: 12px; + gap: 4px; + margin-top: 10px; + transition: all 0.15s; +} +.vv-video-ph:hover { + border-color: #7c3aed; + color: #7c3aed; + background: #f9f7ff; +} + +/* ── 音色列表(参考 AI 数字人「龙小淳」卡片) ──────── */ +.vv-voice-tabs { + margin-bottom: 10px; +} +.vv-voice-list { + max-height: 280px; + overflow-y: auto; + border: 1px solid #f3f4f6; + border-radius: 8px; +} +.vv-voice-list::-webkit-scrollbar { + width: 6px; +} +.vv-voice-list::-webkit-scrollbar-thumb { + background: #e5e7eb; + border-radius: 3px; +} +.vv-voice-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid #f3f4f6; + cursor: pointer; + transition: background 0.15s; +} +.vv-voice-item:last-child { + border-bottom: none; +} +.vv-voice-item:hover { + background: #f9fafb; +} +.vv-voice-item.selected { + background: #f3f0ff; +} +.vv-voice-radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #d1d5db; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} +.vv-voice-item.selected .vv-voice-radio { + border-color: #7c3aed; +} +.vv-voice-item.selected .vv-voice-radio::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: #7c3aed; +} +.vv-voice-info { + flex: 1; + min-width: 0; +} +.vv-voice-name { + font-size: 13px; + color: #111827; + font-weight: 500; +} +.vv-voice-desc { + font-size: 11px; + color: #9ca3af; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.vv-voice-play { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid #e5e7eb; + background: #fff; + color: #6b7280; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + flex-shrink: 0; + transition: all 0.15s; +} +.vv-voice-play:hover { + border-color: #7c3aed; + color: #7c3aed; +} +.vv-voice-play.playing { + background: #7c3aed; + border-color: #7c3aed; + color: #fff; +} + +/* ── AI 摘要确认卡(黄色高亮) ─────────────────────── */ +.vv-intent { + background: #fffbeb; + border: 1px solid #fcd34d; + border-radius: 10px; + padding: 14px; + margin-top: 12px; +} +.vv-intent-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #b45309; + margin-bottom: 10px; +} +.vv-intent-row { + margin-bottom: 8px; + font-size: 13px; + line-height: 1.6; +} +.vv-intent-row .vv-k { + font-size: 12px; + color: #92400e; + margin-bottom: 3px; +} +.vv-intent-row .vv-v { + color: #111827; +} +.vv-chip { + display: inline-block; + padding: 2px 8px; + background: #fef3c7; + border-radius: 4px; + font-size: 11px; + color: #92400e; + margin-right: 4px; + margin-bottom: 3px; +} + +/* ── 9:16 预览区 ────────────────────────────────────── */ +.vv-preview { + width: 100%; + aspect-ratio: 9/16; + max-height: 480px; + border-radius: 12px; + background: #fff; + border: 1.5px dashed #d1d5db; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} +.vv-preview video { + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} +.vv-preview-placeholder { + text-align: center; + color: #9ca3af; + padding: 20px; +} +.vv-preview-placeholder .ph-icon { + font-size: 40px; + opacity: 0.4; + margin-bottom: 8px; +} +.vv-preview-placeholder .ph-txt { + font-size: 12px; +} + +.vv-progress-bar { + width: 100%; + height: 6px; + background: #f3f4f6; + border-radius: 3px; + overflow: hidden; + margin-top: 10px; +} +.vv-progress-fill { + height: 100%; + background: linear-gradient(90deg, #7c3aed, #a855f7); + border-radius: 3px; + transition: width 0.4s ease; +} +.vv-progress-meta { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #9ca3af; + margin-top: 6px; +} +.vv-progress-pct { + color: #7c3aed; + font-weight: 600; +} + +/* ── 扣点 & 按钮 ────────────────────────────────────── */ +.vv-credits { + background: #f9fafb; + border: 1px solid #f3f4f6; + border-radius: 8px; + padding: 10px 12px; + font-size: 12px; + color: #6b7280; + display: flex; + justify-content: space-between; + align-items: center; +} +.vv-credits strong { + color: #7c3aed; + font-weight: 600; +} + +.vv-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s; + font-family: inherit; +} +.vv-btn-primary { + width: 100%; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: #fff; + padding: 12px; + font-size: 14px; + margin-top: 10px; +} +.vv-btn-primary:hover:not(:disabled) { + box-shadow: 0 4px 14px rgba(124, 58, 237, 0.3); + transform: translateY(-1px); +} +.vv-btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.vv-btn-ghost { + background: #fff; + color: #6b7280; + border-color: #e5e7eb; +} +.vv-btn-ghost:hover { + border-color: #7c3aed; + color: #7c3aed; +} +.vv-btn-warn { + background: #fef3c7; + color: #b45309; + border-color: #fcd34d; +} +.vv-btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.vv-error { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 10px 12px; + color: #dc2626; + font-size: 12px; + margin-top: 10px; +} +.vv-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: vvspin 0.8s linear infinite; + display: inline-block; +} +@keyframes vvspin { + to { + transform: rotate(360deg); + } +} + +.vv-muted { + color: #9ca3af; + font-size: 12px; +} +.vv-meta { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #9ca3af; + margin-top: 4px; +} +.vv-file-name { + font-size: 12px; + color: #374151; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 8px; +} +.vv-link-btn { + background: none; + border: none; + color: #7c3aed; + font-size: 12px; + cursor: pointer; + padding: 0; + font-family: inherit; +} +.vv-link-btn:hover { + text-decoration: underline; +} +.vv-flex { + display: flex; +} +.vv-between { + justify-content: space-between; + align-items: center; +} +.vv-gap-8 { + gap: 8px; +} +.vv-mt-8 { + margin-top: 8px; +} +.vv-mt-12 { + margin-top: 12px; +} + +/* disabled 状态 */ +.vv-pill:disabled, +.vv-fusion-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.vv-btn:disabled { + cursor: not-allowed; +} +.vv-section-head { + gap: 8px; +} diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx new file mode 100644 index 000000000..b0e9763a0 --- /dev/null +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -0,0 +1,1164 @@ +import React, { useCallback, useEffect, useRef, useState } from "react" +import { + PlusOutlined, + CloseOutlined, + UploadOutlined, + PlayCircleOutlined, + PauseCircleOutlined, + CaretDownOutlined, + VideoCameraOutlined, + SoundOutlined, + ReloadOutlined, + WarningFilled, + BulbFilled, + AudioOutlined, + ThunderboltOutlined, +} from "@ant-design/icons" +import { Select, message } from "antd" +import { uploadAssetDirect, getAssetLibraries, getAssetsByKind } from "@/api/assets" +import { + FUSION_LEVELS, + STYLE_STRENGTHS, + isAnalysisStage, + type FusionLevel, + type StyleStrength, + type ViralVideoJob, +} from "@/api/viral-video/types" +import { generateViralVideo, confirmViralVideoIntent, retryViralVideo } from "@/api/viral-video" +import { useViralVideoPolling } from "./hooks/useViralVideoPolling" +import CloneModal from "@/components/voice/CloneModal" +import "./ViralVideoPage.css" + +/* ─────────── 常量与类型 ─────────── */ + +type TabTask = { + id: string + title: string + // 编辑态 + images: { + localId: string + url: string + name: string + uploading: boolean + pct: number + ossUrl?: string + file?: File + }[] + refVideo: { + url: string + name: string + uploading: boolean + pct: number + ossUrl?: string + file?: File + } | null + fusionLevel: FusionLevel + styleStrength: StyleStrength + voiceId: string + userCopy: string + industry: string + targetCustomer: string + language: string + viralStructure: string + marketingPurpose: string + duration: number + bgm: string + videoRatio: string + videoModel: string + intentEditText: string + collapsed: { + images: boolean + refVideo: boolean + voice: boolean + fusion: boolean + params: boolean + copy: boolean + preview: boolean + videoParams: boolean + stages: boolean + } + // 运行态 + job: ViralVideoJob | null + jobId: string | null + playingVoiceId: string | null + audioInst: HTMLAudioElement | null + // 按钮 loading + analyzing: boolean + generating: boolean +} + +const INDUSTRIES = [ + "美妆个护", + "服饰鞋包", + "数码3C", + "食品饮料", + "家居日用", + "母婴亲子", + "教育培训", + "其他", +] +const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"] +const STRUCTURES = [ + "痛点开场→卖点→促单", + "反差对比→证据→促单", + "故事讲述→共鸣→促单", + "专家背书→数据→促单", +] +const PURPOSES = ["品牌种草", "新品上市", "促销转化", "活动引流"] +const BGMS = ["无BGM", "欢快活泼", "温柔治愈", "励志燃向", "搞笑魔性", "科技感"] +const RATIOS = [ + { v: "9:16", label: "9:16 竖屏(抖音/视频号)" }, + { v: "16:9", label: "16:9 横屏(B站/YouTube)" }, + { v: "1:1", label: "1:1 方形(小红书)" }, +] +const MODELS = [ + { v: "seedance-2.5", label: "Seedance 2.5(推荐)" }, + { v: "seedance-2.0", label: "Seedance 2.0" }, +] + +const emptyTask = (id: string, title: string): TabTask => ({ + id, + title, + images: [], + refVideo: null, + fusionLevel: "ai_full", + styleStrength: "medium", + voiceId: "", + userCopy: "", + industry: "", + targetCustomer: "", + language: "中文(普通话)", + viralStructure: STRUCTURES[0], + marketingPurpose: PURPOSES[0], + duration: 30, + bgm: BGMS[0], + videoRatio: "9:16", + videoModel: "seedance-2.5", + intentEditText: "", + collapsed: { + images: false, + refVideo: false, + voice: false, + fusion: false, + params: false, + copy: false, + preview: false, + videoParams: false, + stages: false, + }, + job: null, + jobId: null, + playingVoiceId: null, + audioInst: null, + analyzing: false, + generating: false, +}) + +/* ─────────── 折叠 Section ─────────── */ +const Section: React.FC<{ + title: React.ReactNode + step?: number + collapsed: boolean + onToggle: () => void + children: React.ReactNode +}> = ({ title, step, collapsed, onToggle, children }) => ( +
+
+
+ {step !== undefined && {step}} + {title} +
+ +
+ {!collapsed &&
{children}
} +
+) + +/* ─────────── 主页面 ─────────── */ +const ViralVideoPage: React.FC = () => { + const [tasks, setTasks] = useState(() => [emptyTask("t1", "生成 1")]) + const [activeId, setActiveId] = useState("t1") + const [voiceTab, setVoiceTab] = useState<"preset" | "clone">("preset") + const [cloneModalOpen, setCloneModalOpen] = useState(false) + const [voiceAssets, setVoiceAssets] = useState< + { id: string; name: string; url: string; desc?: string }[] + >([]) + const dragImgRef = useRef(null) + const dragVideoRef = useRef(null) + const dragFromIdxRef = useRef(-1) + const audioRef = useRef(null) + + const task = tasks.find((t) => t.id === activeId) || tasks[0] + const setTask = useCallback( + (patch: Partial | ((t: TabTask) => TabTask)) => { + setTasks((ts) => + ts.map((t) => + t.id === activeId ? (typeof patch === "function" ? patch(t) : { ...t, ...patch }) : t, + ), + ) + }, + [activeId], + ) + const toggleCollapse = useCallback( + (key: keyof TabTask["collapsed"]) => { + setTask((t) => ({ ...t, collapsed: { ...t.collapsed, [key]: !t.collapsed[key] } })) + }, + [setTask], + ) + + /* ── 加载音色素材 ── */ + useEffect(() => { + getAssetsByKind("voice", { limit: 50 }) + .then((list) => + setVoiceAssets( + list.map((a) => ({ + id: a.id, + name: a.name, + url: a.file_url || "", + desc: a.duration ? `${Math.round(a.duration)}s` : "我的音色", + })), + ), + ) + .catch(() => {}) + }, []) + + /* ── 轮询 ── */ + const onPollUpdate = useCallback( + (job: ViralVideoJob) => { + setTask((t) => { + // 首次进入 wait_user_confirm 时初始化 intent 编辑文本 + const prevStatus = t.job?.status + const newIntentEdit = + prevStatus !== "wait_user_confirm" && job.status === "wait_user_confirm" + ? job.intent_result?.suggested_copy || t.userCopy + : t.intentEditText + return { + ...t, + job, + intentEditText: newIntentEdit, + analyzing: + job.status === "wait_user_confirm" || + job.status === "completed" || + job.status === "failed" + ? false + : t.analyzing, + generating: + job.status === "completed" || + job.status === "failed" || + job.status === "wait_user_confirm" + ? false + : t.generating, + } + }) + }, + [setTask], + ) + useViralVideoPolling(task.jobId, onPollUpdate) + + /* ── 上传文件到 OSS ── */ + const uploadFile = useCallback( + async (file: File, kind: "image" | "video" | "voice", onProgress?: (pct: number) => void) => { + const libs = await getAssetLibraries() + const lib = (libs as { kind: string; id: string }[]).find((l) => l.kind === kind) || libs[0] + if (!lib) throw new Error("未找到素材库") + const res = await uploadAssetDirect({ + file, + library_id: lib.id, + onProgress: (p: number) => onProgress?.(Math.round(p)), + }) + return res.url + }, + [], + ) + + /* ── 上传图片 ── */ + const handleImageFiles = useCallback( + async (files: FileList | File[]) => { + const remaining = 9 - task.images.length + if (remaining <= 0) { + message.warning("最多上传 9 张产品图") + return + } + const arr = Array.from(files).slice(0, remaining) + if (arr.length === 0) return + const newImgs = arr.map((f) => ({ + localId: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + url: URL.createObjectURL(f), + name: f.name, + uploading: true, + pct: 0, + file: f, + })) + setTask((t) => ({ + ...t, + images: [ + ...t.images, + ...newImgs.map(({ file: _f, ...rest }) => rest as TabTask["images"][number]), + ], + })) + + for (const item of newImgs) { + try { + const url = await uploadFile(item.file, "image", (p) => { + setTask((t) => ({ + ...t, + images: t.images.map((im) => (im.localId === item.localId ? { ...im, pct: p } : im)), + })) + }) + setTask((t) => ({ + ...t, + images: t.images.map((im) => + im.localId === item.localId ? { ...im, uploading: false, pct: 100, ossUrl: url } : im, + ), + })) + } catch { + message.error(`${item.name} 上传失败`) + setTask((t) => ({ ...t, images: t.images.filter((im) => im.localId !== item.localId) })) + } + } + }, + [task.images.length, setTask, uploadFile], + ) + + const onDropImg = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + if (e.dataTransfer.files?.length) handleImageFiles(e.dataTransfer.files) + }, + [handleImageFiles], + ) + + const removeImg = useCallback( + (localId: string) => { + setTask((t) => { + const im = t.images.find((i) => i.localId === localId) + if (im?.url?.startsWith("blob:")) URL.revokeObjectURL(im.url) + return { ...t, images: t.images.filter((i) => i.localId !== localId) } + }) + }, + [setTask], + ) + + const onImgDragStart = (idx: number) => { + dragFromIdxRef.current = idx + } + const onImgDragOver = (e: React.DragEvent) => { + e.preventDefault() + } + const onImgDrop = (idx: number) => { + const from = dragFromIdxRef.current + if (from < 0 || from === idx) return + setTask((t) => { + const imgs = [...t.images] + const [m] = imgs.splice(from, 1) + imgs.splice(idx, 0, m) + return { ...t, images: imgs } + }) + dragFromIdxRef.current = -1 + } + + /* ── 上传参考视频 ── */ + const handleRefVideo = useCallback( + async (file: File) => { + const localUrl = URL.createObjectURL(file) + setTask({ refVideo: { url: localUrl, name: file.name, uploading: true, pct: 0 } }) + try { + const url = await uploadFile(file, "video", (p) => { + setTask((t) => (t.refVideo ? { ...t, refVideo: { ...t.refVideo, pct: p } } : t)) + }) + setTask((t) => + t.refVideo + ? { ...t, refVideo: { ...t.refVideo, uploading: false, pct: 100, ossUrl: url } } + : t, + ) + } catch { + message.error("参考视频上传失败") + if (localUrl.startsWith("blob:")) URL.revokeObjectURL(localUrl) + setTask({ refVideo: null }) + } + }, + [setTask, uploadFile], + ) + + /* ── Tab 管理 ── */ + const newTab = () => { + const id = `t${Date.now()}` + setTasks((ts) => [...ts, emptyTask(id, `生成 ${ts.length + 1}`)]) + setActiveId(id) + } + const closeTab = (id: string) => { + setTasks((ts) => { + const next = ts.filter((t) => t.id !== id) + if (next.length === 0) { + setActiveId("t1") + return [emptyTask("t1", "生成 1")] + } + if (id === activeId) setActiveId(next[0].id) + return next + }) + } + const resetTask = () => { + audioRef.current?.pause() + setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t))) + } + + /* ── STEP1:开始分析(触发图片分析+视频分析+意图理解 → WAIT_USER_CONFIRM 暂停) ── */ + const handleAnalyze = async () => { + const imgUrls = task.images + .filter((i) => i.ossUrl && !i.uploading) + .map((i) => i.ossUrl as string) + if (imgUrls.length === 0) { + message.warning("请至少上传 1 张产品图,等待上传完成后再分析") + return + } + if (task.images.some((i) => i.uploading)) { + message.warning("图片上传中,请稍候") + return + } + if (task.refVideo?.uploading) { + message.warning("参考视频上传中,请稍候") + return + } + setTask({ analyzing: true }) + try { + const job = await generateViralVideo({ + images: imgUrls, + reference_video_url: task.refVideo?.ossUrl || undefined, + style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, + user_copy_text: task.fusionLevel !== "ai_full" ? task.userCopy : undefined, + fusion_level: task.fusionLevel, + // 注意:STEP1「开始分析」阶段还没有真正开始生成视频,后端会跑到 WAIT_USER_CONFIRM 暂停。 + // 配音和视频参数在 STEP2 确认文案时由 confirm-intent 继续使用;但后端接口目前只接收一次 payload, + // 因此先把配音/参数一并提交(用户可在确认前继续调整配音、比例等,但最终生成用的是提交时的值)。 + voice_id: task.voiceId || undefined, + bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm, + industry: task.industry || undefined, + target_customer: task.targetCustomer || undefined, + language: task.language || undefined, + viral_structure: task.viralStructure || undefined, + marketing_purpose: task.marketingPurpose || undefined, + duration: task.duration, + video_model: task.videoModel, + video_ratio: task.videoRatio, + }) + setTask({ job, jobId: job.id, analyzing: false }) + message.success("开始分析,稍候会给出 AI 理解的文案…") + } catch (err: unknown) { + message.error(err instanceof Error ? err.message : "提交失败") + setTask({ analyzing: false }) + } + } + + /* ── STEP2:确认并生成视频(文案融合→分镜→审核→TTS→渲染→上传,一跑到底) ── */ + const handleConfirmAndGenerate = async () => { + if (!task.jobId) return + setTask({ generating: true }) + try { + const j = await confirmViralVideoIntent(task.jobId, { confirmed_copy: task.intentEditText }) + setTask({ job: j }) + message.success("文案已确认,开始生成视频…") + } catch (err: unknown) { + message.error(err instanceof Error ? err.message : "确认失败") + setTask({ generating: false }) + } + } + + const handleRetry = async () => { + if (!task.jobId) return + try { + const j = await retryViralVideo(task.jobId) + setTask({ job: j }) + message.success("已重试") + } catch (err: unknown) { + message.error(err instanceof Error ? err.message : "重试失败") + } + } + + /* ── 音色播放 ── */ + const toggleVoice = (v: { id: string; url: string }) => { + if (task.playingVoiceId === v.id) { + audioRef.current?.pause() + setTask({ playingVoiceId: null }) + return + } + audioRef.current?.pause() + const a = new Audio(v.url) + a.onended = () => setTask({ playingVoiceId: null }) + a.play().catch(() => {}) + audioRef.current = a + setTask({ playingVoiceId: v.id, audioInst: a }) + } + useEffect(() => () => audioRef.current?.pause(), []) + + const onCloneSuccess = (voice: { id?: string }) => { + setCloneModalOpen(false) + if (voice?.id) { + setTask({ voiceId: voice.id }) + getAssetsByKind("voice", { limit: 50 }) + .then((list) => + setVoiceAssets( + list.map((a) => ({ id: a.id, name: a.name, url: a.file_url || "", desc: "我的音色" })), + ), + ) + .catch(() => {}) + message.success("音色已上传,已自动选中") + } + } + + /* ── 派生状态 ── */ + const isAwaitingConfirm = task.job?.status === "wait_user_confirm" + const isPreAnalysis = !task.jobId && !task.analyzing // 还没点开始分析 + const isAnalyzing = + task.analyzing || + (task.job && + task.job.status === "running" && + !isAwaitingConfirm && + !task.generating && + (!task.job.progress_stage || isAnalysisStage(task.job.progress_stage))) + const isGenerating = + task.generating || (task.job?.status === "running" && !isAnalysisStage(task.job.progress_stage)) + const isDone = task.job?.status === "completed" + const isFailed = task.job?.status === "failed" + + return ( +
+ {/* 顶部 Tab */} +
+ {tasks.map((t) => ( +
setActiveId(t.id)} + > + {t.title} + { + e.stopPropagation() + closeTab(t.id) + }} + > + + +
+ ))} + +
+ +
+
+ +
+ {/* ─────────── 左列 STEP 1 ─────────── */} +
+
toggleCollapse("images")} + > +
{ + e.preventDefault() + ;(e.currentTarget as HTMLDivElement).classList.add("dragover") + }} + onDragLeave={(e) => (e.currentTarget as HTMLDivElement).classList.remove("dragover")} + onDrop={onDropImg} + onClick={() => dragImgRef.current?.click()} + > +
+ +
+
点击或拖拽上传 1-9 张产品图
+ 建议 JPG/PNG,单张 ≤ 10MB +
+ { + if (e.target.files) handleImageFiles(e.target.files) + e.target.value = "" + }} + /> + {task.images.length > 0 && ( +
+ {task.images.map((im, i) => ( +
onImgDragStart(i)} + onDragOver={onImgDragOver} + onDrop={() => onImgDrop(i)} + > + {im.name} + {i + 1} + + {im.uploading &&
{im.pct}%
} +
+ ))} + {task.images.length < 9 && ( +
dragImgRef.current?.click()}> +
+ + 添加 +
+
+ )} +
+ )} +
+ +
toggleCollapse("refVideo")} + > + {!task.refVideo ? ( + <> +
dragVideoRef.current?.click()}> + + 上传参考视频 + + AI 会借鉴该视频的镜头/节奏/风格 + +
+ { + if (e.target.files?.[0]) handleRefVideo(e.target.files[0]) + e.target.value = "" + }} + /> + + ) : ( + <> +
+
+ {task.refVideo.uploading && ( + <> +
+
+
+
+ 上传中… + {task.refVideo.pct}% +
+ + )} +
{task.refVideo.name}
+ + {task.refVideo.ossUrl && ( +
+
风格借鉴强度
+
+ {STYLE_STRENGTHS.map((s) => ( + + ))} +
+
+ )} + + )} +
+ +
toggleCollapse("voice")} + > +
+ + +
+
+ {voiceAssets.length === 0 && ( +
+ 暂无可用音色,去「我的音色」上传 +
+ )} + {voiceAssets.map((v) => ( +
setTask({ voiceId: v.id })} + > +
+
+
{v.name}
+
{v.desc || ""}
+
+ +
+ ))} +
+ + + {/* STEP1 主按钮:开始分析 */} + +
+ 点击后将开始图片/视频风格分析与意图理解 +
+
+
+ + {/* ─────────── 中列 STEP 2 ─────────── */} +
+
toggleCollapse("fusion")} + > +
+ {FUSION_LEVELS.map((f) => ( + + ))} +
+
+ +