Compare commits

..

1 Commits

Author SHA1 Message Date
CI Bot (saas-backend-agent) 3077eb4e45 perf(worker): change default ffmpeg encode preset from fast to veryfast
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 51s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m9s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m53s
AI Code Review / AI Code Review (pull_request) Successful in 6m30s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8m25s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m34s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 21s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m19s
Benchmark on staging 4vCPU Xeon (60s 1280x720 testsrc, CRF=23):
- preset=fast:     31.7s (1.89x)
- preset=veryfast: 19.5s (3.07x)  <- new default
- preset=ultrafast(mezzanine): 11.3s (5.29x)

~38% speedup on CPU encode with negligible quality loss at CRF=23.
Preset can still be overridden via FFMPEG_ENCODE_PRESET env var.
2026-09-27 13:49:14 +08:00
212 changed files with 3856 additions and 33369 deletions
+3 -36
View File
@@ -79,33 +79,14 @@ CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/1
# ==================== Worker 配置(#2073 队列分流) ====================
#
# 容器内跑三个独立进程:beat(只发定时任务)+ generation worker(实时高优)
# + transcode worker(后台批量/清理)。三个进程的并发与开关独立配置。
# ==================== Worker 配置 ====================
# Worker 进程名称
WORKER_NAME=xiaoxia-saas-worker
# 总并发参考(兼容旧变量):
# - 若 GENERATION_CONCURRENCY 与 TRANSCODE_CONCURRENCY 都未显式设置,
# entrypoint 会按此总数对半分配(gen=ceil(total/2), trans=剩余,各至少 1);
# - 任一个 *_CONCURRENCY 显式设置后,按显式值生效,忽略此变量对应部分。
# Worker 并发数(同时执行的任务数)
WORKER_CONCURRENCY=4
# Generation worker 并发数(用户实时任务:视频生成/TTS/音色克隆/lipsync/数字人)
# 实时链路对延迟敏感,建议 2C 以上机器设为 2;高负载场景可加到 4。
GENERATION_CONCURRENCY=2
# Transcode worker 并发数(后台批量:素材入库转码/AI 分类打标/质量评分/查重/批量下载)
# 后台任务可排队,独立伸缩;素材入库量大时可加到 4。
TRANSCODE_CONCURRENCY=2
# 是否在本容器启动 celery beat 进程(默认 1)。
# 默认 beat 与 worker 同容器部署;若要独立 beat 容器部署,worker 容器设为 0、
# beat 容器单独跑 `celery -A worker_app.celery_app beat` 并设 BEAT_ENABLED=1。
BEAT_ENABLED=1
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
WORKER_MAX_TASKS_PER_CHILD=1000
@@ -211,24 +192,10 @@ COSYVOICE_CLONE_MODEL=voice-enrollment
# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景
DOUBAO_API_KEY=your-doubao-api-key
DOUBAO_MODEL=doubao-seed-2-1-pro-260915
DOUBAO_FAST_MODEL=doubao-seed-2-1-lite-260915
DOUBAO_MODEL=doubao-seed-1-6-250615
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
# 视觉模型:pro 精度高,lite 速度快(viral-video 商品识别默认用 lite 提速)
DOUBAO_VISION_MODEL=doubao-seed-2-1-pro-260915
DOUBAO_VISION_LITE_MODEL=doubao-seed-2-1-lite-260915
DOUBAO_VISION_USE_LITE=true
# Embedding 向量化模型
DOUBAO_EMBEDDING_MODEL=doubao-embedding-vision-251215
# 视频模型(Seedance 2.5,统一走方舟;真人参考图通过信任链自动 AI 化)
DOUBAO_VIDEO_MODEL=doubao-seedance-2-5-260628
DOUBAO_VIDEO_TIMEOUT=480
DOUBAO_VIDEO_POLL_INTERVAL=10
# 图片模型(Seedream 5.0 Pro,用于信任链真人 AI 化 + 文生图)
DOUBAO_IMAGE_MODEL=doubao-seedream-5-0-pro-260628
DOUBAO_IMAGE_TIMEOUT=120
# ==================== 积分/会员系统 (#1895) ====================
# 积分系统总开关:默认 false(暂停积分系统)。
+5 -22
View File
@@ -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 }}"
@@ -1246,13 +1238,12 @@ jobs:
ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}"
run: |
set -eux
# Staging 业务机 = 116.62.226.203(公网 sshd 端口 22)。
# 47.98.113.167 现为生产机(sshd 端口 22222),不承载 staging 容器。
# CI job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败,
# Staging 业务机 = 47.98.113.167(公网 sshd 端口 22222;内网 VPC 10.0.0.2:22)。
# CI runner 已迁移到独立 CI 机器、job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败,
# 故默认目标必须是 staging 业务机;仍可通过 secrets 覆盖。
staging_host="${STAGING_SSH_HOST:-116.62.226.203}"
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_user="${STAGING_SSH_USER:-root}"
staging_port="${STAGING_SSH_PORT:-22}"
staging_port="${STAGING_SSH_PORT:-22222}"
echo "Host: $staging_host"
echo "Port: $staging_port"
@@ -1310,16 +1301,8 @@ jobs:
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt"
echo "✅ Douyin cookies uploaded"
# 上传 infra/docker 配置到服务器(compose 单一事实来源)
echo "Uploading infra/docker configs to staging server..."
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \
"mkdir -p /var/lib/xiaoxia-saas-staging/infra/docker"
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no infra/docker/compose.yml \
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/infra/docker/compose.yml"
echo "✅ infra/docker/compose.yml uploaded"
# 通过环境变量传递凭证,避免命令行引号转义问题
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} COMPOSE_SYNC=0 sh"
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
# 清理 CI runner 上的渲染文件
rm -f .env.rendered
-2
View File
@@ -1,2 +0,0 @@
Mon Oct 5 04:09:11 PM CST 2026
2198 lite/pro并行竞速 (commit 9699a1f) — CI rebuild trigger Mon Oct 5 08:09:11 AM UTC 2026
@@ -1,100 +0,0 @@
"""add viral video tables
Revision ID: 086_add_viral_video_tables
Revises: 085_atom_clip_caption_embedding
Create Date: 2026-09-28
新增爆款视频相关表:
- viral_video_jobs: 爆款视频任务
- viral_video_style_templates: 风格模板配置
- viral_video_prompt_templates: Prompt 模板(由 #2040 seed)
"""
import sqlalchemy as sa
from alembic import op
revision = "086_add_viral_video_tables"
down_revision = "085_atom_clip_caption_embedding"
branch_labels = None
depends_on = None
def upgrade() -> None:
# viral_video_jobs
op.create_table(
"viral_video_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("images", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("industry", sa.String(100), nullable=False, server_default=""),
sa.Column("target_customer", sa.String(500), nullable=False, server_default=""),
sa.Column("persona_id", sa.String(36), nullable=False, server_default=""),
sa.Column("viral_structure", sa.String(50), nullable=False, server_default=""),
sa.Column("marketing_purpose", sa.String(100), nullable=False, server_default=""),
sa.Column("bgm_preference", sa.String(50), nullable=False, server_default=""),
sa.Column("duration", sa.Integer(), nullable=False, server_default="30"),
sa.Column("user_copy_text", sa.Text(), nullable=False, server_default=""),
sa.Column("fusion_level", sa.String(20), nullable=False, server_default="ai_polish"),
sa.Column("reference_audio_path", sa.String(1000), nullable=False, server_default=""),
# v1.3 新增
sa.Column("reference_video_url", sa.String(1000), nullable=False, server_default=""),
sa.Column("style_strength", sa.String(20), nullable=False, server_default="medium"),
sa.Column("style_guide", sa.JSON(), nullable=True),
sa.Column("style_template_id", sa.String(36), nullable=False, server_default="", index=True),
# 状态与结果
sa.Column("status", sa.String(30), nullable=False, server_default="pending", index=True),
sa.Column("intent_result", sa.JSON(), nullable=True),
sa.Column("result_video_url", sa.String(1000), nullable=False, server_default=""),
sa.Column("credits_cost", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_msg", sa.Text(), nullable=False, server_default=""),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# viral_video_style_templates
op.create_table(
"viral_video_style_templates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column("thumbnail_url", sa.String(1000), nullable=False, server_default=""),
sa.Column("style_config", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# viral_video_prompt_templates
op.create_table(
"viral_video_prompt_templates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("prompt_type", sa.String(50), nullable=False, index=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
sa.Column("variables", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# Seed 默认风格模板
op.execute("""
INSERT INTO viral_video_style_templates (id, name, description, style_config, is_system, sort_order)
VALUES
('style-tpl-001', '快节奏冲击', '高频切镜+动感BGM,适合食品饮料等快消品', '{"cut_speed": "fast", "transition": "jump_cut", "energy": "high"}', true, 1),
('style-tpl-002', '质感慢镜', '慢节奏+电影感调色,适合美妆护肤珠宝', '{"cut_speed": "slow", "transition": "dissolve", "energy": "low", "color_grade": "cinematic"}', true, 2),
('style-tpl-003', '口播种草', '数字人口播+产品特写穿插', '{"cut_speed": "medium", "transition": "cross_dissolve", "has_talking_head": true}', true, 3),
('style-tpl-004', '场景叙事', '多场景切换+故事线叙述', '{"cut_speed": "medium", "transition": "wipe", "narrative": true}', true, 4)
""")
def downgrade() -> None:
op.drop_table("viral_video_prompt_templates")
op.drop_table("viral_video_style_templates")
op.drop_table("viral_video_jobs")
@@ -1,25 +0,0 @@
"""viral video add image_analysis column
Revision ID: 087_viral_video_image_analysis
Revises: 086_add_viral_video_tables
Create Date: 2026-09-30
#2106 爆款视频 P0:持久化图片分析结果(image_analysis JSON),供 resume 阶段使用。
"""
import sqlalchemy as sa
from alembic import op
revision = "087_viral_video_image_analysis"
down_revision = "086_add_viral_video_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("viral_video_jobs", sa.Column("image_analysis", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("viral_video_jobs", "image_analysis")
@@ -1,51 +0,0 @@
"""viral video add copy_result + voice/video columns
Revision ID: 088_viral_video_copy_result
Revises: 087_viral_video_image_analysis
Create Date: 2026-10-01
v1.6 爆款视频字段补齐:
- copy_result JSON: 编导分镜脚本完整结构(overview/scene_and_lighting/shots/hard_constraints/negative_prompts/voiceover_script)
- voice_id/voice_source: TTS 音色参数
- video_ratio/video_model: Seedance 视频比例/模型
注意:线上启动也有幂等 ADD COLUMN 补列逻辑 (_ensure_viral_video_columns),本 migration 提供标准 Alembic 路径,
两套机制互不冲突(IF NOT EXISTS 等价行为)。
"""
import sqlalchemy as sa
from alembic import op
revision = "088_viral_video_copy_result"
down_revision = "087_viral_video_image_analysis"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 幂等添加列(通过单独执行 + 异常忽略兼容已由 backfill 补上的环境)
cols = [
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
("copy_result", "JSON"),
]
conn = op.get_bind()
for name, ddl in cols:
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
except Exception:
# 不支持 IF NOT EXISTS 的库(如老版本 SQLite)直接尝试 ADD COLUMN,失败则忽略
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
except Exception:
pass
def downgrade() -> None:
for name in ("copy_result", "video_model", "video_ratio", "voice_source", "voice_id"):
try:
op.drop_column("viral_video_jobs", name)
except Exception:
pass
-62
View File
@@ -1,62 +0,0 @@
"""viral video add storyboard + generated_copy_text (complement 088)
Revision ID: 089_viral_video_cols
Revises: 088_viral_video_copy_result
Create Date: 2026-10-01
#2129 兜底迁移:补齐 _VIRAL_VIDEO_BACKFILL_COLS 中所有列,覆盖
# watchtower 自动部署未跑历史 migration、且 AUTO_CREATE_SCHEMA=false 时
# _ensure_viral_video_columns 未执行的场景。
# 幂等 ADD COLUMN IF NOT EXISTS,已存在则跳过。
"""
import sqlalchemy as sa
from alembic import op
revision = "089_viral_video_cols"
down_revision = "088_viral_video_copy_result"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 扩展 alembic_version.version_num 字段长度(原来 VARCHAR(32) 装不下长 revision id)
conn = op.get_bind()
try:
conn.execute(sa.text("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(256)"))
except Exception:
pass
cols = [
("storyboard", "JSON"),
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
("copy_result", "JSON"),
]
for name, ddl in cols:
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
except Exception:
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
except Exception:
pass
def downgrade() -> None:
for name in (
"copy_result",
"video_model",
"video_ratio",
"voice_source",
"voice_id",
"generated_copy_text",
"storyboard",
):
try:
op.drop_column("viral_video_jobs", name)
except Exception:
pass
@@ -1,35 +0,0 @@
"""viral video add phase_message column (#2134)
Revision ID: 090_viral_video_phase_msg
Revises: 089_viral_video_cols
Create Date: 2026-10-02
#2134 阶段细粒度提示:viral_video 表新增 phase_message 列(中文阶段提示文案)。
current_stage 列已在之前版本存在,本迁移只补 phase_message。
幂等 ADD COLUMN IF NOT EXISTS。
"""
import sqlalchemy as sa
from alembic import op
revision = "090_viral_video_phase_msg"
down_revision = "089_viral_video_cols"
branch_labels = None
depends_on = None
def upgrade() -> None:
# SQLite/PostgreSQL 兼容的幂等添加列
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "phase_message" not in cols:
op.add_column(
"viral_video_jobs",
sa.Column("phase_message", sa.String(length=500), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("viral_video_jobs", "phase_message")
-49
View File
@@ -1,49 +0,0 @@
"""viral video add current_stage column (#2137 follow-up)
Revision ID: 091_viral_video_stage
Revises: 090_viral_video_phase_msg
Create Date: 2026-10-02
#2137 follow-up fix: 090 migration missed current_stage column on viral_video_jobs,
causing UndefinedColumn errors and 500s on all authenticated viral-video endpoints.
Idempotently add current_stage and double-check phase_message.
"""
import sqlalchemy as sa
from alembic import op
revision = "091_viral_video_stage"
down_revision = "090_viral_video_phase_msg"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "current_stage" not in cols:
op.add_column(
"viral_video_jobs",
sa.Column(
"current_stage",
sa.String(length=200),
nullable=False,
server_default="",
),
)
if "phase_message" not in cols:
op.add_column(
"viral_video_jobs",
sa.Column(
"phase_message",
sa.String(length=500),
nullable=False,
server_default="",
),
)
def downgrade() -> None:
op.drop_column("viral_video_jobs", "current_stage")
@@ -1,42 +0,0 @@
"""viral_video_jobs 增加 heartbeat_at 列(worker 心跳,用于僵尸任务超时回收)
Revision ID: 092_viral_video_heartbeat
Revises: 091_viral_video_stage
Create Date: 2026-10-02
"""
import sqlalchemy as sa
from alembic import op
revision = "092_viral_video_heartbeat"
down_revision = "091_viral_video_stage"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "heartbeat_at" not in cols:
op.add_column("viral_video_jobs", sa.Column("heartbeat_at", sa.DateTime(), nullable=True))
op.execute(
"UPDATE viral_video_jobs SET heartbeat_at = updated_at " "WHERE status = 'running' AND heartbeat_at IS NULL"
)
try:
op.create_index("ix_viral_video_jobs_heartbeat_at", "viral_video_jobs", ["heartbeat_at"])
except Exception:
pass
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "heartbeat_at" in cols:
try:
op.drop_index("ix_viral_video_jobs_heartbeat_at", table_name="viral_video_jobs")
except Exception:
pass
op.drop_column("viral_video_jobs", "heartbeat_at")
@@ -1,87 +0,0 @@
"""viral_video 动态积分定价 + 积分字段从 Integer 改为 Float (#2151)
Revision ID: 093
Revises: 092_viral_video_heartbeat
Create Date: 2026-10-02
"""
import sqlalchemy as sa
from alembic import op
revision = "093"
down_revision = "092_viral_video_heartbeat"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# 1) points_accounts 三列 Integer -> Float
pa_cols = {c["name"]: c for c in inspector.get_columns("points_accounts")}
for col in ("balance", "total_earned", "total_spent"):
if col in pa_cols:
op.alter_column(
"points_accounts",
col,
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
# 2) points_transactions amount/balance_after Integer -> Float
pt_cols = {c["name"]: c for c in inspector.get_columns("points_transactions")}
for col in ("amount", "balance_after"):
if col in pt_cols:
op.alter_column(
"points_transactions",
col,
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
# 3) users.points_balance Integer -> Float
user_cols = {c["name"]: c for c in inspector.get_columns("users")}
if "points_balance" in user_cols:
op.alter_column(
"users",
"points_balance",
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
# 4) viral_video_jobs.credits_cost Integer -> Float
vv_cols = {c["name"]: c for c in inspector.get_columns("viral_video_jobs")}
if "credits_cost" in vv_cols:
op.alter_column(
"viral_video_jobs",
"credits_cost",
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
# 5) viral_video_jobs 新增列
if "video_resolution" not in vv_cols:
op.add_column(
"viral_video_jobs",
sa.Column("video_resolution", sa.String(20), nullable=False, server_default="720p"),
)
if "credits_prepaid" not in vv_cols:
op.add_column(
"viral_video_jobs",
sa.Column("credits_prepaid", sa.Float(), nullable=False, server_default="0"),
)
if "credits_transaction_id" not in vv_cols:
op.add_column(
"viral_video_jobs",
sa.Column("credits_transaction_id", sa.String(36), nullable=False, server_default=""),
)
def downgrade() -> None:
pass
@@ -1,31 +0,0 @@
"""viral_video_jobs 增加 pre_trusted_images 列(信任链Seedream预热结果)
Revision ID: 094_viral_video_pre_trusted
Revises: 093_viral_video_pricing_points_float
Create Date: 2026-10-04
"""
import sqlalchemy as sa
from alembic import op
revision = "094_viral_video_pre_trusted"
down_revision = "093"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "pre_trusted_images" not in cols:
op.add_column("viral_video_jobs", sa.Column("pre_trusted_images", sa.Text(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "pre_trusted_images" in cols:
op.drop_column("viral_video_jobs", "pre_trusted_images")
@@ -1,102 +0,0 @@
"""爆款视频 Prompt 模板配置表(#2040)。
086 曾预留同名旧表(id varchar / content / variables json),从未被业务使用;
本迁移将其替换为 #2040 新结构。
Revision ID: 095_viral_video_prompt_templates
Revises: 094_viral_video_pre_trusted
Create Date: 2026-10-04
"""
import sqlalchemy as sa
from alembic import op
revision = "095_viral_video_prompt_templates"
down_revision = "094_viral_video_pre_trusted"
branch_labels = None
depends_on = None
def _table_exists(conn, name: str) -> bool:
return name in sa.inspect(conn).get_table_names()
def upgrade() -> None:
conn = op.get_bind()
# 086 预留的旧结构表:先删除(无业务数据、无任何引用)
if _table_exists(conn, "viral_video_prompt_templates"):
op.drop_table("viral_video_prompt_templates")
op.create_table(
"viral_video_prompt_templates",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("prompt_type", sa.String(32), nullable=False),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
sa.Column("system_prompt", sa.Text, nullable=False),
sa.Column("user_prompt_template", sa.Text, nullable=False),
sa.Column("example_output", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index(
"ix_vvpt_type_active",
"viral_video_prompt_templates",
["prompt_type", "is_active"],
)
op.create_index(
"uq_vvpt_type_version",
"viral_video_prompt_templates",
["prompt_type", "version"],
unique=True,
)
def downgrade() -> None:
conn = op.get_bind()
if _table_exists(conn, "viral_video_prompt_templates"):
op.drop_index("uq_vvpt_type_version", table_name="viral_video_prompt_templates")
op.drop_index("ix_vvpt_type_active", table_name="viral_video_prompt_templates")
op.drop_table("viral_video_prompt_templates")
# 恢复 086 的旧预留结构
op.create_table(
"viral_video_prompt_templates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("prompt_type", sa.String(50), nullable=False, index=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("content", sa.Text, nullable=False, server_default=""),
sa.Column("variables", sa.JSON, nullable=False, server_default="[]"),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
sa.Column(
"is_active",
sa.Boolean,
nullable=False,
server_default=sa.text("true"),
index=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
@@ -1,61 +0,0 @@
"""功能计费积分字段(爆款/对口型/智能剪辑 DB 化计费)。
给 gpu_lipsync_tasks / generation_tasks / lipsync_jobs 三张表加积分字段:
- credits_prepaid: 提交任务时预扣积分
- credits_cost: 最终结算积分
- credits_transaction_id: 预扣流水 ID
注意:feature_pricing_configs 配置表由 xiaoxia-admin 侧 migration 建立,
本仓库只读,不在此创建。
Revision ID: 096_feature_billing_fields
Revises: 095_viral_video_prompt_templates
Create Date: 2026-10-05
"""
import sqlalchemy as sa
from alembic import op
revision = "096_feature_billing_fields"
down_revision = "095_viral_video_prompt_templates"
branch_labels = None
depends_on = None
_TABLES = ("gpu_lipsync_tasks", "generation_tasks", "lipsync_jobs")
_COLUMNS = (
("credits_prepaid", sa.Float(), "0"),
("credits_cost", sa.Float(), "0"),
("credits_transaction_id", sa.String(36), ""),
)
def _table_exists(conn, name: str) -> bool:
return name in sa.inspect(conn).get_table_names()
def upgrade() -> None:
conn = op.get_bind()
for table in _TABLES:
if not _table_exists(conn, table):
continue
existing = {c["name"] for c in sa.inspect(conn).get_columns(table)}
for col_name, col_type, default in _COLUMNS:
if col_name in existing:
continue
op.add_column(
table,
sa.Column(col_name, col_type, nullable=False, server_default=default),
)
def downgrade() -> None:
conn = op.get_bind()
for table in _TABLES:
if not _table_exists(conn, table):
continue
existing = {c["name"] for c in sa.inspect(conn).get_columns(table)}
for col_name, _col_type, _default in _COLUMNS:
if col_name not in existing:
continue
op.drop_column(table, col_name)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
@@ -36,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
@@ -241,4 +240,3 @@ api_router.include_router(
prefix="/gpu",
tags=["GPU Worker"],
)
api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"])
@@ -29,6 +29,8 @@ from app.services.ai_avatar_render_service import (
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -42,6 +44,7 @@ def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
@points_gate("ai_digital_human", per_unit=15)
def create_render_job(
body: CreateAiAvatarRenderRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
@@ -27,6 +27,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
)
from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config
from packages.middleware.points_gate import points_gate
from packages.shared.storage import get_shared_storage_service
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
@@ -345,6 +346,7 @@ def _is_trusted_media_url(url: str) -> bool:
@router.post("/generate-cover", response_model=GenerateCoverResponse)
@points_gate("ai_cover")
def generate_cover(
body: GenerateCoverRequest,
template_id: str = Query(..., description="模板 ID"),
+30 -9
View File
@@ -11,7 +11,9 @@ from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
build_rate_limit_detail,
safe_enqueue_generation_task,
)
@@ -41,6 +43,7 @@ from packages.application import (
GetGenerationTaskUseCase,
ListGeneratedVideosByTaskUseCase,
)
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
@@ -269,6 +272,7 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201)
@points_gate("ai_video", quantity_field="preview_count")
def create_preview_generation_task(
request: CreatePreviewGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -298,17 +302,26 @@ def create_preview_generation_task(
count,
)
# 预检查队列限流(按变体总数计)——仅保留全局硬上限,用户上限改为软 warning 在 safe_enqueue 内处理(#2098)
global_pending = generation_task_repository.count_pending_total()
if global_pending + count > GLOBAL_PENDING_LIMIT:
# 预检查队列限流(按变体总数计)
try:
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending + count > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
)
if global_pending + count > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(
GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT),
generation_task_repository,
scope="global",
),
)
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
) from e
# 确定视频比例:优先前端传入,否则从模板 mode 推断
video_ratio = request.video_ratio or ""
@@ -576,6 +589,9 @@ def create_preview_generation_task(
if not enqueued:
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队失败")
except UserPendingLimitExceeded as e:
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
rate_limit_exc = rate_limit_exc or e
except GlobalQueueFull as e:
_mark_task_failed(generation_task_repository, task, "系统队列已满")
rate_limit_exc = rate_limit_exc or e
@@ -587,6 +603,11 @@ def create_preview_generation_task(
# 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败")
if all(r.status == "failed" for r in responses) and rate_limit_exc is not None:
if isinstance(rate_limit_exc, UserPendingLimitExceeded):
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"),
)
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"),
+51 -155
View File
@@ -7,6 +7,7 @@ from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
build_rate_limit_detail,
@@ -44,104 +45,17 @@ from packages.application import (
GetGenerationTaskUseCase,
ListGeneratedVideosByTaskUseCase,
)
from packages.domain import feature_pricing_service
from packages.domain.smart_match import smart_select_assets
# #2035:文案关键词 → 素材分类 映射表(用于 smart_match category_match 维度)
# AssetClassification 枚举: scenic / product / person / animal / food / tech / sport / music / other
_CATEGORY_KEYWORDS: dict[str, set[str]] = {
"scenic": {
"风景",
"自然",
"山水",
"大海",
"天空",
"日落",
"日出",
"森林",
"城市",
"建筑",
"夜景",
"街道",
"公园",
"景区",
"旅行",
"旅游",
"户外",
},
"product": {
"产品",
"商品",
"展示",
"演示",
"开箱",
"评测",
"好物",
"推荐",
"种草",
"购物",
"电商",
"带货",
"品牌",
"广告",
"包装",
},
"person": {
"人物",
"人物采访",
"对话",
"说话",
"讲解",
"演讲",
"采访",
"聊天",
"开会",
"工作",
"办公室",
"团队",
"员工",
"老板",
"女性",
"男性",
"美女",
"帅哥",
},
"scenic": {"风景", "自然", "山水", "大海", "天空", "日落", "日出", "森林", "城市", "建筑", "夜景", "街道", "公园", "景区", "旅行", "旅游", "户外"},
"product": {"产品", "商品", "展示", "演示", "开箱", "评测", "好物", "推荐", "种草", "购物", "电商", "带货", "品牌", "广告", "包装"},
"person": {"人物", "人物采访", "对话", "说话", "讲解", "演讲", "采访", "聊天", "开会", "工作", "办公室", "团队", "员工", "老板", "女性", "男性", "美女", "帅哥"},
"animal": {"动物", "宠物", "狗", "猫", "鸟", "鱼", "马", "牛", "羊", "野生动物", "动物园"},
"food": {
"美食",
"食物",
"餐饮",
"餐厅",
"做饭",
"烹饪",
"厨房",
"菜品",
"饮料",
"水果",
"甜点",
"蛋糕",
"咖啡",
"茶",
"零食",
"吃",
},
"tech": {
"科技",
"数码",
"电脑",
"手机",
"屏幕",
"软件",
"APP",
"互联网",
"AI",
"人工智能",
"机器人",
"办公",
"程序员",
"代码",
"屏幕录制",
},
"food": {"美食", "食物", "餐饮", "餐厅", "做饭", "烹饪", "厨房", "菜品", "饮料", "水果", "甜点", "蛋糕", "咖啡", "茶", "零食", "吃"},
"tech": {"科技", "数码", "电脑", "手机", "屏幕", "软件", "APP", "互联网", "AI", "人工智能", "机器人", "办公", "程序员", "代码", "屏幕录制"},
"sport": {"运动", "健身", "跑步", "篮球", "足球", "游泳", "瑜伽", "户外", "锻炼", "体育", "比赛", "球场"},
"music": {"音乐", "歌曲", "演唱会", "乐器", "唱歌", "跳舞", "舞蹈", "MV", "演出", "乐队", "钢琴", "吉他", "节奏"},
}
@@ -163,6 +77,7 @@ def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
break
return matched or None
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
@@ -278,13 +193,10 @@ def _select_assets_from_library(
# #2035:加载片段级 AI 标签,供叙事模式 AI 加权和 smart 模式语义匹配使用。
# 失败降级为空(不影响选片主流程)。
clip_ai_tags_by_asset: dict[str, list[dict]] = {}
ai_tags_by_asset: dict[
str, dict
] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重)
ai_tags_by_asset: dict[str, dict] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重)
try:
if db is not None:
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
ready_ids = [a.id for a in ready_video_assets]
clip_rows = (
db.query(AssetAtomClipModel.asset_id, AssetAtomClipModel.ai_tags)
@@ -464,6 +376,7 @@ def _resolve_project_and_library(
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
@points_gate("ai_video", quantity_field="count")
def create_generation_task(
request: CreateGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -681,13 +594,21 @@ def create_generation_task(
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
# 预检查(Bug B #2098):只保留全局 503 保护,用户级不再硬拒 429;
# 超额任务直接入队等待 worker 自然消费,前端展示排队位置而非阻止提交。
# USER_PENDING_LIMIT 作为软上限(safe_enqueue 兜底),提高到 20 支持批量提交。
# 预检查:批量提交前先看会不会超限,避免建一半才拒
try:
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending + count > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
)
if global_pending + count > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
@@ -700,17 +621,6 @@ def create_generation_task(
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take"
# ── smart_edit 计费预扣(全局 points 开关 + 功能开关均开才扣) ──
# 首期固定价:dynamic_cost=0,price=(0+fixed_cost)×multiplier,price_cap 封顶。
# 预览任务不扣费;按任务条数扣费,任一任务预扣失败(余额不足)整体拒绝。
smart_edit_charge = 0.0
charged_task_count = 0
if not request.is_preview and feature_pricing_service.is_feature_enabled("smart_edit"):
unit_credits, _bd = feature_pricing_service.calculate_price("smart_edit", 0.0)
if unit_credits > 0:
smart_edit_charge = round(unit_credits * count, 2)
charged_task_count = count
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。
# - 变体 0:clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
@@ -941,42 +851,6 @@ def create_generation_task(
)
# 变体序号写入 extra_meta(响应/排查时可辨识)
task.extra_meta["variant_index"] = task_index
# smart_edit 逐条预扣(首期固定价,credits_cost=prepaid,不做结算)
task_txn_id = ""
if charged_task_count > 0:
from packages.domain.points_service import PointsService
unit_credits = round(smart_edit_charge / count, 2)
res = PointsService().deduct_points(
user_id=user_id,
amount=unit_credits,
source="smart_edit",
db=db,
description="智能剪辑生成预扣",
ref_id=task.id,
)
if not res.get("success"):
# 余额不足:退还本次请求已扣积分后整体拒绝
already_charged = round(unit_credits * task_index, 2)
if already_charged > 0:
PointsService().refund_points(
user_id=user_id,
amount=already_charged,
source="smart_edit",
db=db,
ref_id=task.id,
description="智能剪辑批量提交失败退回",
)
raise HTTPException(
status_code=402,
detail=(f"积分不足:智能剪辑每条需 {unit_credits:.2f} 积分,当前余额 {res.get('balance', 0)}"),
)
task_txn_id = str(res.get("transaction_id") or "")
task.credits_prepaid = unit_credits
task.credits_cost = unit_credits
task.credits_transaction_id = task_txn_id
generation_task_repository.update(task)
try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
@@ -1049,10 +923,14 @@ def create_generation_task(
else:
failed_tasks.append(task)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已改为软限制,此分支理论上不再触发;
# 极端并发兜底仍入队(safe_enqueue 内部会打 warning 日志),不 429 拒绝
logger.warning("[生成任务] 用户 pending 超软限制,仍允许入队: task_id=%s", task.id)
created_tasks.append(task)
# 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
) from _e
break
except GlobalQueueFull as _e:
failed_tasks.append(task)
if not created_tasks:
@@ -1203,8 +1081,10 @@ def confirm_generation(
):
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行
logger.warning("[任务] 用户 pending 超软限制,任务已入队")
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
) from None
except GlobalQueueFull as _e:
raise HTTPException(
status_code=503,
@@ -1383,8 +1263,22 @@ def retry_generation_task(
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查(Bug B #2098):只保留全局 503,用户级不再硬拒
# 预检查:创建前判断,>= 上限就拒绝
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(
UserPendingLimitExceeded(
user_id=user_id,
pending_count=user_pending,
limit=USER_PENDING_LIMIT,
),
generation_task_repository,
scope="user",
),
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
@@ -1427,8 +1321,10 @@ def retry_generation_task(
):
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行
logger.warning("[任务] 用户 pending 超软限制,任务已入队")
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
) from None
except GlobalQueueFull as _e:
raise HTTPException(
status_code=503,
+69 -111
View File
@@ -1,15 +1,14 @@
"""GPU 编码回传 relay 端点。
两个用途:
1. 结果回传(原):P4000 编码完成后通过 HTTP PUT 把结果 mp4 写到 /{key};Worker 用同 URL GET 回本地。
2. Mezzanine 中转(新):Worker 先把 CPU ultrafast 编码出的 mezzanine 通过 PUT 到 /mezzanine/{key},
P4000 通过 Tailscale 内网直接 GET 下载,跳过公网 OSS 中转,节省 18-20s 固定延迟。
编码完成后 DELETE 清理。
P4000 编码完成后通过 HTTP PUT 把结果 mp4 写到这里;Worker 在发起 GPU 请求时携带
带签名(token + 随机 key)的 URL,等待 P4000 写入后用同 URL 把文件 GET 回本地。
安全:
- 生产环境必须配置 GPU_ENCODE_RELAY_SECRET;token=xxx 查询参数必须匹配。
- key 为随机 hex,无法被枚举。
- 写入/读取后 worker 会调用 DELETE 主动清理;文件落地在 generated-files/gpu_relay/。
- 写入/读取后 worker 会调用 DELETE 主动清理;文件落地在 generated-files/gpu_relay/,
跟 generated-files 同卷,nginx 已对 generated-files 做静态挂载,但 gpu_relay/ 子目录
通过本接口走鉴权,不直接暴露为静态目录(文件名随机 + token 保护双重保险)。
"""
from __future__ import annotations
@@ -39,19 +38,15 @@ def _relay_dir() -> Path:
return p
def _mezzanine_dir() -> Path:
p = _relay_dir() / "mezzanine"
p.mkdir(parents=True, exist_ok=True)
return p
def _secret() -> str:
global _DEFAULT_SECRET_LOGGED
secret = (os.getenv("GPU_ENCODE_RELAY_SECRET", "") or "").strip()
if not secret:
env = (os.getenv("APP_ENV", os.getenv("ENV", "development"))).lower()
if env in ("production", "prod"):
# Production: raise so deployment fails fast
raise RuntimeError("GPU_ENCODE_RELAY_SECRET must be set in production")
# Dev: ephemeral random secret, log once
secret = os.environ.setdefault("GPU_ENCODE_RELAY_SECRET", secrets.token_urlsafe(32))
if not _DEFAULT_SECRET_LOGGED:
logger.warning(
@@ -77,8 +72,33 @@ def _check_token(tok: Optional[str]) -> None:
raise HTTPException(status_code=401, detail="unauthorized")
async def _atomic_write(request: Request, dst: Path, log_prefix: str, key_for_log: str) -> int:
"""通用原子写入(流式 → .part → replace)。返回字节数。"""
# ── Worker 侧:生成一个一次性 PUT URL ───────────────────────────────────
def build_relay_put_url(base_url: str, key: str, secret: str) -> str:
"""给 P4000 用的 PUT URL(含 token)。"""
return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/{key}?token={secret}"
def build_relay_get_url(base_url: str, key: str, secret: str) -> str:
"""Worker 取回结果用的 GET URL。"""
return build_relay_put_url(base_url, key, secret)
def generate_key() -> str:
return uuid.uuid4().hex
# ── HTTP endpoints ──────────────────────────────────────────────────────
@router.put("/{key}")
async def put_object(
key: str,
request: Request,
token: Optional[str] = Query(None),
):
_check_token(token)
safe = _safe_key(key)
dst = _relay_dir() / safe
tmp = dst.with_suffix(dst.suffix + ".part")
size = 0
t0 = time.time()
@@ -94,22 +114,40 @@ async def _atomic_write(request: Request, dst: Path, log_prefix: str, key_for_lo
tmp.unlink()
except OSError:
pass
logger.exception("[gpu-relay] %s PUT failed key=%s", log_prefix, key_for_log)
logger.exception("[gpu-relay] PUT failed key=%s", safe)
raise HTTPException(status_code=500, detail=f"write failed: {e}") from e
logger.info(
"[gpu-relay] %s PUT key=%s size=%d took=%.2fs",
log_prefix, key_for_log, size, time.time() - t0,
"[gpu-relay] PUT key=%s size=%d took=%.2fs",
safe, size, time.time() - t0,
)
return size
return {"ok": True, "key": safe, "size": size}
def _file_response(path: Path, download_name: str) -> FileResponse:
@router.get("/{key}")
async def get_object(
key: str,
token: Optional[str] = Query(None),
):
_check_token(token)
safe = _safe_key(key)
path = _relay_dir() / safe
if not path.exists():
raise HTTPException(status_code=404, detail="not found")
return FileResponse(path=path, media_type="video/mp4", filename=f"{download_name}.mp4")
return FileResponse(
path=path,
media_type="video/mp4",
filename=f"{safe}.mp4",
)
def _head_response(path: Path) -> Response:
@router.head("/{key}")
async def head_object(
key: str,
token: Optional[str] = Query(None),
):
_check_token(token)
safe = _safe_key(key)
path = _relay_dir() / safe
if not path.exists():
return Response(status_code=404)
return Response(
@@ -119,97 +157,17 @@ def _head_response(path: Path) -> Response:
)
def _safe_delete(path: Path, err_detail: str) -> dict:
@router.delete("/{key}")
async def delete_object(
key: str,
token: Optional[str] = Query(None),
):
_check_token(token)
safe = _safe_key(key)
path = _relay_dir() / safe
try:
if path.exists():
path.unlink()
except OSError as e:
raise HTTPException(status_code=500, detail=f"{err_detail}: {e}") from e
return {"ok": True}
# ── Worker 侧 URL 构造 ─────────────────────────────────────────────────
def build_relay_put_url(base_url: str, key: str, secret: str) -> str:
"""给 P4000 回传结果用的 PUT URL(外部/Tailscale 可达)。"""
return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/{key}?token={secret}"
def build_relay_get_url(base_url: str, key: str, secret: str) -> str:
"""Worker 取回结果用的 GET URL。"""
return build_relay_put_url(base_url, key, secret)
def build_mezzanine_put_url(base_url: str, key: str, secret: str) -> str:
"""Worker 上传 mezzanine 用的 PUT URL(Docker 内网或 Tailscale)。"""
return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/mezzanine/{key}?token={secret}"
def build_mezzanine_get_url(base_url: str, key: str, secret: str) -> str:
"""P4000 下载 mezzanine 用的 GET URL(必须是 P4000 可达地址,通常是 Tailscale host:8092)。"""
return build_mezzanine_put_url(base_url, key, secret)
def generate_key() -> str:
return uuid.uuid4().hex
# ── 编码结果:PUT/GET/HEAD/DELETE /{key} ──────────────────────────────
@router.put("/{key}")
async def put_object(key: str, request: Request, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
size = await _atomic_write(request, _relay_dir() / safe, "result", safe)
return {"ok": True, "key": safe, "size": size}
@router.get("/{key}")
async def get_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _file_response(_relay_dir() / safe, safe)
@router.head("/{key}")
async def head_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _head_response(_relay_dir() / safe)
@router.delete("/{key}")
async def delete_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _safe_delete(_relay_dir() / safe, "delete failed")
# ── Mezzanine 中转:PUT/GET/HEAD/DELETE /mezzanine/{key} ─────────────
# Worker 上传 mezzanine 用;P4000 通过 Tailscale 直接 GET 下载。
@router.put("/mezzanine/{key}")
async def put_mezzanine(key: str, request: Request, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
dst = _mezzanine_dir() / f"{safe}.mp4"
size = await _atomic_write(request, dst, "mezzanine", safe)
return {"ok": True, "key": safe, "size": size}
@router.get("/mezzanine/{key}")
async def get_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _file_response(_mezzanine_dir() / f"{safe}.mp4", f"{safe}-mezzanine")
@router.head("/mezzanine/{key}")
async def head_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _head_response(_mezzanine_dir() / f"{safe}.mp4")
@router.delete("/mezzanine/{key}")
async def delete_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _safe_delete(_mezzanine_dir() / f"{safe}.mp4", "mezzanine delete failed")
raise HTTPException(status_code=500, detail=f"delete failed: {e}") from e
return {"ok": True, "key": safe}
+2 -9
View File
@@ -9,11 +9,6 @@ from fastapi.responses import JSONResponse
router = APIRouter(tags=["Health"])
def _pg_url(url: str) -> str:
"""Convert SQLAlchemy URL (postgresql+psycopg://...) to libpq connection string."""
return url.replace("postgresql+psycopg://", "postgresql://", 1).replace("postgresql+psycopg2://", "postgresql://", 1)
@router.get("/health", status_code=status.HTTP_200_OK)
async def health_check():
return {
@@ -54,7 +49,7 @@ async def _check_database() -> dict:
"message": "Using in-memory database",
}
try:
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
@@ -129,7 +124,7 @@ async def _check_migrations() -> dict:
"message": "Using in-memory database, no migrations needed",
}
try:
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur:
cur.execute("""
SELECT COUNT(*) FROM information_schema.tables
@@ -142,5 +137,3 @@ async def _check_migrations() -> dict:
return {"status": "unhealthy", "message": f"Missing tables, found {count}/5"}
except Exception as error:
return {"status": "unhealthy", "message": f"Migration check failed: {error}"}
+94 -4
View File
@@ -12,9 +12,11 @@
from __future__ import annotations
import logging
import math
from datetime import UTC
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.dependencies import (
get_db_session,
get_voice_clone_profile_repository,
@@ -30,6 +32,9 @@ from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -56,6 +61,37 @@ def create_lipsync_job(
db: Session = Depends(get_db_session),
svc: LipsyncService = Depends(_get_service),
):
user_id = current_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_digital_human"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
# 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟)
if body.audio_url and body.audio_duration and body.audio_duration > 0:
est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0))
elif body.script_text:
est_minutes = max(1.0, math.ceil(len(body.script_text) / 240))
else:
est_minutes = 1.0
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(current_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(current_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
"""提交对口型任务.
三种模式:
@@ -65,8 +101,6 @@ def create_lipsync_job(
- 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings},
后端同步ffprobe+写入timings+直接提交MediaKit(~2-3s)。
"""
user_id = current_user.user.id
try:
job = svc.create_job(
user_id=user_id,
@@ -84,8 +118,18 @@ def create_lipsync_job(
project_id=body.project_id,
)
except ValueError as exc:
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}")
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MediaKitError as exc:
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}")
status_code = 502
if exc.code in ("VoiceForbidden",):
status_code = 403
@@ -101,11 +145,24 @@ def create_lipsync_job(
) from exc
except Exception as exc:
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型异常退积分异常: err={refund_err}")
raise HTTPException(
status_code=400,
detail=f"创建对口型任务失败: {exc}",
) from exc
# 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等)
# 若任务已创建且状态为 failed,退费
if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed":
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}")
return job
@@ -119,14 +176,37 @@ def preview_tts(
db: Session = Depends(get_db_session),
svc: LipsyncService = Depends(_get_service),
):
user_id = current_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_digital_human"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(current_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(current_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
"""步骤1「生成配音」同步 TTS 预合成.
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。
耗时约 2-3 秒。
"""
user_id = current_user.user.id
try:
result = svc.preview_tts(
user_id=user_id,
@@ -138,6 +218,11 @@ def preview_tts(
emotion=body.emotion,
)
except MediaKitError as exc:
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}")
status_code = 400
if exc.code in ("VoiceForbidden",):
status_code = 403
@@ -152,6 +237,11 @@ def preview_tts(
) from exc
except Exception as exc:
logger.error("TTS 预合成异常: %s", exc, exc_info=True)
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}")
raise HTTPException(
status_code=400,
detail=f"TTS 合成失败: {exc}",
+32 -18
View File
@@ -145,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)
@@ -172,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()
@@ -188,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,
@@ -201,11 +215,11 @@ def check_points(
balance = account["balance"]
return PointsCheckResponse(
allowed=balance >= required,
allowed=is_free_quota or balance >= required,
required_points=required,
current_balance=balance,
remaining_after=balance - required,
is_free_quota=False,
is_free_quota=is_free_quota,
)
+4
View File
@@ -44,6 +44,7 @@ from app.services.script_asr_service import (
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.middleware.points_gate import points_gate
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
@@ -372,6 +373,7 @@ def douyin_diag():
@router.post("/extract-from-douyin", response_model=ExtractFromDouyinResponse)
@points_gate("douyin_extract")
def extract_from_douyin(
request: ExtractFromDouyinRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
@@ -495,6 +497,7 @@ def extract_from_douyin(
@router.post("/ai-rewrite", response_model=AiRewriteResponse)
@points_gate("ai_rewrite")
def ai_rewrite(
request: AiRewriteRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
@@ -534,6 +537,7 @@ def ai_rewrite(
@router.post("/ai-generate-titles", response_model=AiGenerateTitlesResponse)
@points_gate("ai_title")
def ai_generate_titles(
request: AiGenerateTitlesRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
+24 -4
View File
@@ -86,13 +86,33 @@ async def get_current_subscription(
def list_membership_plans(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, list[dict[str, Any]]]:
"""查询可购买的会员套餐(读管理后台 plans 表真实数据)。
"""查询所有会员档位(供前端会员购买页展示)。
仅返回 is_enabled=true 的套餐;后台启停/改价后最多 30 秒生效。
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
"""
from packages.application.catalog.admin_catalog import get_membership_plans
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
return {"plans": get_membership_plans()}
plans: list[dict[str, Any]] = []
for plan_id, info in MEMBERSHIP_PRICES.items():
days = info["duration_days"]
monthly_cents = round(info["price_cents"] * 30 / days)
features: dict[str, Any] = {"max_resolution": "1080p"}
if plan_id == MembershipType.MONTHLY:
features.update({"free_clips_daily": 2})
elif plan_id == MembershipType.QUARTERLY:
features.update({"free_clips_daily": 5})
elif plan_id == MembershipType.YEARLY:
features.update({"free_clips_daily": "unlimited"})
plans.append({
"plan_id": plan_id,
"name": info["name"],
"price_cents": info["price_cents"],
"monthly_price_cents": monthly_cents,
"duration_days": days,
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
"features": features,
})
return {"plans": plans}
@router.get("/billing-records", response_model=list[BillingRecord])
+76
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import json
import logging
import math
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.core.celery_app import celery_app
from app.core.storage import get_storage_service
from app.dependencies import (
@@ -51,6 +53,8 @@ from packages.application.tts_job.use_cases import (
)
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
from packages.domain.voice_presets import list_voices
from packages.ports.asset_library_repository import AssetLibraryRepository
from packages.ports.asset_repository import AssetRepository
@@ -140,6 +144,31 @@ def synthesize(
"""
user_id = authenticated_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_voice"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
# 中文按 ~240 字/分钟粗估时长,至少按 1 分钟扣 1 分
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(authenticated_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(authenticated_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
actual_voice_id = request.voice_id
@@ -202,6 +231,7 @@ def synthesize(
cosyvoice_service=cosyvoice_service,
)
synthesis_error: Exception | None = None
try:
job = workflow.start_synthesis(job.id)
except Exception as e:
@@ -209,10 +239,18 @@ def synthesize(
# 但 DB 异常、网络异常等意外错误可能逃逸。
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
synthesis_error = e
try:
job = workflow.process_synthesis_failure(job.id, str(e))
except Exception as inner_e:
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
# 合成失败且已扣积分 → 退费
if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}")
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
if job.status.value == "processing":
# 分段合成任务 vs 普通单段任务
@@ -231,6 +269,13 @@ def synthesize(
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
# 调度失败退费
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"Celery 调度失败退积分异常: job_id={job.id}, err={refund_err}")
return TTSSynthesizeResponse(
job_id=job.id,
status=job.status,
@@ -565,6 +610,31 @@ def preview_tts(
用于前端预览配音效果,限制文本长度 200 字以内。
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
"""
user_id = authenticated_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_voice"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(authenticated_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(authenticated_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
actual_voice_id = request.voice_id
profile = voice_clone_repo.get(request.voice_id)
@@ -594,6 +664,12 @@ def preview_tts(
language=getattr(request, "language", "zh-CN"),
)
except (CosyVoiceError, ValueError) as e:
# 合成失败退费
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预览失败退积分异常: {refund_err}")
if isinstance(e, CosyVoiceError):
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS 合成失败: {e}") from e
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+28 -131
View File
@@ -191,23 +191,6 @@ def _find_duplicate_asset(
return None
def _get_existing_asset_url(existing: Any, storage_service: Any) -> str:
"""安全获取已存在素材的公网 URL,兼容 domain Asset(无 file_url 字段)和 ORM model。"""
# Domain Asset 只有 storage_key 字段;ORM model 有 file_url 但存的也是 storage_key
key = ""
for attr in ("storage_key", "file_url"):
v = getattr(existing, attr, None)
if v:
key = v
break
if not key:
return ""
try:
return storage_service.get_url(key) or ""
except Exception:
return ""
def _create_pending_asset(
asset_repository,
project_id,
@@ -307,43 +290,6 @@ def _submit_ingest_job(
return job
def _find_active_ingest_job(ingest_job_repository: Any, asset_id: str) -> Any | None:
"""查询 asset 上是否存在"仍在跑或已成功"的 ingest job(FAILED 视为不存在,需重提)。"""
if not asset_id:
return None
find = getattr(ingest_job_repository, "find_by_asset_id", None)
if not callable(find):
# 旧仓储未实现 find_by_asset_id,无法判断 → 保守返回 None(走正常流程,
# _submit_ingest_job 自身有数据库唯一约束/幂等兜底,不会重复建 job)
return None
try:
return find(asset_id)
except Exception: # noqa: BLE001
logger.warning("[upload] find_by_asset_id 查询失败,按无 job 处理: asset=%s", asset_id, exc_info=True)
return None
def _is_true_duplicate(existing_asset: Asset, ingest_job_repository: Any) -> tuple[bool, Any | None]:
"""判断 `existing_asset` 是真重复(应短路返 duplicated)还是占位(应补提 ingest)。
返回 (is_duplicate, existing_job):
- READY 素材:真重复,job 可能为 None(已就绪不需要 job_id)
- PROCESSING/UPLOADING 且已有在跑/已完成 ingest job:幂等重试,真重复,job 返回给前端轮询
- PROCESSING/UPLOADING 且无 job:prepare 建的占位 / 之前 ingest 创建失败 → 非重复,需补提 ingest
- ERROR/DELETED:非重复(允许重新上传覆盖)
"""
status = getattr(existing_asset, "status", None)
if status == AssetStatus.READY:
return True, None
if status in (AssetStatus.PROCESSING, AssetStatus.UPLOADING):
job = _find_active_ingest_job(ingest_job_repository, existing_asset.id)
if job is not None:
return True, job
return False, None
# ERROR / DELETED / 其它:走正常流程重新 ingest
return False, None
@router.post("/direct/prepare", response_model=DirectUploadPrepareResponse)
async def prepare_direct_upload(
request: DirectUploadPrepareRequest,
@@ -407,7 +353,6 @@ async def prepare_direct_upload(
duplicated=True,
skip_transfer=True,
asset_id=existing.id,
url=_get_existing_asset_url(existing, storage_service),
)
file_id = uuid4().hex[:8]
@@ -461,7 +406,6 @@ async def prepare_direct_upload(
duplicated=False,
skip_transfer=False,
asset_id=pending_asset_id,
url="",
)
@@ -500,25 +444,12 @@ async def complete_direct_upload(
file_size=request.file_size,
)
if existing is not None:
is_dup, existing_job = _is_true_duplicate(existing, ingest_job_repository)
if is_dup:
logger.info(
"[upload] complete 幂等命中真重复: asset=%s status=%s job=%s",
existing.id,
getattr(existing, "status", None),
getattr(existing_job, "id", None),
)
return DirectUploadCompleteResponse(
storage_key=existing.storage_key,
ingest_job_id=getattr(existing_job, "id", "") or "",
duplicated=True,
asset_id=existing.id,
url=storage_service.get_url(existing.storage_key),
)
logger.info(
"[upload] complete 命中占位 asset(status=%s 无 ingest job),继续补提 ingest: asset=%s",
getattr(existing, "status", None),
existing.id,
return DirectUploadCompleteResponse(
storage_key=existing.storage_key,
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
url=storage_service.get_url(existing.storage_key),
)
try:
@@ -548,24 +479,14 @@ async def complete_direct_upload(
)
# Issue #1776: 计数由 asset_repository.create() 自动维护
# 幂等保护:补提占位场景下可能已有 job(极端竞态),先查一次
existing_job = _find_active_ingest_job(ingest_job_repository, pending_asset.id)
if existing_job is not None:
logger.info(
"[upload] complete 补提时发现 job 已存在(竞态/并发重试),复用: asset=%s job=%s",
pending_asset.id,
existing_job.id,
)
job = existing_job
else:
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
asset_id=pending_asset.id,
)
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
asset_id=pending_asset.id,
)
return DirectUploadCompleteResponse(
storage_key=normalized_key,
ingest_job_id=job.id,
@@ -612,27 +533,12 @@ async def upload_asset(
file_size=0,
)
if existing is not None:
is_dup, existing_job = _is_true_duplicate(existing, ingest_job_repository)
if is_dup:
logger.info(
"[upload] multipart 幂等命中真重复: asset=%s status=%s job=%s",
existing.id,
getattr(existing, "status", None),
getattr(existing_job, "id", None),
)
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id=getattr(existing_job, "id", "") or "",
url=storage_service.get_url(existing.storage_key)
if getattr(existing, "status", None) == AssetStatus.READY
else "",
duplicated=True,
asset_id=existing.id,
)
logger.info(
"[upload] multipart 命中占位 asset(status=%s 无 ingest job),继续补提 ingest: asset=%s",
getattr(existing, "status", None),
existing.id,
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id="",
url="",
duplicated=True,
asset_id=existing.id,
)
file_id = uuid4().hex[:8]
@@ -668,23 +574,14 @@ async def upload_asset(
)
# Issue #1776: 计数由 asset_repository.create() 自动维护
existing_job = _find_active_ingest_job(ingest_job_repository, pending_asset.id)
if existing_job is not None:
logger.info(
"[upload] multipart 补提时发现 job 已存在(竞态/并发重试),复用: asset=%s job=%s",
pending_asset.id,
existing_job.id,
)
job = existing_job
else:
job = _submit_ingest_job(
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
file_hash=file_hash,
asset_id=pending_asset.id,
)
job = _submit_ingest_job(
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
file_hash=file_hash,
asset_id=pending_asset.id,
)
return UploadAssetResponse(
storage_key=storage_key,
-984
View File
@@ -1,984 +0,0 @@
"""爆款视频 API 路由。
v1.6 三步分步流水线端点(单次 Seedance 出片版):
POST /api/v1/viral-video/analyze-images 阶段1:创建任务 + 仅做图片/视频分析,暂停在 image_analyzed
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染,直到完成
旧端点(兼容保留,旧前端/一键生成模式):
POST /api/v1/viral-video/generate 一键入队,前半段跑到 wait_user_confirm
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
通用:
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
GET /api/v1/viral-video/history 历史记录
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析
GET /api/v1/viral-video/style-templates 风格模板列表
WS /api/v1/viral-video/ws/{job_id}?token= WebSocket 进度推送
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_db_session
from app.schemas.viral_video import (
AnalyzeImagesRequest,
AnalyzeStyleRequest,
AnalyzeStyleResponse,
ConfirmCopyRequest,
ConfirmIntentRequest,
CreateViralVideoRequest,
CreditsFormulaBreakdown,
EstimateCreditsRequest,
EstimateCreditsResponse,
GenerateCopyRequest,
RetryViralVideoRequest,
StyleTemplateListResponse,
StyleTemplateResponse,
ViralVideoHistoryResponse,
ViralVideoJobResponse,
)
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
SQLAlchemyViralVideoJobRepository,
SQLAlchemyViralVideoStyleTemplateRepository,
)
from packages.domain.points_rules import list_viral_video_models
from packages.domain.viral_video import ViralVideoStatus
from packages.shared.dashscope_client import get_dashscope_client
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Helpers ──────────────────────────────────────────────────────────────
def _build_copy_result(job) -> dict | None:
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
"""
cr = getattr(job, "copy_result", None)
if isinstance(cr, dict) and cr:
out = dict(cr)
# 向后兼容字段
voiceover = out.get("voiceover_script", "") or ""
out.setdefault("final_copy", voiceover)
out.setdefault("suggested_copy", voiceover)
out.setdefault("title", "")
return out
# 兼容 v1.5 老数据:storyboard 是老格式 [{order,type,description,text,duration,...}]
copy_text = getattr(job, "generated_copy_text", "") or ""
sb = getattr(job, "storyboard", None) or []
intent = getattr(job, "intent_result", None) or {}
if not copy_text and not sb:
return None
title = ""
if isinstance(intent, dict):
title = intent.get("suggested_title") or intent.get("intent", "") or ""
shots = []
for seg in sb:
if isinstance(seg, dict):
shots.append(
{
"time_range": "",
"shot_type_angle_movement": seg.get("ken_burns", ""),
"scene_and_dialogue": (seg.get("text") or "")
+ (" " + seg.get("description", "") if seg.get("description") else ""),
"action_details": "",
"audio_bgm": "",
"transition": seg.get("transition", "硬切"),
"reference_image_index": None,
}
)
ratio = getattr(job, "video_ratio", None) or "9:16"
return {
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
"scene_and_lighting": "",
"shots": shots,
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
"voiceover_script": copy_text,
"final_copy": copy_text,
"suggested_copy": copy_text,
"title": title,
}
def _to_response(job) -> ViralVideoJobResponse:
return ViralVideoJobResponse(
id=job.id,
user_id=job.user_id,
images=job.images,
industry=job.industry,
target_customer=job.target_customer,
persona_id=job.persona_id,
viral_structure=job.viral_structure,
marketing_purpose=job.marketing_purpose,
bgm_preference=job.bgm_preference,
duration=job.duration or 15,
user_copy_text=job.user_copy_text,
fusion_level=job.fusion_level,
reference_audio_path=job.reference_audio_path,
reference_video_url=job.reference_video_url,
style_strength=job.style_strength,
style_guide=job.style_guide,
style_template_id=job.style_template_id,
status=job.status,
current_stage=getattr(job, "current_stage", "") or "",
phase_message=getattr(job, "phase_message", "") or "",
image_analysis=getattr(job, "image_analysis", None),
storyboard=getattr(job, "storyboard", None),
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
copy_result=_build_copy_result(job),
voice_id=getattr(job, "voice_id", "") or "",
voice_source=getattr(job, "voice_source", "") or "",
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
video_model=getattr(job, "video_model", "") or "",
intent_result=job.intent_result,
result_video_url=job.result_video_url,
pre_trusted_images=getattr(job, "pre_trusted_images", None) or None,
video_resolution=getattr(job, "video_resolution", "720p") or "720p",
credits_prepaid=float(getattr(job, "credits_prepaid", 0) or 0),
credits_cost=float(getattr(job, "credits_cost", 0) or 0),
error_msg=job.error_msg,
retry_count=job.retry_count,
started_at=job.started_at,
completed_at=job.completed_at,
created_at=job.created_at,
updated_at=job.updated_at,
)
def _get_job_repo(session: Session) -> SQLAlchemyViralVideoJobRepository:
return SQLAlchemyViralVideoJobRepository(session)
def _get_style_repo(session: Session) -> SQLAlchemyViralVideoStyleTemplateRepository:
return SQLAlchemyViralVideoStyleTemplateRepository(session)
# ── Endpoints ────────────────────────────────────────────────────────────
@router.post("/generate", response_model=ViralVideoJobResponse)
def create_viral_video(
request: CreateViralVideoRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""创建爆款视频任务,入队 Celery 编排器。"""
from packages.domain.viral_video import ViralVideoJob
repo = _get_job_repo(session)
# 创建领域实体
job = ViralVideoJob(
user_id=authenticated_user.user.id,
images=list(request.images),
industry=request.industry,
target_customer=request.target_customer,
persona_id=request.persona_id,
viral_structure=request.viral_structure,
marketing_purpose=request.marketing_purpose,
bgm_preference=request.bgm_preference,
duration=request.duration or 15,
user_copy_text=request.user_copy_text,
fusion_level=request.fusion_level,
reference_audio_path=request.reference_audio_path,
reference_video_url=request.reference_video_url,
style_strength=request.style_strength,
style_template_id=request.style_template_id,
voice_id=getattr(request, "voice_id", "") or "",
voice_source=getattr(request, "voice_source", "") or "",
video_ratio=getattr(request, "video_ratio", "9:16") or "9:16",
video_model=getattr(request, "video_model", "") or "",
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
copy_result=None,
)
# 持久化
repo.save(job)
# 入队 Celery 任务
try:
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 任务已入队: job_id=%s user_id=%s", job.id, job.user_id)
except Exception as e:
logger.error("[爆款视频] 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/analyze-images", response_model=ViralVideoJobResponse)
def analyze_images(
request: AnalyzeImagesRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.5 阶段1:创建任务并仅做图片/视频 VLM 分析,跑完后状态=image_analyzed。
前端拿到 image_analysis(商品名/品牌/特征/颜色/材质等结构化结果)展示给用户;
用户填完营销参数后再调 /{id}/generate-copy 进入阶段2。
"""
from packages.domain.viral_video import ViralVideoJob
repo = _get_job_repo(session)
job = ViralVideoJob(
user_id=authenticated_user.user.id,
images=list(request.images),
reference_video_url=request.reference_video_url or "",
style_template_id=request.style_template_id or "",
style_strength=request.style_strength or "medium",
voice_id=request.voice_id or "",
voice_source=request.voice_source or "",
video_ratio=request.video_ratio or "9:16",
video_model=request.video_model or "",
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
duration=request.duration or 15,
)
repo.save(job)
try:
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
logger.info("[爆款视频][阶段1] analyze-images 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段1] analyze-images 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/generate-copy", response_model=ViralVideoJobResponse)
def generate_copy(
job_id: str,
request: GenerateCopyRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.6 阶段2:用户填完营销参数后,跑 意图解析 → 编导分镜脚本生成 → 合规审核。
跑完后状态=copy_generated,响应 copy_result(含 overview/scene_and_lighting/shots/
hard_constraints/negative_prompts/voiceover_script),前端展示脚本与口播供用户编辑;
确认/编辑后调 /{id}/confirm-copy 进入阶段3(TTS + 单次 Seedance 出片)。
"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING, ViralVideoStatus.FAILED):
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能生成文案")
# 允许失败任务重试:重置
if job.status == ViralVideoStatus.FAILED:
job.retry_count += 1
job.error_msg = ""
# 把用户填的营销参数写到 job 上
job.industry = request.industry or job.industry
job.target_customer = request.target_customer or job.target_customer
job.persona_id = request.persona_id or job.persona_id
job.viral_structure = request.viral_structure or job.viral_structure
job.marketing_purpose = request.marketing_purpose or job.marketing_purpose
job.bgm_preference = request.bgm_preference or job.bgm_preference
if request.duration:
job.duration = max(5, min(30, int(request.duration)))
job.user_copy_text = request.user_copy_text if request.user_copy_text else job.user_copy_text
job.fusion_level = request.fusion_level or job.fusion_level
job.reference_audio_path = request.reference_audio_path or job.reference_audio_path
job.reference_video_url = request.reference_video_url or job.reference_video_url
job.style_strength = request.style_strength or job.style_strength
job.style_template_id = request.style_template_id or job.style_template_id
if request.style_guide is not None:
job.style_guide = request.style_guide
job.voice_id = request.voice_id or job.voice_id
job.voice_source = request.voice_source or job.voice_source
job.video_ratio = request.video_ratio or job.video_ratio or "9:16"
job.video_model = request.video_model or job.video_model or ""
job.video_resolution = getattr(request, "video_resolution", "") or job.video_resolution or "720p"
job.resume_from_image_analyzed()
repo.update(job)
try:
celery_app.send_task("worker.run_viral_video_generate_copy", args=[job.id])
logger.info("[爆款视频][阶段2] generate-copy 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段2] generate-copy 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/confirm-copy", response_model=ViralVideoJobResponse)
def confirm_copy(
job_id: str,
request: ConfirmCopyRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.6 阶段3:用户确认/编辑口播后开始 TTS + 单次 Seedance 生成 + 上传。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.COPY_GENERATED:
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能确认文案(需 copy_generated)")
# #2218: 额外校验 copy_result 完整性,防止孤儿/脏数据进入渲染
if not isinstance(job.copy_result, dict) or not job.copy_result:
raise HTTPException(status_code=409, detail="文案数据缺失,请先点击「生成文案」")
# 积分预扣(已扣过/重试任务跳过)
from app.config import settings as _settings
if _settings.points_enabled:
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
float(getattr(job, "credits_cost", 0) or 0) > 0
)
if not already_paid:
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
from packages.domain.points_service import PointsService
w, h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p",
job.video_ratio or "9:16",
)
est_credits = calculate_viral_video_credits(
int(job.duration or 15), w, h, job.video_model or "seedance-2.5"
)
svc = PointsService()
res = svc.deduct_viral_video(authenticated_user.user.id, est_credits, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {est_credits} 积分,当前余额 {balance}",
"required": est_credits,
"balance": balance,
},
)
job.credits_prepaid = est_credits
job.credits_transaction_id = res.get("transaction_id", "") or ""
repo.update(job)
job.resume_from_copy_generated(edited_copy=request.edited_copy or None)
repo.update(job)
try:
celery_app.send_task("worker.run_viral_video_render", args=[job.id])
logger.info("[爆款视频][阶段3] confirm-copy 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段3] confirm-copy 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/estimate-credits", response_model=EstimateCreditsResponse)
def estimate_credits(
request: EstimateCreditsRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> EstimateCreditsResponse:
"""爆款视频积分预估(纯计算,不扣费、不创建任务)。
返回 estimated_credits 与 formula_breakdown(tokens / video_cost / fixed_cost /
profit_multiplier / model_price / width / height / fps),便于前端展示计费明细。
同时兼容前端传 model 或 video_model、resolution 或 video_resolution、ratio 或 video_ratio。
"""
from packages.domain.points_rules import (
calculate_viral_video_credits_with_breakdown,
resolve_video_dimensions,
)
model = (request.model or "").strip() or "seedance-2.5"
resolution = (request.resolution or "").strip() or "720p"
ratio = (request.ratio or "").strip() or "9:16"
duration = int(request.duration or 15)
w, h = resolve_video_dimensions(resolution, ratio)
credits, bd = calculate_viral_video_credits_with_breakdown(
duration,
w,
h,
model,
)
breakdown = CreditsFormulaBreakdown(**bd)
return EstimateCreditsResponse(estimated_credits=credits, formula_breakdown=breakdown)
@router.get("/history", response_model=ViralVideoHistoryResponse)
def list_viral_video_history(
limit: int = 50,
offset: int = 0,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoHistoryResponse:
"""获取用户的爆款视频历史列表。"""
repo = _get_job_repo(session)
jobs = repo.list_by_user(authenticated_user.user.id, limit=limit, offset=offset)
items = [_to_response(j) for j in jobs]
return ViralVideoHistoryResponse(items=items, total=len(items))
@router.get("/style-templates", response_model=StyleTemplateListResponse)
def list_style_templates(
session: Session = Depends(get_db_session),
) -> StyleTemplateListResponse:
"""获取风格模板列表。"""
repo = _get_style_repo(session)
templates = repo.list_all()
items = [
StyleTemplateResponse(
id=t["id"],
name=t["name"],
description=t["description"],
thumbnail_url=t["thumbnail_url"],
style_config=t["style_config"],
)
for t in templates
]
return StyleTemplateListResponse(items=items)
@router.get("/models")
def list_available_models() -> dict:
"""返回爆款视频可用模型列表(供前端模型选择器使用)。"""
dashscope_available = get_dashscope_client() is not None
models = list_viral_video_models(
include_placeholder=False,
dashscope_available=dashscope_available,
)
return {"models": models}
@router.get("/{job_id}", response_model=ViralVideoJobResponse)
def get_viral_video_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""查询爆款视频任务状态。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权查看此任务")
return _to_response(job)
@router.post("/{job_id}/retry", response_model=ViralVideoJobResponse)
def retry_viral_video_job(
job_id: str,
request: RetryViralVideoRequest | None = None,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""重试失败的爆款视频任务(也支持对僵尸/超时 running 任务强制重置后重试)。
可选 body (RetryViralVideoRequest):若传入新的 duration/video_resolution/video_ratio/
video_model,会重新预估积分并与原 credits_prepaid 做差额多退少补(不足抛 402 阻止重试);
不传 body 或参数无变化时,保持原参数、原预扣金额不变,仅重置状态并入队。
credits_prepaid 为 0 的老任务首次重试会走预扣流程(与 confirm-copy 一致)。
"""
from datetime import datetime, timezone
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
# 判定是否为僵尸 running 任务:running 超过 10 分钟且心跳停止超过 2 分钟
now = datetime.now(timezone.utc)
is_stale_running = False
if job.status == ViralVideoStatus.RUNNING and job.started_at is not None:
hb = getattr(job, "heartbeat_at", None) or job.updated_at
if (now - job.started_at).total_seconds() > 10 * 60 and hb is not None and (now - hb).total_seconds() > 2 * 60:
is_stale_running = True
if job.status != ViralVideoStatus.FAILED and not is_stale_running:
raise HTTPException(status_code=409, detail="只有失败或超时的任务可以重试")
# ── 参数变更检测 + 积分多退少补 ──────────────────────────────────────
req = request or RetryViralVideoRequest()
new_duration = req.duration
new_resolution = (req.video_resolution or "").strip() or None
new_ratio = (req.video_ratio or "").strip() or None
new_model = (req.video_model or "").strip() or None
old_duration = int(getattr(job, "duration", 15) or 15)
old_resolution = (getattr(job, "video_resolution", "720p") or "720p").strip() or "720p"
old_ratio = (getattr(job, "video_ratio", "9:16") or "9:16").strip() or "9:16"
old_model = (getattr(job, "video_model", "") or "").strip()
# 仅当有任意字段传入且值不同才算"参数变更"
param_changed = bool(
(new_duration is not None and int(new_duration) != old_duration)
or (new_resolution is not None and new_resolution != old_resolution)
or (new_ratio is not None and new_ratio != old_ratio)
or (new_model is not None and new_model != old_model)
)
from app.config import settings as _settings
need_points_settle = False
new_est = 0.0
if _settings.points_enabled and param_changed:
from packages.domain.points_rules import (
calculate_viral_video_credits_with_breakdown,
resolve_video_dimensions,
)
eff_dur = int(new_duration if new_duration is not None else old_duration)
eff_res = new_resolution if new_resolution is not None else old_resolution
eff_ratio = new_ratio if new_ratio is not None else old_ratio
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
w, h = resolve_video_dimensions(eff_res, eff_ratio)
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model or "seedance-2.5")
need_points_settle = True
# 写入新参数(即使不开 points 也要允许用户重试时改参数)
if new_duration is not None:
job.duration = max(5, min(30, int(new_duration)))
if new_resolution is not None:
job.video_resolution = new_resolution
if new_ratio is not None:
job.video_ratio = new_ratio
if new_model is not None:
job.video_model = new_model
if need_points_settle:
from packages.domain.points_service import PointsService
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
svc = PointsService()
diff = round(new_est - old_prepaid, 2)
if abs(diff) >= 0.01:
if diff > 0:
# 新预扣更多:补扣差额
res = svc.deduct_viral_video(authenticated_user.user.id, diff, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
"required": new_est,
"balance": balance,
"delta": diff,
},
)
job.credits_prepaid = round(old_prepaid + diff, 2)
logger.info(
"[爆款视频][retry] 补扣差额 job_id=%s diff=%.2f new_prepaid=%.2f",
job.id,
diff,
job.credits_prepaid,
)
else:
# 新预扣更少:退还差额
refund = round(-diff, 2)
txn_id = getattr(job, "credits_transaction_id", "") or ""
svc.refund_points(
user_id=authenticated_user.user.id,
amount=refund,
source="viral_video",
db=session,
ref_id=txn_id or job.id,
description="爆款视频重试参数变更退费",
)
job.credits_prepaid = round(old_prepaid - refund, 2)
logger.info(
"[爆款视频][retry] 退还差额 job_id=%s refund=%.2f new_prepaid=%.2f",
job.id,
refund,
job.credits_prepaid,
)
# 差额为 0 则不调整
# 重置状态
job.retry_count += 1
job.status = ViralVideoStatus.PENDING
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
job.started_at = None
job.completed_at = None
job.current_stage = ""
job.phase_message = ""
job.heartbeat_at = None
repo.update(job)
# 重新入队
try:
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info(
"[爆款视频] 重试入队: job_id=%s retry_count=%d stale=%s params_changed=%s",
job.id,
job.retry_count,
is_stale_running,
param_changed,
)
except Exception as e:
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
job.mark_failed(f"重试入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/confirm-intent", response_model=ViralVideoJobResponse)
def confirm_intent(
job_id: str,
request: ConfirmIntentRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""用户确认/修改 AI 生成的意图文案,恢复流水线。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.WAIT_USER_CONFIRM:
raise HTTPException(status_code=409, detail="任务当前不在等待确认状态")
# 更新文案
if request.confirmed_copy:
job.user_copy_text = request.confirmed_copy
# 恢复流水线
job.resume_from_confirm()
repo.update(job)
# 从断点恢复 Celery 任务
try:
celery_app.send_task("worker.resume_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 意图确认,恢复流水线: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频] 恢复流水线失败: %s", e, exc_info=True)
job.mark_failed(f"恢复流水线失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/analyze-style", response_model=AnalyzeStyleResponse)
def analyze_style(
job_id: str,
request: AnalyzeStyleRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> AnalyzeStyleResponse:
"""触发参考视频风格分析(独立步骤,可在生成前单独调用)。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
# 更新参考视频 URL
job.reference_video_url = request.reference_video_url
if request.style_template_id:
job.style_template_id = request.style_template_id
repo.update(job)
# 入队风格分析任务
try:
celery_app.send_task("worker.run_video_style_analysis", args=[job.id])
logger.info("[爆款视频] 风格分析入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频] 风格分析入队失败: %s", e, exc_info=True)
return AnalyzeStyleResponse(
job_id=job.id,
status="analyzing",
style_guide=None,
)
# ── WebSocket 进度推送 ──────────────────────────────────────────────────
def _ws_authenticate_user(token: str):
"""从 token 字符串解析用户(复用 HTTP Bearer 的解码 + 黑名单逻辑)。
WebSocket 握手阶段不能发自定义 Authorization header,
因此统一通过 query 参数 ``?token=...`` 传 JWT。
"""
from app.auth import _decode_user_token
from app.dependencies import get_user_repository
if not token:
return None
try:
payload = _decode_user_token(token)
except Exception:
return None
user_id = payload.get("sub")
if not isinstance(user_id, str) or not user_id:
return None
# 同步场景下手动拉 repository 实例
from app.db import SessionLocal
session = SessionLocal()
try:
user_repo = get_user_repository(session)
user = user_repo.find_by_id(user_id)
return user
finally:
session.close()
async def _run_pubsub_forwarder(
websocket, redis_lib, settings, job_id: str
) -> None: # pragma: no cover - integration tested (real Redis + thread)
"""订阅 Redis 频道并把消息桥接到 WebSocket,终态消息后自动关闭。
该函数封装了线程 + asyncio.Queue 桥接逻辑,在单测中可被整体替换为桩,
避免引入真实 Redis 与线程调度的不确定性。
"""
import asyncio
import json
import threading
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
pubsub = r.pubsub(ignore_subscribe_messages=True)
channel = f"viral_video:{job_id}"
pubsub.subscribe(channel)
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
stop_event = asyncio.Event()
def _reader() -> None:
try:
while not stop_event.is_set():
msg = pubsub.get_message(timeout=0.5)
if msg is None or msg.get("type") != "message":
continue
raw = msg.get("data")
if not isinstance(raw, str):
continue
try:
payload = json.loads(raw)
except Exception:
payload = {"type": "viral_video:progress", "data": {"raw": raw}}
loop.call_soon_threadsafe(queue.put_nowait, payload)
if payload.get("type") in ("viral_video:completed", "viral_video:failed"):
loop.call_soon_threadsafe(stop_event.set)
break
except Exception as e:
logger.warning("[爆款视频WS] pubsub reader 异常退出: %s", e)
loop.call_soon_threadsafe(stop_event.set)
try:
reader_thread = threading.Thread(target=_reader, name=f"viral-video-ws-{job_id}", daemon=True)
reader_thread.start()
while not stop_event.is_set():
try:
payload = await asyncio.wait_for(queue.get(), timeout=1.0)
except asyncio.TimeoutError:
continue
try:
await websocket.send_json(payload)
except Exception:
break
if payload.get("type") in ("viral_video:completed", "viral_video:failed"):
break
except WebSocketDisconnect:
logger.info("[爆款视频WS] 客户端断开: job_id=%s", job_id)
except Exception as e:
logger.error("[爆款视频WS] 转发异常: %s", e, exc_info=True)
try:
await websocket.send_json({"type": "viral_video:error", "message": f"服务异常: {e}"})
except Exception:
pass
finally:
stop_event.set()
try:
pubsub.unsubscribe(channel)
pubsub.close()
except Exception:
pass
try:
r.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
@router.websocket("/ws/{job_id}")
async def viral_video_websocket(websocket: WebSocket, job_id: str) -> None:
"""WebSocket 桥接:订阅 Redis `viral_video:{job_id}` 频道并转发给前端。
认证:通过 ``?token=<jwt>`` query 参数传 JWT(浏览器 WS 握手不支持自定义 header)。
事件类型:
- viral_video:progress 中间进度(progress: 0-100)
- viral_video:wait_user 等待用户确认意图文案
- viral_video:completed 任务完成(data.video_url)
- viral_video:failed 任务失败(data.error)
- viral_video:error 服务端错误(如鉴权失败 / job 不存在 / 无权限)
"""
import redis as redis_lib
from app.config import settings
# ── 1. 鉴权 ──────────────────────────────────────────────────────
token = websocket.query_params.get("token", "")
user = _ws_authenticate_user(token)
if user is None:
await websocket.close(code=4401, reason="Unauthorized")
return
# ── 2. 校验 job 归属 ─────────────────────────────────────────────
from app.db import SessionLocal
session = SessionLocal()
try:
job_repo = SQLAlchemyViralVideoJobRepository(session)
job = job_repo.get(job_id)
if job is None:
await websocket.close(code=4404, reason="Job not found")
return
if job.user_id != user.id:
await websocket.close(code=4403, reason="Forbidden")
return
finally:
session.close()
await websocket.accept()
# ── 3. 发送一条初始状态(前端连接后立即拿到当前进度) ────────────
try:
session = SessionLocal()
job_repo = SQLAlchemyViralVideoJobRepository(session)
job = job_repo.get(job_id)
if job is not None:
status_val = job.status.value if hasattr(job.status, "value") else str(job.status)
initial = {
"type": "viral_video:progress",
"job_id": job_id,
"stage": _stage_from_status(job),
"progress": _estimate_progress(job),
"message": _initial_message(job),
"data": {"status": status_val},
}
await websocket.send_json(initial)
# 已经终态 → 再发一条终态事件后立即关闭,避免占连接
if job.is_terminal:
is_completed = status_val == "completed"
terminal_type = "viral_video:completed" if is_completed else "viral_video:failed"
terminal_data = (
{"video_url": job.result_video_url or ""} if is_completed else {"error": job.error_msg or ""}
)
await websocket.send_json(
{
"type": terminal_type,
"job_id": job_id,
"stage": "",
"progress": 100 if is_completed else 0,
"message": "视频生成完成" if is_completed else "任务失败",
"data": terminal_data,
}
)
await websocket.close()
return
session.close()
except Exception as e:
logger.warning("[爆款视频WS] 发送初始状态失败: %s", e)
try:
session.close()
except Exception:
pass
# ── 4. 订阅 Redis 频道并转发 ─────────────────────────────────────
# redis-py 的 pubsub 是同步阻塞的,放到线程里跑,通过 asyncio.Queue 桥接到 event loop。
# 该段依赖真实 Redis + 线程调度,属于集成测试范围,单测通过桩替换。
await _run_pubsub_forwarder(websocket, redis_lib, settings, job_id)
def _job_status(job) -> str:
return job.status.value if hasattr(job.status, "value") else str(job.status)
# 初始快照的 stage 推断:领域对象不持久化 stage,
# 只能根据 status 给一个占位,后续 worker 推送的真实进度事件会覆盖。
_STATUS_STAGE = {
"pending": "",
"running": "",
"image_analyzed": "image_analysis",
"copy_generated": "review",
"wait_user_confirm": "intent_parsing",
"completed": "uploading",
"failed": "",
"cancelled": "",
}
_STATUS_PROGRESS = {
"pending": 0.0,
"running": 5.0,
"image_analyzed": 15.0,
"copy_generated": 70.0,
"wait_user_confirm": 35.0,
"completed": 100.0,
"failed": 0.0,
"cancelled": 0.0,
}
_STATUS_MESSAGE = {
"pending": "任务已创建,等待执行",
"running": "任务执行中",
"image_analyzed": "图片分析完成,等待填写营销参数",
"copy_generated": "文案与分镜已生成,等待确认文案",
"wait_user_confirm": "等待用户确认意图文案",
"completed": "视频生成完成",
"failed": "任务失败",
"cancelled": "任务已取消",
}
def _stage_from_status(job) -> str:
return _STATUS_STAGE.get(_job_status(job), "")
def _estimate_progress(job) -> float:
"""根据 status 粗略估算百分比(0-100),用于连接初始快照;
连接建立后由 Redis 推送的真实事件持续更新。
"""
return _STATUS_PROGRESS.get(_job_status(job), 5.0)
def _initial_message(job) -> str:
"""给新连接的前端一个可读的初始状态文案。"""
status_val = _job_status(job)
if status_val == "failed" and job.error_msg:
return f"任务失败: {job.error_msg}"
return _STATUS_MESSAGE.get(status_val, "任务准备中")
+33 -24
View File
@@ -6,7 +6,7 @@ from app.core.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
USER_PENDING_LIMIT = 20 # 单用户 pending 上限(#2098: 从 3 提到 20,支持批量任务自动排队)
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值)
@@ -154,18 +154,19 @@ def check_queue_limits(
user_pending_limit: int = USER_PENDING_LIMIT,
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
) -> None:
"""检查队列限流(预检查用,任务创建前调用)。
"""检查队列限流(预检查用,任务创建前调用),超限抛对应异常。
#2098 语义变更:用户级限流改为软提示,不再抛异常拒绝;仅全局硬上限抛 GlobalQueueFull。
边界语义:>= 上限即拒绝(达到上限就不能再加新任务)。
Args:
user_id: 用户 ID(保留参数,当前不做用户级硬拒)
user_id: 用户 ID
generation_task_repository: 任务仓储
user_pending_limit: 单用户 pending 上限(保留,当前未硬拒)
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
Raises:
GlobalQueueFull: 全局超限时抛出
GlobalQueueFull: 全局超限时抛出(优先级更高,先查全局)
UserPendingLimitExceeded: 用户超限时抛出
"""
# 先查全局(系统级保护优先级更高)
global_pending = generation_task_repository.count_pending_total()
@@ -178,9 +179,17 @@ def check_queue_limits(
)
raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
# #2098: 用户级限流改为软提示,不在预检查阶段拒绝(超额任务仍入队排队)。
# 真正的系统保护由全局 GLOBAL_PENDING_LIMIT 硬上限承担。
# UserPendingLimitExceeded 保留以兼容历史 import/except,但预检查与 safe_enqueue 均不再 raise。
# 再查用户级
if user_id:
user_pending = generation_task_repository.count_pending_by_user(user_id)
if user_pending >= user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 任务数超限: user_id=%s, count=%d/%d",
user_id,
user_pending,
user_pending_limit,
)
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
def _mark_task_failed_safely(
@@ -237,6 +246,7 @@ def safe_enqueue_generation_task(
Raises:
GlobalQueueFull: 全局 pending 超限时抛出,任务会被标记为 failed
UserPendingLimitExceeded: 用户 pending 超限时抛出,任务会被标记为 failed
"""
# ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ──
@@ -253,18 +263,19 @@ def safe_enqueue_generation_task(
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# Bug B #2098: 用户级限流改为软提示,不再硬拒;所有任务都入队等待 worker 自然消费。
# user_pending_limit 作为兜底阈值保留(默认 20),达到时打 warning 日志但仍入队,
# 避免极端情况下恶意用户无限堆积任务。真正的系统保护由全局 GLOBAL_PENDING_LIMIT 承担。
# 用户级限流检查(传了 user_id 才做)
if user_id:
user_pending = generation_task_repository.count_pending_by_user(user_id)
if user_pending > user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 任务数超过软上限(入队): user_id=%s, count=%d/%d, 仍允许入队排队",
"[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d",
user_id,
user_pending,
user_pending_limit,
)
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# ── 发送 Celery 任务 ──
try:
@@ -306,18 +317,16 @@ def safe_enqueue_generation_task(
user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0
global_over = global_after > global_pending_limit
# Bug B #2098: 用户超限仅日志警告,不回滚任务
if user_id and user_after > user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 超软上限(入队后): user_id=%s, count=%d/%d",
user_id,
user_after,
user_pending_limit,
)
user_over = bool(user_id and user_after > user_pending_limit)
if global_over or user_over:
if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
else:
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
logger.warning(
"[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed",
reason,
+2 -2
View File
@@ -7,9 +7,9 @@ from packages.adapters.sqlalchemy_impl import (
)
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
ensure_database_exists(settings.effective_database_url)
ensure_database_exists(settings.DATABASE_URL)
engine, SessionLocal = build_session_factory(
settings.effective_database_url,
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
+1 -1
View File
@@ -56,7 +56,7 @@ from packages.adapters.sqlalchemy_impl.voice_library_repository import (
from packages.ports.tag_repository import TagRepository
from packages.ports.user_repository import UserRepository
_engine, _SessionLocal = build_session_factory(settings.effective_database_url)
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
def get_db_session() -> Generator[Session, None, None]:
+10 -10
View File
@@ -13,9 +13,9 @@ 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="会员到期时间")
@@ -30,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
@@ -99,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
@@ -112,7 +112,7 @@ class PointsDeductRequest(BaseModel):
"""积分扣减请求"""
scene_key: str
amount: float
amount: int
description: Optional[str] = ""
ref_id: Optional[str] = ""
@@ -170,7 +170,7 @@ class MembershipStatusResponse(BaseModel):
is_member: bool
member_type: Optional[str] = None
member_expires_at: Optional[datetime] = None
points_balance: float
points_balance: int
max_resolution: str = Field(
default="1080p",
description="可用最高分辨率: 720p(free) / 1080p(paid)",
-2
View File
@@ -29,8 +29,6 @@ class DirectUploadPrepareResponse(BaseModel):
duplicated: bool = False
skip_transfer: bool = False
asset_id: str = ""
# duplicated=true 时填充已存在素材的公网 URL,前端可直接用而不必再调 complete
url: str = Field(default="", description="duplicated=true 时已存在素材的公网 URL")
class DirectUploadCompleteRequest(BaseModel):
-320
View File
@@ -1,320 +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 = ""
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)
class GenerateCopyRequest(BaseModel):
"""v1.5+ 阶段2:填完营销参数,生成编导脚本。"""
industry: str = ""
target_customer: str = ""
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = Field(default=15, ge=5, le=30)
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
reference_video_url: str = ""
style_strength: str = "medium"
style_template_id: str = ""
style_guide: dict | None = None
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
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")
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 = ""
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,
)
-157
View File
@@ -38,7 +38,6 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError
from packages.config import get_api_settings
from packages.domain import feature_pricing_service
from packages.domain.sentence_timings import (
compute_sentence_timings,
probe_audio_duration,
@@ -369,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",
@@ -418,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(
@@ -584,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(
@@ -629,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()
@@ -826,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
# 未提交的任务不轮询
@@ -853,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
@@ -872,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:
@@ -967,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(
-29
View File
@@ -104,7 +104,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[lipsync_gpu_async] GPU 任务已被用户取消: job_id=%s", job_id)
_refund_lip_sync(db, job)
return
if final_task.status != "done":
@@ -142,7 +141,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
job_id,
job.output_duration,
)
_settle_lip_sync(db, job, final_task)
except Exception as exc:
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
try:
@@ -159,33 +157,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
db.close()
def _settle_lip_sync(db: Session, job: LipsyncJobModel, gpu_task) -> None:
"""GPU 成功后结算:同步 credits_cost 到 gpu 任务并按实际时长多退少不补。"""
try:
from app.services.lipsync_service import LipsyncService
# GPU 任务表先同步结算结果(标记用)
LipsyncService._settle_lip_sync(job, float(getattr(gpu_task, "result_duration", 0) or 0.0))
gpu_task.credits_cost = float(job.credits_cost or 0.0)
db.commit()
except Exception: # noqa: BLE001
logger.exception("[lipsync_gpu_async] lip_sync 结算异常 job_id=%s(不阻塞)", job.id)
try:
db.rollback()
except Exception: # noqa: BLE001
pass
def _refund_lip_sync(db: Session, job: LipsyncJobModel) -> None:
"""GPU 取消/失败路径全额退款。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._refund_lip_sync(job)
except Exception: # noqa: BLE001
logger.exception("[lipsync_gpu_async] lip_sync 退款异常 job_id=%s", job.id)
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
"""GPU 失败时回退到 MediaKit 云端渲染。"""
try:
-2
View File
@@ -150,8 +150,6 @@ export interface DirectUploadPrepareResult {
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
*/
skip_transfer?: boolean
/** duplicated=true 时后端返回已存在素材的公网 URL,前端直接用而不必再调 complete */
url?: string
}
/** 直传完成确认返回 */
+6 -47
View File
@@ -3,24 +3,9 @@
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import { ensureDefaultLibrary } from "./libraries"
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
import { computeFileHash, makeClientUploadId } from "./uploadDedup"
/** 根据 File.type 推断素材库 kind(image/video/voice);无法推断时默认 image */
function inferKindFromFile(file: File): "image" | "video" | "voice" {
const t = (file.type || "").toLowerCase()
if (t.startsWith("image/")) return "image"
if (t.startsWith("video/")) return "video"
if (t.startsWith("audio/")) return "voice"
// 兜底:按扩展名再判一次
const name = file.name.toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg|avif)$/.test(name)) return "image"
if (/\.(mp4|mov|webm|avi|mkv|flv|wmv|m4v)$/.test(name)) return "video"
if (/\.(mp3|wav|m4a|aac|ogg|flac|opus|webm)$/.test(name)) return "voice"
return "image"
}
/** 预签名直传准备 */
export const prepareDirectUpload = async (data: {
project_id: string
@@ -123,8 +108,6 @@ const putToOSS = (
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */
export interface DirectUploadHandle {
/** 实际使用的素材库(内部解析出来,便于调用方做后续 UI/缓存操作) */
library: { id: string; kind: "image" | "video" | "voice" }
/** prepare 返回(含可能的预建 asset_id) */
prepared: DirectUploadPrepareResult
/** 直传 OSS(可重复调用用于重试) */
@@ -136,17 +119,10 @@ export interface DirectUploadHandle {
/**
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
*
* 修复 P0 404:library_id 改为可选;未传时自动根据文件类型在默认项目下确保对应素材库存在,
* 避免调用方从「全部素材库列表」里挑一个 library_id、但与默认项目 project_id 不匹配,
* 导致后端返回 "Asset library not found" 404。
*/
export const prepareDirectUploadHandle = async (data: {
file: File
/** 素材库 ID;未传时按文件类型自动在默认项目下 ensure-default */
library_id?: string
/** 显式指定素材库 kind;未传时按 MIME/扩展名推断 */
kind?: "image" | "video" | "voice"
library_id: string
/** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */
fileHash?: string
/** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */
@@ -162,17 +138,9 @@ export const prepareDirectUploadHandle = async (data: {
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
}
// 解析 library_id:调用方传了就用,没传就按 kind 自动 ensure-default
let resolvedLibraryId = data.library_id
const resolvedKind = data.kind ?? inferKindFromFile(data.file)
if (!resolvedLibraryId) {
const lib = await ensureDefaultLibrary({ project_id: project.id, kind: resolvedKind })
resolvedLibraryId = lib.id
}
const prepared = await prepareDirectUpload({
project_id: project.id,
library_id: resolvedLibraryId,
library_id: data.library_id,
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
@@ -181,13 +149,12 @@ export const prepareDirectUploadHandle = async (data: {
})
return {
library: { id: resolvedLibraryId, kind: resolvedKind },
prepared,
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
complete: () =>
completeDirectUpload({
project_id: project.id,
library_id: resolvedLibraryId,
library_id: data.library_id,
storage_key: prepared.storage_key,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
@@ -197,17 +164,10 @@ export const prepareDirectUploadHandle = async (data: {
}
}
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete
*
* P0 404 修复:library_id 可选;不传时内部按文件类型自动匹配正确项目下的素材库,
* 保证 project_id 与 library_id 必然一致。
*/
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
export const uploadAssetDirect = async (data: {
file: File
/** 素材库 ID;可选,不传按文件类型自动解析默认项目下的对应素材库(推荐用法) */
library_id?: string
/** 显式指定素材库 kind;未传时按文件 MIME/扩展名推断 */
kind?: "image" | "video" | "voice"
library_id: string
onProgress?: (percent: number) => void
/** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */
fileHash?: string
@@ -220,7 +180,6 @@ export const uploadAssetDirect = async (data: {
const handle = await prepareDirectUploadHandle({
file: data.file,
library_id: data.library_id,
kind: data.kind,
fileHash,
clientUploadId,
})
@@ -229,7 +188,7 @@ export const uploadAssetDirect = async (data: {
return {
storage_key: handle.prepared.storage_key,
ingest_job_id: "",
url: handle.prepared.url || "",
url: "",
duplicated: true,
asset_id: handle.prepared.asset_id,
}
+1 -2
View File
@@ -3,8 +3,7 @@
*/
/** 任务状态 */
export type TaskStatus =
"pending" | "waiting" | "running" | "awaiting_cover" | "completed" | "failed" | "cancelled"
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
/** 任务类型 */
export type TaskType = "ingest" | "generation" | string
-156
View File
@@ -1,156 +0,0 @@
import apiClient from "@/api/client"
import type {
GenerateViralVideoRequest,
HistoryResponse,
StyleTemplate,
ViralVideoJob,
ImageAnalysisResult,
CopyResult,
AnalyzeImagesRequest,
GenerateCopyRequest,
ConfirmCopyRequest,
ViralVideoModel,
ViralVideoModelsResponse,
} from "./types"
/** 创建爆款视频任务 */
export function generateViralVideo(payload: GenerateViralVideoRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/generate", payload).then((r) => r.data)
}
/** 查询单个任务 */
export function getViralVideoJob(id: string) {
return apiClient.get<ViralVideoJob>(`/viral-video/${id}`).then((r) => r.data)
}
/** 用户确认/修改 AI 理解的意图后继续 */
export function confirmViralVideoIntent(
id: string,
payload: { confirmed_copy?: string; edits?: Record<string, unknown> },
) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-intent`, payload)
.then((r) => r.data)
}
/** 重试失败任务 */
export function retryViralVideo(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/retry`).then((r) => r.data)
}
/** 历史记录(分页) */
export function getViralVideoHistory(params?: { page?: number; page_size?: number }) {
return apiClient.get<HistoryResponse>("/viral-video/history", { params }).then((r) => r.data)
}
/** 预设风格模板 */
export function getViralStyleTemplates() {
return apiClient.get<StyleTemplate[]>("/viral-video/style-templates").then((r) => r.data)
}
/** 上传参考视频后触发风格分析 */
export function analyzeViralStyle(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/analyze-style`).then((r) => r.data)
}
/** 动态预估积分消耗(STEP3 参数变化时调用) */
export function estimateViralVideoCredits(params: {
video_model: string
resolution: string
video_ratio: string
duration: number
}) {
return apiClient
.post<{ estimated_credits: number }>("/viral-video/estimate-credits", params)
.then((r) => r.data)
}
/** 获取支持的视频模型列表(GET /viral-video/models)。后端返回 {models: [...]} 包装 */
export function getViralVideoModels() {
return apiClient.get<ViralVideoModelsResponse>("/viral-video/models").then((r) => {
const data = r.data as ViralVideoModelsResponse | ViralVideoModel[] | null | undefined
if (Array.isArray(data)) return data
if (data && Array.isArray((data as ViralVideoModelsResponse).models)) {
return (data as ViralVideoModelsResponse).models
}
return []
})
}
/** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */
/**
* 客户端图片分析 mock(后端未提供 analyze-only 端点前的占位方案):
* 基于已上传图片生成一份示例识别汇览,让 STEP1→STEP2 交互可走通。
* 后端上线后改为调用真实接口。
*/
export function mockImageAnalysis(images: { name: string }[]): Promise<ImageAnalysisResult> {
return new Promise((resolve) => {
setTimeout(() => {
const products = images.slice(0, 3).map((img, i) => {
const n = img.name.replace(/\.[^.]+$/, "")
return {
name: n || `商品 ${i + 1}`,
spec: i === 0 ? "500ml/瓶" : i === 1 ? "300g/盒" : undefined,
brand: i === 0 ? "示例品牌" : undefined,
features:
i === 0
? "瓶身透明、蓝色标签、白色瓶盖;标签上印有品牌Logo和产品名称;光线均匀,主体居中"
: i === 1
? "盒装包装、主色调为米白+暖黄;正面有产品实物图;文字清晰可辨"
: "产品主体清晰、背景干净、色彩鲜艳,突出核心卖点",
label_text: i === 0 ? "包装正面印有产品名称、净含量、品牌Logo" : undefined,
image_index: i,
}
})
resolve({ products })
}, 1800)
})
}
/**
* 客户端文案生成 mock(后端未提供 generate-copy 端点前的占位方案):
* 后端上线后改为调用真实接口。
*/
export function mockGenerateCopy(params: {
product: string
sellingPoints?: string[]
tone?: string
duration?: number
marketingPurpose?: string
industry?: string
targetCustomer?: string
}): Promise<CopyResult> {
return new Promise((resolve) => {
setTimeout(() => {
const product = params.product || "这款产品"
const tone = params.tone || "亲切务实"
const purpose = params.marketingPurpose || "品牌种草"
resolve({
title: `【${purpose}】${product},用过的人都说好!`,
final_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`,
suggested_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`,
})
}, 2200)
})
}
/** ── 三步拆分 v1.5 真实后端 API(PR #2117 合入后启用,前端可替换 mock 调用) ── */
/** 阶段1:上传图片后仅做 VLM 图片分析 + 可选参考视频风格分析,完成后状态=image_analyzed */
export function analyzeViralImages(payload: AnalyzeImagesRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/analyze-images", payload).then((r) => r.data)
}
/** 阶段2:用户填完营销参数后生成文案+分镜+合规审核,完成后状态=copy_generated,返回 copy_result */
export function generateViralCopy(id: string, payload: GenerateCopyRequest) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/generate-copy`, payload)
.then((r) => r.data)
}
/** 阶段3:用户确认/编辑文案后开始 TTS→渲染→上传,完成后状态=completed */
export function confirmViralCopy(id: string, payload: ConfirmCopyRequest = {}) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-copy`, payload)
.then((r) => r.data)
}
-317
View File
@@ -1,317 +0,0 @@
export type FusionLevel = "ai_full" | "ai_polish" | "user_primary"
export const FUSION_LEVELS: { value: FusionLevel; label: string; desc: string }[] = [
{ value: "ai_full", label: "AI 全写", desc: "给我方向,全由AI创作" },
{ value: "ai_polish", label: "AI润色", desc: "我写草稿,AI帮我润色" },
{ value: "user_primary", label: "按我写的来", desc: "几乎不改我的文案" },
]
export type StyleStrength = "light" | "medium" | "strict"
export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [
{ value: "light", label: "轻度借鉴" },
{ value: "medium", label: "中度参考" },
{ value: "strict", label: "像素级复刻" },
]
/** v1.6 前端时长下拉选项(5/10/15/20/25/30秒) */
export const VALID_DURATIONS = [5, 10, 15, 20, 25, 30] as const
export type VideoDuration = (typeof VALID_DURATIONS)[number]
/** v1.6 支持的画幅比例 */
export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
export type VideoRatio = (typeof VALID_RATIOS)[number]
export type ViralVideoStatus =
| "pending"
| "running"
| "wait_user_confirm"
| "image_analyzed"
| "copy_generated"
| "completed"
| "failed"
| "cancelled"
/**
* v1.6 后端流水线阶段。单次 Seedance 出片版:
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
*/
export type ViralVideoStage =
| "image_analysis"
| "video_analysis"
| "intent_parsing"
| "script_generation"
| "review"
| "tts"
| "rendering"
| "uploading"
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
export function isImageAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && IMAGE_ANALYSIS_STAGES.has(stage)
}
export function isCopyStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && COPY_STAGES.has(stage)
}
export function isVideoStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && VIDEO_STAGES.has(stage)
}
/** 兼容旧调用:分析图片+生成文案 的所有前置阶段 */
export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return isImageAnalysisStage(stage) || isCopyStage(stage)
}
/** 单张图片 VLM 识别出的商品信息 */
export interface ImageProductAnalysis {
name?: string
category?: string
brand?: string
colors?: string[]
material_or_texture?: string
key_features?: string[]
visual_style?: string
scene?: string
target_audience_hint?: string
text_on_image?: string
/** 旧字段兼容 */
spec?: string
features?: string[] | string
label_text?: string
selling_points?: string
image_index?: number
}
export interface ImageAnalysisResult {
products?: ImageProductAnalysis[]
}
/** v1.6 编导分镜脚本 - 单镜头 */
export interface ShotScript {
/** 时间区间,如 "0-3秒" */
time_range?: string
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
shot_type_angle_movement?: string
/** 场景描述+对白 */
scene_and_dialogue?: string
/** 人物动作/表情/物品操作细节 */
action_details?: string
/** 环境音+BGM提示 */
audio_bgm?: string
/** 转场方式(硬切/淡入淡出/叠化/结束) */
transition?: string
/** 参考图片索引(0-based,对应上传产品图数组) */
reference_image_index?: number | null
}
/** v1.6 编导分镜脚本 - 总览 */
export interface CopyResultOverview {
theme?: string
total_duration?: number
aspect_ratio?: string
}
/** v1.6 编导分镜脚本(核心输出结构,给 Seedance 做 prompt,给 TTS 取 voiceover_script) */
export interface CopyResult {
overview?: CopyResultOverview
/** 整体场景+光线描述 */
scene_and_lighting?: string
/** 逐镜头时间轴 */
shots?: ShotScript[]
/** 硬性约束(禁止字幕/水印/变形等) */
hard_constraints?: string[]
/** 负面提示词 */
negative_prompts?: string[]
/** 完整口播稿(纯文本,用于 TTS 合成) */
voiceover_script?: string
/** 向后兼容:= voiceover_script */
final_copy?: string
/** 向后兼容:= voiceover_script */
suggested_copy?: string
title?: string
/** v1.5 旧字段兼容(老数据降级时可能出现) */
scenes?: Array<{ shot: string; narration: string; duration?: number }>
}
export interface StyleTemplate {
id: string
name: string
description?: string
thumbnail_url?: string
style_config?: Record<string, unknown>
tags?: string[]
}
export interface IntentResult {
intent?: string
key_messages?: string[]
tone?: string
target_emotion?: string
call_to_action?: string
suggested_title?: string
/** v1.5 旧字段兼容 */
product?: string
selling_points?: string[]
target_audience?: string
structure?: string
duration?: number
suggested_copy?: string
}
export interface ViralVideoJob {
id: string
status: ViralVideoStatus
images: string[]
reference_video_url?: string
style_strength?: StyleStrength
style_template_id?: string
style_guide?: string | Record<string, unknown>
user_copy_text?: string
/** v1.6: = copy_result.voiceover_script(从 copy_result 派生,向后兼容) */
final_copy_text?: string
generated_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_mode?: "global" | "per_video"
voice_source?: "preset" | "library" | "clone" | "upload" | "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
progress_stage?: ViralVideoStage
progress_percent?: number
progress_message?: string
output_url?: string
result_video_url?: string
error_message?: string
error_msg?: string
credits_cost?: number
created_at?: string
updated_at?: string
}
export interface GenerateViralVideoRequest {
images: string[]
reference_video_url?: string
douyin_url?: string
style_strength?: StyleStrength
style_template_id?: string
user_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
bgm_preference?: string
industry?: string
target_customer?: string
language?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
/** 视频时长(5-30秒,默认15) */
duration?: number
video_model?: string
video_ratio?: string
/** 三步拆分:step 控制后端执行到哪一步暂停 */
step?: "analyze" | "generate_copy" | "generate_video"
}
export interface HistoryResponse {
items: ViralVideoJob[]
total: number
page: number
page_size: number
}
/** v1.6 阶段1请求:图片/视频分析(POST /viral-video/analyze-images) */
export interface AnalyzeImagesRequest {
images: string[]
reference_video_url?: string
style_template_id?: string
style_strength?: StyleStrength
/** TTS 音色 ID(STEP1 已选音色时传) */
voice_id?: string
/** 音色来源:preset | library | clone | upload */
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
/** Seedance 视频比例:9:16 | 16:9 | 1:1 */
video_ratio?: string
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
}
/** v1.6 阶段2请求:填完营销参数后生成编导分镜脚本(POST /viral-video/{id}/generate-copy) */
export interface GenerateCopyRequest {
industry?: string
target_customer?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
bgm_preference?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
user_copy_text?: string
fusion_level?: FusionLevel
reference_audio_path?: string
reference_video_url?: string
style_strength?: StyleStrength
style_template_id?: string
style_guide?: string | Record<string, unknown>
/** TTS 音色 ID(优先级高于 persona_id) */
voice_id?: string
/** 音色来源:preset | library | clone | upload */
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
/** Seedance 视频比例(9:16/16:9/1:1 等) */
video_ratio?: string
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
}
/** 视频模型描述(GET /viral-video/models) */
export interface ViralVideoModel {
key: string
display_name: string
supports_audio: boolean
supported_resolutions: string[]
max_duration: number
/** 计费模式(可选):per_second / per_video / token 等 */
billing_mode?: string
is_default?: boolean
}
/** GET /viral-video/models 响应包装 */
export interface ViralVideoModelsResponse {
models: ViralVideoModel[]
}
/** v1.6 阶段3请求:用户确认/编辑口播文案后开始单次 Seedance 出片(POST /viral-video/{id}/confirm-copy) */
export interface ConfirmCopyRequest {
/** 用户编辑后的口播文案;为空则使用 AI 生成的 voiceover_script */
edited_copy?: string
/** 视频模型 key,覆盖默认 */
video_model?: string
}
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
export interface StoryboardSegment {
order: number
type: string
description: string
text: string
duration: number
ken_burns?: string
transition?: string
}
-2
View File
@@ -18,8 +18,6 @@ export interface VoiceClone {
language: string
gender: string
error_message: string | null
/** CosyVoice 实际使用的音色 ID(status=ready 时由后端填充,用于 TTS 调用) */
voice_id?: string | null
created_at: string
updated_at: string
}
-1
View File
@@ -18,7 +18,6 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
voice_id: profile.voice_id,
created_at: profile.created_at,
updated_at: profile.updated_at,
})
@@ -1,182 +0,0 @@
/* DurationWheelPicker —— 弹层式滚轮选择器(样式与表单一致) */
/* 触发按钮:外观复用 .vv-select 风格 */
.dw-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 36px;
padding: 0 12px;
background: #fff;
border: 1px solid #e0e0e8;
border-radius: 8px;
font-size: 13px;
color: #1f2937;
cursor: pointer;
box-sizing: border-box;
transition: all 0.15s;
user-select: none;
}
.dw-trigger:hover {
border-color: #c0c0d0;
}
.dw-trigger-open,
.dw-trigger:focus-within {
border-color: #7c3aed !important;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12);
}
.dw-trigger-disabled {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.dw-trigger-val {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dw-trigger-placeholder {
color: #9ca3af;
}
.dw-trigger-arrow {
font-size: 10px;
color: #9ca3af;
margin-left: 8px;
transition: transform 0.2s;
}
.dw-trigger-arrow-up {
transform: rotate(180deg);
}
/* 弹层容器 */
.dw-popup {
padding: 8px;
min-width: 140px;
}
/* 滚轮 */
.dw-picker {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 8px;
background: #fafafe;
border: 1px solid #e5e7eb;
}
.dw-picker-list {
margin: 0;
padding: 0;
list-style: none;
height: 100%;
overflow-y: scroll;
scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.dw-picker-list::-webkit-scrollbar {
display: none;
}
.dw-picker-item {
display: flex;
align-items: baseline;
justify-content: center;
gap: 3px;
scroll-snap-align: center;
cursor: pointer;
font-size: 15px;
color: #9ca3af;
font-weight: 400;
transition:
color 0.15s,
transform 0.15s,
font-weight 0.15s;
}
.dw-picker-item-val {
font-variant-numeric: tabular-nums;
}
.dw-picker-item-unit {
font-size: 13px;
color: inherit;
}
.dw-picker-item-active {
color: #7c3aed;
font-weight: 600;
}
.dw-picker-item-active .dw-picker-item-val {
font-size: 18px;
}
.dw-picker-item-active .dw-picker-item-unit {
font-size: 14px;
}
/* 中心选中条 */
.dw-picker-mask {
position: absolute;
left: 6px;
right: 6px;
pointer-events: none;
background: #f5f0ff;
border-radius: 6px;
z-index: 1;
}
.dw-picker-mask::before,
.dw-picker-mask::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 1px;
background: #d8c4ff;
}
.dw-picker-mask::before {
top: 0;
}
.dw-picker-mask::after {
bottom: 0;
}
/* 上下渐变 */
.dw-picker-fade {
position: absolute;
left: 0;
right: 0;
height: 40%;
pointer-events: none;
z-index: 2;
}
.dw-picker-fade-top {
top: 0;
background: linear-gradient(to bottom, #fafafe 25%, rgba(250, 250, 254, 0));
}
.dw-picker-fade-bottom {
bottom: 0;
background: linear-gradient(to top, #fafafe 25%, rgba(250, 250, 254, 0));
}
/* 弹层按钮区 */
.dw-popup-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.dw-popup-actions .ant-btn {
border-radius: 6px;
}
.dw-popup-actions .ant-btn-primary {
background: #7c3aed;
}
.dw-popup-actions .ant-btn-primary:hover {
background: #6d28d9 !important;
}
/* 覆盖 antd Popover 默认内边距 */
.dw-popover .ant-popover-inner {
padding: 0 !important;
overflow: hidden;
}
.dw-popover .ant-popover-arrow {
display: none;
}
@@ -1,180 +0,0 @@
/**
* DurationWheelPicker —— 竖屏滚轮式时长选择器(弹层版)
*
* 设计:
* - 外观是和其他表单 Select 一致的输入框(白色底+1px灰边+紫色focus ring)
* - 点击输入框弹出 Popover,内部是滚轮 picker(原生 scroll-snap,零依赖)
* - 滚轮样式:白底容器,选中行 #7c3aed 紫字加粗+浅紫背景条
* - 支持触摸/鼠标滚轮/点击;松手吸附;底部"确认/取消"按钮
* - 默认范围 15–30 秒,步长 1 秒
*/
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
import { Popover, Button } from "antd"
import { DownOutlined } from "@ant-design/icons"
import "./DurationWheelPicker.css"
export interface DurationWheelPickerProps {
value?: number
min?: number
max?: number
step?: number
unit?: string
onChange?: (value: number) => void
placeholder?: string
disabled?: boolean
/** 弹层宽度,默认 160px */
popupWidth?: number
/** 弹层内滚轮高度,默认 180px */
wheelHeight?: number
}
const ITEM_HEIGHT = 36
const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
value = 20,
min = 15,
max = 30,
step = 1,
unit = "秒",
onChange,
placeholder = "请选择时长",
disabled = false,
popupWidth = 160,
wheelHeight = 180,
}) => {
const options = useMemo(() => {
const arr: number[] = []
for (let v = min; v <= max; v += step) arr.push(v)
return arr
}, [min, max, step])
const [open, setOpen] = useState(false)
// 弹层内暂存值,点确认才提交
const [draft, setDraft] = useState<number>(value)
const listRef = useRef<HTMLUListElement>(null)
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (open) {
setDraft(value)
// 下一帧滚到当前值
requestAnimationFrame(() => scrollToValue(value, false))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const scrollToValue = useCallback(
(v: number, smooth = true) => {
const list = listRef.current
if (!list) return
const idx = options.indexOf(v)
if (idx < 0) return
list.scrollTo({ top: idx * ITEM_HEIGHT, behavior: smooth ? "smooth" : "auto" })
},
[options],
)
const handleScroll = () => {
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = setTimeout(() => {
const list = listRef.current
if (!list) return
const idx = Math.round(list.scrollTop / ITEM_HEIGHT)
const clamped = Math.max(0, Math.min(options.length - 1, idx))
const targetTop = clamped * ITEM_HEIGHT
if (Math.abs(list.scrollTop - targetTop) > 1) {
list.scrollTo({ top: targetTop, behavior: "smooth" })
}
setDraft(options[clamped])
}, 100)
}
const handleConfirm = () => {
onChange?.(draft)
setOpen(false)
}
const handleCancel = () => {
setOpen(false)
}
const handleItemClick = (v: number) => {
setDraft(v)
scrollToValue(v, true)
}
const maskTop = wheelHeight / 2 - ITEM_HEIGHT / 2
const wheel = (
<div className="dw-popup">
<div
className="dw-picker"
style={{ height: wheelHeight, width: popupWidth - 24 /* padding */ }}
>
<div className="dw-picker-mask" style={{ top: maskTop, height: ITEM_HEIGHT }} aria-hidden />
<div className="dw-picker-fade dw-picker-fade-top" aria-hidden />
<div className="dw-picker-fade dw-picker-fade-bottom" aria-hidden />
<ul
ref={listRef}
className="dw-picker-list"
onScroll={handleScroll}
style={{
paddingTop: wheelHeight / 2 - ITEM_HEIGHT / 2,
paddingBottom: wheelHeight / 2 - ITEM_HEIGHT / 2,
}}
>
{options.map((v) => {
const isActive = v === draft
return (
<li
key={v}
className={`dw-picker-item${isActive ? " dw-picker-item-active" : ""}`}
style={{ height: ITEM_HEIGHT, lineHeight: `${ITEM_HEIGHT}px` }}
onClick={() => handleItemClick(v)}
aria-selected={isActive}
role="option"
>
<span className="dw-picker-item-val">{v}</span>
<span className="dw-picker-item-unit">{unit}</span>
</li>
)
})}
</ul>
</div>
<div className="dw-popup-actions">
<Button size="small" onClick={handleCancel}>
取消
</Button>
<Button size="small" type="primary" onClick={handleConfirm}>
确认
</Button>
</div>
</div>
)
return (
<Popover
open={!disabled && open}
onOpenChange={(v) => setOpen(v)}
content={wheel}
trigger="click"
placement="bottomLeft"
overlayClassName="dw-popover"
overlayStyle={{ padding: 0 }}
overlayInnerStyle={{ padding: 0, borderRadius: 10 }}
destroyTooltipOnHide
>
<div
className={`dw-trigger${disabled ? " dw-trigger-disabled" : ""}${open ? " dw-trigger-open" : ""}`}
style={{ height: 36 }}
>
<span className={`dw-trigger-val${value != null ? "" : " dw-trigger-placeholder"}`}>
{value != null ? `${value}${unit}` : placeholder}
</span>
<DownOutlined className={`dw-trigger-arrow${open ? " dw-trigger-arrow-up" : ""}`} />
</div>
</Popover>
)
}
export default DurationWheelPicker
@@ -2,7 +2,6 @@
export const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/dashboard": "首页",
"/app/generate": "智能剪辑",
"/app/viral-video": "爆款视频",
"/app/assets": "视频库",
"/app/voices": "配音库",
"/app/products": "成片库",
@@ -1,271 +0,0 @@
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleStyleSettings } from "@/components/title/settings"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleStyleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
background?: string
/** 高度(可选,默认按 portrait 选比例) */
height?: number
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
transparent?: boolean
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
portrait?: boolean
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
transparent = false,
portrait = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
const text = (sampleText || "预览标题").trim() || "预览标题"
useEffect(() => {
let cancelled = false
const draw = () => {
if (cancelled) return
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景(transparent 时跳过,用于叠加在图片上)
if (!transparent) {
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
}
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致
const r = previewR
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
const botMargin = portrait ? r(24) : r(16)
startY = h - totalH - botMargin + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色,
// 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」
ctx.fillStyle = settings.color
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}
// 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用)
const previewScale = width / 360
const previewR = (v: number) => Math.round(v * previewScale)
const buildFontString = () => {
const size = previewR(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
return parts.join(" ")
}
// Web Font 加载保障:
// 1) 等 document.fonts.ready(CSS @font-face 首次可用)
// 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载
// 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块
const doDrawWhenReady = async () => {
try {
if (typeof document !== "undefined" && document.fonts) {
await document.fonts.ready
try {
await document.fonts.load(buildFontString(), text)
} catch {
/* ignore */
}
}
} finally {
if (!cancelled) draw()
}
}
doDrawWhenReady()
return () => {
cancelled = true
}
}, [settings, width, h, text, transparent, portrait, background])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background: transparent ? "transparent" : background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -1,458 +0,0 @@
/* ============================================================
TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦)
从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」
两个页面共用。AI数字人页面不引入 generate.css,直接由
TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、
配色描边、选中态与智能剪辑页面完全一致。
注意:本文件规则与 generate.css 中同名规则一一对应、取值相同;
智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。
============================================================ */
/* ── 区块容器 ── */
.xx-title-style-section {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid var(--border-light);
}
.xx-section-subtitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
.xx-title-style-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.xx-half-field {
margin-bottom: 0;
}
.xx-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.xx-field-label-row label {
margin-bottom: 0;
}
.xx-field-value {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
}
/* ── 共用表单字段(位置/字体下拉) ── */
.xx-title-style-section .xx-form-field {
margin-bottom: 14px;
}
.xx-title-style-section .xx-form-field:last-child {
margin-bottom: 0;
}
.xx-title-style-section .xx-form-field label {
display: block;
font-weight: 600;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select,
.xx-title-style-section .xx-form-field input {
width: 100%;
height: 44px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 14px;
font-size: 14px;
outline: 0;
transition: 0.15s ease;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select:focus,
.xx-title-style-section .xx-form-field input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* ── 字号滑块 ── */
.xx-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.xx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
.xx-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
/* ── 标题预设卡片网格(24 个 T 格子) ── */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(6, 52px);
gap: 1px;
}
.xx-title-preset-card {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: #404040;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.xx-title-preset-card:hover {
border-color: #666;
background: #4d4d4d;
}
.xx-title-preset-card.active {
border-color: #409eff;
background: #4d4d4d;
}
.xx-title-preset-preview-text {
font-size: 32px;
line-height: 1;
user-select: none;
}
/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */
.xx-style-btns {
display: flex;
gap: 8px;
}
.xx-style-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
cursor: pointer;
font-size: 15px;
color: var(--text-secondary);
transition: all 0.15s;
}
.xx-style-btn:hover {
border-color: var(--primary-300);
color: var(--primary-color);
}
.xx-style-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
#2001 爆款标题样式面板升级 — 新增样式(ts- 前缀)
============================================================ */
.ts-panel {
position: relative;
}
/* 预览 */
.ts-preview-wrap {
margin-bottom: 14px;
display: flex;
justify-content: center;
padding: 10px;
background: #0f172a;
border-radius: 8px;
}
/* 表单字段 */
.ts-form-field {
margin-bottom: 12px;
}
.ts-form-field label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-primary, #1f2937);
}
.ts-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ts-field-value {
font-size: 12px;
font-weight: 600;
color: var(--primary-color, #7c3aed);
}
.ts-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.ts-half {
margin-bottom: 0;
}
.ts-select {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
background: var(--bg-primary, #fff);
padding: 0 10px;
font-size: 13px;
outline: 0;
color: var(--text-primary, #1f2937);
}
.ts-select:focus {
border-color: var(--primary-color, #7c3aed);
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1);
}
.ts-input {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
outline: 0;
}
.ts-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #e5e7eb;
border-radius: 2px;
outline: none;
}
.ts-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.ts-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
}
/* 样式按钮 B/I/S/☁ */
.ts-style-btns {
display: flex;
gap: 6px;
}
.ts-style-btn {
width: 34px;
height: 34px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: 0.15s;
color: #374151;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ts-style-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.ts-style-btn.active {
background: #faf5ff;
color: #6d28d9;
border-color: #7c3aed;
font-weight: 700;
}
/* 色板 */
.ts-color-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ts-color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e5e7eb;
cursor: pointer;
padding: 0;
transition: 0.15s;
}
.ts-color-swatch:hover {
transform: scale(1.1);
}
.ts-color-swatch.active {
box-shadow: 0 0 0 2px #7c3aed;
transform: scale(1.1);
}
.ts-color-custom {
background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px;
color: #666;
font-size: 14px;
line-height: 20px;
}
.ts-color-native {
width: 0;
height: 0;
border: 0;
padding: 0;
}
/* 预设网格 10个 - 5列 */
.ts-presets-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.ts-preset-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
padding: 4px;
cursor: pointer;
transition: 0.15s;
display: flex;
flex-direction: column;
gap: 4px;
}
.ts-preset-card:hover {
border-color: #7c3aed;
}
.ts-preset-card.active {
border-color: #7c3aed;
background: #faf5ff;
box-shadow: 0 0 0 1px #7c3aed;
}
.ts-preset-preview {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: #0f172a;
}
.ts-preset-preview canvas {
max-width: 100%;
max-height: 100%;
}
.ts-preset-meta {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #4b5563;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 2px 2px;
}
.ts-preset-emoji {
font-size: 11px;
}
.ts-preset-label {
overflow: hidden;
text-overflow: ellipsis;
}
.ts-toggle-row label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 10px;
}
.ts-toggle-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #7c3aed;
}
/* Tabs 紧凑样式 */
.xx-title-style-section .ant-tabs-nav {
margin-bottom: 10px;
}
.xx-title-style-section .ant-tabs-tab {
font-size: 12px !important;
padding: 6px 8px !important;
}
/* 标题模板入口按钮(#2003) */
.ts-template-btn {
border: none;
background: transparent;
color: var(--primary-color, #7c3aed);
font-size: 12px;
cursor: pointer;
padding: 2px 0;
font-weight: 500;
}
.ts-template-btn:hover {
text-decoration: underline;
}
@@ -1,445 +0,0 @@
/**
* 标题样式参数 Tab 面板(共享组件)
*
* 包含:基础/描边/阴影/背景/排版/封面 共 6 个 Tab
* 仅负责 UI 渲染和参数 patch 回调,不维护 state、不调 API
*/
import React, { useState } from "react"
import { Tabs } from "antd"
import type { TitleStyleSettings } from "./settings"
import {
FONT_OPTIONS,
TITLE_COLOR_PALETTE,
STROKE_COLOR_PALETTE,
BG_COLOR_PALETTE,
} from "./constants"
export interface PositionOption {
value: string
label: string
}
export interface FontOption {
value: string
label: string
family: string
tag?: "hot" | "new"
}
export interface TitleStyleParamsTabProps {
settings: TitleStyleSettings
onUpdatePosition: (p: string) => void
onUpdateFont: (f: string) => void
onUpdateSize: (v: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onUpdatePatch: (patch: Partial<TitleStyleSettings>) => void
positionOptions: PositionOption[]
fontOptions?: FontOption[]
/** 是否显示「封面」Tab(独立封面标题开关) */
showCoverToggle?: boolean
/** 封面独立标题开关状态 */
coverEnabled?: boolean
/** 封面开关变化 */
onToggleCover?: (enabled: boolean) => void
}
/* ── Slider 行 ── */
const SliderRow: React.FC<{
label: string
value: number
min: number
max: number
step?: number
unit?: string
onChange: (v: number) => void
}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => (
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>{label}</label>
<span className="ts-field-value">
{value}
{unit}
</span>
</div>
<input
type="range"
className="ts-slider"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
)
/* ── 色板 ── */
const ColorPicker: React.FC<{
label?: string
value: string
palette: string[]
onChange: (c: string) => void
}> = ({ label, value, palette, onChange }) => {
const [customOpen, setCustomOpen] = useState(false)
return (
<div className="ts-form-field">
{label && <label>{label}</label>}
<div className="ts-color-row">
{palette.map((c) => (
<button
key={c}
type="button"
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
style={{ background: c }}
onClick={() => onChange(c)}
title={c}
/>
))}
<button
type="button"
className="ts-color-swatch ts-color-custom"
onClick={() => setCustomOpen((v) => !v)}
title="自定义颜色"
>
+
</button>
<input
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => {
onChange(e.target.value)
setCustomOpen(false)
}}
style={{
opacity: customOpen ? 1 : 0,
position: customOpen ? "static" : "absolute",
pointerEvents: customOpen ? "auto" : "none",
width: customOpen ? 28 : 0,
height: customOpen ? 28 : 0,
border: "none",
padding: 0,
cursor: "pointer",
background: "transparent",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
当前:<code style={{ fontSize: 11 }}>{value}</code>
</div>
</div>
)
}
const TitleStyleParamsTab: React.FC<TitleStyleParamsTabProps> = ({
settings,
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onUpdatePatch,
positionOptions,
fontOptions = FONT_OPTIONS,
showCoverToggle = false,
coverEnabled = false,
onToggleCover,
}) => {
const upd = onUpdatePatch
return (
<Tabs
size="small"
defaultActiveKey="basic"
items={[
{
key: "basic",
label: "基础",
children: (
<>
<div className="ts-row-2">
<div className="ts-form-field ts-half">
<label>位置</label>
<select
className="ts-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{positionOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="ts-form-field ts-half">
<label>字体</label>
<select
className="ts-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{fontOptions.map((f) => (
<option key={f.value} value={f.value}>
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
{f.label}
</option>
))}
</select>
</div>
</div>
<SliderRow
label="字号"
value={settings.size}
min={16}
max={120}
onChange={onUpdateSize}
/>
<div className="ts-form-field">
<label>样式</label>
<div className="ts-style-btns">
<button
type="button"
className={`ts-style-btn${settings.bold ? " active" : ""}`}
onClick={onToggleBold}
>
<b>B</b>
</button>
<button
type="button"
className={`ts-style-btn${settings.italic ? " active" : ""}`}
onClick={onToggleItalic}
>
<i>I</i>
</button>
<button
type="button"
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
onClick={() => {
onToggleStroke()
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2)
upd({ strokeWidth: 4 })
}}
title="描边"
>
S
</button>
<button
type="button"
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
onClick={() => {
onToggleShadow()
if (!settings.shadow) {
upd({
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
})
}
}}
title="阴影"
>
☁
</button>
</div>
</div>
<ColorPicker
label="字色"
value={settings.color}
palette={TITLE_COLOR_PALETTE}
onChange={(c) => upd({ color: c })}
/>
</>
),
},
{
key: "stroke",
label: "描边",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
启用描边
</label>
</div>
{settings.stroke && (
<>
<SliderRow
label="描边宽度"
value={settings.strokeWidth ?? 4}
min={0}
max={20}
onChange={(v) => upd({ strokeWidth: v })}
/>
<ColorPicker
label="描边颜色"
value={settings.strokeColor ?? "#000000"}
palette={STROKE_COLOR_PALETTE}
onChange={(c) => upd({ strokeColor: c })}
/>
</>
)}
</>
),
},
{
key: "shadow",
label: "阴影",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
启用阴影
</label>
</div>
{settings.shadow && (
<>
<SliderRow
label="X偏移"
value={settings.shadowOffsetX ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetX: v })}
/>
<SliderRow
label="Y偏移"
value={settings.shadowOffsetY ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetY: v })}
/>
<SliderRow
label="模糊半径"
value={settings.shadowBlur ?? 4}
min={0}
max={30}
onChange={(v) => upd({ shadowBlur: v })}
/>
<div className="ts-form-field">
<label>阴影颜色</label>
<input
type="text"
className="ts-input"
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
onChange={(e) => upd({ shadowColor: e.target.value })}
placeholder="rgba(0,0,0,0.8)"
/>
</div>
</>
)}
</>
),
},
{
key: "bg",
label: "背景",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={settings.bgEnabled}
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
/>
启用背景色块
</label>
</div>
{settings.bgEnabled && (
<>
<ColorPicker
label="背景颜色(含透明度)"
value={settings.bgColor}
palette={BG_COLOR_PALETTE}
onChange={(c) => upd({ bgColor: c })}
/>
<SliderRow
label="内边距"
value={settings.bgPadding}
min={0}
max={40}
onChange={(v) => upd({ bgPadding: v })}
/>
<SliderRow
label="圆角"
value={settings.bgRadius}
min={0}
max={30}
onChange={(v) => upd({ bgRadius: v })}
/>
</>
)}
</>
),
},
{
key: "layout",
label: "排版",
children: (
<>
<SliderRow
label="每行最大字符数"
value={settings.maxCharsPerLine ?? 0}
min={0}
max={20}
unit=""
onChange={(v) => upd({ maxCharsPerLine: v })}
/>
<div
className="ts-form-field"
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
>
0 = 不自动换行(按 / 手动分行)
</div>
<SliderRow
label="行距倍数"
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
min={1}
max={2}
step={0.05}
unit=""
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
/>
<SliderRow
label="顶部边距"
value={settings.marginTop ?? 24}
min={0}
max={200}
onChange={(v) => upd({ marginTop: v })}
/>
</>
),
},
...(showCoverToggle
? [
{
key: "cover",
label: "封面",
children: (
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={coverEnabled}
onChange={(e) => onToggleCover?.(e.target.checked)}
/>
封面使用独立标题样式
</label>
</div>
),
},
]
: []),
]}
/>
)
}
export default TitleStyleParamsTab
@@ -1,30 +1,29 @@
/**
* 标题模板编辑器(公共组件)
* 标题模板编辑器(v3 重构)
*
* - Modal 弹窗 860px 宽
* - 左侧:300px 竖屏预览区(图片背景+暗角+透明 Canvas 叠字)+ 模板名称输入
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStyleParamsTab
* - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式
* - 底部:取消 / 保存模板 按钮
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 onSave 的调用方处理)
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理)
*/
import React, { useEffect, useMemo, useState } from "react"
import { Modal, Button, Input, message } from "antd"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import TitleStylePanel from "../../pages/generate/components/title/TitleStylePanel"
import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview"
import { POSITION_OPTIONS } from "../../pages/generate/constants"
import { FONT_OPTIONS } from "./constants"
import type { TitleSettings } from "../../pages/generate/types"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleConfig } from "./types"
import { POSITION_OPTIONS } from "./position-options"
import { FONT_OPTIONS } from "./constants"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleStyleParamsTab from "./TitleStyleParamsTab"
import "./TitleTemplate.css"
import "./TitleStylePanel.css"
interface Props {
open: boolean
template: TitleTemplate
onClose: () => void
/** 用户点击保存:将编辑结果回调给父组件(父组件统一做 CRUD,避免双 hook 实例不同步) */
onSave: (data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => void
}
@@ -32,9 +31,10 @@ interface Props {
const EDITOR_BG = "/title-templates/portrait1.jpg"
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
const [settings, setSettings] = useState<TitleStyleSettings>(() => ({
...DEFAULT_TITLE_STYLE_SETTINGS,
const [settings, setSettings] = useState<TitleSettings>(() => ({
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
title: "预览标题文字",
}))
const [formName, setFormName] = useState(template.name || "")
const [formEmoji, setFormEmoji] = useState(template.emoji || "✨")
@@ -43,15 +43,16 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
useEffect(() => {
if (open) {
setSettings({
...DEFAULT_TITLE_STYLE_SETTINGS,
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
title: "预览标题文字",
})
setFormName(template.name || "")
setFormEmoji(template.emoji || "✨")
}
}, [open, template])
const upd = (patch: Partial<TitleStyleSettings>) => setSettings((s) => ({ ...s, ...patch }))
const upd = (patch: Partial<TitleSettings>) => setSettings((s) => ({ ...s, ...patch }))
const handleSave = () => {
const name = formName.trim()
@@ -68,11 +69,11 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
}
}
// 编辑器预览 settings:竖屏宽度 200px,字号按比例缩放
const previewSettings = useMemo<TitleStyleSettings>(
() => ({ ...settings, size: Math.round(settings.size * 0.55) }),
[settings],
)
// 编辑器内的预览用 settings:字号适配竖屏
const previewSettings = useMemo<TitleSettings>(() => {
// 竖屏宽度 200px,按比例缩放字号,让预览看起来协调
return { ...settings, size: Math.round(settings.size * 0.55) }
}, [settings])
return (
<Modal
@@ -139,7 +140,7 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
</div>
{/* 右侧:参数 Tab */}
<div className="ttv3-editor-right">
<TitleStyleParamsTab
<TitleStylePanel
settings={settings}
onUpdatePosition={(p) => upd({ position: p, posX: null, posY: null })}
onUpdateFont={(f) => upd({ font: f })}
@@ -154,9 +155,15 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
})
}
onToggleShadow={() => upd({ shadow: !settings.shadow })}
onUpdatePatch={upd}
positionOptions={POSITION_OPTIONS}
fontOptions={FONT_OPTIONS}
onApplyPreset={() => {
/* 编辑器内不使用系统预设快捷键 */
}}
onUpdateStyle={(patch) => upd(patch)}
activePreset={null}
titlePresets={[]}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
paramsOnly
/>
</div>
</div>
@@ -1,343 +0,0 @@
/**
* 标题模板选择器 — 大卡片网格(共享组件)
*
* 渲染「我的模板」+「系统模板」两个分组的 3:4 竖版大圆角卡片:
* - 卡片上半:示例背景图 + vignette 暗角 + 透明 Canvas 大字预览
* - 卡片下半:emoji + 名称 + 系统/我的标签 + 始终可见的编辑/复制/导出/删除按钮
* - 选中紫色边框;右上角「新建模板」按钮;点编辑/新建弹 TitleTemplateEditor
*
* Props 通用化,不耦合业务 state。
*/
import React, { useCallback, useMemo, useState } from "react"
import { Button, message, Popconfirm } from "antd"
import {
PlusOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
ExportOutlined,
CheckOutlined,
} from "@ant-design/icons"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import {
titleStyleConfigToCamel,
camelToTitleStyleConfig,
templateToPreviewSettings,
} from "./utils"
import { useTitleTemplates } from "./useTitleTemplates"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "./TitleTemplateEditor"
import "./TitleTemplate.css"
import "./TitleStylePanel.css"
export interface TitleTemplateSelectorProps {
/** 当前选中模板 id(受控) */
value?: string | null
/** 选中模板时回调(templateId, fullStyleSettings, template) */
onChange?: (templateId: string, style: TitleStyleSettings, template: TitleTemplate) => void
/** 是否显示编辑器入口(新建/编辑按钮),默认 true */
showEditor?: boolean
/** 显示哪些分组,默认全部 */
categories?: Array<"system" | "custom">
/** 使用场景标识(仅作 data-attr,不影响样式) */
context?: string
}
/* ── 卡片预览背景图池(按 index 轮换) ── */
const PREVIEW_BG_IMAGES = [
"/title-templates/portrait1.jpg",
"/title-templates/portrait2.jpg",
"/title-templates/scene1.jpg",
]
/* ── 预览容器:用 ref 测量宽度后再渲染透明 Canvas,保证文字清晰 ── */
const FillPreview: React.FC<{
settings: TitleStyleSettings
sampleText: string
portrait?: boolean
}> = ({ settings, sampleText, portrait }) => {
const [w, setW] = useState(0)
// 首次挂载后测量一次
const setRef = useCallback((el: HTMLDivElement | null) => {
if (el) setW(Math.floor(el.clientWidth))
}, [])
return (
<div ref={setRef} className="tt-fill-canvas-wrap">
{w > 0 && (
<TitleMiniPreview
settings={settings}
width={w}
sampleText={sampleText}
transparent
portrait={portrait}
/>
)}
</div>
)
}
const TitleTemplateSelector: React.FC<TitleTemplateSelectorProps> = ({
value,
onChange,
showEditor = true,
categories = ["system", "custom"],
context,
}) => {
const {
templates,
createTemplate,
duplicateTemplate,
updateTemplate,
deleteTemplate,
exportTemplate,
} = useTitleTemplates()
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const grouped = useMemo(
() => ({
builtin: templates.filter((t) => t.isBuiltin),
custom: templates.filter((t) => !t.isBuiltin),
}),
[templates],
)
const showSys = categories.includes("system")
const showMine = categories.includes("custom")
/* ── 选中模板:合成完整 TitleStyleSettings 回调给父组件 ── */
const handleSelectTemplate = useCallback(
(tpl: TitleTemplate) => {
const full: TitleStyleSettings = {
...DEFAULT_TITLE_STYLE_SETTINGS,
...titleStyleConfigToCamel(tpl.style),
}
onChange?.(tpl.id, full, tpl)
},
[onChange],
)
const handleRequestCreate = useCallback(() => {
// 新建:以当前选中模板样式为起点,否则用默认样式
let base: TitleStyleSettings = DEFAULT_TITLE_STYLE_SETTINGS
if (value) {
const sel = templates.find((t) => t.id === value)
if (sel) {
base = { ...DEFAULT_TITLE_STYLE_SETTINGS, ...titleStyleConfigToCamel(sel.style) }
}
}
const draft: TitleTemplate = {
id: "",
name: "我的标题模板",
emoji: "✨",
isBuiltin: false,
style: camelToTitleStyleConfig({
...base,
position: base.position === "custom" ? "bottom" : base.position,
}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setEditingTemplate(draft)
setEditorOpen(true)
}, [value, templates])
const handleRequestEdit = useCallback((tpl: TitleTemplate) => {
setEditingTemplate(tpl)
setEditorOpen(true)
}, [])
const handleDuplicate = useCallback(
(t: TitleTemplate) => {
const dup = duplicateTemplate(t.id)
if (dup) message.success(`已复制:${dup.name}`)
},
[duplicateTemplate],
)
const handleDelete = useCallback(
(t: TitleTemplate) => {
deleteTemplate(t.id)
message.success("已删除模板")
},
[deleteTemplate],
)
const handleExport = useCallback(
(t: TitleTemplate) => {
const json = exportTemplate(t.id)
if (!json) return
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${t.name}.title-template.json`
a.click()
URL.revokeObjectURL(url)
},
[exportTemplate],
)
const handleEditorSave = useCallback(
(data: { name: string; emoji: string; style: Partial<import("./types").TitleStyleConfig> }) => {
if (!editingTemplate) return
let saved: TitleTemplate
if (editingTemplate.isBuiltin || !editingTemplate.id) {
saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style })
} else {
updateTemplate(editingTemplate.id, {
name: data.name,
emoji: data.emoji,
style: data.style,
})
saved = {
...editingTemplate,
name: data.name,
emoji: data.emoji,
style: data.style,
updatedAt: new Date().toISOString(),
}
}
setEditorOpen(false)
setEditingTemplate(null)
message.success(`已保存:${data.name}`)
handleSelectTemplate(saved)
},
[editingTemplate, createTemplate, updateTemplate, handleSelectTemplate],
)
/* ── 渲染单张大卡片 ── */
const renderCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => {
const isSelected = value === t.id
const bgIdx = idx % PREVIEW_BG_IMAGES.length
const bgImg = PREVIEW_BG_IMAGES[bgIdx]
const preview = templateToPreviewSettings(t, 42)
return (
<div
key={t.id}
className={`ttv3-card${isSelected ? " selected" : ""}`}
onClick={() => handleSelectTemplate(t)}
data-context={context}
>
<div className="ttv3-preview">
<img className="ttv3-bg" src={bgImg} alt="" />
<div className="ttv3-vignette" />
<FillPreview settings={preview} sampleText="预览标题文字" portrait />
<span className={`ttv3-badge ttv3-badge--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
<span className={`ttv3-check${isSelected ? " on" : ""}`}>
{isSelected && <CheckOutlined />}
</span>
</div>
<div className="ttv3-footer">
<div className="ttv3-name-row">
<span className="ttv3-emoji">{t.emoji || "✨"}</span>
<span className="ttv3-name" title={t.name}>
{t.name}
</span>
<span className={`ttv3-tag ttv3-tag--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
</div>
{showEditor && (
<div className="ttv3-actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="ttv3-act ttv3-act--primary"
disabled={t.isBuiltin}
onClick={() => handleRequestEdit(t)}
title={t.isBuiltin ? "系统模板不可编辑,点击复制后可编辑" : "编辑"}
>
<EditOutlined /> 编辑
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleDuplicate(t)}
title="复制"
>
<CopyOutlined /> 复制
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleExport(t)}
title="导出"
>
<ExportOutlined /> 导出
</button>
<Popconfirm title="删除该模板?" onConfirm={() => handleDelete(t)}>
<button
type="button"
className="ttv3-act ttv3-act--danger"
disabled={t.isBuiltin}
title={t.isBuiltin ? "系统模板不可删除" : "删除"}
>
<DeleteOutlined /> 删除
</button>
</Popconfirm>
</div>
)}
</div>
</div>
)
}
return (
<div className="xx-title-style-section ttv3-panel">
<div className="ttv3-header">
<span className="ttv3-title">标题模板</span>
{showEditor && (
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleRequestCreate}
className="ttv3-new-btn"
>
新建模板
</Button>
)}
</div>
{showMine && (
<div className="ttv3-section">
<div className="ttv3-section-label">我的模板</div>
{grouped.custom.length === 0 ? (
<div className="ttv3-empty">
<div className="ttv3-empty-icon">✨</div>
<div className="ttv3-empty-text">还没有自定义模板,点右上角「新建模板」创建</div>
</div>
) : (
<div className="ttv3-grid">
{grouped.custom.map((t, i) => renderCard(t, i, "mine"))}
</div>
)}
</div>
)}
{showSys && (
<div className="ttv3-section">
<div className="ttv3-section-label">系统模板</div>
<div className="ttv3-grid">{grouped.builtin.map((t, i) => renderCard(t, i, "sys"))}</div>
</div>
)}
{showEditor && editorOpen && editingTemplate && (
<TitleTemplateEditor
open={editorOpen}
template={editingTemplate}
onClose={() => {
setEditorOpen(false)
setEditingTemplate(null)
}}
onSave={handleEditorSave}
/>
)}
</div>
)
}
export default TitleTemplateSelector
-19
View File
@@ -1,19 +0,0 @@
/**
* 公共标题模板/样式组件统一导出
*
* 任何页面需要标题样式配置/模板选择/模板编辑,从这里 import,
* 不要直接 import pages/generate/components/title/* 下的内部组件。
*/
export { default as TitleTemplateSelector } from "./TitleTemplateSelector"
export { default as TitleTemplateEditor } from "./TitleTemplateEditor"
export { default as TitleStyleParamsTab } from "./TitleStyleParamsTab"
export { default as TitleMiniPreview } from "./TitleMiniPreview"
export { useTitleTemplates } from "./useTitleTemplates"
export * from "./constants"
export * from "./types"
export * from "./template-types"
export * from "./settings"
export * from "./utils"
export { POSITION_OPTIONS } from "./position-options"
export type { PositionOption, FontOption, TitleStyleParamsTabProps } from "./TitleStyleParamsTab"
export type { TitleTemplateSelectorProps } from "./TitleTemplateSelector"
@@ -1,14 +0,0 @@
/**
* 标题位置选项(公共常量)
*/
export interface PositionOption {
value: string
label: string
}
export const POSITION_OPTIONS: PositionOption[] = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
-64
View File
@@ -1,64 +0,0 @@
/**
* 标题样式设置 — 公共 camelCase 类型与默认值
*
* 本文件是 @/components/title 公共包的唯一样式类型出口,不依赖任何业务页面(generate/ai-avatar)的私有类型。
* - 字段与后端 snake_case TitleStyleConfig 一一对应(camelCase 版本)
* - DEFAULT_TITLE_STYLE_SETTINGS 用于组件内部补全默认值
* - aiAutoSelect / title / coverTitle 等业务状态不在本类型中——它们属于页面业务 state
*/
import type { TitleLineOverride } from "./types"
export interface TitleStyleSettings {
position: string
font: string
size: number
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
color: string
posX: number | null
posY: number | null
lineHeight: number
marginTop: number
maxCharsPerLine: number
strokeWidth: number
strokeColor: string
shadowOffsetX: number
shadowOffsetY: number
shadowBlur: number
shadowColor: string
bgEnabled: boolean
bgColor: string
bgPadding: number
bgRadius: number
lineOverrides: TitleLineOverride[]
}
/** 公共默认样式(经典白字黑描边) */
export const DEFAULT_TITLE_STYLE_SETTINGS: TitleStyleSettings = {
position: "bottom",
font: "思源黑体",
size: 56,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
posX: null,
posY: null,
lineHeight: 1.2,
marginTop: 24,
maxCharsPerLine: 10,
strokeWidth: 5,
strokeColor: "#000000",
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
bgEnabled: false,
bgColor: "rgba(0,0,0,0.5)",
bgPadding: 12,
bgRadius: 8,
lineOverrides: [],
}
+22 -23
View File
@@ -1,25 +1,24 @@
/**
* 标题样式工具(#2001 / 模板系统 #2003)
*
* - snake_case TitleStyleConfig <-> camelCase TitleStyleSettings 互转
* - snake_case TitleStyleConfig ↔ camelCase TitleSettings 互转
* - preset 归一化预览(修复"标题"两字大小不一)
* - template -> preview settings 转换
*/
import type { TitleStyleConfig } from "./types"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import type { TitleSettings } from "../../pages/generate/types"
import { TITLE_PRESETS } from "./constants"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import type { TitleTemplate } from "./template-types"
/** snake_case TitleStyleConfig -> camelCase TitleStyleSettings(仅覆盖已知字段) */
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleStyleSettings> {
const out: Partial<TitleStyleSettings> = {}
/** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleSettings> {
const out: Partial<TitleSettings> = {}
if (s.font != null) out.font = s.font
if (s.size != null) out.size = s.size
if (s.color != null) out.color = s.color
if (s.bold != null) out.bold = s.bold
if (s.italic != null) out.italic = s.italic
if (s.position != null) out.position = s.position
if (s.position != null) out.position = s.position as TitleSettings["position"]
if (s.pos_x != null) out.posX = s.pos_x
if (s.pos_y != null) out.posY = s.pos_y
if (s.line_height != null) out.lineHeight = s.line_height
@@ -41,8 +40,8 @@ export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<T
return out
}
/** camelCase TitleStyleSettings patch -> snake_case TitleStyleConfig patch */
export function camelToTitleStyleConfig(p: Partial<TitleStyleSettings>): Partial<TitleStyleConfig> {
/** camelCase TitleSettings patch → snake_case TitleStyleConfig patch */
export function camelToTitleStyleConfig(p: Partial<TitleSettings>): Partial<TitleStyleConfig> {
const out: Partial<TitleStyleConfig> = {}
if (p.font != null) out.font = p.font
if (p.size != null) out.size = p.size
@@ -72,15 +71,15 @@ export function camelToTitleStyleConfig(p: Partial<TitleStyleSettings>): Partial
}
/**
* 把 preset style(snake_case)归一化为固定字号的 TitleStyleSettings,
* 把 preset style(snake_case)归一化为固定字号的 TitleSettings,
* 用于"预设卡片"缩略预览——所有卡片视觉上"标题"两字大小一致,便于辨识。
* 描边/阴影/背景padding 按 fixedSize / 原始 size 比例缩放,避免粗描边爆框。
*/
export function buildPresetPreviewSettings(
base: TitleStyleSettings,
base: TitleSettings,
presetKey: string,
fixedSize = 56,
): TitleStyleSettings {
): TitleSettings {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return base
const origSize = preset.style.size ?? fixedSize
@@ -88,25 +87,25 @@ export function buildPresetPreviewSettings(
const scale = (v: number | undefined, fallback: number): number =>
v != null ? Math.round(v * ratio) : fallback
return {
...DEFAULT_TITLE_STYLE_SETTINGS,
...base,
...titleStyleConfigToCamel(preset.style),
size: fixedSize,
strokeWidth: scale(preset.style.stroke_width, base.strokeWidth),
shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX),
shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY),
shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur),
bgPadding: scale(preset.style.bg_padding, base.bgPadding),
strokeWidth: scale(preset.style.stroke_width, base.strokeWidth) ?? base.strokeWidth,
shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX) ?? base.shadowOffsetX,
shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY) ?? base.shadowOffsetY,
shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur) ?? base.shadowBlur,
bgPadding: scale(preset.style.bg_padding, base.bgPadding) ?? base.bgPadding,
lineOverrides: [],
}
}
/**
* 把 TitleTemplate 渲染为完整 TitleStyleSettings(带默认值),用于卡片预览。
* 把 TitleTemplate 渲染为完整 TitleSettings(带默认值),用于卡片预览。
* 与模板选择器中保持一致,抽出共用。
*/
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleStyleSettings {
const base: TitleStyleSettings = {
...DEFAULT_TITLE_STYLE_SETTINGS,
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleSettings {
const base: TitleSettings = {
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(t.style),
}
// 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放
+1 -1
View File
@@ -10,4 +10,4 @@
* 功能流程不做积分预校验,直接走生成。
* - true:展示完整积分系统 UI。
*/
export const ENABLE_CREDIT_SYSTEM = true
export const ENABLE_CREDIT_SYSTEM = false
-13
View File
@@ -18,7 +18,6 @@ import {
ThunderboltOutlined,
UnorderedListOutlined,
UserOutlined,
FireOutlined,
} from "@ant-design/icons"
/** 导航项类型 */
@@ -77,12 +76,6 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "viral-video",
label: "爆款视频",
path: "/app/viral-video",
icon: React.createElement(FireOutlined),
},
{
key: "history",
label: "任务历史",
@@ -149,12 +142,6 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "viral-video",
label: "爆款视频",
path: "/app/viral-video",
icon: React.createElement(FireOutlined),
},
],
},
{
+16 -22
View File
@@ -80,8 +80,6 @@ const AiAvatarPage: React.FC = () => {
const [finalizeLoading, setFinalizeLoading] = useState(false)
/* ── 对口型轮询 ── */
/** 对口型轮询总时长上限(10分钟):超过后停止轮询并提示去历史记录查看 */
const LIPSYNC_POLL_MAX_MS = 10 * 60 * 1000
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 渲染进度轮询 ── */
const renderTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -272,24 +270,7 @@ const AiAvatarPage: React.FC = () => {
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
// 但仍需轮询等 completed
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
// 轮询间隔 5 秒;单请求超时 5 分钟(见 api/aiAvatar.ts);总轮询上限 10 分钟
// 单次请求失败/超时不中断轮询,继续下一轮;超过总上限后停止并提示用户去历史记录查看
lipsyncTimerRef.current = setInterval(async () => {
// 总时长保护:超过 10 分钟停止轮询
if (Date.now() - lipsyncStartAtRef.current > LIPSYNC_POLL_MAX_MS) {
if (lipsyncTimerRef.current) {
clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = null
}
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setLipsyncStatus("failed")
setLipsyncErrorMessage("渲染时间较长,请稍后在历史记录中查看")
message.warning("对口型渲染时间较长,已停止自动刷新,请稍后在历史记录中查看")
return
}
try {
const updated = await getLipsyncJob(job.id)
state.setLipsyncJob(updated)
@@ -315,10 +296,9 @@ const AiAvatarPage: React.FC = () => {
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
}
} catch (err) {
// 单次轮询失败(含 timeout):不中断轮询,打印日志后等下一轮
console.warn("[对口型] 轮询请求失败,将继续下一轮:", err)
console.error("[对口型] 轮询错误:", err)
}
}, 5000)
}, 3000)
} catch (err) {
console.error("[对口型] 创建失败:", {
status: (err as { response?: { status?: number } })?.response?.status,
@@ -600,6 +580,20 @@ const AiAvatarPage: React.FC = () => {
return (
<div className="aa-page">
<div className="aa-page-header">
<h1>AI数字人</h1>
</div>
{/* 步骤切换导航条 */}
<div className="aa-step-nav">
<span className={`aa-step-nav__item${currentStep === 1 ? " active" : ""}`}>
1. 视频 / 配音 / 文案
</span>
<span className={`aa-step-nav__item${currentStep === 2 ? " active" : ""}`}>
2. 对口型 / 标题 / 封面 / 生成
</span>
</div>
<div className="aa-page-body">
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */}
{currentStep === 1 && (
+2 -6
View File
@@ -72,8 +72,7 @@ export const previewTts = async (data: {
}
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
// MuseTalk 渲染 8s 视频约 54s + 排队时间,给足 5 分钟超时避免单次轮询 AxiosError 中断
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 300_000 })
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
return response.data
}
@@ -92,10 +91,7 @@ export const submitRender = async (data: {
}
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
// 渲染链路(对口型+B-roll+标题+合成+上传)耗时较长,给足 5 分钟超时
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, {
timeout: 300_000,
})
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
return response.data
}
@@ -412,7 +412,7 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
onUpdateStyle={handleUpdateStyle}
showCoverToggle
previewWidth={280}
enableTemplates={true}
enableTemplates={false}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={handleApplyTemplate}
activePreset={activePreset}
+10 -8
View File
@@ -13,6 +13,8 @@ import CloneModal from "@/components/voice/CloneModal"
import VoiceSelectModal from "./components/VoiceSelectModal"
import ScriptSelectModal from "./components/ScriptSelectModal"
import TtsVoiceModal from "./components/TtsVoiceModal"
import GenerateHeader from "./components/GenerateHeader"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
@@ -86,6 +88,7 @@ const GeneratePage: React.FC = () => {
style,
autoSubtitles,
bgm,
editPlanId,
sourceEditPlanId,
previewTaskId,
setPreviewTaskId,
@@ -134,8 +137,8 @@ const GeneratePage: React.FC = () => {
}
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
/* ── 标题面板模式:true = 内联大卡片模板网格(默认),false = 旧预设+参数 Tab ── */
const enableTemplates = true
/* ── 标题面板模式:默认展示样式参数(false);需要大卡片模板网格时再切 true ── */
const enableTemplates = false
/* ── Step5 保存中状态 ── */
const [finishing, setFinishing] = useState(false)
@@ -236,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)
}
},
})
@@ -520,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 (
@@ -6,13 +6,12 @@
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
*/
import React, { useMemo } from "react"
import React, { useMemo, useState } from "react"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS } from "../constants"
import { FONT_OPTIONS } from "@/components/title/constants"
import { useStep4Title } from "../hooks/useStep4Title"
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
import TitleStylePanel from "./title/TitleStylePanel"
import TitleStyleModal from "./title/TitleStyleModal"
import Button from "@/components/ui/Button"
import type { TitleTemplate } from "@/components/title/template-types"
interface Step4TitleSettingsProps {
@@ -58,13 +57,6 @@ interface Step4TitleSettingsProps {
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
const t = useStep4Title(props)
const {
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onApplyPreset,
onUpdateStyle,
activePreset,
@@ -72,14 +64,12 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
previewCount = 1,
previewTitles,
onPreviewTitlesChange,
enableTemplates,
selectedTemplateId,
onApplyTemplate,
onConfirmGenerate,
generating,
selectedCount = 1,
} = props
const [showTitleStyleModal, setShowTitleStyleModal] = useState(false)
const isBatch = previewCount > 1
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
@@ -157,7 +147,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
/>
</div>
) : (
/* ── 批量:N 个独立标题输入框(两列布局 #2096) ── */
/* ── 批量:N 个独立标题输入框 ── */
<div className="xx-batch-titles">
<div
style={{
@@ -170,49 +160,44 @@ 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>
)}
{/* 标题样式面板(全局共用) */}
<TitleStylePanel
{/* 标题样式:点击打开弹窗(参考字幕模板/封面模板 Modal 模式) */}
<TitleStyleModal
open={showTitleStyleModal}
onClose={() => setShowTitleStyleModal(false)}
titleText={previewTitles?.[0] ?? t.titleSettings.title}
settings={t.titleSettings}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
showCoverToggle
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
onSave={(nextSettings, nextPreset) => {
onUpdateStyle?.(nextSettings)
if (nextPreset) onApplyPreset(nextPreset)
}}
/>
<div className="xx-form-field">
<label>标题样式</label>
<Button
buttonType="ghost"
onClick={() => setShowTitleStyleModal(true)}
style={{ justifyContent: "flex-start", textAlign: "left" }}
>
{activePreset
? titlePresets.find((p) => p.key === activePreset)?.label || "自定义样式"
: "点击选择标题样式(预设 / 字体 / 颜色 / 描边 / 阴影)"}
</Button>
</div>
</div>
)
}
@@ -17,7 +17,7 @@ import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateCover as apiGenerateCover } from "@/api/generation"
import { uploadAssetDirect } from "@/api/assets"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
@@ -176,8 +176,15 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
thumbnail_url: previewUrl,
mode: "upload",
})
// 后端自动在默认项目下确保图片素材库存在(P0 404 修复)
const result = await uploadAssetDirect({ file, kind: "image" })
// 查找图片素材库(复用批量封面的逻辑)
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
hide()
message.error("未找到素材库,请先创建图片素材库")
return previewUrl
}
const result = await uploadAssetDirect({ file, library_id: imageLib.id })
const realUrl = result?.url || ""
if (!realUrl) {
hide()
@@ -1 +1,271 @@
export { default } from "@/components/title/TitleMiniPreview"
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleSettings } from "../../types"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
background?: string
/** 高度(可选,默认按 portrait 选比例) */
height?: number
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
transparent?: boolean
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
portrait?: boolean
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
transparent = false,
portrait = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
useEffect(() => {
let cancelled = false
const draw = () => {
if (cancelled) return
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景(transparent 时跳过,用于叠加在图片上)
if (!transparent) {
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
}
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致
const r = previewR
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
const botMargin = portrait ? r(24) : r(16)
startY = h - totalH - botMargin + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色,
// 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」
ctx.fillStyle = settings.color
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}
// 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用)
const previewScale = width / 360
const previewR = (v: number) => Math.round(v * previewScale)
const buildFontString = () => {
const size = previewR(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
return parts.join(" ")
}
// Web Font 加载保障:
// 1) 等 document.fonts.ready(CSS @font-face 首次可用)
// 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载
// 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块
const doDrawWhenReady = async () => {
try {
if (typeof document !== "undefined" && document.fonts) {
await document.fonts.ready
try {
await document.fonts.load(buildFontString(), text)
} catch {
/* ignore */
}
}
} finally {
if (!cancelled) draw()
}
}
doDrawWhenReady()
return () => {
cancelled = true
}
}, [settings, width, h, text, transparent, portrait, background])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background: transparent ? "transparent" : background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -0,0 +1,90 @@
.tsm-body {
padding: 0;
}
.tsm-preview {
width: 100%;
background: #111;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
display: flex;
justify-content: center;
overflow: hidden;
}
.tsm-presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
max-height: 440px;
overflow-y: auto;
padding: 4px 2px 8px;
}
.tsm-preset-card {
display: flex;
flex-direction: column;
align-items: center;
background: #fff;
border: 2px solid #e5e7eb;
border-radius: 10px;
padding: 8px 6px 6px;
cursor: pointer;
transition: all 0.15s ease;
overflow: hidden;
}
.tsm-preset-card:hover {
border-color: #c4b5fd;
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(124, 58, 237, 0.12);
}
.tsm-preset-card.active {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.18);
}
.tsm-preset-preview {
width: 100%;
aspect-ratio: 9 / 16;
background: #1a1a1a;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.tsm-preset-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 6px;
font-size: 12px;
color: #374151;
}
.tsm-preset-emoji {
font-size: 14px;
}
.tsm-preset-label {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
.tsm-custom-panel {
max-height: 440px;
overflow-y: auto;
padding: 4px 2px 8px;
}
.tsm-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid #f3f4f6;
}
@@ -0,0 +1,189 @@
/**
* 标题样式弹窗(Step3/智能剪辑用)
*
* 交互:
* - 点击"标题样式"按钮/卡片打开
* - 默认 Tab「预设」:10 个爆款预设卡片网格(Canvas 实时预览),点击选中
* - Tab「自定义」:详细参数(位置/字体/字号/粗斜/描边/阴影/颜色/背景)
* - 底部「取消」「保存」按钮:保存才会把临时编辑的 settings 应用到父组件
*
* 设计参考:CoverSettingsModal / 字幕模板选择弹窗
*/
import React, { useEffect, useState } from "react"
import { Tabs } from "antd"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import type { TitleSettings } from "../../types"
import { POSITION_OPTIONS } from "../../constants"
import { FONT_OPTIONS } from "@/components/title/constants"
import TitleStylePanel from "./TitleStylePanel"
import TitleMiniPreview from "./TitleMiniPreview"
import { titleStyleConfigToCamel } from "@/components/title/utils"
import { TITLE_PRESETS as TITLE_PRESETS_DATA } from "@/components/title/constants"
import "./TitleStyleModal.css"
interface TitleStyleModalProps {
open: boolean
onClose: () => void
/** 当前标题文字(用于预览) */
titleText?: string
/** 当前生效的标题样式 */
settings: TitleSettings
/** 保存回调:把编辑后的 settings 回传父组件 */
onSave: (settings: TitleSettings, presetKey: string | null) => void
/** 当前选中的爆款预设 key(用于高亮) */
activePreset?: string | null
}
/** 把预设应用到 base settings(参考 useTitleStyleUpdaters.applyPreset 逻辑) */
function applyPresetToSettings(base: TitleSettings, presetKey: string): TitleSettings {
const preset = TITLE_PRESETS_DATA.find((p) => p.key === presetKey)
if (!preset) return base
return {
...base,
...(titleStyleConfigToCamel(preset.style) as Partial<TitleSettings>),
lineOverrides: [],
}
}
const TitleStyleModal: React.FC<TitleStyleModalProps> = ({
open,
onClose,
titleText = "标题",
settings,
onSave,
activePreset: externalActivePreset = null,
}) => {
// 弹窗内维护一份草稿 settings,点保存才提交到父组件;取消/关闭丢弃
const [draft, setDraft] = useState<TitleSettings>(settings)
const [activePreset, setActivePreset] = useState<string | null>(externalActivePreset)
// 每次打开弹窗,用父组件最新 settings 重置草稿
useEffect(() => {
if (open) {
setDraft(settings)
setActivePreset(externalActivePreset)
}
}, [open, settings, externalActivePreset])
const handleApplyPreset = (presetKey: string) => {
setActivePreset(presetKey)
const next = applyPresetToSettings(draft, presetKey)
setDraft(next)
}
const handleUpdateStyle = (patch: Partial<TitleSettings>) => {
setDraft((prev) => ({ ...prev, ...patch }))
// 手动调了参数,清除预设高亮(因为不再严格匹配某个预设)
setActivePreset(null)
}
const handleUpdatePosition = (position: string) =>
handleUpdateStyle({ position: position as TitleSettings["position"] })
const handleUpdateFont = (font: string) => handleUpdateStyle({ font })
const handleUpdateSize = (size: number) => handleUpdateStyle({ size })
const handleToggleBold = () => handleUpdateStyle({ bold: !draft.bold })
const handleToggleItalic = () => handleUpdateStyle({ italic: !draft.italic })
const handleToggleStroke = () => handleUpdateStyle({ stroke: !draft.stroke })
const handleToggleShadow = () => handleUpdateStyle({ shadow: !draft.shadow })
const handleSave = () => {
onSave(draft, activePreset)
onClose()
}
return (
<Modal open={open} onCancel={onClose} title="标题样式" width={720} footer={null} destroyOnClose>
<div className="tsm-body">
{/* 顶部预览 */}
<div className="tsm-preview">
<TitleMiniPreview
settings={draft}
width={640}
sampleText={titleText || "标题"}
transparent={false}
portrait
/>
</div>
<Tabs
defaultActiveKey="presets"
size="small"
items={[
{
key: "presets",
label: "爆款预设",
children: (
<div className="tsm-presets-grid">
{TITLE_PRESETS_DATA.map((p) => {
const previewSettings = applyPresetToSettings(draft, p.key)
const isActive = activePreset === p.key
return (
<button
key={p.key}
type="button"
className={`tsm-preset-card${isActive ? " active" : ""}`}
onClick={() => handleApplyPreset(p.key)}
title={p.label}
>
<div className="tsm-preset-preview">
<TitleMiniPreview
settings={previewSettings}
width={140}
sampleText="标题"
/>
</div>
<div className="tsm-preset-meta">
<span className="tsm-preset-emoji">{p.emoji}</span>
<span className="tsm-preset-label">{p.label}</span>
</div>
</button>
)
})}
</div>
),
},
{
key: "custom",
label: "自定义",
children: (
<div className="tsm-custom-panel">
<TitleStylePanel
settings={draft}
onUpdatePosition={handleUpdatePosition}
onUpdateFont={handleUpdateFont}
onUpdateSize={handleUpdateSize}
onToggleBold={handleToggleBold}
onToggleItalic={handleToggleItalic}
onToggleStroke={handleToggleStroke}
onToggleShadow={handleToggleShadow}
onApplyPreset={handleApplyPreset}
onUpdateStyle={handleUpdateStyle}
activePreset={activePreset}
titlePresets={[]}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
paramsOnly
hideEditor
/>
</div>
),
},
]}
/>
</div>
{/* 底部按钮栏 */}
<div className="tsm-footer">
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={handleSave}>
保存
</Button>
</div>
</Modal>
)
}
export default TitleStyleModal
@@ -31,7 +31,7 @@ import {
} from "@/components/title/constants"
import { buildPresetPreviewSettings } from "@/components/title/utils"
import TitleMiniPreview from "@/components/title/TitleMiniPreview"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "@/components/title/TitleTemplateEditor"
import { useTitleTemplates } from "@/components/title/useTitleTemplates"
import {
@@ -177,7 +177,7 @@ const ColorPicker: React.FC<{
/* ── 卡片预览:用 ref 测量容器宽度后再渲染透明 Canvas,保证文字清晰 ── */
const FillPreview: React.FC<{
settings: import("@/components/title/settings").TitleStyleSettings
settings: TitleSettings
sampleText: string
portrait?: boolean
}> = ({ settings, sampleText, portrait }) => {
+7 -3
View File
@@ -46,9 +46,13 @@ export const CLIP_COUNT_STEP = 1
export const MAX_PREVIEW_COUNT = 10
export const MIN_PREVIEW_COUNT = 1
/* ── 标题位置选项(统一从公共层重导出) ── */
export { POSITION_OPTIONS } from "@/components/title/position-options"
export type { PositionOption } from "@/components/title/position-options"
/* ── 标题位置选项 ── */
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
/* ── 标题字体:统一使用公共层定义(#2001) ── */
export { getFontFamily } from "@/components/title/constants"
@@ -41,8 +41,8 @@ export interface UseGenerateVideoProps {
enabled: boolean
music_id?: string
}
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态);status=awaiting_cover 表示需进封面选择 */
onGenerationSuccess?: (status?: "completed" | "awaiting_cover") => void
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
onGenerationSuccess?: () => void
/* ── 批量生成(#1677)── */
/** 生成数量(1=单条旧逻辑,>1=批量) */
previewCount?: number
@@ -1,4 +1,4 @@
import { useRef, useCallback, useState, useEffect } from "react"
import { useRef, useCallback, useState } from "react"
import { message } from "antd"
import axios from "axios"
import { getGenerationTask, retryTask as retryGenerationTaskApi } from "@/api/tasks/tasks"
@@ -10,7 +10,7 @@ export interface BatchTaskState {
taskId: string
/** 变体序号(0-based,与标题/封面数组对齐) */
variantIndex: number
status: "running" | "completed" | "awaiting_cover" | "failed" | "queued"
status: "running" | "completed" | "awaiting_cover" | "failed"
progress: number
error: string | null
/** 完成后的成片视频 */
@@ -19,7 +19,7 @@ export interface BatchTaskState {
interface UseGenerationPollingOptions {
onProgress: (progress: number) => void
onComplete: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
onComplete: (videos: unknown[]) => void
onFailed: (errorMsg: string) => void
/** 批量:单任务状态变化(第5步逐卡片展示) */
onBatchTaskUpdate?: (taskId: string, patch: Partial<BatchTaskState>) => void
@@ -31,19 +31,13 @@ const MAX_RETRYABLE_ERRORS = 10
const MAX_RESULTS_RETRIES = 3
/**
* 生成状态轮询 Hook(v5 — awaiting_cover 状态识别 + visibilitychange 恢复 + 状态透传)
* 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试)
*
* startPolling(taskId) 轮询单个任务;
* startPollingBatch(tasks) 并行轮询 N 个任务:
* - 每个任务独立进度/状态/失败,通过 onBatchTaskUpdate 实时回传
* - 全部成功才 onComplete(聚合视频按变体顺序);任一失败不影响其他任务继续
* - retryTask(taskId) 单独重试失败任务(重新轮询,后端任务仍在跑则直接接续)
*
* v5 修复(#2088):
* 1. 单任务路径透传 taskStatus(completed / awaiting_cover)到 onComplete,外层据此区分跳转
* 2. 监听 visibilitychange,页面从后台切回可见时立即补拉一次,解决切后台 setInterval 被浏览器
* 降频/冻结导致进度卡在 56% 的问题
* 3. 非 4xx/5xx 网络错误按 3s 退避重试(已有 MAX_RETRYABLE_ERRORS=10 兜底)
*/
export function useGenerationPolling({
onProgress,
@@ -55,15 +49,12 @@ export function useGenerationPolling({
const cancelledRef = useRef(false)
/** 批量任务上下文:taskId → 变体序号 */
const batchContextRef = useRef<Map<string, number>>(new Map())
/** 当前活跃的「立刻补拉一次」函数(visibilitychange 回调使用) */
const immediateTickRef = useRef<(() => void) | null>(null)
const [, forceTick] = useState(0)
const clearTimer = useCallback(() => {
cancelledRef.current = true
progressTimer.current.forEach((t) => clearTimeout(t))
progressTimer.current = []
immediateTickRef.current = null
}, [])
/** 任务完成后拉取结果列表,带重试 */
@@ -122,7 +113,6 @@ export function useGenerationPolling({
if (task.status === "completed" || task.status === "awaiting_cover") {
done = true
immediateTickRef.current = null
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
@@ -138,7 +128,6 @@ export function useGenerationPolling({
if (task.status === "failed" || task.status === "cancelled") {
done = true
immediateTickRef.current = null
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
@@ -149,7 +138,6 @@ export function useGenerationPolling({
return
}
// running / pending / waiting:更新进度并安排下一次轮询
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
callbacks?.onTaskProgress?.(pct)
if (!callbacks && runId === 0) {
@@ -161,20 +149,16 @@ export function useGenerationPolling({
if (cancelledRef.current || done) return
console.error("[轮询出错] taskId:", taskId, pollErr)
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
// 4xx 视为不可重试(任务不存在/权限问题等),直接失败
if (status && status >= 400 && status < 500) {
done = true
immediateTickRef.current = null
const msg = extractErrorMessage(pollErr, status)
callbacks?.onTaskFailed?.(msg)
reject(new Error(msg))
return
}
// 网络错误 / 5xx:3s 退避重试,最多 MAX_RETRYABLE_ERRORS 次
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
done = true
immediateTickRef.current = null
const msg = "任务状态查询连续失败,请稍后在任务列表查看结果"
callbacks?.onTaskFailed?.(msg)
reject(new Error(msg))
@@ -185,18 +169,6 @@ export function useGenerationPolling({
}
}
// 注册「立刻补拉一次」回调,供 visibilitychange 恢复时调用
// 注意:必须在 done 后清理,避免切换页面时误触发已结束任务的补拉
immediateTickRef.current = () => {
if (!done && !cancelledRef.current) {
// 清除未触发的 setTimeout,立即拉一次
progressTimer.current.forEach((t) => clearTimeout(t))
progressTimer.current = []
consecutiveErrors = 0
void poll()
}
}
const timer = setTimeout(poll, 1500)
progressTimer.current.push(timer)
})
@@ -209,22 +181,12 @@ export function useGenerationPolling({
(taskId: string) => {
cancelledRef.current = false
batchContextRef.current.clear()
let resolvedStatus: "completed" | "awaiting_cover" = "completed"
pollSingleTask(taskId, 0, {
onTaskProgress: (pct) => onProgress(pct),
onTaskCompleted: (videos, taskStatus) => {
resolvedStatus = taskStatus ?? "completed"
onProgress(100)
onComplete(videos, resolvedStatus)
},
onTaskFailed: (msg) => onFailed(msg),
})
.then(() => {
pollSingleTask(taskId, 0)
.then((videos) => {
if (cancelledRef.current) return
// awaiting_cover 是中间态(进封面选择页),不弹"完成"toast;completed 才弹
if (resolvedStatus === "completed") {
message.success("视频生成完成!")
}
onProgress(100)
onComplete(videos)
message.success("视频生成完成!")
})
.catch((err: Error) => {
if (cancelledRef.current) return
@@ -239,7 +201,7 @@ export function useGenerationPolling({
/**
* 批量多任务轮询:
* - 每个任务独立进度/状态回传 onBatchTaskUpdate
* - 全部完成后按变体顺序聚合视频 onComplete
* * 全部完成后按变体顺序聚合视频 onComplete
* - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed
*/
const startPollingBatch = useCallback(
@@ -249,7 +211,6 @@ export function useGenerationPolling({
const progressMap = new Map<string, number>()
const resultMap = new Map<string, unknown[]>()
const failureMap = new Map<string, string>()
const statusMap = new Map<string, "completed" | "awaiting_cover">()
batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex]))
const reportAggregateProgress = () => {
@@ -264,9 +225,7 @@ export function useGenerationPolling({
if (resultMap.size === tasks.length) {
onProgress(100)
const ordered = tasks.map((t) => resultMap.get(t.taskId) || []).flat()
// 批量:任一任务为 awaiting_cover,则整体透传 awaiting_cover(进封面页)
const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover")
onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed")
onComplete(ordered)
message.success(`全部 ${tasks.length} 个视频生成完成!`)
} else if (resultMap.size > 0) {
// 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮
@@ -275,8 +234,7 @@ export function useGenerationPolling({
.filter((t) => resultMap.has(t.taskId))
.map((t) => resultMap.get(t.taskId) || [])
.flat()
const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover")
onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed")
onComplete(ordered)
message.warning(
`${failureMap.size} 个视频生成失败,可点击卡片上的「重试此视频」,成功的视频可先进入下一步`,
)
@@ -305,7 +263,6 @@ export function useGenerationPolling({
progressMap.set(taskId, 100)
resultMap.set(taskId, videos)
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
statusMap.set(taskId, _finalStatus)
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
reportAggregateProgress()
checkAllSettled()
@@ -352,64 +309,5 @@ export function useGenerationPolling({
[pollSingleTask, onBatchTaskUpdate],
)
/**
* visibilitychange 恢复:页面从后台切回前台时,立刻触发一次补拉。
* 解决浏览器后台标签页对 setTimeout 的 1Hz 节流/冻结导致的"进度卡 56%"问题。
*/
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && immediateTickRef.current) {
immediateTickRef.current()
}
}
document.addEventListener("visibilitychange", handleVisibilityChange)
// 页面聚焦也兜底一次(部分浏览器 visibilitychange 触发时机不一致)
const handleFocus = () => {
if (immediateTickRef.current) immediateTickRef.current()
}
window.addEventListener("focus", handleFocus)
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange)
window.removeEventListener("focus", handleFocus)
}
}, [])
/**
* 批量队列模式:逐任务追加到轮询队列(支持串行提交、429 排队重试场景)。
* 与 startPollingBatch 不同的是:
* - 不会 reset batchContextRef;多次调用会累积
* - 不触发整体 onComplete / onFailed(完成判定交给外层 useEffect 按状态聚合)
* - 仍通过 onBatchTaskUpdate 回传单任务状态
*/
const pollBatchTaskQueued = useCallback(
(taskId: string, variantIndex: number) => {
cancelledRef.current = false
batchContextRef.current.set(taskId, variantIndex)
onBatchTaskUpdate?.(taskId, {
taskId,
variantIndex,
status: "running",
progress: 0,
error: null,
videos: [],
})
pollSingleTask(taskId, Date.now(), {
onTaskProgress: (pct) => {
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
},
onTaskCompleted: (videos, taskStatus) => {
const finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: finalStatus, progress: 100, videos })
},
onTaskFailed: (msg) => {
onBatchTaskUpdate?.(taskId, { status: "failed", error: msg })
},
}).catch(() => {
/* onTaskFailed 已处理 */
})
},
[pollSingleTask, onBatchTaskUpdate],
)
return { startPolling, startPollingBatch, pollBatchTaskQueued, retryTask, clearTimer }
return { startPolling, startPollingBatch, retryTask, clearTimer }
}
@@ -12,7 +12,7 @@
import { useCallback, useState } from "react"
import { message } from "antd"
import { generateCover } from "@/api/generation"
import { uploadAssetDirect } from "@/api/assets"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import type { GeneratedVideo } from "@/api/template-editor"
/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */
@@ -182,9 +182,15 @@ export function useBatchCovers({
async (index: number, file: File) => {
addUploading(index)
try {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
message.error("未找到素材库,请先创建")
return
}
const result = await uploadAssetDirect({
file,
kind: "image",
library_id: imageLib.id,
})
const url = result?.url || ""
if (url) {
@@ -2,12 +2,10 @@
* 视频生成 Hook
* 封装视频生成的核心逻辑、状态管理、轮询等
*/
import { useState, useCallback, useEffect, useRef } from "react"
import { useState, useCallback, useEffect } from "react"
import { message } from "antd"
import axios from "axios"
import { type GeneratedVideo, getEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
import { createGenerationTask } from "@/api/tasks/tasks"
import type { CreateGenerationTaskRequest } from "@/api/tasks/types"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling, type BatchTaskState } from "./generate-video/useGenerationPolling"
@@ -15,28 +13,6 @@ import { validateGenerateInputs } from "./generate-video/buildPayload"
import { calculateResolution } from "../utils/calculateResolution"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
export type GenerationCompleteStatus = "completed" | "awaiting_cover" | null
/** 判断是否是用户队列已满 429(需要排队重试而非直接报错) */
function isUserQueueFullError(err: unknown): { waitMs: number } | null {
if (!axios.isAxiosError(err)) return null
if (err.response?.status !== 429 && err.response?.status !== 503) return null
const detail = (err.response?.data as { detail?: unknown })?.detail
const code =
typeof detail === "object" && detail !== null ? (detail as { code?: string }).code : undefined
if (code === "USER_QUEUE_FULL" || code === "SYSTEM_QUEUE_FULL") {
const waitSec =
typeof detail === "object" && detail !== null
? Number((detail as { estimated_wait_seconds?: number }).estimated_wait_seconds) || 0
: 0
return { waitMs: Math.max(15_000, waitSec * 1000 || 30_000) }
}
return null
}
/** sleep */
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
export function useGenerateVideo(props: UseGenerateVideoProps) {
const { selectedTemplate, onGenerationSuccess } = props
@@ -46,29 +22,11 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const [generated, setGenerated] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
/** #2088:任务最终状态,区分 awaiting_cover(选封面)/ completed(已完成) */
const [completionStatus, setCompletionStatus] = useState<GenerationCompleteStatus>(null)
/** 单视频模式:当前任务 ID(封面 finalize 需要) */
const [currentTaskId, setCurrentTaskId] = useState<string>("")
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
/** 排队中重试的定时器,unmount / 新提交时清理 */
const queueTimersRef = useRef<number[]>([])
const cancelledRef = useRef(false)
const clearQueueTimers = useCallback(() => {
queueTimersRef.current.forEach((id) => clearTimeout(id))
queueTimersRef.current = []
}, [])
useEffect(() => {
return () => {
cancelledRef.current = true
clearQueueTimers()
}
}, [clearQueueTimers])
const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial<BatchTaskState>) => {
setBatchTasks((prev) => {
const list = prev || []
@@ -95,10 +53,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const handleProgress = useCallback((p: number) => setProgress(p), [])
const handleComplete = useCallback(
(videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => {
(videos: unknown[]) => {
setGenerating(false)
setGenerated(true)
const finalStatus: GenerationCompleteStatus = taskStatus ?? "completed"
setCompletionStatus(finalStatus)
setGeneratedVideos(videos as GeneratedVideo[])
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
setBatchTasks((prev) =>
@@ -113,35 +70,26 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
: t,
),
)
onGenerationSuccess?.(finalStatus)
onGenerationSuccess?.()
},
[onGenerationSuccess],
)
const handleFailed = useCallback((errorMsg: string) => {
setGenerating(false)
setGenerateError(errorMsg)
}, [])
/* 批量:任务状态变化时聚合已完成成片(含失败重试成功后补入),
按变体索引排序,供步骤6封面按勾选顺序逐个取视频。
当全部任务都已结束(completed/awaiting_cover/failed)且无排队/渲染中任务时,关闭 generating。 */
按变体索引排序,供步骤6封面按勾选顺序逐个取视频 */
useEffect(() => {
if (batchTasks.length === 0) return
const byVariant = new Map<number, GeneratedVideo>()
let hasQueued = false
let hasRunning = false
let hasSuccess = false
let allDone = true
batchTasks.forEach((t) => {
if (t.status === "queued") hasQueued = true
else if (t.status === "running") hasRunning = true
if (t.status === "completed" || t.status === "awaiting_cover") {
hasSuccess = true
if (t.videos && t.videos.length > 0) {
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
}
}
if (t.status !== "completed" && t.status !== "awaiting_cover" && t.status !== "failed") {
allDone = false
if (
t.status === "completed" ||
(t.status === "awaiting_cover" && t.videos && t.videos.length > 0)
) {
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
}
})
const ordered = [...byVariant.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v)
@@ -151,185 +99,17 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
return ordered
})
if (allDone && !hasQueued && !hasRunning) {
setGenerating(false)
if (hasSuccess) {
setGenerated(true)
setCompletionStatus("awaiting_cover")
}
}
}, [batchTasks])
const { startPolling, pollBatchTaskQueued, retryTask, clearTimer } = useGenerationPolling({
const { startPolling, startPollingBatch, retryTask, clearTimer } = useGenerationPolling({
onProgress: handleProgress,
onComplete: handleComplete,
onFailed: handleFailed,
onBatchTaskUpdate: handleBatchTaskUpdate,
})
/** 根据 props 构造基础 payload(批量/单任务共用的字段) */
const buildBasePayload = useCallback((): Omit<
CreateGenerationTaskRequest,
"count" | "titles" | "voice_library_ids" | "cover_urls" | "variant_plan_ids"
> => {
const { width: outputWidth, height: outputHeight } = calculateResolution(
props.videoRatio || "9:16",
)
const editMode = props.editMode ?? "random"
const dedupEnabled = props.dedupEnabled !== false
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice
const voiceLibraryId =
editMode === "narrative"
? props.ttsVoiceId || ""
: props.voiceMode === "clone"
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
const bgmConfig = {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
}
const titleConfig = props.titleSettings?.title
? {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
...(props.titleSettings.position === "custom" &&
props.titleSettings.posX != null &&
props.titleSettings.posY != null
? {
pos_x: Math.round(props.titleSettings.posX),
pos_y: Math.round(props.titleSettings.posY),
}
: {}),
bold: props.titleSettings.bold,
italic: props.titleSettings.italic,
stroke: props.titleSettings.stroke
? {
enabled: true,
width: props.titleSettings.strokeWidth ?? 4,
color: props.titleSettings.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.shadow
? {
enabled: true,
offset_x: props.titleSettings.shadowOffsetX ?? 2,
offset_y: props.titleSettings.shadowOffsetY ?? 2,
blur: props.titleSettings.shadowBlur ?? 4,
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: props.titleSettings.lineHeight ?? 1.2,
margin_top: props.titleSettings.marginTop ?? 24,
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
...(props.titleSettings.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.bgColor,
padding: props.titleSettings.bgPadding,
radius: props.titleSettings.bgRadius,
},
}
: { background: { enabled: false } }),
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
line_index: lo.line_index,
text: lo.text,
size: lo.size,
color: lo.color,
bold: lo.bold,
italic: lo.italic,
stroke: lo.stroke,
highlights: lo.highlights?.map((h) => ({
word: h.word,
color: h.color,
bold: h.bold,
scale: h.scale,
})),
})),
...(props.titleSettings.coverTitle
? {
cover_title_config: {
title: props.titleSettings.coverTitle.title,
font: props.titleSettings.coverTitle.font,
font_size: props.titleSettings.coverTitle.size,
font_color: props.titleSettings.coverTitle.color,
bold: props.titleSettings.coverTitle.bold,
italic: props.titleSettings.coverTitle.italic,
position: props.titleSettings.coverTitle.position,
stroke: props.titleSettings.coverTitle.stroke
? {
enabled: true,
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.coverTitle.shadow
? {
enabled: true,
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
color: props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
...(props.titleSettings.coverTitle.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.coverTitle.bgColor,
padding: props.titleSettings.coverTitle.bgPadding,
radius: props.titleSettings.coverTitle.bgRadius,
},
}
: { background: { enabled: false } }),
},
}
: {}),
}
: undefined
const payload: Omit<
CreateGenerationTaskRequest,
"count" | "titles" | "voice_library_ids" | "cover_urls" | "variant_plan_ids"
> = {
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
assembly_mode: editMode,
...(editMode === "narrative" && props.selectedScript?.id
? {
script_id: props.selectedScript.id,
tts_voice_id: props.ttsVoiceId || undefined,
tts_voice_source: props.ttsVoiceSource || undefined,
tts_style: props.ttsStyle || undefined,
}
: {}),
dedup_enabled: dedupEnabled,
voice_library_id: voiceLibraryId,
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
bgm_config: bgmConfig as CreateGenerationTaskRequest["bgm_config"],
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(titleConfig ? ({ title_config: titleConfig } as Record<string, unknown>) : {}),
}
return payload
}, [props, selectedTemplate])
/* ── 生成视频 ──
返回 true 表示任务创建成功并已开始轮询(含排队中);false 表示校验未通过或创建失败 */
返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */
const generate = useCallback(async (): Promise<boolean> => {
const errorMsg = validateGenerateInputs(props)
if (errorMsg) {
@@ -337,28 +117,34 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
return false
}
cancelledRef.current = false
clearQueueTimers()
setGenerating(true)
setProgress(0)
setGenerated(false)
setGenerateError(null)
setCompletionStatus(null)
setBatchTasks([])
setGeneratedVideos([])
setCurrentTaskId("")
clearTimer()
const basePayload = buildBasePayload()
const assetIds = basePayload.asset_ids
const isBatch = (props.previewCount || 1) > 1
try {
// from-assets 兜底:片段不存在则补一次
const { width: outputWidth, height: outputHeight } = calculateResolution(
props.videoRatio || "9:16",
)
const editMode = props.editMode ?? "random"
const dedupEnabled = props.dedupEnabled !== false
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用,
// 后端已改为异步秒级返回,这里做一次轻量兜底:
// 单次查 clips,已有则直接放行;没有则再调一次 from-assets。
if (assetIds.length > 0 && selectedTemplate) {
try {
const clipList = await getEditPlanClips(selectedTemplate, { limit: 500 })
if (clipList.items.length === 0) {
// 片段不存在(极端情况:useStep2Materials 的 debounce 还没触发)
// 手动补一次 from-assets(后端秒级返回)
await createClipsFromAssets(selectedTemplate, assetIds, "main")
}
} catch {
@@ -366,185 +152,221 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
}
if (!isBatch) {
/* ── 单视频:原逻辑(一次提交 count=1) ── */
const hide = message.loading("正在生成预览视频...", 0)
try {
const taskResp = await createGenerationTask({ ...basePayload, count: 1 })
hide()
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
if (taskIds.length === 0) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
const isBatch = (props.previewCount || 1) > 1
const hide = message.loading(
isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...",
0,
)
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice
const voiceLibraryId =
editMode === "narrative"
? props.ttsVoiceId || ""
: props.voiceMode === "clone"
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
const indexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
const batchCount = isBatch ? indexes.length : 1
// 标题文字数组:批量时按勾选顺序
const titlesArr =
isBatch && (props.variantTitles?.length || 0) >= batchCount
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
: []
// 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id)
const voiceArr =
isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId)
: []
// 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面)
const coversArr =
isBatch && props.variantCoverUrls?.length
? indexes.map((i) => props.variantCoverUrls![i] || "")
: []
// #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传,
// 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。
// 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。
const variantPlansArr =
isBatch && props.variantPlanIds?.length
? indexes.map((i) => props.variantPlanIds![i] || "")
: []
const hasVariantPlans = variantPlansArr.some((id) => !!id)
try {
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
assembly_mode: editMode,
...(editMode === "narrative" && props.selectedScript?.id
? {
script_id: props.selectedScript.id,
tts_voice_id: props.ttsVoiceId || undefined,
tts_voice_source: props.ttsVoiceSource || undefined,
tts_style: props.ttsStyle || undefined,
}
: {}),
dedup_enabled: dedupEnabled,
voice_library_id: voiceLibraryId,
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
bgm_config: {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(isBatch ? { count: batchCount } : {}),
...(titlesArr.length ? { titles: titlesArr } : {}),
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
...(coversArr.length ? { cover_urls: coversArr } : {}),
...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}),
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
...(props.titleSettings.position === "custom" &&
props.titleSettings.posX != null &&
props.titleSettings.posY != null
? {
pos_x: Math.round(props.titleSettings.posX),
pos_y: Math.round(props.titleSettings.posY),
}
: {}),
bold: props.titleSettings.bold,
italic: props.titleSettings.italic,
stroke: props.titleSettings.stroke
? {
enabled: true,
width: props.titleSettings.strokeWidth ?? 4,
color: props.titleSettings.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.shadow
? {
enabled: true,
offset_x: props.titleSettings.shadowOffsetX ?? 2,
offset_y: props.titleSettings.shadowOffsetY ?? 2,
blur: props.titleSettings.shadowBlur ?? 4,
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: props.titleSettings.lineHeight ?? 1.2,
margin_top: props.titleSettings.marginTop ?? 24,
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
...(props.titleSettings.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.bgColor,
padding: props.titleSettings.bgPadding,
radius: props.titleSettings.bgRadius,
},
}
: { background: { enabled: false } }),
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
line_index: lo.line_index,
text: lo.text,
size: lo.size,
color: lo.color,
bold: lo.bold,
italic: lo.italic,
stroke: lo.stroke,
highlights: lo.highlights?.map((h) => ({
word: h.word,
color: h.color,
bold: h.bold,
scale: h.scale,
})),
})),
...(props.titleSettings.coverTitle
? {
cover_title_config: {
title: props.titleSettings.coverTitle.title,
font: props.titleSettings.coverTitle.font,
font_size: props.titleSettings.coverTitle.size,
font_color: props.titleSettings.coverTitle.color,
bold: props.titleSettings.coverTitle.bold,
italic: props.titleSettings.coverTitle.italic,
position: props.titleSettings.coverTitle.position,
stroke: props.titleSettings.coverTitle.stroke
? {
enabled: true,
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.coverTitle.shadow
? {
enabled: true,
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
color:
props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
...(props.titleSettings.coverTitle.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.coverTitle.bgColor,
padding: props.titleSettings.coverTitle.bgPadding,
radius: props.titleSettings.coverTitle.bgRadius,
},
}
: { background: { enabled: false } }),
},
}
: {}),
},
}
: {}),
})
hide()
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
if (taskIds.length === 0) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
if (taskIds.length > 1) {
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
setCurrentTaskId("")
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
} else {
setCurrentTaskId(taskIds[0])
startPolling(taskIds[0])
} catch (err) {
hide()
throw err
}
return true
} catch (err) {
hide()
throw err
}
/* ── 批量:支持任意数量视频,按队列容量串行提交,429 自动排队重试 ── */
const indexes = props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
const batchCount = indexes.length
const titlesAll =
(props.variantTitles?.length || 0) >= batchCount
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
: indexes.map(() => props.titleSettings?.title || "")
const voiceArrAll =
props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
? indexes.map(
(i) => props.variantVoiceLibraryIds![i] || basePayload.voice_library_id || "",
)
: []
const coversAll = props.variantCoverUrls?.length
? indexes.map((i) => props.variantCoverUrls![i] || "")
: indexes.map(() => "")
const plansAll = props.variantPlanIds?.length
? indexes.map((i) => props.variantPlanIds![i] || "")
: indexes.map(() => "")
const hasAnyVoice = voiceArrAll.some((v) => !!v)
const hasAnyCover = coversAll.some((u) => !!u)
const hasAnyPlan = plansAll.some((id) => !!id)
// 先用占位 ID 把所有变体卡片置为 queued,UI 可见
const placeholderIds = indexes.map((_, i) => `__queued_${Date.now()}_${i}`)
const initialTasks: BatchTaskState[] = indexes.map((variantIndex, i) => ({
taskId: placeholderIds[i],
variantIndex,
status: "queued",
progress: 0,
error: null,
videos: [],
}))
setBatchTasks(initialTasks)
message.loading({
content: `已提交 ${batchCount} 个视频任务,系统按队列容量依次渲染…`,
key: "batch-gen",
duration: 3,
})
/** 将占位 taskId 更新为真实 taskId(卡片引用同一对象) */
const replacePlaceholder = (placeholderId: string, realTaskId: string) => {
setBatchTasks((prev) => {
const idx = prev.findIndex((t) => t.taskId === placeholderId)
if (idx === -1) return prev
const next = [...prev]
next[idx] = { ...next[idx], taskId: realTaskId }
return next
})
}
/** 提交某一索引的单任务(count=1),成功后返回真实 taskId;429/503 则返回 waitMs */
const submitOne = async (
i: number,
): Promise<{ queued: true; waitMs: number } | { queued: false; taskId: string }> => {
const body: CreateGenerationTaskRequest = {
...basePayload,
count: 1,
titles: [titlesAll[i] || ""],
...(hasAnyVoice
? { voice_library_ids: [voiceArrAll[i] || basePayload.voice_library_id || ""] }
: {}),
...(hasAnyCover ? { cover_urls: [coversAll[i] || ""] } : {}),
...(hasAnyPlan && plansAll[i] ? { variant_plan_ids: [plansAll[i]] } : {}),
}
try {
const resp = await createGenerationTask(body)
const item = resp.items?.[0]
const tid = item?.id
if (!tid) throw new Error("创建任务成功但未返回任务 ID")
return { queued: false, taskId: tid }
} catch (err) {
const q = isUserQueueFullError(err)
if (q) return { queued: true, waitMs: q.waitMs }
throw err
}
}
// 串行提交:每次提交一个;429/503 则等待后重试;其它错误立即标记该任务失败
let fatalErr: unknown = null
for (let i = 0; i < batchCount; i++) {
if (cancelledRef.current) return false
const variantIndex = indexes[i]
const placeholderId = placeholderIds[i]
let attempt = 0
let submitted = false
while (!submitted) {
if (cancelledRef.current) return false
attempt++
try {
const result = await submitOne(i)
if (!result.queued) {
replacePlaceholder(placeholderId, result.taskId)
// 先更新到 running,再启动单任务增量轮询(不触发整体 onComplete)
pollBatchTaskQueued(result.taskId, variantIndex)
submitted = true
} else {
// 排队:保持 queued 状态,等待后重试
handleBatchTaskUpdate(placeholderId, {
taskId: placeholderId,
variantIndex,
status: "queued",
progress: 0,
error: null,
})
if (attempt === 1) {
message.info({
content: `队列繁忙,${Math.round(result.waitMs / 1000)} 秒后自动继续提交后续视频…`,
key: "batch-gen",
duration: 4,
})
}
await sleep(Math.min(result.waitMs, 60_000))
}
} catch (err) {
// 非限流错误:该任务标记失败,继续后续任务(不阻断整个批量)
console.error("[batch generate] 任务提交失败:", err)
const msg = translateError(extractBackendError(err))
handleBatchTaskUpdate(placeholderId, {
taskId: placeholderId,
variantIndex,
status: "failed",
error: msg,
progress: 0,
})
submitted = true
if (!fatalErr) fatalErr = err
}
}
}
if (fatalErr) {
// 有任务失败但其余已成功,整体不 throw;由 UI 展示单个失败卡片
}
return true
} catch (err: unknown) {
console.error("[handleGenerate] 生成失败:", err)
setGenerating(false)
const backendMsg = extractBackendError(err)
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err)
const finalMsg = translateError(backendMsg)
setGenerateError(finalMsg)
setGenerating(false)
message.error(finalMsg)
return false
}
}, [
props,
clearTimer,
startPolling,
selectedTemplate,
buildBasePayload,
handleBatchTaskUpdate,
clearQueueTimers,
pollBatchTaskQueued,
])
return true
}, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate])
const retry = useCallback(() => {
setGenerateError(null)
@@ -554,10 +376,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
/** 第5步:单独重试某个失败任务 */
const retryBatchTask = useCallback(
(taskId: string) => {
handleBatchTaskUpdate(taskId, { status: "running", progress: 0, error: null, videos: [] })
retryTask(taskId)
},
[retryTask, handleBatchTaskUpdate],
[retryTask],
)
const dismissError = useCallback(() => {
@@ -602,7 +423,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
generated,
generateError,
generatedVideos,
completionStatus,
currentTaskId,
generate,
retry,
-5
View File
@@ -45,11 +45,6 @@ export const STATUS_CONFIG: Record<
color: "processing",
icon: <SyncOutlined spin />,
},
awaiting_cover: {
label: "待选封面",
color: "warning",
icon: <ClockCircleOutlined />,
},
completed: {
label: "已完成",
color: "success",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,263 +0,0 @@
/**
* 爆款视频素材选择弹窗(通用版,支持 image/video/voice)
* 基于 ai-avatar 的 ModalAssetPicker 改造:
* - kind 可传 "image" | "video" | "voice"
* - 多图场景 multiple=true 时底部"确认选择"
* - 单选场景点击即回调关闭
*/
import { useEffect, useState } from "react"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
export interface AssetPickerModalProps {
open: boolean
kind: "image" | "video" | "voice"
multiple?: boolean
title?: string
onClose: () => void
onSelect: (assets: AssetItem[]) => void
}
const KIND_LABEL: Record<AssetPickerModalProps["kind"], string> = {
image: "图片",
video: "视频",
voice: "音频",
}
const MIME_KIND: Record<AssetPickerModalProps["kind"], string> = {
image: "image",
video: "video",
voice: "audio",
}
export default function AssetPickerModal({
open,
kind,
multiple = false,
title,
onClose,
onSelect,
}: AssetPickerModalProps) {
const [keyword, setKeyword] = useState("")
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
const [libraryId, setLibraryId] = useState<string>("")
const [assets, setAssets] = useState<AssetItem[]>([])
const [picked, setPicked] = useState<Set<string>>(new Set())
const [loadingLibs, setLoadingLibs] = useState(false)
const [loadingAssets, setLoadingAssets] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
if (!open) return
setKeyword("")
setLibraries([])
setLibraryId("")
setAssets([])
setError("")
setPicked(new Set())
}, [open])
useEffect(() => {
if (!open) return
let cancelled = false
setLoadingLibs(true)
getAssetLibraries(kind)
.then((libs) => {
if (cancelled) return
const list = Array.isArray(libs) ? libs : []
setLibraries(list)
if (list.length > 0) setLibraryId(list[0].id)
})
.catch(() => {
if (!cancelled) setError("素材库加载失败,请重试")
})
.finally(() => {
if (!cancelled) setLoadingLibs(false)
})
return () => {
cancelled = true
}
}, [open, kind])
useEffect(() => {
if (!open || !libraryId) return
let cancelled = false
setLoadingAssets(true)
const load = async () => {
try {
const { items } = await getAssets(libraryId, { page_size: 100 })
if (cancelled) return
let list = Array.isArray(items) ? items : []
const mimePrefix = MIME_KIND[kind]
list = list.filter((a) => !a.mime_type || a.mime_type.startsWith(mimePrefix))
const kw = keyword.trim()
if (kw) list = list.filter((a) => a.name?.includes(kw))
setAssets(list)
setError("")
} catch {
if (!cancelled) {
setError("素材加载失败,请重试")
setAssets([])
}
} finally {
if (!cancelled) setLoadingAssets(false)
}
}
const timer = window.setTimeout(load, 250)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [open, libraryId, keyword, kind])
const thumbFor = (a: AssetItem) => {
if (kind === "image") return a.thumbnail_url || a.file_url
if (kind === "video") return a.thumbnail_url
return ""
}
const togglePick = (id: string) => {
if (multiple) {
setPicked((prev) => {
const n = new Set(prev)
if (n.has(id)) n.delete(id)
else n.add(id)
return n
})
} else {
const asset = assets.find((a) => a.id === id)
if (asset) {
onSelect([asset])
onClose()
}
}
}
const handleConfirm = () => {
const list = assets.filter((a) => picked.has(a.id))
if (list.length > 0) onSelect(list)
onClose()
}
if (!open) return null
return (
<div className="vv-modal-mask" onClick={onClose}>
<div className="vv-modal" onClick={(e) => e.stopPropagation()}>
<div className="vv-modal-head">
<span className="vv-modal-title">{title || `选择${KIND_LABEL[kind]}素材`}</span>
<button className="vv-modal-close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
<div className="vv-modal-body">
<div className="vv-asset-search">
<select
className="vv-input"
style={{ width: 170, flex: "0 0 auto" }}
value={libraryId}
onChange={(e) => setLibraryId(e.target.value)}
disabled={loadingLibs || libraries.length === 0}
>
{libraries.length === 0 ? (
<option value="">
{loadingLibs ? "加载中…" : `暂无${KIND_LABEL[kind]}素材库`}
</option>
) : (
libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
📁 {lib.name}
</option>
))
)}
</select>
<input
className="vv-input"
type="text"
placeholder={`搜索${KIND_LABEL[kind]}名称…`}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
{libraries.length === 0 && !loadingLibs ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">📁</div>
暂无{KIND_LABEL[kind]}素材库,请先在「素材库」中创建并上传
</div>
) : loadingAssets ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">⏳</div>
素材加载中…
</div>
) : error ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">⚠️</div>
{error}
</div>
) : assets.length === 0 ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">
{kind === "image" ? "🖼️" : kind === "video" ? "🎬" : "🎵"}
</div>
{kind === "voice" ? (
<>
<div style={{ marginTop: 8, fontSize: 13 }}>暂无配音素材</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#9ca3af" }}>
请先在「配音/我的音色」中上传音频文件,或在素材库管理中添加
</div>
</>
) : (
<>该素材库暂无{KIND_LABEL[kind]}素材</>
)}
</div>
) : (
<div className={`vv-asset-thumbs vv-asset-${kind}`}>
{assets.map((asset) => {
const active = picked.has(asset.id)
const thumb = thumbFor(asset)
return (
<div
key={asset.id}
className={`vv-thumb-card${active ? " selected" : ""}`}
onClick={() => togglePick(asset.id)}
>
{thumb ? (
<img src={thumb} alt={asset.name} />
) : kind === "video" ? (
<video src={asset.file_url} muted preload="metadata" />
) : (
<div className="vv-thumb-ph">{kind === "voice" ? "🎵" : "📄"}</div>
)}
{active && <div className="vv-thumb-check">✓</div>}
<div className="vv-thumb-name" title={asset.name}>
<span className="vv-thumb-name-txt">{asset.name}</span>
{kind === "voice" &&
typeof asset.duration === "number" &&
asset.duration > 0 && (
<span className="vv-thumb-dur">{Math.round(asset.duration)}s</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{multiple && (
<div className="vv-modal-foot">
<button className="vv-btn vv-btn-ghost" onClick={onClose}>
取消
</button>
<button
className="vv-btn vv-btn-primary"
onClick={handleConfirm}
disabled={picked.size === 0}
>
确认选择({picked.size})
</button>
</div>
)}
</div>
</div>
)
}
@@ -1,355 +0,0 @@
/**
* 内置音色选择弹窗(浅色紫调版)
* - 标题「选择音色」+ 搜索框 + 分类筛选 + 3列卡片网格 + 试听 + 选中 + 完成选择
*/
import React, { useEffect, useMemo, useRef, useState } from "react"
import {
CloseOutlined,
SearchOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
UserOutlined,
} from "@ant-design/icons"
import { Select, Input } from "antd"
export interface PresetVoice {
id: string
name: string
gender?: "female" | "male" | "child" | "other"
gender_label?: string
category?: string
avatar_url?: string
sample_audio_url?: string
desc?: string
}
interface Props {
open: boolean
voices?: PresetVoice[]
loading?: boolean
selectedId?: string
onClose: () => void
onConfirm: (voice: PresetVoice) => void
}
/** 兜底 mock 音色(后端 /api/v1/tts/presets 返回字段不够时使用) */
const MOCK_VOICES: PresetVoice[] = [
// ⚠️ 兜底 mock,仅在 /voices/presets 接口不可达时使用;ID 必须与后端
// packages/domain/preset_voices.py PRESET_VOICES 的 voice_id 对齐(v3后缀)
{
id: "longxiaochun_v3",
name: "龙小淳",
gender: "female",
category: "女声",
desc: "知性积极女声,适合语音助手",
},
{
id: "longxiaoxia_v3",
name: "龙小夏",
gender: "female",
category: "女声",
desc: "沉稳权威女声,适合新闻播报",
},
{
id: "longsanshu_v3",
name: "龙三叔",
gender: "male",
category: "男声",
desc: "沉稳质感男声,适合有声书",
},
{
id: "longyue_v3",
name: "龙悦",
gender: "female",
category: "女声",
desc: "温暖磁性女声,适合广告配音",
},
{
id: "longshu_v3",
name: "龙书",
gender: "male",
category: "男声",
desc: "沉稳青年男声,适合教育讲解",
},
{
id: "longyingjing_v3",
name: "龙应静",
gender: "female",
category: "女声",
desc: "低调冷静女声,适合纪录片解说",
},
{
id: "longshuo_v3",
name: "龙硕",
gender: "male",
category: "男声",
desc: "博才干练男声,适合科技类内容",
},
{
id: "longtian_v3",
name: "龙甜",
gender: "female",
category: "女声",
desc: "活泼女声,适合短视频配音",
},
]
const CATEGORY_LABELS: Record<string, string> = {
all: "全部分类",
female: "女声",
male: "男声",
child: "童声",
dialect: "方言",
emotion: "情绪",
}
const GENDER_LABEL = (v: PresetVoice) => {
if (v.gender_label) return v.gender_label
const g = v.gender
if (g === "female") return "女声·女声"
if (g === "male") return "男声·男声"
if (g === "child") return "童声·童声"
return "性别未标注·其他"
}
const AVATAR_BG = (gender?: string) => {
if (gender === "female") return "#fce7f3"
if (gender === "male") return "#dbeafe"
if (gender === "child") return "#fef3c7"
return "#f3f0ff"
}
const AVATAR_COLOR = (gender?: string) => {
if (gender === "female") return "#be185d"
if (gender === "male") return "#1d4ed8"
if (gender === "child") return "#b45309"
return "#7c3aed"
}
const PresetVoicePickerModal: React.FC<Props> = ({
open,
voices,
loading,
selectedId,
onClose,
onConfirm,
}) => {
const [keyword, setKeyword] = useState("")
const [category, setCategory] = useState<string>("all")
const [pickedId, setPickedId] = useState<string | undefined>(selectedId)
const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
if (open) {
setKeyword("")
setCategory("all")
setPickedId(selectedId)
setPlayingId(null)
}
}, [open, selectedId])
// 停止播放
useEffect(() => {
return () => {
audioRef.current?.pause()
audioRef.current = null
}
}, [])
// 合并真实数据和 mock:如果真实数据 gender/category 缺失,用 mock 兜底
const allVoices: PresetVoice[] = useMemo(() => {
// 真实 API 返回的 voice_id 以 API 为准(如 longxiaochun_v3),前端不做硬编码覆盖
const realList: PresetVoice[] = (voices || []).map((v) => {
// 按 id 精确匹配 mock 获取补充元信息(id 即 voice_id,唯一稳定键)
const mockMatch = MOCK_VOICES.find((m) => m.id === v.id)
return {
...v,
gender: v.gender || mockMatch?.gender,
category:
v.category ||
mockMatch?.category ||
(v.gender === "female" ? "女声" : v.gender === "male" ? "男声" : "其他"),
desc: v.desc || mockMatch?.desc,
sample_audio_url: v.sample_audio_url,
}
})
// 如果没有真实数据,使用兜底 mock(接口失败时)
return realList.length > 0 ? realList : MOCK_VOICES
}, [voices])
const categories = useMemo(() => {
const set = new Set<string>()
allVoices.forEach((v) => {
if (v.category) set.add(v.category)
})
return Array.from(set)
}, [allVoices])
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase()
return allVoices.filter((v) => {
if (category !== "all") {
if (v.category !== category && category !== CATEGORY_LABELS[v.gender || ""]) {
// gender 兜底匹配
if (
!(category === "女声" && v.gender === "female") &&
!(category === "男声" && v.gender === "male") &&
!(category === "童声" && v.gender === "child") &&
!(category === "方言" && v.category === "方言") &&
!(category === "情绪" && v.category === "情绪")
) {
return false
}
}
}
if (!kw) return true
return (
v.name?.toLowerCase().includes(kw) ||
v.desc?.toLowerCase().includes(kw) ||
v.category?.toLowerCase().includes(kw)
)
})
}, [allVoices, keyword, category])
const handlePreview = (v: PresetVoice) => {
if (!v.sample_audio_url) {
// 无示例音频
return
}
if (playingId === v.id) {
audioRef.current?.pause()
setPlayingId(null)
return
}
audioRef.current?.pause()
const a = new Audio(v.sample_audio_url)
a.onended = () => setPlayingId(null)
a.onerror = () => setPlayingId(null)
a.play().catch(() => {})
audioRef.current = a
setPlayingId(v.id)
}
const handleConfirm = () => {
const picked = allVoices.find((v) => v.id === pickedId)
if (!picked) return
onConfirm(picked)
}
if (!open) return null
return (
<div className="vv-modal-mask" onClick={onClose}>
<div className="vv-modal vv-modal-lg" onClick={(e) => e.stopPropagation()}>
<div className="vv-modal-head">
<div className="vv-modal-title">选择音色</div>
<button className="vv-modal-close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="vv-modal-body">
{/* 搜索 */}
<Input
className="vv-voice-search"
placeholder="搜索音色名称或风格"
prefix={<SearchOutlined style={{ color: "#9ca3af" }} />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
size="large"
/>
{/* 分类筛选 */}
<div className="vv-voice-cat-row">
<span className="vv-voice-cat-label">音色分类</span>
<Select
value={category}
onChange={setCategory}
style={{ width: 180 }}
options={[
{ value: "all", label: "全部分类" },
...[
"女声",
"男声",
"童声",
"方言",
"情绪",
...categories.filter(
(c) => !["女声", "男声", "童声", "方言", "情绪"].includes(c),
),
].map((c) => ({ value: c, label: c })),
]}
/>
</div>
{/* 卡片网格 */}
<div className="vv-voice-grid">
{loading && filtered.length === 0 ? (
<div className="vv-modal-empty">加载中…</div>
) : filtered.length === 0 ? (
<div className="vv-modal-empty">没有匹配的音色</div>
) : (
filtered.map((v) => {
const isPicked = pickedId === v.id
const isPlaying = playingId === v.id
return (
<div
key={v.id}
className={`vv-voice-card ${isPicked ? "selected" : ""}`}
onClick={() => setPickedId(v.id)}
>
<div
className="vv-voice-card-avatar"
style={{ background: AVATAR_BG(v.gender), color: AVATAR_COLOR(v.gender) }}
>
{v.avatar_url ? (
<img src={v.avatar_url} alt={v.name} />
) : (
<UserOutlined style={{ fontSize: 22 }} />
)}
</div>
<div className="vv-voice-card-name" title={v.name}>
{v.name}
</div>
<div className="vv-voice-card-gender">{GENDER_LABEL(v)}</div>
{v.desc && <div className="vv-voice-card-desc">{v.desc}</div>}
<div className="vv-voice-card-actions">
<button
className={`vv-voice-card-btn ${isPicked ? "picked" : ""}`}
onClick={(e) => {
e.stopPropagation()
setPickedId(v.id)
}}
>
{isPicked ? "✓ 已选择" : "选择"}
</button>
<button
className={`vv-voice-card-btn vv-voice-card-btn-preview ${isPlaying ? "playing" : ""} ${!v.sample_audio_url ? "disabled" : ""}`}
onClick={(e) => {
e.stopPropagation()
handlePreview(v)
}}
disabled={!v.sample_audio_url}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
{isPlaying ? "停止" : "试听"}
</button>
</div>
</div>
)
})
)}
</div>
</div>
<div className="vv-modal-foot">
<button className="vv-btn vv-btn-ghost" onClick={onClose}>
取消
</button>
<button className="vv-btn vv-btn-primary" onClick={handleConfirm} disabled={!pickedId}>
完成选择
</button>
</div>
</div>
</div>
)
}
export default PresetVoicePickerModal
@@ -1,74 +0,0 @@
import { useCallback, useEffect, useRef } from "react"
import { getViralVideoJob } from "@/api/viral-video"
import { isAnalysisStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
export interface UseViralVideoPollingOptions {
/** 轮询间隔(毫秒),默认 1500 */
intervalMs?: number
}
/**
* 爆款视频任务 HTTP 轮询 hook。
* 负责持续拉取任务状态并回调给上层;上层负责根据状态/阶段切换 UI 文案。
* 任务进入终态(completed/failed/cancelled)后自动停止。
*/
export function useViralVideoPolling(
jobId: string | null | undefined,
onUpdate: (job: ViralVideoJob) => void,
options: UseViralVideoPollingOptions = {},
) {
const { intervalMs = 1500 } = options
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stoppedRef = useRef(false)
const failCountRef = useRef(0)
const stop = useCallback(() => {
stoppedRef.current = true
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const pollOnce = useCallback(
async (id: string) => {
try {
const job = await getViralVideoJob(id)
failCountRef.current = 0
onUpdate(job)
if (TERMINAL.includes(job.status)) {
stop()
return
}
if (stoppedRef.current) return
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
const inRender = job.progress_stage === "rendering"
// 分析阶段走默认间隔即可
const isAnalyzing = isAnalysisStage(job.progress_stage)
const nextDelay = inRender ? 3000 : isAnalyzing ? 2000 : intervalMs
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
} catch (_err) {
failCountRef.current += 1
if (stoppedRef.current) return
const delay = Math.min(intervalMs * 2 ** Math.min(failCountRef.current, 3), 10000)
timerRef.current = setTimeout(() => pollOnce(id), delay)
}
},
[intervalMs, onUpdate, stop],
)
useEffect(() => {
stoppedRef.current = false
failCountRef.current = 0
if (!jobId) {
stop()
return
}
pollOnce(jobId)
return stop
}, [jobId, pollOnce, stop])
return { stop }
}
@@ -1,26 +1,25 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { uploadAssetDirect, getIngestJob, type AssetLibraryItem } from "@/api/assets"
import {
uploadAssetDirect,
getAssetLibraries,
getIngestJob,
type AssetLibraryItem,
} from "@/api/assets"
import { tagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial } from "../../../types"
interface UseVoiceUploadOptions {
voiceLibrary?: { id: string; kind: string }
createLibMutation?: {
mutateAsync: () => Promise<AssetLibraryItem>
isPending: boolean
}
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
}
/**
* 配音素材上传 Hook
* 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签
*/
export function useVoiceUpload({
voiceLibrary,
createLibMutation: _createLibMutation,
}: UseVoiceUploadOptions) {
export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) {
const queryClient = useQueryClient()
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
@@ -34,12 +33,24 @@ export function useVoiceUpload({
}) => {
setUploadProgress(0)
try {
// 1. 上传文件:后端自动在默认项目下确保配音库存在(P0 404 修复)
// 兼容 voiceLibrary 参数:若调用方已传入正确的库 ID 则直接复用,否则内部自动解析
// 1. 获取或等待 voice library
let lib = voiceLibrary
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync()
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: () => getAssetLibraries(),
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
// 2. 上传文件(带进度,后端自动创建 ingest job)
const complete = await uploadAssetDirect({
file: data.file,
library_id: voiceLibrary?.id,
kind: "voice",
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
@@ -1,6 +1,6 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { uploadAssetDirect, getIngestJob } from "@/api/assets"
import { uploadAssetDirect, getAssetLibraries, getIngestJob } from "@/api/assets"
/**
* 配音上传 Hook
@@ -23,10 +23,18 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
mutationFn: async (data: { file: File; name: string; description: string }) => {
setUploadProgress(0)
try {
/* 直传文件(后端会自动在默认项目下确保配音库存在,P0 404 修复) */
/* 获取或创建默认配音库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: () => getAssetLibraries(),
})
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
/* 直传文件(后端会自动创建 ingest job) */
const complete = await uploadAssetDirect({
file: data.file,
kind: "voice",
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
-4
View File
@@ -52,10 +52,6 @@ const appChildren: RouteObject[] = [
path: "ai-avatar",
lazy: lazyRoute(() => import("@/pages/ai-avatar/AiAvatarPage")),
},
{
path: "viral-video",
lazy: lazyRoute(() => import("@/pages/viral-video/ViralVideoPage")),
},
{
path: "voice-clone",
lazy: lazyRoute(() => import("@/pages/voice-clone/VoiceClone")),
-45
View File
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest"
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
describe("api/errors", () => {
it("returns string error directly", () => {
expect(getErrorMessage("plain")).toBe("plain")
})
it("uses Error.message", () => {
expect(getErrorMessage(new Error("boom"))).toBe("boom")
})
it("returns fallback for empty/unknown", () => {
expect(getErrorMessage(null)).toBe("操作失败,请稍后重试")
expect(getErrorMessage(undefined, "f")).toBe("f")
})
it("reads axios-like response.data.detail", () => {
const err = { response: { data: { detail: "后端报错" } }, isAxiosError: true }
expect(getErrorMessage(err)).toContain("后端报错")
})
it("reads axios-like response.data.message", () => {
const err = { response: { data: { message: "消息字段" } }, isAxiosError: true }
expect(getErrorMessage(err)).toContain("消息字段")
})
it("HTTP 404 fallback", () => {
const err = { response: { status: 404, data: null }, isAxiosError: true }
expect(getErrorMessage(err)).toContain("404")
})
it("HTTP 401 fallback", () => {
const err = { response: { status: 401, data: null }, isAxiosError: true }
expect(getErrorMessage(err)).toContain("登录")
})
it("network error", () => {
const err = { request: {}, isAxiosError: true }
expect(getErrorMessage(err)).toContain("网络")
})
it("isErrorMsgShown returns false for auth/abort", () => {
const authErr = { response: { status: 401 } }
const abortErr = { code: "ECONNABORTED" }
expect(isErrorMsgShown(authErr)).toBe(false)
expect(isErrorMsgShown(abortErr)).toBe(false)
const e: any = new Error("x")
e.__msgShown = true
expect(isErrorMsgShown(e)).toBe(true)
expect(isErrorMsgShown(new Error("x"))).toBe(false)
})
})
-226
View File
@@ -1,226 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import {
generateViralVideo,
getViralVideoJob,
confirmViralVideoIntent,
retryViralVideo,
getViralVideoHistory,
getViralStyleTemplates,
analyzeViralStyle,
mockImageAnalysis,
mockGenerateCopy,
analyzeViralImages,
generateViralCopy,
confirmViralCopy,
} from "@/api/viral-video"
import {
VALID_DURATIONS,
VALID_RATIOS,
isVideoStage,
isImageAnalysisStage,
isCopyStage,
isAnalysisStage,
} from "@/api/viral-video/types"
const mockGet = vi.fn()
const mockPost = vi.fn()
vi.mock("@/api/client", () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
},
}))
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
// 让 setTimeout 同步执行,避免测试等待 1.8s/2.2s
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: {} })
mockPost.mockResolvedValue({ data: {} })
})
afterEach(() => {
vi.useRealTimers()
})
describe("viral-video constants & stage helpers", () => {
afterEach(() => {
vi.useRealTimers()
})
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
})
it("VALID_DURATIONS/VALID_RATIOS", () => {
expect(VALID_DURATIONS).toEqual([5, 10, 15, 20, 25, 30])
expect(VALID_RATIOS).toEqual(expect.arrayContaining(["9:16", "16:9", "1:1"]))
})
it("isVideoStage", () => {
expect(isVideoStage("tts")).toBe(true)
expect(isVideoStage("rendering")).toBe(true)
expect(isVideoStage("uploading")).toBe(true)
expect(isVideoStage("script_generation")).toBe(false)
expect(isVideoStage("completed")).toBe(false)
expect(isVideoStage(undefined)).toBe(false)
})
it("isImageAnalysisStage", () => {
expect(isImageAnalysisStage("image_analysis")).toBe(true)
expect(isImageAnalysisStage("video_analysis")).toBe(true)
expect(isImageAnalysisStage("script_generation")).toBe(false)
expect(isImageAnalysisStage(undefined)).toBe(false)
})
it("isCopyStage", () => {
expect(isCopyStage("intent_parsing")).toBe(true)
expect(isCopyStage("script_generation")).toBe(true)
expect(isCopyStage("review")).toBe(true)
expect(isCopyStage("tts")).toBe(false)
})
it("isAnalysisStage is union", () => {
expect(isAnalysisStage("image_analysis")).toBe(true)
expect(isAnalysisStage("script_generation")).toBe(true)
expect(isAnalysisStage("tts")).toBe(false)
expect(isAnalysisStage(undefined)).toBe(false)
})
})
describe("viral-video API wrappers", () => {
afterEach(() => {
vi.useRealTimers()
})
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: {} })
mockPost.mockResolvedValue({ data: {} })
})
it("generateViralVideo", async () => {
mockPost.mockResolvedValue({ data: { id: "j1" } })
const r = generateViralVideo({ images: ["img1"] } as never)
vi.runAllTimersAsync()
expect(await r).toEqual({ id: "j1" })
expect(mockPost).toHaveBeenCalledWith("/viral-video/generate", { images: ["img1"] })
})
it("getViralVideoJob", async () => {
mockGet.mockResolvedValue({ data: { id: "j2" } })
const r = getViralVideoJob("j2")
vi.runAllTimersAsync()
expect(await r).toEqual({ id: "j2" })
expect(mockGet).toHaveBeenCalledWith("/viral-video/j2")
})
it("confirmViralVideoIntent", async () => {
mockPost.mockResolvedValue({ data: { id: "j3" } })
const r = confirmViralVideoIntent("j3", { confirmed_copy: "hi" })
vi.runAllTimersAsync()
await r
expect(mockPost).toHaveBeenCalledWith("/viral-video/j3/confirm-intent", {
confirmed_copy: "hi",
})
})
it("retryViralVideo", async () => {
mockPost.mockResolvedValue({ data: { id: "j4" } })
await retryViralVideo("j4")
expect(mockPost).toHaveBeenCalledWith("/viral-video/j4/retry")
})
it("getViralVideoHistory", async () => {
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
await getViralVideoHistory({ page: 1, page_size: 20 })
expect(mockGet).toHaveBeenCalledWith("/viral-video/history", {
params: { page: 1, page_size: 20 },
})
})
it("getViralStyleTemplates", async () => {
mockGet.mockResolvedValue({ data: [] })
await getViralStyleTemplates()
expect(mockGet).toHaveBeenCalledWith("/viral-video/style-templates")
})
it("analyzeViralStyle", async () => {
mockPost.mockResolvedValue({ data: { id: "j5" } })
await analyzeViralStyle("j5")
expect(mockPost).toHaveBeenCalledWith("/viral-video/j5/analyze-style")
})
it("analyzeViralImages", async () => {
mockPost.mockResolvedValue({ data: { id: "j6" } })
await analyzeViralImages({ images: ["a.png"] } as never)
expect(mockPost).toHaveBeenCalledWith("/viral-video/analyze-images", { images: ["a.png"] })
})
it("generateViralCopy", async () => {
mockPost.mockResolvedValue({ data: { id: "j7" } })
await generateViralCopy("j7", { duration: 15 } as never)
expect(mockPost).toHaveBeenCalledWith("/viral-video/j7/generate-copy", { duration: 15 })
})
it("confirmViralCopy", async () => {
mockPost.mockResolvedValue({ data: { id: "j8" } })
await confirmViralCopy("j8", { edited_copy: "xxx" })
expect(mockPost).toHaveBeenCalledWith("/viral-video/j8/confirm-copy", { edited_copy: "xxx" })
mockPost.mockClear()
await confirmViralCopy("j8")
expect(mockPost).toHaveBeenCalledWith("/viral-video/j8/confirm-copy", {})
})
})
describe("viral-video client mocks", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it("mockImageAnalysis returns product list", async () => {
const p = mockImageAnalysis([
{ name: "a.png" },
{ name: "b.jpg" },
{ name: "c.webp" },
{ name: "d.png" },
])
vi.advanceTimersByTime(2000)
const r = await p
expect(r.products).toHaveLength(3)
expect(r.products[0].image_index).toBe(0)
expect(r.products[0].brand).toBe("示例品牌")
expect(r.products[1].spec).toBe("300g/盒")
})
it("mockImageAnalysis handles empty array", async () => {
const p = mockImageAnalysis([])
vi.advanceTimersByTime(2000)
const r = await p
expect(r.products).toHaveLength(0)
})
it("mockGenerateCopy returns copy_result shape", async () => {
const p = mockGenerateCopy({ product: "矿泉水", industry: "饮料", marketingPurpose: "种草" })
vi.advanceTimersByTime(3000)
const r = await p
expect(r.title).toContain("种草")
expect(r.title).toContain("矿泉水")
expect(r.final_copy.length).toBeGreaterThan(50)
expect(r.suggested_copy).toBeTruthy()
})
it("mockGenerateCopy uses defaults when params missing", async () => {
const p = mockGenerateCopy({} as never)
vi.advanceTimersByTime(3000)
const r = await p
expect(r.title).toContain("品牌种草")
expect(r.final_copy).toContain("这款产品")
})
})
@@ -1,21 +0,0 @@
import { describe, it, expect } from "vitest"
import { getGenerationPhase } from "@/pages/generate/hooks/generate-video/phase"
describe("getGenerationPhase", () => {
it("returns 分析素材与配置 for p<20", () => {
expect(getGenerationPhase(0)).toEqual({ label: "分析素材与配置", icon: "🔍" })
expect(getGenerationPhase(19).label).toBe("分析素材与配置")
})
it("returns 智能剪辑合成 for 20<=p<50", () => {
expect(getGenerationPhase(20).label).toBe("智能剪辑合成")
expect(getGenerationPhase(49).label).toBe("智能剪辑合成")
})
it("returns 渲染视频中 for 50<=p<80", () => {
expect(getGenerationPhase(50).label).toBe("渲染视频中")
expect(getGenerationPhase(79).label).toBe("渲染视频中")
})
it("returns 即将完成 for p>=80", () => {
expect(getGenerationPhase(80)).toEqual({ label: "即将完成", icon: "✨" })
expect(getGenerationPhase(100).label).toBe("即将完成")
})
})
@@ -1,26 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest"
import { formatDuration, formatFileSize, formatDate } from "@/pages/products/detailUtils"
describe("products/detailUtils", () => {
afterEach(() => {
vi.useRealTimers()
})
it("formatDuration", () => {
expect(formatDuration(0)).toBe("00:00")
expect(formatDuration(-1)).toBe("00:00")
expect(formatDuration(5)).toBe("00:05")
expect(formatDuration(65)).toBe("01:05")
expect(formatDuration(3600)).toBe("60:00")
})
it("formatFileSize MB/GB", () => {
expect(formatFileSize(0)).toBe("-")
expect(formatFileSize(-1)).toBe("-")
expect(formatFileSize(5.3)).toBe("5.3 MB")
expect(formatFileSize(2048)).toBe("2.00 GB")
})
it("formatDate returns zh-CN format", () => {
vi.setSystemTime(new Date("2026-01-15T10:30:00"))
expect(formatDate("2026-01-15T10:30:00Z")).toMatch(/2026/)
expect(formatDate("")).toBe("-")
})
})
@@ -1,54 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
import { renderHook, act } from "@testing-library/react"
import { useViralVideoPolling } from "@/pages/viral-video/hooks/useViralVideoPolling"
const getViralVideoJobMock = vi.fn()
vi.mock("@/api/viral-video", () => ({
getViralVideoJob: (...args: unknown[]) => getViralVideoJobMock(...args),
}))
describe("useViralVideoPolling", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("不传入 jobId 时不发起请求", () => {
renderHook(() => useViralVideoPolling(null, vi.fn()))
expect(getViralVideoJobMock).not.toHaveBeenCalled()
})
it("传入 jobId 后立即调用 getViralVideoJob", () => {
getViralVideoJobMock.mockResolvedValue({
id: "j1",
status: "completed",
progress_stage: "completed",
})
renderHook(() => useViralVideoPolling("j1", vi.fn()))
expect(getViralVideoJobMock).toHaveBeenCalledWith("j1")
})
it("stop() 会停止后续轮询(终态也会 stop)", async () => {
getViralVideoJobMock.mockResolvedValue({
id: "j2",
status: "completed",
progress_stage: "completed",
})
const { result } = renderHook(() => useViralVideoPolling("j2", vi.fn(), { intervalMs: 50 }))
// 等第一次 promise 完成
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
// 终态后不会再调度新请求
const calls = getViralVideoJobMock.mock.calls.length
act(() => {
vi.advanceTimersByTime(2000)
})
expect(getViralVideoJobMock).toHaveBeenCalledTimes(calls)
expect(result.current.stop).toBeTypeOf("function")
})
})
@@ -1,35 +0,0 @@
import { describe, it, expect } from "vitest"
import {
genderLabel,
languageLabel,
genderClass,
formatTime,
formatFileSize,
} from "@/pages/voices/utils/format"
describe("voices utils/format", () => {
it("genderLabel returns label or falls back to value", () => {
expect(genderLabel("female")).toContain("女")
expect(genderLabel("male")).toContain("男")
expect(genderLabel("unknown" as never)).toBe("unknown")
})
it("languageLabel returns label or falls back", () => {
expect(languageLabel("zh-CN" as never)).toBeTruthy()
expect(languageLabel("xx-XX" as never)).toBe("xx-XX")
})
it("genderClass returns css class", () => {
expect(genderClass("female")).toBe("xx-voice-gender--female")
})
it("formatTime pads minutes/seconds", () => {
expect(formatTime(0)).toBe("00:00")
expect(formatTime(5)).toBe("00:05")
expect(formatTime(65)).toBe("01:05")
expect(formatTime(3600)).toBe("60:00")
})
it("formatFileSize human-readable", () => {
expect(formatFileSize(0)).toBe("0 B")
expect(formatFileSize(512)).toBe("512 B")
expect(formatFileSize(2048)).toBe("2.0 KB")
expect(formatFileSize(2 * 1024 * 1024)).toBe("2.0 MB")
})
})
+1 -2
View File
@@ -28,12 +28,11 @@ export default defineConfig({
"src/pages/editing-planner/EditingPlanner.tsx",
"src/pages/assets/AssetLibrary.tsx",
"src/pages/voice-materials/VoiceMaterialLibrary.tsx",
"src/pages/viral-video/ViralVideoPage.tsx",
],
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
thresholds: {
lines: 49,
lines: 50,
branches: 50,
functions: 20,
},
View File
@@ -1,112 +0,0 @@
"""一次性脚本:对历史 quality_score 缺失的视频素材重新打分。
背景(#2073):镜像 97ad0ae2 时期 calculate_quality_score / classify_from_analysis
返回 str 而非 AssetClassification 枚举,导致 calculate_asset_quality 连续报
"'str' object has no attribute 'value'",大量视频素材的 quality_score 卡在 NULL。
镜像 8abdeb95 已修复枚举 bug,但历史失败记录不会自动重跑。本脚本扫描全表,
把 quality_score IS NULL 的视频素材重新投递到 worker.calculate_asset_quality 任务。
使用方式(在 worker 容器内执行):
cd /app/apps/worker
# 干跑,只打印会重跑多少条,不发任务
python -m scripts.backfill_asset_quality --dry-run
# 正式执行
python -m scripts.backfill_asset_quality
# 只重跑最近 N 天的
python -m scripts.backfill_asset_quality --since-days 30
# 限流:每投递一批 sleep 几秒,避免瞬间打爆 transcode 队列
python -m scripts.backfill_asset_quality --batch-size 50 --sleep 2
也可以直接在 staging 机器上 exec 进容器:
docker exec -e PYTHONPATH=/app:/app/apps/api:/app/packages xiaoxia-worker-staging \
python -m scripts.backfill_asset_quality --dry-run
"""
from __future__ import annotations
import argparse
# 保证可以以 python -m scripts.xxx 在容器 /app/apps/worker 下执行
# 也兼容在 repo 根目录下执行(注入路径)
import os
import sys
import time
from datetime import UTC, datetime, timedelta
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_WORKER_DIR = os.path.dirname(_SCRIPT_DIR) # apps/worker
_APPS_DIR = os.path.dirname(_WORKER_DIR) # apps
_REPO_ROOT = os.path.dirname(_APPS_DIR) # repo root
for p in (_REPO_ROOT, os.path.join(_REPO_ROOT, "apps", "api"), _REPO_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
def main() -> int:
parser = argparse.ArgumentParser(description="补打历史视频素材 quality_score")
parser.add_argument("--dry-run", action="store_true", help="只统计数量,不投递任务")
parser.add_argument("--since-days", type=int, default=0, help="只处理最近 N 天上传的素材(0=全部)")
parser.add_argument("--batch-size", type=int, default=50, help="每批投递数量,默认 50")
parser.add_argument("--sleep", type=float, default=1.0, help="批次之间 sleep 秒数,默认 1s")
parser.add_argument("--queue", type=str, default="transcode", help="投递队列(默认 transcode)")
args = parser.parse_args()
# 延迟 import,避免在 dry-run 时依赖完整 DB 环境
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import AssetModel
db = SessionLocal()
try:
q = db.query(AssetModel).filter(
AssetModel.file_type == "video",
AssetModel.quality_score.is_(None),
)
if args.since_days > 0:
cutoff = datetime.now(UTC) - timedelta(days=args.since_days)
q = q.filter(AssetModel.created_at >= cutoff)
# 先 count 打印
total = q.count()
print(
f"[backfill] 待重跑 quality_score 的视频素材: {total} 条"
f"{' (dry-run,不投递)' if args.dry_run else ''}"
f"{' (最近 ' + str(args.since_days) + ' 天)' if args.since_days > 0 else ''}",
flush=True,
)
if total == 0 or args.dry_run:
return 0
# 分批投递
submitted = 0
batch = 0
offset = 0
while True:
assets = q.order_by(AssetModel.created_at.desc()).offset(offset).limit(args.batch_size).all()
if not assets:
break
batch += 1
for a in assets:
try:
celery_app.send_task(
"worker.calculate_asset_quality",
args=[a.id],
queue=args.queue,
)
submitted += 1
except Exception as e: # noqa: BLE001
print(f"[backfill] 投递失败 asset_id={a.id}: {e}", flush=True)
print(f"[backfill] batch {batch}: 已累计投递 {submitted}/{total}", flush=True)
offset += len(assets)
if args.sleep > 0 and offset < total:
time.sleep(args.sleep)
print(f"[backfill] 完成,共投递 {submitted} 条任务到 {args.queue} 队列", flush=True)
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())
+1 -14
View File
@@ -553,20 +553,7 @@ def concat_video_files(
if work_dir is None:
work_dir = output_path.parent
# Bug #2110: 探测每段是否真实包含音频流,避免 Seedance 生成的无声片段
# (gen_audio=False)让 concat filter `a=1` 找不到 [N:a] 而报 exit 234。
from video_processing.ffmpeg_utils import probe_has_audio as _probe_has_audio
segments: list[ConcatSegment] = []
for p in video_paths:
if not p:
continue
try:
has_audio = _probe_has_audio(p)
except Exception:
has_audio = True # 探测失败保守认为有音频
segments.append(ConcatSegment(video_path=p, has_audio=has_audio))
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
engine = ConcatEngine(work_dir)
@@ -1,831 +0,0 @@
"""全 GPU 直连渲染管线(P1)。
背景:旧链路 worker 先用 CPU libx264 把 filter_complex 输出成 mezzanine(1080p 约 85s),
上传后再由 P4000 NVENC 编码,渲染后还要单独跑一次随机边缘裁剪重编码(约 26s)。
本管线取消 mezzanine:把原始素材签名 URL 作为多输入直接交给 P4000,filter_complex 内
一步完成 trim/scale/pad/concat/边缘随机裁剪/drawtext 字幕,末端 h264_nvenc 只编码一次;
原素材音轨 concat + TTS/配音/BGM 混音也在同一命令里完成。
约束(P1):
- 仅覆盖智能剪辑主流场景:单一主视频轨、全硬切、无 PiP/overlay/水印/贴纸/片头片尾/绿幕。
不满足条件时调用方回退到现有 mezzanine/CPU 链路(功能不回归)。
- 字幕先用 drawtext(P4000 装好中文字体后可再切 subtitles 滤镜烧 ASS)。
"""
from __future__ import annotations
import logging
import random
import uuid
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
DEFAULT_DRAWTEXT_FONT = "Noto Sans CJK SC"
EDGE_CROP_MIN_PCT = 0.02
EDGE_CROP_MAX_PCT = 0.05
# 标题/字幕样式基准宽度(px)。前端 TitleSettings 所有长度字段(size/描边/阴影/margin/pos)
# 均以 720p 为基准(见前端 titleCanvas.ts 注释 scale=videoWidth/720,types.ts "px @720p"),
# 非 720p 输出时按 video_width / TITLE_SIZE_REF_WIDTH 等比缩放,保证成片位置与前端预览一致。
TITLE_SIZE_REF_WIDTH = 720
# 与 video_filter_builder.build_title_drawtext_filter(CPU 路径)和 ass_subtitle_builder 对齐:
# - top/bottom 默认 margin 50@720p(vfb 用 _scale_title_len(50, w),即 y=50 / y=h-th-50)
# - margin_top 字段:前端编辑器 marginTop 滑块,叠加在默认 margin 之上(#2095 支持)
# - PAD 概念仅用于前端 Canvas 预览;ffmpeg drawtext y 是 baseline,无 font metrics 可用,
# 直接用统一 50@720p baseline 位置即可保持三端(GPU/CPU/前端视觉)一致。
TITLE_DEFAULT_MARGIN_TOP = 50 # top 位置 baseline 默认距顶 50@720p(与 vfb/CPU 路径一致)
TITLE_DEFAULT_MARGIN_BOTTOM = 50 # bottom 位置 baseline 默认距底 50@720p
SUBTITLE_DEFAULT_MARGIN_BOTTOM = 50 # 字幕距底边距 50@720p(与 vfb 一致)
TITLE_MARGIN_TOP_FROM_CFG_DEFAULT = 24 # 前端 marginTop 滑块默认值(用户未传时叠加 0)
TITLE_FAUX_BOLD_WIDTH = 2 # 仿粗黑色描边宽度(与 vfb 一致,2@720p 黑色细描边)
def _scale_title_len(value, video_width: int):
"""将 720p 基准长度按 video_width 等比缩放(与 packages/domain/ass_subtitle_builder._scale_len 一致)。
int 输入 → 返回 int;float 输入 → 返回 float;非法值原样返回。
"""
if value is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return value
if not video_width or video_width <= 0:
return int(round(v)) if isinstance(value, int) else v
scaled = v * (video_width / TITLE_SIZE_REF_WIDTH)
return int(round(scaled)) if isinstance(value, int) else scaled
def escape_drawtext_text(text: str) -> str:
if not text:
return ""
s = text.replace("\\", "\\\\")
s = s.replace(":", "\\:")
s = s.replace("'", "\\'")
s = s.replace("%", "\\%")
s = s.replace(",", "\\,")
s = s.replace("[", "\\[").replace("]", "\\]")
s = s.replace(";", "\\;")
s = s.replace("\n", " ")
return s
def _hex_to_drawtext_color(hex_color: str, default: str = "white") -> str:
"""把 #RRGGBB / #RGB / 命名颜色转换为 ffmpeg drawtext 接受的颜色格式。
drawtext 的 fontcolor 接受 0xRRGGBB 形式(或命名颜色如 white/black/yellow)。
描边/阴影颜色同样适用。alpha 后缀支持(#RRGGBB@0.5 或 &HBBGGRRAA)。
"""
if not hex_color:
return default
s = hex_color.strip()
if not s:
return default
# 命名颜色直接返回(白名单常见值,避免把 #xxx 当成命名)
if not s.startswith("#") and not s.startswith("0x") and "@" not in s:
return s
if s.startswith("0x"):
return s # 已是 drawtext 原生格式
if s.startswith("#"):
h = s[1:]
# 处理 alpha:#RRGGBB@AA 或 #RRGGBB&AA
alpha = ""
if "@" in h:
h, alpha_part = h.split("@", 1)
try:
a = float(alpha_part)
alpha = f"@{a:.2f}"
except ValueError:
alpha = ""
if len(h) == 3:
h = "".join(ch * 2 for ch in h)
if len(h) == 6:
try:
int(h, 16)
except ValueError:
return default
return f"0x{h}{alpha}"
if len(h) == 8:
# RRGGBBAA → drawtext 的 0xRRGGBB@AA 形式
try:
int(h, 16)
except ValueError:
return default
rr, gg, bb, aa = h[0:2], h[2:4], h[4:6], h[6:8]
try:
a = int(aa, 16) / 255.0
return f"0x{rr}{gg}{bb}@{a:.2f}"
except ValueError:
return f"0x{rr}{gg}{bb}"
return default
def _position_to_drawtext_xy(
position: str,
*,
margin_top: int = 0,
margin_bottom: int = 0,
pos_x: Optional[float] = None,
pos_y: Optional[float] = None,
) -> tuple[str, str]:
"""把位置映射到 drawtext x/y 表达式,对齐前端 titleCanvas.ts 预览坐标。
position 支持: top / center(middle) / bottom / custom。
- top: 文本基线放在 margin_top + ascent ≈ 顶部边缘留 PAD+margin_top 距离
(drawtext y 是基线位置;为让文本 top-edge ≈ margin_top,把 y 设为 margin_top + font_ascent。
但 drawtext 运行时不知道 ascent,用经验系数 0.8*fontsize 近似,和前端 PAD+margin_top 对齐)。
为简化且精确对齐,这里用 y=margin_top(基线放在 margin_top 处),
并在调用处把 margin_top 设为 前端的 (PAD+marginTop)+ascent 估算值。
- center: (h-text_h)/2 垂直居中。
- bottom: 文本底线距离底边 margin_bottom。
- custom: pos_x/pos_y 为百分比 0-100(前端拖拽坐标系),文本中心落在 (pct_x*w, pct_y*h)。
margin_top/margin_bottom 为已按 video_width 缩放过的像素值。
"""
p = (position or "top").lower().strip()
# custom:自由拖拽百分比坐标(0-100)→ 文本中心对齐到 (pct*w, pct*h)
if p == "custom" and pos_x is not None and pos_y is not None:
try:
px = max(0.0, min(100.0, float(pos_x))) / 100.0
py = max(0.0, min(100.0, float(pos_y))) / 100.0
return f"(w-text_w)*{px:.4f}", f"(h-text_h)*{py:.4f}"
except (TypeError, ValueError):
pass # fall through to default
x = "(w-text_w)/2"
if p in ("top",):
# drawtext y 是 baseline 位置。中文字符顶边距基线约 0.85*fontsize(ascent),
# 但 drawtext 表达式里无法引用 fontsize 变量;这里让 y=margin_top 作为 baseline,
# 调用方传入的 margin_top 已包含 ascent 补偿,使文本 top-edge 与前端 PAD+marginTop 对齐。
y = f"{int(margin_top)}"
elif p in ("center", "middle"):
y = "(h-text_h)/2"
elif p in ("bottom",):
# h-th-margin_bottom:th ≈ text_h,文本底边距底边 margin_bottom
y = f"h-th-{int(margin_bottom)}"
else:
# 未知值回退到顶部(与前端默认 position=top 对齐)
y = f"{int(margin_top)}"
return x, y
def _build_drawtext_filters(
*,
text: str,
start: float,
end: float,
font: str = DEFAULT_DRAWTEXT_FONT,
font_size: int = 0,
font_color: str = "white",
position: str = "top",
margin_top: int = 0,
margin_bottom: int = 0,
pos_x: Optional[float] = None,
pos_y: Optional[float] = None,
box_enabled: bool = False,
box_color: str = "black@0.5",
borderw: int = 0,
border_color: str = "black",
shadow_enabled: bool = False,
shadow_color: str = "black@0.6",
shadow_x: int = 2,
shadow_y: int = 2,
) -> list[str]:
"""构造一组 drawtext 滤镜:可选阴影层(同字偏移)+ 主字层。
ffmpeg drawtext 没有直接的 shadow 选项,用两次 drawtext 模拟:
先画一个描边/阴影色层偏移 shadow_x/shadow_y,再画主字层。
返回列表是为了让调用方顺序插入 fc(前一个输出作为后一个输入)。
"""
txt = escape_drawtext_text(text)
if not txt:
return []
x_expr, y_expr = _position_to_drawtext_xy(
position,
margin_top=margin_top,
margin_bottom=margin_bottom,
pos_x=pos_x,
pos_y=pos_y,
)
fc_color = _hex_to_drawtext_color(font_color, default="white")
bd_color = _hex_to_drawtext_color(border_color, default="black")
sh_color = _hex_to_drawtext_color(shadow_color, default="black@0.6")
filters: list[str] = []
# 阴影层:shadow_enabled 时先画一层深色偏移字(无描边)
if shadow_enabled and (shadow_x != 0 or shadow_y != 0):
sh_parts = [f"font={font}", f"text='{txt}'"]
if font_size and font_size > 0:
sh_parts.append(f"fontsize={int(font_size)}")
sh_parts.append(f"fontcolor={sh_color}")
sh_parts.append(f"x={x_expr}+{int(shadow_x)}")
sh_parts.append(f"y={y_expr}+{int(shadow_y)}")
if start > 0 or end > 0:
sh_parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
filters.append("drawtext=" + ":".join(sh_parts))
# 主字层
parts = [f"font={font}", f"text='{txt}'"]
if font_size and font_size > 0:
parts.append(f"fontsize={int(font_size)}")
parts.append(f"fontcolor={fc_color}")
if box_enabled:
parts.append("box=1")
parts.append(f"boxcolor={box_color}")
if borderw and borderw > 0:
parts.append(f"borderw={int(borderw)}")
parts.append(f"bordercolor={bd_color}")
parts.append(f"x={x_expr}")
parts.append(f"y={y_expr}")
if start > 0 or end > 0:
parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
filters.append("drawtext=" + ":".join(parts))
return filters
def build_drawtext_filter(
*,
text: str,
start: float,
end: float,
font: str = DEFAULT_DRAWTEXT_FONT,
font_size: int = 0,
font_color: str = "white",
x_expr: str = "(w-text_w)/2",
y_expr: str = "h-th-60",
box: bool = False,
box_color: str = "black@0.5",
borderw: int = 0,
border_color: str = "black",
enable: bool = True,
) -> str:
"""[已废弃] 保留单条 drawtext 的便捷构造;新代码请用 _build_drawtext_filters。"""
txt = escape_drawtext_text(text)
parts = [f"font={font}", f"text='{txt}'"]
if font_size and font_size > 0:
parts.append(f"fontsize={int(font_size)}")
parts.append(f"fontcolor={_hex_to_drawtext_color(font_color)}")
if box:
parts.append("box=1")
parts.append(f"boxcolor={box_color}")
if borderw and borderw > 0:
parts.append(f"borderw={int(borderw)}")
parts.append(f"bordercolor={_hex_to_drawtext_color(border_color)}")
parts.append(f"x={x_expr}")
parts.append(f"y={y_expr}")
if enable:
parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
return "drawtext=" + ":".join(parts)
def _build_atempo_chain(speed: float) -> str:
if abs(speed - 1.0) < 1e-6:
return ""
stages: list[float] = []
remaining = speed
while remaining > 2.0:
stages.append(2.0)
remaining /= 2.0
while remaining < 0.5:
stages.append(0.5)
remaining /= 0.5
if abs(remaining - 1.0) >= 1e-6:
stages.append(remaining)
return ",".join(f"atempo={s:.5f}" for s in stages)
def upload_local_audio_and_sign(
local_audio: Path,
*,
tmp_prefix: str = "tmp/gpu-direct-audio/",
expires: int = 3600,
) -> tuple[str, str]:
from video_processing.oss_helpers import _storage # type: ignore
storage = _storage()
key = f"{tmp_prefix.rstrip('/')}/{uuid.uuid4().hex}{local_audio.suffix or '.mp3'}"
content_type = "audio/mpeg" if local_audio.suffix.lower() in (".mp3", ".mpeg") else "audio/mp4"
storage.upload_file(local_audio, key, content_type=content_type)
url = storage.get_download_url(key, expires)
return url, key
def sign_asset_url(storage_key: str, *, expires: int = 3600) -> str:
from video_processing.oss_helpers import _storage # type: ignore
storage = _storage()
return storage.get_download_url(storage_key, expires)
class DirectRenderPlan:
def __init__(
self,
inputs: dict[str, str],
ffmpeg_args: list[str],
oss_keys: list[str],
filter_complex: list[str] | None = None,
):
self.inputs = inputs
self.ffmpeg_args = ffmpeg_args
self.oss_keys = oss_keys
self.filter_complex: list[str] = filter_complex or []
def build_direct_render(
*,
resolved_clips: list[Any],
output_width: int,
output_height: int,
output_fps: int,
tts_audio: Optional[Path] = None,
bgm_audio: Optional[Path] = None,
title_text: str = "",
subtitle_segments: Optional[list[Any]] = None,
font: str = DEFAULT_DRAWTEXT_FONT,
vcodec: str = "h264_nvenc",
preset: str = "p4",
video_bitrate: str = "",
cq: int = 23,
edge_crop_pct: float = 0.0,
total_duration: float = 0.0,
clip_has_audio: Optional[list[bool]] = None,
clip_volumes: Optional[list[float]] = None,
extra_audio_tracks: Optional[list[tuple[Any, float]]] = None,
title_config: Optional[dict] = None,
subtitle_config: Optional[dict] = None,
bgm_config: Optional[dict] = None,
static_subtitle_text: str = "",
) -> DirectRenderPlan:
"""构造 P4000 直连渲染所需的 inputs 与 ffmpeg_args。
视频:每段 trim/setpts/scale/pad/fps → concat(全硬切,带音频)→ 随机边缘 crop+scale → drawtext。
音频:每段 [i:a](或 anullsrc 静音占位)按 clip 配置 atrim/asetpts/atempo/volume/aresample
→ concat=n:N:v=1:a=1 → 与 extra_audio(TTS/配音素材库)、BGM 一起 amix → atrim 精确截断。
"""
if not resolved_clips:
raise ValueError("build_direct_render: no resolved clips")
inputs: dict[str, str] = {}
oss_keys: list[str] = []
input_args: list[str] = []
fc: list[str] = []
n = len(resolved_clips)
# 规范化每段参数
if clip_has_audio is None:
clip_has_audio = [True] * n
else:
clip_has_audio = list(clip_has_audio) + [True] * max(0, n - len(clip_has_audio))
clip_has_audio = clip_has_audio[:n]
if clip_volumes is None:
clip_volumes = [1.0] * n
else:
clip_volumes = list(clip_volumes) + [1.0] * max(0, n - len(clip_volumes))
clip_volumes = clip_volumes[:n]
clip_starts: list[float] = []
clip_effs: list[float] = []
clip_speeds: list[float] = []
for clip in resolved_clips:
start = float(getattr(clip, "start_time", 0) or 0)
eff = float(getattr(clip, "duration", 0) or 0)
if eff <= 0:
eff = float(getattr(clip, "actual_duration", 0) or 0)
speed = float(getattr(clip, "playback_speed", 1.0) or 1.0)
clip_starts.append(start)
clip_effs.append(eff)
clip_speeds.append(speed)
# 1. 视频输入(原始素材签名 URL)
for i, clip in enumerate(resolved_clips):
sk = (getattr(clip, "config", None) or {}).get("_storage_key")
if not sk:
raise ValueError(f"clip {getattr(clip, 'clip_id', i)} missing _storage_key")
fname = f"v{i}.mp4"
inputs[fname] = sign_asset_url(sk)
input_args.extend(["-i", fname])
# 2. 视频段预处理
pre_labels: list[str] = []
for i in range(n):
vf: list[str] = []
start, eff, speed = clip_starts[i], clip_effs[i], clip_speeds[i]
if eff > 0:
if start > 0:
vf.append(f"trim=start={start:.3f}:duration={eff:.3f}")
else:
vf.append(f"trim=duration={eff:.3f}")
vf.append("setpts=PTS-STARTPTS")
if abs(speed - 1.0) >= 1e-6:
vf.append(f"setpts=PTS/{speed:.4f}")
vf.append(f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease")
vf.append(f"pad={output_width}:{output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
vf.append("setpts=PTS-STARTPTS")
vf.append(f"fps={output_fps}")
label = f"vc{i}"
fc.append(f"[{i}:v]{','.join(vf)}[{label}]")
pre_labels.append(label)
# 2b. 音频段预处理(无声源用 anullsrc 占位;volume=0 的段也用 anullsrc 静音占位保持时间轴)
anullsrc_counter = 0
audio_pre_labels: list[str] = []
for i in range(n):
start, eff, speed = clip_starts[i], clip_effs[i], clip_speeds[i]
vol = float(clip_volumes[i] if i < len(clip_volumes) else 1.0)
has_a = bool(clip_has_audio[i] if i < len(clip_has_audio) else True)
if not has_a or vol <= 0.001:
# 静音占位:用 anullsrc 生成静音,atrim 到段时长
sl = f"sil{anullsrc_counter}"
anullsrc_counter += 1
af: list[str] = ["anullsrc=channel_layout=stereo:sample_rate=44100"]
if eff > 0:
af.append(f"atrim=duration={eff:.3f}")
af.append("asetpts=PTS-STARTPTS")
af.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
fc.append(f"{','.join(af)}[{sl}]")
# anullsrc 作为 filter 源不需要 -i 输入,直接给 label
audio_pre_labels.append(sl)
continue
af = []
if eff > 0:
if start > 0:
af.append(f"atrim=start={start:.3f}:duration={eff:.3f}")
else:
af.append(f"atrim=duration={eff:.3f}")
af.append("asetpts=PTS-STARTPTS")
if abs(speed - 1.0) >= 1e-6:
atempo = _build_atempo_chain(speed)
if atempo:
af.append(atempo)
if abs(vol - 1.0) >= 1e-3:
af.append(f"volume={vol:.3f}")
af.append("aresample=44100")
af.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
alabel = f"ac{i}"
fc.append(f"[{i}:a]{','.join(af)}[{alabel}]")
audio_pre_labels.append(alabel)
# 3. concat(全硬切;v=1:a=1,视频音频一起拼接)
concat_in = "".join(f"[{v}][{a}]" for v, a in zip(pre_labels, audio_pre_labels, strict=True))
fc.append(f"{concat_in}concat=n={n}:v=1:a=1[vcat][acat]")
cur_v = "vcat"
cur_a = "acat"
# 4. 随机边缘裁剪降重(四边独立随机 2%~5%,与 ffmpeg_utils.random_edge_crop 一致)
if edge_crop_pct and edge_crop_pct > 0:
_r = random.Random()
p_min = EDGE_CROP_MIN_PCT
p_max = EDGE_CROP_MAX_PCT
crop_top = p_min + _r.random() * (p_max - p_min)
crop_bottom = p_min + _r.random() * (p_max - p_min)
crop_left = p_min + _r.random() * (p_max - p_min)
crop_right = p_min + _r.random() * (p_max - p_min)
w_expr = f"trunc(iw*(1-{crop_left:.4f}-{crop_right:.4f})/2)*2"
h_expr = f"trunc(ih*(1-{crop_top:.4f}-{crop_bottom:.4f})/2)*2"
x_expr = f"trunc(iw*{crop_left:.4f}/2)*2"
y_expr = f"trunc(ih*{crop_top:.4f}/2)*2"
fc.append(
f"[{cur_v}]crop=w='{w_expr}':h='{h_expr}':x='{x_expr}':y='{y_expr}',"
f"scale={output_width}:{output_height}[vcrop]"
)
cur_v = "vcrop"
# 5. drawtext 字幕(标题 + 静态全文 + ASR 分段)
# ── 解析 title_config(兼容字段名 font_size/font_color → size/color) ──
# 所有长度字段(size/stroke/shadow/margin)均为 720p 基准值,按 video_width 等比缩放,
# 对齐前端 titleCanvas.ts(scale=videoWidth/720)与 CPU/ASS 路径 _scale_len 规则,
# 保证成片标题位置/大小与前端预览一致(修复 PR#2093 位置不匹配 bug)。
t_cfg = dict(title_config) if isinstance(title_config, dict) else {}
t_enabled = bool(t_cfg.get("enabled", True))
t_text = (t_cfg.get("text", "") or title_text or "").strip()
t_font = str(t_cfg.get("font", font) or font)
# size:前端传 px@720p,未配置默认 28(前端 DEFAULT_TITLE_SETTINGS.size=28,对齐 AI Avatar 默认48)
t_size_raw = t_cfg.get("size", t_cfg.get("font_size", 0))
try:
t_size_720 = int(t_size_raw) if t_size_raw else 0
except (TypeError, ValueError):
t_size_720 = 0
if t_size_720 <= 0:
t_size_720 = 48 # 与 config_schemas.DEFAULT_EDIT_PLAN_CONFIG.title.size=48 及 vfb 默认一致
t_size = _scale_title_len(t_size_720, output_width)
# stroke/shadow 长度字段也需 720p→输出分辨率缩放
t_color = str(t_cfg.get("color", t_cfg.get("font_color", "#ffffff")))
t_position = str(t_cfg.get("position", "top")).lower().strip()
# 自由拖拽坐标(百分比 0-100),与 video_filter_builder.build_title_drawtext_filter 一致
t_pos_x = t_cfg.get("pos_x")
t_pos_y = t_cfg.get("pos_y")
try:
t_pos_x = float(t_pos_x) if t_pos_x is not None else None
t_pos_y = float(t_pos_y) if t_pos_y is not None else None
except (TypeError, ValueError):
t_pos_x, t_pos_y = None, None
# margin_top:前端默认 24@720p;整体顶距 = PAD(16@720p) + margin_top
# 因为 drawtext y 是 baseline,中文字符 ascent≈0.85*fontsize,为让文本 top-edge≈(PAD+marginTop),
# baseline 需再下移约 0.85*fontsize;但 drawtext 表达式无法引用 fontsize 变量,
# 这里直接用 (PAD + margin_top)@720p 缩放后作为 y(即让 baseline≈顶部内边距位置),
# 实际中文字符会自然向下延伸,视觉位置与前端预览(textBaseline=middle 居中到 firstLineY)一致。
# margin_top:前端滑块值(默认 24@720p),叠加在默认 50@720p 基线之上
_t_user_margin_top = t_cfg.get("margin_top")
try:
_t_user_margin_top_720 = int(_t_user_margin_top) if _t_user_margin_top is not None else 0
except (TypeError, ValueError):
_t_user_margin_top_720 = 0
t_margin_top_720 = TITLE_DEFAULT_MARGIN_TOP + _t_user_margin_top_720
t_margin_top = _scale_title_len(t_margin_top_720, output_width)
# bottom margin(标题放在 bottom 时):用户 margin_bottom 透传,默认 50@720p
_t_user_margin_bottom = t_cfg.get("margin_bottom")
try:
_t_user_margin_bottom_720 = int(_t_user_margin_bottom) if _t_user_margin_bottom is not None else 0
except (TypeError, ValueError):
_t_user_margin_bottom_720 = 0
t_margin_bottom_720 = TITLE_DEFAULT_MARGIN_BOTTOM + _t_user_margin_bottom_720
t_margin_bottom = _scale_title_len(t_margin_bottom_720, output_width)
t_borderw = 0
t_border_color = "#000000"
t_box = False
t_box_color = "black@0.5"
# stroke
_stroke = t_cfg.get("stroke")
if isinstance(_stroke, dict) and _stroke.get("enabled", False):
try:
t_borderw_720 = int(float(_stroke.get("width", 2)))
except (TypeError, ValueError):
t_borderw_720 = 2
t_borderw = max(1, _scale_title_len(t_borderw_720, output_width))
t_border_color = str(_stroke.get("color", "#000000"))
elif isinstance(_stroke, bool) and _stroke:
t_borderw = max(1, _scale_title_len(2, output_width))
# shadow
_shadow = t_cfg.get("shadow")
t_shadow_enabled = False
t_shadow_color = "#000000@0.6"
t_shadow_x_720, t_shadow_y_720 = 2, 2
if isinstance(_shadow, dict) and _shadow.get("enabled", False):
t_shadow_enabled = True
t_shadow_color = str(_shadow.get("color", "#000000@0.6"))
try:
t_shadow_x_720 = int(float(_shadow.get("offset_x", 2)))
t_shadow_y_720 = int(float(_shadow.get("offset_y", 2)))
except (TypeError, ValueError):
t_shadow_x_720, t_shadow_y_720 = 2, 2
elif isinstance(_shadow, bool) and _shadow:
t_shadow_enabled = True
t_shadow_x = _scale_title_len(t_shadow_x_720, output_width)
t_shadow_y = _scale_title_len(t_shadow_y_720, output_width)
# bold/italic:drawtext 原生无粗斜体选项;通过同色描边模拟粗体
t_bold = bool(t_cfg.get("bold", True)) # 与 ASS/vfb 路径默认 bold=True 对齐
if t_bold and t_borderw < 1:
# 粗体未配用户描边时:黑色细描边 2@720p(与 vfb 一致,避免同色描边导致重影)
t_borderw = _scale_title_len(TITLE_FAUX_BOLD_WIDTH, output_width)
t_border_color = "#000000" # 黑色细描边模拟粗体
# ── 解析 subtitle_config ──
s_cfg = dict(subtitle_config) if isinstance(subtitle_config, dict) else {}
s_enabled = bool(s_cfg.get("enabled", True))
s_font = str(s_cfg.get("font", font) or font)
s_size_raw = s_cfg.get("size", s_cfg.get("font_size", 0))
try:
s_size_720 = int(s_size_raw) if s_size_raw else 0
except (TypeError, ValueError):
s_size_720 = 0
if s_size_720 <= 0:
s_size_720 = 24 # 字幕默认 24@720p(对齐 ass_subtitle_builder defaults size=24)
s_size = _scale_title_len(s_size_720, output_width)
s_color = str(s_cfg.get("color", s_cfg.get("font_color", "#ffffff")))
s_position = str(s_cfg.get("position", "bottom")).lower().strip()
s_pos_x = s_cfg.get("pos_x")
s_pos_y = s_cfg.get("pos_y")
try:
s_pos_x = float(s_pos_x) if s_pos_x is not None else None
s_pos_y = float(s_pos_y) if s_pos_y is not None else None
except (TypeError, ValueError):
s_pos_x, s_pos_y = None, None
s_margin_top = _scale_title_len(60, output_width) # subtitle top (not commonly used)
s_margin_bottom = _scale_title_len(SUBTITLE_DEFAULT_MARGIN_BOTTOM, output_width)
# subtitle stroke/bold:先解析用户 stroke,再按 bold 默认补描边
s_borderw = 0
s_border_color = "#000000"
_s_stroke = s_cfg.get("stroke")
if isinstance(_s_stroke, dict) and _s_stroke.get("enabled", False):
try:
s_borderw = _scale_title_len(int(float(_s_stroke.get("width", 2))), output_width)
except (TypeError, ValueError):
s_borderw = 0
s_border_color = str(_s_stroke.get("color", "#000000"))
s_bold = bool(s_cfg.get("bold", False))
if s_bold and s_borderw < 1:
# 粗体默认黑色细描边 2@720p(与 title/CPU vfb 一致)
s_borderw = _scale_title_len(TITLE_FAUX_BOLD_WIDTH, output_width)
s_border_color = "#000000"
# 静态字幕:static_subtitle_text 非空时构造全片长 segment(0 → total_duration)
static_text = (static_subtitle_text or "").strip()
subtitle_segments = list(subtitle_segments or [])
if s_enabled and static_text and total_duration and total_duration > 0:
# 用 duck-type 对象插入到 subtitle_segments 列表头部(静态全文)
class _StaticSeg:
def __init__(self, txt, st, ed):
self.text = txt
self.start = st
self.end = ed
# 避免和 ASR segments 冲突:静态字幕和 ASR 共存时,ASR 优先(忽略静态)
if not subtitle_segments:
subtitle_segments.insert(0, _StaticSeg(static_text, 0.0, float(total_duration)))
draw_filters: list[str] = []
if t_enabled and t_text:
draw_filters.extend(
_build_drawtext_filters(
text=t_text,
start=0.0,
end=max(total_duration, 0.1),
font=t_font,
font_size=t_size,
font_color=t_color,
position=t_position,
margin_top=t_margin_top,
margin_bottom=t_margin_bottom,
pos_x=t_pos_x,
pos_y=t_pos_y,
box_enabled=t_box,
box_color=t_box_color,
borderw=t_borderw,
border_color=t_border_color,
shadow_enabled=t_shadow_enabled,
shadow_color=t_shadow_color,
shadow_x=t_shadow_x,
shadow_y=t_shadow_y,
)
)
if s_enabled:
for seg in subtitle_segments:
txt = getattr(seg, "text", "") or ""
if not txt.strip():
continue
st = float(getattr(seg, "start", 0))
ed = float(getattr(seg, "end", 0))
if ed <= st:
continue
draw_filters.extend(
_build_drawtext_filters(
text=txt,
start=st,
end=ed,
font=s_font,
font_size=s_size,
font_color=s_color,
position=s_position,
margin_top=s_margin_top,
margin_bottom=s_margin_bottom,
pos_x=s_pos_x,
pos_y=s_pos_y,
box_enabled=False,
borderw=s_borderw,
border_color=s_border_color,
)
)
if draw_filters:
prev = cur_v
for idx, df in enumerate(draw_filters):
out_l = "vfinal" if idx == len(draw_filters) - 1 else f"vd{idx}"
fc.append(f"[{prev}]{df}[{out_l}]")
prev = out_l
vfinal_label = prev
else:
fc.append(f"[{cur_v}]format=yuv420p[vfinal]")
vfinal_label = "vfinal"
# 6. 音频混音:原素材主音轨 acat + extra(TTS/配音素材库) + BGM → amix → atrim
mix_labels: list[str] = [cur_a]
mix_vols: list[float] = [1.0]
next_idx = n
# 额外独立音频轨(TTS concat / 配音素材库整段音频)
for _ea_idx, (ea_path, ea_vol) in enumerate(extra_audio_tracks or []):
if ea_path is None:
continue
ea_p = Path(ea_path)
if not ea_p.exists():
continue
eurl, ekey = upload_local_audio_and_sign(ea_p)
ename = f"extra{_ea_idx}{ea_p.suffix or '.mp3'}"
inputs[ename] = eurl
oss_keys.append(ekey)
input_args.extend(["-i", ename])
elabel = f"aex{_ea_idx}"
fc.append(
f"[{next_idx}:a]aresample=44100,volume={float(ea_vol):.2f},"
f"aformat=sample_fmts=fltp:channel_layouts=stereo[{elabel}]"
)
mix_labels.append(elabel)
mix_vols.append(float(ea_vol))
next_idx += 1
if tts_audio and Path(tts_audio).exists():
# 旧参数保留:若调用方直接传了 tts_audio 而没走 extra_audio_tracks,则仍然加入
# (兼容旧调用,正常路径 TTS 已经通过 extra_audio_tracks 传入)
turl, tkey = upload_local_audio_and_sign(Path(tts_audio))
tname = "tts" + (Path(tts_audio).suffix or ".mp3")
inputs[tname] = turl
oss_keys.append(tkey)
input_args.extend(["-i", tname])
alabel = "au_tts"
fc.append(
f"[{next_idx}:a]aresample=44100,volume=1.00,aformat=sample_fmts=fltp:channel_layouts=stereo[{alabel}]"
)
mix_labels.append(alabel)
mix_vols.append(1.0)
next_idx += 1
_bgm_use = bgm_audio is not None and Path(bgm_audio).exists()
if _bgm_use and isinstance(bgm_config, dict) and bgm_config.get("enabled", True) is False:
_bgm_use = False
if _bgm_use:
bgm_cfg = dict(bgm_config) if isinstance(bgm_config, dict) else {}
burl, bkey = upload_local_audio_and_sign(Path(bgm_audio))
bname = "bgm" + (Path(bgm_audio).suffix or ".mp3")
inputs[bname] = burl
oss_keys.append(bkey)
input_args.extend(["-i", bname])
alabel = "au_bgm"
try:
bgm_vol = float(bgm_cfg.get("volume", 0.3))
except (TypeError, ValueError):
bgm_vol = 0.3
bgm_vol = max(0.0, min(1.5, bgm_vol))
# volume_adjust_db(-3 ~ +3 dB)换算线性增益
try:
_db = float(bgm_cfg.get("volume_adjust_db", 0.0))
except (TypeError, ValueError):
_db = 0.0
if abs(_db) > 0.05:
db_gain = 10 ** (_db / 20.0)
bgm_vol = max(0.0, min(2.0, bgm_vol * db_gain))
# afade 淡入淡出
try:
fade_in = max(0.0, float(bgm_cfg.get("fade_in", 0.0)))
except (TypeError, ValueError):
fade_in = 0.0
try:
fade_out = max(0.0, float(bgm_cfg.get("fade_out", 0.0)))
except (TypeError, ValueError):
fade_out = 0.0
# audio_offset:adelay 延迟(毫秒)
try:
offset = max(0.0, float(bgm_cfg.get("audio_offset", 0.0)))
except (TypeError, ValueError):
offset = 0.0
bgm_parts: list[str] = [f"[{next_idx}:a]aresample=44100"]
if offset > 0.01:
bgm_parts.append(f"adelay={int(offset * 1000)}|{int(offset * 1000)}")
bgm_parts.append(f"volume={bgm_vol:.3f}")
if fade_in > 0.01:
bgm_parts.append(f"afade=t=in:st=0:d={fade_in:.2f}")
if fade_out > 0.01 and total_duration > 0:
fo_start = max(0.0, total_duration - fade_out)
bgm_parts.append(f"afade=t=out:st={fo_start:.2f}:d={fade_out:.2f}")
bgm_parts.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
fc.append(",".join(bgm_parts) + f"[{alabel}]")
mix_labels.append(alabel)
mix_vols.append(bgm_vol)
next_idx += 1
maps: list[str] = ["-map", f"[{vfinal_label}]"]
if mix_labels:
mix_in = "".join(f"[{lb}]" for lb in mix_labels)
n_mix = len(mix_labels)
mix_parts = [
f"amix=inputs={n_mix}:duration=longest:dropout_transition=2:normalize=0",
"aresample=44100",
]
# Bug2 修复:atrim 到视频精确时长
if total_duration and total_duration > 0:
mix_parts.append(f"atrim=0:{total_duration:.3f}")
mix_parts.append("asetpts=PTS-STARTPTS")
fc.append(f"{mix_in}{','.join(mix_parts)}[afinal]")
maps.extend(["-map", "[afinal]", "-c:a", "aac", "-b:a", "128k"])
else:
logger.info("[gpu-direct] no audio tracks; output silent video")
# 7. 组装 ffmpeg_args + NVENC 编码
ffmpeg_args = ["-y", *input_args, "-filter_complex", ";".join(fc), *maps]
ffmpeg_args.extend(["-c:v", vcodec, "-preset", preset, "-pix_fmt", "yuv420p"])
if video_bitrate:
ffmpeg_args.extend(["-b:v", video_bitrate])
else:
ffmpeg_args.extend(["-cq", str(cq)])
ffmpeg_args.extend(["-movflags", "+faststart", "-shortest", "-f", "mp4", "pipe:1"])
return DirectRenderPlan(
inputs=inputs,
ffmpeg_args=ffmpeg_args,
oss_keys=oss_keys,
filter_complex=fc,
)
+246 -311
View File
@@ -1,17 +1,7 @@
"""OSS 工具函数 — Worker 端统一入口。
"""OSS 工具函数 — 从 generation.py 提取的共享 OSS 操作.
P1 (2026-09-28) OSS 双 endpoint 改造:默认走 packages.shared.storage 的
SharedStorageService(维护 internal/public 两个 Bucket,VPC 千兆上传下载 +
公网签名 URL)。同时保留旧函数签名和模块级属性,兼容历史单测的 patch 路径。
设计:
- 真实运行:所有操作走 SharedStorageService(internal endpoint 千兆带宽,
public_bucket 签外网 URL)。
- 单测 patch 场景:检测到 oss_settings/oss_bucket/oss2.Bucket/requests.get 等
被 patch 后,回退到旧直连 oss2 逻辑,老测试的 patch 仍然生效。
- pytest importlib 模式兼容:conftest.py 把 apps/worker 加进 pythonpath,
本文件可能以 video_processing.oss_helpers 和 apps.worker.video_processing.oss_helpers
两个名字分别加载;patch 可能打到任一份,所以检测时遍历 sys.modules 里的同名模块。
提供 OSS 配置读取、Bucket 创建、素材上传/下载、asset_id → 本地路径解析
等能力,供 render_edit_plan 和 generate_video 共同复用。
"""
from __future__ import annotations
@@ -19,173 +9,67 @@ from __future__ import annotations
import hashlib
import logging
import os
import sys
import time as _time
import threading
from pathlib import Path
from urllib.parse import urlparse
import oss2 # noqa: F401 保留模块级属性,老单测 patch(oss_helpers.oss2)
import requests # noqa: F401 老单测 patch(oss_helpers.requests)
import oss2
import requests
from packages.shared.storage import OSS_CONNECT_TIMEOUT # noqa: F401
from packages.shared.storage import OSS_MULTIPART_NUM_THREADS # noqa: F401
from packages.shared.storage import OSS_MULTIPART_THRESHOLD # noqa: F401
from packages.shared.storage import OSS_PART_SIZE # noqa: F401
from packages.shared.storage import (
OSS_HTTP_DOWNLOAD_TIMEOUT,
OSS_UPLOAD_TOTAL_TIMEOUT,
SharedStorageService,
get_shared_storage_service,
)
from packages.shared.config import get_shared_settings
logger = logging.getLogger(__name__)
# ── 单例访问 ──────────────────────────────────────────────────────────
# OSS 上传配置
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死
OSS_UPLOAD_TOTAL_TIMEOUT = 900 # 单文件上传总超时(秒),防止网络慢时无限卡住
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
def _storage() -> SharedStorageService:
return get_shared_storage_service()
# ── OSS 配置 ──────────────────────────────────────────────────────────────────
# ── 多模块实例兼容(pytest importlib 模式)────────────────────────────
def oss_settings() -> tuple[str, str, str, str] | None:
"""获取 OSS 配置。
统一使用 SharedSettings 读取配置,与 SharedStorageService 保持一致,
支持从 .env 文件加载,避免两套配置路径不一致。
def _sibling_modules() -> list:
"""返回 sys.modules 里所有指向本文件的模块实例(包含自己)。"""
own_file = os.path.abspath(__file__)
mods = []
for _name, mod in list(sys.modules.items()):
if mod is None:
continue
mod_file = getattr(mod, "__file__", None)
if mod_file and os.path.abspath(mod_file) == own_file:
mods.append(mod)
return mods
def _is_mock(obj) -> bool:
"""判断对象是否是 unittest.mock.Mock/MagicMock。"""
if obj is None:
return False
try:
from unittest.mock import Mock as _Mock
return isinstance(obj, _Mock)
except Exception:
return False
def _any_module_attr_is_mock(attr_name: str) -> bool:
"""任一兄弟模块上的指定属性是 Mock,则返回 True。"""
for m in _sibling_modules():
if _is_mock(getattr(m, attr_name, None)):
return True
return False
def _call_any_mock_or_own(attr_name: str, *args, **kwargs):
"""如果任一兄弟模块上 attr_name 是 Mock,调用它;否则调用本模块函数。"""
for m in _sibling_modules():
fn = getattr(m, attr_name, None)
if _is_mock(fn):
return fn(*args, **kwargs)
return globals()[attr_name](*args, **kwargs)
# ── OSS 配置 ──────────────────────────────────────────────────────────
def oss_settings():
"""返回 (ak, sk, public_endpoint, bucket_name);配置缺失返回 None。"""
from packages.config import get_shared_settings
s = get_shared_settings()
if not (s.oss_access_key_id and s.oss_access_key_secret and s.oss_endpoint and s.oss_bucket_name):
Returns:
(access_key_id, access_key_secret, endpoint, bucket_name) 元组,
配置缺失时返回 None。
"""
settings = get_shared_settings()
access_key_id = settings.oss_access_key_id
access_key_secret = settings.oss_access_key_secret
endpoint = settings.oss_endpoint
bucket_name = settings.oss_bucket_name
if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
return None
return (
s.oss_access_key_id,
s.oss_access_key_secret,
s.oss_endpoint,
s.oss_bucket_name,
)
return access_key_id, access_key_secret, endpoint, bucket_name
def _get_oss_settings_from_any_module():
"""从任一兄弟模块上取 oss_settings() 的返回值(mock 场景下兄弟模块上的
oss_settings 可能被 patch 成返回 None 或 tuple)。返回 None 表示所有模块
都返回 None(无配置);返回 tuple 表示有配置;返回 Mock 表示被 patch。"""
any_mock = False
for m in _sibling_modules():
fn = getattr(m, "oss_settings", None)
if not callable(fn):
continue
is_mock = _is_mock(fn)
if is_mock:
any_mock = True
try:
result = fn()
except Exception:
continue
if is_mock:
# 被 patch 的函数:返回值就是 mock 的 return_value
if result is None:
# patch(oss_settings, return_value=None) → 无配置场景
return None
return result # 可能是 tuple 或 Mock
if isinstance(result, tuple):
return result
if any_mock:
return None
return None
def oss_bucket() -> oss2.Bucket | None:
"""获取 OSS Bucket 实例。
P0-2 修复:endpoint 不带 scheme 时自动补 https:// 前缀,
确保 sign_url 等依赖 scheme 的方法返回 HTTPS URL。
def _legacy_path_active() -> bool:
"""是否走旧实现路径(兼容老单测 patch 路径,严格隔离不 fallback)。"""
# 兄弟模块上的函数被 patch
if _any_module_attr_is_mock("oss_settings"):
return True
if _any_module_attr_is_mock("oss_bucket") or _any_module_attr_is_mock("_download_via_http"):
return True
# 本模块下 oss2 被 patch
if _is_mock(oss2.Bucket) or _is_mock(oss2.Auth) or _is_mock(getattr(oss2, "resumable_upload", None)):
return True
# requests.get 被 patch
if _is_mock(requests) or _is_mock(requests.get):
return True
# 超时阈值被改成小值(老单测用 1s 做超时测试)
if OSS_UPLOAD_TOTAL_TIMEOUT <= 2:
return True
return False
P0-staging 修复:增加 connect_timeout=10s,防止网络抖动时
TCP 握手阶段无限挂死,导致 worker 进程卡死。
def _ensure_scheme(endpoint: str) -> str:
if endpoint.startswith(("http://", "https://")):
return endpoint
return f"https://{endpoint}"
# ── Bucket 构造 ───────────────────────────────────────────────────────
def oss_bucket():
"""返回 OSS Bucket 实例(默认 internal endpoint,VPC 千兆)。"""
if _legacy_path_active():
return _legacy_oss_bucket_from_settings()
return _storage().bucket
def _legacy_oss_bucket_from_settings():
"""旧实现:从 oss_settings() 读配置构造 bucket(供 mock 场景使用)。"""
settings = _get_oss_settings_from_any_module()
Returns:
oss2.Bucket 实例,配置缺失时返回 None。
"""
settings = oss_settings()
if settings is None:
return None
try:
access_key_id, access_key_secret, endpoint, bucket_name = settings
except Exception:
return None
if not isinstance(endpoint, str):
endpoint = str(endpoint)
endpoint = _ensure_scheme(endpoint)
access_key_id, access_key_secret, endpoint, bucket_name = settings
# endpoint 无 scheme 时补 https://,与 API 端 storage.py 保持一致
if not endpoint.startswith(("http://", "https://")):
endpoint = f"https://{endpoint}"
return oss2.Bucket(
oss2.Auth(access_key_id, access_key_secret),
endpoint,
@@ -194,211 +78,262 @@ def _legacy_oss_bucket_from_settings():
)
def public_bucket():
"""返回公网 endpoint bucket(仅用于 sign_url)。"""
return _storage().public_bucket
def normalize_storage_key(storage_key_or_url: str) -> str:
"""标准化存储键:URL 取 path + URL decode,开头斜杠去掉。"""
return _storage().normalize_storage_key(storage_key_or_url)
"""标准化存储键 — 如果是完整 URL 则提取 path 部分。
Examples:
"https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4"
→ "path/to/file.mp4"
"path/to/file.mp4" → "path/to/file.mp4"
"""
if storage_key_or_url.startswith(("http://", "https://")):
return urlparse(storage_key_or_url).path.lstrip("/")
return storage_key_or_url.lstrip("/")
# ── HTTP 下载(保留模块级函数方便 patch)─────────────────────────────
# ── 上传 / 下载 ───────────────────────────────────────────────────────────────
def download_asset(asset_storage_key: str, local_path: Path) -> bool:
"""从 OSS 下载素材文件到本地路径。
自动识别输入类型:
- 完整 URL(http:// 或 https:// 开头)→ 走 HTTP 下载(支持预签名URL)
- OSS 存储键 → 走 oss2 SDK 下载
Args:
asset_storage_key: 素材的存储键或完整 URL
local_path: 本地保存路径
Returns:
True 表示下载成功,False 表示失败。
"""
# 完整URL走HTTP下载(兼容预签名URL)
if asset_storage_key.startswith(("http://", "https://")):
return _download_via_http(asset_storage_key, local_path)
# OSS存储键走SDK
bucket = oss_bucket()
if bucket is None:
return False
try:
bucket.get_object_to_file(normalize_storage_key(asset_storage_key), str(local_path))
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("下载素材失败: %s", asset_storage_key)
return False
def _download_via_http(url: str, local_path: Path) -> bool:
"""通过 HTTP 下载文件(用 oss_helpers.requests,方便单测 patch)。"""
"""通过 HTTP 下载文件(支持预签名 URL)。
使用流式下载避免大文件内存溢出,超时 900s。
"""
try:
resp = requests.get(url, stream=True, timeout=OSS_HTTP_DOWNLOAD_TIMEOUT)
resp = requests.get(url, stream=True, timeout=900)
resp.raise_for_status()
os.makedirs(Path(local_path).parent, exist_ok=True)
with open(local_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024):
if chunk:
f.write(chunk)
return Path(local_path).exists() and Path(local_path).stat().st_size > 0
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("HTTP下载失败: %s", url[:100])
logger.exception("HTTP下载素材失败: %s", url)
return False
# ── 下载 / 上传 ───────────────────────────────────────────────────────
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公开 URL。
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
上传加总超时保护(默认 900s),防止网络异常时无限挂死。
def download_asset(asset_storage_key: str, local_path: Path) -> bool:
"""下载素材:HTTP URL 走本地 _download_via_http,OSS key 走 internal endpoint。"""
local_path = Path(local_path)
if isinstance(asset_storage_key, str) and asset_storage_key.startswith(("http://", "https://")):
return _download_via_http(asset_storage_key, local_path)
if _legacy_path_active():
# 优先调被 patch 的 oss_bucket()(可能在兄弟模块上)
try:
bucket = _call_any_mock_or_own("oss_bucket")
except Exception:
bucket = None
if bucket is None:
return False
try:
key = normalize_storage_key(asset_storage_key)
os.makedirs(local_path.parent, exist_ok=True)
bucket.get_object_to_file(key, str(local_path))
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("下载素材失败: %s", asset_storage_key[:80])
return False
return _storage().download_asset(asset_storage_key, local_path)
Args:
local_path: 本地文件路径(Path 或 str 均可)
storage_key: 目标存储键
def _legacy_upload_to_oss(local_path: Path, storage_key: str) -> str | None:
"""旧实现:put_object_from_file / resumable_upload 二选一 + 超时保护。"""
bucket = _legacy_oss_bucket_from_settings()
Returns:
公开访问 URL,上传失败或 OSS 未配置时返回 None。
"""
local_path = Path(local_path) # 统一转 Path,兼容 str 调用
bucket = oss_bucket()
if bucket is None:
return None
settings = _get_oss_settings_from_any_module()
if settings is None:
return None
try:
_, _, endpoint, bucket_name = settings
except Exception:
return None
endpoint = _ensure_scheme(endpoint) if isinstance(endpoint, str) else f"https://{endpoint}"
public_host = endpoint.split("://", 1)[1]
url = f"https://{bucket_name}.{public_host}/{storage_key.lstrip('/')}"
local_path = Path(local_path)
try:
file_size = local_path.stat().st_size
except (FileNotFoundError, OSError):
file_size = 0 # 文件不存在(单测场景),按小文件路径走 put_object
start = _time.monotonic()
result: dict = {"url": None, "error": None, "file_size": 0}
done = threading.Event()
def _timed_out() -> bool:
return (_time.monotonic() - start) > OSS_UPLOAD_TOTAL_TIMEOUT
def _do_upload():
try:
# 尝试获取文件大小,用于分片判断和日志;stat 失败时 fallback 走普通上传
try:
file_size = local_path.stat().st_size
result["file_size"] = file_size
use_multipart = file_size >= OSS_MULTIPART_THRESHOLD
except OSError:
use_multipart = False
file_size = 0
try:
if file_size < OSS_MULTIPART_THRESHOLD:
if _timed_out():
return None
bucket.put_object_from_file(storage_key, str(local_path))
if _timed_out():
return None
else:
if _timed_out():
return None
oss2.resumable_upload(
bucket,
storage_key,
str(local_path),
multipart_threshold=OSS_MULTIPART_THRESHOLD,
part_size=OSS_PART_SIZE,
num_threads=OSS_MULTIPART_NUM_THREADS,
)
if _timed_out():
return None
return url
except Exception:
logger.exception("上传OSS失败: %s", storage_key[:80])
if use_multipart:
# 分片上传:降低内存峰值,每片 8MB,3 线程并发
logger.info(
"大文件分片上传: storage_key=%s, size=%.1fMB, part_size=%dMB, threads=%d",
storage_key[:80],
file_size / 1024 / 1024,
OSS_PART_SIZE // 1024 // 1024,
OSS_MULTIPART_NUM_THREADS,
)
oss2.resumable_upload(
bucket,
storage_key,
str(local_path),
multipart_threshold=OSS_MULTIPART_THRESHOLD,
part_size=OSS_PART_SIZE,
num_threads=OSS_MULTIPART_NUM_THREADS,
)
else:
bucket.put_object_from_file(storage_key, str(local_path))
# 构造返回 URL
settings = oss_settings()
if settings:
_, _, endpoint, bucket_name = settings
endpoint_clean = endpoint.replace("https://", "").replace("http://", "")
result["url"] = f"https://{bucket_name}.{endpoint_clean}/{storage_key}"
except Exception as e:
result["error"] = e
logger.exception("上传 OSS 失败: %s", storage_key)
finally:
done.set()
upload_thread = threading.Thread(target=_do_upload, daemon=True)
upload_thread.start()
finished = done.wait(timeout=OSS_UPLOAD_TOTAL_TIMEOUT)
if not finished:
logger.error(
"OSS 上传超时(%.0fs),强制中止: storage_key=%s, size=%.1fMB",
OSS_UPLOAD_TOTAL_TIMEOUT,
storage_key[:80],
result["file_size"] / 1024 / 1024 if result["file_size"] else 0,
)
return None
if result["error"]:
return None
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公网 URL。"""
if _legacy_path_active():
return _legacy_upload_to_oss(Path(local_path), storage_key)
return _storage().upload_file_smart(local_path, storage_key)
return result["url"]
def get_signed_download_url(storage_key_or_url: str, expires_seconds: int = 3600) -> str | None:
"""生成预签名下载 URL(公网域名,外网可访问)。"""
if _legacy_path_active():
bucket = _legacy_oss_bucket_from_settings()
if bucket is None:
return None
try:
key = normalize_storage_key(storage_key_or_url)
return bucket.sign_url("GET", key, expires_seconds)
except Exception:
logger.exception("生成预签名URL失败: %s", storage_key_or_url[:80])
return None
s = _storage()
if s.public_bucket is None and s.bucket is None:
"""生成预签名下载 URL(用于私有 bucket 的 URL 校验或临时下载)。
Args:
storage_key_or_url: 存储键或完整 URL(URL 会自动提取 path)
expires_seconds: 签名有效期(秒)
Returns:
预签名 URL,失败或 OSS 未配置时返回 None。
"""
bucket = oss_bucket()
if bucket is None:
return None
try:
return s.get_download_url(storage_key_or_url, expires_seconds=expires_seconds)
storage_key = normalize_storage_key(storage_key_or_url)
signed = bucket.sign_url("GET", storage_key, expires_seconds)
logger.info("生成预签名URL: key=%s url_prefix=%s", storage_key[:80], signed[:60])
return signed
except Exception:
logger.exception("生成预签名URL失败: %s", storage_key_or_url[:80])
return None
# ── Asset 解析 ────────────────────────────────────────────────────────
# ── Asset 解析 ────────────────────────────────────────────────────────────────
def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
"""从 asset_id 解析到本地路径(缓存优先,否则 OSS 下载)。
"""从 asset_id 解析到本地文件路径。
在 wrapper 层实现缓存逻辑,方便老单测 patch(oss_helpers.download_asset)。
策略(按优先级):
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 安全校验后返回
2. 如果 work_dir 下已有缓存文件 → 返回缓存路径
3. 从 OSS 下载到 work_dir/{hash}.mp4 → 返回下载路径
4. 下载失败 → 返回 None
缓存策略:以 asset_id 的 SHA256 前 16 位为文件名,避免重复下载。
安全:
- 本地绝对路径必须在 ASSET_ALLOWED_DIRS 环境变量指定的目录内
- 文件名经过 sanitize,防止路径遍历
- 禁止空字节、控制字符
"""
from video_processing.path_security import (
PathSecurityError,
get_allowed_local_dirs,
is_in_allowed_dirs,
sanitize_filename,
)
if not asset_id or not isinstance(asset_id, str):
return None
# 空字节检测
if "\x00" in asset_id:
logger.warning("asset_id 包含空字节,拒绝: %s", asset_id[:50])
return None
work_dir = Path(work_dir)
os.makedirs(work_dir, exist_ok=True)
if asset_id.startswith("/") or ".." in Path(asset_id).parts:
logger.warning("非法 asset_id: %s", asset_id)
return None
# 1. 本地绝对路径 — 必须在允许的目录内
if asset_id.startswith("/") and os.path.exists(asset_id):
try:
resolved = Path(asset_id).resolve()
if is_in_allowed_dirs(resolved, get_allowed_local_dirs()):
return resolved
else:
logger.warning(
"本地素材路径不在允许目录内,拒绝: %s (allowed=%s)",
asset_id[:80],
get_allowed_local_dirs(),
)
return None
except (OSError, PathSecurityError):
return None
# 2. 缓存命中(使用 hash 而非原始 ID,防止路径遍历)
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
local_path = work_dir / f"{cache_hash}.mp4"
safe_name = sanitize_filename(cache_hash)
cached_path = work_dir / f"{safe_name}.mp4"
if cached_path.exists() and cached_path.stat().st_size > 0:
return cached_path
if local_path.exists() and local_path.stat().st_size > 0:
return local_path
# 3. 从 OSS 下载(先标准化 key,防止路径遍历注入)
safe_key = normalize_storage_key(asset_id)
# 额外校验:存储键不能包含 ../ 或绝对路径
if ".." in safe_key or safe_key.startswith("/"):
logger.warning("asset_id 包含路径遍历模式,拒绝下载: %s", asset_id[:80])
return None
if download_asset(safe_key, cached_path):
return cached_path
try:
ok = download_asset(asset_id, local_path)
if ok and local_path.exists() and local_path.stat().st_size > 0:
return local_path
except Exception:
logger.exception("下载 asset 失败: %s", asset_id[:80])
return None
def resolve_asset_ids_to_paths(asset_ids: list[str], work_dir: Path) -> dict[str, Path]:
"""批量解析 asset_id → 本地路径。"""
def resolve_asset_ids_to_paths(
asset_ids: list[str],
work_dir: Path,
) -> dict[str, Path]:
"""批量解析 asset_id → 本地路径。
Args:
asset_ids: 素材 ID 列表
work_dir: 工作目录
Returns:
{asset_id: local_path} 映射,仅包含成功解析的条目。
"""
result: dict[str, Path] = {}
for aid in asset_ids:
p = resolve_asset_path(aid, work_dir)
if p is not None:
result[aid] = p
local_path = resolve_asset_path(aid, work_dir)
if local_path:
result[aid] = local_path
return result
def delete_from_oss(storage_key_or_url: str) -> bool:
"""从 OSS 删除对象(best-effort,internal endpoint)。"""
s = _storage()
if s.bucket is None:
return False
try:
key = normalize_storage_key(storage_key_or_url)
s.delete_file(key)
return True
except Exception:
logger.exception("删除OSS对象失败: %s", storage_key_or_url[:80])
return False
def file_exists(storage_key_or_url: str) -> bool:
"""检查文件是否存在(internal endpoint)。"""
s = _storage()
if s.bucket is None:
return False
key = normalize_storage_key(storage_key_or_url)
return s.file_exists(key)
def get_public_url(storage_key: str) -> str:
"""返回公网 URL(不带签名)。"""
return _storage().get_url(storage_key)
+13 -75
View File
@@ -86,7 +86,6 @@ class RenderAdapterResult:
None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
)
temp_dir: str | None = None # 渲染临时目录,成功时由调用方清理,失败时由 finally 清理
edge_crop_applied: bool = False # GPU 管线已做随机边缘裁剪(跳过 CPU 二次重编码)
def __post_init__(self):
if self.rendered_clip_ids is None:
@@ -132,7 +131,6 @@ class RenderAdapter:
work_dir: Path | None = None,
progress_cb: ProgressCallback | None = None,
voiceover_audio_path: str | None = None,
task_config_override: dict | None = None, # Bug A: task 级 config 覆盖,防并发竞态
) -> RenderAdapterResult:
"""渲染一个 EditPlan。
@@ -191,9 +189,7 @@ class RenderAdapter:
self._report_progress(progress_cb, 15.0, f"下载素材({len(ready_clips)} 个)")
# 2. 下载素材
asset_path_map, rendered_clip_ids, failed_clip_ids, asset_storage_map = self._download_assets(
ready_clips, work_dir
)
asset_path_map, rendered_clip_ids, failed_clip_ids = self._download_assets(ready_clips, work_dir)
if not asset_path_map:
return RenderAdapterResult(
success=False,
@@ -210,7 +206,6 @@ class RenderAdapter:
plan=plan,
clips=ready_clips,
asset_path_map=asset_path_map,
asset_storage_map=asset_storage_map,
work_dir=work_dir,
plan_id=plan_id,
job_id=job_id,
@@ -218,7 +213,6 @@ class RenderAdapter:
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
voiceover_audio_path=voiceover_audio_path,
task_config_override=task_config_override,
)
# 成功时将临时目录所有权转移给调用方,阻止 finally 清理
if result.success and temp_dir:
@@ -321,7 +315,7 @@ class RenderAdapter:
def _download_assets(
self, clips: list[EditPlanClip], work_dir: Path
) -> tuple[dict[str, Path], list[str], list[str], dict[str, str]]:
) -> tuple[dict[str, Path], list[str], list[str]]:
"""下载片段素材到本地。
先通过 asset_id 批量查询 assets 表获取 file_url(OSS存储路径),
@@ -392,9 +386,9 @@ class RenderAdapter:
failed_clip_ids.append(clip.id)
logger.warning("素材下载失败: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
return asset_path_map, rendered_clip_ids, failed_clip_ids, asset_storage_map
return asset_path_map, rendered_clip_ids, failed_clip_ids
def _prepare_bgm(self, plan, work_dir: Path, plan_id: str, *, bgm_override: dict | None = None) -> str | None:
def _prepare_bgm(self, plan, work_dir: Path, plan_id: str) -> str | None:
"""准备 BGM 音频文件(从 plan.config.bgm 读取配置)。
支持 3 种来源(按优先级):
@@ -407,9 +401,7 @@ class RenderAdapter:
from urllib.parse import urlparse
plan_config = plan.config or {}
bgm_config = dict(plan_config.get("bgm", {}) or {})
if isinstance(bgm_override, dict) and bgm_override:
bgm_config.update(bgm_override) # Bug A: 任务级 BGM 覆盖,防并发竞态
bgm_config = plan_config.get("bgm", {}) or {}
if not bgm_config.get("enabled", False):
return None
@@ -465,27 +457,13 @@ class RenderAdapter:
from packages.domain.preset_bgm import get_preset_bgm
preset = get_preset_bgm(preset_id)
if preset is None:
logger.warning("[plan_id=%s] [BGM] 预设BGM不存在: preset_id=%s", plan_id, preset_id)
elif not preset.audio_url:
logger.warning(
"[plan_id=%s] [BGM] 预设BGM未部署音频文件: preset_id=%s name=%s(audio_url 为空,请运维上传音频后填入 preset_bgm.py)",
plan_id,
preset_id,
preset.name,
)
else:
if preset and preset.audio_url:
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info(
"[plan_id=%s] [BGM] 从预设库下载: preset_id=%s url=%s",
plan_id,
preset_id,
preset.audio_url[:80],
)
logger.info("[plan_id=%s] [BGM] 从预设库下载: preset_id=%s", plan_id, preset_id)
safe_download_file(
preset.audio_url,
str(bgm_file),
@@ -498,14 +476,7 @@ class RenderAdapter:
except Exception as e:
logger.warning("[plan_id=%s] [BGM] 预设库下载失败: %s", plan_id, e)
logger.warning(
"[plan_id=%s] [BGM] 所有来源都无法获取BGM(enabled=%s audio_url=%s asset_id=%s preset_id=%s),跳过",
plan_id,
bool(bgm_config.get("enabled")),
"set" if audio_url else "empty",
asset_id[:12] + "…" if len(asset_id) > 12 else asset_id or "empty",
preset_id or "empty",
)
logger.warning("[plan_id=%s] [BGM] 所有来源都无法获取BGM,跳过", plan_id)
return None
@staticmethod
@@ -570,8 +541,6 @@ class RenderAdapter:
rendered_clip_ids: list[str] | None = None,
failed_clip_ids: list[str] | None = None,
voiceover_audio_path: str | None = None,
asset_storage_map: dict[str, str] | None = None,
task_config_override: dict | None = None, # Bug A: task 级 config 覆盖,防并发竞态
) -> RenderAdapterResult:
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
@@ -585,9 +554,8 @@ class RenderAdapter:
Returns:
RenderAdapterResult
"""
# 1. 准备 BGM(Bug A: 传 task 级 bgm override)
_bgm_override = (task_config_override or {}).get("bgm") if isinstance(task_config_override, dict) else None
bgm_path = self._prepare_bgm(plan, work_dir, plan_id, bgm_override=_bgm_override)
# 1. 准备 BGM
bgm_path = self._prepare_bgm(plan, work_dir, plan_id)
self._report_progress(progress_cb, 40.0, "执行视频渲染")
@@ -595,10 +563,8 @@ class RenderAdapter:
plan_config = plan.config or {}
asr_service = self._get_asr_service()
# 3. 读取输出分辨率(Bug A: task override 优先)
export_config = dict(plan_config.get("export", {}) or {})
if isinstance(task_config_override, dict) and isinstance(task_config_override.get("export"), dict):
export_config.update(task_config_override["export"])
# 3. 读取输出分辨率
export_config = plan_config.get("export", {}) or {}
if not isinstance(export_config, dict):
export_config = {}
output_width, output_height = _parse_resolution(export_config.get("resolution"))
@@ -623,22 +589,9 @@ class RenderAdapter:
asr_service=asr_service,
voiceover_audio_path=voiceover_audio_path,
clip_has_text=clip_has_text,
override_config=task_config_override,
)
# 注入每个视频段对应素材的 storage_key,供全 GPU 直连管线直接签名下载
_storage_map = asset_storage_map or {}
for c in clips:
sk = _storage_map.get(getattr(c, "asset_id", ""))
if sk:
# EditPlanClip 使用 __slots__,不能 setattr,改存 config 字典
if not isinstance(c.config, dict):
c.config = dict(c.config) if c.config else {}
c.config["_storage_key"] = sk
result = render_svc.render()
# 4.4 透传 GPU 直连路径的 edge_crop 状态(供外层跳过 CPU 二次裁剪)
edge_crop_applied_flag = bool(getattr(result, "edge_crop_applied", False))
# 4.5 渲染后校验输出完整性
validation = validate_video_output(result.output_path)
if not validation.valid:
@@ -684,22 +637,8 @@ class RenderAdapter:
# 已渲染视频在统一渲染阶段已通过 ASS 字幕把标题烧录进画面,
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
# 构造clip分段边界 [(start, duration), ...] 供封面抽帧智能取各段中点
try:
_clip_boundaries = [
(float(getattr(c, "start_time", 0.0) or 0.0), float(getattr(c, "duration", 0.0) or 0.0))
for c in clips
if float(getattr(c, "duration", 0.0) or 0.0) > 0
]
except Exception:
_clip_boundaries = None
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path),
plan_id,
task_id=job_id,
num_frames=5,
title_text="",
clip_boundaries=_clip_boundaries,
str(result.output_path), plan_id, task_id=job_id, num_frames=5, title_text=""
)
if cover_candidates:
logger.info(
@@ -746,7 +685,6 @@ class RenderAdapter:
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
cover_candidates=cover_candidates,
edge_crop_applied=edge_crop_applied_flag,
)
def render_from_memory(

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