POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底

- 缓存热点批量流程(有采集缓存不触发 Google)
- 简报不足直接从采集缓存生成(轻量补齐)
- 三图合成(模特/印花/底图)+ 底图压缩 <2MB
- 热点去重→风格去重自动切换 + 不适合类目 review 兜底
- 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述)
- 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
2026-08-22 14:14:01 +08:00
commit f493bde8a9
98 changed files with 10280 additions and 0 deletions
+584
View File
@@ -0,0 +1,584 @@
"""节点 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,
find_first_model_folder,
first_available_sku,
list_colors,
list_spus,
)
from graph.validate import with_fallback
_USED_LOCK = threading.Lock() # used_designs.json 并发写锁
_MODEL_LOCK = threading.Lock() # 同款共用模特缓存并发锁
_MODEL_CACHE: Dict[str, Any] = {} # 同款共用模特:spu_code → model 路径
def _next_img_idx(prod_dir: Path, prefix: str) -> int:
"""货号续号:扫 prod_dir 已有 {prefix}{数字}* 文件,返回下一个起始序号(不覆盖旧产物)。"""
import re
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衣服上的印花图案与衣服底色。"
)
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 _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。"""
import time as _t
last = None
for i in range(attempts):
try:
return fn(*args, **kwargs)
except Exception as e: # noqa: BLE001
last = e
if i < attempts - 1:
_t.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,
) -> Optional[Dict[str, Any]]:
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
(按货号命名,包含该货号对应的所有图片)。
返回 result dict;内部异常已兜底,不中断。
"""
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
prod_dir = prod_dir / img_code
prod_dir.mkdir(parents=True, exist_ok=True)
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="1024x1024") # 印花设计统一 1024x1024
result["design_path"] = design_path
result["design_from"] = "product"
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
except Exception as e: # noqa: BLE001
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(底图 + 印花设计)
if int(spu.get("mark") or 0) == 1:
print(f"{tag} SPU {spu['code']} mark=1 → 使用三图合成模板(图1模特+图2印花+图3底图)")
if model_img is not None:
# 任务级模特分配(product_node 预分配:一个 SPU 一个模特,SPU 数>模特数循环兜底)
model_copy = prod_dir / f"{img_code}_model{model_img.suffix}"
shutil.copy2(model_img, model_copy)
result["model_path"] = str(model_copy)
result["model_folder"] = model_img.parent.name
print(f"{tag} 模特图(任务级分配,{model_img.parent.name}/: {model_copy}")
else:
print(f"{tag} material_library 无模特图,回退两图合成(composite_prompt")
else:
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
# 7) 合成:
# 有模特图 → 三图合成(图1=模特 / 图2=印花设计 / 图3=底图)
# 无模特图 → 两图合成平铺服装图(图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:
# 三图合成:优先用简报的 composite_prompt(模板化三图文案),回退内置 MODEL_WEAR_PROMPT
wear_prompt = (brief.get("composite_prompt") or "").strip() or MODEL_WEAR_PROMPT
print(f"{tag} 三图合成提交中(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="1504x2000") # 三合一统一 1504x2000
result["composite_path"] = composite_path
print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s: {composite_path}")
except Exception as e: # noqa: BLE001
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
print(f"{tag} 三图合成失败,退避重试…: {e}")
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
brief.get("composite_negative", ""),
extra_images=[design_path, str(basemap_img)], size="1504x2000")
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
else:
printed_path = str(prod_dir / f"{img_code}_printed.png")
try:
# 两图合成(无模特):用平铺印图文案(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="1504x2000") # 合成统一 1504x2000
result["printed_path"] = printed_path
print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}")
except Exception as e: # noqa: BLE001
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
brief.get("composite_negative", ""),
extra_images=[design_path], size="1504x2000")
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
# 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(MODEL_WEAR_PROMPT, str(model_img), cp,
brief.get("composite_negative", ""),
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
size="1504x2000") # 三合一统一 1504x2000
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
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"):
result["en_title"] = t.get("en_title", "")
result["cn_title"] = t.get("cn_title", "")
result["ja_title"] = t.get("ja_title", "")
print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... "
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_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) 任务级模特分配(material_library-<category>):
# 一个 SPU 对应一个模特;SPU(不同款)数 > 模特数 → 从全部模特循环兜底(允许重复)
model_assign: Dict[str, Any] = {}
_all_models: List[str] = []
try:
_folder, _all_models = find_first_model_folder(material_root, category)
except Exception: # noqa: BLE001
_all_models = []
if _all_models:
seen_spu: Dict[str, str] = {}
for _i, (_spu, _skus, _tb) in enumerate(worklist):
code = _spu.get("code", "")
if code not in seen_spu:
seen_spu[code] = _all_models[_i % len(_all_models)] # SPU>模特数 → 循环兜底
model_assign[code] = seen_spu[code]
print(f"[product] 任务级模特分配:{len(seen_spu)} 个 SPU,模特池 {len(_all_models)}"
f"{'SPU>模特,循环兜底)' if len(seen_spu) > len(_all_models) else ''}")
# 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)} 个产品任务")
def _run_one(idx: int, spu, skus, tb):
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
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
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
tb, ib, spu, skus, pcfg, errors, design_path, title_backend,
country, img_code=img_code,
model_img=model_assign.get(spu.get("code", "")))
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, start_idx + 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
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")