Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f741a0e76e | |||
| 4ae09f2b1d |
+2
-1
@@ -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
@@ -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(暂停积分系统)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ✓")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
|
||||
Generated
-12
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -150,8 +150,6 @@ export interface DirectUploadPrepareResult {
|
||||
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
|
||||
*/
|
||||
skip_transfer?: boolean
|
||||
/** duplicated=true 时后端返回已存在素材的公网 URL,前端直接用而不必再调 complete */
|
||||
url?: string
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,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 }
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ export interface PointsBalance {
|
||||
member_type: "monthly" | "quarterly" | "yearly" | null
|
||||
/** 会员到期时间 */
|
||||
member_expires_at: ISODate | null
|
||||
/** 后端积分系统是否启用(false=免费放行,不做余额预校验) */
|
||||
credits_enabled?: boolean
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "账单管理",
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
* 功能流程不做积分预校验,直接走生成。
|
||||
* - true:展示完整积分系统 UI。
|
||||
*/
|
||||
export const ENABLE_CREDIT_SYSTEM = true
|
||||
export const ENABLE_CREDIT_SYSTEM = false
|
||||
|
||||
@@ -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: "会员订阅",
|
||||
|
||||
@@ -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
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user