@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/asset_catalog.py +159 -0
  4. package/assets/base_template.pptx +0 -0
  5. package/assets/cover_deco.png +0 -0
  6. package/assets/logo.png +0 -0
  7. package/assets/slide2_right.png +0 -0
  8. package/assets/theme_apricot.jpg +0 -0
  9. package/assets/theme_blush.jpg +0 -0
  10. package/assets/theme_coral.jpg +0 -0
  11. package/assets/theme_glow.jpg +0 -0
  12. package/assets/theme_mauve.jpg +0 -0
  13. package/assets/theme_peach.jpg +0 -0
  14. package/assets/theme_rose.jpg +0 -0
  15. package/assets/theme_sunset.jpg +0 -0
  16. package/assets/topright_deco.png +0 -0
  17. package/build_deck.py +2082 -0
  18. package/charts.py +329 -0
  19. package/check.py +236 -0
  20. package/config.json +5 -0
  21. package/contact_sheet.py +88 -0
  22. package/cordis.patch.yml +457 -0
  23. package/deck_baseline.py +335 -0
  24. package/delivery.py +143 -0
  25. package/density-recipe.md +110 -0
  26. package/example.json +26 -0
  27. package/formula.py +125 -0
  28. package/gen_image.py +395 -0
  29. package/generate_consensus.py +892 -0
  30. package/generate_ppt.py +1356 -0
  31. package/grasp_evidence.py +150 -0
  32. package/image_check.py +148 -0
  33. package/image_fetch.py +253 -0
  34. package/ingest.py +93 -0
  35. package/media.py +470 -0
  36. package/package.json +68 -4
  37. package/preset.yml +2 -0
  38. package/quality_check.py +196 -0
  39. package/render_ppt.py +91 -0
  40. package/requirements.txt +7 -0
  41. package/restyle.py +80 -0
  42. package/run_pptfast.sh +27 -0
  43. package/run_py.sh +43 -0
  44. package/slide_ops.py +160 -0
  45. package/slide_patch.py +153 -0
  46. package/speaker_notes.py +245 -0
  47. package/style-recipes.md +166 -0
  48. package/video_gif.py +164 -0
  49. package/vision_check.py +79 -0
package/formula.py ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """Typeset MathText formulas as native editable PowerPoint vector shapes."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from lxml import etree as ET
11
+ from matplotlib.mathtext import MathTextParser
12
+ from matplotlib.path import Path as MathPath
13
+ from pptx import Presentation
14
+ from pptx.dml.color import RGBColor
15
+ from pptx.util import Inches
16
+
17
+ A = "http://schemas.openxmlformats.org/drawingml/2006/main"
18
+
19
+
20
+ def element(parent, tag, **attrs):
21
+ return ET.SubElement(parent, f"{{{A}}}{tag}", **attrs)
22
+
23
+
24
+ def add_formula(slide, expression: str, left: int, top: int, width: int, height: int,
25
+ color: str = "202020", name: str = "Formula"):
26
+ """Fit one supported MathText expression to a box; emit native cubic curves."""
27
+ if width <= 0 or height <= 0:
28
+ raise ValueError("positive formula box required")
29
+ parsed = MathTextParser("path").parse(expression, dpi=72)
30
+ if not parsed.glyphs or parsed.width <= 0 or parsed.height <= 0:
31
+ raise ValueError("formula must contain typeset glyphs")
32
+ scale = min(width / parsed.width, height / parsed.height)
33
+ w, h = int(parsed.width * scale), int(parsed.height * scale)
34
+ shape = slide.shapes.build_freeform(0, 0).add_line_segments([(1, 0), (1, 1)], close=True).convert_to_shape()
35
+ shape.left, shape.top = left, top
36
+ shape.width, shape.height = w, h
37
+ shape.name = name
38
+ sppr = shape._element.spPr
39
+ geometry = sppr.find(f"{{{A}}}custGeom")
40
+ for item in list(geometry):
41
+ geometry.remove(item)
42
+ for tag in ("avLst", "gdLst", "ahLst", "cxnLst"):
43
+ element(geometry, tag)
44
+ element(geometry, "rect", l="0", t="0", r="r", b="b")
45
+ paths = element(geometry, "pathLst")
46
+ precision = 1000
47
+ path = element(paths, "path", w=str(round(parsed.width * precision)),
48
+ h=str(round(parsed.height * precision)), stroke="0")
49
+
50
+ def point(parent, x, y):
51
+ element(parent, "pt", x=str(round(x * precision)),
52
+ y=str(round((parsed.height - parsed.depth - y) * precision)))
53
+
54
+ for font, size, code, ox, oy in parsed.glyphs:
55
+ font.set_size(size, 72)
56
+ font.load_char(code)
57
+ vertices, codes = font.get_path()
58
+ current = None
59
+ index = 0
60
+ while index < len(codes):
61
+ code = codes[index]
62
+ vertex = vertices[index] + (ox, oy)
63
+ if code == MathPath.MOVETO:
64
+ point(element(path, "moveTo"), *vertex)
65
+ current = vertex
66
+ elif code == MathPath.LINETO:
67
+ point(element(path, "lnTo"), *vertex)
68
+ current = vertex
69
+ elif code == MathPath.CURVE3:
70
+ end = vertices[index + 1] + (ox, oy)
71
+ cubic = element(path, "cubicBezTo")
72
+ point(cubic, *(current + (vertex - current) * 2 / 3))
73
+ point(cubic, *(end + (vertex - end) * 2 / 3))
74
+ point(cubic, *end)
75
+ current = end
76
+ index += 1
77
+ elif code == MathPath.CURVE4:
78
+ cubic = element(path, "cubicBezTo")
79
+ for offset in range(3):
80
+ point(cubic, *(vertices[index + offset] + (ox, oy)))
81
+ current = vertices[index + 2] + (ox, oy)
82
+ index += 2
83
+ elif code == MathPath.CLOSEPOLY:
84
+ element(path, "close")
85
+ index += 1
86
+ for x, y, rw, rh in parsed.rects:
87
+ for tag, coordinates in (("moveTo", (x, y)), ("lnTo", (x + rw, y)),
88
+ ("lnTo", (x + rw, y + rh)), ("lnTo", (x, y + rh))):
89
+ point(element(path, tag), *coordinates)
90
+ element(path, "close")
91
+ shape.fill.solid()
92
+ shape.fill.fore_color.rgb = RGBColor.from_string(color)
93
+ shape.line.fill.background()
94
+ return shape
95
+
96
+
97
+ def main() -> int:
98
+ parser = argparse.ArgumentParser(description=__doc__)
99
+ parser.add_argument("pptx", type=Path)
100
+ parser.add_argument("spec", type=Path, help="JSON list: page, expression, box_inches, optional color/name")
101
+ parser.add_argument("output", type=Path)
102
+ args = parser.parse_args()
103
+ prs = Presentation(args.pptx)
104
+ specs = json.loads(args.spec.read_text(encoding="utf-8"))
105
+ for item in specs:
106
+ page = item["page"]
107
+ if not 1 <= page <= len(prs.slides):
108
+ raise ValueError("formula page is outside deck")
109
+ box = [Inches(value) for value in item["box_inches"]]
110
+ if len(box) != 4 or box[0] < 0 or box[1] < 0 or box[0] + box[2] > prs.slide_width or box[1] + box[3] > prs.slide_height:
111
+ raise ValueError("formula box must fit inside slide")
112
+ add_formula(prs.slides[page - 1], item["expression"], *box,
113
+ item.get("color", "202020"), item.get("name", "Formula"))
114
+ args.output.parent.mkdir(parents=True, exist_ok=True)
115
+ with tempfile.TemporaryDirectory(dir=args.output.parent, prefix=".formula-") as directory:
116
+ temporary = Path(directory) / "deck.pptx"
117
+ prs.save(temporary)
118
+ Presentation(temporary)
119
+ temporary.replace(args.output)
120
+ print(json.dumps({"formulas_added": len(specs), "native_vectors": True}))
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
package/gen_image.py ADDED
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """Generate deck imagery with an OpenAI-compatible Images API (gpt-image).
3
+
4
+ The PPT assistant owns its images: a hero/section picture is generated for the
5
+ deck's own topic instead of borrowing a stock photo that contradicts the copy.
6
+ The tool is a thin, dependency-free client (stdlib only) so it also runs before
7
+ ``requirements.txt`` is installed.
8
+
9
+ Credentials live outside the repository. Resolution order:
10
+
11
+ 1. ``--config PATH``
12
+ 2. ``$PPT_IMAGE_CONFIG``
13
+ 3. ``<engine-root>/image-config.json`` (git-ignored, per deployment)
14
+ 4. ``~/.config/ppt-assistant/image.json``
15
+ 5. the image-web ``config.json`` shipped beside the 经验库 tools
16
+ 6. environment: ``UUAPI_API_KEY`` / ``UUAPI_BASE_URL``
17
+
18
+ A config is ``{"api_base": "https://uuapi.io/v1", "api_key": "..."}``. The key is
19
+ never printed, copied into the deck, or written into the metadata sidecar.
20
+
21
+ Examples:
22
+ python3 gen_image.py --prompt-file hero.txt --out assets/hero.png \\
23
+ --size landscape --model gpt-image-2
24
+
25
+ python3 gen_image.py --prompt "..." --out assets/lead.png \\
26
+ --ref product.png --size 1024x1024
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import base64
32
+ import hashlib
33
+ import json
34
+ import mimetypes
35
+ import os
36
+ import sys
37
+ import time
38
+ import urllib.error
39
+ import urllib.request
40
+ import uuid
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ ENGINE = Path(__file__).resolve().parent
45
+ MAX_REFERENCE_BYTES = 20 * 1024 * 1024
46
+ SIZE_ALIASES = {
47
+ "landscape": "1536x1024",
48
+ "16:9": "1536x1024",
49
+ "wide": "1536x1024",
50
+ "square": "1024x1024",
51
+ "1:1": "1024x1024",
52
+ "portrait": "1024x1536",
53
+ "3:4": "1024x1536",
54
+ }
55
+ DEFAULT_MODELS = ("gpt-image-2", "gpt-image-2.5-flare")
56
+
57
+
58
+ class ImageError(RuntimeError):
59
+ pass
60
+
61
+
62
+ def install_proxy() -> str | None:
63
+ """Route this one process through a SOCKS proxy when asked.
64
+
65
+ The workspace can lose direct egress to the image host while an SSH tunnel
66
+ is available (``ssh -D``). PySocks is optional: when it is missing the
67
+ command keeps using the normal route instead of failing unexpectedly.
68
+ """
69
+ proxy = (os.environ.get("PPT_IMAGE_PROXY") or os.environ.get("ALL_PROXY") or "")
70
+ if not proxy.startswith("socks"):
71
+ return None
72
+ try:
73
+ import socket
74
+ import socks
75
+ from urllib.parse import urlsplit
76
+ except ImportError:
77
+ return None
78
+ parsed = urlsplit(proxy)
79
+ if not parsed.hostname:
80
+ return None
81
+ socks.set_default_proxy(socks.SOCKS5, parsed.hostname, parsed.port or 1080,
82
+ rdns=proxy.startswith("socks5h"))
83
+ socket.socket = socks.socksocket
84
+ return proxy
85
+
86
+
87
+ def config_candidates() -> list[Path]:
88
+ home = Path.home()
89
+ return [
90
+ ENGINE / "image-config.json",
91
+ home / ".config/ppt-assistant/image.json",
92
+ home / "Documents/Obsidian Vault/AI Handoff/AI经验库/项目书技术路线图"
93
+ "/技术路线图_审图模型重绘/工具/uuapi-image-web/config.json",
94
+ home / "Downloads/项目书技术路线图_绘图经验库_开箱包/技术路线图_审图模型重绘"
95
+ "/工具/uuapi-image-web/config.json",
96
+ ]
97
+
98
+
99
+ def load_config(explicit: str | None) -> tuple[str, str, Path | None]:
100
+ if explicit:
101
+ paths = [Path(explicit).expanduser()]
102
+ elif os.environ.get("PPT_IMAGE_CONFIG"):
103
+ paths = [Path(os.environ["PPT_IMAGE_CONFIG"]).expanduser()]
104
+ else:
105
+ paths = config_candidates()
106
+ for path in paths:
107
+ if path.is_file():
108
+ try:
109
+ data = json.loads(path.read_text(encoding="utf-8"))
110
+ except (OSError, json.JSONDecodeError) as error:
111
+ raise ImageError(f"图片配置无法读取: {path}: {error}") from error
112
+ base = str(data.get("api_base") or data.get("base_url") or "").strip()
113
+ key = str(data.get("api_key") or data.get("key") or "").strip()
114
+ if base and key:
115
+ return base.rstrip("/"), key, path
116
+ raise ImageError(f"图片配置缺少 api_base/api_key: {path}")
117
+ base = os.environ.get("UUAPI_BASE_URL", "").strip()
118
+ key = os.environ.get("UUAPI_API_KEY", "").strip()
119
+ if base and key:
120
+ return base.rstrip("/"), key, None
121
+ raise ImageError(
122
+ "未找到生图配置。请设置 PPT_IMAGE_CONFIG 指向含 api_base/api_key 的 JSON,"
123
+ "或写入 ~/.config/ppt-assistant/image.json,或导出 UUAPI_API_KEY。")
124
+
125
+
126
+ def ssl_context():
127
+ # The upstream is HTTPS; keep verification on. An explicit opt-out exists for
128
+ # the rare local TLS-inspection setup, matching the image-web tool.
129
+ if os.environ.get("PPT_IMAGE_INSECURE_TLS") == "1":
130
+ import ssl
131
+ return ssl._create_unverified_context()
132
+ return None
133
+
134
+
135
+ def _proxy_map():
136
+ proxy = (os.environ.get("PPT_IMAGE_PROXY") or os.environ.get("HTTPS_PROXY")
137
+ or os.environ.get("https_proxy"))
138
+ if proxy and proxy.startswith(("socks", "http")):
139
+ return {"http": proxy, "https": proxy}
140
+ return None
141
+
142
+
143
+ def api_call(url: str, key: str, body: bytes, content_type: str,
144
+ timeout: int) -> dict[str, Any]:
145
+ headers = {
146
+ "Authorization": f"Bearer {key}",
147
+ "Content-Type": content_type,
148
+ "User-Agent": "ppt-assistant-image/1.0",
149
+ }
150
+ try:
151
+ import requests
152
+ except ImportError:
153
+ requests = None
154
+ if requests is not None:
155
+ try:
156
+ response = requests.post(url, data=body, headers=headers,
157
+ timeout=timeout, proxies=_proxy_map())
158
+ except requests.RequestException as error:
159
+ raise ImageError(f"无法连接图片服务: {error}") from error
160
+ if response.status_code >= 400:
161
+ detail = response.text
162
+ try:
163
+ detail = response.json().get("error", detail)
164
+ except ValueError:
165
+ pass
166
+ raise ImageError(f"接口返回 {response.status_code}: {str(detail)[:600]}")
167
+ try:
168
+ return response.json()
169
+ except ValueError as error:
170
+ raise ImageError("接口返回的不是 JSON") from error
171
+
172
+ request = urllib.request.Request(url, data=body, method="POST", headers=headers)
173
+ try:
174
+ with urllib.request.urlopen(request, timeout=timeout, context=ssl_context()) as response:
175
+ return json.loads(response.read().decode("utf-8"))
176
+ except urllib.error.HTTPError as error:
177
+ detail = error.read().decode("utf-8", "replace")
178
+ try:
179
+ detail = json.loads(detail).get("error", detail)
180
+ except json.JSONDecodeError:
181
+ pass
182
+ raise ImageError(f"接口返回 {error.code}: {str(detail)[:600]}") from error
183
+ except urllib.error.URLError as error:
184
+ raise ImageError(f"无法连接图片服务: {error.reason}") from error
185
+ except TimeoutError as error:
186
+ raise ImageError(f"图片服务超时({timeout}s)") from error
187
+
188
+
189
+ def download(url: str, timeout: int = 120) -> bytes:
190
+ try:
191
+ import requests
192
+ except ImportError:
193
+ requests = None
194
+ if requests is not None:
195
+ try:
196
+ response = requests.get(url, timeout=timeout, proxies=_proxy_map(),
197
+ headers={"User-Agent": "ppt-assistant-image/1.0"})
198
+ except requests.RequestException as error:
199
+ raise ImageError(f"图片下载失败: {error}") from error
200
+ if response.status_code >= 400:
201
+ raise ImageError(f"图片下载失败: HTTP {response.status_code}")
202
+ return response.content
203
+ request = urllib.request.Request(url, headers={"User-Agent": "ppt-assistant-image/1.0"})
204
+ try:
205
+ with urllib.request.urlopen(request, timeout=timeout, context=ssl_context()) as response:
206
+ return response.read()
207
+ except (urllib.error.HTTPError, urllib.error.URLError) as error:
208
+ raise ImageError(f"图片下载失败: {error}") from error
209
+
210
+
211
+ def multipart(fields: dict[str, str], files: list[tuple[str, str, str, bytes]]) -> tuple[bytes, str]:
212
+ boundary = f"----ppt-image-{uuid.uuid4().hex}"
213
+ chunks: list[bytes] = []
214
+ for name, value in fields.items():
215
+ chunks.extend((
216
+ f"--{boundary}\r\n".encode(),
217
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
218
+ str(value).encode("utf-8"),
219
+ b"\r\n",
220
+ ))
221
+ for field, filename, mime_type, data in files:
222
+ chunks.extend((
223
+ f"--{boundary}\r\n".encode(),
224
+ f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'.encode(),
225
+ f"Content-Type: {mime_type}\r\n\r\n".encode(),
226
+ data,
227
+ b"\r\n",
228
+ ))
229
+ chunks.append(f"--{boundary}--\r\n".encode())
230
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
231
+
232
+
233
+ def read_reference(path: Path) -> tuple[str, str, bytes]:
234
+ if not path.is_file():
235
+ raise ImageError(f"参考图不存在: {path}")
236
+ data = path.read_bytes()
237
+ if not data or len(data) > MAX_REFERENCE_BYTES:
238
+ raise ImageError(f"参考图必须大于 0 且不超过 20 MB: {path}")
239
+ mime = mimetypes.guess_type(path.name)[0] or "image/png"
240
+ if not mime.startswith("image/"):
241
+ raise ImageError(f"参考图必须是图片: {path}")
242
+ return path.name, mime, data
243
+
244
+
245
+ def save_payload(payload: bytes, target: Path) -> str:
246
+ if not payload:
247
+ raise ImageError("接口返回了空图片")
248
+ if payload[:8] == b"\x89PNG\r\n\x1a\n":
249
+ suffix = ".png"
250
+ elif payload[:3] == b"\xff\xd8\xff":
251
+ suffix = ".jpg"
252
+ else:
253
+ suffix = target.suffix or ".png"
254
+ final = target if target.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"} else target.with_suffix(suffix)
255
+ final.parent.mkdir(parents=True, exist_ok=True)
256
+ final.write_bytes(payload)
257
+ return str(final)
258
+
259
+
260
+ def main() -> int:
261
+ parser = argparse.ArgumentParser(description=__doc__,
262
+ formatter_class=argparse.RawDescriptionHelpFormatter)
263
+ prompt_group = parser.add_mutually_exclusive_group(required=True)
264
+ prompt_group.add_argument("--prompt", help="prompt text")
265
+ prompt_group.add_argument("--prompt-file", help="read prompt text from a file")
266
+ parser.add_argument("--out", required=True, help="output image path")
267
+ parser.add_argument("--size", default="landscape",
268
+ help="landscape(1536x1024) | square(1024x1024) | portrait(1024x1536) | WxH")
269
+ parser.add_argument("--model", default=os.environ.get("PPT_IMAGE_MODEL", DEFAULT_MODELS[0]))
270
+ parser.add_argument("--ref", action="append", default=[], help="reference image (repeatable, max 4)")
271
+ parser.add_argument("--mask", help="optional mask image for edits")
272
+ parser.add_argument("--transparent", action="store_true",
273
+ help="request a transparent background (PNG cutout)")
274
+ parser.add_argument("--n", type=int, default=1, help="number of images (1-4)")
275
+ parser.add_argument("--timeout", type=int, default=480)
276
+ parser.add_argument("--retries", type=int, default=1)
277
+ parser.add_argument("--config", help="path to image config JSON")
278
+ parser.add_argument("--no-meta", action="store_true", help="skip the .json sidecar")
279
+ parser.add_argument("--dry-run", action="store_true", help="validate inputs and print the request only")
280
+ args = parser.parse_args()
281
+ install_proxy()
282
+
283
+ if args.prompt_file:
284
+ prompt = Path(args.prompt_file).expanduser().read_text(encoding="utf-8").strip()
285
+ else:
286
+ prompt = args.prompt.strip()
287
+ if not prompt:
288
+ raise ImageError("提示词不能为空")
289
+ if not 1 <= args.n <= 4:
290
+ raise ImageError("--n 必须在 1–4 之间")
291
+ if len(args.ref) > 4:
292
+ raise ImageError("参考图最多 4 张")
293
+
294
+ size = SIZE_ALIASES.get(args.size.lower(), args.size)
295
+ if size not in {"1024x1024", "1024x1536", "1536x1024"}:
296
+ raise ImageError(f"当前账户支持 1024x1024 / 1024x1536 / 1536x1024,收到 {args.size}")
297
+
298
+ base, key, config_path = load_config(args.config)
299
+ mode = "edit" if args.ref else "generate"
300
+ if args.mask and mode != "edit":
301
+ raise ImageError("--mask 只能与 --ref 一起使用")
302
+
303
+ if args.dry_run:
304
+ print(json.dumps({
305
+ "mode": mode, "model": args.model, "size": size, "n": args.n,
306
+ "config": str(config_path) if config_path else None,
307
+ "endpoint": f"{base}/images/{'edits' if mode == 'edit' else 'generations'}",
308
+ "prompt_chars": len(prompt), "references": [str(Path(r).expanduser()) for r in args.ref],
309
+ "out": str(Path(args.out).expanduser()),
310
+ }, ensure_ascii=False, indent=2))
311
+ return 0
312
+
313
+ started = time.time()
314
+ last_error: ImageError | None = None
315
+ for attempt in range(args.retries + 1):
316
+ try:
317
+ if mode == "edit":
318
+ files = [("image", *read_reference(Path(path).expanduser())) for path in args.ref]
319
+ if args.mask:
320
+ files.append(("mask", *read_reference(Path(args.mask).expanduser())))
321
+ fields = {"model": args.model, "prompt": prompt, "size": size,
322
+ "n": str(args.n)}
323
+ if args.transparent:
324
+ fields.update({"background": "transparent", "output_format": "png"})
325
+ body, content_type = multipart(fields, files)
326
+ result = api_call(f"{base}/images/edits", key, body, content_type, args.timeout)
327
+ else:
328
+ payload = {"model": args.model, "prompt": prompt, "size": size,
329
+ "n": args.n, "response_format": "url"}
330
+ if args.transparent:
331
+ payload.update({"background": "transparent", "output_format": "png"})
332
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
333
+ result = api_call(f"{base}/images/generations", key, body,
334
+ "application/json", args.timeout)
335
+ break
336
+ except ImageError as error:
337
+ last_error = error
338
+ if attempt < args.retries:
339
+ time.sleep(3 + attempt * 4)
340
+ continue
341
+ raise
342
+ else: # pragma: no cover - the loop always breaks or raises
343
+ raise last_error or ImageError("图片生成失败")
344
+
345
+ items = result.get("data") or []
346
+ if not items:
347
+ raise ImageError(f"接口未返回图片: {json.dumps(result, ensure_ascii=False)[:400]}")
348
+
349
+ target = Path(args.out).expanduser()
350
+ saved: list[str] = []
351
+ for index, item in enumerate(items):
352
+ suffix = "" if len(items) == 1 else f"-{index + 1}"
353
+ item_target = target.with_name(f"{target.stem}{suffix}{target.suffix or '.png'}")
354
+ if item.get("url"):
355
+ saved.append(save_payload(download(item["url"]), item_target))
356
+ elif item.get("b64_json"):
357
+ try:
358
+ payload = base64.b64decode(item["b64_json"], validate=True)
359
+ except (ValueError, TypeError) as error:
360
+ raise ImageError("接口返回的 base64 图片无效") from error
361
+ saved.append(save_payload(payload, item_target))
362
+ else:
363
+ raise ImageError("接口返回项既没有 url 也没有 b64_json")
364
+
365
+ elapsed = round(time.time() - started, 1)
366
+ summary = {
367
+ "ok": True,
368
+ "mode": mode,
369
+ "model": result.get("model") or args.model,
370
+ "requested_model": args.model,
371
+ "size": size,
372
+ "files": saved,
373
+ "seconds": elapsed,
374
+ "prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:16],
375
+ "prompt_chars": len(prompt),
376
+ "transparent": bool(args.transparent),
377
+ }
378
+ if not args.no_meta:
379
+ meta_path = Path(saved[0]).with_suffix(".json")
380
+ meta_path.write_text(json.dumps({
381
+ **summary,
382
+ "prompt": prompt,
383
+ "references": [str(Path(r).expanduser()) for r in args.ref],
384
+ }, ensure_ascii=False, indent=2), encoding="utf-8")
385
+ summary["meta"] = str(meta_path)
386
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
387
+ return 0
388
+
389
+
390
+ if __name__ == "__main__":
391
+ try:
392
+ raise SystemExit(main())
393
+ except ImageError as error:
394
+ print(f"gen_image: {error}", file=sys.stderr)
395
+ raise SystemExit(2)