scene_set.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. import json
  2. import os
  3. import re
  4. import pandas as pd
  5. import requests
  6. from natsort.natsort import natsorted
  7. from PIL import Image, ImageDraw, ImageFont
  8. import settings
  9. from logger import logger
  10. PLATE_LONG = 2048
  11. PLATE_SIZE = (1366, 2048)
  12. PLATE_COLOR = (186, 186, 186)
  13. OUTPUT_NAME = "场景套图"
  14. MARKER_NAME = "场景套图_任务.json"
  15. TEMP_NAME = "_场景套图临时"
  16. LAYOUT_PLATE = {
  17. "handheld": {"height_ratio": 0.30, "bottom_ratio": 0.10, "max_width_ratio": 0.66},
  18. "floor": {"height_ratio": 0.44, "bottom_ratio": 0.02, "max_width_ratio": 0.90},
  19. }
  20. ANCHOR_LABELS = {
  21. "top-left": "左上",
  22. "top-right": "右上",
  23. "bottom-left": "左下",
  24. "bottom-right": "右下",
  25. "center": "中间",
  26. }
  27. SKIP_FOLDERS = {"原始图", "原始图_已抠图", "800x800", "阴影图处理", "200images", OUTPUT_NAME, TEMP_NAME}
  28. def prepare_scene_set(payload):
  29. goods_dir = resolve_goods_dir(payload)
  30. cutout_dir = os.path.join(goods_dir, "原始图_已抠图")
  31. if not os.path.isdir(cutout_dir):
  32. raise RuntimeError("没有找到原始图_已抠图,不会重新抠图")
  33. files = natsorted([
  34. name for name in os.listdir(cutout_dir)
  35. if name.lower().endswith(".png") and os.path.isfile(os.path.join(cutout_dir, name))
  36. ])
  37. if not files:
  38. raise RuntimeError("原始图_已抠图里没有 PNG,不会重新抠图")
  39. layout = payload.get("layout") or "handheld"
  40. slots = [slot for slot in (payload.get("slots") or []) if (slot.get("layout") or layout) == layout]
  41. slots = sorted(slots, key=lambda slot: int(slot.get("seq") or 0))
  42. cutouts = []
  43. for index, name in enumerate(files):
  44. slot = slots[index] if index < len(slots) else {}
  45. cutouts.append({
  46. "file": name,
  47. "path": os.path.join(cutout_dir, name),
  48. "index": index + 1,
  49. "slot_id": slot.get("id") or 0,
  50. "name": slot.get("name") or "未识别",
  51. "role": slot.get("role") or "",
  52. "rotation": int(slot.get("rotation") or 0),
  53. "use_long_scene": int(slot.get("use_long_scene") or 0),
  54. "use_three_view": int(slot.get("use_three_view") or 0),
  55. "use_detail": int(slot.get("use_detail") or 0),
  56. "use_for_scene": int(slot.get("use_for_scene") or 0) if slot else 0,
  57. "crops": slot.get("crops") or [],
  58. })
  59. output_dir = os.path.join(goods_dir, OUTPUT_NAME)
  60. logger.info("场景套图使用已有抠图 goods=%s dir=%s count=%s", payload.get("goods_art_no"), goods_dir, len(cutouts))
  61. return {
  62. "goods_dir": goods_dir,
  63. "cutout_dir": cutout_dir,
  64. "output_dir": output_dir,
  65. "output_exists": os.path.isdir(output_dir),
  66. "cutouts": cutouts,
  67. "message": "使用已有抠图,没有重新抠图",
  68. }
  69. def run_scene_set(payload):
  70. goods_dir = resolve_goods_dir(payload)
  71. output_dir = os.path.join(goods_dir, OUTPUT_NAME)
  72. if os.path.isdir(output_dir):
  73. return {
  74. "skipped": True,
  75. "reason": "已有场景套图目录,要重出请先删除该目录",
  76. "output_dir": output_dir,
  77. "goods_dir": goods_dir,
  78. }
  79. marker = os.path.join(goods_dir, MARKER_NAME)
  80. if os.path.isfile(marker) and not int(payload.get("force") or 0):
  81. saved = json.loads(open(marker, "r", encoding="utf-8").read())
  82. if saved.get("job_id"):
  83. return {
  84. "resumed": True,
  85. "job_id": saved["job_id"],
  86. "goods_dir": goods_dir,
  87. "output_dir": output_dir,
  88. "reason": "继续上次未完成的任务",
  89. }
  90. cutouts = payload.get("cutouts") or []
  91. source = next((item for item in cutouts if int(item.get("use_for_scene") or 0) and int(item.get("use_long_scene") or 0)), None)
  92. if not source:
  93. raise RuntimeError("没有可用于场景的机位")
  94. layout = payload.get("layout") or "handheld"
  95. temp_dir = os.path.join(goods_dir, TEMP_NAME)
  96. os.makedirs(temp_dir, exist_ok=True)
  97. plate_path = os.path.join(temp_dir, "plate.jpg")
  98. png_path = os.path.join(temp_dir, "cutout.png")
  99. build_plate(source["path"], int(source.get("rotation") or 0), layout, png_path, plate_path)
  100. logger.info("场景套图上传抠图 PNG,不转成 JPEG %s", png_path)
  101. upload_file(png_path)
  102. plate_url = upload_file(plate_path)
  103. materials = collect_materials(payload)
  104. body = {
  105. "category_id": int(payload.get("category_id") or 0),
  106. "goods_art_no": payload.get("goods_art_no") or "",
  107. "basis": payload.get("basis") or "selling_point",
  108. "scene_tags": payload.get("scene_tags") or [],
  109. "materials": materials,
  110. "cutouts": [public_cutout(item) for item in cutouts],
  111. "copy_anchor": payload.get("copy_anchor") or "top-left",
  112. "plate_url": plate_url,
  113. "options": {
  114. "layout": layout,
  115. "height_cm": payload.get("height_cm") or "",
  116. "missing_height": payload.get("missing_height") or "skip",
  117. "default_tier": payload.get("default_tier") or "M",
  118. "visible_features": payload.get("visible_features") or [],
  119. },
  120. }
  121. data = backend_post("/product_scene/create_job", body)
  122. if data.get("job_id"):
  123. with open(marker, "w", encoding="utf-8") as file:
  124. json.dump({"job_id": data["job_id"]}, file, ensure_ascii=False)
  125. data["goods_dir"] = goods_dir
  126. data["output_dir"] = output_dir
  127. data["message"] = "使用已有抠图,没有重新抠图"
  128. return data
  129. def save_scene_set(payload):
  130. goods_dir = payload.get("goods_dir") or ""
  131. if not goods_dir or not os.path.isdir(goods_dir):
  132. raise RuntimeError("货号目录不存在")
  133. job = payload.get("job") or {}
  134. cutouts = payload.get("cutouts") or []
  135. anchor = job.get("copy_anchor") or payload.get("copy_anchor") or "top-left"
  136. output_dir = os.path.join(goods_dir, OUTPUT_NAME)
  137. os.makedirs(output_dir, exist_ok=True)
  138. saved = []
  139. notes = list(job.get("progress_notes") or [])
  140. for image in job.get("images") or []:
  141. kind = image.get("kind")
  142. if kind == "scene":
  143. if int(image.get("status") or 0) != 2 or not image.get("result_url"):
  144. notes.append((image.get("scene_name") or "场景") + " 未出图:" + (image.get("error_msg") or "失败"))
  145. continue
  146. stem = "场景_{:02d}_{}".format(int(image.get("sort") or 1), safe_name(image.get("scene_name") or "场景"))
  147. jpg_path = os.path.join(output_dir, stem + ".jpg")
  148. download(image["result_url"], jpg_path)
  149. png_path = os.path.join(output_dir, stem + ".png")
  150. compose_copy(Image.open(jpg_path).convert("RGB"), anchor, image.get("title") or "", image.get("lines") or []).save(png_path, "PNG")
  151. saved.append(jpg_path)
  152. saved.append(png_path)
  153. elif kind == "three_view":
  154. path = os.path.join(output_dir, "三视图.png")
  155. render_three_view(cutouts, image, anchor).save(path, "PNG")
  156. saved.append(path)
  157. elif kind == "detail":
  158. path = os.path.join(output_dir, "细节.png")
  159. render_detail(cutouts, image, anchor).save(path, "PNG")
  160. saved.append(path)
  161. elif kind in ("size", "param"):
  162. filename = "尺寸.png" if kind == "size" else "参数.png"
  163. path = os.path.join(output_dir, filename)
  164. render_text_page(image.get("title") or filename[:-4], image.get("lines") or [], anchor).save(path, "PNG")
  165. saved.append(path)
  166. txt_path = os.path.join(output_dir, "卖点.txt")
  167. with open(txt_path, "w", encoding="utf-8") as file:
  168. file.write(selling_text(job, anchor, notes))
  169. saved.append(txt_path)
  170. marker = os.path.join(goods_dir, MARKER_NAME)
  171. if os.path.isfile(marker):
  172. os.remove(marker)
  173. logger.info("场景套图已写入 %s", output_dir)
  174. return {"output_dir": output_dir, "files": saved, "notes": notes}
  175. def resolve_goods_dir(payload):
  176. goods_dir = payload.get("goods_dir") or ""
  177. if goods_dir and os.path.isdir(goods_dir) and has_cutout_png(goods_dir):
  178. return os.path.abspath(goods_dir)
  179. image_dir = goods_dir_from_image(payload.get("image_path") or "")
  180. if image_dir and has_cutout_png(image_dir):
  181. return image_dir
  182. found = find_output_goods_dir(payload.get("goods_art_no") or "")
  183. if found:
  184. return found
  185. if image_dir:
  186. return image_dir
  187. if goods_dir and os.path.isdir(goods_dir):
  188. return os.path.abspath(goods_dir)
  189. raise RuntimeError("没有找到货号目录")
  190. def goods_dir_from_image(image_path):
  191. if not image_path or not os.path.exists(image_path):
  192. return ""
  193. parent = os.path.dirname(os.path.abspath(image_path))
  194. if os.path.basename(parent) in SKIP_FOLDERS:
  195. parent = os.path.dirname(parent)
  196. return parent
  197. def has_cutout_png(goods_dir):
  198. cutout_dir = os.path.join(goods_dir, "原始图_已抠图")
  199. if not os.path.isdir(cutout_dir):
  200. return False
  201. for name in os.listdir(cutout_dir):
  202. path = os.path.join(cutout_dir, name)
  203. if name.lower().endswith(".png") and os.path.isfile(path):
  204. return True
  205. return False
  206. def find_output_goods_dir(goods_art_no):
  207. goods_art_no = str(goods_art_no or "").strip()
  208. output_dir = str(settings.OUTPUT_DIR)
  209. if not goods_art_no or not os.path.isdir(output_dir):
  210. return ""
  211. matches = []
  212. try:
  213. names = os.listdir(output_dir)
  214. except OSError:
  215. return ""
  216. for date_dir in names:
  217. date_path = os.path.join(output_dir, date_dir)
  218. goods_path = os.path.join(date_path, goods_art_no)
  219. if os.path.isdir(date_path) and has_cutout_png(goods_path):
  220. matches.append(os.path.abspath(goods_path))
  221. if not matches:
  222. return ""
  223. matches.sort(key=lambda path: os.path.basename(os.path.dirname(path)))
  224. return matches[-1]
  225. def build_plate(source_path, rotation, layout, png_path, plate_path):
  226. image = Image.open(source_path).convert("RGBA")
  227. if rotation:
  228. image = image.rotate(-int(rotation), expand=True)
  229. image = trim_alpha(image)
  230. image = fit_long_edge(image, PLATE_LONG)
  231. image.save(png_path, "PNG")
  232. ratios = LAYOUT_PLATE.get(layout) or LAYOUT_PLATE["handheld"]
  233. canvas = Image.new("RGB", PLATE_SIZE, PLATE_COLOR)
  234. width, height = image.size
  235. scale = min(
  236. ratios["height_ratio"] * canvas.height / height,
  237. ratios["max_width_ratio"] * canvas.width / width,
  238. )
  239. resized = image.resize((max(1, int(width * scale)), max(1, int(height * scale))), Image.Resampling.LANCZOS)
  240. x = int((canvas.width - resized.width) / 2)
  241. y = int(canvas.height - ratios["bottom_ratio"] * canvas.height - resized.height)
  242. canvas.paste(resized, (x, y), resized.getchannel("A"))
  243. canvas.save(plate_path, "JPEG", quality=90)
  244. def trim_alpha(image, threshold=16, pad=12):
  245. alpha = image.getchannel("A")
  246. mask = alpha.point(lambda value: 255 if value > threshold else 0)
  247. box = mask.getbbox()
  248. if not box:
  249. return image
  250. left, top, right, bottom = box
  251. left = max(0, left - pad)
  252. top = max(0, top - pad)
  253. right = min(image.width, right + pad)
  254. bottom = min(image.height, bottom + pad)
  255. return image.crop((left, top, right, bottom))
  256. def fit_long_edge(image, long_edge):
  257. width, height = image.size
  258. longest = max(width, height)
  259. if longest <= long_edge:
  260. return image
  261. scale = long_edge / float(longest)
  262. return image.resize((max(1, int(width * scale)), max(1, int(height * scale))), Image.Resampling.LANCZOS)
  263. def upload_file(path):
  264. ext = os.path.splitext(path)[1].lower()
  265. content_type = "image/png" if ext == ".png" else "image/jpeg"
  266. with open(path, "rb") as file:
  267. result = requests.post(
  268. settings.DOMAIN + "/api/upload",
  269. files={"file": (os.path.basename(path), file, content_type)},
  270. timeout=120,
  271. ).json()
  272. url = ((result or {}).get("data") or {}).get("url")
  273. if not url:
  274. raise RuntimeError("图片上传失败")
  275. return url
  276. def collect_materials(payload):
  277. fields = dict(payload.get("materials") or {})
  278. selling = (payload.get("selling_points") or "").strip()
  279. excel_path = payload.get("excel_path") or ""
  280. if excel_path:
  281. parsed = read_excel(excel_path, payload.get("goods_art_no") or "")
  282. for key, value in parsed["fields"].items():
  283. fields.setdefault(key, value)
  284. if not selling:
  285. selling = parsed["selling_points"]
  286. return {"fields": fields, "selling_points": selling}
  287. def read_excel(path, goods_art_no):
  288. if not os.path.isfile(path):
  289. raise RuntimeError("资料文件不存在")
  290. frame = pd.read_excel(path)
  291. frame.columns = [str(column).strip() for column in frame.columns]
  292. if {"字段", "内容"}.issubset(set(frame.columns)):
  293. fields = {}
  294. selling = []
  295. for _, row in frame.iterrows():
  296. name = "" if pd.isna(row["字段"]) else str(row["字段"]).strip()
  297. value = "" if pd.isna(row["内容"]) else str(row["内容"]).strip()
  298. if not name or not value:
  299. continue
  300. if name in ("卖点", "卖点文案"):
  301. selling.append(value)
  302. else:
  303. fields[name] = value
  304. return {"fields": fields, "selling_points": "\n".join(selling)}
  305. row = frame.iloc[0]
  306. if "货号" in frame.columns and goods_art_no:
  307. matched = frame[frame["货号"].astype(str).str.strip() == str(goods_art_no).strip()]
  308. if matched.empty:
  309. raise RuntimeError("资料里没有这个货号")
  310. row = matched.iloc[0]
  311. fields = {}
  312. selling = []
  313. for column, value in row.items():
  314. if pd.isna(value):
  315. continue
  316. text = str(value).strip()
  317. if not text or column == "货号":
  318. continue
  319. if column in ("卖点", "卖点文案"):
  320. selling.append(text)
  321. else:
  322. fields[column] = text
  323. return {"fields": fields, "selling_points": "\n".join(selling)}
  324. def public_cutout(item):
  325. return {
  326. "name": item.get("name") or "",
  327. "role": item.get("role") or "",
  328. "use_three_view": int(item.get("use_three_view") or 0),
  329. "use_detail": int(item.get("use_detail") or 0),
  330. "use_for_scene": int(item.get("use_for_scene") or 0),
  331. "use_long_scene": int(item.get("use_long_scene") or 0),
  332. "crops": item.get("crops") or [],
  333. }
  334. def backend_post(path, payload):
  335. response = requests.post(
  336. settings.DOMAIN + "/api/ai_image/camera_machine" + path,
  337. headers={
  338. "Authorization": "Bearer {}".format(settings.USER_TOKEN or ""),
  339. "Content-Type": "application/json",
  340. },
  341. json=payload,
  342. timeout=180,
  343. )
  344. try:
  345. body = response.json()
  346. except Exception:
  347. raise RuntimeError("后端没有返回结果")
  348. if body.get("code") not in (0, "0"):
  349. raise RuntimeError(body.get("message") or body.get("msg") or "后端请求失败")
  350. return body.get("data") or {}
  351. def download(url, path):
  352. response = requests.get(url, timeout=180)
  353. response.raise_for_status()
  354. with open(path, "wb") as file:
  355. file.write(response.content)
  356. def compose_copy(base, anchor, title, lines):
  357. canvas = base.convert("RGBA")
  358. scale = canvas.width / 750.0
  359. title_lines = split_title(title)
  360. body = [str(line).strip() for line in lines if str(line).strip() and not str(line).startswith("卖点")]
  361. if not title_lines and not body:
  362. return canvas.convert("RGB")
  363. title_font = load_font(int(34 * scale), bold=True)
  364. body_font = load_font(int(20 * scale), bold=False)
  365. draw = ImageDraw.Draw(canvas)
  366. max_width = int(canvas.width * 0.72)
  367. wrapped = []
  368. for line in title_lines:
  369. wrapped.extend(wrap_text(line, title_font, max_width, draw))
  370. body_wrapped = []
  371. for line in body:
  372. body_wrapped.extend(wrap_text(line, body_font, max_width, draw))
  373. block = measure_block(draw, wrapped, title_font, body_wrapped, body_font, scale)
  374. if anchor == "center":
  375. origin = (int((canvas.width - block[0]) / 2), int((canvas.height - block[1]) / 2))
  376. plate = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
  377. plate_draw = ImageDraw.Draw(plate)
  378. pad = int(28 * scale)
  379. plate_draw.rounded_rectangle(
  380. [origin[0] - pad, origin[1] - pad, origin[0] + block[0] + pad, origin[1] + block[1] + pad],
  381. radius=int(18 * scale),
  382. fill=(0, 0, 0, 140),
  383. )
  384. canvas = Image.alpha_composite(canvas, plate)
  385. else:
  386. canvas = Image.alpha_composite(canvas, shade_layer(canvas.size, anchor))
  387. origin = text_origin(canvas.size, block, anchor, scale)
  388. draw = ImageDraw.Draw(canvas)
  389. y = origin[1]
  390. for line in wrapped:
  391. x = align_x(origin[0], block[0], draw.textlength(line, font=title_font), anchor)
  392. draw.text((x, y), line, font=title_font, fill=(255, 255, 255, 255))
  393. y += int(46 * scale)
  394. if wrapped and body_wrapped:
  395. y += int(8 * scale)
  396. for line in body_wrapped:
  397. x = align_x(origin[0], block[0], draw.textlength(line, font=body_font), anchor)
  398. draw.text((x, y), line, font=body_font, fill=(255, 255, 255, 230))
  399. y += int(32 * scale)
  400. return canvas.convert("RGB")
  401. def render_three_view(cutouts, image, anchor):
  402. canvas = Image.new("RGB", (1500, 2400), (243, 240, 235))
  403. plan = ((image.get("extra") or {}).get("plan") or [])
  404. margin, gap = 48, 28
  405. card_w = 1500 - margin * 2
  406. card_h = (2400 - margin * 2 - gap * 2) // 3
  407. y = margin
  408. for item in plan:
  409. card = product_card(find_cutout(cutouts, item.get("role")), card_w, card_h, item.get("label") or "")
  410. canvas.paste(card, (margin, y))
  411. y += card_h + gap
  412. return compose_copy(canvas, anchor, image.get("title") or "", image.get("lines") or [])
  413. def render_detail(cutouts, image, anchor):
  414. canvas = Image.new("RGB", (1500, 2400), (243, 240, 235))
  415. plan = ((image.get("extra") or {}).get("plan") or [])[:4]
  416. if not plan:
  417. return compose_copy(canvas, anchor, image.get("title") or "", image.get("lines") or [])
  418. margin, gap = 48, 24
  419. card_w = 1500 - margin * 2
  420. card_h = (2400 - margin * 2 - gap * (len(plan) - 1)) // len(plan)
  421. y = margin
  422. for item in plan:
  423. cutout = find_cutout(cutouts, item.get("role"), item.get("name"))
  424. cropped = crop_box(open_cutout(cutout), item.get("box") or [0, 0, 1, 1]) if cutout else None
  425. card = product_card_image(cropped, card_w, card_h, item.get("label") or "细节")
  426. canvas.paste(card, (margin, y))
  427. y += card_h + gap
  428. return compose_copy(canvas, anchor, image.get("title") or "", image.get("lines") or [])
  429. def render_text_page(title, lines, anchor):
  430. canvas = Image.new("RGB", (1500, 2400), (243, 240, 235))
  431. return compose_copy(canvas, anchor, title, lines)
  432. def product_card(cutout, width, height, label):
  433. image = open_cutout(cutout) if cutout else None
  434. return product_card_image(image, width, height, label)
  435. def product_card_image(image, width, height, label):
  436. card = Image.new("RGB", (width, height), (255, 255, 255))
  437. draw = ImageDraw.Draw(card)
  438. font = load_font(28, bold=False)
  439. label_h = 64
  440. if image:
  441. fitted = fit_box(image, width - 48, height - label_h - 48)
  442. x = int((width - fitted.width) / 2)
  443. y = int((height - label_h - fitted.height) / 2)
  444. card.paste(fitted, (x, max(16, y)), fitted.getchannel("A"))
  445. text = label or ""
  446. draw.text(((width - draw.textlength(text, font=font)) / 2, height - label_h), text, font=font, fill=(40, 40, 40, 255))
  447. return card
  448. def open_cutout(cutout):
  449. if not cutout or not cutout.get("path") or not os.path.isfile(cutout["path"]):
  450. return None
  451. image = Image.open(cutout["path"]).convert("RGBA")
  452. rotation = int(cutout.get("rotation") or 0)
  453. if rotation:
  454. image = image.rotate(-rotation, expand=True)
  455. return trim_alpha(image)
  456. def crop_box(image, box):
  457. if image is None:
  458. return None
  459. left, top, right, bottom = box
  460. width, height = image.size
  461. return image.crop((
  462. int(float(left) * width),
  463. int(float(top) * height),
  464. int(float(right) * width),
  465. int(float(bottom) * height),
  466. ))
  467. def find_cutout(cutouts, role, name=""):
  468. for item in cutouts:
  469. if role and item.get("role") == role:
  470. return item
  471. for item in cutouts:
  472. if name and item.get("name") == name:
  473. return item
  474. return None
  475. def fit_box(image, width, height):
  476. scale = min(width / image.width, height / image.height)
  477. return image.resize((max(1, int(image.width * scale)), max(1, int(image.height * scale))), Image.Resampling.LANCZOS)
  478. def shade_layer(size, anchor):
  479. width, height = size
  480. mask = Image.new("L", size, 0)
  481. draw = ImageDraw.Draw(mask)
  482. span = int(height * 0.46)
  483. if anchor in ("top-left", "top-right"):
  484. for y in range(span):
  485. draw.line([(0, y), (width, y)], fill=int(170 * (1 - y / float(span))))
  486. elif anchor in ("bottom-left", "bottom-right"):
  487. start = height - span
  488. for y in range(start, height):
  489. draw.line([(0, y), (width, y)], fill=int(170 * ((y - start) / float(span))))
  490. layer = Image.new("RGBA", size, (0, 0, 0, 0))
  491. layer.putalpha(mask)
  492. return layer
  493. def split_title(title):
  494. text = str(title or "").replace("卖点:", "").replace("卖点:", "").strip()
  495. if not text:
  496. return []
  497. return [part.strip() for part in re.split(r"\s*/\s*", text) if part.strip()]
  498. def wrap_text(text, font, max_width, draw):
  499. lines = []
  500. current = ""
  501. for char in str(text):
  502. trial = current + char
  503. if draw.textlength(trial, font=font) <= max_width:
  504. current = trial
  505. else:
  506. if current:
  507. lines.append(current)
  508. current = char
  509. if current:
  510. lines.append(current)
  511. return lines
  512. def measure_block(draw, title_lines, title_font, body_lines, body_font, scale):
  513. width = 0
  514. for line in title_lines:
  515. width = max(width, draw.textlength(line, font=title_font))
  516. for line in body_lines:
  517. width = max(width, draw.textlength(line, font=body_font))
  518. height = len(title_lines) * int(46 * scale) + len(body_lines) * int(32 * scale)
  519. if title_lines and body_lines:
  520. height += int(8 * scale)
  521. return int(width), int(height)
  522. def text_origin(size, block, anchor, scale):
  523. pad = int(42 * scale)
  524. width, height = size
  525. block_w, block_h = block
  526. if anchor == "top-left":
  527. return pad, pad
  528. if anchor == "top-right":
  529. return width - pad - block_w, pad
  530. if anchor == "bottom-left":
  531. return pad, height - pad - block_h
  532. if anchor == "bottom-right":
  533. return width - pad - block_w, height - pad - block_h
  534. return int((width - block_w) / 2), int((height - block_h) / 2)
  535. def align_x(origin_x, block_w, line_w, anchor):
  536. if anchor in ("top-right", "bottom-right"):
  537. return origin_x + block_w - line_w
  538. if anchor == "center":
  539. return origin_x + (block_w - line_w) / 2
  540. return origin_x
  541. def load_font(size, bold=False):
  542. path = r"C:\Windows\Fonts\msyhbd.ttc" if bold else r"C:\Windows\Fonts\msyh.ttc"
  543. if not os.path.isfile(path):
  544. path = r"C:\Windows\Fonts\msyh.ttc"
  545. if not os.path.isfile(path):
  546. raise RuntimeError("没有找到微软雅黑")
  547. return ImageFont.truetype(path, max(12, int(size)), index=0)
  548. def selling_text(job, anchor, notes):
  549. rows = ["来源:{}".format(job.get("selling_source") or ""), "文案位置:{}".format(ANCHOR_LABELS.get(anchor, anchor)), "", "条目"]
  550. points = job.get("selling_points") or []
  551. if not points:
  552. rows.append("(无)")
  553. for index, point in enumerate(points, start=1):
  554. tags = "、".join(point.get("tags") or [])
  555. rows.append("{}. {}".format(index, point.get("text") or ""))
  556. rows.append("依据:{}".format(point.get("evidence") or ""))
  557. if tags:
  558. rows.append("标签:{}".format(tags))
  559. rows.append("")
  560. rows.append("图片")
  561. for image in job.get("images") or []:
  562. status = {0: "等待", 1: "生成中", 2: "成功", 4: "本地", -1: "失败"}.get(int(image.get("status") or 0), str(image.get("status")))
  563. rows.append("{} {} {}".format(image.get("kind") or "", image.get("scene_name") or "", status))
  564. if image.get("lines"):
  565. rows.append("文案:" + " / ".join(image.get("lines") or []))
  566. if image.get("error_msg"):
  567. rows.append("说明:" + image.get("error_msg"))
  568. if notes:
  569. rows.append("")
  570. rows.append("进度")
  571. rows.extend(notes)
  572. return "\n".join(rows) + "\n"
  573. def safe_name(name):
  574. return re.sub(r'[\\/:*?"<>|]', "_", str(name))[:40]