Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2332b5ef98 |
+2
-1
@@ -1 +1,2 @@
|
||||
trigger staging deploy for 3fa1fbf0 (MOCK_STORYBOARD fix) at 2026-10-12 13:40:16
|
||||
CI trigger file - safe to delete
|
||||
retrigger at 2026-09-15 20:31:24 UTC
|
||||
|
||||
+1
-15
@@ -211,24 +211,10 @@ COSYVOICE_CLONE_MODEL=voice-enrollment
|
||||
# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景
|
||||
|
||||
DOUBAO_API_KEY=your-doubao-api-key
|
||||
DOUBAO_MODEL=doubao-seed-2-1-pro-260915
|
||||
DOUBAO_FAST_MODEL=doubao-seed-2-1-lite-260915
|
||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
# 视觉模型:pro 精度高,lite 速度快(viral-video 商品识别默认用 lite 提速)
|
||||
DOUBAO_VISION_MODEL=doubao-seed-2-1-pro-260915
|
||||
DOUBAO_VISION_LITE_MODEL=doubao-seed-2-1-lite-260915
|
||||
DOUBAO_VISION_USE_LITE=true
|
||||
# Embedding 向量化模型
|
||||
DOUBAO_EMBEDDING_MODEL=doubao-embedding-vision-251215
|
||||
# 视频模型(Seedance 2.5,统一走方舟;真人参考图通过信任链自动 AI 化)
|
||||
DOUBAO_VIDEO_MODEL=doubao-seedance-2-5-260628
|
||||
DOUBAO_VIDEO_TIMEOUT=480
|
||||
DOUBAO_VIDEO_POLL_INTERVAL=10
|
||||
# 图片模型(Seedream 5.0 Pro,用于信任链真人 AI 化 + 文生图)
|
||||
DOUBAO_IMAGE_MODEL=doubao-seedream-5-0-pro-260628
|
||||
DOUBAO_IMAGE_TIMEOUT=120
|
||||
|
||||
# ==================== 积分/会员系统 (#1895) ====================
|
||||
# 积分系统总开关:默认 false(暂停积分系统)。
|
||||
|
||||
@@ -437,7 +437,7 @@ jobs:
|
||||
if: "always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'"
|
||||
name: Unit Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
@@ -1187,15 +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 }}"
|
||||
DITTO_EMOTION_TIMEOUT: "${{ secrets.DITTO_EMOTION_TIMEOUT }}"
|
||||
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
|
||||
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
|
||||
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
|
||||
|
||||
@@ -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,51 +0,0 @@
|
||||
"""viral video add copy_result + voice/video columns
|
||||
|
||||
Revision ID: 088_viral_video_copy_result
|
||||
Revises: 087_viral_video_image_analysis
|
||||
Create Date: 2026-10-01
|
||||
|
||||
v1.6 爆款视频字段补齐:
|
||||
- copy_result JSON: 编导分镜脚本完整结构(overview/scene_and_lighting/shots/hard_constraints/negative_prompts/voiceover_script)
|
||||
- voice_id/voice_source: TTS 音色参数
|
||||
- video_ratio/video_model: Seedance 视频比例/模型
|
||||
注意:线上启动也有幂等 ADD COLUMN 补列逻辑 (_ensure_viral_video_columns),本 migration 提供标准 Alembic 路径,
|
||||
两套机制互不冲突(IF NOT EXISTS 等价行为)。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "088_viral_video_copy_result"
|
||||
down_revision = "087_viral_video_image_analysis"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 幂等添加列(通过单独执行 + 异常忽略兼容已由 backfill 补上的环境)
|
||||
cols = [
|
||||
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
|
||||
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
|
||||
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
|
||||
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
("copy_result", "JSON"),
|
||||
]
|
||||
conn = op.get_bind()
|
||||
for name, ddl in cols:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
|
||||
except Exception:
|
||||
# 不支持 IF NOT EXISTS 的库(如老版本 SQLite)直接尝试 ADD COLUMN,失败则忽略
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in ("copy_result", "video_model", "video_ratio", "voice_source", "voice_id"):
|
||||
try:
|
||||
op.drop_column("viral_video_jobs", name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,62 +0,0 @@
|
||||
"""viral video add storyboard + generated_copy_text (complement 088)
|
||||
|
||||
Revision ID: 089_viral_video_cols
|
||||
Revises: 088_viral_video_copy_result
|
||||
Create Date: 2026-10-01
|
||||
|
||||
#2129 兜底迁移:补齐 _VIRAL_VIDEO_BACKFILL_COLS 中所有列,覆盖
|
||||
# watchtower 自动部署未跑历史 migration、且 AUTO_CREATE_SCHEMA=false 时
|
||||
# _ensure_viral_video_columns 未执行的场景。
|
||||
# 幂等 ADD COLUMN IF NOT EXISTS,已存在则跳过。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "089_viral_video_cols"
|
||||
down_revision = "088_viral_video_copy_result"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 扩展 alembic_version.version_num 字段长度(原来 VARCHAR(32) 装不下长 revision id)
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
conn.execute(sa.text("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(256)"))
|
||||
except Exception:
|
||||
pass
|
||||
cols = [
|
||||
("storyboard", "JSON"),
|
||||
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
|
||||
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
|
||||
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
|
||||
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
|
||||
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
("copy_result", "JSON"),
|
||||
]
|
||||
for name, ddl in cols:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in (
|
||||
"copy_result",
|
||||
"video_model",
|
||||
"video_ratio",
|
||||
"voice_source",
|
||||
"voice_id",
|
||||
"generated_copy_text",
|
||||
"storyboard",
|
||||
):
|
||||
try:
|
||||
op.drop_column("viral_video_jobs", name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,35 +0,0 @@
|
||||
"""viral video add phase_message column (#2134)
|
||||
|
||||
Revision ID: 090_viral_video_phase_msg
|
||||
Revises: 089_viral_video_cols
|
||||
Create Date: 2026-10-02
|
||||
|
||||
#2134 阶段细粒度提示:viral_video 表新增 phase_message 列(中文阶段提示文案)。
|
||||
current_stage 列已在之前版本存在,本迁移只补 phase_message。
|
||||
幂等 ADD COLUMN IF NOT EXISTS。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "090_viral_video_phase_msg"
|
||||
down_revision = "089_viral_video_cols"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite/PostgreSQL 兼容的幂等添加列
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "phase_message" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("phase_message", sa.String(length=500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("viral_video_jobs", "phase_message")
|
||||
@@ -1,49 +0,0 @@
|
||||
"""viral video add current_stage column (#2137 follow-up)
|
||||
|
||||
Revision ID: 091_viral_video_stage
|
||||
Revises: 090_viral_video_phase_msg
|
||||
Create Date: 2026-10-02
|
||||
|
||||
#2137 follow-up fix: 090 migration missed current_stage column on viral_video_jobs,
|
||||
causing UndefinedColumn errors and 500s on all authenticated viral-video endpoints.
|
||||
Idempotently add current_stage and double-check phase_message.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "091_viral_video_stage"
|
||||
down_revision = "090_viral_video_phase_msg"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "current_stage" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column(
|
||||
"current_stage",
|
||||
sa.String(length=200),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
if "phase_message" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column(
|
||||
"phase_message",
|
||||
sa.String(length=500),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("viral_video_jobs", "current_stage")
|
||||
@@ -1,42 +0,0 @@
|
||||
"""viral_video_jobs 增加 heartbeat_at 列(worker 心跳,用于僵尸任务超时回收)
|
||||
|
||||
Revision ID: 092_viral_video_heartbeat
|
||||
Revises: 091_viral_video_stage
|
||||
Create Date: 2026-10-02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "092_viral_video_heartbeat"
|
||||
down_revision = "091_viral_video_stage"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "heartbeat_at" not in cols:
|
||||
op.add_column("viral_video_jobs", sa.Column("heartbeat_at", sa.DateTime(), nullable=True))
|
||||
op.execute(
|
||||
"UPDATE viral_video_jobs SET heartbeat_at = updated_at " "WHERE status = 'running' AND heartbeat_at IS NULL"
|
||||
)
|
||||
try:
|
||||
op.create_index("ix_viral_video_jobs_heartbeat_at", "viral_video_jobs", ["heartbeat_at"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "heartbeat_at" in cols:
|
||||
try:
|
||||
op.drop_index("ix_viral_video_jobs_heartbeat_at", table_name="viral_video_jobs")
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_column("viral_video_jobs", "heartbeat_at")
|
||||
@@ -1,87 +0,0 @@
|
||||
"""viral_video 动态积分定价 + 积分字段从 Integer 改为 Float (#2151)
|
||||
|
||||
Revision ID: 093
|
||||
Revises: 092_viral_video_heartbeat
|
||||
Create Date: 2026-10-02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "093"
|
||||
down_revision = "092_viral_video_heartbeat"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# 1) points_accounts 三列 Integer -> Float
|
||||
pa_cols = {c["name"]: c for c in inspector.get_columns("points_accounts")}
|
||||
for col in ("balance", "total_earned", "total_spent"):
|
||||
if col in pa_cols:
|
||||
op.alter_column(
|
||||
"points_accounts",
|
||||
col,
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# 2) points_transactions amount/balance_after Integer -> Float
|
||||
pt_cols = {c["name"]: c for c in inspector.get_columns("points_transactions")}
|
||||
for col in ("amount", "balance_after"):
|
||||
if col in pt_cols:
|
||||
op.alter_column(
|
||||
"points_transactions",
|
||||
col,
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# 3) users.points_balance Integer -> Float
|
||||
user_cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||
if "points_balance" in user_cols:
|
||||
op.alter_column(
|
||||
"users",
|
||||
"points_balance",
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# 4) viral_video_jobs.credits_cost Integer -> Float
|
||||
vv_cols = {c["name"]: c for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "credits_cost" in vv_cols:
|
||||
op.alter_column(
|
||||
"viral_video_jobs",
|
||||
"credits_cost",
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# 5) viral_video_jobs 新增列
|
||||
if "video_resolution" not in vv_cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("video_resolution", sa.String(20), nullable=False, server_default="720p"),
|
||||
)
|
||||
if "credits_prepaid" not in vv_cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("credits_prepaid", sa.Float(), nullable=False, server_default="0"),
|
||||
)
|
||||
if "credits_transaction_id" not in vv_cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("credits_transaction_id", sa.String(36), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,31 +0,0 @@
|
||||
"""viral_video_jobs 增加 pre_trusted_images 列(信任链Seedream预热结果)
|
||||
|
||||
Revision ID: 094_viral_video_pre_trusted
|
||||
Revises: 093_viral_video_pricing_points_float
|
||||
Create Date: 2026-10-04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "094_viral_video_pre_trusted"
|
||||
down_revision = "093"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "pre_trusted_images" not in cols:
|
||||
op.add_column("viral_video_jobs", sa.Column("pre_trusted_images", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "pre_trusted_images" in cols:
|
||||
op.drop_column("viral_video_jobs", "pre_trusted_images")
|
||||
@@ -1,102 +0,0 @@
|
||||
"""爆款视频 Prompt 模板配置表(#2040)。
|
||||
|
||||
086 曾预留同名旧表(id varchar / content / variables json),从未被业务使用;
|
||||
本迁移将其替换为 #2040 新结构。
|
||||
|
||||
Revision ID: 095_viral_video_prompt_templates
|
||||
Revises: 094_viral_video_pre_trusted
|
||||
Create Date: 2026-10-04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "095_viral_video_prompt_templates"
|
||||
down_revision = "094_viral_video_pre_trusted"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, name: str) -> bool:
|
||||
return name in sa.inspect(conn).get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# 086 预留的旧结构表:先删除(无业务数据、无任何引用)
|
||||
if _table_exists(conn, "viral_video_prompt_templates"):
|
||||
op.drop_table("viral_video_prompt_templates")
|
||||
|
||||
op.create_table(
|
||||
"viral_video_prompt_templates",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("prompt_type", sa.String(32), nullable=False),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("system_prompt", sa.Text, nullable=False),
|
||||
sa.Column("user_prompt_template", sa.Text, nullable=False),
|
||||
sa.Column("example_output", sa.Text, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_vvpt_type_active",
|
||||
"viral_video_prompt_templates",
|
||||
["prompt_type", "is_active"],
|
||||
)
|
||||
op.create_index(
|
||||
"uq_vvpt_type_version",
|
||||
"viral_video_prompt_templates",
|
||||
["prompt_type", "version"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _table_exists(conn, "viral_video_prompt_templates"):
|
||||
op.drop_index("uq_vvpt_type_version", table_name="viral_video_prompt_templates")
|
||||
op.drop_index("ix_vvpt_type_active", table_name="viral_video_prompt_templates")
|
||||
op.drop_table("viral_video_prompt_templates")
|
||||
|
||||
# 恢复 086 的旧预留结构
|
||||
op.create_table(
|
||||
"viral_video_prompt_templates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("prompt_type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("variables", sa.JSON, nullable=False, server_default="[]"),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("true"),
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""功能计费积分字段(爆款/对口型/智能剪辑 DB 化计费)。
|
||||
|
||||
给 gpu_lipsync_tasks / generation_tasks / lipsync_jobs 三张表加积分字段:
|
||||
- credits_prepaid: 提交任务时预扣积分
|
||||
- credits_cost: 最终结算积分
|
||||
- credits_transaction_id: 预扣流水 ID
|
||||
|
||||
注意:feature_pricing_configs 配置表由 xiaoxia-admin 侧 migration 建立,
|
||||
本仓库只读,不在此创建。
|
||||
|
||||
Revision ID: 096_feature_billing_fields
|
||||
Revises: 095_viral_video_prompt_templates
|
||||
Create Date: 2026-10-05
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "096_feature_billing_fields"
|
||||
down_revision = "095_viral_video_prompt_templates"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLES = ("gpu_lipsync_tasks", "generation_tasks", "lipsync_jobs")
|
||||
_COLUMNS = (
|
||||
("credits_prepaid", sa.Float(), "0"),
|
||||
("credits_cost", sa.Float(), "0"),
|
||||
("credits_transaction_id", sa.String(36), ""),
|
||||
)
|
||||
|
||||
|
||||
def _table_exists(conn, name: str) -> bool:
|
||||
return name in sa.inspect(conn).get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in _TABLES:
|
||||
if not _table_exists(conn, table):
|
||||
continue
|
||||
existing = {c["name"] for c in sa.inspect(conn).get_columns(table)}
|
||||
for col_name, col_type, default in _COLUMNS:
|
||||
if col_name in existing:
|
||||
continue
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column(col_name, col_type, nullable=False, server_default=default),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in _TABLES:
|
||||
if not _table_exists(conn, table):
|
||||
continue
|
||||
existing = {c["name"] for c in sa.inspect(conn).get_columns(table)}
|
||||
for col_name, _col_type, _default in _COLUMNS:
|
||||
if col_name not in existing:
|
||||
continue
|
||||
op.drop_column(table, col_name)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,222 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""099: AI 模型路由层 seed — 补齐缺失模型和能力配置.
|
||||
|
||||
幂等:所有 INSERT 先检查存在性。
|
||||
- ai_models: 补齐 qwen3.7-plus, seedream, seedance, embedding, wan3.0 等
|
||||
- ai_capability_configs: 补齐 image_generation, video_generation, embedding
|
||||
- 更新已有 capability 的 lite_model_id
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "099_ai_model_router_seed"
|
||||
down_revision = "098_viral_video_image_analysis_v5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# CI 环境下 ai_models 表可能尚未创建(由 ORM 自动建表,非 migration)
|
||||
# 如果表不存在则跳过 seed,由应用启动时 ORM 建表后首次访问时生效
|
||||
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
|
||||
if not table_check:
|
||||
# ai_models 表不存在,跳过所有 seed(CI 环境)
|
||||
return
|
||||
|
||||
# ── 1. 补齐 ai_models 缺失记录 ────────────────────────────────────────────
|
||||
existing_models = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
sa.text("SELECT model_key FROM ai_models WHERE deleted_at IS NULL")
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
# 从已有 active 记录获取 API key(复用,不硬编码)
|
||||
dashscope_key_row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT api_key FROM ai_models WHERE provider='dashscope' AND deleted_at IS NULL AND api_key IS NOT NULL AND api_key != '' LIMIT 1"
|
||||
)
|
||||
).first()
|
||||
dashscope_key = dashscope_key_row[0] if dashscope_key_row else ""
|
||||
|
||||
volcengine_key_row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT api_key FROM ai_models WHERE provider='volcengine' AND deleted_at IS NULL AND api_key IS NOT NULL AND api_key != '' LIMIT 1"
|
||||
)
|
||||
).first()
|
||||
volcengine_key = volcengine_key_row[0] if volcengine_key_row else ""
|
||||
|
||||
new_models = [
|
||||
{
|
||||
"model_key": "qwen3.7-plus",
|
||||
"name": "通义千问3.7 Plus(VLM 兜底)",
|
||||
"provider": "dashscope",
|
||||
"api_key": dashscope_key,
|
||||
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"description": "阿里云百炼 Qwen3.7 Plus 多模态模型,用于 VLM 兜底分析",
|
||||
},
|
||||
{
|
||||
"model_key": "doubao-seedream-5-0-flash-260915",
|
||||
"name": "Seedream 5.0 Flash(图片生成)",
|
||||
"provider": "volcengine",
|
||||
"api_key": volcengine_key,
|
||||
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"description": "火山引擎 Seedream 5.0 Flash 文生图模型",
|
||||
},
|
||||
{
|
||||
"model_key": "doubao-seedance-2-5-260628",
|
||||
"name": "Seedance 2.5(视频生成)",
|
||||
"provider": "volcengine",
|
||||
"api_key": volcengine_key,
|
||||
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"description": "火山引擎 Seedance 2.5 图/文生视频模型",
|
||||
},
|
||||
{
|
||||
"model_key": "doubao-embedding-vision-251215",
|
||||
"name": "豆包多模态向量嵌入",
|
||||
"provider": "volcengine",
|
||||
"api_key": volcengine_key,
|
||||
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"description": "火山引擎豆包多模态向量嵌入模型",
|
||||
},
|
||||
{
|
||||
"model_key": "wan3.0-video",
|
||||
"name": "Wan 3.0 视频生成",
|
||||
"provider": "dashscope",
|
||||
"api_key": dashscope_key,
|
||||
"api_base": "https://dashscope.aliyuncs.com/api/v1",
|
||||
"description": "阿里云百炼 Wan 3.0 视频生成模型",
|
||||
},
|
||||
{
|
||||
"model_key": "doubao-seed-2-1-pro-260915",
|
||||
"name": "豆包 Seed 2.1 Pro(高精度推理)",
|
||||
"provider": "volcengine",
|
||||
"api_key": volcengine_key,
|
||||
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"description": "火山引擎豆包 Seed 2.1 Pro 深度思考+多模态",
|
||||
},
|
||||
]
|
||||
|
||||
for m in new_models:
|
||||
if m["model_key"] not in existing_models:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO ai_models (id, name, provider, model_key, api_key, api_base, description, status, is_default, usage_today, created_at, updated_at)
|
||||
VALUES (gen_random_uuid()::text, :name, :provider, :model_key, :api_key, :api_base, :description, 'active', false, 0, now(), now())
|
||||
"""
|
||||
),
|
||||
m,
|
||||
)
|
||||
|
||||
# ── 2. 补齐 ai_capability_configs 缺失项 ──────────────────────────────────
|
||||
cap_table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
|
||||
if not cap_table_check:
|
||||
return
|
||||
|
||||
existing_caps = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
sa.text("SELECT capability_key FROM ai_capability_configs")
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
def _get_model_id(model_key: str) -> str | None:
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id FROM ai_models WHERE model_key = :key AND deleted_at IS NULL AND status = 'active' LIMIT 1"
|
||||
),
|
||||
{"key": model_key},
|
||||
).first()
|
||||
return row[0] if row else None
|
||||
|
||||
# image_generation
|
||||
if "image_generation" not in existing_caps:
|
||||
mid = _get_model_id("doubao-seedream-5-0-flash-260915")
|
||||
if mid:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
|
||||
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, 60, 1, 2, :ep, true, now(), now())
|
||||
"""
|
||||
),
|
||||
{
|
||||
"ck": "image_generation",
|
||||
"cn": "图片生成(Seedream)",
|
||||
"pm": mid,
|
||||
"ep": json.dumps({"size": "1K"}),
|
||||
},
|
||||
)
|
||||
|
||||
# video_generation
|
||||
if "video_generation" not in existing_caps:
|
||||
mid = _get_model_id("doubao-seedance-2-5-260628")
|
||||
fb_mid = _get_model_id("wan3.0-video")
|
||||
if mid:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, fallback_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
|
||||
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, :fm, 600, 1, 1, :ep, true, now(), now())
|
||||
"""
|
||||
),
|
||||
{
|
||||
"ck": "video_generation",
|
||||
"cn": "视频生成(Seedance/Wan)",
|
||||
"pm": mid,
|
||||
"fm": fb_mid,
|
||||
"ep": json.dumps({}),
|
||||
},
|
||||
)
|
||||
|
||||
# embedding
|
||||
if "embedding" not in existing_caps:
|
||||
mid = _get_model_id("doubao-embedding-vision-251215")
|
||||
if mid:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
|
||||
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, 30, 2, 5, :ep, true, now(), now())
|
||||
"""
|
||||
),
|
||||
{
|
||||
"ck": "embedding",
|
||||
"cn": "向量嵌入",
|
||||
"pm": mid,
|
||||
"ep": json.dumps({}),
|
||||
},
|
||||
)
|
||||
|
||||
# ── 3. 更新 image_analysis 的 lite_model_id ─────────────────────────────
|
||||
lite_model_id = _get_model_id("qwen3.8-flash")
|
||||
if lite_model_id:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE ai_capability_configs SET lite_model_id = :lite WHERE capability_key = 'image_analysis' AND lite_model_id IS NULL"
|
||||
),
|
||||
{"lite": lite_model_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# 安全检查表是否存在
|
||||
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
|
||||
if not table_check:
|
||||
return
|
||||
conn.execute(
|
||||
sa.text("DELETE FROM ai_capability_configs WHERE capability_key IN ('image_generation', 'video_generation', 'embedding')")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"DELETE FROM ai_models WHERE model_key IN ('qwen3.7-plus', 'doubao-seedream-5-0-flash-260915', 'doubao-seedance-2-5-260628', 'doubao-embedding-vision-251215', 'wan3.0-video', 'doubao-seed-2-1-pro-260915') AND deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""100: 修正已有 capability 的模型绑定.
|
||||
|
||||
幂等:仅当 primary_model_id 当前绑定到旧模型 (doubao-seed-1-6) 时才更新,
|
||||
避免覆盖用户在后台的自定义配置。
|
||||
|
||||
- 更新 5 个 LLM capability (intent_parsing, copy_fusion, storyboard, copy_review, asset_classify)
|
||||
的 primary_model_id 从 doubao-seed-1-6 改为 doubao-seed-2-1-pro-260915
|
||||
- 更新 image_analysis 的 primary/lite/fallback 模型绑定
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "100_fix_capability_model_bindings"
|
||||
down_revision = "099_ai_model_router_seed"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Check tables exist
|
||||
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
|
||||
if not table_check:
|
||||
return
|
||||
|
||||
config_table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
|
||||
if not config_table_check:
|
||||
return
|
||||
|
||||
# Look up model IDs by model_key (not hardcoded UUIDs)
|
||||
pro_model_row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id FROM ai_models WHERE model_key = 'doubao-seed-2-1-pro-260915' AND deleted_at IS NULL LIMIT 1"
|
||||
)
|
||||
).first()
|
||||
if not pro_model_row:
|
||||
return
|
||||
pro_model_id = pro_model_row[0]
|
||||
|
||||
old_model_row = conn.execute(
|
||||
sa.text("SELECT id FROM ai_models WHERE model_key = 'doubao-seed-1-6-250615' LIMIT 1")
|
||||
).first()
|
||||
old_model_id = old_model_row[0] if old_model_row else None
|
||||
|
||||
llm_capabilities = [
|
||||
"intent_parsing",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"copy_review",
|
||||
"asset_classify",
|
||||
]
|
||||
|
||||
for cap_key in llm_capabilities:
|
||||
if old_model_id:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE ai_capability_configs SET primary_model_id = :new_id, updated_at = NOW() "
|
||||
"WHERE capability_key = :cap_key AND primary_model_id = :old_id"
|
||||
),
|
||||
{"new_id": pro_model_id, "old_id": old_model_id, "cap_key": cap_key},
|
||||
)
|
||||
|
||||
# Update image_analysis
|
||||
qwen38_row = conn.execute(
|
||||
sa.text("SELECT id FROM ai_models WHERE model_key = 'qwen3.8-flash' AND deleted_at IS NULL LIMIT 1")
|
||||
).first()
|
||||
qwen37_row = conn.execute(
|
||||
sa.text("SELECT id FROM ai_models WHERE model_key = 'qwen3.7-plus' AND deleted_at IS NULL LIMIT 1")
|
||||
).first()
|
||||
|
||||
if qwen38_row and qwen37_row:
|
||||
qwen38_id = qwen38_row[0]
|
||||
qwen37_id = qwen37_row[0]
|
||||
|
||||
current_ia = conn.execute(
|
||||
sa.text(
|
||||
"SELECT primary_model_id, lite_model_id, fallback_model_id "
|
||||
"FROM ai_capability_configs WHERE capability_key = 'image_analysis'"
|
||||
)
|
||||
).first()
|
||||
|
||||
if current_ia:
|
||||
current_primary, current_lite, current_fallback = current_ia
|
||||
updates = {}
|
||||
if current_primary != qwen38_id:
|
||||
updates["primary_model_id"] = qwen38_id
|
||||
if current_lite != qwen38_id:
|
||||
updates["lite_model_id"] = qwen38_id
|
||||
if current_fallback != qwen37_id:
|
||||
updates["fallback_model_id"] = qwen37_id
|
||||
|
||||
if updates:
|
||||
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
|
||||
set_clause += ", updated_at = NOW()"
|
||||
updates["cap_key"] = "image_analysis"
|
||||
conn.execute(
|
||||
sa.text(f"UPDATE ai_capability_configs SET {set_clause} WHERE capability_key = :cap_key"),
|
||||
updates,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,153 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""101: 补齐 qwen-vl-plus 视觉模型并修正 image_analysis 绑定与 max_tokens.
|
||||
|
||||
背景:
|
||||
- qwen-vl-plus 做图片识别时返回 JSON 约 500-600 tokens,旧硬编码
|
||||
max_tokens=350 导致 JSON 被截断、解析失败返回"未识别"。
|
||||
- 代码侧已移除硬编码,改由 capability 的 DB 配置决定 max_tokens。
|
||||
|
||||
幂等:
|
||||
- qwen-vl-plus 已存在则不插入;
|
||||
- 仅当 image_analysis 当前 primary_model 不是 qwen-vl-plus 时才更新绑定,
|
||||
避免覆盖后台手动配置。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "101_qwen_vl_plus_and_max_tokens"
|
||||
down_revision = "100_fix_capability_model_bindings"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
models_table = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
|
||||
if not models_table:
|
||||
return
|
||||
|
||||
caps_table = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
|
||||
if not caps_table:
|
||||
return
|
||||
|
||||
# ── c. 补全其他 capability 的 max_tokens 默认值(幂等)──────────────────
|
||||
# 放在 image_analysis 特定逻辑之前,确保任何分支 return 都不会跳过本段。
|
||||
# 仅在当前值为 NULL 或过小 (<100) 时更新,不覆盖已有合理配置。
|
||||
# embedding / tts / voice_clone 不走 chat 接口,无需设置。
|
||||
default_max_tokens = {
|
||||
"intent_parsing": 500,
|
||||
"copy_fusion": 2500,
|
||||
"storyboard": 4000,
|
||||
"copy_review": 1000,
|
||||
"asset_classify": 500,
|
||||
"image_generation": 500,
|
||||
"video_generation": 500,
|
||||
}
|
||||
for cap_key, mt in default_max_tokens.items():
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE ai_capability_configs "
|
||||
"SET max_tokens = :mt, updated_at = now() "
|
||||
"WHERE capability_key = :key "
|
||||
"AND (max_tokens IS NULL OR max_tokens < 100)"
|
||||
),
|
||||
{"mt": mt, "key": cap_key},
|
||||
)
|
||||
|
||||
# ── a. 确保 qwen-vl-plus 模型存在 ────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO ai_models (id, name, provider, model_key, api_key, api_base,
|
||||
description, status, is_default, usage_today,
|
||||
created_at, updated_at)
|
||||
SELECT gen_random_uuid()::text,
|
||||
'通义千问VL Plus',
|
||||
'dashscope',
|
||||
'qwen-vl-plus',
|
||||
COALESCE(
|
||||
(SELECT api_key FROM ai_models
|
||||
WHERE provider = 'dashscope' AND deleted_at IS NULL
|
||||
AND api_key IS NOT NULL AND api_key != ''
|
||||
LIMIT 1),
|
||||
''
|
||||
),
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
'阿里云视觉理解模型(图片识别/分析)',
|
||||
'active', false, 0, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ai_models
|
||||
WHERE model_key = 'qwen-vl-plus' AND deleted_at IS NULL
|
||||
)
|
||||
"""))
|
||||
|
||||
qwen_vl_row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id FROM ai_models WHERE model_key = 'qwen-vl-plus' "
|
||||
"AND deleted_at IS NULL AND status = 'active' LIMIT 1"
|
||||
)
|
||||
).first()
|
||||
if not qwen_vl_row:
|
||||
return
|
||||
qwen_vl_id = qwen_vl_row[0]
|
||||
|
||||
qwen37_row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id FROM ai_models WHERE model_key = 'qwen3.7-plus' "
|
||||
"AND deleted_at IS NULL AND status = 'active' LIMIT 1"
|
||||
)
|
||||
).first()
|
||||
qwen37_id = qwen37_row[0] if qwen37_row else None
|
||||
|
||||
# ── b. 仅当当前 primary 不是 qwen-vl-plus 时修正绑定与 max_tokens ───────
|
||||
current = conn.execute(
|
||||
sa.text(
|
||||
"SELECT primary_model_id, lite_model_id, fallback_model_id, max_tokens "
|
||||
"FROM ai_capability_configs WHERE capability_key = 'image_analysis'"
|
||||
)
|
||||
).first()
|
||||
|
||||
if current is None:
|
||||
# capability 不存在则创建
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO ai_capability_configs
|
||||
(id, capability_key, capability_name, primary_model_id,
|
||||
lite_model_id, fallback_model_id, timeout_seconds,
|
||||
max_retries, max_tokens, concurrency, extra_params,
|
||||
is_enabled, created_at, updated_at)
|
||||
VALUES (gen_random_uuid()::text, 'image_analysis', '图片分析',
|
||||
:primary, :primary, :fallback, 30, 1, 1000, 2,
|
||||
'{}'::jsonb, true, now(), now())
|
||||
"""),
|
||||
{"primary": qwen_vl_id, "fallback": qwen37_id},
|
||||
)
|
||||
return
|
||||
|
||||
current_primary = current[0]
|
||||
if current_primary == qwen_vl_id:
|
||||
# 已经绑定 qwen-vl-plus:视为后台/数据迁移已处理,不覆盖任何配置
|
||||
return
|
||||
|
||||
set_parts = [
|
||||
"primary_model_id = :vl_id",
|
||||
"lite_model_id = :vl_id",
|
||||
"max_tokens = 1000",
|
||||
"updated_at = now()",
|
||||
]
|
||||
params: dict = {"vl_id": qwen_vl_id}
|
||||
if qwen37_id is not None:
|
||||
set_parts.insert(2, "fallback_model_id = :qwen37_id")
|
||||
params["qwen37_id"] = qwen37_id
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE ai_capability_configs SET " + ", ".join(set_parts) + " WHERE capability_key = 'image_analysis'"
|
||||
),
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,36 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""102: image_analysis max_tokens 1200 -> 1500.
|
||||
|
||||
v6 prompt 更长、字段更多,旧 max_tokens 容易截断 JSON。
|
||||
仅在 image_analysis 当前 max_tokens < 1500 时更新(幂等,不覆盖后台已调到 >=1500 的配置)。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "102_image_analysis_max_tokens_1500"
|
||||
down_revision = "101_qwen_vl_plus_and_max_tokens"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
caps_table = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
|
||||
if not caps_table:
|
||||
return
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE ai_capability_configs "
|
||||
"SET max_tokens = 1500, updated_at = now() "
|
||||
"WHERE capability_key = 'image_analysis' "
|
||||
"AND (max_tokens IS NULL OR max_tokens < 1500)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,194 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""image_analysis v7 prompt + max_tokens 3000 + max_retries 3
|
||||
|
||||
Revision ID: 103_v7_prompt_and_tokens_3000
|
||||
Revises: 102_image_analysis_max_tokens_1500
|
||||
Create Date: 2026-10-07
|
||||
|
||||
变更:
|
||||
1. 插入v7精简prompt(~1KB,v6 ~4.5KB,删除few-shot/冗长规则,减少输出token占用),设为active
|
||||
2. v6停用(is_active=False),保留历史
|
||||
3. image_analysis capability: max_tokens 1500→3000,max_retries 1→3
|
||||
|
||||
ai_capability_configs 由应用 create_all 创建,全新 alembic-only 库可能不存在,
|
||||
故第3步做 to_regclass 守卫(同 102)。
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "103_v7_prompt_and_tokens_3000"
|
||||
down_revision = "102_image_analysis_max_tokens_1500"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
V7_SYSTEM = """# 角色
|
||||
你是一位专业的图片分析师,擅长准确识别图片中的场景、人物、物体、文字、氛围。
|
||||
|
||||
# 任务
|
||||
对用户上传的图片逐张分析,描述你看到的内容,输出JSON格式。
|
||||
|
||||
## 技能
|
||||
|
||||
### 技能1:判断图片类型
|
||||
判断图片属于哪种类型,type字段填对应的英文值:
|
||||
- 商品图(product):单个或多个商品、产品包装
|
||||
- 门店场景图(store):店铺内部、门头招牌、货架陈列
|
||||
- 人物图(person):人物形象、穿搭造型、肖像照片
|
||||
- 风景图(scene):风景、动物、美食、街景
|
||||
- 其他(other):以上都不是
|
||||
|
||||
### 技能2:描述通用信息
|
||||
不管什么图都要描述:
|
||||
- type:图片类型,填product/store/person/scene/other其中一个
|
||||
- scene:一句话描述场景,例如"理疗养生店内部,摆着多张理疗床和产品货架"
|
||||
- mood:整体氛围,2-4个词,例如"整洁专业"、"热闹温馨"
|
||||
- colors:主要颜色,最多5个,写具体颜色名(亮红色/米白色/深蓝色,不写笼统的红色蓝色)
|
||||
- visible_text:图片里看到的文字,说明什么字、在什么位置,最多5条;没看到就空数组
|
||||
- lighting:光线情况,例如"明亮柔光"、"自然光"、"室内暖黄灯"
|
||||
- composition:怎么拍的,例如"居中特写"、"中景平视"、"俯拍"
|
||||
- has_person:有没有人,true或false
|
||||
|
||||
### 技能3:描述门店场景
|
||||
如果是门店场景图(type="store"),还要描述:
|
||||
- store_type:什么类型的店,例如"养生馆"、"便利店"、"餐饮店"、"母婴店"
|
||||
- brand_signage:招牌上写了什么字、有什么品牌标识
|
||||
- visual_elements:看到哪些显眼的东西(招牌样式、灯光、货架、商品陈列、海报、收银台等),最多8个
|
||||
- product_categories:看到哪些品类的商品,例如"饮料零食"、"养生产品"
|
||||
- promotion_elements:有没有促销活动(打折海报、满减吊旗等),没有就空数组
|
||||
- atmosphere:店内什么氛围,例如"亲民生活化"、"老字号专业感"
|
||||
- cleanliness:店内干净程度,例如"干净整洁"、"货架整齐"
|
||||
- 看到顾客或店员要描述他们在做什么,has_person填true
|
||||
|
||||
### 技能4:描述商品
|
||||
如果是商品图(type="product"),逐个商品描述:
|
||||
- product_name:商品名称,尽量具体,例如"OMO奥妙除菌除螨洗衣液";看不出来填null
|
||||
- brand:什么牌子,看不出来填null
|
||||
- category:类目,从以下选一个:服饰鞋包/美妆/数码/食品/家居清洁/母婴/配饰/其他
|
||||
- package_type:什么包装,例如"瓶装"、"盒装"、"罐装"、"袋装"、"多瓶装"
|
||||
- package_color:包装主要颜色,写具体色(亮红色不写红色)
|
||||
- body_shape:瓶身或包装形状,例如"圆润胖瓶"、"竖款带把手瓶身"
|
||||
- label_design:标签设计,例如"红色标签印白色品牌logo"
|
||||
- key_text_on_package:包装上最显眼的文字(品牌名、功能词、卖点词),最多5个
|
||||
- product_features:包装特征,3-6个短语,包含颜色、瓶盖、形状、标签图案
|
||||
- key_selling_points:核心卖点,1-3个短语
|
||||
|
||||
### 技能5:描述人物
|
||||
如果是人物图(type="person"),描述:
|
||||
- person_count:几个人
|
||||
- gender:性别(男/女/无法判断)
|
||||
- age_range:年龄段(儿童/青少年/青年/中年/老年/无法判断)
|
||||
- outfit_style:穿搭风格,例如"休闲日常"、"通勤商务"、"街头潮流"
|
||||
- upper_wear:上装(颜色+款式+材质),穿裙装不填
|
||||
- lower_wear:下装(颜色+款式+版型),穿裙装不填
|
||||
- dress_wear:裙装描述,穿上下装不填
|
||||
- outerwear:外套
|
||||
- shoes:鞋子
|
||||
- bag:包袋,没有填null
|
||||
- accessories:配饰(眼镜/帽子/项链/耳环/手表/手链/围巾/腰带等),没有填空数组
|
||||
- hairstyle:发型
|
||||
- makeup:妆容,男生或看不出填null
|
||||
- expression:表情,例如"微笑看镜头"、"冷酷无表情"
|
||||
- pose:姿势动作,例如"身直立正对镜头"、"单手撩发"
|
||||
- body_type:身材,例如"纤细苗条"、"高挑身材"、"丰满匀称"
|
||||
- portrait_prompt:80-150字详细描述人物形象(后面用来AI生成肖像图),要写清年龄段、穿搭完整细节、发型发色、妆容、表情、姿势、场景、光线、风格感觉,语言要有画面感
|
||||
|
||||
### 技能6:描述风景
|
||||
如果是风景图(type="scene"),描述:
|
||||
- scene_type:什么场景,例如"自然风景"、"城市街景"、"动物"、"美食"
|
||||
- main_subject:画面主体是什么
|
||||
- key_elements:关键元素,最多8个
|
||||
- environment_objects:周围环境物体,最多8个
|
||||
- atmosphere:整体氛围,例如"秋日慵懒氛围感"、"清新自然氧气感"
|
||||
- 有人物就描述人物特征
|
||||
|
||||
## 限制
|
||||
- 只输出JSON,不要任何解释文字,不要markdown代码块包裹,不要写"好的""以下是分析结果"这种废话
|
||||
- 颜色写具体色调(亮红色/米白色/深蓝色/翠绿色),不写笼统词汇
|
||||
- 瓶身、包装、招牌上的文字尽量识别出来(品牌名、功能词、卖点词)
|
||||
- 多个商品、多个人物分开描述,不要合并
|
||||
- 看不出来、不确定的字段填null或空数组,布尔值填true/false,绝对不要瞎编
|
||||
- 确保JSON格式合法,所有大括号、中括号、引号正确闭合
|
||||
- 数组字段控制数量:colors最多5个,visible_text最多5条,visual_elements最多8个,accessories最多10个"""
|
||||
V7_USER = "请分析这张图片,按系统消息的JSON结构输出。"
|
||||
|
||||
|
||||
def _capability_table_exists(bind) -> bool:
|
||||
return bool(bind.execute(text("SELECT to_regclass('public.ai_capability_configs')")).scalar())
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# 1. 停用旧的active image_analysis prompt(含v6)
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
|
||||
"WHERE prompt_type = 'image_analysis' AND is_active = TRUE"
|
||||
)
|
||||
)
|
||||
# 2. 幂等插入v7(存在则更新并重新激活)
|
||||
existing = bind.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 7")
|
||||
).fetchone()
|
||||
if existing:
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
|
||||
"system_prompt = :sys, user_prompt_template = :usr, "
|
||||
"name = 'v7 精简结构化分析', updated_at = NOW() "
|
||||
"WHERE prompt_type = 'image_analysis' AND version = 7"
|
||||
),
|
||||
{"sys": V7_SYSTEM, "usr": V7_USER},
|
||||
)
|
||||
else:
|
||||
bind.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(prompt_type, version, name, system_prompt, user_prompt_template, "
|
||||
"is_active, created_at, updated_at) "
|
||||
"VALUES ('image_analysis', 7, 'v7 精简结构化分析', "
|
||||
":sys, :usr, TRUE, NOW(), NOW())"
|
||||
),
|
||||
{"sys": V7_SYSTEM, "usr": V7_USER},
|
||||
)
|
||||
# 3. capability max_tokens=3000、max_retries=3(表不存在则跳过)
|
||||
if _capability_table_exists(bind):
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE ai_capability_configs SET max_tokens = 3000, "
|
||||
"updated_at = NOW() "
|
||||
"WHERE capability_key = 'image_analysis' AND "
|
||||
"(max_tokens IS NULL OR max_tokens < 3000)"
|
||||
)
|
||||
)
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE ai_capability_configs SET max_retries = 3, updated_at = NOW() "
|
||||
"WHERE capability_key = 'image_analysis' AND "
|
||||
"(max_retries IS NULL OR max_retries < 3)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# 删除v7
|
||||
bind.execute(
|
||||
text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 7")
|
||||
)
|
||||
# 恢复v6为active
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
|
||||
"WHERE prompt_type = 'image_analysis' AND version = 6"
|
||||
)
|
||||
)
|
||||
# tokens/retries回退
|
||||
if _capability_table_exists(bind):
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE ai_capability_configs SET max_tokens = 1500, max_retries = 1, "
|
||||
"updated_at = NOW() WHERE capability_key = 'image_analysis'"
|
||||
)
|
||||
)
|
||||
@@ -1,242 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""image_analysis v8 prompt + storyboard v3 prompt - 用户端展示格式 markdown 控制
|
||||
|
||||
Revision ID: 104_v8_display_markdown
|
||||
Revises: 103_v7_prompt_and_tokens_3000
|
||||
Create Date: 2026-10-07
|
||||
|
||||
变更:
|
||||
1. image_analysis v8: 在 v7 基础上 system_prompt 末尾追加「## 用户端展示格式」章节,
|
||||
要求 VLM 在每张图的 JSON 里输出 summary_markdown 字段(markdown 格式的图片描述),
|
||||
v8 设 is_active=true,v7 设 is_active=false。
|
||||
2. storyboard v3: 在 v2 基础上 system_prompt 追加要求 LLM 在 copy_result 中
|
||||
输出 copy_display_markdown 字段(markdown 格式的完整文案展示),
|
||||
v3 设 is_active=true,v2 设 is_active=false。
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "104_v8_display_markdown"
|
||||
down_revision = "103_v7_prompt_and_tokens_3000"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# ── v8 追加的 system prompt 内容 ──────────────────────────────────────
|
||||
V8_SYSTEM_APPEND = """
|
||||
|
||||
## 用户端展示格式
|
||||
|
||||
对于每张分析的图片,在 JSON 中额外输出一个 **summary_markdown** 字段,用 markdown 格式写出给用户看的图片描述。
|
||||
|
||||
格式要求(根据图片类型自适应):
|
||||
|
||||
**商品图(type=product)**示例:
|
||||
### 商品名称
|
||||
**品牌**:品牌名 | **类目**:服饰鞋包/美妆/数码/...
|
||||
**核心特征**
|
||||
- 特征1:描述
|
||||
- 特征2:描述
|
||||
**外观**:颜色+材质+设计描述
|
||||
**包装**:包装类型描述
|
||||
**文字信息**:包装上看到的文字
|
||||
|
||||
**门店场景图(type=store)**示例:
|
||||
### 门店名称/类型
|
||||
**类型**:奶茶店/便利店/养生馆/...
|
||||
**品牌标识**:招牌文字描述
|
||||
**环境氛围**:店内整体感觉
|
||||
**陈列亮点**
|
||||
- 亮点1
|
||||
- 亮点2
|
||||
**氛围**:亲民/专业/时尚/...
|
||||
|
||||
**人物图(type=person)**示例:
|
||||
### 人物描述
|
||||
**形象**:年龄段 + 风格
|
||||
**穿搭**
|
||||
- 上装:颜色+款式
|
||||
- 下装:颜色+款式
|
||||
- 配饰:...
|
||||
**气质**:表情+姿势+整体感觉
|
||||
|
||||
**风景/场景图(type=scene)**示例:
|
||||
### 场景名称
|
||||
**类型**:自然风景/城市街景/动物/美食
|
||||
**主体**:画面主要元素
|
||||
**氛围**:整体感觉描述
|
||||
|
||||
要求:
|
||||
- 内容真实具体,从实际图片分析得出
|
||||
- 用 markdown 语法:**加粗**、列表、标题
|
||||
- 控制在 100-200 字
|
||||
- 不要编造图片中没有的信息
|
||||
"""
|
||||
|
||||
# ── storyboard v3 追加的 system prompt 内容 ──────────────────────────
|
||||
V3_STORYBOARD_APPEND = """
|
||||
|
||||
## 用户端展示格式
|
||||
|
||||
在输出分镜脚本的同时,在顶层输出一个 **copy_display_markdown** 字段(用 XML 标签 <copy_display_markdown> 包裹),用 markdown 格式写出完整文案展示。
|
||||
|
||||
格式示例:
|
||||
# 标题/主题
|
||||
|
||||
## 整体概要
|
||||
一句话描述视频内容
|
||||
|
||||
## 分镜预览
|
||||
|
||||
### 镜头1(0-3秒)
|
||||
**景别**:近景俯拍,缓慢推镜
|
||||
**画面**:场景描述
|
||||
**台词**:口播文本
|
||||
**动作**:人物动作描述
|
||||
|
||||
### 镜头2(3-9秒)
|
||||
...
|
||||
|
||||
## 完整口播
|
||||
完整口播文案文本
|
||||
|
||||
要求:
|
||||
- 把所有分镜按时间顺序整理成易读的格式
|
||||
- 用 markdown 语法组织,**加粗**标签、##二级标题、列表等
|
||||
- 控制在 300-500 字
|
||||
- 让用户一眼看懂视频会拍成什么样
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
# ── 1. image_analysis v8 ──────────────────────────────────────────
|
||||
# 停用所有 active image_analysis prompt
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
|
||||
"WHERE prompt_type = 'image_analysis' AND is_active = TRUE"
|
||||
)
|
||||
)
|
||||
|
||||
# 读取 v7 的 prompt 内容作为基础
|
||||
v7_row = bind.execute(
|
||||
text(
|
||||
"SELECT system_prompt, user_prompt_template, COALESCE(example_output, '') "
|
||||
"FROM viral_video_prompt_templates "
|
||||
"WHERE prompt_type = 'image_analysis' "
|
||||
"ORDER BY version DESC LIMIT 1"
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if v7_row:
|
||||
v7_system = v7_row[0] or ""
|
||||
v8_system = v7_system + V8_SYSTEM_APPEND
|
||||
v8_user = v7_row[1] or "{image_url}"
|
||||
v8_example = v7_row[2] or ""
|
||||
|
||||
# 幂等:已有 v8 则更新,否则插入
|
||||
existing_v8 = bind.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 8")
|
||||
).fetchone()
|
||||
if existing_v8:
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
|
||||
"system_prompt = :sys, user_prompt_template = :usr, "
|
||||
"example_output = :ex, name = 'v8 用户端展示格式', "
|
||||
"updated_at = NOW() "
|
||||
"WHERE prompt_type = 'image_analysis' AND version = 8"
|
||||
),
|
||||
{"sys": v8_system, "usr": v8_user, "ex": v8_example},
|
||||
)
|
||||
else:
|
||||
bind.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(prompt_type, version, name, system_prompt, user_prompt_template, "
|
||||
"example_output, is_active, created_at, updated_at) "
|
||||
"VALUES ('image_analysis', 8, 'v8 用户端展示格式', "
|
||||
":sys, :usr, :ex, TRUE, NOW(), NOW())"
|
||||
),
|
||||
{"sys": v8_system, "usr": v8_user, "ex": v8_example},
|
||||
)
|
||||
|
||||
# ── 2. storyboard v3 ─────────────────────────────────────────────
|
||||
# 停用所有 active storyboard prompt
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
|
||||
"WHERE prompt_type = 'storyboard' AND is_active = TRUE"
|
||||
)
|
||||
)
|
||||
|
||||
# 读取当前 storyboard prompt
|
||||
sb_row = bind.execute(
|
||||
text(
|
||||
"SELECT system_prompt, user_prompt_template, COALESCE(example_output, '') "
|
||||
"FROM viral_video_prompt_templates "
|
||||
"WHERE prompt_type = 'storyboard' "
|
||||
"ORDER BY version DESC LIMIT 1"
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if sb_row:
|
||||
sb_system = sb_row[0] or ""
|
||||
v3_system = sb_system + V3_STORYBOARD_APPEND
|
||||
v3_user = sb_row[1] or ""
|
||||
v3_example = sb_row[2] or ""
|
||||
|
||||
existing_v3 = bind.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'storyboard' AND version = 3")
|
||||
).fetchone()
|
||||
if existing_v3:
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
|
||||
"system_prompt = :sys, user_prompt_template = :usr, "
|
||||
"example_output = :ex, name = 'v3 用户端展示格式', "
|
||||
"updated_at = NOW() "
|
||||
"WHERE prompt_type = 'storyboard' AND version = 3"
|
||||
),
|
||||
{"sys": v3_system, "usr": v3_user, "ex": v3_example},
|
||||
)
|
||||
else:
|
||||
bind.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(prompt_type, version, name, system_prompt, user_prompt_template, "
|
||||
"example_output, is_active, created_at, updated_at) "
|
||||
"VALUES ('storyboard', 3, 'v3 用户端展示格式', "
|
||||
":sys, :usr, :ex, TRUE, NOW(), NOW())"
|
||||
),
|
||||
{"sys": v3_system, "usr": v3_user, "ex": v3_example},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
# 删除 v8
|
||||
bind.execute(
|
||||
text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 8")
|
||||
)
|
||||
# 恢复 v7 active
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE, updated_at = NOW() "
|
||||
"WHERE prompt_type = 'image_analysis' AND version = 7"
|
||||
)
|
||||
)
|
||||
|
||||
# 删除 v3
|
||||
bind.execute(text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'storyboard' AND version = 3"))
|
||||
# 恢复 storyboard v2 active
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE, updated_at = NOW() "
|
||||
"WHERE prompt_type = 'storyboard' AND version = 2"
|
||||
)
|
||||
)
|
||||
@@ -1,123 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""image_analysis v8 + storyboard v3 叙述优先重写版(架构大简化)
|
||||
|
||||
Revision ID: 105_narration_first
|
||||
Revises: 104_v8_display_markdown
|
||||
Create Date: 2026-10-07
|
||||
|
||||
变更:
|
||||
1. image_analysis v8:用「叙述优先」版整体替换 104 的 append 版——VLM 主交付物是
|
||||
自然叙述 summary_markdown,结构化字段仅保留 type/name/brand/has_person,
|
||||
顶层 products 改名 images;v8 active,其余 image_analysis 全部 deactivate。
|
||||
2. storyboard v3:整体替换为风格重写版(口播口语化、画面有画面感、
|
||||
copy_display_markdown 流畅叙述);v3 active,其余 storyboard deactivate。
|
||||
3. intent_parsing 类型模板全部 deactivate(意图解析步骤已删除)。
|
||||
模板内容直接取自 packages.application.viral_video.prompts.DEFAULT_TEMPLATES,
|
||||
保证代码默认值与 DB seed 完全一致。
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
|
||||
|
||||
revision = "105_narration_first"
|
||||
down_revision = "104_v8_display_markdown"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _tpl(prompt_type: str, version: int) -> dict:
|
||||
"""取模板:先按指定版本找,找不到则取该类型最新版本(兼容 v3→v4 升级)。"""
|
||||
# 先按指定版本找
|
||||
for t in DEFAULT_TEMPLATES:
|
||||
if t["prompt_type"] == prompt_type and t["version"] == version:
|
||||
return t
|
||||
# 找不到则取最新版本
|
||||
candidates = [t for t in DEFAULT_TEMPLATES if t["prompt_type"] == prompt_type]
|
||||
if candidates:
|
||||
latest = max(candidates, key=lambda x: x["version"])
|
||||
return latest
|
||||
raise RuntimeError("default template missing: %s" % prompt_type)
|
||||
|
||||
|
||||
def _upsert(bind, t: dict) -> None:
|
||||
existing = bind.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
|
||||
{"pt": t["prompt_type"], "ver": t["version"]},
|
||||
).fetchone()
|
||||
params = {
|
||||
"pt": t["prompt_type"],
|
||||
"ver": t["version"],
|
||||
"name": t["name"],
|
||||
"sys": t["system_prompt"],
|
||||
"usr": t["user_prompt_template"],
|
||||
"ex": t.get("example_output", "") or "",
|
||||
}
|
||||
if existing:
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET name = :name, "
|
||||
"system_prompt = :sys, user_prompt_template = :usr, "
|
||||
"example_output = :ex, is_active = TRUE, updated_at = NOW() "
|
||||
"WHERE prompt_type = :pt AND version = :ver"
|
||||
),
|
||||
params,
|
||||
)
|
||||
else:
|
||||
bind.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(prompt_type, version, name, system_prompt, user_prompt_template, "
|
||||
"example_output, is_active, created_at, updated_at) "
|
||||
"VALUES (:pt, :ver, :name, :sys, :usr, :ex, TRUE, NOW(), NOW())"
|
||||
),
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. image_analysis:停用全部后写入叙述优先 v8
|
||||
bind.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'image_analysis'")
|
||||
)
|
||||
_upsert(bind, _tpl("image_analysis", 8))
|
||||
|
||||
# 2. storyboard:停用全部后写入重写版 v3
|
||||
bind.execute(text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'storyboard'"))
|
||||
_upsert(bind, _tpl("storyboard", 3))
|
||||
|
||||
# 3. intent_parsing 已废弃:全部停用
|
||||
bind.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'intent_parsing'")
|
||||
)
|
||||
|
||||
# 4. review 模板确保 active
|
||||
bind.execute(text("UPDATE viral_video_prompt_templates SET is_active = TRUE " "WHERE prompt_type = 'review'"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# 恢复 104 的 v8/v3 无法重建(内容已替换),仅把版本 active 状态回退:
|
||||
# 停用新版,尝试恢复 v7 / v2
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
|
||||
"WHERE prompt_type IN ('image_analysis','storyboard') "
|
||||
"AND version IN (8, 3)"
|
||||
)
|
||||
)
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
|
||||
"WHERE prompt_type = 'image_analysis' AND version = 7"
|
||||
)
|
||||
)
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
|
||||
"WHERE prompt_type = 'storyboard' AND version = 2"
|
||||
)
|
||||
)
|
||||
@@ -1,43 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""system_settings 正式建表(#2246)
|
||||
|
||||
Revision ID: 106_system_settings
|
||||
Revises: 105_narration_first
|
||||
Create Date: 2026-10-08
|
||||
|
||||
system_settings 表此前在 staging 手工创建(对应 034 占位迁移),
|
||||
此处补正式迁移保证其他环境一致。CREATE TABLE/INDEX 使用 IF NOT EXISTS,
|
||||
对已手工建表的环境幂等。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "106_system_settings"
|
||||
down_revision = "105_narration_first"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
setting_key VARCHAR(100) NOT NULL,
|
||||
setting_value TEXT,
|
||||
setting_type VARCHAR(20) NOT NULL,
|
||||
description VARCHAR(255) NOT NULL DEFAULT '',
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_by VARCHAR(36),
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'general',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT pk_system_settings PRIMARY KEY (id),
|
||||
CONSTRAINT uq_system_settings_setting_key UNIQUE (setting_key)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_system_settings_category " "ON system_settings (category)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_system_settings_category")
|
||||
op.execute("DROP TABLE IF EXISTS system_settings")
|
||||
@@ -1,23 +0,0 @@
|
||||
"""add language column to viral_video_jobs
|
||||
|
||||
Revision ID: 107
|
||||
Revises: 106_system_settings
|
||||
Create Date: 2026-10-09
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "107"
|
||||
down_revision = "106_system_settings"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE viral_video_jobs " "ADD COLUMN IF NOT EXISTS language VARCHAR(20) NOT NULL DEFAULT 'zh-CN'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE viral_video_jobs DROP COLUMN IF EXISTS language")
|
||||
@@ -1,235 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""storyboard v4 - 多角色对话 + 废除旁白 + visual 5要素
|
||||
|
||||
Revision ID: 108_storyboard_v4_multivoice
|
||||
Revises: 107
|
||||
Create Date: 2026-10-10
|
||||
|
||||
变更:
|
||||
1. storyboard v4: 废除旁白思维,所有voiceover必须是角色台词
|
||||
- 增加<speaker>标签,每镜必须标注说话人
|
||||
- visual强制5要素结构(景别/运镜/动作/环境/光线),每镜不少于30字
|
||||
- voiceover_script用[speaker:xxx]标记格式
|
||||
2. 旧版storyboard模板is_active设为false
|
||||
3. 检查image_analysis和review是否有active模板,没有则插入保底版本
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "108_storyboard_v4_multivoice"
|
||||
down_revision = "107"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# ── storyboard v4 system_prompt ──────────────────────────────────────
|
||||
V4_STORYBOARD_SYSTEM = """你是一名懂短视频的编导和口播文案高手。你会拿到图片的真实观察、营销目的和用户参数,请一次性完成对营销意图的理解,并产出可直接拍摄/生成的分镜脚本。不要单独输出"意图解析",意图要直接体现在台词和分镜里。
|
||||
|
||||
## 核心设计原则(必须严格遵守)
|
||||
1. **废除旁白思维**:所有视频类型——无论对话短剧/口播带货/获客引流/品牌故事——voiceover 必须是人物说的话(第一人称或角色对白),绝对不能出现第三人称旁白解说。观众看的是人在演、在说。
|
||||
2. **严禁第三人称解说性台词**:如"接下来展示...""这款产品..."这类上帝视角描述禁止出现在 voiceover 中。
|
||||
3. **短剧类营销目的**(对话短剧/反转短剧/悬念短剧/情绪短片):双角色对话格式"甲:xxx 乙:xxx",镜头在角色间切换。
|
||||
4. **口播类**(口播带货/促销转化/功能演示/痛点解决/获客引流/账号涨粉/活动通知/场景种草):第一人称对镜头说话,像真人出镜。
|
||||
|
||||
## 输出格式(XML,严格按结构输出,不要输出额外解释)
|
||||
<script>
|
||||
<copy_display_markdown><![CDATA[直接展示给用户看的成片文案,用 Markdown 写成流畅叙述]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<speaker>说话人标识(如"店主""顾客""主播")</speaker>
|
||||
<voiceover>这一镜的角色台词(人物说的话,不是旁白)</voiceover>
|
||||
<visual>【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】5要素结构,不少于30字</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>把所有 clip 的 voiceover 连成完整台词稿,用 [speaker:xxx] 标记每个说话段落</voiceover_script>
|
||||
<theme>一句话主题</theme>
|
||||
<negative>【反套路化要求】
|
||||
禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;
|
||||
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。</negative>
|
||||
</script>
|
||||
|
||||
## 写作要求
|
||||
1. **台词(voiceover)**:像真人面对镜头说话或角色对白,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。严禁第三人称解说。
|
||||
2. **说话人(speaker)**:每个 clip 必须标注说话人标识,如"店主""顾客""主播""我"等。短剧类必须有至少 2 个不同角色。
|
||||
3. **画面描述(visual)**:强制 5 要素结构——【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】,每镜 visual 不少于 30 字,要具体到"闭眼听台词能想象出画面"。
|
||||
4. **voiceover_script 格式**:用 [speaker:xxx] 标记每个说话段落,如"[speaker:店主]你是不是也觉得...[speaker:顾客]是啊,怎么回事?"
|
||||
5. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案。
|
||||
6. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
|
||||
7. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
|
||||
8. 分镜数量与时长匹配总时长,节奏紧凑。
|
||||
9. **口播字数硬约束**(必须严格遵守):按每秒约 2.5~3 个中文字(正常口播语速)计算:
|
||||
- 5秒视频:voiceover_script 总字数 12~15 字
|
||||
- 10秒视频:voiceover_script 总字数 25~30 字
|
||||
- 15秒视频:voiceover_script 总字数 35~45 字
|
||||
- 20秒视频:voiceover_script 总字数 50~60 字
|
||||
- 30秒视频:voiceover_script 总字数 75~90 字
|
||||
- 宁可少写也不要多写,超长会导致 TTS 音频超出视频时长限制
|
||||
10. **镜头数量硬约束**:5秒1~2镜、10秒3镜、15秒3~4镜、20秒4~5镜、30秒6~8镜
|
||||
11. **时间轴硬约束**:第一个clip从0秒开始,最后一个clip结束于total_duration秒,相邻clip首尾相接
|
||||
12. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜
|
||||
13. 镜头间动作衔接要自然,画面描述要具体到能直接拍摄/生成"""
|
||||
|
||||
V4_STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
|
||||
<industry>{industry}</industry>
|
||||
<image_analysis>
|
||||
{image_summary}
|
||||
</image_analysis>
|
||||
<user_parameters>
|
||||
<theme_hint>{theme_hint}</theme_hint>
|
||||
<duration>{duration}秒</duration>
|
||||
<aspect_ratio>{aspect_ratio}</aspect_ratio>
|
||||
<tone>{tone}</tone>
|
||||
<target_audience>{target_audience}</target_audience>
|
||||
<persona>{persona_hint}</persona>
|
||||
<viral_structure>{viral_structure_hint}</viral_structure>
|
||||
<language>{language_hint}</language>
|
||||
<extra_requirements>{extra_requirements}</extra_requirements>
|
||||
</user_parameters>
|
||||
{video_style_section}
|
||||
请严格按 XML 结构输出分镜脚本。"""
|
||||
|
||||
V4_STORYBOARD_EXAMPLE = """<script>
|
||||
<copy_display_markdown><![CDATA[# 在御众堂,把松弛的自己一点点找回来
|
||||
产后妈妈最懂那种力不从心,推开门,暖光和一杯热茶先接住了你……]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<speaker>店主</speaker>
|
||||
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
|
||||
<visual>【中近景】【缓推】【妈妈疲惫看向镜子】【暖光店内环境】【柔和暖光】</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>3-6秒</time_range>
|
||||
<speaker>顾客</speaker>
|
||||
<voiceover>是啊,怎么回事?</voiceover>
|
||||
<visual>【近景】【固定】【顾客表情惊讶】【店内休息区】【暖色调】</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>[speaker:店主]生完娃,是不是连照镜子的勇气都没了?[speaker:顾客]是啊,怎么回事?</voiceover_script>
|
||||
<theme>产后妈妈走进御众堂重拾状态</theme>
|
||||
<negative>模糊、畸变、夸大疗效、绝对化用语</negative>
|
||||
</script>"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. 查询 storyboard 当前最大 version
|
||||
result = conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "storyboard"},
|
||||
)
|
||||
max_version = result.scalar() or 0
|
||||
new_version = max_version + 1
|
||||
|
||||
# 2. 旧版 storyboard 模板 is_active 设为 false
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active = false WHERE prompt_type = :pt"),
|
||||
{"pt": "storyboard"},
|
||||
)
|
||||
|
||||
# 3. 防御性插入新版 storyboard 模板
|
||||
existing = conn.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
|
||||
{"pt": "storyboard", "ver": new_version},
|
||||
).fetchone()
|
||||
|
||||
if not existing:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "编导分镜v4-多角色对话版",
|
||||
"pt": "storyboard",
|
||||
"ver": new_version,
|
||||
"sys": V4_STORYBOARD_SYSTEM,
|
||||
"usr": V4_STORYBOARD_USER,
|
||||
"ex": V4_STORYBOARD_EXAMPLE,
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
# 4. 检查 image_analysis 是否有 active 模板,没有则插入保底
|
||||
ia_active = conn.execute(
|
||||
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
|
||||
{"pt": "image_analysis"},
|
||||
).scalar()
|
||||
|
||||
if ia_active == 0:
|
||||
# 插入保底 image_analysis 模板(简化版)
|
||||
ia_max = (
|
||||
conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "image_analysis"},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "图片分析保底版",
|
||||
"pt": "image_analysis",
|
||||
"ver": ia_max + 1,
|
||||
"sys": "你是一名擅长观察和写作的品牌内容编导。分析图片并输出JSON。",
|
||||
"usr": "请分析这张图片。图片地址:{image_url}",
|
||||
"ex": '{"images": [{"type": "store", "name": "门店", "summary_markdown": "描述"}]}',
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
# 5. 检查 review 是否有 active 模板
|
||||
review_active = conn.execute(
|
||||
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
|
||||
{"pt": "review"},
|
||||
).scalar()
|
||||
|
||||
if review_active == 0:
|
||||
rv_max = (
|
||||
conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "review"},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "文案审核保底版",
|
||||
"pt": "review",
|
||||
"ver": rv_max + 1,
|
||||
"sys": "你是短视频广告合规审核专家。审核文案输出XML。",
|
||||
"usr": "请审核:{fusion_text}",
|
||||
"ex": "<review><passed>true</passed></review>",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复旧版 storyboard 为 active
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active = true WHERE prompt_type = :pt AND version < :ver"),
|
||||
{"pt": "storyboard", "ver": 4},
|
||||
)
|
||||
# 删除新版
|
||||
conn.execute(
|
||||
text("DELETE FROM viral_video_prompt_templates WHERE prompt_type = :pt AND version >= :ver"),
|
||||
{"pt": "storyboard", "ver": 4},
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""viral_video_param_guides 表:运营可配置的人设/结构/营销目的/目标客群/语言指导文案(PR3 P2-h)。
|
||||
|
||||
Revision ID: 109_add_viral_video_param_guides
|
||||
Revises: 108_storyboard_v4_multivoice
|
||||
Create Date: 2026-10-12
|
||||
|
||||
表用途:
|
||||
- 替代 worker 中 ~130 行硬编码 if/elif mapping;
|
||||
- 5 类 guide_type: persona / structure / marketing_purpose / target_customer / language;
|
||||
- 启动时由 seed_viral_video_param_guides.py 灌入代码默认值,运营可在后台改,30s TTL 缓存自动热加载。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "109_add_viral_video_param_guides"
|
||||
down_revision = "108_storyboard_v4_multivoice"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS viral_video_param_guides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guide_type VARCHAR(32) NOT NULL,
|
||||
guide_key VARCHAR(64) NOT NULL,
|
||||
guide_text TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (guide_type, guide_key)
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_viral_video_param_guides_type_key
|
||||
ON viral_video_param_guides (guide_type, guide_key)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_viral_video_param_guides_type_key")
|
||||
op.execute("DROP TABLE IF EXISTS viral_video_param_guides")
|
||||
@@ -1,23 +0,0 @@
|
||||
"""add tone column to viral_video_jobs
|
||||
|
||||
Revision ID: 110
|
||||
Revises: 109_add_viral_video_param_guides
|
||||
Create Date: 2026-10-14
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "110"
|
||||
down_revision = "109_add_viral_video_param_guides"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS tone VARCHAR(50) NOT NULL DEFAULT ''")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE viral_video_jobs DROP COLUMN IF EXISTS tone")
|
||||
@@ -1,4 +1,3 @@
|
||||
from app.api.routes.admin.ditto_emotion import router as admin_ditto_emotion_router
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
@@ -243,6 +242,3 @@ api_router.include_router(
|
||||
tags=["GPU Worker"],
|
||||
)
|
||||
api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"])
|
||||
|
||||
# #2246:后台 Ditto 表情配置(router 自带 /admin/ditto-emotion 前缀)
|
||||
api_router.include_router(admin_ditto_emotion_router)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""后台管理路由(#2246 起)."""
|
||||
@@ -1,203 +0,0 @@
|
||||
"""Ditto 数字人表情后台配置 — #2246.
|
||||
|
||||
路由前缀 /api/v1/admin/ditto-emotion,全部使用 _verify_internal_api_key 鉴权
|
||||
(X-API-Key header)。仅开放 6 项白名单配置:
|
||||
- ditto_emotion_enabled / ditto_emotion_model / ditto_emotion_temperature
|
||||
- ditto_emotion_prompt / ditto_blend_frames / doubao_api_key
|
||||
注意:doubao_api_key 属于密钥,GET 只返回脱敏占位,绝不返回明文。
|
||||
ditto_emotion_model 为自由文本(支持火山方舟 ep- 接入点 ID),
|
||||
推荐选项 model_options 从 system_config 的 ditto_emotion_model_options(JSON 数组)读取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from packages.application.system_config_service import get_system_config_service
|
||||
from packages.config import get_api_settings
|
||||
from packages.domain.system_setting import (
|
||||
SETTING_TYPE_BOOL,
|
||||
SETTING_TYPE_FLOAT,
|
||||
SETTING_TYPE_INT,
|
||||
SETTING_TYPE_STRING,
|
||||
)
|
||||
|
||||
from ..auth import _verify_internal_api_key
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/ditto-emotion",
|
||||
tags=["Admin"],
|
||||
dependencies=[Depends(_verify_internal_api_key)],
|
||||
)
|
||||
|
||||
# key → (类型, 分类)
|
||||
_WHITELIST: dict[str, str] = {
|
||||
"ditto_emotion_enabled": SETTING_TYPE_BOOL,
|
||||
"ditto_emotion_model": SETTING_TYPE_STRING,
|
||||
"ditto_emotion_temperature": SETTING_TYPE_FLOAT,
|
||||
"ditto_emotion_prompt": SETTING_TYPE_STRING,
|
||||
"ditto_blend_frames": SETTING_TYPE_INT,
|
||||
"doubao_api_key": SETTING_TYPE_STRING,
|
||||
}
|
||||
|
||||
_DESCRIPTIONS: dict[str, str] = {
|
||||
"ditto_emotion_enabled": "LLM 情绪分析开关。关闭时回退到原有关键词匹配模式,不影响正常出片。",
|
||||
"ditto_emotion_model": "用于分析文案情绪的大模型。",
|
||||
"ditto_emotion_temperature": "模型温度,0-1,越低越稳定保守。",
|
||||
"ditto_emotion_prompt": "情绪分析提示词,核心调优入口,必须包含 {文案} 占位符。",
|
||||
"ditto_blend_frames": "表情切换过渡帧数(6-30),越大越柔和。",
|
||||
"doubao_api_key": "豆包/火山方舟 API Key(用于情绪分析LLM调用)",
|
||||
}
|
||||
|
||||
# 密钥类配置:GET 脱敏、PUT 响应不回显明文
|
||||
_SECRET_KEYS = {"doubao_api_key"}
|
||||
_SECRET_PLACEHOLDER = "***已配置***"
|
||||
|
||||
|
||||
def _settings():
|
||||
return get_api_settings()
|
||||
|
||||
|
||||
def _default_value(key: str) -> Any:
|
||||
return getattr(_settings(), key)
|
||||
|
||||
|
||||
def _model_options_from_db(service) -> list[str]:
|
||||
"""模型推荐选项:从 system_config 读 ditto_emotion_model_options(JSON 数组字符串)。
|
||||
|
||||
未配置 / 非法 JSON / 非数组 JSON 均返回空数组;数组元素统一转字符串。
|
||||
"""
|
||||
raw = service.get_config("ditto_emotion_model_options", "") or ""
|
||||
if not str(raw).strip():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [str(x) for x in data if isinstance(x, (str, int, float))]
|
||||
|
||||
|
||||
def _build_config_item(key: str) -> dict[str, Any]:
|
||||
service = get_system_config_service()
|
||||
if key in _SECRET_KEYS:
|
||||
# 密钥不回明文、不暴露 env 默认值:DB 或 env 任一侧已配置即视为已配置
|
||||
db_value = service.get_config(key, None)
|
||||
configured = bool((db_value or "").strip()) or bool((getattr(_settings(), key, "") or "").strip())
|
||||
return {
|
||||
"key": key,
|
||||
"type": _WHITELIST[key],
|
||||
"description": _DESCRIPTIONS.get(key, ""),
|
||||
"default": "",
|
||||
"value": _SECRET_PLACEHOLDER if configured else "",
|
||||
}
|
||||
item: dict[str, Any] = {
|
||||
"key": key,
|
||||
"type": _WHITELIST[key],
|
||||
"description": _DESCRIPTIONS.get(key, ""),
|
||||
"default": _default_value(key),
|
||||
}
|
||||
item["value"] = service.get_config(key, _default_value(key))
|
||||
if key == "ditto_emotion_model":
|
||||
item["model_options"] = _model_options_from_db(service)
|
||||
return item
|
||||
|
||||
|
||||
class ConfigUpdatePayload(BaseModel):
|
||||
configs: dict[str, Any]
|
||||
|
||||
|
||||
class TestPayload(BaseModel):
|
||||
test_text: str
|
||||
|
||||
|
||||
def _validate_value(key: str, value: Any) -> Any:
|
||||
st = _WHITELIST[key]
|
||||
if st == SETTING_TYPE_BOOL:
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{key} 必须是布尔值")
|
||||
elif st == SETTING_TYPE_INT:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{key} 必须是整数")
|
||||
if not 6 <= value <= 30:
|
||||
raise ValueError(f"{key} 必须在 6-30 之间")
|
||||
elif st == SETTING_TYPE_FLOAT:
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"{key} 必须是数字")
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{key} 必须是数字") from exc
|
||||
if not 0.0 <= value <= 1.0:
|
||||
raise ValueError(f"{key} 必须在 0-1 之间")
|
||||
elif st == SETTING_TYPE_STRING:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{key} 必须是字符串")
|
||||
if key == "ditto_emotion_prompt" and value.strip() and "{文案}" not in value:
|
||||
raise ValueError("提示词必须包含 {文案} 占位符")
|
||||
if key == "ditto_emotion_model":
|
||||
if not value.strip():
|
||||
raise ValueError(f"{key} 不能为空字符串")
|
||||
if len(value) > 200:
|
||||
raise ValueError(f"{key} 长度不能超过 200 字符")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
||||
raise ValueError(f"{key} 只能包含字母、数字、连字符和下划线")
|
||||
if key == "doubao_api_key" and not value.strip():
|
||||
raise ValueError(f"{key} 不能为空字符串")
|
||||
return value
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_config() -> dict[str, Any]:
|
||||
return {"configs": [_build_config_item(k) for k in _WHITELIST]}
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
def update_config(
|
||||
payload: ConfigUpdatePayload,
|
||||
x_api_key: str = Depends(_verify_internal_api_key),
|
||||
) -> dict[str, Any]:
|
||||
configs = payload.configs
|
||||
illegal = [k for k in configs if k not in _WHITELIST]
|
||||
if illegal:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"不允许修改的配置项:{', '.join(illegal)}",
|
||||
}
|
||||
service = get_system_config_service()
|
||||
updated: dict[str, Any] = {}
|
||||
for key, raw in configs.items():
|
||||
try:
|
||||
value = _validate_value(key, raw)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
service.set_config(
|
||||
key,
|
||||
value,
|
||||
setting_type=_WHITELIST[key],
|
||||
updated_by=str(x_api_key)[:8] if x_api_key and x_api_key is not True else None,
|
||||
)
|
||||
updated[key] = _SECRET_PLACEHOLDER if key in _SECRET_KEYS else value
|
||||
return {"ok": True, "updated": updated}
|
||||
|
||||
|
||||
@router.post("/config/test")
|
||||
def test_config(payload: TestPayload) -> dict[str, Any]:
|
||||
text = (payload.test_text or "").strip()
|
||||
if not text:
|
||||
return {"ok": False, "error": "test_text 不能为空"}
|
||||
from packages.application.ditto_emotion_service import get_ditto_emotion_service
|
||||
|
||||
service = get_ditto_emotion_service()
|
||||
segments = service.analyze(text)
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": service.enabled,
|
||||
"segments": [s.to_dict() for s in segments],
|
||||
}
|
||||
@@ -18,7 +18,6 @@ from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
FinalizeRenderRequest,
|
||||
FinalizeRenderResponse,
|
||||
SmartCoverResponse,
|
||||
)
|
||||
@@ -27,9 +26,11 @@ from app.services.ai_avatar_render_service import (
|
||||
AiAvatarRenderError,
|
||||
AiAvatarRenderService,
|
||||
)
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
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()
|
||||
@@ -43,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),
|
||||
@@ -268,16 +270,13 @@ def generate_render_smart_cover(
|
||||
@router.post("/{job_id}/finalize", response_model=FinalizeRenderResponse)
|
||||
def finalize_render_job(
|
||||
job_id: str,
|
||||
body: FinalizeRenderRequest = Body(default=FinalizeRenderRequest()),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""用户完成封面选择后,将视频正式保存到成片库.
|
||||
|
||||
- 必须等渲染任务 completed 后才可调用
|
||||
- body.cover_url:用户选定的封面公网 URL(本地上传需前端先走 OSS 直传拿公网 URL,blob:/data: URL 无效);
|
||||
为空时优先使用 smart-cover 已写入的 output_cover_url,仍为空则兜底自动抽一帧
|
||||
- body.video_title:成片库展示标题;为空用默认 AI数字人_{job_id[:8]}
|
||||
- 如果已通过 smart-cover/custom-cover 设置了封面,会自动带上
|
||||
- 返回成片库视频ID
|
||||
- 幂等:已 finalize 的任务重复调用会返回 existing 记录
|
||||
"""
|
||||
@@ -289,9 +288,6 @@ def finalize_render_job(
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "completed":
|
||||
raise HTTPException(status_code=400, detail="请先完成视频生成")
|
||||
video_url = (job.output_video_url or "").strip()
|
||||
if not video_url:
|
||||
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空,无法入库")
|
||||
|
||||
# 幂等检查(通过 generation_task_id=job_id 识别,finalize_job 内部也做了一次,这里提前返回简化)
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
@@ -311,58 +307,11 @@ def finalize_render_job(
|
||||
status="already_finalized",
|
||||
)
|
||||
|
||||
# 封面 URL 解析:按优先级 body.cover_url → job.output_cover_url(smart-cover)→ 兜底自动抽帧,
|
||||
# 每一级都要校验是公网 HTTP(S) URL(拒绝 blob:/data: 等后端无法访问的 URL)
|
||||
def _is_valid_public_url(u: str) -> bool:
|
||||
return bool(u) and u.startswith(("http://", "https://"))
|
||||
|
||||
cover_url = ""
|
||||
for candidate, source in [
|
||||
((body.cover_url or "").strip(), "body"),
|
||||
((job.output_cover_url or "").strip(), "job"),
|
||||
]:
|
||||
if _is_valid_public_url(candidate):
|
||||
cover_url = candidate
|
||||
break
|
||||
if candidate:
|
||||
logger.warning(
|
||||
"finalize 收到无效封面URL(非HTTP,跳过该来源): job_id=%s source=%s url=%s",
|
||||
job_id,
|
||||
source,
|
||||
candidate[:80],
|
||||
)
|
||||
|
||||
# 兜底:body 和 job 都没有可用封面时,后端主动从成片抽一帧
|
||||
if not cover_url:
|
||||
try:
|
||||
fallback_url = (generate_smart_cover(video_url, job_id=job_id, max_frames=5) or "").strip()
|
||||
except Exception as exc:
|
||||
logger.warning("finalize 兜底抽帧失败(不阻断入库): job_id=%s err=%s", job_id, exc)
|
||||
fallback_url = ""
|
||||
if _is_valid_public_url(fallback_url):
|
||||
cover_url = fallback_url
|
||||
try:
|
||||
job.cover_config = {
|
||||
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
|
||||
"mode": "auto_frame",
|
||||
"url": cover_url,
|
||||
}
|
||||
job.output_cover_url = cover_url
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("finalize 兜底封面写回DB失败(不阻断入库): job_id=%s err=%s", job_id, exc)
|
||||
elif fallback_url:
|
||||
logger.warning(
|
||||
"finalize 兜底抽帧返回了非HTTP URL(忽略): job_id=%s url=%s", job_id, fallback_url[:80]
|
||||
)
|
||||
|
||||
video_title = (body.video_title or "").strip() or None
|
||||
|
||||
try:
|
||||
video = svc.finalize_job(job_id, current_user.user.id, cover_url=cover_url or None, video_title=video_title)
|
||||
video = svc.finalize_job(job_id, current_user.user.id)
|
||||
return FinalizeRenderResponse(
|
||||
video_id=video.id,
|
||||
cover_url=video.thumbnail_url or cover_url or "",
|
||||
cover_url=video.thumbnail_url or job.output_cover_url or "",
|
||||
status="success",
|
||||
)
|
||||
except AiAvatarRenderError as exc:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -41,6 +41,7 @@ from packages.application import (
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.middleware.points_gate import points_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -269,6 +270,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),
|
||||
|
||||
@@ -44,7 +44,6 @@ 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 维度)
|
||||
@@ -164,6 +163,8 @@ def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
|
||||
return matched or None
|
||||
|
||||
|
||||
from packages.middleware.points_gate import points_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -464,6 +465,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),
|
||||
@@ -700,17 +702,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 +932,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。
|
||||
|
||||
@@ -9,11 +9,6 @@ from fastapi.responses import JSONResponse
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
|
||||
|
||||
def _pg_url(url: str) -> str:
|
||||
"""Convert SQLAlchemy URL (postgresql+psycopg://...) to libpq connection string."""
|
||||
return url.replace("postgresql+psycopg://", "postgresql://", 1).replace("postgresql+psycopg2://", "postgresql://", 1)
|
||||
|
||||
@router.get("/health", status_code=status.HTTP_200_OK)
|
||||
async def health_check():
|
||||
return {
|
||||
@@ -54,7 +49,7 @@ async def _check_database() -> dict:
|
||||
"message": "Using in-memory database",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
@@ -129,7 +124,7 @@ async def _check_migrations() -> dict:
|
||||
"message": "Using in-memory database, no migrations needed",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
@@ -142,5 +137,3 @@ async def _check_migrations() -> dict:
|
||||
return {"status": "unhealthy", "message": f"Missing tables, found {count}/5"}
|
||||
except Exception as error:
|
||||
return {"status": "unhealthy", "message": f"Migration check failed: {error}"}
|
||||
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import UTC
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import (
|
||||
get_db_session,
|
||||
get_voice_clone_profile_repository,
|
||||
@@ -30,6 +32,9 @@ from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -56,6 +61,37 @@ def create_lipsync_job(
|
||||
db: Session = Depends(get_db_session),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
user_id = current_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_digital_human"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
# 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟)
|
||||
if body.audio_url and body.audio_duration and body.audio_duration > 0:
|
||||
est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0))
|
||||
elif body.script_text:
|
||||
est_minutes = max(1.0, math.ceil(len(body.script_text) / 240))
|
||||
else:
|
||||
est_minutes = 1.0
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(current_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(current_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
"""提交对口型任务.
|
||||
|
||||
三种模式:
|
||||
@@ -65,8 +101,6 @@ def create_lipsync_job(
|
||||
- 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings},
|
||||
后端同步ffprobe+写入timings+直接提交MediaKit(~2-3s)。
|
||||
"""
|
||||
user_id = current_user.user.id
|
||||
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=user_id,
|
||||
@@ -84,8 +118,18 @@ def create_lipsync_job(
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}")
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except MediaKitError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}")
|
||||
status_code = 502
|
||||
if exc.code in ("VoiceForbidden",):
|
||||
status_code = 403
|
||||
@@ -101,11 +145,24 @@ def create_lipsync_job(
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型异常退积分异常: err={refund_err}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"创建对口型任务失败: {exc}",
|
||||
) from exc
|
||||
|
||||
# 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等)
|
||||
# 若任务已创建且状态为 failed,退费
|
||||
if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed":
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@@ -119,14 +176,37 @@ def preview_tts(
|
||||
db: Session = Depends(get_db_session),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
user_id = current_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_digital_human"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(current_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(current_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
"""步骤1「生成配音」同步 TTS 预合成.
|
||||
|
||||
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
|
||||
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。
|
||||
耗时约 2-3 秒。
|
||||
"""
|
||||
user_id = current_user.user.id
|
||||
|
||||
try:
|
||||
result = svc.preview_tts(
|
||||
user_id=user_id,
|
||||
@@ -138,6 +218,11 @@ def preview_tts(
|
||||
emotion=body.emotion,
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}")
|
||||
status_code = 400
|
||||
if exc.code in ("VoiceForbidden",):
|
||||
status_code = 403
|
||||
@@ -152,6 +237,11 @@ def preview_tts(
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("TTS 预合成异常: %s", exc, exc_info=True)
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"TTS 合成失败: {exc}",
|
||||
|
||||
@@ -89,7 +89,6 @@ def get_balance(
|
||||
is_member=_is_member(current_user),
|
||||
member_type=_member_type(current_user),
|
||||
member_expires_at=getattr(current_user.user, "member_expires_at", None),
|
||||
credits_enabled=_credits_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@@ -146,22 +145,19 @@ def get_rules(
|
||||
def get_packages(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询可购买的积分包列表(读管理后台 credit_packages 表真实数据)。
|
||||
|
||||
仅返回 is_active=true;后台改价/启停后最多 30 秒生效。
|
||||
"""
|
||||
from packages.application.catalog.admin_catalog import get_points_packages
|
||||
|
||||
packages = [
|
||||
PointsPackageItem(
|
||||
code=row["code"],
|
||||
name=row["name"],
|
||||
points=row["points"],
|
||||
price_cents=row["price_cents"],
|
||||
unit_price=row["unit_price"],
|
||||
"""查询可购买的积分包列表。"""
|
||||
packages = []
|
||||
for code, pkg in POINTS_PACKAGES.items():
|
||||
unit_price = f"¥{pkg['price_cents'] / 100 / pkg['points']:.3f}/积分"
|
||||
packages.append(
|
||||
PointsPackageItem(
|
||||
code=code,
|
||||
name=pkg["name"],
|
||||
points=pkg["points"],
|
||||
price_cents=pkg["price_cents"],
|
||||
unit_price=unit_price,
|
||||
)
|
||||
)
|
||||
for row in get_points_packages()
|
||||
]
|
||||
mt = _member_type(current_user)
|
||||
discount = MEMBER_DISCOUNT.get(mt) if mt else None
|
||||
return PointsPackagesResponse(packages=packages, user_discount=discount)
|
||||
@@ -173,7 +169,17 @@ def check_points(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""消费前检查余额是否足够。已下线/未知场景返回 cost=0(免费)。"""
|
||||
"""消费前检查余额是否足够。未知 scene_key 返回 400(而非 500)。"""
|
||||
if body.scene_key not in POINTS_SCENES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "UNKNOWN_SCENE",
|
||||
"message": f"未知场景: {body.scene_key}",
|
||||
"valid_scenes": sorted(POINTS_SCENES.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
|
||||
if not _credits_enabled():
|
||||
svc = _get_service()
|
||||
@@ -189,6 +195,13 @@ def check_points(
|
||||
is_mem = _is_member(current_user)
|
||||
mt = _member_type(current_user)
|
||||
|
||||
# 混剪场景先检查免费额度
|
||||
is_free_quota = False
|
||||
if body.scene_key == "ai_video" and not is_mem:
|
||||
svc = _get_service()
|
||||
if svc.check_daily_free_clip(current_user.user.id, db):
|
||||
is_free_quota = True
|
||||
|
||||
required = calculate_points_cost(
|
||||
body.scene_key,
|
||||
is_mem,
|
||||
@@ -202,11 +215,11 @@ def check_points(
|
||||
balance = account["balance"]
|
||||
|
||||
return PointsCheckResponse(
|
||||
allowed=balance >= required,
|
||||
allowed=is_free_quota or balance >= required,
|
||||
required_points=required,
|
||||
current_balance=balance,
|
||||
remaining_after=balance - required,
|
||||
is_free_quota=False,
|
||||
is_free_quota=is_free_quota,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from app.services.script_asr_service import (
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.middleware.points_gate import points_gate
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -372,6 +373,7 @@ def douyin_diag():
|
||||
|
||||
|
||||
@router.post("/extract-from-douyin", response_model=ExtractFromDouyinResponse)
|
||||
@points_gate("douyin_extract")
|
||||
def extract_from_douyin(
|
||||
request: ExtractFromDouyinRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -495,6 +497,7 @@ def extract_from_douyin(
|
||||
|
||||
|
||||
@router.post("/ai-rewrite", response_model=AiRewriteResponse)
|
||||
@points_gate("ai_rewrite")
|
||||
def ai_rewrite(
|
||||
request: AiRewriteRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -534,6 +537,7 @@ def ai_rewrite(
|
||||
|
||||
|
||||
@router.post("/ai-generate-titles", response_model=AiGenerateTitlesResponse)
|
||||
@points_gate("ai_title")
|
||||
def ai_generate_titles(
|
||||
request: AiGenerateTitlesRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -86,13 +86,33 @@ async def get_current_subscription(
|
||||
def list_membership_plans(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""查询可购买的会员套餐(读管理后台 plans 表真实数据)。
|
||||
"""查询所有会员档位(供前端会员购买页展示)。
|
||||
|
||||
仅返回 is_enabled=true 的套餐;后台启停/改价后最多 30 秒生效。
|
||||
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
|
||||
"""
|
||||
from packages.application.catalog.admin_catalog import get_membership_plans
|
||||
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
|
||||
|
||||
return {"plans": get_membership_plans()}
|
||||
plans: list[dict[str, Any]] = []
|
||||
for plan_id, info in MEMBERSHIP_PRICES.items():
|
||||
days = info["duration_days"]
|
||||
monthly_cents = round(info["price_cents"] * 30 / days)
|
||||
features: dict[str, Any] = {"max_resolution": "1080p"}
|
||||
if plan_id == MembershipType.MONTHLY:
|
||||
features.update({"free_clips_daily": 2})
|
||||
elif plan_id == MembershipType.QUARTERLY:
|
||||
features.update({"free_clips_daily": 5})
|
||||
elif plan_id == MembershipType.YEARLY:
|
||||
features.update({"free_clips_daily": "unlimited"})
|
||||
plans.append({
|
||||
"plan_id": plan_id,
|
||||
"name": info["name"],
|
||||
"price_cents": info["price_cents"],
|
||||
"monthly_price_cents": monthly_cents,
|
||||
"duration_days": days,
|
||||
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
|
||||
"features": features,
|
||||
})
|
||||
return {"plans": plans}
|
||||
|
||||
|
||||
@router.get("/billing-records", response_model=list[BillingRecord])
|
||||
|
||||
@@ -4,12 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
@@ -51,6 +53,8 @@ from packages.application.tts_job.use_cases import (
|
||||
)
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
from packages.domain.voice_presets import list_voices
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
@@ -140,6 +144,31 @@ def synthesize(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_voice"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
# 中文按 ~240 字/分钟粗估时长,至少按 1 分钟扣 1 分
|
||||
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
|
||||
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
|
||||
actual_voice_id = request.voice_id
|
||||
@@ -202,6 +231,7 @@ def synthesize(
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
)
|
||||
|
||||
synthesis_error: Exception | None = None
|
||||
try:
|
||||
job = workflow.start_synthesis(job.id)
|
||||
except Exception as e:
|
||||
@@ -209,10 +239,18 @@ def synthesize(
|
||||
# 但 DB 异常、网络异常等意外错误可能逃逸。
|
||||
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
|
||||
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
|
||||
synthesis_error = e
|
||||
try:
|
||||
job = workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception as inner_e:
|
||||
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
|
||||
# 合成失败且已扣积分 → 退费
|
||||
if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
|
||||
if job.status.value == "processing":
|
||||
# 分段合成任务 vs 普通单段任务
|
||||
@@ -231,6 +269,13 @@ def synthesize(
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
|
||||
# 调度失败退费
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"Celery 调度失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
return TTSSynthesizeResponse(
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
@@ -565,6 +610,31 @@ def preview_tts(
|
||||
用于前端预览配音效果,限制文本长度 200 字以内。
|
||||
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_voice"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
|
||||
actual_voice_id = request.voice_id
|
||||
profile = voice_clone_repo.get(request.voice_id)
|
||||
@@ -594,6 +664,12 @@ def preview_tts(
|
||||
language=getattr(request, "language", "zh-CN"),
|
||||
)
|
||||
except (CosyVoiceError, ValueError) as e:
|
||||
# 合成失败退费
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预览失败退积分异常: {refund_err}")
|
||||
if isinstance(e, CosyVoiceError):
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS 合成失败: {e}") from e
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
@@ -191,23 +191,6 @@ def _find_duplicate_asset(
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _get_existing_asset_url(existing: Any, storage_service: Any) -> str:
|
||||
"""安全获取已存在素材的公网 URL,兼容 domain Asset(无 file_url 字段)和 ORM model。"""
|
||||
# Domain Asset 只有 storage_key 字段;ORM model 有 file_url 但存的也是 storage_key
|
||||
key = ""
|
||||
for attr in ("storage_key", "file_url"):
|
||||
v = getattr(existing, attr, None)
|
||||
if v:
|
||||
key = v
|
||||
break
|
||||
if not key:
|
||||
return ""
|
||||
try:
|
||||
return storage_service.get_url(key) or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _create_pending_asset(
|
||||
asset_repository,
|
||||
project_id,
|
||||
@@ -407,7 +390,7 @@ async def prepare_direct_upload(
|
||||
duplicated=True,
|
||||
skip_transfer=True,
|
||||
asset_id=existing.id,
|
||||
url=_get_existing_asset_url(existing, storage_service),
|
||||
url=existing.file_url or storage_service.get_url(existing.storage_key) or "",
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
"""爆款视频 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:用户确认/编辑文案后跑渲染(TTS+Seedance+上传),直到完成
|
||||
|
||||
意图分析已并入脚本生成一步完成,不再有独立的 intent_parsing 阶段或 wait_user_confirm 状态。
|
||||
|
||||
通用:
|
||||
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 进度推送
|
||||
端点:
|
||||
POST /api/v1/viral-video/generate 创建爆款视频任务
|
||||
GET /api/v1/viral-video/{job_id} 查询任务状态
|
||||
GET /api/v1/viral-video/history 历史记录
|
||||
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
|
||||
POST /api/v1/viral-video/{job_id}/confirm-intent 确认意图文案
|
||||
POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析
|
||||
GET /api/v1/viral-video/style-templates 获取风格模板列表
|
||||
WS /api/v1/viral-video/ws/{job_id}?token= WebSocket 进度推送(订阅 Redis pub/sub)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,15 +19,10 @@ 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,
|
||||
CreditsFormulaBreakdown,
|
||||
EstimateCreditsRequest,
|
||||
EstimateCreditsResponse,
|
||||
GenerateCopyRequest,
|
||||
RetryViralVideoRequest,
|
||||
ConfirmIntentRequest,
|
||||
CreateViralVideoRequest,
|
||||
StyleTemplateListResponse,
|
||||
StyleTemplateResponse,
|
||||
ViralVideoHistoryResponse,
|
||||
@@ -45,19 +35,7 @@ from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
SQLAlchemyViralVideoStyleTemplateRepository,
|
||||
)
|
||||
from packages.application.viral_video.constants import (
|
||||
API_FALLBACK_HARD_CONSTRAINTS,
|
||||
API_FALLBACK_NEGATIVE_PROMPTS,
|
||||
)
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits,
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
list_viral_video_models,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
from packages.domain.points_service import PointsService
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
from packages.shared.dashscope_client import get_dashscope_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,56 +45,6 @@ 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=分镜列表)拼装兼容结构。
|
||||
"""
|
||||
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 []
|
||||
if not copy_text and not sb:
|
||||
return None
|
||||
title = ""
|
||||
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": list(API_FALLBACK_HARD_CONSTRAINTS),
|
||||
"negative_prompts": list(API_FALLBACK_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,
|
||||
@@ -127,9 +55,8 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
persona_id=job.persona_id,
|
||||
viral_structure=job.viral_structure,
|
||||
marketing_purpose=job.marketing_purpose,
|
||||
tone=getattr(job, "tone", "") or "",
|
||||
bgm_preference=job.bgm_preference,
|
||||
duration=job.duration or 15,
|
||||
duration=job.duration,
|
||||
user_copy_text=job.user_copy_text,
|
||||
fusion_level=job.fusion_level,
|
||||
reference_audio_path=job.reference_audio_path,
|
||||
@@ -138,22 +65,9 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
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 "",
|
||||
language=getattr(job, "language", "zh-CN") or "zh-CN",
|
||||
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),
|
||||
credits_cost=job.credits_cost,
|
||||
error_msg=job.error_msg,
|
||||
retry_count=job.retry_count,
|
||||
started_at=job.started_at,
|
||||
@@ -171,385 +85,54 @@ def _get_style_repo(session: Session) -> SQLAlchemyViralVideoStyleTemplateReposi
|
||||
return SQLAlchemyViralVideoStyleTemplateRepository(session)
|
||||
|
||||
|
||||
def _settle_viral_video_credits(
|
||||
user_id: int,
|
||||
job,
|
||||
session: Session,
|
||||
*,
|
||||
action: str,
|
||||
old_duration: int | None = None,
|
||||
old_resolution: str | None = None,
|
||||
old_ratio: str | None = None,
|
||||
old_model: str | None = None,
|
||||
new_duration: int | None = None,
|
||||
new_resolution: str | None = None,
|
||||
new_ratio: str | None = None,
|
||||
new_model: str | None = None,
|
||||
) -> dict:
|
||||
"""爆款视频积分结算统一入口(confirm-copy / retry 共用)。
|
||||
|
||||
action 取值:
|
||||
- "deduct_first" 首次扣费(confirm 时还没付过)
|
||||
- "recompute" 参数变更重算:退旧扣新(confirm_copy 时传 old/new)
|
||||
- "delta" 参数变更差额结算:多退少补(retry 时传 old/new)
|
||||
返回 credits_info dict,包含 old_est / new_est / action / diff 等。
|
||||
积分不足时 raise HTTPException(402),调用方无需再捕获。
|
||||
"""
|
||||
from app.config import settings as _settings
|
||||
|
||||
info: dict = {"action": action, "old_est": 0.0, "new_est": 0.0, "diff": 0.0, "settled": False}
|
||||
if not _settings.points_enabled:
|
||||
return info
|
||||
|
||||
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
|
||||
float(getattr(job, "credits_cost", 0) or 0) > 0
|
||||
)
|
||||
|
||||
if action == "deduct_first":
|
||||
if already_paid:
|
||||
return info
|
||||
w, h = resolve_video_dimensions(
|
||||
getattr(job, "video_resolution", "720p") or "720p", job.video_ratio or "9:16"
|
||||
)
|
||||
est = calculate_viral_video_credits(int(job.duration or 15), w, h, job.video_model or "seedance-2.5")
|
||||
info["new_est"] = est
|
||||
svc = PointsService()
|
||||
res = svc.deduct_viral_video(user_id, est, 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} 积分,当前余额 {balance}",
|
||||
"required": est,
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = est
|
||||
job.credits_transaction_id = res.get("transaction_id", "") or ""
|
||||
info["settled"] = True
|
||||
info["diff"] = est
|
||||
return info
|
||||
|
||||
if action == "recompute":
|
||||
if not already_paid:
|
||||
# 等价于首次扣费
|
||||
return _settle_viral_video_credits(user_id, job, session, action="deduct_first")
|
||||
old_w, old_h = resolve_video_dimensions(old_resolution or "720p", old_ratio or "9:16")
|
||||
old_est = calculate_viral_video_credits(
|
||||
int(old_duration or 15), old_w, old_h, old_model or "seedance-2.5"
|
||||
)
|
||||
new_w, new_h = resolve_video_dimensions(
|
||||
getattr(job, "video_resolution", "720p") or "720p", job.video_ratio or "9:16"
|
||||
)
|
||||
new_est = calculate_viral_video_credits(
|
||||
int(job.duration or 15), new_w, new_h, job.video_model or "seedance-2.5"
|
||||
)
|
||||
info.update({"old_est": old_est, "new_est": new_est, "diff": new_est - old_est})
|
||||
svc = PointsService()
|
||||
if getattr(job, "credits_transaction_id", None):
|
||||
svc.refund_points(
|
||||
user_id=user_id,
|
||||
amount=float(job.credits_prepaid),
|
||||
source="viral_video",
|
||||
db=session,
|
||||
ref_id=job.credits_transaction_id,
|
||||
description="confirm-copy 参数变更退还旧预扣",
|
||||
)
|
||||
if new_est > 0:
|
||||
res = svc.deduct_viral_video(user_id, new_est, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {new_est} 积分,当前余额 {balance}",
|
||||
"required": new_est,
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = new_est
|
||||
job.credits_transaction_id = res.get("transaction_id", "") or ""
|
||||
info["settled"] = True
|
||||
return info
|
||||
|
||||
if action == "delta":
|
||||
eff_dur = int(new_duration if new_duration is not None else old_duration or 15)
|
||||
eff_res = new_resolution if new_resolution is not None else (old_resolution or "720p")
|
||||
eff_ratio = new_ratio if new_ratio is not None else (old_ratio or "9:16")
|
||||
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)
|
||||
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
diff = round(new_est - old_prepaid, 2)
|
||||
info.update({"old_est": old_prepaid, "new_est": new_est, "diff": diff})
|
||||
if abs(diff) < 0.01:
|
||||
return info
|
||||
svc = PointsService()
|
||||
if diff > 0:
|
||||
res = svc.deduct_viral_video(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)
|
||||
else:
|
||||
refund = round(-diff, 2)
|
||||
txn_id = getattr(job, "credits_transaction_id", "") or ""
|
||||
svc.refund_points(
|
||||
user_id=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)
|
||||
info["settled"] = True
|
||||
return info
|
||||
|
||||
raise ValueError(f"unknown action: {action}")
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/analyze-images", response_model=ViralVideoJobResponse)
|
||||
def analyze_images(
|
||||
request: AnalyzeImagesRequest,
|
||||
@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:
|
||||
"""v1.5 阶段1:创建任务并仅做图片/视频 VLM 分析,跑完后状态=image_analyzed。
|
||||
|
||||
前端拿到 image_analysis(商品名/品牌/特征/颜色/材质等结构化结果)展示给用户;
|
||||
用户填完营销参数后再调 /{id}/generate-copy 进入阶段2。
|
||||
"""
|
||||
"""创建爆款视频任务,入队 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),
|
||||
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 "",
|
||||
language=getattr(request, "language", "zh-CN") or "zh-CN",
|
||||
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,
|
||||
industry=request.industry,
|
||||
target_customer=request.target_customer,
|
||||
persona_id=request.persona_id,
|
||||
viral_structure=request.viral_structure,
|
||||
marketing_purpose=request.marketing_purpose,
|
||||
bgm_preference=request.bgm_preference,
|
||||
duration=request.duration,
|
||||
user_copy_text=request.user_copy_text,
|
||||
fusion_level=request.fusion_level,
|
||||
reference_audio_path=request.reference_audio_path,
|
||||
reference_video_url=request.reference_video_url,
|
||||
style_strength=request.style_strength,
|
||||
style_template_id=request.style_template_id,
|
||||
)
|
||||
|
||||
# 持久化
|
||||
repo.save(job)
|
||||
|
||||
# 入队 Celery 任务
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
|
||||
logger.info("[爆款视频][阶段1] analyze-images 入队: job_id=%s", job.id)
|
||||
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("[爆款视频][阶段1] analyze-images 入队失败: %s", e, exc_info=True)
|
||||
logger.error("[爆款视频] 入队失败: %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="无权操作此任务")
|
||||
# 允许首次进入(IMAGE_ANALYZED/PENDING)、失败重试(FAILED)、文案重新生成(COPY_GENERATED/COMPLETED)
|
||||
if job.status not in (
|
||||
ViralVideoStatus.IMAGE_ANALYZED,
|
||||
ViralVideoStatus.PENDING,
|
||||
ViralVideoStatus.FAILED,
|
||||
ViralVideoStatus.COPY_GENERATED,
|
||||
ViralVideoStatus.COMPLETED,
|
||||
):
|
||||
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能生成文案")
|
||||
|
||||
# 失败重试 / 重新生成:retry_count 自增
|
||||
if job.status in (ViralVideoStatus.FAILED, ViralVideoStatus.COPY_GENERATED, ViralVideoStatus.COMPLETED):
|
||||
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.tone = getattr(request, "tone", "") or job.tone
|
||||
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.language = getattr(request, "language", "") or job.language or "zh-CN"
|
||||
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="文案数据缺失,请先点击「生成文案」")
|
||||
|
||||
# Bug1 fix: 用户 confirm 时允许修改 video_model/video_resolution/video_ratio/duration
|
||||
old_duration = int(getattr(job, "duration", 15) or 15)
|
||||
old_resolution = getattr(job, "video_resolution", "720p") or "720p"
|
||||
old_ratio = getattr(job, "video_ratio", "9:16") or "9:16"
|
||||
old_model = getattr(job, "video_model", None) or "seedance-2.5"
|
||||
|
||||
if request.duration is not None:
|
||||
job.duration = max(5, min(30, int(request.duration)))
|
||||
if request.video_resolution is not None:
|
||||
job.video_resolution = request.video_resolution
|
||||
if request.video_ratio is not None:
|
||||
job.video_ratio = request.video_ratio
|
||||
if request.video_model is not None:
|
||||
job.video_model = request.video_model
|
||||
if request.voice_id is not None:
|
||||
job.voice_id = request.voice_id
|
||||
if request.voice_source is not None:
|
||||
job.voice_source = request.voice_source
|
||||
if getattr(request, "language", None) is not None:
|
||||
job.language = request.language
|
||||
|
||||
param_changed = (
|
||||
(request.duration is not None and int(request.duration) != old_duration)
|
||||
or (request.video_resolution is not None and request.video_resolution != old_resolution)
|
||||
or (request.video_ratio is not None and request.video_ratio != old_ratio)
|
||||
or (request.video_model is not None and request.video_model != old_model)
|
||||
)
|
||||
|
||||
# 积分预扣(PR2:统一走 _settle_viral_video_credits)
|
||||
if param_changed:
|
||||
credits_info = _settle_viral_video_credits(
|
||||
authenticated_user.user.id,
|
||||
job,
|
||||
session,
|
||||
action="recompute",
|
||||
old_duration=old_duration,
|
||||
old_resolution=old_resolution,
|
||||
old_ratio=old_ratio,
|
||||
old_model=old_model,
|
||||
)
|
||||
logger.info(
|
||||
"[爆款视频][confirm-copy] 参数变更,积分重算: old=%s new=%s job_id=%s",
|
||||
credits_info.get("old_est"),
|
||||
credits_info.get("new_est"),
|
||||
job.id,
|
||||
)
|
||||
else:
|
||||
_settle_viral_video_credits(authenticated_user.user.id, job, session, action="deduct_first")
|
||||
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)
|
||||
# TODO(PR2-followup): 入队失败应返回 HTTP 503 而非 mark_failed + 200,避免前端收到"成功"但 job 已 failed 的不一致状态
|
||||
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。
|
||||
"""
|
||||
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,
|
||||
@@ -584,17 +167,6 @@ def list_style_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,
|
||||
@@ -614,128 +186,76 @@ def get_viral_video_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="无权操作此任务")
|
||||
if job.status != ViralVideoStatus.FAILED:
|
||||
raise HTTPException(status_code=409, 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)
|
||||
)
|
||||
|
||||
# 写入新参数(即使不开 points 也要允许用户重试时改参数;delta 结算依赖这些新值)
|
||||
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
|
||||
|
||||
# PR2:统一走 _settle_viral_video_credits(delta 多退少补)
|
||||
if param_changed:
|
||||
credits_info = _settle_viral_video_credits(
|
||||
authenticated_user.user.id,
|
||||
job,
|
||||
session,
|
||||
action="delta",
|
||||
old_duration=old_duration,
|
||||
old_resolution=old_resolution,
|
||||
old_ratio=old_ratio,
|
||||
old_model=old_model,
|
||||
new_duration=new_duration,
|
||||
new_resolution=new_resolution,
|
||||
new_ratio=new_ratio,
|
||||
new_model=new_model,
|
||||
)
|
||||
if credits_info.get("settled"):
|
||||
logger.info(
|
||||
"[爆款视频][retry] 积分差额结算 job_id=%s diff=%.2f new_prepaid=%.2f",
|
||||
job.id,
|
||||
credits_info["diff"],
|
||||
job.credits_prepaid,
|
||||
)
|
||||
|
||||
# 重置状态(从阶段1重新开始,清空上一次产物避免脏数据)
|
||||
# 重置状态
|
||||
job.retry_count += 1
|
||||
job.status = ViralVideoStatus.PENDING
|
||||
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
|
||||
job.error_msg = ""
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
job.current_stage = ""
|
||||
job.phase_message = ""
|
||||
job.heartbeat_at = None
|
||||
# 重试时清空上次产物(参考 resume_from_image_analyzed 逻辑)
|
||||
job.copy_result = None
|
||||
job.storyboard = None
|
||||
job.generated_copy_text = ""
|
||||
job.result_video_url = ""
|
||||
job.pre_trusted_images = None
|
||||
job.credits_cost = 0.0
|
||||
repo.update(job)
|
||||
|
||||
# 重新入队:从图片分析阶段1重新跑
|
||||
# 重新入队
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_analyze", 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,
|
||||
)
|
||||
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
|
||||
logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d", job.id, job.retry_count)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
|
||||
# TODO(PR2-followup): 入队失败应返回 HTTP 503 而非 mark_failed + 200
|
||||
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,
|
||||
@@ -934,28 +454,13 @@ async def viral_video_websocket(websocket: WebSocket, job_id: str) -> None:
|
||||
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)
|
||||
# #P0: 初始快照必须包含前端重连/刷新所需的业务字段,
|
||||
# 结构对齐 worker 推送的 image_analyzed / copy_generated 事件。
|
||||
data: dict = {"status": status_val}
|
||||
ia = getattr(job, "image_analysis", None)
|
||||
if isinstance(ia, dict) and ia:
|
||||
data["image_analysis"] = ia
|
||||
cr = _build_copy_result(job)
|
||||
if isinstance(cr, dict) and cr:
|
||||
data["copy_result"] = cr
|
||||
gct = getattr(job, "generated_copy_text", "") or ""
|
||||
if gct:
|
||||
data["generated_copy_text"] = gct
|
||||
sb = getattr(job, "storyboard", None) or []
|
||||
if sb:
|
||||
data["storyboard"] = sb
|
||||
initial = {
|
||||
"type": "viral_video:progress",
|
||||
"job_id": job_id,
|
||||
"stage": _stage_from_status(job),
|
||||
"progress": _estimate_progress(job),
|
||||
"message": _initial_message(job),
|
||||
"data": data,
|
||||
"data": {"status": status_val},
|
||||
}
|
||||
await websocket.send_json(initial)
|
||||
# 已经终态 → 再发一条终态事件后立即关闭,避免占连接
|
||||
@@ -1000,8 +505,7 @@ def _job_status(job) -> str:
|
||||
_STATUS_STAGE = {
|
||||
"pending": "",
|
||||
"running": "",
|
||||
"image_analyzed": "image_analysis",
|
||||
"copy_generated": "review",
|
||||
"wait_user_confirm": "intent_parsing",
|
||||
"completed": "uploading",
|
||||
"failed": "",
|
||||
"cancelled": "",
|
||||
@@ -1010,8 +514,7 @@ _STATUS_STAGE = {
|
||||
_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,
|
||||
@@ -1020,8 +523,7 @@ _STATUS_PROGRESS = {
|
||||
_STATUS_MESSAGE = {
|
||||
"pending": "任务已创建,等待执行",
|
||||
"running": "任务执行中",
|
||||
"image_analyzed": "图片分析完成,等待填写营销参数",
|
||||
"copy_generated": "文案与分镜已生成,等待确认文案",
|
||||
"wait_user_confirm": "等待用户确认意图文案",
|
||||
"completed": "视频生成完成",
|
||||
"failed": "任务失败",
|
||||
"cancelled": "任务已取消",
|
||||
|
||||
@@ -124,15 +124,6 @@ class SmartCoverResponse(BaseModel):
|
||||
message: str = Field("", description="失败原因(如有)")
|
||||
|
||||
|
||||
class FinalizeRenderRequest(BaseModel):
|
||||
"""finalize 入库请求体(所有字段可选,缺省时用渲染任务自身状态)."""
|
||||
|
||||
cover_url: Optional[str] = Field(
|
||||
None, description="用户选定的封面公网 URL(必须是 OSS/HTTP(S) 公网地址,blob:/data: URL 无效)"
|
||||
)
|
||||
video_title: Optional[str] = Field(None, description="成片库展示标题;为空时用默认 AI数字人_{job_id[:8]}")
|
||||
|
||||
|
||||
class FinalizeRenderResponse(BaseModel):
|
||||
"""封面选好后点「完成」,正式入库成片库的响应."""
|
||||
|
||||
|
||||
@@ -13,13 +13,12 @@ from pydantic import BaseModel, Field
|
||||
class PointsBalanceResponse(BaseModel):
|
||||
"""积分余额 + 会员状态"""
|
||||
|
||||
balance: float = Field(..., description="当前积分余额")
|
||||
total_earned: float = Field(..., description="累计获得积分")
|
||||
total_spent: float = Field(..., description="累计消耗积分")
|
||||
balance: int = Field(..., description="当前积分余额")
|
||||
total_earned: int = Field(..., description="累计获得积分")
|
||||
total_spent: int = Field(..., description="累计消耗积分")
|
||||
is_member: bool = Field(default=False, description="是否付费会员")
|
||||
member_type: Optional[str] = Field(None, description="会员类型: monthly/quarterly/yearly")
|
||||
member_expires_at: Optional[datetime] = Field(None, description="会员到期时间")
|
||||
credits_enabled: bool = Field(default=False, description="积分系统是否启用(false=免费放行不扣分)")
|
||||
|
||||
|
||||
# ============ 流水 ============
|
||||
@@ -31,8 +30,8 @@ class PointsTransactionItem(BaseModel):
|
||||
id: str
|
||||
type: str = Field(..., description="类型: add/deduct")
|
||||
source: str = Field(..., description="来源场景")
|
||||
amount: float
|
||||
balance_after: float
|
||||
amount: int
|
||||
balance_after: int
|
||||
description: str = ""
|
||||
ref_id: str = ""
|
||||
created_at: Optional[str] = None
|
||||
@@ -100,9 +99,9 @@ class PointsCheckResponse(BaseModel):
|
||||
"""消费前余额检查响应"""
|
||||
|
||||
allowed: bool
|
||||
required_points: float
|
||||
current_balance: float
|
||||
remaining_after: float
|
||||
required_points: int
|
||||
current_balance: int
|
||||
remaining_after: int
|
||||
is_free_quota: bool = False
|
||||
|
||||
|
||||
@@ -113,7 +112,7 @@ class PointsDeductRequest(BaseModel):
|
||||
"""积分扣减请求"""
|
||||
|
||||
scene_key: str
|
||||
amount: float
|
||||
amount: int
|
||||
description: Optional[str] = ""
|
||||
ref_id: Optional[str] = ""
|
||||
|
||||
@@ -171,7 +170,7 @@ class MembershipStatusResponse(BaseModel):
|
||||
is_member: bool
|
||||
member_type: Optional[str] = None
|
||||
member_expires_at: Optional[datetime] = None
|
||||
points_balance: float
|
||||
points_balance: int
|
||||
max_resolution: str = Field(
|
||||
default="1080p",
|
||||
description="可用最高分辨率: 720p(free) / 1080p(paid)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""爆款视频 API schemas (v1.6 单次 Seedance 出片版)。"""
|
||||
"""爆款视频 API schemas。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,149 +6,81 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# -- 枚举常量 --
|
||||
# ── 枚举常量 ─────────────────────────────────────────────────────────────
|
||||
|
||||
VALID_FUSION_LEVELS = ("ai_full", "full_ai", "ai_polish", "user_primary")
|
||||
VALID_FUSION_LEVELS = ("ai_full", "ai_polish", "user_primary")
|
||||
VALID_STYLE_STRENGTHS = ("light", "medium", "strict")
|
||||
VALID_STAGES = (
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"script_generation",
|
||||
"intent_parsing",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"review",
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"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) --
|
||||
# ── Request Schemas ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ShotScript(BaseModel):
|
||||
"""逐镜头分镜。"""
|
||||
class CreateViralVideoRequest(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="转场方式:硬切/淡入淡出/叠化")
|
||||
voiceover: 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 AnalyzeImagesRequest(BaseModel):
|
||||
"""v1.5+ 阶段1:创建任务 + 图片/视频分析。"""
|
||||
|
||||
images: list[str] = Field(..., min_length=1, max_length=30)
|
||||
reference_video_url: str = ""
|
||||
style_template_id: str = ""
|
||||
style_strength: str = "medium"
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
video_resolution: str = "720p"
|
||||
duration: int = Field(default=15, ge=5, le=30)
|
||||
language: str = "zh-CN"
|
||||
|
||||
|
||||
class GenerateCopyRequest(BaseModel):
|
||||
"""v1.5+ 阶段2:填完营销参数,生成编导脚本。"""
|
||||
|
||||
industry: str = ""
|
||||
target_customer: str = ""
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
tone: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = Field(default=15, ge=5, le=30)
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = "ai_polish"
|
||||
reference_audio_path: str = ""
|
||||
reference_video_url: str = ""
|
||||
style_strength: str = "medium"
|
||||
style_template_id: str = ""
|
||||
style_guide: dict | None = None
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
language: str = "zh-CN"
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
video_resolution: str = "720p"
|
||||
images: list[str] = Field(..., min_length=1, max_length=20, description="产品图片 URL 列表")
|
||||
industry: str = Field(default="", description="行业")
|
||||
target_customer: str = Field(default="", description="目标客户描述")
|
||||
persona_id: str = Field(default="", description="人设 ID")
|
||||
viral_structure: str = Field(default="", description="爆款结构类型")
|
||||
marketing_purpose: str = Field(default="", description="营销目的")
|
||||
bgm_preference: str = Field(default="", description="BGM 偏好")
|
||||
duration: int = Field(default=30, ge=5, le=180, description="视频时长(秒)")
|
||||
user_copy_text: str = Field(default="", description="用户原始文案(我说你写)")
|
||||
fusion_level: str = Field(default="ai_polish", description="文案融合级别: ai_full/ai_polish/user_primary")
|
||||
reference_audio_path: str = Field(default="", description="参考音频路径")
|
||||
# v1.3 新增
|
||||
reference_video_url: str = Field(default="", description="参考爆款视频 URL")
|
||||
style_strength: str = Field(default="medium", description="风格强度: light/medium/strict")
|
||||
style_template_id: str = Field(default="", description="风格模板 ID")
|
||||
|
||||
@field_validator("fusion_level")
|
||||
@classmethod
|
||||
def _v_fl(cls, v: str) -> str:
|
||||
if v == "full_ai":
|
||||
return "ai_full"
|
||||
def _validate_fusion_level(cls, v: str) -> str:
|
||||
if v not in VALID_FUSION_LEVELS:
|
||||
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
|
||||
raise ValueError(f"fusion_level 必须是 {VALID_FUSION_LEVELS} 之一")
|
||||
return v
|
||||
|
||||
@field_validator("style_strength")
|
||||
@classmethod
|
||||
def _v_ss(cls, v: str) -> str:
|
||||
def _validate_style_strength(cls, v: str) -> str:
|
||||
if v not in VALID_STYLE_STRENGTHS:
|
||||
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
|
||||
raise ValueError(f"style_strength 必须是 {VALID_STYLE_STRENGTHS} 之一")
|
||||
return v
|
||||
|
||||
|
||||
class ConfirmCopyRequest(BaseModel):
|
||||
"""v1.5+ 阶段3:用户确认/编辑口播后开始渲染(TTS+单次Seedance)。"""
|
||||
class ConfirmIntentRequest(BaseModel):
|
||||
"""确认意图请求(confirm-intent)。"""
|
||||
|
||||
edited_copy: str = Field(default="", description="用户编辑后的口播文案;为空则用 AI 生成的 voiceover_script")
|
||||
video_model: str | None = Field(default=None, description="用户选定的视频生成模型(confirm时可选)")
|
||||
video_resolution: str | None = Field(default=None, description="用户选定的分辨率(confirm时可选)")
|
||||
video_ratio: str | None = Field(default=None, description="用户选定的比例(confirm时可选)")
|
||||
duration: int | None = Field(default=None, ge=5, le=30, description="用户选定的时长秒数(confirm时可选,5~30)")
|
||||
voice_id: str | None = Field(default=None, description="用户选定的音色ID(confirm时可选)")
|
||||
voice_source: str | None = Field(default=None, description="用户选定的音色来源(confirm时可选)")
|
||||
language: str | None = Field(default=None, description="用户选定的语言(confirm时可选)")
|
||||
confirmed_copy: str = Field(default="", description="用户确认/修改后的文案,为空表示使用 AI 生成的文案")
|
||||
adjustments: str = Field(default="", description="用户对 AI 文案的调整意见")
|
||||
|
||||
|
||||
class AnalyzeStyleRequest(BaseModel):
|
||||
"""触发参考视频风格分析请求。"""
|
||||
|
||||
reference_video_url: str = Field(..., description="参考视频 URL")
|
||||
style_template_id: str = ""
|
||||
style_template_id: str = Field(default="", description="风格模板 ID(可选覆盖)")
|
||||
|
||||
|
||||
# -- Response Schemas --
|
||||
# ── Response Schemas ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ViralVideoJobResponse(BaseModel):
|
||||
"""爆款视频任务响应(v1.6 包含 copy_result 编导脚本结构)。"""
|
||||
"""爆款视频任务响应。"""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
@@ -158,9 +90,8 @@ class ViralVideoJobResponse(BaseModel):
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
tone: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 15
|
||||
duration: int = 30
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = "ai_polish"
|
||||
reference_audio_path: str = ""
|
||||
@@ -169,27 +100,9 @@ class ViralVideoJobResponse(BaseModel):
|
||||
style_guide: dict | None = None
|
||||
style_template_id: str = ""
|
||||
status: str
|
||||
current_stage: str = (
|
||||
"" # 细粒度阶段 snake_case(analyzing_images/parsing_intent/generating_script/reviewing/tts_synthesizing/rendering_video/uploading)
|
||||
)
|
||||
phase_message: str = "" # 中文阶段提示文案(前端轮询/SSE 直接展示)
|
||||
image_analysis: dict | None = None
|
||||
# v1.6 编导脚本(推荐前端使用)
|
||||
copy_result: dict | None = None
|
||||
# v1.5 兼容字段
|
||||
storyboard: list | None = None
|
||||
generated_copy_text: str = ""
|
||||
# 音色/视频参数
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
language: str = "zh-CN"
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
pre_trusted_images: list[str] | None = None
|
||||
video_resolution: str = "720p"
|
||||
credits_prepaid: float = 0.0
|
||||
credits_cost: float = 0.0
|
||||
credits_cost: int = 0
|
||||
error_msg: str = ""
|
||||
retry_count: int = 0
|
||||
started_at: datetime | None = None
|
||||
@@ -199,11 +112,15 @@ class ViralVideoJobResponse(BaseModel):
|
||||
|
||||
|
||||
class ViralVideoHistoryResponse(BaseModel):
|
||||
"""历史记录列表响应。"""
|
||||
|
||||
items: list[ViralVideoJobResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class StyleTemplateResponse(BaseModel):
|
||||
"""风格模板响应。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
@@ -212,70 +129,25 @@ class StyleTemplateResponse(BaseModel):
|
||||
|
||||
|
||||
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 --
|
||||
# ── WebSocket 事件 Schema ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WSProgressEvent(BaseModel):
|
||||
"""WebSocket 进度推送事件。"""
|
||||
|
||||
type: str = "viral_video:progress"
|
||||
job_id: str
|
||||
stage: str
|
||||
|
||||
@@ -420,25 +420,19 @@ class AiAvatarRenderService:
|
||||
logger.exception("渲染任务异常 [%s]", job_id)
|
||||
raise
|
||||
|
||||
def _persist_to_library(
|
||||
self,
|
||||
job: AiAvatarRenderJob,
|
||||
cover_url: Optional[str] = None,
|
||||
video_title: Optional[str] = None,
|
||||
):
|
||||
def _persist_to_library(self, job: AiAvatarRenderJob, cover_url: Optional[str] = None):
|
||||
"""将渲染结果写入成片库,返回 GeneratedVideo 领域对象.
|
||||
|
||||
Args:
|
||||
job: 渲染任务(必须 status=completed 且 output_video_url 非空)
|
||||
cover_url: 可选的封面 URL 覆盖(finalize 时传入即优先使用,否则取 job.output_cover_url)
|
||||
video_title: 可选的成片标题覆盖;为空用默认 AI数字人_{job.id[:8]}
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
clip_name = (video_title or "").strip() or f"AI数字人_{job.id[:8]}"
|
||||
clip_name = f"AI数字人_{job.id[:8]}"
|
||||
# AI数字人入口是独立页面,前端可能不传 project_id(无项目概念),
|
||||
# 兜底为 "ai_avatar" 避免 DB 非空约束/查询问题;generation_task_id 用 render_job_id 便于反查。
|
||||
clip_project_id = (job.project_id or "").strip() or "ai_avatar"
|
||||
@@ -462,18 +456,11 @@ class AiAvatarRenderService:
|
||||
logger.info("[数字人渲染] 成片已入库: clip_id=%s render_job=%s", clip.id, job.id)
|
||||
return clip
|
||||
|
||||
def finalize_job(
|
||||
self,
|
||||
job_id: str,
|
||||
user_id: str,
|
||||
cover_url: Optional[str] = None,
|
||||
video_title: Optional[str] = None,
|
||||
):
|
||||
def finalize_job(self, job_id: str, user_id: str, cover_url: Optional[str] = None):
|
||||
"""用户在前端点「完成」后调用:将已 completed 的渲染任务正式入库到成片库.
|
||||
|
||||
- 必须 status=completed 才可调用
|
||||
- cover_url 若传入则优先使用并回写 job.output_cover_url;否则使用 job.output_cover_url(smart-cover/custom-cover 已写入)
|
||||
- video_title 若传入则作为成片库展示标题;否则使用默认 AI数字人_{job_id[:8]}
|
||||
- 幂等:已入库则返回已存在的 GeneratedVideo
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
@@ -512,7 +499,7 @@ class AiAvatarRenderService:
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
|
||||
return self._persist_to_library(job, cover_url=cover_url, video_title=video_title)
|
||||
return self._persist_to_library(job, cover_url=cover_url)
|
||||
|
||||
def _download_video(self, url: str) -> str:
|
||||
"""下载视频到临时文件."""
|
||||
|
||||
@@ -228,8 +228,6 @@ class GpuLipsyncService:
|
||||
lipsync_job_id: str = "",
|
||||
user_id: str = "",
|
||||
project_id: str = "",
|
||||
credits_prepaid: float = 0.0,
|
||||
credits_transaction_id: str = "",
|
||||
) -> GpuLipsyncTaskModel:
|
||||
task_id = str(uuid.uuid4())
|
||||
now = datetime.now(UTC)
|
||||
@@ -242,8 +240,6 @@ class GpuLipsyncService:
|
||||
audio_url=audio_url,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
credits_prepaid=float(credits_prepaid or 0.0),
|
||||
credits_transaction_id=str(credits_transaction_id or ""),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@@ -38,7 +38,6 @@ from sqlalchemy.orm import Session
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
from packages.config import get_api_settings
|
||||
from packages.domain import feature_pricing_service
|
||||
from packages.domain.sentence_timings import (
|
||||
compute_sentence_timings,
|
||||
probe_audio_duration,
|
||||
@@ -223,47 +222,7 @@ class LipsyncService:
|
||||
if timings:
|
||||
job.sentence_timings = timings
|
||||
|
||||
# 4. 检查是否走 Ditto(蚂蚁数字人,#2076):开关 + 配置完整
|
||||
use_ditto = False
|
||||
if self.settings.use_ditto_lipsync:
|
||||
try:
|
||||
from packages.application.ditto_service import get_ditto_client
|
||||
|
||||
ditto = get_ditto_client()
|
||||
if ditto.is_configured:
|
||||
use_ditto = True
|
||||
logger.info("[lipsync] 优先走 Ditto 蚂蚁数字人: job_id=%s", job.id)
|
||||
else:
|
||||
logger.info(
|
||||
"[lipsync] Ditto 开关已开但配置不完整(base_url=%s, template=%s),继续判断 GPU: job_id=%s",
|
||||
bool(ditto.base_url),
|
||||
bool(ditto.default_video_url),
|
||||
job.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[lipsync] Ditto 初始化失败,继续判断 GPU: job_id=%s err=%s", job.id, exc)
|
||||
|
||||
if use_ditto:
|
||||
try:
|
||||
# Ditto 使用预置人物模板视频,不用用户上传的 video_url;
|
||||
# 但保留用户 video_url 以便失败回退到 GPU/MediaKit。
|
||||
job.status = "processing"
|
||||
job.mediakit_task_id = "ditto:submitted"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
from app.tasks.lipsync_ditto import lipsync_ditto_process_async
|
||||
|
||||
lipsync_ditto_process_async.apply_async(args=(job.id, job.user_id))
|
||||
logger.info("[lipsync] Ditto 任务已异步派发: job_id=%s", job.id)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("[lipsync] Ditto 派发失败,回退 GPU/MediaKit: job_id=%s err=%s", job.id, exc)
|
||||
try:
|
||||
self.db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
|
||||
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
|
||||
use_gpu = False
|
||||
if self.settings.use_gpu_lipsync:
|
||||
try:
|
||||
@@ -409,8 +368,6 @@ class LipsyncService:
|
||||
lipsync_job_id=job.id,
|
||||
user_id=job.user_id,
|
||||
project_id=job.project_id,
|
||||
credits_prepaid=float(getattr(job, "credits_prepaid", 0) or 0),
|
||||
credits_transaction_id=str(getattr(job, "credits_transaction_id", "") or ""),
|
||||
)
|
||||
logger.info(
|
||||
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
|
||||
@@ -458,121 +415,6 @@ class LipsyncService:
|
||||
job.output_duration,
|
||||
)
|
||||
|
||||
# ── lip_sync 计费辅助 ────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _estimate_duration(
|
||||
*,
|
||||
audio_duration: Optional[float] = None,
|
||||
sentence_timings: Optional[list] = None,
|
||||
script_text: str = "",
|
||||
) -> float:
|
||||
"""预估音频/成片秒数。
|
||||
|
||||
优先级:audio_duration(预合成前端已 ffprobe)> timings 末句 end_time >
|
||||
脚本字数 / 5 字每秒 > 默认 10 秒。
|
||||
"""
|
||||
if audio_duration and float(audio_duration) > 0:
|
||||
return float(audio_duration)
|
||||
if sentence_timings:
|
||||
max_end = 0.0
|
||||
for item in sentence_timings:
|
||||
if isinstance(item, dict):
|
||||
end = item.get("end_time") or item.get("end") or 0.0
|
||||
else:
|
||||
end = 0.0
|
||||
try:
|
||||
max_end = max(max_end, float(end))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if max_end > 0:
|
||||
return max_end
|
||||
text = (script_text or "").strip()
|
||||
if text:
|
||||
return max(1.0, len(text) / 5.0)
|
||||
return 10.0
|
||||
|
||||
def _settle_lip_sync(self, job: LipsyncJobModel, actual_duration: float) -> None:
|
||||
"""按实际时长结算(首期只退不补:final < prepaid 退差额,> 不补)。
|
||||
|
||||
幂等:credits_cost 已 > 0 说明结算过,直接跳过。
|
||||
结算失败不阻塞业务(结果已产出),仅记录日志。
|
||||
"""
|
||||
try:
|
||||
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
if prepaid <= 0:
|
||||
return
|
||||
if float(getattr(job, "credits_cost", 0) or 0) > 0:
|
||||
return
|
||||
feature_cfg = feature_pricing_service.get_feature_config("lip_sync")
|
||||
unit_cost = float(feature_cfg.dynamic_unit_cost) if feature_cfg is not None else 0.0
|
||||
duration = float(actual_duration or 0.0)
|
||||
if duration <= 0:
|
||||
duration = self._estimate_duration(
|
||||
sentence_timings=job.sentence_timings,
|
||||
script_text=job.script_text,
|
||||
)
|
||||
final_price, _bd = feature_pricing_service.calculate_price("lip_sync", duration * unit_cost)
|
||||
final_price = round(float(final_price), 2)
|
||||
job.credits_cost = final_price
|
||||
if final_price < prepaid - 0.009:
|
||||
refund = round(prepaid - final_price, 2)
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
res = PointsService().refund_points(
|
||||
user_id=job.user_id,
|
||||
amount=refund,
|
||||
source="lip_sync",
|
||||
db=self.db,
|
||||
ref_id=str(job.credits_transaction_id or job.id),
|
||||
description="对口型结算退费",
|
||||
)
|
||||
if not res.get("success"):
|
||||
logger.warning(
|
||||
"[lip_sync] 结算退费失败 job_id=%s refund=%.2f(不阻塞)",
|
||||
job.id,
|
||||
refund,
|
||||
)
|
||||
# final > prepaid:首期只退不补,不补扣
|
||||
self.db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[lip_sync] 结算异常 job_id=%s(不阻塞结果)", job.id)
|
||||
try:
|
||||
self.db.rollback()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _refund_lip_sync(self, job: LipsyncJobModel) -> None:
|
||||
"""任务失败/取消时全额退还预扣积分(credits_cost 已结算则退实际未消耗部分)。"""
|
||||
try:
|
||||
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
if prepaid <= 0:
|
||||
return
|
||||
txn_id = str(getattr(job, "credits_transaction_id", "") or "")
|
||||
cost = float(getattr(job, "credits_cost", 0) or 0)
|
||||
refund = round(prepaid - cost, 2) if cost > 0 else round(prepaid, 2)
|
||||
if refund <= 0:
|
||||
return
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
res = PointsService().refund_points(
|
||||
user_id=job.user_id,
|
||||
amount=refund,
|
||||
source="lip_sync",
|
||||
db=self.db,
|
||||
ref_id=txn_id or job.id,
|
||||
description="对口型失败/取消退款",
|
||||
)
|
||||
if res.get("success"):
|
||||
job.credits_cost = prepaid # 标记已全额退回,防重复退
|
||||
self.db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[lip_sync] 退款异常 job_id=%s", job.id)
|
||||
try:
|
||||
self.db.rollback()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
@@ -624,35 +466,6 @@ class LipsyncService:
|
||||
if not isinstance(sentence_timings, list) or len(sentence_timings) == 0:
|
||||
raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput")
|
||||
|
||||
# 0.5 lip_sync 计费预扣(全局 points 开关 + 功能开关均开才扣)
|
||||
prepaid_credits = 0.0
|
||||
prepaid_txn_id = ""
|
||||
if feature_pricing_service.is_feature_enabled("lip_sync"):
|
||||
est_duration = self._estimate_duration(
|
||||
audio_duration=audio_duration,
|
||||
sentence_timings=sentence_timings,
|
||||
script_text=script_text,
|
||||
)
|
||||
feature_cfg = feature_pricing_service.get_feature_config("lip_sync")
|
||||
unit_cost = float(feature_cfg.dynamic_unit_cost) if feature_cfg is not None else 0.0
|
||||
dynamic_cost = est_duration * unit_cost
|
||||
prepaid_credits, _bd = feature_pricing_service.calculate_price("lip_sync", dynamic_cost)
|
||||
if prepaid_credits > 0:
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
res = PointsService().deduct_points(
|
||||
user_id=user_id,
|
||||
amount=prepaid_credits,
|
||||
source="lip_sync",
|
||||
db=self.db,
|
||||
description="对口型生成预扣",
|
||||
)
|
||||
if not res.get("success"):
|
||||
raise ValueError(
|
||||
f"积分不足:本次对口型需 {prepaid_credits:.2f} 积分,当前余额 {res.get('balance', 0)}"
|
||||
)
|
||||
prepaid_txn_id = str(res.get("transaction_id") or "")
|
||||
|
||||
# 1. 创建数据库记录
|
||||
job_id = str(uuid.uuid4())
|
||||
job = LipsyncJobModel(
|
||||
@@ -669,8 +482,6 @@ class LipsyncService:
|
||||
emotion=emotion or "",
|
||||
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
|
||||
status="tts_processing" if is_tts_mode else "pending",
|
||||
credits_prepaid=prepaid_credits,
|
||||
credits_transaction_id=prepaid_txn_id,
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
@@ -845,30 +656,6 @@ class LipsyncService:
|
||||
if job.status in (STATUS_COMPLETED, "failed"):
|
||||
return job
|
||||
|
||||
# Ditto 异步路径:mediakit_task_id 以 "ditto:" 开头,由 Celery 任务异步更新
|
||||
# 不做 MediaKit 轮询,只检查是否卡住太久(>10 分钟)则标失败
|
||||
if job.mediakit_task_id and job.mediakit_task_id.startswith("ditto:"):
|
||||
if job.status in ("processing", "submitted"):
|
||||
_now = datetime.now(UTC)
|
||||
_upd = job.updated_at
|
||||
if _upd is not None and _upd.tzinfo is None:
|
||||
_upd = _upd.replace(tzinfo=UTC)
|
||||
stale_minutes = 10
|
||||
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
|
||||
logger.warning(
|
||||
"Ditto 异步任务超时(>%d 分钟),标记失败: job_id=%s",
|
||||
stale_minutes,
|
||||
job_id,
|
||||
)
|
||||
job.status = "failed"
|
||||
job.error_message = f"Ditto 处理超时(>{stale_minutes} 分钟)"
|
||||
job.error_code = "DittoTimeout"
|
||||
job.completed_at = _now
|
||||
job.updated_at = _now
|
||||
self.db.commit()
|
||||
self._refund_lip_sync(job)
|
||||
return job
|
||||
|
||||
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
|
||||
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
|
||||
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
|
||||
@@ -890,8 +677,6 @@ class LipsyncService:
|
||||
job.completed_at = _now
|
||||
job.updated_at = _now
|
||||
self.db.commit()
|
||||
# lip_sync 超时全额退款
|
||||
self._refund_lip_sync(job)
|
||||
return job
|
||||
|
||||
# 未提交的任务不轮询
|
||||
@@ -917,8 +702,6 @@ class LipsyncService:
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
# lip_sync 结算(只退不补)
|
||||
self._settle_lip_sync(job, float(job.output_duration or 0.0))
|
||||
# 异步转存自家 OSS
|
||||
try:
|
||||
from app.tasks.lipsync_tts import persist_output_video_task
|
||||
@@ -936,8 +719,6 @@ class LipsyncService:
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(UTC)
|
||||
# lip_sync 失败全额退款(先退款再统一 commit)
|
||||
self._refund_lip_sync(job)
|
||||
else:
|
||||
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
@@ -1031,8 +812,6 @@ class LipsyncService:
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
# lip_sync 取消全额退款
|
||||
self._refund_lip_sync(job)
|
||||
self.db.refresh(job)
|
||||
|
||||
return job
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
存储路径与元信息约定),返回 asset_id —— 下游仍以 voice_library_id(实为
|
||||
audio asset id)消费,渲染链路零改动。
|
||||
|
||||
积分扣点与 /tts 合成端点保持一致(ai_voice 场景),失败退费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
@@ -30,10 +32,13 @@ from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POINTS_SCENE = "ai_voice"
|
||||
_SYNTH_TIMEOUT = 180.0 # 叙事配音在 HTTP 请求内同步等待,长文案分段合成时留出余量
|
||||
_CONTENT_TYPE_MAP = {"mp3": "audio/mpeg", "wav": "audio/wav", "pcm": "audio/pcm", "opus": "audio/opus"}
|
||||
|
||||
@@ -268,6 +273,24 @@ def prepare_narrative_voice(
|
||||
voice_clone_repository=voice_clone_repository,
|
||||
)
|
||||
|
||||
# 积分扣点(与 /tts 合成端点同口径),失败时在合成失败分支退费
|
||||
points_svc = PointsService() if points_enabled else None
|
||||
points_deducted = 0
|
||||
if points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(content) / 240))
|
||||
points_deducted = calculate_points_cost(
|
||||
_POINTS_SCENE,
|
||||
is_member=is_member,
|
||||
duration_minutes=est_minutes,
|
||||
member_type=member_type,
|
||||
)
|
||||
deduct_res = points_svc.deduct_points(user_id, points_deducted, _POINTS_SCENE, db)
|
||||
if not deduct_res["success"]:
|
||||
raise NarrativeError(
|
||||
f"积分不足,需要 {points_deducted} 积分,当前余额 {deduct_res['balance']}",
|
||||
status_code=402,
|
||||
)
|
||||
|
||||
use_case = CreateTTSJobUseCase(tts_repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
@@ -288,9 +311,19 @@ def prepare_narrative_voice(
|
||||
workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("标记叙事 TTS job 失败出错: job_id=%s", job.id, exc_info=True)
|
||||
if points_deducted and points_svc is not None:
|
||||
try:
|
||||
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("叙事 TTS 失败退积分异常: job_id=%s", job.id, exc_info=True)
|
||||
raise NarrativeError(f"配音合成失败:{e}", status_code=502) from e
|
||||
|
||||
if not job.is_completed:
|
||||
if points_deducted and points_svc is not None:
|
||||
try:
|
||||
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("叙事 TTS 未完成退积分异常: job_id=%s", job.id, exc_info=True)
|
||||
raise NarrativeError("配音合成未完成,请稍后重试", status_code=504)
|
||||
|
||||
asset = _save_tts_job_as_voice_asset(
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
"""Ditto 蚂蚁数字人口型异步任务 — #2076.
|
||||
|
||||
把 Ditto 同步 HTTP 调用(30-120s)从 API 请求移到 Celery 后台执行:
|
||||
1. 加载 LipsyncJob
|
||||
2. 确定驱动视频:用户上传的 video_url 优先,无则用 settings.ditto_default_video_url 兜底
|
||||
3. 视频时长对齐:若视频 < 音频+2s,用 ffmpeg 循环视频到足够长度后上传临时文件
|
||||
4. 调 DittoClient.generate_and_persist
|
||||
5. 成功:标记 completed,写入 output_video_url(Ditto 输出自带音频,无需二次混流/超分)
|
||||
6. 失败:回退 GPU MuseTalk → 再失败回退 MediaKit
|
||||
|
||||
注意:
|
||||
- 保留 MuseTalk 代码不动;Ditto 优先,失败按原链路兜底
|
||||
- Ditto 默认使用用户上传的视频作为驱动模板,default_video_url 仅作兜底
|
||||
- 用户视频短于音频时,ffmpeg stream_loop 循环到音频时长+2s余量
|
||||
- 不传 GFPGAN 超分,不需要 ffmpeg 音视频混流(Ditto 输出已带音视频)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DITTO_URL_TTL_SECONDS = 7 * 24 * 3600 # Ditto 结果 OSS URL 7 天有效
|
||||
_VIDEO_LOOP_MARGIN_SECONDS = 2.0 # 循环视频时比音频多留 2 秒余量
|
||||
_MAX_VIDEO_PREPROCESS_SIZE = 200 * 1024 * 1024 # 用户视频最大 200MB
|
||||
|
||||
|
||||
def _get_db_session() -> Session:
|
||||
try:
|
||||
from worker_app.db import SessionLocal # type: ignore
|
||||
except ImportError:
|
||||
from app.db import SessionLocal # type: ignore
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
def _sign_media_url(url: str) -> str:
|
||||
"""对自家 OSS URL 签 7 天预签名。"""
|
||||
if not url:
|
||||
return url
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return url
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
host = urlparse(url).netloc.lower()
|
||||
if not own_host or host != own_host:
|
||||
return url
|
||||
return storage.get_download_url(url, expires_seconds=_DITTO_URL_TTL_SECONDS)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
|
||||
def _probe_media_duration(path_or_bytes, *, is_bytes: bool = False) -> float:
|
||||
"""用 ffprobe 探测视频/音频时长(秒);失败返回 0。
|
||||
|
||||
Args:
|
||||
path_or_bytes: 文件路径(str) 或 字节数据(bytes)
|
||||
is_bytes: 传入的是 bytes 还是文件路径
|
||||
"""
|
||||
tmp_path = None
|
||||
try:
|
||||
if is_bytes:
|
||||
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as tmp:
|
||||
tmp.write(path_or_bytes)
|
||||
tmp_path = tmp.name
|
||||
target = tmp_path
|
||||
else:
|
||||
target = path_or_bytes
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
target,
|
||||
],
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=15,
|
||||
)
|
||||
return float(out.decode().strip() or 0)
|
||||
except Exception as exc:
|
||||
logger.warning("[ditto_task] ffprobe 失败: %s", exc)
|
||||
return 0.0
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _probe_video_duration(video_bytes: bytes) -> float:
|
||||
"""用 ffprobe 探测视频时长(秒);失败返回 0。"""
|
||||
return _probe_media_duration(video_bytes, is_bytes=True)
|
||||
|
||||
|
||||
def _prepare_driver_video(
|
||||
*,
|
||||
user_video_url: str,
|
||||
default_video_url: str,
|
||||
audio_duration: float,
|
||||
job_id: str,
|
||||
user_id: str,
|
||||
) -> tuple[str, bool]:
|
||||
"""准备传给 Ditto 的驱动视频 URL。
|
||||
|
||||
逻辑:
|
||||
1. 优先使用用户上传的视频(user_video_url),无则用 default_video_url 兜底
|
||||
2. 下载视频,探测时长
|
||||
3. 若视频时长 >= 音频时长+2s 余量:直接用原 URL(签名后)
|
||||
4. 若视频时长 < 音频时长+2s:ffmpeg stream_loop 循环到目标时长,上传临时 OSS,返回临时 URL
|
||||
5. 任何异常:回退到 default_video_url(兜底)
|
||||
|
||||
Returns:
|
||||
(video_url_for_ditto, is_temporary) — is_temporary=True 表示 URL 是本次临时生成的
|
||||
"""
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
from packages.shared.url_security import safe_download_bytes
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
|
||||
# 1. 选择源 URL(用户视频优先)
|
||||
source_url = user_video_url or default_video_url
|
||||
source_label = "user" if user_video_url else "default"
|
||||
if not source_url:
|
||||
raise RuntimeError("无可用驱动视频(用户视频和默认模板都为空)")
|
||||
|
||||
# 2. 下载视频
|
||||
try:
|
||||
# 用户视频需要签名才能下载
|
||||
signed_source = _sign_media_url(source_url) if user_video_url else source_url
|
||||
video_bytes = safe_download_bytes(
|
||||
signed_source,
|
||||
purpose="ditto-driver-video",
|
||||
max_size=_MAX_VIDEO_PREPROCESS_SIZE,
|
||||
allowed_mime_types=("video/mp4", "video/quicktime", "video/x-msvideo", "video/webm"),
|
||||
timeout=60,
|
||||
)
|
||||
except Exception as dl_exc:
|
||||
logger.warning(
|
||||
"[ditto_task] 下载驱动视频失败(%s),回退默认模板: job=%s err=%s",
|
||||
source_label,
|
||||
job_id,
|
||||
dl_exc,
|
||||
)
|
||||
if user_video_url and default_video_url:
|
||||
return default_video_url, False
|
||||
raise
|
||||
|
||||
# 3. 探测视频时长
|
||||
video_duration = _probe_media_duration(video_bytes, is_bytes=True)
|
||||
target_duration = audio_duration + _VIDEO_LOOP_MARGIN_SECONDS
|
||||
|
||||
# 4. 视频足够长:直接用签名后的原 URL
|
||||
if video_duration >= target_duration:
|
||||
logger.info(
|
||||
"[ditto_task] 驱动视频足够长(%.2fs >= %.2fs),直接使用: job=%s source=%s",
|
||||
video_duration,
|
||||
target_duration,
|
||||
job_id,
|
||||
source_label,
|
||||
)
|
||||
return _sign_media_url(source_url) if user_video_url else source_url, False
|
||||
|
||||
# 5. 视频不够长:ffmpeg 循环到目标时长
|
||||
logger.info(
|
||||
"[ditto_task] 驱动视频不够长(%.2fs < %.2fs),ffmpeg 循环延长: job=%s",
|
||||
video_duration,
|
||||
target_duration,
|
||||
job_id,
|
||||
)
|
||||
looped_path = None
|
||||
try:
|
||||
# 写临时文件
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as src_tmp:
|
||||
src_tmp.write(video_bytes)
|
||||
src_path = src_tmp.name
|
||||
looped_fd, looped_path = tempfile.mkstemp(suffix=".mp4")
|
||||
os.close(looped_fd)
|
||||
|
||||
# ffmpeg: stream_loop -1 循环输入,-t 截到目标时长
|
||||
# 使用 -c copy 快速复制流(不重编码),速度快无画质损失
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
src_path,
|
||||
"-t",
|
||||
str(target_duration),
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
looped_path,
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
|
||||
except subprocess.CalledProcessError:
|
||||
# copy 模式失败(编码不兼容),回退重编码
|
||||
logger.warning("[ditto_task] stream_loop copy 失败,回退重编码: job=%s", job_id)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
src_path,
|
||||
"-t",
|
||||
str(target_duration),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
looped_path,
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
|
||||
|
||||
# 上传临时 OSS
|
||||
looped_key = f"ditto-tmp/{user_id}/{job_id}_looped.mp4"
|
||||
with open(looped_path, "rb") as f:
|
||||
tmp_url = storage.upload_file(
|
||||
f,
|
||||
looped_key,
|
||||
content_type="video/mp4",
|
||||
)
|
||||
# 临时文件需要签名(private bucket)
|
||||
signed_tmp_url = _sign_media_url(tmp_url)
|
||||
logger.info(
|
||||
"[ditto_task] 循环视频已上传: job=%s key=%s dur=%.2fs",
|
||||
job_id,
|
||||
looped_key,
|
||||
target_duration,
|
||||
)
|
||||
return signed_tmp_url, True
|
||||
|
||||
except Exception as loop_exc:
|
||||
logger.warning(
|
||||
"[ditto_task] 视频循环处理失败,回退直接使用(Ditto 侧处理): job=%s err=%s",
|
||||
job_id,
|
||||
loop_exc,
|
||||
)
|
||||
# 兜底:直接用原视频(交给 Ditto 侧处理时长不一致)
|
||||
return _sign_media_url(source_url) if user_video_url else source_url, False
|
||||
finally:
|
||||
for p in (locals().get("src_path"), looped_path):
|
||||
if p and os.path.exists(p):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _refund_lip_sync(db: Session, job: "LipsyncJobModel") -> None:
|
||||
"""Ditto 失败/取消时全额退款(复用 lipsync_service 的退款逻辑)。"""
|
||||
try:
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
LipsyncService(db)._refund_lip_sync(job)
|
||||
except Exception:
|
||||
logger.exception("[ditto_task] lip_sync 退款异常 job_id=%s", job.id)
|
||||
|
||||
|
||||
def _settle_lip_sync(db: Session, job: "LipsyncJobModel", duration: float) -> None:
|
||||
"""Ditto 成功后按实际时长结算。"""
|
||||
try:
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
LipsyncService(db)._settle_lip_sync(job, duration)
|
||||
except Exception:
|
||||
logger.exception("[ditto_task] lip_sync 结算异常 job_id=%s(不阻塞)", job.id)
|
||||
|
||||
|
||||
def _fallback_to_gpu_then_mediakit(db: Session, job: "LipsyncJobModel") -> None:
|
||||
"""Ditto 失败后:优先回退 GPU MuseTalk,再回退 MediaKit 云端。
|
||||
|
||||
复用 lipsync_service 现有路径逻辑以保证兜底一致性。
|
||||
"""
|
||||
# 先尝试走 GPU MuseTalk(若可用)
|
||||
try:
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
|
||||
|
||||
gpu_svc = GpuLipsyncService(db)
|
||||
if gpu_svc.has_available_worker():
|
||||
logger.info("[ditto_task] 回退 GPU MuseTalk: job_id=%s", job.id)
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db)
|
||||
storage = _shared_storage()
|
||||
persisted_audio = None
|
||||
try:
|
||||
persisted_audio = svc._persist_external_audio_for_gpu(job=job, storage=storage)
|
||||
except Exception as exc:
|
||||
logger.warning("[ditto_task] GPU 外部音频转存失败: %s", exc)
|
||||
audio_url_for_task = persisted_audio or job.audio_url
|
||||
gpu_task = gpu_svc.create_task(
|
||||
video_url=job.video_url,
|
||||
audio_url=audio_url_for_task,
|
||||
lipsync_job_id=job.id,
|
||||
user_id=job.user_id,
|
||||
)
|
||||
if gpu_task is not None:
|
||||
job.mediakit_task_id = f"gpu:{gpu_task.id}"
|
||||
job.status = "processing"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
|
||||
return
|
||||
db.rollback()
|
||||
except Exception as exc:
|
||||
logger.warning("[ditto_task] GPU MuseTalk 回退失败,转 MediaKit: %s", exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 最后兜底:MediaKit 云端
|
||||
try:
|
||||
from app.services.mediakit_client import get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
video_url = _sign_media_url(job.video_url)
|
||||
signed_audio_url = _sign_media_url(job.audio_url)
|
||||
result = client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=signed_audio_url,
|
||||
enable_video_loop=job.enable_video_loop,
|
||||
client_token=job.id,
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info("[ditto_task] 已回退 MediaKit: job_id=%s task_id=%s", job.id, result["task_id"])
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"Ditto/GPU/MediaKit 均失败: {exc}"
|
||||
job.error_code = "AllBackendsFailed"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.error("[ditto_task] 所有兜底均失败: job_id=%s err=%s", job.id, exc)
|
||||
|
||||
|
||||
def _shared_storage():
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
return get_shared_storage_service()
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="lipsync_ditto_process_async",
|
||||
bind=True,
|
||||
max_retries=0,
|
||||
acks_late=True,
|
||||
time_limit=600,
|
||||
soft_time_limit=540,
|
||||
)
|
||||
def lipsync_ditto_process_async(self, job_id: str, user_id: str) -> None:
|
||||
"""异步调用 Ditto 生成口型视频。
|
||||
|
||||
Args:
|
||||
job_id: LipsyncJob ID
|
||||
user_id: 用户 ID
|
||||
"""
|
||||
from packages.application.ditto_emotion_service import get_ditto_emotion_service
|
||||
from packages.application.ditto_service import DittoError, get_ditto_client
|
||||
from packages.config import get_api_settings
|
||||
from packages.domain.sentence_timings import probe_audio_duration
|
||||
from packages.shared.url_security import safe_download_bytes
|
||||
|
||||
db: Session = _get_db_session()
|
||||
job: Optional[LipsyncJobModel] = None
|
||||
prepared_video_url: str = ""
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
|
||||
if job is None:
|
||||
logger.error("[ditto_task] job 不存在: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
if job.status != "processing":
|
||||
logger.warning(
|
||||
"[ditto_task] job 状态异常(非 processing),跳过: job_id=%s status=%s",
|
||||
job_id,
|
||||
job.status,
|
||||
)
|
||||
return
|
||||
|
||||
audio_url = job.audio_url or ""
|
||||
script = job.script_text or ""
|
||||
user_video_url = job.video_url or ""
|
||||
if not audio_url:
|
||||
raise DittoError("job.audio_url 为空,无法调用 Ditto", code="InvalidParam")
|
||||
|
||||
settings = get_api_settings()
|
||||
default_video_url = settings.ditto_default_video_url or ""
|
||||
|
||||
logger.info(
|
||||
"[ditto_task] 开始 Ditto 生成: job_id=%s has_user_video=%s script_len=%d",
|
||||
job_id,
|
||||
bool(user_video_url),
|
||||
len(script),
|
||||
)
|
||||
|
||||
# ── 0. 探测音频时长(情绪分析 + 视频循环都需要)──
|
||||
audio_duration = 0.0
|
||||
audio_bytes_for_probe = None
|
||||
try:
|
||||
audio_bytes_for_probe = safe_download_bytes(
|
||||
audio_url,
|
||||
allowed_mime_types=("audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp3"),
|
||||
timeout=30,
|
||||
)
|
||||
audio_duration = probe_audio_duration(audio_bytes_for_probe)
|
||||
logger.info("[ditto_task] 音频时长: %.2fs", audio_duration)
|
||||
except Exception as audio_exc:
|
||||
logger.warning("[ditto_task] 音频时长探测失败: %s", audio_exc)
|
||||
audio_duration = 0.0
|
||||
|
||||
# ── 1. LLM 情绪分析(生成 emo_timeline)──
|
||||
emo_timeline = ""
|
||||
try:
|
||||
emo_svc = get_ditto_emotion_service()
|
||||
if emo_svc.enabled and script and audio_duration > 0:
|
||||
sentence_timings = getattr(job, "sentence_timings", None)
|
||||
emo_timeline = emo_svc.build_timeline(
|
||||
text=script,
|
||||
audio_duration=audio_duration,
|
||||
sentence_timings=sentence_timings,
|
||||
)
|
||||
if emo_timeline:
|
||||
logger.info("[ditto_task] 情绪时间线已生成: segments≈%d", len(emo_timeline) // 50)
|
||||
except Exception as emo_exc:
|
||||
logger.warning("[ditto_task] 情绪分析异常(降级中性): %s", emo_exc)
|
||||
emo_timeline = ""
|
||||
|
||||
# ── 2. 准备驱动视频(用户视频优先,必要时循环延长)──
|
||||
prepared_video_url, _is_tmp = _prepare_driver_video(
|
||||
user_video_url=user_video_url,
|
||||
default_video_url=default_video_url,
|
||||
audio_duration=audio_duration if audio_duration > 0 else 10.0, # 探测失败时按10s估
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# ── 3. 调用 Ditto ──
|
||||
client = get_ditto_client()
|
||||
result = client.generate_and_persist(
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
audio_url=audio_url,
|
||||
script=script,
|
||||
video_url=prepared_video_url,
|
||||
emo_timeline=emo_timeline,
|
||||
)
|
||||
|
||||
# Ditto 返回的 MP4 自带音频,签名 OSS URL(7天有效)后标记完成
|
||||
job.output_video_url = _sign_media_url(result.video_url)
|
||||
# 探测时长(用于计费)
|
||||
duration = _probe_video_duration(result.video_bytes)
|
||||
if duration <= 0:
|
||||
# 兜底:按音频时长估算
|
||||
if audio_bytes_for_probe is not None:
|
||||
try:
|
||||
duration = probe_audio_duration(audio_bytes_for_probe)
|
||||
except Exception:
|
||||
duration = 0.0
|
||||
if duration <= 0 and audio_duration > 0:
|
||||
duration = audio_duration
|
||||
job.output_duration = duration
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[ditto_task] Ditto 完成: job_id=%s url=%s duration=%.2fs rtf=%.2f frames=%d",
|
||||
job_id,
|
||||
result.video_url[:100],
|
||||
duration,
|
||||
result.rtf,
|
||||
result.frames,
|
||||
)
|
||||
_settle_lip_sync(db, job, duration)
|
||||
|
||||
except DittoError as exc:
|
||||
logger.error("[ditto_task] Ditto 失败,回退: job_id=%s code=%s err=%s", job_id, exc.code, exc)
|
||||
if job is not None:
|
||||
try:
|
||||
db.rollback()
|
||||
job = db.query(type(job)).filter_by(id=job_id).first() if hasattr(job, "id") else job
|
||||
_fallback_to_gpu_then_mediakit(db, job)
|
||||
except Exception as fallback_exc:
|
||||
logger.exception("[ditto_task] 回退也失败 job_id=%s err=%s", job_id, fallback_exc)
|
||||
try:
|
||||
if job:
|
||||
job.status = "failed"
|
||||
job.error_message = f"Ditto 失败且回退异常: {exc}; fallback: {fallback_exc}"
|
||||
job.error_code = "FallbackError"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.exception("[ditto_task] 未预期异常: job_id=%s err=%s", job_id, exc)
|
||||
if job is not None:
|
||||
try:
|
||||
db.rollback()
|
||||
job = db.query(type(job)).filter_by(id=job_id).first()
|
||||
_fallback_to_gpu_then_mediakit(db, job)
|
||||
except Exception as fallback_exc:
|
||||
logger.exception("[ditto_task] 回退也失败 job_id=%s err=%s", job_id, fallback_exc)
|
||||
try:
|
||||
if job:
|
||||
job.status = "failed"
|
||||
job.error_message = f"Ditto 异常: {exc}"
|
||||
job.error_code = "DittoAsyncError"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
@@ -104,7 +104,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info("[lipsync_gpu_async] GPU 任务已被用户取消: job_id=%s", job_id)
|
||||
_refund_lip_sync(db, job)
|
||||
return
|
||||
|
||||
if final_task.status != "done":
|
||||
@@ -142,7 +141,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
|
||||
job_id,
|
||||
job.output_duration,
|
||||
)
|
||||
_settle_lip_sync(db, job, final_task)
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
|
||||
try:
|
||||
@@ -159,33 +157,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
|
||||
db.close()
|
||||
|
||||
|
||||
def _settle_lip_sync(db: Session, job: LipsyncJobModel, gpu_task) -> None:
|
||||
"""GPU 成功后结算:同步 credits_cost 到 gpu 任务并按实际时长多退少不补。"""
|
||||
try:
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
# GPU 任务表先同步结算结果(标记用)
|
||||
LipsyncService._settle_lip_sync(job, float(getattr(gpu_task, "result_duration", 0) or 0.0))
|
||||
gpu_task.credits_cost = float(job.credits_cost or 0.0)
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[lipsync_gpu_async] lip_sync 结算异常 job_id=%s(不阻塞)", job.id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _refund_lip_sync(db: Session, job: LipsyncJobModel) -> None:
|
||||
"""GPU 取消/失败路径全额退款。"""
|
||||
try:
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
LipsyncService(db)._refund_lip_sync(job)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[lipsync_gpu_async] lip_sync 退款异常 job_id=%s", job.id)
|
||||
|
||||
|
||||
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
|
||||
"""GPU 失败时回退到 MediaKit 云端渲染。"""
|
||||
try:
|
||||
|
||||
@@ -261,33 +261,7 @@ def tts_synthesize_and_submit(
|
||||
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
|
||||
)
|
||||
|
||||
# 3. 优先走 Ditto(#2076):开关打开且配置完整时,派发 Ditto 异步任务,不再走 MediaKit
|
||||
ditto_dispatched = False
|
||||
try:
|
||||
from packages.config import get_api_settings as _get_settings
|
||||
|
||||
_settings = _get_settings()
|
||||
if _settings.use_ditto_lipsync and _settings.ditto_api_base_url and _settings.ditto_default_video_url:
|
||||
from app.tasks.lipsync_ditto import lipsync_ditto_process_async
|
||||
|
||||
job.status = "processing"
|
||||
job.mediakit_task_id = "ditto:tts-submitted"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
lipsync_ditto_process_async.apply_async(args=(job_id, user_id))
|
||||
logger.info("[lipsync_tts] TTS 完成,已派发 Ditto 任务: job_id=%s", job_id)
|
||||
ditto_dispatched = True
|
||||
except Exception as _ditto_err:
|
||||
logger.warning("[lipsync_tts] Ditto 派发失败,回退 MediaKit: job_id=%s err=%s", job_id, _ditto_err)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if ditto_dispatched:
|
||||
return
|
||||
|
||||
# 4. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
|
||||
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
|
||||
audio_url = _sign_media_url(job.audio_url)
|
||||
video_url = _sign_media_url(job.video_url)
|
||||
|
||||
|
||||
@@ -11,34 +11,12 @@ const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".leng
|
||||
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
if (!apiOrigin) return
|
||||
// 单一通用路由(Playwright 按注册逆序匹配,故不拆分多个 glob 以免相互遮蔽)
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
try {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
// /points/balance:Staging 后端积分总开关关闭(credits_enabled=false,免费放行)。
|
||||
// 旧 bundle 部署的接口暂未返回该字段、且预校验只看余额,这里同时补开关与余额,
|
||||
// 以验证“免费期不拦截生成”;新后端+新前端部署后读 credits_enabled=false 直接放行,
|
||||
// 余额被忽略,此补丁随之成为 no-op。
|
||||
if (sourceUrl.pathname.endsWith("/points/balance")) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: { ...body, credits_enabled: false, balance: 999999 },
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({ response })
|
||||
} catch {
|
||||
// 测试收尾时页面/上下文可能已关闭,忽略仍在途的请求,避免误判为失败
|
||||
try {
|
||||
await route.abort()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,13 +45,7 @@ async function loginWithRetry(
|
||||
async function setupFreshUser(
|
||||
request: APIRequestContext,
|
||||
label: string,
|
||||
): Promise<{
|
||||
token: string
|
||||
user: Record<string, unknown>
|
||||
libraryId: string
|
||||
assetId: string
|
||||
suffix: string
|
||||
}> {
|
||||
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const email = `e2e-${label}-${suffix}@example.com`
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -81,19 +53,7 @@ async function setupFreshUser(
|
||||
})
|
||||
const token = await loginWithRetry(request, email, PASSWORD)
|
||||
const auth = { Authorization: `Bearer ${token}` }
|
||||
const me = await request.get(`${apiBase}/auth/me`, { headers: auth })
|
||||
expect(me.ok(), `fetch profile: ${await me.text()}`).toBeTruthy()
|
||||
const user = (await me.json()) as Record<string, unknown>
|
||||
|
||||
// 预置一条文案:标题下拉候选来自文案库,新用户为空会导致无法选中标题
|
||||
await request.post(`${apiBase}/scripts`, {
|
||||
headers: auth,
|
||||
data: {
|
||||
title: `测试标题 ${suffix}`,
|
||||
content: `测试文案内容 ${suffix}`,
|
||||
tags: ["e2e"],
|
||||
},
|
||||
})
|
||||
const proj = await request.post(`${apiBase}/projects`, {
|
||||
headers: auth,
|
||||
data: { name: `Smoke ${label} ${suffix}` },
|
||||
@@ -133,7 +93,7 @@ async function setupFreshUser(
|
||||
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
|
||||
)
|
||||
.toBe("ready")
|
||||
return { token, user, libraryId, assetId, suffix }
|
||||
return { token, libraryId, assetId, suffix }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +112,7 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
|
||||
test.setTimeout(600_000)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
const { token, user, suffix } = await setupFreshUser(request, "random")
|
||||
const { token, suffix } = await setupFreshUser(request, "random")
|
||||
const authHeader = { Authorization: `Bearer ${token}` }
|
||||
|
||||
// 确保默认模板存在(智能剪辑页依赖模板)
|
||||
@@ -166,24 +126,13 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
expect(templates.length).toBeGreaterThan(0)
|
||||
|
||||
// 注入登录态 + 路由 API
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
window.localStorage.setItem("access_token", token)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: {
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ token, user },
|
||||
)
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ──
|
||||
@@ -229,28 +178,16 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
|
||||
|
||||
// ── Step 2:选择素材 ──────────────────────────────────────────
|
||||
await expect(page.getByText(/选择素材/).first()).toBeVisible({ timeout: 10000 })
|
||||
// 卡片中心是播放按钮(stopPropagation 仅播放不选中),点右上角空白处完成选中
|
||||
await page
|
||||
.getByTestId("material-card")
|
||||
.first()
|
||||
.click({ position: { x: 70, y: 12 } })
|
||||
await expect(page.getByText(/已选 1 个素材|已选[^0]*[1-9]/)).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
await page.getByTestId("material-card").first().click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 3:填写标题 ──────────────────────────────────────────
|
||||
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
|
||||
await expect(page.getByText(/选择标题/).first()).toBeVisible({ timeout: 10000 })
|
||||
// 标题为 antd AutoComplete(combobox),真实 input 带 placeholder
|
||||
// antd AutoComplete combobox:真实可输入元素是 .ant-select-selection-search-input,
|
||||
// 灰色提示语是单独的 placeholder span(input 自身无 placeholder 属性)
|
||||
// 标题候选来自文案库(setupFreshUser 已预置一条文案)。
|
||||
// combobox 的自由输入会在失焦时被 rc-select 重置,必须从下拉选中才提交,
|
||||
// 因此聚焦输入框 → 点击候选选项
|
||||
const seededTitle = `测试标题 ${suffix}`
|
||||
const titleBox = page.locator(".ant-select-selection-search-input:visible").first()
|
||||
await titleBox.click()
|
||||
await page.locator(".ant-select-item-option", { hasText: seededTitle }).first().click()
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
||||
await titleInput.fill(`测试随机剪辑 ${suffix}`)
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
@@ -267,11 +204,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
await confirmBtn.click()
|
||||
const taskResp = await createTask
|
||||
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
|
||||
const taskBody = await taskResp.json()
|
||||
const taskId = taskBody.items?.[0]?.id ?? taskBody.id ?? taskBody.task_id
|
||||
expect(taskId, "created task should return an id").toBeTruthy()
|
||||
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
|
||||
console.log("[random] Generation task created:", taskId)
|
||||
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
|
||||
console.log("[random] Wizard flow completed ✓")
|
||||
})
|
||||
|
||||
@@ -281,28 +216,113 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
}) => {
|
||||
test.setTimeout(600_000)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
const { token, user, suffix } = await setupFreshUser(request, "narrative")
|
||||
const { token, suffix } = await setupFreshUser(request, "narrative")
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
window.localStorage.setItem("access_token", token)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: {
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ token, user },
|
||||
)
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
|
||||
const mockScriptId = `script-mock-${suffix}`
|
||||
const mockVoiceId = `preset-voice-${suffix}`
|
||||
const mockJobId = `tts-job-${suffix}`
|
||||
|
||||
// 文案列表(ScriptSelectModal 查询 /scripts)
|
||||
await page.route("**/api/v1/scripts**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.includes("/extract-from-douyin")) {
|
||||
route.continue()
|
||||
return
|
||||
}
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: mockScriptId,
|
||||
title: "测试带货文案",
|
||||
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
|
||||
tags: ["带货"],
|
||||
title_category: "daihuo",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// 预设音色(TtsVoiceModal 查询 GET /voices/presets)
|
||||
await page.route("**/api/v1/voices/presets**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
voice_id: mockVoiceId,
|
||||
name: "晓晓(女声)",
|
||||
description: "温柔女声",
|
||||
gender: "female",
|
||||
language: "zh-CN",
|
||||
preview_url: null,
|
||||
tags: ["温柔"],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// 克隆音色:空列表
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/voice-clones"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
}),
|
||||
)
|
||||
|
||||
// TTS 合成:直接返回 completed 任务
|
||||
await page.route("**/api/v1/tts/synthesize", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
job_id: mockJobId,
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
audio_url: "data:audio/mpeg;base64,",
|
||||
duration: 5,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto("/app/generate")
|
||||
// ── 页面标题 ─────────────────────────────────────────────────
|
||||
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
|
||||
@@ -314,38 +334,28 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
|
||||
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
|
||||
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText(`测试标题 ${suffix}`).first().click()
|
||||
await page.getByText("测试带货文案").first().click()
|
||||
await page.getByRole("button", { name: "确认选择" }).click()
|
||||
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
|
||||
|
||||
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
|
||||
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("龙小夏").first().click()
|
||||
await page.getByText("晓晓(女声)").first().click()
|
||||
await page.getByRole("button", { name: "🎧 合成配音" }).click()
|
||||
// 真实阿里云 CosyVoice 合成耗时偶有波动,放宽到 90s
|
||||
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 90000 })
|
||||
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
|
||||
|
||||
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
|
||||
await expect(page.getByText(/选择素材/).first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText(/AI智能匹配/).first()).toBeVisible()
|
||||
// 卡片中心是播放按钮(stopPropagation 仅播放不选中),点右上角空白处完成选中
|
||||
await page
|
||||
.getByTestId("material-card")
|
||||
.first()
|
||||
.click({ position: { x: 70, y: 12 } })
|
||||
await expect(page.getByText(/已选 1 个素材|已选[^0]*[1-9]/)).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
|
||||
await page.getByTestId("material-card").first().click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
|
||||
// (#2048: PreviewCountModal 已移除)
|
||||
await expect(page.getByText(/选择标题/).first()).toBeVisible({ timeout: 10000 })
|
||||
// 选中文案后标题框已预填该文案标题,下拉按当前输入过滤,直接选中该选项确认
|
||||
const titleBox2 = page.locator(".ant-select-selection-search-input:visible").first()
|
||||
await titleBox2.click()
|
||||
await page
|
||||
.locator(".ant-select-item-option", { hasText: `测试标题 ${suffix}` })
|
||||
.first()
|
||||
.click()
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput2).toBeVisible({ timeout: 5000 })
|
||||
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
@@ -361,11 +371,8 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
await confirmBtn2.click()
|
||||
const taskResp2 = await createTask2
|
||||
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
|
||||
const taskBody2 = await taskResp2.json()
|
||||
const taskId2 = taskBody2.items?.[0]?.id ?? taskBody2.id ?? taskBody2.task_id
|
||||
expect(taskId2, "created task should return an id").toBeTruthy()
|
||||
console.log("[narrative] Generation task created:", taskId2)
|
||||
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
|
||||
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
|
||||
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
|
||||
console.log("[narrative] Wizard flow completed ✓")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,20 +11,11 @@ const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".leng
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
try {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
} catch {
|
||||
// 收尾时页面可能已关闭,忽略在途请求避免误判
|
||||
try {
|
||||
await route.abort()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,7 +45,8 @@ type LibraryResponse = { id: string }
|
||||
test.describe("Core media upload flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test("uploads a video asset and shows it in the asset library", async ({ page, request }) => {
|
||||
// 串行执行时登录可能触发 429,两次退避约 130s,沿用 describe 的 180s 超时
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-mov-${suffix}@example.com`
|
||||
|
||||
Generated
-12
@@ -14,7 +14,6 @@
|
||||
"axios": "^1.7.2",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.23",
|
||||
"marked": "^12.0.2",
|
||||
"mp4box": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -4503,17 +4502,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "12.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz",
|
||||
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"axios": "^1.7.2",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.23",
|
||||
"marked": "^12.0.2",
|
||||
"mp4box": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 运行时开关安全读取
|
||||
*
|
||||
* 背景:本项目使用 Vite 构建,浏览器运行时不存在 Node 的全局 `process`。
|
||||
* 直接写 `process.env.XXX` 会在模块加载阶段抛出 `ReferenceError: process is not defined`,
|
||||
* 导致整个页面白屏崩溃。
|
||||
*
|
||||
* 统一通过本模块读取这类仅在构建/调试期注入的布尔开关:
|
||||
* - 优先读取 Vite 的 `import.meta.env.VITE_XXX`
|
||||
* - 兼容历史上未加 VITE_ 前缀、经由 Node 环境(单测 / 旧构建脚本)注入的 `process.env.XXX`
|
||||
* - 任何情况下访问失败都安全返回 false(默认走真实后端 API,不启用 mock)
|
||||
*/
|
||||
|
||||
/** 从可能不存在的 Node 全局 process 上安全读取环境变量 */
|
||||
function readNodeEnv(name: string): string | undefined {
|
||||
try {
|
||||
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
|
||||
return proc?.env?.[name]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取运行时布尔开关。
|
||||
*
|
||||
* @param name 开关名(不含 VITE_ 前缀的历史名称,如 POINTS_API_MOCK)
|
||||
* @returns 开关是否显式置为 "true";未设置或读取失败时为 false
|
||||
*/
|
||||
export function readRuntimeFlag(name: string): boolean {
|
||||
// Vite 注入的环境变量(需 VITE_ 前缀才会暴露到浏览器)
|
||||
const viteKey = `VITE_${name}`
|
||||
const viteEnv = (import.meta as unknown as { env?: Record<string, string | undefined> }).env
|
||||
const viteVal = viteEnv?.[viteKey] ?? viteEnv?.[name]
|
||||
|
||||
// 兼容 Node 环境下无前缀的历史变量名
|
||||
const nodeVal = readNodeEnv(name)
|
||||
|
||||
const raw = viteVal ?? nodeVal
|
||||
return raw === "true"
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
* 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { readRuntimeFlag } from "../env-flags"
|
||||
import type {
|
||||
PointsBalance,
|
||||
PointsRulesResponse,
|
||||
@@ -202,7 +201,7 @@ const MOCK_MEMBERSHIP: MembershipResponse = {
|
||||
|
||||
/** 获取积分余额 */
|
||||
export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_BALANCE }
|
||||
}
|
||||
@@ -212,7 +211,7 @@ export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
|
||||
/** 获取积分消耗规则 */
|
||||
export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier }
|
||||
}
|
||||
@@ -222,7 +221,7 @@ export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
|
||||
/** 获取充值包列表 */
|
||||
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null }
|
||||
}
|
||||
@@ -237,7 +236,7 @@ export async function getPointsTransactions(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PointsTransactionsResponse> {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
const start = (page - 1) * pageSize
|
||||
const items = MOCK_TRANSACTIONS.slice(start, start + pageSize)
|
||||
@@ -262,7 +261,7 @@ export async function getPointsTransactions(
|
||||
export async function createPointsOrder(
|
||||
data: CreateRechargeOrderRequest,
|
||||
): Promise<CreateRechargeOrderResponse> {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2))
|
||||
const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id)
|
||||
if (!pkg) throw new Error("充值包不存在")
|
||||
@@ -286,7 +285,7 @@ export async function createPointsOrder(
|
||||
* 积分预检查(消耗前调用)
|
||||
*/
|
||||
export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheckResponse> {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key)
|
||||
if (!rule) {
|
||||
@@ -327,7 +326,7 @@ export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheck
|
||||
|
||||
/** 获取每日免费额度使用情况 */
|
||||
export async function getDailyUsage(): Promise<DailyUsage> {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_DAILY_USAGE }
|
||||
}
|
||||
@@ -337,7 +336,7 @@ export async function getDailyUsage(): Promise<DailyUsage> {
|
||||
|
||||
/** 获取会员聚合信息(创作页可用来判断 max_resolution) */
|
||||
export async function getMembership(): Promise<MembershipResponse> {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_MEMBERSHIP }
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ export interface PointsBalance {
|
||||
member_type: "monthly" | "quarterly" | "yearly" | null
|
||||
/** 会员到期时间 */
|
||||
member_expires_at: ISODate | null
|
||||
/** 后端积分系统是否启用(false=免费放行,不做余额预校验) */
|
||||
credits_enabled?: boolean
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { readRuntimeFlag } from "../env-flags"
|
||||
import type {
|
||||
ScriptItem,
|
||||
ScriptListParams,
|
||||
@@ -17,7 +16,7 @@ import type {
|
||||
* #1894:文案库接口已上线,默认 false 走真实 API;
|
||||
* 通过 SCRIPTS_API_MOCK=true 环境变量可本地开启 mock 调试(行为同 POINTS_API_MOCK)。
|
||||
*/
|
||||
export const SCRIPTS_API_MOCK = readRuntimeFlag("SCRIPTS_API_MOCK")
|
||||
export const SCRIPTS_API_MOCK = (process.env.SCRIPTS_API_MOCK as string | undefined) === "true"
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { readRuntimeFlag } from "../env-flags"
|
||||
import type {
|
||||
SubscriptionInfo,
|
||||
SubscriptionPlan,
|
||||
@@ -73,7 +72,7 @@ const MOCK_PLANS: SubscriptionPlan[] = [
|
||||
|
||||
const MOCK_BILLING: BillingRecord[] = []
|
||||
|
||||
const isMock = () => readRuntimeFlag("POINTS_API_MOCK")
|
||||
const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true"
|
||||
|
||||
/** 获取当前订阅 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
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)
|
||||
@@ -32,109 +41,7 @@ export function getViralStyleTemplates() {
|
||||
return apiClient.get<StyleTemplate[]>("/viral-video/style-templates").then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 上传参考视频后触发风格分析 */
|
||||
/** 上传参考视频后触发风格分析(返回带 style_guide 的任务详情) */
|
||||
export function analyzeViralStyle(id: string) {
|
||||
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/analyze-style`).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 动态预估积分消耗(STEP3 参数变化时调用) */
|
||||
export function estimateViralVideoCredits(params: {
|
||||
video_model: string
|
||||
resolution: string
|
||||
video_ratio: string
|
||||
duration: number
|
||||
}) {
|
||||
return apiClient
|
||||
.post<{ estimated_credits: number }>("/viral-video/estimate-credits", params)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 获取支持的视频模型列表(GET /viral-video/models)。后端返回 {models: [...]} 包装 */
|
||||
export function getViralVideoModels() {
|
||||
return apiClient.get<ViralVideoModelsResponse>("/viral-video/models").then((r) => {
|
||||
const data = r.data as ViralVideoModelsResponse | ViralVideoModel[] | null | undefined
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray((data as ViralVideoModelsResponse).models)) {
|
||||
return (data as ViralVideoModelsResponse).models
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
/** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */
|
||||
|
||||
/**
|
||||
* 客户端图片分析 mock(后端未提供 analyze-only 端点前的占位方案):
|
||||
* 基于已上传图片生成一份示例识别汇览,让 STEP1→STEP2 交互可走通。
|
||||
* 后端上线后改为调用真实接口。
|
||||
*/
|
||||
export function mockImageAnalysis(images: { name: string }[]): Promise<ImageAnalysisResult> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const products = images.slice(0, 3).map((img, i) => {
|
||||
const n = img.name.replace(/\.[^.]+$/, "")
|
||||
return {
|
||||
name: n || `商品 ${i + 1}`,
|
||||
spec: i === 0 ? "500ml/瓶" : i === 1 ? "300g/盒" : undefined,
|
||||
brand: i === 0 ? "示例品牌" : undefined,
|
||||
features:
|
||||
i === 0
|
||||
? "瓶身透明、蓝色标签、白色瓶盖;标签上印有品牌Logo和产品名称;光线均匀,主体居中"
|
||||
: i === 1
|
||||
? "盒装包装、主色调为米白+暖黄;正面有产品实物图;文字清晰可辨"
|
||||
: "产品主体清晰、背景干净、色彩鲜艳,突出核心卖点",
|
||||
label_text: i === 0 ? "包装正面印有产品名称、净含量、品牌Logo" : undefined,
|
||||
image_index: i,
|
||||
}
|
||||
})
|
||||
resolve({ products })
|
||||
}, 1800)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端文案生成 mock(后端未提供 generate-copy 端点前的占位方案):
|
||||
* 后端上线后改为调用真实接口。
|
||||
*/
|
||||
export function mockGenerateCopy(params: {
|
||||
product: string
|
||||
sellingPoints?: string[]
|
||||
tone?: string
|
||||
duration?: number
|
||||
marketingPurpose?: string
|
||||
industry?: string
|
||||
targetCustomer?: string
|
||||
}): Promise<CopyResult> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const product = params.product || "这款产品"
|
||||
const tone = params.tone || "亲切务实"
|
||||
const purpose = params.marketingPurpose || "品牌种草"
|
||||
resolve({
|
||||
title: `【${purpose}】${product},用过的人都说好!`,
|
||||
final_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`,
|
||||
suggested_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`,
|
||||
})
|
||||
}, 2200)
|
||||
})
|
||||
}
|
||||
|
||||
/** ── 三步拆分 v1.5 真实后端 API(PR #2117 合入后启用,前端可替换 mock 调用) ── */
|
||||
|
||||
/** 阶段1:上传图片后仅做 VLM 图片分析 + 可选参考视频风格分析,完成后状态=image_analyzed */
|
||||
export function analyzeViralImages(payload: AnalyzeImagesRequest) {
|
||||
return apiClient.post<ViralVideoJob>("/viral-video/analyze-images", payload).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 阶段2:用户填完营销参数后生成文案+分镜+合规审核,完成后状态=copy_generated,返回 copy_result */
|
||||
export function generateViralCopy(id: string, payload: GenerateCopyRequest) {
|
||||
return apiClient
|
||||
.post<ViralVideoJob>(`/viral-video/${id}/generate-copy`, payload)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 阶段3:用户确认/编辑文案后开始 TTS→渲染→上传,完成后状态=completed */
|
||||
export function confirmViralCopy(id: string, payload: ConfirmCopyRequest = {}) {
|
||||
return apiClient
|
||||
.post<ViralVideoJob>(`/viral-video/${id}/confirm-copy`, payload)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
@@ -1,158 +1,67 @@
|
||||
export type FusionLevel = "ai_full" | "ai_polish" | "user_primary"
|
||||
export type FusionLevel = "full_ai" | "polish" | "as_is"
|
||||
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: "几乎不改我的文案" },
|
||||
{ value: "full_ai", label: "AI 全写", desc: "给我方向,全由AI创作" },
|
||||
{ value: "polish", label: "AI润色", desc: "我写草稿,AI帮我润色" },
|
||||
{ value: "as_is", 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: "像素级复刻" },
|
||||
{ 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" | "image_analyzed" | "copy_generated" | "completed" | "failed" | "cancelled"
|
||||
"pending" | "running" | "wait_user_confirm" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/**
|
||||
* v1.6 后端流水线阶段。单次 Seedance 出片版:
|
||||
* image_analysis → video_analysis(可选) → script_generation(含意图理解) → review → tts → rendering → uploading
|
||||
* 后端流水线阶段字符串。前端不展示逐阶段进度列表,仅保留类型
|
||||
* 用于轮询时判断当前在哪个大阶段(分析中 vs 视频生成中)以选择轮询间隔/文案。
|
||||
*/
|
||||
export type ViralVideoStage =
|
||||
| "image_analysis"
|
||||
| "video_analysis"
|
||||
| "script_generation"
|
||||
| "intent_parsing"
|
||||
| "copy_fusion"
|
||||
| "storyboard"
|
||||
| "review"
|
||||
| "tts"
|
||||
| "bgm_select"
|
||||
| "rendering"
|
||||
| "musetalk"
|
||||
| "uploading"
|
||||
|
||||
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
|
||||
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
|
||||
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
|
||||
const COPY_STAGES = new Set<ViralVideoStage>(["script_generation", "review"])
|
||||
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
|
||||
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
|
||||
/** 分析类阶段(image_analysis / video_analysis / intent_parsing):属于「开始分析」阶段 */
|
||||
const ANALYSIS_STAGES = new Set<ViralVideoStage>([
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
])
|
||||
|
||||
export function isImageAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return !!stage && IMAGE_ANALYSIS_STAGES.has(stage)
|
||||
}
|
||||
export function isCopyStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return !!stage && COPY_STAGES.has(stage)
|
||||
}
|
||||
export function isVideoStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return !!stage && VIDEO_STAGES.has(stage)
|
||||
}
|
||||
/** 兼容旧调用:分析图片+生成文案 的所有前置阶段 */
|
||||
export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return isImageAnalysisStage(stage) || isCopyStage(stage)
|
||||
}
|
||||
|
||||
/** 从 job 对象取到有效阶段(兼容 progress_stage/current_stage 两种字段名) */
|
||||
export function getJobStage(
|
||||
job: { current_stage?: ViralVideoStage; progress_stage?: ViralVideoStage } | null | undefined,
|
||||
): ViralVideoStage | undefined {
|
||||
if (!job) return undefined
|
||||
return job.current_stage || job.progress_stage
|
||||
}
|
||||
|
||||
/** 从 job 对象取到阶段提示文案(兼容 phase_message/progress_message) */
|
||||
export function getJobPhaseMessage(
|
||||
job: { phase_message?: string; progress_message?: string } | null | undefined,
|
||||
): string {
|
||||
if (!job) return ""
|
||||
return job.phase_message || job.progress_message || ""
|
||||
}
|
||||
|
||||
/** 单张图片 VLM 识别结果(v8 叙述优先,仅保留最少结构化字段) */
|
||||
export interface ImageProductAnalysis {
|
||||
/** store / product / person / scene */
|
||||
type?: string
|
||||
name?: string
|
||||
brand?: string
|
||||
has_person?: boolean
|
||||
/** v8: 用户端展示用的叙述 markdown(由提示词控制排版) */
|
||||
summary_markdown?: string
|
||||
/** 标题行兼容字段 */
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface ImageAnalysisResult {
|
||||
/** v8 字段 */
|
||||
images?: ImageProductAnalysis[]
|
||||
/** 老数据兼容 */
|
||||
products?: ImageProductAnalysis[]
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本 - 单镜头 */
|
||||
export interface ShotScript {
|
||||
/** 时间区间,如 "0-3秒" */
|
||||
time_range?: string
|
||||
/** 说话人角色,如 "店主"、"顾客" */
|
||||
speaker?: string
|
||||
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
|
||||
shot_type_angle_movement?: string
|
||||
/** 场景描述+对白 */
|
||||
scene_and_dialogue?: string
|
||||
/** 人物动作/表情/物品操作细节 */
|
||||
action_details?: string
|
||||
/** 环境音+BGM提示 */
|
||||
audio_bgm?: string
|
||||
/** 转场方式(硬切/淡入淡出/叠化/结束) */
|
||||
transition?: string
|
||||
/** 参考图片索引(0-based,对应上传产品图数组) */
|
||||
reference_image_index?: number | null
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本 - 总览 */
|
||||
export interface CopyResultOverview {
|
||||
theme?: string
|
||||
total_duration?: number
|
||||
aspect_ratio?: string
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本(核心输出结构,给 Seedance 做 prompt,给 TTS 取 voiceover_script) */
|
||||
export interface CopyResult {
|
||||
overview?: CopyResultOverview
|
||||
/** 整体场景+光线描述 */
|
||||
scene_and_lighting?: string
|
||||
/** 逐镜头时间轴 */
|
||||
shots?: ShotScript[]
|
||||
/** 硬性约束(禁止字幕/水印/变形等) */
|
||||
hard_constraints?: string[]
|
||||
/** 负面提示词 */
|
||||
negative_prompts?: string[]
|
||||
/** 完整口播稿(纯文本,用于 TTS 合成) */
|
||||
voiceover_script?: string
|
||||
/** 向后兼容:= voiceover_script */
|
||||
final_copy?: string
|
||||
/** 向后兼容:= voiceover_script */
|
||||
suggested_copy?: string
|
||||
title?: string
|
||||
/** v3 storyboard: 用户端展示用的 markdown 文案(由提示词控制排版) */
|
||||
copy_display_markdown?: string
|
||||
/** v1.5 旧字段兼容(老数据降级时可能出现) */
|
||||
scenes?: Array<{ shot: string; narration: string; duration?: number }>
|
||||
return !!stage && ANALYSIS_STAGES.has(stage)
|
||||
}
|
||||
|
||||
export interface StyleTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
thumbnail_url?: string
|
||||
style_config?: Record<string, unknown>
|
||||
preview_url?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface IntentResult {
|
||||
product: string
|
||||
selling_points: string[]
|
||||
target_audience: string
|
||||
tone: string
|
||||
structure: string
|
||||
duration: number
|
||||
suggested_title?: string
|
||||
suggested_copy?: string
|
||||
}
|
||||
|
||||
export interface ViralVideoJob {
|
||||
id: string
|
||||
status: ViralVideoStatus
|
||||
@@ -160,141 +69,48 @@ export interface ViralVideoJob {
|
||||
reference_video_url?: string
|
||||
style_strength?: StyleStrength
|
||||
style_template_id?: string
|
||||
style_guide?: string | Record<string, unknown>
|
||||
style_guide?: string
|
||||
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
|
||||
/** v1.6 编导分镜脚本(核心产物) */
|
||||
copy_result?: CopyResult
|
||||
/** 向后兼容:= copy_result.shots */
|
||||
storyboard?: ShotScript[]
|
||||
image_analysis?: ImageAnalysisResult
|
||||
/** 视频比例:9:16 / 16:9 / 1:1,默认 9:16 */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空=后端默认) */
|
||||
video_model?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
/** 后端真实字段名(snake_case):当前细粒度阶段 */
|
||||
current_stage?: ViralVideoStage
|
||||
/** 后端真实字段名:当前阶段中文提示文案 */
|
||||
phase_message?: string
|
||||
/** @deprecated 兼容旧前端:使用 current_stage */
|
||||
intent_result?: IntentResult
|
||||
intent_text?: string
|
||||
progress_stage?: ViralVideoStage
|
||||
progress_percent?: number
|
||||
/** @deprecated 兼容旧前端:使用 phase_message */
|
||||
progress_message?: string
|
||||
output_url?: string
|
||||
result_video_url?: string
|
||||
error_message?: string
|
||||
error_msg?: string
|
||||
credits_cost?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface GenerateViralVideoRequest {
|
||||
images: string[]
|
||||
reference_video_url?: string
|
||||
style_strength?: StyleStrength
|
||||
style_template_id?: string
|
||||
user_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
voice_id?: string
|
||||
bgm_preference?: string
|
||||
industry?: string
|
||||
target_customer?: string
|
||||
language?: string
|
||||
persona_id?: string
|
||||
viral_structure?: string
|
||||
marketing_purpose?: string
|
||||
duration?: number
|
||||
video_model?: string
|
||||
video_ratio?: string
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
items: ViralVideoJob[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** 分辨率,如 480p/720p/1080p */
|
||||
video_resolution?: string
|
||||
/** 画面比例,如 9:16/16:9/1:1 */
|
||||
video_ratio?: string
|
||||
/** 成片时长秒数 */
|
||||
duration?: number
|
||||
/** 音色 ID(CosyVoice voice_id 或 preset id) */
|
||||
voice_id?: string
|
||||
/** 音色来源:preset/library/upload/clone/my_voice */
|
||||
voice_source?: string
|
||||
/** 口播语言 */
|
||||
language?: string
|
||||
}
|
||||
|
||||
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
|
||||
export interface StoryboardSegment {
|
||||
order: number
|
||||
type: string
|
||||
description: string
|
||||
text: string
|
||||
duration: number
|
||||
ken_burns?: string
|
||||
transition?: string
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ export interface VoiceClone {
|
||||
language: string
|
||||
gender: string
|
||||
error_message: string | null
|
||||
/** CosyVoice 实际使用的音色 ID(status=ready 时由后端填充,用于 TTS 调用) */
|
||||
voice_id?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
|
||||
language: profile.language || "",
|
||||
gender: profile.gender || "",
|
||||
error_message: profile.error_message || null,
|
||||
voice_id: profile.voice_id,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
})
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
/* DurationWheelPicker —— 弹层式滚轮选择器(样式与表单一致) */
|
||||
|
||||
/* 触发按钮:外观复用 .vv-select 风格 */
|
||||
.dw-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e8;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.dw-trigger:hover {
|
||||
border-color: #c0c0d0;
|
||||
}
|
||||
.dw-trigger-open,
|
||||
.dw-trigger:focus-within {
|
||||
border-color: #7c3aed !important;
|
||||
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
.dw-trigger-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.dw-trigger-val {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dw-trigger-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dw-trigger-arrow {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.dw-trigger-arrow-up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 弹层容器 */
|
||||
.dw-popup {
|
||||
padding: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* 滚轮 */
|
||||
.dw-picker {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #fafafe;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.dw-picker-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.dw-picker-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.dw-picker-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
scroll-snap-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
transition:
|
||||
color 0.15s,
|
||||
transform 0.15s,
|
||||
font-weight 0.15s;
|
||||
}
|
||||
.dw-picker-item-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dw-picker-item-unit {
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
.dw-picker-item-active {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dw-picker-item-active .dw-picker-item-val {
|
||||
font-size: 18px;
|
||||
}
|
||||
.dw-picker-item-active .dw-picker-item-unit {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 中心选中条 */
|
||||
.dw-picker-mask {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
pointer-events: none;
|
||||
background: #f5f0ff;
|
||||
border-radius: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
.dw-picker-mask::before,
|
||||
.dw-picker-mask::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #d8c4ff;
|
||||
}
|
||||
.dw-picker-mask::before {
|
||||
top: 0;
|
||||
}
|
||||
.dw-picker-mask::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* 上下渐变 */
|
||||
.dw-picker-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.dw-picker-fade-top {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, #fafafe 25%, rgba(250, 250, 254, 0));
|
||||
}
|
||||
.dw-picker-fade-bottom {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, #fafafe 25%, rgba(250, 250, 254, 0));
|
||||
}
|
||||
|
||||
/* 弹层按钮区 */
|
||||
.dw-popup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dw-popup-actions .ant-btn {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dw-popup-actions .ant-btn-primary {
|
||||
background: #7c3aed;
|
||||
}
|
||||
.dw-popup-actions .ant-btn-primary:hover {
|
||||
background: #6d28d9 !important;
|
||||
}
|
||||
|
||||
/* 覆盖 antd Popover 默认内边距 */
|
||||
.dw-popover .ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dw-popover .ant-popover-arrow {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* DurationWheelPicker —— 竖屏滚轮式时长选择器(弹层版)
|
||||
*
|
||||
* 设计:
|
||||
* - 外观是和其他表单 Select 一致的输入框(白色底+1px灰边+紫色focus ring)
|
||||
* - 点击输入框弹出 Popover,内部是滚轮 picker(原生 scroll-snap,零依赖)
|
||||
* - 滚轮样式:白底容器,选中行 #7c3aed 紫字加粗+浅紫背景条
|
||||
* - 支持触摸/鼠标滚轮/点击;松手吸附;底部"确认/取消"按钮
|
||||
* - 默认范围 15–30 秒,步长 1 秒
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
|
||||
import { Popover, Button } from "antd"
|
||||
import { DownOutlined } from "@ant-design/icons"
|
||||
import "./DurationWheelPicker.css"
|
||||
|
||||
export interface DurationWheelPickerProps {
|
||||
value?: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
unit?: string
|
||||
onChange?: (value: number) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** 弹层宽度,默认 160px */
|
||||
popupWidth?: number
|
||||
/** 弹层内滚轮高度,默认 180px */
|
||||
wheelHeight?: number
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 36
|
||||
|
||||
const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
|
||||
value = 20,
|
||||
min = 15,
|
||||
max = 30,
|
||||
step = 1,
|
||||
unit = "秒",
|
||||
onChange,
|
||||
placeholder = "请选择时长",
|
||||
disabled = false,
|
||||
popupWidth = 160,
|
||||
wheelHeight = 180,
|
||||
}) => {
|
||||
const options = useMemo(() => {
|
||||
const arr: number[] = []
|
||||
for (let v = min; v <= max; v += step) arr.push(v)
|
||||
return arr
|
||||
}, [min, max, step])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
// 弹层内暂存值,点确认才提交
|
||||
const [draft, setDraft] = useState<number>(value)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(value)
|
||||
// 下一帧滚到当前值
|
||||
requestAnimationFrame(() => scrollToValue(value, false))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const scrollToValue = useCallback(
|
||||
(v: number, smooth = true) => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const idx = options.indexOf(v)
|
||||
if (idx < 0) return
|
||||
list.scrollTo({ top: idx * ITEM_HEIGHT, behavior: smooth ? "smooth" : "auto" })
|
||||
},
|
||||
[options],
|
||||
)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const idx = Math.round(list.scrollTop / ITEM_HEIGHT)
|
||||
const clamped = Math.max(0, Math.min(options.length - 1, idx))
|
||||
const targetTop = clamped * ITEM_HEIGHT
|
||||
if (Math.abs(list.scrollTop - targetTop) > 1) {
|
||||
list.scrollTo({ top: targetTop, behavior: "smooth" })
|
||||
}
|
||||
setDraft(options[clamped])
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onChange?.(draft)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleItemClick = (v: number) => {
|
||||
setDraft(v)
|
||||
scrollToValue(v, true)
|
||||
}
|
||||
|
||||
const maskTop = wheelHeight / 2 - ITEM_HEIGHT / 2
|
||||
|
||||
const wheel = (
|
||||
<div className="dw-popup">
|
||||
<div
|
||||
className="dw-picker"
|
||||
style={{ height: wheelHeight, width: popupWidth - 24 /* padding */ }}
|
||||
>
|
||||
<div className="dw-picker-mask" style={{ top: maskTop, height: ITEM_HEIGHT }} aria-hidden />
|
||||
<div className="dw-picker-fade dw-picker-fade-top" aria-hidden />
|
||||
<div className="dw-picker-fade dw-picker-fade-bottom" aria-hidden />
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="dw-picker-list"
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
paddingTop: wheelHeight / 2 - ITEM_HEIGHT / 2,
|
||||
paddingBottom: wheelHeight / 2 - ITEM_HEIGHT / 2,
|
||||
}}
|
||||
>
|
||||
{options.map((v) => {
|
||||
const isActive = v === draft
|
||||
return (
|
||||
<li
|
||||
key={v}
|
||||
className={`dw-picker-item${isActive ? " dw-picker-item-active" : ""}`}
|
||||
style={{ height: ITEM_HEIGHT, lineHeight: `${ITEM_HEIGHT}px` }}
|
||||
onClick={() => handleItemClick(v)}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
>
|
||||
<span className="dw-picker-item-val">{v}</span>
|
||||
<span className="dw-picker-item-unit">{unit}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="dw-popup-actions">
|
||||
<Button size="small" onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={handleConfirm}>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!disabled && open}
|
||||
onOpenChange={(v) => setOpen(v)}
|
||||
content={wheel}
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
overlayClassName="dw-popover"
|
||||
overlayStyle={{ padding: 0 }}
|
||||
overlayInnerStyle={{ padding: 0, borderRadius: 10 }}
|
||||
destroyTooltipOnHide
|
||||
>
|
||||
<div
|
||||
className={`dw-trigger${disabled ? " dw-trigger-disabled" : ""}${open ? " dw-trigger-open" : ""}`}
|
||||
style={{ height: 36 }}
|
||||
>
|
||||
<span className={`dw-trigger-val${value != null ? "" : " dw-trigger-placeholder"}`}>
|
||||
{value != null ? `${value}${unit}` : placeholder}
|
||||
</span>
|
||||
<DownOutlined className={`dw-trigger-arrow${open ? " dw-trigger-arrow-up" : ""}`} />
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default DurationWheelPicker
|
||||
@@ -8,6 +8,11 @@ export const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/products": "成片库",
|
||||
"/app/templates": "模板库",
|
||||
"/app/history": "任务历史",
|
||||
"/app/admin": "控制台",
|
||||
"/app/admin/users": "用户管理",
|
||||
"/app/admin/analytics": "数据分析",
|
||||
"/app/admin/monitor": "系统监控",
|
||||
"/app/admin/logs": "系统日志",
|
||||
"/app/subscription": "订阅管理",
|
||||
"/app/subscription/upgrade": "升级订阅",
|
||||
"/app/subscription/billing": "账单管理",
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
* 功能流程不做积分预校验,直接走生成。
|
||||
* - true:展示完整积分系统 UI。
|
||||
*/
|
||||
export const ENABLE_CREDIT_SYSTEM = true
|
||||
export const ENABLE_CREDIT_SYSTEM = false
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
@@ -206,6 +207,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "控制台",
|
||||
path: "/app/admin",
|
||||
icon: React.createElement(ControlOutlined),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "会员订阅",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Admin 页面样式(Phase 3 精简)
|
||||
*
|
||||
* 原始 477 行 → 精简至仅保留实际使用的 class。
|
||||
* 已迁移至 global.css / ui.css 的样式不再重复定义:
|
||||
* .xx-page-head → global.css
|
||||
* .xx-primary-btn → global.css
|
||||
* .xx-tag / .xx-card → ui.css / global.css
|
||||
*
|
||||
* 以下 class 仅被 AdminComingSoon.tsx 使用。
|
||||
*/
|
||||
|
||||
.admin-coming-soon-page {
|
||||
padding: 32px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-result-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.xx-result-center .ant-result {
|
||||
padding: 48px;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react"
|
||||
import { Button, Card, Result } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import "./Admin.css"
|
||||
|
||||
const AdminComingSoon: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="admin-coming-soon-page">
|
||||
<div className="xx-result-center">
|
||||
<Card className="xx-card">
|
||||
<Result
|
||||
status="info"
|
||||
title="Admin 后台暂未开放"
|
||||
subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。"
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate("/app/dashboard")}
|
||||
className="xx-primary-btn"
|
||||
>
|
||||
返回首页
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminComingSoon
|
||||
|
||||
export const Component = AdminComingSoon
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
generateRenderSmartCover,
|
||||
finalizeRenderJob,
|
||||
} from "./api/aiAvatar"
|
||||
import { uploadAssetDirect } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { RenderJob, SentenceTiming } from "./types"
|
||||
import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract"
|
||||
@@ -81,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)
|
||||
@@ -273,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)
|
||||
@@ -316,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,
|
||||
@@ -532,7 +511,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
setShowCoverModal(false)
|
||||
}, [])
|
||||
|
||||
/* ── 自定义上传封面:本地预览 + 保存 File 引用,点完成时直传 OSS 再 finalize ── */
|
||||
/* ── 自定义上传封面(本地预览,不单独上传;点完成时一起入库) ── */
|
||||
const handleUploadCover = useCallback(
|
||||
(file: File) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
@@ -541,15 +520,13 @@ const AiAvatarPage: React.FC = () => {
|
||||
mode: "upload",
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
smart_cover_url: null,
|
||||
upload_file: file,
|
||||
}))
|
||||
setSelectedCoverUrl(url)
|
||||
},
|
||||
[state],
|
||||
)
|
||||
|
||||
/* ── 点「完成」:本地上传封面先直传 OSS,再调 finalize 入库成片库 ── */
|
||||
/* ── 点「完成」:调用 finalize 入库成片库,成功后跳转到成片库 ── */
|
||||
const handleFinalize = useCallback(async () => {
|
||||
if (!currentRenderJob?.id) {
|
||||
message.error("渲染任务不存在")
|
||||
@@ -559,35 +536,9 @@ const AiAvatarPage: React.FC = () => {
|
||||
message.warning("请先完成视频生成")
|
||||
return
|
||||
}
|
||||
const { coverConfig } = state
|
||||
const hasSmartCover = !!coverConfig.smart_cover_url
|
||||
const hasLocalUpload = coverConfig.mode === "upload" && !!coverConfig.upload_file
|
||||
setFinalizeLoading(true)
|
||||
try {
|
||||
// 本地上传封面:先走 OSS 直传拿到公网 URL(智能封面已由 /smart-cover 后端上传 OSS,无需重复传;
|
||||
// 两种都没选则不传 cover_url,后端 finalize 兜底自动抽帧)
|
||||
let finalCoverUrl: string | undefined
|
||||
if (hasSmartCover) {
|
||||
finalCoverUrl = coverConfig.smart_cover_url!
|
||||
} else if (hasLocalUpload && coverConfig.upload_file) {
|
||||
const hide = message.loading("正在上传封面...", 0)
|
||||
try {
|
||||
const result = await uploadAssetDirect({
|
||||
file: coverConfig.upload_file,
|
||||
kind: "image",
|
||||
})
|
||||
finalCoverUrl = result.url || undefined
|
||||
if (!finalCoverUrl) {
|
||||
message.warning("封面上传未返回URL,将使用视频首帧作为封面")
|
||||
}
|
||||
} finally {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
const res = await finalizeRenderJob(
|
||||
currentRenderJob.id,
|
||||
finalCoverUrl ? { cover_url: finalCoverUrl } : undefined,
|
||||
)
|
||||
const res = await finalizeRenderJob(currentRenderJob.id)
|
||||
if (res.data?.status === "success" || res.data?.status === "already_finalized") {
|
||||
message.success("已保存到成片库")
|
||||
navigate("/app/products")
|
||||
@@ -603,7 +554,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
} finally {
|
||||
setFinalizeLoading(false)
|
||||
}
|
||||
}, [currentRenderJob, navigate, state])
|
||||
}, [currentRenderJob, navigate])
|
||||
|
||||
/* ── 配置汇总 ── */
|
||||
const coverStatus: "not_ready" | "pending" | "selected" = (() => {
|
||||
@@ -629,6 +580,20 @@ const AiAvatarPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="aa-page">
|
||||
<div className="aa-page-header">
|
||||
<h1>AI数字人</h1>
|
||||
</div>
|
||||
|
||||
{/* 步骤切换导航条 */}
|
||||
<div className="aa-step-nav">
|
||||
<span className={`aa-step-nav__item${currentStep === 1 ? " active" : ""}`}>
|
||||
1. 视频 / 配音 / 文案
|
||||
</span>
|
||||
<span className={`aa-step-nav__item${currentStep === 2 ? " active" : ""}`}>
|
||||
2. 对口型 / 标题 / 封面 / 生成
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="aa-page-body">
|
||||
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */}
|
||||
{currentStep === 1 && (
|
||||
|
||||
@@ -72,8 +72,7 @@ export const previewTts = async (data: {
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
// MuseTalk 渲染 8s 视频约 54s + 排队时间,给足 5 分钟超时避免单次轮询 AxiosError 中断
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 300_000 })
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -92,10 +91,7 @@ export const submitRender = async (data: {
|
||||
}
|
||||
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
// 渲染链路(对口型+B-roll+标题+合成+上传)耗时较长,给足 5 分钟超时
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, {
|
||||
timeout: 300_000,
|
||||
})
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -119,12 +115,8 @@ export const generateRenderSmartCover = async (
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 封面选定后点「完成」正式入库(POST /ai-avatar/render/{job_id}/finalize)
|
||||
* 后端契约:body 支持可选 cover_url(OSS 公网 URL,智能封面已经由后端/smart-cover 写入则不传)
|
||||
*/
|
||||
export const finalizeRenderJob = (renderId: string, payload?: { cover_url?: string }) =>
|
||||
/* ── 封面选定后点「完成」正式入库(POST /ai-avatar/render/{job_id}/finalize) ── */
|
||||
export const finalizeRenderJob = (renderId: string) =>
|
||||
apiClient.post<{ video_id: string; cover_url: string; status: string }>(
|
||||
`/ai-avatar/render/${renderId}/finalize`,
|
||||
payload ?? {},
|
||||
{ timeout: 120_000 }, // 后端兜底抽帧/写入成片库可能耗时较长
|
||||
)
|
||||
|
||||
@@ -73,8 +73,6 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
|
||||
mode: "auto_frame",
|
||||
thumbnail_url: coverUrl,
|
||||
smart_cover_url: coverUrl,
|
||||
upload_url: null,
|
||||
upload_file: null,
|
||||
})
|
||||
onCoverSelected(coverUrl)
|
||||
message.success("智能封面已生成")
|
||||
@@ -115,7 +113,6 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
|
||||
mode: "upload",
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
smart_cover_url: null,
|
||||
})
|
||||
onCoverSelected(url)
|
||||
}
|
||||
|
||||
@@ -162,8 +162,6 @@ export interface AiAvatarCoverConfig {
|
||||
thumbnail_url: string | null
|
||||
/** 智能封面(MediaKit 选帧)返回的 OSS 非临时 URL(#1822) */
|
||||
smart_cover_url: string | null
|
||||
/** 本地上传封面时保存的原始 File 对象,finalize 时先直传 OSS 再带公网 URL */
|
||||
upload_file?: File | null
|
||||
}
|
||||
|
||||
/* ── 渲染任务 ── */
|
||||
@@ -217,5 +215,4 @@ export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
|
||||
upload_url: null,
|
||||
thumbnail_url: null,
|
||||
smart_cover_url: null,
|
||||
upload_file: null,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import CloneModal from "@/components/voice/CloneModal"
|
||||
import VoiceSelectModal from "./components/VoiceSelectModal"
|
||||
import ScriptSelectModal from "./components/ScriptSelectModal"
|
||||
import TtsVoiceModal from "./components/TtsVoiceModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
@@ -38,8 +40,6 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 积分状态 ── */
|
||||
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
|
||||
// 后端积分总开关(由 /points/balance 返回;数据未就绪时保守按 UI 开关处理)
|
||||
const creditsEnabled = balance?.credits_enabled ?? ENABLE_CREDIT_SYSTEM
|
||||
useEffect(() => {
|
||||
initPoints()
|
||||
}, [initPoints])
|
||||
@@ -88,6 +88,7 @@ const GeneratePage: React.FC = () => {
|
||||
style,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
previewTaskId,
|
||||
setPreviewTaskId,
|
||||
@@ -344,8 +345,7 @@ const GeneratePage: React.FC = () => {
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
|
||||
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
|
||||
// 仅当后端积分系统真实启用时才做余额预校验(后端免费放行时前端不得拦截)
|
||||
if (creditsEnabled) {
|
||||
if (ENABLE_CREDIT_SYSTEM) {
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
check = hasEnoughPoints(
|
||||
balance ?? null,
|
||||
@@ -391,7 +391,6 @@ const GeneratePage: React.FC = () => {
|
||||
balance,
|
||||
dailyUsage,
|
||||
rules,
|
||||
creditsEnabled,
|
||||
])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -506,7 +505,7 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const pointsEstimate = useMemo(() => {
|
||||
if (!creditsEnabled) return { sufficient: true, cost: 0 }
|
||||
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
|
||||
return hasEnoughPoints(
|
||||
balance ?? null,
|
||||
unitsForCost,
|
||||
@@ -515,8 +514,8 @@ const GeneratePage: React.FC = () => {
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
)
|
||||
}, [unitsForCost, balance, dailyUsage, rules, creditsEnabled])
|
||||
const insufficientPoints = creditsEnabled && !pointsEstimate.sufficient
|
||||
}, [unitsForCost, balance, dailyUsage, rules])
|
||||
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -524,6 +523,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">
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { ViralVideoModel } from "@/api/viral-video/types"
|
||||
|
||||
export const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"]
|
||||
export const TARGET_CUSTOMERS = [
|
||||
"小镇青年",
|
||||
"小镇中老年",
|
||||
"Z世代",
|
||||
"精致妈妈",
|
||||
"新锐白领",
|
||||
"资深中产",
|
||||
"都市蓝领",
|
||||
"都市银发",
|
||||
]
|
||||
export const PERSONAS = [
|
||||
"通用个人IP",
|
||||
"老板型IP",
|
||||
"专家型IP",
|
||||
"顾问型IP",
|
||||
"创始人IP",
|
||||
"创业者IP",
|
||||
"从业者经验派",
|
||||
"避坑顾问型",
|
||||
"知识科普型",
|
||||
"测评种草型",
|
||||
]
|
||||
export const STRUCTURES = [
|
||||
"反差破局+亮明观点+还原现状",
|
||||
"痛点场景植入+给出通关秘籍",
|
||||
"反常识开场+扎心现状+制造焦虑",
|
||||
"核心难题+化解焦虑+描绘愿景",
|
||||
"痛点前置+前后对比+给出期许",
|
||||
"悬念拉满+揭露危害+明确目的",
|
||||
"情绪共鸣+勾起好奇+拔高价值",
|
||||
"颠覆认知+现状拆解+赋予价值",
|
||||
"结果先行+消除顾虑+保留悬念",
|
||||
"真实困境+认知反转+传授秘籍",
|
||||
"反差事实+案例佐证+戳破真相",
|
||||
"预埋悬念+标杆案例+引出核心",
|
||||
"危机渲染+放大痛点+输出干货",
|
||||
"逆袭开场+成功诀窍+低价引流",
|
||||
"反常识观点+分析危害+描述现状",
|
||||
"正反对比+解析原因+简易方法",
|
||||
"真实场景+内心刻画+共情引导+展望未来",
|
||||
"连续否定+代入感受+打破认知+精准戳痛",
|
||||
"揭秘行业+类比施压+放大焦虑+精准锁客",
|
||||
]
|
||||
export const PURPOSES = [
|
||||
"获客引流",
|
||||
"账号涨粉",
|
||||
"活动通知",
|
||||
"口播带货",
|
||||
"促销转化",
|
||||
"场景种草",
|
||||
"痛点解决",
|
||||
"功能演示",
|
||||
"门店发现",
|
||||
"到店实录",
|
||||
"招牌体验",
|
||||
"同城团购",
|
||||
"品牌主张",
|
||||
"品牌故事",
|
||||
"生活方式",
|
||||
"创意概念",
|
||||
"对话短剧",
|
||||
"反转短剧",
|
||||
"悬念短剧",
|
||||
"情绪短片",
|
||||
]
|
||||
export const RATIOS = [
|
||||
{ v: "9:16", label: "9:16 竖屏(抖音/视频号)" },
|
||||
{ v: "16:9", label: "16:9 横屏(B站/YouTube)" },
|
||||
{ v: "1:1", label: "1:1 方形(小红书)" },
|
||||
]
|
||||
export const DURATIONS = Array.from({ length: 16 }, (_, i) => 15 + i)
|
||||
/** 兜底模型列表(接口未返回时使用) */
|
||||
export const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [
|
||||
{
|
||||
key: "seedance-2.5",
|
||||
display_name: "Seedance 2.5 — 最新最强",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p", "1080p"],
|
||||
max_duration: 30,
|
||||
is_default: true,
|
||||
},
|
||||
{
|
||||
key: "seedance-2.0",
|
||||
display_name: "Seedance 2.0 — 正式首选",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p", "1080p"],
|
||||
max_duration: 15,
|
||||
},
|
||||
{
|
||||
key: "seedance-2.0-fast",
|
||||
display_name: "Seedance 2.0 Fast — 快速测试",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p"],
|
||||
max_duration: 15,
|
||||
},
|
||||
{
|
||||
key: "seedance-2.0-mini",
|
||||
display_name: "Seedance 2.0 Mini — 低成本",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p"],
|
||||
max_duration: 15,
|
||||
},
|
||||
{
|
||||
key: "wan-3.0",
|
||||
display_name: "Wan 3.0 — 通义万相全能",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p", "1080p"],
|
||||
max_duration: 15,
|
||||
},
|
||||
]
|
||||
export const RESOLUTION_ORDER = ["480p", "720p", "1080p", "4k"]
|
||||
export const QUALITY_OPTIONS = [
|
||||
{ v: "480p", label: "480p(快速)" },
|
||||
{ v: "720p", label: "720p(清晰)" },
|
||||
{ v: "1080p", label: "1080p(高清)" },
|
||||
]
|
||||
export const UPSCALE_OPTIONS = [
|
||||
{ v: "default", label: "默认" },
|
||||
{ v: "sd", label: "普清" },
|
||||
{ v: "hd", label: "高清" },
|
||||
{ v: "uhd", label: "超清" },
|
||||
{ v: "omnipotent", label: "全能" },
|
||||
]
|
||||
export const VOICE_TIPS =
|
||||
"支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,262 +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 vv-w-170"
|
||||
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 className="vv-mt-8 vv-fs-13">暂无配音素材</div>
|
||||
<div className="vv-muted vv-mt-4 vv-fs-12">
|
||||
请先在「配音/我的音色」中上传音频文件,或在素材库管理中添加
|
||||
</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,33 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
export interface EmptyBlockProps {
|
||||
icon?: React.ReactNode
|
||||
title?: string
|
||||
desc?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一空态组件:icon + title + desc + action,避免散落的 vv-muted 空文案
|
||||
*/
|
||||
export const EmptyBlock: React.FC<EmptyBlockProps> = ({
|
||||
icon = "🎬",
|
||||
title,
|
||||
desc,
|
||||
action,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`vv-empty-block ${className || ""}`} style={style}>
|
||||
<div className="vv-empty-icon">{icon}</div>
|
||||
{title && <div className="vv-empty-title">{title}</div>}
|
||||
{desc && <div className="vv-empty-desc vv-muted">{desc}</div>}
|
||||
{action && <div className="vv-empty-action">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyBlock
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from "react"
|
||||
import { WarningFilled, ReloadOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface ErrorRetryBarProps {
|
||||
label?: string
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
retryText?: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一错误条:警告图标 + 标签前缀 + 错误消息 + 可选重试按钮
|
||||
* 替换主页面里散落的 .vv-error 块
|
||||
*/
|
||||
export const ErrorRetryBar: React.FC<ErrorRetryBarProps> = ({
|
||||
label,
|
||||
message,
|
||||
onRetry,
|
||||
retryText = "重试",
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`vv-error ${className || ""}`} style={style}>
|
||||
<WarningFilled style={{ marginRight: 6 }} />
|
||||
{label ? `${label}:` : ""}
|
||||
{message}
|
||||
{onRetry && (
|
||||
<button
|
||||
className="vv-btn vv-btn-warn vv-btn-sm"
|
||||
style={{ marginLeft: 10 }}
|
||||
onClick={onRetry}
|
||||
>
|
||||
<ReloadOutlined /> {retryText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorRetryBar
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react"
|
||||
|
||||
export interface InlineTextProps {
|
||||
value: string
|
||||
onChange?: (v: string) => void // 受控模式
|
||||
onCommit: (v: string) => void // blur/Enter 提交
|
||||
onCancel?: () => void
|
||||
multiline?: boolean
|
||||
textareaClass?: string
|
||||
textClass?: string
|
||||
placeholder?: string
|
||||
editing: boolean
|
||||
onStartEdit: () => void
|
||||
rows?: number
|
||||
maxLength?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用"点文字→textarea→blur/Enter保存→Esc取消"内联编辑组件
|
||||
* 替换 ViralVideoPage 里 11 处反射式 textarea/span 复制粘贴
|
||||
*/
|
||||
export const InlineText: React.FC<InlineTextProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
multiline,
|
||||
textareaClass,
|
||||
textClass,
|
||||
placeholder,
|
||||
editing,
|
||||
onStartEdit,
|
||||
rows = 2,
|
||||
maxLength,
|
||||
disabled,
|
||||
}) => {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [local, setLocal] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && taRef.current) {
|
||||
taRef.current.focus()
|
||||
taRef.current.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const commit = useCallback(() => {
|
||||
const v = (onChange ? value : local).trim()
|
||||
const cur = (value ?? "").trim()
|
||||
if (v !== cur) onCommit(v)
|
||||
else onCancel?.()
|
||||
}, [local, value, onCommit, onCancel, onChange])
|
||||
|
||||
const handleKey = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
onCancel?.()
|
||||
}
|
||||
if (!multiline && e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
commit()
|
||||
}
|
||||
},
|
||||
[multiline, commit, onCancel],
|
||||
)
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<textarea
|
||||
ref={taRef}
|
||||
className={textareaClass}
|
||||
value={onChange ? value : local}
|
||||
onChange={(e) => (onChange ? onChange(e.target.value) : setLocal(e.target.value))}
|
||||
onBlur={commit}
|
||||
onKeyDown={handleKey}
|
||||
rows={rows}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const displayText = value || placeholder || "点击编辑"
|
||||
const isPlaceholder = !value
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${textClass || ""} ${isPlaceholder ? "vv-inline-placeholder" : ""} ${
|
||||
disabled ? "vv-inline-disabled" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!disabled) onStartEdit()
|
||||
}}
|
||||
style={
|
||||
isPlaceholder
|
||||
? { color: "#9ca3af", cursor: disabled ? "not-allowed" : "text" }
|
||||
: { cursor: disabled ? "not-allowed" : "text" }
|
||||
}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default InlineText
|
||||
@@ -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 className="vv-fs-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,315 +0,0 @@
|
||||
import React, { useMemo } from "react"
|
||||
import {
|
||||
UploadOutlined,
|
||||
FolderOpenOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import { renderMarkdown } from "../utils/renderMarkdown"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
import { VoicePickerSection } from "./VoicePickerSection"
|
||||
import type { UploadedImage, RefVideo, RefAudio, VoicePanel, ImageAnalysisResult } from "../types"
|
||||
import type { ImageProductAnalysis, StyleStrength } from "@/api/viral-video/types"
|
||||
import { STYLE_STRENGTHS } from "@/api/viral-video/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
export interface Step1UploadPanelProps {
|
||||
images: UploadedImage[]
|
||||
refVideo: RefVideo
|
||||
voicePanel: VoicePanel
|
||||
refAudio: RefAudio
|
||||
voiceTipsOpen: boolean
|
||||
myVoices: VoiceClone[]
|
||||
myVoicesLoading: boolean
|
||||
voiceAssets: { id: string; name: string; url: string; desc?: string }[]
|
||||
playingVoiceId: string | null
|
||||
styleStrength: StyleStrength
|
||||
imageAnalysis: ImageAnalysisResult | null
|
||||
isAnalyzing: boolean
|
||||
analysisError?: string
|
||||
canAnalyze: boolean
|
||||
analyzeBtnText: React.ReactNode
|
||||
// callbacks - images
|
||||
onPickLocalImages: () => void
|
||||
onPickAssetImages: () => void
|
||||
onRemoveImage: (localId: string) => void
|
||||
onMoveImage: (idx: number, dir: -1 | 1) => void
|
||||
onClearImages: () => void
|
||||
onImagesSelected: (files: FileList) => void
|
||||
onAnalyze: () => void
|
||||
// callbacks - ref video
|
||||
onPickLocalVideo: () => void
|
||||
onVideoSelected: (file: File) => void
|
||||
onRemoveRefVideo: () => void
|
||||
onStyleStrengthChange: (v: StyleStrength) => void
|
||||
// callbacks - voice (delegated to VoicePickerSection)
|
||||
onPickLocalAudio: () => void
|
||||
onPickAssetAudio: () => void
|
||||
onRemoveRefAudio: () => void
|
||||
onToggleVoicePanel: (p: VoicePanel) => void
|
||||
onSelectMyVoice: (v: VoiceClone) => void
|
||||
onOpenCloneModal: () => void
|
||||
onSelectVoiceAsset: (a: AssetItem) => void
|
||||
onToggleVoicePlay: (v: { id: string; url: string }) => void
|
||||
onVoiceSelected: (file: File) => void
|
||||
onToggleVoiceTips: () => void
|
||||
// refs
|
||||
imgInputRef: React.Ref<HTMLInputElement>
|
||||
videoInputRef: React.Ref<HTMLInputElement>
|
||||
voiceInputRef: React.Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
const fmtSize = (bytes: number | undefined) => {
|
||||
if (!bytes || bytes <= 0) return "--"
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
function RecognitionBlock({
|
||||
imageAnalysis,
|
||||
isAnalyzing,
|
||||
}: {
|
||||
imageAnalysis: ImageAnalysisResult | null
|
||||
isAnalyzing: boolean
|
||||
}) {
|
||||
const images: ImageProductAnalysis[] = useMemo(() => {
|
||||
return (
|
||||
(imageAnalysis?.images as ImageProductAnalysis[] | undefined) ||
|
||||
(imageAnalysis?.products as ImageProductAnalysis[] | undefined) ||
|
||||
[]
|
||||
)
|
||||
}, [imageAnalysis])
|
||||
|
||||
if (isAnalyzing) {
|
||||
return (
|
||||
<div className="vv-recog">
|
||||
<div className="vv-recog-title">
|
||||
<VVSpinner color="#7c3aed" className="vv-inline-spinner" />
|
||||
识别描述汇览
|
||||
</div>
|
||||
<div className="vv-muted">AI 正在识别画面…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (images.length === 0) return null
|
||||
return (
|
||||
<div className="vv-recog">
|
||||
<div className="vv-recog-title">
|
||||
<CheckCircleFilled style={{ color: "#10b981" }} />
|
||||
识别描述汇览
|
||||
</div>
|
||||
{images.map((p, i) => {
|
||||
const meta = [p.name || "未识别", p.brand, p.category].filter(Boolean)
|
||||
return (
|
||||
<div key={i} className="vv-recog-item vv-recog-md">
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">图片{i + 1}:</span>
|
||||
<span>{meta.join(" · ")}</span>
|
||||
</div>
|
||||
{p.summary_markdown ? (
|
||||
<div
|
||||
className="vv-md-body"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(p.summary_markdown) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="vv-muted">(暂无叙述描述)</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Step1UploadPanel: React.FC<Step1UploadPanelProps> = (p) => {
|
||||
return (
|
||||
<div className="vv-section">
|
||||
<div className="vv-step1-head">
|
||||
<span className="vv-step-badge">1</span>
|
||||
<span className="vv-step1-title">STEP 1 上传素材并分析图片</span>
|
||||
</div>
|
||||
<div className="vv-section-body vv-section-body-scroll">
|
||||
<div className="vv-scroll-inner">
|
||||
{/* 图片按钮 */}
|
||||
<div className="vv-action-row">
|
||||
<button className="vv-action-btn" onClick={p.onPickLocalImages}>
|
||||
<UploadOutlined /> 本地上传
|
||||
</button>
|
||||
<button className="vv-action-btn" onClick={p.onPickAssetImages}>
|
||||
<FolderOpenOutlined /> 从素材库选择图片
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={p.imgInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files) p.onImagesSelected(e.target.files)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="vv-count-row">
|
||||
<span className="vv-muted">
|
||||
已选 <strong style={{ color: "#111827" }}>{p.images.length}</strong>/9
|
||||
</span>
|
||||
{p.images.length > 0 && (
|
||||
<button className="vv-link-btn" onClick={p.onClearImages}>
|
||||
清空图片
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{p.images.length > 0 && (
|
||||
<div className="vv-file-list">
|
||||
{p.images.map((im, i) => (
|
||||
<div key={im.localId} className="vv-file-item">
|
||||
<div className="vv-file-thumb">
|
||||
<span className="vv-img-badge">{i + 1}</span>
|
||||
<img src={im.ossUrl || im.url} alt={im.name} />
|
||||
</div>
|
||||
<div className="vv-file-info">
|
||||
<div className="vv-file-name" title={im.name}>
|
||||
{im.name}
|
||||
</div>
|
||||
<div className="vv-file-sub">
|
||||
{im.uploading ? (
|
||||
<>
|
||||
<span className="vv-progress-mini-inline" />
|
||||
上传中 {im.pct}%
|
||||
</>
|
||||
) : (
|
||||
<>{fmtSize(im.size)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vv-file-actions">
|
||||
<button
|
||||
className="vv-icon-btn"
|
||||
onClick={() => p.onMoveImage(i, -1)}
|
||||
disabled={i === 0 || im.uploading}
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUpOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="vv-icon-btn"
|
||||
onClick={() => p.onMoveImage(i, 1)}
|
||||
disabled={i === p.images.length - 1 || im.uploading}
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDownOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="vv-icon-btn vv-icon-btn-danger"
|
||||
onClick={() => p.onRemoveImage(im.localId)}
|
||||
disabled={!!im.uploading}
|
||||
title="删除"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考视频 */}
|
||||
<div className="vv-subblock">
|
||||
<div className="vv-subblock-head">
|
||||
<span className="vv-subblock-title">参考视频</span>
|
||||
<span className="vv-opt-tag">选填</span>
|
||||
<span className="vv-subblock-hint">最大 200MB · 最长 120 秒</span>
|
||||
</div>
|
||||
<div className="vv-action-row">
|
||||
<button className="vv-action-btn" onClick={p.onPickLocalVideo}>
|
||||
<UploadOutlined /> 本地上传视频
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={p.videoInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) p.onVideoSelected(e.target.files[0])
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
{p.refVideo && (
|
||||
<div className="vv-ref-video-box">
|
||||
<video
|
||||
src={p.refVideo.ossUrl || p.refVideo.url}
|
||||
controls
|
||||
className="vv-ref-video-el"
|
||||
/>
|
||||
<div className="vv-file-info vv-mt-6">
|
||||
<div className="vv-file-name">{p.refVideo.name}</div>
|
||||
{p.refVideo.uploading && (
|
||||
<div className="vv-progress-bar vv-progress-bar-sm">
|
||||
<div className="vv-progress-fill" style={{ width: `${p.refVideo.pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="vv-link-btn" onClick={p.onRemoveRefVideo}>
|
||||
删除重传
|
||||
</button>
|
||||
{p.refVideo.ossUrl && (
|
||||
<div className="vv-mt-8">
|
||||
<div className="vv-label vv-mb-6">风格借鉴强度</div>
|
||||
<div className="vv-pill-row">
|
||||
{STYLE_STRENGTHS.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
className={`vv-pill ${p.styleStrength === s.value ? "active" : ""}`}
|
||||
onClick={() => p.onStyleStrengthChange(s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VoicePickerSection
|
||||
refAudio={p.refAudio}
|
||||
voicePanel={p.voicePanel}
|
||||
voiceTipsOpen={p.voiceTipsOpen}
|
||||
myVoices={p.myVoices}
|
||||
myVoicesLoading={p.myVoicesLoading}
|
||||
voiceAssets={p.voiceAssets}
|
||||
playingVoiceId={p.playingVoiceId}
|
||||
voiceInputRef={p.voiceInputRef}
|
||||
onOpenCloneModal={p.onOpenCloneModal}
|
||||
onToggleVoicePanel={p.onToggleVoicePanel}
|
||||
onSelectMyVoice={p.onSelectMyVoice}
|
||||
onSelectVoiceAsset={p.onSelectVoiceAsset}
|
||||
onToggleVoicePlay={p.onToggleVoicePlay}
|
||||
onPickLocalAudio={p.onPickLocalAudio}
|
||||
onPickAssetAudio={p.onPickAssetAudio}
|
||||
onVoiceSelected={p.onVoiceSelected}
|
||||
onRemoveRefAudio={p.onRemoveRefAudio}
|
||||
onToggleVoiceTips={p.onToggleVoiceTips}
|
||||
/>
|
||||
|
||||
<RecognitionBlock imageAnalysis={p.imageAnalysis} isAnalyzing={p.isAnalyzing} />
|
||||
</div>
|
||||
|
||||
<button className="vv-btn vv-btn-primary" disabled={!p.canAnalyze} onClick={p.onAnalyze}>
|
||||
{p.analyzeBtnText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step1UploadPanel
|
||||
@@ -1,237 +0,0 @@
|
||||
import React from "react"
|
||||
import { EditOutlined } from "@ant-design/icons"
|
||||
import { Select } from "antd"
|
||||
import { FUSION_LEVELS } from "@/api/viral-video/types"
|
||||
import type { FusionLevel } from "@/api/viral-video/types"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import { ErrorRetryBar } from "./ErrorRetryBar"
|
||||
import { StoryboardCard } from "./StoryboardCard"
|
||||
import type { SbDoc, StoryboardEditor } from "../hooks/useStoryboardEditor"
|
||||
import type { UploadedImage, UIStep, ViralVideoJob } from "../types"
|
||||
import {
|
||||
LANGUAGES,
|
||||
TARGET_CUSTOMERS,
|
||||
PERSONAS,
|
||||
STRUCTURES,
|
||||
PURPOSES,
|
||||
DURATIONS,
|
||||
} from "../ViralVideoPage.constants"
|
||||
import { getJobPhaseMessage } from "@/api/viral-video/types"
|
||||
|
||||
export interface Step2CopyPanelProps {
|
||||
// 启用状态
|
||||
step2Enabled: boolean
|
||||
uiStep: UIStep
|
||||
// 文案参数
|
||||
fusionLevel: FusionLevel
|
||||
userCopy: string
|
||||
industry: string
|
||||
targetCustomer: string
|
||||
persona: string
|
||||
language: string
|
||||
viralStructure: string
|
||||
marketingPurpose: string
|
||||
duration: number
|
||||
// 回调
|
||||
onFusionLevelChange: (v: FusionLevel) => void
|
||||
onUserCopyChange: (v: string) => void
|
||||
onParamChange: (patch: {
|
||||
industry?: string
|
||||
targetCustomer?: string
|
||||
persona?: string
|
||||
language?: string
|
||||
viralStructure?: string
|
||||
marketingPurpose?: string
|
||||
duration?: number
|
||||
}) => void
|
||||
onGenerateCopy: () => void
|
||||
// 分镜/流式预览
|
||||
storyboard: SbDoc | null
|
||||
scriptStreamText: string
|
||||
scriptStreamMessage?: string
|
||||
editor: StoryboardEditor
|
||||
images: UploadedImage[]
|
||||
locked: boolean
|
||||
job: ViralVideoJob | null
|
||||
copyError?: string
|
||||
}
|
||||
|
||||
export const Step2CopyPanel: React.FC<Step2CopyPanelProps> = (p) => {
|
||||
const isGenerating = p.uiStep === "step2_generating"
|
||||
const showGenerateBtn =
|
||||
p.uiStep !== "step2_copy_ready" && p.uiStep !== "step3_generating" && p.uiStep !== "step3_done"
|
||||
|
||||
return (
|
||||
<div className="vv-section">
|
||||
<div className="vv-step-subhead">
|
||||
<span className={`vv-step-badge ${p.step2Enabled ? "" : "vv-step-badge-dim"}`}>2</span>
|
||||
<span className="vv-step-subtitle">STEP 2 生成视频文案</span>
|
||||
{!p.step2Enabled && <span className="vv-step-lock">请先完成 STEP 1</span>}
|
||||
</div>
|
||||
<div className="vv-section-body vv-section-body-scroll">
|
||||
<div className="vv-scroll-inner">
|
||||
{/* 文案融合方式 */}
|
||||
<div className="vv-section-subtitle">文案融合方式</div>
|
||||
<div className="vv-fusion-grid">
|
||||
{FUSION_LEVELS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
className={`vv-fusion-btn ${p.fusionLevel === f.value ? "active" : ""}`}
|
||||
onClick={() => p.onFusionLevelChange(f.value)}
|
||||
>
|
||||
<strong>{f.label}</strong>
|
||||
{f.desc}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(p.fusionLevel === "ai_polish" || p.fusionLevel === "user_primary") && (
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">
|
||||
{p.fusionLevel === "ai_polish" ? "写下你的文案草稿" : "写下你的文案/草稿"}
|
||||
</label>
|
||||
<textarea
|
||||
key={p.fusionLevel}
|
||||
className="vv-textarea"
|
||||
placeholder={
|
||||
p.fusionLevel === "ai_polish"
|
||||
? "请输入你的文案草稿,AI将在此基础上润色优化..."
|
||||
: "请输入你想要的原始文案,AI将严格保留你的表达..."
|
||||
}
|
||||
value={p.userCopy}
|
||||
onChange={(e) => p.onUserCopyChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参数(2列网格) */}
|
||||
<div className="vv-form-grid">
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">
|
||||
行业与产品 <span className="vv-label-optional">(选填)</span>
|
||||
</label>
|
||||
<input
|
||||
className="vv-input"
|
||||
placeholder="如:家具店、宠物店、装修公司"
|
||||
value={p.industry}
|
||||
onChange={(e) => p.onParamChange({ industry: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">目标客户</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择目标客户"
|
||||
value={p.targetCustomer || undefined}
|
||||
onChange={(v) => p.onParamChange({ targetCustomer: v })}
|
||||
options={TARGET_CUSTOMERS.map((i) => ({ value: i, label: i }))}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">台词语言</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={p.language}
|
||||
onChange={(v) => p.onParamChange({ language: v })}
|
||||
options={LANGUAGES.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">我的人设</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择人设类型"
|
||||
optionFilterProp="children"
|
||||
value={p.persona || undefined}
|
||||
onChange={(v) => p.onParamChange({ persona: v })}
|
||||
options={PERSONAS.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">爆款结构</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={p.viralStructure}
|
||||
onChange={(v) => p.onParamChange({ viralStructure: v })}
|
||||
options={STRUCTURES.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">营销目的</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择营销目的"
|
||||
value={p.marketingPurpose || undefined}
|
||||
onChange={(v) => p.onParamChange({ marketingPurpose: v })}
|
||||
options={PURPOSES.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">文案视频时长</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={p.duration}
|
||||
onChange={(v) => p.onParamChange({ duration: v })}
|
||||
options={DURATIONS.map((n) => ({ value: n, label: `${n}秒` }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 短剧模式提示 */}
|
||||
{p.marketingPurpose && p.marketingPurpose.includes("短剧") && (
|
||||
<div className="vv-shortdrama-hint">
|
||||
💡 短剧模式已选中,对手戏角色将自动分配配角音色
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文案审核错误(持久显示) */}
|
||||
{p.copyError && <ErrorRetryBar label="文案审核未通过" message={p.copyError} />}
|
||||
|
||||
{/* 分镜结果区 / 流式预览 */}
|
||||
<StoryboardCard
|
||||
uiStep={p.uiStep}
|
||||
scriptStreamText={p.scriptStreamText}
|
||||
scriptStreamMessage={p.scriptStreamMessage}
|
||||
jobPhaseMessage={getJobPhaseMessage(p.job)}
|
||||
storyboard={p.storyboard}
|
||||
editor={p.editor}
|
||||
images={p.images}
|
||||
locked={p.locked}
|
||||
onRegenerateCopy={p.onGenerateCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showGenerateBtn && (
|
||||
<button
|
||||
className={`vv-btn vv-btn-primary ${!p.step2Enabled ? "vv-btn-disabled-gray" : ""}`}
|
||||
onClick={p.onGenerateCopy}
|
||||
disabled={isGenerating || !p.step2Enabled}
|
||||
title={!p.step2Enabled ? "请先完成图片分析" : ""}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<VVSpinner /> AI 生成文案中…
|
||||
</>
|
||||
) : !p.step2Enabled ? (
|
||||
<>
|
||||
<EditOutlined /> 生成文案(请先完成图片分析)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EditOutlined /> 生成文案
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step2CopyPanel
|
||||
@@ -1,216 +0,0 @@
|
||||
import React from "react"
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
ReloadOutlined,
|
||||
HistoryOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Select } from "antd"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import { ErrorRetryBar } from "./ErrorRetryBar"
|
||||
import type { UIStep, ViralVideoJob, ViralVideoModel } from "../types"
|
||||
import { getJobPhaseMessage } from "@/api/viral-video/types"
|
||||
import { RATIOS, QUALITY_OPTIONS, UPSCALE_OPTIONS } from "../ViralVideoPage.constants"
|
||||
|
||||
export interface Step3VideoPanelProps {
|
||||
step3Enabled: boolean
|
||||
uiStep: UIStep
|
||||
videoRatio: string
|
||||
videoModel: string
|
||||
quality: string
|
||||
upscale: string
|
||||
duration: number
|
||||
estimatedCredits: number | null
|
||||
estimatedCreditsError: boolean
|
||||
isGenerating: boolean
|
||||
outputUrl: string | null
|
||||
videoError?: string
|
||||
creditsReady: boolean
|
||||
job: ViralVideoJob | null
|
||||
videoModels: ViralVideoModel[]
|
||||
locked: boolean
|
||||
// callbacks
|
||||
onRatioChange: (v: string) => void
|
||||
onModelChange: (v: string) => void
|
||||
onQualityChange: (v: string) => void
|
||||
onUpscaleChange: (v: string) => void
|
||||
onGenerate: () => void
|
||||
onReset: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export const Step3VideoPanel: React.FC<Step3VideoPanelProps> = (p) => {
|
||||
const isDone = p.uiStep === "step3_done"
|
||||
const isGenerating = p.uiStep === "step3_generating"
|
||||
const isFailed = p.uiStep === "failed"
|
||||
|
||||
const curM = p.videoModels.find((m) => m && m.key === p.videoModel)
|
||||
const supportedRes = Array.isArray(curM?.supported_resolutions)
|
||||
? curM!.supported_resolutions
|
||||
: QUALITY_OPTIONS.map((o) => o.v)
|
||||
const qualityPool =
|
||||
supportedRes && supportedRes.length > 0 ? supportedRes : QUALITY_OPTIONS.map((o) => o.v)
|
||||
const qualityOptions = QUALITY_OPTIONS.filter((o) => qualityPool.includes(o.v))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vv-section">
|
||||
<div className="vv-step-subhead vv-step3-head">
|
||||
<div className="vv-step3-head-left">
|
||||
<span className={`vv-step-badge ${p.step3Enabled ? "" : "vv-step-badge-dim"}`}>3</span>
|
||||
<span className="vv-step-subtitle">STEP 3 生成视频</span>
|
||||
</div>
|
||||
{p.step3Enabled && (
|
||||
<button
|
||||
className="vv-btn vv-btn-ghost vv-btn-xs vv-history-btn"
|
||||
onClick={() => {
|
||||
// 历史作品功能待开发,此处保留原行为(message 由父组件触发)
|
||||
}}
|
||||
title="历史作品"
|
||||
>
|
||||
<HistoryOutlined /> 历史作品
|
||||
</button>
|
||||
)}
|
||||
{!p.step3Enabled && <span className="vv-step-lock">请先完成 STEP 2</span>}
|
||||
</div>
|
||||
<div className="vv-step3-divider" />
|
||||
<div className="vv-section-body vv-section-body-scroll vv-step3-config-body">
|
||||
<div className="vv-scroll-inner">
|
||||
<div className="vv-step3-grid">
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">视频比例</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={p.videoRatio}
|
||||
onChange={p.onRatioChange}
|
||||
disabled={p.locked}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">视频模型</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={p.videoModel}
|
||||
disabled={p.locked}
|
||||
onChange={(v) => p.onModelChange(v)}
|
||||
options={p.videoModels
|
||||
.filter((m) => m && typeof m === "object" && m.key)
|
||||
.map((m) => ({ value: m.key, label: m.display_name || m.key }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">清晰度</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={p.quality}
|
||||
onChange={p.onQualityChange}
|
||||
disabled={p.locked}
|
||||
options={qualityOptions.map((o) => ({ value: o.v, label: o.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">成片超分</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={p.upscale}
|
||||
onChange={p.onUpscaleChange}
|
||||
disabled={p.locked}
|
||||
options={UPSCALE_OPTIONS.map((m) => ({ value: m.v, label: m.label }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.creditsReady && (
|
||||
<div className="vv-credits">
|
||||
<span>
|
||||
<SoundOutlined style={{ marginRight: 4 }} />
|
||||
预计消耗
|
||||
</span>
|
||||
<strong>
|
||||
{p.estimatedCreditsError
|
||||
? "-- 积分"
|
||||
: p.estimatedCredits === null
|
||||
? "… 积分"
|
||||
: `${p.estimatedCredits.toFixed(2)} 积分`}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频预览 */}
|
||||
<div className="vv-section vv-section-preview">
|
||||
<div className="vv-section-body vv-preview-body">
|
||||
<div className="vv-preview">
|
||||
{isDone && p.outputUrl ? (
|
||||
<video src={p.outputUrl} controls />
|
||||
) : isGenerating ? (
|
||||
<div className="vv-preview-placeholder">
|
||||
<div className="ph-icon">
|
||||
<LoadingOutlined className="vv-loading-ic" />
|
||||
</div>
|
||||
<div className="ph-txt">
|
||||
AI 正在生成视频中…
|
||||
<br />
|
||||
预计 1-3 分钟,请勿关闭页面
|
||||
</div>
|
||||
{getJobPhaseMessage(p.job) && (
|
||||
<div className="vv-muted vv-mt-6 vv-fs-12">{getJobPhaseMessage(p.job)}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="vv-preview-placeholder">
|
||||
<div className="ph-icon">🎬</div>
|
||||
<div className="ph-txt">完成前面步骤后视频将在此处预览</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误条 */}
|
||||
{isFailed && p.videoError && (
|
||||
<ErrorRetryBar label="视频生成失败" message={p.videoError} onRetry={p.onRetry} />
|
||||
)}
|
||||
|
||||
{/* 主按钮 */}
|
||||
{!isDone ? (
|
||||
<button
|
||||
className={`vv-btn vv-btn-primary ${!p.step3Enabled ? "vv-btn-disabled-gray" : ""}`}
|
||||
onClick={p.onGenerate}
|
||||
disabled={isGenerating || !p.step3Enabled}
|
||||
title={!p.step3Enabled ? "请先在 STEP 2 生成文案" : ""}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<VVSpinner /> AI 正在生成视频中…
|
||||
</>
|
||||
) : !p.step3Enabled ? (
|
||||
<>
|
||||
<VideoCameraOutlined /> 开始生成视频(请先生成文案)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<VideoCameraOutlined /> 开始生成视频
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
p.outputUrl && (
|
||||
<button className="vv-btn vv-btn-ghost vv-btn-block" onClick={p.onReset}>
|
||||
<ReloadOutlined /> 再生成一个
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3VideoPanel
|
||||
@@ -1,327 +0,0 @@
|
||||
import React from "react"
|
||||
import { Select } from "antd"
|
||||
import { FileTextOutlined, SoundOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { InlineText } from "./InlineEditTextarea"
|
||||
import { StoryboardShotItem } from "./StoryboardShotItem"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import type { StoryboardEditor, SbDoc } from "../hooks/useStoryboardEditor"
|
||||
import type { UploadedImage, UIStep } from "../types"
|
||||
import { RATIOS } from "../ViralVideoPage.constants"
|
||||
|
||||
export interface StoryboardCardProps {
|
||||
/** 当前 uiStep(仅用于按钮/锁定判定) */
|
||||
uiStep: UIStep
|
||||
/** 当前流式预览文本(step2_generating 时优先展示) */
|
||||
scriptStreamText: string
|
||||
scriptStreamMessage?: string
|
||||
jobPhaseMessage?: string
|
||||
/** 真实 storyboard(已补默认值的 SbDoc) */
|
||||
storyboard: SbDoc | null
|
||||
editor: StoryboardEditor
|
||||
images: UploadedImage[]
|
||||
/** 是否锁定编辑(视频生成中/完成时为 true) */
|
||||
locked: boolean
|
||||
onRegenerateCopy?: () => void
|
||||
}
|
||||
|
||||
import { renderMarkdown } from "../utils/renderMarkdown"
|
||||
|
||||
/** 解析 voiceover_script 中的 [speaker:xxx] 标记,渲染为带颜色的 React 片段 */
|
||||
function renderVoiceoverWithSpeakers(text: string) {
|
||||
if (!text) return null
|
||||
const parts = text.split(/\[speaker:([^\]]+)\]/)
|
||||
if (parts.length === 1) return <span>{text}</span>
|
||||
const elements: React.ReactNode[] = []
|
||||
const MAIN_SPEAKERS = ["店主", "主播", "老板", "我", "主讲人"]
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const speaker = parts[i]
|
||||
const segText = parts[i + 1] || ""
|
||||
const isMain = MAIN_SPEAKERS.includes(speaker)
|
||||
elements.push(
|
||||
<span key={i}>
|
||||
<strong className={isMain ? "vv-sb-speaker-main" : "vv-sb-speaker-guest"}>{speaker}</strong>
|
||||
<span>{segText}</span>
|
||||
</span>,
|
||||
)
|
||||
}
|
||||
return <>{elements}</>
|
||||
}
|
||||
|
||||
export const StoryboardCard: React.FC<StoryboardCardProps> = ({
|
||||
uiStep,
|
||||
scriptStreamText,
|
||||
scriptStreamMessage,
|
||||
jobPhaseMessage,
|
||||
storyboard,
|
||||
editor,
|
||||
images,
|
||||
locked,
|
||||
onRegenerateCopy,
|
||||
}) => {
|
||||
const {
|
||||
editingField,
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
commitField,
|
||||
updateOverview,
|
||||
updateConstraint,
|
||||
addConstraint,
|
||||
removeConstraint,
|
||||
updateSbField,
|
||||
} = editor
|
||||
|
||||
/* ── 生成中:流式预览 or 骨架 ── */
|
||||
if (uiStep === "step2_generating") {
|
||||
const hasStream = scriptStreamText.trim().length > 0
|
||||
const stageMsg = scriptStreamMessage || jobPhaseMessage || "🎬 AI 正在创作视频分镜脚本..."
|
||||
if (!hasStream) {
|
||||
return (
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<VVSpinner />
|
||||
<span className="vv-copy-loading-text">{stageMsg}</span>
|
||||
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="vv-copy-box vv-storyboard vv-streaming-box">
|
||||
<div className="vv-streaming-header">
|
||||
<VVSpinner />
|
||||
<span className="vv-streaming-title">{stageMsg}</span>
|
||||
<span className="vv-streaming-count">{scriptStreamText.length} 字</span>
|
||||
</div>
|
||||
<div className="vv-sb-doc vv-streaming-doc">
|
||||
<pre className="vv-streaming-pre">
|
||||
{scriptStreamText
|
||||
.replace(/<\/?[a-zA-Z_][\w:-]*(?:\s[^>]*)?>/g, "")
|
||||
.replace(/\s{3,}/g, "\n\n")
|
||||
.trim()}
|
||||
<span className="vv-streaming-caret" aria-hidden="true" />
|
||||
</pre>
|
||||
</div>
|
||||
<div className="vv-sb-actions vv-streaming-actions">
|
||||
<span className="vv-streaming-hint">AI 实时输出中,完成后可编辑分镜与口播文案</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 没有 storyboard 时的空态 ── */
|
||||
if (!storyboard) {
|
||||
if (uiStep === "step2_copy_ready" || uiStep === "step3_generating" || uiStep === "step3_done") {
|
||||
return (
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<span className="vv-muted">暂无分镜数据</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const sb = storyboard
|
||||
const textCls = "vv-sb-field-val"
|
||||
const taCls = "vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
|
||||
|
||||
return (
|
||||
<div className="vv-copy-box vv-storyboard">
|
||||
<div className="vv-sb-doc">
|
||||
{/* 文案预览 markdown(只读) */}
|
||||
{sb.copy_display_markdown && (
|
||||
<div className="vv-copy-preview">
|
||||
<h4 className="vv-sb-h vv-copy-preview-h">
|
||||
<FileTextOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
|
||||
文案预览
|
||||
</h4>
|
||||
<div
|
||||
className="vv-md-body"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(sb.copy_display_markdown) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频总览 */}
|
||||
<h4 className="vv-sb-h">视频总览</h4>
|
||||
<p className="vv-sb-inline-row">
|
||||
<strong className="vv-sb-field-k">整体主题:</strong>
|
||||
<InlineText
|
||||
value={sb.overview.theme}
|
||||
editing={!locked && editingField === "overview.theme"}
|
||||
onStartEdit={() => startEdit("overview.theme")}
|
||||
onCommit={(v) => commitField("overview.theme", v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={`${taCls} vv-sb-inline-edit-ta-sm`}
|
||||
disabled={locked}
|
||||
rows={1}
|
||||
/>
|
||||
</p>
|
||||
<p className="vv-sb-inline-row">
|
||||
<strong className="vv-sb-field-k">总时长:</strong>
|
||||
<InlineText
|
||||
value={sb.overview.total_duration}
|
||||
editing={!locked && editingField === "overview.total_duration"}
|
||||
onStartEdit={() => startEdit("overview.total_duration")}
|
||||
onCommit={(v) => commitField("overview.total_duration", v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={`${taCls} vv-sb-inline-edit-ta-sm`}
|
||||
disabled={locked}
|
||||
rows={1}
|
||||
/>
|
||||
</p>
|
||||
<p className="vv-sb-inline-row">
|
||||
<strong className="vv-sb-field-k">画幅:</strong>
|
||||
<Select
|
||||
className="vv-select vv-sb-inline-select"
|
||||
style={{ width: 90 }}
|
||||
value={sb.overview.aspect_ratio}
|
||||
disabled={locked}
|
||||
onChange={(v) => updateOverview({ aspect_ratio: v })}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
|
||||
variant="borderless"
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 场景与光线 */}
|
||||
<h4 className="vv-sb-h">场景与光线</h4>
|
||||
<p className="vv-sb-para">
|
||||
<InlineText
|
||||
value={sb.scene_and_lighting}
|
||||
editing={!locked && editingField === "scene_and_lighting"}
|
||||
onStartEdit={() => startEdit("scene_and_lighting")}
|
||||
onCommit={(v) => commitField("scene_and_lighting", v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
placeholder="描述整体场景氛围、光线方向与色温…"
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 逐镜头 */}
|
||||
<h4 className="vv-sb-h">逐秒镜头拆解</h4>
|
||||
<div className="vv-sb-doc-shots">
|
||||
{sb.shots.map((sh, idx) => (
|
||||
<StoryboardShotItem
|
||||
key={idx}
|
||||
index={idx}
|
||||
shot={sh}
|
||||
editor={editor}
|
||||
images={images}
|
||||
locked={locked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 硬性约束 */}
|
||||
<h4 className="vv-sb-h">硬性约束</h4>
|
||||
<div className="vv-sb-tags-block">
|
||||
<div className="vv-sb-taglist">
|
||||
{sb.hard_constraints.map((c, i) => (
|
||||
<span key={i} className="vv-sb-tag vv-sb-tag-hard">
|
||||
<input
|
||||
className="vv-sb-tag-input"
|
||||
value={c}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateConstraint("hard_constraints", i, e.target.value)}
|
||||
/>
|
||||
{!locked && (
|
||||
<button
|
||||
className="vv-sb-tag-del"
|
||||
onClick={() => removeConstraint("hard_constraints", i)}
|
||||
aria-label="删除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!locked && (
|
||||
<button className="vv-sb-tag-add" onClick={() => addConstraint("hard_constraints")}>
|
||||
+ 添加
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 负面提示词 */}
|
||||
<h4 className="vv-sb-h">负面提示词</h4>
|
||||
<div className="vv-sb-tags-block">
|
||||
<div className="vv-sb-taglist">
|
||||
{sb.negative_prompts.map((c, i) => (
|
||||
<span key={i} className="vv-sb-tag vv-sb-tag-neg">
|
||||
<input
|
||||
className="vv-sb-tag-input"
|
||||
value={c}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateConstraint("negative_prompts", i, e.target.value)}
|
||||
/>
|
||||
{!locked && (
|
||||
<button
|
||||
className="vv-sb-tag-del"
|
||||
onClick={() => removeConstraint("negative_prompts", i)}
|
||||
aria-label="删除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!locked && (
|
||||
<button className="vv-sb-tag-add" onClick={() => addConstraint("negative_prompts")}>
|
||||
+ 添加
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完整口播稿 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">
|
||||
<SoundOutlined style={{ color: "#7c3aed", marginRight: 4 }} />
|
||||
口播稿:
|
||||
</strong>
|
||||
{editingField === "voiceover_script" ? (
|
||||
<textarea
|
||||
className={taCls}
|
||||
value={sb.voiceover_script}
|
||||
autoFocus
|
||||
onChange={(e) => updateSbField("voiceover_script", e.target.value)}
|
||||
onBlur={() => {
|
||||
// 受控模式:blur 即退出编辑(内容已实时同步)
|
||||
if (editingField === "voiceover_script") cancelEdit()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") cancelEdit()
|
||||
}}
|
||||
placeholder="AI 合成配音用的完整口播稿…"
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={textCls}
|
||||
onClick={() => {
|
||||
if (!locked) startEdit("voiceover_script")
|
||||
}}
|
||||
style={{ cursor: locked ? "not-allowed" : "text" }}
|
||||
>
|
||||
{renderVoiceoverWithSpeakers(sb.voiceover_script)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="vv-sb-actions">
|
||||
{uiStep === "step2_copy_ready" && onRegenerateCopy && (
|
||||
<button className="vv-btn vv-btn-ghost" onClick={onRegenerateCopy}>
|
||||
<ReloadOutlined /> 重新生成文案
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoryboardCard
|
||||
@@ -1,182 +0,0 @@
|
||||
import React from "react"
|
||||
import { InlineText } from "./InlineEditTextarea"
|
||||
import type { StoryboardEditor, SbShot } from "../hooks/useStoryboardEditor"
|
||||
import type { UploadedImage } from "../types"
|
||||
|
||||
export interface StoryboardShotItemProps {
|
||||
index: number
|
||||
shot: SbShot
|
||||
editor: StoryboardEditor
|
||||
images: UploadedImage[]
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
const MAIN_SPEAKERS = ["店主", "主播", "老板", "我", "主讲人"]
|
||||
|
||||
const SHOT_FIELD_KEY = (idx: number, field: string) => `shot.${idx}.${field}`
|
||||
|
||||
/** 单个镜头行:时间/说话人/景别/场景/动作/BGM/转场/参考图 */
|
||||
export const StoryboardShotItem: React.FC<StoryboardShotItemProps> = ({
|
||||
index,
|
||||
shot,
|
||||
editor,
|
||||
images,
|
||||
locked,
|
||||
}) => {
|
||||
const { editingField, startEdit, cancelEdit, commitField, updateShot } = editor
|
||||
const refImg =
|
||||
typeof shot.reference_image_index === "number" ? images[shot.reference_image_index] : undefined
|
||||
|
||||
const textCls = "vv-sb-field-val"
|
||||
const taCls = "vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
|
||||
|
||||
const isMainSpeaker = shot.speaker ? MAIN_SPEAKERS.includes(shot.speaker) : false
|
||||
|
||||
return (
|
||||
<div className="vv-sb-doc-shot">
|
||||
{/* 时间 */}
|
||||
<InlineText
|
||||
value={shot.time_range}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "time_range")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "time_range"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "time_range"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass="vv-sb-time-doc"
|
||||
textareaClass={`${taCls} vv-sb-time-ta`}
|
||||
placeholder="0-3秒"
|
||||
disabled={locked}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* 说话人标签(有 speaker 时展示,纯展示不加编辑) */}
|
||||
{shot.speaker && (
|
||||
<span
|
||||
className={`vv-sb-speaker-tag ${isMainSpeaker ? "vv-sb-speaker-tag-main" : "vv-sb-speaker-tag-guest"}`}
|
||||
>
|
||||
{shot.speaker}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 景别/角度与运镜 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">景别/角度与运镜:</strong>
|
||||
<InlineText
|
||||
value={shot.shot_type_angle_movement}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "shot_type_angle_movement")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "shot_type_angle_movement"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "shot_type_angle_movement"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 场景与对白 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">场景与对白:</strong>
|
||||
<InlineText
|
||||
value={shot.scene_and_dialogue}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "scene_and_dialogue")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "scene_and_dialogue"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "scene_and_dialogue"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 动作与真人细节 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">动作与真人细节:</strong>
|
||||
<InlineText
|
||||
value={shot.action_details}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "action_details")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "action_details"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "action_details"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 音效/BGM */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">音效/BGM:</strong>
|
||||
<InlineText
|
||||
value={shot.audio_bgm}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "audio_bgm")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "audio_bgm"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "audio_bgm"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={1}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 转场 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">转场:</strong>
|
||||
<InlineText
|
||||
value={shot.transition}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "transition")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "transition"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "transition"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={1}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 参考图片 */}
|
||||
<p className="vv-sb-field vv-sb-ref-row">
|
||||
<strong className="vv-sb-field-k">参考图片:</strong>
|
||||
{refImg?.url ? (
|
||||
<span className="vv-sb-ref-badge">
|
||||
<img src={refImg.url} alt={`ref-${index}`} className="vv-sb-ref-thumb" />
|
||||
{!locked && (
|
||||
<button
|
||||
className="vv-sb-ref-x"
|
||||
onClick={() => updateShot(index, { reference_image_index: undefined })}
|
||||
aria-label="移除参考图"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
) : !locked && images.length > 0 ? (
|
||||
<span className="vv-sb-ref-pick">
|
||||
{images.map((im, i) => (
|
||||
<button
|
||||
key={im.localId}
|
||||
className="vv-sb-ref-opt"
|
||||
onClick={() => updateShot(index, { reference_image_index: i })}
|
||||
title={`@图片${i + 1}`}
|
||||
>
|
||||
<img src={im.url} alt={`img${i}`} />
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="vv-sb-muted">未指定</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoryboardShotItem
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
export interface VVSpinnerProps {
|
||||
size?: number
|
||||
color?: string
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 spinner 组件:复用 vv-spinner CSS 动画类,避免多处散落的 LoadingOutlined
|
||||
*/
|
||||
export const VVSpinner: React.FC<VVSpinnerProps> = ({ size, color, style, className }) => {
|
||||
return (
|
||||
<span
|
||||
className={`vv-spinner ${className || ""}`}
|
||||
style={{
|
||||
...(size ? { width: size, height: size, borderWidth: Math.max(2, size / 8) } : {}),
|
||||
...(color ? { borderTopColor: color } : {}),
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default VVSpinner
|
||||
@@ -1,216 +0,0 @@
|
||||
import React from "react"
|
||||
import {
|
||||
UserOutlined,
|
||||
AudioMutedOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
FileTextOutlined,
|
||||
FolderOpenOutlined,
|
||||
UploadOutlined,
|
||||
CaretDownOutlined,
|
||||
CaretRightOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { message } from "antd"
|
||||
import type { RefAudio, VoicePanel } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { getVoiceClonePreview } from "@/api/voice-clone"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { VOICE_TIPS } from "../ViralVideoPage.constants"
|
||||
|
||||
export interface VoicePickerSectionProps {
|
||||
refAudio: RefAudio
|
||||
voicePanel: VoicePanel
|
||||
voiceTipsOpen: boolean
|
||||
myVoices: VoiceClone[]
|
||||
myVoicesLoading: boolean
|
||||
voiceAssets: { id: string; name: string; url: string; desc?: string }[]
|
||||
playingVoiceId: string | null
|
||||
voiceInputRef: React.Ref<HTMLInputElement>
|
||||
onOpenCloneModal: () => void
|
||||
onToggleVoicePanel: (p: VoicePanel) => void
|
||||
onSelectMyVoice: (v: VoiceClone) => void
|
||||
onSelectVoiceAsset: (a: AssetItem) => void
|
||||
onToggleVoicePlay: (v: { id: string; url: string }) => void
|
||||
onPickLocalAudio: () => void
|
||||
onPickAssetAudio: () => void
|
||||
onVoiceSelected: (file: File) => void
|
||||
onRemoveRefAudio: () => void
|
||||
onToggleVoiceTips: () => void
|
||||
}
|
||||
|
||||
export const VoicePickerSection: React.FC<VoicePickerSectionProps> = (p) => {
|
||||
return (
|
||||
<div className="vv-subblock">
|
||||
<div className="vv-subblock-head">
|
||||
<span className="vv-subblock-title">AI 配音</span>
|
||||
<span className="vv-opt-tag">选填</span>
|
||||
<span className="vv-subblock-hint">默认使用我的克隆音色,也可选择内置音色或上传配音</span>
|
||||
</div>
|
||||
|
||||
{/* 我的音色(主路径) */}
|
||||
<div className="vv-voice-section">
|
||||
<div className="vv-voice-section-head">
|
||||
<span className="vv-voice-section-title">
|
||||
<UserOutlined /> 我的音色
|
||||
</span>
|
||||
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onOpenCloneModal}>
|
||||
<PlusOutlined /> 录制新音色
|
||||
</button>
|
||||
</div>
|
||||
{p.myVoicesLoading ? (
|
||||
<div className="vv-muted vv-voice-empty">加载中…</div>
|
||||
) : p.myVoices.length === 0 ? (
|
||||
<div className="vv-voice-empty">
|
||||
<AudioMutedOutlined className="vv-voice-empty-ic" />
|
||||
<div className="vv-voice-empty-text">还没有克隆音色</div>
|
||||
<button className="vv-btn vv-btn-primary vv-btn-sm" onClick={p.onOpenCloneModal}>
|
||||
去录制一个
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vv-my-voice-list">
|
||||
{p.myVoices.map((v) => {
|
||||
const selected = p.refAudio?.source === "my_voice" && p.refAudio.id === v.id
|
||||
const playing = p.playingVoiceId === `my-${v.id}`
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`vv-my-voice-item ${selected ? "selected" : ""}`}
|
||||
onClick={() => p.onSelectMyVoice(v)}
|
||||
>
|
||||
<div className="vv-voice-radio" />
|
||||
<div className="vv-voice-info">
|
||||
<div className="vv-voice-name">{v.name}</div>
|
||||
<div className="vv-voice-desc">
|
||||
AI 克隆声音
|
||||
{v.gender
|
||||
? ` · ${v.gender === "male" ? "男" : v.gender === "female" ? "女" : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`vv-voice-play ${playing ? "playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (v.sample_url) {
|
||||
p.onToggleVoicePlay({ id: `my-${v.id}`, url: v.sample_url })
|
||||
} else {
|
||||
message.info("正在获取试听音频…")
|
||||
getVoiceClonePreview(v.id)
|
||||
.then((r) => {
|
||||
if (r.audio_url)
|
||||
p.onToggleVoicePlay({ id: `my-${v.id}`, url: r.audio_url })
|
||||
})
|
||||
.catch(() => message.error("试听获取失败"))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{playing ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 次要选项 */}
|
||||
<div className="vv-voice-alt-row">
|
||||
<button
|
||||
className={`vv-voice-alt-btn ${p.refAudio?.source === "preset" ? "selected" : ""}`}
|
||||
onClick={() => p.onToggleVoicePanel("preset")}
|
||||
>
|
||||
<FileTextOutlined /> 内置音色
|
||||
</button>
|
||||
<button
|
||||
className={`vv-voice-alt-btn ${p.refAudio?.source === "upload" || p.refAudio?.source === "library" ? "selected" : ""}`}
|
||||
onClick={() => p.onToggleVoicePanel("library")}
|
||||
>
|
||||
<FolderOpenOutlined /> 上传配音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={p.voiceInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) p.onVoiceSelected(e.target.files[0])
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
{p.refAudio && (
|
||||
<div className="vv-selected-audio">
|
||||
<span className="vv-mr-6">🔊</span>
|
||||
<span className="vv-flex-1">
|
||||
已选:
|
||||
{p.refAudio.source === "my_voice"
|
||||
? `我的音色 · ${p.refAudio.name}`
|
||||
: p.refAudio.source === "preset"
|
||||
? `内置 · ${p.refAudio.name}`
|
||||
: p.refAudio.name}
|
||||
</span>
|
||||
<button className="vv-link-btn" onClick={p.onRemoveRefAudio}>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.voicePanel === "library" && (
|
||||
<div className="vv-voice-panel">
|
||||
<div className="vv-voice-panel-actions">
|
||||
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onPickLocalAudio}>
|
||||
<UploadOutlined /> 本地上传音频文件
|
||||
</button>
|
||||
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onPickAssetAudio}>
|
||||
<FolderOpenOutlined /> 从素材库选择
|
||||
</button>
|
||||
</div>
|
||||
{p.voiceAssets.length === 0 ? (
|
||||
<div className="vv-muted vv-text-center vv-p-12">暂无已上传的配音文件</div>
|
||||
) : (
|
||||
<div className="vv-voice-list">
|
||||
{p.voiceAssets.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`vv-voice-item ${p.refAudio?.id === v.id && (p.refAudio.source === "library" || p.refAudio.source === "upload") ? "selected" : ""}`}
|
||||
onClick={() =>
|
||||
p.onSelectVoiceAsset({ id: v.id, name: v.name, file_url: v.url } as AssetItem)
|
||||
}
|
||||
>
|
||||
<div className="vv-voice-radio" />
|
||||
<div className="vv-voice-info">
|
||||
<div className="vv-voice-name">{v.name}</div>
|
||||
<div className="vv-voice-desc">{v.desc || ""}</div>
|
||||
</div>
|
||||
{v.url && (
|
||||
<button
|
||||
className={`vv-voice-play ${p.playingVoiceId === v.id ? "playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
p.onToggleVoicePlay(v)
|
||||
}}
|
||||
>
|
||||
{p.playingVoiceId === v.id ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vv-tips">
|
||||
<button className="vv-tips-head" onClick={p.onToggleVoiceTips}>
|
||||
{p.voiceTipsOpen ? <CaretDownOutlined /> : <CaretRightOutlined />}
|
||||
支持格式与录音建议
|
||||
</button>
|
||||
{p.voiceTipsOpen && <div className="vv-tips-body">{VOICE_TIPS}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoicePickerSection
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
// 最小化的 Storyboard 类型,避免循环依赖(字段名与 api/viral-video/types 对齐)
|
||||
export interface SbShot {
|
||||
time_range: string
|
||||
speaker?: string
|
||||
shot_type_angle_movement: string
|
||||
scene_and_dialogue: string
|
||||
action_details: string
|
||||
audio_bgm: string
|
||||
transition: string
|
||||
reference_image_index?: number
|
||||
}
|
||||
export interface SbOverview {
|
||||
theme: string
|
||||
total_duration: string
|
||||
aspect_ratio: string
|
||||
}
|
||||
export interface SbDoc {
|
||||
overview: SbOverview
|
||||
scene_and_lighting: string
|
||||
shots: SbShot[]
|
||||
hard_constraints: string[]
|
||||
negative_prompts: string[]
|
||||
voiceover_script: string
|
||||
copy_display_markdown: string
|
||||
}
|
||||
|
||||
export type SbUpdater = (patch: (sb: SbDoc) => SbDoc) => void
|
||||
|
||||
export interface StoryboardEditor {
|
||||
editingField: string | null
|
||||
startEdit: (field: string) => void
|
||||
cancelEdit: () => void
|
||||
updateShot: (idx: number, patch: Partial<SbShot>) => void
|
||||
updateOverview: (patch: Partial<SbOverview>) => void
|
||||
updateSbField: <K extends keyof SbDoc>(key: K, value: SbDoc[K]) => void
|
||||
updateConstraint: (list: "hard_constraints" | "negative_prompts", idx: number, v: string) => void
|
||||
addConstraint: (list: "hard_constraints" | "negative_prompts") => void
|
||||
removeConstraint: (list: "hard_constraints" | "negative_prompts", idx: number) => void
|
||||
commitField: (field: string, value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 分镜脚本编辑器 hook:管理 editingField 状态,以及 storyboard 字段级更新方法。
|
||||
* 父组件传入 updateSb(fn) 即可,fn 接收当前 sb 返回新 sb(immer 风格)。
|
||||
*/
|
||||
export function useStoryboardEditor(updateSb: SbUpdater): StoryboardEditor {
|
||||
const [editingField, setEditingField] = useState<string | null>(null)
|
||||
|
||||
const startEdit = useCallback((field: string) => {
|
||||
setEditingField(field)
|
||||
}, [])
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingField(null)
|
||||
}, [])
|
||||
|
||||
const updateShot = useCallback(
|
||||
(idx: number, patch: Partial<SbShot>) => {
|
||||
updateSb((sb) => ({
|
||||
...sb,
|
||||
shots: sb.shots.map((s, i) => (i === idx ? { ...s, ...patch } : s)),
|
||||
}))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const updateOverview = useCallback(
|
||||
(patch: Partial<SbOverview>) => {
|
||||
updateSb((sb) => ({ ...sb, overview: { ...sb.overview, ...patch } }))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const updateSbField = useCallback(
|
||||
<K extends keyof SbDoc>(key: K, value: SbDoc[K]) => {
|
||||
updateSb((sb) => ({ ...sb, [key]: value }))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const updateConstraint = useCallback(
|
||||
(list: "hard_constraints" | "negative_prompts", idx: number, v: string) => {
|
||||
updateSb((sb) => {
|
||||
const arr = [...sb[list]]
|
||||
arr[idx] = v
|
||||
return { ...sb, [list]: arr }
|
||||
})
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const addConstraint = useCallback(
|
||||
(list: "hard_constraints" | "negative_prompts") => {
|
||||
updateSb((sb) => ({ ...sb, [list]: [...sb[list], ""] }))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const removeConstraint = useCallback(
|
||||
(list: "hard_constraints" | "negative_prompts", idx: number) => {
|
||||
updateSb((sb) => ({ ...sb, [list]: sb[list].filter((_, i) => i !== idx) }))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const commitField = useCallback(
|
||||
(field: string, value: string) => {
|
||||
if (field.startsWith("shot.")) {
|
||||
const parts = field.split(".")
|
||||
const idx = Number(parts[1])
|
||||
const shotField = parts.slice(2).join(".") as keyof SbShot
|
||||
updateShot(idx, { [shotField]: value } as Partial<SbShot>)
|
||||
} else if (field.startsWith("overview.")) {
|
||||
const f = field.split(".")[1] as keyof SbOverview
|
||||
updateOverview({ [f]: value } as Partial<SbOverview>)
|
||||
} else if (field === "scene_and_lighting" || field === "voiceover_script") {
|
||||
updateSbField(field, value)
|
||||
}
|
||||
setEditingField(null)
|
||||
},
|
||||
[updateShot, updateOverview, updateSbField],
|
||||
)
|
||||
|
||||
return {
|
||||
editingField,
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
updateShot,
|
||||
updateOverview,
|
||||
updateSbField,
|
||||
updateConstraint,
|
||||
addConstraint,
|
||||
removeConstraint,
|
||||
commitField,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStoryboardEditor
|
||||
@@ -1,403 +0,0 @@
|
||||
import { useCallback, useEffect } from "react"
|
||||
import axios from "axios"
|
||||
import { message } from "antd"
|
||||
import { isVideoStage, isCopyStage, getJobStage } from "@/api/viral-video/types"
|
||||
import type { UIStep, TabTask } from "../types"
|
||||
import {
|
||||
retryViralVideo,
|
||||
analyzeViralImages,
|
||||
generateViralCopy,
|
||||
confirmViralCopy,
|
||||
} from "@/api/viral-video"
|
||||
import type { CopyResult } from "@/api/viral-video/types"
|
||||
import type { ViralVideoJob } from "@/api/viral-video/types"
|
||||
import { applyPollUpdate } from "../utils/applyPollUpdate"
|
||||
import {
|
||||
useViralVideoMedia,
|
||||
type UseViralVideoMediaOptions,
|
||||
type MediaActions,
|
||||
} from "./useViralVideoMedia"
|
||||
import { resolveModelSwitch } from "../utils/resolveModelSwitch"
|
||||
|
||||
export interface ViralVideoActionsApi extends MediaActions {
|
||||
resetTask: () => void
|
||||
handleAnalyzeImages: () => void
|
||||
handleGenerateCopy: () => void
|
||||
handleGenerateVideo: () => void
|
||||
handleRetry: () => void
|
||||
onPollUpdate: (job: ViralVideoJob) => void
|
||||
onScriptDelta: (ev: { data: { full_text?: string }; progress?: number; message?: string }) => void
|
||||
handleModelChange: (
|
||||
v: string,
|
||||
videoModels: { key: string; supported_resolutions?: string[]; max_duration?: number }[],
|
||||
) => void
|
||||
setTask: (patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => void
|
||||
setAssetPicker: React.Dispatch<
|
||||
React.SetStateAction<{ open: boolean; kind: "image" | "video" | "voice"; multiple: boolean }>
|
||||
>
|
||||
setCloneModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setPresetVoicePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
refreshVoiceAssets: () => void
|
||||
reloadMyVoices: () => void
|
||||
}
|
||||
|
||||
export interface UseViralVideoHandlersOptions extends Omit<UseViralVideoMediaOptions, "setTask"> {
|
||||
activeId: string
|
||||
setTasks: React.Dispatch<React.SetStateAction<TabTask[]>>
|
||||
videoModels: {
|
||||
key: string
|
||||
supported_resolutions?: string[]
|
||||
max_duration?: number
|
||||
display_name?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
function emptyTask(id: string, title: string): TabTask {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
images: [],
|
||||
refVideo: null,
|
||||
voicePanel: null,
|
||||
refAudio: null,
|
||||
fusionLevel: "ai_full",
|
||||
styleStrength: "medium",
|
||||
userCopy: "",
|
||||
industry: "",
|
||||
targetCustomer: "",
|
||||
language: "中文(普通话)",
|
||||
viralStructure: "反差破局+亮明观点+还原现状",
|
||||
marketingPurpose: "",
|
||||
duration: 20,
|
||||
persona: "",
|
||||
videoRatio: "9:16",
|
||||
videoModel: "seedance-2.5",
|
||||
quality: "480p",
|
||||
upscale: "default",
|
||||
estimatedCredits: null,
|
||||
estimatedCreditsError: false,
|
||||
uiStep: "step1_upload",
|
||||
imageAnalysis: null,
|
||||
storyboard: null,
|
||||
voiceTipsOpen: false,
|
||||
job: null,
|
||||
jobId: null,
|
||||
playingVoiceId: null,
|
||||
scriptStreamText: "",
|
||||
scriptStreamProgress: 0,
|
||||
scriptStreamMessage: "",
|
||||
}
|
||||
}
|
||||
|
||||
const CLEAR_STREAM = {
|
||||
scriptStreamText: "",
|
||||
scriptStreamProgress: 0,
|
||||
scriptStreamMessage: "",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 业务 action hook:聚合素材操作 + 三步骤 submit/retry/polling/model switch 等业务动作。
|
||||
*/
|
||||
export function useViralVideoHandlers(opts: UseViralVideoHandlersOptions): ViralVideoActionsApi {
|
||||
const {
|
||||
task,
|
||||
activeId,
|
||||
setTasks,
|
||||
setAssetPicker,
|
||||
setCloneModalOpen,
|
||||
refreshVoiceAssets,
|
||||
reloadMyVoices,
|
||||
setPresetVoicePickerOpen,
|
||||
} = opts
|
||||
|
||||
const setTask = useCallback(
|
||||
(patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => {
|
||||
setTasks((ts) =>
|
||||
ts.map((t) =>
|
||||
t.id === activeId ? (typeof patch === "function" ? patch(t) : { ...t, ...patch }) : t,
|
||||
),
|
||||
)
|
||||
},
|
||||
[activeId, setTasks],
|
||||
)
|
||||
|
||||
const media = useViralVideoMedia({
|
||||
task,
|
||||
setTask,
|
||||
setAssetPicker,
|
||||
setCloneModalOpen,
|
||||
setPresetVoicePickerOpen,
|
||||
refreshVoiceAssets,
|
||||
reloadMyVoices,
|
||||
})
|
||||
|
||||
// 卸载时停掉正在播放的音频
|
||||
useEffect(() => () => media.audioRef.current?.pause(), [media.audioRef])
|
||||
|
||||
const resetTask = () => {
|
||||
media.audioRef.current?.pause()
|
||||
setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t)))
|
||||
}
|
||||
|
||||
const handleAnalyzeImages = async () => {
|
||||
const readyImgs = task.images.filter((i) => i.ossUrl && !i.uploading)
|
||||
if (readyImgs.length === 0) {
|
||||
message.warning("请至少上传 1 张产品图")
|
||||
return
|
||||
}
|
||||
if (task.images.some((i) => i.uploading)) {
|
||||
message.warning("图片上传中,请稍候")
|
||||
return
|
||||
}
|
||||
if (task.refVideo?.uploading) {
|
||||
message.warning("参考视频上传中,请稍候")
|
||||
return
|
||||
}
|
||||
setTask({
|
||||
uiStep: "step1_analyzing",
|
||||
jobId: null,
|
||||
job: null,
|
||||
analysisError: undefined,
|
||||
copyError: undefined,
|
||||
videoError: undefined,
|
||||
imageAnalysis: undefined,
|
||||
storyboard: undefined,
|
||||
...CLEAR_STREAM,
|
||||
estimatedCredits: null,
|
||||
estimatedCreditsError: false,
|
||||
})
|
||||
try {
|
||||
const job = await analyzeViralImages({
|
||||
images: readyImgs.map((i) => i.ossUrl as string),
|
||||
reference_video_url: task.refVideo?.ossUrl || undefined,
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
})
|
||||
setTask({ job, jobId: job.id })
|
||||
message.success("已提交图片分析…")
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "分析失败")
|
||||
setTask({
|
||||
uiStep: "failed",
|
||||
analysisError: err instanceof Error ? err.message : "分析失败",
|
||||
copyError: undefined,
|
||||
videoError: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateCopy = async () => {
|
||||
if (!task.jobId) {
|
||||
message.warning("请先完成图片分析")
|
||||
return
|
||||
}
|
||||
setTask({
|
||||
uiStep: "step2_generating",
|
||||
copyError: undefined,
|
||||
videoError: undefined,
|
||||
analysisError: undefined,
|
||||
...CLEAR_STREAM,
|
||||
})
|
||||
try {
|
||||
const job = await generateViralCopy(task.jobId, {
|
||||
industry: task.industry || undefined,
|
||||
target_customer: task.targetCustomer || undefined,
|
||||
viral_structure: task.viralStructure || undefined,
|
||||
marketing_purpose: task.marketingPurpose || undefined,
|
||||
duration: task.duration,
|
||||
persona_id: task.persona || undefined,
|
||||
user_copy_text:
|
||||
(task.fusionLevel === "user_primary" || task.fusionLevel === "ai_polish") && task.userCopy
|
||||
? task.userCopy
|
||||
: undefined,
|
||||
fusion_level: task.fusionLevel,
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
voice_id:
|
||||
task.refAudio?.source === "preset"
|
||||
? task.refAudio.id
|
||||
: task.refAudio?.source === "my_voice"
|
||||
? task.refAudio.ttsVoiceId || task.refAudio.id
|
||||
: undefined,
|
||||
voice_source: task.refAudio?.source || undefined,
|
||||
video_ratio: task.videoRatio,
|
||||
video_model: task.videoModel,
|
||||
})
|
||||
setTask({ job })
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "文案生成失败")
|
||||
setTask({
|
||||
uiStep: "failed",
|
||||
copyError: err instanceof Error ? err.message : "文案生成失败",
|
||||
videoError: undefined,
|
||||
analysisError: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateVideo = async () => {
|
||||
setTask({
|
||||
uiStep: "step3_generating",
|
||||
videoError: undefined,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
...CLEAR_STREAM,
|
||||
})
|
||||
try {
|
||||
let job
|
||||
if (task.jobId && task.storyboard && task.storyboard.voiceover_script.trim()) {
|
||||
const edited = task.storyboard.voiceover_script.trim()
|
||||
const originalCopy =
|
||||
(task.job?.copy_result as CopyResult | null | undefined)?.voiceover_script ||
|
||||
task.job?.copy_result?.final_copy ||
|
||||
""
|
||||
job = await confirmViralCopy(task.jobId, {
|
||||
edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined,
|
||||
video_model: task.videoModel,
|
||||
video_resolution: task.quality,
|
||||
video_ratio: task.videoRatio,
|
||||
duration: task.duration,
|
||||
voice_id:
|
||||
task.refAudio?.source === "preset"
|
||||
? task.refAudio.id
|
||||
: task.refAudio?.source === "my_voice"
|
||||
? task.refAudio.ttsVoiceId || task.refAudio.id
|
||||
: undefined,
|
||||
voice_source: task.refAudio?.source || undefined,
|
||||
language: task.language || undefined,
|
||||
})
|
||||
} else {
|
||||
message.warning("请先生成文案后再生成视频")
|
||||
setTask({
|
||||
uiStep: task.storyboard
|
||||
? "step2_copy_ready"
|
||||
: task.imageAnalysis
|
||||
? "step1_done"
|
||||
: "step1_upload",
|
||||
})
|
||||
return
|
||||
}
|
||||
setTask({ job, jobId: job.id })
|
||||
message.success("视频已提交生成,预计 1-3 分钟…")
|
||||
} catch (err: unknown) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 402) {
|
||||
const detail =
|
||||
(err.response.data as { detail?: string; message?: string; msg?: string } | undefined)
|
||||
?.detail ||
|
||||
(err.response.data as { detail?: string; message?: string; msg?: string } | undefined)
|
||||
?.message ||
|
||||
"积分不足,请充值"
|
||||
message.error(detail)
|
||||
setTask({
|
||||
uiStep: "step2_copy_ready",
|
||||
videoError: detail,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
...CLEAR_STREAM,
|
||||
})
|
||||
return
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "提交失败"
|
||||
message.error(msg)
|
||||
setTask({ uiStep: "failed", videoError: msg, copyError: undefined, analysisError: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (task.jobId) {
|
||||
try {
|
||||
const j = await retryViralVideo(task.jobId)
|
||||
const retryStage = getJobStage(j)
|
||||
let retryStep: UIStep = "step1_analyzing"
|
||||
if (isVideoStage(retryStage)) retryStep = "step3_generating"
|
||||
else if (isCopyStage(retryStage)) retryStep = "step2_generating"
|
||||
else if (task.storyboard) retryStep = "step2_copy_ready"
|
||||
else if (task.imageAnalysis) retryStep = "step1_done"
|
||||
setTask({
|
||||
job: j,
|
||||
jobId: j.id,
|
||||
uiStep: retryStep,
|
||||
videoError: undefined,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
imageAnalysis: undefined,
|
||||
storyboard: undefined,
|
||||
...CLEAR_STREAM,
|
||||
estimatedCredits: null,
|
||||
estimatedCreditsError: false,
|
||||
})
|
||||
message.success("已重试")
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "重试失败")
|
||||
}
|
||||
} else {
|
||||
setTask({
|
||||
uiStep: task.storyboard ? "step2_copy_ready" : "step1_done",
|
||||
jobId: null,
|
||||
job: null,
|
||||
videoError: undefined,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
imageAnalysis: undefined,
|
||||
storyboard: undefined,
|
||||
...CLEAR_STREAM,
|
||||
estimatedCredits: null,
|
||||
estimatedCreditsError: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onPollUpdate = useCallback(
|
||||
(job: ViralVideoJob) => {
|
||||
setTask((t) => applyPollUpdate(t, job))
|
||||
},
|
||||
[setTask],
|
||||
)
|
||||
|
||||
const onScriptDelta = useCallback(
|
||||
(ev: { data: { full_text?: string }; progress?: number; message?: string }) => {
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
scriptStreamText: ev.data.full_text || t.scriptStreamText,
|
||||
scriptStreamProgress:
|
||||
typeof ev.progress === "number" ? ev.progress : t.scriptStreamProgress,
|
||||
scriptStreamMessage: ev.message || t.scriptStreamMessage,
|
||||
}))
|
||||
},
|
||||
[setTask],
|
||||
)
|
||||
|
||||
const handleModelChange = (
|
||||
v: string,
|
||||
vms: { key: string; supported_resolutions?: string[]; max_duration?: number }[],
|
||||
) => {
|
||||
const m = vms.find((x) => x && x.key === v)
|
||||
if (!m) {
|
||||
setTask({ videoModel: v })
|
||||
return
|
||||
}
|
||||
const { quality, duration } = resolveModelSwitch(
|
||||
{ quality: task.quality, duration: task.duration },
|
||||
m,
|
||||
)
|
||||
setTask({ videoModel: v, quality, duration })
|
||||
}
|
||||
|
||||
return {
|
||||
...media,
|
||||
resetTask,
|
||||
handleAnalyzeImages,
|
||||
handleGenerateCopy,
|
||||
handleGenerateVideo,
|
||||
handleRetry,
|
||||
onPollUpdate,
|
||||
onScriptDelta,
|
||||
handleModelChange,
|
||||
setTask,
|
||||
setAssetPicker,
|
||||
setCloneModalOpen,
|
||||
setPresetVoicePickerOpen,
|
||||
refreshVoiceAssets,
|
||||
reloadMyVoices,
|
||||
}
|
||||
}
|
||||
|
||||
export default useViralVideoHandlers
|
||||
@@ -1,349 +0,0 @@
|
||||
import { useCallback, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import { getAssetLibraries, uploadAssetDirect, type AssetItem } from "@/api/assets"
|
||||
import type { UploadedImage, RefVideo, RefAudio, VoicePanel, TabTask } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
export interface UseViralVideoMediaOptions {
|
||||
task: TabTask
|
||||
setTask: (patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => void
|
||||
setAssetPicker: React.Dispatch<
|
||||
React.SetStateAction<{ open: boolean; kind: "image" | "video" | "voice"; multiple: boolean }>
|
||||
>
|
||||
setCloneModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setPresetVoicePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
refreshVoiceAssets: () => void
|
||||
reloadMyVoices: () => void
|
||||
}
|
||||
|
||||
export interface MediaActions {
|
||||
imgInputRef: React.MutableRefObject<HTMLInputElement | null>
|
||||
videoInputRef: React.MutableRefObject<HTMLInputElement | null>
|
||||
voiceInputRef: React.MutableRefObject<HTMLInputElement | null>
|
||||
audioRef: React.MutableRefObject<HTMLAudioElement | null>
|
||||
appendImagesFromFiles: (files: FileList | File[]) => void
|
||||
appendImagesFromAssets: (assets: AssetItem[]) => void
|
||||
clearImages: () => void
|
||||
removeImg: (localId: string) => void
|
||||
moveImg: (idx: number, dir: -1 | 1) => void
|
||||
handleLocalRefVideo: (file: File) => void
|
||||
removeRefVideo: () => void
|
||||
handleVoiceFile: (file: File) => void
|
||||
onCloneSuccess: (v: VoiceClone | { id?: string; name?: string; voice_id?: string | null }) => void
|
||||
pickVoiceAsset: (a: AssetItem) => void
|
||||
selectMyVoice: (v: VoiceClone) => void
|
||||
toggleVoicePanel: (p: VoicePanel) => void
|
||||
toggleVoice: (v: { id: string; url: string }) => void
|
||||
openAssetPicker: (kind: "image" | "video" | "voice", multiple: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材上传/音色选择/图片列表/参考视频等操作:独立 hook,保持每个文件 ≤400 行。
|
||||
*/
|
||||
export function useViralVideoMedia(opts: UseViralVideoMediaOptions): MediaActions {
|
||||
const {
|
||||
task,
|
||||
setTask,
|
||||
setAssetPicker,
|
||||
setCloneModalOpen,
|
||||
refreshVoiceAssets,
|
||||
setPresetVoicePickerOpen,
|
||||
} = opts
|
||||
const imgInputRef = useRef<HTMLInputElement>(null)
|
||||
const videoInputRef = useRef<HTMLInputElement>(null)
|
||||
const voiceInputRef = useRef<HTMLInputElement>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File, kind: "image" | "video" | "voice", onProgress?: (pct: number) => void) => {
|
||||
const libs = await getAssetLibraries()
|
||||
const lib = (libs as { kind: string; id: string }[]).find((l) => l.kind === kind) || libs[0]
|
||||
if (!lib) throw new Error("未找到素材库")
|
||||
const res = await uploadAssetDirect({
|
||||
file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => onProgress?.(Math.round(p)),
|
||||
})
|
||||
return res.url
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const appendImagesFromFiles = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
const remaining = 9 - task.images.length
|
||||
if (remaining <= 0) {
|
||||
message.warning("最多上传 9 张产品图")
|
||||
return
|
||||
}
|
||||
const arr = Array.from(files)
|
||||
.filter((f) => f.type.startsWith("image/"))
|
||||
.slice(0, remaining)
|
||||
if (arr.length === 0) return
|
||||
const newImgs: UploadedImage[] = arr.map((f) => ({
|
||||
localId: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
url: URL.createObjectURL(f),
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
uploading: true,
|
||||
pct: 0,
|
||||
file: f,
|
||||
}))
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
images: [...t.images, ...newImgs.map(({ file: _f, ...rest }) => rest)],
|
||||
}))
|
||||
for (const item of newImgs) {
|
||||
try {
|
||||
const url = await uploadFile(item.file!, "image", (p) => {
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
images: t.images.map((im) => (im.localId === item.localId ? { ...im, pct: p } : im)),
|
||||
}))
|
||||
})
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
images: t.images.map((im) =>
|
||||
im.localId === item.localId
|
||||
? {
|
||||
...im,
|
||||
uploading: false,
|
||||
pct: 100,
|
||||
ossUrl: url,
|
||||
size: item.file!.size || im.size,
|
||||
}
|
||||
: im,
|
||||
),
|
||||
}))
|
||||
} catch {
|
||||
message.error(`${item.name} 上传失败`)
|
||||
setTask((t) => ({ ...t, images: t.images.filter((im) => im.localId !== item.localId) }))
|
||||
}
|
||||
}
|
||||
},
|
||||
[task.images.length, setTask, uploadFile],
|
||||
)
|
||||
|
||||
const appendImagesFromAssets = useCallback(
|
||||
(assets: AssetItem[]) => {
|
||||
const remaining = 9 - task.images.length
|
||||
const picked = assets.slice(0, remaining)
|
||||
if (picked.length === 0) return
|
||||
const newImgs: UploadedImage[] = picked.map((a) => ({
|
||||
localId: `lib_${a.id}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
url: a.thumbnail_url || a.file_url || "",
|
||||
name: a.name || "未命名",
|
||||
size: a.file_size || undefined,
|
||||
uploading: false,
|
||||
pct: 100,
|
||||
ossUrl: a.file_url || undefined,
|
||||
}))
|
||||
setTask((t) => ({ ...t, images: [...t.images, ...newImgs] }))
|
||||
message.success(`已添加 ${picked.length} 张图片`)
|
||||
},
|
||||
[task.images.length, setTask],
|
||||
)
|
||||
|
||||
const clearImages = () => {
|
||||
setTask((t) => {
|
||||
t.images.forEach((im) => {
|
||||
if (im.url?.startsWith("blob:")) URL.revokeObjectURL(im.url)
|
||||
})
|
||||
return {
|
||||
...t,
|
||||
images: [],
|
||||
imageAnalysis: null,
|
||||
uiStep: "step1_upload",
|
||||
storyboard: null,
|
||||
job: null,
|
||||
jobId: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeImg = (localId: string) => {
|
||||
setTask((t) => {
|
||||
const im = t.images.find((i) => i.localId === localId)
|
||||
if (im?.url?.startsWith("blob:")) URL.revokeObjectURL(im.url)
|
||||
const newImgs = t.images.filter((i) => i.localId !== localId)
|
||||
return {
|
||||
...t,
|
||||
images: newImgs,
|
||||
imageAnalysis: newImgs.length === 0 ? null : t.imageAnalysis,
|
||||
storyboard: newImgs.length === 0 ? null : t.storyboard,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const moveImg = (idx: number, dir: -1 | 1) => {
|
||||
setTask((t) => {
|
||||
const imgs = [...t.images]
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= imgs.length) return t
|
||||
;[imgs[idx], imgs[target]] = [imgs[target], imgs[idx]]
|
||||
return { ...t, images: imgs }
|
||||
})
|
||||
}
|
||||
|
||||
const handleLocalRefVideo = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > 200 * 1024 * 1024) {
|
||||
message.warning("视频不能超过 200MB")
|
||||
return
|
||||
}
|
||||
const localUrl = URL.createObjectURL(file)
|
||||
setTask({
|
||||
refVideo: {
|
||||
source: "local",
|
||||
url: localUrl,
|
||||
name: file.name,
|
||||
uploading: true,
|
||||
pct: 0,
|
||||
file,
|
||||
} as RefVideo,
|
||||
})
|
||||
try {
|
||||
const url = await uploadFile(file, "video", (p) => {
|
||||
setTask((t) => (t.refVideo ? { ...t, refVideo: { ...t.refVideo, pct: p } } : t))
|
||||
})
|
||||
setTask((t) =>
|
||||
t.refVideo
|
||||
? { ...t, refVideo: { ...t.refVideo, uploading: false, pct: 100, ossUrl: url } }
|
||||
: t,
|
||||
)
|
||||
} catch {
|
||||
message.error("参考视频上传失败")
|
||||
if (localUrl.startsWith("blob:")) URL.revokeObjectURL(localUrl)
|
||||
setTask({ refVideo: null })
|
||||
}
|
||||
},
|
||||
[setTask, uploadFile],
|
||||
)
|
||||
|
||||
const removeRefVideo = () => {
|
||||
setTask((t) => {
|
||||
if (t.refVideo?.url.startsWith("blob:")) URL.revokeObjectURL(t.refVideo.url)
|
||||
return { ...t, refVideo: null }
|
||||
})
|
||||
}
|
||||
|
||||
const handleVoiceFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
message.warning("音频不能超过 10MB")
|
||||
return
|
||||
}
|
||||
const localUrl = URL.createObjectURL(file)
|
||||
try {
|
||||
const url = await uploadFile(file, "voice")
|
||||
setTask({
|
||||
refAudio: { source: "upload", name: file.name, url, id: url } as RefAudio,
|
||||
voicePanel: null,
|
||||
})
|
||||
message.success("音频已上传")
|
||||
refreshVoiceAssets()
|
||||
} catch {
|
||||
message.error("音频上传失败")
|
||||
URL.revokeObjectURL(localUrl)
|
||||
}
|
||||
},
|
||||
[setTask, uploadFile, refreshVoiceAssets],
|
||||
)
|
||||
|
||||
const onCloneSuccess = (
|
||||
v: VoiceClone | { id?: string; name?: string; voice_id?: string | null },
|
||||
) => {
|
||||
setCloneModalOpen(false)
|
||||
opts.reloadMyVoices()
|
||||
const vid = "voice_id" in v ? v.voice_id : undefined
|
||||
if (v?.id) {
|
||||
setTask({
|
||||
refAudio: {
|
||||
source: "my_voice",
|
||||
id: v.id,
|
||||
name: v.name || "我录制的音色",
|
||||
ttsVoiceId: vid || undefined,
|
||||
} as RefAudio,
|
||||
voicePanel: null,
|
||||
})
|
||||
refreshVoiceAssets()
|
||||
message.success("音色克隆完成,已自动选中")
|
||||
}
|
||||
}
|
||||
|
||||
const pickVoiceAsset = (asset: AssetItem) => {
|
||||
setTask({
|
||||
refAudio: {
|
||||
source: "library",
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
url: asset.file_url || "",
|
||||
} as RefAudio,
|
||||
voicePanel: null,
|
||||
})
|
||||
}
|
||||
|
||||
const selectMyVoice = (v: VoiceClone) => {
|
||||
setTask({
|
||||
refAudio: {
|
||||
source: "my_voice",
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
ttsVoiceId: v.voice_id || undefined,
|
||||
} as RefAudio,
|
||||
voicePanel: null,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleVoicePanel = (p: VoicePanel) => {
|
||||
if (p === "preset") {
|
||||
setPresetVoicePickerOpen(true)
|
||||
return
|
||||
}
|
||||
setTask({ voicePanel: task.voicePanel === p ? null : p })
|
||||
}
|
||||
|
||||
const openAssetPicker = (kind: "image" | "video" | "voice", multiple: boolean) => {
|
||||
setAssetPicker({ open: true, kind, multiple })
|
||||
}
|
||||
|
||||
const toggleVoice = (v: { id: string; url: string }) => {
|
||||
if (!v.url) return
|
||||
if (task.playingVoiceId === v.id) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setTask({ playingVoiceId: null })
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
const a = new Audio(v.url)
|
||||
a.dataset.playingId = v.id
|
||||
a.onended = () => setTask({ playingVoiceId: null })
|
||||
a.play().catch(() => {})
|
||||
audioRef.current = a
|
||||
setTask({ playingVoiceId: v.id })
|
||||
}
|
||||
|
||||
return {
|
||||
imgInputRef,
|
||||
videoInputRef,
|
||||
voiceInputRef,
|
||||
audioRef,
|
||||
appendImagesFromFiles,
|
||||
appendImagesFromAssets,
|
||||
clearImages,
|
||||
removeImg,
|
||||
moveImg,
|
||||
handleLocalRefVideo,
|
||||
removeRefVideo,
|
||||
handleVoiceFile,
|
||||
onCloneSuccess,
|
||||
pickVoiceAsset,
|
||||
selectMyVoice,
|
||||
toggleVoicePanel,
|
||||
toggleVoice,
|
||||
openAssetPicker,
|
||||
}
|
||||
}
|
||||
|
||||
export default useViralVideoMedia
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { getViralVideoJob } from "@/api/viral-video"
|
||||
import {
|
||||
isAnalysisStage,
|
||||
getJobStage,
|
||||
type ViralVideoJob,
|
||||
type ViralVideoStatus,
|
||||
} from "@/api/viral-video/types"
|
||||
import { isAnalysisStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
|
||||
|
||||
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
|
||||
|
||||
@@ -16,9 +11,8 @@ export interface UseViralVideoPollingOptions {
|
||||
|
||||
/**
|
||||
* 爆款视频任务 HTTP 轮询 hook。
|
||||
* - onUpdate 通过 ref 持有,避免 useCallback 依赖变化导致重启轮询。
|
||||
* - 任务进入终态(completed/failed/cancelled)后自动停止。
|
||||
* - 连续失败 20 次(约 2 分钟)后熔断。
|
||||
* 负责持续拉取任务状态并回调给上层;上层负责根据状态/阶段切换 UI 文案。
|
||||
* 任务进入终态(completed/failed/cancelled)后自动停止。
|
||||
*/
|
||||
export function useViralVideoPolling(
|
||||
jobId: string | null | undefined,
|
||||
@@ -29,9 +23,6 @@ export function useViralVideoPolling(
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const stoppedRef = useRef(false)
|
||||
const failCountRef = useRef(0)
|
||||
// ref 持有最新 onUpdate,避免依赖变化导致 pollOnce 重建、轮询被重启
|
||||
const onUpdateRef = useRef(onUpdate)
|
||||
onUpdateRef.current = onUpdate
|
||||
|
||||
const stop = useCallback(() => {
|
||||
stoppedRef.current = true
|
||||
@@ -46,30 +37,26 @@ export function useViralVideoPolling(
|
||||
try {
|
||||
const job = await getViralVideoJob(id)
|
||||
failCountRef.current = 0
|
||||
onUpdateRef.current(job)
|
||||
onUpdate(job)
|
||||
if (TERMINAL.includes(job.status)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (stoppedRef.current) return
|
||||
const stage = getJobStage(job)
|
||||
const inRender = stage === "rendering"
|
||||
const isAnalyzing = isAnalysisStage(stage)
|
||||
// 视频渲染阶段(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
|
||||
if (failCountRef.current >= 20) {
|
||||
// 轮询连续失败 20 次,停止轮询
|
||||
stop()
|
||||
return
|
||||
}
|
||||
const delay = Math.min(intervalMs * 2 ** Math.min(failCountRef.current, 3), 10000)
|
||||
timerRef.current = setTimeout(() => pollOnce(id), delay)
|
||||
}
|
||||
},
|
||||
[intervalMs, stop],
|
||||
[intervalMs, onUpdate, stop],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
export interface ViralVideoDeltaEvent {
|
||||
type: "viral_video:script_delta"
|
||||
job_id: string
|
||||
stage: string
|
||||
progress: number
|
||||
message?: string
|
||||
data: {
|
||||
delta: string
|
||||
full_text: string
|
||||
text_length: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ViralVideoWSError {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ViralVideoTerminalEvent {
|
||||
type: "viral_video:completed" | "viral_video:failed" | "viral_video:copy_generated"
|
||||
job_id: string
|
||||
}
|
||||
|
||||
export interface UseViralVideoWSOptions {
|
||||
/** delta 事件回调(script_delta 推送时触发) */
|
||||
onDelta?: (ev: ViralVideoDeltaEvent) => void
|
||||
/** 连接关闭/失败回调 */
|
||||
onError?: (err: ViralVideoWSError) => void
|
||||
/** 连接建立回调 */
|
||||
onOpen?: () => void
|
||||
/** P2-6: 收到终态事件(copy_generated/completed/failed)时回调,上层应立即主动拉取一次最新 job,减少终态延迟 */
|
||||
onTerminal?: (ev: ViralVideoTerminalEvent) => void
|
||||
}
|
||||
|
||||
const WS_SCHEME =
|
||||
typeof window !== "undefined" && window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
function buildWsUrl(jobId: string): string {
|
||||
const token = localStorage.getItem("access_token") || ""
|
||||
const host = window.location.host
|
||||
return `${WS_SCHEME}//${host}/api/v1/viral-video/ws/${jobId}?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 爆款视频 WebSocket 订阅 hook。
|
||||
* 仅在 jobId 非空时建立连接;组件卸载/ jobId 变更时自动关闭。
|
||||
* 失败静默:后端已说明流式异常会自动 fallback 到 HTTP 轮询的 copy_generated 终态,
|
||||
* 前端不需要中断主流程,连接失败时只记录不影响轮询继续推进 UI。
|
||||
*/
|
||||
export function useViralVideoWS(
|
||||
jobId: string | null | undefined,
|
||||
{ onDelta, onError, onOpen, onTerminal }: UseViralVideoWSOptions = {},
|
||||
) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const manualCloseRef = useRef(false)
|
||||
const onDeltaRef = useRef(onDelta)
|
||||
const onErrorRef = useRef(onError)
|
||||
const onOpenRef = useRef(onOpen)
|
||||
const onTerminalRef = useRef<UseViralVideoWSOptions["onTerminal"]>()
|
||||
onDeltaRef.current = onDelta
|
||||
onErrorRef.current = onError
|
||||
onOpenRef.current = onOpen
|
||||
onTerminalRef.current = onTerminal
|
||||
|
||||
const close = useCallback(() => {
|
||||
manualCloseRef.current = true
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
manualCloseRef.current = false
|
||||
let closed = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retries = 0
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
try {
|
||||
const ws = new WebSocket(buildWsUrl(jobId))
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (closed) return
|
||||
retries = 0
|
||||
onOpenRef.current?.()
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const payload = JSON.parse(ev.data)
|
||||
if (payload?.type === "viral_video:script_delta" && payload.data) {
|
||||
onDeltaRef.current?.(payload as ViralVideoDeltaEvent)
|
||||
} else if (
|
||||
payload?.type === "viral_video:completed" ||
|
||||
payload?.type === "viral_video:failed" ||
|
||||
payload?.type === "viral_video:copy_generated"
|
||||
) {
|
||||
// P2-6: 收到终态事件时主动通知上层立即拉取,降低终态延迟(不用等下一个轮询周期)
|
||||
onTerminalRef.current?.(payload as ViralVideoTerminalEvent)
|
||||
}
|
||||
// 心跳/其他事件忽略
|
||||
} catch {
|
||||
// 心跳/非 JSON 消息忽略
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
onErrorRef.current?.({ message: "websocket error" })
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null
|
||||
if (closed || manualCloseRef.current) return
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries += 1
|
||||
const delay = 500 * 2 ** (retries - 1)
|
||||
retryTimer = setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
onErrorRef.current?.({ message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟 100ms 再连,给后端 job 初始化留一点时间
|
||||
startTimer = setTimeout(connect, 100)
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
if (startTimer) clearTimeout(startTimer)
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
close()
|
||||
}
|
||||
}, [jobId, close])
|
||||
|
||||
return { close }
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type {
|
||||
FusionLevel,
|
||||
StyleStrength,
|
||||
ViralVideoJob,
|
||||
ImageAnalysisResult,
|
||||
CopyResult,
|
||||
ShotScript,
|
||||
ViralVideoModel,
|
||||
} from "@/api/viral-video/types"
|
||||
|
||||
/* ─────────── UI 阶段(三步骤独立交互) ─────────── */
|
||||
export type UIStep =
|
||||
| "step1_upload" // 初始:上传素材
|
||||
| "step1_analyzing" // 图片分析中
|
||||
| "step1_done" // 图片分析完成
|
||||
| "step2_generating" // 文案生成中
|
||||
| "step2_copy_ready" // 文案已生成,等待确认
|
||||
| "step3_generating" // 视频生成中
|
||||
| "step3_done" // 视频完成
|
||||
| "failed" // 失败
|
||||
|
||||
export const TERMINAL_UI_STEPS: UIStep[] = ["step3_done", "failed"]
|
||||
|
||||
export type UploadedImage = {
|
||||
localId: string
|
||||
url: string
|
||||
name: string
|
||||
size: number | undefined
|
||||
uploading: boolean
|
||||
pct: number
|
||||
ossUrl?: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
export type RefVideo = {
|
||||
source: "local"
|
||||
url: string
|
||||
name: string
|
||||
uploading: boolean
|
||||
pct: number
|
||||
ossUrl?: string
|
||||
file?: File
|
||||
} | null
|
||||
|
||||
export type RefAudio = {
|
||||
source: "preset" | "library" | "upload" | "clone" | "my_voice"
|
||||
id?: string
|
||||
name: string
|
||||
url?: string
|
||||
/** 克隆音色的 CosyVoice voice_id(source=my_voice 时使用) */
|
||||
ttsVoiceId?: string
|
||||
} | null
|
||||
|
||||
export type VoicePanel = "my_voices" | "preset" | "upload" | "library" | "record" | null
|
||||
|
||||
// ── 分镜脚本数据模型(新后端 copy_result 结构) ──
|
||||
export interface StoryboardShot {
|
||||
time_range: string
|
||||
speaker?: string
|
||||
shot_type_angle_movement: string
|
||||
scene_and_dialogue: string
|
||||
action_details: string
|
||||
audio_bgm: string
|
||||
transition: string
|
||||
reference_image_index?: number
|
||||
}
|
||||
export interface Storyboard {
|
||||
overview: { theme: string; total_duration: string; aspect_ratio: string }
|
||||
scene_and_lighting: string
|
||||
shots: StoryboardShot[]
|
||||
hard_constraints: string[]
|
||||
negative_prompts: string[]
|
||||
voiceover_script: string
|
||||
/** v3: 用户端展示用 markdown 文案(由提示词控制排版) */
|
||||
copy_display_markdown: string
|
||||
}
|
||||
|
||||
export type TabTask = {
|
||||
id: string
|
||||
title: string
|
||||
// 素材
|
||||
images: UploadedImage[]
|
||||
refVideo: RefVideo
|
||||
voicePanel: VoicePanel
|
||||
refAudio: RefAudio
|
||||
// STEP2 参数
|
||||
fusionLevel: FusionLevel
|
||||
styleStrength: StyleStrength
|
||||
userCopy: string
|
||||
industry: string
|
||||
targetCustomer: string
|
||||
persona: string
|
||||
language: string
|
||||
viralStructure: string
|
||||
marketingPurpose: string
|
||||
duration: number
|
||||
// STEP3 设置
|
||||
videoRatio: string
|
||||
videoModel: string
|
||||
quality: string
|
||||
upscale: string
|
||||
// 积分预估
|
||||
estimatedCredits: number | null
|
||||
estimatedCreditsError: boolean
|
||||
// UI 状态
|
||||
uiStep: UIStep
|
||||
imageAnalysis: ImageAnalysisResult | null
|
||||
storyboard: Storyboard | null
|
||||
voiceTipsOpen: boolean
|
||||
copyError?: string
|
||||
videoError?: string
|
||||
analysisError?: string
|
||||
// 运行态(后端 job)
|
||||
job: ViralVideoJob | null
|
||||
jobId: string | null
|
||||
playingVoiceId: string | null
|
||||
// 分镜脚本流式预览
|
||||
scriptStreamText: string
|
||||
scriptStreamProgress: number
|
||||
scriptStreamMessage: string
|
||||
}
|
||||
|
||||
// re-export api types for convenience
|
||||
export type {
|
||||
FusionLevel,
|
||||
StyleStrength,
|
||||
ViralVideoJob,
|
||||
ImageAnalysisResult,
|
||||
CopyResult,
|
||||
ShotScript,
|
||||
ViralVideoModel,
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { message } from "antd"
|
||||
import {
|
||||
isVideoStage,
|
||||
isCopyStage,
|
||||
isImageAnalysisStage,
|
||||
getJobStage,
|
||||
type ViralVideoJob,
|
||||
} from "@/api/viral-video/types"
|
||||
import type { UIStep, TabTask } from "../types"
|
||||
import { TERMINAL_UI_STEPS } from "../types"
|
||||
import { copyResultToStoryboard } from "./copyResultToStoryboard"
|
||||
import { classifyJobError, cleanErrorMessage } from "./errorClassification"
|
||||
|
||||
/**
|
||||
* 根据 job 状态计算下一个 TabTask 快照:单调性守卫 + 阶段推进 + 错误分类。
|
||||
* 返回 null 表示丢弃此次更新(终态守卫命中,使用旧状态即可)。
|
||||
*/
|
||||
export function applyPollUpdate(t: TabTask, job: ViralVideoJob): TabTask {
|
||||
if (
|
||||
TERMINAL_UI_STEPS.includes(t.uiStep) &&
|
||||
job.status !== "completed" &&
|
||||
job.status !== "failed"
|
||||
) {
|
||||
return t
|
||||
}
|
||||
let nextStep: UIStep = t.uiStep
|
||||
let copyError = t.copyError
|
||||
let videoError = t.videoError
|
||||
let analysisError = t.analysisError
|
||||
const err = job.error_message || job.error_msg
|
||||
const nextImageAnalysis = job.image_analysis || t.imageAnalysis
|
||||
const nextCopy = job.copy_result
|
||||
const nextStoryboard = nextCopy ? copyResultToStoryboard(nextCopy) || t.storyboard : t.storyboard
|
||||
const stage = getJobStage(job)
|
||||
|
||||
switch (job.status) {
|
||||
case "image_analyzed":
|
||||
nextStep = "step1_done"
|
||||
copyError = undefined
|
||||
break
|
||||
case "copy_generated":
|
||||
nextStep = "step2_copy_ready"
|
||||
copyError = undefined
|
||||
break
|
||||
case "running":
|
||||
if (isVideoStage(stage)) nextStep = "step3_generating"
|
||||
else if (isCopyStage(stage)) nextStep = "step2_generating"
|
||||
else if (isImageAnalysisStage(stage)) nextStep = "step1_analyzing"
|
||||
copyError = undefined
|
||||
videoError = undefined
|
||||
break
|
||||
case "completed":
|
||||
nextStep = "step3_done"
|
||||
copyError = undefined
|
||||
videoError = undefined
|
||||
break
|
||||
case "failed": {
|
||||
if (stage === "review" && err && (err.includes("合规") || err.includes("审核未通过"))) {
|
||||
nextStep = "step2_copy_ready"
|
||||
copyError = err
|
||||
videoError = undefined
|
||||
analysisError = undefined
|
||||
message.warning(err)
|
||||
break
|
||||
}
|
||||
nextStep = "failed"
|
||||
const cleaned = cleanErrorMessage(err || "")
|
||||
const kind = classifyJobError(cleaned, stage)
|
||||
if (kind === "video") {
|
||||
videoError = cleaned || "视频生成失败"
|
||||
copyError = undefined
|
||||
analysisError = undefined
|
||||
} else if (kind === "image") {
|
||||
analysisError = cleaned || "图片分析失败"
|
||||
copyError = undefined
|
||||
videoError = undefined
|
||||
} else {
|
||||
copyError = cleaned || "文案生成失败"
|
||||
videoError = undefined
|
||||
analysisError = undefined
|
||||
}
|
||||
break
|
||||
}
|
||||
case "pending":
|
||||
break
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
job,
|
||||
uiStep: nextStep,
|
||||
imageAnalysis: nextImageAnalysis,
|
||||
storyboard: nextStoryboard,
|
||||
copyError,
|
||||
videoError,
|
||||
analysisError,
|
||||
}
|
||||
}
|
||||
|
||||
export default applyPollUpdate
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Storyboard } from "../types"
|
||||
import type { CopyResult, ShotScript } from "@/api/viral-video/types"
|
||||
|
||||
export function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard | null {
|
||||
if (!cr) return null
|
||||
if (Array.isArray(cr.shots) && cr.overview) {
|
||||
return {
|
||||
overview: {
|
||||
theme: cr.overview.theme || cr.title || "",
|
||||
total_duration: (() => {
|
||||
const d = cr.overview.total_duration
|
||||
if (!d) return ""
|
||||
return String(d).endsWith("s") ? String(d) : `${d}s`
|
||||
})(),
|
||||
aspect_ratio: cr.overview.aspect_ratio || "9:16",
|
||||
},
|
||||
scene_and_lighting: cr.scene_and_lighting || "",
|
||||
shots: ((cr.shots ?? []) as ShotScript[]).map((s) => ({
|
||||
time_range: s.time_range || "",
|
||||
speaker: s.speaker || "",
|
||||
shot_type_angle_movement: s.shot_type_angle_movement || "",
|
||||
scene_and_dialogue: s.scene_and_dialogue || "",
|
||||
action_details: s.action_details || "",
|
||||
audio_bgm: s.audio_bgm || "",
|
||||
transition: s.transition || "",
|
||||
reference_image_index:
|
||||
typeof s.reference_image_index === "number" ? s.reference_image_index : undefined,
|
||||
})),
|
||||
hard_constraints: Array.isArray(cr.hard_constraints) ? cr.hard_constraints : [],
|
||||
negative_prompts: Array.isArray(cr.negative_prompts) ? cr.negative_prompts : [],
|
||||
voiceover_script: cr.voiceover_script || cr.final_copy || cr.suggested_copy || "",
|
||||
copy_display_markdown: cr.copy_display_markdown || "",
|
||||
}
|
||||
}
|
||||
const finalCopy = cr.final_copy || cr.suggested_copy || ""
|
||||
const title = cr.title || ""
|
||||
return {
|
||||
overview: { theme: title || "AI 分镜脚本", total_duration: "", aspect_ratio: "9:16" },
|
||||
scene_and_lighting: "",
|
||||
shots:
|
||||
cr.scenes && cr.scenes.length
|
||||
? cr.scenes.map((sc, i: number) => ({
|
||||
time_range: sc.duration
|
||||
? `${i * sc.duration}-${(i + 1) * sc.duration}s`
|
||||
: `镜头${i + 1}`,
|
||||
shot_type_angle_movement: sc.shot || "",
|
||||
scene_and_dialogue: sc.narration || "",
|
||||
action_details: "",
|
||||
audio_bgm: "",
|
||||
transition: "",
|
||||
}))
|
||||
: finalCopy
|
||||
? [
|
||||
{
|
||||
time_range: "0-15s",
|
||||
shot_type_angle_movement: "",
|
||||
scene_and_dialogue: finalCopy,
|
||||
action_details: "",
|
||||
audio_bgm: "",
|
||||
transition: "",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
hard_constraints: [],
|
||||
negative_prompts: [],
|
||||
voiceover_script: finalCopy,
|
||||
copy_display_markdown: cr.copy_display_markdown || "",
|
||||
}
|
||||
}
|
||||
|
||||
export default copyResultToStoryboard
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user