dsh-courseware 0.1.0
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 +109 -0
- package/cordis.patch.yml +9 -0
- package/engine/pptxgen/__init__.py +8 -0
- package/engine/pptxgen/__main__.py +4 -0
- package/engine/pptxgen/builtin_template.py +218 -0
- package/engine/pptxgen/cli.py +575 -0
- package/engine/pptxgen/examples.py +290 -0
- package/engine/pptxgen/imagegen.py +564 -0
- package/engine/pptxgen/layoutmap.py +211 -0
- package/engine/pptxgen/patterns.py +106 -0
- package/engine/pptxgen/preview.py +126 -0
- package/engine/pptxgen/profile.py +456 -0
- package/engine/pptxgen/render.py +1673 -0
- package/engine/pptxgen/review.py +184 -0
- package/engine/pptxgen/schemacheck.py +122 -0
- package/engine/pptxgen/schemas/XAdES.xsd +466 -0
- package/engine/pptxgen/schemas/XAdESv141.xsd +15 -0
- package/engine/pptxgen/schemas/chartEx.xsd +838 -0
- package/engine/pptxgen/schemas/dml-chart.xsd +1499 -0
- package/engine/pptxgen/schemas/dml-chartDrawing.xsd +146 -0
- package/engine/pptxgen/schemas/dml-diagram.xsd +1085 -0
- package/engine/pptxgen/schemas/dml-drawing.xsd +63 -0
- package/engine/pptxgen/schemas/dml-lockedCanvas.xsd +11 -0
- package/engine/pptxgen/schemas/dml-main.xsd +3081 -0
- package/engine/pptxgen/schemas/dml-picture.xsd +23 -0
- package/engine/pptxgen/schemas/dml-spreadsheetDrawing.xsd +185 -0
- package/engine/pptxgen/schemas/dml-wordprocessingDrawing.xsd +287 -0
- package/engine/pptxgen/schemas/drawing-chart2012.xsd +129 -0
- package/engine/pptxgen/schemas/markup-compatibility.xsd +95 -0
- package/engine/pptxgen/schemas/opc-digSig.xsd +49 -0
- package/engine/pptxgen/schemas/opc-relationships.xsd +33 -0
- package/engine/pptxgen/schemas/pml.xsd +1676 -0
- package/engine/pptxgen/schemas/shared-additionalCharacteristics.xsd +28 -0
- package/engine/pptxgen/schemas/shared-bibliography.xsd +144 -0
- package/engine/pptxgen/schemas/shared-commonSimpleTypes.xsd +172 -0
- package/engine/pptxgen/schemas/shared-customXmlDataProperties.xsd +25 -0
- package/engine/pptxgen/schemas/shared-customXmlSchemaProperties.xsd +18 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesCustom.xsd +59 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesExtended.xsd +56 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/engine/pptxgen/schemas/shared-math.xsd +582 -0
- package/engine/pptxgen/schemas/shared-relationshipReference.xsd +25 -0
- package/engine/pptxgen/schemas/signatureInfo.xsd +103 -0
- package/engine/pptxgen/schemas/sml.xsd +4439 -0
- package/engine/pptxgen/schemas/visio.xsd +829 -0
- package/engine/pptxgen/schemas/vml-main.xsd +570 -0
- package/engine/pptxgen/schemas/vml-officeDrawing.xsd +509 -0
- package/engine/pptxgen/schemas/vml-presentationDrawing.xsd +12 -0
- package/engine/pptxgen/schemas/vml-spreadsheetDrawing.xsd +108 -0
- package/engine/pptxgen/schemas/vml-wordprocessingDrawing.xsd +96 -0
- package/engine/pptxgen/schemas/vmlDrawing.xsd +36 -0
- package/engine/pptxgen/schemas/wml.xsd +3643 -0
- package/engine/pptxgen/schemas/word12.xsd +66 -0
- package/engine/pptxgen/schemas/xlThreadedComments.xsd +59 -0
- package/engine/pptxgen/schemas/xlThreadedComments2.xsd +22 -0
- package/engine/pptxgen/schemas/xmldsig-core-schema.xsd +318 -0
- package/engine/pptxgen/shapes.py +417 -0
- package/engine/pptxgen/spec.py +529 -0
- package/engine/pptxgen/template.py +834 -0
- package/engine/pptxgen/util.py +218 -0
- package/engine/pptxgen/xmlutil.py +298 -0
- package/engine/pptxgen.sh +7 -0
- package/engine/requirements.txt +9 -0
- package/lib/index.js +186 -0
- package/package.json +34 -0
- package/skills/courseware/SKILL.md +130 -0
- package/skills/courseware/references/content-polish.md +121 -0
- package/skills/courseware/references/deck-spec.md +155 -0
- package/skills/courseware/references/design-review.md +121 -0
- package/skills/courseware/references/image-backends.md +94 -0
- package/skills/courseware/references/quality-gates.md +71 -0
- package/skills/courseware/references/roadmap.md +55 -0
- package/skills/courseware/references/template-fidelity.md +100 -0
- package/skills/courseware/workflows/generate.md +172 -0
- package/skills/courseware/workflows/quick.md +41 -0
- package/skills/courseware/workflows/revise.md +40 -0
- package/skills/courseware/workflows/routing.md +49 -0
- package/skills/courseware/workflows/stages/generate-images.md +74 -0
- package/skills/courseware/workflows/template-intake.md +112 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""配图生成:可插拔的图像模型后端 + 按 prompt 哈希缓存。
|
|
2
|
+
|
|
3
|
+
设计要点
|
|
4
|
+
- 不绑定任何一家:`openai-compatible` 适配任意 OpenAI 协议的中转,另附常见国内/国外后端。
|
|
5
|
+
- 按 (后端, 模型, 尺寸, prompt, 风格) 的 sha1 缓存到磁盘,重跑不重复计费。
|
|
6
|
+
- 只用标准库发 HTTP,不引入新依赖。
|
|
7
|
+
- 没有 key 时绝不发请求,而是明确告诉用户该配什么。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import ssl
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
DEFAULT_STYLE = (
|
|
25
|
+
"儿童科普插画风格,扁平化矢量插画,明亮清爽的配色,构图简洁,主体突出,"
|
|
26
|
+
"背景干净,画面中不要出现任何文字、字母或数字"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
CONFIG_PATHS = [
|
|
30
|
+
os.path.join(os.getcwd(), ".pptxgen.yaml"),
|
|
31
|
+
os.path.expanduser("~/.pptxgen/config.yaml"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --------------------------------------------------------------------------
|
|
36
|
+
# 后端定义
|
|
37
|
+
# --------------------------------------------------------------------------
|
|
38
|
+
@dataclass
|
|
39
|
+
class BackendSpec:
|
|
40
|
+
name: str
|
|
41
|
+
label: str
|
|
42
|
+
kind: str # openai | gemini | dashscope | ark
|
|
43
|
+
base_url: str
|
|
44
|
+
default_model: str
|
|
45
|
+
default_size: str
|
|
46
|
+
key_envs: List[str]
|
|
47
|
+
supports_size: bool = True
|
|
48
|
+
alias_of: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
BACKENDS: Dict[str, BackendSpec] = {
|
|
52
|
+
"openai-compatible": BackendSpec(
|
|
53
|
+
"openai-compatible", "任意 OpenAI 兼容中转", "openai",
|
|
54
|
+
"", "", "1024x1024",
|
|
55
|
+
["PPTXGEN_IMAGE_API_KEY", "IMAGE_API_KEY", "OPENAI_API_KEY"],
|
|
56
|
+
),
|
|
57
|
+
"openai": BackendSpec(
|
|
58
|
+
"openai", "OpenAI", "openai",
|
|
59
|
+
"https://api.openai.com/v1", "gpt-image-1", "1536x1024",
|
|
60
|
+
["OPENAI_API_KEY"],
|
|
61
|
+
),
|
|
62
|
+
"qwen": BackendSpec(
|
|
63
|
+
"qwen", "阿里通义千问 / 百炼", "dashscope",
|
|
64
|
+
"https://dashscope.aliyuncs.com/api/v1", "qwen-image-2.0-pro", "1328*1328",
|
|
65
|
+
["QWEN_API_KEY", "DASHSCOPE_API_KEY"], supports_size=True,
|
|
66
|
+
),
|
|
67
|
+
"zhipu": BackendSpec(
|
|
68
|
+
"zhipu", "智谱 GLM", "openai",
|
|
69
|
+
"https://open.bigmodel.cn/api/paas/v4", "glm-image", "1024x1024",
|
|
70
|
+
["ZHIPU_API_KEY", "BIGMODEL_API_KEY"],
|
|
71
|
+
),
|
|
72
|
+
"volcengine": BackendSpec(
|
|
73
|
+
"volcengine", "火山引擎豆包 Seedream", "openai",
|
|
74
|
+
"https://ark.cn-beijing.volces.com/api/v3", "doubao-seedream-4-5-251128", "1024x1024",
|
|
75
|
+
["ARK_API_KEY", "VOLCENGINE_API_KEY", "LAS_API_KEY"],
|
|
76
|
+
),
|
|
77
|
+
"siliconflow": BackendSpec(
|
|
78
|
+
"siliconflow", "硅基流动", "openai",
|
|
79
|
+
"https://api.siliconflow.cn/v1", "Qwen/Qwen-Image", "1024x1024",
|
|
80
|
+
["SILICONFLOW_API_KEY"],
|
|
81
|
+
),
|
|
82
|
+
"modelscope": BackendSpec(
|
|
83
|
+
"modelscope", "ModelScope 魔搭", "openai",
|
|
84
|
+
"https://api-inference.modelscope.cn/v1", "Qwen/Qwen-Image", "1024x1024",
|
|
85
|
+
["MODELSCOPE_API_KEY"],
|
|
86
|
+
),
|
|
87
|
+
"gemini": BackendSpec(
|
|
88
|
+
"gemini", "Google Gemini", "gemini",
|
|
89
|
+
"https://generativelanguage.googleapis.com/v1beta", "gemini-3.1-flash-image",
|
|
90
|
+
"1024x1024", ["GEMINI_API_KEY", "GOOGLE_API_KEY"], supports_size=False,
|
|
91
|
+
),
|
|
92
|
+
"openrouter": BackendSpec(
|
|
93
|
+
"openrouter", "OpenRouter", "openai",
|
|
94
|
+
"https://openrouter.ai/api/v1", "google/gemini-3.1-flash-image", "1024x1024",
|
|
95
|
+
["OPENROUTER_API_KEY"],
|
|
96
|
+
),
|
|
97
|
+
"mock": BackendSpec(
|
|
98
|
+
"mock", "本地占位图(不联网,仅用于预览排版)", "mock",
|
|
99
|
+
"local", "placeholder", "1024x1024", [],
|
|
100
|
+
),
|
|
101
|
+
"minimax": BackendSpec(
|
|
102
|
+
"minimax", "MiniMax", "openai",
|
|
103
|
+
"https://api.minimax.chat/v1", "image-01", "1024x1024",
|
|
104
|
+
["MINIMAX_API_KEY"],
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ImageGenError(Exception):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
PLACEHOLDER_HINTS = ("your-relay", "example.com", "your-image-model", "xxxxxxxx",
|
|
114
|
+
"your-model", "sk-xxxx", "your-key")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_placeholder(value) -> bool:
|
|
118
|
+
"""判断一个配置值是不是模板里的占位符。"""
|
|
119
|
+
if value is None:
|
|
120
|
+
return True
|
|
121
|
+
if not isinstance(value, str):
|
|
122
|
+
return False
|
|
123
|
+
low = value.strip().lower()
|
|
124
|
+
if not low:
|
|
125
|
+
return True
|
|
126
|
+
return any(h in low for h in PLACEHOLDER_HINTS)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------
|
|
130
|
+
# 配置
|
|
131
|
+
# --------------------------------------------------------------------------
|
|
132
|
+
@dataclass
|
|
133
|
+
class ImageConfig:
|
|
134
|
+
backend: str = "openai-compatible"
|
|
135
|
+
api_key: Optional[str] = None
|
|
136
|
+
base_url: Optional[str] = None
|
|
137
|
+
model: Optional[str] = None
|
|
138
|
+
size: Optional[str] = None
|
|
139
|
+
style: str = DEFAULT_STYLE
|
|
140
|
+
cache_dir: str = "out/images"
|
|
141
|
+
timeout: int = 180
|
|
142
|
+
retries: int = 2
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def spec(self) -> BackendSpec:
|
|
146
|
+
return BACKENDS.get(self.backend) or BACKENDS["openai-compatible"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_config(deck_options: Optional[dict] = None) -> ImageConfig:
|
|
150
|
+
"""环境变量 > 课件 options.images > 配置文件。"""
|
|
151
|
+
# 按顺序合并(先出现的优先),但**占位符不算已定义** ——
|
|
152
|
+
# 否则工作区里的模板文件会遮蔽用户级配置里的真实值。
|
|
153
|
+
file_cfg: dict = {}
|
|
154
|
+
config_sources: list = []
|
|
155
|
+
for path in CONFIG_PATHS:
|
|
156
|
+
if not os.path.exists(path):
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
import yaml
|
|
160
|
+
|
|
161
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
162
|
+
data = yaml.safe_load(f) or {}
|
|
163
|
+
one = (data.get("images") or data) if isinstance(data, dict) else {}
|
|
164
|
+
if not isinstance(one, dict):
|
|
165
|
+
continue
|
|
166
|
+
used = False
|
|
167
|
+
for k, v in one.items():
|
|
168
|
+
if _is_placeholder(v):
|
|
169
|
+
continue
|
|
170
|
+
if k not in file_cfg:
|
|
171
|
+
file_cfg[k] = v
|
|
172
|
+
used = True
|
|
173
|
+
config_sources.append(path if used else f"{path}(全是占位符,已忽略)")
|
|
174
|
+
except Exception:
|
|
175
|
+
continue
|
|
176
|
+
load_config.sources = config_sources # type: ignore[attr-defined]
|
|
177
|
+
load_config.source = config_sources[0] if config_sources else None # type: ignore[attr-defined]
|
|
178
|
+
|
|
179
|
+
def pick(key: str, env: List[str], default=None):
|
|
180
|
+
for e in env:
|
|
181
|
+
v = os.environ.get(e)
|
|
182
|
+
if v:
|
|
183
|
+
return v
|
|
184
|
+
v = (deck_options or {}).get(key)
|
|
185
|
+
if v:
|
|
186
|
+
return v
|
|
187
|
+
return file_cfg.get(key, default)
|
|
188
|
+
|
|
189
|
+
cfg = ImageConfig()
|
|
190
|
+
cfg.backend = pick("backend", ["PPTXGEN_IMAGE_BACKEND"], cfg.backend) or cfg.backend
|
|
191
|
+
cfg.style = pick("style", ["PPTXGEN_IMAGE_STYLE"], cfg.style) or cfg.style
|
|
192
|
+
cfg.cache_dir = pick("cache_dir", ["PPTXGEN_IMAGE_CACHE"], cfg.cache_dir) or cfg.cache_dir
|
|
193
|
+
spec = cfg.spec
|
|
194
|
+
|
|
195
|
+
# mock 是本地占位图后端,不需要任何 base_url / model / key
|
|
196
|
+
if spec.kind == "mock":
|
|
197
|
+
cfg.base_url = config_base = spec.base_url
|
|
198
|
+
cfg.model = spec.default_model
|
|
199
|
+
cfg.size = cfg.size or spec.default_size
|
|
200
|
+
return cfg
|
|
201
|
+
|
|
202
|
+
cfg.api_key = pick("api_key", ["PPTXGEN_IMAGE_API_KEY"] + spec.key_envs)
|
|
203
|
+
cfg.base_url = pick("base_url", ["PPTXGEN_IMAGE_BASE_URL"], spec.base_url)
|
|
204
|
+
cfg.model = pick("model", ["PPTXGEN_IMAGE_MODEL"], spec.default_model)
|
|
205
|
+
cfg.size = pick("size", ["PPTXGEN_IMAGE_SIZE"], spec.default_size)
|
|
206
|
+
|
|
207
|
+
# 兜底:任何来源的占位符都不能当成真配置
|
|
208
|
+
for fieldname in ("base_url", "model", "api_key"):
|
|
209
|
+
if _is_placeholder(getattr(cfg, fieldname)):
|
|
210
|
+
setattr(cfg, fieldname, None)
|
|
211
|
+
if cfg.base_url is None and cfg.model is None:
|
|
212
|
+
srcs = getattr(load_config, "sources", None) or []
|
|
213
|
+
if srcs:
|
|
214
|
+
where = " 检查过的配置文件:\n" + "".join(f" - {x}\n" for x in srcs)
|
|
215
|
+
else:
|
|
216
|
+
where = (" 还没有配置文件。建议创建全局配置(在任何项目下都生效):\n"
|
|
217
|
+
f" mkdir -p {os.path.dirname(CONFIG_PATHS[1])}\n"
|
|
218
|
+
f" cp <引擎目录>/.pptxgen.yaml {CONFIG_PATHS[1]}\n")
|
|
219
|
+
raise ImageGenError(
|
|
220
|
+
"还没有配置图像模型。\n"
|
|
221
|
+
+ where
|
|
222
|
+
+ " 需要填的三项:base_url / model / api_key\n"
|
|
223
|
+
+ " 也可以用环境变量:PPTXGEN_IMAGE_BASE_URL / PPTXGEN_IMAGE_MODEL / PPTXGEN_IMAGE_API_KEY\n"
|
|
224
|
+
+ " 想先看版式效果可以加 --backend mock(本地占位图,不联网不花钱)。"
|
|
225
|
+
)
|
|
226
|
+
if not cfg.base_url:
|
|
227
|
+
raise ImageGenError(
|
|
228
|
+
f"后端 {cfg.backend} 没有配置 base_url。\n"
|
|
229
|
+
" 请设置 PPTXGEN_IMAGE_BASE_URL,或在 .pptxgen.yaml 里写 images.base_url"
|
|
230
|
+
)
|
|
231
|
+
if not cfg.model:
|
|
232
|
+
raise ImageGenError(
|
|
233
|
+
f"后端 {cfg.backend} 没有配置模型名。\n"
|
|
234
|
+
" 请设置 PPTXGEN_IMAGE_MODEL,或在 .pptxgen.yaml 里写 images.model"
|
|
235
|
+
)
|
|
236
|
+
return cfg
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def write_config_template(path: str, backend: str = "openai-compatible") -> str:
|
|
240
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
|
|
241
|
+
content = f"""\
|
|
242
|
+
# pptxgen 配图生成配置
|
|
243
|
+
# 环境变量优先级高于本文件;也可以把本文件放在 ~/.pptxgen/config.yaml
|
|
244
|
+
images:
|
|
245
|
+
backend: {backend}
|
|
246
|
+
base_url: https://your-relay.example.com/v1
|
|
247
|
+
model: your-image-model-name
|
|
248
|
+
api_key: sk-xxxxxxxx
|
|
249
|
+
size: "1024x1024"
|
|
250
|
+
cache_dir: out/images
|
|
251
|
+
style: >
|
|
252
|
+
{DEFAULT_STYLE}
|
|
253
|
+
"""
|
|
254
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
255
|
+
f.write(content)
|
|
256
|
+
return path
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --------------------------------------------------------------------------
|
|
260
|
+
# 缓存
|
|
261
|
+
# --------------------------------------------------------------------------
|
|
262
|
+
def cache_key(prompt: str, style: str, model: str, size: str, backend: str) -> str:
|
|
263
|
+
raw = "\u0000".join([backend, model, size, style, prompt])
|
|
264
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cache_path(cache_dir: str, key: str) -> str:
|
|
268
|
+
return os.path.join(cache_dir, f"{key}.png")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# --------------------------------------------------------------------------
|
|
272
|
+
# 生成器
|
|
273
|
+
# --------------------------------------------------------------------------
|
|
274
|
+
class ImageGenerator:
|
|
275
|
+
def __init__(self, config: ImageConfig, *, verbose: bool = True):
|
|
276
|
+
self.cfg = config
|
|
277
|
+
self.verbose = verbose
|
|
278
|
+
|
|
279
|
+
# ---- 对外接口 ----------------------------------------------------
|
|
280
|
+
def key_for(self, prompt: str) -> str:
|
|
281
|
+
return cache_key(prompt, self.cfg.style, self.cfg.model or "", self.cfg.size or "",
|
|
282
|
+
self.cfg.backend)
|
|
283
|
+
|
|
284
|
+
def lookup(self, prompt: str) -> Optional[str]:
|
|
285
|
+
p = cache_path(self.cfg.cache_dir, self.key_for(prompt))
|
|
286
|
+
return p if os.path.exists(p) and os.path.getsize(p) > 0 else None
|
|
287
|
+
|
|
288
|
+
def generate(self, prompt: str, *, force: bool = False) -> Tuple[str, bool]:
|
|
289
|
+
"""返回 (图片路径, 是否命中缓存)。"""
|
|
290
|
+
key = self.key_for(prompt)
|
|
291
|
+
out = cache_path(self.cfg.cache_dir, key)
|
|
292
|
+
if not force and os.path.exists(out) and os.path.getsize(out) > 0:
|
|
293
|
+
return out, True
|
|
294
|
+
if self.cfg.spec.kind == "mock":
|
|
295
|
+
os.makedirs(self.cfg.cache_dir, exist_ok=True)
|
|
296
|
+
blob = _mock_image(prompt, self.cfg.size or "1024x1024")
|
|
297
|
+
tmp = out + ".part"
|
|
298
|
+
with open(tmp, "wb") as f:
|
|
299
|
+
f.write(blob)
|
|
300
|
+
os.replace(tmp, out)
|
|
301
|
+
return out, False
|
|
302
|
+
if not self.cfg.api_key:
|
|
303
|
+
raise ImageGenError(
|
|
304
|
+
f"后端 {self.cfg.backend} 缺少 API key。\n"
|
|
305
|
+
f" 请设置 {' 或 '.join(self.cfg.spec.key_envs[:2])},"
|
|
306
|
+
"或运行 `pptxgen images --init-config` 生成配置文件。"
|
|
307
|
+
)
|
|
308
|
+
os.makedirs(self.cfg.cache_dir, exist_ok=True)
|
|
309
|
+
full = self._compose(prompt)
|
|
310
|
+
blob = self._request(full, prompt)
|
|
311
|
+
tmp = out + ".part"
|
|
312
|
+
with open(tmp, "wb") as f:
|
|
313
|
+
f.write(blob)
|
|
314
|
+
os.replace(tmp, out)
|
|
315
|
+
return out, False
|
|
316
|
+
|
|
317
|
+
def _compose(self, prompt: str) -> str:
|
|
318
|
+
style = (self.cfg.style or "").strip()
|
|
319
|
+
return f"{prompt.strip()}。{style}" if style else prompt.strip()
|
|
320
|
+
|
|
321
|
+
# ---- HTTP --------------------------------------------------------
|
|
322
|
+
def _request(self, full_prompt: str, raw_prompt: str) -> bytes:
|
|
323
|
+
kind = self.cfg.spec.kind
|
|
324
|
+
last: Optional[Exception] = None
|
|
325
|
+
for attempt in range(self.cfg.retries + 1):
|
|
326
|
+
try:
|
|
327
|
+
if kind == "gemini":
|
|
328
|
+
return self._gemini(full_prompt)
|
|
329
|
+
if kind == "dashscope":
|
|
330
|
+
return self._dashscope(full_prompt)
|
|
331
|
+
return self._openai(full_prompt)
|
|
332
|
+
except Exception as exc: # 网络抖动可重试
|
|
333
|
+
last = exc
|
|
334
|
+
if attempt < self.cfg.retries:
|
|
335
|
+
time.sleep(2 * (attempt + 1))
|
|
336
|
+
continue
|
|
337
|
+
raise ImageGenError(f"生成失败:{last}")
|
|
338
|
+
|
|
339
|
+
def _openai(self, prompt: str) -> bytes:
|
|
340
|
+
url = self.cfg.base_url.rstrip("/") + "/images/generations"
|
|
341
|
+
body = {"model": self.cfg.model, "prompt": prompt, "n": 1}
|
|
342
|
+
if self.cfg.size:
|
|
343
|
+
body["size"] = self.cfg.size
|
|
344
|
+
data = json.dumps(body).encode("utf-8")
|
|
345
|
+
req = urllib.request.Request(url, data=data, method="POST")
|
|
346
|
+
req.add_header("Content-Type", "application/json")
|
|
347
|
+
req.add_header("Authorization", f"Bearer {self.cfg.api_key}")
|
|
348
|
+
payload = _post_json(req, self.cfg.timeout)
|
|
349
|
+
return _extract_image(payload, self.cfg.timeout)
|
|
350
|
+
|
|
351
|
+
def _gemini(self, prompt: str) -> bytes:
|
|
352
|
+
url = (f"{self.cfg.base_url.rstrip('/')}/models/{self.cfg.model}:generateContent"
|
|
353
|
+
f"?key={self.cfg.api_key}")
|
|
354
|
+
body = {"contents": [{"parts": [{"text": prompt}]}]}
|
|
355
|
+
req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), method="POST")
|
|
356
|
+
req.add_header("Content-Type", "application/json")
|
|
357
|
+
payload = _post_json(req, self.cfg.timeout)
|
|
358
|
+
for cand in payload.get("candidates", []):
|
|
359
|
+
for part in cand.get("content", {}).get("parts", []):
|
|
360
|
+
inline = part.get("inlineData") or part.get("inline_data")
|
|
361
|
+
if inline and inline.get("data"):
|
|
362
|
+
return base64.b64decode(inline["data"])
|
|
363
|
+
raise ImageGenError(f"Gemini 返回里没有图片:{json.dumps(payload)[:300]}")
|
|
364
|
+
|
|
365
|
+
def _dashscope(self, prompt: str) -> bytes:
|
|
366
|
+
"""百炼/通义万相:异步提交 + 轮询。"""
|
|
367
|
+
url = self.cfg.base_url.rstrip("/") + "/services/aigc/text2image/image-synthesis"
|
|
368
|
+
body = {
|
|
369
|
+
"model": self.cfg.model,
|
|
370
|
+
"input": {"prompt": prompt},
|
|
371
|
+
"parameters": {"n": 1, "size": (self.cfg.size or "1024*1024").replace("x", "*")},
|
|
372
|
+
}
|
|
373
|
+
req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), method="POST")
|
|
374
|
+
req.add_header("Content-Type", "application/json")
|
|
375
|
+
req.add_header("Authorization", f"Bearer {self.cfg.api_key}")
|
|
376
|
+
req.add_header("X-DashScope-Async", "enable")
|
|
377
|
+
payload = _post_json(req, self.cfg.timeout)
|
|
378
|
+
task = (payload.get("output") or {}).get("task_id")
|
|
379
|
+
if not task:
|
|
380
|
+
raise ImageGenError(f"DashScope 未返回 task_id:{json.dumps(payload)[:300]}")
|
|
381
|
+
deadline = time.time() + self.cfg.timeout
|
|
382
|
+
while time.time() < deadline:
|
|
383
|
+
time.sleep(3)
|
|
384
|
+
q = urllib.request.Request(
|
|
385
|
+
f"{self.cfg.base_url.rstrip('/')}/tasks/{task}")
|
|
386
|
+
q.add_header("Authorization", f"Bearer {self.cfg.api_key}")
|
|
387
|
+
st = _post_json(q, self.cfg.timeout, method="GET")
|
|
388
|
+
out = st.get("output") or {}
|
|
389
|
+
if out.get("task_status") == "SUCCEEDED":
|
|
390
|
+
results = out.get("results") or []
|
|
391
|
+
if results and results[0].get("url"):
|
|
392
|
+
return _download(results[0]["url"], self.cfg.timeout)
|
|
393
|
+
if results and results[0].get("b64_image"):
|
|
394
|
+
return base64.b64decode(results[0]["b64_image"])
|
|
395
|
+
raise ImageGenError("DashScope 成功但没返回图片")
|
|
396
|
+
if out.get("task_status") == "FAILED":
|
|
397
|
+
raise ImageGenError(f"DashScope 任务失败:{out.get('message')}")
|
|
398
|
+
raise ImageGenError("DashScope 轮询超时")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _mock_image(prompt: str, size: str) -> bytes:
|
|
402
|
+
"""生成一张本地占位图:柔和渐变 + 简单几何图形,用来预览版面效果。"""
|
|
403
|
+
import io
|
|
404
|
+
import random
|
|
405
|
+
|
|
406
|
+
from PIL import Image, ImageDraw
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
w, h = (int(x) for x in size.lower().replace("*", "x").split("x"))
|
|
410
|
+
except Exception:
|
|
411
|
+
w, h = 1024, 1024
|
|
412
|
+
seed = int(hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:8], 16)
|
|
413
|
+
rnd = random.Random(seed)
|
|
414
|
+
hue = rnd.random()
|
|
415
|
+
|
|
416
|
+
def hsl(t, s_=0.55, l_=0.80):
|
|
417
|
+
import colorsys
|
|
418
|
+
|
|
419
|
+
r, g, b = colorsys.hls_to_rgb(t % 1.0, l_, s_)
|
|
420
|
+
return int(r * 255), int(g * 255), int(b * 255)
|
|
421
|
+
|
|
422
|
+
img = Image.new("RGB", (w, h), hsl(hue, 0.35, 0.95))
|
|
423
|
+
d = ImageDraw.Draw(img, "RGBA")
|
|
424
|
+
for i in range(h):
|
|
425
|
+
t = i / max(h - 1, 1)
|
|
426
|
+
c = hsl(hue + 0.06 * t, 0.40, 0.97 - 0.13 * t)
|
|
427
|
+
d.line([(0, i), (w, i)], fill=c)
|
|
428
|
+
cx, cy = int(w * 0.5), int(h * 0.44)
|
|
429
|
+
r = int(min(w, h) * 0.21)
|
|
430
|
+
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=hsl(hue + 0.12, 0.62, 0.66))
|
|
431
|
+
d.ellipse([cx - int(r * 1.9), cy + int(r * 0.55), cx + int(r * 1.9), cy + int(r * 1.35)],
|
|
432
|
+
fill=hsl(hue + 0.30, 0.45, 0.86, ))
|
|
433
|
+
d.ellipse([cx - int(r * 2.5), cy + int(r * 0.95), cx + int(r * 2.5), cy + int(r * 1.8)],
|
|
434
|
+
fill=hsl(hue + 0.42, 0.40, 0.92))
|
|
435
|
+
buf = io.BytesIO()
|
|
436
|
+
img.save(buf, format="PNG")
|
|
437
|
+
return buf.getvalue()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def normalize_dir(path: str, base: str) -> str:
|
|
441
|
+
"""把相对 cache_dir 解析到 base 下并规范化(去掉 .. 段)。"""
|
|
442
|
+
if not path:
|
|
443
|
+
path = "out/images"
|
|
444
|
+
if not os.path.isabs(path):
|
|
445
|
+
path = os.path.join(base, path)
|
|
446
|
+
return os.path.normpath(path)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# --------------------------------------------------------------------------
|
|
450
|
+
# HTTP 工具
|
|
451
|
+
# --------------------------------------------------------------------------
|
|
452
|
+
def _ssl_ctx():
|
|
453
|
+
try:
|
|
454
|
+
import certifi # type: ignore
|
|
455
|
+
|
|
456
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
457
|
+
except Exception:
|
|
458
|
+
return ssl.create_default_context()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _post_json(req, timeout: int, method: Optional[str] = None) -> dict:
|
|
462
|
+
if method:
|
|
463
|
+
req.method = method
|
|
464
|
+
try:
|
|
465
|
+
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_ctx()) as resp:
|
|
466
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
467
|
+
except urllib.error.HTTPError as exc:
|
|
468
|
+
detail = exc.read().decode("utf-8", "ignore")[:400]
|
|
469
|
+
raise ImageGenError(f"HTTP {exc.code}:{detail}") from exc
|
|
470
|
+
except urllib.error.URLError as exc:
|
|
471
|
+
raise ImageGenError(f"网络错误:{exc.reason}") from exc
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _download(url: str, timeout: int) -> bytes:
|
|
475
|
+
with urllib.request.urlopen(url, timeout=timeout, context=_ssl_ctx()) as resp:
|
|
476
|
+
return resp.read()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _extract_image(payload: dict, timeout: int) -> bytes:
|
|
480
|
+
data = payload.get("data")
|
|
481
|
+
if isinstance(data, list) and data:
|
|
482
|
+
item = data[0]
|
|
483
|
+
if item.get("b64_json"):
|
|
484
|
+
return base64.b64decode(item["b64_json"])
|
|
485
|
+
if item.get("url"):
|
|
486
|
+
return _download(item["url"], timeout)
|
|
487
|
+
if isinstance(data, dict) and data.get("url"):
|
|
488
|
+
return _download(data["url"], timeout)
|
|
489
|
+
raise ImageGenError(f"返回里没有图片数据:{json.dumps(payload)[:300]}")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# --------------------------------------------------------------------------
|
|
493
|
+
# 与课件规格对接
|
|
494
|
+
# --------------------------------------------------------------------------
|
|
495
|
+
def collect_jobs(deck: dict, cfg: ImageConfig) -> List[dict]:
|
|
496
|
+
"""列出这份课件里所有待生成的配图。"""
|
|
497
|
+
jobs = []
|
|
498
|
+
for i, spec in enumerate(deck.get("slides", []), start=1):
|
|
499
|
+
prompt = (spec.get("image_prompt") or "").strip()
|
|
500
|
+
if not prompt:
|
|
501
|
+
continue
|
|
502
|
+
key = cache_key(prompt, cfg.style, cfg.model or "", cfg.size or "", cfg.backend)
|
|
503
|
+
jobs.append({
|
|
504
|
+
"index": i,
|
|
505
|
+
"type": spec.get("type"),
|
|
506
|
+
"title": spec.get("title", ""),
|
|
507
|
+
"prompt": prompt,
|
|
508
|
+
"key": key,
|
|
509
|
+
"path": cache_path(cfg.cache_dir, key),
|
|
510
|
+
"cached": os.path.exists(cache_path(cfg.cache_dir, key)),
|
|
511
|
+
})
|
|
512
|
+
return jobs
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
MANIFEST_NAME = "manifest.json"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def manifest_path(cache_dir: str) -> str:
|
|
519
|
+
return os.path.join(cache_dir, MANIFEST_NAME)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def read_manifest(cache_dir: str) -> Dict[str, str]:
|
|
523
|
+
"""prompt -> 图片路径。渲染阶段靠它把 image_prompt 变成真实图片。"""
|
|
524
|
+
try:
|
|
525
|
+
with open(manifest_path(cache_dir), "r", encoding="utf-8") as f:
|
|
526
|
+
data = json.load(f)
|
|
527
|
+
return {k: v for k, v in (data.get("images") or {}).items() if os.path.exists(v)}
|
|
528
|
+
except Exception:
|
|
529
|
+
return {}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _write_manifest(cache_dir: str, mapping: Dict[str, str]) -> None:
|
|
533
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
534
|
+
with open(manifest_path(cache_dir), "w", encoding="utf-8") as f:
|
|
535
|
+
json.dump({"images": mapping}, f, ensure_ascii=False, indent=2)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def generate_for_deck(deck: dict, cfg: ImageConfig, *, force: bool = False,
|
|
539
|
+
limit: Optional[int] = None, verbose: bool = True) -> dict:
|
|
540
|
+
"""为整份课件生成配图,返回统计。"""
|
|
541
|
+
gen = ImageGenerator(cfg, verbose=verbose)
|
|
542
|
+
jobs = collect_jobs(deck, cfg)
|
|
543
|
+
if limit:
|
|
544
|
+
jobs = jobs[:limit]
|
|
545
|
+
stats = {"total": len(jobs), "done": 0, "cached": 0, "failed": []}
|
|
546
|
+
mapping = read_manifest(cfg.cache_dir)
|
|
547
|
+
for j in jobs:
|
|
548
|
+
try:
|
|
549
|
+
path, hit = gen.generate(j["prompt"], force=force)
|
|
550
|
+
mapping[j["prompt"]] = os.path.abspath(path)
|
|
551
|
+
if hit:
|
|
552
|
+
stats["cached"] += 1
|
|
553
|
+
if verbose:
|
|
554
|
+
print(f" [缓存] P{j['index']:>2} {j['title'][:26]}")
|
|
555
|
+
else:
|
|
556
|
+
stats["done"] += 1
|
|
557
|
+
if verbose:
|
|
558
|
+
print(f" [生成] P{j['index']:>2} {j['title'][:26]} -> {os.path.basename(path)}")
|
|
559
|
+
except Exception as exc:
|
|
560
|
+
stats["failed"].append((j["index"], str(exc)))
|
|
561
|
+
if verbose:
|
|
562
|
+
print(f" [失败] P{j['index']:>2} {j['title'][:26]} {exc}", file=sys.stderr)
|
|
563
|
+
_write_manifest(cfg.cache_dir, mapping)
|
|
564
|
+
return stats
|