Compare commits

..

1 Commits

Author SHA1 Message Date
saas-backend 05b5de2ea8 feat(web): 标题模板管理系统 — 预设区域升级为模板卡片网格,支持新建/编辑/复制/删除 + 持久化
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m9s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1m40s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 3m57s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m28s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m33s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 2s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m45s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m58s
Bug修复:
- Bug1:预设选中后手动改参数自动脱离预设(useRef→useState管理activePreset)
- Bug2:预设卡片预览字号统一(canvas固定120px宽度渲染)
- Bug3:描边参数始终可手动调整(预设只给初始值不锁定)

功能升级:
- 预设区域改为4列模板卡片网格(预览+名称+系统/自定义标签)
- 新增「新建模板」按钮,编辑模式实时预览+参数面板
- 支持编辑/复制/删除用户自定义模板
- 模板数据持久化到localStorage(key: xiaoxia-title-templates)

影响范围:TitleStylePanel公共组件,智能剪辑和AI数字人两个页面自动生效
2026-09-22 22:25:58 +08:00
233 changed files with 7661 additions and 32248 deletions
+2 -21
View File
@@ -79,33 +79,14 @@ CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/1
# ==================== Worker 配置(#2073 队列分流) ====================
#
# 容器内跑三个独立进程:beat(只发定时任务)+ generation worker(实时高优)
# + transcode worker(后台批量/清理)。三个进程的并发与开关独立配置。
# ==================== Worker 配置 ====================
# Worker 进程名称
WORKER_NAME=xiaoxia-saas-worker
# 总并发参考(兼容旧变量):
# - 若 GENERATION_CONCURRENCY 与 TRANSCODE_CONCURRENCY 都未显式设置,
# entrypoint 会按此总数对半分配(gen=ceil(total/2), trans=剩余,各至少 1);
# - 任一个 *_CONCURRENCY 显式设置后,按显式值生效,忽略此变量对应部分。
# Worker 并发数(同时执行的任务数)
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
+7 -17
View File
@@ -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
@@ -1238,11 +1238,9 @@ jobs:
ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}"
run: |
set -eux
# 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}"
# 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_user="${STAGING_SSH_USER:-root}"
staging_port="${STAGING_SSH_PORT:-22}"
echo "Host: $staging_host"
@@ -1302,16 +1300,8 @@ 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} COMPOSE_SYNC=0 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} sh"
# 清理 CI runner 上的渲染文件
rm -f .env.rendered
@@ -1562,7 +1552,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=""
@@ -1,33 +0,0 @@
"""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")
@@ -1,100 +0,0 @@
"""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")
@@ -1,25 +0,0 @@
"""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")
@@ -1,51 +0,0 @@
"""viral video add copy_result + voice/video columns
Revision ID: 088_viral_video_copy_result
Revises: 087_viral_video_image_analysis
Create Date: 2026-10-01
v1.6 爆款视频字段补齐:
- copy_result JSON: 编导分镜脚本完整结构(overview/scene_and_lighting/shots/hard_constraints/negative_prompts/voiceover_script)
- voice_id/voice_source: TTS 音色参数
- video_ratio/video_model: Seedance 视频比例/模型
注意:线上启动也有幂等 ADD COLUMN 补列逻辑 (_ensure_viral_video_columns),本 migration 提供标准 Alembic 路径,
两套机制互不冲突(IF NOT EXISTS 等价行为)。
"""
import sqlalchemy as sa
from alembic import op
revision = "088_viral_video_copy_result"
down_revision = "087_viral_video_image_analysis"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 幂等添加列(通过单独执行 + 异常忽略兼容已由 backfill 补上的环境)
cols = [
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
("copy_result", "JSON"),
]
conn = op.get_bind()
for name, ddl in cols:
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
except Exception:
# 不支持 IF NOT EXISTS 的库(如老版本 SQLite)直接尝试 ADD COLUMN,失败则忽略
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
except Exception:
pass
def downgrade() -> None:
for name in ("copy_result", "video_model", "video_ratio", "voice_source", "voice_id"):
try:
op.drop_column("viral_video_jobs", name)
except Exception:
pass
-62
View File
@@ -1,62 +0,0 @@
"""viral video add storyboard + generated_copy_text (complement 088)
Revision ID: 089_viral_video_cols
Revises: 088_viral_video_copy_result
Create Date: 2026-10-01
#2129 兜底迁移:补齐 _VIRAL_VIDEO_BACKFILL_COLS 中所有列,覆盖
# watchtower 自动部署未跑历史 migration、且 AUTO_CREATE_SCHEMA=false 时
# _ensure_viral_video_columns 未执行的场景。
# 幂等 ADD COLUMN IF NOT EXISTS,已存在则跳过。
"""
import sqlalchemy as sa
from alembic import op
revision = "089_viral_video_cols"
down_revision = "088_viral_video_copy_result"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 扩展 alembic_version.version_num 字段长度(原来 VARCHAR(32) 装不下长 revision id)
conn = op.get_bind()
try:
conn.execute(sa.text("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(256)"))
except Exception:
pass
cols = [
("storyboard", "JSON"),
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
("copy_result", "JSON"),
]
for name, ddl in cols:
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
except Exception:
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
except Exception:
pass
def downgrade() -> None:
for name in (
"copy_result",
"video_model",
"video_ratio",
"voice_source",
"voice_id",
"generated_copy_text",
"storyboard",
):
try:
op.drop_column("viral_video_jobs", name)
except Exception:
pass
+1 -26
View File
@@ -8,7 +8,6 @@ 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
@@ -16,13 +15,11 @@ from app.api.routes.generation_preview import router as generation_preview_route
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 router as points_router
from app.api.routes.points import usage_router
from app.api.routes.points import points_router, 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
@@ -36,7 +33,6 @@ 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
@@ -45,19 +41,6 @@ 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"],
@@ -186,9 +169,6 @@ 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",
@@ -207,10 +187,6 @@ 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",
@@ -241,4 +217,3 @@ api_router.include_router(
prefix="/gpu",
tags=["GPU Worker"],
)
api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"])
@@ -1,102 +0,0 @@
"""独立的草稿端点(不依赖 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,
)
+5 -19
View File
@@ -76,7 +76,10 @@ 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 不可用或评分失败时,返回第一帧。
@@ -229,10 +232,7 @@ def _persist_cover_frame(
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
"""
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
@@ -241,20 +241,6 @@ 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
+29 -25
View File
@@ -11,7 +11,9 @@ 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,
)
@@ -300,17 +302,26 @@ def create_preview_generation_task(
count,
)
# 预检查队列限流(按变体总数计)——仅保留全局硬上限,用户上限改为软 warning 在 safe_enqueue 内处理(#2098)
global_pending = generation_task_repository.count_pending_total()
if global_pending + count > GLOBAL_PENDING_LIMIT:
# 预检查队列限流(按变体总数计)
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,
detail=build_rate_limit_detail(
GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT),
generation_task_repository,
scope="global",
),
)
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
) from e
# 确定视频比例:优先前端传入,否则从模板 mode 推断
video_ratio = request.video_ratio or ""
@@ -578,6 +589,9 @@ 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
@@ -589,6 +603,11 @@ 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"),
@@ -631,26 +650,11 @@ 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)
+48 -308
View File
@@ -7,6 +7,7 @@ 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,
@@ -30,8 +31,6 @@ from app.schemas.generation_task import (
BatchGenerationTaskResponse,
ConfirmGenerationRequest,
CreateGenerationTaskRequest,
FinalizeGenerationRequest,
FinalizeGenerationResponse,
GenerationTaskResponse,
ListGenerationTasksResponse,
)
@@ -45,124 +44,6 @@ 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__)
@@ -251,11 +132,10 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
def _select_assets_from_library(
assets: list,
mode: str,
count: int = 0,
count: int,
rng=None,
script_tags: list | None = None,
tag_names_by_id: dict | None = None,
db=None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -276,45 +156,6 @@ 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
@@ -324,7 +165,6 @@ 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,
)
@@ -335,18 +175,7 @@ def _select_assets_from_library(
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
limit = count if count > 0 else None
# #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,
)
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
return [r.asset.id for r in results]
# 默认 all 模式:返回全部 ready 视频素材
@@ -565,7 +394,6 @@ 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 模式(或叙事模式按标签匹配)时自动选取
@@ -580,7 +408,6 @@ 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(
@@ -683,13 +510,21 @@ 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,
@@ -1004,10 +839,14 @@ def create_generation_task(
else:
failed_tasks.append(task)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已改为软限制,此分支理论上不再触发;
# 极端并发兜底仍入队(safe_enqueue 内部会打 warning 日志),不 429 拒绝
logger.warning("[生成任务] 用户 pending 超软限制,仍允许入队: task_id=%s", task.id)
created_tasks.append(task)
# 兜底:如果预检查后又并发提交了,在这里也拦住
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
except GlobalQueueFull as _e:
failed_tasks.append(task)
if not created_tasks:
@@ -1052,14 +891,8 @@ def confirm_generation(
if source_task.project_id:
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
# 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:
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
if source_task.is_completed and getattr(source_task, "is_preview", False):
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
req_w = request.output_width or 0
req_h = request.output_height or 0
@@ -1074,25 +907,13 @@ 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 沿用计划已有值,不在此覆盖
@@ -1105,7 +926,7 @@ def confirm_generation(
)
logger.info(
"[确认生成] 复用预览产物(等待 finalize): task_id=%s, user_id=%s",
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
task_id,
authenticated_user.user.id,
)
@@ -1158,8 +979,10 @@ def confirm_generation(
):
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行
logger.warning("[任务] 用户 pending 超软限制,任务已入队")
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
) from None
except GlobalQueueFull as _e:
raise HTTPException(
status_code=503,
@@ -1172,67 +995,6 @@ 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),
@@ -1280,44 +1042,6 @@ 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)
@@ -1338,8 +1062,22 @@ def retry_generation_task(
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查(Bug B #2098):只保留全局 503,用户级不再硬拒
# 预检查:创建前判断,>= 上限就拒绝
user_pending = generation_task_repository.count_pending_by_user(user_id)
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,
@@ -1382,8 +1120,10 @@ def retry_generation_task(
):
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行
logger.warning("[任务] 用户 pending 超软限制,任务已入队")
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
) from None
except GlobalQueueFull as _e:
raise HTTPException(
status_code=503,
-215
View File
@@ -1,215 +0,0 @@
"""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")
+2 -9
View File
@@ -9,11 +9,6 @@ from fastapi.responses import JSONResponse
router = APIRouter(tags=["Health"])
def _pg_url(url: str) -> str:
"""Convert SQLAlchemy URL (postgresql+psycopg://...) to libpq connection string."""
return url.replace("postgresql+psycopg://", "postgresql://", 1).replace("postgresql+psycopg2://", "postgresql://", 1)
@router.get("/health", status_code=status.HTTP_200_OK)
async def health_check():
return {
@@ -54,7 +49,7 @@ async def _check_database() -> dict:
"message": "Using in-memory database",
}
try:
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
@@ -129,7 +124,7 @@ async def _check_migrations() -> dict:
"message": "Using in-memory database, no migrations needed",
}
try:
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur:
cur.execute("""
SELECT COUNT(*) FROM information_schema.tables
@@ -142,5 +137,3 @@ async def _check_migrations() -> dict:
return {"status": "unhealthy", "message": f"Missing tables, found {count}/5"}
except Exception as error:
return {"status": "unhealthy", "message": f"Migration check failed: {error}"}
+3 -9
View File
@@ -63,8 +63,6 @@ def _generation_step(task) -> str:
return "等待 Worker 执行"
if s == "running":
return "正在生成成片"
if s == "awaiting_cover":
return "等待确认封面"
if s == "completed":
return "生成完成"
if s == "failed":
@@ -131,7 +129,7 @@ def _validate_status(status: str | None) -> str | None:
"""校验状态值合法性。"""
if status is None:
return None
valid = {"pending", "running", "awaiting_cover", "completed", "failed", "cancelled"}
valid = {"pending", "running", "completed", "failed", "cancelled"}
if status not in valid:
raise HTTPException(
status_code=400,
@@ -153,9 +151,7 @@ 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/awaiting_cover/completed/failed/cancelled"
),
status: str | None = Query(None, description="按状态筛选:pending/running/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="每页数量"),
@@ -252,9 +248,7 @@ 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/awaiting_cover/completed/failed/cancelled"
),
status: str | None = Query(None, description="按状态筛选:pending/running/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="每页数量"),
@@ -682,50 +682,17 @@ 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 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:
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算 smart_match 综合评分,用于候选排序
smart_score, _ = score_asset(asset)
asset_smart_scores[asset_id] = smart_score
except Exception:
asset_smart_scores[asset_id] = 0.0
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
try:
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
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),
+28 -131
View File
@@ -191,23 +191,6 @@ 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,
@@ -307,43 +290,6 @@ 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,
@@ -407,7 +353,6 @@ 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]
@@ -461,7 +406,6 @@ async def prepare_direct_upload(
duplicated=False,
skip_transfer=False,
asset_id=pending_asset_id,
url="",
)
@@ -500,25 +444,12 @@ async def complete_direct_upload(
file_size=request.file_size,
)
if existing is not None:
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,
return DirectUploadCompleteResponse(
storage_key=existing.storage_key,
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
url=storage_service.get_url(existing.storage_key),
)
try:
@@ -548,24 +479,14 @@ async def complete_direct_upload(
)
# Issue #1776: 计数由 asset_repository.create() 自动维护
# 幂等保护:补提占位场景下可能已有 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,
)
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,
@@ -612,27 +533,12 @@ async def upload_asset(
file_size=0,
)
if existing is not None:
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,
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id="",
url="",
duplicated=True,
asset_id=existing.id,
)
file_id = uuid4().hex[:8]
@@ -668,23 +574,14 @@ async def upload_asset(
)
# Issue #1776: 计数由 asset_repository.create() 自动维护
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,
)
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,
-764
View File
@@ -1,764 +0,0 @@
"""爆款视频 API 路由。
v1.6 三步分步流水线端点(单次 Seedance 出片版):
POST /api/v1/viral-video/analyze-images 阶段1:创建任务 + 仅做图片/视频分析,暂停在 image_analyzed
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染,直到完成
旧端点(兼容保留,旧前端/一键生成模式):
POST /api/v1/viral-video/generate 一键入队,前半段跑到 wait_user_confirm
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
通用:
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
GET /api/v1/viral-video/history 历史记录
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
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 进度推送
"""
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 (
AnalyzeImagesRequest,
AnalyzeStyleRequest,
AnalyzeStyleResponse,
ConfirmCopyRequest,
ConfirmIntentRequest,
CreateViralVideoRequest,
GenerateCopyRequest,
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 _build_copy_result(job) -> dict | None:
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
"""
cr = getattr(job, "copy_result", None)
if isinstance(cr, dict) and cr:
out = dict(cr)
# 向后兼容字段
voiceover = out.get("voiceover_script", "") or ""
out.setdefault("final_copy", voiceover)
out.setdefault("suggested_copy", voiceover)
out.setdefault("title", "")
return out
# 兼容 v1.5 老数据:storyboard 是老格式 [{order,type,description,text,duration,...}]
copy_text = getattr(job, "generated_copy_text", "") or ""
sb = getattr(job, "storyboard", None) or []
intent = getattr(job, "intent_result", None) or {}
if not copy_text and not sb:
return None
title = ""
if isinstance(intent, dict):
title = intent.get("suggested_title") or intent.get("intent", "") or ""
shots = []
for seg in sb:
if isinstance(seg, dict):
shots.append(
{
"time_range": "",
"shot_type_angle_movement": seg.get("ken_burns", ""),
"scene_and_dialogue": (seg.get("text") or "")
+ (" " + seg.get("description", "") if seg.get("description") else ""),
"action_details": "",
"audio_bgm": "",
"transition": seg.get("transition", "硬切"),
"reference_image_index": None,
}
)
ratio = getattr(job, "video_ratio", None) or "9:16"
return {
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
"scene_and_lighting": "",
"shots": shots,
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
"voiceover_script": copy_text,
"final_copy": copy_text,
"suggested_copy": copy_text,
"title": title,
}
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 or 15,
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,
image_analysis=getattr(job, "image_analysis", None),
storyboard=getattr(job, "storyboard", None),
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
copy_result=_build_copy_result(job),
voice_id=getattr(job, "voice_id", "") or "",
voice_source=getattr(job, "voice_source", "") or "",
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
video_model=getattr(job, "video_model", "") or "",
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 or 15,
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,
voice_id=getattr(request, "voice_id", "") or "",
voice_source=getattr(request, "voice_source", "") or "",
video_ratio=getattr(request, "video_ratio", "9:16") or "9:16",
video_model=getattr(request, "video_model", "") or "",
copy_result=None,
)
# 持久化
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.post("/analyze-images", response_model=ViralVideoJobResponse)
def analyze_images(
request: AnalyzeImagesRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.5 阶段1:创建任务并仅做图片/视频 VLM 分析,跑完后状态=image_analyzed。
前端拿到 image_analysis(商品名/品牌/特征/颜色/材质等结构化结果)展示给用户;
用户填完营销参数后再调 /{id}/generate-copy 进入阶段2。
"""
from packages.domain.viral_video import ViralVideoJob
repo = _get_job_repo(session)
job = ViralVideoJob(
user_id=authenticated_user.user.id,
images=list(request.images),
reference_video_url=request.reference_video_url or "",
style_template_id=request.style_template_id or "",
style_strength=request.style_strength or "medium",
voice_id=request.voice_id or "",
voice_source=request.voice_source or "",
video_ratio=request.video_ratio or "9:16",
video_model=request.video_model or "",
duration=request.duration or 15,
)
repo.save(job)
try:
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
logger.info("[爆款视频][阶段1] analyze-images 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段1] analyze-images 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/generate-copy", response_model=ViralVideoJobResponse)
def generate_copy(
job_id: str,
request: GenerateCopyRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.6 阶段2:用户填完营销参数后,跑 意图解析 → 编导分镜脚本生成 → 合规审核。
跑完后状态=copy_generated,响应 copy_result(含 overview/scene_and_lighting/shots/
hard_constraints/negative_prompts/voiceover_script),前端展示脚本与口播供用户编辑;
确认/编辑后调 /{id}/confirm-copy 进入阶段3(TTS + 单次 Seedance 出片)。
"""
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 not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING, ViralVideoStatus.FAILED):
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能生成文案")
# 允许失败任务重试:重置
if job.status == ViralVideoStatus.FAILED:
job.retry_count += 1
job.error_msg = ""
# 把用户填的营销参数写到 job 上
job.industry = request.industry or job.industry
job.target_customer = request.target_customer or job.target_customer
job.persona_id = request.persona_id or job.persona_id
job.viral_structure = request.viral_structure or job.viral_structure
job.marketing_purpose = request.marketing_purpose or job.marketing_purpose
job.bgm_preference = request.bgm_preference or job.bgm_preference
if request.duration:
job.duration = max(5, min(30, int(request.duration)))
job.user_copy_text = request.user_copy_text if request.user_copy_text else job.user_copy_text
job.fusion_level = request.fusion_level or job.fusion_level
job.reference_audio_path = request.reference_audio_path or job.reference_audio_path
job.reference_video_url = request.reference_video_url or job.reference_video_url
job.style_strength = request.style_strength or job.style_strength
job.style_template_id = request.style_template_id or job.style_template_id
if request.style_guide is not None:
job.style_guide = request.style_guide
job.voice_id = request.voice_id or job.voice_id
job.voice_source = request.voice_source or job.voice_source
job.video_ratio = request.video_ratio or job.video_ratio or "9:16"
job.video_model = request.video_model or job.video_model or ""
job.resume_from_image_analyzed()
repo.update(job)
try:
celery_app.send_task("worker.run_viral_video_generate_copy", args=[job.id])
logger.info("[爆款视频][阶段2] generate-copy 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段2] generate-copy 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/confirm-copy", response_model=ViralVideoJobResponse)
def confirm_copy(
job_id: str,
request: ConfirmCopyRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.6 阶段3:用户确认/编辑口播后开始 TTS + 单次 Seedance 生成 + 上传。"""
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.COPY_GENERATED:
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能确认文案(需 copy_generated)")
job.resume_from_copy_generated(edited_copy=request.edited_copy or None)
repo.update(job)
try:
celery_app.send_task("worker.run_viral_video_render", args=[job.id])
logger.info("[爆款视频][阶段3] confirm-copy 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段3] confirm-copy 入队失败: %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=<jwt>`` 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": "",
"image_analyzed": "image_analysis",
"copy_generated": "review",
"wait_user_confirm": "intent_parsing",
"completed": "uploading",
"failed": "",
"cancelled": "",
}
_STATUS_PROGRESS = {
"pending": 0.0,
"running": 5.0,
"image_analyzed": 15.0,
"copy_generated": 70.0,
"wait_user_confirm": 35.0,
"completed": 100.0,
"failed": 0.0,
"cancelled": 0.0,
}
_STATUS_MESSAGE = {
"pending": "任务已创建,等待执行",
"running": "任务执行中",
"image_analyzed": "图片分析完成,等待填写营销参数",
"copy_generated": "文案与分镜已生成,等待确认文案",
"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, "任务准备中")
+33 -24
View File
@@ -6,7 +6,7 @@ from app.core.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
USER_PENDING_LIMIT = 20 # 单用户 pending 上限(#2098: 从 3 提到 20,支持批量任务自动排队)
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值)
@@ -154,18 +154,19 @@ 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: 单用户 pending 上限,默认 USER_PENDING_LIMIT
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
Raises:
GlobalQueueFull: 全局超限时抛出
GlobalQueueFull: 全局超限时抛出(优先级更高,先查全局)
UserPendingLimitExceeded: 用户超限时抛出
"""
# 先查全局(系统级保护优先级更高)
global_pending = generation_task_repository.count_pending_total()
@@ -178,9 +179,17 @@ def check_queue_limits(
)
raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
# #2098: 用户级限流改为软提示,不在预检查阶段拒绝(超额任务仍入队排队)。
# 真正的系统保护由全局 GLOBAL_PENDING_LIMIT 硬上限承担。
# UserPendingLimitExceeded 保留以兼容历史 import/except,但预检查与 safe_enqueue 均不再 raise。
# 再查用户级
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)
def _mark_task_failed_safely(
@@ -237,6 +246,7 @@ def safe_enqueue_generation_task(
Raises:
GlobalQueueFull: 全局 pending 超限时抛出,任务会被标记为 failed
UserPendingLimitExceeded: 用户 pending 超限时抛出,任务会被标记为 failed
"""
# ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ──
@@ -253,18 +263,19 @@ def safe_enqueue_generation_task(
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# Bug B #2098: 用户级限流改为软提示,不再硬拒;所有任务都入队等待 worker 自然消费。
# user_pending_limit 作为兜底阈值保留(默认 20),达到时打 warning 日志但仍入队,
# 避免极端情况下恶意用户无限堆积任务。真正的系统保护由全局 GLOBAL_PENDING_LIMIT 承担。
# 用户级限流检查(传了 user_id 才做)
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:
@@ -306,18 +317,16 @@ 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
# 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,
)
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)
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,
+2 -2
View File
@@ -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.effective_database_url)
ensure_database_exists(settings.DATABASE_URL)
engine, SessionLocal = build_session_factory(
settings.effective_database_url,
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
+1 -1
View File
@@ -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.effective_database_url)
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
def get_db_session() -> Generator[Session, None, None]:
-27
View File
@@ -13,33 +13,6 @@ 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):
"""创建生成任务请求。
-2
View File
@@ -29,8 +29,6 @@ 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):
-258
View File
@@ -1,258 +0,0 @@
"""爆款视频 API schemas (v1.6 单次 Seedance 出片版)。"""
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",
"script_generation",
"review",
"tts",
"rendering",
"uploading",
)
VALID_VIDEO_RATIOS = ("9:16", "16:9", "1:1", "4:3", "3:4", "21:9")
VALID_DURATIONS = (5, 10, 15, 20, 25, 30)
# -- 编导脚本结构(v1.6) --
class ShotScript(BaseModel):
"""逐镜头分镜。"""
time_range: str = Field(default="", description="时间区间,如 0-3秒")
shot_type_angle_movement: str = Field(default="", description="景别/角度/运镜,如『近景俯拍45度,缓慢推镜』")
scene_and_dialogue: str = Field(default="", description="场景描述+口播台词")
action_details: str = Field(default="", description="人物动作、表情、物品操作细节")
audio_bgm: str = Field(default="", description="环境音+BGM提示")
transition: str = Field(default="硬切", description="转场方式:硬切/淡入淡出/叠化")
reference_image_index: int | None = Field(
default=None, description="参考图片索引(0-based,对应上传的第几张产品图)"
)
class CopyResultOverview(BaseModel):
theme: str = ""
total_duration: int = 15
aspect_ratio: str = "9:16"
class CopyResult(BaseModel):
"""v1.6 编导分镜脚本结构(给前端 + Seedance 用)。"""
overview: CopyResultOverview = Field(default_factory=CopyResultOverview)
scene_and_lighting: str = ""
shots: list[ShotScript] = Field(default_factory=list)
hard_constraints: list[str] = Field(default_factory=list)
negative_prompts: list[str] = Field(default_factory=list)
voiceover_script: str = Field(
default="", description="纯口播对白,从各镜 scene_and_dialogue 的对白部分拼接,供 TTS 使用"
)
# 向后兼容:final_copy = voiceover_script
final_copy: str = ""
suggested_copy: str = ""
title: str = ""
# -- Request Schemas --
class CreateViralVideoRequest(BaseModel):
"""旧接口:一键创建(保留兼容)。"""
images: list[str] = Field(..., min_length=1, max_length=20)
industry: str = ""
target_customer: str = ""
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = Field(default=15, ge=5, le=30, description="视频时长(秒),5-30")
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
reference_video_url: str = ""
style_strength: str = "medium"
style_template_id: str = ""
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
@field_validator("fusion_level")
@classmethod
def _v_fl(cls, v: str) -> str:
if v == "full_ai":
return "ai_full"
if v not in VALID_FUSION_LEVELS:
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
return v
@field_validator("style_strength")
@classmethod
def _v_ss(cls, v: str) -> str:
if v not in VALID_STYLE_STRENGTHS:
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
return v
class AnalyzeImagesRequest(BaseModel):
"""v1.5+ 阶段1:创建任务 + 图片/视频分析。"""
images: list[str] = Field(..., min_length=1, max_length=30)
reference_video_url: str = ""
style_template_id: str = ""
style_strength: str = "medium"
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
duration: int = Field(default=15, ge=5, le=30)
class GenerateCopyRequest(BaseModel):
"""v1.5+ 阶段2:填完营销参数,生成编导脚本。"""
industry: str = ""
target_customer: str = ""
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = Field(default=15, ge=5, le=30)
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
reference_video_url: str = ""
style_strength: str = "medium"
style_template_id: str = ""
style_guide: dict | None = None
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
@field_validator("fusion_level")
@classmethod
def _v_fl(cls, v: str) -> str:
if v == "full_ai":
return "ai_full"
if v not in VALID_FUSION_LEVELS:
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
return v
@field_validator("style_strength")
@classmethod
def _v_ss(cls, v: str) -> str:
if v not in VALID_STYLE_STRENGTHS:
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
return v
class ConfirmCopyRequest(BaseModel):
"""v1.5+ 阶段3:用户确认/编辑口播后开始渲染(TTS+单次Seedance)。"""
edited_copy: str = Field(default="", description="用户编辑后的口播文案;为空则用 AI 生成的 voiceover_script")
class ConfirmIntentRequest(BaseModel):
"""旧 confirm-intent(兼容)。"""
confirmed_copy: str = ""
adjustments: str = ""
class AnalyzeStyleRequest(BaseModel):
reference_video_url: str = Field(..., description="参考视频 URL")
style_template_id: str = ""
# -- Response Schemas --
class ViralVideoJobResponse(BaseModel):
"""爆款视频任务响应(v1.6 包含 copy_result 编导脚本结构)。"""
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 = 15
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
image_analysis: dict | None = None
# v1.6 编导脚本(推荐前端使用)
copy_result: dict | None = None
# v1.5 兼容字段
storyboard: list | None = None
generated_copy_text: str = ""
# 音色/视频参数
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: 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):
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)
+13 -22
View File
@@ -999,35 +999,26 @@ 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/视觉/像素扰动)。
#2034:dedup_enabled=False 时跳过 visual_perturbation/pixel_perturbation,
保留 rhythm_template 和 BGM 池分配(合理的多变体差异,不属于降重扰动)。
"""
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。"""
upd: dict = {}
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)
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
@@ -1,142 +0,0 @@
"""视频生成任务 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
+20 -16
View File
@@ -160,14 +160,12 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
)
await page.goto("/app/generate")
// ── 页面标题 ─────────────────────────────────────────────────
// GenerateHeader: <h2><ThunderboltOutlined />智能剪辑</h2>
// SVG icon 可能干扰 role=heading 的 accessible name,用文本包含兜底
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
})
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
// h3 实际文案: "🎬 选择剪辑模式"(非 "选择模式"),用正则包含匹配
await expect(page.getByText(/选择剪辑模式/)).toBeVisible()
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await expect(page.getByText("随机混剪")).toBeVisible()
await page.getByRole("button", { name: /下一步/ }).click()
@@ -182,8 +180,11 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗:默认 1 个 → 确认 ───────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题 ──────────────────────────────────────────
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput).toBeVisible({ timeout: 5000 })
@@ -191,10 +192,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
// (#2024: Step4 不再显示"📋 生成配置"卡片,内容区仅显示进度/错误)
// 等待底部操作栏的「✨ 确认生成视频」按钮可见即可
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("随机混剪")).toBeVisible()
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn).toBeVisible({ timeout: 10000 })
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
const createTask = page.waitForResponse(
@@ -324,11 +324,12 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
)
await page.goto("/app/generate")
// ── 页面标题 ─────────────────────────────────────────────────
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
})
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
await expect(page.getByText(/选择剪辑模式/)).toBeVisible()
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await page.getByText("叙事剪辑").click()
await page.getByRole("button", { name: /下一步/ }).click()
@@ -350,8 +351,11 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗 ─────────────────────────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).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 })
@@ -359,9 +363,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
// (#2024: Step4 不再显示"📋 生成配置"卡片)
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("叙事剪辑")).toBeVisible()
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn2).toBeVisible({ timeout: 10000 })
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
const createTask2 = page.waitForResponse(
+1 -1
View File
@@ -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: 90_000, intervals: [3_000, 5_000, 10_000] },
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
)
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/)
-7
View File
@@ -4,13 +4,6 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 标题字体(#2001 / #font-selection 修复):Google Fonts CDN 引入中文字体,保证优设标题黑/抖音美好体/阿里普惠体等fallback可用 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&family=ZCOOL+QingKe+HuangYou&family=Ma+Shan+Zheng&family=Long+Cang&family=Liu+Jian+Mao+Cao&family=Zhi+Mang+Xing&display=swap"
rel="stylesheet"
/>
<title>小虾 SaaS - 自动化视频剪辑平台</title>
</head>
<body>
+3 -5
View File
@@ -12,8 +12,6 @@
"@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",
@@ -3007,9 +3005,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
-2
View File
@@ -23,8 +23,6 @@
"@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",
+115 -137
View File
@@ -18,16 +18,7 @@ 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(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
version: 1.18.1
react:
specifier: ^18.3.1
version: 18.3.1
@@ -52,7 +43,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.7(@testing-library/dom@10.4.1)
version: 14.6.1(@testing-library/dom@10.4.1)
"@types/node":
specifier: ^20.14.9
version: 20.19.43
@@ -64,34 +55,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(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)
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)
"@typescript-eslint/parser":
specifier: ^7.13.1
version: 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
version: 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@vitejs/plugin-react":
specifier: ^4.3.1
version: 4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))
version: 4.3.1(vite@5.3.1(@types/node@20.19.43))
"@vitest/coverage-v8":
specifier: ^1.6.1
version: 1.6.1(supports-color@7.2.0)(vitest@1.6.0)
version: 1.6.1(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(supports-color@7.2.0)
version: 8.57.0
eslint-config-prettier:
specifier: ^9.1.2
version: 9.1.2(eslint@8.57.0(supports-color@7.2.0))
version: 9.1.2(eslint@8.57.0)
eslint-plugin-react-hooks:
specifier: ^4.6.2
version: 4.6.2(eslint@8.57.0(supports-color@7.2.0))
version: 4.6.2(eslint@8.57.0)
eslint-plugin-react-refresh:
specifier: ^0.4.7
version: 0.4.26(eslint@8.57.0(supports-color@7.2.0))
version: 0.4.26(eslint@8.57.0)
jsdom:
specifier: ^24.1.0
version: 24.1.0(supports-color@7.2.0)
version: 24.1.0
prettier:
specifier: ^3.9.5
version: 3.9.5
@@ -103,7 +94,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(supports-color@7.2.0))(supports-color@7.2.0)
version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
packages:
"@adobe/css-tools@4.5.0":
@@ -1136,10 +1127,10 @@ packages:
"@types/react-dom":
optional: true
"@testing-library/user-event@14.6.7":
"@testing-library/user-event@14.6.1":
resolution:
{
integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==,
integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==,
}
engines: { node: ">=12", npm: ">=6" }
peerDependencies:
@@ -1681,10 +1672,10 @@ packages:
}
engines: { node: ">=18" }
dayjs@1.11.23:
dayjs@1.11.21:
resolution:
{
integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==,
integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==,
}
debug@4.4.3:
@@ -2555,13 +2546,6 @@ 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:
{
@@ -3877,20 +3861,20 @@ snapshots:
"@babel/compat-data@7.29.7": {}
"@babel/core@7.29.7(supports-color@7.2.0)":
"@babel/core@7.29.7":
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(supports-color@7.2.0))(supports-color@7.2.0)
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7)
"@babel/helpers": 7.29.7
"@babel/parser": 7.29.7
"@babel/template": 7.29.7
"@babel/traverse": 7.29.7(supports-color@7.2.0)
"@babel/traverse": 7.29.7
"@babel/types": 7.29.7
"@jridgewell/remapping": 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -3915,19 +3899,19 @@ snapshots:
"@babel/helper-globals@7.29.7": {}
"@babel/helper-module-imports@7.29.7(supports-color@7.2.0)":
"@babel/helper-module-imports@7.29.7":
dependencies:
"@babel/traverse": 7.29.7(supports-color@7.2.0)
"@babel/traverse": 7.29.7
"@babel/types": 7.29.7
transitivePeerDependencies:
- supports-color
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)":
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)":
dependencies:
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/helper-module-imports": 7.29.7(supports-color@7.2.0)
"@babel/core": 7.29.7
"@babel/helper-module-imports": 7.29.7
"@babel/helper-validator-identifier": 7.29.7
"@babel/traverse": 7.29.7(supports-color@7.2.0)
"@babel/traverse": 7.29.7
transitivePeerDependencies:
- supports-color
@@ -3948,14 +3932,14 @@ snapshots:
dependencies:
"@babel/types": 7.29.7
"@babel/plugin-transform-react-jsx-self@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)":
dependencies:
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/core": 7.29.7
"@babel/helper-plugin-utils": 7.29.7
"@babel/plugin-transform-react-jsx-source@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)":
dependencies:
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/core": 7.29.7
"@babel/helper-plugin-utils": 7.29.7
"@babel/runtime@7.29.7": {}
@@ -3966,7 +3950,7 @@ snapshots:
"@babel/parser": 7.29.7
"@babel/types": 7.29.7
"@babel/traverse@7.29.7(supports-color@7.2.0)":
"@babel/traverse@7.29.7":
dependencies:
"@babel/code-frame": 7.29.7
"@babel/generator": 7.29.7
@@ -3974,7 +3958,7 @@ snapshots:
"@babel/parser": 7.29.7
"@babel/template": 7.29.7
"@babel/types": 7.29.7
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -4060,17 +4044,17 @@ snapshots:
"@esbuild/win32-x64@0.21.3":
optional: true
"@eslint-community/eslint-utils@4.9.1(eslint@8.57.0(supports-color@7.2.0))":
"@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)":
dependencies:
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
eslint-visitor-keys: 3.4.3
"@eslint-community/regexpp@4.12.2": {}
"@eslint/eslintrc@2.1.4(supports-color@7.2.0)":
"@eslint/eslintrc@2.1.4":
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
espree: 9.6.1
globals: 13.19.0
ignore: 5.2.0
@@ -4083,10 +4067,10 @@ snapshots:
"@eslint/js@8.57.0": {}
"@humanwhocodes/config-array@0.11.14(supports-color@7.2.0)":
"@humanwhocodes/config-array@0.11.14":
dependencies:
"@humanwhocodes/object-schema": 2.0.3
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -4376,7 +4360,7 @@ snapshots:
"@types/react": 18.3.31
"@types/react-dom": 18.3.7(@types/react@18.3.31)
"@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)":
"@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)":
dependencies:
"@testing-library/dom": 10.4.1
@@ -4436,15 +4420,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(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/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)":
dependencies:
"@eslint-community/regexpp": 4.12.2
"@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/parser": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@typescript-eslint/scope-manager": 7.13.1
"@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/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/visitor-keys": 7.13.1
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
graphemer: 1.4.0
ignore: 5.3.1
natural-compare: 1.4.0
@@ -4454,14 +4438,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
"@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/parser@7.13.1(eslint@8.57.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(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
"@typescript-eslint/visitor-keys": 7.13.1
debug: 4.4.3(supports-color@7.2.0)
eslint: 8.57.0(supports-color@7.2.0)
debug: 4.4.3
eslint: 8.57.0
optionalDependencies:
typescript: 5.5.3
transitivePeerDependencies:
@@ -4472,12 +4456,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(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
"@typescript-eslint/type-utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
dependencies:
"@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)
"@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
ts-api-utils: 1.3.0(typescript@5.5.3)
optionalDependencies:
typescript: 5.5.3
@@ -4486,11 +4470,11 @@ snapshots:
"@typescript-eslint/types@7.13.1": {}
"@typescript-eslint/typescript-estree@7.13.1(supports-color@7.2.0)(typescript@5.5.3)":
"@typescript-eslint/typescript-estree@7.13.1(typescript@5.5.3)":
dependencies:
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/visitor-keys": 7.13.1
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.9
@@ -4501,13 +4485,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
"@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/utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
dependencies:
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0))
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0)
"@typescript-eslint/scope-manager": 7.13.1
"@typescript-eslint/types": 7.13.1
"@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)
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
eslint: 8.57.0
transitivePeerDependencies:
- supports-color
- typescript
@@ -4519,25 +4503,25 @@ snapshots:
"@ungap/structured-clone@1.3.3": {}
"@vitejs/plugin-react@4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))":
"@vitejs/plugin-react@4.3.1(vite@5.3.1(@types/node@20.19.43))":
dependencies:
"@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))
"@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)
"@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(supports-color@7.2.0)(vitest@1.6.0)":
"@vitest/coverage-v8@1.6.1(vitest@1.6.0)":
dependencies:
"@ampproject/remapping": 2.3.0
"@bcoe/v8-coverage": 0.2.3
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0)
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.5
magicast: 0.3.3
@@ -4545,7 +4529,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(supports-color@7.2.0))(supports-color@7.2.0)
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
transitivePeerDependencies:
- supports-color
@@ -4580,7 +4564,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(supports-color@7.2.0))(supports-color@7.2.0)
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
"@vitest/utils@1.6.0":
dependencies:
@@ -4599,21 +4583,21 @@ snapshots:
acorn@8.17.0: {}
agent-base@6.0.0(supports-color@7.2.0):
agent-base@6.0.0:
dependencies:
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
transitivePeerDependencies:
- supports-color
agent-base@7.0.2(supports-color@7.2.0):
agent-base@7.0.2:
dependencies:
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
transitivePeerDependencies:
- supports-color
agent-base@7.1.0(supports-color@7.2.0):
agent-base@7.1.0:
dependencies:
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -4648,7 +4632,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.23
dayjs: 1.11.21
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)
@@ -4664,7 +4648,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.23)(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-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)
@@ -4704,11 +4688,11 @@ snapshots:
asynckit@0.4.0: {}
axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0):
axios@1.18.1:
dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
follow-redirects: 1.16.0
form-data: 4.0.6
https-proxy-agent: 5.0.1(supports-color@7.2.0)
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
@@ -4812,13 +4796,11 @@ snapshots:
whatwg-mimetype: 4.0.0
whatwg-url: 14.0.0
dayjs@1.11.23: {}
dayjs@1.11.21: {}
debug@4.4.3(supports-color@7.2.0):
debug@4.4.3:
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
decimal.js@10.6.0: {}
@@ -4901,17 +4883,17 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@9.1.2(eslint@8.57.0(supports-color@7.2.0)):
eslint-config-prettier@9.1.2(eslint@8.57.0):
dependencies:
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
eslint-plugin-react-hooks@4.6.2(eslint@8.57.0(supports-color@7.2.0)):
eslint-plugin-react-hooks@4.6.2(eslint@8.57.0):
dependencies:
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
eslint-plugin-react-refresh@0.4.26(eslint@8.57.0(supports-color@7.2.0)):
eslint-plugin-react-refresh@0.4.26(eslint@8.57.0):
dependencies:
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
eslint-scope@7.2.2:
dependencies:
@@ -4920,20 +4902,20 @@ snapshots:
eslint-visitor-keys@3.4.3: {}
eslint@8.57.0(supports-color@7.2.0):
eslint@8.57.0:
dependencies:
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0))
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0)
"@eslint-community/regexpp": 4.12.2
"@eslint/eslintrc": 2.1.4(supports-color@7.2.0)
"@eslint/eslintrc": 2.1.4
"@eslint/js": 8.57.0
"@humanwhocodes/config-array": 0.11.14(supports-color@7.2.0)
"@humanwhocodes/config-array": 0.11.14
"@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(supports-color@7.2.0)
debug: 4.4.3
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -5037,9 +5019,7 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)):
optionalDependencies:
debug: 4.4.3(supports-color@7.2.0)
follow-redirects@1.16.0: {}
form-data@4.0.6:
dependencies:
@@ -5135,24 +5115,24 @@ snapshots:
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2(supports-color@7.2.0):
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.0(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
agent-base: 7.1.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@5.0.1(supports-color@7.2.0):
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.0(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
agent-base: 6.0.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.4(supports-color@7.2.0):
https-proxy-agent@7.0.4:
dependencies:
agent-base: 7.0.2(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
agent-base: 7.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -5206,10 +5186,10 @@ snapshots:
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6(supports-color@7.2.0):
istanbul-lib-source-maps@5.0.6:
dependencies:
"@jridgewell/trace-mapping": 0.3.31
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -5229,15 +5209,15 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@24.1.0(supports-color@7.2.0):
jsdom@24.1.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(supports-color@7.2.0)
https-proxy-agent: 7.0.4(supports-color@7.2.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.4
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.24
parse5: 7.1.2
@@ -5351,8 +5331,6 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
mp4box@2.4.1: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -5633,7 +5611,7 @@ snapshots:
react: 18.3.1
react-dom: 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-picker@4.11.3(dayjs@1.11.21)(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)
@@ -5644,7 +5622,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
dayjs: 1.11.23
dayjs: 1.11.21
rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@@ -6030,10 +6008,10 @@ snapshots:
dependencies:
react: 18.3.1
vite-node@1.6.0(@types/node@20.19.43)(supports-color@7.2.0):
vite-node@1.6.0(@types/node@20.19.43):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.3.1(@types/node@20.19.43)
@@ -6056,7 +6034,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(supports-color@7.2.0))(supports-color@7.2.0):
vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0):
dependencies:
"@vitest/expect": 1.6.0
"@vitest/runner": 1.6.0
@@ -6065,7 +6043,7 @@ snapshots:
"@vitest/utils": 1.6.0
acorn-walk: 8.3.5
chai: 4.3.10
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
execa: 8.0.1
local-pkg: 0.5.0
magic-string: 0.30.5
@@ -6076,12 +6054,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)(supports-color@7.2.0)
vite-node: 1.6.0(@types/node@20.19.43)
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(supports-color@7.2.0)
jsdom: 24.1.0
transitivePeerDependencies:
- less
- lightningcss
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

-2
View File
@@ -150,8 +150,6 @@ export interface DirectUploadPrepareResult {
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
*/
skip_transfer?: boolean
/** duplicated=true 时后端返回已存在素材的公网 URL,前端直接用而不必再调 complete */
url?: string
}
/** 直传完成确认返回 */
+6 -47
View File
@@ -3,24 +3,9 @@
*/
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
@@ -123,8 +108,6 @@ const putToOSS = (
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */
export interface DirectUploadHandle {
/** 实际使用的素材库(内部解析出来,便于调用方做后续 UI/缓存操作) */
library: { id: string; kind: "image" | "video" | "voice" }
/** prepare 返回(含可能的预建 asset_id) */
prepared: DirectUploadPrepareResult
/** 直传 OSS(可重复调用用于重试) */
@@ -136,17 +119,10 @@ 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
/** 素材库 ID;未传时按文件类型自动在默认项目下 ensure-default */
library_id?: string
/** 显式指定素材库 kind;未传时按 MIME/扩展名推断 */
kind?: "image" | "video" | "voice"
library_id: string
/** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */
fileHash?: string
/** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */
@@ -162,17 +138,9 @@ 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: resolvedLibraryId,
library_id: data.library_id,
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
@@ -181,13 +149,12 @@ 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: resolvedLibraryId,
library_id: data.library_id,
storage_key: prepared.storage_key,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
@@ -197,17 +164,10 @@ export const prepareDirectUploadHandle = async (data: {
}
}
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete
*
* P0 404 修复:library_id 可选;不传时内部按文件类型自动匹配正确项目下的素材库,
* 保证 project_id 与 library_id 必然一致。
*/
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
export const uploadAssetDirect = async (data: {
file: File
/** 素材库 ID;可选,不传按文件类型自动解析默认项目下的对应素材库(推荐用法) */
library_id?: string
/** 显式指定素材库 kind;未传时按文件 MIME/扩展名推断 */
kind?: "image" | "video" | "voice"
library_id: string
onProgress?: (percent: number) => void
/** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */
fileHash?: string
@@ -220,7 +180,6 @@ export const uploadAssetDirect = async (data: {
const handle = await prepareDirectUploadHandle({
file: data.file,
library_id: data.library_id,
kind: data.kind,
fileHash,
clientUploadId,
})
@@ -229,7 +188,7 @@ export const uploadAssetDirect = async (data: {
return {
storage_key: handle.prepared.storage_key,
ingest_job_id: "",
url: handle.prepared.url || "",
url: "",
duplicated: true,
asset_id: handle.prepared.asset_id,
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh
// 创建 Axios 实例
const apiClient = axios.create({
baseURL: "/api/v1",
timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
+9 -2
View File
@@ -3,7 +3,7 @@
* 后端路由: /api/v1/cover-templates
*/
import apiClient from "./client"
import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover"
import type { CoverTemplate } from "@/pages/generate/types/cover"
export interface CoverTemplateListResponse {
items: CoverTemplate[]
@@ -12,7 +12,14 @@ export interface CoverTemplateListResponse {
export interface CoverTemplateCreateRequest {
name: string
config?: CoverEditorConfig
config?: {
background_enabled?: boolean
background_color?: string
portrait_enabled?: boolean
title_text?: string
subtitle_text?: string
mask_enabled?: boolean
}
}
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
+2 -8
View File
@@ -51,18 +51,12 @@ export interface GenerateCoverResponse {
/** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */
export async function generateCover(
templateId: string | undefined | null,
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
// templateId 为空时不传该参数,让后端使用默认模板配置
// (前端此前用 "default" 作为占位符,该 id 不存在于后端模板库会 404)
const params: Record<string, string> = {}
if (templateId && templateId !== "default") {
params.template_id = templateId
}
const response = await apiClient.post<GenerateCoverResponse>("/generation/generate-cover", data, {
timeout: 300000,
params,
params: { template_id: templateId },
})
return response.data
}
-37
View File
@@ -1,37 +0,0 @@
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<FinalizeGenerationResponse> => {
const response = await apiClient.post<FinalizeGenerationResponse>(
`/generation/tasks/${taskId}/finalize`,
params,
)
return response.data
}
+1 -2
View File
@@ -3,8 +3,7 @@
*/
/** 任务状态 */
export type TaskStatus =
"pending" | "waiting" | "running" | "awaiting_cover" | "completed" | "failed" | "cancelled"
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
/** 任务类型 */
export type TaskType = "ingest" | "generation" | string
@@ -6,20 +6,17 @@ import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`, { timeout: 30_000 })
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消;超时 60s 防止大 config 写入失败) */
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
signal?: AbortSignal,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data, {
signal,
timeout: 60_000,
})
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
return response.data
}
-131
View File
@@ -1,131 +0,0 @@
import apiClient from "@/api/client"
import type {
GenerateViralVideoRequest,
HistoryResponse,
StyleTemplate,
ViralVideoJob,
ImageAnalysisResult,
CopyResult,
AnalyzeImagesRequest,
GenerateCopyRequest,
ConfirmCopyRequest,
} from "./types"
/** 创建爆款视频任务 */
export function generateViralVideo(payload: GenerateViralVideoRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/generate", payload).then((r) => r.data)
}
/** 查询单个任务 */
export function getViralVideoJob(id: string) {
return apiClient.get<ViralVideoJob>(`/viral-video/${id}`).then((r) => r.data)
}
/** 用户确认/修改 AI 理解的意图后继续 */
export function confirmViralVideoIntent(
id: string,
payload: { confirmed_copy?: string; edits?: Record<string, unknown> },
) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-intent`, payload)
.then((r) => r.data)
}
/** 重试失败任务 */
export function retryViralVideo(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/retry`).then((r) => r.data)
}
/** 历史记录(分页) */
export function getViralVideoHistory(params?: { page?: number; page_size?: number }) {
return apiClient.get<HistoryResponse>("/viral-video/history", { params }).then((r) => r.data)
}
/** 预设风格模板 */
export function getViralStyleTemplates() {
return apiClient.get<StyleTemplate[]>("/viral-video/style-templates").then((r) => r.data)
}
/** 上传参考视频后触发风格分析 */
export function analyzeViralStyle(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/analyze-style`).then((r) => r.data)
}
/** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */
/**
* 客户端图片分析 mock(后端未提供 analyze-only 端点前的占位方案):
* 基于已上传图片生成一份示例识别汇览,让 STEP1→STEP2 交互可走通。
* 后端上线后改为调用真实接口。
*/
export function mockImageAnalysis(images: { name: string }[]): Promise<ImageAnalysisResult> {
return new Promise((resolve) => {
setTimeout(() => {
const products = images.slice(0, 3).map((img, i) => {
const n = img.name.replace(/\.[^.]+$/, "")
return {
name: n || `商品 ${i + 1}`,
spec: i === 0 ? "500ml/瓶" : i === 1 ? "300g/盒" : undefined,
brand: i === 0 ? "示例品牌" : undefined,
features:
i === 0
? "瓶身透明、蓝色标签、白色瓶盖;标签上印有品牌Logo和产品名称;光线均匀,主体居中"
: i === 1
? "盒装包装、主色调为米白+暖黄;正面有产品实物图;文字清晰可辨"
: "产品主体清晰、背景干净、色彩鲜艳,突出核心卖点",
label_text: i === 0 ? "包装正面印有产品名称、净含量、品牌Logo" : undefined,
image_index: i,
}
})
resolve({ products })
}, 1800)
})
}
/**
* 客户端文案生成 mock(后端未提供 generate-copy 端点前的占位方案):
* 后端上线后改为调用真实接口。
*/
export function mockGenerateCopy(params: {
product: string
sellingPoints?: string[]
tone?: string
duration?: number
marketingPurpose?: string
industry?: string
targetCustomer?: string
}): Promise<CopyResult> {
return new Promise((resolve) => {
setTimeout(() => {
const product = params.product || "这款产品"
const tone = params.tone || "亲切务实"
const purpose = params.marketingPurpose || "品牌种草"
resolve({
title: `【${purpose}】${product},用过的人都说好!`,
final_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`,
suggested_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`,
})
}, 2200)
})
}
/** ── 三步拆分 v1.5 真实后端 API(PR #2117 合入后启用,前端可替换 mock 调用) ── */
/** 阶段1:上传图片后仅做 VLM 图片分析 + 可选参考视频风格分析,完成后状态=image_analyzed */
export function analyzeViralImages(payload: AnalyzeImagesRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/analyze-images", payload).then((r) => r.data)
}
/** 阶段2:用户填完营销参数后生成文案+分镜+合规审核,完成后状态=copy_generated,返回 copy_result */
export function generateViralCopy(id: string, payload: GenerateCopyRequest) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/generate-copy`, payload)
.then((r) => r.data)
}
/** 阶段3:用户确认/编辑文案后开始 TTS→渲染→上传,完成后状态=completed */
export function confirmViralCopy(id: string, payload: ConfirmCopyRequest = {}) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-copy`, payload)
.then((r) => r.data)
}
-298
View File
@@ -1,298 +0,0 @@
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: "像素级复刻" },
]
/** v1.6 前端时长下拉选项(5/10/15/20/25/30秒) */
export const VALID_DURATIONS = [5, 10, 15, 20, 25, 30] as const
export type VideoDuration = (typeof VALID_DURATIONS)[number]
/** v1.6 支持的画幅比例 */
export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
export type VideoRatio = (typeof VALID_RATIOS)[number]
export type ViralVideoStatus =
| "pending"
| "running"
| "wait_user_confirm"
| "image_analyzed"
| "copy_generated"
| "completed"
| "failed"
| "cancelled"
/**
* v1.6 后端流水线阶段。单次 Seedance 出片版:
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
*/
export type ViralVideoStage =
| "image_analysis"
| "video_analysis"
| "intent_parsing"
| "script_generation"
| "review"
| "tts"
| "rendering"
| "uploading"
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
export function isImageAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && IMAGE_ANALYSIS_STAGES.has(stage)
}
export function isCopyStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && COPY_STAGES.has(stage)
}
export function isVideoStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && VIDEO_STAGES.has(stage)
}
/** 兼容旧调用:分析图片+生成文案 的所有前置阶段 */
export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return isImageAnalysisStage(stage) || isCopyStage(stage)
}
/** 单张图片 VLM 识别出的商品信息 */
export interface ImageProductAnalysis {
name?: string
category?: string
brand?: string
colors?: string[]
material_or_texture?: string
key_features?: string[]
visual_style?: string
scene?: string
target_audience_hint?: string
text_on_image?: string
/** 旧字段兼容 */
spec?: string
features?: string[] | string
label_text?: string
selling_points?: string
image_index?: number
}
export interface ImageAnalysisResult {
products?: ImageProductAnalysis[]
}
/** v1.6 编导分镜脚本 - 单镜头 */
export interface ShotScript {
/** 时间区间,如 "0-3秒" */
time_range?: string
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
shot_type_angle_movement?: string
/** 场景描述+对白 */
scene_and_dialogue?: string
/** 人物动作/表情/物品操作细节 */
action_details?: string
/** 环境音+BGM提示 */
audio_bgm?: string
/** 转场方式(硬切/淡入淡出/叠化/结束) */
transition?: string
/** 参考图片索引(0-based,对应上传产品图数组) */
reference_image_index?: number | null
}
/** v1.6 编导分镜脚本 - 总览 */
export interface CopyResultOverview {
theme?: string
total_duration?: number
aspect_ratio?: string
}
/** v1.6 编导分镜脚本(核心输出结构,给 Seedance 做 prompt,给 TTS 取 voiceover_script) */
export interface CopyResult {
overview?: CopyResultOverview
/** 整体场景+光线描述 */
scene_and_lighting?: string
/** 逐镜头时间轴 */
shots?: ShotScript[]
/** 硬性约束(禁止字幕/水印/变形等) */
hard_constraints?: string[]
/** 负面提示词 */
negative_prompts?: string[]
/** 完整口播稿(纯文本,用于 TTS 合成) */
voiceover_script?: string
/** 向后兼容:= voiceover_script */
final_copy?: string
/** 向后兼容:= voiceover_script */
suggested_copy?: string
title?: string
/** v1.5 旧字段兼容(老数据降级时可能出现) */
scenes?: Array<{ shot: string; narration: string; duration?: number }>
}
export interface StyleTemplate {
id: string
name: string
description?: string
thumbnail_url?: string
style_config?: Record<string, unknown>
tags?: string[]
}
export interface IntentResult {
intent?: string
key_messages?: string[]
tone?: string
target_emotion?: string
call_to_action?: string
suggested_title?: string
/** v1.5 旧字段兼容 */
product?: string
selling_points?: string[]
target_audience?: string
structure?: string
duration?: number
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 | Record<string, unknown>
user_copy_text?: string
/** v1.6: = copy_result.voiceover_script(从 copy_result 派生,向后兼容) */
final_copy_text?: string
generated_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_mode?: "global" | "per_video"
voice_source?: "preset" | "library" | "clone" | "upload"
bgm_preference?: string
intent_result?: IntentResult
intent_text?: string
/** v1.6 编导分镜脚本(核心产物) */
copy_result?: CopyResult
/** 向后兼容:= copy_result.shots */
storyboard?: ShotScript[]
image_analysis?: ImageAnalysisResult
/** 视频比例:9:16 / 16:9 / 1:1,默认 9:16 */
video_ratio?: string
/** Seedance 模型 ID(空=后端默认) */
video_model?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
progress_stage?: ViralVideoStage
progress_percent?: number
progress_message?: string
output_url?: string
result_video_url?: string
error_message?: string
error_msg?: string
credits_cost?: number
created_at?: string
updated_at?: string
}
export interface GenerateViralVideoRequest {
images: string[]
reference_video_url?: string
douyin_url?: string
style_strength?: StyleStrength
style_template_id?: string
user_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_source?: "preset" | "library" | "clone" | "upload"
bgm_preference?: string
industry?: string
target_customer?: string
language?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
/** 视频时长(5-30秒,默认15) */
duration?: number
video_model?: string
video_ratio?: string
/** 三步拆分:step 控制后端执行到哪一步暂停 */
step?: "analyze" | "generate_copy" | "generate_video"
}
export interface HistoryResponse {
items: ViralVideoJob[]
total: number
page: number
page_size: number
}
/** v1.6 阶段1请求:图片/视频分析(POST /viral-video/analyze-images) */
export interface AnalyzeImagesRequest {
images: string[]
reference_video_url?: string
style_template_id?: string
style_strength?: StyleStrength
/** TTS 音色 ID(STEP1 已选音色时传) */
voice_id?: string
/** 音色来源:preset | library | clone | upload */
voice_source?: "preset" | "library" | "clone" | "upload"
/** Seedance 视频比例:9:16 | 16:9 | 1:1 */
video_ratio?: string
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
}
/** v1.6 阶段2请求:填完营销参数后生成编导分镜脚本(POST /viral-video/{id}/generate-copy) */
export interface GenerateCopyRequest {
industry?: string
target_customer?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
bgm_preference?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
user_copy_text?: string
fusion_level?: FusionLevel
reference_audio_path?: string
reference_video_url?: string
style_strength?: StyleStrength
style_template_id?: string
style_guide?: string | Record<string, unknown>
/** TTS 音色 ID(优先级高于 persona_id) */
voice_id?: string
/** 音色来源:preset | library | clone | upload */
voice_source?: "preset" | "library" | "clone" | "upload"
/** Seedance 视频比例(9:16/16:9/1:1 等) */
video_ratio?: string
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
}
/** v1.6 阶段3请求:用户确认/编辑口播文案后开始单次 Seedance 出片(POST /viral-video/{id}/confirm-copy) */
export interface ConfirmCopyRequest {
/** 用户编辑后的口播文案;为空则使用 AI 生成的 voiceover_script */
edited_copy?: string
}
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
export interface StoryboardSegment {
order: number
type: string
description: string
text: string
duration: number
ken_burns?: string
transition?: string
}
-563
View File
@@ -1,563 +0,0 @@
/**
* 共享封面编辑器样式(智能剪辑 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%);
}
-2
View File
@@ -1,2 +0,0 @@
export { useSharedCover } from "./useSharedCover"
export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover"
@@ -1,315 +0,0 @@
/**
* 共享封面选择 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<string | null | undefined>
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<void>
handleDeleteTemplate: (id: string) => Promise<void>
generating: boolean
generateAutoCover: () => Promise<void>
uploadInputRef: React.RefObject<HTMLInputElement>
handleUploadClick: () => void
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
setOnUploadFile: (fn: (file: File) => Promise<string | null> | 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<string>(initialTemplateId)
const [editingTemplate, setEditingTemplate] = useState<CoverTemplate | null>(null)
const [templates, setTemplates] = useState<CoverTemplate[]>([])
const [templatesLoading, setTemplatesLoading] = useState(false)
const [templatesError, setTemplatesError] = useState<string | null>(null)
const uploadInputRef = useRef<HTMLInputElement>(null)
const onUploadFileRef = useRef<
((file: File) => Promise<string | null> | string | null) | undefined
>(undefined)
const setOnUploadFile = useCallback(
(fn: (file: File) => Promise<string | null> | 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<HTMLInputElement>) => {
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
@@ -2,7 +2,6 @@
export const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/dashboard": "首页",
"/app/generate": "智能剪辑",
"/app/viral-video": "爆款视频",
"/app/assets": "视频库",
"/app/voices": "配音库",
"/app/products": "成片库",
@@ -1,271 +0,0 @@
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleStyleSettings } from "@/components/title/settings"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleStyleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
background?: string
/** 高度(可选,默认按 portrait 选比例) */
height?: number
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
transparent?: boolean
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
portrait?: boolean
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
transparent = false,
portrait = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
const text = (sampleText || "预览标题").trim() || "预览标题"
useEffect(() => {
let cancelled = false
const draw = () => {
if (cancelled) return
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景(transparent 时跳过,用于叠加在图片上)
if (!transparent) {
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
}
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致
const r = previewR
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
const botMargin = portrait ? r(24) : r(16)
startY = h - totalH - botMargin + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色,
// 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」
ctx.fillStyle = settings.color
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}
// 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用)
const previewScale = width / 360
const previewR = (v: number) => Math.round(v * previewScale)
const buildFontString = () => {
const size = previewR(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
return parts.join(" ")
}
// Web Font 加载保障:
// 1) 等 document.fonts.ready(CSS @font-face 首次可用)
// 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载
// 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块
const doDrawWhenReady = async () => {
try {
if (typeof document !== "undefined" && document.fonts) {
await document.fonts.ready
try {
await document.fonts.load(buildFontString(), text)
} catch {
/* ignore */
}
}
} finally {
if (!cancelled) draw()
}
}
doDrawWhenReady()
return () => {
cancelled = true
}
}, [settings, width, h, text, transparent, portrait, background])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background: transparent ? "transparent" : background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -1,458 +0,0 @@
/* ============================================================
TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦)
从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」
两个页面共用。AI数字人页面不引入 generate.css,直接由
TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、
配色描边、选中态与智能剪辑页面完全一致。
注意:本文件规则与 generate.css 中同名规则一一对应、取值相同;
智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。
============================================================ */
/* ── 区块容器 ── */
.xx-title-style-section {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid var(--border-light);
}
.xx-section-subtitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
.xx-title-style-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.xx-half-field {
margin-bottom: 0;
}
.xx-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.xx-field-label-row label {
margin-bottom: 0;
}
.xx-field-value {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
}
/* ── 共用表单字段(位置/字体下拉) ── */
.xx-title-style-section .xx-form-field {
margin-bottom: 14px;
}
.xx-title-style-section .xx-form-field:last-child {
margin-bottom: 0;
}
.xx-title-style-section .xx-form-field label {
display: block;
font-weight: 600;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select,
.xx-title-style-section .xx-form-field input {
width: 100%;
height: 44px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 14px;
font-size: 14px;
outline: 0;
transition: 0.15s ease;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select:focus,
.xx-title-style-section .xx-form-field input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* ── 字号滑块 ── */
.xx-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.xx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
.xx-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
/* ── 标题预设卡片网格(24 个 T 格子) ── */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(6, 52px);
gap: 1px;
}
.xx-title-preset-card {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: #404040;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.xx-title-preset-card:hover {
border-color: #666;
background: #4d4d4d;
}
.xx-title-preset-card.active {
border-color: #409eff;
background: #4d4d4d;
}
.xx-title-preset-preview-text {
font-size: 32px;
line-height: 1;
user-select: none;
}
/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */
.xx-style-btns {
display: flex;
gap: 8px;
}
.xx-style-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
cursor: pointer;
font-size: 15px;
color: var(--text-secondary);
transition: all 0.15s;
}
.xx-style-btn:hover {
border-color: var(--primary-300);
color: var(--primary-color);
}
.xx-style-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
#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;
}
@@ -1,445 +0,0 @@
/**
* 标题样式参数 Tab 面板(共享组件)
*
* 包含:基础/描边/阴影/背景/排版/封面 共 6 个 Tab
* 仅负责 UI 渲染和参数 patch 回调,不维护 state、不调 API
*/
import React, { useState } from "react"
import { Tabs } from "antd"
import type { TitleStyleSettings } from "./settings"
import {
FONT_OPTIONS,
TITLE_COLOR_PALETTE,
STROKE_COLOR_PALETTE,
BG_COLOR_PALETTE,
} from "./constants"
export interface PositionOption {
value: string
label: string
}
export interface FontOption {
value: string
label: string
family: string
tag?: "hot" | "new"
}
export interface TitleStyleParamsTabProps {
settings: TitleStyleSettings
onUpdatePosition: (p: string) => void
onUpdateFont: (f: string) => void
onUpdateSize: (v: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onUpdatePatch: (patch: Partial<TitleStyleSettings>) => void
positionOptions: PositionOption[]
fontOptions?: FontOption[]
/** 是否显示「封面」Tab(独立封面标题开关) */
showCoverToggle?: boolean
/** 封面独立标题开关状态 */
coverEnabled?: boolean
/** 封面开关变化 */
onToggleCover?: (enabled: boolean) => void
}
/* ── 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 }) => (
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>{label}</label>
<span className="ts-field-value">
{value}
{unit}
</span>
</div>
<input
type="range"
className="ts-slider"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
)
/* ── 色板 ── */
const ColorPicker: React.FC<{
label?: string
value: string
palette: string[]
onChange: (c: string) => void
}> = ({ label, value, palette, onChange }) => {
const [customOpen, setCustomOpen] = useState(false)
return (
<div className="ts-form-field">
{label && <label>{label}</label>}
<div className="ts-color-row">
{palette.map((c) => (
<button
key={c}
type="button"
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
style={{ background: c }}
onClick={() => onChange(c)}
title={c}
/>
))}
<button
type="button"
className="ts-color-swatch ts-color-custom"
onClick={() => setCustomOpen((v) => !v)}
title="自定义颜色"
>
+
</button>
<input
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => {
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",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
当前:<code style={{ fontSize: 11 }}>{value}</code>
</div>
</div>
)
}
const TitleStyleParamsTab: React.FC<TitleStyleParamsTabProps> = ({
settings,
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onUpdatePatch,
positionOptions,
fontOptions = FONT_OPTIONS,
showCoverToggle = false,
coverEnabled = false,
onToggleCover,
}) => {
const upd = onUpdatePatch
return (
<Tabs
size="small"
defaultActiveKey="basic"
items={[
{
key: "basic",
label: "基础",
children: (
<>
<div className="ts-row-2">
<div className="ts-form-field ts-half">
<label>位置</label>
<select
className="ts-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{positionOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="ts-form-field ts-half">
<label>字体</label>
<select
className="ts-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{fontOptions.map((f) => (
<option key={f.value} value={f.value}>
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
{f.label}
</option>
))}
</select>
</div>
</div>
<SliderRow
label="字号"
value={settings.size}
min={16}
max={120}
onChange={onUpdateSize}
/>
<div className="ts-form-field">
<label>样式</label>
<div className="ts-style-btns">
<button
type="button"
className={`ts-style-btn${settings.bold ? " active" : ""}`}
onClick={onToggleBold}
>
<b>B</b>
</button>
<button
type="button"
className={`ts-style-btn${settings.italic ? " active" : ""}`}
onClick={onToggleItalic}
>
<i>I</i>
</button>
<button
type="button"
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
onClick={() => {
onToggleStroke()
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2)
upd({ strokeWidth: 4 })
}}
title="描边"
>
S
</button>
<button
type="button"
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
onClick={() => {
onToggleShadow()
if (!settings.shadow) {
upd({
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
})
}
}}
title="阴影"
>
☁
</button>
</div>
</div>
<ColorPicker
label="字色"
value={settings.color}
palette={TITLE_COLOR_PALETTE}
onChange={(c) => upd({ color: c })}
/>
</>
),
},
{
key: "stroke",
label: "描边",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
启用描边
</label>
</div>
{settings.stroke && (
<>
<SliderRow
label="描边宽度"
value={settings.strokeWidth ?? 4}
min={0}
max={20}
onChange={(v) => upd({ strokeWidth: v })}
/>
<ColorPicker
label="描边颜色"
value={settings.strokeColor ?? "#000000"}
palette={STROKE_COLOR_PALETTE}
onChange={(c) => upd({ strokeColor: c })}
/>
</>
)}
</>
),
},
{
key: "shadow",
label: "阴影",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
启用阴影
</label>
</div>
{settings.shadow && (
<>
<SliderRow
label="X偏移"
value={settings.shadowOffsetX ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetX: v })}
/>
<SliderRow
label="Y偏移"
value={settings.shadowOffsetY ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetY: v })}
/>
<SliderRow
label="模糊半径"
value={settings.shadowBlur ?? 4}
min={0}
max={30}
onChange={(v) => upd({ shadowBlur: v })}
/>
<div className="ts-form-field">
<label>阴影颜色</label>
<input
type="text"
className="ts-input"
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
onChange={(e) => upd({ shadowColor: e.target.value })}
placeholder="rgba(0,0,0,0.8)"
/>
</div>
</>
)}
</>
),
},
{
key: "bg",
label: "背景",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={settings.bgEnabled}
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
/>
启用背景色块
</label>
</div>
{settings.bgEnabled && (
<>
<ColorPicker
label="背景颜色(含透明度)"
value={settings.bgColor}
palette={BG_COLOR_PALETTE}
onChange={(c) => upd({ bgColor: c })}
/>
<SliderRow
label="内边距"
value={settings.bgPadding}
min={0}
max={40}
onChange={(v) => upd({ bgPadding: v })}
/>
<SliderRow
label="圆角"
value={settings.bgRadius}
min={0}
max={30}
onChange={(v) => upd({ bgRadius: v })}
/>
</>
)}
</>
),
},
{
key: "layout",
label: "排版",
children: (
<>
<SliderRow
label="每行最大字符数"
value={settings.maxCharsPerLine ?? 0}
min={0}
max={20}
unit=""
onChange={(v) => upd({ maxCharsPerLine: v })}
/>
<div
className="ts-form-field"
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
>
0 = 不自动换行(按 / 手动分行)
</div>
<SliderRow
label="行距倍数"
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
min={1}
max={2}
step={0.05}
unit=""
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
/>
<SliderRow
label="顶部边距"
value={settings.marginTop ?? 24}
min={0}
max={200}
onChange={(v) => upd({ marginTop: v })}
/>
</>
),
},
...(showCoverToggle
? [
{
key: "cover",
label: "封面",
children: (
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={coverEnabled}
onChange={(e) => onToggleCover?.(e.target.checked)}
/>
封面使用独立标题样式
</label>
</div>
),
},
]
: []),
]}
/>
)
}
export default TitleStyleParamsTab
@@ -1,391 +0,0 @@
/* ============================================================
标题模板系统 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;
}
}
@@ -1,167 +0,0 @@
/**
* 标题模板编辑器(公共组件)
*
* - 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<TitleStyleConfig> }) => void
}
/** 编辑器预览用的背景图(复用卡片池第一张) */
const EDITOR_BG = "/title-templates/portrait1.jpg"
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
const [settings, setSettings] = useState<TitleStyleSettings>(() => ({
...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<TitleStyleSettings>) => 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<TitleStyleSettings>(
() => ({ ...settings, size: Math.round(settings.size * 0.55) }),
[settings],
)
return (
<Modal
title={
!template.id
? "新建模板"
: template.isBuiltin
? `基于「${template.name}」创建模板`
: `编辑模板:${template.name}`
}
open={open}
onCancel={onClose}
width={860}
footer={
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" loading={saving} onClick={handleSave}>
保存模板
</Button>
</div>
}
destroyOnClose
className="ttv3-modal"
styles={{ body: { padding: 0 } }}
>
<div className="ttv3-editor">
{/* 左侧:竖屏预览 + 名称 */}
<div className="ttv3-editor-left">
<div className="ttv3-editor-canvas">
<img className="ttv3-editor-bg" src={EDITOR_BG} alt="" />
<div className="ttv3-vignette" />
<div className="ttv3-editor-canvas-inner">
<TitleMiniPreview
settings={previewSettings}
width={200}
sampleText="预览标题文字"
transparent
portrait
/>
</div>
</div>
<div className="ttv3-editor-form">
<div className="ttv3-form-row">
<label>图标</label>
<Input
value={formEmoji}
maxLength={2}
style={{ textAlign: "center", width: 64 }}
onChange={(e) => setFormEmoji(e.target.value)}
/>
</div>
<div className="ttv3-form-row ttv3-form-row--grow">
<label>
模板名称<span style={{ color: "#ef4444" }}>*</span>
</label>
<Input
placeholder="给模板起个名字"
value={formName}
maxLength={20}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
</div>
</div>
{/* 右侧:参数 Tab */}
<div className="ttv3-editor-right">
<TitleStyleParamsTab
settings={settings}
onUpdatePosition={(p) => 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}
/>
</div>
</div>
</Modal>
)
}
export default TitleTemplateEditor
@@ -1,343 +0,0 @@
/**
* 标题模板选择器 — 大卡片网格(共享组件)
*
* 渲染「我的模板」+「系统模板」两个分组的 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 (
<div ref={setRef} className="tt-fill-canvas-wrap">
{w > 0 && (
<TitleMiniPreview
settings={settings}
width={w}
sampleText={sampleText}
transparent
portrait={portrait}
/>
)}
</div>
)
}
const TitleTemplateSelector: React.FC<TitleTemplateSelectorProps> = ({
value,
onChange,
showEditor = true,
categories = ["system", "custom"],
context,
}) => {
const {
templates,
createTemplate,
duplicateTemplate,
updateTemplate,
deleteTemplate,
exportTemplate,
} = useTitleTemplates()
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(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<import("./types").TitleStyleConfig> }) => {
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 (
<div
key={t.id}
className={`ttv3-card${isSelected ? " selected" : ""}`}
onClick={() => handleSelectTemplate(t)}
data-context={context}
>
<div className="ttv3-preview">
<img className="ttv3-bg" src={bgImg} alt="" />
<div className="ttv3-vignette" />
<FillPreview settings={preview} sampleText="预览标题文字" portrait />
<span className={`ttv3-badge ttv3-badge--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
<span className={`ttv3-check${isSelected ? " on" : ""}`}>
{isSelected && <CheckOutlined />}
</span>
</div>
<div className="ttv3-footer">
<div className="ttv3-name-row">
<span className="ttv3-emoji">{t.emoji || "✨"}</span>
<span className="ttv3-name" title={t.name}>
{t.name}
</span>
<span className={`ttv3-tag ttv3-tag--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
</div>
{showEditor && (
<div className="ttv3-actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="ttv3-act ttv3-act--primary"
disabled={t.isBuiltin}
onClick={() => handleRequestEdit(t)}
title={t.isBuiltin ? "系统模板不可编辑,点击复制后可编辑" : "编辑"}
>
<EditOutlined /> 编辑
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleDuplicate(t)}
title="复制"
>
<CopyOutlined /> 复制
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleExport(t)}
title="导出"
>
<ExportOutlined /> 导出
</button>
<Popconfirm title="删除该模板?" onConfirm={() => handleDelete(t)}>
<button
type="button"
className="ttv3-act ttv3-act--danger"
disabled={t.isBuiltin}
title={t.isBuiltin ? "系统模板不可删除" : "删除"}
>
<DeleteOutlined /> 删除
</button>
</Popconfirm>
</div>
)}
</div>
</div>
)
}
return (
<div className="xx-title-style-section ttv3-panel">
<div className="ttv3-header">
<span className="ttv3-title">标题模板</span>
{showEditor && (
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleRequestCreate}
className="ttv3-new-btn"
>
新建模板
</Button>
)}
</div>
{showMine && (
<div className="ttv3-section">
<div className="ttv3-section-label">我的模板</div>
{grouped.custom.length === 0 ? (
<div className="ttv3-empty">
<div className="ttv3-empty-icon">✨</div>
<div className="ttv3-empty-text">还没有自定义模板,点右上角「新建模板」创建</div>
</div>
) : (
<div className="ttv3-grid">
{grouped.custom.map((t, i) => renderCard(t, i, "mine"))}
</div>
)}
</div>
)}
{showSys && (
<div className="ttv3-section">
<div className="ttv3-section-label">系统模板</div>
<div className="ttv3-grid">{grouped.builtin.map((t, i) => renderCard(t, i, "sys"))}</div>
</div>
)}
{showEditor && editorOpen && editingTemplate && (
<TitleTemplateEditor
open={editorOpen}
template={editingTemplate}
onClose={() => {
setEditorOpen(false)
setEditingTemplate(null)
}}
onSave={handleEditorSave}
/>
)}
</div>
)
}
export default TitleTemplateSelector
+9 -40
View File
@@ -20,84 +20,54 @@ 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',
'"YouShe Title Black","YouSheBiaoTiHei","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",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',
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",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',
family: '"Douyin Sans","DouyinSans","Source Han Sans SC","PingFang SC",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',
'"Source Han Sans SC Heavy","Noto Sans SC","Source Han Sans CN Heavy","PingFang SC",sans-serif',
tag: "new",
},
{
value: "思源黑体",
label: "思源黑体",
family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
family: '"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
},
{
value: "思源宋体",
label: "思源宋体",
family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif',
family: '"Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif',
},
{
value: "苹方",
label: "苹方",
family:
'"PingFang SC",-apple-system,blinkmacsystemfont,"Helvetica Neue","Noto Sans SC",sans-serif',
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
},
{
value: "微软雅黑",
label: "微软雅黑",
family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
family: '"Microsoft YaHei","PingFang 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',
family: '"KaiTi","STKaiti","DFKai-SB",serif',
},
]
@@ -157,7 +127,6 @@ export interface TitlePreset {
}
const BASE: Partial<TitleStyleConfig> = {
position: "bottom",
line_overrides: [],
cover_title_config: null,
}
-19
View File
@@ -1,19 +0,0 @@
/**
* 公共标题模板/样式组件统一导出
*
* 任何页面需要标题样式配置/模板选择/模板编辑,从这里 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"
@@ -1,14 +0,0 @@
/**
* 标题位置选项(公共常量)
*/
export interface PositionOption {
value: string
label: string
}
export const POSITION_OPTIONS: PositionOption[] = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
-64
View File
@@ -1,64 +0,0 @@
/**
* 标题样式设置 — 公共 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: [],
}
@@ -1,28 +0,0 @@
/**
* 标题模板系统类型(#2003)
*
* 设计:
* - 内置系统模板(从 TITLE_PRESETS 自动派生,不可编辑/删除,可"复制为我的")
* - 用户自定义模板(保存在 localStorage,可编辑/复制/导出/删除)
* - 模板存完整 TitleStyleConfig(snake_case),与后端契约一致
*/
import type { TitleStyleConfig } from "./types"
export interface TitleTemplate {
/** 唯一 ID:系统模板 `sys:<presetKey>`,用户模板 `usr:<uuid>` */
id: string
/** 模板名称(必填,保存时校验) */
name: string
/** 模板描述(可选) */
description?: string
/** 是否为系统内置(不可删除/编辑源) */
isBuiltin: boolean
/** emoji(展示用,可选) */
emoji?: string
/** 创建时间(ISO 字符串,系统模板=固定值) */
createdAt: string
/** 更新时间 */
updatedAt: string
/** 完整样式配置(snake_case,与后端 title_config 对齐) */
style: Partial<TitleStyleConfig>
}
@@ -1,213 +0,0 @@
/**
* 标题模板 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<TitleStyleConfig>
}) => TitleTemplate
/** 复制模板(内置模板也可复制,自动加"副本"后缀) */
duplicateTemplate: (id: string) => TitleTemplate | null
/** 更新自定义模板(系统模板不可改) */
updateTemplate: (id: string, patch: Partial<Omit<TitleTemplate, "id" | "isBuiltin">>) => 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<TitleTemplate[]>(() => 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<UseTitleTemplatesResult["createTemplate"]>(
({ 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<UseTitleTemplatesResult["duplicateTemplate"]>(
(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<UseTitleTemplatesResult["updateTemplate"]>(
(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<UseTitleTemplatesResult["deleteTemplate"]>(
(id) => {
if (id.startsWith("sys:")) return
persist(custom.filter((t) => t.id !== id))
},
[custom, persist],
)
const exportTemplate = useCallback<UseTitleTemplatesResult["exportTemplate"]>(
(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<UseTitleTemplatesResult["importTemplate"]>(
(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,
}
}
-126
View File
@@ -1,126 +0,0 @@
/**
* 标题样式工具(#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<TitleStyleConfig>): Partial<TitleStyleSettings> {
const out: Partial<TitleStyleSettings> = {}
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<TitleStyleSettings>): Partial<TitleStyleConfig> {
const out: Partial<TitleStyleConfig> = {}
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
}
-13
View File
@@ -18,7 +18,6 @@ import {
ThunderboltOutlined,
UnorderedListOutlined,
UserOutlined,
FireOutlined,
} from "@ant-design/icons"
/** 导航项类型 */
@@ -77,12 +76,6 @@ 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: "任务历史",
@@ -149,12 +142,6 @@ 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),
},
],
},
{
+21 -58
View File
@@ -61,10 +61,6 @@ const AiAvatarPage: React.FC = () => {
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ── 对口型耗时计时(秒) ── */
const [lipsyncElapsed, setLipsyncElapsed] = useState(0)
const lipsyncStartAtRef = useRef<number>(0)
const lipsyncTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -226,13 +222,6 @@ 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
@@ -276,11 +265,6 @@ 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)
@@ -288,10 +272,6 @@ 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 || "对口型生成失败")
}
@@ -305,10 +285,6 @@ 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 : "对口型任务提交失败,请重试")
}
@@ -329,21 +305,15 @@ 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)
}
}, [])
@@ -580,6 +550,20 @@ const AiAvatarPage: React.FC = () => {
return (
<div className="aa-page">
<div className="aa-page-header">
<h1>AI数字人</h1>
</div>
{/* 步骤切换导航条 */}
<div className="aa-step-nav">
<span className={`aa-step-nav__item${currentStep === 1 ? " active" : ""}`}>
1. 视频 / 配音 / 文案
</span>
<span className={`aa-step-nav__item${currentStep === 2 ? " active" : ""}`}>
2. 对口型 / 标题 / 封面 / 生成
</span>
</div>
<div className="aa-page-body">
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */}
{currentStep === 1 && (
@@ -712,11 +696,17 @@ const AiAvatarPage: React.FC = () => {
<div className="aa-panel__body">
{currentRenderJob?.status !== "completed" ? (
<PanelCoverAndGenerate
variant="setup"
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
renderJob={currentRenderJob}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
renderJob={currentRenderJob}
summary={summary}
/>
) : (
@@ -973,19 +963,6 @@ const AiAvatarPage: React.FC = () => {
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成中…
</div>
<div
style={{
marginTop: 12,
fontSize: 28,
fontWeight: 700,
fontVariantNumeric: "tabular-nums",
color: "#7c3aed",
}}
>
{`${Math.floor(lipsyncElapsed / 60)
.toString()
.padStart(2, "0")}:${(lipsyncElapsed % 60).toString().padStart(2, "0")}`}
</div>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
请勿关闭页面,完成后将自动提示
</div>
@@ -997,20 +974,6 @@ const AiAvatarPage: React.FC = () => {
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成完成
</div>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#10b981",
fontVariantNumeric: "tabular-nums",
}}
>
总耗时{" "}
{Math.floor(lipsyncElapsed / 60)
.toString()
.padStart(2, "0")}
:{(lipsyncElapsed % 60).toString().padStart(2, "0")}
</div>
</>
)}
{lipsyncStatus === "failed" && (
+4 -7
View File
@@ -99,18 +99,15 @@ export const cancelRenderJob = async (jobId: string): Promise<void> => {
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
}
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ──
* #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧)
*/
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
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`,
templateId && templateId !== "default" ? { template_id: templateId } : {},
// 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s
{ timeout: templateId && templateId !== "default" ? 180000 : 120000 },
{},
// 抽帧+评分+转存 OSS 链路较长,120s 超时
{ timeout: 120000 },
)
return response.data
}
@@ -1,25 +1,11 @@
/**
* 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数字人共享同一套模板库)。
* AI数字人 — 封面选择弹窗
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
*/
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 React from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
interface ModalCoverSelectProps {
open: boolean
@@ -27,13 +13,7 @@ interface ModalCoverSelectProps {
renderJob: RenderJob | null
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
/**
* 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入
* 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。
*/
onGenerateRenderSmartCover?: (
renderId: string,
) => Promise<{ cover_url: string; message?: string }>
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
onUploadCover?: (file: File) => void
onCoverSelected: (coverUrl: string) => void
}
@@ -48,302 +28,31 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
onUploadCover,
onCoverSelected,
}) => {
const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id
const generateFn = useCallback(
async (templateId: string): Promise<string | null> => {
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 (
<Modal
open={open}
onCancel={onClose}
title="选择封面"
width={560}
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确定
</Button>
<div className="aa-modal-overlay" onClick={onClose}>
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
<div className="aa-modal__header">
<span className="aa-modal__title">选择封面</span>
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
}
>
<div style={{ padding: "8px 0" }}>
{renderJob && (
<div
style={{
padding: "8px 12px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 12,
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 从渲染成片中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div
style={{
display: "flex",
gap: 12,
alignItems: "flex-start",
}}
>
{/* 左:封面预览 */}
<div
style={{
width: 180,
flexShrink: 0,
}}
>
<div
className="xx-ce-canvas"
style={{
position: "relative",
width: "100%",
aspectRatio: "9 / 16",
borderRadius: 8,
overflow: "hidden",
background: "linear-gradient(135deg, #1e3a8a 0%, #312e81 100%)",
border: previewUrl ? "none" : "1px dashed #d9d9d9",
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt="封面预览"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
gap: 6,
opacity: 0.7,
}}
>
<span style={{ fontSize: 28 }}>🖼️</span>
<span>
{isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}
</span>
</div>
)}
{shared.generating && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
flexDirection: "column",
gap: 8,
}}
>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>AI 选帧中…</span>
</div>
)}
</div>
<div
style={{
marginTop: 6,
textAlign: "center",
fontSize: 11,
color: "#8c8ca1",
}}
>
9:16 竖版封面
</div>
</div>
{/* 右:操作按钮 */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!isRenderCompleted || shared.generating}
loading={shared.generating}
style={{ width: "100%" }}
>
✨ 自动生成封面
</Button>
<Button
buttonType="ghost"
onClick={() => shared.setShowCoverSettings(true)}
style={{ width: "100%" }}
>
⚙️ 封面模板
</Button>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={!isRenderCompleted || shared.generating}
style={{ width: "100%" }}
>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<div
style={{
fontSize: 11,
color: "#8c8ca1",
lineHeight: 1.5,
marginTop: 4,
padding: "6px 8px",
background: "#f7f8fa",
borderRadius: 6,
}}
>
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
</div>
</div>
<div className="aa-modal__body" style={{ padding: 20 }}>
<PanelCoverAndGenerate
variant="select-cover"
coverConfig={coverConfig}
onCoverConfigChange={onCoverConfigChange}
renderJob={renderJob}
onGenerateRenderSmartCover={onGenerateRenderSmartCover}
onUploadCover={onUploadCover}
onClose={onClose}
onCoverSelected={onCoverSelected}
/>
</div>
</div>
{/* 模板选择弹窗 */}
<CoverSettingsModal
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}
/>
{/* 自定义编辑器弹窗 */}
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
{/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */}
<AntModal open={shared.generating} closable={false} footer={null} centered width={320}>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</AntModal>
</Modal>
</div>
)
}
@@ -1,19 +1,36 @@
/**
* AI数字人 — 面板5 / 生成配置面板(渲染前)
* #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮)
* 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件)
* AI数字人 — 面板5 / 封面选择弹窗内容:
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
*
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
*/
import React from "react"
import type { RenderJob } from "../types"
import React, { useRef, useState } from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
type PanelVariant = "setup" | "select-cover"
interface PanelCoverAndGenerateProps {
variant?: PanelVariant
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => 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
@@ -21,6 +38,7 @@ interface PanelCoverAndGenerateProps {
lipsyncStatus: string | null
brollCount: number
hasTitle: boolean
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
coverStatus: "not_ready" | "pending" | "selected"
}
}
@@ -40,15 +58,89 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
}
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
variant = "setup",
coverConfig,
onCoverConfigChange,
resolution = "720p",
onResolutionChange,
isGenerating = false,
onGenerate,
renderJob: _renderJob,
renderJob,
onGenerateRenderSmartCover,
onUploadCover,
onClose,
onCoverSelected,
summary,
}) => {
const uploadInputRef = useRef<HTMLInputElement>(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<AiAvatarCoverConfig> = {
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<HTMLInputElement>) => {
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 = (() => {
@@ -62,6 +154,69 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
return <span className="aa-config-summary__empty">生成视频后可选</span>
})()
// ── 封面选择区(两种 variant 共用) ─────────────────────────────────
const coverSection = (
<div className="aa-cover-section" style={{ marginTop: variant === "select-cover" ? 0 : 16 }}>
<div className="aa-label" style={{ marginBottom: 8 }}>
{variant === "select-cover" ? "选择封面" : "封面"}
</div>
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : (
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
)}
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
</div>
<div className="aa-cover-actions">
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={!canSmartCover}
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
disabled={!isRenderCompleted || smartCoverLoading}
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
>
📷 自定义上传
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
// ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ──
if (variant === "select-cover") {
return (
<div className="aa-cover-generate">
{coverSection}
{onClose && (
<div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
<button type="button" className="aa-btn aa-btn--primary" onClick={onClose}>
确定
</button>
</div>
)}
</div>
)
}
// ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ──
return (
<div className="aa-cover-generate">
{/* 分辨率选择 */}
@@ -10,13 +10,12 @@
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 } from "@/pages/generate/constants"
import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants"
import { useTitleTemplates } from "@/pages/generate/hooks/useStep4Title/useTitleTemplates"
import type { AiAvatarTitleConfig } from "../types"
// #1894: 标题数据源切换到文案库,取 script.title 作为候选
import { getScripts } from "@/api/scripts"
@@ -31,7 +30,9 @@ interface PanelTitleConfigProps {
const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpdate }) => {
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
const [activePreset, setActivePreset] = useState<string | null>(null)
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
/** 模板管理 */
const { allTemplates, saveTemplate, updateTemplate, deleteTemplate, duplicateTemplate } = useTitleTemplates()
/** 标题库选项(#1894:从文案库 scripts[].title 取候选) */
const [titleOptions, setTitleOptions] = useState<TitleOption[]>([])
@@ -221,33 +222,63 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
}
}, [titleConfig])
/** 应用预设:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */
/** 应用预设/模板:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */
const handleApplyPreset = (presetKey: string) => {
// 先找系统预设
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
setActivePreset(presetKey)
const st = preset.style || {}
onUpdate({
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,
})
if (preset) {
setActivePreset(presetKey)
const st = preset.style || {}
onUpdate({
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,
})
return
}
// 再找用户模板
const tmpl = allTemplates.find((t) => t.key === presetKey)
if (tmpl && !tmpl.isSystem) {
setActivePreset(presetKey)
const st = tmpl.style || {}
onUpdate({
font: st.font,
size: st.size,
color: st.color,
bold: st.bold,
italic: st.italic,
stroke: st.stroke,
stroke_width: (st as any).stroke_width,
stroke_color: (st as any).stroke_color,
shadow: st.shadow,
shadow_offset_x: (st as any).shadow_offset_x,
shadow_offset_y: (st as any).shadow_offset_y,
shadow_blur: (st as any).shadow_blur,
shadow_color: (st as any).shadow_color,
bg_enabled: (st as any).bg_enabled,
bg_color: (st as any).bg_color,
bg_padding: (st as any).bg_padding,
bg_radius: (st as any).bg_radius,
line_overrides: [],
cover_title_config: null,
})
}
}
/** 字段 patch 透传:TitleStylePanel 的 onUpdateStyle(camelCase → snake_case) */
@@ -332,40 +363,6 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
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,
})
}
return (
<div className="aa-title-config">
{/* 主标题输入 — TextArea 多行 + 标题库选择 */}
@@ -412,15 +409,17 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
onUpdateStyle={handleUpdateStyle}
showCoverToggle
previewWidth={280}
enableTemplates={true}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={handleApplyTemplate}
activePreset={activePreset}
titlePresets={
TITLE_PRESETS as unknown as React.ComponentProps<typeof TitleStylePanel>["titlePresets"]
}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
templates={allTemplates}
onSaveTemplate={(label, style, emoji) => saveTemplate(label, style as any, emoji)}
onUpdateTemplate={(key, patch) => updateTemplate(key, patch as any)}
onDeleteTemplate={deleteTemplate}
onDuplicateTemplate={duplicateTemplate}
/>
{/* 自动生成字幕 */}
+260 -150
View File
@@ -8,20 +8,30 @@ 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 { useTitleTemplates } from "./hooks/useStep4Title/useTitleTemplates"
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"
@@ -33,9 +43,6 @@ const GeneratePage: React.FC = () => {
/* ── 表单状态 ── */
const formState = useGenerateFormState()
/* ── 标题模板(#2003)当前选中模板 id ── */
const [selectedTitleTemplateId, setSelectedTitleTemplateId] = useState<string | null>(null)
/* ── 积分状态 ── */
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
useEffect(() => {
@@ -86,11 +93,14 @@ const GeneratePage: React.FC = () => {
style,
autoSubtitles,
bgm,
editPlanId,
sourceEditPlanId,
previewTaskId,
setPreviewTaskId,
storedSourceEditPlanId,
setStoredSourceEditPlanId,
serverClips,
setServerClips,
previewCount,
setPreviewCount,
previewTitles,
@@ -103,7 +113,6 @@ const GeneratePage: React.FC = () => {
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
setSelectedTemplate,
} = formState
const isBatch = previewCount > 1
@@ -134,11 +143,8 @@ const GeneratePage: React.FC = () => {
}
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
/* ── 标题面板模式:true = 内联大卡片模板网格(默认),false = 旧预设+参数 Tab ── */
const enableTemplates = true
/* ── Step5 保存中状态 ── */
const [finishing, setFinishing] = useState(false)
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── #1970 流程重构:分支弹窗 ── */
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
@@ -151,6 +157,65 @@ const GeneratePage: React.FC = () => {
onTitleSettingsChange: setTitleSettings,
})
/* ── 模板管理 ─ */
const { allTemplates, saveTemplate, updateTemplate, deleteTemplate, duplicateTemplate } = useTitleTemplates()
/* ── 配音素材库(TTS 试听)── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
const ttsAbortRef = useRef<AbortController | null>(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()
@@ -175,11 +240,30 @@ 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 { planIdsByVariant: variantPlanIds } = useBatchVariantPlans({
enabled: isBatch && currentStep === 3,
const {
clipsByVariant: variantClips,
planIdsByVariant: variantPlanIds,
voiceDurationsByVariant: variantVoiceDurations,
loading: variantClipsLoading,
error: variantClipsError,
retry: retryVariantClips,
} = useBatchVariantPlans({
enabled: isBatch && currentStep === 3 && previewAssetsReady,
count: previewCount,
templateId: selectedTemplate || "",
assetIds: previewAssetIds,
@@ -189,6 +273,39 @@ 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<string>()
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,
@@ -196,7 +313,6 @@ const GeneratePage: React.FC = () => {
generated,
generateError,
generatedVideos,
currentTaskId,
batchTasks,
generate: handleGenerate,
retry: handleRetryGenerate,
@@ -236,48 +352,44 @@ const GeneratePage: React.FC = () => {
voiceModePerVideo,
variantCoverUrls: previewCovers,
selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
onGenerationSuccess: (status?: "completed" | "awaiting_cover") => {
onGenerationSuccess: () => {
setPreviewTaskId(null)
setStoredSourceEditPlanId(null)
// #2088:渲染完成后自动跳到封面选择页(step 5),不再等用户手动点「下一步」
// awaiting_cover 和 completed 都走封面页(completed 是旧 worker 或 finalize 后状态,仍支持选封面)
if (status === "awaiting_cover" || status === "completed" || !status) {
setCurrentStep(5)
}
},
})
/* ── 对齐批量数组长度到 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,
])
/* ── 数量弹窗确认 ── */
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,
],
)
/* ── #1970:Step1 弹窗回调 ── */
const handleVoiceModalConfirm = useCallback(
@@ -373,6 +485,10 @@ const GeneratePage: React.FC = () => {
return
}
}
if (!previewReady) {
message.warning("预览素材正在加载,请稍候")
return
}
const ok = await handleGenerate()
if (ok) {
setCurrentStep(4)
@@ -383,6 +499,7 @@ const GeneratePage: React.FC = () => {
previewTitles,
titleSettings.aiAutoSelect,
titleSettings.title,
previewReady,
handleGenerate,
setCurrentStep,
balance,
@@ -400,7 +517,7 @@ const GeneratePage: React.FC = () => {
smartSelectedIds,
titleSettings,
generated,
onBeforeEnterStep3: ensureArraysAligned,
onOpenCountModal: () => setCountModalOpen(true),
onOpenStep1Modal: () => {
if (editMode === "random") {
setVoiceModalOpen(true)
@@ -410,94 +527,14 @@ 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 = "xx-generate-layout full-width"
const layoutClassName = useMemo(() => {
if (currentStep === 3) return "xx-generate-layout step4-layout"
return "xx-generate-layout full-width"
}, [currentStep])
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
@@ -520,8 +557,71 @@ const GeneratePage: React.FC = () => {
return (
<div className="xx-generate-page">
<GenerateHeader fromEditPlan={!!editPlanId} />
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={layoutClassName}>
{/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
{/* ════ 步骤3:左侧预览大区域 ════ */}
{currentStep === 3 && (
<div className="xx-generate-preview-col">
{!isBatch ? (
<FrontendPreviewPlayer
assets={previewAssets}
videoRatio={videoRatio as "9:16" | "16:9"}
ready={previewAssets.length > 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}
/>
) : (
<div className="xx-form-section">
<div className="xx-preview-header">
<h3>🎬 {previewCount} 个视频预览</h3>
<span
style={{
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
实时预览,勾选要生成的视频
</span>
</div>
<CanvasPreviewGrid
count={previewCount}
assets={previewAssets}
videoRatio={videoRatio as "9:16" | "16:9"}
titles={previewTitles}
titleSettings={titleSettings}
voiceAudioUrls={variantVoiceAudioUrls}
voiceDurations={variantVoiceDurations}
variantClips={variantClips}
clipsLoading={variantClipsLoading}
clipsError={variantClipsError}
onRetryClips={retryVariantClips}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelect}
selectable={!generating}
/>
</div>
)}
</div>
)}
{/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
<div className="xx-generate-form">
<GenerateStepContent
currentStep={currentStep}
@@ -547,12 +647,11 @@ const GeneratePage: React.FC = () => {
onUpdateStyle={styleUpdaters.updateStyle}
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTitleTemplateId}
onApplyTemplate={(settings, tpl) => {
styleUpdaters.applyTemplate(settings)
setSelectedTitleTemplateId(tpl.id)
}}
templates={allTemplates}
onSaveTemplate={(label, style, emoji) => saveTemplate(label, style as any, emoji)}
onUpdateTemplate={(key, patch) => updateTemplate(key, patch as any)}
onDeleteTemplate={deleteTemplate}
onDuplicateTemplate={duplicateTemplate}
bgm={bgm}
bgmConfig={bgmConfig}
coverSettings={coverSettings}
@@ -565,14 +664,16 @@ 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}
@@ -587,9 +688,6 @@ const GeneratePage: React.FC = () => {
previewCovers={previewCovers}
onPreviewCoversChange={setPreviewCovers}
selectedVariantIds={selectedVariantIds}
selectedCoverTemplate={selectedTemplate}
onSelectedCoverTemplateChange={setSelectedTemplate}
onConfirmGenerate={handleConfirmGenerate}
/>
{/* ════ 步骤4(单视频):成片播放器 ════ */}
@@ -652,6 +750,12 @@ const GeneratePage: React.FC = () => {
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗 分享
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁 前往成片库
</button>
</div>
</div>
</div>
@@ -671,12 +775,18 @@ const GeneratePage: React.FC = () => {
pointsInsufficient={insufficientPoints}
insufficientReason={pointsEstimate.reason}
onRecharge={() => navigate("/app/points/recharge")}
onFinish={handleFinish}
finishing={finishing}
/>
</div>
</div>
{/* 数量选择弹窗 */}
<PreviewCountModal
open={countModalOpen}
defaultCount={1}
onConfirm={handleCountConfirm}
onCancel={() => setCountModalOpen(false)}
/>
{/* 音色克隆弹窗 */}
<CloneModal
open={cloneModalOpen}
@@ -11,12 +11,7 @@
* 防止长标题在窄列里溢出导致与相邻卡片进度条视觉重叠。
*/
import React from "react"
import {
LoadingOutlined,
CheckCircleFilled,
CloseCircleOutlined,
ClockCircleOutlined,
} from "@ant-design/icons"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
import type { GeneratedVideo } from "@/api/template-editor"
@@ -42,9 +37,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-preview-header">
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
完成{" "}
{tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "}
{tasks.length}
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
</span>
</div>
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
@@ -56,7 +49,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" || task.status === "awaiting_cover" ? (
{task.status === "completed" ? (
<CheckCircleFilled
className="xx-batch-gen-card-icon"
style={{ color: "#52c41a" }}
@@ -66,11 +59,6 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
className="xx-batch-gen-card-icon"
style={{ color: "#ef4444" }}
/>
) : task.status === "queued" ? (
<ClockCircleOutlined
className="xx-batch-gen-card-icon"
style={{ color: "#faad14" }}
/>
) : (
<LoadingOutlined
className="xx-batch-gen-card-icon"
@@ -95,22 +83,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
</>
)}
{task.status === "queued" && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: "var(--text-secondary, #faad14)",
fontSize: 13,
padding: "8px 0",
}}
>
<ClockCircleOutlined />
<span>排队等待中,前面任务完成后自动开始渲染</span>
</div>
)}
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
{task.status === "completed" && video && (
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
<div
@@ -138,7 +111,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
/>
</div>
)}
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
{task.status === "completed" && !video && (
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
)}
{task.status === "failed" && (
@@ -0,0 +1,156 @@
/**
* 批量前端 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<CanvasPreviewGridProps> = ({
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<number | null>(null)
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
return (
<div className="xx-canvas-grid">
{Array.from({ length: count }, (_, i) => {
const checked = selectedIds.includes(i)
return (
<div
key={i}
className={`xx-canvas-grid-card${checked ? " selected" : ""}`}
data-variant={i}
>
<div className="xx-canvas-grid-card-bar">
<label className="xx-canvas-grid-check">
<input
type="checkbox"
checked={checked}
disabled={!selectable}
onChange={() => onToggleSelect(i)}
/>
<span>视频 {i + 1}</span>
</label>
</div>
{clipsError ? (
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
<div className="xx-variant-clips-status" role="alert">
<span className="xx-variant-clips-error-text">预览加载失败,请重试</span>
{i === 0 && onRetryClips ? (
<Button
size="small"
icon={<ReloadOutlined />}
onClick={onRetryClips}
style={{ marginTop: 12 }}
>
重试
</Button>
) : null}
</div>
) : clipsLoading || !variantClips?.[i]?.length ? (
// ── 加载态:9:16 占位防塌陷 ──
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
<LoadingOutlined />
<span style={{ marginTop: 8 }}>独立选片中…</span>
</div>
) : (
<FrontendPreviewPlayer
assets={assets}
videoRatio={videoRatio}
ready={assets.length > 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,
}}
/>
)}
</div>
)
})}
</div>
)
}
export default CanvasPreviewGrid
@@ -0,0 +1,602 @@
/**
* 前端预览播放器 — 原生 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 "@/components/title/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<FrontendPreviewPlayerProps> = ({
assets,
videoRatio,
ready,
serverClips,
voiceAudioUrl,
voiceDurationHint,
titleSettings,
onTitlePositionChange,
playToken,
variantTitle,
compact = false,
activePlayToken = null,
onPlayTokenChange,
}) => {
// #1754→P0:配音时长作为音画时长锚点。
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
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<HTMLDivElement>(null)
const playerContainerRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<number | null>(null)
const tailStartRef = useRef<number>(0)
const tailBaseRef = useRef<number>(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 (
<div
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"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)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<SoundOutlined style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }} />
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
准备预览素材...
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
加载素材后即可预览播放
</p>
</div>
)
}
// ── 无播放片段 ──
if (!canPlay) {
return (
<div
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"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)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
暂无可播放素材
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
请先在左侧选择素材
</p>
</div>
)
}
return (
<div
ref={playerContainerRef}
style={{
position: "relative",
width: "100%",
maxWidth: compact ? "100%" : 280,
margin: compact ? 0 : "0 auto",
aspectRatio: "9 / 16",
background: compact ? "transparent" : "#0a0a0a",
borderRadius: compact ? 10 : 24,
overflow: "hidden",
boxShadow: compact
? "inset 0 0 0 1px rgba(255,255,255,0.06)"
: "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)",
}}
>
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
{segments.map((seg, i) => (
<video
key={seg.assetId}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === currentSegmentIndex ? 1 : 0,
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
}}
muted={hasVoice || muted}
playsInline
/>
))}
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{titleSettings?.title && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 5,
pointerEvents: "none",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
width: `${100 - 2 * titleSidePct}%`,
maxWidth: `${100 - 2 * titleSidePct}%`,
...(customTitleXPct != null && customTitleYPct != null
? {
left: `${customTitleXPct}%`,
top: `${customTitleYPct}%`,
transform: "translate(-50%, -50%)",
textAlign: "center" as const,
}
: {
left: "50%",
transform: "translateX(-50%)",
textAlign: "center" as const,
...(titleSettings.position === "top"
? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translate(-50%, -50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
padding: "8px 12px",
boxShadow: "inset 0 0 0 16px transparent",
}}
ref={titleDragRef}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
>
<span
style={{
fontSize: `${titleFontSizePx}px`,
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
color: titleSettings.color || "#ffffff",
fontWeight: titleSettings.bold ? 700 : 400,
fontStyle: titleSettings.italic ? "italic" : "normal",
lineHeight: 1.05,
wordBreak: "break-word",
WebkitTextStroke: titleSettings.stroke
? `${titleStrokeWidth}px #000000`
: undefined,
textShadow: titleSettings.shadow
? `${titleShadowOffset}px ${titleShadowOffset}px ${titleShadowBlur}px rgba(0,0,0,0.8)`
: undefined,
}}
>
{(effectiveTitle || "").split(/[//]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</span>
</div>
</div>
)}
<PreviewControls
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
muted={muted}
onToggleMute={() => setMuted((m) => !m)}
hasSegments={segments.length > 0}
segmentIndex={currentSegmentIndex}
segmentCount={segments.length}
currentTime={displayCurrentTime}
totalDuration={effectiveTotalDuration}
onSeek={handleSeekTo}
compact={compact}
/>
</div>
)
}
export default FrontendPreviewPlayer
@@ -5,7 +5,7 @@
* 步骤 1~3:上一步 / 下一步
* 步骤 4(确认生成/进度):未开始 →「✨ 确认生成视频」;生成中 →「⏳ 视频渲染中…」;
* 失败 →「🔄 重新生成」;全部完成 →「下一步:选择封面 →」
* 步骤 5(选择封面):上一步 + 完成按钮
* 步骤 5(选择封面):仅上一步,无主按钮
*/
import React from "react"
import { Tooltip } from "antd"
@@ -32,10 +32,6 @@ export interface GenerateStepActionsProps {
freeClipsUsedThisTime?: number
/** 前往充值 */
onRecharge?: () => void
/** Step5 点击完成(保存入库并跳转) */
onFinish?: () => void | Promise<void>
/** Step5 保存中 */
finishing?: boolean
}
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
@@ -52,8 +48,6 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
insufficientReason,
freeClipsUsedThisTime,
onRecharge,
onFinish,
finishing,
}) => {
const renderPrimaryButton = () => {
/* 步骤 1~3:上一步 / 下一步 */
@@ -132,18 +126,8 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 5(封面,最后一步):完成按钮 */
return (
<button
className="xx-btn xx-btn-primary"
onClick={() => {
if (onFinish && !finishing) void onFinish()
}}
disabled={finishing}
>
{finishing ? "⏳ 保存中…" : "✅ 完成"}
</button>
)
/* 步骤 5(封面,最后一步):无主按钮 */
return null
}
return (
@@ -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
@@ -59,15 +59,27 @@ export interface GenerateStepContentProps {
emoji?: string
style: Record<string, unknown>
}>
enableTemplates?: boolean
selectedTemplateId?: string | null
onApplyTemplate?: (settings: import("../types").TitleSettings, template: TitleTemplate) => void
/* ── 模板管理 ── */
templates?: Array<{
key: string
label: string
emoji?: string
isSystem: boolean
style: Record<string, unknown>
}>
onSaveTemplate?: (label: string, style: Record<string, unknown>, emoji?: string) => void
onUpdateTemplate?: (key: string, patch: { label?: string; style?: Record<string, unknown>; emoji?: string }) => void
onDeleteTemplate?: (key: string) => void
onDuplicateTemplate?: (key: string) => void
selectedTemplateKey?: string | null
onSelectTemplate?: (key: string | null) => void
/* ── 封面 ── */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/* ── 配音 ── */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
/* ── 生成 ── */
generating: boolean
generated: boolean
@@ -90,12 +102,10 @@ export interface GenerateStepContentProps {
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
selectedVariantIds?: number[]
selectedCoverTemplate?: string
onSelectedCoverTemplateChange?: (templateId: string) => void
/** 单视频任务 ID(兜底,awaiting_cover 状态下 results 接口未入库时用) */
currentTaskId?: string
/** Step3 右上角确认生成按钮 */
onConfirmGenerate?: () => void | Promise<void>
/* ── 摘要信息(#1970 Step4 展示用) ── */
selectedScript: ScriptItem | null
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -130,13 +140,18 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onToggleShadow,
onApplyPreset,
onUpdateStyle,
enableTemplates,
selectedTemplateId,
onApplyTemplate,
activePreset,
titlePresets,
templates,
onSaveTemplate,
onUpdateTemplate,
onDeleteTemplate,
onDuplicateTemplate,
selectedTemplateKey,
onSelectTemplate,
coverSettings,
onCoverSettingsChange,
onServerClipsChange,
generating,
generateError,
progress,
@@ -149,17 +164,18 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
selectedCoverTemplate,
onSelectedCoverTemplateChange,
onConfirmGenerate,
selectedVoice,
onSelectedVoiceChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
selectedScript,
ttsVoiceId,
ttsVoiceSource,
} = props
const handleClipsChange = React.useCallback(
(clips: EditPlanClip[], _templateId?: string) => {
onServerClipsChange(clips)
},
[onServerClipsChange],
)
switch (currentStep) {
case 1:
return (
@@ -187,54 +203,74 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
clipCount={clipCount}
onClipCountChange={onClipCountChange}
selectedTemplate={selectedTemplate}
onServerClipsChange={handleClipsChange}
/>
)
case 3:
return (
<>
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
activePreset={activePreset}
titlePresets={titlePresets}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
onConfirmGenerate={onConfirmGenerate}
generating={props.generating}
selectedCount={
props.previewCount && props.previewCount > 1
? props.selectedVariantIds?.length || 1
: 1
}
/>
{/* 批量配音选择:共用/独立切换(#2096) */}
<Step3VoiceWithMode
previewCount={previewCount}
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
voiceModePerVideo={voiceModePerVideo}
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
/>
</>
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
activePreset={activePreset}
titlePresets={titlePresets}
templates={templates}
onSaveTemplate={onSaveTemplate}
onUpdateTemplate={onUpdateTemplate}
onDeleteTemplate={onDeleteTemplate}
onDuplicateTemplate={onDuplicateTemplate}
selectedTemplateKey={selectedTemplateKey}
onSelectTemplate={onSelectTemplate}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
/>
)
case 4:
return (
<div className="xx-form-section">
{/* 配置摘要(#1970) */}
<div
style={{
padding: 14,
background: "#f9fafb",
borderRadius: 8,
marginBottom: 16,
fontSize: 13,
lineHeight: 1.8,
color: "#374151",
}}
>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6, color: "#111" }}>
📋 生成配置
</div>
<div>🎬 剪辑模式:{editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}</div>
{editMode === "random" ? (
<div>🎙️ 配音来源:配音库音频</div>
) : (
<>
<div>📝 文案:{selectedScript?.title ?? "未选择"}</div>
<div>
🎙️ 合成配音音色:
{ttsVoiceId
? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}(${ttsVoiceId.slice(0, 8)}...)`
: "未选择"}
</div>
</>
)}
<div>📱 视频比例:{videoRatio}</div>
<div>🎯 智能降重:{dedupEnabled ? "已开启" : "已关闭"}</div>
{previewCount > 1 && <div>📦 生成数量:{previewCount} 个</div>}
</div>
{previewCount > 1 ? (
<BatchGenerationGrid
tasks={batchTasks}
@@ -285,9 +321,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers={previewCovers}
onPreviewCoversChange={onPreviewCoversChange}
selectedVariantIndexes={selectedVariantIds}
selectedTemplate={selectedCoverTemplate}
onTemplateChange={onSelectedCoverTemplateChange}
currentTaskId={props.currentTaskId}
/>
)
default:
@@ -0,0 +1,195 @@
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<PreviewControlsProps> = ({
isPlaying,
onTogglePlay,
muted,
onToggleMute,
hasSegments,
segmentIndex,
segmentCount,
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
return (
<>
{/* 静音/有声切换(#1741):左上角 */}
{hasSegments && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={onToggleMute}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${segmentIndex + 1} / ${segmentCount}`}
</div>
{/* 中央播放按钮 */}
{!isPlaying && (
<button
onClick={onTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={onTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<PreviewProgressBar
currentTime={currentTime}
totalDuration={totalDuration}
onSeek={onSeek}
compact={compact}
/>
</div>
</>
)
}
@@ -0,0 +1,127 @@
/**
* 生成数量选择弹窗(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<PreviewCountModalProps> = ({
open,
defaultCount = 1,
onConfirm,
onCancel,
}) => {
const [count, setCount] = useState(defaultCount)
const inputRef = useRef<HTMLInputElement>(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 (
<div className="xx-modal-mask" onClick={onCancel}>
<div className="xx-modal-box xx-count-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: "0 0 8px", fontSize: 18 }}>要生成几个视频?</h3>
<p style={{ margin: "0 0 20px", fontSize: 13, color: "var(--text-secondary, #666)" }}>
素材共用,AI 随机剪辑出不同版本,每个视频可独立设置标题、配音和封面
</p>
<div className="xx-count-selector">
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c - 1))}
disabled={count <= 1}
aria-label="减少"
>
−
</button>
<input
ref={inputRef}
type="number"
min={1}
max={MAX_PREVIEW_COUNT}
value={count}
onChange={(e) => setCount(clamp(parseInt(e.target.value, 10) || 1))}
onKeyDown={handleKeyDown}
className="xx-count-input"
/>
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c + 1))}
disabled={count >= MAX_PREVIEW_COUNT}
aria-label="增加"
>
+
</button>
</div>
<div className="xx-count-quick">
{[1, 3, 5, 10].map((n) => (
<button
key={n}
type="button"
className={`xx-count-chip ${count === n ? "active" : ""}`}
onClick={() => setCount(n)}
>
{n} 个
</button>
))}
</div>
<div className="xx-count-actions">
<button type="button" className="xx-btn xx-btn-ghost" onClick={onCancel}>
取消
</button>
<button type="button" className="xx-btn xx-btn-primary" onClick={handleConfirm}>
{count === 1 ? "生成 1 个视频" : `生成 ${count} 个视频`}
</button>
</div>
<p
style={{
margin: "12px 0 0",
fontSize: 12,
color: "var(--text-tertiary, #999)",
textAlign: "center",
}}
>
直接按回车 = 生成 1 个
</p>
</div>
</div>
)
}
export default PreviewCountModal
@@ -0,0 +1,108 @@
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<PreviewProgressBarProps> = ({
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
const progressRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</>
)
}
@@ -1,19 +1,24 @@
/**
* Step 4 选择标题(Issue #1677 批量生成)
*
* 布局:全宽区域,标题输入 + 标题模板卡片网格。
* - 单视频:TitleLibraryAutoComplete 标题库输入
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
* 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格),
* 右侧边栏标题设置。本组件渲染在右侧边栏:
* - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归)
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成
* (一次生成 N 个标题,分别填入各变体,可单独换一个)
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
*/
import React, { useMemo } from "react"
import React, { useMemo, useState } from "react"
import { Input, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { TitleSettings } from "../types"
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 type { TitleTemplate } from "@/components/title/template-types"
import { AI_TITLE_TEMPLATES } from "../constants"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
@@ -38,21 +43,56 @@ interface Step4TitleSettingsProps {
style?: Record<string, unknown>
previewStyle?: React.CSSProperties
}>
/* ── 模板管理 ── */
templates?: Array<{
key: string
label: string
emoji?: string
isSystem: boolean
style: Record<string, unknown>
}>
onSaveTemplate?: (label: string, style: Record<string, unknown>, emoji?: string) => void
onUpdateTemplate?: (key: string, patch: { label?: string; style?: Record<string, unknown>; emoji?: string }) => void
onDeleteTemplate?: (key: string) => void
onDuplicateTemplate?: (key: string) => void
selectedTemplateKey?: string | null
onSelectTemplate?: (key: string | null) => void
/* ── 批量生成(#1677)── */
/** 生成数量 */
previewCount?: number
/** 每个变体的标题文字(长度=previewCount) */
previewTitles?: string[]
onPreviewTitlesChange?: (titles: string[]) => void
enableTemplates?: boolean
selectedTemplateId?: string | null
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
/** Step3 右上角「🎬 确认生成」主按钮 */
onConfirmGenerate?: () => void | Promise<void>
/** 是否生成中 */
generating?: boolean
/** 批量模式下勾选数量 */
selectedCount?: number
}
/** 从本地 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("")
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -69,18 +109,21 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onUpdateStyle,
activePreset,
titlePresets,
templates,
onSaveTemplate,
onUpdateTemplate,
onDeleteTemplate,
onDuplicateTemplate,
selectedTemplateKey,
onSelectTemplate,
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) => {
@@ -93,71 +136,117 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (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 (
<div className="xx-form-section" style={{ position: "relative" }}>
{/* ── 右上角「🎬 确认生成」主按钮 ── */}
{onConfirmGenerate && (
<button
type="button"
onClick={() => {
if (generating) return
void onConfirmGenerate()
}}
disabled={generating}
style={{
position: "absolute",
top: 0,
right: 0,
background: generating ? "#a78bfa" : "#7c3aed",
color: "#fff",
border: "none",
borderRadius: 10,
padding: "12px 24px",
fontSize: 15,
fontWeight: 600,
cursor: generating ? "not-allowed" : "pointer",
boxShadow: "0 4px 14px rgba(124,58,237,0.4)",
transition: "all .2s",
zIndex: 5,
whiteSpace: "nowrap",
}}
onMouseEnter={(e) => {
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#6d28d9"
}}
onMouseLeave={(e) => {
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#7c3aed"
}}
>
{generating
? "⏳ 生成中..."
: selectedCount > 1
? `🎬 确认生成 ${selectedCount} 个视频`
: "🎬 确认生成"}
</button>
)}
<div className="xx-form-section xx-title-sidebar">
<h3>📝 选择标题</h3>
{!isBatch ? (
/* ── 单视频:标题输入框 ── */
<div className="xx-form-field" style={{ maxWidth: 640 }}>
<label>标题</label>
<TitleLibraryAutoComplete
placeholder="输入或从标题库选择"
value={previewTitles?.[0] ?? t.titleSettings.title}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={titleOptions}
/>
</div>
/* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */
<>
{t.titleSettings.aiAutoSelect ? (
<>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label>当前 AI 选定标题</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
</span>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄 换一个
</button>
</div>
</div>
</>
) : (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label>标题</label>
<TitleLibraryAutoComplete
placeholder="输入或从标题库选择"
value={previewTitles?.[0] ?? t.titleSettings.title}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={titleOptions}
/>
</div>
</>
)}
</>
) : (
/* ── 批量:N 个独立标题输入框(两列布局 #2096) ── */
/* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */
<div className="xx-batch-titles">
<div
style={{
@@ -167,28 +256,51 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
lineHeight: 1.6,
}}
>
为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i} style={{ maxWidth: "100%" }}>
<label>视频 {i + 1} 标题</label>
<TitleLibraryAutoComplete
placeholder={`输入或选择视频 ${i + 1} 的标题`}
value={previewTitles?.[i] || ""}
onChange={(val) => updateVariantTitle(i, val)}
options={titleOptions}
/>
</div>
))}
{/* 批量 AI 标题 */}
<div className="xx-batch-ai-row">
<Input
placeholder="主题词,如:萌宠日常、旅行vlog"
value={batchAiTopic || t.aiTitleInput}
onChange={(e) => {
setBatchAiTopic(e.target.value)
t.setAiTitleInput(e.target.value)
}}
maxLength={30}
size="small"
style={{ flex: 1 }}
/>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
disabled={batchAiLoading}
onClick={() => handleBatchAiGenerate(false)}
>
{batchAiLoading ? <LoadingOutlined /> : "✨"} 一键生成 {previewCount} 个标题
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
disabled={batchAiLoading}
onClick={() => handleBatchAiGenerate(true)}
>
补填空标题
</button>
</div>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i}>
<label>视频 {i + 1} 标题</label>
<TitleLibraryAutoComplete
placeholder={`输入或选择视频 ${i + 1} 的标题`}
value={previewTitles?.[i] || ""}
onChange={(val) => updateVariantTitle(i, val)}
options={titleOptions}
/>
</div>
))}
</div>
)}
@@ -209,9 +321,13 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
templates={templates}
onSaveTemplate={onSaveTemplate}
onUpdateTemplate={onUpdateTemplate}
onDeleteTemplate={onDeleteTemplate}
onDuplicateTemplate={onDuplicateTemplate}
selectedTemplateKey={selectedTemplateKey}
onSelectTemplate={onSelectTemplate}
/>
</div>
)
@@ -1,242 +1,84 @@
/**
* Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传)
* - N 个视频:N 张封面卡片,每张带对应视频标题,支持统一选择封面模板、逐个自动生成或上传
*
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
* Step 5 选择封面(Issue #1677 批量生成改造)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
*/
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { Modal, Spin, message } from "antd"
import React, { useRef } from "react"
import { Modal, Spin } 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<Step6CoverSettingsProps> = (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<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
/** 最终成片:取第一个已完成视频(单视频场景) */
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<string, unknown>
} = {
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<string | null> => {
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<HTMLInputElement>(null)
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
const handleBatchUploadClick = (cardPos: number) => {
setBatchUploadCard(cardPos)
batchUploadRef.current?.click()
}
const handleBatchUploadChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
/**
* 批量生成:selectedTemplateId 来自用户在 CoverSettingsModal 中选择的模板,
* 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。
*/
const batchCovers = useBatchCovers({
selectedTemplate: shared.selectedTemplateId,
selectedTemplate: props.selectedTemplate || "",
generatedVideos: props.generatedVideos,
titles: batchTitles,
titleStyle: {
@@ -250,6 +92,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (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 || [])]
@@ -260,7 +103,22 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
},
})
const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
const handleUploadClick = (variantIndex: number) => {
uploadTargetRef.current = variantIndex
uploadInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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)
}
}
/* ── 批量封面 ── */
if (isBatch) {
@@ -280,32 +138,17 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
}}
>
🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" · "}当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<Button
buttonType="primary"
onClick={() => void batchCovers.generateAll()}
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
loading={batchCovers.busyIndexes.length > 0}
>
✨ 一键全部自动生成
</Button>
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
{shared.selectedTemplateId && shared.selectedTemplateId !== "default"
? `:${shared.selectedTemplateName}`
: ""}
</Button>
<Button buttonType="ghost" onClick={shared.handleCreateTemplate}>
➕ 新建模板
</Button>
</div>
<div className="xx-cover-grid">
@@ -366,7 +209,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => handleBatchUploadClick(cardPos)}
onClick={() => handleUploadClick(variantIndex)}
disabled={isLoading || isUploading}
>
📤 上传
@@ -377,44 +220,24 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
})}
</div>
{/* 隐藏的文件选择 input,批量上传复用 */}
<input
ref={batchUploadRef}
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleBatchUploadChange}
/>
<CoverSettingsModal
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}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
onChange={handleFileChange}
/>
</div>
)
}
/* ── 单视频 ── */
/* ── 单视频:原有流程保持不变 ── */
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{(finalVideo || effectiveTaskId) && (
{/* 最终成片信息 */}
{finalVideo && (
<div
style={{
padding: "10px 14px",
@@ -426,51 +249,17 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
color: "var(--text-secondary, #666)",
}}
>
🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
</div>
)}
<div className="xx-cover-actions">
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!canGenerateCover || shared.generating}
loading={shared.generating}
title={!canGenerateCover ? "请先完成视频生成" : ""}
>
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
✨ 自动生成封面
</Button>
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
⚙️ 封面设置
</Button>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={uploadingLocalCover}
loading={uploadingLocalCover}
>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<input
ref={batchUploadRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleBatchUploadChange}
/>
</div>
<div className="xx-section-title">封面预览</div>
@@ -487,26 +276,30 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
</div>
<CoverSettingsModal
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}
open={showCoverSettings}
onClose={() => setShowCoverSettings(false)}
templates={coverTemplates}
loading={templatesLoading}
error={templatesError}
selectedTemplateId={selectedTemplateId}
onSelectTemplate={handleSelectTemplate}
onEditTemplate={handleEditTemplate}
onDeleteTemplate={handleDeleteTemplate}
onCreateNew={() => {
setShowCoverSettings(false)
setShowCoverEditor(true)
}}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
open={showCoverEditor}
onClose={() => setShowCoverEditor(false)}
template={editingTemplate}
onSave={handleSaveTemplate}
/>
<Modal open={shared.generating} closable={false} footer={null} centered>
{/* AI 生成封面进度弹窗 */}
<Modal open={generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
@@ -518,6 +311,4 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
)
}
Step6CoverSettings.displayName = "Step6CoverSettings"
export default Step6CoverSettings
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,7 @@
import React, { useMemo, useState } from "react"
import React 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
@@ -17,58 +16,15 @@ interface CoverSettingsModalProps {
onCreateNew: () => void
}
/** 模板缩略图:优先渲染 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 (
<div
className="xx-cover-template-thumb"
style={{
background: url ? "#000" : placeholderBg,
position: "relative",
overflow: "hidden",
}}
>
{isSelected && <span className="xx-cover-template-check">✓</span>}
{url ? (
<img
src={url}
alt={tpl.name}
onError={() => setErrored(true)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<span style={{ fontSize: 28, opacity: 0.5 }}>🖼️</span>
)}
</div>
)
const GRADIENT_MAP: Record<string, string> = {
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)",
}
const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
@@ -84,26 +40,12 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
onCreateNew,
}) => {
return (
<Modal
open={open}
onCancel={onClose}
width={800}
title="封面设置"
centered
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确认应用
</Button>
</div>
}
>
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
<div className="xx-cover-modal-toolbar">
<Button buttonType="primary">选择素材文件</Button>
<Button buttonType="ghost">导出全部</Button>
<Button buttonType="primary" onClick={onCreateNew}>
+ 创建新模板
创建新模板
</Button>
</div>
@@ -117,77 +59,52 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
)}
{!loading && !error && templates.length === 0 && (
<div
style={{
textAlign: "center",
padding: "40px 0",
color: "var(--text-secondary)",
fontSize: 13,
}}
>
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
</div>
)}
{!loading && !error && templates.length > 0 && (
{!loading && !error && (
<div className="xx-cover-template-grid">
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id
return (
{templates.map((tpl) => (
<div
key={tpl.id}
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
<div
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
className="xx-cover-template-thumb"
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
>
<TemplateThumb tpl={tpl} isSelected={isSelected} />
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统</span>}
</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
🖼️
</div>
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统模板</span>}
</div>
<div className="xx-cover-template-date">{tpl.created_at}</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
编辑
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onEditTemplate(tpl)}
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
编辑
删除
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
)}
</div>
)}
<Button buttonType="ghost" buttonSize="sm">
导出
</Button>
</div>
</div>
)
})}
</div>
))}
</div>
)}
<div
style={{
marginTop: 12,
padding: "8px 12px",
background: "rgba(124,58,237,0.06)",
borderRadius: 6,
fontSize: 12,
color: "#6d28d9",
}}
>
💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面
</div>
</Modal>
)
}
@@ -0,0 +1,35 @@
/**
* 单个 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<AiTitleCardProps> = ({
title,
highlight,
style,
selected,
onClick,
}) => {
return (
<div className={`xx-ai-title-card ${selected ? "selected" : ""} ${style}`} onClick={onClick}>
<div className="xx-ai-title-card-text">{title}</div>
<div className="xx-ai-title-card-tag">{highlight}</div>
{selected && (
<div className="xx-ai-title-card-check">
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
</div>
)}
</div>
)
}
export default AiTitleCard
@@ -0,0 +1,105 @@
/**
* 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<AiTitleGeneratorProps> = ({
inputValue,
onInputChange,
generating,
onGenerate,
results,
hasGenerated,
onSelect,
selectedTitle,
onRefresh,
}) => {
return (
<div className="xx-ai-title-section">
<div className="xx-ai-title-header">
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
</div>
<div className="xx-ai-title-input-row">
<input
className="xx-ai-title-input"
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onGenerate()
}}
/>
<button
type="button"
className="xx-btn xx-btn-primary"
onClick={onGenerate}
disabled={generating || !inputValue.trim()}
>
{generating ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
生成中
</>
) : (
"生成标题"
)}
</button>
</div>
{/* 生成结果 */}
{hasGenerated && !generating && results.length > 0 && (
<div className="xx-ai-title-results">
<div className="xx-ai-title-results-header">
<span className="xx-ai-title-results-count">为你生成 {results.length} 个标题</span>
<button type="button" className="xx-link-btn" onClick={onRefresh} disabled={generating}>
🔄 换一批
</button>
</div>
<div className="xx-ai-title-list">
{results.map((item, idx) => (
<AiTitleCard
key={idx}
title={item.title}
highlight={item.highlight}
style={item.style}
selected={selectedTitle === item.title}
onClick={() => onSelect(item.title)}
/>
))}
</div>
</div>
)}
{/* 生成中 */}
{generating && (
<div className="xx-ai-title-loading">
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
AI 正在为你创作标题…
</div>
)}
</div>
)
}
export default AiTitleGenerator
@@ -1 +1,221 @@
export { default } from "@/components/title/TitleMiniPreview"
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleSettings } from "../../types"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底) */
background?: string
/** 高度(可选,默认 width/2) */
height?: number
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width / 1.8)
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
useEffect(() => {
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半)
const scale = width / 360
const r = (v: number) => Math.round(v * scale)
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
startY = h - totalH - r(16) + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}, [settings, width, h, text])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -443,16 +443,189 @@
padding: 6px 8px !important;
}
/* 标题模板入口按钮(#2003) */
.ts-template-btn {
border: none;
background: transparent;
color: var(--primary-color, #7c3aed);
/* ============================================================
#模板管理系统 — 模板卡片网格样式
============================================================ */
/* 模板头部(标签 + 新建按钮) */
.ts-template-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.ts-template-header label {
margin-bottom: 0;
}
.ts-new-template-btn {
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
padding: 2px 0;
font-weight: 500;
color: #7c3aed;
background: #faf5ff;
border: 1px solid #ddd6fe;
border-radius: 6px;
cursor: pointer;
transition: 0.15s;
}
.ts-template-btn:hover {
text-decoration: underline;
.ts-new-template-btn:hover {
background: #ede9fe;
border-color: #7c3aed;
}
/* 模板网格:4列 */
.ts-template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
/* 模板卡片 */
.ts-template-card {
position: relative;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition: 0.15s;
}
.ts-template-card:hover {
border-color: #c4b5fd;
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);
}
.ts-template-card.active {
border-color: #7c3aed;
background: #faf5ff;
box-shadow: 0 0 0 1px #7c3aed;
}
/* 预览按钮 */
.ts-template-preview-btn {
display: block;
width: 100%;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
/* 预览 canvas 容器 */
.ts-template-canvas-wrap {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #0f172a;
overflow: hidden;
}
.ts-template-canvas-wrap canvas {
max-width: 100%;
max-height: 100%;
}
/* 标签行 */
.ts-template-label-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px;
gap: 4px;
}
.ts-template-name {
font-size: 10px;
color: #4b5563;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.ts-template-tag {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
flex-shrink: 0;
}
.ts-template-tag.system {
background: #f3f4f6;
color: #6b7280;
}
.ts-template-tag.user {
background: #ede9fe;
color: #7c3aed;
}
/* 操作按钮 */
.ts-template-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 2px;
padding: 3px 4px;
background: rgba(255, 255, 255, 0.95);
border-top: 1px solid #e5e7eb;
z-index: 2;
}
.ts-template-action-btn {
padding: 2px 6px;
font-size: 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: #fff;
cursor: pointer;
color: #374151;
transition: 0.1s;
}
.ts-template-action-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.ts-template-action-delete:hover {
border-color: #ef4444;
color: #ef4444;
}
/* 编辑模式横幅 */
.ts-editing-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 10px;
background: #ede9fe;
border: 1px solid #c4b5fd;
border-radius: 8px;
font-size: 13px;
color: #5b21b6;
}
.ts-editing-save-btn {
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: #7c3aed;
border: none;
border-radius: 6px;
cursor: pointer;
margin-left: auto;
transition: 0.15s;
}
.ts-editing-save-btn:hover {
background: #6d28d9;
}
.ts-editing-cancel-btn {
padding: 4px 12px;
font-size: 12px;
color: #6b7280;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: 0.15s;
}
.ts-editing-cancel-btn:hover {
border-color: #9ca3af;
color: #374151;
}
File diff suppressed because it is too large Load Diff
+32 -3
View File
@@ -46,9 +46,13 @@ export const CLIP_COUNT_STEP = 1
export const MAX_PREVIEW_COUNT = 10
export const MIN_PREVIEW_COUNT = 1
/* ── 标题位置选项(统一从公共层重导出) ── */
export { POSITION_OPTIONS } from "@/components/title/position-options"
export type { PositionOption } from "@/components/title/position-options"
/* ── 标题位置选项 ── */
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
/* ── 标题字体:统一使用公共层定义(#2001) ── */
export { getFontFamily } from "@/components/title/constants"
@@ -531,6 +535,31 @@ export const SMART_MATCH_REASONS = [
"人物表情生动",
]
/* ── AI 标题模板 ── */
export const AI_TITLE_TEMPLATES: Record<string, string[]> = {
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,
+150 -463
View File
@@ -2739,7 +2739,6 @@
justify-content: center;
font-size: 32px;
color: #ccc;
position: relative;
}
/* 卡片信息区 */
@@ -2881,6 +2880,27 @@
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;
@@ -3220,6 +3240,135 @@
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;
@@ -3336,465 +3485,3 @@
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;
}
@@ -41,8 +41,8 @@ export interface UseGenerateVideoProps {
enabled: boolean
music_id?: string
}
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态);status=awaiting_cover 表示需进封面选择 */
onGenerationSuccess?: (status?: "completed" | "awaiting_cover") => void
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
onGenerationSuccess?: () => void
/* ── 批量生成(#1677)── */
/** 生成数量(1=单条旧逻辑,>1=批量) */
previewCount?: number
@@ -1,4 +1,4 @@
import { useRef, useCallback, useState, useEffect } from "react"
import { useRef, useCallback, useState } 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" | "awaiting_cover" | "failed" | "queued"
status: "running" | "completed" | "failed"
progress: number
error: string | null
/** 完成后的成片视频 */
@@ -19,7 +19,7 @@ export interface BatchTaskState {
interface UseGenerationPollingOptions {
onProgress: (progress: number) => void
onComplete: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
onComplete: (videos: unknown[]) => void
onFailed: (errorMsg: string) => void
/** 批量:单任务状态变化(第5步逐卡片展示) */
onBatchTaskUpdate?: (taskId: string, patch: Partial<BatchTaskState>) => void
@@ -31,19 +31,13 @@ const MAX_RETRYABLE_ERRORS = 10
const MAX_RESULTS_RETRIES = 3
/**
* 生成状态轮询 Hook(v5 — awaiting_cover 状态识别 + visibilitychange 恢复 + 状态透传)
* 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试)
*
* 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,
@@ -55,15 +49,12 @@ export function useGenerationPolling({
const cancelledRef = useRef(false)
/** 批量任务上下文:taskId → 变体序号 */
const batchContextRef = useRef<Map<string, number>>(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
}, [])
/** 任务完成后拉取结果列表,带重试 */
@@ -105,7 +96,7 @@ export function useGenerationPolling({
runId: number,
callbacks?: {
onTaskProgress?: (pct: number) => void
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
onTaskCompleted?: (videos: unknown[]) => void
onTaskFailed?: (msg: string) => void
},
): Promise<unknown[]> => {
@@ -120,9 +111,8 @@ export function useGenerationPolling({
if (cancelledRef.current || done) return
consecutiveErrors = 0
if (task.status === "completed" || task.status === "awaiting_cover") {
if (task.status === "completed") {
done = true
immediateTickRef.current = null
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
@@ -131,14 +121,13 @@ export function useGenerationPolling({
reject(new Error(msg))
return
}
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
callbacks?.onTaskCompleted?.(videos)
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 ||
@@ -149,7 +138,6 @@ 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) {
@@ -161,20 +149,16 @@ 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))
@@ -185,18 +169,6 @@ 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)
})
@@ -209,22 +181,12 @@ export function useGenerationPolling({
(taskId: string) => {
cancelledRef.current = false
batchContextRef.current.clear()
let resolvedStatus: "completed" | "awaiting_cover" = "completed"
pollSingleTask(taskId, 0, {
onTaskProgress: (pct) => onProgress(pct),
onTaskCompleted: (videos, taskStatus) => {
resolvedStatus = taskStatus ?? "completed"
onProgress(100)
onComplete(videos, resolvedStatus)
},
onTaskFailed: (msg) => onFailed(msg),
})
.then(() => {
pollSingleTask(taskId, 0)
.then((videos) => {
if (cancelledRef.current) return
// awaiting_cover 是中间态(进封面选择页),不弹"完成"toast;completed 才弹
if (resolvedStatus === "completed") {
message.success("视频生成完成!")
}
onProgress(100)
onComplete(videos)
message.success("视频生成完成!")
})
.catch((err: Error) => {
if (cancelledRef.current) return
@@ -239,7 +201,7 @@ export function useGenerationPolling({
/**
* 批量多任务轮询:
* - 每个任务独立进度/状态回传 onBatchTaskUpdate
* - 全部完成后按变体顺序聚合视频 onComplete
* * 全部完成后按变体顺序聚合视频 onComplete
* - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed
*/
const startPollingBatch = useCallback(
@@ -249,7 +211,6 @@ export function useGenerationPolling({
const progressMap = new Map<string, number>()
const resultMap = new Map<string, unknown[]>()
const failureMap = new Map<string, string>()
const statusMap = new Map<string, "completed" | "awaiting_cover">()
batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex]))
const reportAggregateProgress = () => {
@@ -264,9 +225,7 @@ export function useGenerationPolling({
if (resultMap.size === tasks.length) {
onProgress(100)
const ordered = tasks.map((t) => resultMap.get(t.taskId) || []).flat()
// 批量:任一任务为 awaiting_cover,则整体透传 awaiting_cover(进封面页)
const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover")
onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed")
onComplete(ordered)
message.success(`全部 ${tasks.length} 个视频生成完成!`)
} else if (resultMap.size > 0) {
// 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮
@@ -275,8 +234,7 @@ export function useGenerationPolling({
.filter((t) => resultMap.has(t.taskId))
.map((t) => resultMap.get(t.taskId) || [])
.flat()
const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover")
onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed")
onComplete(ordered)
message.warning(
`${failureMap.size} 个视频生成失败,可点击卡片上的「重试此视频」,成功的视频可先进入下一步`,
)
@@ -301,12 +259,10 @@ export function useGenerationPolling({
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
reportAggregateProgress()
},
onTaskCompleted: (videos, taskStatus) => {
onTaskCompleted: (videos) => {
progressMap.set(taskId, 100)
resultMap.set(taskId, videos)
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
statusMap.set(taskId, _finalStatus)
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
reportAggregateProgress()
checkAllSettled()
},
@@ -337,9 +293,8 @@ export function useGenerationPolling({
}
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 })
onTaskCompleted: (videos) => {
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
message.success(`视频 ${variantIndex + 1} 重试成功`)
},
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
@@ -352,64 +307,5 @@ export function useGenerationPolling({
[pollSingleTask, onBatchTaskUpdate],
)
/**
* 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 }
return { startPolling, startPollingBatch, retryTask, clearTimer }
}
@@ -12,7 +12,7 @@
import { useCallback, useState } from "react"
import { message } from "antd"
import { generateCover } from "@/api/generation"
import { uploadAssetDirect } from "@/api/assets"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import type { GeneratedVideo } from "@/api/template-editor"
/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */
@@ -93,12 +93,7 @@ export function useBatchCovers({
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
const generateOne = useCallback(
async (index: number): Promise<boolean> => {
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" ||
v.status === "awaiting_cover" ||
v.status === "awaiting_cover",
)
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
@@ -107,57 +102,54 @@ export function useBatchCovers({
addBusy(index)
try {
const titleText = titles[index] || ""
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<string, unknown>
>,
},
}
: {}),
},
)
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,
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<string, unknown>
>,
},
}
: {}),
})
const url = response.cover?.image_url || response.cover?.thumbnail_url || ""
if (url) {
patchCover(index, url)
@@ -182,9 +174,15 @@ 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,
kind: "image",
library_id: imageLib.id,
})
const url = result?.url || ""
if (url) {
@@ -205,10 +203,7 @@ export function useBatchCovers({
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
)
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const total = finalVideos.length
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
@@ -2,12 +2,10 @@
* 视频生成 Hook
* 封装视频生成的核心逻辑、状态管理、轮询等
*/
import { useState, useCallback, useEffect, useRef } from "react"
import { useState, useCallback, useEffect } 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"
@@ -15,28 +13,6 @@ 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<void>((r) => setTimeout(r, ms))
export function useGenerateVideo(props: UseGenerateVideoProps) {
const { selectedTemplate, onGenerationSuccess } = props
@@ -46,29 +22,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const [generated, setGenerated] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
/** #2088:任务最终状态,区分 awaiting_cover(选封面)/ completed(已完成) */
const [completionStatus, setCompletionStatus] = useState<GenerationCompleteStatus>(null)
/** 单视频模式:当前任务 ID(封面 finalize 需要) */
const [currentTaskId, setCurrentTaskId] = useState<string>("")
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
/** 排队中重试的定时器,unmount / 新提交时清理 */
const queueTimersRef = useRef<number[]>([])
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<BatchTaskState>) => {
setBatchTasks((prev) => {
const list = prev || []
@@ -95,15 +51,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const handleProgress = useCallback((p: number) => setProgress(p), [])
const handleComplete = useCallback(
(videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => {
(videos: unknown[]) => {
setGenerating(false)
setGenerated(true)
const finalStatus: GenerationCompleteStatus = taskStatus ?? "completed"
setCompletionStatus(finalStatus)
setGeneratedVideos(videos as GeneratedVideo[])
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
setBatchTasks((prev) =>
(prev || []).map((t) =>
t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0)
t.status === "completed" && t.videos.length === 0
? {
...t,
videos: (videos as GeneratedVideo[]).filter(
@@ -113,35 +68,23 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
: t,
),
)
onGenerationSuccess?.(finalStatus)
onGenerationSuccess?.()
},
[onGenerationSuccess],
)
const handleFailed = useCallback((errorMsg: string) => {
setGenerating(false)
setGenerateError(errorMsg)
}, [])
/* 批量:任务状态变化时聚合已完成成片(含失败重试成功后补入),
按变体索引排序,供步骤6封面按勾选顺序逐个取视频。
当全部任务都已结束(completed/awaiting_cover/failed)且无排队/渲染中任务时,关闭 generating。 */
按变体索引排序,供步骤6封面按勾选顺序逐个取视频 */
useEffect(() => {
if (batchTasks.length === 0) return
const byVariant = new Map<number, GeneratedVideo>()
let hasQueued = false
let hasRunning = false
let hasSuccess = false
let allDone = true
batchTasks.forEach((t) => {
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
if (t.status === "completed" && t.videos && t.videos.length > 0) {
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
}
})
const ordered = [...byVariant.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v)
@@ -151,185 +94,17 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
return ordered
})
if (allDone && !hasQueued && !hasRunning) {
setGenerating(false)
if (hasSuccess) {
setGenerated(true)
setCompletionStatus("awaiting_cover")
}
}
}, [batchTasks])
const { startPolling, pollBatchTaskQueued, retryTask, clearTimer } = useGenerationPolling({
const { startPolling, startPollingBatch, 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<string, unknown>) : {}),
}
return payload
}, [props, selectedTemplate])
/* ── 生成视频 ──
返回 true 表示任务创建成功并已开始轮询(含排队中);false 表示校验未通过或创建失败 */
返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */
const generate = useCallback(async (): Promise<boolean> => {
const errorMsg = validateGenerateInputs(props)
if (errorMsg) {
@@ -337,28 +112,32 @@ 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 {
// from-assets 兜底:片段不存在则补一次
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。
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 {
@@ -366,185 +145,219 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
}
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
}
return true
}
const isBatch = (props.previewCount || 1) > 1
const hide = message.loading(
isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...",
0,
)
/* ── 批量:支持任意数量视频,按队列容量串行提交,429 自动排队重试 ── */
const indexes = props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
const batchCount = indexes.length
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
const titlesAll =
(props.variantTitles?.length || 0) >= batchCount
// #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 || "")
: 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(() => "")
// 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 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)
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,
})
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,
tts_style: props.ttsStyle || undefined,
}
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
}
}
}
: {}),
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,
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 } }),
},
}
: {}),
},
}
: {}),
})
hide()
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
if (fatalErr) {
// 有任务失败但其余已成功,整体不 throw;由 UI 展示单个失败卡片
if (taskIds.length === 0) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
if (taskIds.length > 1) {
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
} else {
startPolling(taskIds[0])
}
} catch (err) {
hide()
throw err
}
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
}
}, [
props,
clearTimer,
startPolling,
selectedTemplate,
buildBasePayload,
handleBatchTaskUpdate,
clearQueueTimers,
pollBatchTaskQueued,
])
return true
}, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate])
const retry = useCallback(() => {
setGenerateError(null)
@@ -554,10 +367,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
/** 第5步:单独重试某个失败任务 */
const retryBatchTask = useCallback(
(taskId: string) => {
handleBatchTaskUpdate(taskId, { status: "running", progress: 0, error: null, videos: [] })
retryTask(taskId)
},
[retryTask, handleBatchTaskUpdate],
[retryTask],
)
const dismissError = useCallback(() => {
@@ -602,8 +414,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
generated,
generateError,
generatedVideos,
completionStatus,
currentTaskId,
generate,
retry,
retryBatchTask,
@@ -0,0 +1,155 @@
/**
* 预览素材加载 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<AssetItem[]> {
// 防御:过滤空值/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<AssetItem>(`/assets/${id}`)),
)
return results
.filter(
(r): r is PromiseFulfilledResult<AxiosResponse<AssetItem>> =>
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<void>
}
/**
* usePreviewAssets — 加载选中素材的视频文件信息
*/
export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn {
const [assets, setAssets] = useState<AssetItem[]>([])
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<AssetItem[]>([])
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<T>(array: T[]): T[] {
const ref = useRef<T[]>(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
@@ -0,0 +1,131 @@
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<HTMLAudioElement | null>(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 }
}
@@ -0,0 +1,384 @@
/**
* 素材片段调度器 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<void> => {
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
@@ -1,8 +1,9 @@
import { useEffect } from "react"
import { useEffect, useRef } 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"
@@ -15,7 +16,7 @@ interface UseStep4TitleProps {
/**
* Step 4 标题设置 Hook
* 封装标题样式设置等逻辑
* 封装 AI 标题生成、标题样式设置等逻辑
*/
export function useStep4Title({
titleSettings,
@@ -33,9 +34,26 @@ 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(() => {
@@ -68,12 +86,41 @@ 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,
setActivePreset: styleUpdaters.setActivePreset,
titlePresets: styleUpdaters.titlePresets,
// AI 标题操作
handleGenerateAiTitles,
handleSelectAiTitle,
handleRefreshAiTitles,
autoGenerateTitle,
// 标题设置操作
updateTitle: styleUpdaters.updateTitle,
toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect,
@@ -86,6 +133,7 @@ export function useStep4Title({
toggleStroke: styleUpdaters.toggleStroke,
toggleShadow: styleUpdaters.toggleShadow,
applyPreset: styleUpdaters.applyPreset,
updateStyle: styleUpdaters.updateStyle,
}
}
@@ -0,0 +1,124 @@
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<AiTitleItem[]>([])
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,
}
}
@@ -1,7 +1,6 @@
import { useCallback, useMemo } from "react"
import { useCallback, useState } 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 {
@@ -12,44 +11,15 @@ interface UseTitleStyleUpdatersOptions {
/**
* 标题样式更新 Hook
* 封装标题文字、位置、字体、样式等所有设置更新函数
*
* Bug修复:手动修改参数时自动脱离预设(#模板管理系统)
*/
export function useTitleStyleUpdaters({
titleSettings,
onTitleSettingsChange,
}: UseTitleStyleUpdatersOptions) {
/** 匹配预设:对新预设(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 &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
/** 当前选中的预设 key(手动修改参数时置 null) */
const [activePreset, setActivePreset] = useState<string | null>(null)
const updateTitle = useCallback(
(title: string) => {
@@ -64,7 +34,6 @@ export function useTitleStyleUpdaters({
const updatePosition = useCallback(
(position: string) => {
// 切回三档预设时清掉自定义坐标
onTitleSettingsChange({ ...titleSettings, position, posX: null, posY: null })
},
[titleSettings, onTitleSettingsChange],
@@ -78,8 +47,18 @@ export function useTitleStyleUpdaters({
[titleSettings, onTitleSettingsChange],
)
/** 通用手动更新:自动脱离预设 */
const updateStyle = useCallback(
(patch: Partial<TitleSettings>) => {
setActivePreset(null)
onTitleSettingsChange({ ...titleSettings, ...patch })
},
[titleSettings, onTitleSettingsChange],
)
const updateFont = useCallback(
(font: string) => {
setActivePreset(null)
onTitleSettingsChange({ ...titleSettings, font })
},
[titleSettings, onTitleSettingsChange],
@@ -87,6 +66,7 @@ export function useTitleStyleUpdaters({
const updateSize = useCallback(
(size: number) => {
setActivePreset(null)
onTitleSettingsChange({ ...titleSettings, size })
},
[titleSettings, onTitleSettingsChange],
@@ -94,36 +74,33 @@ export function useTitleStyleUpdaters({
const updateColor = useCallback(
(color: string) => {
setActivePreset(null)
onTitleSettingsChange({ ...titleSettings, color })
},
[titleSettings, onTitleSettingsChange],
)
const toggleBold = useCallback(() => {
setActivePreset(null)
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
}, [titleSettings, onTitleSettingsChange])
const toggleItalic = useCallback(() => {
setActivePreset(null)
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
}, [titleSettings, onTitleSettingsChange])
const toggleStroke = useCallback(() => {
onTitleSettingsChange({
...titleSettings,
stroke: !titleSettings.stroke,
// 开启描边时若宽度过小给个默认值(让滑块可见可调)
strokeWidth:
!titleSettings.stroke && (titleSettings.strokeWidth ?? 0) < 2
? 4
: titleSettings.strokeWidth,
})
setActivePreset(null)
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
}, [titleSettings, onTitleSettingsChange])
const toggleShadow = useCallback(() => {
setActivePreset(null)
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
}, [titleSettings, onTitleSettingsChange])
/** 应用预设:正确把 snake_case 的 preset.style 转为 camelCase 再 spread */
/** 应用预设(支持新预设细粒度字段) */
const applyPreset = useCallback(
(presetKey: string) => {
const newPreset = NEW_TITLE_PRESETS.find((p) => p.key === presetKey)
@@ -131,71 +108,30 @@ export function useTitleStyleUpdaters({
if (newPreset) {
onTitleSettingsChange({
...titleSettings,
...titleStyleConfigToCamel(newPreset.style),
// 封面独立标题保持不变(不清空,避免破坏封面定制)
...(newPreset.style as Partial<TitleSettings>),
lineOverrides: [],
})
setActivePreset(presetKey)
return
}
if (!oldPreset) return
onTitleSettingsChange({
...titleSettings,
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<TitleSettings>) => {
onTitleSettingsChange({ ...titleSettings, ...patch })
if (oldPreset) {
onTitleSettingsChange({
...titleSettings,
color: oldPreset.style.color,
bold: oldPreset.style.bold,
italic: oldPreset.style.italic,
stroke: oldPreset.style.stroke,
shadow: oldPreset.style.shadow,
})
setActivePreset(presetKey)
}
},
[titleSettings, onTitleSettingsChange],
)
return {
activePreset,
setActivePreset,
titlePresets: NEW_TITLE_PRESETS,
updateTitle,
toggleAiAutoSelect,
@@ -209,7 +145,6 @@ export function useTitleStyleUpdaters({
toggleStroke,
toggleShadow,
applyPreset,
applyTemplate,
updateStyle,
}
}
@@ -0,0 +1,82 @@
/**
* 标题模板管理 Hook
* - 系统预设从 TITLE_PRESETS 加载(始终可用)
* - 用户自定义模板存 localStorage
* - 支持创建、编辑、复制、删除
*/
import { useState, useCallback, useEffect } from "react"
import { TITLE_PRESETS } from "@/components/title/constants"
import type { TitleStyleConfig } from "@/components/title/types"
const STORAGE_KEY = "xiaoxia-title-templates"
export interface TitleTemplate {
key: string
label: string
emoji?: string
isSystem: boolean
style: Partial<TitleStyleConfig>
}
/** 生成唯一模板 key */
function genTemplateKey(label: string): string {
const slug = label
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
return `user_${slug}_${Date.now().toString(36)}`
}
export function useTitleTemplates() {
const [userTemplates, setUserTemplates] = useState<TitleTemplate[]>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
})
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(userTemplates))
} catch {
// ignore
}
}, [userTemplates])
const allTemplates: TitleTemplate[] = [
...TITLE_PRESETS.map((p) => ({ ...p, isSystem: true })),
...userTemplates,
]
const saveTemplate = useCallback((label: string, style: Partial<TitleStyleConfig>, emoji?: string) => {
const t: TitleTemplate = { key: genTemplateKey(label), label, emoji: emoji || "", isSystem: false, style }
setUserTemplates((prev) => [...prev, t])
return t
}, [])
const updateTemplate = useCallback((key: string, patch: { label?: string; style?: Partial<TitleStyleConfig>; emoji?: string }) => {
setUserTemplates((prev) => prev.map((t) => (t.key === key ? { ...t, ...patch } : t)))
}, [])
const deleteTemplate = useCallback((key: string) => {
setUserTemplates((prev) => prev.filter((t) => t.key !== key))
}, [])
const duplicateTemplate = useCallback((key: string) => {
setUserTemplates((prev) => {
const src = prev.find((t) => t.key === key)
if (!src) return prev
const dup: TitleTemplate = {
...src,
key: genTemplateKey(src.label + "_copy"),
label: src.label + " 副本",
}
return [...prev, dup]
})
}, [])
return { allTemplates, saveTemplate, updateTemplate, deleteTemplate, duplicateTemplate }
}
@@ -19,7 +19,7 @@ import {
interface UseStep6CoverProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/** 当前选中的视频模板 ID(封面模板由本地 state 管理) */
/** 当前选中的模板 ID */
selectedTemplate?: string
/** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
@@ -30,7 +30,7 @@ interface UseStep6CoverProps {
export function useStep6Cover({
coverSettings,
onCoverSettingsChange,
selectedTemplate: _selectedTemplate = "",
selectedTemplate = "",
titleSettings,
generatedVideos,
}: UseStep6CoverProps) {
@@ -48,11 +48,7 @@ export function useStep6Cover({
const [templatesError, setTemplatesError] = useState<string | null>(null)
/** 最终成片:取第一个已完成视频 */
const finalVideo =
generatedVideos.find(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
) || generatedVideos[0]
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
/** 从后端加载封面模板列表 */
const loadTemplates = useCallback(async () => {
@@ -83,8 +79,7 @@ export function useStep6Cover({
return
}
const activeCoverTemplateId = selectedTemplateId || "default"
if (!activeCoverTemplateId) {
if (!selectedTemplate) {
message.error("请先选择模板")
return
}
@@ -100,7 +95,7 @@ export function useStep6Cover({
}, 300000)
try {
const response = await generateCover(activeCoverTemplateId, {
const response = await generateCover(selectedTemplate, {
generated_video_id: finalVideo.id,
video_url: finalVideo.file_url || finalVideo.download_url || "",
cover_type: "ai_frame",
@@ -161,7 +156,7 @@ export function useStep6Cover({
setGenerating(false)
}
}, [
selectedTemplateId,
selectedTemplate,
finalVideo,
coverSettings,
onCoverSettingsChange,
@@ -175,18 +170,7 @@ export function useStep6Cover({
}, [])
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
// 系统模板不可修改:复制为新模板草稿,走"另存为"流程
if (tpl.is_system) {
setEditingTemplate({
...tpl,
id: "",
name: tpl.name + " 副本",
is_system: false,
created_at: "",
})
} else {
setEditingTemplate(tpl)
}
setEditingTemplate(tpl)
setShowCoverEditor(true)
}, [])
@@ -194,25 +178,20 @@ export function useStep6Cover({
const handleSaveTemplate = useCallback(
async (tpl: CoverTemplate) => {
try {
// 系统模板或无 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 {
if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) {
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)
}

Some files were not shown because too many files have changed in this diff Show More