1) 模板导出:识别「基码表-胸围」填 sku.bust(多个胸围列都填);申报价格模糊匹配多列统一按加价后价格填写;详情图文不再拼接 img_url_2;SPU 款式来源统一填「现货款」;商品产地国家简称映射(沙特→沙特阿拉伯) 2) 模特性别分组:model_features 按男女分组,按模板类目含男/女固定取对应性别模特(含 Pinterest 模式 pipeline) 3) 三合一提示词:去掉 DESIGN CONTENT 四要素描述(设计已由设计稿提供) 4) 生图尺寸:全部改为读 config 不再硬编码(设计图 compose.design_size / 合成图 compose.size / 种草图 seed_shot.size)
271 lines
14 KiB
Python
271 lines
14 KiB
Python
"""节点 5.5/6:生成印花设计稿 + 导出简报包(compose)。
|
||
|
||
流程位置:prompt_build → compose → product(compose 在 product 之前)。
|
||
职责��
|
||
1. 生成纯印花设计稿:对前 N 个 safe 简报(N=config.compose.design_count,默认 1),
|
||
用 image_prompt 调图像后端 generate()(白底、可直接打印),产物存 output/<country>/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 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 _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,
|
||
size: str = "1024x1024") -> 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(且含「内容/图片」)时回调(供调用方累计放弃计数)。
|
||
"""
|
||
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:
|
||
# 图生图:以爬取图为参考,按分析简报生成原创设计(不复制原图)
|
||
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)
|
||
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)
|
||
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}",
|
||
state.get("errors"), seed=_seed,
|
||
size=compose_cfg.get("design_size", "1024x1024"))
|
||
if out_path is None:
|
||
return i, b, None, None
|
||
return i, b, out_path, None
|
||
|
||
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}")
|
||
|
||
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 []}
|