v88 功能增强:产品落盘持久化 + 生图网关适配 + 模板导出优化
- 产品持久化:每完成一个产品立即追加写入 products_pending.jsonl,崩溃不丢已完成产品,finish 读盘合并后统一写模板 - 503 致命错误提前终止:compose/product/seed_shot 端到端识别,提前终止搜索分析,丢弃未完成简报,保留已完成落盘产品直接合成模板 - 模特分配:material_library 合格模特图按任务序号独立随机,同 SPU 多款不再共用同一模特 - 图像网关适配:execution_mode/background 默认不再传入 yunfei 等标准网关,base_url 需带 /v1;429/5xx/空响应退避重试 - Pinterest 分析:删除 term 注入与纯文本降级,失败直接放弃;图片上传前 PIL 完整性校验;suitable_for_print=False 过滤丢弃 - 模板导出:不再产生空白 xlsx,文件名=模板原文件名_已填写;写入前按货号末 3 位升序排序 - 删除对接文档.md,更新 README,gitignore 排除测试产物
This commit is contained in:
+103
-47
@@ -34,22 +34,18 @@ from typing import Any, Dict, List, Optional
|
||||
from graph.paths import project_root, runtime_root
|
||||
from graph.product import (
|
||||
find_basemap,
|
||||
find_first_model_folder,
|
||||
first_available_sku,
|
||||
list_colors,
|
||||
list_spus,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
from graph.validate import ThreadSafeErrors, with_fallback
|
||||
|
||||
|
||||
_USED_LOCK = threading.Lock() # used_designs.json 并发写锁
|
||||
_MODEL_LOCK = threading.Lock() # 同款共用模特缓存并发锁
|
||||
_MODEL_CACHE: Dict[str, Any] = {} # 同款共用模特:spu_code → model 路径
|
||||
|
||||
|
||||
def _next_img_idx(prod_dir: Path, prefix: str) -> int:
|
||||
"""货号续号:扫 prod_dir 已有 {prefix}{数字}* 文件,返回下一个起始序号(不覆盖旧产物)。"""
|
||||
import re
|
||||
max_n = -1
|
||||
try:
|
||||
if prod_dir.exists():
|
||||
@@ -116,17 +112,30 @@ def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
|
||||
return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"
|
||||
|
||||
|
||||
def _is_fatal_50x(e) -> bool:
|
||||
"""致命图像服务错误(503 / No available compatible accounts)→ 不重试,提前终止。"""
|
||||
try:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
return PinterestPipeline.is_fatal_503(e)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
|
||||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。"""
|
||||
import time as _t
|
||||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。
|
||||
|
||||
致命 503(账户不可用)重试无效 → 直接抛出,交由调用方终止任务。
|
||||
"""
|
||||
last = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _is_fatal_50x(e):
|
||||
raise
|
||||
last = e
|
||||
if i < attempts - 1:
|
||||
_t.sleep(backoff[i])
|
||||
time.sleep(backoff[i])
|
||||
print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
|
||||
return None
|
||||
|
||||
@@ -135,16 +144,31 @@ def _process_spu(
|
||||
db_path, basemap_root, material_root, category, prod_dir, brief, ib,
|
||||
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
|
||||
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
|
||||
on_503=None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
|
||||
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
|
||||
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
|
||||
(按货号命名,包含该货号对应的所有图片)。
|
||||
on_503: 致命图像服务错误(503/账户不可用)回调(供调用方提前终止任务)。
|
||||
返回 result dict;内部异常已兜底,不中断。
|
||||
"""
|
||||
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
|
||||
prod_dir = prod_dir / img_code
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _fatal(e) -> bool:
|
||||
"""致命图像服务错误(503/账户不可用)→ 通知 on_503 并返回 True(调用方应立即终止)。"""
|
||||
try:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
if PinterestPipeline.is_fatal_503(e):
|
||||
if on_503 is not None:
|
||||
on_503()
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
colors = list_colors(db_path, spu["code"])
|
||||
valid_codes = {c["sku_code"] for c in colors}
|
||||
if sku_code:
|
||||
@@ -220,6 +244,9 @@ def _process_spu(
|
||||
result["design_from"] = "product"
|
||||
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
|
||||
print(f"{tag} 设计稿生成失败: {e}")
|
||||
|
||||
@@ -262,17 +289,26 @@ def _process_spu(
|
||||
print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
|
||||
print(f"{tag} 三图合成失败,退避重试…: {e}")
|
||||
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], size=compose_size)
|
||||
if retried is not None:
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
print(f"{tag} 三图合成失败,退避重试…: {e}")
|
||||
try:
|
||||
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], size=compose_size)
|
||||
if retried is not None:
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
return None
|
||||
except Exception as e2: # noqa: BLE001
|
||||
if _fatal(e2):
|
||||
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
|
||||
return None
|
||||
raise
|
||||
else:
|
||||
# mark=1 无模特图 → 统一只做三合一,不做印花+底图两图合成
|
||||
if int(spu.get("mark") or 0) == 1:
|
||||
@@ -289,17 +325,26 @@ def _process_spu(
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
|
||||
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], size=compose_size)
|
||||
if retried is not None:
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图重试成功: {printed_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
|
||||
try:
|
||||
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], size=compose_size)
|
||||
if retried is not None:
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图重试成功: {printed_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
return None
|
||||
except Exception as e2: # noqa: BLE001
|
||||
if _fatal(e2):
|
||||
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
|
||||
return None
|
||||
raise
|
||||
|
||||
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
|
||||
color_composites: List[Dict[str, Any]] = []
|
||||
@@ -322,6 +367,9 @@ def _process_spu(
|
||||
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
|
||||
print(f"{tag} 颜色 {sc}({col})三合一已生成: {cp}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _fatal(e):
|
||||
print(f"{tag} 颜色 {sc} 三合一遇 503,终止: {e}")
|
||||
return None
|
||||
errors.append({"node": "product", "type": type(e).__name__,
|
||||
"message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
|
||||
result["color_composites"] = color_composites
|
||||
@@ -445,23 +493,25 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
prod_dir = output_dir / "product"
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 4.1) 任务级模特分配(material_library-<category>):
|
||||
# 一个 SPU 对应一个模特;SPU(不同款)数 > 模特数 → 从全部模特循环兜底(允许重复)
|
||||
model_assign: Dict[str, Any] = {}
|
||||
_all_models: List[str] = []
|
||||
# 4.1) 任务级模特分配(按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽取):
|
||||
# 每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复
|
||||
model_assign: Dict[int, Any] = {}
|
||||
try:
|
||||
_folder, _all_models = find_first_model_folder(material_root, category)
|
||||
from graph.product import build_mark_model_map, find_model_images_for_mark
|
||||
mark_map = build_mark_model_map(db_path, material_root)
|
||||
except Exception: # noqa: BLE001
|
||||
_all_models = []
|
||||
if _all_models:
|
||||
seen_spu: Dict[str, str] = {}
|
||||
for _i, (_spu, _skus, _tb) in enumerate(worklist):
|
||||
code = _spu.get("code", "")
|
||||
if code not in seen_spu:
|
||||
seen_spu[code] = _all_models[_i % len(_all_models)] # SPU>模特数 → 循环兜底
|
||||
model_assign[code] = seen_spu[code]
|
||||
print(f"[product] 任务级模特分配:{len(seen_spu)} 个 SPU,模特池 {len(_all_models)} 张"
|
||||
f"{'(SPU>模特,循环兜底)' if len(seen_spu) > len(_all_models) else ''}")
|
||||
mark_map = {}
|
||||
for _i, (_spu, _skus, _tb) in enumerate(worklist):
|
||||
mark = str(_spu.get("mark") or "").strip() or "1"
|
||||
folder = mark_map.get(mark, category)
|
||||
try:
|
||||
pool_imgs = find_model_images_for_mark(db_path, material_root, mark, folder)
|
||||
except Exception: # noqa: BLE001
|
||||
pool_imgs = []
|
||||
if pool_imgs:
|
||||
model_assign[_i] = random.choice(pool_imgs) # 过滤后随机抽(按任务序号)
|
||||
if model_assign:
|
||||
print(f"[product] 任务级模特分配:{len(model_assign)} 个产品任务(按 mark 过滤 3:4 后随机抽取)")
|
||||
|
||||
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
|
||||
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
|
||||
@@ -503,8 +553,11 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
concurrency = int(pcfg.get("concurrency") or 0) or min(len(worklist), 5)
|
||||
print(f"[product] 并发 {concurrency}(每 SPU 一线程,上限 {concurrency})处理 {len(worklist)} 个产品任务")
|
||||
|
||||
def _run_one(idx: int, spu, skus, tb):
|
||||
_safe_errors = ThreadSafeErrors()
|
||||
|
||||
def _run_one(wi: int, spu, skus, tb):
|
||||
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
|
||||
idx = start_idx + wi # 实际货号序号(start_idx 起自动续号)
|
||||
img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…)
|
||||
try:
|
||||
# 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png)
|
||||
@@ -519,9 +572,9 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tb = dict(tb)
|
||||
tb["design_path"] = design_path
|
||||
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
|
||||
tb, ib, spu, skus, pcfg, errors, design_path, title_backend,
|
||||
tb, ib, spu, skus, pcfg, _safe_errors, design_path, title_backend,
|
||||
country, img_code=img_code,
|
||||
model_img=model_assign.get(spu.get("code", "")),
|
||||
model_img=model_assign.get(wi), # 按任务序号取独立随机模特
|
||||
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
|
||||
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
|
||||
if r:
|
||||
@@ -535,7 +588,7 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 货号自动续号:任务一开始全部按序分配(start_idx 起),不覆盖已生成的产物
|
||||
start_idx = _next_img_idx(prod_dir, prefix)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
|
||||
futures = [ex.submit(_run_one, start_idx + i, spu, skus, tb)
|
||||
futures = [ex.submit(_run_one, i, spu, skus, tb)
|
||||
for i, (spu, skus, tb) in enumerate(worklist)]
|
||||
for f in concurrent.futures.as_completed(futures):
|
||||
r, img_code = f.result()
|
||||
@@ -545,6 +598,9 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
_record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
|
||||
if len(_safe_errors):
|
||||
errors.extend(list(_safe_errors))
|
||||
|
||||
results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定
|
||||
_write_products(prod_dir, results)
|
||||
|
||||
Reference in New Issue
Block a user