@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.2
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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/asset_catalog.py +159 -0
- package/assets/base_template.pptx +0 -0
- package/assets/cover_deco.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/slide2_right.png +0 -0
- package/assets/theme_apricot.jpg +0 -0
- package/assets/theme_blush.jpg +0 -0
- package/assets/theme_coral.jpg +0 -0
- package/assets/theme_glow.jpg +0 -0
- package/assets/theme_mauve.jpg +0 -0
- package/assets/theme_peach.jpg +0 -0
- package/assets/theme_rose.jpg +0 -0
- package/assets/theme_sunset.jpg +0 -0
- package/assets/topright_deco.png +0 -0
- package/build_deck.py +2288 -0
- package/charts.py +329 -0
- package/check.py +236 -0
- package/config.json +5 -0
- package/contact_sheet.py +88 -0
- package/cordis.patch.yml +457 -0
- package/deck_baseline.py +335 -0
- package/delivery.py +143 -0
- package/density-recipe.md +110 -0
- package/example.json +26 -0
- package/formula.py +125 -0
- package/gen_image.py +395 -0
- package/generate_consensus.py +892 -0
- package/generate_ppt.py +1356 -0
- package/grasp_evidence.py +150 -0
- package/image_check.py +148 -0
- package/image_fetch.py +253 -0
- package/ingest.py +93 -0
- package/media.py +470 -0
- package/package.json +68 -4
- package/preset.yml +2 -0
- package/quality_check.py +196 -0
- package/render_ppt.py +91 -0
- package/requirements.txt +7 -0
- package/restyle.py +80 -0
- package/run_pptfast.sh +27 -0
- package/run_py.sh +43 -0
- package/slide_ops.py +160 -0
- package/slide_patch.py +153 -0
- package/speaker_notes.py +245 -0
- package/style-recipes.md +166 -0
- package/video_gif.py +164 -0
- 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)
|