"""节点 5.5/6:生成印花设计稿 + 导出简报包(compose)。 流程位置:prompt_build → compose → product(compose 在 product 之前)。 职责�� 1. 生成纯印花设计稿:对前 N 个 safe 简报(N=config.compose.design_count,默认 1), 用 image_prompt 调图像后端 generate()(白底、可直接打印),产物存 output//designs/, 设计稿路径写回 brief.design_path,并汇总返回 designs 列表供 product 节点使用(图2)。 2. 导出简报包:design_briefs.json/md、composite_prompts.json/md、report.md。 """ import json import time from pathlib import Path from typing import Any, Dict, List from graph.validate import with_fallback RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"} def _build_briefs_md(briefs: List[Dict[str, Any]], generated_at: str) -> str: lines = [ "# POD 印花设计简报(LLM 合规筛选 + 生图提示词)", "", f"- 生成时间: {generated_at}", f"- 通过筛选: {len(briefs)} 条", "", "## 一、安全设计清单(按综合分排序)", "", "| 排名 | 国家 | 热点词 | 类别 | 风险 | 设计概念 |", "|---|---|---|---|---|---|", ] for i, r in enumerate(briefs, 1): flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level")) lines.append( f"| {i} | {r.get('country','')} | {r.get('topic','')} | {r.get('design_category','')} | {flag} | {r.get('concept','')} |" ) lines += ["", "## 二、设计要素 + 封装提示词", ""] lines.append("> 工作流:① `image_prompt` = 印花设计稿(白底,单独生图);② 上传平铺衣服底图(图1)后,") lines.append("> 用 `composite_prompt` + 图1 经 img2img 把设计印到衣服;规则写死:保留衣服、胸前居中印花、真实丝网质感。") lines.append("") for i, r in enumerate(briefs, 1): flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level")) lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')} ({flag})") lines.append(f"- 类别: {r.get('design_category','')}") lines.append(f"- 设计要素: 主体=「{r.get('motif','')}」 | 风格=「{r.get('art_style','')}」 | 配色=「{r.get('color_palette','')}」 | 构图=「{r.get('composition','')}」") lines.append(f"- 概念: {r.get('concept','')}") if r.get("risk_reasons"): lines.append(f"- 风险提示: {'; '.join(r['risk_reasons'])}") if r.get("design_path"): lines.append(f"- **设计稿**: {r['design_path']}") lines.append(f"- **设计稿 Prompt (image_prompt)**: {r.get('image_prompt','')}") lines.append(f"- **印到底图 Prompt (composite_prompt)**: {r.get('composite_prompt','')}") lines.append(f"- **Composite Negative**: {r.get('composite_negative','')}") lines.append("") return "\n".join(lines) def _build_composite_md(briefs: List[Dict[str, Any]]) -> str: lines = [ "# 封装提示词包(印到平铺衣服底图 图1)", "", f"- 共 {len(briefs)} 条,每条含 `composite_prompt`(印图指令)+ `composite_negative`。", "- 用法:将你的平铺衣服参考图作为图1,连同 `composite_prompt` 送入任意 img2img / inpaint 模型。", "", ] for i, r in enumerate(briefs, 1): lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')}") lines.append(f"- composite_prompt: {r.get('composite_prompt','')}") lines.append(f"- composite_negative: {r.get('composite_negative','')}") lines.append("") return "\n".join(lines) def _build_report_md(state: Dict[str, Any]) -> str: country = state.get("country", "") stats = state.get("stats") or {} errors = state.get("errors") or [] lines = [ f"# POD 热点抓取报告 - {country}", "", f"- 生成时间: {time.strftime('%Y-%m-%dT%H:%M:%S')}", "", "## 各阶段统计", "", "| 阶段 | 指标 |", "|---|---|", ] for k, v in stats.items(): lines.append(f"| {k} | {v} |") lines += ["", "## 兜底错误记录(节点级 fallback 捕获)", ""] if errors: for e in errors: lines.append(f"- [{e.get('node')}] {e.get('type')}: {e.get('message')}") else: lines.append("- 无(全部节点正常)") return "\n".join(lines) @with_fallback("compose") def compose_node(state: Dict[str, Any]) -> Dict[str, Any]: briefs: List[Dict[str, Any]] = state.get("briefs") or [] output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹) output_dir.mkdir(parents=True, exist_ok=True) cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录) config = state["config"] country = state.get("country", "") generated_at = time.strftime("%Y-%m-%dT%H:%M:%S") # 1) design_briefs.json(缓存 → 根目录,不进时间戳任务文件夹) (cache_dir / "design_briefs.json").write_text( json.dumps({"generated_at": generated_at, "total": len(briefs), "design_briefs": briefs}, ensure_ascii=False, indent=2), encoding="utf-8") # 2) design_briefs.md (cache_dir / "design_briefs.md").write_text( _build_briefs_md(briefs, generated_at), encoding="utf-8") # 3) composite_prompts.json / .md (cache_dir / "composite_prompts.json").write_text( json.dumps({"generated_at": generated_at, "total": len(briefs), "composite_prompts": briefs}, ensure_ascii=False, indent=2), encoding="utf-8") (cache_dir / "composite_prompts.md").write_text( _build_composite_md(briefs), encoding="utf-8") # 4) report.md(本次任务报告 → 产物目录) (output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8") # 5) 生成纯印花设计稿(图2):前 N 个 safe 简报用 image_prompt 文生图 designs: List[Dict[str, Any]] = [] compose_cfg = config.get("compose") or {} backend_name = compose_cfg.get("backend", "") ib = None if backend_name: try: from graph.backends import get_image_backend ib = get_image_backend(backend_name) if ib is not None: ib.bind_config(compose_cfg) except Exception as e: # noqa: BLE001 print(f"[compose] 图像后端 {backend_name} 不可用: {e}") if ib is None: print("[compose] 未配置 compose.backend(openai/mock),跳过印花设计稿生成。") else: # 设计稿覆盖所有简报(含 review):每个热点一张设计,避免 review 热点无设计 # 导致 product 回退生成重复占位图;风险由 assign 层(allow_review)控制是否分配 safe_briefs = briefs design_count = int(compose_cfg.get("design_count", 1)) # 联动总任务数:每个产品一张设计 → 生成 扩展后 spu_tasks 总数 张设计 task_n = int(len((state.get("config") or {}).get("product", {}).get("spu_tasks") or [])) if task_n > design_count: design_count = task_n design_dir = output_dir / "designs" design_dir.mkdir(exist_ok=True) from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint from concurrent.futures import ThreadPoolExecutor, as_completed def _gen_one(i: int, b: Dict[str, Any]): """单张设计稿生成(并发线程内调用,每设计一线程)。""" try: img_prompt = sanitize_image_prompt(b.get("image_prompt", "")) img_prompt = ensure_rebrand_hint(b, img_prompt) # review → 原创化魔改引导 out_path = ib.generate( img_prompt, str(design_dir / f"{country}_{i:02d}_design.png"), b.get("composite_negative", ""), size="1024x1024") # 印花设计统一 1024x1024 return i, b, out_path, None except Exception as e: # noqa: BLE001 return i, b, None, e targets = [(i, b) for i, b in enumerate(safe_briefs[:design_count], 1)] # 并发生成:每张设计一个线程(并行调图像网关),数量多时不串行等待 workers = max(1, min(len(targets), int((config.get("compose") or {}).get("design_workers", 5)))) print(f"[compose] 并发生成 {len(targets)} 张设计稿({workers} 线程)…") with ThreadPoolExecutor(max_workers=workers) as _ex: _futs = [_ex.submit(_gen_one, i, b) for i, b in targets] for _f in as_completed(_futs): i, b, out_path, err = _f.result() if err is not None: print(f"[compose] 设计稿生成失败 {b.get('topic', '')}: {err}") state.setdefault("errors", []).append({ "node": "compose", "type": type(err).__name__, "message": f"设计稿生成失败 {b.get('topic','')}: {err}", "trace": ""}) else: b["design_path"] = out_path designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path}) print(f"[compose] 印花设计稿已生成: {out_path}") stats = dict(state.get("stats") or {}) stats["compose"] = {"written": len(briefs), "designs": len(designs), "output_dir": str(output_dir)} return {"composite": briefs, "designs": designs, "stats": stats, "errors": state.get("errors") or []}