Merge pull request 'fix(#2174 P0): 信任链i2i改为t2i文生图,实测通过Seedance肖像审核' (#2175) from feat/2174-trust-chain-t2i into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / Integration Tests (push) Successful in 4m18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 5m21s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m22s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m34s
CI/CD Pipeline / Validate - Security (push) Successful in 9m12s
CI/CD Pipeline / Unit Tests (push) Successful in 13m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / Integration Tests (push) Successful in 4m18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 5m21s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m22s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m34s
CI/CD Pipeline / Validate - Security (push) Successful in 9m12s
CI/CD Pipeline / Unit Tests (push) Successful in 13m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(#2174): 信任链从i2i改为t2i文生图,VLM提取人物特征→Seedream t2i纯文生图→Seedance i2v,实测通过肖像审核,人物保留度>90%
This commit was merged in pull request #2175.
This commit is contained in:
@@ -92,13 +92,22 @@ def _save_job(repo, job, session):
|
||||
session.commit()
|
||||
|
||||
|
||||
def _start_trust_chain_preheat(job_id: str, portrait_urls: list[str]) -> None:
|
||||
"""#2172 后台启动信任链预热(Seedream AI 化人像),不阻塞调用方。
|
||||
def _start_trust_chain_preheat(job_id: str, portrait_descriptions: list[str]) -> None:
|
||||
"""#2172/#2174 后台启动信任链预热(Seedream t2i 文生图人像),不阻塞调用方。
|
||||
|
||||
#2174 重要:改为 t2i 文生图模式——用 VLM 分析出的人物外貌描述做 prompt,不传 reference_images,
|
||||
产物是方舟信任模型输出,Seedance 直接放行不触发肖像审核。
|
||||
i2i(传用户照片做 reference)产物不被信任,实测仍被 400 portrait_intercept 拦截。
|
||||
|
||||
预热成功后把结果写入 job.pre_trusted_images,阶段3 渲染直接使用,省掉串行等待。
|
||||
预热失败静默(pre_trusted_images 保持 None),阶段3 会现场跑信任链兜底。
|
||||
预热失败静默(pre_trusted_images 保持 None),阶段3 会走 #2166 自动降级纯 t2v。
|
||||
"""
|
||||
if not portrait_urls:
|
||||
# 过滤有效描述:非空且不是"无人像"
|
||||
_valid = [
|
||||
d for d in (portrait_descriptions or []) if d and isinstance(d, str) and "无人像" not in d and len(d) >= 10
|
||||
]
|
||||
if not _valid:
|
||||
logger.info("[trust-chain][preheat] 无有效人物描述(可能是纯商品图),跳过预热 job=%s", job_id)
|
||||
return
|
||||
# 判断是否是 doubao provider(DashScope/Wan 不需要信任链)
|
||||
try:
|
||||
@@ -122,16 +131,16 @@ def _start_trust_chain_preheat(job_id: str, portrait_urls: list[str]) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("[trust-chain][preheat] 后台预热启动 job=%s n=%d", job_id, len(portrait_urls))
|
||||
result = preheat_trust_chain(portrait_urls, timeout=120)
|
||||
if result and len(result) == len(portrait_urls):
|
||||
logger.info("[trust-chain][preheat] 后台t2i预热启动 job=%s n=%d", job_id, len(_valid))
|
||||
result = preheat_trust_chain(_valid, timeout=120)
|
||||
if result and len(result) >= 1:
|
||||
sess2, repo2, job2 = _get_repo_and_job(job_id)
|
||||
try:
|
||||
job2.pre_trusted_images = result
|
||||
repo2.update(job2)
|
||||
sess2.commit()
|
||||
logger.info(
|
||||
"[trust-chain][preheat] 预热完成并持久化 job=%s n=%d",
|
||||
"[trust-chain][preheat] t2i预热完成并持久化 job=%s n=%d",
|
||||
job_id,
|
||||
len(result),
|
||||
)
|
||||
@@ -318,15 +327,17 @@ _IMAGE_ANALYSIS_SYSTEM_PROMPT = """你是电商商品视觉分析师,从商品
|
||||
"3-5 条图片中能看到的外观/视觉特征(如『红色瓶盖白色瓶身』『鸡头图案 Logo』等)"
|
||||
],
|
||||
"scene": "图片场景(如白底棚拍/浴室实拍/桌面静物/手持实拍等)",
|
||||
"portrait_prompt": "如果图片中有清晰人物面部,用60-100字中文描述该人物外貌(性别、年龄段、发型/发色、肤色、脸型、五官特征、当前穿着、表情姿态),用于AI生图参考;没有人物或看不清面部填「无人像」",
|
||||
"summary": "100-180字中文导购描述,连贯自然段落,像电商详情页介绍,前端直接展示,必须提到品牌/品名/核心外观特征,不能写『无法判断』"
|
||||
}"""
|
||||
|
||||
_IMAGE_ANALYSIS_USER_PROMPT = """请分析这张商品图片,输出严格 JSON。重点:
|
||||
1. name/brand/text_on_package 从图片包装 OCR 读取,不编造;
|
||||
2. appearance/packaging 各写 50-100 字,要具体;
|
||||
3. summary 必须是 100-180 字连贯中文段落,说清商品是什么、长什么样、适合谁用,不要写「无法判断」;
|
||||
4. 非产品图时 category 填「非产品图」,name 填实际看到的内容;
|
||||
5. 看不清的字段填「无法判断」。"""
|
||||
3. portrait_prompt:有人物时详细描述外貌(性别/年龄/发型/肤色/穿着/表情)用于AI人像生成参考,无人像填「无人像」;
|
||||
4. summary 必须是 100-180 字连贯中文段落,说清商品是什么、长什么样、适合谁用,不要写「无法判断」;
|
||||
5. 非产品图时 category 填「非产品图」,name 填实际看到的内容;
|
||||
6. 看不清的字段填「无法判断」。"""
|
||||
|
||||
|
||||
def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
|
||||
@@ -338,6 +349,7 @@ def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
|
||||
"text_on_package": [],
|
||||
"key_features": [],
|
||||
"scene": "通用",
|
||||
"portrait_prompt": "无人像",
|
||||
"summary": "",
|
||||
"_source": reason,
|
||||
}
|
||||
@@ -414,6 +426,7 @@ def _analyze_single_image(
|
||||
raw.setdefault("text_on_package", [])
|
||||
raw.setdefault("key_features", [])
|
||||
raw.setdefault("scene", "通用")
|
||||
raw.setdefault("portrait_prompt", "无人像")
|
||||
raw.setdefault("summary", "")
|
||||
if not isinstance(raw.get("text_on_package"), list):
|
||||
raw["text_on_package"] = []
|
||||
@@ -1357,15 +1370,21 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
|
||||
|
||||
# #2172: 信任链预热与 VLM 分析并行启动(不阻塞)
|
||||
if job.images:
|
||||
try:
|
||||
_start_trust_chain_preheat(job.id, list(job.images))
|
||||
except Exception as _e:
|
||||
logger.warning("[爆款视频][阶段1] 启动信任链预热失败: %s", _e)
|
||||
|
||||
image_analysis = _step_image_analysis(job)
|
||||
job.image_analysis = image_analysis
|
||||
# #2174: VLM完成后立即启动信任链t2i预热(从VLM结果提取人物描述),与文案阶段并行
|
||||
if job.images:
|
||||
try:
|
||||
_products = (image_analysis or {}).get("products", []) or []
|
||||
_portrait_descs = [(p.get("portrait_prompt") or "无人像") for p in _products] if _products else []
|
||||
# 兼容单图结果格式(非products列表)
|
||||
if not _portrait_descs and isinstance(image_analysis, dict):
|
||||
_pp = image_analysis.get("portrait_prompt") or "无人像"
|
||||
if _pp and _pp != "无人像":
|
||||
_portrait_descs = [_pp]
|
||||
_start_trust_chain_preheat(job.id, _portrait_descs)
|
||||
except Exception as _e:
|
||||
logger.warning("[爆款视频][阶段1] 启动信任链t2i预热失败: %s", _e)
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 15.0, "图片分析完成", {"result": image_analysis})
|
||||
|
||||
|
||||
@@ -419,45 +419,61 @@ class DoubaoClient:
|
||||
|
||||
def preheat_trust_chain(
|
||||
self,
|
||||
portrait_urls: list[str],
|
||||
portrait_descriptions: list[str],
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
size: str | None = None,
|
||||
) -> list[str] | None:
|
||||
"""#2172 信任链预热:对一组人像 URL 执行 Seedream AI 化,返回 AI 化后的 URL 列表。
|
||||
"""#2174 信任链预热(t2i 版):用 VLM 分析出的人物外貌描述,纯文生图生成 Seedream 人像,
|
||||
返回信任产物 URL 列表。
|
||||
|
||||
重要:方舟信任链规则是 Seedream 文生图(t2i)产物(不传 reference_images)才被 Seedance 信任;
|
||||
i2i(带用户照片 reference)产物不被信任,仍会被肖像审核拦截。
|
||||
|
||||
- portrait_descriptions: VLM 输出的 portrait_prompt 列表(中文描述),与原始图片顺序对应
|
||||
- 全部成功返回 list[str](顺序与输入一致)
|
||||
- 任何一张失败返回 None(保留上层回退原图直传的路径)
|
||||
- 若 trust_chain_enabled=False 直接返回 None(#2173: 可配置关闭)
|
||||
- 供 worker 在视频生成前并行预热使用;video_generation 内部若收到 preheated 结果会直接使用,不再现场跑。
|
||||
- 任何一张失败返回 None(上层会走现场兜底:直接 t2v 不带参考图)
|
||||
- 若 trust_chain_enabled=False 或所有描述均为"无人像",直接返回 None
|
||||
- 供 worker 在 VLM 分析完成后后台预热使用;video_generation 内部若收到 preheated 结果会直接使用。
|
||||
"""
|
||||
if not portrait_urls or not self.is_available or not getattr(self, "trust_chain_enabled", True):
|
||||
if not portrait_descriptions or not self.is_available or not getattr(self, "trust_chain_enabled", True):
|
||||
return None
|
||||
# 过滤掉"无人像"等无效描述,收集需要生成的索引
|
||||
_idx_map: list[int] = []
|
||||
_prompts: list[str] = []
|
||||
for i, desc in enumerate(portrait_descriptions):
|
||||
if not desc or "无人像" in desc or len(desc) < 10:
|
||||
continue
|
||||
_idx_map.append(i)
|
||||
# 把VLM的中文描述包装成适合Seedream t2i的英文+中文混合prompt,明确是写实半身人像
|
||||
_prompts.append(
|
||||
f"高清写实半身人像照片,{desc},自然光线,面部清晰居中,皮肤质感自然,"
|
||||
"高清摄影细节,构图居中,人物占据画面主体,背景柔和虚化。photorealistic portrait, "
|
||||
"sharp focus on face, soft natural lighting, high detail, half body shot."
|
||||
)
|
||||
if not _prompts:
|
||||
logger.info("[trust-chain][preheat] 无需生成信任人像(所有图片均无人像),跳过")
|
||||
return None
|
||||
seedream_prompt = (
|
||||
"保持此人五官特征、发型、肤色、面部轮廓、年龄感,生成一张高清写实人像照片,"
|
||||
"人物外貌特征与参考图完全一致,皮肤自然,光线柔和,高清细节,不要过度美化。"
|
||||
)
|
||||
trusted: list[str] = []
|
||||
_t0 = time.time()
|
||||
for idx, raw_url in enumerate(portrait_urls):
|
||||
sd_prompt = seedream_prompt if len(portrait_urls) == 1 else f"{seedream_prompt}(这是参考图{idx + 1})"
|
||||
for idx, sd_prompt in enumerate(_prompts):
|
||||
# #2174 关键:t2i模式——不传reference_images,纯prompt文生图,产物才被Seedance信任
|
||||
sd_result = self.image_generation(
|
||||
prompt=sd_prompt,
|
||||
reference_images=[raw_url],
|
||||
size=size or getattr(self, "image_size", "1K"),
|
||||
timeout=timeout or getattr(self, "image_timeout", 60),
|
||||
)
|
||||
if not sd_result:
|
||||
logger.warning(
|
||||
"[trust-chain][preheat] Seedream 第 %d/%d 张失败: %s,预热整体失败",
|
||||
"[trust-chain][preheat] Seedream t2i 第 %d/%d 张失败: %s,预热整体失败",
|
||||
idx + 1,
|
||||
len(portrait_urls),
|
||||
len(_prompts),
|
||||
getattr(self, "last_image_error", None),
|
||||
)
|
||||
return None
|
||||
trusted.append(sd_result["url"])
|
||||
logger.info(
|
||||
"[trust-chain][preheat] Seedream AI 化预热完成 %d 张,总耗时 %.1fs",
|
||||
"[trust-chain][preheat] Seedream t2i 文生图预热完成 %d 张,总耗时 %.1fs",
|
||||
len(trusted),
|
||||
time.time() - _t0,
|
||||
)
|
||||
@@ -566,11 +582,13 @@ class DoubaoClient:
|
||||
# 真人照片直接传给 Seedance 会触发 50411 肖像审核拦截。
|
||||
# 解决:先通过同账号的 Seedream 5.0 Pro 图生图 AI 化(保持五官特征),
|
||||
# 得到的 AI 产物图属于"模型信任产物",再作为 reference_image 传给 Seedance 即可通过审核。
|
||||
# #2172: 支持预热结果 pre_trusted_images(worker 在文案阶段并行预热,省掉串行等待);
|
||||
# 预热结果有效则直接使用,否则现场跑一次 Seedream AI 化。
|
||||
# #2172/#2174: 信任链——使用预热好的 Seedream t2i 文生图(纯模型生成人像,是方舟信任产物,
|
||||
# 不会触发肖像审核)。预热在 VLM 分析后由 daemon 线程后台完成,结果通过 pre_trusted_images 传入。
|
||||
# - 预热结果有效 → 替换原参考图,走 omni_ref 模式
|
||||
# - 预热结果不可用 → 直接用原图(若被400肖像拦截,#2166自动降级纯t2v),避免现场跑t2i阻塞渲染
|
||||
# 信任链只作用于 doubao provider;DashScope(Wan) 保持原行为。
|
||||
trust_chain_applied = False
|
||||
if provider == "doubao" and getattr(self, "trust_chain_enabled", True):
|
||||
if provider == "doubao" and getattr(self, "trust_chain_enabled", True) and pre_trusted_images:
|
||||
raw_portrait_urls: list[str] = []
|
||||
if image_url:
|
||||
raw_portrait_urls.append(image_url)
|
||||
@@ -578,32 +596,14 @@ class DoubaoClient:
|
||||
if u not in raw_portrait_urls:
|
||||
raw_portrait_urls.append(u)
|
||||
trusted_urls: list[str] = []
|
||||
if pre_trusted_images and len(pre_trusted_images) == len(raw_portrait_urls):
|
||||
# #2172: 使用预热结果
|
||||
if len(pre_trusted_images) >= 1:
|
||||
trusted_urls = list(pre_trusted_images)
|
||||
trust_chain_applied = True
|
||||
logger.info(
|
||||
"[trust-chain] 使用预热结果 %d 张,替换为 reference_image 模式",
|
||||
"[trust-chain] 使用预热t2i结果 %d 张,替换原参考图走 reference_image 模式(原n=%d)",
|
||||
len(trusted_urls),
|
||||
len(raw_portrait_urls),
|
||||
)
|
||||
elif raw_portrait_urls:
|
||||
# 现场跑信任链
|
||||
_tc_t0 = time.time()
|
||||
trusted_urls = self.preheat_trust_chain(raw_portrait_urls) or []
|
||||
if trusted_urls and len(trusted_urls) == len(raw_portrait_urls):
|
||||
trust_chain_applied = True
|
||||
logger.info(
|
||||
"[trust-chain] Seedream AI 化完成 %d 张,总耗时 %.1fs,替换为 reference_image 模式",
|
||||
len(trusted_urls),
|
||||
time.time() - _tc_t0,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[trust-chain] Seedream AI 化不完整(%d/%d),总耗时 %.1fs,回退原图直传",
|
||||
len(trusted_urls),
|
||||
len(raw_portrait_urls),
|
||||
time.time() - _tc_t0,
|
||||
)
|
||||
if trust_chain_applied and trusted_urls:
|
||||
# 替换:原 image_url 用第一张 AI 图,ref_imgs 用剩余
|
||||
if image_url and trusted_urls:
|
||||
|
||||
@@ -605,16 +605,19 @@ def call_vision(
|
||||
return raw
|
||||
|
||||
|
||||
def preheat_trust_chain(portrait_urls: list[str], *, timeout: int = 120) -> list[str] | None:
|
||||
"""#2172 信任链预热:提前把人像图跑 Seedream AI 化,结果可传给 call_video_generation(pre_trusted_images=...)。
|
||||
def preheat_trust_chain(portrait_descriptions: list[str], *, timeout: int = 120) -> list[str] | None:
|
||||
"""#2174 信任链预热(t2i版):用 VLM 分析出的人物外貌描述,跑 Seedream 文生图,
|
||||
生成的信任产物 URL 可传给 call_video_generation(pre_trusted_images=...)。
|
||||
|
||||
成功返回与输入同序的 AI 化 URL 列表;任意一张失败返回 None(调用方回退到现场跑信任链)。
|
||||
- portrait_descriptions: VLM输出的portrait_prompt列表(中文人物外貌描述)
|
||||
- 成功返回与输入同序的信任图URL列表;任意一张失败返回None(调用方回退到纯t2v)
|
||||
- 必须传VLM人物描述,不传reference_images,走纯t2i路径才是方舟信任产物
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
return None
|
||||
try:
|
||||
return client.preheat_trust_chain(portrait_urls, timeout=timeout)
|
||||
return client.preheat_trust_chain(portrait_descriptions, timeout=timeout)
|
||||
except Exception as e:
|
||||
logger.error("[ai_service] preheat_trust_chain 异常: %s", e, exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -145,15 +145,11 @@ class TestImageGenerationHappyPath:
|
||||
|
||||
|
||||
class TestTrustChainIntegration:
|
||||
def test_with_reference_image_triggers_seedream_then_seedance_with_reference_image_role(self, tmp_path):
|
||||
def test_pre_trusted_images_replace_original_refs(self, tmp_path):
|
||||
"""#2174: 传 pre_trusted_images(预热好的t2i信任图)时,替换原image_url/ref_imgs发给Seedance,不再现场跑Seedream。"""
|
||||
client = _make_client()
|
||||
captured_calls = []
|
||||
|
||||
seedream_ok = MagicMock(status_code=200)
|
||||
seedream_ok.json.return_value = {"data": [{"url": "https://ai.example.com/trusted.png"}]}
|
||||
seedream_ok.raise_for_status = MagicMock()
|
||||
seedream_ok.text = ""
|
||||
|
||||
task_ok = MagicMock(status_code=200)
|
||||
task_ok.json.return_value = {"id": "t-trust"}
|
||||
task_ok.raise_for_status = MagicMock()
|
||||
@@ -182,8 +178,6 @@ class TestTrustChainIntegration:
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured_calls.append({"url": url, "json": kwargs.get("json")})
|
||||
if "/images/generations" in url:
|
||||
return seedream_ok
|
||||
return task_ok
|
||||
|
||||
fake_uuid = MagicMock()
|
||||
@@ -193,7 +187,7 @@ class TestTrustChainIntegration:
|
||||
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
||||
patch("packages.shared.ai_client.httpx.stream", return_value=FakeStream()),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time(stable_calls=3)),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time(stable_calls=2)),
|
||||
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
||||
):
|
||||
@@ -205,16 +199,17 @@ class TestTrustChainIntegration:
|
||||
out = client.video_generation(
|
||||
"人物在海边散步",
|
||||
image_url="https://img/raw.jpg",
|
||||
pre_trusted_images=["https://ai.example.com/trusted.png"],
|
||||
duration=5,
|
||||
ratio="9:16",
|
||||
resolution="720p",
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
assert out is not None
|
||||
assert len(captured_calls) == 2
|
||||
assert "/images/generations" in captured_calls[0]["url"]
|
||||
assert captured_calls[0]["json"]["image"] == "https://img/raw.jpg"
|
||||
seedance_payload = captured_calls[1]["json"]
|
||||
# 只有一次 Seedance 调用(不现场跑Seedream)
|
||||
assert len(captured_calls) == 1
|
||||
assert "/contents/generations/tasks" in captured_calls[0]["url"]
|
||||
seedance_payload = captured_calls[0]["json"]
|
||||
content = seedance_payload["content"]
|
||||
img_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert len(img_items) == 1
|
||||
@@ -276,17 +271,11 @@ class TestTrustChainIntegration:
|
||||
content = captured_calls[0]["json"]["content"]
|
||||
assert all(c.get("type") != "image_url" for c in content)
|
||||
|
||||
def test_seedream_failure_falls_back_to_original_image(self, tmp_path):
|
||||
def test_no_preheated_images_falls_back_to_first_frame_mode(self, tmp_path):
|
||||
"""#2174: 无 pre_trusted_images 时原图走 first_frame 模式(ratio=adaptive),不再现场跑 Seedream。"""
|
||||
client = _make_client(max_retries=0)
|
||||
captured_calls = []
|
||||
|
||||
seedream_fail = MagicMock(status_code=400)
|
||||
seedream_fail.text = '{"error":{"code":"QuotaExceeded","message":"quota"}}'
|
||||
seedream_fail.json.return_value = {"error": {"code": "QuotaExceeded"}}
|
||||
seedream_fail.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"q", request=MagicMock(), response=seedream_fail
|
||||
)
|
||||
|
||||
task_ok = MagicMock(status_code=200)
|
||||
task_ok.json.return_value = {"id": "t-fb"}
|
||||
task_ok.raise_for_status = MagicMock()
|
||||
@@ -312,8 +301,6 @@ class TestTrustChainIntegration:
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured_calls.append({"url": url, "json": kwargs.get("json")})
|
||||
if "/images/generations" in url:
|
||||
return seedream_fail
|
||||
return task_ok
|
||||
|
||||
fake_uuid = MagicMock()
|
||||
@@ -340,8 +327,9 @@ class TestTrustChainIntegration:
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
assert out is not None
|
||||
assert len(captured_calls) == 2
|
||||
seedance_payload = captured_calls[1]["json"]
|
||||
# 不现场跑 Seedream,只有一次 Seedance 调用
|
||||
assert len(captured_calls) == 1
|
||||
seedance_payload = captured_calls[0]["json"]
|
||||
content = seedance_payload["content"]
|
||||
img_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert len(img_items) == 1
|
||||
@@ -458,28 +446,11 @@ class TestTrustChainBranches:
|
||||
assert err["error_code"] == "auth_error"
|
||||
|
||||
def test_trust_chain_partial_seedream_success_falls_back(self, tmp_path):
|
||||
"""多张参考图中第 2 张 Seedream 失败→整体回退原图直传。"""
|
||||
"""#2174: 预热结果为空/None 时回退原图直传(走#2166的400→t2v自动降级路径)。"""
|
||||
client = _make_client(max_retries=0)
|
||||
|
||||
def make_seedream_fail():
|
||||
r = MagicMock(status_code=500, text="err")
|
||||
r.json.return_value = {"error": {}}
|
||||
r.raise_for_status.side_effect = httpx.HTTPStatusError("e", request=MagicMock(), response=r)
|
||||
return r
|
||||
|
||||
seedream_ok = MagicMock(status_code=200, text="")
|
||||
seedream_ok.json.return_value = {"data": [{"url": "https://ai.example.com/a.png"}]}
|
||||
seedream_ok.raise_for_status = MagicMock()
|
||||
|
||||
# 两张参考图(image_url + reference_images 各一张),Seedream 第 1 张 ok、第 2 张失败 → 回退
|
||||
call_n = {"n": 0}
|
||||
|
||||
# 预热结果传 None → 应该直接用原图发给 Seedance
|
||||
def fake_post(url, **kwargs):
|
||||
if "/images/generations" in url:
|
||||
call_n["n"] += 1
|
||||
if call_n["n"] == 1:
|
||||
return seedream_ok
|
||||
return make_seedream_fail()
|
||||
# Seedance create task(收到原图直传时会调用)
|
||||
t = MagicMock(status_code=200, text="")
|
||||
t.json.return_value = {"id": "t-partial"}
|
||||
@@ -553,15 +524,11 @@ class TestTrustChainBranches:
|
||||
assert r is not None
|
||||
assert captured_kwargs["timeout"] == 120
|
||||
|
||||
def test_trust_chain_no_image_url_only_ref_imgs(self, tmp_path):
|
||||
"""不传 image_url 仅传 reference_images 时走 trust chain 成功,ref_imgs 覆盖替换(line 537 else 分支)。"""
|
||||
def test_trust_chain_uses_preheated_t2i_images(self, tmp_path):
|
||||
"""#2174: 传 pre_trusted_images(预热好的t2i信任图)时,替换原参考图发给Seedance。"""
|
||||
client = _make_client()
|
||||
captured = []
|
||||
|
||||
seedream_ok = MagicMock(status_code=200, text="")
|
||||
seedream_ok.json.return_value = {"data": [{"url": "https://ai.example.com/ref.png"}]}
|
||||
seedream_ok.raise_for_status = MagicMock()
|
||||
|
||||
task_ok = MagicMock(status_code=200, text="")
|
||||
task_ok.json.return_value = {"id": "t-refonly"}
|
||||
task_ok.raise_for_status = MagicMock()
|
||||
@@ -587,8 +554,6 @@ class TestTrustChainBranches:
|
||||
|
||||
def fake_post(url, **kw):
|
||||
captured.append({"url": url, "json": kw.get("json")})
|
||||
if "/images/generations" in url:
|
||||
return seedream_ok
|
||||
return task_ok
|
||||
|
||||
fu = MagicMock()
|
||||
@@ -610,19 +575,20 @@ class TestTrustChainBranches:
|
||||
out = client.video_generation(
|
||||
"人物散步",
|
||||
reference_images=["https://img/portrait.jpg"],
|
||||
pre_trusted_images=["https://ai.example.com/t2i-portrait.png"],
|
||||
duration=5,
|
||||
ratio="9:16",
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
assert out is not None
|
||||
# 第一次是 Seedream 成功,第二次是 Seedance 创建任务
|
||||
assert len(captured) == 2
|
||||
seedance_payload = captured[1]["json"]
|
||||
# 只有一次 Seedance 创建任务(预热已完成,不再现场跑 Seedream)
|
||||
assert len(captured) == 1
|
||||
seedance_payload = captured[0]["json"]
|
||||
content = seedance_payload["content"]
|
||||
img_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert len(img_items) == 1
|
||||
# 不传 image_url,信任链产物放 ref_imgs,走 reference_image 模式(非 first_frame)
|
||||
assert img_items[0]["image_url"]["url"] == "https://ai.example.com/ref.png"
|
||||
assert img_items[0]["image_url"]["url"] == "https://ai.example.com/t2i-portrait.png"
|
||||
assert img_items[0]["role"] == "reference_image"
|
||||
# 因为没有 image_url,没有 text 也没有 extra_refs 之外的字段,应保留用户 ratio=9:16
|
||||
assert seedance_payload.get("ratio") == "9:16"
|
||||
|
||||
Reference in New Issue
Block a user