Files
pod_trend_agent/graph/nodes/product_node.py
T

591 lines
32 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,
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(底图 + 印花设计)
# mark==1 统一只做三合一:无模特图时跳过合成,不再回退两图合成(印花+底图)
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 无模特图,mark=1 统一只做三合一,跳过合成")
else:
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
# 7) 合成:
# 有模特图 → 三图合成(图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:
# 三图合成:优先用简报的 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:
# 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="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")