"""节点 8/8:种草图生成(seed_shot)——在 oss_upload 之后。 对每个 product 的合成图(图1),按 seed_shot_templates.yaml 模板 + model_features.yaml 随机模特特征 生成 N 张种草图(config.seed_shot.count,默认 1): - [商品名称] ← product 的 cn_title(上一节点多模态生成) - [材质] ← 数据库 SPU.material 字段 - [模特特征] ← model_features.yaml 随机一条 种草图同样压缩上传到 OSS(货号计数与 oss_upload 共用 state["oss_seq"] 续接)。 未配置图像后端 / 无合成图 / count=0 时跳过,不中断。 """ import time from pathlib import Path from typing import Any, Dict, List from graph.validate import with_fallback @with_fallback("seed_shot") def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]: products: List[Dict[str, Any]] = state.get("product") or [] config = state["config"] or {} country = state.get("country", "") output_dir = Path(state["output_dir"]) ss_cfg = config.get("seed_shot") or {} count = int(ss_cfg.get("count", 1)) if not bool(ss_cfg.get("enabled", True)) or count <= 0 or not products: return {"seed_shots": [], "stats": state.get("stats") or {}} # 图像后端(复用 compose 配置) compose_cfg = config.get("compose") or {} ib = None if compose_cfg.get("backend"): from graph.backends import get_image_backend try: ib = get_image_backend(compose_cfg["backend"]) if ib is not None: ib.bind_config(compose_cfg) except Exception as e: # noqa: BLE001 print(f"[seed_shot] 图像后端不可用: {e}") if ib is None: print("[seed_shot] 未配置 compose.backend(openai/mock),跳过种草图生成") return {"seed_shots": [], "stats": state.get("stats") or {}} # 材质映射:db SPU.material(清洗换行) material_map: Dict[str, str] = {} try: from graph.product import list_spus import yaml dbp = (config.get("product") or {}).get("db_path", "db/spu_sku.db") p = Path(dbp) if not p.is_absolute(): from graph.paths import project_root, runtime_root for root in (runtime_root(), project_root()): if (root / p).exists(): p = root / p break for s in list_spus(str(p)): m = " ".join(str(s.get("material", "")).replace("\r", " ").replace("\n", " ").split()) material_map[s["code"]] = m except Exception as e: # noqa: BLE001 print(f"[seed_shot] 材质读取失败(用空): {e}") from graph.seed_shot import generate_seed_shots from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss from graph.nodes.oss_upload_node import _gen_rand4, MAX_CODE ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S")) prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip() seq = int(state.get("oss_seq") or 0) oss_cfg = config.get("oss") or {} oss_enabled = bool(oss_cfg.get("enabled", True)) and bool(oss_cfg.get("oss_bucket")) all_shots: List[Dict[str, Any]] = [] shot_dir = output_dir / "seed_shots" shot_dir.mkdir(parents=True, exist_ok=True) import concurrent.futures import threading as _th seq_lock = _th.Lock() # seq(货号计数)跨线程共享,需加锁 def _shot_one(r: Dict[str, Any]): """单个产品的种草图生成+上传(每产品独立线程)。""" nonlocal seq base = r.get("composite_path") or r.get("printed_path") if not base or not Path(base).exists(): print(f"[seed_shot] {r.get('spu_code', '')} 无合成图,跳过种草图") return None cn = (r.get("cn_title") or "").strip() or r.get("topic", "") material = material_map.get(r.get("spu_code", ""), "") paths = generate_seed_shots(ib, base, cn, material, count, str(shot_dir), r.get("composite_negative", ""), size=str((config.get("seed_shot") or {}).get("size") or "1504x2000"), prefix=r.get("img_code") or r.get("oss_code") or "") if not paths: return None r["seed_shot_paths"] = paths urls: List[str] = [] for pth in paths: with seq_lock: if seq >= MAX_CODE: print(f"[seed_shot] 货号计数达上限 999,停止上传种草图") break code = f"{prefix}{seq:03d}" seq += 1 if oss_enabled: try: compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg"))) url = upload_to_oss(oss_cfg, compressed, build_oss_key(country, ts, code, _gen_rand4())) if url: urls.append(url) r["seed_shot_urls"] = urls except Exception as e: # noqa: BLE001 print(f"[seed_shot] 种草图上传失败 {pth}: {e}") else: print(f"[seed_shot] oss 未启用,仅本地保存: {pth}") return {"spu_code": r.get("spu_code"), "sku_code": r.get("sku_code"), "paths": paths, "urls": urls} # 并发:每个产品一个独立线程(默认);config.seed_shot.concurrency 可覆盖 seed_concurrency = int((config.get("seed_shot") or {}).get("concurrency") or 0) or len(products) or 1 if len(products) > 1: print(f"[seed_shot] 并发 {seed_concurrency} 生成种草图({len(products)} 个产品)") with concurrent.futures.ThreadPoolExecutor(max_workers=seed_concurrency) as _ex: for item in _ex.map(_shot_one, products): if item: all_shots.append(item) stats = dict(state.get("stats") or {}) stats["seed_shot"] = {"count": len(all_shots), "seq": seq} return {"seed_shots": all_shots, "product": products, "oss_seq": seq, "stats": stats}