Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee5a4042d | |||
| a979af1488 | |||
| 77e19a6b44 | |||
| e4c3f9a046 | |||
| 902effc1f9 | |||
| a408cfdc97 | |||
| 9464322710 | |||
| aa1f318308 | |||
| 3a59948f53 | |||
| 3e6f87a8b5 | |||
| 5da46945fa | |||
| bff20b03d7 | |||
| 63c8496fa8 | |||
| 99ba9b7c58 | |||
| ef82192679 | |||
| 0bd4123ae5 | |||
| 9139c697b0 | |||
| b60de7202a | |||
| f1bd816449 | |||
| 249b70e53e | |||
| dbc6db02e0 | |||
| ec28699806 | |||
| afc7a37d17 |
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""image_analysis v7 prompt + max_tokens 3000 + max_retries 3
|
||||
|
||||
Revision ID: 103_v7_prompt_and_tokens_3000
|
||||
Revises: 102_image_analysis_max_tokens_1500
|
||||
Create Date: 2026-10-07
|
||||
|
||||
变更:
|
||||
1. 插入v7精简prompt(~1KB,v6 ~4.5KB,删除few-shot/冗长规则,减少输出token占用),设为active
|
||||
2. v6停用(is_active=False),保留历史
|
||||
3. image_analysis capability: max_tokens 1500→3000,max_retries 1→3
|
||||
|
||||
ai_capability_configs 由应用 create_all 创建,全新 alembic-only 库可能不存在,
|
||||
故第3步做 to_regclass 守卫(同 102)。
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "103_v7_prompt_and_tokens_3000"
|
||||
down_revision = "102_image_analysis_max_tokens_1500"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
V7_SYSTEM = """# 角色
|
||||
你是一位专业的图片分析师,擅长准确识别图片中的场景、人物、物体、文字、氛围。
|
||||
|
||||
# 任务
|
||||
对用户上传的图片逐张分析,描述你看到的内容,输出JSON格式。
|
||||
|
||||
## 技能
|
||||
|
||||
### 技能1:判断图片类型
|
||||
判断图片属于哪种类型,type字段填对应的英文值:
|
||||
- 商品图(product):单个或多个商品、产品包装
|
||||
- 门店场景图(store):店铺内部、门头招牌、货架陈列
|
||||
- 人物图(person):人物形象、穿搭造型、肖像照片
|
||||
- 风景图(scene):风景、动物、美食、街景
|
||||
- 其他(other):以上都不是
|
||||
|
||||
### 技能2:描述通用信息
|
||||
不管什么图都要描述:
|
||||
- type:图片类型,填product/store/person/scene/other其中一个
|
||||
- scene:一句话描述场景,例如"理疗养生店内部,摆着多张理疗床和产品货架"
|
||||
- mood:整体氛围,2-4个词,例如"整洁专业"、"热闹温馨"
|
||||
- colors:主要颜色,最多5个,写具体颜色名(亮红色/米白色/深蓝色,不写笼统的红色蓝色)
|
||||
- visible_text:图片里看到的文字,说明什么字、在什么位置,最多5条;没看到就空数组
|
||||
- lighting:光线情况,例如"明亮柔光"、"自然光"、"室内暖黄灯"
|
||||
- composition:怎么拍的,例如"居中特写"、"中景平视"、"俯拍"
|
||||
- has_person:有没有人,true或false
|
||||
|
||||
### 技能3:描述门店场景
|
||||
如果是门店场景图(type="store"),还要描述:
|
||||
- store_type:什么类型的店,例如"养生馆"、"便利店"、"餐饮店"、"母婴店"
|
||||
- brand_signage:招牌上写了什么字、有什么品牌标识
|
||||
- visual_elements:看到哪些显眼的东西(招牌样式、灯光、货架、商品陈列、海报、收银台等),最多8个
|
||||
- product_categories:看到哪些品类的商品,例如"饮料零食"、"养生产品"
|
||||
- promotion_elements:有没有促销活动(打折海报、满减吊旗等),没有就空数组
|
||||
- atmosphere:店内什么氛围,例如"亲民生活化"、"老字号专业感"
|
||||
- cleanliness:店内干净程度,例如"干净整洁"、"货架整齐"
|
||||
- 看到顾客或店员要描述他们在做什么,has_person填true
|
||||
|
||||
### 技能4:描述商品
|
||||
如果是商品图(type="product"),逐个商品描述:
|
||||
- product_name:商品名称,尽量具体,例如"OMO奥妙除菌除螨洗衣液";看不出来填null
|
||||
- brand:什么牌子,看不出来填null
|
||||
- category:类目,从以下选一个:服饰鞋包/美妆/数码/食品/家居清洁/母婴/配饰/其他
|
||||
- package_type:什么包装,例如"瓶装"、"盒装"、"罐装"、"袋装"、"多瓶装"
|
||||
- package_color:包装主要颜色,写具体色(亮红色不写红色)
|
||||
- body_shape:瓶身或包装形状,例如"圆润胖瓶"、"竖款带把手瓶身"
|
||||
- label_design:标签设计,例如"红色标签印白色品牌logo"
|
||||
- key_text_on_package:包装上最显眼的文字(品牌名、功能词、卖点词),最多5个
|
||||
- product_features:包装特征,3-6个短语,包含颜色、瓶盖、形状、标签图案
|
||||
- key_selling_points:核心卖点,1-3个短语
|
||||
|
||||
### 技能5:描述人物
|
||||
如果是人物图(type="person"),描述:
|
||||
- person_count:几个人
|
||||
- gender:性别(男/女/无法判断)
|
||||
- age_range:年龄段(儿童/青少年/青年/中年/老年/无法判断)
|
||||
- outfit_style:穿搭风格,例如"休闲日常"、"通勤商务"、"街头潮流"
|
||||
- upper_wear:上装(颜色+款式+材质),穿裙装不填
|
||||
- lower_wear:下装(颜色+款式+版型),穿裙装不填
|
||||
- dress_wear:裙装描述,穿上下装不填
|
||||
- outerwear:外套
|
||||
- shoes:鞋子
|
||||
- bag:包袋,没有填null
|
||||
- accessories:配饰(眼镜/帽子/项链/耳环/手表/手链/围巾/腰带等),没有填空数组
|
||||
- hairstyle:发型
|
||||
- makeup:妆容,男生或看不出填null
|
||||
- expression:表情,例如"微笑看镜头"、"冷酷无表情"
|
||||
- pose:姿势动作,例如"身直立正对镜头"、"单手撩发"
|
||||
- body_type:身材,例如"纤细苗条"、"高挑身材"、"丰满匀称"
|
||||
- portrait_prompt:80-150字详细描述人物形象(后面用来AI生成肖像图),要写清年龄段、穿搭完整细节、发型发色、妆容、表情、姿势、场景、光线、风格感觉,语言要有画面感
|
||||
|
||||
### 技能6:描述风景
|
||||
如果是风景图(type="scene"),描述:
|
||||
- scene_type:什么场景,例如"自然风景"、"城市街景"、"动物"、"美食"
|
||||
- main_subject:画面主体是什么
|
||||
- key_elements:关键元素,最多8个
|
||||
- environment_objects:周围环境物体,最多8个
|
||||
- atmosphere:整体氛围,例如"秋日慵懒氛围感"、"清新自然氧气感"
|
||||
- 有人物就描述人物特征
|
||||
|
||||
## 限制
|
||||
- 只输出JSON,不要任何解释文字,不要markdown代码块包裹,不要写"好的""以下是分析结果"这种废话
|
||||
- 颜色写具体色调(亮红色/米白色/深蓝色/翠绿色),不写笼统词汇
|
||||
- 瓶身、包装、招牌上的文字尽量识别出来(品牌名、功能词、卖点词)
|
||||
- 多个商品、多个人物分开描述,不要合并
|
||||
- 看不出来、不确定的字段填null或空数组,布尔值填true/false,绝对不要瞎编
|
||||
- 确保JSON格式合法,所有大括号、中括号、引号正确闭合
|
||||
- 数组字段控制数量:colors最多5个,visible_text最多5条,visual_elements最多8个,accessories最多10个"""
|
||||
V7_USER = "请分析这张图片,按系统消息的JSON结构输出。"
|
||||
|
||||
|
||||
def _capability_table_exists(bind) -> bool:
|
||||
return bool(bind.execute(text("SELECT to_regclass('public.ai_capability_configs')")).scalar())
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# 1. 停用旧的active image_analysis prompt(含v6)
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
|
||||
"WHERE prompt_type = 'image_analysis' AND is_active = TRUE"
|
||||
)
|
||||
)
|
||||
# 2. 幂等插入v7(存在则更新并重新激活)
|
||||
existing = bind.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 7")
|
||||
).fetchone()
|
||||
if existing:
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
|
||||
"system_prompt = :sys, user_prompt_template = :usr, "
|
||||
"name = 'v7 精简结构化分析', updated_at = NOW() "
|
||||
"WHERE prompt_type = 'image_analysis' AND version = 7"
|
||||
),
|
||||
{"sys": V7_SYSTEM, "usr": V7_USER},
|
||||
)
|
||||
else:
|
||||
bind.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(prompt_type, version, name, system_prompt, user_prompt_template, "
|
||||
"is_active, created_at, updated_at) "
|
||||
"VALUES ('image_analysis', 7, 'v7 精简结构化分析', "
|
||||
":sys, :usr, TRUE, NOW(), NOW())"
|
||||
),
|
||||
{"sys": V7_SYSTEM, "usr": V7_USER},
|
||||
)
|
||||
# 3. capability max_tokens=3000、max_retries=3(表不存在则跳过)
|
||||
if _capability_table_exists(bind):
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE ai_capability_configs SET max_tokens = 3000, "
|
||||
"updated_at = NOW() "
|
||||
"WHERE capability_key = 'image_analysis' AND "
|
||||
"(max_tokens IS NULL OR max_tokens < 3000)"
|
||||
)
|
||||
)
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE ai_capability_configs SET max_retries = 3, updated_at = NOW() "
|
||||
"WHERE capability_key = 'image_analysis' AND "
|
||||
"(max_retries IS NULL OR max_retries < 3)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# 删除v7
|
||||
bind.execute(
|
||||
text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 7")
|
||||
)
|
||||
# 恢复v6为active
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
|
||||
"WHERE prompt_type = 'image_analysis' AND version = 6"
|
||||
)
|
||||
)
|
||||
# tokens/retries回退
|
||||
if _capability_table_exists(bind):
|
||||
bind.execute(
|
||||
text(
|
||||
"UPDATE ai_capability_configs SET max_tokens = 1500, max_retries = 1, "
|
||||
"updated_at = NOW() WHERE capability_key = 'image_analysis'"
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Ditto 蚂蚁数字人口型异步任务 — #2076.
|
||||
|
||||
把 Ditto 同步 HTTP 调用(30-120s)从 API 请求移到 Celery 后台执行:
|
||||
1. 加载 LipsyncJob
|
||||
2. 调 DittoClient.generate_and_persist(video_url=默认模板, audio_url=job.audio_url, script=job.script_text)
|
||||
3. 成功:标记 completed,写入 output_video_url(Ditto 输出自带音频,无需二次混流/超分)
|
||||
4. 失败:回退 GPU MuseTalk → 再失败回退 MediaKit
|
||||
|
||||
注意:
|
||||
- 保留 MuseTalk 代码不动;Ditto 优先,失败按原链路兜底
|
||||
- Ditto 使用预置的人物模板视频(settings.ditto_default_video_url),不用用户上传的 video_url
|
||||
- 不传 GFPGAN 超分,不需要 ffmpeg 音视频混流
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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 天有效
|
||||
|
||||
|
||||
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_video_duration(video_bytes: bytes) -> float:
|
||||
"""用 ffprobe 探测视频时长(秒);失败返回 0。"""
|
||||
try:
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
|
||||
tmp.write(video_bytes)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
tmp_path,
|
||||
],
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=10,
|
||||
)
|
||||
return float(out.decode().strip() or 0)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
except Exception as exc:
|
||||
logger.warning("[ditto_task] ffprobe 失败: %s", exc)
|
||||
return 0.0
|
||||
|
||||
|
||||
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)
|
||||
# 复用 lipsync_service._submit_to_gpu_create 逻辑
|
||||
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_service import DittoError, get_ditto_client
|
||||
|
||||
db: Session = _get_db_session()
|
||||
job: Optional[LipsyncJobModel] = None
|
||||
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 ""
|
||||
if not audio_url:
|
||||
raise DittoError("job.audio_url 为空,无法调用 Ditto", code="InvalidParam")
|
||||
|
||||
logger.info(
|
||||
"[ditto_task] 开始 Ditto 生成: job_id=%s audio=%s script_len=%d",
|
||||
job_id,
|
||||
audio_url[:100],
|
||||
len(script),
|
||||
)
|
||||
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 不传则用默认模板
|
||||
)
|
||||
|
||||
# Ditto 返回的 MP4 自带音频,直接标记完成
|
||||
job.output_video_url = result.video_url
|
||||
# 探测时长(用于计费)
|
||||
duration = _probe_video_duration(result.video_bytes)
|
||||
if duration <= 0:
|
||||
# 兜底:按音频时长估算(1秒≈1秒)
|
||||
try:
|
||||
from packages.domain.sentence_timings import probe_audio_duration
|
||||
from packages.shared.url_security import safe_download_bytes
|
||||
|
||||
audio_data = safe_download_bytes(
|
||||
audio_url, allowed_mime_types=("audio/mpeg", "audio/wav", "audio/x-wav"), timeout=30
|
||||
)
|
||||
duration = probe_audio_duration(audio_data)
|
||||
except Exception:
|
||||
duration = 0.0
|
||||
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
|
||||
# 回退 GPU/MediaKit
|
||||
_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)
|
||||
|
||||
|
||||
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",
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface ImageProductAnalysis {
|
||||
label_text?: string
|
||||
selling_points?: string
|
||||
image_index?: number
|
||||
/** v8: 用户端展示用的 markdown 描述(由提示词控制排版) */
|
||||
summary_markdown?: string
|
||||
}
|
||||
|
||||
export interface ImageAnalysisResult {
|
||||
@@ -132,6 +134,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 }>
|
||||
}
|
||||
|
||||
@@ -1973,3 +1973,99 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import axios from "axios"
|
||||
import { marked } from "marked"
|
||||
import {
|
||||
PlusOutlined,
|
||||
CloseOutlined,
|
||||
@@ -150,6 +151,16 @@ type TabTask = {
|
||||
audioInst: HTMLAudioElement | null
|
||||
}
|
||||
|
||||
/* ── marked 配置:禁用 mangle/headerIds,输出干净 HTML ── */
|
||||
marked.setOptions({ gfm: true, breaks: false })
|
||||
const renderMarkdown = (md: string): string => {
|
||||
try {
|
||||
return marked.parse(md ?? "", { async: false }) as string
|
||||
} catch {
|
||||
return (md ?? "").replace(/&/g, "&").replace(/</g, "<")
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────── 常量 ─────────── */
|
||||
|
||||
const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"]
|
||||
@@ -296,6 +307,8 @@ interface Storyboard {
|
||||
hard_constraints: string[]
|
||||
negative_prompts: string[]
|
||||
voiceover_script: string
|
||||
/** v3: 用户端展示用 markdown 文案(由提示词控制排版) */
|
||||
copy_display_markdown: string
|
||||
}
|
||||
|
||||
/** 兼容旧 copy_result(final_copy/title/scenes)→ 新 Storyboard 结构 */
|
||||
@@ -323,6 +336,7 @@ function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard |
|
||||
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 || "",
|
||||
}
|
||||
}
|
||||
// 兜底:旧结构转简单分镜
|
||||
@@ -358,6 +372,7 @@ function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard |
|
||||
hard_constraints: [],
|
||||
negative_prompts: [],
|
||||
voiceover_script: finalCopy,
|
||||
copy_display_markdown: cr.copy_display_markdown || "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +427,7 @@ const MOCK_STORYBOARD: Storyboard = {
|
||||
negative_prompts: ["冷色调", "模糊", "变形", "水印文字", "卡通风格", "空无一人"],
|
||||
voiceover_script:
|
||||
"还在为餐桌选不到好桌子发愁?这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。全实木、无贴皮,纹理好看又耐刮。点小黄车,给家里添一张好桌子。",
|
||||
copy_display_markdown: "",
|
||||
}
|
||||
|
||||
const fmtSize = (bytes: number | undefined) => {
|
||||
@@ -1217,67 +1233,76 @@ const ViralVideoPage: React.FC = () => {
|
||||
<CheckCircleFilled style={{ color: "#10b981" }} />
|
||||
识别描述汇览
|
||||
</div>
|
||||
{products.map((p, i) => (
|
||||
<div key={i} className="vv-recog-item">
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">图片{i + 1}:</span>
|
||||
<span>
|
||||
{p.name || "未识别"}
|
||||
{p.spec && <span className="vv-recog-meta">({p.spec})</span>}
|
||||
{p.brand && <span className="vv-recog-meta"> · {p.brand}</span>}
|
||||
{p.category && <span className="vv-recog-meta"> · {p.category}</span>}
|
||||
</span>
|
||||
{products.map((p, i) =>
|
||||
p.summary_markdown ? (
|
||||
<div key={i} className="vv-recog-item vv-recog-md">
|
||||
<div
|
||||
className="vv-md-body"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(p.summary_markdown) }}
|
||||
/>
|
||||
</div>
|
||||
{featureText(p.key_features ?? p.features) && (
|
||||
) : (
|
||||
<div key={i} className="vv-recog-item">
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">核心特征:</span>
|
||||
<span className="vv-recog-v">{featureText(p.key_features ?? p.features)}</span>
|
||||
<span className="vv-recog-k">图片{i + 1}:</span>
|
||||
<span>
|
||||
{p.name || "未识别"}
|
||||
{p.spec && <span className="vv-recog-meta">({p.spec})</span>}
|
||||
{p.brand && <span className="vv-recog-meta"> · {p.brand}</span>}
|
||||
{p.category && <span className="vv-recog-meta"> · {p.category}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{p.colors && p.colors.length > 0 && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">主色调:</span>
|
||||
<span className="vv-recog-v">{p.colors.join(" / ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.material_or_texture && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">材质/纹理:</span>
|
||||
<span className="vv-recog-v">{p.material_or_texture}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.visual_style && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">视觉风格:</span>
|
||||
<span className="vv-recog-v">{p.visual_style}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.scene && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">场景:</span>
|
||||
<span className="vv-recog-v">{p.scene}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.target_audience_hint && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">目标人群:</span>
|
||||
<span className="vv-recog-v">{p.target_audience_hint}</span>
|
||||
</div>
|
||||
)}
|
||||
{(p.text_on_image || p.label_text) && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">包装文字:</span>
|
||||
<span className="vv-recog-v">{p.text_on_image || p.label_text}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.selling_points && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">卖点:</span>
|
||||
<span className="vv-recog-v">{p.selling_points}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{featureText(p.key_features ?? p.features) && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">核心特征:</span>
|
||||
<span className="vv-recog-v">{featureText(p.key_features ?? p.features)}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.colors && p.colors.length > 0 && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">主色调:</span>
|
||||
<span className="vv-recog-v">{p.colors.join(" / ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.material_or_texture && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">材质/纹理:</span>
|
||||
<span className="vv-recog-v">{p.material_or_texture}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.visual_style && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">视觉风格:</span>
|
||||
<span className="vv-recog-v">{p.visual_style}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.scene && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">场景:</span>
|
||||
<span className="vv-recog-v">{p.scene}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.target_audience_hint && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">目标人群:</span>
|
||||
<span className="vv-recog-v">{p.target_audience_hint}</span>
|
||||
</div>
|
||||
)}
|
||||
{(p.text_on_image || p.label_text) && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">包装文字:</span>
|
||||
<span className="vv-recog-v">{p.text_on_image || p.label_text}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.selling_points && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">卖点:</span>
|
||||
<span className="vv-recog-v">{p.selling_points}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1416,6 +1441,19 @@ const ViralVideoPage: React.FC = () => {
|
||||
return (
|
||||
<div className="vv-copy-box vv-storyboard">
|
||||
<div className="vv-sb-doc">
|
||||
{/* 文案预览(提示词控制排版,只读;编辑在下方分镜字段中进行) */}
|
||||
{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">
|
||||
|
||||
@@ -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 定时任务调度
|
||||
|
||||
@@ -316,9 +316,9 @@ _DEFAULT_NEGATIVE_PROMPTS = [
|
||||
]
|
||||
|
||||
|
||||
def _empty_copy_result(duration: int = 15, ratio: str = "9:16") -> dict:
|
||||
def _empty_copy_result(duration: int = 15, ratio: str = "9:16", theme: str = "") -> dict:
|
||||
return {
|
||||
"overview": {"theme": "好物推荐", "total_duration": duration, "aspect_ratio": ratio},
|
||||
"overview": {"theme": theme or "好物推荐", "total_duration": duration, "aspect_ratio": ratio},
|
||||
"scene_and_lighting": "简洁明亮的室内场景,柔和自然光,产品主体清晰",
|
||||
"shots": [],
|
||||
"hard_constraints": list(_DEFAULT_HARD_CONSTRAINTS),
|
||||
@@ -471,11 +471,15 @@ def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
|
||||
|
||||
template = get_template("intent_parsing")
|
||||
system = render_system_prompt(template)
|
||||
marketing_purpose = getattr(job, "marketing_purpose", "") or "未指定"
|
||||
image_category_hint = _determine_theme(image_analysis, marketing_purpose)
|
||||
user = render_user_prompt(
|
||||
template,
|
||||
user_copy_text=job.user_copy_text or "(未提供,全由 AI 创作)",
|
||||
industry=job.industry or "未指定",
|
||||
image_analysis=products_summary or "- (无图片分析结果)",
|
||||
marketing_purpose=marketing_purpose,
|
||||
image_category_hint=image_category_hint,
|
||||
)
|
||||
|
||||
def _parse(raw: str) -> dict:
|
||||
@@ -504,16 +508,25 @@ def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
|
||||
|
||||
_client_fast = ai_router.get_llm_client("intent_parsing", variant="primary")
|
||||
_client_pro = ai_router.get_llm_client("intent_parsing", variant="lite")
|
||||
_intent_deadline = time.time() + 60
|
||||
_seen_models: set[str] = set()
|
||||
for _client, _lbl in [(_client_fast, "fast"), (_client_pro, "pro-fallback")]:
|
||||
if not _client or not _client.is_available:
|
||||
continue
|
||||
if _client.model in _seen_models:
|
||||
logger.info("[爆款视频] 意图解析跳过重复模型 %s label=%s", _client.model, _lbl)
|
||||
continue
|
||||
_seen_models.add(_client.model)
|
||||
if time.time() > _intent_deadline:
|
||||
logger.warning("[爆款视频] 意图解析超过60s总预算,跳过 label=%s", _lbl)
|
||||
break
|
||||
try:
|
||||
logger.info("[爆款视频] 意图解析 model=%s label=%s", _client.model, _lbl)
|
||||
raw = _client.chat_completion(
|
||||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
temperature=0.4,
|
||||
max_tokens=1024,
|
||||
timeout=60,
|
||||
timeout=20,
|
||||
)
|
||||
if not raw:
|
||||
continue
|
||||
@@ -550,6 +563,70 @@ def _persona_style_hint(persona_id: str) -> str:
|
||||
return "【人设风格:未指定】亲切自然、像朋友分享好物"
|
||||
|
||||
|
||||
def _determine_theme(image_analysis: dict | None, marketing_purpose: str = "") -> str:
|
||||
"""根据图片分析结果和营销目的,智能推断默认主题。
|
||||
|
||||
门店类→门店探店/到店体验;商品图→好物分享/产品种草;
|
||||
人物图→穿搭/人物故事;场景图→场景氛围/空间体验。
|
||||
"""
|
||||
products = (image_analysis or {}).get("products", []) or []
|
||||
type_counts: dict[str, int] = {}
|
||||
for p in products:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
cat = (p.get("category") or "").strip()
|
||||
if any(
|
||||
k in cat
|
||||
for k in (
|
||||
"门店",
|
||||
"店铺",
|
||||
"餐饮",
|
||||
"美容",
|
||||
"美发",
|
||||
"养生",
|
||||
"健身",
|
||||
"酒店",
|
||||
"咖啡",
|
||||
"奶茶",
|
||||
"餐厅",
|
||||
"颈肩",
|
||||
"调理",
|
||||
)
|
||||
):
|
||||
type_counts["store"] = type_counts.get("store", 0) + 1
|
||||
elif any(k in cat for k in ("人物", "穿搭", "人像", "服装")):
|
||||
type_counts["person"] = type_counts.get("person", 0) + 1
|
||||
elif any(k in cat for k in ("场景", "空间", "环境", "非产品")):
|
||||
type_counts["scene"] = type_counts.get("scene", 0) + 1
|
||||
elif cat and cat not in ("无法判断", "非产品图", ""):
|
||||
type_counts["product"] = type_counts.get("product", 0) + 1
|
||||
src = p.get("_source") or ""
|
||||
if "store" in src:
|
||||
type_counts["store"] = type_counts.get("store", 0) + 1
|
||||
elif "person" in src:
|
||||
type_counts["person"] = type_counts.get("person", 0) + 1
|
||||
|
||||
dominant = max(type_counts, key=type_counts.get) if type_counts else "product"
|
||||
mp = (marketing_purpose or "").strip()
|
||||
|
||||
if any(k in mp for k in ("获客", "引流", "到店")):
|
||||
if dominant == "store":
|
||||
return "门店探店·到店体验"
|
||||
return "门店探店·到店体验"
|
||||
if any(k in mp for k in ("品牌", "宣传")):
|
||||
return "品牌故事·门店体验" if dominant == "store" else "品牌故事·产品展示"
|
||||
if any(k in mp for k in ("种草", "推荐")):
|
||||
return "穿搭分享·人物种草" if dominant == "person" else "好物分享·产品种草"
|
||||
|
||||
theme_map = {
|
||||
"store": "门店探店·到店体验",
|
||||
"person": "穿搭分享·人物故事",
|
||||
"scene": "空间体验·场景氛围",
|
||||
"product": "好物分享·产品种草",
|
||||
}
|
||||
return theme_map.get(dominant, "好物分享·产品种草")
|
||||
|
||||
|
||||
def _build_products_summary(image_analysis: dict) -> str:
|
||||
"""把 VLM 返回的商品分析结果拼给文案/分镜生成 prompt 用。
|
||||
优先用 summary(自然段落);没有时用结构化字段兜底拼一段。"""
|
||||
@@ -666,14 +743,36 @@ def _fallback_script(job: ViralVideoJob) -> dict:
|
||||
"""脚本生成失败时的兜底脚本(极简但可用)。"""
|
||||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||||
base = _empty_copy_result(dur, ratio)
|
||||
voiceover = job.user_copy_text or "你好,给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。"
|
||||
_ia = getattr(job, "image_analysis", None) or {}
|
||||
_mp = getattr(job, "marketing_purpose", "") or ""
|
||||
default_theme = _determine_theme(_ia, _mp)
|
||||
base = _empty_copy_result(dur, ratio, theme=default_theme)
|
||||
_voiceover_map = {
|
||||
"store": "带你探店!今天来到这家店,环境真的超棒,服务也很到位,推荐大家来体验一下。",
|
||||
"person": "哈喽,今天给大家分享我的日常穿搭,简单舒适又好看,你们觉得怎么样?",
|
||||
"scene": "带大家感受一下这个空间,氛围感拉满,真的很适合打卡体验。",
|
||||
"product": "你好,给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。",
|
||||
}
|
||||
_products = (_ia or {}).get("products", []) or []
|
||||
_dominant = "product"
|
||||
for p in _products:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
src = p.get("_source") or ""
|
||||
cat = p.get("category") or ""
|
||||
if "store" in src or any(k in cat for k in ("门店", "店铺", "餐饮", "美容", "颈肩", "调理")):
|
||||
_dominant = "store"
|
||||
break
|
||||
elif "person" in src or any(k in cat for k in ("人物", "穿搭", "人像")):
|
||||
_dominant = "person"
|
||||
break
|
||||
voiceover = job.user_copy_text or _voiceover_map.get(_dominant, _voiceover_map["product"])
|
||||
shots = [
|
||||
{
|
||||
"time_range": f"0-{dur}秒",
|
||||
"shot_type_angle_movement": "中景平视,缓慢推镜",
|
||||
"scene_and_dialogue": "明亮室内,人物自然出镜,微笑着看向镜头。" + voiceover,
|
||||
"action_details": "人物手持产品自然展示,表情亲切,动作流畅",
|
||||
"scene_and_dialogue": voiceover,
|
||||
"action_details": "自然展示,表情亲切,动作流畅",
|
||||
"audio_bgm": "轻快流行BGM",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 0 if job.images else None,
|
||||
@@ -683,7 +782,7 @@ def _fallback_script(job: ViralVideoJob) -> dict:
|
||||
base["voiceover_script"] = voiceover
|
||||
base["final_copy"] = voiceover
|
||||
base["suggested_copy"] = voiceover
|
||||
base["title"] = "好物分享"
|
||||
base["title"] = default_theme
|
||||
return base
|
||||
|
||||
|
||||
@@ -701,12 +800,18 @@ def _validate_and_normalize_script(raw, job: ViralVideoJob) -> dict:
|
||||
ov = raw.get("overview")
|
||||
if isinstance(ov, dict):
|
||||
base["overview"] = {
|
||||
"theme": str(ov.get("theme") or "好物分享"),
|
||||
"theme": str(
|
||||
ov.get("theme")
|
||||
or _determine_theme(getattr(job, "image_analysis", None), getattr(job, "marketing_purpose", ""))
|
||||
),
|
||||
"total_duration": int(ov.get("total_duration") or dur),
|
||||
"aspect_ratio": str(ov.get("aspect_ratio") or ratio),
|
||||
}
|
||||
else:
|
||||
base["overview"]["theme"] = str(raw.get("title") or "好物分享")
|
||||
base["overview"]["theme"] = str(
|
||||
raw.get("title")
|
||||
or _determine_theme(getattr(job, "image_analysis", None), getattr(job, "marketing_purpose", ""))
|
||||
)
|
||||
|
||||
base["scene_and_lighting"] = str(raw.get("scene_and_lighting") or base["scene_and_lighting"])
|
||||
|
||||
@@ -793,7 +898,11 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
base = _empty_copy_result(dur, ratio)
|
||||
if not raw:
|
||||
return None
|
||||
base["overview"]["theme"] = xp.text_of(raw, "overview_theme") or xp.text_of(raw, "title") or "好物分享"
|
||||
base["overview"]["theme"] = (
|
||||
xp.text_of(raw, "overview_theme")
|
||||
or xp.text_of(raw, "title")
|
||||
or _determine_theme(getattr(job, "image_analysis", None), getattr(job, "marketing_purpose", ""))
|
||||
)
|
||||
est = xp.attr_int(xp.text_of(raw, "estimated_duration"), 0)
|
||||
if est:
|
||||
base["overview"]["total_duration"] = est
|
||||
@@ -839,6 +948,10 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
base["final_copy"] = joined
|
||||
base["suggested_copy"] = joined
|
||||
base["title"] = base["overview"]["theme"]
|
||||
# 提取 copy_display_markdown(用户端展示格式)
|
||||
copy_display_md = xp.text_of(raw, "copy_display_markdown")
|
||||
if copy_display_md:
|
||||
base["copy_display_markdown"] = copy_display_md
|
||||
return base
|
||||
|
||||
|
||||
@@ -883,8 +996,11 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di
|
||||
system_tpl = system_tpl.replace("{global_constraints}", GLOBAL_CONSTRAINTS)
|
||||
system_tpl = system_tpl.replace("{negative_rules}", NEGATIVE_RULES)
|
||||
|
||||
marketing_purpose = getattr(job, "marketing_purpose", "") or "未指定"
|
||||
image_category_hint = _determine_theme(image_analysis, marketing_purpose)
|
||||
fusion_brief = (
|
||||
f"意图:{intent_str}\n关键信息:{key_msgs}\n调性:{tone}\n"
|
||||
f"营销目的:{marketing_purpose}\n建议主题方向:{image_category_hint}\n"
|
||||
f"用户原文:{job.user_copy_text or '(未提供)'}\n创作模式:{fusion_level}"
|
||||
)
|
||||
user = render_user_prompt(
|
||||
@@ -936,22 +1052,32 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di
|
||||
# #2220: 直接用 ai_router 获取 client,不再手动提取 model_key
|
||||
_client_fast = ai_router.get_llm_client("storyboard", variant="primary")
|
||||
_client_pro = ai_router.get_llm_client("storyboard", variant="lite")
|
||||
_script_fast_tmo = int(os.environ.get("VIRAL_VIDEO_SCRIPT_FAST_TIMEOUT", "150"))
|
||||
_script_pro_tmo = int(os.environ.get("VIRAL_VIDEO_SCRIPT_PRO_TIMEOUT", "150"))
|
||||
_script_fast_tmo = int(os.environ.get("VIRAL_VIDEO_SCRIPT_FAST_TIMEOUT", "90"))
|
||||
_script_pro_tmo = int(os.environ.get("VIRAL_VIDEO_SCRIPT_PRO_TIMEOUT", "60"))
|
||||
_script_deadline = time.time() + 180
|
||||
try:
|
||||
# #2217: doubao-seed-2-1-pro生成长编导脚本高峰期>90s,上调到150s,支持ENV覆盖
|
||||
# #2233: fast_timeout=90s, pro_timeout=60s,总deadline 180s
|
||||
normalized = _try_gen(_client_fast, 0.8, 2500, "fast-first", tmo=_script_fast_tmo)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
if time.time() > _script_deadline:
|
||||
logger.warning("[爆款视频] 编导脚本超过180s总预算,使用兜底脚本")
|
||||
return _fallback_script(job)
|
||||
normalized = _try_gen(_client_fast, 0.6, 3200, "fast-retry", tmo=_script_fast_tmo)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
# 第三次:用 lite/pro 模型兜底
|
||||
# 第三次:用 lite/pro 模型兜底,跳过与 primary 相同的模型
|
||||
if _client_pro and _client_pro.is_available:
|
||||
normalized = _try_gen(_client_pro, 0.7, 3500, "pro-fallback", tmo=_script_pro_tmo)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
logger.warning("[爆款视频] 编导脚本三次都未生成合格结果,使用兜底脚本")
|
||||
if _client_pro.model != _client_fast.model:
|
||||
if time.time() <= _script_deadline:
|
||||
normalized = _try_gen(_client_pro, 0.7, 3500, "pro-fallback", tmo=_script_pro_tmo)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
else:
|
||||
logger.warning("[爆款视频] 编导脚本超过180s总预算,跳过pro-fallback")
|
||||
else:
|
||||
logger.info("[爆款视频] pro-fallback模型与primary相同(%s),跳过重复调用", _client_pro.model)
|
||||
logger.warning("[爆款视频] 编导脚本均未生成合格结果,使用兜底脚本")
|
||||
return _fallback_script(job)
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 编导脚本生成异常: %s,使用兜底脚本", e, exc_info=True)
|
||||
@@ -1950,18 +2076,27 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
|
||||
try:
|
||||
review_result = _step_review(job, copy_result)
|
||||
if not review_result.get("passed", True):
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
|
||||
# #2040: Reviewer 已在 _step_review 内完成 1 次自动重写
|
||||
rewritten = review_result.get("rewritten_copy")
|
||||
if isinstance(rewritten, dict) and rewritten:
|
||||
copy_result = rewritten
|
||||
else:
|
||||
# #2218: 审核重写失败不再从意图解析重跑,直接报错让用户重新生成文案
|
||||
logger.error(
|
||||
"[爆款视频][阶段3] 合规审核未通过且自动重写失败 job_id=%s,终止渲染",
|
||||
# #2233: 如果issues为空但passed=False,说明是LLM超时/异常导致的误判,降级放行
|
||||
_issues = review_result.get("issues") or []
|
||||
if not _issues:
|
||||
logger.warning(
|
||||
"[爆款视频][阶段3] 审核未通过但无具体问题(可能LLM超时),降级放行 job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
raise ValueError("文案合规审核未通过,请修改文案后重试或重新生成文案")
|
||||
review_result["passed"] = True
|
||||
else:
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
|
||||
# #2040: Reviewer 已在 _step_review 内完成 1 次自动重写
|
||||
rewritten = review_result.get("rewritten_copy")
|
||||
if isinstance(rewritten, dict) and rewritten:
|
||||
copy_result = rewritten
|
||||
else:
|
||||
# #2218: 审核重写失败不再从意图解析重跑,直接报错让用户重新生成文案
|
||||
logger.error(
|
||||
"[爆款视频][阶段3] 合规审核未通过且自动重写失败 job_id=%s,终止渲染",
|
||||
job_id,
|
||||
)
|
||||
raise ValueError("文案合规审核未通过,请修改文案后重试或重新生成文案")
|
||||
job.copy_result = copy_result
|
||||
job.generated_copy_text = copy_result.get("voiceover_script", "") or ""
|
||||
_save_job(repo, job, session)
|
||||
|
||||
@@ -22,6 +22,90 @@ _AGE_PREFIX = {"青年": "年轻", "中年": "中年", "老年": "老年"}
|
||||
_GENDER_WORD = {"男": "男性", "女": "女性"}
|
||||
|
||||
|
||||
def _build_summary_markdown(product: dict) -> str:
|
||||
"""根据结构化字段生成 summary_markdown 兜底(VLM未返回时调用)。"""
|
||||
p = product or {}
|
||||
name = p.get("name") or "未识别"
|
||||
brand = p.get("brand") or ""
|
||||
category = p.get("category") or ""
|
||||
appearance = p.get("appearance") or ""
|
||||
packaging = p.get("packaging") or ""
|
||||
scene = p.get("scene") or ""
|
||||
mood = p.get("mood") or ""
|
||||
key_features = p.get("key_features") or []
|
||||
text_on_package = p.get("text_on_package") or []
|
||||
store_type = p.get("store_type") or ""
|
||||
brand_signage = p.get("brand_signage") or ""
|
||||
visual_elements = p.get("visual_elements") or []
|
||||
atmosphere = p.get("atmosphere") or ""
|
||||
outfit_style = p.get("outfit_style") or ""
|
||||
upper_wear = p.get("upper_wear") or ""
|
||||
lower_wear = p.get("lower_wear") or ""
|
||||
expression = p.get("expression") or ""
|
||||
has_person = p.get("has_person", False)
|
||||
person_count = p.get("person_count") or 0
|
||||
|
||||
lines = []
|
||||
|
||||
# 门店场景
|
||||
if store_type or "门店" in str(category) or "店铺" in str(category):
|
||||
lines.append(f"### {brand_signage or store_type or '门店'}")
|
||||
if store_type:
|
||||
lines.append(f"**类型**:{store_type}")
|
||||
if brand_signage:
|
||||
lines.append(f"**品牌标识**:{brand_signage}")
|
||||
if atmosphere:
|
||||
lines.append(f"**氛围**:{atmosphere}")
|
||||
if visual_elements:
|
||||
lines.append("**陈列亮点**")
|
||||
for e in visual_elements[:5]:
|
||||
lines.append(f"- {e}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# 人物
|
||||
if has_person or person_count or outfit_style:
|
||||
lines.append("### 人物描述")
|
||||
if person_count:
|
||||
lines.append(f"**人数**:{person_count}人")
|
||||
if outfit_style:
|
||||
lines.append(f"**风格**:{outfit_style}")
|
||||
wear_parts = []
|
||||
if upper_wear:
|
||||
wear_parts.append(f"上装:{upper_wear}")
|
||||
if lower_wear:
|
||||
wear_parts.append(f"下装:{lower_wear}")
|
||||
if wear_parts:
|
||||
lines.append("**穿搭**")
|
||||
for w in wear_parts:
|
||||
lines.append(f"- {w}")
|
||||
if expression:
|
||||
lines.append(f"**气质**:{expression}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# 商品(默认)
|
||||
lines.append(f"### {name}")
|
||||
if brand and brand != "无法判断":
|
||||
lines.append(f"**品牌**:{brand}")
|
||||
if category and category != "非产品图" and category != "无法判断":
|
||||
lines.append(f"**类目**:{category}")
|
||||
if key_features:
|
||||
lines.append("**核心特征**")
|
||||
for kf in key_features[:5]:
|
||||
lines.append(f"- {kf}")
|
||||
if appearance:
|
||||
lines.append(f"**外观**:{appearance[:100]}")
|
||||
if packaging and packaging != "无法判断":
|
||||
lines.append(f"**包装**:{packaging}")
|
||||
if text_on_package:
|
||||
lines.append(f"**文字信息**:{'、'.join(text_on_package[:3])}")
|
||||
if scene and scene != "通用":
|
||||
lines.append(f"**场景**:{scene}")
|
||||
if mood:
|
||||
lines.append(f"**氛围**:{mood}")
|
||||
|
||||
return "\n".join(lines) if lines else f"### {name}\n暂无详细描述"
|
||||
|
||||
|
||||
def _person_subject(gender: str, age: str) -> str:
|
||||
gw = _GENDER_WORD.get(gender, "")
|
||||
if age == "儿童":
|
||||
@@ -381,9 +465,59 @@ def assemble_result(idx: int, fast_json: dict | None, ocr_texts: list[str]) -> d
|
||||
ocr_texts = ocr_texts or []
|
||||
|
||||
if _is_v4_schema(fj):
|
||||
return _assemble_v4(idx, fj, ocr_texts)
|
||||
result = _assemble_v4(idx, fj, ocr_texts)
|
||||
else:
|
||||
return _assemble_old(idx, fj, ocr_texts)
|
||||
result = _assemble_old(idx, fj, ocr_texts)
|
||||
result = _apply_partial_fallback(result, fj)
|
||||
# summary_markdown: 优先用VLM输出,否则兜底生成
|
||||
if "summary_markdown" not in result or not result.get("summary_markdown"):
|
||||
result["summary_markdown"] = _build_summary_markdown(result)
|
||||
return result
|
||||
|
||||
|
||||
def _apply_partial_fallback(result: dict[str, Any], fj: dict) -> dict[str, Any]:
|
||||
"""partial(截断修复)产物的字段兜底:用已有碎片填充空字段,
|
||||
避免"无法判断"直接透传给下游。非partial产物原样返回。"""
|
||||
if not fj.get("_partial"):
|
||||
return result
|
||||
desc = str(fj.get("description") or "").strip()
|
||||
# 收集所有顶层标量碎片作为兜底素材
|
||||
fragments: list[str] = []
|
||||
for k in ("main_subject", "store_type", "scene_type", "description"):
|
||||
v = fj.get(k)
|
||||
if isinstance(v, str) and v.strip() and v != "无法判断":
|
||||
fragments.append(v.strip())
|
||||
for arr_k in ("environment_objects", "key_elements", "visual_elements"):
|
||||
arr = fj.get(arr_k) or []
|
||||
if isinstance(arr, list):
|
||||
for item in arr[:3]:
|
||||
if isinstance(item, str) and item.strip():
|
||||
fragments.append(item.strip())
|
||||
elif isinstance(item, dict):
|
||||
tv = item.get("text") or item.get("name")
|
||||
if tv:
|
||||
fragments.append(str(tv))
|
||||
frag_text = ";".join(fragments[:3])
|
||||
|
||||
if result.get("name") in ("未识别", "", None) and (desc or frag_text):
|
||||
result["name"] = (desc or fragments[0])[:30]
|
||||
if str(result.get("appearance", "")).startswith("无法判断"):
|
||||
if desc:
|
||||
result["appearance"] = desc[:200]
|
||||
elif frag_text:
|
||||
result["appearance"] = frag_text[:200]
|
||||
if result.get("key_features") in (["无法判断"], []) and (desc or fragments):
|
||||
kf = []
|
||||
if desc:
|
||||
kf.append(desc[:30])
|
||||
for f in fragments[:3]:
|
||||
if f not in kf:
|
||||
kf.append(f[:40])
|
||||
result["key_features"] = kf[:8]
|
||||
if result.get("summary") in ("未识别", "", None) and (desc or fragments):
|
||||
result["summary"] = (desc or fragments[0])[:40]
|
||||
result["_partial"] = True
|
||||
return result
|
||||
|
||||
|
||||
def _assemble_v4(idx: int, fj: dict, ocr_texts: list[str]) -> dict[str, Any]:
|
||||
@@ -441,6 +575,16 @@ def _assemble_v4(idx: int, fj: dict, ocr_texts: list[str]) -> dict[str, Any]:
|
||||
parts.append(f"人物:{pv2}")
|
||||
return parts
|
||||
|
||||
# partial截断保护:声明了product但products数组没来得及输出时,
|
||||
# 按已返回的碎片字段改路由,避免直接掉到other丢信息
|
||||
if fj.get("_partial") and vtype == "product" and not products:
|
||||
if any(fj.get(k) for k in ("signage_details", "store_layout", "brand_signage", "store_type")):
|
||||
vtype = "store"
|
||||
elif any(fj.get(k) for k in ("key_elements", "main_subject", "scene_type", "spatial_layout")):
|
||||
vtype = "scene"
|
||||
else:
|
||||
vtype = "other"
|
||||
|
||||
# ── 人物类 ──
|
||||
if vtype == "person":
|
||||
# 取第一个人物信息(v5 schema人物信息在顶层)
|
||||
@@ -601,15 +745,54 @@ def _assemble_v4(idx: int, fj: dict, ocr_texts: list[str]) -> dict[str, Any]:
|
||||
# 门店类
|
||||
if vtype == "store":
|
||||
store_type = fj.get("store_type") or "店铺"
|
||||
name = store_type
|
||||
brand = fj.get("brand_signage") or "无法判断"
|
||||
# brand 多级兜底:brand_signage → visible_text招牌文字 → text_on_package短词
|
||||
brand_raw = fj.get("brand_signage")
|
||||
if not brand_raw or brand_raw in ("无法判断", "", None):
|
||||
brand = None
|
||||
# 从visible_text找招牌文字(通常是位置含招牌/门头/背景的短词)
|
||||
for vt in visible_text:
|
||||
vt_str = vt.get("text") if isinstance(vt, dict) else str(vt)
|
||||
if not vt_str or len(vt_str) < 2 or len(vt_str) > 12:
|
||||
continue
|
||||
loc = (vt.get("location") or "") if isinstance(vt, dict) else ""
|
||||
if any(k in loc for k in ("招牌", "门头", "背景", "招牌墙")):
|
||||
brand = vt_str
|
||||
break
|
||||
# 从text_on_package找2-8字的短词(非描述性)
|
||||
if not brand:
|
||||
_desc_words = {"干净", "整洁", "温馨", "专业", "明亮", "舒适", "宽敞", "现代", "传统", "时尚"}
|
||||
for t in text_on_package:
|
||||
if 2 <= len(t) <= 8 and t not in _desc_words and not any(c in t for c in "的了是在我"):
|
||||
brand = t
|
||||
break
|
||||
if not brand:
|
||||
brand = "无法判断"
|
||||
else:
|
||||
brand = brand_raw
|
||||
# name兜底:更保守的策略
|
||||
# - store_type有具体值(非"店铺")时直接用store_type
|
||||
# - store_type为默认"店铺"时,用"门店门头"而非brand(避免与brand字段重复)
|
||||
if store_type and store_type != "店铺":
|
||||
name = store_type
|
||||
else:
|
||||
name = "门店门头"
|
||||
category = "门店场景"
|
||||
# appearance: store_layout + furnishings + 陈设色调
|
||||
appearance_parts = []
|
||||
if fj.get("store_layout"):
|
||||
appearance_parts.append(str(fj["store_layout"]))
|
||||
furnishings = fj.get("furnishings") or []
|
||||
if isinstance(furnishings, list) and furnishings:
|
||||
if isinstance(furnishings, dict):
|
||||
_furn_vals = []
|
||||
for fk in ("materials", "furniture", "shelving", "seating"):
|
||||
fv = furnishings.get(fk)
|
||||
if isinstance(fv, list):
|
||||
_furn_vals.extend(str(x) for x in fv if x)
|
||||
elif isinstance(fv, str) and fv:
|
||||
_furn_vals.append(fv)
|
||||
if _furn_vals:
|
||||
appearance_parts.append("陈设:" + "、".join(_furn_vals[:4]))
|
||||
elif isinstance(furnishings, list) and furnishings:
|
||||
appearance_parts.append("陈设:" + "、".join(str(f) for f in furnishings[:4] if f))
|
||||
if fj.get("cleanliness"):
|
||||
appearance_parts.append(str(fj["cleanliness"]))
|
||||
|
||||
@@ -23,10 +23,10 @@ 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", "15"))
|
||||
_FAST_JSON_TIMEOUT = float(os.environ.get("VISION_V2_FAST_JSON_TIMEOUT", "15"))
|
||||
_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", "30"))
|
||||
_PRO_TIMEOUT = float(os.environ.get("VISION_V2_PRO_TIMEOUT", "45"))
|
||||
|
||||
_FALLBACK_RESULT = {
|
||||
"name": "未识别",
|
||||
@@ -58,27 +58,29 @@ def analyze_image_v2(idx: int, img_url: str) -> dict[str, Any]:
|
||||
|
||||
fj_result: dict[str, Any] | None = None
|
||||
ocr_result: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
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)
|
||||
try:
|
||||
for fut in as_completed([f_fj, f_ocr], timeout=_FAST_TIMEOUT):
|
||||
try:
|
||||
res = fut.result(timeout=1)
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 图片 #%d 子任务异常: %s", idx, e)
|
||||
continue
|
||||
if fut is f_fj and isinstance(res, dict):
|
||||
fj_result = res
|
||||
elif fut is f_ocr and isinstance(res, list):
|
||||
ocr_result = res
|
||||
except TimeoutError:
|
||||
for f in (f_fj, f_ocr):
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
logger.warning("[vision.v2] 图片 #%d fast路径超时(%.0fs),走pro兜底", idx, _FAST_TIMEOUT)
|
||||
|
||||
fast_elapsed = time.time() - t0
|
||||
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)
|
||||
try:
|
||||
for fut in as_completed([f_fj, f_ocr], timeout=_FAST_TIMEOUT):
|
||||
try:
|
||||
res = fut.result(timeout=1)
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 图片 #%d 子任务异常: %s", idx, e)
|
||||
continue
|
||||
if fut is f_fj and isinstance(res, dict):
|
||||
fj_result = res
|
||||
elif fut is f_ocr and isinstance(res, list):
|
||||
ocr_result = res
|
||||
except TimeoutError:
|
||||
for f in (f_fj, f_ocr):
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
logger.warning("[vision.v2] 图片 #%d fast路径超时(%.0fs),走pro兜底", idx, _FAST_TIMEOUT)
|
||||
finally:
|
||||
fast_elapsed = time.time() - t0
|
||||
pool.shutdown(wait=False) # 不等待未完成的线程,避免计时膨胀
|
||||
|
||||
if fj_result:
|
||||
assembled = assembler.assemble_result(idx, fj_result, ocr_result)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""VLM 返回文本的稳健 JSON 提取工具。
|
||||
|
||||
背景:复杂门店图 VLM 输出经常被 max_tokens 截断(finish_reason=length),
|
||||
json.loads 失败后整个结果被丢弃,导致"未识别"。本工具提供:
|
||||
1. markdown 代码块剥离(含只开不闭的截断场景)
|
||||
2. 最外层 { } 切片
|
||||
3. 非法控制字符清理
|
||||
4. 直接 json.loads
|
||||
5. 截断 JSON 括号/引号栈补全修复
|
||||
6. 尾部逐字符截断重试(去除最后一个不完整 token 后修复)
|
||||
|
||||
成功返回 dict;截断修复产物带 _partial=True 标记;彻底失败返回 None。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CODE_FENCE_RE = re.compile(r"^```(?:json)?\s*\n?(.*?)\n?```\s*$", re.DOTALL)
|
||||
|
||||
|
||||
def _strip_code_fence(s: str) -> str:
|
||||
s = s.strip()
|
||||
m = _CODE_FENCE_RE.match(s)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
# 兼容开头 ```json 但结尾无 ```(截断场景)
|
||||
if s.startswith("```"):
|
||||
lines = s.split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
s = "\n".join(lines).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _repair_truncated_json(text: str) -> str:
|
||||
"""尝试补全被截断的JSON:维护 bracket/quote 栈,在末尾补闭合符。"""
|
||||
stack: list[str] = []
|
||||
in_string = False
|
||||
escape = False
|
||||
for ch in text:
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\" and in_string:
|
||||
escape = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch in "{[":
|
||||
stack.append(ch)
|
||||
elif ch == "}":
|
||||
if stack and stack[-1] == "{":
|
||||
stack.pop()
|
||||
elif ch == "]":
|
||||
if stack and stack[-1] == "[":
|
||||
stack.pop()
|
||||
repair = ""
|
||||
if in_string:
|
||||
repair += '"'
|
||||
for opener in reversed(stack):
|
||||
repair += "}" if opener == "{" else "]"
|
||||
if repair:
|
||||
logger.info(
|
||||
"[json_utils] 截断JSON修复: 补全%d个闭合符 in_string=%s",
|
||||
len(repair),
|
||||
in_string,
|
||||
)
|
||||
return text + repair
|
||||
|
||||
|
||||
def _clean_invalid_chars(text: str) -> str:
|
||||
"""清理JSON中非法的控制字符(tab/newline 之外的 0x00-0x1f 段)。"""
|
||||
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text)
|
||||
|
||||
|
||||
def extract_json_object(text: str) -> dict | None:
|
||||
"""从VLM返回文本中稳健提取JSON对象。
|
||||
|
||||
返回 dict 或 None。成功的 dict 可能带 _partial=True 标记,
|
||||
表示原始文本被截断、经括号补全后得到的产物。
|
||||
"""
|
||||
if not text or not isinstance(text, str):
|
||||
return None
|
||||
# 1. 剥离 markdown
|
||||
text = _strip_code_fence(text)
|
||||
# 2. 找最外层 { }
|
||||
lpos = text.find("{")
|
||||
if lpos < 0:
|
||||
return None
|
||||
rpos = text.rfind("}")
|
||||
if rpos > lpos:
|
||||
text = text[lpos : rpos + 1]
|
||||
else:
|
||||
# 截断场景:无任何闭合 },取到末尾交给修复器
|
||||
text = text[lpos:]
|
||||
# 3. 清理非法控制字符
|
||||
text = _clean_invalid_chars(text)
|
||||
# 4. 直接 loads
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
return obj if isinstance(obj, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 5. 尝试截断修复
|
||||
repaired = _repair_truncated_json(text)
|
||||
try:
|
||||
obj = json.loads(repaired)
|
||||
if isinstance(obj, dict):
|
||||
obj["_partial"] = True
|
||||
return obj
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 6. 尾部逐字符截断重试(去除最后一个不完整 token)
|
||||
for _ in range(50):
|
||||
last_comma = repaired.rfind(",")
|
||||
last_brace = max(repaired.rfind("}"), repaired.rfind("]"))
|
||||
cut = max(last_comma, last_brace)
|
||||
if cut < 10:
|
||||
break
|
||||
repaired = repaired[: cut + 1]
|
||||
repaired = _repair_truncated_json(repaired)
|
||||
try:
|
||||
obj = json.loads(repaired)
|
||||
if isinstance(obj, dict):
|
||||
obj["_partial"] = True
|
||||
return obj
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
@@ -13,7 +13,6 @@ fast_json 超时/返回非 JSON/识别为空时,本路径单次调用兜底。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -22,7 +21,7 @@ from . import _prompt, assembler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = 30
|
||||
_DEFAULT_TIMEOUT = 45
|
||||
|
||||
|
||||
def call_pro_vlm(
|
||||
@@ -38,7 +37,7 @@ def call_pro_vlm(
|
||||
try:
|
||||
from packages.shared.ai_router import ai_router
|
||||
|
||||
client = ai_router.get_vision_client("image_analysis", variant="primary")
|
||||
client = ai_router.get_vision_client("image_analysis", variant="fallback")
|
||||
if not client or not client.is_available:
|
||||
logger.warning("[vision.v2] pro vision client 不可用,跳过")
|
||||
return None
|
||||
@@ -68,30 +67,43 @@ def call_pro_vlm(
|
||||
"enable_thinking": False,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
if max_tokens is not None:
|
||||
call_kwargs["max_tokens"] = max_tokens
|
||||
raw = client.vision_completion(**call_kwargs)
|
||||
elapsed = time.time() - t0
|
||||
if not raw:
|
||||
logger.warning("[vision.v2] pro 返回空 elapsed=%.1fs", elapsed)
|
||||
return None
|
||||
# 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。"}
|
||||
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:
|
||||
break
|
||||
logger.warning("[vision.v2] pro 非JSON(100字) outer=%s: %s", _outer, raw[:100])
|
||||
|
||||
elapsed = time.time() - t0
|
||||
if obj is None:
|
||||
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",
|
||||
"[vision.v2] pro 完成 model=%s elapsed=%.1fs type=%s",
|
||||
client.model,
|
||||
elapsed,
|
||||
obj.get("type"),
|
||||
)
|
||||
s = _strip_code_fence(raw)
|
||||
lpos, rr = s.find("{"), s.rfind("}")
|
||||
if lpos >= 0 and rr > lpos:
|
||||
s = s[lpos : rr + 1]
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[vision.v2] pro JSON 解析失败 head=%s", raw[:200])
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
|
||||
# 通过assembler统一组装,兼容v4嵌套schema和旧扁平schema
|
||||
result = assembler.assemble_result(idx, obj, [])
|
||||
@@ -102,15 +114,3 @@ def call_pro_vlm(
|
||||
elapsed = time.time() - t0
|
||||
logger.warning("[vision.v2] pro 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _strip_code_fence(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
lines = s.split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip().startswith("```"):
|
||||
lines = lines[:-1]
|
||||
s = "\n".join(lines).strip()
|
||||
return s
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -23,19 +22,7 @@ from . import _prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = 15
|
||||
|
||||
|
||||
def _strip_code_fence(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
lines = s.split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip().startswith("```"):
|
||||
lines = lines[:-1]
|
||||
s = "\n".join(lines).strip()
|
||||
return s
|
||||
_DEFAULT_TIMEOUT = 20
|
||||
|
||||
|
||||
def call_fast_json(
|
||||
@@ -86,35 +73,48 @@ def call_fast_json(
|
||||
}
|
||||
if max_tokens is not None:
|
||||
call_kwargs["max_tokens"] = max_tokens
|
||||
raw = client.vision_completion(**call_kwargs)
|
||||
elapsed = time.time() - t0
|
||||
if not raw:
|
||||
logger.warning("[vision.v2] fast_json 返回空 elapsed=%.1fs", elapsed)
|
||||
return None
|
||||
|
||||
# 双重防护:第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。"}
|
||||
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],
|
||||
)
|
||||
|
||||
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.info(
|
||||
"[vision.v2] fast_json 完成 model=%s elapsed=%.1fs",
|
||||
"[vision.v2] fast_json 完成 model=%s elapsed=%.1fs has_person=%s type=%s",
|
||||
client.model,
|
||||
elapsed,
|
||||
)
|
||||
text = _strip_code_fence(raw)
|
||||
lpos, r = text.find("{"), text.rfind("}")
|
||||
if lpos >= 0 and r > lpos:
|
||||
text = text[lpos : r + 1]
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[vision.v2] fast_json JSON 解析失败 elapsed=%.1fs head=%s", elapsed, raw[:200])
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
logger.warning("[vision.v2] fast_json 非 dict: %s", type(obj))
|
||||
return None
|
||||
logger.info(
|
||||
"[vision.v2] fast_json 完成 elapsed=%.1fs has_person=%s has_product=%s category=%s",
|
||||
elapsed,
|
||||
obj.get("has_person"),
|
||||
obj.get("has_product"),
|
||||
obj.get("category"),
|
||||
obj.get("type"),
|
||||
)
|
||||
return obj
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""蚂蚁 Ditto 数字人口型 API 客户端 — #2076.
|
||||
|
||||
封装 Ditto FastAPI(部署在 5060Ti GPU 节点,Tailscale 内网可达):
|
||||
- GET /health 健康检查
|
||||
- POST /generate 生成口型视频(同步返回 MP4 流)
|
||||
|
||||
关键特性:
|
||||
- 入参:video_url(人物模板视频 URL) + audio_url(TTS 音频 URL) + script(文案原文)
|
||||
- 出参:直接返回 video/mp4 字节流(自带音频,无需二次混流)
|
||||
- 429 时指数退避重试(最多 ditto_max_retries 次)
|
||||
- 500/超时视为失败
|
||||
- 输出 MP4 字节流转存到自家 OSS,返回公网 URL
|
||||
|
||||
注意:
|
||||
- 保留 MuseTalk/GPU 路径不变;本服务作为更高优先级的第三条口型路径
|
||||
- 不传 emotion/表情精细控制,使用默认 emo_global=4(中性)+ use_script_emo=true(关键词驱动表情)
|
||||
- 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)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""配置是否完整(base_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: int = 6,
|
||||
) -> 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 = " "
|
||||
|
||||
payload = {
|
||||
"video_url": driver_url,
|
||||
"audio_url": audio_url,
|
||||
"script": script,
|
||||
"emo_global": emo_global,
|
||||
"use_script_emo": use_script_emo,
|
||||
"blend_frames": blend_frames,
|
||||
}
|
||||
url = f"{self.base_url}/generate"
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
start = time.monotonic()
|
||||
with httpx.Client(timeout=self.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.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 请求超时({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,
|
||||
) -> DittoResult:
|
||||
"""调用 generate 并把 MP4 转存到自家 OSS,返回带 video_url 的结果."""
|
||||
result = self.generate(audio_url=audio_url, script=script, video_url=video_url)
|
||||
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
|
||||
@@ -110,10 +110,12 @@ _INTENT_SYSTEM = f"""你负责理解用户的营销意图。用户给的文案
|
||||
|
||||
_INTENT_USER = """用户原始文案:{user_copy_text}
|
||||
所属行业:{industry}
|
||||
营销目的:{marketing_purpose}
|
||||
图片分析结果(供参考):
|
||||
{image_analysis}
|
||||
图片类型推断:{image_category_hint}
|
||||
|
||||
请理解用户意图,按标签格式输出。"""
|
||||
请理解用户意图,按标签格式输出。注意:theme和emotion_tone应与图片类型和营销目的匹配——门店类图片偏向"门店探店/到店体验",商品图偏向"好物分享/产品种草",人物图偏向"穿搭/人物故事"。"""
|
||||
|
||||
_INTENT_EXAMPLE = """<intent_summary>一款厨房去油污神器,喷一喷油污就掉</intent_summary>
|
||||
<core_messages>
|
||||
@@ -176,7 +178,7 @@ _FUSION_EXAMPLE = """<title>厨房重油污,别再用洗洁精硬擦了</title
|
||||
<segment duration_sec="4" image_index="0">39块钱625ml,厨房重油污的可以试一瓶</segment>
|
||||
</script_segments>
|
||||
<voiceover_script>这油污我真的忍很久了,用洗洁精擦半天都没用。后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净。39块钱625ml,厨房重油污的可以试一瓶。</voiceover_script>
|
||||
<overview_theme>厨房油污清洁好物分享</overview_theme>
|
||||
<overview_theme>厨房好物分享·产品种草</overview_theme>
|
||||
<scene_and_lighting>简洁明亮的厨房台面场景,自然光从窗户洒入,色调温暖柔和,突出产品白色瓶身与去油污对比效果。</scene_and_lighting>
|
||||
<word_count>58</word_count>
|
||||
<estimated_duration>13</estimated_duration>"""
|
||||
@@ -215,6 +217,8 @@ _STORYBOARD_USER = """目标时长:{duration}秒
|
||||
图片分析结果:
|
||||
{image_analysis}
|
||||
|
||||
重要:overview_theme 必须与图片实际内容和营销目的匹配。门店/餐饮/服务类图片用"门店探店·到店体验";商品图用"好物分享·产品种草";人物图用"穿搭分享·人物故事";场景图用"空间体验·场景氛围"。不要对所有图片都使用"好物分享"。
|
||||
|
||||
请按标签格式输出分镜。"""
|
||||
|
||||
_STORYBOARD_EXAMPLE = """<clips>
|
||||
|
||||
@@ -53,6 +53,9 @@ _LOCATIONS = ["title", "hook", "body_points", "cta", "script_segments"]
|
||||
|
||||
|
||||
class Reviewer:
|
||||
# markdown展示字段不参与合规审核(避免格式字符误判)
|
||||
_MARKDOWN_FIELDS = {"summary_markdown", "copy_display_markdown"}
|
||||
|
||||
def __init__(self, client=None):
|
||||
if client is None:
|
||||
try:
|
||||
@@ -70,8 +73,9 @@ class Reviewer:
|
||||
local = self._rule_check(fusion, intent, fusion_level)
|
||||
llm_result = self._llm_review(fusion, intent, fusion_level)
|
||||
if llm_result is None:
|
||||
# LLM审核失败(超时/网络错误等),降级放行,不阻断渲染
|
||||
return ReviewResult(
|
||||
passed=not local,
|
||||
passed=True,
|
||||
issues=local,
|
||||
rewrite_suggestions=[],
|
||||
raw="",
|
||||
@@ -86,6 +90,17 @@ class Reviewer:
|
||||
)
|
||||
|
||||
def _llm_review(self, fusion: FusionResult, intent: IntentResult, fusion_level: str) -> Optional[ReviewResult]:
|
||||
try:
|
||||
return self._llm_review_inner(fusion, intent, fusion_level)
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("[Reviewer] LLM审核调用异常,降级放行: %s", e)
|
||||
return None
|
||||
|
||||
def _llm_review_inner(
|
||||
self, fusion: FusionResult, intent: IntentResult, fusion_level: str
|
||||
) -> Optional[ReviewResult]:
|
||||
template = get_template("review")
|
||||
system = render_system_prompt(template)
|
||||
user = render_user_prompt(
|
||||
@@ -101,6 +116,7 @@ class Reviewer:
|
||||
],
|
||||
temperature=0.2,
|
||||
max_tokens=1024,
|
||||
timeout=25,
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
@@ -247,6 +263,7 @@ class Reviewer:
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=2048,
|
||||
timeout=25,
|
||||
)
|
||||
if not raw:
|
||||
return self._rule_fix(fusion, review)
|
||||
@@ -301,10 +318,13 @@ class Reviewer:
|
||||
|
||||
@staticmethod
|
||||
def _fusion_text(fusion: FusionResult) -> str:
|
||||
_MARKDOWN_FIELDS = {"summary_markdown", "copy_display_markdown"}
|
||||
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)
|
||||
# 过滤掉markdown展示字段,避免格式字符被误判
|
||||
parts = [p for p in parts if not any(mk in p for mk in _MARKDOWN_FIELDS)]
|
||||
return "\n".join(p for p in parts if p)
|
||||
|
||||
@staticmethod
|
||||
|
||||
+30
-1
@@ -96,7 +96,7 @@ class SharedSettings(BaseSettings):
|
||||
doubao_fast_model: str = ""
|
||||
doubao_base_url: str = ""
|
||||
doubao_timeout: int = 45
|
||||
doubao_max_retries: int = 1
|
||||
doubao_max_retries: int = 3
|
||||
doubao_vision_model: str = ""
|
||||
doubao_vision_lite_model: str = ""
|
||||
doubao_vision_use_lite: bool = True
|
||||
@@ -173,6 +173,35 @@ 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 秒循环、光线均匀、半身)。Ditto 模式下忽略
|
||||
# 用户上传的驱动视频/图片,统一用该模板;后续可扩展为多模板让用户选择。
|
||||
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 单次请求超时(秒):数字人半身视频推理通常 30-120s
|
||||
ditto_request_timeout: int = Field(
|
||||
default=300,
|
||||
validation_alias=AliasChoices("DITTO_REQUEST_TIMEOUT", "ditto_request_timeout"),
|
||||
)
|
||||
|
||||
# ── P4000 NVENC 硬件编码 ────────────────────────────────────────────
|
||||
# GPU 编码总开关;关闭或 endpoint 为空时始终走本机 CPU libx264
|
||||
enable_gpu_encode: bool = Field(
|
||||
|
||||
@@ -198,6 +198,7 @@ class DoubaoClient:
|
||||
self.temperature: float | None = temperature
|
||||
self.extra_params: dict = extra_params or {}
|
||||
self.vision_model: str = settings.doubao_vision_model
|
||||
self.last_finish_reason: str = ""
|
||||
self.vision_lite_model: str = settings.doubao_vision_lite_model
|
||||
self.fast_model: str = settings.doubao_fast_model
|
||||
self.embedding_model: str = settings.doubao_embedding_model
|
||||
@@ -210,6 +211,13 @@ class DoubaoClient:
|
||||
# 最近一次图片生成的详细错误,供上层读取
|
||||
self.last_image_error: dict = {}
|
||||
|
||||
def _resolve_timeout(self, timeout) -> "httpx.Timeout":
|
||||
"""将整数超时转为 httpx.Timeout,区分 connect/read/write/pool,避免 read 卡到 TCP 120s 默认值."""
|
||||
if isinstance(timeout, httpx.Timeout):
|
||||
return timeout
|
||||
t = int(timeout) if timeout else 60
|
||||
return httpx.Timeout(connect=10, read=max(t, 10), write=10, pool=5)
|
||||
|
||||
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
|
||||
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
|
||||
if not self.is_available or not text or not text.strip():
|
||||
@@ -300,7 +308,7 @@ class DoubaoClient:
|
||||
_t0 = time.time()
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
_req_timeout = timeout if timeout is not None else self.timeout
|
||||
_req_timeout = self._resolve_timeout(timeout if timeout is not None else self.timeout)
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers=headers,
|
||||
@@ -311,9 +319,9 @@ class DoubaoClient:
|
||||
data = response.json()
|
||||
finish_reason = (data.get("choices") or [{}])[0].get("finish_reason", "")
|
||||
if finish_reason == "length" and attempt < self.max_retries:
|
||||
# 输出被 max_tokens 截断:1.5x 扩容后重试(计入 max_retries,不额外增加)
|
||||
# 输出被 max_tokens 截断:2.0x 扩容后重试(计入 max_retries,不额外增加)
|
||||
old_max = int(payload["max_tokens"])
|
||||
new_max = int(old_max * 1.5)
|
||||
new_max = int(old_max * 2)
|
||||
payload["max_tokens"] = new_max
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
@@ -326,15 +334,16 @@ class DoubaoClient:
|
||||
time.sleep(wait)
|
||||
continue
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
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:
|
||||
@@ -424,7 +433,7 @@ class DoubaoClient:
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
|
||||
req_timeout = timeout or self.timeout
|
||||
req_timeout = self._resolve_timeout(timeout or self.timeout)
|
||||
last_error: Optional[Exception] = None
|
||||
_t0 = time.time()
|
||||
for attempt in range(self.max_retries + 1):
|
||||
@@ -439,9 +448,9 @@ class DoubaoClient:
|
||||
data = response.json()
|
||||
finish_reason = (data.get("choices") or [{}])[0].get("finish_reason", "")
|
||||
if finish_reason == "length" and attempt < self.max_retries:
|
||||
# 视觉输出被 max_tokens 截断:1.5x 扩容后重试(计入 max_retries)
|
||||
# 视觉输出被 max_tokens 截断:2.0x 扩容后重试(计入 max_retries)
|
||||
old_max = int(payload["max_tokens"])
|
||||
new_max = int(old_max * 1.5)
|
||||
new_max = int(old_max * 2)
|
||||
payload["max_tokens"] = new_max
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
@@ -454,6 +463,7 @@ class DoubaoClient:
|
||||
time.sleep(wait)
|
||||
continue
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
self.last_finish_reason = finish_reason
|
||||
_elapsed = time.time() - _t0
|
||||
logger.info(
|
||||
"[doubao] vision_completion 完成 model=%s tokens_in=%d tokens_out=%d elapsed=%.1fs attempt=%d",
|
||||
|
||||
@@ -62,7 +62,15 @@ class CapabilityConfig:
|
||||
class TTSClient:
|
||||
"""TTS 客户端(简单配置持有者,实际调用由 CosyVoiceService 完成)"""
|
||||
|
||||
def __init__(self, provider: str, api_key: str, base_url: str, model: str, timeout: int = 60, extra_params: dict | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: int = 60,
|
||||
extra_params: dict | None = None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
@@ -78,7 +86,15 @@ class TTSClient:
|
||||
class ImageGenClient:
|
||||
"""图片生成客户端(简单配置持有者)"""
|
||||
|
||||
def __init__(self, provider: str, api_key: str, base_url: str, model: str, timeout: int = 60, extra_params: dict | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: int = 60,
|
||||
extra_params: dict | None = None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
@@ -94,7 +110,15 @@ class ImageGenClient:
|
||||
class VideoGenClient:
|
||||
"""视频生成客户端(简单配置持有者)"""
|
||||
|
||||
def __init__(self, provider: str, api_key: str, base_url: str, model: str, timeout: int = 600, extra_params: dict | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: int = 600,
|
||||
extra_params: dict | None = None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
@@ -253,13 +277,27 @@ class AIRouter:
|
||||
return config
|
||||
|
||||
def _get_model_or_fallback(self, cap: CapabilityConfig, variant: str = "primary") -> ModelConfig | None:
|
||||
"""按 variant 选择模型,不存在则 fallback"""
|
||||
if variant == "lite" and cap.lite_model:
|
||||
return cap.lite_model
|
||||
if cap.primary_model:
|
||||
return cap.primary_model
|
||||
if cap.fallback_model:
|
||||
return cap.fallback_model
|
||||
"""按 variant 选择模型,不存在则降级。
|
||||
|
||||
- primary: primary → fallback
|
||||
- lite: lite → primary
|
||||
- fallback: fallback → primary(修复点:此前 fallback variant 被忽略,错误地使用了 primary 模型)
|
||||
"""
|
||||
if variant == "fallback":
|
||||
if cap.fallback_model:
|
||||
return cap.fallback_model
|
||||
if cap.primary_model:
|
||||
return cap.primary_model
|
||||
elif variant == "lite":
|
||||
if cap.lite_model:
|
||||
return cap.lite_model
|
||||
if cap.primary_model:
|
||||
return cap.primary_model
|
||||
else: # primary
|
||||
if cap.primary_model:
|
||||
return cap.primary_model
|
||||
if cap.fallback_model:
|
||||
return cap.fallback_model
|
||||
return None
|
||||
|
||||
# ── 构建客户端 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -81,7 +81,7 @@ class TestSharedSettingsDefaults:
|
||||
s = SharedSettings()
|
||||
assert s.doubao_model == "" # 零硬编码:默认值已清空
|
||||
assert s.doubao_timeout == 45 # #2180 默认提到45s
|
||||
assert s.doubao_max_retries == 1
|
||||
assert s.doubao_max_retries == 3
|
||||
|
||||
|
||||
class TestAPISettingsDefaults:
|
||||
|
||||
@@ -111,7 +111,7 @@ class TestSharedSettingsDefaults:
|
||||
"""豆包默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.doubao_timeout == 45 # #2180 默认提到45s
|
||||
assert s.doubao_max_retries == 1
|
||||
assert s.doubao_max_retries == 3
|
||||
assert s.doubao_base_url == "" # 零硬编码:默认值已清空
|
||||
|
||||
def test_default_empty_api_keys(self):
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Ditto 蚂蚁数字人客户端单元测试 — #2076."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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"] == " "
|
||||
Reference in New Issue
Block a user