Compare commits

..

2 Commits

Author SHA1 Message Date
Coze Agent f741a0e76e refactor(shared): 抽取封面选择为公共组件CoverSelector,智能剪辑/AI数字人复用
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 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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) Successful in 1m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m2s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m27s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m7s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 4m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m58s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 10m20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m35s
- 新增公共目录 src/components/cover/(在原 useSharedCover/cover.css 基础上补全):
  - CoverSelector.tsx:公共主组件,支持inline/modal两种模式、props(showAutoGenerate/showTemplate/showUpload/canGenerate/aspectRatio/hint/onChange/onAutoGenerate/onTemplateChange等),自管理loading/上传状态
  - CoverSettingsModal.tsx:封面模板选择弹窗(卡片网格+系统标签+编辑/删除)
  - CoverEditorModal.tsx:封面模板自定义编辑器(7面板:基础/人像/背景/主副标题/蒙版)
  - CoverModeSelector/FrameCoverPicker/UploadCoverPicker:子组件
  - types.ts:CoverTemplate/CoverEditorConfig/TextStyleConfig/ALL_FONTS等公共类型+默认值(从pages/generate/types/cover迁移)
  - uploadCover.ts:OSS直传公共工具(uploadCoverToOSS/uploadCoverWithPreview),blob预览→OSS上传两阶段
  - cover.css:新增cs-*前缀样式,保证公共组件自带样式不依赖页面CSS
  - index.ts:barrel统一导出
- 智能剪辑 Step6CoverSettings:
  - 单视频场景改为使用公共CoverSelector(inline模式),自动生成/模板/上传/预览统一
  - 批量场景保留useBatchCovers+卡片网格逻辑,模板选择通过嵌入一个只开showTemplate的CoverSelector实现
  - props解构消除hooks exhaustive-deps警告
- AI数字人 ModalCoverSelect:
  - 由原来自行拼接布局+内联按钮+弹窗改为使用公共CoverSelector(modal模式)
  - 保持AiAvatarCoverConfig回调签名不变
- 旧路径(pages/generate/components/cover-settings/*、pages/generate/types/cover.ts)改为re-export wrapper,保持向后兼容
- api/cover-templates.ts 和 useSharedCover.ts 改为从公共层import类型,消除pages反向依赖
- tsc/eslint/prettier/vite build全通过,CoverSelector独立打包为72KB懒加载chunk
2026-09-27 21:35:34 +08:00
Coze Agent 4ae09f2b1d refactor(shared): 抽取标题模板为公共组件,智能剪辑与AI数字人复用
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 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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) Successful in 1m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m36s
AI Code Review / AI Code Review (pull_request) Successful in 6m39s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m34s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m41s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 36s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 48s
- 新增公共组件 @/components/title:
  - TitleTemplateSelector: 大卡片网格选择器(我的/系统模板分组、新建/编辑/复制/导出/删除)
  - TitleTemplateEditor: 模板编辑器弹窗(预览+参数Tab)
  - TitleStyleParamsTab: 参数Tab面板(基础/描边/阴影/背景/排版)
  - TitleMiniPreview: Canvas大字预览(迁移自pages内部)
  - settings.ts: 公共 TitleStyleSettings 类型+DEFAULT(不含业务字段 aiAutoSelect/title/coverTitle)
  - utils.ts: 重构为依赖公共类型,消除 pages/generate/types 反向依赖
  - position-options.ts: 公共 POSITION_OPTIONS 常量
  - index.ts: 统一 barrel export
- 智能剪辑 TitleStylePanel(pages/generate/components/title/):
  - 改为 import @/components/title/TitleMiniPreview,旧路径改为 re-export
  - 修复 FillPreview 类型,消除 TitleStyleSettings/TitleSettings 类型不匹配
  - generate/constants.ts 重导出 POSITION_OPTIONS 自公共层
- AI数字人 PanelTitleConfig:
  - enableTemplates 改为 true,启用大卡片网格模式(与智能剪辑 Step3 一致)
  - 共享同一份 TITLE_PRESETS 系统预设、localStorage 用户模板
- tsc/eslint/prettier/vite build 全部通过
2026-09-27 20:19:52 +08:00
268 changed files with 5912 additions and 41938 deletions
+2 -1
View File
@@ -1 +1,2 @@
trigger staging deploy for 3fa1fbf0 (MOCK_STORYBOARD fix) at 2026-10-12 13:40:16
CI trigger file - safe to delete
retrigger at 2026-09-15 20:31:24 UTC
+3 -36
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
@@ -211,24 +192,10 @@ COSYVOICE_CLONE_MODEL=voice-enrollment
# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景
DOUBAO_API_KEY=your-doubao-api-key
DOUBAO_MODEL=doubao-seed-2-1-pro-260915
DOUBAO_FAST_MODEL=doubao-seed-2-1-lite-260915
DOUBAO_MODEL=doubao-seed-1-6-250615
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
# 视觉模型:pro 精度高,lite 速度快(viral-video 商品识别默认用 lite 提速)
DOUBAO_VISION_MODEL=doubao-seed-2-1-pro-260915
DOUBAO_VISION_LITE_MODEL=doubao-seed-2-1-lite-260915
DOUBAO_VISION_USE_LITE=true
# Embedding 向量化模型
DOUBAO_EMBEDDING_MODEL=doubao-embedding-vision-251215
# 视频模型(Seedance 2.5,统一走方舟;真人参考图通过信任链自动 AI 化)
DOUBAO_VIDEO_MODEL=doubao-seedance-2-5-260628
DOUBAO_VIDEO_TIMEOUT=480
DOUBAO_VIDEO_POLL_INTERVAL=10
# 图片模型(Seedream 5.0 Pro,用于信任链真人 AI 化 + 文生图)
DOUBAO_IMAGE_MODEL=doubao-seedream-5-0-pro-260628
DOUBAO_IMAGE_TIMEOUT=120
# ==================== 积分/会员系统 (#1895) ====================
# 积分系统总开关:默认 false(暂停积分系统)。
+2 -18
View File
@@ -437,7 +437,7 @@ jobs:
if: "always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'"
name: Unit Tests
runs-on: ci-l2
timeout-minutes: 20
timeout-minutes: 8
env:
PIP_CACHE_DIR: /root/.cache/pip
PIP_NO_CACHE_DIR: ''
@@ -1187,14 +1187,6 @@ jobs:
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
DOUBAO_VISION_LITE_MODEL: "${{ secrets.DOUBAO_VISION_LITE_MODEL }}"
DOUBAO_VISION_USE_LITE: "${{ secrets.DOUBAO_VISION_USE_LITE }}"
DOUBAO_IMAGE_MODEL: "${{ secrets.DOUBAO_IMAGE_MODEL }}"
DOUBAO_IMAGE_SIZE: "${{ secrets.DOUBAO_IMAGE_SIZE }}"
DOUBAO_IMAGE_TIMEOUT: "${{ secrets.DOUBAO_IMAGE_TIMEOUT }}"
DOUBAO_FAST_MODEL: "${{ secrets.DOUBAO_FAST_MODEL }}"
DOUBAO_TIMEOUT: "${{ secrets.DOUBAO_TIMEOUT }}"
DOUBAO_MAX_RETRIES: "${{ secrets.DOUBAO_MAX_RETRIES }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
@@ -1310,16 +1302,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
-2
View File
@@ -1,2 +0,0 @@
Mon Oct 5 04:09:11 PM CST 2026
2198 lite/pro并行竞速 (commit 9699a1f) — CI rebuild trigger Mon Oct 5 08:09:11 AM UTC 2026
@@ -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,35 +0,0 @@
"""viral video add phase_message column (#2134)
Revision ID: 090_viral_video_phase_msg
Revises: 089_viral_video_cols
Create Date: 2026-10-02
#2134 阶段细粒度提示:viral_video 表新增 phase_message 列(中文阶段提示文案)。
current_stage 列已在之前版本存在,本迁移只补 phase_message。
幂等 ADD COLUMN IF NOT EXISTS。
"""
import sqlalchemy as sa
from alembic import op
revision = "090_viral_video_phase_msg"
down_revision = "089_viral_video_cols"
branch_labels = None
depends_on = None
def upgrade() -> None:
# SQLite/PostgreSQL 兼容的幂等添加列
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "phase_message" not in cols:
op.add_column(
"viral_video_jobs",
sa.Column("phase_message", sa.String(length=500), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("viral_video_jobs", "phase_message")
-49
View File
@@ -1,49 +0,0 @@
"""viral video add current_stage column (#2137 follow-up)
Revision ID: 091_viral_video_stage
Revises: 090_viral_video_phase_msg
Create Date: 2026-10-02
#2137 follow-up fix: 090 migration missed current_stage column on viral_video_jobs,
causing UndefinedColumn errors and 500s on all authenticated viral-video endpoints.
Idempotently add current_stage and double-check phase_message.
"""
import sqlalchemy as sa
from alembic import op
revision = "091_viral_video_stage"
down_revision = "090_viral_video_phase_msg"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "current_stage" not in cols:
op.add_column(
"viral_video_jobs",
sa.Column(
"current_stage",
sa.String(length=200),
nullable=False,
server_default="",
),
)
if "phase_message" not in cols:
op.add_column(
"viral_video_jobs",
sa.Column(
"phase_message",
sa.String(length=500),
nullable=False,
server_default="",
),
)
def downgrade() -> None:
op.drop_column("viral_video_jobs", "current_stage")
@@ -1,42 +0,0 @@
"""viral_video_jobs 增加 heartbeat_at 列(worker 心跳,用于僵尸任务超时回收)
Revision ID: 092_viral_video_heartbeat
Revises: 091_viral_video_stage
Create Date: 2026-10-02
"""
import sqlalchemy as sa
from alembic import op
revision = "092_viral_video_heartbeat"
down_revision = "091_viral_video_stage"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "heartbeat_at" not in cols:
op.add_column("viral_video_jobs", sa.Column("heartbeat_at", sa.DateTime(), nullable=True))
op.execute(
"UPDATE viral_video_jobs SET heartbeat_at = updated_at " "WHERE status = 'running' AND heartbeat_at IS NULL"
)
try:
op.create_index("ix_viral_video_jobs_heartbeat_at", "viral_video_jobs", ["heartbeat_at"])
except Exception:
pass
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "heartbeat_at" in cols:
try:
op.drop_index("ix_viral_video_jobs_heartbeat_at", table_name="viral_video_jobs")
except Exception:
pass
op.drop_column("viral_video_jobs", "heartbeat_at")
@@ -1,87 +0,0 @@
"""viral_video 动态积分定价 + 积分字段从 Integer 改为 Float (#2151)
Revision ID: 093
Revises: 092_viral_video_heartbeat
Create Date: 2026-10-02
"""
import sqlalchemy as sa
from alembic import op
revision = "093"
down_revision = "092_viral_video_heartbeat"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# 1) points_accounts 三列 Integer -> Float
pa_cols = {c["name"]: c for c in inspector.get_columns("points_accounts")}
for col in ("balance", "total_earned", "total_spent"):
if col in pa_cols:
op.alter_column(
"points_accounts",
col,
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
# 2) points_transactions amount/balance_after Integer -> Float
pt_cols = {c["name"]: c for c in inspector.get_columns("points_transactions")}
for col in ("amount", "balance_after"):
if col in pt_cols:
op.alter_column(
"points_transactions",
col,
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
# 3) users.points_balance Integer -> Float
user_cols = {c["name"]: c for c in inspector.get_columns("users")}
if "points_balance" in user_cols:
op.alter_column(
"users",
"points_balance",
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
# 4) viral_video_jobs.credits_cost Integer -> Float
vv_cols = {c["name"]: c for c in inspector.get_columns("viral_video_jobs")}
if "credits_cost" in vv_cols:
op.alter_column(
"viral_video_jobs",
"credits_cost",
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
# 5) viral_video_jobs 新增列
if "video_resolution" not in vv_cols:
op.add_column(
"viral_video_jobs",
sa.Column("video_resolution", sa.String(20), nullable=False, server_default="720p"),
)
if "credits_prepaid" not in vv_cols:
op.add_column(
"viral_video_jobs",
sa.Column("credits_prepaid", sa.Float(), nullable=False, server_default="0"),
)
if "credits_transaction_id" not in vv_cols:
op.add_column(
"viral_video_jobs",
sa.Column("credits_transaction_id", sa.String(36), nullable=False, server_default=""),
)
def downgrade() -> None:
pass
@@ -1,31 +0,0 @@
"""viral_video_jobs 增加 pre_trusted_images 列(信任链Seedream预热结果)
Revision ID: 094_viral_video_pre_trusted
Revises: 093_viral_video_pricing_points_float
Create Date: 2026-10-04
"""
import sqlalchemy as sa
from alembic import op
revision = "094_viral_video_pre_trusted"
down_revision = "093"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "pre_trusted_images" not in cols:
op.add_column("viral_video_jobs", sa.Column("pre_trusted_images", sa.Text(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "pre_trusted_images" in cols:
op.drop_column("viral_video_jobs", "pre_trusted_images")
@@ -1,102 +0,0 @@
"""爆款视频 Prompt 模板配置表(#2040)。
086 曾预留同名旧表(id varchar / content / variables json),从未被业务使用;
本迁移将其替换为 #2040 新结构。
Revision ID: 095_viral_video_prompt_templates
Revises: 094_viral_video_pre_trusted
Create Date: 2026-10-04
"""
import sqlalchemy as sa
from alembic import op
revision = "095_viral_video_prompt_templates"
down_revision = "094_viral_video_pre_trusted"
branch_labels = None
depends_on = None
def _table_exists(conn, name: str) -> bool:
return name in sa.inspect(conn).get_table_names()
def upgrade() -> None:
conn = op.get_bind()
# 086 预留的旧结构表:先删除(无业务数据、无任何引用)
if _table_exists(conn, "viral_video_prompt_templates"):
op.drop_table("viral_video_prompt_templates")
op.create_table(
"viral_video_prompt_templates",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("prompt_type", sa.String(32), nullable=False),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
sa.Column("system_prompt", sa.Text, nullable=False),
sa.Column("user_prompt_template", sa.Text, nullable=False),
sa.Column("example_output", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index(
"ix_vvpt_type_active",
"viral_video_prompt_templates",
["prompt_type", "is_active"],
)
op.create_index(
"uq_vvpt_type_version",
"viral_video_prompt_templates",
["prompt_type", "version"],
unique=True,
)
def downgrade() -> None:
conn = op.get_bind()
if _table_exists(conn, "viral_video_prompt_templates"):
op.drop_index("uq_vvpt_type_version", table_name="viral_video_prompt_templates")
op.drop_index("ix_vvpt_type_active", table_name="viral_video_prompt_templates")
op.drop_table("viral_video_prompt_templates")
# 恢复 086 的旧预留结构
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(),
),
)
@@ -1,61 +0,0 @@
"""功能计费积分字段(爆款/对口型/智能剪辑 DB 化计费)。
给 gpu_lipsync_tasks / generation_tasks / lipsync_jobs 三张表加积分字段:
- credits_prepaid: 提交任务时预扣积分
- credits_cost: 最终结算积分
- credits_transaction_id: 预扣流水 ID
注意:feature_pricing_configs 配置表由 xiaoxia-admin 侧 migration 建立,
本仓库只读,不在此创建。
Revision ID: 096_feature_billing_fields
Revises: 095_viral_video_prompt_templates
Create Date: 2026-10-05
"""
import sqlalchemy as sa
from alembic import op
revision = "096_feature_billing_fields"
down_revision = "095_viral_video_prompt_templates"
branch_labels = None
depends_on = None
_TABLES = ("gpu_lipsync_tasks", "generation_tasks", "lipsync_jobs")
_COLUMNS = (
("credits_prepaid", sa.Float(), "0"),
("credits_cost", sa.Float(), "0"),
("credits_transaction_id", sa.String(36), ""),
)
def _table_exists(conn, name: str) -> bool:
return name in sa.inspect(conn).get_table_names()
def upgrade() -> None:
conn = op.get_bind()
for table in _TABLES:
if not _table_exists(conn, table):
continue
existing = {c["name"] for c in sa.inspect(conn).get_columns(table)}
for col_name, col_type, default in _COLUMNS:
if col_name in existing:
continue
op.add_column(
table,
sa.Column(col_name, col_type, nullable=False, server_default=default),
)
def downgrade() -> None:
conn = op.get_bind()
for table in _TABLES:
if not _table_exists(conn, table):
continue
existing = {c["name"] for c in sa.inspect(conn).get_columns(table)}
for col_name, _col_type, _default in _COLUMNS:
if col_name not in existing:
continue
op.drop_column(table, col_name)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,222 +0,0 @@
# -*- coding: utf-8 -*-
"""099: AI 模型路由层 seed — 补齐缺失模型和能力配置.
幂等:所有 INSERT 先检查存在性。
- ai_models: 补齐 qwen3.7-plus, seedream, seedance, embedding, wan3.0 等
- ai_capability_configs: 补齐 image_generation, video_generation, embedding
- 更新已有 capability 的 lite_model_id
"""
import json
import sqlalchemy as sa
from alembic import op
revision = "099_ai_model_router_seed"
down_revision = "098_viral_video_image_analysis_v5"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# CI 环境下 ai_models 表可能尚未创建(由 ORM 自动建表,非 migration)
# 如果表不存在则跳过 seed,由应用启动时 ORM 建表后首次访问时生效
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
if not table_check:
# ai_models 表不存在,跳过所有 seed(CI 环境)
return
# ── 1. 补齐 ai_models 缺失记录 ────────────────────────────────────────────
existing_models = {
row[0]
for row in conn.execute(
sa.text("SELECT model_key FROM ai_models WHERE deleted_at IS NULL")
).fetchall()
}
# 从已有 active 记录获取 API key(复用,不硬编码)
dashscope_key_row = conn.execute(
sa.text(
"SELECT api_key FROM ai_models WHERE provider='dashscope' AND deleted_at IS NULL AND api_key IS NOT NULL AND api_key != '' LIMIT 1"
)
).first()
dashscope_key = dashscope_key_row[0] if dashscope_key_row else ""
volcengine_key_row = conn.execute(
sa.text(
"SELECT api_key FROM ai_models WHERE provider='volcengine' AND deleted_at IS NULL AND api_key IS NOT NULL AND api_key != '' LIMIT 1"
)
).first()
volcengine_key = volcengine_key_row[0] if volcengine_key_row else ""
new_models = [
{
"model_key": "qwen3.7-plus",
"name": "通义千问3.7 Plus(VLM 兜底)",
"provider": "dashscope",
"api_key": dashscope_key,
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"description": "阿里云百炼 Qwen3.7 Plus 多模态模型,用于 VLM 兜底分析",
},
{
"model_key": "doubao-seedream-5-0-flash-260915",
"name": "Seedream 5.0 Flash(图片生成)",
"provider": "volcengine",
"api_key": volcengine_key,
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"description": "火山引擎 Seedream 5.0 Flash 文生图模型",
},
{
"model_key": "doubao-seedance-2-5-260628",
"name": "Seedance 2.5(视频生成)",
"provider": "volcengine",
"api_key": volcengine_key,
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"description": "火山引擎 Seedance 2.5 图/文生视频模型",
},
{
"model_key": "doubao-embedding-vision-251215",
"name": "豆包多模态向量嵌入",
"provider": "volcengine",
"api_key": volcengine_key,
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"description": "火山引擎豆包多模态向量嵌入模型",
},
{
"model_key": "wan3.0-video",
"name": "Wan 3.0 视频生成",
"provider": "dashscope",
"api_key": dashscope_key,
"api_base": "https://dashscope.aliyuncs.com/api/v1",
"description": "阿里云百炼 Wan 3.0 视频生成模型",
},
{
"model_key": "doubao-seed-2-1-pro-260915",
"name": "豆包 Seed 2.1 Pro(高精度推理)",
"provider": "volcengine",
"api_key": volcengine_key,
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"description": "火山引擎豆包 Seed 2.1 Pro 深度思考+多模态",
},
]
for m in new_models:
if m["model_key"] not in existing_models:
conn.execute(
sa.text(
"""
INSERT INTO ai_models (id, name, provider, model_key, api_key, api_base, description, status, is_default, usage_today, created_at, updated_at)
VALUES (gen_random_uuid()::text, :name, :provider, :model_key, :api_key, :api_base, :description, 'active', false, 0, now(), now())
"""
),
m,
)
# ── 2. 补齐 ai_capability_configs 缺失项 ──────────────────────────────────
cap_table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
if not cap_table_check:
return
existing_caps = {
row[0]
for row in conn.execute(
sa.text("SELECT capability_key FROM ai_capability_configs")
).fetchall()
}
def _get_model_id(model_key: str) -> str | None:
row = conn.execute(
sa.text(
"SELECT id FROM ai_models WHERE model_key = :key AND deleted_at IS NULL AND status = 'active' LIMIT 1"
),
{"key": model_key},
).first()
return row[0] if row else None
# image_generation
if "image_generation" not in existing_caps:
mid = _get_model_id("doubao-seedream-5-0-flash-260915")
if mid:
conn.execute(
sa.text(
"""
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, 60, 1, 2, :ep, true, now(), now())
"""
),
{
"ck": "image_generation",
"cn": "图片生成(Seedream)",
"pm": mid,
"ep": json.dumps({"size": "1K"}),
},
)
# video_generation
if "video_generation" not in existing_caps:
mid = _get_model_id("doubao-seedance-2-5-260628")
fb_mid = _get_model_id("wan3.0-video")
if mid:
conn.execute(
sa.text(
"""
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, fallback_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, :fm, 600, 1, 1, :ep, true, now(), now())
"""
),
{
"ck": "video_generation",
"cn": "视频生成(Seedance/Wan)",
"pm": mid,
"fm": fb_mid,
"ep": json.dumps({}),
},
)
# embedding
if "embedding" not in existing_caps:
mid = _get_model_id("doubao-embedding-vision-251215")
if mid:
conn.execute(
sa.text(
"""
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, 30, 2, 5, :ep, true, now(), now())
"""
),
{
"ck": "embedding",
"cn": "向量嵌入",
"pm": mid,
"ep": json.dumps({}),
},
)
# ── 3. 更新 image_analysis 的 lite_model_id ─────────────────────────────
lite_model_id = _get_model_id("qwen3.8-flash")
if lite_model_id:
conn.execute(
sa.text(
"UPDATE ai_capability_configs SET lite_model_id = :lite WHERE capability_key = 'image_analysis' AND lite_model_id IS NULL"
),
{"lite": lite_model_id},
)
def downgrade() -> None:
conn = op.get_bind()
# 安全检查表是否存在
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
if not table_check:
return
conn.execute(
sa.text("DELETE FROM ai_capability_configs WHERE capability_key IN ('image_generation', 'video_generation', 'embedding')")
)
conn.execute(
sa.text(
"DELETE FROM ai_models WHERE model_key IN ('qwen3.7-plus', 'doubao-seedream-5-0-flash-260915', 'doubao-seedance-2-5-260628', 'doubao-embedding-vision-251215', 'wan3.0-video', 'doubao-seed-2-1-pro-260915') AND deleted_at IS NULL"
)
)
@@ -1,107 +0,0 @@
# -*- coding: utf-8 -*-
"""100: 修正已有 capability 的模型绑定.
幂等:仅当 primary_model_id 当前绑定到旧模型 (doubao-seed-1-6) 时才更新,
避免覆盖用户在后台的自定义配置。
- 更新 5 个 LLM capability (intent_parsing, copy_fusion, storyboard, copy_review, asset_classify)
的 primary_model_id 从 doubao-seed-1-6 改为 doubao-seed-2-1-pro-260915
- 更新 image_analysis 的 primary/lite/fallback 模型绑定
"""
import sqlalchemy as sa
from alembic import op
revision = "100_fix_capability_model_bindings"
down_revision = "099_ai_model_router_seed"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Check tables exist
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
if not table_check:
return
config_table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
if not config_table_check:
return
# Look up model IDs by model_key (not hardcoded UUIDs)
pro_model_row = conn.execute(
sa.text(
"SELECT id FROM ai_models WHERE model_key = 'doubao-seed-2-1-pro-260915' AND deleted_at IS NULL LIMIT 1"
)
).first()
if not pro_model_row:
return
pro_model_id = pro_model_row[0]
old_model_row = conn.execute(
sa.text("SELECT id FROM ai_models WHERE model_key = 'doubao-seed-1-6-250615' LIMIT 1")
).first()
old_model_id = old_model_row[0] if old_model_row else None
llm_capabilities = [
"intent_parsing",
"copy_fusion",
"storyboard",
"copy_review",
"asset_classify",
]
for cap_key in llm_capabilities:
if old_model_id:
conn.execute(
sa.text(
"UPDATE ai_capability_configs SET primary_model_id = :new_id, updated_at = NOW() "
"WHERE capability_key = :cap_key AND primary_model_id = :old_id"
),
{"new_id": pro_model_id, "old_id": old_model_id, "cap_key": cap_key},
)
# Update image_analysis
qwen38_row = conn.execute(
sa.text("SELECT id FROM ai_models WHERE model_key = 'qwen3.8-flash' AND deleted_at IS NULL LIMIT 1")
).first()
qwen37_row = conn.execute(
sa.text("SELECT id FROM ai_models WHERE model_key = 'qwen3.7-plus' AND deleted_at IS NULL LIMIT 1")
).first()
if qwen38_row and qwen37_row:
qwen38_id = qwen38_row[0]
qwen37_id = qwen37_row[0]
current_ia = conn.execute(
sa.text(
"SELECT primary_model_id, lite_model_id, fallback_model_id "
"FROM ai_capability_configs WHERE capability_key = 'image_analysis'"
)
).first()
if current_ia:
current_primary, current_lite, current_fallback = current_ia
updates = {}
if current_primary != qwen38_id:
updates["primary_model_id"] = qwen38_id
if current_lite != qwen38_id:
updates["lite_model_id"] = qwen38_id
if current_fallback != qwen37_id:
updates["fallback_model_id"] = qwen37_id
if updates:
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
set_clause += ", updated_at = NOW()"
updates["cap_key"] = "image_analysis"
conn.execute(
sa.text(f"UPDATE ai_capability_configs SET {set_clause} WHERE capability_key = :cap_key"),
updates,
)
def downgrade() -> None:
pass
@@ -1,153 +0,0 @@
# -*- coding: utf-8 -*-
"""101: 补齐 qwen-vl-plus 视觉模型并修正 image_analysis 绑定与 max_tokens.
背景:
- qwen-vl-plus 做图片识别时返回 JSON 约 500-600 tokens,旧硬编码
max_tokens=350 导致 JSON 被截断、解析失败返回"未识别"。
- 代码侧已移除硬编码,改由 capability 的 DB 配置决定 max_tokens。
幂等:
- qwen-vl-plus 已存在则不插入;
- 仅当 image_analysis 当前 primary_model 不是 qwen-vl-plus 时才更新绑定,
避免覆盖后台手动配置。
"""
import sqlalchemy as sa
from alembic import op
revision = "101_qwen_vl_plus_and_max_tokens"
down_revision = "100_fix_capability_model_bindings"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
models_table = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
if not models_table:
return
caps_table = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
if not caps_table:
return
# ── c. 补全其他 capability 的 max_tokens 默认值(幂等)──────────────────
# 放在 image_analysis 特定逻辑之前,确保任何分支 return 都不会跳过本段。
# 仅在当前值为 NULL 或过小 (<100) 时更新,不覆盖已有合理配置。
# embedding / tts / voice_clone 不走 chat 接口,无需设置。
default_max_tokens = {
"intent_parsing": 500,
"copy_fusion": 2500,
"storyboard": 4000,
"copy_review": 1000,
"asset_classify": 500,
"image_generation": 500,
"video_generation": 500,
}
for cap_key, mt in default_max_tokens.items():
conn.execute(
sa.text(
"UPDATE ai_capability_configs "
"SET max_tokens = :mt, updated_at = now() "
"WHERE capability_key = :key "
"AND (max_tokens IS NULL OR max_tokens < 100)"
),
{"mt": mt, "key": cap_key},
)
# ── a. 确保 qwen-vl-plus 模型存在 ────────────────────────────────────────
conn.execute(sa.text("""
INSERT INTO ai_models (id, name, provider, model_key, api_key, api_base,
description, status, is_default, usage_today,
created_at, updated_at)
SELECT gen_random_uuid()::text,
'通义千问VL Plus',
'dashscope',
'qwen-vl-plus',
COALESCE(
(SELECT api_key FROM ai_models
WHERE provider = 'dashscope' AND deleted_at IS NULL
AND api_key IS NOT NULL AND api_key != ''
LIMIT 1),
''
),
'https://dashscope.aliyuncs.com/compatible-mode/v1',
'阿里云视觉理解模型(图片识别/分析)',
'active', false, 0, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM ai_models
WHERE model_key = 'qwen-vl-plus' AND deleted_at IS NULL
)
"""))
qwen_vl_row = conn.execute(
sa.text(
"SELECT id FROM ai_models WHERE model_key = 'qwen-vl-plus' "
"AND deleted_at IS NULL AND status = 'active' LIMIT 1"
)
).first()
if not qwen_vl_row:
return
qwen_vl_id = qwen_vl_row[0]
qwen37_row = conn.execute(
sa.text(
"SELECT id FROM ai_models WHERE model_key = 'qwen3.7-plus' "
"AND deleted_at IS NULL AND status = 'active' LIMIT 1"
)
).first()
qwen37_id = qwen37_row[0] if qwen37_row else None
# ── b. 仅当当前 primary 不是 qwen-vl-plus 时修正绑定与 max_tokens ───────
current = conn.execute(
sa.text(
"SELECT primary_model_id, lite_model_id, fallback_model_id, max_tokens "
"FROM ai_capability_configs WHERE capability_key = 'image_analysis'"
)
).first()
if current is None:
# capability 不存在则创建
conn.execute(
sa.text("""
INSERT INTO ai_capability_configs
(id, capability_key, capability_name, primary_model_id,
lite_model_id, fallback_model_id, timeout_seconds,
max_retries, max_tokens, concurrency, extra_params,
is_enabled, created_at, updated_at)
VALUES (gen_random_uuid()::text, 'image_analysis', '图片分析',
:primary, :primary, :fallback, 30, 1, 1000, 2,
'{}'::jsonb, true, now(), now())
"""),
{"primary": qwen_vl_id, "fallback": qwen37_id},
)
return
current_primary = current[0]
if current_primary == qwen_vl_id:
# 已经绑定 qwen-vl-plus:视为后台/数据迁移已处理,不覆盖任何配置
return
set_parts = [
"primary_model_id = :vl_id",
"lite_model_id = :vl_id",
"max_tokens = 1000",
"updated_at = now()",
]
params: dict = {"vl_id": qwen_vl_id}
if qwen37_id is not None:
set_parts.insert(2, "fallback_model_id = :qwen37_id")
params["qwen37_id"] = qwen37_id
conn.execute(
sa.text(
"UPDATE ai_capability_configs SET " + ", ".join(set_parts) + " WHERE capability_key = 'image_analysis'"
),
params,
)
def downgrade() -> None:
pass
@@ -1,36 +0,0 @@
# -*- coding: utf-8 -*-
"""102: image_analysis max_tokens 1200 -> 1500.
v6 prompt 更长、字段更多,旧 max_tokens 容易截断 JSON。
仅在 image_analysis 当前 max_tokens < 1500 时更新(幂等,不覆盖后台已调到 >=1500 的配置)。
"""
import sqlalchemy as sa
from alembic import op
revision = "102_image_analysis_max_tokens_1500"
down_revision = "101_qwen_vl_plus_and_max_tokens"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
caps_table = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
if not caps_table:
return
conn.execute(
sa.text(
"UPDATE ai_capability_configs "
"SET max_tokens = 1500, updated_at = now() "
"WHERE capability_key = 'image_analysis' "
"AND (max_tokens IS NULL OR max_tokens < 1500)"
)
)
def downgrade() -> None:
pass
@@ -1,194 +0,0 @@
# -*- coding: utf-8 -*-
"""image_analysis v7 prompt + max_tokens 3000 + max_retries 3
Revision ID: 103_v7_prompt_and_tokens_3000
Revises: 102_image_analysis_max_tokens_1500
Create Date: 2026-10-07
变更:
1. 插入v7精简prompt(~1KB,v6 ~4.5KB,删除few-shot/冗长规则,减少输出token占用),设为active
2. v6停用(is_active=False),保留历史
3. image_analysis capability: max_tokens 1500→3000,max_retries 1→3
ai_capability_configs 由应用 create_all 创建,全新 alembic-only 库可能不存在,
故第3步做 to_regclass 守卫(同 102)。
"""
from sqlalchemy import text
from alembic import op
revision = "103_v7_prompt_and_tokens_3000"
down_revision = "102_image_analysis_max_tokens_1500"
branch_labels = None
depends_on = None
V7_SYSTEM = """# 角色
你是一位专业的图片分析师,擅长准确识别图片中的场景、人物、物体、文字、氛围。
# 任务
对用户上传的图片逐张分析,描述你看到的内容,输出JSON格式。
## 技能
### 技能1:判断图片类型
判断图片属于哪种类型,type字段填对应的英文值:
- 商品图(product):单个或多个商品、产品包装
- 门店场景图(store):店铺内部、门头招牌、货架陈列
- 人物图(person):人物形象、穿搭造型、肖像照片
- 风景图(scene):风景、动物、美食、街景
- 其他(other):以上都不是
### 技能2:描述通用信息
不管什么图都要描述:
- type:图片类型,填product/store/person/scene/other其中一个
- scene:一句话描述场景,例如"理疗养生店内部,摆着多张理疗床和产品货架"
- mood:整体氛围,2-4个词,例如"整洁专业"、"热闹温馨"
- colors:主要颜色,最多5个,写具体颜色名(亮红色/米白色/深蓝色,不写笼统的红色蓝色)
- visible_text:图片里看到的文字,说明什么字、在什么位置,最多5条;没看到就空数组
- lighting:光线情况,例如"明亮柔光"、"自然光"、"室内暖黄灯"
- composition:怎么拍的,例如"居中特写"、"中景平视"、"俯拍"
- has_person:有没有人,true或false
### 技能3:描述门店场景
如果是门店场景图(type="store"),还要描述:
- store_type:什么类型的店,例如"养生馆"、"便利店"、"餐饮店"、"母婴店"
- brand_signage:招牌上写了什么字、有什么品牌标识
- visual_elements:看到哪些显眼的东西(招牌样式、灯光、货架、商品陈列、海报、收银台等),最多8个
- product_categories:看到哪些品类的商品,例如"饮料零食"、"养生产品"
- promotion_elements:有没有促销活动(打折海报、满减吊旗等),没有就空数组
- atmosphere:店内什么氛围,例如"亲民生活化"、"老字号专业感"
- cleanliness:店内干净程度,例如"干净整洁"、"货架整齐"
- 看到顾客或店员要描述他们在做什么,has_person填true
### 技能4:描述商品
如果是商品图(type="product"),逐个商品描述:
- product_name:商品名称,尽量具体,例如"OMO奥妙除菌除螨洗衣液";看不出来填null
- brand:什么牌子,看不出来填null
- category:类目,从以下选一个:服饰鞋包/美妆/数码/食品/家居清洁/母婴/配饰/其他
- package_type:什么包装,例如"瓶装"、"盒装"、"罐装"、"袋装"、"多瓶装"
- package_color:包装主要颜色,写具体色(亮红色不写红色)
- body_shape:瓶身或包装形状,例如"圆润胖瓶"、"竖款带把手瓶身"
- label_design:标签设计,例如"红色标签印白色品牌logo"
- key_text_on_package:包装上最显眼的文字(品牌名、功能词、卖点词),最多5个
- product_features:包装特征,3-6个短语,包含颜色、瓶盖、形状、标签图案
- key_selling_points:核心卖点,1-3个短语
### 技能5:描述人物
如果是人物图(type="person"),描述:
- person_count:几个人
- gender:性别(男/女/无法判断)
- age_range:年龄段(儿童/青少年/青年/中年/老年/无法判断)
- outfit_style:穿搭风格,例如"休闲日常"、"通勤商务"、"街头潮流"
- upper_wear:上装(颜色+款式+材质),穿裙装不填
- lower_wear:下装(颜色+款式+版型),穿裙装不填
- dress_wear:裙装描述,穿上下装不填
- outerwear:外套
- shoes:鞋子
- bag:包袋,没有填null
- accessories:配饰(眼镜/帽子/项链/耳环/手表/手链/围巾/腰带等),没有填空数组
- hairstyle:发型
- makeup:妆容,男生或看不出填null
- expression:表情,例如"微笑看镜头"、"冷酷无表情"
- pose:姿势动作,例如"身直立正对镜头"、"单手撩发"
- body_type:身材,例如"纤细苗条"、"高挑身材"、"丰满匀称"
- portrait_prompt:80-150字详细描述人物形象(后面用来AI生成肖像图),要写清年龄段、穿搭完整细节、发型发色、妆容、表情、姿势、场景、光线、风格感觉,语言要有画面感
### 技能6:描述风景
如果是风景图(type="scene"),描述:
- scene_type:什么场景,例如"自然风景"、"城市街景"、"动物"、"美食"
- main_subject:画面主体是什么
- key_elements:关键元素,最多8个
- environment_objects:周围环境物体,最多8个
- atmosphere:整体氛围,例如"秋日慵懒氛围感"、"清新自然氧气感"
- 有人物就描述人物特征
## 限制
- 只输出JSON,不要任何解释文字,不要markdown代码块包裹,不要写"好的""以下是分析结果"这种废话
- 颜色写具体色调(亮红色/米白色/深蓝色/翠绿色),不写笼统词汇
- 瓶身、包装、招牌上的文字尽量识别出来(品牌名、功能词、卖点词)
- 多个商品、多个人物分开描述,不要合并
- 看不出来、不确定的字段填null或空数组,布尔值填true/false,绝对不要瞎编
- 确保JSON格式合法,所有大括号、中括号、引号正确闭合
- 数组字段控制数量:colors最多5个,visible_text最多5条,visual_elements最多8个,accessories最多10个"""
V7_USER = "请分析这张图片,按系统消息的JSON结构输出。"
def _capability_table_exists(bind) -> bool:
return bool(bind.execute(text("SELECT to_regclass('public.ai_capability_configs')")).scalar())
def upgrade() -> None:
bind = op.get_bind()
# 1. 停用旧的active image_analysis prompt(含v6)
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type = 'image_analysis' AND is_active = TRUE"
)
)
# 2. 幂等插入v7(存在则更新并重新激活)
existing = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 7")
).fetchone()
if existing:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
"system_prompt = :sys, user_prompt_template = :usr, "
"name = 'v7 精简结构化分析', updated_at = NOW() "
"WHERE prompt_type = 'image_analysis' AND version = 7"
),
{"sys": V7_SYSTEM, "usr": V7_USER},
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"is_active, created_at, updated_at) "
"VALUES ('image_analysis', 7, 'v7 精简结构化分析', "
":sys, :usr, TRUE, NOW(), NOW())"
),
{"sys": V7_SYSTEM, "usr": V7_USER},
)
# 3. capability max_tokens=3000、max_retries=3(表不存在则跳过)
if _capability_table_exists(bind):
bind.execute(
text(
"UPDATE ai_capability_configs SET max_tokens = 3000, "
"updated_at = NOW() "
"WHERE capability_key = 'image_analysis' AND "
"(max_tokens IS NULL OR max_tokens < 3000)"
)
)
bind.execute(
text(
"UPDATE ai_capability_configs SET max_retries = 3, updated_at = NOW() "
"WHERE capability_key = 'image_analysis' AND "
"(max_retries IS NULL OR max_retries < 3)"
)
)
def downgrade() -> None:
bind = op.get_bind()
# 删除v7
bind.execute(
text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 7")
)
# 恢复v6为active
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
"WHERE prompt_type = 'image_analysis' AND version = 6"
)
)
# tokens/retries回退
if _capability_table_exists(bind):
bind.execute(
text(
"UPDATE ai_capability_configs SET max_tokens = 1500, max_retries = 1, "
"updated_at = NOW() WHERE capability_key = 'image_analysis'"
)
)
@@ -1,242 +0,0 @@
# -*- coding: utf-8 -*-
"""image_analysis v8 prompt + storyboard v3 prompt - 用户端展示格式 markdown 控制
Revision ID: 104_v8_display_markdown
Revises: 103_v7_prompt_and_tokens_3000
Create Date: 2026-10-07
变更:
1. image_analysis v8: 在 v7 基础上 system_prompt 末尾追加「## 用户端展示格式」章节,
要求 VLM 在每张图的 JSON 里输出 summary_markdown 字段(markdown 格式的图片描述),
v8 设 is_active=true,v7 设 is_active=false。
2. storyboard v3: 在 v2 基础上 system_prompt 追加要求 LLM 在 copy_result 中
输出 copy_display_markdown 字段(markdown 格式的完整文案展示),
v3 设 is_active=true,v2 设 is_active=false。
"""
from sqlalchemy import text
from alembic import op
revision = "104_v8_display_markdown"
down_revision = "103_v7_prompt_and_tokens_3000"
branch_labels = None
depends_on = None
# ── v8 追加的 system prompt 内容 ──────────────────────────────────────
V8_SYSTEM_APPEND = """
## 用户端展示格式
对于每张分析的图片,在 JSON 中额外输出一个 **summary_markdown** 字段,用 markdown 格式写出给用户看的图片描述。
格式要求(根据图片类型自适应):
**商品图(type=product)**示例:
### 商品名称
**品牌**:品牌名 | **类目**:服饰鞋包/美妆/数码/...
**核心特征**
- 特征1:描述
- 特征2:描述
**外观**:颜色+材质+设计描述
**包装**:包装类型描述
**文字信息**:包装上看到的文字
**门店场景图(type=store)**示例:
### 门店名称/类型
**类型**:奶茶店/便利店/养生馆/...
**品牌标识**:招牌文字描述
**环境氛围**:店内整体感觉
**陈列亮点**
- 亮点1
- 亮点2
**氛围**:亲民/专业/时尚/...
**人物图(type=person)**示例:
### 人物描述
**形象**:年龄段 + 风格
**穿搭**
- 上装:颜色+款式
- 下装:颜色+款式
- 配饰:...
**气质**:表情+姿势+整体感觉
**风景/场景图(type=scene)**示例:
### 场景名称
**类型**:自然风景/城市街景/动物/美食
**主体**:画面主要元素
**氛围**:整体感觉描述
要求:
- 内容真实具体,从实际图片分析得出
- 用 markdown 语法:**加粗**、列表、标题
- 控制在 100-200 字
- 不要编造图片中没有的信息
"""
# ── storyboard v3 追加的 system prompt 内容 ──────────────────────────
V3_STORYBOARD_APPEND = """
## 用户端展示格式
在输出分镜脚本的同时,在顶层输出一个 **copy_display_markdown** 字段(用 XML 标签 <copy_display_markdown> 包裹),用 markdown 格式写出完整文案展示。
格式示例:
# 标题/主题
## 整体概要
一句话描述视频内容
## 分镜预览
### 镜头1(0-3秒)
**景别**:近景俯拍,缓慢推镜
**画面**:场景描述
**台词**:口播文本
**动作**:人物动作描述
### 镜头2(3-9秒)
...
## 完整口播
完整口播文案文本
要求:
- 把所有分镜按时间顺序整理成易读的格式
- 用 markdown 语法组织,**加粗**标签、##二级标题、列表等
- 控制在 300-500 字
- 让用户一眼看懂视频会拍成什么样
"""
def upgrade() -> None:
bind = op.get_bind()
# ── 1. image_analysis v8 ──────────────────────────────────────────
# 停用所有 active image_analysis prompt
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type = 'image_analysis' AND is_active = TRUE"
)
)
# 读取 v7 的 prompt 内容作为基础
v7_row = bind.execute(
text(
"SELECT system_prompt, user_prompt_template, COALESCE(example_output, '') "
"FROM viral_video_prompt_templates "
"WHERE prompt_type = 'image_analysis' "
"ORDER BY version DESC LIMIT 1"
)
).fetchone()
if v7_row:
v7_system = v7_row[0] or ""
v8_system = v7_system + V8_SYSTEM_APPEND
v8_user = v7_row[1] or "{image_url}"
v8_example = v7_row[2] or ""
# 幂等:已有 v8 则更新,否则插入
existing_v8 = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 8")
).fetchone()
if existing_v8:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
"system_prompt = :sys, user_prompt_template = :usr, "
"example_output = :ex, name = 'v8 用户端展示格式', "
"updated_at = NOW() "
"WHERE prompt_type = 'image_analysis' AND version = 8"
),
{"sys": v8_system, "usr": v8_user, "ex": v8_example},
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"example_output, is_active, created_at, updated_at) "
"VALUES ('image_analysis', 8, 'v8 用户端展示格式', "
":sys, :usr, :ex, TRUE, NOW(), NOW())"
),
{"sys": v8_system, "usr": v8_user, "ex": v8_example},
)
# ── 2. storyboard v3 ─────────────────────────────────────────────
# 停用所有 active storyboard prompt
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type = 'storyboard' AND is_active = TRUE"
)
)
# 读取当前 storyboard prompt
sb_row = bind.execute(
text(
"SELECT system_prompt, user_prompt_template, COALESCE(example_output, '') "
"FROM viral_video_prompt_templates "
"WHERE prompt_type = 'storyboard' "
"ORDER BY version DESC LIMIT 1"
)
).fetchone()
if sb_row:
sb_system = sb_row[0] or ""
v3_system = sb_system + V3_STORYBOARD_APPEND
v3_user = sb_row[1] or ""
v3_example = sb_row[2] or ""
existing_v3 = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'storyboard' AND version = 3")
).fetchone()
if existing_v3:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
"system_prompt = :sys, user_prompt_template = :usr, "
"example_output = :ex, name = 'v3 用户端展示格式', "
"updated_at = NOW() "
"WHERE prompt_type = 'storyboard' AND version = 3"
),
{"sys": v3_system, "usr": v3_user, "ex": v3_example},
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"example_output, is_active, created_at, updated_at) "
"VALUES ('storyboard', 3, 'v3 用户端展示格式', "
":sys, :usr, :ex, TRUE, NOW(), NOW())"
),
{"sys": v3_system, "usr": v3_user, "ex": v3_example},
)
def downgrade() -> None:
bind = op.get_bind()
# 删除 v8
bind.execute(
text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 8")
)
# 恢复 v7 active
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, updated_at = NOW() "
"WHERE prompt_type = 'image_analysis' AND version = 7"
)
)
# 删除 v3
bind.execute(text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'storyboard' AND version = 3"))
# 恢复 storyboard v2 active
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, updated_at = NOW() "
"WHERE prompt_type = 'storyboard' AND version = 2"
)
)
-123
View File
@@ -1,123 +0,0 @@
# -*- coding: utf-8 -*-
"""image_analysis v8 + storyboard v3 叙述优先重写版(架构大简化)
Revision ID: 105_narration_first
Revises: 104_v8_display_markdown
Create Date: 2026-10-07
变更:
1. image_analysis v8:用「叙述优先」版整体替换 104 的 append 版——VLM 主交付物是
自然叙述 summary_markdown,结构化字段仅保留 type/name/brand/has_person,
顶层 products 改名 images;v8 active,其余 image_analysis 全部 deactivate。
2. storyboard v3:整体替换为风格重写版(口播口语化、画面有画面感、
copy_display_markdown 流畅叙述);v3 active,其余 storyboard deactivate。
3. intent_parsing 类型模板全部 deactivate(意图解析步骤已删除)。
模板内容直接取自 packages.application.viral_video.prompts.DEFAULT_TEMPLATES,
保证代码默认值与 DB seed 完全一致。
"""
from sqlalchemy import text
from alembic import op
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
revision = "105_narration_first"
down_revision = "104_v8_display_markdown"
branch_labels = None
depends_on = None
def _tpl(prompt_type: str, version: int) -> dict:
"""取模板:先按指定版本找,找不到则取该类型最新版本(兼容 v3→v4 升级)。"""
# 先按指定版本找
for t in DEFAULT_TEMPLATES:
if t["prompt_type"] == prompt_type and t["version"] == version:
return t
# 找不到则取最新版本
candidates = [t for t in DEFAULT_TEMPLATES if t["prompt_type"] == prompt_type]
if candidates:
latest = max(candidates, key=lambda x: x["version"])
return latest
raise RuntimeError("default template missing: %s" % prompt_type)
def _upsert(bind, t: dict) -> None:
existing = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
{"pt": t["prompt_type"], "ver": t["version"]},
).fetchone()
params = {
"pt": t["prompt_type"],
"ver": t["version"],
"name": t["name"],
"sys": t["system_prompt"],
"usr": t["user_prompt_template"],
"ex": t.get("example_output", "") or "",
}
if existing:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET name = :name, "
"system_prompt = :sys, user_prompt_template = :usr, "
"example_output = :ex, is_active = TRUE, updated_at = NOW() "
"WHERE prompt_type = :pt AND version = :ver"
),
params,
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"example_output, is_active, created_at, updated_at) "
"VALUES (:pt, :ver, :name, :sys, :usr, :ex, TRUE, NOW(), NOW())"
),
params,
)
def upgrade() -> None:
bind = op.get_bind()
# 1. image_analysis:停用全部后写入叙述优先 v8
bind.execute(
text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'image_analysis'")
)
_upsert(bind, _tpl("image_analysis", 8))
# 2. storyboard:停用全部后写入重写版 v3
bind.execute(text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'storyboard'"))
_upsert(bind, _tpl("storyboard", 3))
# 3. intent_parsing 已废弃:全部停用
bind.execute(
text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'intent_parsing'")
)
# 4. review 模板确保 active
bind.execute(text("UPDATE viral_video_prompt_templates SET is_active = TRUE " "WHERE prompt_type = 'review'"))
def downgrade() -> None:
bind = op.get_bind()
# 恢复 104 的 v8/v3 无法重建(内容已替换),仅把版本 active 状态回退:
# 停用新版,尝试恢复 v7 / v2
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type IN ('image_analysis','storyboard') "
"AND version IN (8, 3)"
)
)
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
"WHERE prompt_type = 'image_analysis' AND version = 7"
)
)
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
"WHERE prompt_type = 'storyboard' AND version = 2"
)
)
-43
View File
@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
"""system_settings 正式建表(#2246)
Revision ID: 106_system_settings
Revises: 105_narration_first
Create Date: 2026-10-08
system_settings 表此前在 staging 手工创建(对应 034 占位迁移),
此处补正式迁移保证其他环境一致。CREATE TABLE/INDEX 使用 IF NOT EXISTS,
对已手工建表的环境幂等。
"""
from alembic import op
revision = "106_system_settings"
down_revision = "105_narration_first"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS system_settings (
id VARCHAR(36) NOT NULL,
setting_key VARCHAR(100) NOT NULL,
setting_value TEXT,
setting_type VARCHAR(20) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
is_public BOOLEAN NOT NULL DEFAULT FALSE,
updated_by VARCHAR(36),
category VARCHAR(50) NOT NULL DEFAULT 'general',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT pk_system_settings PRIMARY KEY (id),
CONSTRAINT uq_system_settings_setting_key UNIQUE (setting_key)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_system_settings_category " "ON system_settings (category)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_system_settings_category")
op.execute("DROP TABLE IF EXISTS system_settings")
@@ -1,23 +0,0 @@
"""add language column to viral_video_jobs
Revision ID: 107
Revises: 106_system_settings
Create Date: 2026-10-09
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "107"
down_revision = "106_system_settings"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs " "ADD COLUMN IF NOT EXISTS language VARCHAR(20) NOT NULL DEFAULT 'zh-CN'")
def downgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs DROP COLUMN IF EXISTS language")
@@ -1,235 +0,0 @@
# -*- coding: utf-8 -*-
"""storyboard v4 - 多角色对话 + 废除旁白 + visual 5要素
Revision ID: 108_storyboard_v4_multivoice
Revises: 107
Create Date: 2026-10-10
变更:
1. storyboard v4: 废除旁白思维,所有voiceover必须是角色台词
- 增加<speaker>标签,每镜必须标注说话人
- visual强制5要素结构(景别/运镜/动作/环境/光线),每镜不少于30字
- voiceover_script用[speaker:xxx]标记格式
2. 旧版storyboard模板is_active设为false
3. 检查image_analysis和review是否有active模板,没有则插入保底版本
"""
from sqlalchemy import text
from alembic import op
revision = "108_storyboard_v4_multivoice"
down_revision = "107"
branch_labels = None
depends_on = None
# ── storyboard v4 system_prompt ──────────────────────────────────────
V4_STORYBOARD_SYSTEM = """你是一名懂短视频的编导和口播文案高手。你会拿到图片的真实观察、营销目的和用户参数,请一次性完成对营销意图的理解,并产出可直接拍摄/生成的分镜脚本。不要单独输出"意图解析",意图要直接体现在台词和分镜里。
## 核心设计原则(必须严格遵守)
1. **废除旁白思维**:所有视频类型——无论对话短剧/口播带货/获客引流/品牌故事——voiceover 必须是人物说的话(第一人称或角色对白),绝对不能出现第三人称旁白解说。观众看的是人在演、在说。
2. **严禁第三人称解说性台词**:如"接下来展示...""这款产品..."这类上帝视角描述禁止出现在 voiceover 中。
3. **短剧类营销目的**(对话短剧/反转短剧/悬念短剧/情绪短片):双角色对话格式"甲:xxx 乙:xxx",镜头在角色间切换。
4. **口播类**(口播带货/促销转化/功能演示/痛点解决/获客引流/账号涨粉/活动通知/场景种草):第一人称对镜头说话,像真人出镜。
## 输出格式(XML,严格按结构输出,不要输出额外解释)
<script>
<copy_display_markdown><![CDATA[直接展示给用户看的成片文案,用 Markdown 写成流畅叙述]]></copy_display_markdown>
<clips>
<clip index="1">
<time_range>0-3秒</time_range>
<speaker>说话人标识(如"店主""顾客""主播")</speaker>
<voiceover>这一镜的角色台词(人物说的话,不是旁白)</voiceover>
<visual>【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】5要素结构,不少于30字</visual>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>把所有 clip 的 voiceover 连成完整台词稿,用 [speaker:xxx] 标记每个说话段落</voiceover_script>
<theme>一句话主题</theme>
<negative>【反套路化要求】
禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。</negative>
</script>
## 写作要求
1. **台词(voiceover)**:像真人面对镜头说话或角色对白,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。严禁第三人称解说。
2. **说话人(speaker)**:每个 clip 必须标注说话人标识,如"店主""顾客""主播""我"等。短剧类必须有至少 2 个不同角色。
3. **画面描述(visual)**:强制 5 要素结构——【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】,每镜 visual 不少于 30 字,要具体到"闭眼听台词能想象出画面"。
4. **voiceover_script 格式**:用 [speaker:xxx] 标记每个说话段落,如"[speaker:店主]你是不是也觉得...[speaker:顾客]是啊,怎么回事?"
5. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案。
6. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
7. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
8. 分镜数量与时长匹配总时长,节奏紧凑。
9. **口播字数硬约束**(必须严格遵守):按每秒约 2.5~3 个中文字(正常口播语速)计算:
- 5秒视频:voiceover_script 总字数 12~15 字
- 10秒视频:voiceover_script 总字数 25~30 字
- 15秒视频:voiceover_script 总字数 35~45 字
- 20秒视频:voiceover_script 总字数 50~60 字
- 30秒视频:voiceover_script 总字数 75~90 字
- 宁可少写也不要多写,超长会导致 TTS 音频超出视频时长限制
10. **镜头数量硬约束**:5秒1~2镜、10秒3镜、15秒3~4镜、20秒4~5镜、30秒6~8镜
11. **时间轴硬约束**:第一个clip从0秒开始,最后一个clip结束于total_duration秒,相邻clip首尾相接
12. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜
13. 镜头间动作衔接要自然,画面描述要具体到能直接拍摄/生成"""
V4_STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
<industry>{industry}</industry>
<image_analysis>
{image_summary}
</image_analysis>
<user_parameters>
<theme_hint>{theme_hint}</theme_hint>
<duration>{duration}秒</duration>
<aspect_ratio>{aspect_ratio}</aspect_ratio>
<tone>{tone}</tone>
<target_audience>{target_audience}</target_audience>
<persona>{persona_hint}</persona>
<viral_structure>{viral_structure_hint}</viral_structure>
<language>{language_hint}</language>
<extra_requirements>{extra_requirements}</extra_requirements>
</user_parameters>
{video_style_section}
请严格按 XML 结构输出分镜脚本。"""
V4_STORYBOARD_EXAMPLE = """<script>
<copy_display_markdown><![CDATA[# 在御众堂,把松弛的自己一点点找回来
产后妈妈最懂那种力不从心,推开门,暖光和一杯热茶先接住了你……]]></copy_display_markdown>
<clips>
<clip index="1">
<time_range>0-3秒</time_range>
<speaker>店主</speaker>
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
<visual>【中近景】【缓推】【妈妈疲惫看向镜子】【暖光店内环境】【柔和暖光】</visual>
<reference_image_index>0</reference_image_index>
</clip>
<clip index="2">
<time_range>3-6秒</time_range>
<speaker>顾客</speaker>
<voiceover>是啊,怎么回事?</voiceover>
<visual>【近景】【固定】【顾客表情惊讶】【店内休息区】【暖色调】</visual>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>[speaker:店主]生完娃,是不是连照镜子的勇气都没了?[speaker:顾客]是啊,怎么回事?</voiceover_script>
<theme>产后妈妈走进御众堂重拾状态</theme>
<negative>模糊、畸变、夸大疗效、绝对化用语</negative>
</script>"""
def upgrade() -> None:
conn = op.get_bind()
# 1. 查询 storyboard 当前最大 version
result = conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "storyboard"},
)
max_version = result.scalar() or 0
new_version = max_version + 1
# 2. 旧版 storyboard 模板 is_active 设为 false
conn.execute(
text("UPDATE viral_video_prompt_templates SET is_active = false WHERE prompt_type = :pt"),
{"pt": "storyboard"},
)
# 3. 防御性插入新版 storyboard 模板
existing = conn.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
{"pt": "storyboard", "ver": new_version},
).fetchone()
if not existing:
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "编导分镜v4-多角色对话版",
"pt": "storyboard",
"ver": new_version,
"sys": V4_STORYBOARD_SYSTEM,
"usr": V4_STORYBOARD_USER,
"ex": V4_STORYBOARD_EXAMPLE,
"active": True,
},
)
# 4. 检查 image_analysis 是否有 active 模板,没有则插入保底
ia_active = conn.execute(
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
{"pt": "image_analysis"},
).scalar()
if ia_active == 0:
# 插入保底 image_analysis 模板(简化版)
ia_max = (
conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "image_analysis"},
).scalar()
or 0
)
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "图片分析保底版",
"pt": "image_analysis",
"ver": ia_max + 1,
"sys": "你是一名擅长观察和写作的品牌内容编导。分析图片并输出JSON。",
"usr": "请分析这张图片。图片地址:{image_url}",
"ex": '{"images": [{"type": "store", "name": "门店", "summary_markdown": "描述"}]}',
"active": True,
},
)
# 5. 检查 review 是否有 active 模板
review_active = conn.execute(
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
{"pt": "review"},
).scalar()
if review_active == 0:
rv_max = (
conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "review"},
).scalar()
or 0
)
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "文案审核保底版",
"pt": "review",
"ver": rv_max + 1,
"sys": "你是短视频广告合规审核专家。审核文案输出XML。",
"usr": "请审核:{fusion_text}",
"ex": "<review><passed>true</passed></review>",
"active": True,
},
)
def downgrade() -> None:
# 恢复旧版 storyboard 为 active
conn = op.get_bind()
conn.execute(
text("UPDATE viral_video_prompt_templates SET is_active = true WHERE prompt_type = :pt AND version < :ver"),
{"pt": "storyboard", "ver": 4},
)
# 删除新版
conn.execute(
text("DELETE FROM viral_video_prompt_templates WHERE prompt_type = :pt AND version >= :ver"),
{"pt": "storyboard", "ver": 4},
)
-6
View File
@@ -1,4 +1,3 @@
from app.api.routes.admin.ditto_emotion import router as admin_ditto_emotion_router
from app.api.routes.ai import router as ai_router
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
@@ -37,7 +36,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
@@ -242,7 +240,3 @@ api_router.include_router(
prefix="/gpu",
tags=["GPU Worker"],
)
api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"])
# #2246:后台 Ditto 表情配置(router 自带 /admin/ditto-emotion 前缀)
api_router.include_router(admin_ditto_emotion_router)
@@ -1 +0,0 @@
"""后台管理路由(#2246 起)."""
@@ -1,183 +0,0 @@
"""Ditto 数字人表情后台配置 — #2246.
路由前缀 /api/v1/admin/ditto-emotion,全部使用 _verify_internal_api_key 鉴权
(X-API-Key header)。仅开放 6 项白名单配置:
- ditto_emotion_enabled / ditto_emotion_model / ditto_emotion_temperature
- ditto_emotion_prompt / ditto_blend_frames / doubao_api_key
注意:doubao_api_key 属于密钥,GET 只返回脱敏占位,绝不返回明文。
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from packages.application.system_config_service import get_system_config_service
from packages.config import get_api_settings
from packages.domain.system_setting import (
SETTING_TYPE_BOOL,
SETTING_TYPE_FLOAT,
SETTING_TYPE_INT,
SETTING_TYPE_STRING,
)
from ..auth import _verify_internal_api_key
router = APIRouter(
prefix="/admin/ditto-emotion",
tags=["Admin"],
dependencies=[Depends(_verify_internal_api_key)],
)
MODEL_OPTIONS = [
"doubao-seed-2-1-lite-250915",
"doubao-seed-2-1-pro-250915",
"deepseek-v3",
]
# key → (类型, 分类)
_WHITELIST: dict[str, str] = {
"ditto_emotion_enabled": SETTING_TYPE_BOOL,
"ditto_emotion_model": SETTING_TYPE_STRING,
"ditto_emotion_temperature": SETTING_TYPE_FLOAT,
"ditto_emotion_prompt": SETTING_TYPE_STRING,
"ditto_blend_frames": SETTING_TYPE_INT,
"doubao_api_key": SETTING_TYPE_STRING,
}
_DESCRIPTIONS: dict[str, str] = {
"ditto_emotion_enabled": "LLM 情绪分析开关。关闭时回退到原有关键词匹配模式,不影响正常出片。",
"ditto_emotion_model": "用于分析文案情绪的大模型。",
"ditto_emotion_temperature": "模型温度,0-1,越低越稳定保守。",
"ditto_emotion_prompt": "情绪分析提示词,核心调优入口,必须包含 {文案} 占位符。",
"ditto_blend_frames": "表情切换过渡帧数(6-30),越大越柔和。",
"doubao_api_key": "豆包/火山方舟 API Key(用于情绪分析LLM调用)",
}
# 密钥类配置:GET 脱敏、PUT 响应不回显明文
_SECRET_KEYS = {"doubao_api_key"}
_SECRET_PLACEHOLDER = "***已配置***"
def _settings():
return get_api_settings()
def _default_value(key: str) -> Any:
return getattr(_settings(), key)
def _build_config_item(key: str) -> dict[str, Any]:
service = get_system_config_service()
if key in _SECRET_KEYS:
# 密钥不回明文、不暴露 env 默认值:DB 或 env 任一侧已配置即视为已配置
db_value = service.get_config(key, None)
configured = bool((db_value or "").strip()) or bool((getattr(_settings(), key, "") or "").strip())
return {
"key": key,
"type": _WHITELIST[key],
"description": _DESCRIPTIONS.get(key, ""),
"default": "",
"value": _SECRET_PLACEHOLDER if configured else "",
}
item: dict[str, Any] = {
"key": key,
"type": _WHITELIST[key],
"description": _DESCRIPTIONS.get(key, ""),
"default": _default_value(key),
}
item["value"] = service.get_config(key, _default_value(key))
if key == "ditto_emotion_model":
item["model_options"] = list(MODEL_OPTIONS)
return item
class ConfigUpdatePayload(BaseModel):
configs: dict[str, Any]
class TestPayload(BaseModel):
test_text: str
def _validate_value(key: str, value: Any) -> Any:
st = _WHITELIST[key]
if st == SETTING_TYPE_BOOL:
if not isinstance(value, bool):
raise ValueError(f"{key} 必须是布尔值")
elif st == SETTING_TYPE_INT:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{key} 必须是整数")
if not 6 <= value <= 30:
raise ValueError(f"{key} 必须在 6-30 之间")
elif st == SETTING_TYPE_FLOAT:
if isinstance(value, bool):
raise ValueError(f"{key} 必须是数字")
try:
value = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} 必须是数字") from exc
if not 0.0 <= value <= 1.0:
raise ValueError(f"{key} 必须在 0-1 之间")
elif st == SETTING_TYPE_STRING:
if not isinstance(value, str):
raise ValueError(f"{key} 必须是字符串")
if key == "ditto_emotion_prompt" and value.strip() and "{文案}" not in value:
raise ValueError("提示词必须包含 {文案} 占位符")
if key == "ditto_emotion_model" and value not in MODEL_OPTIONS:
raise ValueError(f"模型必须是以下之一:{', '.join(MODEL_OPTIONS)}")
if key == "doubao_api_key" and not value.strip():
raise ValueError(f"{key} 不能为空字符串")
return value
@router.get("/config")
def get_config() -> dict[str, Any]:
return {"configs": [_build_config_item(k) for k in _WHITELIST]}
@router.put("/config")
def update_config(
payload: ConfigUpdatePayload,
x_api_key: str = Depends(_verify_internal_api_key),
) -> dict[str, Any]:
configs = payload.configs
illegal = [k for k in configs if k not in _WHITELIST]
if illegal:
return {
"ok": False,
"error": f"不允许修改的配置项:{', '.join(illegal)}",
}
service = get_system_config_service()
updated: dict[str, Any] = {}
for key, raw in configs.items():
try:
value = _validate_value(key, raw)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
service.set_config(
key,
value,
setting_type=_WHITELIST[key],
updated_by=str(x_api_key)[:8] if x_api_key and x_api_key is not True else None,
)
updated[key] = _SECRET_PLACEHOLDER if key in _SECRET_KEYS else value
return {"ok": True, "updated": updated}
@router.post("/config/test")
def test_config(payload: TestPayload) -> dict[str, Any]:
text = (payload.test_text or "").strip()
if not text:
return {"ok": False, "error": "test_text 不能为空"}
from packages.application.ditto_emotion_service import get_ditto_emotion_service
service = get_ditto_emotion_service()
segments = service.analyze(text)
return {
"ok": True,
"enabled": service.enabled,
"segments": [s.to_dict() for s in segments],
}
@@ -29,6 +29,8 @@ from app.services.ai_avatar_render_service import (
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -42,6 +44,7 @@ def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
@points_gate("ai_digital_human", per_unit=15)
def create_render_job(
body: CreateAiAvatarRenderRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
@@ -27,6 +27,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
)
from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config
from packages.middleware.points_gate import points_gate
from packages.shared.storage import get_shared_storage_service
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
@@ -345,6 +346,7 @@ def _is_trusted_media_url(url: str) -> bool:
@router.post("/generate-cover", response_model=GenerateCoverResponse)
@points_gate("ai_cover")
def generate_cover(
body: GenerateCoverRequest,
template_id: str = Query(..., description="模板 ID"),
+30 -9
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,
)
@@ -41,6 +43,7 @@ from packages.application import (
GetGenerationTaskUseCase,
ListGeneratedVideosByTaskUseCase,
)
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
@@ -269,6 +272,7 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201)
@points_gate("ai_video", quantity_field="preview_count")
def create_preview_generation_task(
request: CreatePreviewGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -298,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 ""
@@ -576,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
@@ -587,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"),
+51 -155
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,
@@ -44,104 +45,17 @@ from packages.application import (
GetGenerationTaskUseCase,
ListGeneratedVideosByTaskUseCase,
)
from packages.domain import feature_pricing_service
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": {
"人物",
"人物采访",
"对话",
"说话",
"讲解",
"演讲",
"采访",
"聊天",
"开会",
"工作",
"办公室",
"团队",
"员工",
"老板",
"女性",
"男性",
"美女",
"帅哥",
},
"scenic": {"风景", "自然", "山水", "大海", "天空", "日落", "日出", "森林", "城市", "建筑", "夜景", "街道", "公园", "景区", "旅行", "旅游", "户外"},
"product": {"产品", "商品", "展示", "演示", "开箱", "评测", "好物", "推荐", "种草", "购物", "电商", "带货", "品牌", "广告", "包装"},
"person": {"人物", "人物采访", "对话", "说话", "讲解", "演讲", "采访", "聊天", "开会", "工作", "办公室", "团队", "员工", "老板", "女性", "男性", "美女", "帅哥"},
"animal": {"动物", "宠物", "狗", "猫", "鸟", "鱼", "马", "牛", "羊", "野生动物", "动物园"},
"food": {
"美食",
"食物",
"餐饮",
"餐厅",
"做饭",
"烹饪",
"厨房",
"菜品",
"饮料",
"水果",
"甜点",
"蛋糕",
"咖啡",
"茶",
"零食",
"吃",
},
"tech": {
"科技",
"数码",
"电脑",
"手机",
"屏幕",
"软件",
"APP",
"互联网",
"AI",
"人工智能",
"机器人",
"办公",
"程序员",
"代码",
"屏幕录制",
},
"food": {"美食", "食物", "餐饮", "餐厅", "做饭", "烹饪", "厨房", "菜品", "饮料", "水果", "甜点", "蛋糕", "咖啡", "茶", "零食", "吃"},
"tech": {"科技", "数码", "电脑", "手机", "屏幕", "软件", "APP", "互联网", "AI", "人工智能", "机器人", "办公", "程序员", "代码", "屏幕录制"},
"sport": {"运动", "健身", "跑步", "篮球", "足球", "游泳", "瑜伽", "户外", "锻炼", "体育", "比赛", "球场"},
"music": {"音乐", "歌曲", "演唱会", "乐器", "唱歌", "跳舞", "舞蹈", "MV", "演出", "乐队", "钢琴", "吉他", "节奏"},
}
@@ -163,6 +77,7 @@ def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
break
return matched or None
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
@@ -278,13 +193,10 @@ def _select_assets_from_library(
# #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 去重)
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)
@@ -464,6 +376,7 @@ def _resolve_project_and_library(
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
@points_gate("ai_video", quantity_field="count")
def create_generation_task(
request: CreateGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -681,13 +594,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,
@@ -700,17 +621,6 @@ def create_generation_task(
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take"
# ── smart_edit 计费预扣(全局 points 开关 + 功能开关均开才扣) ──
# 首期固定价:dynamic_cost=0,price=(0+fixed_cost)×multiplier,price_cap 封顶。
# 预览任务不扣费;按任务条数扣费,任一任务预扣失败(余额不足)整体拒绝。
smart_edit_charge = 0.0
charged_task_count = 0
if not request.is_preview and feature_pricing_service.is_feature_enabled("smart_edit"):
unit_credits, _bd = feature_pricing_service.calculate_price("smart_edit", 0.0)
if unit_credits > 0:
smart_edit_charge = round(unit_credits * count, 2)
charged_task_count = count
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。
# - 变体 0:clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
@@ -941,42 +851,6 @@ def create_generation_task(
)
# 变体序号写入 extra_meta(响应/排查时可辨识)
task.extra_meta["variant_index"] = task_index
# smart_edit 逐条预扣(首期固定价,credits_cost=prepaid,不做结算)
task_txn_id = ""
if charged_task_count > 0:
from packages.domain.points_service import PointsService
unit_credits = round(smart_edit_charge / count, 2)
res = PointsService().deduct_points(
user_id=user_id,
amount=unit_credits,
source="smart_edit",
db=db,
description="智能剪辑生成预扣",
ref_id=task.id,
)
if not res.get("success"):
# 余额不足:退还本次请求已扣积分后整体拒绝
already_charged = round(unit_credits * task_index, 2)
if already_charged > 0:
PointsService().refund_points(
user_id=user_id,
amount=already_charged,
source="smart_edit",
db=db,
ref_id=task.id,
description="智能剪辑批量提交失败退回",
)
raise HTTPException(
status_code=402,
detail=(f"积分不足:智能剪辑每条需 {unit_credits:.2f} 积分,当前余额 {res.get('balance', 0)}"),
)
task_txn_id = str(res.get("transaction_id") or "")
task.credits_prepaid = unit_credits
task.credits_cost = unit_credits
task.credits_transaction_id = task_txn_id
generation_task_repository.update(task)
try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
@@ -1049,10 +923,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:
@@ -1203,8 +1081,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,
@@ -1383,8 +1263,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,
@@ -1427,8 +1321,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,
+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}"}
+94 -4
View File
@@ -12,9 +12,11 @@
from __future__ import annotations
import logging
import math
from datetime import UTC
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.dependencies import (
get_db_session,
get_voice_clone_profile_repository,
@@ -30,6 +32,9 @@ from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -56,6 +61,37 @@ def create_lipsync_job(
db: Session = Depends(get_db_session),
svc: LipsyncService = Depends(_get_service),
):
user_id = current_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_digital_human"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
# 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟)
if body.audio_url and body.audio_duration and body.audio_duration > 0:
est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0))
elif body.script_text:
est_minutes = max(1.0, math.ceil(len(body.script_text) / 240))
else:
est_minutes = 1.0
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(current_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(current_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
"""提交对口型任务.
三种模式:
@@ -65,8 +101,6 @@ def create_lipsync_job(
- 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings},
后端同步ffprobe+写入timings+直接提交MediaKit(~2-3s)。
"""
user_id = current_user.user.id
try:
job = svc.create_job(
user_id=user_id,
@@ -84,8 +118,18 @@ def create_lipsync_job(
project_id=body.project_id,
)
except ValueError as exc:
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}")
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MediaKitError as exc:
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}")
status_code = 502
if exc.code in ("VoiceForbidden",):
status_code = 403
@@ -101,11 +145,24 @@ def create_lipsync_job(
) from exc
except Exception as exc:
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型异常退积分异常: err={refund_err}")
raise HTTPException(
status_code=400,
detail=f"创建对口型任务失败: {exc}",
) from exc
# 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等)
# 若任务已创建且状态为 failed,退费
if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed":
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}")
return job
@@ -119,14 +176,37 @@ def preview_tts(
db: Session = Depends(get_db_session),
svc: LipsyncService = Depends(_get_service),
):
user_id = current_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_digital_human"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(current_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(current_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
"""步骤1「生成配音」同步 TTS 预合成.
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。
耗时约 2-3 秒。
"""
user_id = current_user.user.id
try:
result = svc.preview_tts(
user_id=user_id,
@@ -138,6 +218,11 @@ def preview_tts(
emotion=body.emotion,
)
except MediaKitError as exc:
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}")
status_code = 400
if exc.code in ("VoiceForbidden",):
status_code = 403
@@ -152,6 +237,11 @@ def preview_tts(
) from exc
except Exception as exc:
logger.error("TTS 预合成异常: %s", exc, exc_info=True)
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}")
raise HTTPException(
status_code=400,
detail=f"TTS 合成失败: {exc}",
+32 -19
View File
@@ -89,7 +89,6 @@ def get_balance(
is_member=_is_member(current_user),
member_type=_member_type(current_user),
member_expires_at=getattr(current_user.user, "member_expires_at", None),
credits_enabled=_credits_enabled(),
)
@@ -146,22 +145,19 @@ def get_rules(
def get_packages(
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""查询可购买的积分包列表(读管理后台 credit_packages 表真实数据)。
仅返回 is_active=true;后台改价/启停后最多 30 秒生效。
"""
from packages.application.catalog.admin_catalog import get_points_packages
packages = [
PointsPackageItem(
code=row["code"],
name=row["name"],
points=row["points"],
price_cents=row["price_cents"],
unit_price=row["unit_price"],
"""查询可购买的积分包列表。"""
packages = []
for code, pkg in POINTS_PACKAGES.items():
unit_price = f"¥{pkg['price_cents'] / 100 / pkg['points']:.3f}/积分"
packages.append(
PointsPackageItem(
code=code,
name=pkg["name"],
points=pkg["points"],
price_cents=pkg["price_cents"],
unit_price=unit_price,
)
)
for row in get_points_packages()
]
mt = _member_type(current_user)
discount = MEMBER_DISCOUNT.get(mt) if mt else None
return PointsPackagesResponse(packages=packages, user_discount=discount)
@@ -173,7 +169,17 @@ def check_points(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""消费前检查余额是否足够。已下线/未知场景返回 cost=0(免费)。"""
"""消费前检查余额是否足够。未知 scene_key 返回 400(而非 500)。"""
if body.scene_key not in POINTS_SCENES:
raise HTTPException(
status_code=400,
detail={
"code": "UNKNOWN_SCENE",
"message": f"未知场景: {body.scene_key}",
"valid_scenes": sorted(POINTS_SCENES.keys()),
},
)
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
if not _credits_enabled():
svc = _get_service()
@@ -189,6 +195,13 @@ def check_points(
is_mem = _is_member(current_user)
mt = _member_type(current_user)
# 混剪场景先检查免费额度
is_free_quota = False
if body.scene_key == "ai_video" and not is_mem:
svc = _get_service()
if svc.check_daily_free_clip(current_user.user.id, db):
is_free_quota = True
required = calculate_points_cost(
body.scene_key,
is_mem,
@@ -202,11 +215,11 @@ def check_points(
balance = account["balance"]
return PointsCheckResponse(
allowed=balance >= required,
allowed=is_free_quota or balance >= required,
required_points=required,
current_balance=balance,
remaining_after=balance - required,
is_free_quota=False,
is_free_quota=is_free_quota,
)
+4
View File
@@ -44,6 +44,7 @@ from app.services.script_asr_service import (
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.middleware.points_gate import points_gate
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
@@ -372,6 +373,7 @@ def douyin_diag():
@router.post("/extract-from-douyin", response_model=ExtractFromDouyinResponse)
@points_gate("douyin_extract")
def extract_from_douyin(
request: ExtractFromDouyinRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
@@ -495,6 +497,7 @@ def extract_from_douyin(
@router.post("/ai-rewrite", response_model=AiRewriteResponse)
@points_gate("ai_rewrite")
def ai_rewrite(
request: AiRewriteRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
@@ -534,6 +537,7 @@ def ai_rewrite(
@router.post("/ai-generate-titles", response_model=AiGenerateTitlesResponse)
@points_gate("ai_title")
def ai_generate_titles(
request: AiGenerateTitlesRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
+24 -4
View File
@@ -86,13 +86,33 @@ async def get_current_subscription(
def list_membership_plans(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, list[dict[str, Any]]]:
"""查询可购买的会员套餐(读管理后台 plans 表真实数据)。
"""查询所有会员档位(供前端会员购买页展示)。
仅返回 is_enabled=true 的套餐;后台启停/改价后最多 30 秒生效。
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
"""
from packages.application.catalog.admin_catalog import get_membership_plans
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
return {"plans": get_membership_plans()}
plans: list[dict[str, Any]] = []
for plan_id, info in MEMBERSHIP_PRICES.items():
days = info["duration_days"]
monthly_cents = round(info["price_cents"] * 30 / days)
features: dict[str, Any] = {"max_resolution": "1080p"}
if plan_id == MembershipType.MONTHLY:
features.update({"free_clips_daily": 2})
elif plan_id == MembershipType.QUARTERLY:
features.update({"free_clips_daily": 5})
elif plan_id == MembershipType.YEARLY:
features.update({"free_clips_daily": "unlimited"})
plans.append({
"plan_id": plan_id,
"name": info["name"],
"price_cents": info["price_cents"],
"monthly_price_cents": monthly_cents,
"duration_days": days,
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
"features": features,
})
return {"plans": plans}
@router.get("/billing-records", response_model=list[BillingRecord])
+76
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import json
import logging
import math
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.core.celery_app import celery_app
from app.core.storage import get_storage_service
from app.dependencies import (
@@ -51,6 +53,8 @@ from packages.application.tts_job.use_cases import (
)
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
from packages.domain.voice_presets import list_voices
from packages.ports.asset_library_repository import AssetLibraryRepository
from packages.ports.asset_repository import AssetRepository
@@ -140,6 +144,31 @@ def synthesize(
"""
user_id = authenticated_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_voice"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
# 中文按 ~240 字/分钟粗估时长,至少按 1 分钟扣 1 分
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(authenticated_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(authenticated_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
actual_voice_id = request.voice_id
@@ -202,6 +231,7 @@ def synthesize(
cosyvoice_service=cosyvoice_service,
)
synthesis_error: Exception | None = None
try:
job = workflow.start_synthesis(job.id)
except Exception as e:
@@ -209,10 +239,18 @@ def synthesize(
# 但 DB 异常、网络异常等意外错误可能逃逸。
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
synthesis_error = e
try:
job = workflow.process_synthesis_failure(job.id, str(e))
except Exception as inner_e:
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
# 合成失败且已扣积分 → 退费
if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}")
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
if job.status.value == "processing":
# 分段合成任务 vs 普通单段任务
@@ -231,6 +269,13 @@ def synthesize(
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
# 调度失败退费
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"Celery 调度失败退积分异常: job_id={job.id}, err={refund_err}")
return TTSSynthesizeResponse(
job_id=job.id,
status=job.status,
@@ -565,6 +610,31 @@ def preview_tts(
用于前端预览配音效果,限制文本长度 200 字以内。
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
"""
user_id = authenticated_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_voice"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(authenticated_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(authenticated_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
actual_voice_id = request.voice_id
profile = voice_clone_repo.get(request.voice_id)
@@ -594,6 +664,12 @@ def preview_tts(
language=getattr(request, "language", "zh-CN"),
)
except (CosyVoiceError, ValueError) as e:
# 合成失败退费
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预览失败退积分异常: {refund_err}")
if isinstance(e, CosyVoiceError):
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS 合成失败: {e}") from e
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+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,
File diff suppressed because it is too large Load Diff
+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]:
+10 -11
View File
@@ -13,13 +13,12 @@ from pydantic import BaseModel, Field
class PointsBalanceResponse(BaseModel):
"""积分余额 + 会员状态"""
balance: float = Field(..., description="当前积分余额")
total_earned: float = Field(..., description="累计获得积分")
total_spent: float = Field(..., description="累计消耗积分")
balance: int = Field(..., description="当前积分余额")
total_earned: int = Field(..., description="累计获得积分")
total_spent: int = Field(..., description="累计消耗积分")
is_member: bool = Field(default=False, description="是否付费会员")
member_type: Optional[str] = Field(None, description="会员类型: monthly/quarterly/yearly")
member_expires_at: Optional[datetime] = Field(None, description="会员到期时间")
credits_enabled: bool = Field(default=False, description="积分系统是否启用(false=免费放行不扣分)")
# ============ 流水 ============
@@ -31,8 +30,8 @@ class PointsTransactionItem(BaseModel):
id: str
type: str = Field(..., description="类型: add/deduct")
source: str = Field(..., description="来源场景")
amount: float
balance_after: float
amount: int
balance_after: int
description: str = ""
ref_id: str = ""
created_at: Optional[str] = None
@@ -100,9 +99,9 @@ class PointsCheckResponse(BaseModel):
"""消费前余额检查响应"""
allowed: bool
required_points: float
current_balance: float
remaining_after: float
required_points: int
current_balance: int
remaining_after: int
is_free_quota: bool = False
@@ -113,7 +112,7 @@ class PointsDeductRequest(BaseModel):
"""积分扣减请求"""
scene_key: str
amount: float
amount: int
description: Optional[str] = ""
ref_id: Optional[str] = ""
@@ -171,7 +170,7 @@ class MembershipStatusResponse(BaseModel):
is_member: bool
member_type: Optional[str] = None
member_expires_at: Optional[datetime] = None
points_balance: float
points_balance: int
max_resolution: str = Field(
default="1080p",
description="可用最高分辨率: 720p(free) / 1080p(paid)",
-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):
-331
View File
@@ -1,331 +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)
VALID_VIDEO_RESOLUTIONS = ("480p", "720p", "1080p", "普清", "高清", "超清")
# -- 编导脚本结构(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 = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
video_resolution: str = "720p"
@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 = ""
video_resolution: str = "720p"
duration: int = Field(default=15, ge=5, le=30)
language: str = "zh-CN"
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 = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
video_resolution: str = "720p"
@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")
video_model: str | None = Field(default=None, description="用户选定的视频生成模型(confirm时可选)")
video_resolution: str | None = Field(default=None, description="用户选定的分辨率(confirm时可选)")
video_ratio: str | None = Field(default=None, description="用户选定的比例(confirm时可选)")
duration: int | None = Field(default=None, ge=5, le=30, description="用户选定的时长秒数(confirm时可选,5~30)")
voice_id: str | None = Field(default=None, description="用户选定的音色ID(confirm时可选)")
voice_source: str | None = Field(default=None, description="用户选定的音色来源(confirm时可选)")
language: str | None = Field(default=None, description="用户选定的语言(confirm时可选)")
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
current_stage: str = (
"" # 细粒度阶段 snake_case(analyzing_images/parsing_intent/generating_script/reviewing/tts_synthesizing/rendering_video/uploading)
)
phase_message: str = "" # 中文阶段提示文案(前端轮询/SSE 直接展示)
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 = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
intent_result: dict | None = None
result_video_url: str = ""
pre_trusted_images: list[str] | None = None
video_resolution: str = "720p"
credits_prepaid: float = 0.0
credits_cost: float = 0.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
# -- 积分预估 --
class EstimateCreditsRequest(BaseModel):
"""爆款视频积分预估请求。
前端可传 model 或 video_model(兼容老字段);resolution/ratio/duration 为预估所需参数。
"""
model: str = Field(default="", alias="video_model")
resolution: str = Field(default="720p", alias="video_resolution")
ratio: str = Field(default="9:16", alias="video_ratio")
duration: int = Field(default=15, ge=5, le=30)
model_config = {"populate_by_name": True}
class CreditsFormulaBreakdown(BaseModel):
"""爆款视频积分计费公式明细(前端展示用)。"""
tokens: float = Field(..., description="估算视频 tokens 数 (duration*width*height*fps/1024)")
video_cost: float = Field(..., description="视频生成成本(元)= tokens/1e6 * model_price")
fixed_cost: float = Field(..., description="固定成本(元),含 VLM/LLM/TTS/OSS/服务器")
profit_multiplier: float = Field(..., description="利润系数(默认 1.3)")
model_price: float = Field(..., description="模型单价(元/百万 tokens)")
width: int = Field(..., description="视频宽度像素")
height: int = Field(..., description="视频高度像素")
fps: int = Field(..., description="视频帧率")
class EstimateCreditsResponse(BaseModel):
"""爆款视频积分预估响应。"""
estimated_credits: float
formula_breakdown: CreditsFormulaBreakdown = Field(..., description="计费公式明细")
class RetryViralVideoRequest(BaseModel):
"""重试爆款视频任务的请求体(可选,允许改参数重新预估积分多退少补)。
不传 body 或字段全缺省:保持原参数、不重新扣点,走默认重置+入队逻辑。
传入新的 duration/video_resolution/video_ratio/video_model:重新预估积分,
与原 credits_prepaid 比较后多退少补(差额补扣不足抛 402)。
"""
duration: int | None = Field(default=None, ge=5, le=30, description="重试时新的视频时长(秒)")
video_resolution: str | None = Field(default=None, description="重试时新的分辨率,如 720p/1080p")
video_ratio: str | None = Field(default=None, description="重试时新的画幅比,如 9:16/16:9")
video_model: str | None = Field(default=None, description="重试时新的视频模型,如 seedance-2.5")
# -- 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)
@@ -228,8 +228,6 @@ class GpuLipsyncService:
lipsync_job_id: str = "",
user_id: str = "",
project_id: str = "",
credits_prepaid: float = 0.0,
credits_transaction_id: str = "",
) -> GpuLipsyncTaskModel:
task_id = str(uuid.uuid4())
now = datetime.now(UTC)
@@ -242,8 +240,6 @@ class GpuLipsyncService:
audio_url=audio_url,
status="pending",
attempt=0,
credits_prepaid=float(credits_prepaid or 0.0),
credits_transaction_id=str(credits_transaction_id or ""),
created_at=now,
updated_at=now,
)
+1 -222
View File
@@ -38,7 +38,6 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError
from packages.config import get_api_settings
from packages.domain import feature_pricing_service
from packages.domain.sentence_timings import (
compute_sentence_timings,
probe_audio_duration,
@@ -223,47 +222,7 @@ class LipsyncService:
if timings:
job.sentence_timings = timings
# 4. 检查是否走 Ditto(蚂蚁数字人,#2076):开关 + 配置完整
use_ditto = False
if self.settings.use_ditto_lipsync:
try:
from packages.application.ditto_service import get_ditto_client
ditto = get_ditto_client()
if ditto.is_configured:
use_ditto = True
logger.info("[lipsync] 优先走 Ditto 蚂蚁数字人: job_id=%s", job.id)
else:
logger.info(
"[lipsync] Ditto 开关已开但配置不完整(base_url=%s, template=%s),继续判断 GPU: job_id=%s",
bool(ditto.base_url),
bool(ditto.default_video_url),
job.id,
)
except Exception as exc:
logger.warning("[lipsync] Ditto 初始化失败,继续判断 GPU: job_id=%s err=%s", job.id, exc)
if use_ditto:
try:
# Ditto 使用预置人物模板视频,不用用户上传的 video_url;
# 但保留用户 video_url 以便失败回退到 GPU/MediaKit。
job.status = "processing"
job.mediakit_task_id = "ditto:submitted"
job.updated_at = datetime.now(UTC)
self.db.commit()
from app.tasks.lipsync_ditto import lipsync_ditto_process_async
lipsync_ditto_process_async.apply_async(args=(job.id, job.user_id))
logger.info("[lipsync] Ditto 任务已异步派发: job_id=%s", job.id)
return
except Exception as exc:
logger.warning("[lipsync] Ditto 派发失败,回退 GPU/MediaKit: job_id=%s err=%s", job.id, exc)
try:
self.db.rollback()
except Exception:
pass
# 5. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
use_gpu = False
if self.settings.use_gpu_lipsync:
try:
@@ -409,8 +368,6 @@ class LipsyncService:
lipsync_job_id=job.id,
user_id=job.user_id,
project_id=job.project_id,
credits_prepaid=float(getattr(job, "credits_prepaid", 0) or 0),
credits_transaction_id=str(getattr(job, "credits_transaction_id", "") or ""),
)
logger.info(
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
@@ -458,121 +415,6 @@ class LipsyncService:
job.output_duration,
)
# ── lip_sync 计费辅助 ────────────────────────────────────────────────
@staticmethod
def _estimate_duration(
*,
audio_duration: Optional[float] = None,
sentence_timings: Optional[list] = None,
script_text: str = "",
) -> float:
"""预估音频/成片秒数。
优先级:audio_duration(预合成前端已 ffprobe)> timings 末句 end_time >
脚本字数 / 5 字每秒 > 默认 10 秒。
"""
if audio_duration and float(audio_duration) > 0:
return float(audio_duration)
if sentence_timings:
max_end = 0.0
for item in sentence_timings:
if isinstance(item, dict):
end = item.get("end_time") or item.get("end") or 0.0
else:
end = 0.0
try:
max_end = max(max_end, float(end))
except (TypeError, ValueError):
continue
if max_end > 0:
return max_end
text = (script_text or "").strip()
if text:
return max(1.0, len(text) / 5.0)
return 10.0
def _settle_lip_sync(self, job: LipsyncJobModel, actual_duration: float) -> None:
"""按实际时长结算(首期只退不补:final < prepaid 退差额,> 不补)。
幂等:credits_cost 已 > 0 说明结算过,直接跳过。
结算失败不阻塞业务(结果已产出),仅记录日志。
"""
try:
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
if prepaid <= 0:
return
if float(getattr(job, "credits_cost", 0) or 0) > 0:
return
feature_cfg = feature_pricing_service.get_feature_config("lip_sync")
unit_cost = float(feature_cfg.dynamic_unit_cost) if feature_cfg is not None else 0.0
duration = float(actual_duration or 0.0)
if duration <= 0:
duration = self._estimate_duration(
sentence_timings=job.sentence_timings,
script_text=job.script_text,
)
final_price, _bd = feature_pricing_service.calculate_price("lip_sync", duration * unit_cost)
final_price = round(float(final_price), 2)
job.credits_cost = final_price
if final_price < prepaid - 0.009:
refund = round(prepaid - final_price, 2)
from packages.domain.points_service import PointsService
res = PointsService().refund_points(
user_id=job.user_id,
amount=refund,
source="lip_sync",
db=self.db,
ref_id=str(job.credits_transaction_id or job.id),
description="对口型结算退费",
)
if not res.get("success"):
logger.warning(
"[lip_sync] 结算退费失败 job_id=%s refund=%.2f(不阻塞)",
job.id,
refund,
)
# final > prepaid:首期只退不补,不补扣
self.db.commit()
except Exception: # noqa: BLE001
logger.exception("[lip_sync] 结算异常 job_id=%s(不阻塞结果)", job.id)
try:
self.db.rollback()
except Exception: # noqa: BLE001
pass
def _refund_lip_sync(self, job: LipsyncJobModel) -> None:
"""任务失败/取消时全额退还预扣积分(credits_cost 已结算则退实际未消耗部分)。"""
try:
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
if prepaid <= 0:
return
txn_id = str(getattr(job, "credits_transaction_id", "") or "")
cost = float(getattr(job, "credits_cost", 0) or 0)
refund = round(prepaid - cost, 2) if cost > 0 else round(prepaid, 2)
if refund <= 0:
return
from packages.domain.points_service import PointsService
res = PointsService().refund_points(
user_id=job.user_id,
amount=refund,
source="lip_sync",
db=self.db,
ref_id=txn_id or job.id,
description="对口型失败/取消退款",
)
if res.get("success"):
job.credits_cost = prepaid # 标记已全额退回,防重复退
self.db.commit()
except Exception: # noqa: BLE001
logger.exception("[lip_sync] 退款异常 job_id=%s", job.id)
try:
self.db.rollback()
except Exception: # noqa: BLE001
pass
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
@@ -624,35 +466,6 @@ class LipsyncService:
if not isinstance(sentence_timings, list) or len(sentence_timings) == 0:
raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput")
# 0.5 lip_sync 计费预扣(全局 points 开关 + 功能开关均开才扣)
prepaid_credits = 0.0
prepaid_txn_id = ""
if feature_pricing_service.is_feature_enabled("lip_sync"):
est_duration = self._estimate_duration(
audio_duration=audio_duration,
sentence_timings=sentence_timings,
script_text=script_text,
)
feature_cfg = feature_pricing_service.get_feature_config("lip_sync")
unit_cost = float(feature_cfg.dynamic_unit_cost) if feature_cfg is not None else 0.0
dynamic_cost = est_duration * unit_cost
prepaid_credits, _bd = feature_pricing_service.calculate_price("lip_sync", dynamic_cost)
if prepaid_credits > 0:
from packages.domain.points_service import PointsService
res = PointsService().deduct_points(
user_id=user_id,
amount=prepaid_credits,
source="lip_sync",
db=self.db,
description="对口型生成预扣",
)
if not res.get("success"):
raise ValueError(
f"积分不足:本次对口型需 {prepaid_credits:.2f} 积分,当前余额 {res.get('balance', 0)}"
)
prepaid_txn_id = str(res.get("transaction_id") or "")
# 1. 创建数据库记录
job_id = str(uuid.uuid4())
job = LipsyncJobModel(
@@ -669,8 +482,6 @@ class LipsyncService:
emotion=emotion or "",
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
status="tts_processing" if is_tts_mode else "pending",
credits_prepaid=prepaid_credits,
credits_transaction_id=prepaid_txn_id,
)
self.db.add(job)
self.db.flush()
@@ -845,30 +656,6 @@ class LipsyncService:
if job.status in (STATUS_COMPLETED, "failed"):
return job
# Ditto 异步路径:mediakit_task_id 以 "ditto:" 开头,由 Celery 任务异步更新
# 不做 MediaKit 轮询,只检查是否卡住太久(>10 分钟)则标失败
if job.mediakit_task_id and job.mediakit_task_id.startswith("ditto:"):
if job.status in ("processing", "submitted"):
_now = datetime.now(UTC)
_upd = job.updated_at
if _upd is not None and _upd.tzinfo is None:
_upd = _upd.replace(tzinfo=UTC)
stale_minutes = 10
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
logger.warning(
"Ditto 异步任务超时(>%d 分钟),标记失败: job_id=%s",
stale_minutes,
job_id,
)
job.status = "failed"
job.error_message = f"Ditto 处理超时(>{stale_minutes} 分钟)"
job.error_code = "DittoTimeout"
job.completed_at = _now
job.updated_at = _now
self.db.commit()
self._refund_lip_sync(job)
return job
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
@@ -890,8 +677,6 @@ class LipsyncService:
job.completed_at = _now
job.updated_at = _now
self.db.commit()
# lip_sync 超时全额退款
self._refund_lip_sync(job)
return job
# 未提交的任务不轮询
@@ -917,8 +702,6 @@ class LipsyncService:
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
self.db.commit()
# lip_sync 结算(只退不补)
self._settle_lip_sync(job, float(job.output_duration or 0.0))
# 异步转存自家 OSS
try:
from app.tasks.lipsync_tts import persist_output_video_task
@@ -936,8 +719,6 @@ class LipsyncService:
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(UTC)
# lip_sync 失败全额退款(先退款再统一 commit)
self._refund_lip_sync(job)
else:
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
if isinstance(mk_status, str) and mk_status:
@@ -1031,8 +812,6 @@ class LipsyncService:
job.status = "cancelled"
job.updated_at = datetime.now(UTC)
self.db.commit()
# lip_sync 取消全额退款
self._refund_lip_sync(job)
self.db.refresh(job)
return job
@@ -11,12 +11,14 @@
存储路径与元信息约定),返回 asset_id —— 下游仍以 voice_library_id(实为
audio asset id)消费,渲染链路零改动。
积分扣点与 /tts 合成端点保持一致(ai_voice 场景),失败退费。
"""
from __future__ import annotations
import json
import logging
import math
import subprocess
import tempfile
from dataclasses import dataclass
@@ -30,10 +32,13 @@ from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
from packages.shared.storage import SharedStorageService
logger = logging.getLogger(__name__)
_POINTS_SCENE = "ai_voice"
_SYNTH_TIMEOUT = 180.0 # 叙事配音在 HTTP 请求内同步等待,长文案分段合成时留出余量
_CONTENT_TYPE_MAP = {"mp3": "audio/mpeg", "wav": "audio/wav", "pcm": "audio/pcm", "opus": "audio/opus"}
@@ -268,6 +273,24 @@ def prepare_narrative_voice(
voice_clone_repository=voice_clone_repository,
)
# 积分扣点(与 /tts 合成端点同口径),失败时在合成失败分支退费
points_svc = PointsService() if points_enabled else None
points_deducted = 0
if points_svc is not None:
est_minutes = max(1.0, math.ceil(len(content) / 240))
points_deducted = calculate_points_cost(
_POINTS_SCENE,
is_member=is_member,
duration_minutes=est_minutes,
member_type=member_type,
)
deduct_res = points_svc.deduct_points(user_id, points_deducted, _POINTS_SCENE, db)
if not deduct_res["success"]:
raise NarrativeError(
f"积分不足,需要 {points_deducted} 积分,当前余额 {deduct_res['balance']}",
status_code=402,
)
use_case = CreateTTSJobUseCase(tts_repository)
job = use_case.execute(
user_id=user_id,
@@ -288,9 +311,19 @@ def prepare_narrative_voice(
workflow.process_synthesis_failure(job.id, str(e))
except Exception: # noqa: BLE001
logger.warning("标记叙事 TTS job 失败出错: job_id=%s", job.id, exc_info=True)
if points_deducted and points_svc is not None:
try:
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
except Exception: # noqa: BLE001
logger.warning("叙事 TTS 失败退积分异常: job_id=%s", job.id, exc_info=True)
raise NarrativeError(f"配音合成失败:{e}", status_code=502) from e
if not job.is_completed:
if points_deducted and points_svc is not None:
try:
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
except Exception: # noqa: BLE001
logger.warning("叙事 TTS 未完成退积分异常: job_id=%s", job.id, exc_info=True)
raise NarrativeError("配音合成未完成,请稍后重试", status_code=504)
asset = _save_tts_job_as_voice_asset(
-552
View File
@@ -1,552 +0,0 @@
"""Ditto 蚂蚁数字人口型异步任务 — #2076.
把 Ditto 同步 HTTP 调用(30-120s)从 API 请求移到 Celery 后台执行:
1. 加载 LipsyncJob
2. 确定驱动视频:用户上传的 video_url 优先,无则用 settings.ditto_default_video_url 兜底
3. 视频时长对齐:若视频 < 音频+2s,用 ffmpeg 循环视频到足够长度后上传临时文件
4. 调 DittoClient.generate_and_persist
5. 成功:标记 completed,写入 output_video_url(Ditto 输出自带音频,无需二次混流/超分)
6. 失败:回退 GPU MuseTalk → 再失败回退 MediaKit
注意:
- 保留 MuseTalk 代码不动;Ditto 优先,失败按原链路兜底
- Ditto 默认使用用户上传的视频作为驱动模板,default_video_url 仅作兜底
- 用户视频短于音频时,ffmpeg stream_loop 循环到音频时长+2s余量
- 不传 GFPGAN 超分,不需要 ffmpeg 音视频混流(Ditto 输出已带音视频)
"""
from __future__ import annotations
import logging
import os
import subprocess
import tempfile
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Optional
from celery import shared_task
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
logger = logging.getLogger(__name__)
_DITTO_URL_TTL_SECONDS = 7 * 24 * 3600 # Ditto 结果 OSS URL 7 天有效
_VIDEO_LOOP_MARGIN_SECONDS = 2.0 # 循环视频时比音频多留 2 秒余量
_MAX_VIDEO_PREPROCESS_SIZE = 200 * 1024 * 1024 # 用户视频最大 200MB
def _get_db_session() -> Session:
try:
from worker_app.db import SessionLocal # type: ignore
except ImportError:
from app.db import SessionLocal # type: ignore
return SessionLocal()
def _sign_media_url(url: str) -> str:
"""对自家 OSS URL 签 7 天预签名。"""
if not url:
return url
try:
from urllib.parse import urlparse
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url
return storage.get_download_url(url, expires_seconds=_DITTO_URL_TTL_SECONDS)
except Exception:
return url
def _probe_media_duration(path_or_bytes, *, is_bytes: bool = False) -> float:
"""用 ffprobe 探测视频/音频时长(秒);失败返回 0。
Args:
path_or_bytes: 文件路径(str) 或 字节数据(bytes)
is_bytes: 传入的是 bytes 还是文件路径
"""
tmp_path = None
try:
if is_bytes:
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as tmp:
tmp.write(path_or_bytes)
tmp_path = tmp.name
target = tmp_path
else:
target = path_or_bytes
out = subprocess.check_output(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
target,
],
stderr=subprocess.STDOUT,
timeout=15,
)
return float(out.decode().strip() or 0)
except Exception as exc:
logger.warning("[ditto_task] ffprobe 失败: %s", exc)
return 0.0
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception:
pass
def _probe_video_duration(video_bytes: bytes) -> float:
"""用 ffprobe 探测视频时长(秒);失败返回 0。"""
return _probe_media_duration(video_bytes, is_bytes=True)
def _prepare_driver_video(
*,
user_video_url: str,
default_video_url: str,
audio_duration: float,
job_id: str,
user_id: str,
) -> tuple[str, bool]:
"""准备传给 Ditto 的驱动视频 URL。
逻辑:
1. 优先使用用户上传的视频(user_video_url),无则用 default_video_url 兜底
2. 下载视频,探测时长
3. 若视频时长 >= 音频时长+2s 余量:直接用原 URL(签名后)
4. 若视频时长 < 音频时长+2s:ffmpeg stream_loop 循环到目标时长,上传临时 OSS,返回临时 URL
5. 任何异常:回退到 default_video_url(兜底)
Returns:
(video_url_for_ditto, is_temporary) — is_temporary=True 表示 URL 是本次临时生成的
"""
from packages.shared.storage import get_shared_storage_service
from packages.shared.url_security import safe_download_bytes
storage = get_shared_storage_service()
# 1. 选择源 URL(用户视频优先)
source_url = user_video_url or default_video_url
source_label = "user" if user_video_url else "default"
if not source_url:
raise RuntimeError("无可用驱动视频(用户视频和默认模板都为空)")
# 2. 下载视频
try:
# 用户视频需要签名才能下载
signed_source = _sign_media_url(source_url) if user_video_url else source_url
video_bytes = safe_download_bytes(
signed_source,
purpose="ditto-driver-video",
max_size=_MAX_VIDEO_PREPROCESS_SIZE,
allowed_mime_types=("video/mp4", "video/quicktime", "video/x-msvideo", "video/webm"),
timeout=60,
)
except Exception as dl_exc:
logger.warning(
"[ditto_task] 下载驱动视频失败(%s),回退默认模板: job=%s err=%s",
source_label,
job_id,
dl_exc,
)
if user_video_url and default_video_url:
return default_video_url, False
raise
# 3. 探测视频时长
video_duration = _probe_media_duration(video_bytes, is_bytes=True)
target_duration = audio_duration + _VIDEO_LOOP_MARGIN_SECONDS
# 4. 视频足够长:直接用签名后的原 URL
if video_duration >= target_duration:
logger.info(
"[ditto_task] 驱动视频足够长(%.2fs >= %.2fs),直接使用: job=%s source=%s",
video_duration,
target_duration,
job_id,
source_label,
)
return _sign_media_url(source_url) if user_video_url else source_url, False
# 5. 视频不够长:ffmpeg 循环到目标时长
logger.info(
"[ditto_task] 驱动视频不够长(%.2fs < %.2fs),ffmpeg 循环延长: job=%s",
video_duration,
target_duration,
job_id,
)
looped_path = None
try:
# 写临时文件
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as src_tmp:
src_tmp.write(video_bytes)
src_path = src_tmp.name
looped_fd, looped_path = tempfile.mkstemp(suffix=".mp4")
os.close(looped_fd)
# ffmpeg: stream_loop -1 循环输入,-t 截到目标时长
# 使用 -c copy 快速复制流(不重编码),速度快无画质损失
cmd = [
"ffmpeg",
"-y",
"-stream_loop",
"-1",
"-i",
src_path,
"-t",
str(target_duration),
"-c",
"copy",
"-movflags",
"+faststart",
looped_path,
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
except subprocess.CalledProcessError:
# copy 模式失败(编码不兼容),回退重编码
logger.warning("[ditto_task] stream_loop copy 失败,回退重编码: job=%s", job_id)
cmd = [
"ffmpeg",
"-y",
"-stream_loop",
"-1",
"-i",
src_path,
"-t",
str(target_duration),
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-c:a",
"aac",
"-movflags",
"+faststart",
looped_path,
]
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
# 上传临时 OSS
looped_key = f"ditto-tmp/{user_id}/{job_id}_looped.mp4"
with open(looped_path, "rb") as f:
tmp_url = storage.upload_file(
f,
looped_key,
content_type="video/mp4",
)
# 临时文件需要签名(private bucket)
signed_tmp_url = _sign_media_url(tmp_url)
logger.info(
"[ditto_task] 循环视频已上传: job=%s key=%s dur=%.2fs",
job_id,
looped_key,
target_duration,
)
return signed_tmp_url, True
except Exception as loop_exc:
logger.warning(
"[ditto_task] 视频循环处理失败,回退直接使用(Ditto 侧处理): job=%s err=%s",
job_id,
loop_exc,
)
# 兜底:直接用原视频(交给 Ditto 侧处理时长不一致)
return _sign_media_url(source_url) if user_video_url else source_url, False
finally:
for p in (locals().get("src_path"), looped_path):
if p and os.path.exists(p):
try:
os.unlink(p)
except Exception:
pass
def _refund_lip_sync(db: Session, job: "LipsyncJobModel") -> None:
"""Ditto 失败/取消时全额退款(复用 lipsync_service 的退款逻辑)。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._refund_lip_sync(job)
except Exception:
logger.exception("[ditto_task] lip_sync 退款异常 job_id=%s", job.id)
def _settle_lip_sync(db: Session, job: "LipsyncJobModel", duration: float) -> None:
"""Ditto 成功后按实际时长结算。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._settle_lip_sync(job, duration)
except Exception:
logger.exception("[ditto_task] lip_sync 结算异常 job_id=%s(不阻塞)", job.id)
def _fallback_to_gpu_then_mediakit(db: Session, job: "LipsyncJobModel") -> None:
"""Ditto 失败后:优先回退 GPU MuseTalk,再回退 MediaKit 云端。
复用 lipsync_service 现有路径逻辑以保证兜底一致性。
"""
# 先尝试走 GPU MuseTalk(若可用)
try:
from app.services.gpu_lipsync_service import GpuLipsyncService
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
gpu_svc = GpuLipsyncService(db)
if gpu_svc.has_available_worker():
logger.info("[ditto_task] 回退 GPU MuseTalk: job_id=%s", job.id)
from app.services.lipsync_service import LipsyncService
svc = LipsyncService(db)
storage = _shared_storage()
persisted_audio = None
try:
persisted_audio = svc._persist_external_audio_for_gpu(job=job, storage=storage)
except Exception as exc:
logger.warning("[ditto_task] GPU 外部音频转存失败: %s", exc)
audio_url_for_task = persisted_audio or job.audio_url
gpu_task = gpu_svc.create_task(
video_url=job.video_url,
audio_url=audio_url_for_task,
lipsync_job_id=job.id,
user_id=job.user_id,
)
if gpu_task is not None:
job.mediakit_task_id = f"gpu:{gpu_task.id}"
job.status = "processing"
job.updated_at = datetime.now(UTC)
db.commit()
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
return
db.rollback()
except Exception as exc:
logger.warning("[ditto_task] GPU MuseTalk 回退失败,转 MediaKit: %s", exc)
try:
db.rollback()
except Exception:
pass
# 最后兜底:MediaKit 云端
try:
from app.services.mediakit_client import get_mediakit_client
client = get_mediakit_client()
video_url = _sign_media_url(job.video_url)
signed_audio_url = _sign_media_url(job.audio_url)
result = client.submit_lipsync(
video_url=video_url,
audio_url=signed_audio_url,
enable_video_loop=job.enable_video_loop,
client_token=job.id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[ditto_task] 已回退 MediaKit: job_id=%s task_id=%s", job.id, result["task_id"])
except Exception as exc:
job.status = "failed"
job.error_message = f"Ditto/GPU/MediaKit 均失败: {exc}"
job.error_code = "AllBackendsFailed"
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[ditto_task] 所有兜底均失败: job_id=%s err=%s", job.id, exc)
def _shared_storage():
from packages.shared.storage import get_shared_storage_service
return get_shared_storage_service()
@shared_task(
name="lipsync_ditto_process_async",
bind=True,
max_retries=0,
acks_late=True,
time_limit=600,
soft_time_limit=540,
)
def lipsync_ditto_process_async(self, job_id: str, user_id: str) -> None:
"""异步调用 Ditto 生成口型视频。
Args:
job_id: LipsyncJob ID
user_id: 用户 ID
"""
from packages.application.ditto_emotion_service import get_ditto_emotion_service
from packages.application.ditto_service import DittoError, get_ditto_client
from packages.config import get_api_settings
from packages.domain.sentence_timings import probe_audio_duration
from packages.shared.url_security import safe_download_bytes
db: Session = _get_db_session()
job: Optional[LipsyncJobModel] = None
prepared_video_url: str = ""
try:
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
if job is None:
logger.error("[ditto_task] job 不存在: job_id=%s", job_id)
return
if job.status != "processing":
logger.warning(
"[ditto_task] job 状态异常(非 processing),跳过: job_id=%s status=%s",
job_id,
job.status,
)
return
audio_url = job.audio_url or ""
script = job.script_text or ""
user_video_url = job.video_url or ""
if not audio_url:
raise DittoError("job.audio_url 为空,无法调用 Ditto", code="InvalidParam")
settings = get_api_settings()
default_video_url = settings.ditto_default_video_url or ""
logger.info(
"[ditto_task] 开始 Ditto 生成: job_id=%s has_user_video=%s script_len=%d",
job_id,
bool(user_video_url),
len(script),
)
# ── 0. 探测音频时长(情绪分析 + 视频循环都需要)──
audio_duration = 0.0
audio_bytes_for_probe = None
try:
audio_bytes_for_probe = safe_download_bytes(
audio_url,
allowed_mime_types=("audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp3"),
timeout=30,
)
audio_duration = probe_audio_duration(audio_bytes_for_probe)
logger.info("[ditto_task] 音频时长: %.2fs", audio_duration)
except Exception as audio_exc:
logger.warning("[ditto_task] 音频时长探测失败: %s", audio_exc)
audio_duration = 0.0
# ── 1. LLM 情绪分析(生成 emo_timeline)──
emo_timeline = ""
try:
emo_svc = get_ditto_emotion_service()
if emo_svc.enabled and script and audio_duration > 0:
sentence_timings = getattr(job, "sentence_timings", None)
emo_timeline = emo_svc.build_timeline(
text=script,
audio_duration=audio_duration,
sentence_timings=sentence_timings,
)
if emo_timeline:
logger.info("[ditto_task] 情绪时间线已生成: segments≈%d", len(emo_timeline) // 50)
except Exception as emo_exc:
logger.warning("[ditto_task] 情绪分析异常(降级中性): %s", emo_exc)
emo_timeline = ""
# ── 2. 准备驱动视频(用户视频优先,必要时循环延长)──
prepared_video_url, _is_tmp = _prepare_driver_video(
user_video_url=user_video_url,
default_video_url=default_video_url,
audio_duration=audio_duration if audio_duration > 0 else 10.0, # 探测失败时按10s估
job_id=job_id,
user_id=user_id,
)
# ── 3. 调用 Ditto ──
client = get_ditto_client()
result = client.generate_and_persist(
job_id=job_id,
user_id=user_id,
audio_url=audio_url,
script=script,
video_url=prepared_video_url,
emo_timeline=emo_timeline,
)
# Ditto 返回的 MP4 自带音频,签名 OSS URL(7天有效)后标记完成
job.output_video_url = _sign_media_url(result.video_url)
# 探测时长(用于计费)
duration = _probe_video_duration(result.video_bytes)
if duration <= 0:
# 兜底:按音频时长估算
if audio_bytes_for_probe is not None:
try:
duration = probe_audio_duration(audio_bytes_for_probe)
except Exception:
duration = 0.0
if duration <= 0 and audio_duration > 0:
duration = audio_duration
job.output_duration = duration
job.status = "completed"
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[ditto_task] Ditto 完成: job_id=%s url=%s duration=%.2fs rtf=%.2f frames=%d",
job_id,
result.video_url[:100],
duration,
result.rtf,
result.frames,
)
_settle_lip_sync(db, job, duration)
except DittoError as exc:
logger.error("[ditto_task] Ditto 失败,回退: job_id=%s code=%s err=%s", job_id, exc.code, exc)
if job is not None:
try:
db.rollback()
job = db.query(type(job)).filter_by(id=job_id).first() if hasattr(job, "id") else job
_fallback_to_gpu_then_mediakit(db, job)
except Exception as fallback_exc:
logger.exception("[ditto_task] 回退也失败 job_id=%s err=%s", job_id, fallback_exc)
try:
if job:
job.status = "failed"
job.error_message = f"Ditto 失败且回退异常: {exc}; fallback: {fallback_exc}"
job.error_code = "FallbackError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
except Exception as exc:
logger.exception("[ditto_task] 未预期异常: job_id=%s err=%s", job_id, exc)
if job is not None:
try:
db.rollback()
job = db.query(type(job)).filter_by(id=job_id).first()
_fallback_to_gpu_then_mediakit(db, job)
except Exception as fallback_exc:
logger.exception("[ditto_task] 回退也失败 job_id=%s err=%s", job_id, fallback_exc)
try:
if job:
job.status = "failed"
job.error_message = f"Ditto 异常: {exc}"
job.error_code = "DittoAsyncError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
finally:
db.close()
-29
View File
@@ -104,7 +104,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[lipsync_gpu_async] GPU 任务已被用户取消: job_id=%s", job_id)
_refund_lip_sync(db, job)
return
if final_task.status != "done":
@@ -142,7 +141,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
job_id,
job.output_duration,
)
_settle_lip_sync(db, job, final_task)
except Exception as exc:
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
try:
@@ -159,33 +157,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
db.close()
def _settle_lip_sync(db: Session, job: LipsyncJobModel, gpu_task) -> None:
"""GPU 成功后结算:同步 credits_cost 到 gpu 任务并按实际时长多退少不补。"""
try:
from app.services.lipsync_service import LipsyncService
# GPU 任务表先同步结算结果(标记用)
LipsyncService._settle_lip_sync(job, float(getattr(gpu_task, "result_duration", 0) or 0.0))
gpu_task.credits_cost = float(job.credits_cost or 0.0)
db.commit()
except Exception: # noqa: BLE001
logger.exception("[lipsync_gpu_async] lip_sync 结算异常 job_id=%s(不阻塞)", job.id)
try:
db.rollback()
except Exception: # noqa: BLE001
pass
def _refund_lip_sync(db: Session, job: LipsyncJobModel) -> None:
"""GPU 取消/失败路径全额退款。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._refund_lip_sync(job)
except Exception: # noqa: BLE001
logger.exception("[lipsync_gpu_async] lip_sync 退款异常 job_id=%s", job.id)
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
"""GPU 失败时回退到 MediaKit 云端渲染。"""
try:
+1 -27
View File
@@ -261,33 +261,7 @@ def tts_synthesize_and_submit(
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
)
# 3. 优先走 Ditto(#2076):开关打开且配置完整时,派发 Ditto 异步任务,不再走 MediaKit
ditto_dispatched = False
try:
from packages.config import get_api_settings as _get_settings
_settings = _get_settings()
if _settings.use_ditto_lipsync and _settings.ditto_api_base_url and _settings.ditto_default_video_url:
from app.tasks.lipsync_ditto import lipsync_ditto_process_async
job.status = "processing"
job.mediakit_task_id = "ditto:tts-submitted"
job.updated_at = datetime.now(UTC)
db.commit()
lipsync_ditto_process_async.apply_async(args=(job_id, user_id))
logger.info("[lipsync_tts] TTS 完成,已派发 Ditto 任务: job_id=%s", job_id)
ditto_dispatched = True
except Exception as _ditto_err:
logger.warning("[lipsync_tts] Ditto 派发失败,回退 MediaKit: job_id=%s err=%s", job_id, _ditto_err)
try:
db.rollback()
except Exception:
pass
if ditto_dispatched:
return
# 4. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
audio_url = _sign_media_url(job.audio_url)
video_url = _sign_media_url(job.video_url)
+139 -132
View File
@@ -11,34 +11,12 @@ const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".leng
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
// 单一通用路由(Playwright 按注册逆序匹配,故不拆分多个 glob 以免相互遮蔽)
await page.route("**/api/v1/**", async (route) => {
try {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
// /points/balance:Staging 后端积分总开关关闭(credits_enabled=false,免费放行)。
// 旧 bundle 部署的接口暂未返回该字段、且预校验只看余额,这里同时补开关与余额,
// 以验证“免费期不拦截生成”;新后端+新前端部署后读 credits_enabled=false 直接放行,
// 余额被忽略,此补丁随之成为 no-op。
if (sourceUrl.pathname.endsWith("/points/balance")) {
const body = await response.json().catch(() => ({}))
await route.fulfill({
response,
json: { ...body, credits_enabled: false, balance: 999999 },
})
return
}
await route.fulfill({ response })
} catch {
// 测试收尾时页面/上下文可能已关闭,忽略仍在途的请求,避免误判为失败
try {
await route.abort()
} catch {
/* noop */
}
}
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
@@ -67,13 +45,7 @@ async function loginWithRetry(
async function setupFreshUser(
request: APIRequestContext,
label: string,
): Promise<{
token: string
user: Record<string, unknown>
libraryId: string
assetId: string
suffix: string
}> {
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-${label}-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
@@ -81,19 +53,7 @@ async function setupFreshUser(
})
const token = await loginWithRetry(request, email, PASSWORD)
const auth = { Authorization: `Bearer ${token}` }
const me = await request.get(`${apiBase}/auth/me`, { headers: auth })
expect(me.ok(), `fetch profile: ${await me.text()}`).toBeTruthy()
const user = (await me.json()) as Record<string, unknown>
// 预置一条文案:标题下拉候选来自文案库,新用户为空会导致无法选中标题
await request.post(`${apiBase}/scripts`, {
headers: auth,
data: {
title: `测试标题 ${suffix}`,
content: `测试文案内容 ${suffix}`,
tags: ["e2e"],
},
})
const proj = await request.post(`${apiBase}/projects`, {
headers: auth,
data: { name: `Smoke ${label} ${suffix}` },
@@ -133,7 +93,7 @@ async function setupFreshUser(
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
)
.toBe("ready")
return { token, user, libraryId, assetId, suffix }
return { token, libraryId, assetId, suffix }
}
/**
@@ -152,7 +112,7 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, user, suffix } = await setupFreshUser(request, "random")
const { token, suffix } = await setupFreshUser(request, "random")
const authHeader = { Authorization: `Bearer ${token}` }
// 确保默认模板存在(智能剪辑页依赖模板)
@@ -166,24 +126,13 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
expect(templates.length).toBeGreaterThan(0)
// 注入登录态 + 路由 API
await page.addInitScript(
({ token, user }) => {
window.localStorage.setItem("access_token", token)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({
state: {
user,
isAuthenticated: true,
accessToken: token,
refreshToken: null,
},
version: 0,
}),
)
},
{ token, user },
)
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ──
@@ -229,28 +178,16 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
// ── Step 2:选择素材 ──────────────────────────────────────────
await expect(page.getByText(/选择素材/).first()).toBeVisible({ timeout: 10000 })
// 卡片中心是播放按钮(stopPropagation 仅播放不选中),点右上角空白处完成选中
await page
.getByTestId("material-card")
.first()
.click({ position: { x: 70, y: 12 } })
await expect(page.getByText(/已选 1 个素材|已选[^0]*[1-9]/)).toBeVisible({ timeout: 5000 })
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 3:填写标题 ──────────────────────────────────────────
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
await expect(page.getByText(/选择标题/).first()).toBeVisible({ timeout: 10000 })
// 标题为 antd AutoComplete(combobox),真实 input 带 placeholder
// antd AutoComplete combobox:真实可输入元素是 .ant-select-selection-search-input,
// 灰色提示语是单独的 placeholder span(input 自身无 placeholder 属性)
// 标题候选来自文案库(setupFreshUser 已预置一条文案)。
// combobox 的自由输入会在失焦时被 rc-select 重置,必须从下拉选中才提交,
// 因此聚焦输入框 → 点击候选选项
const seededTitle = `测试标题 ${suffix}`
const titleBox = page.locator(".ant-select-selection-search-input:visible").first()
await titleBox.click()
await page.locator(".ant-select-item-option", { hasText: seededTitle }).first().click()
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`测试随机剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
@@ -267,11 +204,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await confirmBtn.click()
const taskResp = await createTask
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
const taskBody = await taskResp.json()
const taskId = taskBody.items?.[0]?.id ?? taskBody.id ?? taskBody.task_id
expect(taskId, "created task should return an id").toBeTruthy()
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
console.log("[random] Generation task created:", taskId)
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[random] Wizard flow completed ✓")
})
@@ -281,28 +216,113 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
}) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, user, suffix } = await setupFreshUser(request, "narrative")
const { token, suffix } = await setupFreshUser(request, "narrative")
await page.addInitScript(
({ token, user }) => {
window.localStorage.setItem("access_token", token)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({
state: {
user,
isAuthenticated: true,
accessToken: token,
refreshToken: null,
},
version: 0,
}),
)
},
{ token, user },
)
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
const mockScriptId = `script-mock-${suffix}`
const mockVoiceId = `preset-voice-${suffix}`
const mockJobId = `tts-job-${suffix}`
// 文案列表(ScriptSelectModal 查询 /scripts)
await page.route("**/api/v1/scripts**", (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes("/extract-from-douyin")) {
route.continue()
return
}
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: mockScriptId,
title: "测试带货文案",
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
tags: ["带货"],
title_category: "daihuo",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
total: 1,
page: 1,
page_size: 200,
}),
})
})
// 预设音色(TtsVoiceModal 查询 GET /voices/presets)
await page.route("**/api/v1/voices/presets**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
voice_id: mockVoiceId,
name: "晓晓(女声)",
description: "温柔女声",
gender: "female",
language: "zh-CN",
preview_url: null,
tags: ["温柔"],
},
],
total: 1,
}),
}),
)
// 克隆音色:空列表
await page.route(
(url) => url.pathname.endsWith("/voice-clones"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
)
// TTS 合成:直接返回 completed 任务
await page.route("**/api/v1/tts/synthesize", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
job_id: mockJobId,
status: "completed",
progress: 100,
audio_url: "data:audio/mpeg;base64,",
duration: 5,
}),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
}),
)
await page.goto("/app/generate")
// ── 页面标题 ─────────────────────────────────────────────────
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
@@ -314,38 +334,28 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
await page.getByText(`测试标题 ${suffix}`).first().click()
await page.getByText("测试带货文案").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
await page.getByText("龙小夏").first().click()
await page.getByText("晓晓(女声)").first().click()
await page.getByRole("button", { name: "🎧 合成配音" }).click()
// 真实阿里云 CosyVoice 合成耗时偶有波动,放宽到 90s
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 90000 })
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
await expect(page.getByText(/选择素材/).first()).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/).first()).toBeVisible()
// 卡片中心是播放按钮(stopPropagation 仅播放不选中),点右上角空白处完成选中
await page
.getByTestId("material-card")
.first()
.click({ position: { x: 70, y: 12 } })
await expect(page.getByText(/已选 1 个素材|已选[^0]*[1-9]/)).toBeVisible({ timeout: 5000 })
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
// (#2048: PreviewCountModal 已移除)
await expect(page.getByText(/选择标题/).first()).toBeVisible({ timeout: 10000 })
// 选中文案后标题框已预填该文案标题,下拉按当前输入过滤,直接选中该选项确认
const titleBox2 = page.locator(".ant-select-selection-search-input:visible").first()
await titleBox2.click()
await page
.locator(".ant-select-item-option", { hasText: `测试标题 ${suffix}` })
.first()
.click()
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
@@ -361,11 +371,8 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await confirmBtn2.click()
const taskResp2 = await createTask2
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
const taskBody2 = await taskResp2.json()
const taskId2 = taskBody2.items?.[0]?.id ?? taskBody2.id ?? taskBody2.task_id
expect(taskId2, "created task should return an id").toBeTruthy()
console.log("[narrative] Generation task created:", taskId2)
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[narrative] Wizard flow completed ✓")
})
})
+7 -15
View File
@@ -11,20 +11,11 @@ const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".leng
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
try {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
} catch {
// 收尾时页面可能已关闭,忽略在途请求避免误判
try {
await route.abort()
} catch {
/* noop */
}
}
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
@@ -54,7 +45,8 @@ type LibraryResponse = { id: string }
test.describe("Core media upload flow", () => {
test.describe.configure({ timeout: 180_000 })
test("uploads a video asset and shows it in the asset library", async ({ page, request }) => {
// 串行执行时登录可能触发 429,两次退避约 130s,沿用 describe 的 180s 超时
test.setTimeout(120_000)
await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36)
const email = `e2e-mov-${suffix}@example.com`
-12
View File
@@ -14,7 +14,6 @@
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"marked": "^12.0.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -4503,17 +4502,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
-1
View File
@@ -25,7 +25,6 @@
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"marked": "^12.0.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
-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
@@ -3,7 +3,7 @@
* 后端路由: /api/v1/cover-templates
*/
import apiClient from "./client"
import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover"
import type { CoverTemplate, CoverEditorConfig } from "@/components/cover/types"
export interface CoverTemplateListResponse {
items: CoverTemplate[]
-41
View File
@@ -1,41 +0,0 @@
/**
* 运行时开关安全读取
*
* 背景:本项目使用 Vite 构建,浏览器运行时不存在 Node 的全局 `process`。
* 直接写 `process.env.XXX` 会在模块加载阶段抛出 `ReferenceError: process is not defined`,
* 导致整个页面白屏崩溃。
*
* 统一通过本模块读取这类仅在构建/调试期注入的布尔开关:
* - 优先读取 Vite 的 `import.meta.env.VITE_XXX`
* - 兼容历史上未加 VITE_ 前缀、经由 Node 环境(单测 / 旧构建脚本)注入的 `process.env.XXX`
* - 任何情况下访问失败都安全返回 false(默认走真实后端 API,不启用 mock)
*/
/** 从可能不存在的 Node 全局 process 上安全读取环境变量 */
function readNodeEnv(name: string): string | undefined {
try {
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
return proc?.env?.[name]
} catch {
return undefined
}
}
/**
* 读取运行时布尔开关。
*
* @param name 开关名(不含 VITE_ 前缀的历史名称,如 POINTS_API_MOCK)
* @returns 开关是否显式置为 "true";未设置或读取失败时为 false
*/
export function readRuntimeFlag(name: string): boolean {
// Vite 注入的环境变量(需 VITE_ 前缀才会暴露到浏览器)
const viteKey = `VITE_${name}`
const viteEnv = (import.meta as unknown as { env?: Record<string, string | undefined> }).env
const viteVal = viteEnv?.[viteKey] ?? viteEnv?.[name]
// 兼容 Node 环境下无前缀的历史变量名
const nodeVal = readNodeEnv(name)
const raw = viteVal ?? nodeVal
return raw === "true"
}
+8 -9
View File
@@ -8,7 +8,6 @@
* 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
PointsBalance,
PointsRulesResponse,
@@ -202,7 +201,7 @@ const MOCK_MEMBERSHIP: MembershipResponse = {
/** 获取积分余额 */
export async function getPointsBalance(): Promise<PointsBalance> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_BALANCE }
}
@@ -212,7 +211,7 @@ export async function getPointsBalance(): Promise<PointsBalance> {
/** 获取积分消耗规则 */
export async function getPointsRules(): Promise<PointsRulesResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier }
}
@@ -222,7 +221,7 @@ export async function getPointsRules(): Promise<PointsRulesResponse> {
/** 获取充值包列表 */
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null }
}
@@ -237,7 +236,7 @@ export async function getPointsTransactions(
page = 1,
pageSize = 20,
): Promise<PointsTransactionsResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const start = (page - 1) * pageSize
const items = MOCK_TRANSACTIONS.slice(start, start + pageSize)
@@ -262,7 +261,7 @@ export async function getPointsTransactions(
export async function createPointsOrder(
data: CreateRechargeOrderRequest,
): Promise<CreateRechargeOrderResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2))
const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id)
if (!pkg) throw new Error("充值包不存在")
@@ -286,7 +285,7 @@ export async function createPointsOrder(
* 积分预检查(消耗前调用)
*/
export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheckResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key)
if (!rule) {
@@ -327,7 +326,7 @@ export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheck
/** 获取每日免费额度使用情况 */
export async function getDailyUsage(): Promise<DailyUsage> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_DAILY_USAGE }
}
@@ -337,7 +336,7 @@ export async function getDailyUsage(): Promise<DailyUsage> {
/** 获取会员聚合信息(创作页可用来判断 max_resolution) */
export async function getMembership(): Promise<MembershipResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_MEMBERSHIP }
}
-2
View File
@@ -50,8 +50,6 @@ export interface PointsBalance {
member_type: "monthly" | "quarterly" | "yearly" | null
/** 会员到期时间 */
member_expires_at: ISODate | null
/** 后端积分系统是否启用(false=免费放行,不做余额预校验) */
credits_enabled?: boolean
}
/* ================================================================
+1 -2
View File
@@ -3,7 +3,6 @@
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
ScriptItem,
ScriptListParams,
@@ -17,7 +16,7 @@ import type {
* #1894:文案库接口已上线,默认 false 走真实 API;
* 通过 SCRIPTS_API_MOCK=true 环境变量可本地开启 mock 调试(行为同 POINTS_API_MOCK)。
*/
export const SCRIPTS_API_MOCK = readRuntimeFlag("SCRIPTS_API_MOCK")
export const SCRIPTS_API_MOCK = (process.env.SCRIPTS_API_MOCK as string | undefined) === "true"
// ==================== Mock 数据 ====================
@@ -6,7 +6,6 @@
* 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
SubscriptionInfo,
SubscriptionPlan,
@@ -73,7 +72,7 @@ const MOCK_PLANS: SubscriptionPlan[] = [
const MOCK_BILLING: BillingRecord[] = []
const isMock = () => readRuntimeFlag("POINTS_API_MOCK")
const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true"
/** 获取当前订阅 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
+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
-156
View File
@@ -1,156 +0,0 @@
import apiClient from "@/api/client"
import type {
GenerateViralVideoRequest,
HistoryResponse,
StyleTemplate,
ViralVideoJob,
ImageAnalysisResult,
CopyResult,
AnalyzeImagesRequest,
GenerateCopyRequest,
ConfirmCopyRequest,
ViralVideoModel,
ViralVideoModelsResponse,
} 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)
}
/** 动态预估积分消耗(STEP3 参数变化时调用) */
export function estimateViralVideoCredits(params: {
video_model: string
resolution: string
video_ratio: string
duration: number
}) {
return apiClient
.post<{ estimated_credits: number }>("/viral-video/estimate-credits", params)
.then((r) => r.data)
}
/** 获取支持的视频模型列表(GET /viral-video/models)。后端返回 {models: [...]} 包装 */
export function getViralVideoModels() {
return apiClient.get<ViralVideoModelsResponse>("/viral-video/models").then((r) => {
const data = r.data as ViralVideoModelsResponse | ViralVideoModel[] | null | undefined
if (Array.isArray(data)) return data
if (data && Array.isArray((data as ViralVideoModelsResponse).models)) {
return (data as ViralVideoModelsResponse).models
}
return []
})
}
/** ── 三步拆分:前端 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)
}
-339
View File
@@ -1,339 +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)
}
/** 从 job 对象取到有效阶段(兼容 progress_stage/current_stage 两种字段名) */
export function getJobStage(
job: { current_stage?: ViralVideoStage; progress_stage?: ViralVideoStage } | null | undefined,
): ViralVideoStage | undefined {
if (!job) return undefined
return job.current_stage || job.progress_stage
}
/** 从 job 对象取到阶段提示文案(兼容 phase_message/progress_message) */
export function getJobPhaseMessage(
job: { phase_message?: string; progress_message?: string } | null | undefined,
): string {
if (!job) return ""
return job.phase_message || job.progress_message || ""
}
/** 单张图片 VLM 识别结果(v8 叙述优先,仅保留最少结构化字段) */
export interface ImageProductAnalysis {
/** store / product / person / scene */
type?: string
name?: string
brand?: string
has_person?: boolean
/** v8: 用户端展示用的叙述 markdown(由提示词控制排版) */
summary_markdown?: string
/** 标题行兼容字段 */
category?: string
}
export interface ImageAnalysisResult {
/** v8 字段 */
images?: ImageProductAnalysis[]
/** 老数据兼容 */
products?: ImageProductAnalysis[]
}
/** v1.6 编导分镜脚本 - 单镜头 */
export interface ShotScript {
/** 时间区间,如 "0-3秒" */
time_range?: string
/** 说话人角色,如 "店主"、"顾客" */
speaker?: 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
/** v3 storyboard: 用户端展示用的 markdown 文案(由提示词控制排版) */
copy_display_markdown?: 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" | "my_voice"
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
/** 后端真实字段名(snake_case):当前细粒度阶段 */
current_stage?: ViralVideoStage
/** 后端真实字段名:当前阶段中文提示文案 */
phase_message?: string
/** @deprecated 兼容旧前端:使用 current_stage */
progress_stage?: ViralVideoStage
progress_percent?: number
/** @deprecated 兼容旧前端:使用 phase_message */
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" | "my_voice"
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" | "my_voice"
/** 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" | "my_voice"
/** Seedance 视频比例(9:16/16:9/1:1 等) */
video_ratio?: string
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
}
/** 视频模型描述(GET /viral-video/models) */
export interface ViralVideoModel {
key: string
display_name: string
supports_audio: boolean
supported_resolutions: string[]
max_duration: number
/** 计费模式(可选):per_second / per_video / token 等 */
billing_mode?: string
is_default?: boolean
}
/** GET /viral-video/models 响应包装 */
export interface ViralVideoModelsResponse {
models: ViralVideoModel[]
}
/** v1.6 阶段3请求:用户确认/编辑口播文案后开始单次 Seedance 出片(POST /viral-video/{id}/confirm-copy) */
export interface ConfirmCopyRequest {
/** 用户编辑后的口播文案;为空则使用 AI 生成的 voiceover_script */
edited_copy?: string
/** 视频模型 key,覆盖默认 */
video_model?: string
}
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
export interface StoryboardSegment {
order: number
type: string
description: string
text: string
duration: number
ken_burns?: string
transition?: string
}
-2
View File
@@ -18,8 +18,6 @@ export interface VoiceClone {
language: string
gender: string
error_message: string | null
/** CosyVoice 实际使用的音色 ID(status=ready 时由后端填充,用于 TTS 调用) */
voice_id?: string | null
created_at: string
updated_at: string
}
-1
View File
@@ -18,7 +18,6 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
voice_id: profile.voice_id,
created_at: profile.created_at,
updated_at: profile.updated_at,
})
@@ -1,182 +0,0 @@
/* DurationWheelPicker —— 弹层式滚轮选择器(样式与表单一致) */
/* 触发按钮:外观复用 .vv-select 风格 */
.dw-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 36px;
padding: 0 12px;
background: #fff;
border: 1px solid #e0e0e8;
border-radius: 8px;
font-size: 13px;
color: #1f2937;
cursor: pointer;
box-sizing: border-box;
transition: all 0.15s;
user-select: none;
}
.dw-trigger:hover {
border-color: #c0c0d0;
}
.dw-trigger-open,
.dw-trigger:focus-within {
border-color: #7c3aed !important;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12);
}
.dw-trigger-disabled {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.dw-trigger-val {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dw-trigger-placeholder {
color: #9ca3af;
}
.dw-trigger-arrow {
font-size: 10px;
color: #9ca3af;
margin-left: 8px;
transition: transform 0.2s;
}
.dw-trigger-arrow-up {
transform: rotate(180deg);
}
/* 弹层容器 */
.dw-popup {
padding: 8px;
min-width: 140px;
}
/* 滚轮 */
.dw-picker {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 8px;
background: #fafafe;
border: 1px solid #e5e7eb;
}
.dw-picker-list {
margin: 0;
padding: 0;
list-style: none;
height: 100%;
overflow-y: scroll;
scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.dw-picker-list::-webkit-scrollbar {
display: none;
}
.dw-picker-item {
display: flex;
align-items: baseline;
justify-content: center;
gap: 3px;
scroll-snap-align: center;
cursor: pointer;
font-size: 15px;
color: #9ca3af;
font-weight: 400;
transition:
color 0.15s,
transform 0.15s,
font-weight 0.15s;
}
.dw-picker-item-val {
font-variant-numeric: tabular-nums;
}
.dw-picker-item-unit {
font-size: 13px;
color: inherit;
}
.dw-picker-item-active {
color: #7c3aed;
font-weight: 600;
}
.dw-picker-item-active .dw-picker-item-val {
font-size: 18px;
}
.dw-picker-item-active .dw-picker-item-unit {
font-size: 14px;
}
/* 中心选中条 */
.dw-picker-mask {
position: absolute;
left: 6px;
right: 6px;
pointer-events: none;
background: #f5f0ff;
border-radius: 6px;
z-index: 1;
}
.dw-picker-mask::before,
.dw-picker-mask::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 1px;
background: #d8c4ff;
}
.dw-picker-mask::before {
top: 0;
}
.dw-picker-mask::after {
bottom: 0;
}
/* 上下渐变 */
.dw-picker-fade {
position: absolute;
left: 0;
right: 0;
height: 40%;
pointer-events: none;
z-index: 2;
}
.dw-picker-fade-top {
top: 0;
background: linear-gradient(to bottom, #fafafe 25%, rgba(250, 250, 254, 0));
}
.dw-picker-fade-bottom {
bottom: 0;
background: linear-gradient(to top, #fafafe 25%, rgba(250, 250, 254, 0));
}
/* 弹层按钮区 */
.dw-popup-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.dw-popup-actions .ant-btn {
border-radius: 6px;
}
.dw-popup-actions .ant-btn-primary {
background: #7c3aed;
}
.dw-popup-actions .ant-btn-primary:hover {
background: #6d28d9 !important;
}
/* 覆盖 antd Popover 默认内边距 */
.dw-popover .ant-popover-inner {
padding: 0 !important;
overflow: hidden;
}
.dw-popover .ant-popover-arrow {
display: none;
}
@@ -1,180 +0,0 @@
/**
* DurationWheelPicker —— 竖屏滚轮式时长选择器(弹层版)
*
* 设计:
* - 外观是和其他表单 Select 一致的输入框(白色底+1px灰边+紫色focus ring)
* - 点击输入框弹出 Popover,内部是滚轮 picker(原生 scroll-snap,零依赖)
* - 滚轮样式:白底容器,选中行 #7c3aed 紫字加粗+浅紫背景条
* - 支持触摸/鼠标滚轮/点击;松手吸附;底部"确认/取消"按钮
* - 默认范围 15–30 秒,步长 1 秒
*/
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
import { Popover, Button } from "antd"
import { DownOutlined } from "@ant-design/icons"
import "./DurationWheelPicker.css"
export interface DurationWheelPickerProps {
value?: number
min?: number
max?: number
step?: number
unit?: string
onChange?: (value: number) => void
placeholder?: string
disabled?: boolean
/** 弹层宽度,默认 160px */
popupWidth?: number
/** 弹层内滚轮高度,默认 180px */
wheelHeight?: number
}
const ITEM_HEIGHT = 36
const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
value = 20,
min = 15,
max = 30,
step = 1,
unit = "秒",
onChange,
placeholder = "请选择时长",
disabled = false,
popupWidth = 160,
wheelHeight = 180,
}) => {
const options = useMemo(() => {
const arr: number[] = []
for (let v = min; v <= max; v += step) arr.push(v)
return arr
}, [min, max, step])
const [open, setOpen] = useState(false)
// 弹层内暂存值,点确认才提交
const [draft, setDraft] = useState<number>(value)
const listRef = useRef<HTMLUListElement>(null)
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (open) {
setDraft(value)
// 下一帧滚到当前值
requestAnimationFrame(() => scrollToValue(value, false))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const scrollToValue = useCallback(
(v: number, smooth = true) => {
const list = listRef.current
if (!list) return
const idx = options.indexOf(v)
if (idx < 0) return
list.scrollTo({ top: idx * ITEM_HEIGHT, behavior: smooth ? "smooth" : "auto" })
},
[options],
)
const handleScroll = () => {
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = setTimeout(() => {
const list = listRef.current
if (!list) return
const idx = Math.round(list.scrollTop / ITEM_HEIGHT)
const clamped = Math.max(0, Math.min(options.length - 1, idx))
const targetTop = clamped * ITEM_HEIGHT
if (Math.abs(list.scrollTop - targetTop) > 1) {
list.scrollTo({ top: targetTop, behavior: "smooth" })
}
setDraft(options[clamped])
}, 100)
}
const handleConfirm = () => {
onChange?.(draft)
setOpen(false)
}
const handleCancel = () => {
setOpen(false)
}
const handleItemClick = (v: number) => {
setDraft(v)
scrollToValue(v, true)
}
const maskTop = wheelHeight / 2 - ITEM_HEIGHT / 2
const wheel = (
<div className="dw-popup">
<div
className="dw-picker"
style={{ height: wheelHeight, width: popupWidth - 24 /* padding */ }}
>
<div className="dw-picker-mask" style={{ top: maskTop, height: ITEM_HEIGHT }} aria-hidden />
<div className="dw-picker-fade dw-picker-fade-top" aria-hidden />
<div className="dw-picker-fade dw-picker-fade-bottom" aria-hidden />
<ul
ref={listRef}
className="dw-picker-list"
onScroll={handleScroll}
style={{
paddingTop: wheelHeight / 2 - ITEM_HEIGHT / 2,
paddingBottom: wheelHeight / 2 - ITEM_HEIGHT / 2,
}}
>
{options.map((v) => {
const isActive = v === draft
return (
<li
key={v}
className={`dw-picker-item${isActive ? " dw-picker-item-active" : ""}`}
style={{ height: ITEM_HEIGHT, lineHeight: `${ITEM_HEIGHT}px` }}
onClick={() => handleItemClick(v)}
aria-selected={isActive}
role="option"
>
<span className="dw-picker-item-val">{v}</span>
<span className="dw-picker-item-unit">{unit}</span>
</li>
)
})}
</ul>
</div>
<div className="dw-popup-actions">
<Button size="small" onClick={handleCancel}>
取消
</Button>
<Button size="small" type="primary" onClick={handleConfirm}>
确认
</Button>
</div>
</div>
)
return (
<Popover
open={!disabled && open}
onOpenChange={(v) => setOpen(v)}
content={wheel}
trigger="click"
placement="bottomLeft"
overlayClassName="dw-popover"
overlayStyle={{ padding: 0 }}
overlayInnerStyle={{ padding: 0, borderRadius: 10 }}
destroyTooltipOnHide
>
<div
className={`dw-trigger${disabled ? " dw-trigger-disabled" : ""}${open ? " dw-trigger-open" : ""}`}
style={{ height: 36 }}
>
<span className={`dw-trigger-val${value != null ? "" : " dw-trigger-placeholder"}`}>
{value != null ? `${value}${unit}` : placeholder}
</span>
<DownOutlined className={`dw-trigger-arrow${open ? " dw-trigger-arrow-up" : ""}`} />
</div>
</Popover>
)
}
export default DurationWheelPicker
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
import React from "react"
import type { CoverMode } from "./types"
interface CoverModeSelectorProps {
mode: CoverMode
onModeChange: (mode: CoverMode) => void
modeLabels: Record<CoverMode, string>
modeIcons: Record<CoverMode, string>
}
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
mode,
onModeChange,
modeLabels,
modeIcons,
}) => {
const modes: CoverMode[] = ["auto", "frame", "upload"]
return (
<div className="xx-cover-mode-tabs">
{modes.map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
onClick={() => onModeChange(m)}
>
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
</button>
))}
</div>
)
}
@@ -0,0 +1,315 @@
/**
* 封面选择器 — 公共主组件
*
* 提供三种封面来源(自动生成 / 封面模板 / 本地上传)+ 9:16 预览;
* 供智能剪辑、AI数字人及未来新功能统一调用。
*
* 不耦合任何业务 state,回调只传 URL/templateId 等通用字段。
*/
import React, { useCallback, useEffect, useState } from "react"
import { Modal as AntModal, Spin } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import Button from "@/components/ui/Button"
import Modal from "@/components/ui/Modal"
import { useSharedCover } from "./useSharedCover"
import CoverSettingsModal from "./CoverSettingsModal"
import CoverEditorModal from "./CoverEditorModal"
import { uploadCoverWithPreview } from "./uploadCover"
import "./cover.css"
export interface CoverSelectorProps {
/** 当前封面 URL(预览显示) */
value?: string
/** 当前选中的模板 ID(受控) */
templateId?: string | null
/** 选中模板变化回调 */
onTemplateChange?: (templateId: string, templateName: string) => void
/** 选择封面 URL 变化回调(自动生成/上传都会触发) */
onChange?: (
coverUrl: string,
source: "auto" | "upload" | "template",
extra?: Record<string, unknown>,
) => void
/** 自动生成封面:父组件负责调用后端,返回封面 URL;不传则隐藏自动生成按钮 */
onAutoGenerate?: (templateId: string) => Promise<string | null | undefined>
/** 是否可以自动生成(视频就绪等条件) */
canGenerate?: boolean
/** 不可生成时提示文案 */
disabledHint?: string
/** 功能开关 */
showAutoGenerate?: boolean
showTemplate?: boolean
showUpload?: boolean
/** 封面比例,默认 "9 / 16" */
aspectRatio?: string
/** 弹窗模式(AI数字人)/ 内联模式(智能剪辑 Step6) */
mode?: "inline" | "modal"
/** modal 模式下的弹窗控制 */
open?: boolean
onClose?: () => void
title?: string
/** 预览区上方的提示条内容(可传 ReactNode) */
hint?: React.ReactNode
/** 预览区宽度(inline 模式默认 220,modal 模式 180) */
previewWidth?: number
/** 自定义 class */
className?: string
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
value,
templateId,
onTemplateChange,
onChange,
onAutoGenerate,
canGenerate = true,
disabledHint,
showAutoGenerate = true,
showTemplate = true,
showUpload = true,
aspectRatio = "9 / 16",
mode = "inline",
open = false,
onClose,
title = "选择封面",
hint,
previewWidth,
className,
}) => {
const [uploading, setUploading] = useState(false)
const generateFn = useCallback(
async (tplId: string): Promise<string | null> => {
if (!onAutoGenerate) return null
const url = await onAutoGenerate(tplId)
if (url) onChange?.(url, "auto", { templateId: tplId })
return url ?? null
},
[onAutoGenerate, onChange],
)
const shared = useSharedCover({
canGenerate: canGenerate && showAutoGenerate && !!onAutoGenerate,
disabledHint,
initialTemplateId: templateId || "default",
generateFn,
})
// 受控 templateId 同步
useEffect(() => {
if (templateId && templateId !== shared.selectedTemplateId) {
shared.handleSelectTemplate(templateId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateId])
// 模板选中 → 通知父层
useEffect(() => {
if (shared.selectedTemplateId && shared.selectedTemplateId !== "default") {
onTemplateChange?.(shared.selectedTemplateId, shared.selectedTemplateName)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shared.selectedTemplateId])
// 上传文件处理
useEffect(() => {
shared.setOnUploadFile(async (file: File) => {
setUploading(true)
try {
const { previewUrl, finalUrl } = await uploadCoverWithPreview(file, {
onPreview: (blobUrl) => onChange?.(blobUrl, "upload"),
onUploaded: (url) => onChange?.(url, "upload"),
})
return finalUrl || previewUrl
} catch {
return null
} finally {
setUploading(false)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onChange])
const previewUrl = value || ""
const _uploadInput = (
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
)
void _uploadInput
const actionButtons = (fullWidth = false) => (
<>
{showAutoGenerate && (
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!canGenerate || shared.generating}
loading={shared.generating}
style={fullWidth ? { width: "100%" } : undefined}
>
✨ 自动生成封面
</Button>
)}
{showTemplate && (
<Button
buttonType="ghost"
onClick={() => shared.setShowCoverSettings(true)}
style={fullWidth ? { width: "100%" } : undefined}
>
⚙️ 封面模板
{shared.selectedTemplateId && shared.selectedTemplateId !== "default"
? `:${shared.selectedTemplateName}`
: ""}
</Button>
)}
{showUpload && (
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={uploading}
loading={uploading}
style={fullWidth ? { width: "100%" } : undefined}
>
📷 本地上传
</Button>
)}
</>
)
const hintNode = hint ? <div className="cs-hint">{hint}</div> : null
const previewBox = (w?: number) => (
<div
className={`cs-preview-box${previewUrl ? " has-image" : ""}`}
style={{
width: w ?? (mode === "modal" ? 180 : 220),
aspectRatio,
...(mode === "modal" ? { margin: "0 auto" } : {}),
}}
>
{previewUrl ? (
<img src={previewUrl} alt="封面预览" className="cs-preview-img" />
) : (
<div className="cs-preview-placeholder">
<span style={{ fontSize: 28 }}>🖼️</span>
<span>{canGenerate ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}</span>
</div>
)}
<span className="cs-preview-ratio">{aspectRatio.replace(/\s/g, "")}</span>
{(shared.generating || uploading) && (
<div className="cs-preview-loading">
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>{uploading ? "上传中…" : "AI 选帧中…"}</span>
</div>
)}
</div>
)
const body =
mode === "modal" ? (
<div style={{ padding: "8px 0" }} className={className}>
{hintNode}
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
<div style={{ width: 180, flexShrink: 0 }}>
{previewBox(180)}
<div style={{ marginTop: 6, textAlign: "center", fontSize: 11, color: "#8c8ca1" }}>
{aspectRatio.replace(/\s/g, "")} 竖版封面
</div>
</div>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
{actionButtons(true)}
<div className="cs-help-tip">
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
</div>
</div>
</div>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
</div>
) : (
<div className={className}>
{hintNode}
<div className="cs-actions">{actionButtons(false)}</div>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<div className="cs-section-title">封面预览</div>
{previewBox(previewWidth)}
</div>
)
return (
<>
{mode === "modal" ? (
<Modal
open={open}
onCancel={onClose}
title={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>
}
>
{body}
</Modal>
) : (
body
)}
<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}
/>
<AntModal
open={shared.generating && mode === "inline"}
closable={false}
footer={null}
centered
>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</AntModal>
</>
)
}
export default CoverSelector
@@ -0,0 +1,195 @@
import React, { useMemo, useState } from "react"
import type { CoverTemplate } from "./types"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import "@/components/cover/cover.css"
interface CoverSettingsModalProps {
open: boolean
onClose: () => void
templates: CoverTemplate[]
loading?: boolean
error?: string | null
selectedTemplateId: string
onSelectTemplate: (id: string) => void
onEditTemplate: (template: CoverTemplate) => void
onDeleteTemplate: (id: string) => void
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 CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
open,
onClose,
templates,
loading = false,
error = null,
selectedTemplateId,
onSelectTemplate,
onEditTemplate,
onDeleteTemplate,
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>
}
>
<div className="xx-cover-modal-toolbar">
<Button buttonType="primary" onClick={onCreateNew}>
+ 创建新模板
</Button>
</div>
{loading && (
<div style={{ textAlign: "center", padding: "40px 0", color: "var(--text-secondary)" }}>
加载中...
</div>
)}
{error && !loading && (
<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 && (
<div className="xx-cover-template-grid">
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id
return (
<div
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
<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()}>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onEditTemplate(tpl)}
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
>
编辑
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</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>
)
}
export default CoverSettingsModal
+58
View File
@@ -0,0 +1,58 @@
import React from "react"
interface FrameCoverPickerProps {
frameTime: number
totalDuration: number
formatTime: (seconds: number) => string
onFrameTimeChange: (time: number) => void
}
export const FrameCoverPicker: React.FC<FrameCoverPickerProps> = ({
frameTime,
totalDuration,
formatTime,
onFrameTimeChange,
}) => {
const quickRatios = [0, 0.25, 0.5, 0.75]
return (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞️</span>
<span className="xx-cover-frame-time">{formatTime(frameTime)}</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span>拖动选择封面帧</span>
<span className="xx-cover-frame-value">{formatTime(frameTime)}</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={frameTime}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label">快捷选帧:</span>
{quickRatios.map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="xx-cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import React from "react"
interface UploadCoverPickerProps {
uploadUrl: string
onUpload: (file: File) => void
}
export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl, onUpload }) => {
const handleClick = () => {
const input = document.getElementById("cover-upload-input")
input?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
onUpload(file)
}
}
return (
<div className="xx-cover-upload">
<div className="xx-cover-upload-area" onClick={handleClick}>
{uploadUrl ? (
<div className="xx-cover-upload-preview">
<img src={uploadUrl} alt="封面预览" />
<div className="xx-cover-upload-overlay">点击更换</div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text">点击上传封面图片</span>
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 9:16 比例</span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
}
+87
View File
@@ -561,3 +561,90 @@
z-index: 0;
background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%);
}
/* ── CoverSelector 公共组件样式 ── */
.cs-preview-box {
position: relative;
width: 220px;
aspect-ratio: 9 / 16;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%);
border: 1px dashed #d9d9d9;
}
.cs-preview-box.has-image {
border: none;
}
.cs-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cs-preview-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
gap: 6px;
opacity: 0.7;
}
.cs-preview-ratio {
position: absolute;
right: 6px;
bottom: 6px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
}
.cs-preview-loading {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
flex-direction: column;
gap: 8px;
}
.cs-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.cs-actions .btn-block {
width: 100%;
}
.cs-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1f2937);
margin: 12px 0 8px;
}
.cs-help-tip {
font-size: 11px;
color: #8c8ca1;
line-height: 1.5;
margin-top: 4px;
padding: 6px 8px;
background: #f7f8fa;
border-radius: 6px;
}
.cs-hint {
padding: 10px 14px;
background: rgba(16, 185, 129, 0.08);
border-radius: 8px;
margin-bottom: 16px;
border: 1px solid rgba(16, 185, 129, 0.15);
font-size: 13px;
color: var(--text-secondary, #666);
}
+15
View File
@@ -1,2 +1,17 @@
/**
* 公共封面选择/编辑组件统一导出
*
* 任何页面需要选封面、封面模板选择、封面编辑器、本地上传封面,从这里 import:
* import { CoverSelector, useSharedCover, ... } from "@/components/cover"
*/
export { default as CoverSelector } from "./CoverSelector"
export { default as CoverSettingsModal } from "./CoverSettingsModal"
export { default as CoverEditorModal } from "./CoverEditorModal"
export { CoverModeSelector } from "./CoverModeSelector"
export { FrameCoverPicker } from "./FrameCoverPicker"
export { UploadCoverPicker } from "./UploadCoverPicker"
export { useSharedCover } from "./useSharedCover"
export { uploadCoverToOSS, uploadCoverWithPreview } from "./uploadCover"
export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover"
export * from "./types"
import "./cover.css"
+318
View File
@@ -0,0 +1,318 @@
/**
* 封面模板/编辑器公共类型
*
* 抽离自 pages/generate/types/cover.ts,供智能剪辑/AI数字人/未来新功能共享。
* 不含页面业务状态类型(CoverConfig/CoverMode/DEFAULT_COVER_CONFIG 留在 pages 侧)。
*/
/** 封面来源模式 */
export type CoverMode = "auto" | "frame" | "upload"
/** 封面配置(业务状态,抽离自 generate 模块供多页面共享) */
export interface CoverConfig {
/** 是否启用自定义封面 */
enabled: boolean
/** 封面来源模式 */
mode: CoverMode
/** 抽帧时间点(秒,mode=frame 时使用) */
frame_time: number
/** 上传的封面 URL(mode=upload 时使用) */
upload_url: string
/** AI 智能推荐的抽帧时间(由后端分析得出) */
ai_suggested_time: number | null
/** 封面缩略图 URL */
thumbnail_url: string
}
/** 默认封面配置 */
export const DEFAULT_COVER_CONFIG: CoverConfig = {
enabled: false,
mode: "auto",
frame_time: 0,
upload_url: "",
ai_suggested_time: null,
thumbnail_url: "",
}
/** 文字方向 */
export type TextDirection = "horizontal" | "vertical"
/** 文字背景形状 */
export type TextBgShape = "rectangle" | "polygon"
/** 描边样式 */
export type StrokeStyle = "solid" | "dashed"
/** 阴影层 */
export interface ShadowLayer {
color: string
offsetX: number
offsetY: number
blur: number
}
/** 文字位置 */
export interface TextPosition {
x: number
y: number
}
/** 文字背景配置 */
export interface TextBackground {
enabled: boolean
color: string
opacity: number
shape: TextBgShape
width: number
height: number
/** 相对文字的上下偏移(百分比),背景自动跟随文字位置 */
offsetY: number
}
/** 文字样式配置(主标题/副标题共用) */
export interface TextStyleConfig {
text: string
fontFamily: string
fontSize: number
fontWeight: number
direction: TextDirection
charsPerLine: number
letterSpacing: number
lineHeight: number
color: string
strokeColor: string
strokeWidth: number
shadows: ShadowLayer[]
traditionalShadow: boolean
position: TextPosition
rotation: number
background: TextBackground
}
/** 编辑器完整配置 */
export interface CoverEditorConfig {
// 基础设置
blurEnabled: boolean
blurAmount: number
personStrokeEnabled: boolean
personStrokeStyle: StrokeStyle
personStrokeColor: string
personStrokeWidth: number
autoSplitEnabled: boolean
titleMaxChars: number
subtitleMaxChars: number
// 人像设置
portraitEnabled: boolean
portraitSize: number
portraitPosition: TextPosition
portraitImage?: string
// 背景设置
backgroundEnabled: boolean
backgroundSize: number
backgroundPosition: TextPosition
backgroundImage?: string
backgroundColor?: string
// 主标题
title: TextStyleConfig
// 副标题
subtitle: TextStyleConfig
// 蒙版
maskEnabled: boolean
maskImage: string
maskSize: number
maskPosition: TextPosition
maskColor: string
maskOpacity: number
maskShape: string
}
/** 默认主标题配置 */
export const DEFAULT_TITLE_CONFIG: TextStyleConfig = {
text: "主标题文字",
fontFamily: "思源黑体",
fontSize: 120,
fontWeight: 700,
direction: "horizontal",
charsPerLine: 10,
letterSpacing: 24,
lineHeight: 144,
color: "#FFD700",
strokeColor: "#000000",
strokeWidth: 3,
shadows: [],
traditionalShadow: false,
position: { x: 50, y: 30 },
rotation: 0,
background: {
enabled: false,
color: "#FFFFFF",
opacity: 25,
shape: "polygon",
width: 30,
height: 10,
offsetY: 0,
},
}
/** 默认副标题配置 */
export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = {
text: "副标题文字",
fontFamily: "思源黑体",
fontSize: 82,
fontWeight: 500,
direction: "horizontal",
charsPerLine: 17,
letterSpacing: 23,
lineHeight: 72,
color: "#FFFFFF",
strokeColor: "#000000",
strokeWidth: 1,
shadows: [],
traditionalShadow: false,
position: { x: 50, y: 70 },
rotation: 0,
background: {
enabled: true,
color: "#000000",
opacity: 70,
shape: "rectangle",
width: 100,
height: 20,
offsetY: 8,
},
}
/** 默认编辑器配置 */
export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = {
blurEnabled: false,
blurAmount: 10,
personStrokeEnabled: false,
personStrokeStyle: "solid",
personStrokeColor: "#FFFFFF",
personStrokeWidth: 8,
autoSplitEnabled: false,
titleMaxChars: 4,
subtitleMaxChars: 10,
portraitEnabled: false,
portraitSize: 50,
portraitPosition: { x: 50, y: 70 },
backgroundEnabled: true,
backgroundSize: 100,
backgroundPosition: { x: 50, y: 50 },
title: DEFAULT_TITLE_CONFIG,
subtitle: DEFAULT_SUBTITLE_CONFIG,
maskEnabled: false,
maskImage: "",
maskSize: 100,
maskPosition: { x: 50, y: 50 },
maskColor: "#000000",
maskOpacity: 40,
maskShape: "矩形",
}
/** 预置字体(已与 @/components/title/constants 字体表保持一致;自定义商业字体兜底 Google Fonts 开源中文字体) */
// 封面编辑器预置字体:与标题样式字体列表保持一致(从 @/components/title/constants 同步),
// 并补全西文常用系统字体,保证在中英文环境下都有可用字体。
// 注:需要配合 index.html 引入的 Google Fonts(Noto Sans SC / ZCOOL / Ma Shan Zheng 等)。
export interface CoverFont {
name: string
family: string
tag?: "preset" | "hand" | "serif" | "sans" | "mono"
}
/** 预置中文字体(爆款/常用) */
export const PRESET_FONTS: CoverFont[] = [
{
name: "优设标题黑",
family:
'"YouSheBiaoTiHei","ZCOOL QingKe HuangYou","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "阿里普惠体Bold",
family:
'"Alibaba PuHuiTi","Alibaba Sans","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "抖音美好体",
family:
'"Douyin Sans","ZCOOL KuaiLe","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "思源黑体Heavy",
family: '"Noto Sans SC","Source Han Sans SC Heavy","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "思源黑体",
family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "思源宋体",
family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif',
tag: "serif",
},
{ name: "站酷小薇体", family: '"ZCOOL XiaoWei","Noto Serif SC",serif', tag: "preset" },
{ name: "马善政毛笔", family: '"Ma Shan Zheng","STXingkai","KaiTi",cursive', tag: "hand" },
{ name: "龙藏体", family: '"Long Cang","STXingkai",cursive', tag: "hand" },
{ name: "楷体", family: '"KaiTi","STKaiti","DFKai-SB",serif', tag: "serif" },
{
name: "苹方",
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
tag: "sans",
},
{
name: "微软雅黑",
family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
tag: "sans",
},
]
/** 系统字体(西文 + 通用中文) */
export const SYSTEM_FONTS: CoverFont[] = [
{ name: "Arial", family: "Arial, Helvetica, sans-serif", tag: "sans" },
{ name: "Helvetica", family: "Helvetica, Arial, sans-serif", tag: "sans" },
{ name: "Times New Roman", family: '"Times New Roman", Times, serif', tag: "serif" },
{ name: "Georgia", family: "Georgia, serif", tag: "serif" },
{ name: "Verdana", family: "Verdana, Geneva, sans-serif", tag: "sans" },
{ name: "Tahoma", family: "Tahoma, Geneva, sans-serif", tag: "sans" },
{ name: "Impact", family: 'Impact, "Arial Black", sans-serif', tag: "sans" },
{ name: "Comic Sans MS", family: '"Comic Sans MS", cursive', tag: "hand" },
{ name: "Courier New", family: '"Courier New", Courier, monospace', tag: "mono" },
{ name: "宋体", family: "SimSun, 'Noto Serif SC', serif", tag: "serif" },
{ name: "黑体", family: "SimHei, 'Noto Sans SC', sans-serif", tag: "sans" },
{ name: "仿宋", family: "FangSong, 'Noto Serif SC', serif", tag: "serif" },
{ name: "Trebuchet MS", family: '"Trebuchet MS", sans-serif', tag: "sans" },
{ name: "Lucida Console", family: '"Lucida Console", Monaco, monospace', tag: "mono" },
{ name: "Palatino", family: 'Palatino, "Palatino Linotype", serif', tag: "serif" },
{ name: "Garamond", family: "Garamond, serif", tag: "serif" },
{ name: "Calibri", family: "Calibri, sans-serif", tag: "sans" },
{ name: "Cambria", family: "Cambria, serif", tag: "serif" },
{ name: "Candara", family: "Candara, sans-serif", tag: "sans" },
{ name: "Consolas", family: "Consolas, monospace", tag: "mono" },
]
/** 所有字体列表 */
export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS]
/** 封面模板 */
export interface CoverTemplate {
id: string
name: string
thumbnail_url: string
is_system: boolean
created_at: string
config?: CoverEditorConfig
}
@@ -0,0 +1,62 @@
/**
* 封面本地上传 — OSS 直传公共工具(PR#2061 修复)
*
* 选完文件后:
* 1. 立即返回 blob URL 供调用方即时预览
* 2. 异步上传到素材库 OSS,拿到真实 URL 后回调 onUploaded(url)
* 3. 失败时调用 onError,blob URL 仍保留作为兜底
*/
import { message } from "antd"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
export interface UploadCoverResult {
/** blob: 预览 URL(立即返回) */
previewUrl: string
/** 真实 OSS URL(上传完成后) */
finalUrl?: string
}
export interface UploadCoverCallbacks {
onPreview?: (previewUrl: string) => void
onUploaded?: (finalUrl: string) => void
onError?: (err: unknown) => void
}
/** 把本地封面图片上传到素材库(图片库)OSS,返回 Promise<最终URL> */
export async function uploadCoverToOSS(file: File): Promise<string> {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
throw new Error("未找到图片素材库")
}
const result = await uploadAssetDirect({ file, library_id: imageLib.id })
const url = result?.url || ""
if (!url) throw new Error("上传完成但未获取到URL")
return url
}
/**
* 一站式上传:立即创建 blob URL 预览,异步上传 OSS;返回 Promise<{previewUrl, finalUrl}>
* 供封面选择器复用。
*/
export async function uploadCoverWithPreview(
file: File,
callbacks: UploadCoverCallbacks = {},
): Promise<UploadCoverResult> {
const hide = message.loading("正在上传封面...", 0)
try {
const previewUrl = URL.createObjectURL(file)
callbacks.onPreview?.(previewUrl)
const finalUrl = await uploadCoverToOSS(file)
hide()
callbacks.onUploaded?.(finalUrl)
message.success("封面上传成功")
return { previewUrl, finalUrl }
} catch (err) {
hide()
console.error("[Cover] 封面上传失败:", err)
callbacks.onError?.(err)
message.error("封面上传失败,请重试")
throw err
}
}
@@ -10,7 +10,7 @@
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 type { CoverTemplate } from "./types"
import {
fetchCoverTemplates,
createCoverTemplate,
@@ -2,12 +2,16 @@
export const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/dashboard": "首页",
"/app/generate": "智能剪辑",
"/app/viral-video": "爆款视频",
"/app/assets": "视频库",
"/app/voices": "配音库",
"/app/products": "成片库",
"/app/templates": "模板库",
"/app/history": "任务历史",
"/app/admin": "控制台",
"/app/admin/users": "用户管理",
"/app/admin/analytics": "数据分析",
"/app/admin/monitor": "系统监控",
"/app/admin/logs": "系统日志",
"/app/subscription": "订阅管理",
"/app/subscription/upgrade": "升级订阅",
"/app/subscription/billing": "账单管理",
+1 -1
View File
@@ -10,4 +10,4 @@
* 功能流程不做积分预校验,直接走生成。
* - true:展示完整积分系统 UI。
*/
export const ENABLE_CREDIT_SYSTEM = true
export const ENABLE_CREDIT_SYSTEM = false
+7 -13
View File
@@ -13,11 +13,11 @@ import {
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
ControlOutlined,
CrownOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
UserOutlined,
FireOutlined,
} from "@ant-design/icons"
/** 导航项类型 */
@@ -76,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: "任务历史",
@@ -148,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),
},
],
},
{
@@ -206,6 +194,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/duplication",
icon: React.createElement(ScanOutlined),
},
{
key: "admin",
label: "控制台",
path: "/app/admin",
icon: React.createElement(ControlOutlined),
},
{
key: "subscription",
label: "会员订阅",
+27
View File
@@ -0,0 +1,27 @@
/* Admin 页面样式(Phase 3 精简)
*
* 原始 477 行 → 精简至仅保留实际使用的 class。
* 已迁移至 global.css / ui.css 的样式不再重复定义:
* .xx-page-head → global.css
* .xx-primary-btn → global.css
* .xx-tag / .xx-card → ui.css / global.css
*
* 以下 class 仅被 AdminComingSoon.tsx 使用。
*/
.admin-coming-soon-page {
padding: 32px;
max-width: 1680px;
margin: 0 auto;
}
.xx-result-center {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.xx-result-center .ant-result {
padding: 48px;
}
@@ -0,0 +1,35 @@
import React from "react"
import { Button, Card, Result } from "antd"
import { useNavigate } from "react-router-dom"
import "./Admin.css"
const AdminComingSoon: React.FC = () => {
const navigate = useNavigate()
return (
<div className="admin-coming-soon-page">
<div className="xx-result-center">
<Card className="xx-card">
<Result
status="info"
title="Admin 后台暂未开放"
subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。"
extra={[
<Button
type="primary"
onClick={() => navigate("/app/dashboard")}
className="xx-primary-btn"
>
返回首页
</Button>,
]}
/>
</Card>
</div>
</div>
)
}
export default AdminComingSoon
export const Component = AdminComingSoon
+16 -22
View File
@@ -80,8 +80,6 @@ const AiAvatarPage: React.FC = () => {
const [finalizeLoading, setFinalizeLoading] = useState(false)
/* ── 对口型轮询 ── */
/** 对口型轮询总时长上限(10分钟):超过后停止轮询并提示去历史记录查看 */
const LIPSYNC_POLL_MAX_MS = 10 * 60 * 1000
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 渲染进度轮询 ── */
const renderTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -272,24 +270,7 @@ const AiAvatarPage: React.FC = () => {
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
// 但仍需轮询等 completed
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
// 轮询间隔 5 秒;单请求超时 5 分钟(见 api/aiAvatar.ts);总轮询上限 10 分钟
// 单次请求失败/超时不中断轮询,继续下一轮;超过总上限后停止并提示用户去历史记录查看
lipsyncTimerRef.current = setInterval(async () => {
// 总时长保护:超过 10 分钟停止轮询
if (Date.now() - lipsyncStartAtRef.current > LIPSYNC_POLL_MAX_MS) {
if (lipsyncTimerRef.current) {
clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = null
}
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setLipsyncStatus("failed")
setLipsyncErrorMessage("渲染时间较长,请稍后在历史记录中查看")
message.warning("对口型渲染时间较长,已停止自动刷新,请稍后在历史记录中查看")
return
}
try {
const updated = await getLipsyncJob(job.id)
state.setLipsyncJob(updated)
@@ -315,10 +296,9 @@ const AiAvatarPage: React.FC = () => {
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
}
} catch (err) {
// 单次轮询失败(含 timeout):不中断轮询,打印日志后等下一轮
console.warn("[对口型] 轮询请求失败,将继续下一轮:", err)
console.error("[对口型] 轮询错误:", err)
}
}, 5000)
}, 3000)
} catch (err) {
console.error("[对口型] 创建失败:", {
status: (err as { response?: { status?: number } })?.response?.status,
@@ -600,6 +580,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 && (
+2 -6
View File
@@ -72,8 +72,7 @@ export const previewTts = async (data: {
}
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
// MuseTalk 渲染 8s 视频约 54s + 排队时间,给足 5 分钟超时避免单次轮询 AxiosError 中断
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 300_000 })
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
return response.data
}
@@ -92,10 +91,7 @@ export const submitRender = async (data: {
}
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
// 渲染链路(对口型+B-roll+标题+合成+上传)耗时较长,给足 5 分钟超时
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, {
timeout: 300_000,
})
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
return response.data
}
@@ -1,25 +1,15 @@
/**
* AI数字人 — 封面选择弹窗(#2033 共享封面组件重构)
* AI数字人 — 封面选择弹窗(重构为使用公共 CoverSelector)
*
* 复用智能剪辑的 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
* 复用 @/components/cover/CoverSelector(自动生成/封面模板/本地上传+9:16预览),
* 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构。
*
* 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。
*/
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, { useCallback } from "react"
import { CoverSelector } from "@/components/cover"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar"
interface ModalCoverSelectProps {
open: boolean
@@ -27,10 +17,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 }>
@@ -45,46 +32,33 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
coverConfig,
onCoverConfigChange,
onGenerateRenderSmartCover,
onUploadCover,
onCoverSelected,
}) => {
const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id
const generateFn = useCallback(
const handleAutoGenerate = 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
}
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) {
throw new Error(res.message)
}
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
}
if (coverUrl) {
onCoverConfigChange({
mode: "auto_frame",
thumbnail_url: coverUrl,
smart_cover_url: coverUrl,
})
onCoverSelected(coverUrl)
}
return coverUrl || null
},
[
renderJob,
@@ -95,20 +69,9 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
],
)
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)
const handleChange = useCallback(
(url: string, source: "auto" | "upload" | "template") => {
if (source === "upload") {
onCoverConfigChange({
mode: "upload",
upload_url: url,
@@ -116,234 +79,30 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
})
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],
// auto 已在 handleAutoGenerate 内更新
},
[onCoverConfigChange, onCoverSelected],
)
if (!open) return null
const previewUrl =
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || ""
return (
<Modal
<CoverSelector
mode="modal"
open={open}
onCancel={onClose}
onClose={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>
}
>
<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>
</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>
value={previewUrl}
onChange={handleChange}
onAutoGenerate={isRenderCompleted ? handleAutoGenerate : undefined}
canGenerate={isRenderCompleted}
disabledHint="请先完成视频生成再选择封面"
showAutoGenerate
showTemplate
showUpload
hint={renderJob ? <>🎬 从渲染成片中智能选帧</> : undefined}
/>
)
}
+12 -14
View File
@@ -13,6 +13,8 @@ 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 GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
@@ -38,8 +40,6 @@ const GeneratePage: React.FC = () => {
/* ── 积分状态 ── */
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
// 后端积分总开关(由 /points/balance 返回;数据未就绪时保守按 UI 开关处理)
const creditsEnabled = balance?.credits_enabled ?? ENABLE_CREDIT_SYSTEM
useEffect(() => {
initPoints()
}, [initPoints])
@@ -88,6 +88,7 @@ const GeneratePage: React.FC = () => {
style,
autoSubtitles,
bgm,
editPlanId,
sourceEditPlanId,
previewTaskId,
setPreviewTaskId,
@@ -238,14 +239,9 @@ 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)
}
},
})
@@ -344,8 +340,7 @@ const GeneratePage: React.FC = () => {
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
// 仅当后端积分系统真实启用时才做余额预校验(后端免费放行时前端不得拦截)
if (creditsEnabled) {
if (ENABLE_CREDIT_SYSTEM) {
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
check = hasEnoughPoints(
balance ?? null,
@@ -391,7 +386,6 @@ const GeneratePage: React.FC = () => {
balance,
dailyUsage,
rules,
creditsEnabled,
])
/* ── 步骤导航 ── */
@@ -506,7 +500,7 @@ const GeneratePage: React.FC = () => {
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(() => {
if (!creditsEnabled) return { sufficient: true, cost: 0 }
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
return hasEnoughPoints(
balance ?? null,
unitsForCost,
@@ -515,8 +509,8 @@ const GeneratePage: React.FC = () => {
"free",
rules?.free_user_multiplier ?? 1.15,
)
}, [unitsForCost, balance, dailyUsage, rules, creditsEnabled])
const insufficientPoints = creditsEnabled && !pointsEstimate.sufficient
}, [unitsForCost, balance, dailyUsage, rules])
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
/* ================================================================
渲染
@@ -524,6 +518,10 @@ 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 封面 ════ */}
<div className="xx-generate-form">
@@ -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"
@@ -66,11 +61,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,21 +85,6 @@ 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 && (
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
@@ -12,7 +12,6 @@ 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"
@@ -152,12 +151,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedCoverTemplate,
onSelectedCoverTemplateChange,
onConfirmGenerate,
selectedVoice,
onSelectedVoiceChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
} = props
switch (currentStep) {
@@ -191,46 +184,32 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
)
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}
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
}
/>
)
case 4:
return (
@@ -157,7 +157,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
/>
</div>
) : (
/* ── 批量:N 个独立标题输入框(两列布局 #2096) ── */
/* ── 批量:N 个独立标题输入框 ── */
<div className="xx-batch-titles">
<div
style={{
@@ -170,25 +170,17 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
</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>
))}
</div>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i} style={{ maxWidth: 640 }}>
<label>视频 {i + 1} 标题</label>
<TitleLibraryAutoComplete
placeholder={`输入或选择视频 ${i + 1} 的标题`}
value={previewTitles?.[i] || ""}
onChange={(val) => updateVariantTitle(i, val)}
options={titleOptions}
/>
</div>
))}
</div>
)}
@@ -1,23 +1,22 @@
/**
* Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传)
* Step 6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择)
*
* - 单视频:统一使用公共 CoverSelector(自动生成/封面模板/本地上传+9:16预览)
* - N 个视频:N 张封面卡片,每张带对应视频标题,支持统一选择封面模板、逐个自动生成或上传
*
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
* 公共组件:@/components/cover/CoverSelector(单视频 UI)+ useSharedCover(模板 CRUD)
* 批量逻辑(generateAll/批量上传)保留在本文件,使用 useBatchCovers hook。
*/
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { Modal, Spin, message } from "antd"
import React, { useCallback, useMemo, useState } from "react"
import { Spin } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { CoverConfig } from "../types/cover"
import type { CoverConfig } from "@/components/cover/types"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
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 { CoverSelector } from "@/components/cover"
import { generateCover as apiGenerateCover } from "@/api/generation"
import { uploadAssetDirect } from "@/api/assets"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
@@ -36,39 +35,43 @@ interface Step6CoverSettingsProps {
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const previewCount = props.previewCount || 1
const {
coverSettings,
onCoverSettingsChange,
selectedTemplate,
titleSettings,
generatedVideos,
previewCount: _pc,
previewTitles = [],
previewCovers = [],
onPreviewCoversChange,
selectedVariantIndexes,
onTemplateChange,
currentTaskId,
} = props
const previewCount = _pc || 1
const isBatch = previewCount > 1
const previewTitles = props.previewTitles || []
const previewCovers = props.previewCovers || []
const cardIndexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
isBatch && selectedVariantIndexes?.length
? selectedVariantIndexes
: Array.from({ length: previewCount }, (_, i) => i)
/** 最终成片:取第一个已完成视频(单视频场景) */
const finalVideo =
props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") ||
props.generatedVideos[0]
generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") ||
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 ||
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" ||
@@ -77,142 +80,118 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const canGenerateCover = !isBatch && (!!finalVideo || !!effectiveTaskId) && isVideoReady
const completedVideos = useMemo(
() =>
props.generatedVideos.filter(
(v) => v.status === "completed" || v.status === "awaiting_cover",
),
[props.generatedVideos],
() => generatedVideos.filter((v) => v.status === "completed" || v.status === "awaiting_cover"),
[generatedVideos],
)
/**
* 单视频自动生成(点击"自动生成封面"按钮):使用当前选中的模板
* 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发
*/
const shared = useSharedCover({
canGenerate: canGenerateCover,
disabledHint: isBatch
? "批量场景请在上方操作卡片"
: !finalVideo && !effectiveTaskId
? "请先生成视频再选择封面"
: "视频尚未就绪,请稍候",
initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default
generateFn: async (tplId) => {
/** 单视频自动生成:调用后端 /generation/generate-cover */
const handleAutoGenerate = useCallback(
async (tplId: string): Promise<string | null> => {
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) {
} = { cover_type: "ai_frame" }
if (effectiveVideoId) requestBody.generated_video_id = effectiveVideoId
if (effectiveVideoUrl) requestBody.video_url = effectiveVideoUrl
if (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,
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: 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,
onCoverSettingsChange({
...coverSettings,
thumbnail_url: url,
ai_suggested_time: response.cover?.frame_time ?? null,
})
} else {
console.warn("[Cover] generate returned empty url:", response)
}
return url
return url || null
},
[
isBatch,
finalVideo,
effectiveTaskId,
effectiveVideoId,
effectiveVideoUrl,
titleSettings,
coverSettings,
onCoverSettingsChange,
],
)
/** 本地上传回调(公共组件 uploadCoverWithPreview 已处理 blob 预览 + OSS 上传) */
const handleCoverChange = useCallback(
(url: string, source: "auto" | "upload" | "template") => {
if (source === "upload") {
onCoverSettingsChange({
...coverSettings,
upload_url: url,
thumbnail_url: url,
mode: "upload",
})
}
// auto 已在 handleAutoGenerate 内部更新 coverSettings
// template 切换模板不自动更新 coverSettings(等用户点"自动生成"才应用)
},
[coverSettings, onCoverSettingsChange],
)
const hintNode = useMemo(() => {
return <>🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧</>
}, [finalVideo?.name])
/** ── 批量封面(保留原逻辑) ── */
const [localSelectedTemplate, setLocalSelectedTemplate] = useState<string>(
selectedTemplate || "default",
)
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
const batchCovers = useBatchCovers({
selectedTemplate: localSelectedTemplate === "default" ? "" : localSelectedTemplate,
generatedVideos: generatedVideos,
titles: batchTitles,
titleStyle: {
font: titleSettings?.font || "思源黑体",
size: titleSettings?.size || 28,
color: titleSettings?.color || "#ffffff",
position: titleSettings?.position || "top",
bold: titleSettings?.bold ?? true,
stroke: titleSettings?.stroke ?? true,
shadow: titleSettings?.shadow ?? false,
},
covers: batchCoversList,
onCoversChange: (updater) => {
const prevCardView = cardIndexes.map((vi) => (previewCovers || [])[vi] || "")
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
const next = [...(previewCovers || [])]
cardIndexes.forEach((vi, cardPos) => {
next[vi] = nextCardView[cardPos] || ""
})
onPreviewCoversChange?.(next)
},
})
// 选中模板变化时通知父组件(用于批量生成时透传 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])
// 批量:模板选择状态与 CoverSelector 同步
// (批量使用 CoverSelector 的 showUpload/showAutoGenerate=false,仅展示模板选择)
const batchUploadRef = React.useRef<HTMLInputElement>(null)
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
const handleBatchUploadClick = (cardPos: number) => {
@@ -228,41 +207,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
void batchCovers.uploadOne(cardPos, file)
}
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
/**
* 批量生成:selectedTemplateId 来自用户在 CoverSettingsModal 中选择的模板,
* 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。
*/
const batchCovers = useBatchCovers({
selectedTemplate: shared.selectedTemplateId,
generatedVideos: props.generatedVideos,
titles: batchTitles,
titleStyle: {
font: props.titleSettings?.font || "思源黑体",
size: props.titleSettings?.size || 28,
color: props.titleSettings?.color || "#ffffff",
position: props.titleSettings?.position || "top",
bold: props.titleSettings?.bold ?? true,
stroke: props.titleSettings?.stroke ?? true,
shadow: props.titleSettings?.shadow ?? false,
},
covers: batchCoversList,
onCoversChange: (updater) => {
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
const next = [...(props.previewCovers || [])]
cardIndexes.forEach((vi, cardPos) => {
next[vi] = nextCardView[cardPos] || ""
})
props.onPreviewCoversChange?.(next)
},
})
const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url
/* ── 批量封面 ── */
if (isBatch) {
return (
<div className="xx-form-section">
@@ -280,9 +224,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
}}
>
🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
{localSelectedTemplate && localSelectedTemplate !== "default" && (
<>
{" · "}当前模板:<strong>{shared.selectedTemplateName}</strong>
{" · "}当前模板:<strong>{localSelectedTemplate}</strong>
</>
)}
</div>
@@ -297,17 +241,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
>
✨ 一键全部自动生成
</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">
{cardIndexes.map((variantIndex, cardPos) => {
const url = batchCoversList[cardPos]
@@ -377,7 +313,23 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
})}
</div>
{/* 隐藏的文件选择 input,批量上传复用 */}
{/* 批量场景下的模板选择:嵌入一个只开 showTemplate 的 CoverSelector */}
<div style={{ marginTop: 16 }}>
<CoverSelector
mode="inline"
templateId={localSelectedTemplate}
onTemplateChange={(id) => {
setLocalSelectedTemplate(id)
onTemplateChange?.(id)
}}
showAutoGenerate={false}
showUpload={false}
showTemplate
canGenerate={false}
previewWidth={0}
/>
</div>
<input
ref={batchUploadRef}
type="file"
@@ -385,135 +337,30 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
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}
/>
</div>
)
}
/* ── 单视频 ── */
/* ── 单视频:公共 CoverSelector ── */
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{(finalVideo || effectiveTaskId) && (
<div
style={{
padding: "10px 14px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(16, 185, 129, 0.15)",
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div className="xx-cover-actions">
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!canGenerateCover || shared.generating}
loading={shared.generating}
title={!canGenerateCover ? "请先完成视频生成" : ""}
>
✨ 自动生成封面
</Button>
<Button buttonType="ghost" onClick={() => shared.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>
<div className="xx-cover-preview-box">
{previewUrl ? (
<img src={previewUrl} alt="封面预览" className="xx-cover-preview-img" />
) : (
<div className="xx-cover-preview-placeholder">
<span style={{ fontSize: 28 }}>🖼️</span>
<span>点击"自动生成封面"或选择模板</span>
</div>
)}
<div className="xx-cover-preview-ratio">9:16</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}
<CoverSelector
mode="inline"
value={coverSettings.thumbnail_url || coverSettings.upload_url}
templateId={selectedTemplate || null}
onTemplateChange={(id) => onTemplateChange?.(id)}
onChange={handleCoverChange}
onAutoGenerate={handleAutoGenerate}
canGenerate={canGenerateCover}
disabledHint={
!finalVideo && !effectiveTaskId ? "请先生成视频再选择封面" : "视频尚未就绪,请稍候"
}
showAutoGenerate
showTemplate
showUpload
hint={hintNode}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
<Modal open={shared.generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</Modal>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,32 +1,2 @@
import React from "react"
import type { CoverMode } from "../../types/cover"
interface CoverModeSelectorProps {
mode: CoverMode
onModeChange: (mode: CoverMode) => void
modeLabels: Record<CoverMode, string>
modeIcons: Record<CoverMode, string>
}
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
mode,
onModeChange,
modeLabels,
modeIcons,
}) => {
const modes: CoverMode[] = ["auto", "frame", "upload"]
return (
<div className="xx-cover-mode-tabs">
{modes.map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
onClick={() => onModeChange(m)}
>
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
</button>
))}
</div>
)
}
// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容)
export { CoverModeSelector } from "@/components/cover"
@@ -1,195 +1,2 @@
import React, { useMemo, useState } from "react"
import type { CoverTemplate } from "../../types/cover"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import "@/components/cover/cover.css"
interface CoverSettingsModalProps {
open: boolean
onClose: () => void
templates: CoverTemplate[]
loading?: boolean
error?: string | null
selectedTemplateId: string
onSelectTemplate: (id: string) => void
onEditTemplate: (template: CoverTemplate) => void
onDeleteTemplate: (id: string) => void
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 CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
open,
onClose,
templates,
loading = false,
error = null,
selectedTemplateId,
onSelectTemplate,
onEditTemplate,
onDeleteTemplate,
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>
}
>
<div className="xx-cover-modal-toolbar">
<Button buttonType="primary" onClick={onCreateNew}>
+ 创建新模板
</Button>
</div>
{loading && (
<div style={{ textAlign: "center", padding: "40px 0", color: "var(--text-secondary)" }}>
加载中...
</div>
)}
{error && !loading && (
<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 && (
<div className="xx-cover-template-grid">
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id
return (
<div
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
<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()}>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onEditTemplate(tpl)}
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
>
编辑
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</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>
)
}
export default CoverSettingsModal
// Re-export from shared @/components/cover (公共组件抽离后,保留旧路径作兼容)
export { default } from "@/components/cover/CoverSettingsModal"

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