- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
134 lines
6.0 KiB
Python
134 lines
6.0 KiB
Python
"""节点 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}
|