Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c782f89d1 | |||
| 2b4f11036f | |||
| 2881da65cf | |||
| 1037e218bb | |||
| f0514d7487 | |||
| bf8f62ec5b | |||
| cb911f5eba | |||
| f6fa3ea653 | |||
| 6a7709ba43 | |||
| 2c70dd4c29 | |||
| 5548e78eee | |||
| 44bd96b148 | |||
| dda67cd10f | |||
| 233d0272a9 | |||
| 631dd643c0 | |||
| 50489b05d6 | |||
| 252ea71d50 | |||
| d5f6e9499d | |||
| afd6b9c0b8 | |||
| 8a6ebb49be | |||
| 9e024caff5 | |||
| c7e9878f0d | |||
| b6243c8ab8 | |||
| 67e4b3fc6f | |||
| df654bce19 | |||
| bd99c161dd | |||
| 7fee203693 | |||
| 562ffc53cc | |||
| 9a67f727b3 | |||
| 5bb714f25b | |||
| 0f592add29 | |||
| 42dd5fabc6 | |||
| c7aed2c152 | |||
| 3428f4ef73 |
@@ -1187,6 +1187,14 @@ jobs:
|
||||
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
|
||||
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
|
||||
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
|
||||
DOUBAO_VISION_LITE_MODEL: "${{ secrets.DOUBAO_VISION_LITE_MODEL }}"
|
||||
DOUBAO_VISION_USE_LITE: "${{ secrets.DOUBAO_VISION_USE_LITE }}"
|
||||
DOUBAO_IMAGE_MODEL: "${{ secrets.DOUBAO_IMAGE_MODEL }}"
|
||||
DOUBAO_IMAGE_SIZE: "${{ secrets.DOUBAO_IMAGE_SIZE }}"
|
||||
DOUBAO_IMAGE_TIMEOUT: "${{ secrets.DOUBAO_IMAGE_TIMEOUT }}"
|
||||
DOUBAO_FAST_MODEL: "${{ secrets.DOUBAO_FAST_MODEL }}"
|
||||
DOUBAO_TIMEOUT: "${{ secrets.DOUBAO_TIMEOUT }}"
|
||||
DOUBAO_MAX_RETRIES: "${{ secrets.DOUBAO_MAX_RETRIES }}"
|
||||
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
|
||||
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
|
||||
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -241,19 +241,35 @@ DOUBAO_API_KEY=${DOUBAO_API_KEY}
|
||||
|
||||
# 模型 Endpoint ID(在 ARK 控制台创建推理接入点后获得)
|
||||
DOUBAO_MODEL=${DOUBAO_MODEL}
|
||||
DOUBAO_FAST_MODEL=${DOUBAO_FAST_MODEL}
|
||||
|
||||
# API Base URL
|
||||
DOUBAO_BASE_URL=${DOUBAO_BASE_URL}
|
||||
|
||||
# 请求超时(秒)
|
||||
DOUBAO_TIMEOUT=60
|
||||
DOUBAO_TIMEOUT=${DOUBAO_TIMEOUT}
|
||||
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
DOUBAO_MAX_RETRIES=${DOUBAO_MAX_RETRIES}
|
||||
|
||||
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
|
||||
# 视觉模型(支持图片/视频理解的模型,model name 格式)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
# 快速视觉模型(viral-video 图片分析 lite 路径)
|
||||
DOUBAO_VISION_LITE_MODEL=${DOUBAO_VISION_LITE_MODEL}
|
||||
|
||||
# 是否启用 lite 视觉路径(true/false)
|
||||
DOUBAO_VISION_USE_LITE=${DOUBAO_VISION_USE_LITE}
|
||||
|
||||
# 信任链文生图模型(Seedream)
|
||||
DOUBAO_IMAGE_MODEL=${DOUBAO_IMAGE_MODEL}
|
||||
|
||||
# 文生图尺寸
|
||||
DOUBAO_IMAGE_SIZE=${DOUBAO_IMAGE_SIZE}
|
||||
|
||||
# 文生图超时(秒)
|
||||
DOUBAO_IMAGE_TIMEOUT=${DOUBAO_IMAGE_TIMEOUT}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
|
||||
@@ -50,14 +50,25 @@ _IMAGE_ANALYSIS_SYSTEM = f"""你是电商商品视觉分析师,负责从商品
|
||||
请严格按下面的标签格式输出,标签名一个都不能改,不要输出任何解释,不要用代码块:
|
||||
<products> 下面每个产品用一个 <product> 标签,属性 name 是产品名、features 是外观特征、position 是 main 或 secondary、image_index 是第几张图(从0开始)。
|
||||
<colors> 下面每个主要颜色用一个 <color> 标签,属性 hex 是色值、name 是颜色名、coverage 是占比小数。
|
||||
<people> 用一个标签,属性 has_person、count、gender、age_range、pose、expression 分别描述人物情况。
|
||||
<people> 用一个标签,属性 has_person、count、gender、age_range、hair(发型发色)、skin_tone(肤色)、face_shape(脸型)、outfit(穿着)、pose(姿态)、expression(表情)分别描述人物外貌。有人物时属性尽量具体(如hair="黑色长直发"、outfit="白色衬衫"),无人像时除has_person=false外其他填"无法判断"。
|
||||
<mood> 标签写画面整体情绪氛围。
|
||||
<visible_text> 下面每处可见文字用一个 <text_item> 标签,属性 text 是文字内容、position 是位置。
|
||||
<scene> 标签写场景描述。
|
||||
<quality> 用一个标签,属性 resolution、lighting、composition、blur 描述画质。
|
||||
<key_selling_points> 下面每个卖点用一个 <point> 标签。
|
||||
|
||||
看不到或无法判断的内容,属性值填“无法判断”,布尔值填 false,不要留空标签。"""
|
||||
【人物属性硬性要求(has_person=true时必须遵守)】
|
||||
hair/skin_tone/face_shape/outfit四项绝对禁止填“无法判断”,必须基于图片可见特征给出具体中文描述:
|
||||
- hair:必须描述发型+发色,如“黑色齐肩直发”“棕色微卷中长发”“深棕色短发”
|
||||
- skin_tone:必须描述肤色,如“暖调自然肤色”“白皙肤色”“小麦色”
|
||||
- face_shape:必须描述脸型,如“鹅蛋脸”“圆脸”“瓜子脸”“方脸”
|
||||
- outfit:必须描述可见穿着,如“米色翻领衬衫”“白色T恤”“黑色连衣裙”
|
||||
即使局部被遮挡也要根据可见部分合理推断;确实看不清时按最接近的直观印象描述。
|
||||
|
||||
其他非人物属性看不到或无法判断时填“无法判断”,布尔值填false,不要留空标签。
|
||||
|
||||
【有人物场景输出参考(女性手持商品示例,必须写全10个属性,禁止省略)】
|
||||
<people has_person="true" count="1" gender="女" age_range="青年" hair="黑色齐肩直发" skin_tone="暖调自然肤色" face_shape="鹅蛋脸" outfit="米色翻领衬衫" pose="正面半身,手持商品" expression="面带微笑"/>"""
|
||||
|
||||
_IMAGE_ANALYSIS_USER = """请分析以下商品图片,共 {image_count} 张。
|
||||
所属行业:{industry}
|
||||
@@ -73,7 +84,7 @@ _IMAGE_ANALYSIS_EXAMPLE = """<products>
|
||||
<color hex="#D32F2F" name="红色" coverage="0.4"/>
|
||||
<color hex="#FFFFFF" name="白色" coverage="0.5"/>
|
||||
</colors>
|
||||
<people has_person="false" count="0" gender="无法判断" age_range="无法判断" pose="无法判断" expression="无法判断"/>
|
||||
<people has_person="false" count="0" gender="无法判断" age_range="无法判断" hair="无法判断" skin_tone="无法判断" face_shape="无法判断" outfit="无法判断" pose="无法判断" expression="无法判断"/>
|
||||
<mood>干净、实用</mood>
|
||||
<visible_text>
|
||||
<text_item text="多功能油污净" position="瓶身正面"/>
|
||||
@@ -132,7 +143,10 @@ _FUSION_SYSTEM = """你负责为短视频生成营销文案。请按思维链分
|
||||
<hook> 开头3秒钩子,5到15字。
|
||||
<body_points> 每个要点用一个 <point> 标签,属性 elaboration 是展开说明、image_index 是对应第几张图(从0开始),标签内容写要点。
|
||||
<cta> 口语化的行动号召。
|
||||
<script_segments> 每段配音用一个 <segment> 标签,属性 duration_sec 是秒数、image_index 是对应图片,标签内容写配音文案。
|
||||
<script_segments> 每段配音用一个 <segment> 标签,属性 duration_sec 是秒数、image_index 是对应图片,标签内容写配音文案(纯口播文本,不加旁白标注、不加镜头标注、不加"主播:"之类前缀)。
|
||||
<voiceover_script> 把所有 segment 的配音文案按顺序自然拼接成一段完整的纯口播文本(无标记、无括号、无前缀),长度要适配 {duration} 秒,约 {approx_chars} 字。
|
||||
<overview_theme> 视频主题(一句话概括)。
|
||||
<scene_and_lighting> 整体场景描述+光线设定(100-200字,要具体:在哪拍、什么光线、什么色调、什么氛围)。
|
||||
<word_count> 配音总字数,只写数字。
|
||||
<estimated_duration> 预计时长秒数,只写数字。
|
||||
|
||||
@@ -161,25 +175,38 @@ _FUSION_EXAMPLE = """<title>厨房重油污,别再用洗洁精硬擦了</title
|
||||
<segment duration_sec="6" image_index="0">后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净</segment>
|
||||
<segment duration_sec="4" image_index="0">39块钱625ml,厨房重油污的可以试一瓶</segment>
|
||||
</script_segments>
|
||||
<voiceover_script>这油污我真的忍很久了,用洗洁精擦半天都没用。后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净。39块钱625ml,厨房重油污的可以试一瓶。</voiceover_script>
|
||||
<overview_theme>厨房油污清洁好物分享</overview_theme>
|
||||
<scene_and_lighting>简洁明亮的厨房台面场景,自然光从窗户洒入,色调温暖柔和,突出产品白色瓶身与去油污对比效果。</scene_and_lighting>
|
||||
<word_count>58</word_count>
|
||||
<estimated_duration>13</estimated_duration>"""
|
||||
|
||||
# ── 模板4:编导级分镜(LLM)────────────────────────────────────────────
|
||||
_STORYBOARD_SYSTEM = f"""你是短视频编导,负责把文案拆成可拍摄的分镜。
|
||||
_STORYBOARD_SYSTEM = """你是短视频编导,负责把文案拆成可拍摄的分镜,为 Seedance 2.5 视频模型写编导分镜脚本。脚本将整体作为 prompt 一次性传给视频模型,必须让模型在连贯镜头流中清楚每段时间拍什么、画面如何、人物说什么。
|
||||
|
||||
工作方式:
|
||||
1. 按文案的 script_segments 顺序分配镜头。
|
||||
2. 每个镜头确定画面、运镜、时长、配音和字幕。
|
||||
2. 每个镜头确定景别/角度/运镜、画面场景与对白、人物动作细节、音效/BGM、转场。
|
||||
3. 检查所有镜头时长加起来接近目标时长,误差不超过2秒。
|
||||
4. image_index 必须在已上传图片范围内,第一张主图必须用在第一个镜头。
|
||||
|
||||
{GLOBAL_CONSTRAINTS}
|
||||
{fusion_instruction}
|
||||
|
||||
{global_constraints}
|
||||
|
||||
{negative_rules}
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<clips> 下面每个镜头用一个 <clip> 标签,属性 image_index 是图片序号(从0开始)、transition 取 fade、cut、zoom_in、slide_left、dissolve、wipe 之一、zoom 取 in、out 或 null、duration_sec 是该镜头秒数、bgm_note 是该段BGM情绪。每个 <clip> 里面包含:
|
||||
<voice_text> 该镜头配音文本;
|
||||
<subtitle_text> 字幕文本,可与配音一致或更精简;
|
||||
<ken_burns> 用一个空标签,属性 start、end 写“x,y”坐标、ease 写缓动方式;不需要运镜时坐标相同。"""
|
||||
<clips> 下面每个镜头用一个 <clip> 标签,属性 image_index 是图片序号(从0开始)、transition 取 fade/cut/zoom_in/slide_left/dissolve/wipe 之一、zoom 取 in/out/null、duration_sec 是该镜头秒数、bgm_note 是该段BGM情绪。每个 <clip> 里面包含:
|
||||
<voice_text> 该镜头配音文本(纯口播文本,不加旁白标注);
|
||||
<subtitle_text> 字幕文本,可与配音一致或更精简;
|
||||
<shot_type_angle_movement> 景别+角度+运镜(例:近景俯拍45度,缓慢推镜;中景平视,固定镜头;特写平视,快速拉镜);
|
||||
<scene_and_dialogue> 画面场景描述 + 人物口播台词(对白要自然口语化,像朋友聊天,不要硬广推销腔);
|
||||
<action_details> 人物动作、表情、物品操作细节(手怎么动、表情变化、产品怎么展示);
|
||||
<audio_bgm> 环境音+BGM提示(例:轻快流行BGM,环境嘈杂咖啡店背景音);
|
||||
<transition> 硬切/淡入淡出/叠化(最后一镜写『结束』即可);
|
||||
<reference_image_index> 参考图片索引(0-based,对应第几张产品图,无则空);
|
||||
<ken_burns> 用一个空标签,属性 start、end 写"x,y"坐标、ease 写缓动方式;不需要运镜时坐标相同。"""
|
||||
|
||||
_STORYBOARD_USER = """目标时长:{duration}秒
|
||||
上传图片数量:{image_count}张(第1张是主图/封面)
|
||||
@@ -194,17 +221,35 @@ _STORYBOARD_EXAMPLE = """<clips>
|
||||
<clip image_index="0" transition="cut" zoom="null" duration_sec="3" bgm_note="日常、轻微烦躁">
|
||||
<voice_text>这油污我真的忍很久了</voice_text>
|
||||
<subtitle_text>这油污忍很久了</subtitle_text>
|
||||
<shot_type_angle_movement>近景俯拍45度,缓慢推镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>厨房台面,主妇皱眉看着灶台油污。对白:这油污我真的忍很久了</scene_and_dialogue>
|
||||
<action_details>右手拿着脏抹布,无奈摇头</action_details>
|
||||
<audio_bgm>轻快日常BGM,带一点烦躁感</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="zoom_in" zoom="in" duration_sec="6" bgm_note="轻快、出现转机">
|
||||
<voice_text>后来换了大公鸡头油污净,喷上等几分钟,一擦就干净</voice_text>
|
||||
<subtitle_text>喷上等几分钟,一擦就干净</subtitle_text>
|
||||
<shot_type_angle_movement>特写平视,固定镜头</shot_type_angle_movement>
|
||||
<scene_and_dialogue>手部特写,喷油污净在油污处。对白:后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净</scene_and_dialogue>
|
||||
<action_details>左手拿产品瓶身,右手按压喷头,等待片刻后用抹布轻擦</action_details>
|
||||
<audio_bgm>轻快转折BGM,带清爽感</audio_bgm>
|
||||
<transition>淡入淡出</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="20,20" end="80,80" ease="ease-in-out"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="fade" zoom="null" duration_sec="4" bgm_note="温暖、推荐">
|
||||
<voice_text>39块钱625ml,厨房重油污的可以试一瓶</voice_text>
|
||||
<subtitle_text>39元625ml,可以试一瓶</subtitle_text>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
<shot_type_angle_movement>中景平视,缓慢拉镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>产品正面展示,明亮背景。对白:39块钱625ml,厨房重油污的可以试一瓶</scene_and_dialogue>
|
||||
<action_details>产品置于画面中央,轻微转动展示瓶身</action_details>
|
||||
<audio_bgm>温暖收尾BGM</audio_bgm>
|
||||
<transition>结束</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="50,50" end="20,20" ease="ease-in-out"/>
|
||||
</clip>
|
||||
</clips>"""
|
||||
|
||||
|
||||
@@ -92,18 +92,18 @@ class SharedSettings(BaseSettings):
|
||||
doubao_api_key: str = ""
|
||||
doubao_model: str = "doubao-seed-2-1-pro-260915" # 推理模型(Seed 2.1 Pro,深度思考+多模态;原 seed-1-6 已下线)
|
||||
doubao_fast_model: str = (
|
||||
"doubao-seed-2-1-lite-260915" # 快速模型(Seed 2.1 Lite,高 RPM,编导/审核/VLM lite;原 1-5-pro-32k 已 Retiring)
|
||||
"doubao-seed-2-1-pro-260915" # #2181: lite方舟侧100%超时,默认fast_model也走pro;方舟恢复lite后通过ENV DOUBAO_FAST_MODEL切回
|
||||
)
|
||||
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
doubao_timeout: int = 45 # #2180: 方舟LLM高峰期响应6-8s,原30s太紧提到45s
|
||||
doubao_max_retries: int = 1 # #2180: timeout调大后一次调用就够,1次重试防偶发抖动;避免6次重试叠加到351s
|
||||
doubao_vision_model: str = (
|
||||
"doubao-seed-2-1-pro-260915" # 高精度视觉(Seed 2.1 Pro 原生多模态;原 vision-pro-250328 已下线)
|
||||
)
|
||||
doubao_vision_lite_model: str = (
|
||||
"doubao-seed-2-1-lite-260915" # 快速视觉(Seed 2.1 Lite 原生多模态;原 vision-lite-250315 不可用)
|
||||
)
|
||||
doubao_vision_use_lite: bool = True # viral-video 图片分析默认用 lite 提速
|
||||
doubao_vision_use_lite: bool = False # #2181: lite视觉模型100%超时,默认关闭走pro(25-38s稳定返回)
|
||||
doubao_embedding_model: str = "doubao-embedding-vision-251215" # 多模态向量化(原 large-text-240915 已 Retiring)
|
||||
doubao_video_model: str = "doubao-seedance-2-5-260628"
|
||||
doubao_video_timeout: int = 600 # 视频生成轮询总超时(秒)
|
||||
|
||||
@@ -585,7 +585,7 @@ class DoubaoClient:
|
||||
# #2172/#2174: 信任链——使用预热好的 Seedream t2i 文生图(纯模型生成人像,是方舟信任产物,
|
||||
# 不会触发肖像审核)。预热在 VLM 分析后由 daemon 线程后台完成,结果通过 pre_trusted_images 传入。
|
||||
# - 预热结果有效 → 替换原参考图,走 omni_ref 模式
|
||||
# - 预热结果不可用 → 直接用原图(若被400肖像拦截,#2166自动降级纯t2v),避免现场跑t2i阻塞渲染
|
||||
# - 预热结果不可用 → 直接用原图(上层 _step_render 已现场同步跑 Seedream t2i 兜底;若再被400拦截,下方自动降级纯t2v)
|
||||
# 信任链只作用于 doubao provider;DashScope(Wan) 保持原行为。
|
||||
trust_chain_applied = False
|
||||
if provider == "doubao" and getattr(self, "trust_chain_enabled", True) and pre_trusted_images:
|
||||
@@ -763,6 +763,26 @@ class DoubaoClient:
|
||||
# 保留第二次的错误信息
|
||||
sc, body = sc2, body2
|
||||
|
||||
# #2183: portrait_intercept / 真人肖像审核拦截 → 去掉所有参考图(含image_url首帧),纯 t2v 重试一次
|
||||
# 信任链预热或现场 t2i 都失败时的最后兜底,保证能出片
|
||||
if not task_id and sc == 400:
|
||||
_err_code_for_400, _ = _classify_video_error(sc, body, last_err)
|
||||
if _err_code_for_400 == "portrait_intercept" and (image_url or ref_imgs):
|
||||
logger.warning(
|
||||
"Seedance 创建因 portrait_intercept 失败,降级纯 t2v(移除所有参考图)重试: img=%d ref=%d",
|
||||
1 if image_url else 0,
|
||||
len(ref_imgs),
|
||||
)
|
||||
_t2v_payload = dict(create_payload)
|
||||
_t2v_payload["content"] = [{"type": "text", "text": prompt.strip()}]
|
||||
_t2v_payload["ratio"] = ratio or "9:16"
|
||||
task_id, last_err, sc3, body3 = _do_create(_t2v_payload)
|
||||
if task_id:
|
||||
sc, body = sc3, body3
|
||||
logger.info("[trust-chain] portrait_intercept 降级纯 t2v 成功 task_id=%s", task_id)
|
||||
else:
|
||||
sc, body = sc3, body3
|
||||
|
||||
if not task_id:
|
||||
err_code, user_msg = _classify_video_error(sc, body, last_err)
|
||||
self.last_video_error = {
|
||||
|
||||
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
|
||||
fi
|
||||
|
||||
# 共用 secrets 直接导出(如果存在)
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE DOUBAO_IMAGE_MODEL DOUBAO_IMAGE_SIZE DOUBAO_IMAGE_TIMEOUT DOUBAO_FAST_MODEL DOUBAO_TIMEOUT DOUBAO_MAX_RETRIES WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -17,7 +17,7 @@ def mock_settings():
|
||||
doubao_api_key="test-api-key",
|
||||
doubao_model="doubao-pro-32k",
|
||||
doubao_base_url="https://ark.example.com/api/v3",
|
||||
doubao_timeout=30,
|
||||
doubao_timeout=45,
|
||||
doubao_max_retries=2,
|
||||
)
|
||||
yield mock
|
||||
@@ -37,7 +37,7 @@ def client_without_key():
|
||||
doubao_api_key="",
|
||||
doubao_model="doubao-pro-32k",
|
||||
doubao_base_url="https://ark.example.com/api/v3",
|
||||
doubao_timeout=30,
|
||||
doubao_timeout=45,
|
||||
doubao_max_retries=2,
|
||||
)
|
||||
yield DoubaoClient()
|
||||
@@ -52,7 +52,7 @@ class TestDoubaoClientInit:
|
||||
assert client.api_key == "test-api-key"
|
||||
assert client.model == "doubao-pro-32k"
|
||||
assert client.base_url == "https://ark.example.com/api/v3"
|
||||
assert client.timeout == 30
|
||||
assert client.timeout == 45
|
||||
assert client.max_retries == 2
|
||||
|
||||
def test_base_url_strips_trailing_slash(self, mock_settings):
|
||||
|
||||
@@ -80,8 +80,8 @@ class TestSharedSettingsDefaults:
|
||||
def test_default_doubao_settings(self):
|
||||
s = SharedSettings()
|
||||
assert "doubao" in s.doubao_model
|
||||
assert s.doubao_timeout == 30
|
||||
assert s.doubao_max_retries == 2
|
||||
assert s.doubao_timeout == 45 # #2180 默认提到45s
|
||||
assert s.doubao_max_retries == 1
|
||||
|
||||
|
||||
class TestAPISettingsDefaults:
|
||||
|
||||
@@ -110,8 +110,8 @@ class TestSharedSettingsDefaults:
|
||||
def test_default_doubao_config(self):
|
||||
"""豆包默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.doubao_timeout == 30
|
||||
assert s.doubao_max_retries == 2
|
||||
assert s.doubao_timeout == 45 # #2180 默认提到45s
|
||||
assert s.doubao_max_retries == 1
|
||||
assert "volces.com" in s.doubao_base_url
|
||||
|
||||
def test_default_empty_api_keys(self):
|
||||
|
||||
@@ -403,33 +403,30 @@ class TestViralVideoPipeline:
|
||||
"""v1.6: _step_script_generation 返回 dict 形式的 CopyResult,含 voiceover_script + shots。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_script_generation
|
||||
|
||||
mock_llm.return_value = {
|
||||
"overview": {"theme": "口红推荐", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮化妆台,柔和自然光",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-5秒",
|
||||
"shot_type_angle_movement": "近景平视,缓慢推镜",
|
||||
"scene_and_dialogue": "女主微笑展示口红:大家好,今天分享一款口红",
|
||||
"action_details": "手持口红特写",
|
||||
"audio_bgm": "轻快流行BGM",
|
||||
"transition": "硬切",
|
||||
"reference_image_index": 0,
|
||||
},
|
||||
{
|
||||
"time_range": "5-15秒",
|
||||
"shot_type_angle_movement": "特写,固定镜头",
|
||||
"scene_and_dialogue": "涂抹口红:颜色特别好看很显白",
|
||||
"action_details": "嘴唇涂抹特写",
|
||||
"audio_bgm": "轻快BGM继续",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 1,
|
||||
},
|
||||
],
|
||||
"hard_constraints": ["无字幕无水印"],
|
||||
"negative_prompts": ["字幕", "水印"],
|
||||
"voiceover_script": "大家好,今天分享一款口红,颜色特别好看很显白。",
|
||||
}
|
||||
mock_llm.return_value = """<clips>
|
||||
<clip image_index="0" transition="cut" zoom="null" duration_sec="5" bgm_note="轻快流行BGM">
|
||||
<voice_text>大家好,今天分享一款口红</voice_text>
|
||||
<subtitle_text>大家好,今天分享一款口红</subtitle_text>
|
||||
<shot_type_angle_movement>近景平视,缓慢推镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>女主微笑展示口红:大家好,今天分享一款口红</scene_and_dialogue>
|
||||
<action_details>手持口红特写</action_details>
|
||||
<audio_bgm>轻快流行BGM</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="fade" zoom="null" duration_sec="10" bgm_note="轻快BGM">
|
||||
<voice_text>颜色特别好看很显白</voice_text>
|
||||
<subtitle_text>颜色特别好看很显白</subtitle_text>
|
||||
<shot_type_angle_movement>特写,固定镜头</shot_type_angle_movement>
|
||||
<scene_and_dialogue>涂抹口红:颜色特别好看很显白</scene_and_dialogue>
|
||||
<action_details>嘴唇涂抹特写</action_details>
|
||||
<audio_bgm>轻快BGM继续</audio_bgm>
|
||||
<transition>结束</transition>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
</clips>"""
|
||||
result = _step_script_generation(
|
||||
mock_job, {"intent": "推广口红", "key_messages": [], "tone": "亲切"}, {"products": []}
|
||||
)
|
||||
@@ -452,12 +449,13 @@ class TestViralVideoPipeline:
|
||||
assert result["voiceover_script"]
|
||||
assert len(result["shots"]) >= 1
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_review_pass_v16(self, mock_llm, mock_job):
|
||||
@patch("packages.application.viral_video.reviewer.Reviewer.review")
|
||||
def test_review_pass_v16(self, mock_review, mock_job):
|
||||
"""v1.6 _step_review 接收 copy_result dict。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_review
|
||||
from packages.application.viral_video.reviewer import ReviewIssue, ReviewResult
|
||||
|
||||
mock_llm.return_value = {"passed": True, "score": 90, "details": {}}
|
||||
mock_review.return_value = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
|
||||
cr = {"voiceover_script": "大家好", "shots": []}
|
||||
result = _step_review(mock_job, cr)
|
||||
assert result["passed"] is True
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""#2040 接线集成测试:验证运行中的 viral_video 任务使用 prompt_loader 从 DB 读取模板。
|
||||
|
||||
mock LLM/Vision 调用,验证:
|
||||
1. image_analysis 走 loader 模板 + XML 解析
|
||||
2. intent_parsing 走 loader 模板 + XML 解析
|
||||
3. script_generation 走 storyboard 模板 + XML 解析,输出兼容 Seedance 的 copy_result
|
||||
4. review 走 Reviewer(review 模板)带自动重写
|
||||
5. 三档融合(ai_full / ai_polish / user_primary)注入不同 FUSION_INSTRUCTIONS
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path as _Path
|
||||
|
||||
_WORKER_ROOT = _Path(__file__).resolve().parents[2] / "apps" / "worker"
|
||||
if str(_WORKER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_WORKER_ROOT))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job():
|
||||
j = ViralVideoJob(
|
||||
user_id="u1",
|
||||
images=["https://img/1.jpg", "https://img/2.jpg"],
|
||||
industry="美妆",
|
||||
duration=15,
|
||||
user_copy_text="这款口红真的太绝了,显白又持久,姐妹们冲!",
|
||||
fusion_level="ai_polish",
|
||||
)
|
||||
return j
|
||||
|
||||
|
||||
# ── Mock LLM/Vision 返回的 XML 文本 ─────────────────────────────────
|
||||
|
||||
IMAGE_XML = """
|
||||
<analysis>
|
||||
<scene>室内桌面拍摄,柔和自然光</scene>
|
||||
<mood>清新温暖</mood>
|
||||
<product name="lipstick" brand="品牌X" category="唇部彩妆"
|
||||
appearance="管状红色膏体" packaging="黑色金属管"
|
||||
features="显白,持久,滋润" portrait_prompt="无人像"
|
||||
summary="品牌X红色口红">
|
||||
<text_on_package>品牌X,211</text_on_package>
|
||||
</product>
|
||||
</analysis>
|
||||
""".strip()
|
||||
|
||||
INTENT_XML = """
|
||||
<intent>
|
||||
<intent_summary>推广显白持久口红</intent_summary>
|
||||
<core_messages>
|
||||
<message must_keep="true">显白</message>
|
||||
<message must_keep="true">持久</message>
|
||||
</core_messages>
|
||||
<personal_brands>
|
||||
<brand text="品牌X" category="brand"/>
|
||||
</personal_brands>
|
||||
<emotion_tone>亲切自然</emotion_tone>
|
||||
<suggested_title>显白持久口红推荐</suggested_title>
|
||||
</intent>
|
||||
""".strip()
|
||||
|
||||
STORYBOARD_XML = """
|
||||
<clips>
|
||||
<clip image_index="0" transition="cut" zoom="null" duration_sec="5" bgm_note="轻快BGM">
|
||||
<voice_text>这款口红真的太绝了</voice_text>
|
||||
<subtitle_text>显白又持久</subtitle_text>
|
||||
<shot_type_angle_movement>近景俯拍45度,缓慢推镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>厨房台面,主妇展示口红。对白:这款口红真的太绝了</scene_and_dialogue>
|
||||
<action_details>右手持口红展示膏体</action_details>
|
||||
<audio_bgm>轻快BGM</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
</clips>
|
||||
""".strip()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def invalidate_loader_cache():
|
||||
from packages.application.viral_video import prompt_loader as pl
|
||||
|
||||
pl.invalidate()
|
||||
yield
|
||||
pl.invalidate()
|
||||
|
||||
|
||||
# ── 1) 图片分析走模板 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImageAnalysisWiring:
|
||||
def test_uses_loader_template_and_xml_parse(self, job):
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
with patch("packages.shared.ai_service.call_vision", return_value=IMAGE_XML) as mock_v:
|
||||
result = vv._analyze_single_image(0, "https://img/1.jpg", "vlm-lite", 15)
|
||||
|
||||
mock_v.assert_called_once()
|
||||
# 验证调用时传入了 system_prompt(说明走了 loader 渲染的模板)
|
||||
call_kwargs = mock_v.call_args.kwargs
|
||||
assert "system_prompt" in call_kwargs and call_kwargs["system_prompt"]
|
||||
# 结果包含从 XML 解析出的产品信息
|
||||
assert result["name"] == "lipstick"
|
||||
assert result["brand"] == "品牌X"
|
||||
assert "显白" in result["key_features"]
|
||||
assert result["text_on_package"] == ["品牌X", "211"]
|
||||
|
||||
|
||||
# ── 2) 意图解析走模板 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntentParsingWiring:
|
||||
def test_uses_loader_and_parses_xml(self, job):
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
img_result = {"products": [{"name": "lipstick", "brand": "品牌X", "key_features": ["显白", "持久"]}]}
|
||||
with patch("packages.shared.ai_service.call_llm", return_value=INTENT_XML) as mock_llm:
|
||||
result = vv._step_intent_parsing(job, img_result)
|
||||
|
||||
mock_llm.assert_called_once()
|
||||
assert result["intent"] == "推广显白持久口红"
|
||||
assert "显白" in result["key_messages"]
|
||||
assert result["suggested_title"] == "显白持久口红推荐"
|
||||
|
||||
|
||||
# ── 3) 脚本生成:storyboard 模板 + XML 解析 + fusion_level 注入 ────
|
||||
|
||||
|
||||
class TestScriptGenerationWiring:
|
||||
@pytest.mark.parametrize("level", ["ai_full", "ai_polish", "user_primary"])
|
||||
def test_fusion_level_injected(self, job, level):
|
||||
"""三档融合水平被注入到 storyboard 模板的 system_prompt"""
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
from packages.application.viral_video.prompts import FUSION_INSTRUCTIONS
|
||||
|
||||
job.fusion_level = level
|
||||
intent = {"intent": "推广", "key_messages": ["显白"], "tone": "亲切"}
|
||||
|
||||
captured_system = {}
|
||||
|
||||
def fake_call_llm(messages, **kw):
|
||||
captured_system["final"] = messages[0]["content"]
|
||||
return STORYBOARD_XML
|
||||
|
||||
with patch("packages.shared.ai_service.call_llm", side_effect=fake_call_llm):
|
||||
result = vv._step_script_generation(job, intent, {})
|
||||
|
||||
# fusion_level 对应的指令文本被注入到 system prompt 中
|
||||
assert FUSION_INSTRUCTIONS[level] in captured_system["final"], f"fusion_level {level} 指令未注入 system_prompt"
|
||||
# 输出保持 Seedance 兼容结构
|
||||
assert "overview" in result
|
||||
assert "shots" in result
|
||||
assert len(result["shots"]) >= 1
|
||||
assert result["shots"][0]["shot_type_angle_movement"]
|
||||
assert result["voiceover_script"]
|
||||
|
||||
def test_fallback_when_xml_and_json_unparseable(self, job):
|
||||
"""XML 解析失败且无法解析为 JSON 时,回退到兜底脚本"""
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job.fusion_level = "ai_polish"
|
||||
intent = {"intent": "推广", "key_messages": [], "tone": "亲切"}
|
||||
with patch("packages.shared.ai_service.call_llm", return_value="not xml not json"):
|
||||
result = vv._step_script_generation(job, intent, {})
|
||||
assert isinstance(result, dict)
|
||||
assert "voiceover_script" in result
|
||||
assert "shots" in result
|
||||
|
||||
|
||||
# ── 4) Review 使用 Reviewer + 自动重写 ─────────────────────────────
|
||||
|
||||
|
||||
class TestReviewWiring:
|
||||
def test_pass_path(self, job):
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
from packages.application.viral_video.reviewer import Reviewer, ReviewResult
|
||||
|
||||
copy_result = {
|
||||
"title": "口红推荐",
|
||||
"overview": {"theme": "口红推荐"},
|
||||
"voiceover_script": "这款口红显白又持久",
|
||||
"shots": [{"scene_and_dialogue": "展示口红"}],
|
||||
}
|
||||
job.intent_result = {"key_messages": ["显白", "持久"], "intent": "推广"}
|
||||
|
||||
pass_result = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
|
||||
with patch.object(Reviewer, "review", return_value=pass_result):
|
||||
out = vv._step_review(job, copy_result)
|
||||
assert out["passed"] is True
|
||||
|
||||
def test_rewrite_path(self, job):
|
||||
"""审核不通过时触发自动重写,并更新 job.copy_result"""
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
from packages.application.viral_video.reviewer import Reviewer, ReviewResult
|
||||
from packages.application.viral_video.schemas import FusionResult, ReviewIssue, ScriptSegment
|
||||
|
||||
copy_result = {
|
||||
"title": "原标题",
|
||||
"overview": {"theme": "原标题"},
|
||||
"voiceover_script": "这款口红绝了",
|
||||
"shots": [{"scene_and_dialogue": "展示"}],
|
||||
}
|
||||
job.intent_result = {"key_messages": ["显白"], "intent": "推广"}
|
||||
|
||||
fail_result = ReviewResult(
|
||||
passed=False,
|
||||
score=50,
|
||||
issues=[ReviewIssue(dimension="违规词", severity="high", location="开头", text="绝了")],
|
||||
rewrite_suggestions=["去掉夸大词"],
|
||||
)
|
||||
rewritten = FusionResult(
|
||||
title="新标题",
|
||||
hook="修改后钩子",
|
||||
script_segments=[ScriptSegment(text="修改后口播正文")],
|
||||
cta="行动号召",
|
||||
word_count=10,
|
||||
estimated_duration=10,
|
||||
)
|
||||
pass_after = ReviewResult(passed=True, score=88, issues=[], rewrite_suggestions=[])
|
||||
|
||||
with (
|
||||
patch.object(Reviewer, "review", side_effect=[fail_result, pass_after]),
|
||||
patch.object(Reviewer, "rewrite", return_value=rewritten),
|
||||
):
|
||||
out = vv._step_review(job, copy_result)
|
||||
|
||||
assert out["passed"] is True
|
||||
assert "rewritten_copy" in out
|
||||
assert job.generated_copy_text == "修改后口播正文"
|
||||
|
||||
|
||||
# ── 5) 端到端:每个 step 调用 loader 对应 prompt_type ──────────────
|
||||
|
||||
|
||||
class TestEndToEndLoaderUsed:
|
||||
def test_each_step_calls_loader(self, job):
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
from packages.application.viral_video import prompt_loader as pl
|
||||
|
||||
called_types = []
|
||||
real_get = pl.get_template
|
||||
|
||||
def spy_get(prompt_type, **kwargs):
|
||||
called_types.append(prompt_type)
|
||||
return real_get(prompt_type, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(pl, "get_template", side_effect=spy_get),
|
||||
patch("packages.shared.ai_service.call_vision", return_value=IMAGE_XML),
|
||||
patch("packages.shared.ai_service.call_llm", return_value=INTENT_XML),
|
||||
):
|
||||
# 1) image
|
||||
img_res = vv._analyze_single_image(0, "https://img/1.jpg", "vlm", 15)
|
||||
# 2) intent
|
||||
intent_res = vv._step_intent_parsing(job, {"products": [img_res]})
|
||||
|
||||
# 前两步分别调用了 image_analysis 和 intent_parsing
|
||||
assert "image_analysis" in called_types
|
||||
assert "intent_parsing" in called_types
|
||||
|
||||
# script 和 review 单独验证(需要不同的 LLM 返回)
|
||||
called_types_2 = []
|
||||
|
||||
def spy_get_2(prompt_type, **kwargs):
|
||||
called_types_2.append(prompt_type)
|
||||
return real_get(prompt_type, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(pl, "get_template", side_effect=spy_get_2),
|
||||
patch("packages.shared.ai_service.call_llm", return_value=STORYBOARD_XML),
|
||||
):
|
||||
copy_res = vv._step_script_generation(job, intent_res, {"products": [img_res]})
|
||||
assert "storyboard" in called_types_2
|
||||
|
||||
called_types_3 = []
|
||||
|
||||
def spy_get_3(prompt_type, **kwargs):
|
||||
called_types_3.append(prompt_type)
|
||||
return real_get(prompt_type, **kwargs)
|
||||
|
||||
from packages.application.viral_video.reviewer import Reviewer, ReviewResult
|
||||
|
||||
pass_result = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
|
||||
job.intent_result = intent_res
|
||||
job.copy_result = copy_res
|
||||
with (
|
||||
patch.object(pl, "get_template", side_effect=spy_get_3),
|
||||
patch.object(Reviewer, "review", return_value=pass_result) as mock_review,
|
||||
):
|
||||
review_res = vv._step_review(job, copy_res)
|
||||
# review 步骤内部直接调用 Reviewer.review,该方法被 mock,因此 get_template 不会被调用;
|
||||
# 此处验证 Reviewer.review 被调用即可说明 review 步骤走通了。
|
||||
assert mock_review.called, "_step_review 未调用 Reviewer.review"
|
||||
assert isinstance(review_res, dict) and "passed" in review_res
|
||||
Reference in New Issue
Block a user