dsh-plugin-deck-studio 0.0.0-stage → 0.2.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -2
  3. package/client.js +500 -0
  4. package/cordis.example.yml +163 -0
  5. package/cordis.patch.yml +147 -0
  6. package/index.js +2340 -0
  7. package/lib/config.js +204 -0
  8. package/lib/content-density.js +98 -0
  9. package/lib/deck-brief.js +87 -0
  10. package/lib/deck-docs.js +1095 -0
  11. package/lib/deck-rules.js +124 -0
  12. package/lib/deck-templates.js +149 -0
  13. package/lib/deck-workspace.js +480 -0
  14. package/lib/design-style.js +157 -0
  15. package/lib/designs.js +175 -0
  16. package/lib/diff.js +88 -0
  17. package/lib/exporter.js +336 -0
  18. package/lib/faq.js +300 -0
  19. package/lib/git-commit.js +87 -0
  20. package/lib/home-catalog.js +249 -0
  21. package/lib/png.js +275 -0
  22. package/lib/proposal-decision.js +204 -0
  23. package/lib/render-nodes.js +171 -0
  24. package/lib/render-settings.js +213 -0
  25. package/lib/render.js +287 -0
  26. package/lib/review-personas.js +258 -0
  27. package/lib/script-style.js +55 -0
  28. package/lib/script-writer.js +165 -0
  29. package/lib/source-adapter.js +327 -0
  30. package/lib/store.js +212 -0
  31. package/lib/tools.js +2594 -0
  32. package/lib/watermark.js +137 -0
  33. package/package.json +66 -4
  34. package/seeds/resources/deckline-logo.png +0 -0
  35. package/seeds/resources/deckline-logo.svg +12 -0
  36. package/seeds/resources/deckline-mark.png +0 -0
  37. package/seeds/resources/index.json +19 -0
  38. package/seeds/samples/BIZ-01.jpg +0 -0
  39. package/seeds/samples/BIZ-02.jpg +0 -0
  40. package/seeds/samples/BIZ-03.jpg +0 -0
  41. package/seeds/samples/EDT-01.jpg +0 -0
  42. package/seeds/samples/EDT-02.jpg +0 -0
  43. package/seeds/samples/EDT-03.jpg +0 -0
  44. package/seeds/samples/FYI-01.jpg +0 -0
  45. package/seeds/samples/FYI-02.jpg +0 -0
  46. package/seeds/samples/FYI-03.jpg +0 -0
  47. package/seeds/samples/GRD-01.jpg +0 -0
  48. package/seeds/samples/GRD-02.jpg +0 -0
  49. package/seeds/samples/GRD-03.jpg +0 -0
  50. package/seeds/samples/JPN-01.jpg +0 -0
  51. package/seeds/samples/JPN-02.jpg +0 -0
  52. package/seeds/samples/JPN-03.jpg +0 -0
  53. package/seeds/samples/SKT-01.jpg +0 -0
  54. package/seeds/samples/SKT-02.jpg +0 -0
  55. package/seeds/samples/SKT-03.jpg +0 -0
  56. package/seeds/samples/SWS-01.jpg +0 -0
  57. package/seeds/samples/SWS-02.jpg +0 -0
  58. package/seeds/samples/SWS-03.jpg +0 -0
  59. package/seeds/samples/TEC-01.jpg +0 -0
  60. package/seeds/samples/TEC-02.jpg +0 -0
  61. package/seeds/samples/TEC-03.jpg +0 -0
  62. package/seeds/styles/BIZ.json +22 -0
  63. package/seeds/styles/EDT.json +22 -0
  64. package/seeds/styles/FYI.json +22 -0
  65. package/seeds/styles/GRD.json +22 -0
  66. package/seeds/styles/JPN.json +22 -0
  67. package/seeds/styles/SKT.json +22 -0
  68. package/seeds/styles/SWS.json +22 -0
  69. package/seeds/styles/TEC.json +22 -0
  70. package/ui/logo.svg +20 -0
  71. package/ui/workbench.css +687 -0
  72. package/ui/workbench.html +4554 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * 界面可改的生图设置:模式 / 尺寸 / 出图节点(endpoint + key + 模型)。
3
+ *
4
+ * F-126 起设置写 `$dataRoot/render-settings.json`(本机数据目录,不进 git),启动时覆盖
5
+ * config.render,保存即生效、不用重启。F-146 起节点可以「新增 / 编辑 / 删除」:
6
+ * 小白只需要填 节点名 + 接口地址 + API Key + 模型名,key 直接存本机设置文件(0600),
7
+ * 不要求理解环境变量;高级用户仍可用 env 变量名 / key 文件路径。
8
+ *
9
+ * 纪律(不能破):
10
+ * - 设置文件只在本机数据目录,权限 0600,不进仓库、不进 profile;payload 只回 keyConfigured / keySource,
11
+ * 绝不把 key 值返回给 UI;
12
+ * - endpoint 必须是 http(s) URL;payload 里也不会出现完整 key;
13
+ * - profile 里的节点在第一次用设置页保存节点后由设置文件接管(设置页可以把它们一起编辑)。
14
+ *
15
+ * @module dsh-plugin-deck-studio/render-settings
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { expandHome } from "./config.js";
21
+ import { keyConfigured, renderModelPayload } from "./render-nodes.js";
22
+
23
+ export const RENDER_SETTINGS_FILE = "render-settings.json";
24
+ const MAX_NODES = 12;
25
+ const MODEL_RE = /^[A-Za-z0-9._:\/-]{1,80}$/;
26
+
27
+ const asTrimmed = (value) => (typeof value === "string" ? value.trim() : "");
28
+
29
+ /** 读本机保存的设置;坏文件当没配(并给 warning,不崩)。 */
30
+ export function readRenderSettings(dataRoot) {
31
+ const file = join(dataRoot, RENDER_SETTINGS_FILE);
32
+ if (!existsSync(file)) return { found: false, file, values: null };
33
+ try {
34
+ const raw = JSON.parse(readFileSync(file, "utf8"));
35
+ return { found: true, file, values: normalizeSettings(raw) };
36
+ } catch (error) {
37
+ return { found: false, file, warning: `render-settings.json 解析失败:${error?.message ?? error}`, values: null };
38
+ }
39
+ }
40
+
41
+ function normalizeNode(raw = {}, index = 0) {
42
+ return {
43
+ id: asTrimmed(raw.id),
44
+ name: asTrimmed(raw.name) || asTrimmed(raw.id) || `节点 ${index + 1}`,
45
+ endpoint: asTrimmed(raw.endpoint),
46
+ models: (Array.isArray(raw.models) ? raw.models : []).map(asTrimmed).filter(Boolean),
47
+ apiKey: asTrimmed(raw.apiKey),
48
+ apiKeyEnv: asTrimmed(raw.apiKeyEnv),
49
+ apiKeyFile: expandHome(asTrimmed(raw.apiKeyFile)),
50
+ editEndpoint: asTrimmed(raw.editEndpoint),
51
+ editTransport: asTrimmed(raw.editTransport),
52
+ };
53
+ }
54
+
55
+ function normalizeSettings(raw = {}) {
56
+ return {
57
+ mode: asTrimmed(raw.mode) === "real" ? "real" : "fake",
58
+ size: asTrimmed(raw.size),
59
+ default: asTrimmed(raw.default),
60
+ nodes: (Array.isArray(raw.nodes) ? raw.nodes : []).map(normalizeNode).filter((node) => node.endpoint || node.models.length),
61
+ // 兼容 F-126 的单节点老设置(也可以被新节点列表接管后再回填)
62
+ endpoint: asTrimmed(raw.endpoint),
63
+ model: asTrimmed(raw.model),
64
+ apiKeyEnv: asTrimmed(raw.apiKeyEnv),
65
+ apiKeyFile: expandHome(asTrimmed(raw.apiKeyFile)),
66
+ updatedAt: asTrimmed(raw.updatedAt),
67
+ };
68
+ }
69
+
70
+ function makeNodeId(name, taken) {
71
+ const base = String(name ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
72
+ let id = base || `node-${Math.random().toString(36).slice(2, 7)}`;
73
+ let suffix = 2;
74
+ while (taken.has(id)) id = `${base || "node"}-${suffix++}`;
75
+ return id;
76
+ }
77
+
78
+ function normalizeKeyText(value) {
79
+ return asTrimmed(value).replace(/^["']|["']$/g, "");
80
+ }
81
+
82
+ /** 校验 + 落盘;返回规范化后的设置(抛错 = 不合法,前端直接显示)。 */
83
+ export function saveRenderSettings(dataRoot, payload = {}, now = new Date()) {
84
+ const values = normalizeSettings(payload);
85
+ if (values.size && !/^\d{3,4}x\d{3,4}$/.test(values.size)) throw new Error("尺寸格式应为 宽x高,例如 2560x1440");
86
+ if (values.endpoint && !/^https?:\/\/[^\s]+$/i.test(values.endpoint)) throw new Error("接口地址(endpoint)必须是 http(s) 开头的完整 URL");
87
+ if (values.model && !MODEL_RE.test(values.model)) throw new Error("模型名只允许字母 / 数字 / . _ : / -(≤80 字)");
88
+ if (values.apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(values.apiKeyEnv)) throw new Error("key 环境变量名不合法(字母数字下划线,不能以数字开头)");
89
+
90
+ if (Array.isArray(payload?.nodes)) {
91
+ if (values.nodes.length > MAX_NODES) throw new Error(`节点最多 ${MAX_NODES} 个`);
92
+ const taken = new Set();
93
+ values.nodes = values.nodes.map((node) => {
94
+ const id = node.id && /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/.test(node.id) ? node.id : makeNodeId(node.name, taken);
95
+ if (taken.has(id)) throw new Error(`节点 id 重复:${id}`);
96
+ taken.add(id);
97
+ if (!node.endpoint) throw new Error(`节点「${node.name}」缺接口地址(endpoint,http(s) 开头,一般以 /v1 结尾)`);
98
+ if (!/^https?:\/\/[^\s]+$/i.test(node.endpoint)) throw new Error(`节点「${node.name}」的接口地址(endpoint)必须是 http(s) 开头的完整 URL`);
99
+ if (!node.models.length) throw new Error(`节点「${node.name}」至少填一个模型名`);
100
+ const bad = node.models.find((model) => !MODEL_RE.test(model));
101
+ if (bad) throw new Error(`节点「${node.name}」的模型名不合法:${bad}`);
102
+ const key = normalizeKeyText(node.apiKey);
103
+ if (key && !/^\S{8,300}$/.test(key)) throw new Error(`节点「${node.name}」的 API Key 格式不对(不能有空格,8–300 字符)`);
104
+ if (node.apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(node.apiKeyEnv)) throw new Error(`节点「${node.name}」的环境变量名不合法`);
105
+ return { ...node, id, apiKey: key };
106
+ });
107
+ if (values.mode === "real" && !values.nodes.length) throw new Error("real 模式至少要有一个节点(填接口地址 + 模型)");
108
+ // 默认节点:显式指定且合法就用它,否则用第一个节点的第一个模型
109
+ const wantedDefault = values.default;
110
+ const slash = wantedDefault.indexOf("/");
111
+ const defaultNode = slash > 0 ? values.nodes.find((node) => node.id === wantedDefault.slice(0, slash)) : null;
112
+ const defaultModel = defaultNode?.models.find((model) => model === wantedDefault.slice(slash + 1));
113
+ values.default = defaultNode && defaultModel ? `${defaultNode.id}/${defaultModel}` : values.nodes[0] ? `${values.nodes[0].id}/${values.nodes[0].models[0]}` : "";
114
+ } else {
115
+ if (values.mode === "real" && !values.endpoint) throw new Error("real 模式必须填接口地址(endpoint,出图节点的完整 URL)");
116
+ if (values.mode === "real" && !values.model) throw new Error("real 模式必须填模型名");
117
+ values.default = "";
118
+ }
119
+
120
+ // 编辑节点时 UI 不回传 key:按 id 从已有设置文件里把 key 找回来,避免「编辑一次 key 就没了」
121
+ if (Array.isArray(payload?.nodes)) {
122
+ const previous = readRenderSettings(dataRoot);
123
+ const previousKeys = new Map((previous.values?.nodes ?? []).map((node) => [node.id, node.apiKey]).filter(([, key]) => key));
124
+ values.nodes = values.nodes.map((node) => ({ ...node, apiKey: node.apiKey || previousKeys.get(node.id) || "" }));
125
+ }
126
+
127
+ const merged = { ...values, updatedAt: now.toISOString() };
128
+ mkdirSync(dataRoot, { recursive: true });
129
+ writeFileSync(join(dataRoot, RENDER_SETTINGS_FILE), `${JSON.stringify(merged, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
130
+ return merged;
131
+ }
132
+
133
+ /**
134
+ * 把保存的设置套到 config.render 上(原地改):
135
+ * - 有节点列表:整表接管(profile 节点在第一次保存时已经被 UI 一起提交),按 id 回填旧 key;
136
+ * - 只有 F-126 老设置(endpoint + model):保留 profile 其它节点,追加「自定节点」。
137
+ */
138
+ export function applyRenderSettings(config, saved) {
139
+ const values = saved?.values;
140
+ if (!values) return false;
141
+ const render = config.render ?? (config.render = {});
142
+ if (values.mode) render.mode = values.mode;
143
+ if (values.size) render.size = values.size;
144
+ for (const key of ["endpoint", "model", "apiKeyEnv", "apiKeyFile"]) {
145
+ if (values[key]) render[key] = values[key];
146
+ }
147
+ if (Array.isArray(values.nodes) && values.nodes.length) {
148
+ const existing = Array.isArray(render.nodes) ? render.nodes.filter((node) => node && node.id) : [];
149
+ const byId = new Map(existing.map((node) => [node.id, node]));
150
+ render.nodes = values.nodes.map((node) => ({
151
+ ...node,
152
+ // 编辑节点时 UI 不回传 key(payload 里没有明文),这里按 id 保留本机已存的 key
153
+ apiKey: node.apiKey || byId.get(node.id)?.apiKey || "",
154
+ }));
155
+ render.default = values.default || `${render.nodes[0].id}/${render.nodes[0].models[0]}`;
156
+ return true;
157
+ }
158
+ if (values.endpoint && values.model) {
159
+ // F-145:不再把节点列表压成「自定节点」一个——profile 里的多节点要留着。
160
+ const existing = Array.isArray(render.nodes) ? render.nodes.filter((node) => node && node.id) : [];
161
+ const same = existing.find((node) => node.endpoint === values.endpoint && (node.models ?? []).includes(values.model));
162
+ if (same) {
163
+ render.default = `${same.id}/${values.model}`;
164
+ } else {
165
+ const custom = {
166
+ id: "custom",
167
+ name: "自定节点(设置页)",
168
+ endpoint: values.endpoint,
169
+ apiKeyEnv: values.apiKeyEnv,
170
+ apiKeyFile: values.apiKeyFile,
171
+ models: [values.model],
172
+ };
173
+ render.nodes = [...existing.filter((node) => node.id !== "custom"), custom];
174
+ render.default = `custom/${values.model}`;
175
+ }
176
+ }
177
+ return true;
178
+ }
179
+
180
+ /**
181
+ * 对外 payload(设置页 + 工作台顶栏用)。只给 keyConfigured / keySource,不给 key。
182
+ */
183
+ export function renderSettingsPayload(config, { env = process.env, saved = null } = {}) {
184
+ const stored = saved ?? readRenderSettings(config.dataRoot);
185
+ const values = stored.values ?? normalizeSettings({
186
+ mode: config.render?.mode,
187
+ size: config.render?.size,
188
+ endpoint: config.render?.endpoint,
189
+ model: config.render?.model,
190
+ apiKeyEnv: config.render?.apiKeyEnv,
191
+ apiKeyFile: config.render?.apiKeyFile,
192
+ });
193
+ const modelPayload = renderModelPayload(config.render, { env });
194
+ const activeNode = modelPayload.nodes.find((node) => node.id === modelPayload.active?.nodeId) ?? modelPayload.nodes[0] ?? null;
195
+ return {
196
+ found: Boolean(stored.values),
197
+ file: stored.file,
198
+ ...(stored.warning ? { warning: stored.warning } : {}),
199
+ mode: values.mode,
200
+ size: values.size,
201
+ nodes: modelPayload.nodes,
202
+ default: modelPayload.nodes[0] ? `${modelPayload.nodes[0].id}/${modelPayload.nodes[0].models[0]}` : "",
203
+ // 兼容老调用:把当前节点当成「单节点字段」返回
204
+ endpoint: activeNode?.endpoint ?? values.endpoint,
205
+ model: modelPayload.active?.model ?? values.model,
206
+ apiKeyEnv: activeNode?.apiKeyEnv ?? values.apiKeyEnv,
207
+ apiKeyFile: activeNode?.apiKeyFile ?? values.apiKeyFile,
208
+ keyConfigured: activeNode ? activeNode.keyConfigured : keyConfigured({ apiKeyEnv: values.apiKeyEnv, apiKeyFile: values.apiKeyFile }, env),
209
+ active: modelPayload.active,
210
+ ready: modelPayload.active ? Boolean(activeNode?.keyConfigured) && Boolean(activeNode?.endpoint) : false,
211
+ updatedAt: values.updatedAt || "",
212
+ };
213
+ }
package/lib/render.js ADDED
@@ -0,0 +1,287 @@
1
+ /**
2
+ * image render:prompt 构建、QA 门禁、fake / gpt-image adapter、版本落盘;
3
+ * 另含「修图」adapter(以已有图为底图 → edits/多图输入),供 deck_image_edit 工具使用。
4
+ *
5
+ * 门禁(AGENTS.md「出图 prompt 不得包含客户敏感信息」的可执行部分):
6
+ * prompt 非空、不含凭证样式串、尺寸合法。失败不写任何版本文件。
7
+ * @module dsh-plugin-deck-studio/render
8
+ */
9
+
10
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { extname, join } from "node:path";
12
+ import { briefPromptLines, coverPromptLines } from "./deck-brief.js";
13
+
14
+ /** 凭证样式串(命中即拒绝出图)。 */
15
+ const SECRET_PATTERNS = [
16
+ [/sk-[A-Za-z0-9]{8,}/, "OpenAI 样式密钥 (sk-…)"],
17
+ [/api[_-]?key\s*[=:]/i, "api_key 赋值"],
18
+ [/bearer\s+[A-Za-z0-9._-]{16,}/i, "Bearer token"],
19
+ [/password\s*[=::]\s*\S+/i, "password 赋值"],
20
+ ];
21
+
22
+ /**
23
+ * 由 slide 字段 + 全局风格 preset 组 prompt。
24
+ * F-158:封面页单列一套版式纪律(只放标题/副标题/作者/日期,不堆正文);
25
+ * 普通页带上 intent 的受众 / 目的,避免「图很漂亮但讲错人」。
26
+ * @param {object} slide manifest slide
27
+ * @param {string} styleBrief
28
+ * @param {{brief?: object}} [context] readDeckBrief() 的结果
29
+ */
30
+ export function buildPrompt(slide, styleBrief, context = {}) {
31
+ const brief = context.brief ?? {};
32
+ const isCover = slide?.type === "cover" || Number(slide?.order) === 1;
33
+ if (isCover) {
34
+ const parts = [styleBrief, "封面页(16:9):只放标题信息", `主标题:${slide?.title ?? ""}`];
35
+ if (slide?.question) parts.push(`副标题:${slide.question}`);
36
+ parts.push(...coverPromptLines(brief));
37
+ parts.push("版式纪律:只放主标题 / 副标题 / 作者 / 日期;正文不超过一行;不要目录、要点清单、数据图表、大段文字;留白为主。");
38
+ if (slide?.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
39
+ return parts.filter(Boolean).join("\n");
40
+ }
41
+ const parts = [styleBrief, `页面标题:${slide.title ?? ""}`];
42
+ if (slide.question) parts.push(`这页回答:${slide.question}`);
43
+ if (slide.claim) parts.push(`主张:${slide.claim}`);
44
+ const body = String(slide.body ?? "").trim();
45
+ if (body) parts.push(`正文要点:${body.slice(0, 300)}`);
46
+ if (slide.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
47
+ parts.push(...briefPromptLines(brief));
48
+ return parts.filter(Boolean).join("\n");
49
+ }
50
+
51
+ /** QA 门禁。返回 {ok, checks:[{name, ok, detail}]}。 */
52
+ export function qaCheck(prompt, size) {
53
+ const checks = [];
54
+ const promptOk = typeof prompt === "string" && prompt.trim().length >= 8;
55
+ checks.push({ name: "prompt-exists", ok: promptOk, detail: promptOk ? `${prompt.trim().length} chars` : "prompt 为空或过短" });
56
+ const sizeOk = /^\d{3,4}x\d{3,4}$/.test(String(size ?? ""));
57
+ checks.push({ name: "size-valid", ok: sizeOk, detail: String(size ?? "") });
58
+ const secretHit = SECRET_PATTERNS.find(([pattern]) => pattern.test(prompt ?? ""));
59
+ checks.push({ name: "no-secrets", ok: !secretHit, detail: secretHit ? `疑似敏感串:${secretHit[1]}` : "clean" });
60
+ return { ok: checks.every((check) => check.ok), checks };
61
+ }
62
+
63
+ /** 下一个版本号 v001…(按 versions/ 目录现有条目数)。 */
64
+ export function nextRevision(slideDir) {
65
+ const versionsDir = join(slideDir, "versions");
66
+ if (!existsSync(versionsDir)) return "v001";
67
+ const count = readdirSync(versionsDir).filter((name) => /^v\d+$/.test(name)).length;
68
+ return `v${String(count + 1).padStart(3, "0")}`;
69
+ }
70
+
71
+ /** fake renderer:确定性 SVG 占位,不访问网络。 */
72
+ export function createFakeRenderer() {
73
+ return {
74
+ provider: "fake",
75
+ async render({ prompt, slideId, revision, model, endpoint, size, outDir, signal }) {
76
+ const started = Date.now();
77
+ signal?.throwIfAborted();
78
+ const [w, h] = String(size).split("x").map((n) => Number.parseInt(n, 10) || 1536);
79
+ const escape = (text) => String(text).replace(/[<&>]/g, (char) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[char] ?? char));
80
+ const excerpt = escape(prompt.replace(/\n/g, " / ").slice(0, 220));
81
+ const svg =
82
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
83
+ `<rect width="100%" height="100%" fill="#f6f0e5"/>` +
84
+ `<rect x="0" y="${h - 88}" width="100%" height="88" fill="#06363B"/>` +
85
+ `<text x="72" y="140" font-family="sans-serif" font-size="${Math.round(w / 38)}" fill="#06363B">${escape(slideId)} · ${escape(revision)}</text>` +
86
+ `<text x="72" y="220" font-family="sans-serif" font-size="${Math.round(w / 64)}" fill="#1496a8">FAKE RENDER · ${escape(model)} · ${escape(size)}</text>` +
87
+ `<text x="72" y="${h - 120}" font-family="sans-serif" font-size="${Math.round(w / 72)}" fill="#5C6464">${excerpt}</text>` +
88
+ `</svg>`;
89
+ const imagePath = join(outDir, "image.svg");
90
+ writeFileSync(imagePath, svg, "utf8");
91
+ return { imagePath, imageExt: "svg", model, endpoint, size, durationMs: Date.now() - started, provider: "fake" };
92
+ },
93
+ };
94
+ }
95
+
96
+ /** gpt-image adapter:POST {model, prompt, size} → data[0].b64_json → image.png。 */
97
+ /** 下载图片 URL:CDN 抖动时重试(最多 5 次,指数退避);已取消的请求不再重试。 */
98
+ async function downloadImage(fetchImpl, url, signal, attempts = 5) {
99
+ let lastError;
100
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
101
+ if (signal?.aborted) throw signal.reason ?? new Error("render 已取消");
102
+ try {
103
+ const response = await fetchImpl(url, { signal });
104
+ if (response.ok) return response;
105
+ lastError = new Error(`image url 下载失败 HTTP ${response.status}`);
106
+ } catch (error) {
107
+ lastError = error;
108
+ }
109
+ if (attempt + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, Math.min(2000, 500 * 2 ** attempt)));
110
+ }
111
+ throw lastError ?? new Error("image url 下载失败");
112
+ }
113
+
114
+ export function createRealRenderer({ fetchImpl = fetch } = {}) {
115
+ return {
116
+ provider: "gpt-image",
117
+ async render({ prompt, model, endpoint, size, outDir, apiKey, timeoutMs, signal }) {
118
+ if (!apiKey) throw new Error(`real render 需要 API key:设置 env ${"DSH_DECK_IMAGE_API_KEY"}(或 config.render.apiKeyEnv 指向的变量)`);
119
+ if (!endpoint) throw new Error("real render 缺 endpoint 配置");
120
+ const started = Date.now();
121
+ const controller = new AbortController();
122
+ const timer = setTimeout(() => controller.abort(new Error(`render 超时(${timeoutMs}ms)`)), timeoutMs ?? 420_000);
123
+ const onAbort = () => controller.abort(signal?.reason);
124
+ signal?.addEventListener("abort", onAbort, { once: true });
125
+ try {
126
+ const response = await fetchImpl(endpoint, {
127
+ method: "POST",
128
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
129
+ body: JSON.stringify({ model, prompt, size, n: 1 }),
130
+ signal: controller.signal,
131
+ });
132
+ if (!response.ok) {
133
+ const text = await response.text().catch(() => "");
134
+ throw new Error(`image API ${response.status}: ${text.slice(0, 300)}`);
135
+ }
136
+ const body = await response.json();
137
+ const item = body?.data?.[0] ?? {};
138
+ const imagePath = join(outDir, "image.png");
139
+ if (item.b64_json) {
140
+ writeFileSync(imagePath, Buffer.from(item.b64_json, "base64"));
141
+ } else if (item.url) {
142
+ // 中转可能返回 OSS 托管 URL(签名地址,不带凭证),GET 落地。
143
+ // 签名 URL 走 CDN,跨网抖动时会瞬时超时;重试 1 次(不重试已取消的请求)。
144
+ const fileResponse = await downloadImage(fetchImpl, item.url, controller.signal);
145
+ writeFileSync(imagePath, Buffer.from(await fileResponse.arrayBuffer()));
146
+ } else {
147
+ throw new Error("image API 返回缺少 data[0].b64_json / url");
148
+ }
149
+ return { imagePath, imageExt: "png", model, endpoint, size, durationMs: Date.now() - started, provider: "gpt-image" };
150
+ } finally {
151
+ clearTimeout(timer);
152
+ signal?.removeEventListener("abort", onAbort);
153
+ }
154
+ },
155
+ };
156
+ }
157
+
158
+ /** 从 KEY=VALUE 样式的 env 文件读一个变量(不抛错;绝不打日志)。 */
159
+ export function readKeyFromFile(file, name) {
160
+ if (!file || !name) return "";
161
+ try {
162
+ for (const line of readFileSync(file, "utf8").split("\n")) {
163
+ const match = new RegExp(`^\\s*${name}\\s*=\\s*(.*?)\\s*$`).exec(line);
164
+ if (match) return match[1].replace(/^["']|["']$/g, "").trim();
165
+ }
166
+ } catch {
167
+ // 文件不存在 / 不可读:返回空,由调用方报“缺 key”
168
+ }
169
+ return "";
170
+ }
171
+
172
+ /**
173
+ * 从出图 endpoint 派生修图 endpoint:`/v1/images/generations` → `/v1/images/edits`。
174
+ * 节点可以显式配 `editEndpoint` 覆盖;派生不出来就返回空串(调用方报错,不猜地址)。
175
+ */
176
+ export function editEndpointFor(endpoint) {
177
+ const text = String(endpoint ?? "").trim().replace(/\/+$/, "");
178
+ if (!text) return "";
179
+ if (/\/images\/edits$/.test(text)) return text;
180
+ if (/\/images\/generations$/.test(text)) return text.replace(/\/images\/generations$/, "/images/edits");
181
+ if (/\/v1$/.test(text)) return `${text}/images/edits`;
182
+ return "";
183
+ }
184
+
185
+ const MIME_BY_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
186
+
187
+ /** 图片文件 → {mime, ext};不支持的格式返回 null。 */
188
+ export function imageMime(ext) {
189
+ const key = String(ext ?? "").toLowerCase();
190
+ const mime = MIME_BY_EXT[key.startsWith(".") ? key : `.${key}`];
191
+ return mime ? { mime, ext: key.startsWith(".") ? key : `.${key}` } : null;
192
+ }
193
+
194
+ /** 修图响应解析:b64_json 直接落盘,url 走下载(和出图共用重试)。 */
195
+ async function writeEditImage(fetchImpl, item, outDir, signal) {
196
+ const imagePath = join(outDir, "image.png");
197
+ if (item?.b64_json) {
198
+ writeFileSync(imagePath, Buffer.from(item.b64_json, "base64"));
199
+ return imagePath;
200
+ }
201
+ if (item?.url) {
202
+ const fileResponse = await downloadImage(fetchImpl, item.url, signal);
203
+ writeFileSync(imagePath, Buffer.from(await fileResponse.arrayBuffer()));
204
+ return imagePath;
205
+ }
206
+ throw new Error("修图 API 返回缺少 data[0].b64_json / url");
207
+ }
208
+
209
+ /**
210
+ * 修图(image edit)adapter:multipart 传 image + prompt + model + size 到 edits 端点。
211
+ * transport=json 时改用 JSON body(image 传 data URL),给只吃 JSON 的中转兜底。
212
+ */
213
+ export function createRealEditRenderer({ fetchImpl = fetch } = {}) {
214
+ return {
215
+ provider: "gpt-image-edit",
216
+ async edit({ filePath, prompt, model, endpoint, size, outDir, apiKey, timeoutMs, signal, transport = "multipart" }) {
217
+ if (!apiKey) throw new Error("修图需要 API key:设置 env DSH_DECK_IMAGE_API_KEY(或 config.render.apiKeyEnv 指向的变量)");
218
+ if (!endpoint) throw new Error("修图缺 endpoint:在节点配 editEndpoint,或让出图 endpoint 以 /v1/images/generations 结尾自动派生");
219
+ const started = Date.now();
220
+ const controller = new AbortController();
221
+ const timer = setTimeout(() => controller.abort(new Error(`修图超时(${timeoutMs}ms)`)), timeoutMs ?? 420_000);
222
+ const onAbort = () => controller.abort(signal?.reason);
223
+ signal?.addEventListener("abort", onAbort, { once: true });
224
+ try {
225
+ let response;
226
+ if (transport === "json") {
227
+ const mime = imageMime(extname(filePath))?.mime ?? "image/png";
228
+ const dataUrl = `data:${mime};base64,${readFileSync(filePath).toString("base64")}`;
229
+ response = await fetchImpl(endpoint, {
230
+ method: "POST",
231
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
232
+ body: JSON.stringify({ model, prompt, size, n: 1, image: dataUrl }),
233
+ signal: controller.signal,
234
+ });
235
+ } else {
236
+ const info = imageMime(extname(filePath)) ?? { mime: "image/png", ext: ".png" };
237
+ const form = new FormData();
238
+ form.append("model", model);
239
+ form.append("prompt", prompt);
240
+ form.append("size", size);
241
+ form.append("n", "1");
242
+ form.append("image", new Blob([readFileSync(filePath)], { type: info.mime }), `source${info.ext}`);
243
+ response = await fetchImpl(endpoint, {
244
+ method: "POST",
245
+ headers: { authorization: `Bearer ${apiKey}` },
246
+ body: form,
247
+ signal: controller.signal,
248
+ });
249
+ }
250
+ if (!response.ok) {
251
+ const text = await response.text().catch(() => "");
252
+ throw new Error(`修图 API ${response.status}: ${text.slice(0, 300)}`);
253
+ }
254
+ const body = await response.json();
255
+ const imagePath = await writeEditImage(fetchImpl, body?.data?.[0] ?? {}, outDir, controller.signal);
256
+ return { imagePath, imageExt: "png", model, endpoint, size, durationMs: Date.now() - started, provider: "gpt-image-edit", transport };
257
+ } finally {
258
+ clearTimeout(timer);
259
+ signal?.removeEventListener("abort", onAbort);
260
+ }
261
+ },
262
+ };
263
+ }
264
+
265
+ /** fake 修图:把底图复制成新版本(确定性、不联网),用于 smoke / 无 key 演示。 */
266
+ export function createFakeEditRenderer() {
267
+ return {
268
+ provider: "fake-edit",
269
+ async edit({ filePath, outDir, ext = ".png", signal }) {
270
+ signal?.throwIfAborted();
271
+ const started = Date.now();
272
+ const info = imageMime(ext) ?? { ext: ".png" };
273
+ const imagePath = join(outDir, `image${info.ext}`);
274
+ writeFileSync(imagePath, readFileSync(filePath));
275
+ return { imagePath, imageExt: info.ext, provider: "fake-edit", size: null, durationMs: Date.now() - started };
276
+ },
277
+ };
278
+ }
279
+
280
+ /** 按配置选 renderer;real 模式缺 key 直接抛(不悄悄退回 fake)。 */
281
+ export function selectRenderer(renderConfig, env = process.env) {
282
+ if (renderConfig.mode === "real") {
283
+ const apiKey = env[renderConfig.apiKeyEnv] || readKeyFromFile(renderConfig.apiKeyFile, renderConfig.apiKeyEnv);
284
+ return { renderer: createRealRenderer(), apiKey };
285
+ }
286
+ return { renderer: createFakeRenderer(), apiKey: "" };
287
+ }