Files
pod_trend_agent/graph/nodes/product_node.py
T
3218485270 71a48e4ed5 v89-v91 模板增强 + 图源映射 + 多模态提示词可配置化
- 图源映射统一:热点采集与 Pinterest 模式均走 config.product.mark_dirs 配置,按任务序号随机抽模特图/平铺图
- 商品产地固定:统一为「中国大陆」+「产地省份=广东省」(不再读站点/字典映射)
- 模板 SKU 字段检测:按建议售价同一套路检测 SKU分类/SKU数量/SKU数量单位,必填时填入单品/1/件
- 多模态分析提示词可配置:prompts/pinterest_analyze_system.md + user.md,支持国家覆盖,不丢文件回退内置
- 自定义图片模式:新增 pinterest_custom_load_node,图片数量硬校验,选品清单 ≤ 有效图片数
- 模板导出优化:写入前按货号末 3 位升序排序,不再产生空白 xlsx
- 修复 v90 project review 10 项(503 致命终止、线程安全、原子写入等)
2026-08-28 16:24:50 +08:00

710 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""节点 6.5:产品图生成(product)。
在 prompt_build 之后、compose 之前运行(prompt_build → product → compose):
热点提示词 → SPU/颜色选品 → basemap 底图 → 纯印花设计稿 → 模特试穿合成图。
产物写入 output/<country>/product/(底图拷贝 / *_design.png / *_model / *_composite.png / products.json)。
模板选择按 SPU.mark 驱动:
mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1=模特实拍 / 图2=纯印花设计 / 图3=平铺底图)
mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计 → 平铺服装图)
配置(config.yaml product 段):
enabled 开关(默认 true)
db_path SPU/SKU 数据库(默认 db/spu_sku.db,相对路径按运行根解析)
basemap_dir 底图目录(默认 basemap)
material_library_dir 模特图库(默认 material_library
model_category 模特品类子目录(T-shirt);为空/无图时取 material_library 第一个有图子目录
brief_index 用第几个 safe 简报的提示词(0=第一个)
spu_code / sku_code 指定款号/颜色编码(留空自动选第一个有本地底图的)
spu_tasks [{"spu": "DG004", "skus": "DG004-BL01,..."}] 多款号批量选品(优先于 spu_code)
spu_count 款号数量上限(0=不限;取任务清单前 N 个)
spu_per_color true=每颜色一个 SPU 块;false=单 SPU 下挂所有颜色 SKU 变体
backend 图像后端:openai(真生图,需 key) / mock(占位) / 留空=跳过生成仅存底图
缺底图/模特图时跳过对应步骤并提示,不中断流水线;多款号逐个处理,单个失败不影响其它。
"""
import json
import random
import shutil
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from graph.paths import project_root, runtime_root
from graph.product import (
find_basemap,
first_available_sku,
list_colors,
list_spus,
)
from graph.validate import ThreadSafeErrors, with_fallback
_USED_LOCK = threading.Lock() # used_designs.json 并发写锁
def _next_img_idx(prod_dir: Path, prefix: str) -> int:
"""货号续号:扫 prod_dir 已有 {prefix}{数字}* 文件,返回下一个起始序号(不覆盖旧产物)。"""
max_n = -1
try:
if prod_dir.exists():
for f in prod_dir.iterdir():
m = re.match(rf"{re.escape(prefix)}(\d+)", f.stem)
if m:
max_n = max(max_n, int(m.group(1)))
except Exception: # noqa: BLE001
pass
return max_n + 1
MODEL_WEAR_PROMPT = (
"你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计印到图3的衣服底图上,"
"并让图1的模特穿上这件带有图2印花设计的图3底图衣服。\n"
"【图片角色,按提交顺序,不要弄反】\n"
"第一张图(图1)=模特实拍图(基底,要被替换衣服图案和颜色的目标区域,保留原有背景/人物/光影);\n"
"第二张图(图2)=纯印花设计稿(要印上去的图案内容,忽略其背景环境与无关元素,保留图案原始线条与色号);\n"
"第三张图(图3)=平铺衣服底图(只提取衣服本身的底色与面料材质;忽略平铺图的背景、桌面、环境、场景阴影等一切与衣服无关的元素,只保留衣服面料的颜色、质感和纹理)。\n"
"最终效果为图1的模特穿着一件“颜色为图3底色、印有图2图案”的衣服。\n"
"【执行规则】\n"
"1.底色锁定:从图3平铺衣服中提取衣服底色与面料属性,该底色在最终合成中必须100%保持不变,"
"严禁偏色、混入图1原衣服颜色或图2背景色。\n"
"2.印花提取与叠加:从图2中精准提取纯印花图案主体,保留原始线条、色号、比例关系;"
"将印花叠加到图3底色衣服上,形成“图3底色+图2印花”的合成面料。\n"
"3.印花尺寸适配:印花的整体尺寸必须与衣服(图3)的面料面积成合理比例——"
"居中印在胸/背/衣身的主体区域,占衣身面积约30%-45%,四周保留自然留白与衣摆、领口、肩线余量;"
"严禁印花过大(撑满整件衣服、溢出领口袖口下摆)或过小(占比低于20%)。\n"
"4.主体识别与遮罩:识别图1模特的服装穿着区域,忽略皮肤、头发、背景、配饰;"
"将该区域视为“空白画布”,用上述合成面料(图3底色+图2印花)完整覆盖。图1原有衣服颜色与图案全部清除。\n"
"5.精准贴合:合成面料严格跟随图1衣服的立体结构——有褶皱、身体扭转时印花相应变形;"
"印花与新底色须“沉入”褶皱中,保留布料原有明暗纹理与物理属性,杜绝“贴纸感”与“平面涂色感”。\n"
"6.光影融合:提取图1的环境光方向,调整合成面料的亮度/对比度与环境光匹配;"
"图3底色在阴影区须自然变暗,在高光区须有布料反光;印花色彩受环境光影响产生相应明暗变化,但色号本身不偏移。\n"
"7.纯净输出:仅输出一张最终合成图;严禁文字/水印/额外装饰;"
"图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。"
)
# 平铺图专属三图合成提示词(mark 配置 flat_dir 平铺图文件夹):图1平铺实拍 + 图2印花 + 图3底图
FLAT_LAY_PROMPT = (
"你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计"
"印到图3底色的面料上,替换图1平铺衣服原有的底色与图案,输出一张“图3底色+图2印花”的平铺服装商品图。"
"全程无人参与。\n"
"【图片角色,按提交顺序】\n"
"图1=衣服平铺实拍图(基底图:提供衣服版型、轮廓、褶皱、光影、拍摄背景与构图,"
"最终输出必须与图1同角度、同摆放);\n"
"图2=纯印花设计稿;\n"
"图3=平铺衣服底图(只取衣服本身的底色与面料材质,忽略平铺图背景/桌面/场景,只保留面料颜色与质感)。\n"
"TASK: 把图2的印花设计印到图3底色的面料上,替换图1平铺衣服原有的底色与图案,"
"输出一张“图3底色+图2印花”的平铺服装商品图。全程无人参与。\n"
"【执行规则】\n"
"0.禁止人物:输出图中严禁出现任何人体、模特、人台/假人、头颈、手臂或“穿着效果”,"
"必须保持图1的纯平铺俯拍商品图形式,衣服平放在原背景上。\n"
"1.底色锁定:从图3提取衣服底色与面料,最终合成中必须100%保持不变,严禁偏色。\n"
"2.印花提取:从图2精准提取纯印花图案(线条/色号/比例),叠加到图3底色上形成合成面料。\n"
"3.印花尺寸适配:印花整体尺寸与衣服面料面积成合理比例,居中印在胸/背/衣身主体区域,"
"占衣身面积约30%-45%,四周留白,严禁过大撑满整件或过小(低于20%)。\n"
"4.主体遮罩:识别图1中衣服本体的完整区域(领口到下摆、含袖子;忽略背景/桌面/无关物品),"
"用合成面料完整覆盖,彻底清除原衣服的颜色与图案;图1中衣服以外的物品保持原样。\n"
"5.精准贴合:合成面料严格跟随图1衣服的平铺形态,领口/袖口/下摆/侧缝等版型结构清晰保留,"
"褶皱/翻折/堆叠处印花随之自然变形,杜绝“贴纸感”与“平面涂色感”。\n"
"6.光影融合:按图1拍摄光线方向调整亮度/对比度,印花随褶皱产生明暗变化但色号不偏移;"
"衣服投影与图1保持一致。\n"
"7.纯净输出:仅输出一张最终合成平铺图;图1的背景/桌面/构图/角度/光影100%不变,"
"仅替换衣服的底色与印花;画面中不得出现任何人像、肢体或人台。"
)
def _active_prompt(kind: str, prompts: Optional[Dict] = None) -> str:
"""返回当前图源应使用的合成提示词。
kind="model" → 模特三图提示词(配置覆盖优先生效,否则内置 MODEL_WEAR_PROMPT);
kind="flat" → 平铺三图提示词(配置覆盖优先生效,否则内置 FLAT_LAY_PROMPT)。
"""
cfgs = prompts or {}
if kind == "flat":
return (str(cfgs.get("flat_prompt") or "").strip()
or FLAT_LAY_PROMPT)
return (str(cfgs.get("model_prompt") or "").strip()
or MODEL_WEAR_PROMPT)
def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]:
"""选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。"""
if sku_code:
return sku_code
s = first_available_sku(db_path, basemap_root, spu_code)
if s:
return s
if colors:
return colors[0]["sku_code"]
return None
def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
"""模板输出路径:默认 {sku}_已填写.xlsx;若文件被其它程序占用(如已打开),自动换名加序号,避免导出失败。"""
base = prod_dir / f"{chosen_sku}_已填写.xlsx"
try:
with open(base, "ab"):
pass
return base
except OSError:
pass
for i in range(2, 100):
cand = prod_dir / f"{chosen_sku}_已填写_{i}.xlsx"
if not cand.exists():
return cand
return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"
def _is_fatal_50x(e) -> bool:
"""致命图像服务错误(503 / No available compatible accounts)→ 不重试,提前终止。"""
try:
from graph.pinterest_pipeline import PinterestPipeline
return PinterestPipeline.is_fatal_503(e)
except Exception: # noqa: BLE001
return False
def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。
致命 503(账户不可用)重试无效 → 直接抛出,交由调用方终止任务。
"""
last = None
for i in range(attempts):
try:
return fn(*args, **kwargs)
except Exception as e: # noqa: BLE001
if _is_fatal_50x(e):
raise
last = e
if i < attempts - 1:
time.sleep(backoff[i])
print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
return None
def _process_spu(
db_path, basemap_root, material_root, category, prod_dir, brief, ib,
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
on_503=None, model_kind="model", prompts=None,
) -> Optional[Dict[str, Any]]:
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
(按货号命名,包含该货号对应的所有图片)。
on_503: 致命图像服务错误(503/账户不可用)回调(供调用方提前终止任务)。
model_kind: 图源类型 "model"(模特图)/ "flat"(平铺图),决定用哪个合成提示词。
prompts: {"model": str, "flat": str} 可配置提示词覆盖(来自 config.product.mark_dirs);缺省用内置。
返回 result dict;内部异常已兜底,不中断。
"""
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
prod_dir = prod_dir / img_code
prod_dir.mkdir(parents=True, exist_ok=True)
def _fatal(e) -> bool:
"""致命图像服务错误(503/账户不可用)→ 通知 on_503 并返回 True(调用方应立即终止)。"""
try:
from graph.pinterest_pipeline import PinterestPipeline
if PinterestPipeline.is_fatal_503(e):
if on_503 is not None:
on_503()
return True
except Exception: # noqa: BLE001
pass
return False
colors = list_colors(db_path, spu["code"])
valid_codes = {c["sku_code"] for c in colors}
if sku_code:
sku_codes = [s.strip() for s in sku_code.split(",") if s.strip() and s.strip() in valid_codes]
if not sku_codes:
print(f"[product] 颜色 {sku_code!r} 均不在款号 {spu['code']} 下,可选: {[c['sku_code'] for c in colors]}")
return None
else:
first = _resolve_sku(db_path, basemap_root, spu["code"], "", colors)
if first is None:
print(f"[product] 款号 {spu['code']} 无颜色数据,可选: {[c['sku_code'] for c in colors]}")
return None
sku_codes = [first]
# 模板模式自动判定(UI 不再选择):单色=每色一SPU;多色=单SPU多色;CLI --single-spu 显式覆盖
spu_per_color = pcfg.get("spu_per_color")
if spu_per_color is None:
spu_per_color = len(sku_codes) <= 1
else:
spu_per_color = bool(spu_per_color)
chosen_sku = sku_codes[0] # 生图用第一个颜色
tag = f"[product/{img_code or chosen_sku}]" # 日志前缀用货号(DG000),失败/进度一眼定位
basemap_img = find_basemap(basemap_root, spu["code"], chosen_sku)
if basemap_img is None:
print(f"{tag} 底图缺失({basemap_root}/{spu['code']}/{chosen_sku}/),将跳过印花/模特,仅导出模板")
result: Dict[str, Any] = {
"spu_code": spu["code"],
"sku_code": chosen_sku,
"color": next((c["color"] for c in colors if c["sku_code"] == chosen_sku), ""),
"topic": brief.get("topic", ""),
"art_style": brief.get("art_style", ""),
"color_palette": brief.get("color_palette", ""),
"basemap": str(basemap_img) if basemap_img else "",
"composite_prompt": brief.get("composite_prompt", ""),
"markup_percent": pcfg.get("markup_percent", 0), # 加价%(后续定价用)
}
# 拷贝底图(图3):命名 = 货号 + SKU code(如 DG003_DG004-BL01_basemap.jpg),
# 同一 SKU 多个设计(数量 N)时底图文件也各自独立,不覆盖、不混淆
if basemap_img is not None:
base_copy = prod_dir / f"{img_code}_{chosen_sku}_basemap{basemap_img.suffix}"
shutil.copy2(basemap_img, base_copy)
result["basemap_copy"] = str(base_copy)
print(f"{tag} 底图: {base_copy}")
if ib is None:
print(f"{tag} 未配置 product.backendopenai/mock),跳过印花/模特生成。")
elif basemap_img is None:
print(f"{tag} 无底图,跳过印花/模特生成。")
else:
# 5) 纯印花设计稿(图2):直接用 compose 节点生成的共享设计稿(designs/ 已有,不拷贝)
if shared_design and Path(shared_design).exists():
design_path = shared_design
result["design_path"] = design_path
result["design_from"] = "compose"
print(f"{tag} 设计稿(来自 compose 节点,designs/ 已有): {design_path}")
else:
design_path = str(prod_dir / f"{img_code}_design.png")
try:
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", "")))
ib.generate(prompt, design_path,
brief.get("composite_negative", ""),
size=design_size) # 设计稿尺寸按 config compose.design_size
# 全局 MD5 去重:生成了设计后,把 MD5 加入全局过滤(对所有国家生效);重复 → 跳过该产品
from graph.pinterest import design_md5_ok
if not design_md5_ok(design_path):
print(f"{tag} 设计稿 MD5 全局重复,跳过该产品: {design_path}")
return None
result["design_path"] = design_path
result["design_from"] = "product"
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
except Exception as e: # noqa: BLE001
if _fatal(e):
print(f"{tag} 图像服务 503,终止: {e}")
return None
errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
print(f"{tag} 设计稿生成失败: {e}")
# 6) 模板选择按 SPU.mark 决定:
# mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图)
# mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计)
# mark==1 统一只做三合一:无图(模特/平铺)时跳过合成,不再回退两图合成(印花+底图)
if int(spu.get("mark") or 0) == 1:
kind_label = "平铺" if model_kind == "flat" else "模特"
print(f"{tag} SPU {spu['code']} mark=1,图源={kind_label} → 使用三图合成模板(图1{kind_label}+图2印花+图3底图)")
if model_img is not None:
# 任务级图源分配(pipeline 预分配:mark=1 在有图的模特/平铺文件夹间随机抽)
model_copy = prod_dir / f"{img_code}_{model_kind}{model_img.suffix}"
shutil.copy2(model_img, model_copy)
result["model_path"] = str(model_copy)
result["model_folder"] = model_img.parent.name
result["model_kind"] = model_kind
print(f"{tag} {kind_label}图(任务级分配,{model_img.parent.name}/: {model_copy}")
else:
print(f"{tag} material_library 无{kind_label}图,mark=1 统一只做三合一,跳过合成")
else:
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
# 7) 合成:
# mark=1 有图(模特/平铺)→ 三图合成(图1=模特或平铺 / 图2=印花设计 / 图3=底图)
# mark=1 无图 → 跳过合成(统一只做三合一,不做印花+底图两图合成)
# mark!=1 无图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
if "design_path" not in result:
print(f"{tag} 无设计稿,跳过合成")
elif model_img is not None:
composite_path = str(prod_dir / f"{img_code}_composite.png")
try:
# 三图合成:按图源类型选提示词(可配置覆盖优先,否则内置 MODEL_WEAR/FLAT_LAY
wear_prompt = _active_prompt(model_kind, prompts)
kind_label = "平铺" if model_kind == "flat" else "模特"
print(f"{tag} {kind_label}三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…")
t0 = time.time()
ib.print(wear_prompt, str(model_img), composite_path,
brief.get("composite_negative", ""),
extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图
size=compose_size) # 合成图尺寸按 config compose.size
result["composite_path"] = composite_path
print(f"{tag} {kind_label}三图合成图已生成(耗时 {int(time.time()-t0)}s: {composite_path}")
except Exception as e: # noqa: BLE001
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
if _fatal(e):
print(f"{tag} 图像服务 503,终止: {e}")
return None
print(f"{tag} 三图合成失败,退避重试…: {e}")
try:
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
brief.get("composite_negative", ""),
extra_images=[design_path, str(basemap_img)], size=compose_size)
if retried is not None:
result["composite_path"] = composite_path
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s: {composite_path}")
else:
errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
return None
except Exception as e2: # noqa: BLE001
if _fatal(e2):
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
return None
raise
else:
# mark=1 无模特图 → 统一只做三合一,不做印花+底图两图合成
if int(spu.get("mark") or 0) == 1:
print(f"{tag} mark=1 无模特图,跳过合成(统一只做三合一)")
else:
printed_path = str(prod_dir / f"{img_code}_printed.png")
try:
# 两图合成(无模特,mark!=1):用平铺印图文案(wearable_prompt),回退旧 composite_prompt
flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "")
ib.print(flat_prompt, str(basemap_img), printed_path,
brief.get("composite_negative", ""),
extra_images=[design_path], # 图2印花
size=compose_size) # 合成图尺寸按 config compose.size
result["printed_path"] = printed_path
print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}")
except Exception as e: # noqa: BLE001
if _fatal(e):
print(f"{tag} 图像服务 503,终止: {e}")
return None
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
try:
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
brief.get("composite_negative", ""),
extra_images=[design_path], size=compose_size)
if retried is not None:
result["printed_path"] = printed_path
print(f"{tag} 平铺服装图重试成功: {printed_path}")
else:
errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
return None
except Exception as e2: # noqa: BLE001
if _fatal(e2):
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
return None
raise
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
color_composites: List[Dict[str, Any]] = []
if model_img is not None and result.get("composite_path"):
# 首色主图始终记录(单色/多色都走模板填充)
color_composites.append({"sku_code": chosen_sku, "color": result.get("color", ""),
"composite_path": result["composite_path"]})
for sc in sku_codes[1:]:
bm = find_basemap(basemap_root, spu["code"], sc)
if bm is None:
print(f"{tag} 颜色 {sc} 无底图,跳过该色三合一")
continue
cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
try:
ib.print(_active_prompt(model_kind, prompts), str(model_img), cp,
brief.get("composite_negative", ""),
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
size=compose_size) # 合成图尺寸按 config compose.size
col = next((c["color"] for c in colors if c["sku_code"] == sc), sc)
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
print(f"{tag} 颜色 {sc}{col})三合一已生成: {cp}")
except Exception as e: # noqa: BLE001
if _fatal(e):
print(f"{tag} 颜色 {sc} 三合一遇 503,终止: {e}")
return None
errors.append({"node": "product", "type": type(e).__name__,
"message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
result["color_composites"] = color_composites
# 7.5) 多模态标题生成:合成图/平铺图/设计稿 → 中英双语 SEO 标题(按国家路由模板)
if title_backend is not None:
title_img = (result.get("composite_path") or result.get("printed_path")
or result.get("design_path"))
if title_img:
t = title_backend.generate_title(title_img, country=country)
if t.get("en_title") or t.get("cn_title") or t.get("ja_title") or t.get("es_title"):
result["en_title"] = t.get("en_title", "")
result["cn_title"] = t.get("cn_title", "")
result["ja_title"] = t.get("ja_title", "")
result["es_title"] = t.get("es_title", "")
print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... "
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}... "
f"ES={t.get('es_title','')[:30]}...")
return result
@with_fallback("product")
def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
config = state["config"]
pcfg = config.get("product") or {}
stats = dict(state.get("stats") or {})
if not pcfg.get("enabled", True):
return {"product": [], "stats": stats}
country = state["country"]
briefs = state.get("briefs") or []
output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹)
cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录)
errors = list(state.get("errors") or [])
# 路径解析:相对路径 → 优先运行根(exe 旁自定义数据),其次数据根(打包=_MEIPASS 内置)
def _abs(key: str, default: str) -> Path:
p = Path(pcfg.get(key, default))
if p.is_absolute():
return p
for root in (runtime_root(), project_root()):
cand = root / p
if cand.exists():
return cand
return project_root() / p
db_path = _abs("db_path", "db/spu_sku.db")
basemap_root = _abs("basemap_dir", "basemap")
material_root = _abs("material_library_dir", "material_library")
category = pcfg.get("model_category", "T-shirt")
brief_index = int(pcfg.get("brief_index", 0))
spu_code = (pcfg.get("spu_code") or "").strip()
sku_code = (pcfg.get("sku_code") or "").strip()
spu_tasks = pcfg.get("spu_tasks") or []
spu_count = int(pcfg.get("spu_count") or 0)
# 1) 选简报(优先 safe
safe = [b for b in briefs if b.get("risk_level") == "safe"] or briefs
if not safe:
print("[product] 无可用简报,跳过产品图生成")
return {"product": [], "stats": stats}
brief = safe[brief_index] if brief_index < len(safe) else safe[0]
# 2) 图像后端
backend_name = (pcfg.get("backend") or "").strip()
ib = None
if backend_name:
from graph.backends import get_image_backend
ib = get_image_backend(backend_name)
if ib is not None:
ib.bind_config(config.get("compose") or {}) # 复用 compose.api_key/model/size
# 3) 构造款号工作清单:spu_tasks(多款号)优先;否则 spu_code / 自动第一个
spus = list_spus(db_path)
worklist: List[tuple] = [] # (spu, skus, brief) —— 每个款号可绑定自己的热点简报
by_topic = {str(b.get("topic", "")).lower(): b for b in briefs}
if not spus:
print(f"[product] db 无 SPU 数据({db_path}),跳过")
return {"product": [], "stats": stats}
if spu_tasks:
for ti, t in enumerate(spu_tasks):
code = (t.get("spu") or t.get("spu_code") or "").strip()
spu = next((s for s in spus if s["code"] == code), None)
if spu is None:
print(f"[product] 任务款号 {code} 不在 db,跳过(可选: {[s['code'] for s in spus][:12]}")
continue
tb = None
tp = (t.get("topic") or "").strip()
if tp:
tb = by_topic.get(tp.lower())
if tb is None:
print(f"[product] 任务热点「{tp}」不在简报中,回退按序号分配")
if tb is None:
# 未指定热点(完整流水线):按任务序号取不同简报,避免多个产品用同一个
idx = min(ti, len(safe) - 1) if safe else brief_index
tb = safe[idx] if safe else None
if tb is None:
print(f"[product] 无可用简报,跳过任务 {code}")
continue
worklist.append((spu, (t.get("skus") or "").strip(), tb))
if not worklist:
print("[product] 任务清单无有效款号,跳过产品图生成")
return {"product": [], "stats": stats}
elif spu_code:
spu = next((s for s in spus if s["code"] == spu_code), None)
if spu is None:
print(f"[product] 款号 {spu_code} 不在 db,可选: {[s['code'] for s in spus][:12]}")
return {"product": [], "stats": stats}
worklist.append((spu, sku_code, brief))
else:
spu = next((s for s in spus if first_available_sku(db_path, basemap_root, s["code"])), None)
if spu is None:
print(f"[product] 没有任何款号存在本地底图({basemap_root}/<款号>/<SKU.code>/")
return {"product": [], "stats": stats}
worklist.append((spu, sku_code, brief))
# 4) 不再按 spu_count 截断:worklist 已是扩展后的完整任务(数量 N = 每款设计数),
# 全部任务进入队列处理(并发 5)。
prod_dir = output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
# 4.1) 任务级图源分配(与 Pinterest 模式一致):按 config.product.mark_dirs 可配置映射,
# 在「模特图/平铺图」两个有图的文件夹间随机抽图,每张携带 kind(model/flat
# → product_node 用对应提示词合成(模特三图 / 平铺三图)
model_assign: Dict[int, Any] = {}
mark_dirs = pcfg.get("mark_dirs") or {}
prompts_cfg = (mark_dirs.get("1") or {})
try:
from graph.product import build_mark_sources
sources = build_mark_sources(material_root, mark_dirs, category)
except Exception: # noqa: BLE001
sources = {"model": [], "flat": []}
source_pool = []
for kind in ("model", "flat"):
for p in sources.get(kind) or []:
source_pool.append((p, kind))
if source_pool:
for _i in range(len(worklist)):
img, kind = random.choice(source_pool) # 每任务随机抽一张(含 kind
model_assign[_i] = {"img": img, "kind": kind, "prompts": prompts_cfg}
print(f"[product] 任务级图源分配:{len(model_assign)} 个产品任务(mark_dirs 模特/平铺图随机抽,含 kind")
else:
print("[product] material_library 无任何可用图(模特/平铺均无)→ 跳过合成,仅导出模板")
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
designs_dir = output_dir / "designs"
designs_dir.mkdir(parents=True, exist_ok=True)
designs_map = {str(d.get("topic", "")).strip().lower(): d.get("path", "")
for d in (state.get("designs") or []) if isinstance(d, dict)}
for _d in (state.get("briefs") or []):
if isinstance(_d, dict) and _d.get("design_path"):
designs_map.setdefault(str(_d.get("topic", "")).strip().lower(), _d.get("design_path"))
def _resolve_design(tb) -> Optional[str]:
"""任务绑定的简报 → 该热点自己的设计稿路径(按货号命名拷贝到 designs/)。"""
topic = str(tb.get("topic", "")).strip().lower()
src = designs_map.get(topic) or tb.get("design_path")
if not src or not Path(src).exists():
return None
return src
# 6) 逐个款号处理(每款号用自己绑定的热点简报)
title_backend = None
ls_cfg = config.get("llm_screen") or {}
if (ls_cfg.get("provider") or "") not in ("", "mock"):
try:
from graph.llms import get_backend as _glb
_tb = _glb(ls_cfg.get("provider"))
_tb.bind_config(ls_cfg)
if _tb.has_key:
title_backend = _tb
except Exception as e: # noqa: BLE001
print(f"[product] 标题后端初始化失败: {e}")
results: List[Dict[str, Any]] = []
prefix = str(pcfg.get("code_prefix") or "DG").strip()
# 并发:默认每个 SPU 一个独立线程(任务数即并发数,提速);
# config.product.concurrency 显式配置可覆盖(如限流时设 3-5);
# 默认并发上限 5:全量并发(任务数)会压垮图像网关(10 并发 → 全部超时),
# 超出上限的任务在线程池排队,逐批处理
concurrency = int(pcfg.get("concurrency") or 0) or min(len(worklist), 5)
print(f"[product] 并发 {concurrency}(每 SPU 一线程,上限 {concurrency})处理 {len(worklist)} 个产品任务")
_safe_errors = ThreadSafeErrors()
def _run_one(wi: int, spu, skus, tb):
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
idx = start_idx + wi # 实际货号序号(start_idx 起自动续号)
img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…)
try:
# 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png
design_src = _resolve_design(tb)
design_path = None
if design_src:
design_path = str(designs_dir / f"{img_code}_design.png")
try:
shutil.copy2(design_src, design_path)
except Exception: # noqa: BLE001
design_path = design_src
tb = dict(tb)
tb["design_path"] = design_path
_src = model_assign.get(wi) or {}
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
tb, ib, spu, skus, pcfg, _safe_errors, design_path, title_backend,
country, img_code=img_code,
model_img=_src.get("img"), # 按任务序号取独立随机图源
model_kind=_src.get("kind", "model"),
prompts=_src.get("prompts"),
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
if r:
r["img_code"] = img_code
return r, img_code
except Exception as e: # noqa: BLE001 # 单产品任何异常都不拖垮整体
print(f"[product/{img_code}] 产品处理异常(跳过该产品): {e}")
return None, img_code
import concurrent.futures
# 货号自动续号:任务一开始全部按序分配(start_idx 起),不覆盖已生成的产物
start_idx = _next_img_idx(prod_dir, prefix)
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
futures = [ex.submit(_run_one, i, spu, skus, tb)
for i, (spu, skus, tb) in enumerate(worklist)]
for f in concurrent.futures.as_completed(futures):
r, img_code = f.result()
if r:
results.append(r)
try:
_record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录
except Exception: # noqa: BLE001
pass
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
if len(_safe_errors):
errors.extend(list(_safe_errors))
results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定
_write_products(prod_dir, results)
stats["product"] = {
"spus": [r.get("spu_code") for r in results],
"skus": [r.get("sku_code") for r in results],
"topic": brief.get("topic", ""),
"count": len(results),
"composite": sum(1 for r in results if r.get("composite_path")),
"printed": sum(1 for r in results if r.get("printed_path")),
"templates": sum(1 for r in results if r.get("template_path")),
"output_dir": str(prod_dir),
}
return {"product": results, "stats": stats, "errors": errors}
def _record_used(output_dir: Path, r: Dict[str, Any]):
"""记录已用 (热点-风格-配色),供后续去重:output/<国家>/used_designs.json。"""
topic = r.get("topic", "")
if not topic:
return
with _USED_LOCK: # 并发下 used_designs.json 读写互斥
p = output_dir / "used_designs.json"
used = []
if p.exists():
try:
used = json.loads(p.read_text(encoding="utf-8")).get("used", []) or []
except Exception:
used = []
entry = {
"topic": topic,
"art_style": r.get("art_style", ""),
"color_palette": r.get("color_palette", ""),
"spu_code": r.get("spu_code", ""),
"sku_code": r.get("sku_code", ""),
"date": time.strftime("%Y-%m-%d"),
}
# 同 (topic, art_style) 已记录则跳过,避免去重记录重复堆积
if any(str(u.get("topic", "")).strip().lower() == str(entry["topic"]).strip().lower()
and str(u.get("art_style", "")).strip().lower() == str(entry["art_style"]).strip().lower()
for u in used):
return
used.append(entry)
p.write_text(json.dumps(
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "used": used},
ensure_ascii=False, indent=2), encoding="utf-8")
def _write_products(prod_dir: Path, products: List[Dict[str, Any]]):
(prod_dir / "products.json").write_text(
json.dumps({"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "products": products},
ensure_ascii=False, indent=2), encoding="utf-8")