"""节点 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, Optional from graph.validate import ThreadSafeErrors, with_fallback RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"} def _notify_400(on_400, exc) -> None: """HTTP 400(且含「内容/图片」)时触发 on_400 回调(供调用方累计放弃计数)。""" if on_400 is None: return try: from graph.pinterest import is_400_content_image if is_400_content_image(exc): on_400() except Exception: # noqa: BLE001 pass def _notify_503(on_503, exc) -> None: """致命图像服务错误(503 / 账户不可用)时触发 on_503 回调(供调用方提前终止任务)。""" if on_503 is None: return try: from graph.pinterest_pipeline import PinterestPipeline if PinterestPipeline.is_fatal_503(exc): on_503() except Exception: # noqa: BLE001 pass 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) def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str, errors: List[Dict[str, Any]] = None, seed: Optional[int] = None, on_400=None, on_503=None, size: str = "1024x1024", custom_mode: bool = False) -> Optional[str]: """生成单张纯印花设计稿(图2)。返回设计稿路径;失败 / 全局 MD5 重复返回 None。 out_stem: 输出文件名主干(不含扩展名),最终文件 = {out_stem}_design.png。 货号模式传 img_code(如 DG000)→ designs/DG000_design.png; 旧 compose 模式传 {country}_{idx:02d}(如 JP_01)→ designs/JP_01_design.png。 Pinterest 参考模式:简报带 ref_images(爬取图)→ 用 ib.print() 图生图, 把爬取图 + 多模态分析简报(已封装进 image_prompt)一起发给生图模型; 无参考图或图生图失败 → 回退 ib.generate() 纯文生图。 seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。 on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。 on_503: 致命图像服务错误(503/账户不可用)时回调(供调用方提前终止任务)。 """ try: from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint img_prompt = sanitize_image_prompt(brief.get("image_prompt", "")) img_prompt = ensure_rebrand_hint(brief, img_prompt) # review → 原创化魔改引导 out_path = str(design_dir / f"{out_stem}_design.png") ref_images = [str(p) for p in (brief.get("ref_images") or []) if str(p)] if ref_images and hasattr(ib, "print"): try: if custom_mode: # 自定义模式:按 image_prompt 图生图(模板已内置防复制/防服装约束,不追加原创化指令) out_path = ib.print( img_prompt, ref_images[0], out_path, brief.get("composite_negative", ""), extra_images=ref_images[1:] or None, size=size, seed=seed) # 设计稿尺寸按 config compose.design_size else: # Pinterest 模式:追加原创化指令(防止复制原图,保持原有行为) ref_prompt = img_prompt + ( " Create an ORIGINAL, non-copying flat print design inspired ONLY by " "the reference image's style and mood. Do NOT reproduce the reference " "image, its characters, logos, or any text.") out_path = ib.print( ref_prompt, ref_images[0], out_path, brief.get("composite_negative", ""), extra_images=ref_images[1:] or None, size=size, seed=seed) # 设计稿尺寸按 config compose.design_size except Exception as e: # noqa: BLE001 print(f"[compose] 图生图(参考图)失败,回退文生图 {brief.get('topic','')}: {e}") _notify_400(on_400, e) _notify_503(on_503, e) out_path = ib.generate( img_prompt, str(design_dir / f"{out_stem}_design.png"), brief.get("composite_negative", ""), size=size, seed=seed) else: out_path = ib.generate( img_prompt, str(design_dir / f"{out_stem}_design.png"), brief.get("composite_negative", ""), size=size, seed=seed) # 全局 MD5 去重:生成了设计后,把 MD5 加入全局过滤(对所有国家生效); # 已存在的重复设计 → 跳过(不用于产品),避免跨国家重复使用同一设计 from graph.pinterest import design_md5_ok if not design_md5_ok(out_path): print(f"[compose] 设计稿 MD5 全局重复,跳过(不用于产品): {out_path}") return None return out_path except Exception as e: # noqa: BLE001 _notify_400(on_400, e) _notify_503(on_503, e) if errors is not None: errors.append({"node": "compose", "type": type(e).__name__, "message": f"设计稿生成失败 {brief.get('topic','')}: {e}", "trace": ""}) print(f"[compose] 设计稿生成失败 {brief.get('topic', '')}: {e}") return None def write_compose_reports(state: Dict[str, Any], briefs: List[Dict[str, Any]]) -> None: """写 compose 阶段简报报告(design_briefs / composite_prompts / report.md)。 Pinterest 并发生成模式下 compose_node 不再整体执行,由收尾节点调用本函数补写报告。 """ output_dir = Path(state["output_dir"]) output_dir.mkdir(parents=True, exist_ok=True) cache_dir = Path(state.get("cache_dir") or output_dir) generated_at = time.strftime("%Y-%m-%dT%H:%M:%S") (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") (cache_dir / "design_briefs.md").write_text( _build_briefs_md(briefs, generated_at), encoding="utf-8") (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") (output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8") @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", "") # 1-4) 简报报告(design_briefs / composite_prompts / report.md) write_compose_reports(state, briefs) # 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 concurrent.futures import ThreadPoolExecutor, as_completed # 随机种子:config compose.seed >0 时固定(可复现,网关支持才生效);0/留空=每次随机 _seed = int(compose_cfg.get("seed") or 0) _seed = _seed if _seed > 0 else None def _gen_one(i: int, b: Dict[str, Any]): """单张设计稿生成(并发线程内调用,每设计一线程)。""" out_path = generate_design(ib, b, design_dir, f"{country}_{i:02d}", _safe_errors, seed=_seed, size=compose_cfg.get("design_size", "1024x1024"), custom_mode=bool(state.get("custom_mode"))) if out_path is None: return i, b, None, None return i, b, out_path, None _safe_errors = ThreadSafeErrors() 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": ""}) elif out_path is None: print(f"[compose] 设计稿跳过(MD5 全局去重): {b.get('topic', '')}") else: b["design_path"] = out_path designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path}) print(f"[compose] 印花设计稿已生成: {out_path}") # 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加) if len(_safe_errors): state.setdefault("errors", []).extend(list(_safe_errors)) 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 []}