Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c822cfe69b | |||
| 3b71a96b0a | |||
| bee1cdc060 | |||
| 50ef39a7ef | |||
| 88fa27e3aa | |||
| 74d23895d5 | |||
| da5e9d4e56 | |||
| e8c23d6ffa | |||
| 599ae3fdc8 | |||
| cfd37a39d1 | |||
| 0d20c82145 | |||
| dfb81a803e | |||
| 7f661c6594 | |||
| f8ffa4018c | |||
| 7642ca9177 | |||
| faf4573fea | |||
| 653d843b03 | |||
| 457362a55c | |||
| f4ea5435a6 | |||
| c457255c8e | |||
| 35063a691f | |||
| 941e70f95b | |||
| 18d62ec277 | |||
| b36f71b024 | |||
| e9021fb7f8 | |||
| 3e374415d9 | |||
| 4e837ec761 | |||
| 7325d45019 | |||
| e92004fca3 | |||
| c75b08e2fb | |||
| f6ff225abc | |||
| 3fa1fbf05c | |||
| ebf841901f | |||
| b1846207cc | |||
| 451ae99242 | |||
| 58d78c3184 | |||
| 0c1dbfc5a7 | |||
| 312210c3d5 | |||
| 70a26b053a | |||
| bec75a075b | |||
| 222c41bd55 | |||
| 36c30e5b6f | |||
| eceb454111 | |||
| c8c9c8b35e | |||
| e458ffeb57 | |||
| 02866a64dd | |||
| 4248ea2a59 | |||
| 4e5d365277 | |||
| 22b9019325 | |||
| 652d2cd270 | |||
| 019bbe6897 | |||
| f25fa9d978 | |||
| 2080ecc4f9 | |||
| bc45a10fff | |||
| a949426100 | |||
| e22a62802d | |||
| 70d4a21055 | |||
| d6e4a09628 | |||
| adcb59b446 | |||
| 213e89f93a | |||
| 79696e18f0 | |||
| 8ff9ab0cb4 | |||
| f10b5393fa | |||
| ba0e3fc67f | |||
| 6eb528ed44 | |||
| 82ba481e4f | |||
| 5864ab0325 | |||
| 39fcc42533 | |||
| 8d9583b089 | |||
| 783914764f | |||
| c32e6292f7 | |||
| 247af9b755 | |||
| cfb5c7ab89 | |||
| d6d100baeb | |||
| 4d99c20f6b | |||
| e9c119aea4 | |||
| 58ac709e53 | |||
| afdee720ff | |||
| d8f3f9f3c9 | |||
| 37f62e5ad0 | |||
| 31453ae3f7 | |||
| 707aaab28f | |||
| b47d5d9396 | |||
| 0638d0f1ce | |||
| 406e9100c8 | |||
| 25067b4d22 | |||
| 90f6d098ee | |||
| 8612e183ad | |||
| c4b4a1e9c0 | |||
| 1f6d9fc862 | |||
| 3acc309b51 | |||
| bee59b7f27 | |||
| 4bd4a6c390 | |||
| aa898ff0c8 | |||
| fb0d429bd7 | |||
| 0510d101aa | |||
| 606f6988b5 | |||
| 733d8bb75c | |||
| e83048b7ec | |||
| ade6593d66 | |||
| a3a8d2561d | |||
| 7b6dfdc29f | |||
| b5e225e62a | |||
| 2141ddb19b | |||
| 3503542ec2 | |||
| 1a6f04b258 | |||
| ece5f7d8a7 | |||
| b023988402 | |||
| 4ba5b126b6 | |||
| cc8a3caba5 | |||
| fe36a05c51 | |||
| 12a8701283 | |||
| 176a5cbfe3 | |||
| c5e69db50d | |||
| 44bbe0f0e5 | |||
| 92680c47f9 | |||
| b335fbbcce | |||
| cc542f27d9 | |||
| d42ab5ffa8 | |||
| 74896727bb | |||
| 1f6d10f861 | |||
| 3ee5a4042d | |||
| a979af1488 | |||
| 77e19a6b44 | |||
| e4c3f9a046 | |||
| 902effc1f9 | |||
| a408cfdc97 | |||
| 9464322710 | |||
| aa1f318308 | |||
| 3a59948f53 | |||
| 3e6f87a8b5 |
+1
-2
@@ -1,2 +1 @@
|
||||
CI trigger file - safe to delete
|
||||
retrigger at 2026-09-15 20:31:24 UTC
|
||||
trigger staging deploy for 3fa1fbf0 (MOCK_STORYBOARD fix) at 2026-10-12 13:40:16
|
||||
|
||||
@@ -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: 8
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# -*- 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"
|
||||
)
|
||||
)
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
# -*- 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"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- 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")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,235 @@
|
||||
# -*- 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,3 +1,4 @@
|
||||
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
|
||||
@@ -242,3 +243,6 @@ 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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""后台管理路由(#2246 起)."""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""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],
|
||||
}
|
||||
@@ -89,6 +89,7 @@ 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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,17 @@ from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
SQLAlchemyViralVideoStyleTemplateRepository,
|
||||
)
|
||||
from packages.domain.points_rules import list_viral_video_models
|
||||
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
|
||||
|
||||
@@ -105,8 +115,8 @@ def _build_copy_result(job) -> dict | None:
|
||||
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
|
||||
"scene_and_lighting": "",
|
||||
"shots": shots,
|
||||
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
|
||||
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
|
||||
"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,
|
||||
@@ -142,6 +152,7 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
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,
|
||||
@@ -167,6 +178,156 @@ 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -243,6 +404,7 @@ def analyze_images(
|
||||
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",
|
||||
@@ -314,6 +476,7 @@ def generate_copy(
|
||||
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"
|
||||
@@ -352,40 +515,55 @@ def confirm_copy(
|
||||
if not isinstance(job.copy_result, dict) or not job.copy_result:
|
||||
raise HTTPException(status_code=409, detail="文案数据缺失,请先点击「生成文案」")
|
||||
|
||||
# 积分预扣(已扣过/重试任务跳过)
|
||||
from app.config import settings as _settings
|
||||
# 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 _settings.points_enabled:
|
||||
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
|
||||
float(getattr(job, "credits_cost", 0) or 0) > 0
|
||||
if 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,
|
||||
)
|
||||
if not already_paid:
|
||||
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
w, h = resolve_video_dimensions(
|
||||
getattr(job, "video_resolution", "720p") or "720p",
|
||||
job.video_ratio or "9:16",
|
||||
)
|
||||
est_credits = calculate_viral_video_credits(
|
||||
int(job.duration or 15), w, h, job.video_model or "seedance-2.5"
|
||||
)
|
||||
svc = PointsService()
|
||||
res = svc.deduct_viral_video(authenticated_user.user.id, est_credits, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {est_credits} 积分,当前余额 {balance}",
|
||||
"required": est_credits,
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = est_credits
|
||||
job.credits_transaction_id = res.get("transaction_id", "") or ""
|
||||
repo.update(job)
|
||||
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)
|
||||
@@ -395,6 +573,7 @@ def confirm_copy(
|
||||
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)
|
||||
|
||||
@@ -412,11 +591,6 @@ def estimate_credits(
|
||||
profit_multiplier / model_price / width / height / fps),便于前端展示计费明细。
|
||||
同时兼容前端传 model 或 video_model、resolution 或 video_resolution、ratio 或 video_ratio。
|
||||
"""
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
|
||||
model = (request.model or "").strip() or "seedance-2.5"
|
||||
resolution = (request.resolution or "").strip() or "720p"
|
||||
ratio = (request.ratio or "").strip() or "9:16"
|
||||
@@ -548,25 +722,7 @@ def retry_viral_video_job(
|
||||
or (new_model is not None and new_model != old_model)
|
||||
)
|
||||
|
||||
from app.config import settings as _settings
|
||||
|
||||
need_points_settle = False
|
||||
new_est = 0.0
|
||||
if _settings.points_enabled and param_changed:
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
|
||||
eff_dur = int(new_duration if new_duration is not None else old_duration)
|
||||
eff_res = new_resolution if new_resolution is not None else old_resolution
|
||||
eff_ratio = new_ratio if new_ratio is not None else old_ratio
|
||||
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
|
||||
w, h = resolve_video_dimensions(eff_res, eff_ratio)
|
||||
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model or "seedance-2.5")
|
||||
need_points_settle = True
|
||||
|
||||
# 写入新参数(即使不开 points 也要允许用户重试时改参数)
|
||||
# 写入新参数(即使不开 points 也要允许用户重试时改参数;delta 结算依赖这些新值)
|
||||
if new_duration is not None:
|
||||
job.duration = max(5, min(30, int(new_duration)))
|
||||
if new_resolution is not None:
|
||||
@@ -576,57 +732,31 @@ def retry_viral_video_job(
|
||||
if new_model is not None:
|
||||
job.video_model = new_model
|
||||
|
||||
if need_points_settle:
|
||||
from packages.domain.points_service import PointsService
|
||||
# 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,
|
||||
)
|
||||
|
||||
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
svc = PointsService()
|
||||
diff = round(new_est - old_prepaid, 2)
|
||||
if abs(diff) >= 0.01:
|
||||
if diff > 0:
|
||||
# 新预扣更多:补扣差额
|
||||
res = svc.deduct_viral_video(authenticated_user.user.id, diff, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
|
||||
"required": new_est,
|
||||
"balance": balance,
|
||||
"delta": diff,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = round(old_prepaid + diff, 2)
|
||||
logger.info(
|
||||
"[爆款视频][retry] 补扣差额 job_id=%s diff=%.2f new_prepaid=%.2f",
|
||||
job.id,
|
||||
diff,
|
||||
job.credits_prepaid,
|
||||
)
|
||||
else:
|
||||
# 新预扣更少:退还差额
|
||||
refund = round(-diff, 2)
|
||||
txn_id = getattr(job, "credits_transaction_id", "") or ""
|
||||
svc.refund_points(
|
||||
user_id=authenticated_user.user.id,
|
||||
amount=refund,
|
||||
source="viral_video",
|
||||
db=session,
|
||||
ref_id=txn_id or job.id,
|
||||
description="爆款视频重试参数变更退费",
|
||||
)
|
||||
job.credits_prepaid = round(old_prepaid - refund, 2)
|
||||
logger.info(
|
||||
"[爆款视频][retry] 退还差额 job_id=%s refund=%.2f new_prepaid=%.2f",
|
||||
job.id,
|
||||
refund,
|
||||
job.credits_prepaid,
|
||||
)
|
||||
# 差额为 0 则不调整
|
||||
|
||||
# 重置状态
|
||||
# 重置状态(从阶段1重新开始,清空上一次产物避免脏数据)
|
||||
job.retry_count += 1
|
||||
job.status = ViralVideoStatus.PENDING
|
||||
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
|
||||
@@ -635,11 +765,19 @@ def retry_viral_video_job(
|
||||
job.current_stage = ""
|
||||
job.phase_message = ""
|
||||
job.heartbeat_at = None
|
||||
# 重试时清空上次产物(参考 resume_from_image_analyzed 逻辑)
|
||||
job.intent_result = None
|
||||
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_pipeline", args=[job.id])
|
||||
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,
|
||||
@@ -649,6 +787,7 @@ def retry_viral_video_job(
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -890,13 +1029,28 @@ 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": {"status": status_val},
|
||||
"data": data,
|
||||
}
|
||||
await websocket.send_json(initial)
|
||||
# 已经终态 → 再发一条终态事件后立即关闭,避免占连接
|
||||
|
||||
@@ -19,6 +19,7 @@ class PointsBalanceResponse(BaseModel):
|
||||
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=免费放行不扣分)")
|
||||
|
||||
|
||||
# ============ 流水 ============
|
||||
|
||||
@@ -87,6 +87,7 @@ class CreateViralVideoRequest(BaseModel):
|
||||
style_template_id: str = ""
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
language: str = "zh-CN"
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
video_resolution: str = "720p"
|
||||
@@ -121,6 +122,7 @@ class AnalyzeImagesRequest(BaseModel):
|
||||
video_model: str = ""
|
||||
video_resolution: str = "720p"
|
||||
duration: int = Field(default=15, ge=5, le=30)
|
||||
language: str = "zh-CN"
|
||||
|
||||
|
||||
class GenerateCopyRequest(BaseModel):
|
||||
@@ -142,6 +144,7 @@ class GenerateCopyRequest(BaseModel):
|
||||
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"
|
||||
@@ -167,6 +170,13 @@ class ConfirmCopyRequest(BaseModel):
|
||||
"""v1.5+ 阶段3:用户确认/编辑口播后开始渲染(TTS+单次Seedance)。"""
|
||||
|
||||
edited_copy: str = Field(default="", description="用户编辑后的口播文案;为空则用 AI 生成的 voiceover_script")
|
||||
video_model: str | None = Field(default=None, description="用户选定的视频生成模型(confirm时可选)")
|
||||
video_resolution: str | None = Field(default=None, description="用户选定的分辨率(confirm时可选)")
|
||||
video_ratio: str | None = Field(default=None, description="用户选定的比例(confirm时可选)")
|
||||
duration: int | None = Field(default=None, ge=5, le=30, description="用户选定的时长秒数(confirm时可选,5~30)")
|
||||
voice_id: str | None = Field(default=None, description="用户选定的音色ID(confirm时可选)")
|
||||
voice_source: str | None = Field(default=None, description="用户选定的音色来源(confirm时可选)")
|
||||
language: str | None = Field(default=None, description="用户选定的语言(confirm时可选)")
|
||||
|
||||
|
||||
class ConfirmIntentRequest(BaseModel):
|
||||
@@ -218,6 +228,7 @@ class ViralVideoJobResponse(BaseModel):
|
||||
# 音色/视频参数
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
language: str = "zh-CN"
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
intent_result: dict | None = None
|
||||
|
||||
@@ -223,7 +223,47 @@ class LipsyncService:
|
||||
if timings:
|
||||
job.sentence_timings = timings
|
||||
|
||||
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
|
||||
# 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
|
||||
use_gpu = False
|
||||
if self.settings.use_gpu_lipsync:
|
||||
try:
|
||||
@@ -805,6 +845,30 @@ 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:"):
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
"""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()
|
||||
@@ -261,7 +261,33 @@ def tts_synthesize_and_submit(
|
||||
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
|
||||
)
|
||||
|
||||
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
|
||||
# 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 的耦合)
|
||||
audio_url = _sign_media_url(job.audio_url)
|
||||
video_url = _sign_media_url(job.video_url)
|
||||
|
||||
|
||||
@@ -11,12 +11,34 @@ 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) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
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 */
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,7 +67,13 @@ async function loginWithRetry(
|
||||
async function setupFreshUser(
|
||||
request: APIRequestContext,
|
||||
label: string,
|
||||
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
|
||||
): Promise<{
|
||||
token: string
|
||||
user: Record<string, unknown>
|
||||
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`, {
|
||||
@@ -53,7 +81,19 @@ 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}` },
|
||||
@@ -93,7 +133,7 @@ async function setupFreshUser(
|
||||
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
|
||||
)
|
||||
.toBe("ready")
|
||||
return { token, libraryId, assetId, suffix }
|
||||
return { token, user, libraryId, assetId, suffix }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +152,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, suffix } = await setupFreshUser(request, "random")
|
||||
const { token, user, suffix } = await setupFreshUser(request, "random")
|
||||
const authHeader = { Authorization: `Bearer ${token}` }
|
||||
|
||||
// 确保默认模板存在(智能剪辑页依赖模板)
|
||||
@@ -126,13 +166,24 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
expect(templates.length).toBeGreaterThan(0)
|
||||
|
||||
// 注入登录态 + 路由 API
|
||||
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 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 routeBrowserApiToTestApi(page)
|
||||
|
||||
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ──
|
||||
@@ -178,16 +229,28 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
|
||||
|
||||
// ── Step 2:选择素材 ──────────────────────────────────────────
|
||||
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
await page.getByTestId("material-card").first().click()
|
||||
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 page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 3:填写标题 ──────────────────────────────────────────
|
||||
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
||||
await titleInput.fill(`测试随机剪辑 ${suffix}`)
|
||||
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 page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
@@ -204,9 +267,11 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
await confirmBtn.click()
|
||||
const taskResp = await createTask
|
||||
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
|
||||
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
|
||||
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()
|
||||
console.log("[random] Generation task created:", taskId)
|
||||
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
|
||||
console.log("[random] Wizard flow completed ✓")
|
||||
})
|
||||
|
||||
@@ -216,113 +281,28 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
}) => {
|
||||
test.setTimeout(600_000)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
const { token, suffix } = await setupFreshUser(request, "narrative")
|
||||
const { token, user, suffix } = await setupFreshUser(request, "narrative")
|
||||
|
||||
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 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 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 })
|
||||
@@ -334,28 +314,38 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
|
||||
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
|
||||
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("测试带货文案").first().click()
|
||||
await page.getByText(`测试标题 ${suffix}`).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()
|
||||
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
|
||||
// 真实阿里云 CosyVoice 合成耗时偶有波动,放宽到 90s
|
||||
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 90000 })
|
||||
|
||||
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
|
||||
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
|
||||
await page.getByTestId("material-card").first().click()
|
||||
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 page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
|
||||
// (#2048: PreviewCountModal 已移除)
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput2).toBeVisible({ timeout: 5000 })
|
||||
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
|
||||
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 page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
@@ -371,8 +361,11 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
await confirmBtn2.click()
|
||||
const taskResp2 = await createTask2
|
||||
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
|
||||
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
|
||||
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
|
||||
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] Wizard flow completed ✓")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,11 +11,20 @@ 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) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
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 */
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,8 +54,7 @@ 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 }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
// 串行执行时登录可能触发 429,两次退避约 130s,沿用 describe 的 180s 超时
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-mov-${suffix}@example.com`
|
||||
|
||||
Generated
+12
@@ -14,6 +14,7 @@
|
||||
"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",
|
||||
@@ -4502,6 +4503,17 @@
|
||||
"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,6 +25,7 @@
|
||||
"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",
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 运行时开关安全读取
|
||||
*
|
||||
* 背景:本项目使用 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,6 +8,7 @@
|
||||
* 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { readRuntimeFlag } from "../env-flags"
|
||||
import type {
|
||||
PointsBalance,
|
||||
PointsRulesResponse,
|
||||
@@ -201,7 +202,7 @@ const MOCK_MEMBERSHIP: MembershipResponse = {
|
||||
|
||||
/** 获取积分余额 */
|
||||
export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_BALANCE }
|
||||
}
|
||||
@@ -211,7 +212,7 @@ export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
|
||||
/** 获取积分消耗规则 */
|
||||
export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier }
|
||||
}
|
||||
@@ -221,7 +222,7 @@ export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
|
||||
/** 获取充值包列表 */
|
||||
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null }
|
||||
}
|
||||
@@ -236,7 +237,7 @@ export async function getPointsTransactions(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PointsTransactionsResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
const start = (page - 1) * pageSize
|
||||
const items = MOCK_TRANSACTIONS.slice(start, start + pageSize)
|
||||
@@ -261,7 +262,7 @@ export async function getPointsTransactions(
|
||||
export async function createPointsOrder(
|
||||
data: CreateRechargeOrderRequest,
|
||||
): Promise<CreateRechargeOrderResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
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("充值包不存在")
|
||||
@@ -285,7 +286,7 @@ export async function createPointsOrder(
|
||||
* 积分预检查(消耗前调用)
|
||||
*/
|
||||
export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheckResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key)
|
||||
if (!rule) {
|
||||
@@ -326,7 +327,7 @@ export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheck
|
||||
|
||||
/** 获取每日免费额度使用情况 */
|
||||
export async function getDailyUsage(): Promise<DailyUsage> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_DAILY_USAGE }
|
||||
}
|
||||
@@ -336,7 +337,7 @@ export async function getDailyUsage(): Promise<DailyUsage> {
|
||||
|
||||
/** 获取会员聚合信息(创作页可用来判断 max_resolution) */
|
||||
export async function getMembership(): Promise<MembershipResponse> {
|
||||
if (process.env.POINTS_API_MOCK === "true") {
|
||||
if (readRuntimeFlag("POINTS_API_MOCK")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
|
||||
return { ...MOCK_MEMBERSHIP }
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface PointsBalance {
|
||||
member_type: "monthly" | "quarterly" | "yearly" | null
|
||||
/** 会员到期时间 */
|
||||
member_expires_at: ISODate | null
|
||||
/** 后端积分系统是否启用(false=免费放行,不做余额预校验) */
|
||||
credits_enabled?: boolean
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { readRuntimeFlag } from "../env-flags"
|
||||
import type {
|
||||
ScriptItem,
|
||||
ScriptListParams,
|
||||
@@ -16,7 +17,7 @@ import type {
|
||||
* #1894:文案库接口已上线,默认 false 走真实 API;
|
||||
* 通过 SCRIPTS_API_MOCK=true 环境变量可本地开启 mock 调试(行为同 POINTS_API_MOCK)。
|
||||
*/
|
||||
export const SCRIPTS_API_MOCK = (process.env.SCRIPTS_API_MOCK as string | undefined) === "true"
|
||||
export const SCRIPTS_API_MOCK = readRuntimeFlag("SCRIPTS_API_MOCK")
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { readRuntimeFlag } from "../env-flags"
|
||||
import type {
|
||||
SubscriptionInfo,
|
||||
SubscriptionPlan,
|
||||
@@ -72,7 +73,7 @@ const MOCK_PLANS: SubscriptionPlan[] = [
|
||||
|
||||
const MOCK_BILLING: BillingRecord[] = []
|
||||
|
||||
const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true"
|
||||
const isMock = () => readRuntimeFlag("POINTS_API_MOCK")
|
||||
|
||||
/** 获取当前订阅 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
|
||||
@@ -65,27 +65,39 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return isImageAnalysisStage(stage) || isCopyStage(stage)
|
||||
}
|
||||
|
||||
/** 单张图片 VLM 识别出的商品信息 */
|
||||
/** 从 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
|
||||
category?: string
|
||||
brand?: string
|
||||
colors?: string[]
|
||||
material_or_texture?: string
|
||||
key_features?: string[]
|
||||
visual_style?: string
|
||||
scene?: string
|
||||
target_audience_hint?: string
|
||||
text_on_image?: string
|
||||
/** 旧字段兼容 */
|
||||
spec?: string
|
||||
features?: string[] | string
|
||||
label_text?: string
|
||||
selling_points?: string
|
||||
image_index?: number
|
||||
has_person?: boolean
|
||||
/** v8: 用户端展示用的叙述 markdown(由提示词控制排版) */
|
||||
summary_markdown?: string
|
||||
/** 标题行兼容字段 */
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface ImageAnalysisResult {
|
||||
/** v8 字段 */
|
||||
images?: ImageProductAnalysis[]
|
||||
/** 老数据兼容 */
|
||||
products?: ImageProductAnalysis[]
|
||||
}
|
||||
|
||||
@@ -93,6 +105,8 @@ export interface ImageAnalysisResult {
|
||||
export interface ShotScript {
|
||||
/** 时间区间,如 "0-3秒" */
|
||||
time_range?: string
|
||||
/** 说话人角色,如 "店主"、"顾客" */
|
||||
speaker?: string
|
||||
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
|
||||
shot_type_angle_movement?: string
|
||||
/** 场景描述+对白 */
|
||||
@@ -132,6 +146,8 @@ export interface CopyResult {
|
||||
/** 向后兼容:= voiceover_script */
|
||||
suggested_copy?: string
|
||||
title?: string
|
||||
/** v3 storyboard: 用户端展示用的 markdown 文案(由提示词控制排版) */
|
||||
copy_display_markdown?: string
|
||||
/** v1.5 旧字段兼容(老数据降级时可能出现) */
|
||||
scenes?: Array<{ shot: string; narration: string; duration?: number }>
|
||||
}
|
||||
@@ -191,8 +207,14 @@ export interface ViralVideoJob {
|
||||
video_model?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
/** 后端真实字段名(snake_case):当前细粒度阶段 */
|
||||
current_stage?: ViralVideoStage
|
||||
/** 后端真实字段名:当前阶段中文提示文案 */
|
||||
phase_message?: string
|
||||
/** @deprecated 兼容旧前端:使用 current_stage */
|
||||
progress_stage?: ViralVideoStage
|
||||
progress_percent?: number
|
||||
/** @deprecated 兼容旧前端:使用 phase_message */
|
||||
progress_message?: string
|
||||
output_url?: string
|
||||
result_video_url?: string
|
||||
@@ -303,6 +325,18 @@ export interface ConfirmCopyRequest {
|
||||
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) */
|
||||
|
||||
@@ -8,11 +8,6 @@ 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": "账单管理",
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
@@ -207,12 +206,6 @@ 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: "会员订阅",
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
@@ -38,6 +38,8 @@ 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])
|
||||
@@ -342,7 +344,8 @@ const GeneratePage: React.FC = () => {
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
|
||||
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
|
||||
if (ENABLE_CREDIT_SYSTEM) {
|
||||
// 仅当后端积分系统真实启用时才做余额预校验(后端免费放行时前端不得拦截)
|
||||
if (creditsEnabled) {
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
check = hasEnoughPoints(
|
||||
balance ?? null,
|
||||
@@ -388,6 +391,7 @@ const GeneratePage: React.FC = () => {
|
||||
balance,
|
||||
dailyUsage,
|
||||
rules,
|
||||
creditsEnabled,
|
||||
])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -502,7 +506,7 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const pointsEstimate = useMemo(() => {
|
||||
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
|
||||
if (!creditsEnabled) return { sufficient: true, cost: 0 }
|
||||
return hasEnoughPoints(
|
||||
balance ?? null,
|
||||
unitsForCost,
|
||||
@@ -511,8 +515,8 @@ const GeneratePage: React.FC = () => {
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
)
|
||||
}, [unitsForCost, balance, dailyUsage, rules])
|
||||
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
|
||||
}, [unitsForCost, balance, dailyUsage, rules, creditsEnabled])
|
||||
const insufficientPoints = creditsEnabled && !pointsEstimate.sufficient
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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,音量适中。"
|
||||
@@ -1020,6 +1020,11 @@
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-copy-head {
|
||||
display: flex;
|
||||
@@ -1050,7 +1055,8 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 360px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
padding: 28px 16px;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
@@ -1087,7 +1093,8 @@
|
||||
.vv-storyboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 360px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
@@ -1440,7 +1447,8 @@
|
||||
/* ── Preview ──────────────── */
|
||||
.vv-preview {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 360px;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
background: #fff;
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 10px;
|
||||
@@ -1973,3 +1981,294 @@
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px dashed #e5e7eb;
|
||||
}
|
||||
|
||||
/* ─────────── markdown 渲染(提示词控制展示格式) ─────────── */
|
||||
.vv-recog-md {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.vv-copy-preview {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(180deg, #faf7ff 0%, #f6f2ff 100%);
|
||||
border: 1px solid #ece4fb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.vv-copy-preview-h {
|
||||
margin: 0 0 8px;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.vv-md-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #374151;
|
||||
word-break: break-word;
|
||||
}
|
||||
.vv-md-body h1,
|
||||
.vv-md-body h2,
|
||||
.vv-md-body h3,
|
||||
.vv-md-body h4 {
|
||||
margin: 10px 0 6px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.vv-md-body h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.vv-md-body h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.vv-md-body h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
.vv-md-body h4 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.vv-md-body p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.vv-md-body ul,
|
||||
.vv-md-body ol {
|
||||
margin: 6px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.vv-md-body li {
|
||||
margin: 3px 0;
|
||||
}
|
||||
.vv-md-body strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vv-md-body blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 4px 12px;
|
||||
border-left: 3px solid #7c3aed;
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
color: #4b5563;
|
||||
}
|
||||
.vv-md-body code {
|
||||
padding: 1px 5px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #be185d;
|
||||
}
|
||||
.vv-md-body a {
|
||||
color: #7c3aed;
|
||||
text-decoration: none;
|
||||
}
|
||||
.vv-md-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.vv-md-body table {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.vv-md-body th,
|
||||
.vv-md-body td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.vv-md-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* ── 分镜脚本流式预览(WebSocket delta 实时输出) ── */
|
||||
.vv-streaming-box {
|
||||
outline: 2px solid #ede9fe;
|
||||
background: linear-gradient(180deg, #fafafe 0%, #ffffff 100%);
|
||||
}
|
||||
.vv-streaming-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0 10px 0;
|
||||
border-bottom: 1px dashed #ede9fe;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vv-streaming-title {
|
||||
font-size: 13px;
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.vv-streaming-count {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
background: #f5f0ff;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.vv-streaming-doc {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px 8px 2px;
|
||||
/* 复用 vv-sb-doc 的滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d8c4ff transparent;
|
||||
}
|
||||
.vv-streaming-doc::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.vv-streaming-doc::-webkit-scrollbar-thumb {
|
||||
background: #d8c4ff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.vv-streaming-pre {
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: #1f2937;
|
||||
tab-size: 2;
|
||||
}
|
||||
.vv-streaming-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
background: #7c3aed;
|
||||
vertical-align: middle;
|
||||
margin-left: 2px;
|
||||
animation: vv-caret-blink 1s steps(2, start) infinite;
|
||||
}
|
||||
@keyframes vv-caret-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
.vv-streaming-actions {
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.vv-streaming-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── 三列等高布局(2026-10-10 fix) ──
|
||||
* vv-page 作为纵向 flex 容器,tabs 固定 + grid 占满剩余高度;
|
||||
* vv-grid 改用 grid-auto-rows:1fr 强制三列等高;
|
||||
* 每列的卡片使用 flex column + min-height:0,正文区域 flex:1 并内部滚动。
|
||||
*/
|
||||
.vv-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 减去顶部导航 56px 和自身 padding 16*2=32px,给 Tabs + 三列区留一屏 */
|
||||
height: calc(100vh - 56px);
|
||||
min-height: 640px; /* 小窗口保底 */
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vv-grid {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
align-items: stretch; /* 覆盖原来的 start,强制行内格子撑满行高 */
|
||||
grid-auto-rows: 1fr; /* 每行所有格子等高 */
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 列内的卡片(.vv-section)默认允许收缩,正文区域接管滚动 */
|
||||
.vv-col > .vv-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
/* STEP1/STEP2 唯一卡片:撑满列高 */
|
||||
.vv-col > .vv-section:only-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* STEP3 右侧两卡片:配置固定不缩,预览区吸剩余空间 */
|
||||
.vv-col > .vv-section-preview {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.vv-section-preview .vv-section-body.vv-preview-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-section-preview .vv-preview {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 列内 .vv-credits / .vv-error / 主按钮 不参与收缩,永远固定在底部可见 */
|
||||
.vv-col > .vv-credits,
|
||||
.vv-col > .vv-error,
|
||||
.vv-col > .vv-btn-primary {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vv-col > .vv-credits {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 可滚动 section 正文 */
|
||||
.vv-section-body.vv-section-body-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 16px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-scroll-inner {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 美化滚动条(紫白主题) */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d8c4ff transparent;
|
||||
}
|
||||
.vv-scroll-inner::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.vv-scroll-inner::-webkit-scrollbar-thumb {
|
||||
background: #d8c4ff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.vv-scroll-inner::-webkit-scrollbar-thumb:hover {
|
||||
background: #c4a8ff;
|
||||
}
|
||||
.vv-scroll-inner::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 主按钮始终顶在 section-body 底部(在 vv-scroll-inner 之外) */
|
||||
.vv-section-body.vv-section-body-scroll > .vv-btn-primary {
|
||||
margin-top: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* STEP1 文件列表改为根据可用空间自适应,不再用 max-height:260px 硬编码 */
|
||||
.vv-file-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
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
|
||||
@@ -0,0 +1,43 @@
|
||||
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
|
||||
@@ -0,0 +1,111 @@
|
||||
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
|
||||
@@ -0,0 +1,347 @@
|
||||
import React, { useMemo } from "react"
|
||||
import {
|
||||
UploadOutlined,
|
||||
FolderOpenOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
CloseOutlined,
|
||||
LinkOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Input } from "antd"
|
||||
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
|
||||
douyinInput: string
|
||||
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
|
||||
onToggleDouyinPanel: () => void
|
||||
onDouyinInputChange: (v: string) => void
|
||||
onDouyinSubmit: () => 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"
|
||||
style={{ marginRight: 6, display: "inline-block", verticalAlign: "middle" }}
|
||||
/>
|
||||
识别描述汇览
|
||||
</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>
|
||||
<button className="vv-action-btn" onClick={p.onToggleDouyinPanel}>
|
||||
<LinkOutlined /> 抖音链接识别
|
||||
</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.voicePanel === "douyin" && !p.refVideo && (
|
||||
<div className="vv-douyin-input">
|
||||
<Input
|
||||
placeholder="粘贴抖音分享链接,例:https://v.douyin.com/xxxxx/"
|
||||
value={p.douyinInput}
|
||||
onChange={(e) => p.onDouyinInputChange(e.target.value)}
|
||||
onPressEnter={p.onDouyinSubmit}
|
||||
suffix={
|
||||
<button className="vv-link-btn" onClick={p.onDouyinSubmit}>
|
||||
识别
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="vv-muted" style={{ marginTop: 4, fontSize: 11 }}>
|
||||
粘贴后自动解析下载参考视频(敬请期待)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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" style={{ marginTop: 6 }}>
|
||||
<div className="vv-file-name">{p.refVideo.name}</div>
|
||||
{p.refVideo.uploading && (
|
||||
<div className="vv-progress-bar" style={{ margin: "6px 0 4px" }}>
|
||||
<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" style={{ marginBottom: 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
|
||||
@@ -0,0 +1,225 @@
|
||||
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 { 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
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 分镜结果区 / 流式预览 */}
|
||||
<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
|
||||
@@ -0,0 +1,218 @@
|
||||
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 style={{ fontSize: 36, color: "#7c3aed" }} />
|
||||
</div>
|
||||
<div className="ph-txt">
|
||||
AI 正在生成视频中…
|
||||
<br />
|
||||
预计 1-3 分钟,请勿关闭页面
|
||||
</div>
|
||||
{getJobPhaseMessage(p.job) && (
|
||||
<div className="vv-muted" style={{ marginTop: 6, fontSize: 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
|
||||
@@ -0,0 +1,327 @@
|
||||
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 style={{ color: isMain ? "#1890ff" : "#fa8c16" }}>{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 style={{ color: "#9ca3af" }}>暂无分镜数据</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
|
||||
@@ -0,0 +1,193 @@
|
||||
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"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
marginRight: 8,
|
||||
marginLeft: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
backgroundColor: isMainSpeaker ? "#e6f7ff" : "#fff7e6",
|
||||
color: isMainSpeaker ? "#1890ff" : "#fa8c16",
|
||||
}}
|
||||
>
|
||||
{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
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -0,0 +1,218 @@
|
||||
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 style={{ marginRight: 6 }}>🔊</span>
|
||||
<span style={{ 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" style={{ padding: 12, textAlign: "center" }}>
|
||||
暂无已上传的配音文件
|
||||
</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
|
||||
@@ -0,0 +1,140 @@
|
||||
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
|
||||
@@ -0,0 +1,404 @@
|
||||
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,
|
||||
douyinInput: "",
|
||||
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
|
||||
@@ -0,0 +1,365 @@
|
||||
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
|
||||
handleDouyinUrl: () => 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 handleDouyinUrl = () => {
|
||||
const url = task.douyinInput.trim()
|
||||
if (!url) {
|
||||
message.warning("请粘贴抖音分享链接")
|
||||
return
|
||||
}
|
||||
if (!/(douyin\.com|iesdouyin\.com)/.test(url)) {
|
||||
message.warning("请输入有效的抖音链接")
|
||||
return
|
||||
}
|
||||
message.info("抖音链接识别功能即将上线,当前请使用本地上传参考视频")
|
||||
setTask({ douyinInput: "" })
|
||||
}
|
||||
|
||||
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,
|
||||
handleDouyinUrl,
|
||||
removeRefVideo,
|
||||
handleVoiceFile,
|
||||
onCloneSuccess,
|
||||
pickVoiceAsset,
|
||||
selectMyVoice,
|
||||
toggleVoicePanel,
|
||||
toggleVoice,
|
||||
openAssetPicker,
|
||||
}
|
||||
}
|
||||
|
||||
export default useViralVideoMedia
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { getViralVideoJob } from "@/api/viral-video"
|
||||
import { isAnalysisStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
|
||||
import {
|
||||
isAnalysisStage,
|
||||
getJobStage,
|
||||
type ViralVideoJob,
|
||||
type ViralVideoStatus,
|
||||
} from "@/api/viral-video/types"
|
||||
|
||||
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
|
||||
|
||||
@@ -11,8 +16,9 @@ export interface UseViralVideoPollingOptions {
|
||||
|
||||
/**
|
||||
* 爆款视频任务 HTTP 轮询 hook。
|
||||
* 负责持续拉取任务状态并回调给上层;上层负责根据状态/阶段切换 UI 文案。
|
||||
* 任务进入终态(completed/failed/cancelled)后自动停止。
|
||||
* - onUpdate 通过 ref 持有,避免 useCallback 依赖变化导致重启轮询。
|
||||
* - 任务进入终态(completed/failed/cancelled)后自动停止。
|
||||
* - 连续失败 20 次(约 2 分钟)后熔断。
|
||||
*/
|
||||
export function useViralVideoPolling(
|
||||
jobId: string | null | undefined,
|
||||
@@ -23,6 +29,9 @@ 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
|
||||
@@ -37,26 +46,30 @@ export function useViralVideoPolling(
|
||||
try {
|
||||
const job = await getViralVideoJob(id)
|
||||
failCountRef.current = 0
|
||||
onUpdate(job)
|
||||
onUpdateRef.current(job)
|
||||
if (TERMINAL.includes(job.status)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (stoppedRef.current) return
|
||||
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
|
||||
const inRender = job.progress_stage === "rendering"
|
||||
// 分析阶段走默认间隔即可
|
||||
const isAnalyzing = isAnalysisStage(job.progress_stage)
|
||||
const stage = getJobStage(job)
|
||||
const inRender = stage === "rendering"
|
||||
const isAnalyzing = isAnalysisStage(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) {
|
||||
console.warn("[viral-video] 轮询连续失败 20 次,停止轮询,请刷新页面重试")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
const delay = Math.min(intervalMs * 2 ** Math.min(failCountRef.current, 3), 10000)
|
||||
timerRef.current = setTimeout(() => pollOnce(id), delay)
|
||||
}
|
||||
},
|
||||
[intervalMs, onUpdate, stop],
|
||||
[intervalMs, stop],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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" | "douyin"
|
||||
url: string
|
||||
name: string
|
||||
uploading: boolean
|
||||
pct: number
|
||||
ossUrl?: string
|
||||
file?: File
|
||||
douyinUrl?: string
|
||||
} | 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" | "douyin" | 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
|
||||
douyinInput: string
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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 "wait_user_confirm":
|
||||
nextStep = t.uiStep === "step3_generating" ? t.uiStep : "step2_copy_ready"
|
||||
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
|
||||
@@ -0,0 +1,71 @@
|
||||
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
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
isCopyStage,
|
||||
isImageAnalysisStage,
|
||||
isVideoStage,
|
||||
getJobStage,
|
||||
} from "@/api/viral-video/types"
|
||||
import type { ViralVideoJob, ViralVideoStage } from "@/api/viral-video/types"
|
||||
|
||||
/**
|
||||
* 统一的视频/文案/图片阶段错误分类。
|
||||
* 优先按 stage 字段判定;stage 丢失时基于错误消息关键词兜底。
|
||||
*/
|
||||
export type JobErrorKind = "image" | "copy" | "video"
|
||||
|
||||
const VIDEO_ERR_RE =
|
||||
/wan 3\.0|wan-?3|seedance|视频生成|视频下载|视频渲染|tts|配音|render|musetalk|lip.?sync/i
|
||||
const IMAGE_ERR_RE = /vlm|图片分析|vision|商品识别|ocr|image analysis|图片识别|图像分析/i
|
||||
|
||||
export function classifyJobError(err: string, stage?: string | null): JobErrorKind {
|
||||
const msg = err || ""
|
||||
if (stage) {
|
||||
const s = stage as ViralVideoStage
|
||||
if (isVideoStage(s)) return "video"
|
||||
if (isImageAnalysisStage(s)) return "image"
|
||||
if (isCopyStage(s)) return "copy"
|
||||
}
|
||||
if (VIDEO_ERR_RE.test(msg)) return "video"
|
||||
if (IMAGE_ERR_RE.test(msg)) return "image"
|
||||
return "copy"
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉后端错误消息中常见的"XX失败:"前缀,避免前端 UI 重复前缀。
|
||||
*/
|
||||
const ERROR_PREFIX_RE =
|
||||
/^(视频生成失败|文案生成失败|图片分析失败|Wan 3\.0 视频生成失败|Seedance[0-9. ]*视频生成失败)[::]\s*/g
|
||||
|
||||
export function cleanErrorMessage(err: string): string {
|
||||
if (!err) return ""
|
||||
return err.replace(ERROR_PREFIX_RE, "").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 job 统一提取错误分类和清理后的消息;无错误返回 null。
|
||||
*/
|
||||
export function extractJobError(
|
||||
job: ViralVideoJob | null | undefined,
|
||||
): { kind: JobErrorKind; msg: string } | null {
|
||||
if (!job) return null
|
||||
const raw = job.error_message || job.error_msg
|
||||
if (!raw) return null
|
||||
const stage = getJobStage(job)
|
||||
const kind = classifyJobError(raw, stage)
|
||||
return { kind, msg: cleanErrorMessage(raw) }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 极简 markdown 渲染器:仅支持粗体、斜体、标题、列表、换行。
|
||||
* 不依赖 marked 包(node_modules 中暂未安装),满足 viral-video 文案预览的
|
||||
* 简单排版需求。
|
||||
*/
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
function inline(md: string): string {
|
||||
let s = escapeHtml(md)
|
||||
// bold **xxx**
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
// italic *xxx*
|
||||
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||
// inline code `xxx`
|
||||
s = s.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
return s
|
||||
}
|
||||
|
||||
export function renderMarkdown(md: string): string {
|
||||
if (!md) return ""
|
||||
const lines = md.split(/\r?\n/)
|
||||
const out: string[] = []
|
||||
let inList = false
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd()
|
||||
if (!line.trim()) {
|
||||
if (inList) {
|
||||
out.push("</ul>")
|
||||
inList = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
// headings
|
||||
const h = line.match(/^(#{1,4})\s+(.*)$/)
|
||||
if (h) {
|
||||
if (inList) {
|
||||
out.push("</ul>")
|
||||
inList = false
|
||||
}
|
||||
const lvl = h[1].length
|
||||
out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`)
|
||||
continue
|
||||
}
|
||||
// unordered list
|
||||
const li = line.match(/^\s*[-*]\s+(.*)$/)
|
||||
if (li) {
|
||||
if (!inList) {
|
||||
out.push("<ul>")
|
||||
inList = true
|
||||
}
|
||||
out.push(`<li>${inline(li[1])}</li>`)
|
||||
continue
|
||||
}
|
||||
if (inList) {
|
||||
out.push("</ul>")
|
||||
inList = false
|
||||
}
|
||||
out.push(`<p>${inline(line)}</p>`)
|
||||
}
|
||||
if (inList) out.push("</ul>")
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
export default renderMarkdown
|
||||
@@ -0,0 +1,30 @@
|
||||
import { RESOLUTION_ORDER } from "../ViralVideoPage.constants"
|
||||
|
||||
/**
|
||||
* resolveModelSwitch 所需的最小模型信息(结构性类型,兼容 ViralVideoModel)。
|
||||
*/
|
||||
export interface ModelSwitchInfo {
|
||||
supported_resolutions?: string[]
|
||||
max_duration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换视频模型时,联动修正分辨率/时长:新模型不支持当前分辨率则回退到最低支持,
|
||||
* 当前时长超过新模型 max_duration 则截断到 max_duration。
|
||||
*/
|
||||
export function resolveModelSwitch(
|
||||
cur: { quality: string; duration: number },
|
||||
m: ModelSwitchInfo,
|
||||
): { quality: string; duration: number } {
|
||||
const mRes = Array.isArray(m.supported_resolutions)
|
||||
? m.supported_resolutions
|
||||
: ["480p", "720p", "1080p"]
|
||||
const sortedRes = mRes.slice().sort((a, b) => {
|
||||
const ra = RESOLUTION_ORDER.indexOf(a)
|
||||
const rb = RESOLUTION_ORDER.indexOf(b)
|
||||
return (rb === -1 ? -1 : rb) - (ra === -1 ? -1 : ra)
|
||||
})
|
||||
const newRes = mRes.includes(cur.quality) ? cur.quality : sortedRes[0] || "480p"
|
||||
const newDur = Math.min(cur.duration, typeof m.max_duration === "number" ? m.max_duration : 15)
|
||||
return { quality: newRes, duration: newDur }
|
||||
}
|
||||
@@ -116,31 +116,6 @@ const appChildren: RouteObject[] = [
|
||||
path: "profile",
|
||||
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "analytics",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "monitor",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const appRoutes: RouteObject = {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import AdminComingSoon from "@/pages/admin/AdminComingSoon"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe("AdminComingSoon Page", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AdminComingSoon />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByText("Admin 后台暂未开放")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render back button", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AdminComingSoon />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByText("返回首页")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,9 @@ celery_app.conf.imports = (
|
||||
# #1998 GPU MuseTalk 异步推理:wait_for_result→签名 URL→回写 lipsync_jobs
|
||||
# 必须在 Worker 侧注册,否则 apply_async 消息无人消费,job 永远卡在 processing
|
||||
"app.tasks.lipsync_gpu",
|
||||
# #2076 Ditto 蚂蚁数字人异步推理:同步 HTTP 调用 Ditto → MP4 流转存 OSS → 回写 lipsync_jobs
|
||||
# 必须在 Worker 侧注册;失败回退 GPU MuseTalk → MediaKit
|
||||
"app.tasks.lipsync_ditto",
|
||||
)
|
||||
|
||||
# Celery Beat 定时任务调度
|
||||
|
||||
+1444
-1090
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 prompt 解析:优先读后台 viral_video_prompt_templates 表(prompt_type='image_analysis'
|
||||
且 is_active=true),30s TTL 热加载;DB 无有效记录/异常时,fallback 到纯硬编码 JSON schema prompt。
|
||||
"""V2 prompt 解析:优先读后台 viral_video_prompt_templates(prompt_type='image_analysis'
|
||||
且 is_active=true),30s TTL 热加载;DB 无有效记录/异常时,fallback 到 prompts.py 的
|
||||
image_analysis v8 默认 system/user。
|
||||
|
||||
规则(简单直接,不做字符串匹配判断):
|
||||
- DB 有 is_active=true 的 image_analysis 记录(含种子版本和用户修改后的版本):
|
||||
* system = DB.system_prompt(DB prompt 自带完整输出格式,不追加硬编码 schema,
|
||||
避免 DB 写 XML、调用强制 json_object 造成的格式冲突)
|
||||
* user = DB.user_prompt_template 渲染后使用;渲染后为空则用硬编码默认
|
||||
- DB 无记录/连接异常/返回空:system/user 全部用纯硬编码 JSON schema prompt
|
||||
规则:
|
||||
- DB 有 is_active=true 的 image_analysis 记录:system 原样用 DB.system_prompt
|
||||
(自带完整输出格式,不追加任何硬编码 schema),user 用 DB.user_prompt_template
|
||||
渲染(填入 image_url / ocr_text);
|
||||
- DB 无记录/异常:system/user 用 prompts.py 里的 v8 默认模板。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,192 +15,79 @@ from __future__ import annotations
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- 纯硬编码 JSON schema(DB 无有效配置时全量使用) ----
|
||||
|
||||
_FAST_JSON_SCHEMA = (
|
||||
"你是图片结构化识别器。严格按下方 JSON schema 返回一个对象,不要任何解释、"
|
||||
"不要markdown、不要代码块、不要前后缀文字。字段值不确定时填 null 或空数组。\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "upper_wear": "上装款式,如T恤/衬衫/卫衣/毛衣/西装/夹克/连衣裙/吊带/背心/外套等",\n'
|
||||
' "upper_color": "上装主色",\n'
|
||||
' "lower_wear": "下装款式;穿连衣裙时填null",\n'
|
||||
' "lower_color": "下装主色",\n'
|
||||
' "dress_color": "连衣裙主色(穿连衣裙时填)",\n'
|
||||
' "accessories": ["眼镜"/"帽子"/"项链"/"耳环"/"背包"/"手表"等数组],\n'
|
||||
' "hairstyle": "发型,如短发/长发/马尾/卷发/丸子头/光头等",\n'
|
||||
' "expression": "表情,如微笑/严肃/酷/开心等",\n'
|
||||
' "pose": "姿势,如站立/坐姿/侧身/行走等",\n'
|
||||
' "scene": "场景,如室内/街拍/户外/办公室/家居/海边/雪景/森林等",\n'
|
||||
' "style": "风格,如休闲/商务/运动/复古/潮流/甜美/酷飒/优雅/街头/法式等",\n'
|
||||
' "has_product": true/false,\n'
|
||||
' "category": "产品类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
|
||||
' "product_name": "产品名称,非产品图填null",\n'
|
||||
' "brand": "品牌或文字标识,无则null",\n'
|
||||
' "material": "材质,如棉质/牛仔/皮革/真丝/针织/涤纶等",\n'
|
||||
' "pattern": "图案,如纯色/条纹/波点/格子/印花/碎花/Logo等",\n'
|
||||
' "colors": ["主色数组"],\n'
|
||||
' "mood": "整体氛围/情绪,如清新/活力/高级/温暖/冷峻/甜美/复古等"\n'
|
||||
"}\n\n"
|
||||
"你必须只返回一个合法的JSON对象,不要输出任何其他文字、解释、XML标签或markdown。"
|
||||
)
|
||||
DEFAULT_FAST_USER = "识别这张图片的人物穿搭与主体信息,只返回JSON对象。"
|
||||
def _default_template() -> dict:
|
||||
# 延迟导入:避免模块加载时拉起整个 packages 依赖链(也便于旧 Python 收集测试)
|
||||
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
|
||||
|
||||
_PRO_JSON_SCHEMA = (
|
||||
"你是图片分析专家。严格按下方 JSON schema 返回一个对象,不要解释、不要markdown、不要代码块、不要XML标签。\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "outfit": "整体穿着描述(含颜色款式)",\n'
|
||||
' "hair": "发型发色",\n'
|
||||
' "pose": "姿势",\n'
|
||||
' "expression": "表情",\n'
|
||||
' "scene": "场景",\n'
|
||||
' "mood": "氛围",\n'
|
||||
' "has_product": true/false,\n'
|
||||
' "category": "类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
|
||||
' "product_name": "产品名,非产品图填null",\n'
|
||||
' "brand": "品牌,无则null",\n'
|
||||
' "key_features": ["核心特征数组,3-6个短语"]\n'
|
||||
"}\n\n"
|
||||
"你必须只返回一个合法的JSON对象,不要输出任何其他文字、解释、XML标签或markdown。"
|
||||
)
|
||||
DEFAULT_PRO_USER = "分析这张图片,返回符合schema的JSON。"
|
||||
for item in DEFAULT_TEMPLATES:
|
||||
if item["prompt_type"] == "image_analysis":
|
||||
return item
|
||||
raise RuntimeError("image_analysis 默认模板缺失")
|
||||
|
||||
# 保留旧 JSON schema 追加文本作为常量(DB prompt 完全控制输出格式后不再使用,
|
||||
# 保留以便排查历史行为)。
|
||||
_FAST_JSON_APPEND = (
|
||||
"\n\n【输出格式要求】无论上文如何要求,最终你必须只返回一个合法的JSON对象,"
|
||||
"严格包含以下字段(字段值不确定时填null或空数组):\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "upper_wear": "上装款式字符串",\n'
|
||||
' "upper_color": "上装主色",\n'
|
||||
' "lower_wear": "下装款式(穿连衣裙时填null)",\n'
|
||||
' "lower_color": "下装主色",\n'
|
||||
' "dress_color": "连衣裙主色(穿连衣裙时填)",\n'
|
||||
' "accessories": ["配饰数组"],\n'
|
||||
' "hairstyle": "发型",\n'
|
||||
' "expression": "表情",\n'
|
||||
' "pose": "姿势",\n'
|
||||
' "scene": "场景",\n'
|
||||
' "style": "风格",\n'
|
||||
' "has_product": true/false,\n'
|
||||
' "category": "产品类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
|
||||
' "product_name": "产品名称,非产品图填null",\n'
|
||||
' "brand": "品牌或文字标识,无则null",\n'
|
||||
' "material": "材质",\n'
|
||||
' "pattern": "图案",\n'
|
||||
' "colors": ["主色数组"],\n'
|
||||
' "mood": "整体氛围"\n'
|
||||
"}\n"
|
||||
"不要输出任何其他文字、解释、XML标签或markdown。"
|
||||
)
|
||||
|
||||
_PRO_JSON_APPEND = (
|
||||
"\n\n【输出格式要求】无论上文如何要求,最终你必须只返回一个合法的JSON对象,"
|
||||
"严格包含以下字段(字段值不确定时填null或空数组):\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "outfit": "整体穿着描述(含颜色款式)",\n'
|
||||
' "hair": "发型发色",\n'
|
||||
' "pose": "姿势",\n'
|
||||
' "expression": "表情",\n'
|
||||
' "scene": "场景",\n'
|
||||
' "mood": "氛围",\n'
|
||||
' "has_product": true/false,\n'
|
||||
' "category": "类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
|
||||
' "product_name": "产品名,非产品图填null",\n'
|
||||
' "brand": "品牌,无则null",\n'
|
||||
' "key_features": ["核心特征3-6个短语"]\n'
|
||||
"}\n"
|
||||
"不要输出任何其他文字、解释、XML标签或markdown。"
|
||||
)
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, Any]] = {}
|
||||
_cache: dict[str, tuple[float, tuple[str, str]]] = {}
|
||||
_CACHE_TTL = 30.0
|
||||
|
||||
|
||||
def _load_db_template() -> Any | None:
|
||||
"""直接查DB viral_video_prompt_templates 中 is_active=true 的 image_analysis 记录;
|
||||
DB不可达/无记录/异常返回None。
|
||||
复用 prompt_loader._load_from_db,它只查DB不做DEFAULT_TEMPLATES fallback,
|
||||
返回None表示DB无记录或异常。"""
|
||||
def _load_db_template():
|
||||
"""查 DB is_active=true 的 image_analysis 记录;不可达/无记录返回 None。"""
|
||||
try:
|
||||
from packages.application.viral_video.prompt_loader import _load_from_db
|
||||
|
||||
return _load_from_db("image_analysis")
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 查询DB prompt配置失败: %s", e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[vision.v2] 查询DB image_analysis prompt失败: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _render_user(tpl: Any | None, default_user: str) -> str:
|
||||
if not tpl:
|
||||
return default_user
|
||||
tpl_str = getattr(tpl, "user_prompt_template", "") or ""
|
||||
if not tpl_str.strip():
|
||||
return default_user
|
||||
rendered = tpl_str.replace("{image_count}", "1").replace("{industry}", "通用").replace("{image_urls}", "").strip()
|
||||
return rendered or default_user
|
||||
def _render_user(user_tpl: str, image_url: str, ocr_text: str) -> str:
|
||||
try:
|
||||
return user_tpl.format(image_url=image_url, ocr_text=ocr_text or "无")
|
||||
except Exception: # noqa: BLE001
|
||||
return user_tpl
|
||||
|
||||
|
||||
def resolve_fast_prompt() -> tuple[str, str]:
|
||||
return _resolve("fast")
|
||||
|
||||
|
||||
def resolve_pro_prompt() -> tuple[str, str]:
|
||||
return _resolve("pro")
|
||||
|
||||
|
||||
def _resolve(kind: str) -> tuple[str, str]:
|
||||
def _resolve(kind: str, image_url: str = "", ocr_text: str = "") -> tuple[str, str]:
|
||||
now = time.time()
|
||||
cache_key = f"prompt_{kind}"
|
||||
with _cache_lock:
|
||||
hit = _cache.get(cache_key)
|
||||
if hit and now - hit[0] < _CACHE_TTL:
|
||||
return hit[1]
|
||||
sys_prompt, usr_prompt = hit[1]
|
||||
return sys_prompt, _render_user(usr_prompt, image_url, ocr_text)
|
||||
|
||||
default_sys = _FAST_JSON_SCHEMA if kind == "fast" else _PRO_JSON_SCHEMA
|
||||
default_user = DEFAULT_FAST_USER if kind == "fast" else DEFAULT_PRO_USER
|
||||
default = _default_template()
|
||||
sys_prompt = default["system_prompt"]
|
||||
usr_prompt = default["user_prompt_template"]
|
||||
|
||||
sys_prompt = default_sys
|
||||
usr_prompt = default_user
|
||||
try:
|
||||
tpl = _load_db_template()
|
||||
if tpl is not None:
|
||||
db_sys = (getattr(tpl, "system_prompt", "") or "").strip()
|
||||
if db_sys:
|
||||
sys_prompt = db_sys # DB prompt自带完整输出格式,不追加硬编码schema避免冲突
|
||||
usr_prompt = _render_user(tpl, default_user)
|
||||
logger.info(
|
||||
"[vision.v2] 使用DB image_analysis prompt (kind=%s version=%s sys_len=%d)",
|
||||
kind,
|
||||
getattr(tpl, "version", "?"),
|
||||
len(db_sys),
|
||||
)
|
||||
else:
|
||||
logger.debug("[vision.v2] DB image_analysis system_prompt为空,使用默认JSON (kind=%s)", kind)
|
||||
else:
|
||||
logger.debug("[vision.v2] DB无image_analysis记录/不可达,使用默认JSON prompt (kind=%s)", kind)
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 解析DB prompt异常,使用默认: %s", e)
|
||||
tpl = _load_db_template()
|
||||
if tpl is not None:
|
||||
db_sys = (getattr(tpl, "system_prompt", "") or "").strip()
|
||||
if db_sys:
|
||||
sys_prompt = db_sys
|
||||
db_usr = getattr(tpl, "user_prompt_template", "") or usr_prompt
|
||||
usr_prompt = db_usr or usr_prompt
|
||||
logger.info(
|
||||
"[vision.v2] 使用DB image_analysis prompt version=%s",
|
||||
getattr(tpl, "version", "?"),
|
||||
)
|
||||
|
||||
with _cache_lock:
|
||||
_cache[cache_key] = (now, (sys_prompt, usr_prompt))
|
||||
return sys_prompt, usr_prompt
|
||||
return sys_prompt, _render_user(usr_prompt, image_url, ocr_text)
|
||||
|
||||
|
||||
def resolve_fast_prompt(image_url: str = "", ocr_text: str = "") -> tuple[str, str]:
|
||||
return _resolve("fast", image_url, ocr_text)
|
||||
|
||||
|
||||
def resolve_pro_prompt(image_url: str = "", ocr_text: str = "") -> tuple[str, str]:
|
||||
return _resolve("pro", image_url, ocr_text)
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 图片分析主路径:每图并行 OCR(火山MediaKit,未配置时自动跳过)+ qwen3.8-flash JSON VLM,
|
||||
失败时单次 qwen3.7-plus 兜底。
|
||||
"""V2 图片分析主路径(v8 叙述优先):每图并行 OCR(火山 MediaKit,未配置自动跳过)
|
||||
+ fast VLM 强约束 JSON;失败时单次 pro VLM 兜底。
|
||||
|
||||
架构(灵应10-05确认):
|
||||
- 唯一后端:阿里云百炼 DashScope,qwen3.8-flash 做快速路径、qwen3.7-plus 做兜底
|
||||
- 主力:单图2路并行(OCR + fast VLM),外层N图全并发(workers=8)
|
||||
- 兜底:单次 pro VLM 调用,无竞速/重试/复杂超时
|
||||
- 输出 dict 格式与旧版完全一致,下游零改动
|
||||
架构:
|
||||
- 单图 2 路并行(OCR + fast VLM),外层 N 图全并发(workers=8);
|
||||
- 兜底单次 pro VLM,无竞速/复杂重试;
|
||||
- 输出统一为 5 字段 image dict(type/name/brand/has_person/summary_markdown)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,36 +20,24 @@ from . import assembler, ocr_volc, vlm_fallback, vlm_fast_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 超时(可通过环境变量覆盖)
|
||||
_IMG_WORKERS = int(os.environ.get("VISION_V2_IMG_WORKERS", "8"))
|
||||
_FAST_TIMEOUT = float(os.environ.get("VISION_V2_FAST_TIMEOUT", "20"))
|
||||
_FAST_JSON_TIMEOUT = float(os.environ.get("VISION_V2_FAST_JSON_TIMEOUT", "20"))
|
||||
_OCR_TIMEOUT = float(os.environ.get("VISION_V2_OCR_TIMEOUT", "6"))
|
||||
_PRO_TIMEOUT = float(os.environ.get("VISION_V2_PRO_TIMEOUT", "45"))
|
||||
|
||||
_FALLBACK_RESULT = {
|
||||
"name": "未识别",
|
||||
"brand": "无法判断",
|
||||
"category": "非产品图",
|
||||
"appearance": "无法判断",
|
||||
"packaging": "无法判断",
|
||||
"text_on_package": [],
|
||||
"key_features": ["无法判断"],
|
||||
"scene": "通用",
|
||||
"mood": "",
|
||||
"portrait_prompt": "无法判断",
|
||||
"summary": "未识别",
|
||||
}
|
||||
|
||||
def _is_usable(r: dict[str, Any] | None) -> bool:
|
||||
if not isinstance(r, dict):
|
||||
return False
|
||||
return bool((r.get("summary_markdown") or "").strip())
|
||||
|
||||
|
||||
def _is_usable(r: dict[str, Any]) -> bool:
|
||||
pp = (r.get("portrait_prompt") or "").strip()
|
||||
if pp and pp not in ("无人像", "无法判断", "未识别"):
|
||||
return True
|
||||
name = (r.get("name") or "").strip()
|
||||
if name and name not in ("未识别", "无法判断", "未知"):
|
||||
return True
|
||||
return False
|
||||
def _basic_failure(ocr_result: list[str], fast_elapsed: float, source: str) -> dict[str, Any]:
|
||||
image = assembler.assemble_result(-1, {}, ocr_result)
|
||||
image["_source"] = source
|
||||
image["_fast_elapsed"] = round(fast_elapsed, 2)
|
||||
return image
|
||||
|
||||
|
||||
def analyze_image_v2(idx: int, img_url: str) -> dict[str, Any]:
|
||||
@@ -58,7 +45,6 @@ def analyze_image_v2(idx: int, img_url: str) -> dict[str, Any]:
|
||||
|
||||
fj_result: dict[str, Any] | None = None
|
||||
ocr_result: list[str] = []
|
||||
fast_elapsed = 0.0
|
||||
pool = ThreadPoolExecutor(max_workers=2)
|
||||
f_fj = pool.submit(vlm_fast_json.call_fast_json, img_url, timeout=_FAST_JSON_TIMEOUT)
|
||||
f_ocr = pool.submit(ocr_volc.call_ocr, img_url, timeout=_OCR_TIMEOUT)
|
||||
@@ -66,7 +52,7 @@ def analyze_image_v2(idx: int, img_url: str) -> dict[str, Any]:
|
||||
for fut in as_completed([f_fj, f_ocr], timeout=_FAST_TIMEOUT):
|
||||
try:
|
||||
res = fut.result(timeout=1)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[vision.v2] 图片 #%d 子任务异常: %s", idx, e)
|
||||
continue
|
||||
if fut is f_fj and isinstance(res, dict):
|
||||
@@ -80,37 +66,24 @@ def analyze_image_v2(idx: int, img_url: str) -> dict[str, Any]:
|
||||
logger.warning("[vision.v2] 图片 #%d fast路径超时(%.0fs),走pro兜底", idx, _FAST_TIMEOUT)
|
||||
finally:
|
||||
fast_elapsed = time.time() - t0
|
||||
pool.shutdown(wait=False) # 不等待未完成的线程,避免计时膨胀
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
if fj_result:
|
||||
assembled = assembler.assemble_result(idx, fj_result, ocr_result)
|
||||
if _is_usable(assembled):
|
||||
assembled["_fast_elapsed"] = round(fast_elapsed, 2)
|
||||
logger.info(
|
||||
"[vision.v2] 图片 #%d fast命中 elapsed=%.2fs pp=%s",
|
||||
idx,
|
||||
fast_elapsed,
|
||||
(assembled.get("portrait_prompt") or "")[:40],
|
||||
)
|
||||
logger.info("[vision.v2] 图片 #%d fast命中 elapsed=%.2fs", idx, fast_elapsed)
|
||||
return assembled
|
||||
|
||||
pro_t0 = time.time()
|
||||
pro_result = vlm_fallback.call_pro_vlm(img_url, idx, timeout=_PRO_TIMEOUT)
|
||||
if pro_result and _is_usable(pro_result):
|
||||
pro_result = vlm_fallback.call_pro_vlm(img_url, idx, ocr_hint=ocr_result, timeout=_PRO_TIMEOUT)
|
||||
if _is_usable(pro_result):
|
||||
pro_result["_fallback_used"] = True
|
||||
pro_result["_fast_elapsed"] = round(fast_elapsed, 2)
|
||||
pro_result["_pro_elapsed"] = round(time.time() - pro_t0, 2)
|
||||
if ocr_result and not pro_result.get("text_on_package"):
|
||||
pro_result["text_on_package"] = ocr_result[:8]
|
||||
logger.info("[vision.v2] 图片 #%d pro兜底命中 total=%.2fs", idx, time.time() - t0)
|
||||
return pro_result
|
||||
|
||||
logger.warning("[vision.v2] 图片 #%d 全路径失败 elapsed=%.2fs", idx, time.time() - t0)
|
||||
out = dict(_FALLBACK_RESULT)
|
||||
out["_source"] = "v2_all_failed"
|
||||
out["text_on_package"] = ocr_result[:8]
|
||||
out["_fast_elapsed"] = round(fast_elapsed, 2)
|
||||
return out
|
||||
return _basic_failure(ocr_result, fast_elapsed, "v2_all_failed")
|
||||
|
||||
|
||||
def analyze_images_v2(img_urls: list[str]) -> list[dict[str, Any]]:
|
||||
@@ -133,14 +106,19 @@ def analyze_images_v2(img_urls: list[str]) -> list[dict[str, Any]]:
|
||||
idx = future_to_idx[fut]
|
||||
try:
|
||||
results[idx] = fut.result()
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[vision.v2] 图片 #%d future异常: %s", idx, e, exc_info=True)
|
||||
r = dict(_FALLBACK_RESULT)
|
||||
r["_source"] = "v2_future_exception"
|
||||
results[idx] = r
|
||||
results[idx] = assembler.assemble_result(idx, {}, [])
|
||||
results[idx]["_source"] = "v2_future_exception" # type: ignore[index]
|
||||
|
||||
elapsed = time.time() - t0
|
||||
succ = sum(1 for r in results if r and _is_usable(r))
|
||||
succ = sum(1 for r in results if _is_usable(r))
|
||||
fb = sum(1 for r in results if r and r.get("_fallback_used"))
|
||||
logger.info("[vision.v2] 完成 n=%d usable=%d pro_fallback=%d elapsed=%.2fs", len(img_urls), succ, fb, elapsed)
|
||||
return [r for r in results if r is not None]
|
||||
logger.info(
|
||||
"[vision.v2] 完成 n=%d usable=%d pro_fallback=%d elapsed=%.2fs",
|
||||
len(img_urls),
|
||||
succ,
|
||||
fb,
|
||||
elapsed,
|
||||
)
|
||||
return [r for r in results if r is not None] # type: ignore[misc]
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 兜底路径:image_analysis(默认 qwen-vl-plus 视觉模型,fallback qwen3.7-plus / DashScope)单图调用。
|
||||
"""V2 兜底路径:vision client(fallback 变体)单图调用,走 v8 叙述优先 prompt。
|
||||
|
||||
fast_json 超时/返回非 JSON/识别为空时,本路径单次调用兜底。
|
||||
设计要点:
|
||||
- 通过 ai_router.get_vision_client() 获取 DoubaoClient 实例,不再自己拼 httpx 请求
|
||||
- enable_thinking=False + response_format=json_object
|
||||
- system prompt 优先读后台 viral_video_prompt_templates 配置,DB不可用时fallback到硬编码JSON schema
|
||||
- max_tokens 不传,使用 client 中 capability 的 DB 配置(避免硬编码截断 JSON)
|
||||
- timeout=30s
|
||||
- 返回 dict 统一走 assembler.assemble_result 组装,与 fast 路径输出格式完全一致
|
||||
fast 超时/非 JSON/为空时单次调用;输出统一走 assembler.assemble_result 组装,
|
||||
与 fast 路径同为 5 字段 image dict。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,11 +22,12 @@ def call_pro_vlm(
|
||||
img_url: str,
|
||||
idx: int,
|
||||
*,
|
||||
ocr_hint: list[str] | None = None,
|
||||
timeout: int = _DEFAULT_TIMEOUT,
|
||||
max_tokens: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""max_tokens 默认 None:不显式传参,使用 client 内 capability 的 DB 配置。"""
|
||||
t0 = time.time()
|
||||
ocr_text = "、".join(t for t in (ocr_hint or []) if t)[:200]
|
||||
|
||||
try:
|
||||
from packages.shared.ai_router import ai_router
|
||||
@@ -41,13 +36,13 @@ def call_pro_vlm(
|
||||
if not client or not client.is_available:
|
||||
logger.warning("[vision.v2] pro vision client 不可用,跳过")
|
||||
return None
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[vision.v2] ai_router 获取失败: %s", e)
|
||||
return None
|
||||
|
||||
system_prompt, user_prompt = _prompt.resolve_pro_prompt()
|
||||
system_prompt, user_prompt = _prompt.resolve_pro_prompt(img_url, ocr_text)
|
||||
|
||||
messages = [
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
@@ -61,31 +56,31 @@ def call_pro_vlm(
|
||||
try:
|
||||
call_kwargs: dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"images": None, # 图片已在 messages 中
|
||||
"images": None,
|
||||
"temperature": 0.3,
|
||||
"timeout": timeout,
|
||||
"enable_thinking": False,
|
||||
"response_format": {"type": "json_object"},
|
||||
"max_tokens": max_tokens if max_tokens is not None else 4000,
|
||||
}
|
||||
# pro fallback:显式4000 tokens给复杂门店图留足空间
|
||||
call_kwargs["max_tokens"] = max_tokens if max_tokens is not None else 4000
|
||||
|
||||
from .json_utils import extract_json_object
|
||||
|
||||
raw = None
|
||||
obj = None
|
||||
for _outer in range(2):
|
||||
kw = dict(call_kwargs)
|
||||
if _outer == 1:
|
||||
kw.pop("response_format", None)
|
||||
msgs2 = [dict(messages[0]), dict(messages[1])]
|
||||
cont = [dict(c) for c in list(msgs2[1]["content"])]
|
||||
cont[-1] = {"type": "text", "text": user_prompt + "\n严格只输出JSON对象,不要解释或markdown。"}
|
||||
cont = [dict(c) for c in msgs2[1]["content"]]
|
||||
cont[-1] = {
|
||||
"type": "text",
|
||||
"text": user_prompt + "\n严格只输出JSON对象,不要解释或markdown。",
|
||||
}
|
||||
msgs2[1] = {"role": "user", "content": cont}
|
||||
kw["messages"] = msgs2
|
||||
raw = client.vision_completion(**kw)
|
||||
if not raw:
|
||||
logger.warning("[vision.v2] pro 返回空 outer=%s", _outer)
|
||||
continue
|
||||
obj = extract_json_object(raw)
|
||||
if obj is not None:
|
||||
@@ -97,20 +92,18 @@ def call_pro_vlm(
|
||||
logger.warning("[vision.v2] pro 两次均未得到JSON elapsed=%.1fs", elapsed)
|
||||
return None
|
||||
if obj.get("_partial"):
|
||||
logger.warning("[vision.v2] pro 返回截断JSON(partial) elapsed=%.1fs", elapsed)
|
||||
logger.info(
|
||||
"[vision.v2] pro 完成 model=%s elapsed=%.1fs type=%s",
|
||||
client.model,
|
||||
elapsed,
|
||||
obj.get("type"),
|
||||
)
|
||||
logger.warning("[vision.v2] pro 截断JSON(partial) elapsed=%.1fs", elapsed)
|
||||
|
||||
# 通过assembler统一组装,兼容v4嵌套schema和旧扁平schema
|
||||
result = assembler.assemble_result(idx, obj, [])
|
||||
result = assembler.assemble_result(idx, obj, ocr_hint or [])
|
||||
result["_source"] = "vlm_pro"
|
||||
result["_fallback_used"] = True
|
||||
logger.info("[vision.v2] pro 完成 model=%s elapsed=%.1fs", client.model, elapsed)
|
||||
return result
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
logger.warning("[vision.v2] pro 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[vision.v2] pro 异常 elapsed=%.1fs err=%s",
|
||||
time.time() - t0,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 快速路径:image_analysis capability(默认 qwen-vl-plus 视觉模型 / DashScope)强约束 JSON-only 调用。
|
||||
"""V2 快速路径:vision client(默认 image_analysis 能力)强约束 JSON-only 调用。
|
||||
|
||||
目标:替代"人体属性/商品检测/图像标签"三个火山不存在的专用云端 API。
|
||||
设计要点:
|
||||
- 通过 ai_router.get_vision_client() 获取 DoubaoClient 实例,不再自己拼 httpx 请求
|
||||
- enable_thinking=False 关闭推理链(reasoning 是延迟主因)
|
||||
- response_format=json_object 强约束JSON输出
|
||||
- system prompt 优先读后台 viral_video_prompt_templates 配置,DB不可用时fallback到硬编码JSON schema
|
||||
- max_tokens 不传,使用 client 中 capability 的 DB 配置(避免硬编码截断 JSON)
|
||||
- temperature=0.1(稳定输出 JSON)
|
||||
- timeout=15s(失败由外层走 pro 兜底)
|
||||
要点:
|
||||
- 通过 ai_router.get_vision_client() 获取 client;
|
||||
- enable_thinking=False 关闭推理链,response_format=json_object 强约束 JSON;
|
||||
- system/user prompt 优先读后台模板(v8 叙述优先),DB 不可用时用 prompts.py 默认;
|
||||
- temperature=0.1(稳定输出 JSON);两次尝试(第二次去 json_object 约束)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,11 +27,6 @@ def call_fast_json(
|
||||
timeout: int = _DEFAULT_TIMEOUT,
|
||||
max_tokens: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""调用 vision client 返回结构化 dict;失败/非 JSON 返回 None。
|
||||
|
||||
max_tokens 默认 None:不显式传参,使用 client 内 capability 的 DB 配置;
|
||||
显式传入时作为覆盖。
|
||||
"""
|
||||
t0 = time.time()
|
||||
|
||||
try:
|
||||
@@ -45,13 +36,13 @@ def call_fast_json(
|
||||
if not client or not client.is_available:
|
||||
logger.warning("[vision.v2] vision client 不可用,跳过 fast_json")
|
||||
return None
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[vision.v2] ai_router 获取失败: %s", e)
|
||||
return None
|
||||
|
||||
system_prompt, user_prompt = _prompt.resolve_fast_prompt()
|
||||
system_prompt, user_prompt = _prompt.resolve_fast_prompt(img_url, "")
|
||||
|
||||
messages = [
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
@@ -65,7 +56,7 @@ def call_fast_json(
|
||||
try:
|
||||
call_kwargs: dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"images": None, # 图片已在 messages 中
|
||||
"images": None,
|
||||
"temperature": 0.1,
|
||||
"timeout": timeout,
|
||||
"enable_thinking": False,
|
||||
@@ -74,50 +65,47 @@ def call_fast_json(
|
||||
if max_tokens is not None:
|
||||
call_kwargs["max_tokens"] = max_tokens
|
||||
|
||||
# 双重防护:第1次正常调用;第2次去掉json_object强约束(部分模型在该约束下
|
||||
# 反而幻觉),并加严格指令。解析全部走 json_utils,截断partial产物可用。
|
||||
from .json_utils import extract_json_object
|
||||
|
||||
raw = None
|
||||
obj = None
|
||||
for _outer in range(2):
|
||||
kw = dict(call_kwargs)
|
||||
if _outer == 1:
|
||||
kw.pop("response_format", None)
|
||||
msgs2 = [dict(messages[0]), dict(messages[1])]
|
||||
cont = list(msgs2[1]["content"])
|
||||
cont = [dict(c) for c in cont]
|
||||
cont[-1] = {"type": "text", "text": user_prompt + "\n严格只输出JSON对象,不要解释或markdown。"}
|
||||
cont = [dict(c) for c in msgs2[1]["content"]]
|
||||
cont[-1] = {
|
||||
"type": "text",
|
||||
"text": user_prompt + "\n严格只输出JSON对象,不要解释或markdown。",
|
||||
}
|
||||
msgs2[1] = {"role": "user", "content": cont}
|
||||
kw["messages"] = msgs2
|
||||
raw = client.vision_completion(**kw)
|
||||
if not raw:
|
||||
logger.warning("[vision.v2] fast_json 返回空 outer=%s", _outer)
|
||||
continue
|
||||
obj = extract_json_object(raw)
|
||||
if obj is not None:
|
||||
break
|
||||
logger.warning(
|
||||
"[vision.v2] fast_json 非JSON(100字) outer=%s: %s",
|
||||
_outer,
|
||||
raw[:100],
|
||||
)
|
||||
logger.warning("[vision.v2] fast_json 非JSON(100字) outer=%s: %s", _outer, raw[:100])
|
||||
|
||||
elapsed = time.time() - t0
|
||||
if obj is None:
|
||||
logger.warning("[vision.v2] fast_json 两次均未得到JSON elapsed=%.1fs", elapsed)
|
||||
return None
|
||||
if obj.get("_partial"):
|
||||
logger.warning("[vision.v2] fast_json 返回截断JSON(partial) elapsed=%.1fs", elapsed)
|
||||
logger.warning("[vision.v2] fast_json 截断JSON(partial) elapsed=%.1fs", elapsed)
|
||||
logger.info(
|
||||
"[vision.v2] fast_json 完成 model=%s elapsed=%.1fs has_person=%s type=%s",
|
||||
"[vision.v2] fast_json 完成 model=%s elapsed=%.1fs type=%s",
|
||||
client.model,
|
||||
elapsed,
|
||||
obj.get("has_person"),
|
||||
obj.get("type"),
|
||||
)
|
||||
return obj
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
logger.warning("[vision.v2] fast_json 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[vision.v2] fast_json 异常 elapsed=%.1fs err=%s",
|
||||
time.time() - t0,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -311,3 +311,10 @@ GPU_ENCODE_CRF=23
|
||||
GPU_ENCODE_FALLBACK_CPU=true
|
||||
GPU_ENCODE_MEZZANINE_TRANSPORT=oss
|
||||
GPU_ENCODE_OSS_TMP_PREFIX=tmp/gpu-mezzanine/
|
||||
|
||||
# ==================== Ditto 蚂蚁数字人口型 ====================
|
||||
# 注意:这些值必须写死在模板里(不是 CI Secret),否则每次 CI 重新渲染 .env 都会被丢弃,
|
||||
# 导致 staging 发版后 Ditto 口型服务静默降级到 GPU/MediaKit(P0 防复发)。
|
||||
USE_DITTO_LIPSYNC=true
|
||||
DITTO_API_BASE_URL=http://100.76.80.23:8000
|
||||
DITTO_DEFAULT_VIDEO_URL=https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/uploads/default_avatar.mp4
|
||||
|
||||
@@ -40,7 +40,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
@@ -961,6 +961,7 @@ class ViralVideoJobModel(Base):
|
||||
# v1.5 音频/视频参数
|
||||
voice_id = Column(String(200), nullable=False, default="")
|
||||
voice_source = Column(String(20), nullable=False, default="")
|
||||
language = Column(String(20), nullable=False, default="zh-CN")
|
||||
video_ratio = Column(String(10), nullable=False, default="9:16")
|
||||
video_model = Column(String(100), nullable=False, default="")
|
||||
# 结果与状态
|
||||
@@ -1019,3 +1020,20 @@ class ViralVideoPromptTemplateModel(Base):
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class SystemSettingModel(Base):
|
||||
"""系统配置表 ORM 模型(#2246:后台可配置项,表已手工存在于 staging)."""
|
||||
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
setting_key = Column(String(100), nullable=False, unique=True)
|
||||
setting_value = Column(Text, nullable=True)
|
||||
setting_type = Column(String(20), nullable=False)
|
||||
description = Column(String(255), nullable=False, default="", server_default="")
|
||||
is_public = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
updated_by = Column(String(36), nullable=True)
|
||||
category = Column(String(50), nullable=False, default="general", server_default="general")
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""system_settings 表 SQLAlchemy Repository — #2246."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import SystemSettingModel
|
||||
from packages.domain.system_setting import SystemSetting
|
||||
|
||||
|
||||
class SQLAlchemySystemSettingRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def get_by_key(self, setting_key: str) -> SystemSetting | None:
|
||||
model = self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting_key).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def list_all(self, category: str | None = None) -> list[SystemSetting]:
|
||||
query = self.session.query(SystemSettingModel)
|
||||
if category is not None:
|
||||
query = query.filter(SystemSettingModel.category == category)
|
||||
return [self._to_domain(m) for m in query.all()]
|
||||
|
||||
def upsert(self, setting: SystemSetting) -> SystemSetting:
|
||||
model = (
|
||||
self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting.setting_key).first()
|
||||
)
|
||||
if model is None:
|
||||
model = SystemSettingModel(id=setting.id)
|
||||
self.session.add(model)
|
||||
model.setting_key = setting.setting_key
|
||||
model.setting_value = setting.setting_value
|
||||
model.setting_type = setting.setting_type
|
||||
model.description = setting.description
|
||||
model.is_public = setting.is_public
|
||||
model.category = setting.category
|
||||
model.updated_by = setting.updated_by
|
||||
self.session.commit()
|
||||
return setting
|
||||
|
||||
def delete_by_key(self, setting_key: str) -> bool:
|
||||
model = self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting_key).first()
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: SystemSettingModel) -> SystemSetting:
|
||||
return SystemSetting(
|
||||
id=model.id,
|
||||
setting_key=model.setting_key,
|
||||
setting_value=model.setting_value,
|
||||
setting_type=model.setting_type,
|
||||
description=model.description or "",
|
||||
is_public=bool(model.is_public),
|
||||
category=model.category or "general",
|
||||
updated_by=model.updated_by,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
@@ -49,6 +49,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
style_template_id=getattr(model, "style_template_id", "") or "",
|
||||
voice_id=getattr(model, "voice_id", "") or "",
|
||||
voice_source=getattr(model, "voice_source", "") or "",
|
||||
language=getattr(model, "language", "zh-CN") or "zh-CN",
|
||||
video_ratio=getattr(model, "video_ratio", "9:16") or "9:16",
|
||||
video_model=getattr(model, "video_model", "") or "",
|
||||
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
|
||||
@@ -102,6 +103,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
style_template_id=job.style_template_id,
|
||||
voice_id=job.voice_id,
|
||||
voice_source=job.voice_source,
|
||||
language=getattr(job, "language", "zh-CN") or "zh-CN",
|
||||
video_ratio=job.video_ratio,
|
||||
video_model=job.video_model,
|
||||
status=job.status,
|
||||
@@ -169,6 +171,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
model.style_template_id = job.style_template_id
|
||||
model.voice_id = job.voice_id or ""
|
||||
model.voice_source = job.voice_source or ""
|
||||
model.language = getattr(job, "language", "zh-CN") or "zh-CN"
|
||||
model.video_ratio = job.video_ratio or "9:16"
|
||||
model.video_model = job.video_model or ""
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -3,10 +3,24 @@
|
||||
使用 bcrypt 安全存储密码
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
|
||||
# bcrypt 只对前 72 字节有效,且 bcrypt>=4.1 会对超长输入直接抛 ValueError。
|
||||
# 超长密码先做一次 SHA-256(定长 hex),再交给 bcrypt,
|
||||
# 既绕过长度限制又保持对超长不同密码的区分度。
|
||||
_BCRYPT_MAX_BYTES = 72
|
||||
|
||||
|
||||
def _prepare_password_bytes(password: str) -> bytes:
|
||||
raw = password.encode("utf-8")
|
||||
if len(raw) > _BCRYPT_MAX_BYTES:
|
||||
return hashlib.sha256(raw).hexdigest().encode("utf-8")
|
||||
return raw
|
||||
|
||||
|
||||
from packages.domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort
|
||||
|
||||
|
||||
@@ -42,8 +56,8 @@ class PasswordHasher(PasswordHasherPort):
|
||||
if not password:
|
||||
raise ValueError("Password cannot be empty")
|
||||
|
||||
# bcrypt 需要 bytes
|
||||
password_bytes = password.encode("utf-8")
|
||||
# bcrypt 需要 bytes(超长密码先 SHA-256 以兼容 72 字节限制)
|
||||
password_bytes = _prepare_password_bytes(password)
|
||||
|
||||
# 生成 salt 并哈希
|
||||
salt = bcrypt.gensalt(rounds=self.rounds)
|
||||
@@ -67,7 +81,7 @@ class PasswordHasher(PasswordHasherPort):
|
||||
return False
|
||||
|
||||
try:
|
||||
password_bytes = password.encode("utf-8")
|
||||
password_bytes = _prepare_password_bytes(password)
|
||||
hashed_bytes = hashed_password.encode("utf-8")
|
||||
|
||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Ditto LLM 情绪分析服务 — #2076 后续:根据文案生成 emo_timeline.
|
||||
|
||||
职责:
|
||||
1. 分句:先按 。!?; 句末标点切分;整段无句末标点且超长时按逗号等语义停顿二次切分
|
||||
2. 调 DoubaoClient.chat_completion 分析每句表情(emo: 0-7 八种全部开放, intensity: 0-1)
|
||||
3. 结果 LRU 缓存(文案 hash → 情绪列表)
|
||||
4. LLM 失败/超时/格式错 → 返回空列表(降级中性表情,不阻塞生成)
|
||||
5. TTS 完成后按字数比例或 sentence_timings 对齐成秒级 timeline
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 表情常量 ─────────────────────────────────────────────────────
|
||||
EMO_ANGER = 0
|
||||
EMO_DISGUST = 1
|
||||
EMO_FEAR = 2
|
||||
EMO_HAPPY = 3
|
||||
EMO_NEUTRAL = 4
|
||||
EMO_SAD = 5
|
||||
EMO_SURPRISE = 6
|
||||
EMO_CONTEMPT = 7
|
||||
ALLOWED_EMOS = set(range(8)) # 8 种表情全部开放(0-7);超出范围的非法值仍会被降级为中性
|
||||
|
||||
# ── 分句正则 ─────────────────────────────────────────────────────
|
||||
_SENT_SPLIT_RE = re.compile(r"(?<=[。!?;!?;])\s*")
|
||||
# 二次切分:逗号/分号/顿号/冒号等语义停顿(lookbehind 保留标点在前句末尾)
|
||||
_CLAUSE_SPLIT_RE = re.compile(r"(?<=[,,、::])")
|
||||
# 整段无句末标点且超过该长度时才做逗号二次切分
|
||||
_SINGLE_SENTENCE_THRESHOLD = 30
|
||||
# 二次切分后过短片段(<5 字,如"家人们,")并入相邻句避免切太碎;
|
||||
# 4 字以上语义完整的短句(如"心中有爱,")保留独立成段
|
||||
_MIN_CLAUSE_LEN = 5
|
||||
|
||||
# ── 默认 prompt 模板文件路径 ──────────────────────────────────────
|
||||
_DEFAULT_PROMPT_PATH = Path(__file__).parent / "prompts" / "ditto_emotion.txt"
|
||||
|
||||
|
||||
def _load_default_prompt() -> str:
|
||||
try:
|
||||
return _DEFAULT_PROMPT_PATH.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
# 文件不存在时用极简兜底
|
||||
return (
|
||||
"分析文案每句话表情,输出JSON数组:"
|
||||
'[{"text":"句子","emo":4,"intensity":0.2}],emo:0-7八种表情全部开放,'
|
||||
"至少30%句子用非中性表情。\n【文案】\n{文案}"
|
||||
)
|
||||
|
||||
|
||||
# ── 数据结构 ─────────────────────────────────────────────────────
|
||||
class EmotionSegment:
|
||||
"""单句情绪结果(LLM 输出的原始结构)."""
|
||||
|
||||
__slots__ = ("text", "emo", "intensity")
|
||||
|
||||
def __init__(self, text: str, emo: int, intensity: float):
|
||||
self.text = text
|
||||
self.emo = emo
|
||||
self.intensity = intensity
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"text": self.text, "emo": self.emo, "intensity": self.intensity}
|
||||
|
||||
|
||||
class EmotionTimelineEntry:
|
||||
"""对齐到音频时间轴后的情绪片段(传给 Ditto)."""
|
||||
|
||||
__slots__ = ("start", "end", "emo", "intensity")
|
||||
|
||||
def __init__(self, start: float, end: float, emo: int, intensity: float):
|
||||
self.start = round(start, 2)
|
||||
self.end = round(end, 2)
|
||||
self.emo = emo
|
||||
self.intensity = round(intensity, 2)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"start": self.start,
|
||||
"end": self.end,
|
||||
"emo": self.emo,
|
||||
"intensity": self.intensity,
|
||||
}
|
||||
|
||||
|
||||
# ── 分句 ─────────────────────────────────────────────────────────
|
||||
def _split_by_clauses(sentence: str) -> list[str]:
|
||||
"""按逗号/分号等语义停顿二次切分;过短片段(<5 字)并入相邻句,避免切太碎.
|
||||
|
||||
每句尽量控制在 10-50 字左右(50 字为软上限,无更细停顿点时保持语义完整不硬切)。
|
||||
"""
|
||||
raw = [c for c in _CLAUSE_SPLIT_RE.split(sentence) if c and c.strip()]
|
||||
if len(raw) <= 1:
|
||||
return [sentence]
|
||||
merged: list[str] = []
|
||||
buf = ""
|
||||
for clause in raw:
|
||||
if not buf:
|
||||
buf = clause
|
||||
continue
|
||||
if len(buf) < _MIN_CLAUSE_LEN:
|
||||
buf += clause
|
||||
continue
|
||||
merged.append(buf)
|
||||
buf = clause
|
||||
if buf:
|
||||
if merged and len(buf) < _MIN_CLAUSE_LEN:
|
||||
merged[-1] += buf
|
||||
else:
|
||||
merged.append(buf)
|
||||
return [m.strip() for m in merged if m.strip()]
|
||||
|
||||
|
||||
def split_sentences(text: str) -> list[str]:
|
||||
"""按中文句末标点切分,过滤空串.
|
||||
|
||||
Bug1 修复:若按句末标点切分后只有 1 段且文本超过 30 字(文案全是逗号、
|
||||
结尾无标点),再按逗号/分号等语义停顿做二次切分,避免整段只返回 1 个
|
||||
segment 导致 LLM 只能对整段打一个表情。
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
parts = [p.strip() for p in _SENT_SPLIT_RE.split(text.strip()) if p and p.strip()]
|
||||
if len(parts) <= 1 and parts and len(parts[0]) > _SINGLE_SENTENCE_THRESHOLD:
|
||||
parts = _split_by_clauses(parts[0])
|
||||
return parts
|
||||
|
||||
|
||||
# ── 解析 LLM 返回的 JSON ─────────────────────────────────────────
|
||||
def _parse_emotion_json(raw: str) -> list[EmotionSegment]:
|
||||
"""解析 LLM 返回,容错处理:
|
||||
- 去掉 markdown 代码块包裹
|
||||
- 只取第一个 JSON 数组
|
||||
- 逐行校验 emo/intensity 合法性,过滤无效项
|
||||
"""
|
||||
if not raw:
|
||||
return []
|
||||
text = raw.strip()
|
||||
# 去掉 ```json ... ``` 包裹
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
# 找第一个 [ 到最后一个 ]
|
||||
lb = text.find("[")
|
||||
rb = text.rfind("]")
|
||||
if lb == -1 or rb == -1 or rb <= lb:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(text[lb : rb + 1])
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
|
||||
results: list[EmotionSegment] = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
emo = int(item.get("emo", EMO_NEUTRAL))
|
||||
intensity = float(item.get("intensity", 0.2))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if emo not in ALLOWED_EMOS:
|
||||
emo = EMO_NEUTRAL
|
||||
intensity = max(0.05, min(1.0, intensity))
|
||||
sent_text = str(item.get("text", "")).strip()
|
||||
if not sent_text:
|
||||
continue
|
||||
results.append(EmotionSegment(text=sent_text, emo=emo, intensity=intensity))
|
||||
return results
|
||||
|
||||
|
||||
# ── 时间对齐(按字数比例)────────────────────────────────────────
|
||||
def align_timeline_by_length(
|
||||
segments: list[EmotionSegment],
|
||||
audio_duration: float,
|
||||
) -> list[EmotionTimelineEntry]:
|
||||
"""按各句字数占总字数比例分配 audio_duration 时长."""
|
||||
if not segments or audio_duration <= 0:
|
||||
return []
|
||||
total_chars = sum(len(s.text) for s in segments)
|
||||
if total_chars <= 0:
|
||||
return []
|
||||
entries: list[EmotionTimelineEntry] = []
|
||||
pos = 0.0
|
||||
for i, seg in enumerate(segments):
|
||||
if i == len(segments) - 1:
|
||||
end = audio_duration # 最后一段到结尾,避免浮点误差
|
||||
else:
|
||||
end = pos + (len(seg.text) / total_chars) * audio_duration
|
||||
if end > pos:
|
||||
entries.append(
|
||||
EmotionTimelineEntry(
|
||||
start=pos,
|
||||
end=end,
|
||||
emo=seg.emo,
|
||||
intensity=seg.intensity,
|
||||
)
|
||||
)
|
||||
pos = end
|
||||
return entries
|
||||
|
||||
|
||||
def align_timeline_by_timings(
|
||||
segments: list[EmotionSegment],
|
||||
sentence_timings: list[dict[str, Any]],
|
||||
audio_duration: float,
|
||||
) -> list[EmotionTimelineEntry]:
|
||||
"""使用 TTS sentence_timings 精确对齐(优先方案).
|
||||
|
||||
sentence_timings 格式:[{"start":0.0,"end":1.2,"text":"句子"}, ...]
|
||||
按句序匹配 segments 和 timings,长度不一致时回退到按字数比例。
|
||||
"""
|
||||
if not sentence_timings or len(sentence_timings) != len(segments):
|
||||
return align_timeline_by_length(segments, audio_duration)
|
||||
entries: list[EmotionTimelineEntry] = []
|
||||
for seg, timing in zip(segments, sentence_timings, strict=False):
|
||||
try:
|
||||
start = float(timing.get("start", 0))
|
||||
end = float(timing.get("end", 0))
|
||||
except (TypeError, ValueError):
|
||||
return align_timeline_by_length(segments, audio_duration)
|
||||
if end <= start:
|
||||
continue
|
||||
entries.append(
|
||||
EmotionTimelineEntry(
|
||||
start=start,
|
||||
end=end,
|
||||
emo=seg.emo,
|
||||
intensity=seg.intensity,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
# ── LLM 结果合理性校验(提示词遵循度排查)─────────────────────────
|
||||
# 感叹号或明显情绪关键词出现时,LLM 不应返回全中性/低强度
|
||||
_EMO_SIGNAL_RE = re.compile(r"[!!]|是不是|有没有|居然|竟然|只要|太坑|千万别|免费|赶紧|推荐|惊艳")
|
||||
|
||||
|
||||
def _warn_if_suspicious_all_neutral(text: str, segments: list[EmotionSegment]) -> None:
|
||||
"""文案含明显情绪信号但 LLM 返回全部 emo=4 且 intensity<=0.1 时打 warning 日志."""
|
||||
if not segments:
|
||||
return
|
||||
all_flat = all(s.emo == EMO_NEUTRAL and s.intensity <= 0.1 for s in segments)
|
||||
if all_flat and _EMO_SIGNAL_RE.search(text):
|
||||
logger.warning(
|
||||
"[ditto_emotion] 文案含情绪信号(感叹号/关键词)但 LLM 返回全中性/低强度,"
|
||||
"疑似提示词遵循度差: text=%.80s, segments=%d",
|
||||
text,
|
||||
len(segments),
|
||||
)
|
||||
|
||||
|
||||
# ── LLM 情绪分析服务 ─────────────────────────────────────────────
|
||||
class DittoEmotionService:
|
||||
"""Ditto 情绪分析服务(带 LRU 缓存)."""
|
||||
|
||||
def __init__(self, settings=None):
|
||||
from packages.config import get_api_settings
|
||||
|
||||
self.settings = settings or get_api_settings()
|
||||
self._client = None
|
||||
self._client_api_key: str | None = None
|
||||
|
||||
def _cfg(self, key: str) -> Any:
|
||||
"""优先读后台 system_config,未配置则回退到 settings(env 默认)."""
|
||||
try:
|
||||
from packages.application.system_config_service import get_config
|
||||
|
||||
return get_config(key, getattr(self.settings, key, None))
|
||||
except Exception:
|
||||
return getattr(self.settings, key, None)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._cfg("ditto_emotion_enabled"))
|
||||
|
||||
def _get_prompt_template(self) -> str:
|
||||
"""优先用配置(环境变量),否则读文件."""
|
||||
cfg_prompt = self._cfg("ditto_emotion_prompt") or ""
|
||||
if cfg_prompt.strip():
|
||||
return cfg_prompt.strip()
|
||||
return _load_default_prompt()
|
||||
|
||||
def _cache_key(self, text: str) -> str:
|
||||
return hashlib.md5(text.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
def _get_llm_client(self):
|
||||
"""创建/复用 DoubaoClient;API Key 优先读后台配置(DB),fallback 环境变量。
|
||||
|
||||
后台可能随时改 doubao_api_key,每次调用前对比 key,变化则重建 client。
|
||||
"""
|
||||
api_key = (self._cfg("doubao_api_key") or "").strip()
|
||||
if self._client is None or self._client_api_key != api_key:
|
||||
from packages.shared.ai_client import DoubaoClient
|
||||
|
||||
self._client = DoubaoClient(api_key=api_key)
|
||||
self._client_api_key = api_key
|
||||
return self._client
|
||||
|
||||
def _call_llm(self, text: str) -> list[EmotionSegment]:
|
||||
"""调 LLM 分析情绪,失败返回空列表."""
|
||||
template = self._get_prompt_template()
|
||||
prompt = template.replace("{文案}", text)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
model = self._cfg("ditto_emotion_model") or None
|
||||
temperature = self._cfg("ditto_emotion_temperature")
|
||||
timeout = getattr(self.settings, "ditto_emotion_timeout", 10)
|
||||
max_tokens = getattr(self.settings, "ditto_emotion_max_tokens", 1024)
|
||||
try:
|
||||
client = self._get_llm_client()
|
||||
result = client.chat_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[ditto_emotion] LLM 调用异常: %s", exc)
|
||||
return []
|
||||
if not result:
|
||||
return []
|
||||
segments = _parse_emotion_json(result)
|
||||
if not segments:
|
||||
logger.warning("[ditto_emotion] LLM 返回解析失败: %s", result[:200])
|
||||
else:
|
||||
_warn_if_suspicious_all_neutral(text, segments)
|
||||
return segments
|
||||
|
||||
def analyze(self, text: str) -> list[EmotionSegment]:
|
||||
"""分析文案情绪(带缓存),失败返回空列表."""
|
||||
if not self.enabled or not text or not text.strip():
|
||||
return []
|
||||
key = self._cache_key(text)
|
||||
return _cached_analyze(self, key, text)
|
||||
|
||||
def build_timeline(
|
||||
self,
|
||||
text: str,
|
||||
audio_duration: float,
|
||||
sentence_timings: Optional[list[dict[str, Any]]] = None,
|
||||
) -> str:
|
||||
"""完整流程:分句→LLM分析→时间对齐→序列化为JSON字符串.
|
||||
|
||||
返回: JSON 字符串(可直接传 Ditto emo_timeline 参数);空字符串表示降级中性。
|
||||
"""
|
||||
segments = self.analyze(text)
|
||||
if not segments:
|
||||
return ""
|
||||
if sentence_timings:
|
||||
entries = align_timeline_by_timings(segments, sentence_timings, audio_duration)
|
||||
else:
|
||||
entries = align_timeline_by_length(segments, audio_duration)
|
||||
if not entries:
|
||||
return ""
|
||||
return json.dumps([e.to_dict() for e in entries], ensure_ascii=False)
|
||||
|
||||
|
||||
# ── 模块级 LRU 缓存实例 ─────────────────────────────────────────
|
||||
# 每个 service 实例共享缓存(按 cache_key 区分)
|
||||
@lru_cache(maxsize=512)
|
||||
def _cached_analyze(service: DittoEmotionService, cache_key: str, text: str) -> list[EmotionSegment]:
|
||||
"""LRU 缓存包装:cache_key 由文案 hash 生成,maxsize 从配置读."""
|
||||
# 注意:service 参数仅用于传递调用,缓存由 cache_key 驱动
|
||||
segments = service._call_llm(text)
|
||||
# 如果 LLM 返回空(比如分句数量不匹配),尝试直接对预分句结果分析
|
||||
if not segments:
|
||||
pre_splits = split_sentences(text)
|
||||
if len(pre_splits) >= 1:
|
||||
# 用预分句结果兜底:全中性低强度
|
||||
segments = [EmotionSegment(text=s, emo=EMO_NEUTRAL, intensity=0.1) for s in pre_splits]
|
||||
return segments
|
||||
|
||||
|
||||
_singleton: Optional[DittoEmotionService] = None
|
||||
|
||||
|
||||
def get_ditto_emotion_service() -> DittoEmotionService:
|
||||
global _singleton
|
||||
if _singleton is None:
|
||||
_singleton = DittoEmotionService()
|
||||
return _singleton
|
||||
|
||||
|
||||
def reset_ditto_emotion_service() -> None:
|
||||
"""#2246:后台配置变更后重置单例并清空 LLM 结果 LRU 缓存."""
|
||||
global _singleton
|
||||
_singleton = None
|
||||
_cached_analyze.cache_clear()
|
||||
@@ -0,0 +1,308 @@
|
||||
"""蚂蚁 Ditto 数字人口型 API 客户端 — #2076.
|
||||
|
||||
封装 Ditto FastAPI(部署在 5060Ti GPU 节点,Tailscale 内网可达):
|
||||
- GET /health 健康检查
|
||||
- POST /generate 生成口型视频(同步返回 MP4 流)
|
||||
|
||||
关键特性:
|
||||
- 入参:video_url(人物驱动视频 URL,用户上传优先;未传时用 default_video_url 兜底)
|
||||
+ audio_url(TTS 音频 URL) + script(文案原文)
|
||||
+ emo_timeline(可选,LLM 情绪时间线 JSON 字符串)
|
||||
- 出参:直接返回 video/mp4 字节流(自带音频,无需二次混流)
|
||||
- 429 时指数退避重试(最多 ditto_max_retries 次)
|
||||
- 500/超时/网络不可达视为失败
|
||||
- 输出 MP4 字节流转存到自家 OSS,返回公网 URL
|
||||
|
||||
注意:
|
||||
- 保留 MuseTalk/GPU 路径不变;本服务作为更高优先级的第三条口型路径
|
||||
- video_url 时长对齐(视频<音频时循环延长)在 Celery 任务层用 ffmpeg 预处理
|
||||
- Ditto 输出自带音视频,不需要 GFPGAN 超分,不需要 ffmpeg 音视频混流
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.config import get_api_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DittoError(Exception):
|
||||
"""Ditto API 调用失败."""
|
||||
|
||||
def __init__(self, message: str, code: str = "DittoError", status_code: int = 0):
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DittoResult:
|
||||
"""Ditto 生成结果."""
|
||||
|
||||
video_bytes: bytes
|
||||
video_url: str = "" # 转存 OSS 后填充
|
||||
elapsed_seconds: float = 0.0
|
||||
rtf: float = 0.0 # 实时率(响应头 X-RTF)
|
||||
frames: int = 0 # 帧数(响应头 X-Frames)
|
||||
|
||||
|
||||
class DittoClient:
|
||||
"""蚂蚁 Ditto 数字人口型 API 客户端."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
default_video_url: Optional[str] = None,
|
||||
max_retries: Optional[int] = None,
|
||||
timeout: Optional[int] = None,
|
||||
):
|
||||
s = get_api_settings()
|
||||
self.base_url = (base_url or s.ditto_api_base_url or "").rstrip("/")
|
||||
self.default_video_url = default_video_url or s.ditto_default_video_url or ""
|
||||
self.max_retries = int(max_retries if max_retries is not None else s.ditto_max_retries)
|
||||
self.timeout = int(timeout if timeout is not None else s.ditto_request_timeout)
|
||||
try:
|
||||
from packages.application.system_config_service import get_config
|
||||
|
||||
self.blend_frames = int(get_config("ditto_blend_frames", s.ditto_blend_frames))
|
||||
except Exception:
|
||||
self.blend_frames = int(s.ditto_blend_frames)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""配置是否完整(base_url 必填 + 默认模板视频兜底 URL 有值)。
|
||||
|
||||
注意:即使 is_configured=True,实际生成时优先使用用户上传的 video_url;
|
||||
default_video_url 仅作为用户未上传视频时的兜底。
|
||||
"""
|
||||
return bool(self.base_url) and bool(self.default_video_url)
|
||||
|
||||
def health(self) -> bool:
|
||||
"""健康检查;成功返回 True,失败返回 False(不抛异常)."""
|
||||
if not self.base_url:
|
||||
return False
|
||||
url = f"{self.base_url}/health"
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
resp = client.get(url)
|
||||
ok = resp.status_code == 200
|
||||
if ok:
|
||||
logger.info("[ditto] health check OK: %s", url)
|
||||
else:
|
||||
logger.warning("[ditto] health check status=%d: %s", resp.status_code, url)
|
||||
return ok
|
||||
except Exception as exc:
|
||||
logger.warning("[ditto] health check failed: %s", exc)
|
||||
return False
|
||||
|
||||
def generate(
|
||||
self,
|
||||
*,
|
||||
audio_url: str,
|
||||
script: str,
|
||||
video_url: Optional[str] = None,
|
||||
emo_global: int = 4,
|
||||
use_script_emo: bool = True,
|
||||
blend_frames: Optional[int] = None,
|
||||
emo_timeline: str = "",
|
||||
) -> DittoResult:
|
||||
"""调用 Ditto /generate 接口,返回 MP4 字节流结果.
|
||||
|
||||
Raises DittoError on failure.
|
||||
"""
|
||||
if not self.base_url:
|
||||
raise DittoError("DITTO_API_BASE_URL 未配置", code="ConfigMissing")
|
||||
driver_url = video_url or self.default_video_url
|
||||
if not driver_url:
|
||||
raise DittoError("Ditto 人物模板视频 URL 未配置", code="ConfigMissing")
|
||||
if not audio_url:
|
||||
raise DittoError("audio_url 不能为空", code="InvalidParam")
|
||||
if not script:
|
||||
script = " "
|
||||
|
||||
_blend = blend_frames if blend_frames is not None else self.blend_frames
|
||||
payload = {
|
||||
"video_url": driver_url,
|
||||
"audio_url": audio_url,
|
||||
"script": script,
|
||||
"emo_global": emo_global,
|
||||
"use_script_emo": use_script_emo,
|
||||
"blend_frames": _blend,
|
||||
}
|
||||
if emo_timeline:
|
||||
payload["emo_timeline"] = emo_timeline
|
||||
url = f"{self.base_url}/generate"
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
start = time.monotonic()
|
||||
# 精细化超时:connect=10s(网络不通快速失败),read=120s(最长音频~45s按RTF=2.8推算)
|
||||
_timeout = httpx.Timeout(connect=10.0, read=self.timeout, write=30.0, pool=10.0)
|
||||
with httpx.Client(timeout=_timeout, follow_redirects=True) as client:
|
||||
resp = client.post(url, json=payload)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
if resp.status_code == 429:
|
||||
wait = min(2**attempt, 30)
|
||||
logger.warning(
|
||||
"[ditto] GPU 繁忙 (429),%ds 后重试 (%d/%d)",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
)
|
||||
if attempt >= self.max_retries:
|
||||
raise DittoError(
|
||||
f"Ditto GPU 繁忙,重试 {self.max_retries} 次仍失败",
|
||||
code="BusyRetriesExhausted",
|
||||
status_code=429,
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
|
||||
if resp.status_code != 200:
|
||||
_text = (resp.text or "")[:300]
|
||||
logger.error(
|
||||
"[ditto] generate 失败 status=%d attempt=%d body=%s",
|
||||
resp.status_code,
|
||||
attempt + 1,
|
||||
_text,
|
||||
)
|
||||
if resp.status_code >= 500 and attempt < self.max_retries:
|
||||
time.sleep(min(2**attempt, 15))
|
||||
continue
|
||||
raise DittoError(
|
||||
f"Ditto 返回 {resp.status_code}: {_text}",
|
||||
code="DittoAPIError",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
|
||||
video_bytes = resp.content
|
||||
if not video_bytes or len(video_bytes) < 1024:
|
||||
raise DittoError(
|
||||
f"Ditto 返回内容异常(size={len(video_bytes) if video_bytes else 0})",
|
||||
code="EmptyResponse",
|
||||
)
|
||||
try:
|
||||
rtf = float(resp.headers.get("X-RTF", "0") or 0)
|
||||
except ValueError:
|
||||
rtf = 0.0
|
||||
try:
|
||||
frames = int(resp.headers.get("X-Frames", "0") or 0)
|
||||
except ValueError:
|
||||
frames = 0
|
||||
try:
|
||||
x_time = float(resp.headers.get("X-Time", "0") or 0)
|
||||
if x_time > 0:
|
||||
elapsed = x_time
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"[ditto] generate 成功 size=%d rtf=%.2f frames=%d elapsed=%.1fs attempt=%d",
|
||||
len(video_bytes),
|
||||
rtf,
|
||||
frames,
|
||||
elapsed,
|
||||
attempt + 1,
|
||||
)
|
||||
return DittoResult(
|
||||
video_bytes=video_bytes,
|
||||
elapsed_seconds=elapsed,
|
||||
rtf=rtf,
|
||||
frames=frames,
|
||||
)
|
||||
|
||||
except DittoError:
|
||||
raise
|
||||
except (httpx.ConnectError, httpx.NetworkError, ConnectionError, OSError) as exc:
|
||||
# 网络不通/连接被拒(如 GPU 断网/Tailscale 掉线),不重试,直接快速回退
|
||||
logger.warning("[ditto] 网络不可达 attempt=%d err=%s", attempt + 1, exc)
|
||||
raise DittoError(
|
||||
f"Ditto 网络不可达: {exc}",
|
||||
code="NetworkUnreachable",
|
||||
) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
last_exc = exc
|
||||
logger.warning("[ditto] 请求超时 attempt=%d err=%s", attempt + 1, exc)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(min(2**attempt, 15))
|
||||
continue
|
||||
raise DittoError(
|
||||
f"Ditto 请求超时(read={self.timeout}s),重试耗尽",
|
||||
code="Timeout",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
logger.warning("[ditto] 请求异常 attempt=%d err=%s", attempt + 1, exc)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(min(2**attempt, 10))
|
||||
continue
|
||||
raise DittoError(f"Ditto 调用异常: {exc}", code="NetworkError") from exc
|
||||
|
||||
raise DittoError("Ditto 未知错误", code="Unknown") from last_exc
|
||||
|
||||
def generate_and_persist(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
user_id: str,
|
||||
audio_url: str,
|
||||
script: str,
|
||||
video_url: Optional[str] = None,
|
||||
emo_timeline: str = "",
|
||||
blend_frames: Optional[int] = None,
|
||||
) -> DittoResult:
|
||||
"""调用 generate 并把 MP4 转存到自家 OSS,返回带 video_url 的结果."""
|
||||
result = self.generate(
|
||||
audio_url=audio_url,
|
||||
script=script,
|
||||
video_url=video_url,
|
||||
emo_timeline=emo_timeline,
|
||||
blend_frames=blend_frames,
|
||||
)
|
||||
try:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = f"ditto-output/{user_id}/{job_id}.mp4"
|
||||
public_url = storage.upload_file(
|
||||
io.BytesIO(result.video_bytes),
|
||||
storage_key,
|
||||
content_type="video/mp4",
|
||||
)
|
||||
result.video_url = public_url
|
||||
logger.info(
|
||||
"[ditto] 转存 OSS 完成 job=%s key=%s",
|
||||
job_id,
|
||||
storage_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("[ditto] 转存 OSS 失败 job=%s err=%s", job_id, exc, exc_info=True)
|
||||
raise DittoError(f"Ditto 结果转存 OSS 失败: {exc}", code="StorageError") from exc
|
||||
return result
|
||||
|
||||
|
||||
_ditto_client_singleton: Optional[DittoClient] = None
|
||||
|
||||
|
||||
def get_ditto_client() -> DittoClient:
|
||||
"""获取 DittoClient 单例(简易工厂,便于单测 mock)."""
|
||||
global _ditto_client_singleton
|
||||
if _ditto_client_singleton is None:
|
||||
_ditto_client_singleton = DittoClient()
|
||||
return _ditto_client_singleton
|
||||
|
||||
|
||||
def reset_ditto_client() -> None:
|
||||
"""#2246:后台配置变更后重置 DittoClient 单例."""
|
||||
global _ditto_client_singleton
|
||||
_ditto_client_singleton = None
|
||||
@@ -0,0 +1,42 @@
|
||||
你是一个数字人短视频表情导演。为每句话分配一个最贴切的表情,让数字人表情丰富、有情绪感染力,像真人说话一样有起伏变化。
|
||||
|
||||
【8种表情】
|
||||
0=愤怒:怒斥、骂坑、强烈不满、控诉
|
||||
1=厌恶:嫌弃、吐槽劣质、鄙视套路
|
||||
2=害怕:紧张、警告、危险提示、悬念
|
||||
3=开心:夸赞、推荐、好消息、福利、号召
|
||||
4=中性:陈述事实、客观描述、数据、过渡衔接
|
||||
5=伤心:共情痛点、遗憾、难过、踩坑经历
|
||||
6=惊讶:惊喜、超预期、居然、竟然、只要、数字冲击
|
||||
7=轻蔑:嘲讽、冷笑、拆穿套路、不屑
|
||||
|
||||
【分句说明】
|
||||
- 输入文案已按句末标点预分句;若某句内部仍有明显语义停顿(逗号、分号),你应根据语义进一步拆分成更细的句子分别打分
|
||||
- 若输入没有句末标点,要自己根据语义在逗号处分句
|
||||
|
||||
【强度】0.1几乎无变化 0.3轻微自然 0.5明显情绪 0.7强烈 0.9爆发
|
||||
感叹号结尾0.4-0.7,句号0.2-0.4,问号0.3-0.6。
|
||||
|
||||
【示例1】
|
||||
文案:"家人们!你们是不是经常买东西踩坑?花了大价钱买回来的东西根本没法用!但是今天这款产品真的惊艳到我了!"
|
||||
输出:
|
||||
[{"text":"家人们!","emo":6,"intensity":0.5},{"text":"你们是不是经常买东西踩坑?","emo":5,"intensity":0.3},{"text":"花了大价钱买回来的东西根本没法用!","emo":0,"intensity":0.5},{"text":"但是今天这款产品真的惊艳到我了!","emo":3,"intensity":0.6}]
|
||||
|
||||
【示例2】
|
||||
文案:"做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光。"
|
||||
输出:
|
||||
[{"text":"做公益不一定是一个人去做惊天动地的大事,","emo":4,"intensity":0.2},{"text":"而是一群人扎扎实实的干好身边的小事,","emo":4,"intensity":0.2},{"text":"心中有爱,","emo":3,"intensity":0.4},{"text":"眼里有光。","emo":3,"intensity":0.5}]
|
||||
|
||||
【关键要求】
|
||||
- 每句话都必须独立判断情绪,至少有30%的句子使用非中性(4)表情!感叹号结尾的句子intensity不得低于0.4!
|
||||
- 严禁全部句子都返回中性(4)!严禁全部强度都是0.1!
|
||||
- 情绪要有变化:痛点段伤心或愤怒→转折→卖点开心→福利惊讶→号召开心
|
||||
- 语义优先:根据每句话的实际语义选择表情,不要固定开头结尾情绪
|
||||
- 8种表情全部开放,该愤怒就愤怒(0),该厌恶就厌恶(1),该害怕就害怕(2),该轻蔑就轻蔑(7)
|
||||
- 开头第一句就根据内容定情绪,不要强行用中性开场
|
||||
|
||||
【输出】严格JSON数组,只输出JSON,不要其他文字。
|
||||
[{"text":"原文","emo":3,"intensity":0.5}]
|
||||
|
||||
【文案】
|
||||
{文案}
|
||||
@@ -0,0 +1,189 @@
|
||||
"""系统配置应用服务 — #2246.
|
||||
|
||||
- get_config(key, default) / set_config(...) / list_configs(category)
|
||||
- 首次访问时从 DB 加载并进程内缓存;set_config 后失效缓存
|
||||
- set_config 成功后重置 Ditto 情绪服务与 Ditto 客户端单例(含 LRU 缓存),
|
||||
保证后台改动立即生效;worker 不直接 HTTP 读配置,统一通过本模块。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import session as db_session
|
||||
from packages.adapters.sqlalchemy_impl.system_setting_repository import (
|
||||
SQLAlchemySystemSettingRepository,
|
||||
)
|
||||
from packages.domain.system_setting import (
|
||||
SystemSetting,
|
||||
infer_setting_type,
|
||||
serialize_setting_value,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemConfigService:
|
||||
"""进程内缓存的系统配置服务."""
|
||||
|
||||
def __init__(self, session_factory=None):
|
||||
self._session_factory = session_factory
|
||||
self._cache: dict[str, Any] | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── 会话 ─────────────────────────────────────────────────────
|
||||
def _get_session_factory(self):
|
||||
# 必须运行时读取模块属性:模块导入时 SessionLocal 还是 None,
|
||||
# initialize_database() 之后才被赋值,import 时绑定会拿到旧值。
|
||||
factory = self._session_factory or db_session.SessionLocal
|
||||
if factory is None:
|
||||
raise RuntimeError("数据库会话工厂未初始化")
|
||||
return factory
|
||||
|
||||
# ── 缓存 ─────────────────────────────────────────────────────
|
||||
def _load_cache(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self._cache is not None:
|
||||
return self._cache
|
||||
cache: dict[str, Any] = {}
|
||||
factory = self._get_session_factory()
|
||||
session = factory()
|
||||
try:
|
||||
repo = SQLAlchemySystemSettingRepository(session)
|
||||
for setting in repo.list_all():
|
||||
try:
|
||||
cache[setting.setting_key] = setting.get_typed_value()
|
||||
except Exception as exc: # 损坏配置不阻塞启动
|
||||
logger.warning(
|
||||
"[system_config] 跳过损坏配置 %s: %s",
|
||||
setting.setting_key,
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
self._cache = cache
|
||||
return cache
|
||||
|
||||
def reload(self) -> dict[str, Any]:
|
||||
"""强制重新从 DB 加载配置,返回新缓存."""
|
||||
with self._lock:
|
||||
self._cache = None
|
||||
return self._load_cache()
|
||||
|
||||
def invalidate(self) -> None:
|
||||
with self._lock:
|
||||
self._cache = None
|
||||
|
||||
# ── 读 ───────────────────────────────────────────────────────
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
cache = self._load_cache()
|
||||
return cache.get(key, default)
|
||||
|
||||
def list_configs(self, category: str | None = None) -> list[SystemSetting]:
|
||||
factory = self._get_session_factory()
|
||||
session = factory()
|
||||
try:
|
||||
return SQLAlchemySystemSettingRepository(session).list_all(category)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ── 写 ───────────────────────────────────────────────────────
|
||||
def set_config(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
setting_type: str | None = None,
|
||||
updated_by: str | None = None,
|
||||
*,
|
||||
description: str | None = None,
|
||||
category: str = "general",
|
||||
is_public: bool = False,
|
||||
) -> Any:
|
||||
st = setting_type or infer_setting_type(value)
|
||||
serialized = serialize_setting_value(value, st)
|
||||
|
||||
factory = self._get_session_factory()
|
||||
session = factory()
|
||||
try:
|
||||
repo = SQLAlchemySystemSettingRepository(session)
|
||||
existing = repo.get_by_key(key)
|
||||
if existing is not None:
|
||||
existing.setting_value = serialized
|
||||
existing.setting_type = st
|
||||
if updated_by is not None:
|
||||
existing.updated_by = updated_by
|
||||
if description is not None:
|
||||
existing.description = description
|
||||
setting = existing
|
||||
else:
|
||||
setting = SystemSetting(
|
||||
setting_key=key,
|
||||
setting_value=serialized,
|
||||
setting_type=st,
|
||||
description=description or "",
|
||||
is_public=is_public,
|
||||
category=category,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
repo.upsert(setting)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
self.invalidate()
|
||||
self._reset_ditto_singletons(key)
|
||||
return value
|
||||
|
||||
def delete_config(self, key: str) -> bool:
|
||||
factory = self._get_session_factory()
|
||||
session = factory()
|
||||
try:
|
||||
deleted = SQLAlchemySystemSettingRepository(session).delete_by_key(key)
|
||||
finally:
|
||||
session.close()
|
||||
if deleted:
|
||||
self.invalidate()
|
||||
self._reset_ditto_singletons(key)
|
||||
return deleted
|
||||
|
||||
# ── Ditto 联动 ───────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _reset_ditto_singletons(key: str) -> None:
|
||||
try:
|
||||
from packages.application import ditto_emotion_service as emo_mod
|
||||
from packages.application import ditto_service as ditto_mod
|
||||
|
||||
emo_mod.reset_ditto_emotion_service()
|
||||
ditto_mod.reset_ditto_client()
|
||||
logger.info("[system_config] %s 更新,已重置 Ditto 单例", key)
|
||||
except Exception as exc: # 联动失败不影响配置落库
|
||||
logger.warning("[system_config] 重置 Ditto 单例失败: %s", exc)
|
||||
|
||||
|
||||
_service_singleton: SystemConfigService | None = None
|
||||
|
||||
|
||||
def get_system_config_service() -> SystemConfigService:
|
||||
global _service_singleton
|
||||
if _service_singleton is None:
|
||||
_service_singleton = SystemConfigService()
|
||||
return _service_singleton
|
||||
|
||||
|
||||
def get_config(key: str, default: Any = None) -> Any:
|
||||
"""便捷读取:优先 DB 配置,未配置时返回 default(调用方传 env 值兜底)."""
|
||||
try:
|
||||
return get_system_config_service().get_config(key, default)
|
||||
except Exception as exc:
|
||||
logger.warning("[system_config] 读取 %s 失败,使用默认值: %s", key, exc)
|
||||
return default
|
||||
|
||||
|
||||
def set_config(
|
||||
key: str,
|
||||
value: Any,
|
||||
setting_type: str | None = None,
|
||||
updated_by: str | None = None,
|
||||
) -> Any:
|
||||
return get_system_config_service().set_config(key, value, setting_type=setting_type, updated_by=updated_by)
|
||||
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""爆款视频时长-字数-镜头数统一规范。
|
||||
|
||||
prompt 模板与代码校验共用同一份数据,避免不一致。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# 时长规格:key=duration(秒),value=对应约束
|
||||
DURATION_SPEC: dict[int, dict[str, int]] = {
|
||||
5: {"min_chars": 12, "max_chars": 20, "min_shots": 1, "max_shots": 2},
|
||||
10: {"min_chars": 25, "max_chars": 40, "min_shots": 2, "max_shots": 3},
|
||||
15: {"min_chars": 38, "max_chars": 60, "min_shots": 3, "max_shots": 4},
|
||||
20: {"min_chars": 50, "max_chars": 80, "min_shots": 4, "max_shots": 5},
|
||||
30: {"min_chars": 75, "max_chars": 120, "min_shots": 6, "max_shots": 8},
|
||||
}
|
||||
|
||||
|
||||
def get_duration_spec(dur: int) -> dict[str, int]:
|
||||
"""获取时长规格;clamp 到 [5,30] 并选取最近档位。
|
||||
|
||||
注意:dur=0/falsey 时默认取 15 秒档位(兜底);dur 为正数时按实际值 clamp。
|
||||
"""
|
||||
if dur is None:
|
||||
dur = 15
|
||||
try:
|
||||
d = int(dur)
|
||||
except (TypeError, ValueError):
|
||||
d = 15
|
||||
if d <= 0:
|
||||
d = 15
|
||||
clamped = max(5, min(30, d))
|
||||
nearest = min(DURATION_SPEC.keys(), key=lambda k: abs(k - clamped))
|
||||
return DURATION_SPEC[nearest]
|
||||
|
||||
|
||||
def build_duration_constraint_text(dur: int) -> str:
|
||||
"""生成注入到 LLM user prompt 的字数/镜头数约束文本。"""
|
||||
spec = get_duration_spec(dur)
|
||||
return (
|
||||
f"视频时长{dur}秒。口播总字数严格控制在{spec['min_chars']}~{spec['max_chars']}字之间"
|
||||
f"(中文正常口播语速约2.5~4字/秒,{dur}秒={dur * 4}字为硬上限,宁可少写也不要多写,"
|
||||
f"超长会导致TTS配音超出视频时长而失败)。"
|
||||
f"分镜镜头数{spec['min_shots']}~{spec['max_shots']}个,每个镜头的time_range首尾相接,"
|
||||
f"不得跳秒或重叠,每个镜头时长≥2秒。"
|
||||
)
|
||||
|
||||
|
||||
def get_expected_shot_count(dur: int) -> tuple[int, int]:
|
||||
"""返回 (min_shots, max_shots),供代码校验使用。"""
|
||||
spec = get_duration_spec(dur)
|
||||
return spec["min_shots"], spec["max_shots"]
|
||||
|
||||
|
||||
# ── Seedance 生成默认约束(worker/API 共用,避免硬编码漂移) ──────────────
|
||||
DEFAULT_HARD_CONSTRAINTS: list[str] = [
|
||||
"无字幕、无水印、无任何自动生成文字、无 logo",
|
||||
"严格还原参考图片中的真实场景、门店环境、商品陈列、人物外貌服装特征,不得凭空生成与参考图无关的人物、场景或物品",
|
||||
"同一人物全程保持一致的五官、发型、服装、身材,不得换脸或变形",
|
||||
"口播语音必须在指定时长内自然念完,语速自然,口型与语音同步",
|
||||
"画面流畅无闪烁、无多余肢体、无扭曲变形、无穿模",
|
||||
"色彩自然、曝光正确、电影级质感、高清细节",
|
||||
]
|
||||
|
||||
DEFAULT_NEGATIVE_PROMPTS: list[str] = [
|
||||
"字幕",
|
||||
"自动字幕",
|
||||
"水印",
|
||||
"logo",
|
||||
"图标",
|
||||
"错误文字",
|
||||
"乱码文字",
|
||||
"男女声错配",
|
||||
"中途换声",
|
||||
"五官崩坏",
|
||||
"脸部变形",
|
||||
"多余手指",
|
||||
"肢体扭曲",
|
||||
"闪烁",
|
||||
"画面抖动",
|
||||
"模糊",
|
||||
"低分辨率",
|
||||
]
|
||||
|
||||
# API _build_copy_result 兼容老数据的兜底(比 worker 的短,仅用于老字段拼装)
|
||||
API_FALLBACK_HARD_CONSTRAINTS: list[str] = ["无字幕", "无水印", "人物一致性"]
|
||||
API_FALLBACK_NEGATIVE_PROMPTS: list[str] = ["字幕", "水印", "错误文字", "五官崩坏"]
|
||||
@@ -1,427 +1,12 @@
|
||||
"""爆款视频 5 步编排:图片分析 → 意图解析 → 文案融合 → 分镜 → 审核重写。
|
||||
# -*- coding: utf-8 -*-
|
||||
"""爆款视频旧 5 步编排器(已废弃)。
|
||||
|
||||
所有 LLM 调用走 DoubaoClient,单测通过 client 参数注入 mock,不真调 API。
|
||||
任何一步解析失败都走规则 fallback,不抛异常阻断。
|
||||
PR1 (refactor/viral-video-backend-p0-deadcode-and-consistency):CopyGenerator 整体删除。
|
||||
- 图片分析走 worker_app/tasks/vision 下的 V2 fast_path(analyze_images_v2);
|
||||
- 文案/分镜/审核走 worker_app/tasks/viral_video.py 的 _step_script_generation / _step_review;
|
||||
- 旧的 intent_parsing / copy_fusion 两步早已在叙述优先重构中删除,不再有调用点。
|
||||
|
||||
本文件保留为空壳,避免误导入;任何新代码请不要从这里 import。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from packages.application.viral_video import xml_parser as xp
|
||||
from packages.application.viral_video.prompt_loader import (
|
||||
PromptTemplate,
|
||||
get_template,
|
||||
render_system_prompt,
|
||||
render_user_prompt,
|
||||
)
|
||||
from packages.application.viral_video.prompts import (
|
||||
FUSION_INSTRUCTIONS,
|
||||
GLOBAL_CONSTRAINTS,
|
||||
NEGATIVE_RULES,
|
||||
)
|
||||
from packages.application.viral_video.reviewer import Reviewer
|
||||
from packages.application.viral_video.schemas import (
|
||||
BodyPoint,
|
||||
Clip,
|
||||
ColorItem,
|
||||
CoreMessage,
|
||||
FusionResult,
|
||||
ImageAnalysis,
|
||||
IntentResult,
|
||||
KenBurns,
|
||||
PersonalBrand,
|
||||
ProductItem,
|
||||
ReviewResult,
|
||||
ScriptSegment,
|
||||
Storyboard,
|
||||
TextItem,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CopyGenerator:
|
||||
"""5 步 Prompt 编排器。"""
|
||||
|
||||
def __init__(self, client=None, reviewer: Optional[Reviewer] = None):
|
||||
if client is None:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
client = get_doubao_client()
|
||||
self.client = client
|
||||
self.reviewer = reviewer or Reviewer(client)
|
||||
|
||||
# ── 底层调用 ────────────────────────────────────────────────────────
|
||||
def _chat(self, template: PromptTemplate, system_kwargs: dict | None, **user_kwargs) -> str:
|
||||
system = render_system_prompt(template, **(system_kwargs or {}))
|
||||
user = render_user_prompt(template, **user_kwargs)
|
||||
result = self.client.chat_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=2048,
|
||||
)
|
||||
return result or ""
|
||||
|
||||
# ── 步骤1:图片多模态分析 ───────────────────────────────────────────
|
||||
def analyze_images(self, images: list[str], industry: str = "") -> ImageAnalysis:
|
||||
template = get_template("image_analysis")
|
||||
image_urls = "\n".join(f"第{i + 1}张:{url}" for i, url in enumerate(images))
|
||||
system = render_system_prompt(template)
|
||||
user = render_user_prompt(template, image_count=len(images), industry=industry or "通用", image_urls=image_urls)
|
||||
raw = self.client.vision_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
images=images,
|
||||
max_tokens=2048,
|
||||
temperature=0.3,
|
||||
)
|
||||
analysis = self._parse_image_analysis(raw or "")
|
||||
if not analysis.products and not analysis.key_selling_points:
|
||||
logger.warning("图片分析标签解析失败,走规则 fallback")
|
||||
return self._fallback_image_analysis(images, raw or "")
|
||||
return analysis
|
||||
|
||||
def _parse_image_analysis(self, raw: str) -> ImageAnalysis:
|
||||
products = [
|
||||
ProductItem(
|
||||
name=n["attrs"].get("name", "无法判断"),
|
||||
features=n["attrs"].get("features", "无法判断"),
|
||||
position=n["attrs"].get("position", "secondary"),
|
||||
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
|
||||
)
|
||||
for n in xp.find_all(raw, "product")
|
||||
]
|
||||
colors = [
|
||||
ColorItem(
|
||||
hex=c["attrs"].get("hex", "#000000"),
|
||||
name=c["attrs"].get("name", "无法判断"),
|
||||
coverage=xp.attr_float(c["attrs"].get("coverage"), 0.0),
|
||||
)
|
||||
for c in xp.find_all(raw, "color")
|
||||
]
|
||||
people = xp.find_first(raw, "people")
|
||||
visible_text = [
|
||||
TextItem(text=t["attrs"].get("text", ""), position=t["attrs"].get("position", ""))
|
||||
for t in xp.find_all(raw, "text_item")
|
||||
]
|
||||
quality_node = xp.find_first(raw, "quality")
|
||||
selling_points = [n["text"] or n["attrs"].get("text", "") for n in xp.find_all(raw, "point")]
|
||||
return ImageAnalysis(
|
||||
products=products,
|
||||
colors=colors,
|
||||
has_person=xp.attr_bool(people["attrs"].get("has_person")) if people else False,
|
||||
person_count=xp.attr_int(people["attrs"].get("count"), 0) if people else 0,
|
||||
people=people["attrs"] if people else {},
|
||||
mood=xp.text_of(raw, "mood"),
|
||||
visible_text=visible_text,
|
||||
scene=xp.text_of(raw, "scene"),
|
||||
quality=quality_node["attrs"] if quality_node else {},
|
||||
key_selling_points=[p for p in selling_points if p],
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def _fallback_image_analysis(self, images: list[str], raw: str) -> ImageAnalysis:
|
||||
return ImageAnalysis(
|
||||
products=[ProductItem(name="无法判断(视觉分析不可用)", image_index=0)],
|
||||
scene="无法判断",
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
# ── 步骤2:意图解析 ─────────────────────────────────────────────────
|
||||
def parse_intent(self, user_copy_text: str, image_analysis: ImageAnalysis, industry: str = "") -> IntentResult:
|
||||
template = get_template("intent_parsing")
|
||||
raw = self._chat(
|
||||
template,
|
||||
None,
|
||||
user_copy_text=user_copy_text or "(用户没有提供文案)",
|
||||
industry=industry or "通用",
|
||||
image_analysis=self._image_brief(image_analysis),
|
||||
)
|
||||
intent = self._parse_intent(raw)
|
||||
if not intent.intent_summary and not intent.core_messages:
|
||||
logger.warning("意图解析标签解析失败,走规则 fallback")
|
||||
return self._fallback_intent(user_copy_text, raw)
|
||||
return intent
|
||||
|
||||
def _parse_intent(self, raw: str) -> IntentResult:
|
||||
messages = [
|
||||
CoreMessage(
|
||||
text=n["text"],
|
||||
must_keep=xp.attr_bool(n["attrs"].get("must_keep"), default=False),
|
||||
confidence=xp.attr_float(n["attrs"].get("confidence"), 0.0),
|
||||
)
|
||||
for n in xp.find_all(raw, "message")
|
||||
if n["text"]
|
||||
]
|
||||
brands = [
|
||||
PersonalBrand(text=n["text"], category=n["attrs"].get("category", "brand"))
|
||||
for n in xp.find_all(raw, "brand")
|
||||
if n["text"]
|
||||
]
|
||||
missing = [n["text"] for n in xp.find_all(raw, "info") if n["text"]]
|
||||
return IntentResult(
|
||||
intent_summary=xp.text_of(raw, "intent_summary"),
|
||||
core_messages=messages,
|
||||
personal_brands=brands,
|
||||
emotion_tone=xp.text_of(raw, "emotion_tone"),
|
||||
missing_info=missing,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def _fallback_intent(self, user_copy_text: str, raw: str) -> IntentResult:
|
||||
text = (user_copy_text or "").strip()
|
||||
messages = [CoreMessage(text=text[:80], must_keep=True, confidence=1.0)] if text else []
|
||||
return IntentResult(
|
||||
intent_summary=text[:30] or "未提供文案,按产品图片自由创作",
|
||||
core_messages=messages,
|
||||
personal_brands=[],
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
# ── 步骤3:文案融合生成(三档)──────────────────────────────────────
|
||||
def fuse(
|
||||
self,
|
||||
fusion_level: str,
|
||||
image_analysis: ImageAnalysis,
|
||||
intent: IntentResult,
|
||||
industry: str = "",
|
||||
target_customer: str = "",
|
||||
marketing_purpose: str = "",
|
||||
duration: int = 15,
|
||||
) -> FusionResult:
|
||||
template = get_template("copy_fusion")
|
||||
system_kwargs = {
|
||||
"fusion_instruction": FUSION_INSTRUCTIONS.get(fusion_level, FUSION_INSTRUCTIONS["ai_polish"]),
|
||||
"global_constraints": GLOBAL_CONSTRAINTS,
|
||||
"negative_rules": NEGATIVE_RULES,
|
||||
}
|
||||
raw = self._chat(
|
||||
template,
|
||||
system_kwargs,
|
||||
industry=industry or "通用",
|
||||
target_customer=target_customer or "通用消费者",
|
||||
marketing_purpose=marketing_purpose or "产品种草",
|
||||
duration=duration,
|
||||
image_analysis=self._image_brief(image_analysis),
|
||||
intent_result=self._intent_brief(intent),
|
||||
)
|
||||
result = self._parse_fusion(raw)
|
||||
if not result.title and not result.script_segments:
|
||||
logger.warning("文案融合标签解析失败(fusion=%s),走规则 fallback", fusion_level)
|
||||
return self._fallback_fusion(fusion_level, image_analysis, intent, duration, raw)
|
||||
return result
|
||||
|
||||
def _parse_fusion(self, raw: str) -> FusionResult:
|
||||
body_points = [
|
||||
BodyPoint(
|
||||
text=n["text"],
|
||||
elaboration=n["attrs"].get("elaboration", ""),
|
||||
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
|
||||
)
|
||||
for n in xp.find_all(raw, "point")
|
||||
if n["text"]
|
||||
]
|
||||
segments = [
|
||||
ScriptSegment(
|
||||
text=n["text"],
|
||||
duration_sec=xp.attr_float(n["attrs"].get("duration_sec"), 0.0),
|
||||
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
|
||||
)
|
||||
for n in xp.find_all(raw, "segment")
|
||||
if n["text"]
|
||||
]
|
||||
return FusionResult(
|
||||
title=xp.text_of(raw, "title"),
|
||||
hook=xp.text_of(raw, "hook"),
|
||||
body_points=body_points,
|
||||
cta=xp.text_of(raw, "cta"),
|
||||
script_segments=segments,
|
||||
word_count=xp.attr_int(xp.text_of(raw, "word_count"), 0),
|
||||
estimated_duration=xp.attr_int(xp.text_of(raw, "estimated_duration"), 0),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def _fallback_fusion(
|
||||
self,
|
||||
fusion_level: str,
|
||||
image_analysis: ImageAnalysis,
|
||||
intent: IntentResult,
|
||||
duration: int,
|
||||
raw: str,
|
||||
) -> FusionResult:
|
||||
product_name = image_analysis.products[0].name if image_analysis.products else "这款产品"
|
||||
selling = image_analysis.key_selling_points[:2]
|
||||
if fusion_level == "ai_full":
|
||||
title = f"{product_name},很多人用完都回购了"
|
||||
hook = f"这个{product_name},我想认真说说"
|
||||
body = selling or ["图片可见的产品卖点"]
|
||||
cta = "感兴趣的可以了解一下"
|
||||
elif fusion_level == "user_primary":
|
||||
user_text = intent.intent_summary or product_name
|
||||
title = user_text[:20]
|
||||
hook = user_text[:15]
|
||||
body = [m.text for m in intent.core_messages] or [user_text]
|
||||
cta = "想了解的可以看看"
|
||||
else:
|
||||
title = intent.intent_summary[:20] or product_name
|
||||
hook = intent.core_messages[0].text[:15] if intent.core_messages else product_name
|
||||
body = [m.text for m in intent.core_messages] or selling or [product_name]
|
||||
cta = "有需要的可以了解一下"
|
||||
|
||||
brand_texts = [b.text for b in intent.personal_brands]
|
||||
points = [BodyPoint(text=b) for b in body]
|
||||
lines = [hook] + body + brand_texts[:2] + [cta]
|
||||
joined = ",".join(lines)
|
||||
per = max(3, duration // max(1, len(lines)))
|
||||
segments = [ScriptSegment(text=line, duration_sec=per, image_index=0) for line in lines]
|
||||
return FusionResult(
|
||||
title=title,
|
||||
hook=hook,
|
||||
body_points=points,
|
||||
cta=cta,
|
||||
script_segments=segments,
|
||||
word_count=len(joined),
|
||||
estimated_duration=duration,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
# ── 步骤4:编导级分镜 ───────────────────────────────────────────────
|
||||
def storyboard(
|
||||
self, fusion: FusionResult, image_analysis: ImageAnalysis, images: list[str], duration: int
|
||||
) -> Storyboard:
|
||||
template = get_template("storyboard")
|
||||
raw = self._chat(
|
||||
template,
|
||||
None,
|
||||
duration=duration,
|
||||
image_count=len(images),
|
||||
fusion_result=self._fusion_brief(fusion),
|
||||
image_analysis=self._image_brief(image_analysis),
|
||||
)
|
||||
board = self._parse_storyboard(raw)
|
||||
if not board.clips:
|
||||
logger.warning("分镜标签解析失败,走规则 fallback")
|
||||
return self._fallback_storyboard(fusion, duration, raw)
|
||||
return board
|
||||
|
||||
def _parse_storyboard(self, raw: str) -> Storyboard:
|
||||
clips: list[Clip] = []
|
||||
for node in xp.find_all(raw, "clip"):
|
||||
attrs = node["attrs"]
|
||||
body = node["text"]
|
||||
kb = xp.find_first(node["text"] and f"<root>{node['text']}</root>", "ken_burns")
|
||||
clips.append(
|
||||
Clip(
|
||||
image_index=xp.attr_int(attrs.get("image_index"), 0),
|
||||
transition=attrs.get("transition", "cut"),
|
||||
zoom=(None if attrs.get("zoom") in (None, "null", "None", "") else attrs.get("zoom")),
|
||||
duration_sec=xp.attr_float(attrs.get("duration_sec"), 0.0),
|
||||
bgm_note=attrs.get("bgm_note", ""),
|
||||
voice_text=xp.text_of(body and f"<root>{body}</root>", "voice_text"),
|
||||
subtitle_text=xp.text_of(body and f"<root>{body}</root>", "subtitle_text"),
|
||||
ken_burns=KenBurns(
|
||||
start=kb["attrs"].get("start", "0,0") if kb else "0,0",
|
||||
end=kb["attrs"].get("end", "0,0") if kb else "0,0",
|
||||
ease=kb["attrs"].get("ease", "linear") if kb else "linear",
|
||||
),
|
||||
)
|
||||
)
|
||||
return Storyboard(clips=clips, raw=raw)
|
||||
|
||||
def _fallback_storyboard(self, fusion: FusionResult, duration: int, raw: str) -> Storyboard:
|
||||
segments = fusion.script_segments or [ScriptSegment(text=fusion.hook or fusion.title, duration_sec=duration)]
|
||||
total = sum(s.duration_sec for s in segments) or duration
|
||||
clips = [
|
||||
Clip(
|
||||
image_index=min(s.image_index, 0),
|
||||
transition="cut",
|
||||
duration_sec=max(2.0, s.duration_sec * duration / total if total else duration / len(segments)),
|
||||
voice_text=s.text,
|
||||
subtitle_text=s.text[:20],
|
||||
)
|
||||
for s in segments
|
||||
]
|
||||
return Storyboard(clips=clips, raw=raw)
|
||||
|
||||
# ── 步骤5:审核(不通过自动重写1次)─────────────────────────────────
|
||||
def review_and_rewrite(
|
||||
self, fusion: FusionResult, intent: IntentResult, fusion_level: str
|
||||
) -> tuple[FusionResult, ReviewResult, int]:
|
||||
"""返回最终文案、最后一次审核结果、重写次数(0或1)。"""
|
||||
review = self.reviewer.review(fusion, intent, fusion_level)
|
||||
if review.passed:
|
||||
return fusion, review, 0
|
||||
|
||||
logger.info("文案审核不通过,自动重写 1 次:%s", [i.text for i in review.issues])
|
||||
rewritten = self.reviewer.rewrite(fusion, review, intent, fusion_level)
|
||||
second = self.reviewer.review(rewritten, intent, fusion_level)
|
||||
if second.passed:
|
||||
return rewritten, second, 1
|
||||
# 二次仍不通过:带上重写结果和问题返回,由上游决定是否交给前端
|
||||
return rewritten, second, 1
|
||||
|
||||
# ── 全流程编排 ──────────────────────────────────────────────────────
|
||||
def generate(
|
||||
self,
|
||||
images: list[str],
|
||||
*,
|
||||
industry: str = "",
|
||||
target_customer: str = "",
|
||||
marketing_purpose: str = "",
|
||||
duration: int = 15,
|
||||
user_copy_text: str = "",
|
||||
fusion_level: str = "ai_polish",
|
||||
) -> dict:
|
||||
image_analysis = self.analyze_images(images, industry)
|
||||
intent = self.parse_intent(user_copy_text, image_analysis, industry)
|
||||
fusion = self.fuse(
|
||||
fusion_level,
|
||||
image_analysis,
|
||||
intent,
|
||||
industry=industry,
|
||||
target_customer=target_customer,
|
||||
marketing_purpose=marketing_purpose,
|
||||
duration=duration,
|
||||
)
|
||||
fusion, review, rewrites = self.review_and_rewrite(fusion, intent, fusion_level)
|
||||
board = self.storyboard(fusion, image_analysis, images, duration)
|
||||
return {
|
||||
"image_analysis": image_analysis,
|
||||
"intent_result": intent,
|
||||
"fusion_result": fusion,
|
||||
"review_result": review,
|
||||
"storyboard": board,
|
||||
"rewrite_count": rewrites,
|
||||
}
|
||||
|
||||
# ── 简报工具 ────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _image_brief(a) -> str:
|
||||
if a is None:
|
||||
return "无图片分析信息"
|
||||
lines = [f"产品:{p.name}({p.features})" for p in a.products]
|
||||
lines += [f"卖点:{s}" for s in a.key_selling_points]
|
||||
lines.append(f"场景:{a.scene}")
|
||||
return "\n".join(lines) or "无图片分析信息"
|
||||
|
||||
@staticmethod
|
||||
def _intent_brief(i: IntentResult) -> str:
|
||||
lines = [f"意图:{i.intent_summary}"]
|
||||
lines += [f"核心信息[must_keep={m.must_keep}]:{m.text}" for m in i.core_messages]
|
||||
lines += [f"事实({b.category}):{b.text}" for b in i.personal_brands]
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _fusion_brief(f: FusionResult) -> str:
|
||||
lines = [f"标题:{f.title}", f"钩子:{f.hook}"]
|
||||
lines += [f"要点:{p.text}" for p in f.body_points]
|
||||
lines += [f"配音:{s.text}" for s in f.script_segments]
|
||||
lines.append(f"行动号召:{f.cta}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -17,7 +17,7 @@ from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
|
||||
|
||||
CACHE_TTL_SECONDS = 30.0
|
||||
|
||||
_VALID_TYPES = {"image_analysis", "intent_parsing", "copy_fusion", "storyboard", "review"}
|
||||
_VALID_TYPES = {"image_analysis", "storyboard", "review"}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,339 +1,246 @@
|
||||
"""爆款视频 5 套 Prompt 模板默认值(#2040 核心资产)。
|
||||
# -*- coding: utf-8 -*-
|
||||
"""爆款视频 Prompt 模板默认值(v8 / v3 叙述优先重构,PR1 清理版)。
|
||||
|
||||
重要约定(用户明确要求):
|
||||
- 所有 system_prompt / user_prompt_template / example_output 都是**纯文本自然语言 + XML 标签**,
|
||||
运营可直接看懂和编辑,禁止 JSON、禁止 ```json 代码块。
|
||||
- LLM 按 XML 标签输出字段,程序用正则解析(见 xml_parser.py)。
|
||||
- user_prompt_template 中花括号占位符(如 {user_copy_text})在运行时填充。
|
||||
设计原则(灵应 2026-10-07):LLM 直接输出最终给用户看的文案,代码尽量薄。
|
||||
- image_analysis v8:VLM 主交付物是自然叙述风格的 summary_markdown,结构化
|
||||
字段仅保留 type/name/brand/has_person,顶层 products 改名 images;
|
||||
- storyboard v3:口播台词口语化、画面描述有画面感,copy_display_markdown 是
|
||||
LLM 直接写给用户看的流畅叙述文案,代码只做解析不改写;
|
||||
- intent_parsing / copy_fusion 两步整体删除,意图理解并入 storyboard 一次调用。
|
||||
|
||||
模板字段与 DB 表 viral_video_prompt_templates、prompt_loader 完全对应:
|
||||
name / prompt_type / version(int) / system_prompt / user_prompt_template /
|
||||
example_output / is_active。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TEMPLATE_VERSION = 1
|
||||
|
||||
# 所有文案类 Prompt 自动注入的硬约束
|
||||
GLOBAL_CONSTRAINTS = """【必须遵守的硬约束】
|
||||
1. 不编造时间:不写“今年最新”“2024 爆款”等会过时的时间表述。
|
||||
2. 不承诺效果:不写“保证”“一定”“100%有效”“包治百病”等绝对化用语。
|
||||
1. 不编造时间:不写"今年最新""2024 爆款"等会过时的时间表述。
|
||||
2. 不承诺效果:不写"保证""一定""100%有效""包治百病"等绝对化用语。
|
||||
3. 不编造价格、销量、认证、奖项:除非用户在文案中明确给出,否则一律不写。
|
||||
4. 符合广告法及平台社区规范。
|
||||
5. 只描述图片中真实可见的内容,看不到的不瞎猜。"""
|
||||
|
||||
# 反套路化要求
|
||||
# 负向提示(注入 storyboard / 视频生成负面词)
|
||||
NEGATIVE_RULES = """【反套路化要求】
|
||||
禁止使用“家人们谁懂啊”“绝绝子”“宝子们”“家人们”“太绝了”“yyds”等烂大街网络词;
|
||||
禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;
|
||||
禁止使用"很近"等错别字,应使用"最近";
|
||||
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。"""
|
||||
|
||||
# 输出禁用套路词(测试会检查)
|
||||
BANNED_PHRASES = ["家人们谁懂啊", "绝绝子", "宝子们", "yyds", "太绝了"]
|
||||
# 输出禁用套路词(测试/reviewer/worker 三处共享)
|
||||
# 注:广告法绝对化用语(如"最""第一""100%")在 worker 侧 _quick_compliance_blacklist_check 单独做本地替换,
|
||||
# 这里只列出明显的网络烂梗/套路词。
|
||||
COMPLIANCE_BLACKLIST: list[str] = [
|
||||
"家人们谁懂啊",
|
||||
"绝绝子",
|
||||
"宝子们",
|
||||
"yyds",
|
||||
"太绝了",
|
||||
"家人们",
|
||||
]
|
||||
|
||||
# 文案融合三档独立指令段
|
||||
FUSION_INSTRUCTIONS = {
|
||||
"ai_full": """【本次创作模式:AI 全权创作】
|
||||
你是资深短视频编导。用户只提供了产品图片,没有给出具体文案方向。请根据图片内容和营销参数,自由发挥创作完整的爆款短视频文案。充分挖掘产品真实可见的卖点,使用爆款结构,抓人眼球。""",
|
||||
"ai_polish": """【本次创作模式:AI 辅助润色】
|
||||
你是用户的文案助理。用户已经写了草稿/关键词/碎碎念,表达了他想讲的核心意思,但表达不完整、不够吸引人。你的任务是:以用户的意思为主,保留他想表达的所有核心信息点,在此基础上润色扩写、调整语序、增加衔接、优化表达,让文案更流畅更有吸引力。绝对不能改变用户想表达的核心意思,不能把用户的观点换成相反的,不能添加用户没提到的产品卖点。用户提到的品牌名、价格、人名、具体事实必须原样保留。""",
|
||||
"user_primary": """【本次创作模式:以用户原文为主】
|
||||
你是文案润色助手。用户已经写好了明确的文案,这是他最终想表达的内容。你的任务是最小化修改:只做必要的错别字修正、标点调整、语句通顺度优化,以及添加必要的衔接词让口播更自然。用户的核心句子、关键表述、事实信息一律不改。如果用户文案本身已经很好,直接返回,不要为了改而改。personal_brands 中的事实信息必须逐字保留。""",
|
||||
# 向后兼容别名
|
||||
BANNED_PHRASES = COMPLIANCE_BLACKLIST
|
||||
|
||||
# ── 模板1:图片多模态分析 v8(叙述优先)───────────────────────────────
|
||||
_IMAGE_ANALYSIS_SYSTEM = """你是一名擅长观察和写作的品牌内容编导。面对一张真实图片,先用眼睛仔细看,再用自然、流畅、具体的中文把画面写成一段可以直接读给人听的描述。
|
||||
|
||||
## 输出格式(严格 JSON,不要输出 JSON 以外的任何内容)
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"type": "store 或 product 或 person 或 scene,四选一",
|
||||
"name": "主体名称,看不出就写"未识别"",
|
||||
"brand": "品牌名,看不出就留空字符串",
|
||||
"has_person": false,
|
||||
"summary_markdown": "用 Markdown 写成的自然叙述,这是最主要的交付物"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ── 模板1:图片多模态分析(VLM)────────────────────────────────────────
|
||||
_IMAGE_ANALYSIS_SYSTEM = f"""你是电商商品视觉分析师,负责从商品图片中提取真实可见的商品信息。
|
||||
## summary_markdown 写作要求(最重要)
|
||||
1. 写成完整、通顺的句子,像在跟朋友认真描述你看到的画面;不要用分号堆砌关键词,不要罗列"核心特征:xxx""主色调:xxx"这类填表式标签。
|
||||
2. 开头先给一句整体定性,让读者立刻明白这是什么场景、什么主体。
|
||||
3. 颜色、材质、形状、部件要具体可感,写到位置和搭配;画面里出现的文字原样读出并自然融进句子,数字、规格、价格精确引用,看不清的不要编造。
|
||||
4. 只写真实看到的内容,不脑补功能、疗效、销量或画面之外的信息。
|
||||
5. 长度控制在 200-500 字。
|
||||
|
||||
工作方式(分步骤看,不要跳步):
|
||||
1. 先看整体:有哪些产品、什么场景、有没有人物。
|
||||
2. 再看细节:包装文字、颜色构成、人物状态、画面质感。
|
||||
3. 最后提炼卖点:只总结图片里能看到的卖点。
|
||||
## 按类型组织内容
|
||||
- type=store(门店/店内环境):用以下小标题分段,小标题下写连贯的句子而不是清单:
|
||||
###店铺主体
|
||||
###周边物品
|
||||
1.家具陈设
|
||||
2.商品与标识
|
||||
- type=product(商品):按自然段从整体到局部描写——先说是什么、什么品牌,再写包装/外形、颜色与材质、标签文字、可见部件与规格。
|
||||
- type=person(人物):描述人物身份感、姿态、穿着(上下装/颜色/款式)、动作与所处环境;用于品牌宣传时突出其精神状态。
|
||||
- type=scene(纯场景/风景):描述空间或风景的构成、色彩、光线、氛围与关键物件。
|
||||
|
||||
{GLOBAL_CONSTRAINTS}
|
||||
## 判断规则
|
||||
- has_person:画面中出现可辨识的真实人物(脸或完整上半身)才为 true,海报/模特立牌/照片里的人不算。
|
||||
- 一张图只描述其本身;多张图属于同一场景时可呼应,但不编造对应关系。
|
||||
- 输出必须是严格 JSON,summary_markdown 是字符串,内部换行用 \\n 表示。"""
|
||||
|
||||
请严格按下面的标签格式输出,标签名一个都不能改,不要输出任何解释,不要用代码块:
|
||||
<products> 下面每个产品用一个 <product> 标签,属性 name 是产品名、features 是外观特征、position 是 main 或 secondary、image_index 是第几张图(从0开始)。
|
||||
<colors> 下面每个主要颜色用一个 <color> 标签,属性 hex 是色值、name 是颜色名、coverage 是占比小数。
|
||||
<people> 用一个标签,属性 has_person、count、gender、age_range、hair(发型发色)、skin_tone(肤色)、face_shape(脸型)、outfit(穿着)、pose(姿态)、expression(表情)分别描述人物外貌。有人物时属性尽量具体(如hair="黑色长直发"、outfit="白色衬衫"),无人像时除has_person=false外其他填"无法判断"。
|
||||
<mood> 标签写画面整体情绪氛围。
|
||||
<visible_text> 下面每处可见文字用一个 <text_item> 标签,属性 text 是文字内容、position 是位置。
|
||||
<scene> 标签写场景描述。
|
||||
<quality> 用一个标签,属性 resolution、lighting、composition、blur 描述画质。
|
||||
<key_selling_points> 下面每个卖点用一个 <point> 标签。
|
||||
_IMAGE_ANALYSIS_USER = """请分析这张图片。
|
||||
图片地址:{image_url}
|
||||
OCR 辅助文字(可能为空,仅供参考,不要照抄错误识别):{ocr_text}
|
||||
|
||||
【人物属性硬性要求(has_person=true时必须遵守)】
|
||||
hair/skin_tone/face_shape/outfit四项绝对禁止填“无法判断”,必须基于图片可见特征给出具体中文描述:
|
||||
- hair:必须描述发型+发色,如“黑色齐肩直发”“棕色微卷中长发”“深棕色短发”
|
||||
- skin_tone:必须描述肤色,如“暖调自然肤色”“白皙肤色”“小麦色”
|
||||
- face_shape:必须描述脸型,如“鹅蛋脸”“圆脸”“瓜子脸”“方脸”
|
||||
- outfit:必须描述可见穿着,如“米色翻领衬衫”“白色T恤”“黑色连衣裙”
|
||||
即使局部被遮挡也要根据可见部分合理推断;确实看不清时按最接近的直观印象描述。
|
||||
严格按系统要求只输出 JSON。"""
|
||||
|
||||
其他非人物属性看不到或无法判断时填“无法判断”,布尔值填false,不要留空标签。
|
||||
_IMAGE_ANALYSIS_EXAMPLE = """{
|
||||
"images": [
|
||||
{
|
||||
"type": "store",
|
||||
"name": "御众堂门店",
|
||||
"brand": "御众堂",
|
||||
"has_person": false,
|
||||
"summary_markdown": "###店铺主体\\n这是一家名为"御众堂"的线下门店内部,整体暖木色调……"
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
【有人物场景输出参考(女性手持商品示例,必须写全10个属性,禁止省略)】
|
||||
<people has_person="true" count="1" gender="女" age_range="青年" hair="黑色齐肩直发" skin_tone="暖调自然肤色" face_shape="鹅蛋脸" outfit="米色翻领衬衫" pose="正面半身,手持商品" expression="面带微笑"/>"""
|
||||
# ── 模板2:编导级分镜 v3(意图理解 + 分镜一次完成)────────────────────
|
||||
# 字数/镜头数硬约束在 user_prompt 通过 {duration_constraint_text} 占位符注入,
|
||||
# 由 constants.build_duration_constraint_text(dur) 统一生成,避免模板与代码校验不一致。
|
||||
_STORYBOARD_SYSTEM = (
|
||||
"""你是一名懂短视频的编导和口播文案高手。你会拿到图片的真实观察、营销目的和用户参数,请一次性完成对营销意图的理解,并产出可直接拍摄/生成的分镜脚本。不要单独输出"意图解析",意图要直接体现在台词和分镜里。
|
||||
|
||||
_IMAGE_ANALYSIS_USER = """请分析以下商品图片,共 {image_count} 张。
|
||||
所属行业:{industry}
|
||||
图片地址:
|
||||
{image_urls}
|
||||
## 输出格式(XML,严格按结构输出,不要输出额外解释)
|
||||
<script>
|
||||
<copy_display_markdown><![CDATA[直接展示给用户看的成片文案,用 Markdown 写成流畅叙述]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<voiceover>这一镜的口播台词</voiceover>
|
||||
<visual>具体、有画面感的镜头描述(主体/动作/镜头运动/景别/光线)</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>把所有 clip 的 voiceover 连成完整口播稿</voiceover_script>
|
||||
<theme>一句话主题</theme>
|
||||
<negative>"""
|
||||
+ NEGATIVE_RULES
|
||||
+ """</negative>
|
||||
</script>
|
||||
|
||||
按约定的标签格式输出分析结果。"""
|
||||
## 写作要求
|
||||
1. 口播台词:像真人面对镜头说话,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。
|
||||
2. 画面描述:写清"观众会看到什么",有动作、有镜头运动、有景别和光线,具体可拍;不堆砌形容词,不写无法实现的画面。
|
||||
3. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案,可用小标题与短句组织;不要做字段列表,不要出现"镜头一/台词:"这类制作说明。
|
||||
4. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
|
||||
5. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
|
||||
6. 分镜数量与时长匹配总时长,节奏紧凑。
|
||||
7. 【字数/镜头数/时间轴硬约束】:严格遵守 user_prompt 里给出的 {duration_constraint_text},宁少勿多。
|
||||
8. 每个 clip 必须分配一个 reference_image_index(从 0 开始的图片序号),没有合适图片填 -1。
|
||||
9. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜,不能忽略任何一项用户参数。
|
||||
10. 禁止使用"很近"等错别字,统一使用"最近"。"""
|
||||
)
|
||||
|
||||
_IMAGE_ANALYSIS_EXAMPLE = """<products>
|
||||
<product name="大公鸡头 多功能油污净 625ml" features="红色瓶盖白色瓶身,鸡头图案Logo" position="main" image_index="0"/>
|
||||
</products>
|
||||
<colors>
|
||||
<color hex="#D32F2F" name="红色" coverage="0.4"/>
|
||||
<color hex="#FFFFFF" name="白色" coverage="0.5"/>
|
||||
</colors>
|
||||
<people has_person="false" count="0" gender="无法判断" age_range="无法判断" hair="无法判断" skin_tone="无法判断" face_shape="无法判断" outfit="无法判断" pose="无法判断" expression="无法判断"/>
|
||||
<mood>干净、实用</mood>
|
||||
<visible_text>
|
||||
<text_item text="多功能油污净" position="瓶身正面"/>
|
||||
</visible_text>
|
||||
<scene>白底棚拍产品图</scene>
|
||||
<quality resolution="高清" lighting="均匀柔和" composition="主体居中" blur="false"/>
|
||||
<key_selling_points>
|
||||
<point>针对重油污设计</point>
|
||||
<point>大容量625ml</point>
|
||||
</key_selling_points>"""
|
||||
_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}
|
||||
<duration_constraints>
|
||||
{duration_constraint_text}
|
||||
</duration_constraints>
|
||||
请严格按 XML 结构输出分镜脚本。"""
|
||||
|
||||
# ── 模板2:用户文案意图解析(LLM)──────────────────────────────────────
|
||||
_INTENT_SYSTEM = f"""你负责理解用户的营销意图。用户给的文案可能只是几个关键词、碎碎念或者不完整的短句,你要读懂他真正想讲什么。
|
||||
_STORYBOARD_EXAMPLE = """<script>
|
||||
<copy_display_markdown><![CDATA[# 在御众堂,把松弛的自己一点点找回来
|
||||
产后妈妈最懂那种力不从心,推开门,暖光和一杯热茶先接住了你。
|
||||
最近连抱娃都觉得腰发硬,过来躺一会儿,按完整个背都松了,带娃也有力气了。]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-4秒</time_range>
|
||||
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
|
||||
<visual>中近景,暖光下一位妈妈略显疲惫地看向镜中,镜头缓缓推近</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>4-10秒</time_range>
|
||||
<voiceover>推开门,暖光和一杯热茶先接住了你。</voiceover>
|
||||
<visual>中景,门店前台暖光木色装修,店员微笑递上茶水,镜头平移扫过环境</visual>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
</clip>
|
||||
<clip index="3">
|
||||
<time_range>10-15秒</time_range>
|
||||
<voiceover>来御众堂躺一会儿,整个人都松了。</voiceover>
|
||||
<visual>特写,妈妈闭眼放松做护理,暖光打在侧脸,最后镜头拉远露出品牌LOGO</visual>
|
||||
<reference_image_index>2</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>生完娃,是不是连照镜子的勇气都没了?推开门,暖光和一杯热茶先接住了你。来御众堂躺一会儿,整个人都松了。</voiceover_script>
|
||||
<theme>产后妈妈走进御众堂重拾状态</theme>
|
||||
<negative>模糊、畸变、夸大疗效、绝对化用语、"很近"等错别字</negative>
|
||||
</script>"""
|
||||
|
||||
{GLOBAL_CONSTRAINTS}
|
||||
# ── 模板3:文案审核(合规/质量门禁)───────────────────────────────────
|
||||
_REVIEW_SYSTEM = """你是一名短视频广告合规审核与文案优化专家。请审核下面这段口播文案:
|
||||
1) 广告法与平台合规(绝对化用语、虚假承诺、医疗功效宣称、导流违规、套路烂梗词);
|
||||
2) 口播是否自然、口语化、无书面腔;
|
||||
3) 是否有明显错别字(例如把"最近"写成"很近")。
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<intent_summary> 用用户的语言风格,一句话、30字以内概括核心意图。
|
||||
<core_messages> 下面每个核心信息点用一个 <message> 标签,属性 must_keep 为 true 或 false、confidence 为 0 到 1 的小数,标签内容写信息点。
|
||||
<personal_brands> 把用户提到的具体事实——品牌名、价格、人名、地名、时间、产品名——每条用一个 <brand> 标签,属性 category 取 brand、price、person、place、time、product 之一。这些事实必须原样引用,一个字都不能改。
|
||||
<emotion_tone> 写文案的情绪调性。
|
||||
<missing_info> 把你认为缺失、后续生成时需要合理推断的信息,每条用一个 <info> 标签;没有就输出空标签。"""
|
||||
只输出 XML,结构:
|
||||
<review>
|
||||
<passed>true 或 false</passed>
|
||||
<issues>
|
||||
<issue dimension="违规词/夸大承诺/错别字/其他" severity="error或warning">具体问题</issue>
|
||||
</issues>
|
||||
</review>
|
||||
没有问题时 issues 留空、passed 为 true。不要输出 rewrite/rewrite_suggestion 字段。"""
|
||||
|
||||
_INTENT_USER = """用户原始文案:{user_copy_text}
|
||||
所属行业:{industry}
|
||||
营销目的:{marketing_purpose}
|
||||
图片分析结果(供参考):
|
||||
{image_analysis}
|
||||
图片类型推断:{image_category_hint}
|
||||
_REVIEW_USER = """<voiceover_script>
|
||||
{voiceover_script}
|
||||
</voiceover_script>
|
||||
|
||||
请理解用户意图,按标签格式输出。注意:theme和emotion_tone应与图片类型和营销目的匹配——门店类图片偏向"门店探店/到店体验",商品图偏向"好物分享/产品种草",人物图偏向"穿搭/人物故事"。"""
|
||||
请审核以上口播文案。"""
|
||||
|
||||
_INTENT_EXAMPLE = """<intent_summary>一款厨房去油污神器,喷一喷油污就掉</intent_summary>
|
||||
<core_messages>
|
||||
<message must_keep="true" confidence="0.97">去油污效果好,喷上等几分钟再擦</message>
|
||||
<message must_keep="false" confidence="0.7">适合厨房重油污场景</message>
|
||||
</core_messages>
|
||||
<personal_brands>
|
||||
<brand category="product">大公鸡头多功能油污净</brand>
|
||||
<brand category="price">39块钱一瓶</brand>
|
||||
</personal_brands>
|
||||
<emotion_tone>亲切、真实、带分享感</emotion_tone>
|
||||
<missing_info>
|
||||
<info>没有说明具体容量,按图片读出的625ml处理</info>
|
||||
</missing_info>"""
|
||||
|
||||
# ── 模板3:文案融合生成(LLM)──────────────────────────────────────────
|
||||
_FUSION_SYSTEM = """你负责为短视频生成营销文案。请按思维链分步完成:先定人设和目标客户,再找卖点,再搭结构,再安排情绪,最后写行动号召,不要一步到位乱写。
|
||||
|
||||
{fusion_instruction}
|
||||
|
||||
{global_constraints}
|
||||
|
||||
{negative_rules}
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<title> 视频标题。
|
||||
<hook> 开头3秒钩子,5到15字。
|
||||
<body_points> 每个要点用一个 <point> 标签,属性 elaboration 是展开说明、image_index 是对应第几张图(从0开始),标签内容写要点。
|
||||
<cta> 口语化的行动号召。
|
||||
<script_segments> 每段配音用一个 <segment> 标签,属性 duration_sec 是秒数、image_index 是对应图片,标签内容写配音文案(纯口播文本,不加旁白标注、不加镜头标注、不加"主播:"之类前缀)。
|
||||
<voiceover_script> 把所有 segment 的配音文案按顺序自然拼接成一段完整的纯口播文本(无标记、无括号、无前缀),长度要适配 {duration} 秒,约 {approx_chars} 字。
|
||||
<overview_theme> 视频主题(一句话概括)。
|
||||
<scene_and_lighting> 整体场景描述+光线设定(100-200字,要具体:在哪拍、什么光线、什么色调、什么氛围)。
|
||||
<word_count> 配音总字数,只写数字。
|
||||
<estimated_duration> 预计时长秒数,只写数字。
|
||||
|
||||
用户在 personal_brands 中提到的品牌名、价格、人名、地名、时间、产品名等事实信息,必须原样出现在文案里,一个字都不能改。"""
|
||||
|
||||
_FUSION_USER = """所属行业:{industry}
|
||||
目标客户:{target_customer}
|
||||
营销目的:{marketing_purpose}
|
||||
视频时长:{duration}秒
|
||||
图片分析结果:
|
||||
{image_analysis}
|
||||
用户意图解析结果:
|
||||
{intent_result}
|
||||
|
||||
请按标签格式生成文案。"""
|
||||
|
||||
_FUSION_EXAMPLE = """<title>厨房重油污,别再用洗洁精硬擦了</title>
|
||||
<hook>这油污,我真的忍很久了</hook>
|
||||
<body_points>
|
||||
<point elaboration="喷在油污上等几分钟,一擦就干净" image_index="0">大公鸡头油污净去油快</point>
|
||||
<point elaboration="39块钱625ml,能用很久" image_index="0">39块钱一瓶,性价比高</point>
|
||||
</body_points>
|
||||
<cta>厨房油污重的,真的可以试一瓶</cta>
|
||||
<script_segments>
|
||||
<segment duration_sec="3" image_index="0">这油污我真的忍很久了,用洗洁精擦半天都没用</segment>
|
||||
<segment duration_sec="6" image_index="0">后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净</segment>
|
||||
<segment duration_sec="4" image_index="0">39块钱625ml,厨房重油污的可以试一瓶</segment>
|
||||
</script_segments>
|
||||
<voiceover_script>这油污我真的忍很久了,用洗洁精擦半天都没用。后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净。39块钱625ml,厨房重油污的可以试一瓶。</voiceover_script>
|
||||
<overview_theme>厨房好物分享·产品种草</overview_theme>
|
||||
<scene_and_lighting>简洁明亮的厨房台面场景,自然光从窗户洒入,色调温暖柔和,突出产品白色瓶身与去油污对比效果。</scene_and_lighting>
|
||||
<word_count>58</word_count>
|
||||
<estimated_duration>13</estimated_duration>"""
|
||||
|
||||
# ── 模板4:编导级分镜(LLM)────────────────────────────────────────────
|
||||
_STORYBOARD_SYSTEM = """你是短视频编导,负责把文案拆成可拍摄的分镜,为 Seedance 2.5 视频模型写编导分镜脚本。脚本将整体作为 prompt 一次性传给视频模型,必须让模型在连贯镜头流中清楚每段时间拍什么、画面如何、人物说什么。
|
||||
|
||||
工作方式:
|
||||
1. 按文案的 script_segments 顺序分配镜头。
|
||||
2. 每个镜头确定景别/角度/运镜、画面场景与对白、人物动作细节、音效/BGM、转场。
|
||||
3. 检查所有镜头时长加起来接近目标时长,误差不超过2秒。
|
||||
4. image_index 必须在已上传图片范围内,第一张主图必须用在第一个镜头。
|
||||
|
||||
{fusion_instruction}
|
||||
|
||||
{global_constraints}
|
||||
|
||||
{negative_rules}
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<clips> 下面每个镜头用一个 <clip> 标签,属性 image_index 是图片序号(从0开始)、transition 取 fade/cut/zoom_in/slide_left/dissolve/wipe 之一、zoom 取 in/out/null、duration_sec 是该镜头秒数、bgm_note 是该段BGM情绪。每个 <clip> 里面包含:
|
||||
<voice_text> 该镜头配音文本(纯口播文本,不加旁白标注);
|
||||
<subtitle_text> 字幕文本,可与配音一致或更精简;
|
||||
<shot_type_angle_movement> 景别+角度+运镜(例:近景俯拍45度,缓慢推镜;中景平视,固定镜头;特写平视,快速拉镜);
|
||||
<scene_and_dialogue> 画面场景描述 + 人物口播台词(对白要自然口语化,像朋友聊天,不要硬广推销腔);
|
||||
<action_details> 人物动作、表情、物品操作细节(手怎么动、表情变化、产品怎么展示);
|
||||
<audio_bgm> 环境音+BGM提示(例:轻快流行BGM,环境嘈杂咖啡店背景音);
|
||||
<transition> 硬切/淡入淡出/叠化(最后一镜写『结束』即可);
|
||||
<reference_image_index> 参考图片索引(0-based,对应第几张产品图,无则空);
|
||||
<ken_burns> 用一个空标签,属性 start、end 写"x,y"坐标、ease 写缓动方式;不需要运镜时坐标相同。"""
|
||||
|
||||
_STORYBOARD_USER = """目标时长:{duration}秒
|
||||
上传图片数量:{image_count}张(第1张是主图/封面)
|
||||
文案内容:
|
||||
{fusion_result}
|
||||
图片分析结果:
|
||||
{image_analysis}
|
||||
|
||||
重要:overview_theme 必须与图片实际内容和营销目的匹配。门店/餐饮/服务类图片用"门店探店·到店体验";商品图用"好物分享·产品种草";人物图用"穿搭分享·人物故事";场景图用"空间体验·场景氛围"。不要对所有图片都使用"好物分享"。
|
||||
|
||||
请按标签格式输出分镜。"""
|
||||
|
||||
_STORYBOARD_EXAMPLE = """<clips>
|
||||
<clip image_index="0" transition="cut" zoom="null" duration_sec="3" bgm_note="日常、轻微烦躁">
|
||||
<voice_text>这油污我真的忍很久了</voice_text>
|
||||
<subtitle_text>这油污忍很久了</subtitle_text>
|
||||
<shot_type_angle_movement>近景俯拍45度,缓慢推镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>厨房台面,主妇皱眉看着灶台油污。对白:这油污我真的忍很久了</scene_and_dialogue>
|
||||
<action_details>右手拿着脏抹布,无奈摇头</action_details>
|
||||
<audio_bgm>轻快日常BGM,带一点烦躁感</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="zoom_in" zoom="in" duration_sec="6" bgm_note="轻快、出现转机">
|
||||
<voice_text>后来换了大公鸡头油污净,喷上等几分钟,一擦就干净</voice_text>
|
||||
<subtitle_text>喷上等几分钟,一擦就干净</subtitle_text>
|
||||
<shot_type_angle_movement>特写平视,固定镜头</shot_type_angle_movement>
|
||||
<scene_and_dialogue>手部特写,喷油污净在油污处。对白:后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净</scene_and_dialogue>
|
||||
<action_details>左手拿产品瓶身,右手按压喷头,等待片刻后用抹布轻擦</action_details>
|
||||
<audio_bgm>轻快转折BGM,带清爽感</audio_bgm>
|
||||
<transition>淡入淡出</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="20,20" end="80,80" ease="ease-in-out"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="fade" zoom="null" duration_sec="4" bgm_note="温暖、推荐">
|
||||
<voice_text>39块钱625ml,厨房重油污的可以试一瓶</voice_text>
|
||||
<subtitle_text>39元625ml,可以试一瓶</subtitle_text>
|
||||
<shot_type_angle_movement>中景平视,缓慢拉镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>产品正面展示,明亮背景。对白:39块钱625ml,厨房重油污的可以试一瓶</scene_and_dialogue>
|
||||
<action_details>产品置于画面中央,轻微转动展示瓶身</action_details>
|
||||
<audio_bgm>温暖收尾BGM</audio_bgm>
|
||||
<transition>结束</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="50,50" end="20,20" ease="ease-in-out"/>
|
||||
</clip>
|
||||
</clips>"""
|
||||
|
||||
# ── 模板5:文案审核(LLM)──────────────────────────────────────────────
|
||||
_REVIEW_SYSTEM = f"""你是短视频文案合规审核员,从6个维度逐条检查文案:
|
||||
1. 违规词:有没有平台禁用词、敏感词。
|
||||
2. 夸大承诺:有没有“包治百病”“100%有效”“保证赚钱”等绝对化、夸大表述。
|
||||
3. 事实一致性:有没有编造价格、数据、认证,或者用户没提到的产品特性。
|
||||
4. 用户意图保留:在 ai_polish 和 user_primary 模式下,core_messages 中 must_keep=true 的点是否都保留了。
|
||||
5. 结构完整性:标题、钩子、正文、行动号召是否齐全。
|
||||
6. 语气人设:是否符合选定的人设语气,有没有“家人们谁懂啊”“绝绝子”“宝子们”等套路词。
|
||||
|
||||
{GLOBAL_CONSTRAINTS}
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<passed> 整体是否通过,只写 true 或 false。
|
||||
<issues> 每个问题用一个 <issue> 标签,属性 dimension 是维度名、severity 取 error 或 warning、location 是问题所在(如 hook、body_points、cta),标签内容写问题描述;没有问题就输出空标签。
|
||||
<rewrite_suggestions> 每条具体修改建议用一个 <suggestion> 标签;没有就输出空标签。"""
|
||||
|
||||
_REVIEW_USER = """本次创作模式:{fusion_level}
|
||||
待审核文案:
|
||||
{fusion_result}
|
||||
用户意图解析(用于核对核心信息是否保留):
|
||||
{intent_result}
|
||||
|
||||
请按6个维度审核,按标签格式输出。"""
|
||||
|
||||
_REVIEW_EXAMPLE = """<passed>false</passed>
|
||||
<issues>
|
||||
<issue dimension="夸大承诺" severity="error" location="body_points">出现了“一喷100%掉光”的绝对化表述,违反广告法</issue>
|
||||
<issue dimension="用户意图保留" severity="warning" location="cta">用户强调的“39块钱”没有保留</issue>
|
||||
</issues>
|
||||
<rewrite_suggestions>
|
||||
<suggestion>把“一喷100%掉光”改为“喷上等几分钟,大部分油污能擦掉”</suggestion>
|
||||
<suggestion>在结尾补回“39块钱625ml”</suggestion>
|
||||
</rewrite_suggestions>"""
|
||||
_REVIEW_EXAMPLE = """<review>
|
||||
<passed>false</passed>
|
||||
<issues>
|
||||
<issue dimension="夸大承诺" severity="error">使用绝对化用语"100%有效",违反广告法</issue>
|
||||
<issue dimension="违规词" severity="warning">使用套路词"绝绝子"</issue>
|
||||
</issues>
|
||||
</review>"""
|
||||
|
||||
|
||||
# 5 套模板默认数据(seed 数据源与 loader 的兜底)
|
||||
DEFAULT_TEMPLATES: list[dict] = [
|
||||
{
|
||||
"name": "图片多模态分析",
|
||||
"name": "图片多模态分析 v8",
|
||||
"prompt_type": "image_analysis",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"version": 8,
|
||||
"system_prompt": _IMAGE_ANALYSIS_SYSTEM,
|
||||
"user_prompt_template": _IMAGE_ANALYSIS_USER,
|
||||
"example_output": _IMAGE_ANALYSIS_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "用户文案意图解析",
|
||||
"prompt_type": "intent_parsing",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"system_prompt": _INTENT_SYSTEM,
|
||||
"user_prompt_template": _INTENT_USER,
|
||||
"example_output": _INTENT_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "文案融合生成",
|
||||
"prompt_type": "copy_fusion",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"system_prompt": _FUSION_SYSTEM,
|
||||
"user_prompt_template": _FUSION_USER,
|
||||
"example_output": _FUSION_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "编导级分镜",
|
||||
"name": "编导级分镜 v3.1",
|
||||
"prompt_type": "storyboard",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"version": 4,
|
||||
"system_prompt": _STORYBOARD_SYSTEM,
|
||||
"user_prompt_template": _STORYBOARD_USER,
|
||||
"example_output": _STORYBOARD_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "文案审核",
|
||||
"name": "文案审核 v2",
|
||||
"prompt_type": "review",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"version": 2,
|
||||
"system_prompt": _REVIEW_SYSTEM,
|
||||
"user_prompt_template": _REVIEW_USER,
|
||||
"example_output": _REVIEW_EXAMPLE,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""文案审核 + 自动重写(#2040 第5套 Prompt)。
|
||||
# -*- coding: utf-8 -*-
|
||||
"""文案审核(PR1 简化版)。
|
||||
|
||||
6 维度:违规词 / 夸大承诺 / 事实一致性 / 用户意图保留 / 结构完整性 / 语气人设。
|
||||
LLM 审核之外叠加本地规则预检(保证即使 LLM 不可用也能兜住广告法红线)。
|
||||
只做两件事:
|
||||
1. 本地规则预检:广告法绝对化用语 + 套路烂梗词(COMPLIANCE_BLACKLIST);
|
||||
2. LLM 审核(单参数 voiceover_script),LLM 不可用时降级放行并塞 warning issue。
|
||||
|
||||
不再有 rewrite / rule_fix / intent 保留检查等死路径(intent 在新链路里永远是空的,
|
||||
personal_brands/must_keep 检查在原链路里也从未真正生效)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,16 +21,12 @@ from packages.application.viral_video.prompt_loader import (
|
||||
render_system_prompt,
|
||||
render_user_prompt,
|
||||
)
|
||||
from packages.application.viral_video.schemas import (
|
||||
FusionResult,
|
||||
IntentResult,
|
||||
ReviewIssue,
|
||||
ReviewResult,
|
||||
)
|
||||
from packages.application.viral_video.prompts import COMPLIANCE_BLACKLIST
|
||||
from packages.application.viral_video.schemas import ReviewIssue, ReviewResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 本地规则:绝对化/夸大词
|
||||
# 本地规则:绝对化/夸大词(广告法红线,兜底挡住最明显违规)
|
||||
_EXAGGERATION_PATTERNS = [
|
||||
r"100\s*%",
|
||||
r"百分百",
|
||||
@@ -39,61 +40,87 @@ _EXAGGERATION_PATTERNS = [
|
||||
r"一喷(就|全|100)",
|
||||
]
|
||||
|
||||
# 本地规则:平台违规/套路词
|
||||
_VIOLATION_PHRASES = [
|
||||
"家人们谁懂啊",
|
||||
"绝绝子",
|
||||
"宝子们",
|
||||
"yyds",
|
||||
"最(好|强|牛|便宜)", # 广告法极限词
|
||||
"第一(名|品牌)?",
|
||||
# 本地规则:套路烂梗/明显错别字(与 prompts.COMPLIANCE_BLACKLIST 同步)
|
||||
_VIOLATION_PATTERNS = [re.escape(p) for p in COMPLIANCE_BLACKLIST] + [
|
||||
r"最(好|强|牛|便宜|美|棒)", # 广告法极限词(最X)
|
||||
r"第一(名|品牌)?",
|
||||
r"很近", # 常见错别字,应使用"最近"
|
||||
]
|
||||
|
||||
_LOCATIONS = ["title", "hook", "body_points", "cta", "script_segments"]
|
||||
|
||||
|
||||
class Reviewer:
|
||||
"""口播文案审核器。"""
|
||||
|
||||
def __init__(self, client=None):
|
||||
if client is None:
|
||||
try:
|
||||
from packages.shared.ai_router import ai_router
|
||||
|
||||
client = ai_router.get_llm_client("copy_review")
|
||||
except Exception:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
client = get_doubao_client()
|
||||
client = get_doubao_client()
|
||||
except Exception: # noqa: BLE001
|
||||
client = None
|
||||
self.client = client
|
||||
|
||||
# ── 审核 ────────────────────────────────────────────────────────────
|
||||
def review(self, fusion: FusionResult, intent: IntentResult, fusion_level: str) -> ReviewResult:
|
||||
local = self._rule_check(fusion, intent, fusion_level)
|
||||
llm_result = self._llm_review(fusion, intent, fusion_level)
|
||||
def review(
|
||||
self,
|
||||
voiceover_script: str,
|
||||
copy_display_markdown: str = "",
|
||||
) -> ReviewResult:
|
||||
"""审核口播文案。
|
||||
|
||||
Args:
|
||||
voiceover_script: 完整口播稿字符串(必传)。
|
||||
copy_display_markdown: 展示给用户看的 Markdown 文案(可选,保留接口)。
|
||||
|
||||
Returns:
|
||||
ReviewResult: passed=True 表示放行;LLM 不可用时降级放行(带 warning issue)。
|
||||
"""
|
||||
text = voiceover_script or ""
|
||||
local = self._check_compliance(text)
|
||||
llm_result = self._llm_review(text)
|
||||
if llm_result is None:
|
||||
# LLM 审核失败(超时/网络/不可用),降级放行
|
||||
degraded_issue = ReviewIssue(
|
||||
dimension="审核降级",
|
||||
severity="warning",
|
||||
location="voiceover_script",
|
||||
text="LLM 审核不可用,已降级为本地规则审核放行,请人工复核",
|
||||
)
|
||||
return ReviewResult(
|
||||
passed=not local,
|
||||
issues=local,
|
||||
passed=not any(i.severity == "error" for i in local),
|
||||
issues=local + [degraded_issue],
|
||||
rewrite_suggestions=[],
|
||||
raw="",
|
||||
)
|
||||
# LLM 与本地规则合并去重
|
||||
issues = self._merge_issues(llm_result.issues, local)
|
||||
has_error = any(i.severity == "error" for i in issues)
|
||||
return ReviewResult(
|
||||
passed=llm_result.passed and not local,
|
||||
passed=llm_result.passed and not has_error,
|
||||
issues=issues,
|
||||
rewrite_suggestions=llm_result.rewrite_suggestions,
|
||||
raw=llm_result.raw,
|
||||
)
|
||||
|
||||
def _llm_review(self, fusion: FusionResult, intent: IntentResult, fusion_level: str) -> Optional[ReviewResult]:
|
||||
def _llm_review(self, voiceover_script: str) -> Optional[ReviewResult]:
|
||||
try:
|
||||
return self._llm_review_inner(voiceover_script)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[Reviewer] LLM审核调用异常,降级放行: %s", e)
|
||||
return None
|
||||
|
||||
def _llm_review_inner(self, voiceover_script: str) -> Optional[ReviewResult]:
|
||||
if not self.client or not getattr(self.client, "is_available", True):
|
||||
return None
|
||||
template = get_template("review")
|
||||
system = render_system_prompt(template)
|
||||
user = render_user_prompt(
|
||||
template,
|
||||
fusion_level=fusion_level,
|
||||
fusion_result=self._fusion_text(fusion),
|
||||
intent_result=self._intent_text(intent),
|
||||
)
|
||||
user = render_user_prompt(template, voiceover_script=voiceover_script)
|
||||
raw = self.client.chat_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
@@ -105,84 +132,52 @@ class Reviewer:
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
passed = xp.text_of(raw, "passed").strip().lower()
|
||||
passed_text = xp.text_of(raw, "passed").strip().lower()
|
||||
issues = [
|
||||
ReviewIssue(
|
||||
dimension=n["attrs"].get("dimension", "未知维度"),
|
||||
severity=n["attrs"].get("severity", "warning"),
|
||||
location=n["attrs"].get("location", ""),
|
||||
location=n["attrs"].get("location", "voiceover_script"),
|
||||
text=n["text"],
|
||||
)
|
||||
for n in xp.find_all(raw, "issue")
|
||||
if n["text"]
|
||||
]
|
||||
suggestions = [n["text"] for n in xp.find_all(raw, "suggestion") if n["text"]]
|
||||
parsed = ReviewResult(
|
||||
passed=passed == "true" and not issues,
|
||||
return ReviewResult(
|
||||
passed=passed_text == "true" and not issues,
|
||||
issues=issues,
|
||||
rewrite_suggestions=suggestions,
|
||||
raw=raw,
|
||||
)
|
||||
return parsed
|
||||
|
||||
# ── 本地规则预检 ────────────────────────────────────────────────────
|
||||
def _rule_check(self, fusion: FusionResult, intent, fusion_level: str) -> list[ReviewIssue]:
|
||||
def _check_compliance(self, text: str) -> list[ReviewIssue]:
|
||||
"""扫描口播文本中的广告法红线和套路词。"""
|
||||
issues: list[ReviewIssue] = []
|
||||
for location, text in self._segments(fusion):
|
||||
for pattern in _EXAGGERATION_PATTERNS:
|
||||
if re.search(pattern, text):
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="夸大承诺",
|
||||
severity="error",
|
||||
location=location,
|
||||
text=f"出现夸大/绝对化表述:{self._hit(text, pattern)}",
|
||||
)
|
||||
if not text:
|
||||
return issues
|
||||
for pattern in _EXAGGERATION_PATTERNS:
|
||||
if re.search(pattern, text):
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="夸大承诺",
|
||||
severity="error",
|
||||
location="voiceover_script",
|
||||
text="出现夸大/绝对化表述:" + self._hit(text, pattern),
|
||||
)
|
||||
for phrase in _VIOLATION_PHRASES:
|
||||
if re.search(phrase, text, flags=re.IGNORECASE):
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="违规词",
|
||||
severity="error",
|
||||
location=location,
|
||||
text=f"出现违规或套路词:{self._hit(text, phrase)}",
|
||||
)
|
||||
)
|
||||
|
||||
# 结构完整性
|
||||
if not fusion.title:
|
||||
issues.append(ReviewIssue(dimension="结构完整性", severity="warning", location="title", text="缺少标题"))
|
||||
if not fusion.hook:
|
||||
issues.append(ReviewIssue(dimension="结构完整性", severity="warning", location="hook", text="缺少开头钩子"))
|
||||
if not fusion.cta:
|
||||
issues.append(ReviewIssue(dimension="结构完整性", severity="warning", location="cta", text="缺少行动号召"))
|
||||
|
||||
# 用户意图保留(must_keep)
|
||||
full_text = self._fusion_text(fusion)
|
||||
if fusion_level in {"ai_polish", "user_primary"} and intent is not None:
|
||||
for message in intent.core_messages:
|
||||
if message.must_keep:
|
||||
key = self._compact(message.text)
|
||||
if key and key[:10] not in self._compact(full_text):
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="用户意图保留",
|
||||
severity="warning",
|
||||
location="script_segments",
|
||||
text=f"用户核心信息被丢失:{message.text[:30]}",
|
||||
)
|
||||
)
|
||||
for brand in intent.personal_brands:
|
||||
if brand.text and brand.text not in full_text:
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="事实一致性",
|
||||
severity="error",
|
||||
location="script_segments",
|
||||
text=f"personal_brands 事实信息未原样保留:{brand.text[:30]}",
|
||||
)
|
||||
)
|
||||
for pattern in _VIOLATION_PATTERNS:
|
||||
if re.search(pattern, text, flags=re.IGNORECASE):
|
||||
sev = "warning" if pattern == "很近" else "error"
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="违规词",
|
||||
severity=sev,
|
||||
location="voiceover_script",
|
||||
text="出现违规/套路词或错别字:" + self._hit(text, pattern),
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
@staticmethod
|
||||
@@ -192,7 +187,7 @@ class Reviewer:
|
||||
|
||||
@staticmethod
|
||||
def _compact(text: str) -> str:
|
||||
return re.sub(r"[\s,。!?、,.!?;;::\"'“”‘’()()【】\[\]]", "", text)
|
||||
return re.sub(r"[\s,。!?、,.!?;;::\"'\u201c\u201d\u2018\u2019()()【】\[\]]", "", text)
|
||||
|
||||
@staticmethod
|
||||
def _merge_issues(llm_issues: list[ReviewIssue], local: list[ReviewIssue]) -> list[ReviewIssue]:
|
||||
@@ -204,116 +199,3 @@ class Reviewer:
|
||||
merged.append(issue)
|
||||
seen.add(key)
|
||||
return merged
|
||||
|
||||
# ── 自动重写(1 次)─────────────────────────────────────────────────
|
||||
def rewrite(
|
||||
self,
|
||||
fusion: FusionResult,
|
||||
review: ReviewResult,
|
||||
intent: IntentResult,
|
||||
fusion_level: str,
|
||||
) -> FusionResult:
|
||||
from packages.application.viral_video.generator import CopyGenerator
|
||||
|
||||
template = get_template("copy_fusion")
|
||||
system_kwargs = {
|
||||
"fusion_instruction": (
|
||||
"【本次任务:按审核意见修正文案】只修改指出的问题,其他内容尽量原样保留;"
|
||||
"personal_brands 事实信息逐字保留;修正后按原标签格式完整输出。"
|
||||
),
|
||||
"global_constraints": "",
|
||||
"negative_rules": "",
|
||||
}
|
||||
issue_text = "\n".join(f"- [{i.dimension}/{i.location}] {i.text}" for i in review.issues)
|
||||
suggestion_text = "\n".join(f"- {s}" for s in review.rewrite_suggestions)
|
||||
user = render_user_prompt(
|
||||
template,
|
||||
industry="",
|
||||
target_customer="",
|
||||
marketing_purpose="",
|
||||
duration=fusion.estimated_duration or 15,
|
||||
image_analysis="(沿用原图片分析)",
|
||||
intent_result=self._intent_text(intent),
|
||||
)
|
||||
user = (
|
||||
f"{user}\n\n原文案:\n{self._fusion_text(fusion)}\n\n"
|
||||
f"审核发现的问题:\n{issue_text}\n\n修改建议:\n{suggestion_text or '(无)'}\n"
|
||||
"请输出修正后的完整文案。"
|
||||
)
|
||||
system = render_system_prompt(template, **system_kwargs)
|
||||
raw = self.client.chat_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=2048,
|
||||
timeout=25,
|
||||
)
|
||||
if not raw:
|
||||
return self._rule_fix(fusion, review)
|
||||
rewritten = CopyGenerator._parse_fusion(CopyGenerator(self.client), raw)
|
||||
if not rewritten.title and not rewritten.script_segments:
|
||||
return self._rule_fix(fusion, review)
|
||||
# 保底:personal_brands 必须保留
|
||||
full = self._fusion_text(rewritten)
|
||||
for brand in intent.personal_brands:
|
||||
if brand.text and brand.text not in full:
|
||||
rewritten.cta = (rewritten.cta + brand.text).strip()
|
||||
return rewritten
|
||||
|
||||
def _rule_fix(self, fusion: FusionResult, review: ReviewResult) -> FusionResult:
|
||||
"""LLM 重写不可用时的本地兜底:删除/替换明显违规表述。"""
|
||||
replacements = [
|
||||
(re.compile(r"100\s*%|百分百"), "大部分"),
|
||||
(re.compile(r"绝对(有效|安全|靠谱)"), "比较\\1"),
|
||||
(re.compile(r"包治百病"), "适用多种情况"),
|
||||
(re.compile(r"立刻见效"), "坚持使用会有改善"),
|
||||
(re.compile(r"一喷(就|全|100%)"), "喷上等一会儿可以"),
|
||||
(re.compile(r"家人们谁懂啊|绝绝子|宝子们|yyds", re.IGNORECASE), ""),
|
||||
(re.compile(r"最好|最强|最牛|最便宜"), "很不错"),
|
||||
]
|
||||
|
||||
def fix(text: str) -> str:
|
||||
for pattern, repl in replacements:
|
||||
text = pattern.sub(repl, text)
|
||||
return text
|
||||
|
||||
fusion.title = fix(fusion.title)
|
||||
fusion.hook = fix(fusion.hook)
|
||||
fusion.cta = fix(fusion.cta)
|
||||
for point in fusion.body_points:
|
||||
point.text = fix(point.text)
|
||||
point.elaboration = fix(point.elaboration)
|
||||
for segment in fusion.script_segments:
|
||||
segment.text = fix(segment.text)
|
||||
fusion.raw = ""
|
||||
return fusion
|
||||
|
||||
# ── 文本工具 ────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _segments(fusion: FusionResult):
|
||||
yield "title", fusion.title
|
||||
yield "hook", fusion.hook
|
||||
for point in fusion.body_points:
|
||||
yield "body_points", f"{point.text} {point.elaboration}"
|
||||
yield "cta", fusion.cta
|
||||
for segment in fusion.script_segments:
|
||||
yield "script_segments", segment.text
|
||||
|
||||
@staticmethod
|
||||
def _fusion_text(fusion: FusionResult) -> str:
|
||||
parts = [fusion.title, fusion.hook]
|
||||
parts += [p.text for p in fusion.body_points]
|
||||
parts += [s.text for s in fusion.script_segments]
|
||||
parts.append(fusion.cta)
|
||||
return "\n".join(p for p in parts if p)
|
||||
|
||||
@staticmethod
|
||||
def _intent_text(intent) -> str:
|
||||
if intent is None:
|
||||
return "无意图信息"
|
||||
parts = [f"意图:{intent.intent_summary}"]
|
||||
parts += [f"核心信息[must_keep={m.must_keep}]:{m.text}" for m in intent.core_messages]
|
||||
parts += [f"事实({b.category}):{b.text}" for b in intent.personal_brands]
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -13,6 +13,13 @@ from typing import Optional
|
||||
_OPEN_RE = re.compile(r"<(?P<tag>[\w-]+)(?P<attrs>(?:\s(?:[^>]*?\S)?)?)(?P<self>/?)>")
|
||||
_CLOSE_RE = re.compile(r"</(?P<tag>[\w-]+)\s*>")
|
||||
_ATTR_RE = re.compile(r"""([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""")
|
||||
_CDATA_RE = re.compile(r"^<!\[CDATA\[(.*)\]\]>$", re.DOTALL)
|
||||
|
||||
|
||||
def _strip_cdata(s: str) -> str:
|
||||
"""剥离 LLM 可能照抄示例输出的 ``<![CDATA[...]]>`` 包裹层。"""
|
||||
m = _CDATA_RE.match(s.strip())
|
||||
return m.group(1) if m else s
|
||||
|
||||
|
||||
def parse_attributes(raw: str) -> dict[str, str]:
|
||||
@@ -58,6 +65,7 @@ def parse_tags(text: Optional[str]) -> list[dict]:
|
||||
if stack[idx]["tag"] == tag:
|
||||
node = stack[idx]
|
||||
node["text"] = unescape(text[node["_start"] : token.start()].strip())
|
||||
node["text"] = _strip_cdata(node["text"])
|
||||
node.pop("_start", None)
|
||||
del stack[idx:]
|
||||
break
|
||||
@@ -65,6 +73,7 @@ def parse_tags(text: Optional[str]) -> list[dict]:
|
||||
for node in stack:
|
||||
if "_start" in node:
|
||||
node["text"] = unescape(text[node["_start"] :].strip())
|
||||
node["text"] = _strip_cdata(node["text"])
|
||||
node.pop("_start", None)
|
||||
return results
|
||||
|
||||
|
||||
@@ -173,6 +173,73 @@ class SharedSettings(BaseSettings):
|
||||
# 判断 Worker 可用的心跳新鲜度窗口(秒)—— last_heartbeat_at 在窗口内视为在线
|
||||
gpu_worker_stale_seconds: int = 300
|
||||
|
||||
# ── Ditto 蚂蚁数字人口型 API(#2076)─────────────────────────────────
|
||||
# 是否优先使用 Ditto(蚂蚁数字人,替代 MuseTalk)。开关开启且 base_url 配置
|
||||
# 非空时,对口型任务优先走 Ditto;失败后回退 MuseTalk/MediaKit。
|
||||
use_ditto_lipsync: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("USE_DITTO_LIPSYNC", "use_ditto_lipsync"),
|
||||
)
|
||||
# Ditto FastAPI 内网地址(Tailscale),如 http://100.x.x.x:8000
|
||||
ditto_api_base_url: str = Field(
|
||||
default="",
|
||||
validation_alias=AliasChoices("DITTO_API_BASE_URL", "ditto_api_base_url"),
|
||||
)
|
||||
# 默认人物模板视频 URL(兜底用:用户未上传视频时使用,或视频预处理失败时回退)。
|
||||
# 正面 5-10 秒、光线均匀、半身 1080x1920 竖版;正常流程下 Ditto 优先使用用户上传的 video_url。
|
||||
ditto_default_video_url: str = Field(
|
||||
default="",
|
||||
validation_alias=AliasChoices("DITTO_DEFAULT_VIDEO_URL", "ditto_default_video_url"),
|
||||
)
|
||||
# 429 GPU 繁忙时指数退避最大重试次数
|
||||
ditto_max_retries: int = Field(
|
||||
default=3,
|
||||
validation_alias=AliasChoices("DITTO_MAX_RETRIES", "ditto_max_retries"),
|
||||
)
|
||||
# Ditto 单次请求 read 超时(秒):数字人半身视频推理通常 30-120s(RTF≈2.8,40s音频约112s)
|
||||
# connect 超时固定 10s(代码硬编码,网络不通快速失败)
|
||||
ditto_request_timeout: int = Field(
|
||||
default=120,
|
||||
validation_alias=AliasChoices("DITTO_REQUEST_TIMEOUT", "ditto_request_timeout"),
|
||||
)
|
||||
# Ditto 句间过渡帧数(平滑表情/口型切换)
|
||||
ditto_blend_frames: int = Field(
|
||||
default=12,
|
||||
validation_alias=AliasChoices("DITTO_BLEND_FRAMES", "ditto_blend_frames"),
|
||||
)
|
||||
|
||||
# ── Ditto LLM 情绪分析(emo_timeline)──────────────────────────────
|
||||
# 总开关;关闭或 LLM 失败时走 GPU 端关键词匹配兜底
|
||||
ditto_emotion_enabled: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_ENABLED", "ditto_emotion_enabled"),
|
||||
)
|
||||
ditto_emotion_model: str = Field(
|
||||
default="doubao-seed-2-1-lite-250915",
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_MODEL", "ditto_emotion_model"),
|
||||
)
|
||||
ditto_emotion_temperature: float = Field(
|
||||
default=0.8,
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_TEMPERATURE", "ditto_emotion_temperature"),
|
||||
)
|
||||
ditto_emotion_timeout: int = Field(
|
||||
default=10,
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_TIMEOUT", "ditto_emotion_timeout"),
|
||||
)
|
||||
ditto_emotion_max_tokens: int = Field(
|
||||
default=1024,
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_MAX_TOKENS", "ditto_emotion_max_tokens"),
|
||||
)
|
||||
ditto_emotion_cache_size: int = Field(
|
||||
default=500,
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_CACHE_SIZE", "ditto_emotion_cache_size"),
|
||||
)
|
||||
# 提示词模板:必须包含 {文案} 占位符;后台可通过环境变量覆盖
|
||||
ditto_emotion_prompt: str = Field(
|
||||
default="",
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_PROMPT", "ditto_emotion_prompt"),
|
||||
)
|
||||
|
||||
# ── P4000 NVENC 硬件编码 ────────────────────────────────────────────
|
||||
# GPU 编码总开关;关闭或 endpoint 为空时始终走本机 CPU libx264
|
||||
enable_gpu_encode: bool = Field(
|
||||
|
||||
@@ -8,7 +8,12 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
from datetime import UTC
|
||||
except ImportError:
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""系统配置领域实体 — #2246.
|
||||
|
||||
承载 setting_type(bool/int/float/string/json)及 setting_value 的
|
||||
序列化/反序列化规则;与 DB、框架无关。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
SETTING_TYPE_BOOL = "bool"
|
||||
SETTING_TYPE_INT = "int"
|
||||
SETTING_TYPE_FLOAT = "float"
|
||||
SETTING_TYPE_STRING = "string"
|
||||
SETTING_TYPE_JSON = "json"
|
||||
|
||||
VALID_SETTING_TYPES = {
|
||||
SETTING_TYPE_BOOL,
|
||||
SETTING_TYPE_INT,
|
||||
SETTING_TYPE_FLOAT,
|
||||
SETTING_TYPE_STRING,
|
||||
SETTING_TYPE_JSON,
|
||||
}
|
||||
|
||||
|
||||
class SystemSettingError(ValueError):
|
||||
"""系统配置类型或序列化错误."""
|
||||
|
||||
|
||||
def infer_setting_type(value: Any) -> str:
|
||||
"""根据 Python 值推断 setting_type(bool 必须先于 int 判断)."""
|
||||
if isinstance(value, bool):
|
||||
return SETTING_TYPE_BOOL
|
||||
if isinstance(value, int):
|
||||
return SETTING_TYPE_INT
|
||||
if isinstance(value, float):
|
||||
return SETTING_TYPE_FLOAT
|
||||
if isinstance(value, str):
|
||||
return SETTING_TYPE_STRING
|
||||
return SETTING_TYPE_JSON
|
||||
|
||||
|
||||
def serialize_setting_value(value: Any, setting_type: str) -> str:
|
||||
"""把 Python 值按 setting_type 序列化为可入库的字符串."""
|
||||
if setting_type == SETTING_TYPE_BOOL:
|
||||
if not isinstance(value, bool):
|
||||
raise SystemSettingError(f"bool 配置值必须是布尔类型,收到 {value!r}")
|
||||
return "true" if value else "false"
|
||||
if setting_type == SETTING_TYPE_INT:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise SystemSettingError(f"int 配置值必须是整数,收到 {value!r}")
|
||||
return str(value)
|
||||
if setting_type == SETTING_TYPE_FLOAT:
|
||||
if isinstance(value, bool):
|
||||
raise SystemSettingError(f"float 配置值不能是布尔类型,收到 {value!r}")
|
||||
try:
|
||||
return repr(float(value))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SystemSettingError(f"float 配置值非法:{value!r}") from exc
|
||||
if setting_type == SETTING_TYPE_STRING:
|
||||
if not isinstance(value, str):
|
||||
raise SystemSettingError(f"string 配置值必须是字符串,收到 {value!r}")
|
||||
return value
|
||||
if setting_type == SETTING_TYPE_JSON:
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SystemSettingError(f"json 配置值无法序列化:{value!r}") from exc
|
||||
raise SystemSettingError(f"未知 setting_type: {setting_type}")
|
||||
|
||||
|
||||
def deserialize_setting_value(raw: str | None, setting_type: str) -> Any:
|
||||
"""把入库字符串按 setting_type 反序列化为 Python 值."""
|
||||
if raw is None:
|
||||
return None
|
||||
if setting_type == SETTING_TYPE_BOOL:
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
if setting_type == SETTING_TYPE_INT:
|
||||
try:
|
||||
return int(str(raw).strip())
|
||||
except ValueError as exc:
|
||||
raise SystemSettingError(f"int 配置值损坏:{raw!r}") from exc
|
||||
if setting_type == SETTING_TYPE_FLOAT:
|
||||
try:
|
||||
return float(str(raw).strip())
|
||||
except ValueError as exc:
|
||||
raise SystemSettingError(f"float 配置值损坏:{raw!r}") from exc
|
||||
if setting_type == SETTING_TYPE_STRING:
|
||||
return raw
|
||||
if setting_type == SETTING_TYPE_JSON:
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SystemSettingError(f"json 配置值损坏:{raw!r}") from exc
|
||||
raise SystemSettingError(f"未知 setting_type: {setting_type}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SystemSetting:
|
||||
"""系统配置领域实体."""
|
||||
|
||||
setting_key: str
|
||||
setting_value: str | None = None
|
||||
setting_type: str = SETTING_TYPE_STRING
|
||||
description: str = ""
|
||||
is_public: bool = False
|
||||
category: str = "general"
|
||||
updated_by: str | None = None
|
||||
id: str = field(default_factory=lambda: uuid4().hex)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
def get_typed_value(self) -> Any:
|
||||
return deserialize_setting_value(self.setting_value, self.setting_type)
|
||||
|
||||
@classmethod
|
||||
def from_value(
|
||||
cls,
|
||||
setting_key: str,
|
||||
value: Any,
|
||||
setting_type: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> "SystemSetting":
|
||||
st = setting_type or infer_setting_type(value)
|
||||
return cls(
|
||||
setting_key=setting_key,
|
||||
setting_value=serialize_setting_value(value, st),
|
||||
setting_type=st,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -107,6 +107,7 @@ class ViralVideoJob:
|
||||
# v1.5.1 音频/视频参数
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
language: str = "zh-CN"
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
# v1.4+ 产物
|
||||
@@ -243,6 +244,13 @@ class ViralVideoJob:
|
||||
self.heartbeat_at = _now
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
self.updated_at = _now
|
||||
# 清空上一次渲染留下的错误/产物(参考 resume_from_image_analyzed 对齐)
|
||||
self.error_msg = ""
|
||||
self.result_video_url = ""
|
||||
self.current_stage = ""
|
||||
self.phase_message = ""
|
||||
self.completed_at = None
|
||||
self.credits_cost = 0.0
|
||||
|
||||
def resume_from_confirm(self) -> None:
|
||||
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
|
||||
@@ -259,9 +267,12 @@ class ViralVideoJob:
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_failed(self, error_msg: str) -> None:
|
||||
def mark_failed(self, error_msg: str, stage: str = "") -> None:
|
||||
self.status = ViralVideoStatus.FAILED
|
||||
self.error_msg = error_msg
|
||||
# 失败时持久化失败阶段,避免前端错误归因
|
||||
if stage:
|
||||
self.current_stage = stage
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -337,13 +337,13 @@ class DoubaoClient:
|
||||
self.last_finish_reason = finish_reason
|
||||
_elapsed = time.time() - _t0
|
||||
logger.info(
|
||||
"[doubao] chat_completion 完成 model=%s tokens_in=%d tokens_out=%d elapsed=%.1fs attempt=%d timeout=%d",
|
||||
"[doubao] chat_completion 完成 model=%s tokens_in=%d tokens_out=%d elapsed=%.1fs attempt=%d timeout=%s",
|
||||
payload.get("model"),
|
||||
data.get("usage", {}).get("prompt_tokens", 0),
|
||||
data.get("usage", {}).get("completion_tokens", 0),
|
||||
_elapsed,
|
||||
attempt + 1,
|
||||
_req_timeout,
|
||||
getattr(_req_timeout, "read", _req_timeout),
|
||||
)
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
@@ -363,6 +363,111 @@ class DoubaoClient:
|
||||
logger.error("豆包API调用最终失败: elapsed=%.1fs err=%s", time.time() - _t0, last_error)
|
||||
return None
|
||||
|
||||
def chat_completion_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int | None = None,
|
||||
model: str | None = None,
|
||||
timeout: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""流式调用 Chat Completion 接口(SSE),逐块 yield delta 文本。
|
||||
|
||||
Yields:
|
||||
str: 增量文本片段(delta.content);全部结束后 StopIteration。
|
||||
失败时 yield 空并返回(由调用方决定是否降级到同步调用)。
|
||||
|
||||
注意:
|
||||
- 流式不做 finish_reason=length 自动扩容(流式难以拼接重试);
|
||||
如果调用方需要 length 截断处理,建议自行 fallback 到同步 chat_completion。
|
||||
- 重试只在连接建立阶段(首包之前)有效;一旦开始 yield,错误直接抛出。
|
||||
"""
|
||||
if not self.is_available:
|
||||
return
|
||||
|
||||
import json as _json
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
effective_max_tokens = max_tokens if max_tokens is not None else (self.max_tokens or 1024)
|
||||
payload: dict[str, Any] = {
|
||||
"model": model or self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": effective_max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
if self.extra_params:
|
||||
payload.update(self.extra_params)
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
|
||||
_t0 = time.time()
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
_req_timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
if timeout:
|
||||
_req_timeout = httpx.Timeout(connect=10.0, read=max(int(timeout), 30), write=30.0, pool=10.0)
|
||||
with httpx.stream(
|
||||
"POST",
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=_req_timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_str = line[5:].strip()
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = _json.loads(data_str)
|
||||
except (_json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content_piece = delta.get("content") or ""
|
||||
if content_piece:
|
||||
yield content_piece
|
||||
finish_reason = choices[0].get("finish_reason")
|
||||
if finish_reason:
|
||||
self.last_finish_reason = finish_reason
|
||||
_elapsed = time.time() - _t0
|
||||
logger.info(
|
||||
"[doubao] chat_completion_stream 完成 model=%s elapsed=%.1fs attempt=%d",
|
||||
payload.get("model"),
|
||||
_elapsed,
|
||||
attempt + 1,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包流式API失败,%.1fs后重试 (%d/%d, elapsed=%.1fs): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
time.time() - _t0,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.error("豆包流式API最终失败: elapsed=%.1fs err=%s", time.time() - _t0, e)
|
||||
return
|
||||
|
||||
def vision_completion(
|
||||
self,
|
||||
messages: list[dict],
|
||||
@@ -636,6 +741,10 @@ class DoubaoClient:
|
||||
resolution=resolution,
|
||||
output_dir=output_dir,
|
||||
model=video_model,
|
||||
generate_audio=bool(generate_audio),
|
||||
reference_images=reference_images,
|
||||
reference_audios=reference_audios,
|
||||
reference_videos=reference_videos,
|
||||
)
|
||||
if not result and hasattr(ds, "last_video_error") and ds.last_video_error:
|
||||
self.last_video_error = dict(ds.last_video_error)
|
||||
|
||||
@@ -51,6 +51,8 @@ task_routes = {
|
||||
"ai_avatar_render.execute": {"queue": QUEUE_GENERATION},
|
||||
# GPU MuseTalk 口型同步(用户等成片,链路子任务全部走 generation 避免跨队列阻塞)
|
||||
"lipsync_gpu_process_async": {"queue": QUEUE_GENERATION},
|
||||
# #2076 Ditto 蚂蚁数字人口型同步(走 generation 队列,避免跨队列阻塞)
|
||||
"lipsync_ditto_process_async": {"queue": QUEUE_GENERATION},
|
||||
"lipsync_tts.synthesize_and_submit": {"queue": QUEUE_GENERATION},
|
||||
"lipsync_tts.poll_mediakit_status": {"queue": QUEUE_GENERATION},
|
||||
"lipsync_tts.persist_output_video": {"queue": QUEUE_GENERATION},
|
||||
|
||||
@@ -67,10 +67,12 @@ def _classify_dashscope_error(status_code: int, body: str, task_msg: str = "") -
|
||||
return "invalid_param", f"Wan 3.0 参数错误:{msg_in_body or body[:200]}"
|
||||
if status_code == 0:
|
||||
return "network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。"
|
||||
# 任务内失败
|
||||
# 任务内失败(异步轮询结果):此时 HTTP 200 只是查询成功,不代表任务成功,提示文案不要带 HTTP 200 以免误导
|
||||
if task_msg and any(kw in task_msg.lower() for kw in ("portrait", "真人", "人脸", "violation", "blocked")):
|
||||
return "portrait_intercept", "Wan 3.0 视频内容被安全策略拦截,请调整文案或参考图后重试。"
|
||||
detail = msg_in_body or body[:200]
|
||||
if task_msg and status_code == 200:
|
||||
return "task_failed", f"Wan 3.0 视频生成失败:{detail}"
|
||||
return "unknown", f"Wan 3.0 视频生成失败(HTTP {status_code}):{detail}"
|
||||
|
||||
|
||||
@@ -115,10 +117,20 @@ class DashScopeClient:
|
||||
watermark: bool = False,
|
||||
output_dir: str | None = None,
|
||||
model: str = "wan3.0-video",
|
||||
generate_audio: bool = True,
|
||||
reference_images: list[str] | None = None,
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
) -> dict | None:
|
||||
"""调用 DashScope 异步视频合成接口,轮询完成后下载到本地。
|
||||
"""调用 DashScope Wan 3.0 异步视频合成接口,轮询完成后下载到本地。
|
||||
|
||||
返回 {"video_path": str, "usage": dict | None};失败返回 None,错误详情写入 self.last_video_error。
|
||||
官方协议(input.media 数组 + parameters.audio):
|
||||
- 仅 1 张图且无其它参考 -> type=first_frame(首帧模式,严格从该帧起)。
|
||||
- 有参考音频 / 多张图 -> 图片全部走 type=reference_image(全能参考模式,
|
||||
可与 reference_audio 共存);prompt 用"图1/图2/音频1"按 media 顺序引用。
|
||||
- parameters.audio 控制输出是否含音轨;参考音频通过 media 传入。
|
||||
|
||||
返回 {"video_path": str, "usage": dict | None};失败返回 None,错误写入 self.last_video_error。
|
||||
"""
|
||||
self.last_video_error = {}
|
||||
if not self.is_available:
|
||||
@@ -130,7 +142,7 @@ class DashScopeClient:
|
||||
return None
|
||||
|
||||
# DashScope 分辨率参数:720P / 1080P / 480P(大写 P)
|
||||
res_upper = (resolution or "720p").upper().replace("P", "P")
|
||||
res_upper = (resolution or "720p").upper()
|
||||
if res_upper == "480P":
|
||||
ds_res = "480P"
|
||||
elif res_upper == "1080P":
|
||||
@@ -138,16 +150,35 @@ class DashScopeClient:
|
||||
else:
|
||||
ds_res = "720P"
|
||||
|
||||
# 构造 input+parameters
|
||||
# ── 构造官方 media 数组 ────────────────────────────────────────
|
||||
ref_imgs = [u for u in (reference_images or [])[:10] if u]
|
||||
ref_auds = [u for u in (reference_audios or [])[:5] if u]
|
||||
ref_vids = [u for u in (reference_videos or [])[:5] if u]
|
||||
|
||||
media: list[dict[str, Any]] = []
|
||||
all_imgs = ([image_url] if image_url else []) + [u for u in ref_imgs if u != image_url]
|
||||
use_first_frame = bool(image_url) and len(all_imgs) == 1 and not (ref_auds or ref_vids)
|
||||
if use_first_frame:
|
||||
media.append({"type": "first_frame", "url": image_url})
|
||||
else:
|
||||
for u in all_imgs:
|
||||
media.append({"type": "reference_image", "url": u})
|
||||
for u in ref_vids:
|
||||
media.append({"type": "reference_video", "url": u})
|
||||
for u in ref_auds:
|
||||
media.append({"type": "reference_audio", "url": u})
|
||||
|
||||
# ── input + parameters ─────────────────────────────────────────
|
||||
input_obj: dict[str, Any] = {"prompt": prompt.strip()}
|
||||
if image_url:
|
||||
input_obj["img_url"] = image_url
|
||||
if media:
|
||||
input_obj["media"] = media
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"resolution": ds_res,
|
||||
"duration": str(float(duration)),
|
||||
"duration": int(duration),
|
||||
"watermark": bool(watermark),
|
||||
"audio": bool(generate_audio),
|
||||
}
|
||||
# 比例透传:Wan 支持 "9:16" / "16:9" / "1:1" 等
|
||||
if ratio and ratio != "adaptive":
|
||||
params["aspect_ratio"] = ratio
|
||||
|
||||
@@ -163,14 +194,15 @@ class DashScopeClient:
|
||||
}
|
||||
create_url = f"{self.base_url}/services/aigc/video-generation/video-synthesis"
|
||||
logger.info(
|
||||
"[dashscope] 创建任务: model=%s dur=%ds ratio=%s res=%s img=%s",
|
||||
"[dashscope] 创建任务: model=%s dur=%ds ratio=%s res=%s media=%d audio=%s",
|
||||
model,
|
||||
duration,
|
||||
ratio,
|
||||
ds_res,
|
||||
bool(image_url),
|
||||
len(media),
|
||||
generate_audio,
|
||||
)
|
||||
logger.info("[dashscope] 创建任务 payload: model=%s params=%s", model, params)
|
||||
logger.info("[dashscope] media types: %s", [m["type"] for m in media])
|
||||
|
||||
# 创建任务
|
||||
task_id: str | None = None
|
||||
@@ -196,27 +228,14 @@ class DashScopeClient:
|
||||
if tid:
|
||||
task_id = tid
|
||||
break
|
||||
# 部分情况下 code != 错误
|
||||
code = data.get("code")
|
||||
if code and code != "":
|
||||
err_code, user_msg = _classify_dashscope_error(400, body_text, str(code))
|
||||
if code:
|
||||
logger.error("[dashscope] 创建任务返回 code=%s body=%s", code, body_text)
|
||||
err_code, user_msg = _classify_dashscope_error(sc, body_text)
|
||||
self._set_error(err_code, user_msg, sc, body_text, model=model)
|
||||
return None
|
||||
else:
|
||||
self._set_error("unknown", "Wan 3.0 响应格式异常,未返回任务ID", sc, str(data)[:500], model=model)
|
||||
return None
|
||||
except _HTTP_NETWORK_ERRORS as ne:
|
||||
last_sc = 0
|
||||
last_body = f"network error: {ne}"
|
||||
logger.warning(
|
||||
"[dashscope] 网络异常 %s,重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1
|
||||
)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
self._set_error("network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。", 0, str(ne))
|
||||
return None
|
||||
except Exception as _e:
|
||||
logger.warning("[dashscope] 创建任务异常(attempt=%d): %s", attempt, _e)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
@@ -258,7 +277,6 @@ class DashScopeClient:
|
||||
video_url = out.get("video_url") or ""
|
||||
usage = d.get("usage")
|
||||
if not video_url:
|
||||
# 结果在 results 数组
|
||||
results = out.get("results") or []
|
||||
if results and isinstance(results, list):
|
||||
video_url = results[0].get("url") or results[0].get("video_url")
|
||||
@@ -284,7 +302,6 @@ class DashScopeClient:
|
||||
logger.warning("[dashscope] 任务 %s 被取消", task_id)
|
||||
self._set_error("unknown", "Wan 3.0 任务被取消。", 200, "task cancelled", task_id=task_id)
|
||||
return None
|
||||
# PENDING / RUNNING / SUSPENDED → 继续轮询
|
||||
if poll_count % 5 == 0:
|
||||
logger.info("[dashscope] 轮询中 task=%s status=%s polls=%d", task_id, task_status, poll_count)
|
||||
except Exception as e:
|
||||
|
||||
@@ -334,7 +334,7 @@ class SharedStorageService(StoragePort):
|
||||
|
||||
storage_key = self.normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
signed = sign_bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
signed = sign_bucket.sign_url("GET", storage_key, expires_seconds, slash_safe=True)
|
||||
logger.info(
|
||||
"signed URL generated for key=%s prefix=%s",
|
||||
storage_key[:80],
|
||||
|
||||
@@ -12,3 +12,22 @@ export CI_LOCAL_PG_PORT="${CI_LOCAL_PG_PORT:-5432}"
|
||||
|
||||
# === 默认数据库名 ===
|
||||
export CI_DEFAULT_DB="${CI_DEFAULT_DB:-xiaoxia_saas}"
|
||||
|
||||
# === Python 版本保障:本项目依赖 datetime.UTC,需要 Python >= 3.11 ===
|
||||
_ensure_python311() {
|
||||
if python3 -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
for cand in python3.12 python3.11 /opt/python3.12/bin/python3; do
|
||||
if command -v "$cand" >/dev/null 2>&1 && "$cand" -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
|
||||
_d="$(dirname "$(command -v "$cand")")"
|
||||
export PATH="$_d:$PATH"
|
||||
hash -r
|
||||
echo "✅ ci_env: 切换到 $cand ($("$cand" -c 'import sys; print(sys.version.split()[0])'))"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "❌ ci_env: 未找到 Python >= 3.11(datetime.UTC 需要),请安装 Python 3.11/3.12" >&2
|
||||
return 1
|
||||
}
|
||||
_ensure_python311
|
||||
|
||||
@@ -4,8 +4,28 @@
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
# shellcheck source=ci_env.sh
|
||||
source "${SCRIPT_DIR}/ci_env.sh"
|
||||
|
||||
|
||||
echo "=== Installing mypy ==="
|
||||
python3 -m pip install -q mypy
|
||||
# pip源容错: 默认源(阿里云)缺文件时fallback到清华/官方源(2026-10-10 librt-0.6.0 metadata 404)
|
||||
MYPY_SPEC="mypy<1.19"
|
||||
INSTALL_OK=0
|
||||
python3 -m pip install -q "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "WARN: default pip index failed, retry tsinghua mirror..."
|
||||
python3 -m pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
fi
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "WARN: tsinghua mirror failed, retry pypi.org..."
|
||||
python3 -m pip install -q -i https://pypi.org/simple "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
fi
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "ERROR: pip install mypy failed on all indexes"
|
||||
exit 1
|
||||
fi
|
||||
mypy --version
|
||||
echo ""
|
||||
echo "=== Running mypy type check (hard gate mode) ==="
|
||||
|
||||
@@ -7,9 +7,29 @@ JOB_NAME="${1:-Unit Tests}"
|
||||
|
||||
echo "=== CI Unit Tests 开始 ==="
|
||||
|
||||
# --- 依赖缓存检查 ---
|
||||
# --- Python 版本选择(必须 >= 3.11,代码使用 datetime.UTC)---
|
||||
if ! python3 -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
|
||||
for cand in python3.12 python3.11 /opt/python3.12/bin/python3; do
|
||||
if command -v "$cand" >/dev/null 2>&1 && "$cand" -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
|
||||
PY3_DIR="$(dirname "$(command -v "$cand")")"
|
||||
export PATH="$PY3_DIR:$PATH"
|
||||
hash -r
|
||||
echo "✅ python3 版本过低,改用 $cand ($("$cand" -c "import sys; print(sys.version.split()[0])"))"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if ! python3 -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
|
||||
echo "❌ 未找到 Python >= 3.11,本项目要求 Python 3.11+(使用 datetime.UTC)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
PYVER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
echo "使用 Python 版本: $(python3 --version)"
|
||||
|
||||
# --- 依赖缓存检查(按 Python 版本区分缓存文件,避免跨版本复用)---
|
||||
# 如果 requirements 文件未变化且依赖已安装,跳过 pip install(持久 runner 优化)
|
||||
REQ_HASH_FILE="/tmp/.ci_unit_tests_req_hash"
|
||||
REQ_HASH_FILE="/tmp/.ci_unit_tests_req_hash_py${PYVER}"
|
||||
CURRENT_REQ_HASH=""
|
||||
if [ -f requirements-base.txt ] && [ -f requirements.txt ] && [ -f requirements-dev.txt ]; then
|
||||
CURRENT_REQ_HASH=$(cat requirements-base.txt requirements.txt requirements-dev.txt | md5sum | cut -d' ' -f1)
|
||||
|
||||
@@ -77,6 +77,11 @@ set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
# shellcheck source=ci_env.sh
|
||||
source "${SCRIPT_DIR}/ci_env.sh"
|
||||
|
||||
if [ "$BANDIT_EXIT" -ne 0 ]; then
|
||||
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
|
||||
else
|
||||
|
||||
@@ -108,8 +108,10 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""爆款视频 5 套 Prompt 模板种子脚本(#2040)。
|
||||
"""爆款视频 Prompt 模板种子脚本(#2040,PR1 清理至 3 套:image_analysis/storyboard/review)。
|
||||
|
||||
幂等:以 (prompt_type, version) 为唯一键,存在则更新(UPSERT),重复执行结果一致。
|
||||
用法:
|
||||
@@ -72,7 +72,7 @@ def seed(engine) -> int:
|
||||
def main() -> int:
|
||||
engine = _engine()
|
||||
count = seed(engine)
|
||||
print(f"seed 完成:{count} 套模板已写入/更新(image_analysis/intent_parsing/copy_fusion/storyboard/review)")
|
||||
print(f"seed 完成:{count} 套模板已写入/更新(image_analysis/storyboard/review)")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -196,3 +196,159 @@ class TestGetDoubaoClient:
|
||||
"""返回 DoubaoClient 实例"""
|
||||
client = get_doubao_client()
|
||||
assert isinstance(client, DoubaoClient)
|
||||
|
||||
|
||||
class TestChatCompletionStream:
|
||||
"""chat_completion_stream 方法测试"""
|
||||
|
||||
def _fake_sse(self, chunks: list[str]):
|
||||
"""构造一个伪装的 httpx stream 响应,按 chunks 逐行返回 SSE。"""
|
||||
import json as _json
|
||||
|
||||
lines = []
|
||||
for piece in chunks:
|
||||
evt = {"choices": [{"delta": {"content": piece}, "finish_reason": None}]}
|
||||
lines.append("data: " + _json.dumps(evt, ensure_ascii=False))
|
||||
lines.append("data: " + _json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}))
|
||||
lines.append("data: [DONE]")
|
||||
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
m.__enter__ = MagicMock(return_value=m)
|
||||
m.__exit__ = MagicMock(return_value=False)
|
||||
m.raise_for_status = MagicMock()
|
||||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||||
return m
|
||||
|
||||
def test_stream_yields_delta_content(self, client_with_key):
|
||||
"""流式调用逐段 yield delta.content"""
|
||||
import json as _json
|
||||
|
||||
fake = self._fake_sse(["你好", ",", "世界"])
|
||||
with patch("packages.shared.ai_client.httpx.stream", return_value=fake) as mock_stream:
|
||||
out = list(
|
||||
client_with_key.chat_completion_stream(
|
||||
messages=[{"role": "user", "content": "hi"}], max_tokens=100, timeout=30
|
||||
)
|
||||
)
|
||||
assert out == ["你好", ",", "世界"]
|
||||
call_kwargs = mock_stream.call_args[1]
|
||||
assert call_kwargs["json"]["stream"] is True
|
||||
assert call_kwargs["json"]["max_tokens"] == 100
|
||||
|
||||
def test_stream_unavailable_returns_empty(self, client_without_key):
|
||||
"""不可用时返回空生成器(不调用 httpx.stream)"""
|
||||
with patch("packages.shared.ai_client.httpx.stream") as mock_stream:
|
||||
out = list(client_without_key.chat_completion_stream(messages=[{"role": "user", "content": "hi"}]))
|
||||
assert out == []
|
||||
mock_stream.assert_not_called()
|
||||
|
||||
def test_stream_handles_done_marker(self, client_with_key):
|
||||
"""遇到 [DONE] 正确终止,不把它当内容"""
|
||||
import json as _json
|
||||
|
||||
lines = [
|
||||
"data: " + _json.dumps({"choices": [{"delta": {"content": "A"}}]}),
|
||||
"data: [DONE]",
|
||||
"data: " + _json.dumps({"choices": [{"delta": {"content": "NEVER"}}]}), # 应被忽略
|
||||
]
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
m.__enter__ = MagicMock(return_value=m)
|
||||
m.__exit__ = MagicMock(return_value=False)
|
||||
m.raise_for_status = MagicMock()
|
||||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||||
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
|
||||
out = list(
|
||||
client_with_key.chat_completion_stream(
|
||||
messages=[{"role": "user", "content": "hi"}], max_tokens=50, timeout=30
|
||||
)
|
||||
)
|
||||
assert out == ["A"]
|
||||
|
||||
def test_stream_skips_empty_delta(self, client_with_key):
|
||||
"""空 delta(role 等 metadata)不应产出内容"""
|
||||
import json as _json
|
||||
|
||||
lines = [
|
||||
"data: " + _json.dumps({"choices": [{"delta": {"role": "assistant"}}]}),
|
||||
"data: " + _json.dumps({"choices": [{"delta": {"content": "hi"}}]}),
|
||||
"data: " + _json.dumps({"choices": [{"delta": {}}]}),
|
||||
"data: [DONE]",
|
||||
]
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
m.__enter__ = MagicMock(return_value=m)
|
||||
m.__exit__ = MagicMock(return_value=False)
|
||||
m.raise_for_status = MagicMock()
|
||||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||||
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
|
||||
out = list(
|
||||
client_with_key.chat_completion_stream(
|
||||
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
|
||||
)
|
||||
)
|
||||
assert out == ["hi"]
|
||||
|
||||
def test_stream_invalid_json_lines_skipped(self, client_with_key):
|
||||
"""SSE 行里脏数据/非 JSON 不应中断流"""
|
||||
import json as _json
|
||||
|
||||
lines = [
|
||||
"data: " + _json.dumps({"choices": [{"delta": {"content": "ok"}}]}),
|
||||
"data: not-a-json",
|
||||
":comment line",
|
||||
"",
|
||||
"event: ping",
|
||||
"data: " + _json.dumps({"choices": [{"delta": {"content": "2"}}]}),
|
||||
"data: [DONE]",
|
||||
]
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
m.__enter__ = MagicMock(return_value=m)
|
||||
m.__exit__ = MagicMock(return_value=False)
|
||||
m.raise_for_status = MagicMock()
|
||||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||||
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
|
||||
out = list(
|
||||
client_with_key.chat_completion_stream(
|
||||
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
|
||||
)
|
||||
)
|
||||
assert out == ["ok", "2"]
|
||||
|
||||
def test_stream_network_error_yields_empty(self, client_with_key):
|
||||
"""网络错误(重试耗尽)yield 空,不抛异常给调用方"""
|
||||
import httpx as _httpx
|
||||
|
||||
# max_retries=2 → 3 次总尝试
|
||||
client = client_with_key
|
||||
client.max_retries = 1 # 只重试 1 次,缩短测试
|
||||
with patch("packages.shared.ai_client.httpx.stream", side_effect=_httpx.ConnectError("boom")):
|
||||
out = list(
|
||||
client.chat_completion_stream(messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=5)
|
||||
)
|
||||
assert out == []
|
||||
|
||||
def test_stream_records_finish_reason(self, client_with_key):
|
||||
"""流式结束后 last_finish_reason 被正确记录"""
|
||||
import json as _json
|
||||
|
||||
lines = [
|
||||
"data: " + _json.dumps({"choices": [{"delta": {"content": "x"}, "finish_reason": None}]}),
|
||||
"data: " + _json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}),
|
||||
"data: [DONE]",
|
||||
]
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
m.__enter__ = MagicMock(return_value=m)
|
||||
m.__exit__ = MagicMock(return_value=False)
|
||||
m.raise_for_status = MagicMock()
|
||||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||||
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
|
||||
list(
|
||||
client_with_key.chat_completion_stream(
|
||||
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
|
||||
)
|
||||
)
|
||||
assert client_with_key.last_finish_reason == "stop"
|
||||
|
||||
+233
-250
@@ -1,209 +1,210 @@
|
||||
"""AI Router 单元测试 — 23 cases covering routing/cache/fallback/client construction."""
|
||||
"""AI Router 单元测试 — routing/cache/fallback/client construction.
|
||||
|
||||
本文件只做*用例级* mock:通过 autouse fixture 在每个用例内 patch
|
||||
``packages.shared.config.get_shared_settings`` / ``packages.shared.ai_router.get_shared_settings``
|
||||
并在退出时自动恢复,绝不在模块顶层替换 ``sys.modules``,因此不会污染同进程的
|
||||
其他测试模块(如 test_ai_client.py)。
|
||||
|
||||
在 Python 3.12 且依赖齐全的 CI 环境中,直接 import 真实模块即可;Redis / DB
|
||||
会话通过 patch 隔离。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ── Pre-mock heavy import chain to avoid pulling in full app ──
|
||||
_mock_config = MagicMock()
|
||||
_mock_settings = MagicMock()
|
||||
_mock_settings.doubao_model = "doubao-seed-2-1-pro-260915"
|
||||
_mock_settings.doubao_fast_model = "doubao-seed-2-1-pro-260915"
|
||||
_mock_settings.doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
_mock_settings.doubao_api_key = "test-key"
|
||||
_mock_settings.doubao_timeout = 45
|
||||
_mock_settings.doubao_max_retries = 1
|
||||
_mock_settings.doubao_image_model = "doubao-seedream-5-0-flash-260915"
|
||||
_mock_settings.doubao_image_timeout = 60
|
||||
_mock_settings.doubao_video_model = "doubao-seedance-2-5-260628"
|
||||
_mock_settings.doubao_video_timeout = 600
|
||||
_mock_settings.dashscope_api_key = "ds-key"
|
||||
_mock_settings.cosyvoice_api_key = "cv-key"
|
||||
_mock_settings.cosyvoice_base_url = "https://dashscope.aliyuncs.com/api/v1"
|
||||
_mock_settings.cosyvoice_model = "cosyvoice-v3-flash"
|
||||
_mock_settings.redis_url = "redis://localhost:6379/0"
|
||||
_mock_settings.celery_broker_url = "redis://localhost:6379/0"
|
||||
_mock_config.get_shared_settings.return_value = _mock_settings
|
||||
import pytest
|
||||
|
||||
# Prevent the full packages.shared from loading
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if "packages.shared" in mod_name and "ai_router" not in mod_name and "ai_config_version" not in mod_name:
|
||||
pass # don't remove, just prevent new imports
|
||||
from packages.shared import ai_config_version as _config_version_mod
|
||||
from packages.shared import ai_router as ai_router_mod
|
||||
|
||||
# Direct import of our modules (bypassing __init__.py)
|
||||
import importlib.util
|
||||
import os
|
||||
# ── 统一的假配置(等价于旧文件里的 _mock_settings)──────────────────────────
|
||||
|
||||
|
||||
def _load_module_from_file(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
def _make_mock_settings() -> MagicMock:
|
||||
s = MagicMock()
|
||||
s.doubao_model = "doubao-seed-2-1-pro-260915"
|
||||
s.doubao_fast_model = "doubao-seed-2-1-pro-260915"
|
||||
s.doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
s.doubao_api_key = "test-key"
|
||||
s.doubao_timeout = 45
|
||||
s.doubao_max_retries = 1
|
||||
s.doubao_image_model = "doubao-seedream-5-0-flash-260915"
|
||||
s.doubao_image_timeout = 60
|
||||
s.doubao_vision_model = "doubao-seed-1-6-vision-250615"
|
||||
s.doubao_video_model = "doubao-seedance-2-5-260628"
|
||||
s.doubao_video_timeout = 600
|
||||
s.dashscope_api_key = "ds-key"
|
||||
s.dashscope_base_url = "https://dashscope.aliyuncs.com/api/v1"
|
||||
s.dashscope_model = "qwen-vl-max"
|
||||
s.cosyvoice_api_key = "cv-key"
|
||||
s.cosyvoice_base_url = "https://dashscope.aliyuncs.com/api/v1"
|
||||
s.cosyvoice_model = "cosyvoice-v3-flash"
|
||||
s.redis_url = "redis://localhost:6379/0"
|
||||
s.celery_broker_url = "redis://localhost:6379/0"
|
||||
return s
|
||||
|
||||
|
||||
# Load ai_config_version
|
||||
_ai_config_version = _load_module_from_file(
|
||||
"packages.shared.ai_config_version",
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "packages", "shared", "ai_config_version.py"),
|
||||
)
|
||||
# Patch get_shared_settings in the loaded module
|
||||
_ai_config_version.get_shared_settings = lambda: _mock_settings
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_settings_fixture():
|
||||
"""每个用例内 patch 配置来源,退出即恢复,不污染 sys.modules。"""
|
||||
settings = _make_mock_settings()
|
||||
with (
|
||||
patch("packages.shared.config.get_shared_settings", return_value=settings),
|
||||
patch.object(ai_router_mod, "get_shared_settings", return_value=settings),
|
||||
):
|
||||
yield settings
|
||||
|
||||
# Load ai_router - needs packages.shared.config to be available
|
||||
sys.modules["packages.shared.config"] = MagicMock()
|
||||
sys.modules["packages.shared.config"].get_shared_settings = lambda: _mock_settings
|
||||
|
||||
# Mock packages.shared.ai_client to avoid triggering packages.shared.__init__ chain
|
||||
# (which fails on Python 3.10 due to datetime.UTC import in packages.domain)
|
||||
_mock_ai_client = MagicMock()
|
||||
def _capability_row() -> MagicMock:
|
||||
row = MagicMock()
|
||||
row.capability_key = "intent_parsing"
|
||||
row.capability_name = "文案意图解析"
|
||||
row.timeout_seconds = 45
|
||||
row.max_retries = 1
|
||||
row.max_tokens = None
|
||||
row.temperature = None
|
||||
row.concurrency = 2
|
||||
row.extra_params = {}
|
||||
row.is_enabled = True
|
||||
row.pm_id = "model-1"
|
||||
row.pm_name = "豆包"
|
||||
row.pm_provider = "volcengine"
|
||||
row.pm_model_key = "doubao-seed-1-6-250615"
|
||||
row.pm_api_key = "test-key"
|
||||
row.pm_api_base = "https://ark.test.com"
|
||||
row.pm_api_version = None
|
||||
row.pm_status = "active"
|
||||
row.lm_id = None
|
||||
row.fm_id = None
|
||||
return row
|
||||
|
||||
class _FakeDoubaoClient:
|
||||
"""Fake DoubaoClient for testing - mimics the real interface."""
|
||||
def __init__(self, api_key="", base_url="", model="", timeout=0, max_retries=0,
|
||||
max_tokens=None, temperature=None, extra_params=None, provider="volcengine"):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.extra_params = extra_params or {}
|
||||
self.provider = provider
|
||||
self.vision_model = model
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return bool(self.api_key)
|
||||
def _model_config(**overrides):
|
||||
kwargs = dict(
|
||||
id="m1",
|
||||
name="test",
|
||||
provider="volcengine",
|
||||
model_key="test-model",
|
||||
api_key="key",
|
||||
api_base="https://test.com",
|
||||
api_version=None,
|
||||
status="active",
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return ai_router_mod.ModelConfig(**kwargs)
|
||||
|
||||
def chat_completion(self, messages, **kwargs):
|
||||
return None
|
||||
|
||||
def vision_completion(self, messages, **kwargs):
|
||||
return None
|
||||
def _capability_config(**overrides):
|
||||
kwargs = dict(
|
||||
capability_key="test",
|
||||
capability_name="test",
|
||||
primary_model=None,
|
||||
lite_model=None,
|
||||
fallback_model=None,
|
||||
timeout_seconds=30,
|
||||
max_retries=1,
|
||||
max_tokens=None,
|
||||
temperature=None,
|
||||
concurrency=2,
|
||||
extra_params={},
|
||||
is_enabled=True,
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return ai_router_mod.CapabilityConfig(**kwargs)
|
||||
|
||||
_mock_ai_client.DoubaoClient = _FakeDoubaoClient
|
||||
sys.modules["packages.shared.ai_client"] = _mock_ai_client
|
||||
|
||||
_ai_router = _load_module_from_file(
|
||||
"packages.shared.ai_router",
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "packages", "shared", "ai_router.py"),
|
||||
)
|
||||
# ── Redis 版本号机制 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAIConfigVersion(unittest.TestCase):
|
||||
"""Redis 版本号机制测试"""
|
||||
|
||||
@patch.object(_ai_config_version, "_get_redis_client")
|
||||
@patch.object(_config_version_mod, "_get_redis_client")
|
||||
def test_bump_version_success(self, mock_redis_fn):
|
||||
mock_r = MagicMock()
|
||||
mock_r.set.return_value = True
|
||||
mock_redis_fn.return_value = mock_r
|
||||
ver = _ai_config_version.bump_version()
|
||||
ver = _config_version_mod.bump_version()
|
||||
self.assertTrue(ver)
|
||||
self.assertTrue(ver.isdigit())
|
||||
mock_r.set.assert_called_once()
|
||||
|
||||
@patch.object(_ai_config_version, "_get_redis_client")
|
||||
@patch.object(_config_version_mod, "_get_redis_client")
|
||||
def test_bump_version_redis_unavailable(self, mock_redis_fn):
|
||||
mock_redis_fn.return_value = None
|
||||
ver = _ai_config_version.bump_version()
|
||||
ver = _config_version_mod.bump_version()
|
||||
self.assertEqual(ver, "")
|
||||
|
||||
@patch.object(_ai_config_version, "_get_redis_client")
|
||||
@patch.object(_config_version_mod, "_get_redis_client")
|
||||
def test_get_version_success(self, mock_redis_fn):
|
||||
mock_r = MagicMock()
|
||||
mock_r.get.return_value = "1234567890"
|
||||
mock_redis_fn.return_value = mock_r
|
||||
ver = _ai_config_version.get_version()
|
||||
ver = _config_version_mod.get_version()
|
||||
self.assertEqual(ver, "1234567890")
|
||||
|
||||
@patch.object(_ai_config_version, "_get_redis_client")
|
||||
@patch.object(_config_version_mod, "_get_redis_client")
|
||||
def test_get_version_redis_down(self, mock_redis_fn):
|
||||
mock_redis_fn.return_value = None
|
||||
ver = _ai_config_version.get_version()
|
||||
ver = _config_version_mod.get_version()
|
||||
self.assertIsNone(ver)
|
||||
|
||||
@patch.object(_ai_config_version, "_get_redis_client")
|
||||
@patch.object(_config_version_mod, "_get_redis_client")
|
||||
def test_get_version_exception(self, mock_redis_fn):
|
||||
mock_r = MagicMock()
|
||||
mock_r.get.side_effect = Exception("connection refused")
|
||||
mock_redis_fn.return_value = mock_r
|
||||
ver = _ai_config_version.get_version()
|
||||
ver = _config_version_mod.get_version()
|
||||
self.assertIsNone(ver)
|
||||
|
||||
|
||||
# ── AIRouter 路由/缓存/fallback ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAIRouter(unittest.TestCase):
|
||||
"""AIRouter 路由/缓存/fallback 测试"""
|
||||
|
||||
def setUp(self):
|
||||
self.router = _ai_router.AIRouter()
|
||||
self.router = ai_router_mod.AIRouter()
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_get_capability_db_unavailable(self, mock_ver):
|
||||
with patch.object(_ai_router, "_get_session", return_value=None):
|
||||
cap = self.router.get_capability("intent_parsing")
|
||||
self.assertIsNone(cap)
|
||||
def _freeze_version(self, value=None):
|
||||
"""让 get_capability 的版本比对固定,避免走 Redis。"""
|
||||
return patch.object(_config_version_mod, "get_version", return_value=value)
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_get_capability_from_db(self, mock_ver):
|
||||
def test_get_capability_db_unavailable(self):
|
||||
with self._freeze_version(None):
|
||||
with patch.object(ai_router_mod, "_get_session", return_value=None):
|
||||
cap = self.router.get_capability("intent_parsing")
|
||||
self.assertIsNone(cap)
|
||||
|
||||
def test_get_capability_from_db(self):
|
||||
mock_session = MagicMock()
|
||||
mock_row = MagicMock()
|
||||
mock_row.capability_key = "intent_parsing"
|
||||
mock_row.capability_name = "文案意图解析"
|
||||
mock_row.timeout_seconds = 45
|
||||
mock_row.max_retries = 1
|
||||
mock_row.max_tokens = None
|
||||
mock_row.temperature = None
|
||||
mock_row.concurrency = 2
|
||||
mock_row.extra_params = {}
|
||||
mock_row.is_enabled = True
|
||||
mock_row.pm_id = "model-1"
|
||||
mock_row.pm_name = "豆包"
|
||||
mock_row.pm_provider = "volcengine"
|
||||
mock_row.pm_model_key = "doubao-seed-1-6-250615"
|
||||
mock_row.pm_api_key = "test-key"
|
||||
mock_row.pm_api_base = "https://ark.test.com"
|
||||
mock_row.pm_api_version = None
|
||||
mock_row.pm_status = "active"
|
||||
mock_row.lm_id = None
|
||||
mock_row.fm_id = None
|
||||
mock_session.execute.return_value.first.return_value = mock_row
|
||||
mock_session.execute.return_value.first.return_value = _capability_row()
|
||||
with self._freeze_version(None):
|
||||
with patch.object(ai_router_mod, "_get_session", return_value=mock_session):
|
||||
cap = self.router.get_capability("intent_parsing")
|
||||
self.assertIsNotNone(cap)
|
||||
self.assertEqual(cap.capability_key, "intent_parsing")
|
||||
self.assertEqual(cap.primary_model.model_key, "doubao-seed-1-6-250615")
|
||||
|
||||
with patch.object(_ai_router, "_get_session", return_value=mock_session):
|
||||
cap = self.router.get_capability("intent_parsing")
|
||||
self.assertIsNotNone(cap)
|
||||
self.assertEqual(cap.capability_key, "intent_parsing")
|
||||
self.assertEqual(cap.primary_model.model_key, "doubao-seed-1-6-250615")
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", side_effect=[None, "v2"])
|
||||
def test_cache_invalidation_on_version_change(self, mock_ver):
|
||||
with patch.object(self.router, "_load_from_db", return_value=None):
|
||||
self.router.get_capability("test_key")
|
||||
def test_cache_invalidation_on_version_change(self):
|
||||
with self._freeze_version(None):
|
||||
with patch.object(self.router, "_load_from_db", return_value=None):
|
||||
self.router.get_capability("test_key")
|
||||
self.router._local_ver = "v1"
|
||||
self.assertTrue(self.router._check_version())
|
||||
with patch.object(_config_version_mod, "get_version", return_value="v2"):
|
||||
self.assertTrue(self.router._check_version())
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value="same_ver")
|
||||
def test_cache_hit_same_version(self, mock_ver):
|
||||
model = _ai_router.ModelConfig(
|
||||
id="m1", name="test", provider="volcengine", model_key="test-model",
|
||||
api_key="key", api_base="https://test.com", api_version=None, status="active",
|
||||
)
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="test", capability_name="test", primary_model=model,
|
||||
lite_model=None, fallback_model=None, timeout_seconds=30,
|
||||
max_retries=1, max_tokens=None, temperature=None, concurrency=2,
|
||||
extra_params={}, is_enabled=True,
|
||||
def test_cache_hit_same_version(self):
|
||||
cap = _capability_config(
|
||||
primary_model=_model_config(model_key="test-model"),
|
||||
)
|
||||
self.router._cache["test"] = cap
|
||||
self.router._local_ver = "same_ver"
|
||||
result = self.router.get_capability("test")
|
||||
with patch.object(_config_version_mod, "get_version", return_value="same_ver"):
|
||||
result = self.router.get_capability("test")
|
||||
self.assertEqual(result, cap)
|
||||
|
||||
def test_invalidate_clears_cache(self):
|
||||
@@ -213,181 +214,163 @@ class TestAIRouter(unittest.TestCase):
|
||||
self.assertEqual(len(self.router._cache), 0)
|
||||
self.assertIsNone(self.router._local_ver)
|
||||
|
||||
@patch.object(_ai_router, "_get_session", return_value=None)
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_get_llm_client_fallback(self, mock_ver, mock_session):
|
||||
_ai_router.get_shared_settings = lambda: _mock_settings
|
||||
client = self.router.get_llm_client("intent_parsing")
|
||||
def test_get_llm_client_fallback(self):
|
||||
with self._freeze_version(None):
|
||||
with patch.object(ai_router_mod, "_get_session", return_value=None):
|
||||
client = self.router.get_llm_client("intent_parsing")
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "doubao-seed-2-1-pro-260915")
|
||||
self.assertEqual(client.api_key, "test-key")
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_get_llm_client_from_db(self, mock_ver):
|
||||
model = _ai_router.ModelConfig(
|
||||
id="m1", name="test", provider="dashscope", model_key="qwen3.8-flash",
|
||||
api_key="db-key", api_base="https://dashscope.test.com", api_version=None, status="active",
|
||||
)
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="image_analysis", capability_name="图片分析",
|
||||
primary_model=model, lite_model=None, fallback_model=None,
|
||||
timeout_seconds=15, max_retries=1, max_tokens=350, temperature=0.1,
|
||||
concurrency=2, extra_params={}, is_enabled=True,
|
||||
def test_get_llm_client_from_db(self):
|
||||
cap = _capability_config(
|
||||
capability_key="image_analysis",
|
||||
capability_name="图片分析",
|
||||
max_tokens=350,
|
||||
temperature=0.1,
|
||||
primary_model=_model_config(
|
||||
provider="dashscope",
|
||||
model_key="qwen3.8-flash",
|
||||
api_key="db-key",
|
||||
api_base="https://dashscope.test.com",
|
||||
),
|
||||
)
|
||||
with patch.object(self.router, "get_capability", return_value=cap):
|
||||
client = self.router.get_llm_client("image_analysis")
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "qwen3.8-flash")
|
||||
self.assertEqual(client.provider, "dashscope")
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "qwen3.8-flash")
|
||||
self.assertEqual(client.provider, "dashscope")
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_get_vision_client(self, mock_ver):
|
||||
model = _ai_router.ModelConfig(
|
||||
id="m1", name="test", provider="dashscope", model_key="qwen3.8-flash",
|
||||
api_key="key", api_base="https://dashscope.test.com", api_version=None, status="active",
|
||||
)
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="image_analysis", capability_name="图片分析",
|
||||
primary_model=model, lite_model=None, fallback_model=None,
|
||||
timeout_seconds=15, max_retries=1, max_tokens=None, temperature=None,
|
||||
concurrency=2, extra_params={}, is_enabled=True,
|
||||
def test_get_vision_client(self):
|
||||
cap = _capability_config(
|
||||
capability_key="image_analysis",
|
||||
capability_name="图片分析",
|
||||
primary_model=_model_config(
|
||||
provider="dashscope",
|
||||
model_key="qwen3.8-flash",
|
||||
api_key="key",
|
||||
api_base="https://dashscope.test.com",
|
||||
),
|
||||
)
|
||||
with patch.object(self.router, "get_capability", return_value=cap):
|
||||
client = self.router.get_vision_client("image_analysis")
|
||||
self.assertIsNotNone(client)
|
||||
# #2220: vision client is now DoubaoClient with vision_completion
|
||||
self.assertTrue(hasattr(client, "vision_completion"))
|
||||
self.assertIsNotNone(client)
|
||||
# #2220: vision client is now DoubaoClient with vision_completion
|
||||
self.assertTrue(hasattr(client, "vision_completion"))
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_get_tts_client(self, mock_ver):
|
||||
model = _ai_router.ModelConfig(
|
||||
id="m1", name="test", provider="dashscope", model_key="cosyvoice-v3-flash",
|
||||
api_key="key", api_base="https://dashscope.test.com", api_version=None, status="active",
|
||||
)
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="tts", capability_name="语音合成",
|
||||
primary_model=model, lite_model=None, fallback_model=None,
|
||||
timeout_seconds=60, max_retries=1, max_tokens=None, temperature=None,
|
||||
concurrency=2, extra_params={}, is_enabled=True,
|
||||
def test_get_tts_client(self):
|
||||
cap = _capability_config(
|
||||
capability_key="tts",
|
||||
capability_name="语音合成",
|
||||
primary_model=_model_config(
|
||||
provider="dashscope",
|
||||
model_key="cosyvoice-v3-flash",
|
||||
api_key="key",
|
||||
api_base="https://dashscope.test.com",
|
||||
),
|
||||
)
|
||||
with patch.object(self.router, "get_capability", return_value=cap):
|
||||
client = self.router.get_tts_client()
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "cosyvoice-v3-flash")
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "cosyvoice-v3-flash")
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_get_image_gen_client(self, mock_ver):
|
||||
model = _ai_router.ModelConfig(
|
||||
id="m1", name="test", provider="volcengine", model_key="seedream-5.0-flash",
|
||||
api_key="key", api_base="https://ark.test.com", api_version=None, status="active",
|
||||
)
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="image_generation", capability_name="图片生成",
|
||||
primary_model=model, lite_model=None, fallback_model=None,
|
||||
timeout_seconds=60, max_retries=1, max_tokens=None, temperature=None,
|
||||
concurrency=2, extra_params={"size": "1K"}, is_enabled=True,
|
||||
def test_get_image_gen_client(self):
|
||||
cap = _capability_config(
|
||||
capability_key="image_generation",
|
||||
capability_name="图片生成",
|
||||
extra_params={"size": "1K"},
|
||||
primary_model=_model_config(
|
||||
model_key="seedream-5.0-flash",
|
||||
api_key="key",
|
||||
api_base="https://ark.test.com",
|
||||
),
|
||||
)
|
||||
with patch.object(self.router, "get_capability", return_value=cap):
|
||||
client = self.router.get_image_gen_client()
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "seedream-5.0-flash")
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "seedream-5.0-flash")
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_get_video_gen_client(self, mock_ver):
|
||||
model = _ai_router.ModelConfig(
|
||||
id="m1", name="test", provider="volcengine", model_key="seedance-2.5",
|
||||
api_key="key", api_base="https://ark.test.com", api_version=None, status="active",
|
||||
)
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="video_generation", capability_name="视频生成",
|
||||
primary_model=model, lite_model=None, fallback_model=None,
|
||||
timeout_seconds=600, max_retries=1, max_tokens=None, temperature=None,
|
||||
concurrency=1, extra_params={}, is_enabled=True,
|
||||
def test_get_video_gen_client(self):
|
||||
cap = _capability_config(
|
||||
capability_key="video_generation",
|
||||
capability_name="视频生成",
|
||||
concurrency=1,
|
||||
primary_model=_model_config(
|
||||
model_key="seedance-2.5",
|
||||
api_key="key",
|
||||
api_base="https://ark.test.com",
|
||||
),
|
||||
)
|
||||
with patch.object(self.router, "get_capability", return_value=cap):
|
||||
client = self.router.get_video_gen_client()
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "seedance-2.5")
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "seedance-2.5")
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_lite_variant_preference(self, mock_ver):
|
||||
primary = _ai_router.ModelConfig(id="p1", name="pro", provider="volcengine", model_key="pro-model", api_key="k", api_base="u", api_version=None, status="active")
|
||||
lite = _ai_router.ModelConfig(id="l1", name="lite", provider="volcengine", model_key="lite-model", api_key="k", api_base="u", api_version=None, status="active")
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="image_analysis", capability_name="图片分析",
|
||||
primary_model=primary, lite_model=lite, fallback_model=None,
|
||||
timeout_seconds=15, max_retries=1, max_tokens=None, temperature=None,
|
||||
concurrency=2, extra_params={}, is_enabled=True,
|
||||
def test_lite_variant_preference(self):
|
||||
cap = _capability_config(
|
||||
capability_key="image_analysis",
|
||||
capability_name="图片分析",
|
||||
primary_model=_model_config(id="p1", name="pro", model_key="pro-model", api_key="k", api_base="u"),
|
||||
lite_model=_model_config(id="l1", name="lite", model_key="lite-model", api_key="k", api_base="u"),
|
||||
)
|
||||
model = self.router._get_model_or_fallback(cap, "lite")
|
||||
self.assertEqual(model.model_key, "lite-model")
|
||||
model_primary = self.router._get_model_or_fallback(cap, "primary")
|
||||
self.assertEqual(model_primary.model_key, "pro-model")
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_disabled_capability_returns_fallback(self, mock_ver):
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="test", capability_name="test",
|
||||
primary_model=None, lite_model=None, fallback_model=None,
|
||||
timeout_seconds=30, max_retries=1, max_tokens=None, temperature=None,
|
||||
concurrency=2, extra_params={}, is_enabled=False,
|
||||
)
|
||||
_ai_router.get_shared_settings = lambda: _mock_settings
|
||||
def test_disabled_capability_returns_fallback(self):
|
||||
cap = _capability_config(is_enabled=False)
|
||||
with patch.object(self.router, "get_capability", return_value=cap):
|
||||
client = self.router.get_llm_client("test")
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "doubao-seed-2-1-pro-260915")
|
||||
self.assertIsNotNone(client)
|
||||
self.assertEqual(client.model, "doubao-seed-2-1-pro-260915")
|
||||
|
||||
@patch.object(_ai_config_version, "get_version", return_value=None)
|
||||
def test_fallback_chain_primary_none(self, mock_ver):
|
||||
def test_fallback_chain_primary_none(self):
|
||||
"""primary_model 为 None 时 fallback 到 fallback_model"""
|
||||
fb = _ai_router.ModelConfig(id="f1", name="fb", provider="volcengine", model_key="fb-model", api_key="k", api_base="u", api_version=None, status="active")
|
||||
cap = _ai_router.CapabilityConfig(
|
||||
capability_key="test", capability_name="test",
|
||||
primary_model=None, lite_model=None, fallback_model=fb,
|
||||
timeout_seconds=30, max_retries=1, max_tokens=None, temperature=None,
|
||||
concurrency=2, extra_params={}, is_enabled=True,
|
||||
cap = _capability_config(
|
||||
fallback_model=_model_config(id="f1", name="fb", model_key="fb-model", api_key="k", api_base="u"),
|
||||
)
|
||||
model = self.router._get_model_or_fallback(cap, "primary")
|
||||
self.assertEqual(model.model_key, "fb-model")
|
||||
|
||||
|
||||
# ── 数据类冻结 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModelConfig(unittest.TestCase):
|
||||
"""数据类测试"""
|
||||
|
||||
def test_model_config_frozen(self):
|
||||
m = _ai_router.ModelConfig(id="1", name="t", provider="p", model_key="k", api_key="a", api_base="b", api_version=None, status="active")
|
||||
m = _model_config(id="1", name="t", provider="p", model_key="k", api_key="a", api_base="b")
|
||||
with self.assertRaises(AttributeError):
|
||||
m.model_key = "new"
|
||||
|
||||
def test_capability_config_frozen(self):
|
||||
c = _ai_router.CapabilityConfig(
|
||||
capability_key="k", capability_name="n", primary_model=None,
|
||||
lite_model=None, fallback_model=None, timeout_seconds=30,
|
||||
max_retries=1, max_tokens=None, temperature=None, concurrency=2,
|
||||
extra_params={}, is_enabled=True,
|
||||
)
|
||||
c = _capability_config()
|
||||
with self.assertRaises(AttributeError):
|
||||
c.is_enabled = False
|
||||
|
||||
|
||||
# ── 客户端可用性 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClientAvailability(unittest.TestCase):
|
||||
"""客户端可用性测试"""
|
||||
|
||||
def test_tts_client_available(self):
|
||||
c = _ai_router.TTSClient(provider="p", api_key="k", base_url="u", model="m")
|
||||
c = ai_router_mod.TTSClient(provider="p", api_key="k", base_url="u", model="m")
|
||||
self.assertTrue(c.is_available)
|
||||
|
||||
def test_tts_client_unavailable_no_model(self):
|
||||
c = _ai_router.TTSClient(provider="p", api_key="k", base_url="u", model="")
|
||||
c = ai_router_mod.TTSClient(provider="p", api_key="k", base_url="u", model="")
|
||||
self.assertFalse(c.is_available)
|
||||
|
||||
def test_image_gen_client_unavailable_no_url(self):
|
||||
c = _ai_router.ImageGenClient(provider="p", api_key="k", base_url="", model="m")
|
||||
c = ai_router_mod.ImageGenClient(provider="p", api_key="k", base_url="", model="m")
|
||||
self.assertFalse(c.is_available)
|
||||
|
||||
def test_video_gen_client_available(self):
|
||||
c = _ai_router.VideoGenClient(provider="p", api_key="k", base_url="u", model="m")
|
||||
c = ai_router_mod.VideoGenClient(provider="p", api_key="k", base_url="u", model="m")
|
||||
self.assertTrue(c.is_available)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Ditto LLM 情绪分析服务单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.ditto_emotion_service import (
|
||||
EMO_HAPPY,
|
||||
EMO_NEUTRAL,
|
||||
DittoEmotionService,
|
||||
EmotionSegment,
|
||||
_parse_emotion_json,
|
||||
_warn_if_suspicious_all_neutral,
|
||||
align_timeline_by_length,
|
||||
align_timeline_by_timings,
|
||||
split_sentences,
|
||||
)
|
||||
|
||||
|
||||
# ── 分句 ─────────────────────────────────────────────────────────
|
||||
class TestSplitSentences:
|
||||
def test_empty(self):
|
||||
assert split_sentences("") == []
|
||||
|
||||
def test_single(self):
|
||||
assert split_sentences("你好。") == ["你好。"]
|
||||
|
||||
def test_multi(self):
|
||||
sents = split_sentences("大家好!今天给大家推荐一款超棒的产品。它真的很好用;不信你试试?")
|
||||
assert len(sents) == 4
|
||||
assert "大家好!" in sents[0]
|
||||
|
||||
def test_english_punct(self):
|
||||
sents = split_sentences("Hello! How are you? I'm fine.")
|
||||
assert len(sents) == 3
|
||||
|
||||
# ── Bug1 修复:无句末标点长文案按逗号二次切分 ──
|
||||
def test_no_ending_punct_long_text_split_by_comma(self):
|
||||
"""公益文案:全是逗号、结尾无标点,整段超过30字 → 逗号二次切分."""
|
||||
text = "做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光"
|
||||
sents = split_sentences(text)
|
||||
assert len(sents) >= 3
|
||||
assert any("心中有爱" in s for s in sents)
|
||||
assert any("眼里有光" in s for s in sents)
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
"""不超过30字的无句末标点文案不二次切分."""
|
||||
text = "大家好,欢迎收看"
|
||||
assert split_sentences(text) == [text]
|
||||
|
||||
def test_short_clauses_merged(self):
|
||||
"""过短片段(<10字)并入相邻句,避免切太碎."""
|
||||
text = "家人们,你们是不是经常买东西踩坑呢,花了大价钱买回来的东西根本没法用啊"
|
||||
sents = split_sentences(text)
|
||||
assert len(sents) == 2
|
||||
assert all(len(s) >= 10 for s in sents)
|
||||
|
||||
def test_normal_punct_no_clause_split(self):
|
||||
"""有句末标点正常切分的文案不做逗号二次切分."""
|
||||
text = "大家好!今天给大家推荐一款超棒的产品,它真的很好用。不信你试试?"
|
||||
sents = split_sentences(text)
|
||||
assert len(sents) == 3
|
||||
assert "今天给大家推荐一款超棒的产品,它真的很好用。" in sents
|
||||
|
||||
|
||||
# ── JSON 解析 ────────────────────────────────────────────────────
|
||||
class TestParseEmotionJson:
|
||||
def test_valid(self):
|
||||
raw = json.dumps([{"text": "你好", "emo": 4, "intensity": 0.2}])
|
||||
segs = _parse_emotion_json(raw)
|
||||
assert len(segs) == 1
|
||||
assert segs[0].emo == 4
|
||||
assert segs[0].intensity == 0.2
|
||||
|
||||
def test_markdown_wrapped(self):
|
||||
raw = "```json\n" + json.dumps([{"text": "好", "emo": 3, "intensity": 0.5}]) + "\n```"
|
||||
segs = _parse_emotion_json(raw)
|
||||
assert len(segs) == 1
|
||||
assert segs[0].emo == 3
|
||||
|
||||
def test_all_eight_emos_allowed(self):
|
||||
"""8 种表情全部开放:0-7 原样保留,不再强制转中性."""
|
||||
for emo in range(8):
|
||||
raw = json.dumps([{"text": "句", "emo": emo, "intensity": 0.5}])
|
||||
segs = _parse_emotion_json(raw)
|
||||
assert len(segs) == 1
|
||||
assert segs[0].emo == emo
|
||||
|
||||
def test_out_of_range_emo_becomes_neutral(self):
|
||||
"""超出 0-7 范围的非法 emo 值仍降级为中性."""
|
||||
raw = json.dumps([{"text": "句", "emo": 9, "intensity": 0.5}])
|
||||
segs = _parse_emotion_json(raw)
|
||||
assert len(segs) == 1
|
||||
assert segs[0].emo == EMO_NEUTRAL
|
||||
|
||||
def test_invalid_json(self):
|
||||
assert _parse_emotion_json("not json") == []
|
||||
|
||||
def test_empty(self):
|
||||
assert _parse_emotion_json("") == []
|
||||
|
||||
def test_intensity_clamp(self):
|
||||
raw = json.dumps([{"text": "a", "emo": 3, "intensity": 1.5}])
|
||||
segs = _parse_emotion_json(raw)
|
||||
assert segs[0].intensity == 1.0
|
||||
|
||||
def test_missing_text_skipped(self):
|
||||
raw = json.dumps([{"emo": 3, "intensity": 0.4}])
|
||||
segs = _parse_emotion_json(raw)
|
||||
assert len(segs) == 0
|
||||
|
||||
|
||||
# ── 时间对齐(按字数比例)────────────────────────────────────────
|
||||
class TestAlignTimelineByLength:
|
||||
def test_basic(self):
|
||||
segs = [
|
||||
EmotionSegment("ab", EMO_NEUTRAL, 0.2),
|
||||
EmotionSegment("cd", EMO_HAPPY, 0.5),
|
||||
]
|
||||
entries = align_timeline_by_length(segs, 4.0)
|
||||
assert len(entries) == 2
|
||||
assert entries[0].start == 0.0
|
||||
assert entries[0].end == 2.0
|
||||
assert entries[1].start == 2.0
|
||||
assert entries[1].end == 4.0
|
||||
assert entries[0].emo == EMO_NEUTRAL
|
||||
assert entries[1].emo == EMO_HAPPY
|
||||
|
||||
def test_empty_segments(self):
|
||||
assert align_timeline_by_length([], 5.0) == []
|
||||
|
||||
def test_zero_duration(self):
|
||||
segs = [EmotionSegment("ab", EMO_NEUTRAL, 0.2)]
|
||||
assert align_timeline_by_length(segs, 0) == []
|
||||
|
||||
def test_unequal_length(self):
|
||||
segs = [
|
||||
EmotionSegment("a" * 3, EMO_HAPPY, 0.5),
|
||||
EmotionSegment("b" * 1, EMO_NEUTRAL, 0.2),
|
||||
]
|
||||
entries = align_timeline_by_length(segs, 4.0)
|
||||
assert entries[0].end == 3.0
|
||||
assert entries[1].start == 3.0
|
||||
assert entries[1].end == 4.0
|
||||
|
||||
|
||||
# ── 时间对齐(sentence_timings)──────────────────────────────────
|
||||
class TestAlignTimelineByTimings:
|
||||
def test_exact_match(self):
|
||||
segs = [
|
||||
EmotionSegment("hello", EMO_HAPPY, 0.4),
|
||||
EmotionSegment("world", EMO_NEUTRAL, 0.2),
|
||||
]
|
||||
timings = [
|
||||
{"start": 0.0, "end": 1.5},
|
||||
{"start": 1.5, "end": 3.0},
|
||||
]
|
||||
entries = align_timeline_by_timings(segs, timings, 3.0)
|
||||
assert len(entries) == 2
|
||||
assert entries[0].start == 0.0
|
||||
assert entries[0].end == 1.5
|
||||
assert entries[1].start == 1.5
|
||||
assert entries[1].end == 3.0
|
||||
|
||||
def test_length_mismatch_fallback(self):
|
||||
segs = [EmotionSegment("hello", EMO_HAPPY, 0.4)]
|
||||
timings = [{"start": 0, "end": 1}, {"start": 1, "end": 2}]
|
||||
entries = align_timeline_by_timings(segs, timings, 2.0)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].end == 2.0
|
||||
|
||||
|
||||
# ── DittoEmotionService ──────────────────────────────────────────
|
||||
def _make_service(enabled=True, model=None, temperature=0.1, timeout=10, max_tokens=1024, prompt=""):
|
||||
s = MagicMock()
|
||||
s.ditto_emotion_enabled = enabled
|
||||
s.ditto_emotion_model = model or ""
|
||||
s.ditto_emotion_temperature = temperature
|
||||
s.ditto_emotion_timeout = timeout
|
||||
s.ditto_emotion_max_tokens = max_tokens
|
||||
s.ditto_emotion_cache_size = 100
|
||||
s.ditto_emotion_prompt = prompt
|
||||
return DittoEmotionService(settings=s)
|
||||
|
||||
|
||||
class TestDittoEmotionService:
|
||||
def test_disabled_returns_empty(self):
|
||||
svc = _make_service(enabled=False)
|
||||
assert svc.analyze("你好世界") == []
|
||||
|
||||
def test_empty_text_returns_empty(self):
|
||||
svc = _make_service(enabled=True)
|
||||
assert svc.analyze("") == []
|
||||
|
||||
def test_llm_success(self):
|
||||
svc = _make_service(enabled=True)
|
||||
fake_reply = json.dumps([{"text": "你好", "emo": 4, "intensity": 0.2}])
|
||||
with patch.object(svc, "_call_llm", return_value=_parse_emotion_json(fake_reply)):
|
||||
segs = svc.analyze("你好")
|
||||
assert len(segs) == 1
|
||||
assert segs[0].emo == 4
|
||||
|
||||
def test_cache_hit(self):
|
||||
svc = _make_service(enabled=True)
|
||||
fake_reply = json.dumps([{"text": "你好世界", "emo": 3, "intensity": 0.5}])
|
||||
with patch.object(svc, "_call_llm", return_value=_parse_emotion_json(fake_reply)) as mock_call:
|
||||
svc.analyze("你好世界")
|
||||
svc.analyze("你好世界")
|
||||
assert mock_call.call_count == 1
|
||||
|
||||
def test_build_timeline_empty_when_disabled(self):
|
||||
svc = _make_service(enabled=False)
|
||||
assert svc.build_timeline("test", 5.0) == ""
|
||||
|
||||
def test_build_timeline_returns_json(self):
|
||||
svc = _make_service(enabled=True)
|
||||
fake_reply = json.dumps(
|
||||
[
|
||||
{"text": "ab", "emo": 4, "intensity": 0.2},
|
||||
{"text": "cd", "emo": 3, "intensity": 0.4},
|
||||
]
|
||||
)
|
||||
with patch.object(svc, "_call_llm", return_value=_parse_emotion_json(fake_reply)):
|
||||
result = svc.build_timeline("ab。cd。", 4.0)
|
||||
data = json.loads(result)
|
||||
assert len(data) == 2
|
||||
assert data[0]["emo"] == 4
|
||||
assert data[1]["emo"] == 3
|
||||
|
||||
def test_build_timeline_with_sentence_timings(self):
|
||||
svc = _make_service(enabled=True)
|
||||
fake_reply = json.dumps(
|
||||
[
|
||||
{"text": "hello", "emo": 3, "intensity": 0.4},
|
||||
{"text": "world", "emo": 4, "intensity": 0.2},
|
||||
]
|
||||
)
|
||||
timings = [
|
||||
{"start": 0.0, "end": 1.0},
|
||||
{"start": 1.0, "end": 3.0},
|
||||
]
|
||||
with patch.object(svc, "_call_llm", return_value=_parse_emotion_json(fake_reply)):
|
||||
result = svc.build_timeline("hello world", 3.0, sentence_timings=timings)
|
||||
data = json.loads(result)
|
||||
assert data[0]["start"] == 0.0
|
||||
assert data[0]["end"] == 1.0
|
||||
assert data[1]["end"] == 3.0
|
||||
|
||||
|
||||
class TestPromptLoading:
|
||||
def test_default_prompt_contains_placeholder(self):
|
||||
from packages.application.ditto_emotion_service import _load_default_prompt
|
||||
|
||||
prompt = _load_default_prompt()
|
||||
assert "{文案}" in prompt
|
||||
|
||||
def test_config_prompt_override(self):
|
||||
custom = "分析情绪: {文案}"
|
||||
svc = _make_service(enabled=True, prompt=custom)
|
||||
assert svc._get_prompt_template() == custom
|
||||
|
||||
|
||||
# ── _get_llm_client:DB 配置 api_key 优先 + 变化重建 ─────────────
|
||||
class TestGetLLMClient:
|
||||
def test_uses_config_api_key(self):
|
||||
svc = _make_service(enabled=True)
|
||||
with patch.object(svc, "_cfg", return_value="sk-from-db") as mock_cfg:
|
||||
with patch("packages.shared.ai_client.DoubaoClient") as MockClient:
|
||||
svc._get_llm_client()
|
||||
mock_cfg.assert_called_with("doubao_api_key")
|
||||
assert MockClient.call_args.kwargs["api_key"] == "sk-from-db"
|
||||
|
||||
def test_rebuilds_client_when_key_changes(self):
|
||||
svc = _make_service(enabled=True)
|
||||
keys = iter(["sk-key-v1", "sk-key-v2"])
|
||||
with patch.object(svc, "_cfg", side_effect=lambda k: next(keys)):
|
||||
with patch("packages.shared.ai_client.DoubaoClient") as MockClient:
|
||||
MockClient.side_effect = lambda **kw: MagicMock(api_key=kw["api_key"])
|
||||
c1 = svc._get_llm_client()
|
||||
c2 = svc._get_llm_client()
|
||||
assert c1 is not c2
|
||||
assert MockClient.call_count == 2
|
||||
|
||||
def test_reuses_client_when_key_unchanged(self):
|
||||
svc = _make_service(enabled=True)
|
||||
with patch.object(svc, "_cfg", return_value="sk-same"):
|
||||
with patch("packages.shared.ai_client.DoubaoClient") as MockClient:
|
||||
MockClient.side_effect = lambda **kw: MagicMock(api_key=kw["api_key"])
|
||||
c1 = svc._get_llm_client()
|
||||
c2 = svc._get_llm_client()
|
||||
assert c1 is c2
|
||||
assert MockClient.call_count == 1
|
||||
|
||||
def test_empty_key_falls_back_to_doubao_client_default(self):
|
||||
"""DB/env 都没配时传空串,由 DoubaoClient 内部 fallback env / 标记不可用."""
|
||||
svc = _make_service(enabled=True)
|
||||
with patch.object(svc, "_cfg", return_value=""):
|
||||
with patch("packages.shared.ai_client.DoubaoClient") as MockClient:
|
||||
svc._get_llm_client()
|
||||
assert MockClient.call_args.kwargs["api_key"] == ""
|
||||
|
||||
|
||||
# ── 单句兜底:LLM 返回空时单句也走中性兜底(>= 1)────────────────
|
||||
class TestSingleSentenceFallback:
|
||||
def test_single_sentence_gets_neutral_fallback(self):
|
||||
import packages.application.ditto_emotion_service as m
|
||||
|
||||
svc = _make_service(enabled=True)
|
||||
m._cached_analyze.cache_clear()
|
||||
try:
|
||||
with patch.object(svc, "_call_llm", return_value=[]):
|
||||
segs = svc.analyze("独一无二单句兜底测试文案abc123")
|
||||
assert len(segs) == 1
|
||||
assert segs[0].emo == EMO_NEUTRAL
|
||||
assert segs[0].intensity == 0.1
|
||||
finally:
|
||||
m._cached_analyze.cache_clear()
|
||||
|
||||
|
||||
# ── LLM 结果合理性校验(Bug2)────────────────────────────────────
|
||||
class TestWarnIfSuspiciousAllNeutral:
|
||||
def test_warns_when_all_neutral_with_signals(self, caplog):
|
||||
"""文案含感叹号但全中性/低强度 → warning."""
|
||||
segs = [EmotionSegment(text="家人们!", emo=4, intensity=0.1)]
|
||||
with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"):
|
||||
_warn_if_suspicious_all_neutral("家人们!你们是不是经常踩坑?", segs)
|
||||
assert any("全中性" in r.message or "全中性" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_no_warn_when_has_emotion(self, caplog):
|
||||
"""有非中性表情 → 不 warning."""
|
||||
segs = [
|
||||
EmotionSegment(text="家人们!", emo=6, intensity=0.5),
|
||||
EmotionSegment(text="你们是不是经常踩坑?", emo=5, intensity=0.3),
|
||||
]
|
||||
with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"):
|
||||
_warn_if_suspicious_all_neutral("家人们!你们是不是经常踩坑?", segs)
|
||||
assert not caplog.records
|
||||
|
||||
def test_no_warn_when_no_signal(self, caplog):
|
||||
"""文案无情绪信号,即使全中性也不 warning."""
|
||||
segs = [EmotionSegment(text="今天天气不错。", emo=4, intensity=0.1)]
|
||||
with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"):
|
||||
_warn_if_suspicious_all_neutral("今天天气不错。", segs)
|
||||
assert not caplog.records
|
||||
|
||||
def test_no_warn_when_empty(self, caplog):
|
||||
with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"):
|
||||
_warn_if_suspicious_all_neutral("家人们!", [])
|
||||
assert not caplog.records
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Ditto 蚂蚁数字人客户端单元测试 — #2076."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from packages.application.ditto_service import DittoClient, DittoError, DittoResult
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, content=b"\x00\x01" * 1000, headers=None, text=""):
|
||||
self.status_code = status_code
|
||||
self.content = content
|
||||
self.headers = headers or {}
|
||||
self.text = text
|
||||
|
||||
|
||||
def _make_client(base_url="http://ditto:8000", default_video_url="http://oss/tpl.mp4", max_retries=2, timeout=60):
|
||||
with patch("packages.application.ditto_service.get_api_settings") as mock_settings:
|
||||
s = MagicMock()
|
||||
s.ditto_api_base_url = base_url
|
||||
s.ditto_default_video_url = default_video_url
|
||||
s.ditto_max_retries = max_retries
|
||||
s.ditto_request_timeout = timeout
|
||||
mock_settings.return_value = s
|
||||
return DittoClient()
|
||||
|
||||
|
||||
def test_is_configured_true():
|
||||
c = _make_client()
|
||||
assert c.is_configured is True
|
||||
|
||||
|
||||
def test_is_configured_false_without_base():
|
||||
c = _make_client(base_url="")
|
||||
assert c.is_configured is False
|
||||
|
||||
|
||||
def test_is_configured_false_without_template():
|
||||
c = _make_client(default_video_url="")
|
||||
assert c.is_configured is False
|
||||
|
||||
|
||||
def test_health_ok():
|
||||
c = _make_client()
|
||||
with patch("httpx.Client") as mock_cls:
|
||||
client = MagicMock()
|
||||
client.get.return_value = _FakeResponse(200)
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
assert c.health() is True
|
||||
client.get.assert_called_once()
|
||||
|
||||
|
||||
def test_health_fail_status():
|
||||
c = _make_client()
|
||||
with patch("httpx.Client") as mock_cls:
|
||||
client = MagicMock()
|
||||
client.get.return_value = _FakeResponse(500)
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
assert c.health() is False
|
||||
|
||||
|
||||
def test_health_network_error():
|
||||
c = _make_client()
|
||||
with patch("httpx.Client") as mock_cls:
|
||||
client = MagicMock()
|
||||
client.get.side_effect = httpx.ConnectError("fail")
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
assert c.health() is False
|
||||
|
||||
|
||||
def test_generate_missing_base():
|
||||
c = _make_client(base_url="")
|
||||
with pytest.raises(DittoError, match="DITTO_API_BASE_URL"):
|
||||
c.generate(audio_url="http://x/a.mp3", script="你好")
|
||||
|
||||
|
||||
def test_generate_missing_audio():
|
||||
c = _make_client()
|
||||
with pytest.raises(DittoError, match="audio_url"):
|
||||
c.generate(audio_url="", script="你好")
|
||||
|
||||
|
||||
def test_generate_success_with_headers():
|
||||
c = _make_client(max_retries=0)
|
||||
fake_resp = _FakeResponse(
|
||||
status_code=200,
|
||||
content=b"\x00" * 99999,
|
||||
headers={"X-RTF": "0.35", "X-Frames": "125", "X-Time": "12.5"},
|
||||
)
|
||||
with patch("httpx.Client") as mock_cls, patch("time.monotonic", side_effect=[0, 1]):
|
||||
client = MagicMock()
|
||||
client.post.return_value = fake_resp
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
result = c.generate(audio_url="http://x/a.mp3", script="你好")
|
||||
assert isinstance(result, DittoResult)
|
||||
assert len(result.video_bytes) == 99999
|
||||
assert result.rtf == 0.35
|
||||
assert result.frames == 125
|
||||
assert result.elapsed_seconds == 12.5
|
||||
|
||||
|
||||
def test_generate_uses_default_template_when_video_url_empty():
|
||||
c = _make_client(max_retries=0)
|
||||
fake_resp = _FakeResponse(200, b"1" * 99999)
|
||||
with patch("httpx.Client") as mock_cls:
|
||||
client = MagicMock()
|
||||
client.post.return_value = fake_resp
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
c.generate(audio_url="http://x/a.mp3", script="你好")
|
||||
call_kwargs = client.post.call_args
|
||||
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
||||
assert payload["video_url"] == "http://oss/tpl.mp4"
|
||||
assert payload["audio_url"] == "http://x/a.mp3"
|
||||
assert payload["script"] == "你好"
|
||||
assert payload["emo_global"] == 4
|
||||
assert payload["use_script_emo"] is True
|
||||
|
||||
|
||||
def test_generate_retries_on_429_then_success():
|
||||
c = _make_client(max_retries=2)
|
||||
busy = _FakeResponse(429, b"", text="busy")
|
||||
ok = _FakeResponse(200, b"v" * 99999)
|
||||
with patch("httpx.Client") as mock_cls, patch("time.sleep") as mock_sleep:
|
||||
client = MagicMock()
|
||||
client.post.side_effect = [busy, ok]
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
result = c.generate(audio_url="http://x/a.mp3", script="你好")
|
||||
assert len(result.video_bytes) == 99999
|
||||
assert mock_sleep.called
|
||||
assert client.post.call_count == 2
|
||||
|
||||
|
||||
def test_generate_429_exhausted():
|
||||
c = _make_client(max_retries=1)
|
||||
with patch("httpx.Client") as mock_cls, patch("time.sleep"):
|
||||
client = MagicMock()
|
||||
client.post.return_value = _FakeResponse(429, b"", text="busy")
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
with pytest.raises(DittoError, match="重试"):
|
||||
c.generate(audio_url="http://x/a.mp3", script="你好")
|
||||
|
||||
|
||||
def test_generate_400_no_retry():
|
||||
c = _make_client(max_retries=2)
|
||||
with patch("httpx.Client") as mock_cls:
|
||||
client = MagicMock()
|
||||
client.post.return_value = _FakeResponse(400, b"", text="bad request")
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
with pytest.raises(DittoError, match="Ditto 返回 400"):
|
||||
c.generate(audio_url="http://x/a.mp3", script="你好")
|
||||
assert client.post.call_count == 1 # 400 不重试
|
||||
|
||||
|
||||
def test_generate_small_response_raises():
|
||||
c = _make_client(max_retries=0)
|
||||
with patch("httpx.Client") as mock_cls:
|
||||
client = MagicMock()
|
||||
client.post.return_value = _FakeResponse(200, b"xx")
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
with pytest.raises(DittoError) as exc_info:
|
||||
c.generate(audio_url="http://x/a.mp3", script="你好")
|
||||
assert exc_info.value.code == "EmptyResponse"
|
||||
|
||||
|
||||
def test_generate_and_persist_uploads_to_storage():
|
||||
c = _make_client(max_retries=0)
|
||||
fake_resp = _FakeResponse(200, b"v" * 99999)
|
||||
fake_storage = MagicMock()
|
||||
fake_storage.upload_file.return_value = "http://oss/ditto/x.mp4"
|
||||
with (
|
||||
patch("httpx.Client") as mock_cls,
|
||||
patch("packages.shared.storage.get_shared_storage_service", return_value=fake_storage),
|
||||
):
|
||||
client = MagicMock()
|
||||
client.post.return_value = fake_resp
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
result = c.generate_and_persist(job_id="j1", user_id="u1", audio_url="http://x/a.mp3", script="hi")
|
||||
assert result.video_url == "http://oss/ditto/x.mp4"
|
||||
fake_storage.upload_file.assert_called_once()
|
||||
call_args = fake_storage.upload_file.call_args
|
||||
assert call_args.args[1].startswith("ditto-output/u1/j1")
|
||||
|
||||
|
||||
def test_empty_script_replaced_with_space():
|
||||
c = _make_client(max_retries=0)
|
||||
fake_resp = _FakeResponse(200, b"v" * 99999)
|
||||
with patch("httpx.Client") as mock_cls:
|
||||
client = MagicMock()
|
||||
client.post.return_value = fake_resp
|
||||
mock_cls.return_value.__enter__.return_value = client
|
||||
c.generate(audio_url="http://x/a.mp3", script="")
|
||||
payload = client.post.call_args.kwargs["json"]
|
||||
assert payload["script"] == " "
|
||||
|
||||
|
||||
def test_generate_network_error_fails_fast(monkeypatch):
|
||||
"""网络不通(ConnectError)时不重试,直接快速抛 NetworkUnreachable,避免用户等5分钟"""
|
||||
import httpx
|
||||
|
||||
from packages.application import ditto_service as ds_mod
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fake_post(self, url, json=None):
|
||||
calls["n"] += 1
|
||||
raise httpx.ConnectError("[Errno 113] No route to host")
|
||||
|
||||
monkeypatch.setattr(httpx.Client, "post", _fake_post)
|
||||
|
||||
client = _make_client(
|
||||
base_url="http://100.76.80.23:8000",
|
||||
default_video_url="http://oss/tpl.mp4",
|
||||
max_retries=2,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
t0 = time.monotonic()
|
||||
with pytest.raises(ds_mod.DittoError) as exc:
|
||||
client.generate(audio_url="http://oss/a.wav", script="你好")
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
assert exc.value.code == "NetworkUnreachable"
|
||||
assert calls["n"] == 1 # 不重试
|
||||
assert elapsed < 5 # 快速失败<5秒
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user