"""节点 6.5:产品图生成(product)。 在 prompt_build 之后、compose 之前运行(prompt_build → product → compose): 热点提示词 → SPU/颜色选品 → basemap 底图 → 纯印花设计稿 → 模特试穿合成图。 产物写入 output//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.backend(openai/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}/<款号>//)") 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-): # 一个 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")