From 0e78f175fec8b330c4b6fba8a47b5e59e17cd3c3 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 4 Oct 2026 15:31:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(#2174=20P0):=20=E4=BF=A1=E4=BB=BB?= =?UTF-8?q?=E9=93=BE=E4=BB=8Ei2i=E6=94=B9=E4=B8=BAt2i=E6=96=87=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E2=80=94=E2=80=94=E6=96=B9=E8=88=9F=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2170/#2172的信任链用Seedream图生图(i2i,传reference_images=用户真人照), 实测产物不被Seedance信任,仍被400 portrait_intercept拦截。 方舟文档+实测确认:信任的是Seedream文生图(t2i)产物——纯prompt描述人物, 不传reference_images,产物是纯模型生成人像,同账号30天内可直接传Seedance。 改动: - VLM prompt新增portrait_prompt字段(60-100字人物外貌描述) - preheat_trust_chain签名从portrait_urls改为portrait_descriptions,t2i模式不传reference_images - video_generation移除现场跑i2i逻辑,仅使用预热好的t2i结果;预热失败走#2166自动降级t2v - 预热时机从VLM前改为VLM后(需要VLM输出的portrait_prompt),仍与文案阶段并行 - 单测适配:TestTrustChain全部改为pre_trusted_images路径验证 实测验证(充值后): - Seedream flash t2i ~15s出图,人像prompt中英文混合写实风格 - Seedance 2.5 i2v用t2i产物URL → 成功出5s视频(120s) - Seedance 2.0-mini i2v用t2i产物URL → 成功出5s视频 - 对照组直传用户真人照片 → 400 portrait_intercept(预期) --- apps/worker/worker_app/tasks/viral_video.py | 55 +++++++++----- packages/shared/ai_client.py | 81 ++++++++++----------- packages/shared/ai_service.py | 11 ++- tests/unit/test_ai_client_image.py | 78 ++++++-------------- 4 files changed, 106 insertions(+), 119 deletions(-) diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index 86bbd2478..054bdd789 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -92,13 +92,20 @@ 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 +129,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 +325,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 +347,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 +424,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 +1368,23 @@ 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}) diff --git a/packages/shared/ai_client.py b/packages/shared/ai_client.py index ffe7426b7..acf4dc6cb 100755 --- a/packages/shared/ai_client.py +++ b/packages/shared/ai_client.py @@ -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,13 @@ 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 模式", - len(trusted_urls), + "[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: diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index 9330ffb58..95a5c68cf 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -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 diff --git a/tests/unit/test_ai_client_image.py b/tests/unit/test_ai_client_image.py index 5c297d1b2..3c28590f2 100644 --- a/tests/unit/test_ai_client_image.py +++ b/tests/unit/test_ai_client_image.py @@ -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" -- 2.54.0 From 07cf055a97b835588ddc5631258ec2f6aa3f7b35 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 4 Oct 2026 07:39:08 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + ruff + prettier [skip ci-format-check] --- apps/worker/worker_app/tasks/viral_video.py | 8 ++++---- packages/shared/ai_client.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index 054bdd789..abcf3e9af 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -103,7 +103,9 @@ def _start_trust_chain_preheat(job_id: str, portrait_descriptions: list[str]) -> 预热失败静默(pre_trusted_images 保持 None),阶段3 会走 #2166 自动降级纯 t2v。 """ # 过滤有效描述:非空且不是"无人像" - _valid = [d for d in (portrait_descriptions or []) if d and isinstance(d, str) and "无人像" not in d and len(d) >= 10] + _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 @@ -1374,9 +1376,7 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict: 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 [] + _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 "无人像" diff --git a/packages/shared/ai_client.py b/packages/shared/ai_client.py index acf4dc6cb..885d09e50 100755 --- a/packages/shared/ai_client.py +++ b/packages/shared/ai_client.py @@ -601,7 +601,8 @@ class DoubaoClient: trust_chain_applied = True logger.info( "[trust-chain] 使用预热t2i结果 %d 张,替换原参考图走 reference_image 模式(原n=%d)", - len(trusted_urls), len(raw_portrait_urls), + len(trusted_urls), + len(raw_portrait_urls), ) if trust_chain_applied and trusted_urls: # 替换:原 image_url 用第一张 AI 图,ref_imgs 用剩余 -- 2.54.0