@dsh-plus/image-studio 0.1.5 → 0.1.7
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/lib/client.js +523 -98
- package/lib/index.d.ts +172 -9
- package/lib/index.js +537 -57
- package/package.json +2 -2
- package/src/api.ts +83 -3
- package/src/client/api.ts +25 -0
- package/src/client/card.tsx +12 -0
- package/src/client/client.ts +2 -1
- package/src/client/i18n.ts +17 -0
- package/src/client/panel/controller.ts +4 -2
- package/src/client/panel/gallery.tsx +7 -2
- package/src/client/panel/generator.tsx +65 -26
- package/src/client/panel/panel.tsx +4 -1
- package/src/client/panel/params-form.tsx +50 -6
- package/src/client/panel/picker.tsx +124 -40
- package/src/client/panel/uploads.tsx +124 -0
- package/src/client/styles.ts +50 -0
- package/src/config.ts +6 -0
- package/src/dto.ts +20 -2
- package/src/errors.ts +6 -0
- package/src/gallery/store.ts +16 -15
- package/src/images/id.ts +12 -0
- package/src/images/mime.ts +64 -0
- package/src/index.ts +12 -5
- package/src/limits.ts +12 -0
- package/src/params/catalog.ts +9 -3
- package/src/params/size.ts +98 -0
- package/src/params/spec.ts +17 -2
- package/src/provider/openai-images.ts +9 -5
- package/src/provider/registry.ts +1 -1
- package/src/service.ts +160 -28
- package/src/uploads/store.ts +168 -0
package/lib/index.js
CHANGED
|
@@ -47,7 +47,8 @@ const Config = z.object({
|
|
|
47
47
|
maxConcurrent: z.number().min(0).max(16).description("生图任务并发上限(0 = 不限)").default(3),
|
|
48
48
|
requestTimeoutMs: z.number().min(1e4).max(6e5).description("单请求超时(毫秒;生图长耗时,默认 5 分钟)").default(3e5),
|
|
49
49
|
proxy: z.string().description("上游请求代理(空 = 直连)").default(""),
|
|
50
|
-
galleryMaxItems: z.number().min(0).max(1e4).description("画廊保留上限(条,0 = 不清理;超限按最旧删除)").default(500)
|
|
50
|
+
galleryMaxItems: z.number().min(0).max(1e4).description("画廊保留上限(条,0 = 不清理;超限按最旧删除)").default(500),
|
|
51
|
+
uploadTtlHours: z.number().min(1).max(720).description("上传原图保留时长(小时;未被画廊条目引用的超期即回收)").default(24)
|
|
51
52
|
});
|
|
52
53
|
//#endregion
|
|
53
54
|
//#region src/errors.ts
|
|
@@ -70,9 +71,87 @@ function statusOfCode(code) {
|
|
|
70
71
|
case "task-not-runnable": return 400;
|
|
71
72
|
case "credential-unavailable":
|
|
72
73
|
case "settings-unavailable": return 409;
|
|
74
|
+
case "unsupported-media": return 415;
|
|
75
|
+
case "upload-too-large": return 413;
|
|
73
76
|
default: return 500;
|
|
74
77
|
}
|
|
75
78
|
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/images/mime.ts
|
|
81
|
+
/**
|
|
82
|
+
* 图片 MIME 单一事实源:扩展名互转 + 魔数嗅探。
|
|
83
|
+
* 上传边界只认官方 images 端点接受的位图格式(png/jpeg/webp/gif),
|
|
84
|
+
* 不信任客户端声明的 content-type(防伪装文件落盘)。
|
|
85
|
+
* 纯函数零依赖,宿主两半均可安全引入。
|
|
86
|
+
* @module image-studio/images/mime
|
|
87
|
+
*/
|
|
88
|
+
/** 魔数嗅探窗口(最长特征序列 + 偏移)。 */
|
|
89
|
+
const SNIFF_BYTES = 16;
|
|
90
|
+
/** 落盘扩展名 ← MIME(白名单外一律按 png 处理)。 */
|
|
91
|
+
function extOfMime(mime) {
|
|
92
|
+
switch (mime) {
|
|
93
|
+
case "image/jpeg": return "jpg";
|
|
94
|
+
case "image/webp": return "webp";
|
|
95
|
+
case "image/gif": return "gif";
|
|
96
|
+
default: return "png";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** MIME ← 落盘扩展名(读取回程;与 extOfMime 互逆)。 */
|
|
100
|
+
function mimeOfExt(ext) {
|
|
101
|
+
switch (ext) {
|
|
102
|
+
case "jpg":
|
|
103
|
+
case "jpeg": return "image/jpeg";
|
|
104
|
+
case "webp": return "image/webp";
|
|
105
|
+
case "gif": return "image/gif";
|
|
106
|
+
default: return "image/png";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** 前若干字节是否以 prefix 开头。 */
|
|
110
|
+
function startsWith(bytes, prefix, offset = 0) {
|
|
111
|
+
if (bytes.length < offset + prefix.length) return false;
|
|
112
|
+
return prefix.every((value, index) => bytes[offset + index] === value);
|
|
113
|
+
}
|
|
114
|
+
/** ASCII 特征序列 → 字节数组('RIFF' / 'WEBP' 等容器标记)。 */
|
|
115
|
+
function ascii(text) {
|
|
116
|
+
return [...text].map((char) => char.charCodeAt(0));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 魔数嗅探图片类型;非白名单格式返回 null。
|
|
120
|
+
* @param bytes - 文件字节(只需前 16 字节即可判定)。
|
|
121
|
+
*/
|
|
122
|
+
function detectImageMime(bytes) {
|
|
123
|
+
const head = bytes.subarray(0, SNIFF_BYTES);
|
|
124
|
+
if (startsWith(head, [
|
|
125
|
+
137,
|
|
126
|
+
80,
|
|
127
|
+
78,
|
|
128
|
+
71,
|
|
129
|
+
13,
|
|
130
|
+
10,
|
|
131
|
+
26,
|
|
132
|
+
10
|
|
133
|
+
])) return "image/png";
|
|
134
|
+
if (startsWith(head, [
|
|
135
|
+
255,
|
|
136
|
+
216,
|
|
137
|
+
255
|
|
138
|
+
])) return "image/jpeg";
|
|
139
|
+
if (startsWith(head, ascii("GIF87a")) || startsWith(head, ascii("GIF89a"))) return "image/gif";
|
|
140
|
+
if (startsWith(head, ascii("RIFF")) && startsWith(head, ascii("WEBP"), 8)) return "image/webp";
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/limits.ts
|
|
145
|
+
/**
|
|
146
|
+
* 跨半共享的数量/体积上限(单一事实源)。
|
|
147
|
+
* 宿主边界校验与浏览器半控件共用,避免两处各写一个数字而漂移。
|
|
148
|
+
* 纯常量零依赖,浏览器半可安全引入。
|
|
149
|
+
* @module image-studio/limits
|
|
150
|
+
*/
|
|
151
|
+
/** 单次 edits 请求的源图上限(官方 GPT Image 限制)。 */
|
|
152
|
+
const MAX_SOURCE_IMAGES = 16;
|
|
153
|
+
/** 单张上传原图体积上限(50MB,对齐官方 images 端点与仓库上传约定)。 */
|
|
154
|
+
const UPLOAD_MAX_BYTES = 52428800;
|
|
76
155
|
/** 只读目录(冻结防运行期篡改)。 */
|
|
77
156
|
const PARAM_CATALOG = Object.freeze([
|
|
78
157
|
{
|
|
@@ -96,10 +175,11 @@ const PARAM_CATALOG = Object.freeze([
|
|
|
96
175
|
{
|
|
97
176
|
key: "size",
|
|
98
177
|
kind: "string",
|
|
178
|
+
rules: "image-size",
|
|
99
179
|
applies: ["generation", "edit"],
|
|
100
180
|
generation: "current",
|
|
101
181
|
advanced: false,
|
|
102
|
-
description: "
|
|
182
|
+
description: "尺寸:auto 或「宽x高」。推荐 1024x1024 / 1536x1024 / 1024x1536;自定义需宽高为 16 的倍数、长边 ≤3840、比例 ≤3:1、总像素 655360-8294400"
|
|
103
183
|
},
|
|
104
184
|
{
|
|
105
185
|
key: "quality",
|
|
@@ -108,12 +188,14 @@ const PARAM_CATALOG = Object.freeze([
|
|
|
108
188
|
"auto",
|
|
109
189
|
"low",
|
|
110
190
|
"medium",
|
|
111
|
-
"high"
|
|
191
|
+
"high",
|
|
192
|
+
"xhigh",
|
|
193
|
+
"max"
|
|
112
194
|
],
|
|
113
195
|
applies: ["generation", "edit"],
|
|
114
196
|
generation: "current",
|
|
115
197
|
advanced: false,
|
|
116
|
-
description: "
|
|
198
|
+
description: "质量档位(xhigh/max 为 image 2.5 新增高档位)"
|
|
117
199
|
},
|
|
118
200
|
{
|
|
119
201
|
key: "background",
|
|
@@ -223,6 +305,90 @@ function paramKeysFor(endpoint) {
|
|
|
223
305
|
return PARAM_CATALOG.filter((entry) => entry.applies.includes(endpoint)).map((entry) => entry.key);
|
|
224
306
|
}
|
|
225
307
|
//#endregion
|
|
308
|
+
//#region src/params/size.ts
|
|
309
|
+
/** GPT Image 代自定义尺寸规则。 */
|
|
310
|
+
const IMAGE_SIZE_RULE = Object.freeze({
|
|
311
|
+
multiple: 16,
|
|
312
|
+
maxEdge: 3840,
|
|
313
|
+
minPixels: 655360,
|
|
314
|
+
maxPixels: 8294400,
|
|
315
|
+
maxAspect: 3
|
|
316
|
+
});
|
|
317
|
+
/** 免校验取值:实际尺寸由模型按提示词决定。 */
|
|
318
|
+
const SIZE_AUTO = "auto";
|
|
319
|
+
/** 预设尺寸表(顺序即展示序:常用在前;全部满足 16 倍数约束)。 */
|
|
320
|
+
const SIZE_PRESETS = Object.freeze([
|
|
321
|
+
Object.freeze({
|
|
322
|
+
value: SIZE_AUTO,
|
|
323
|
+
label: "auto(由模型决定)"
|
|
324
|
+
}),
|
|
325
|
+
Object.freeze({
|
|
326
|
+
value: "1024x1024",
|
|
327
|
+
label: "1024×1024 方形 1:1"
|
|
328
|
+
}),
|
|
329
|
+
Object.freeze({
|
|
330
|
+
value: "1536x1024",
|
|
331
|
+
label: "1536×1024 横向 3:2"
|
|
332
|
+
}),
|
|
333
|
+
Object.freeze({
|
|
334
|
+
value: "1024x1536",
|
|
335
|
+
label: "1024×1536 纵向 2:3"
|
|
336
|
+
}),
|
|
337
|
+
Object.freeze({
|
|
338
|
+
value: "2048x1152",
|
|
339
|
+
label: "2048×1152 宽屏 16:9"
|
|
340
|
+
}),
|
|
341
|
+
Object.freeze({
|
|
342
|
+
value: "1152x2048",
|
|
343
|
+
label: "1152×2048 竖屏 9:16"
|
|
344
|
+
}),
|
|
345
|
+
Object.freeze({
|
|
346
|
+
value: "3840x2160",
|
|
347
|
+
label: "3840×2160 4K 16:9"
|
|
348
|
+
}),
|
|
349
|
+
Object.freeze({
|
|
350
|
+
value: "2160x3840",
|
|
351
|
+
label: "2160×3840 4K 9:16"
|
|
352
|
+
})
|
|
353
|
+
]);
|
|
354
|
+
const OK = { ok: true };
|
|
355
|
+
/** 「宽x高」规范形态(小写 x、无空白;`auto` 原样)。 */
|
|
356
|
+
function normalizeImageSize(value) {
|
|
357
|
+
const text = value.trim();
|
|
358
|
+
if (text === "auto") return SIZE_AUTO;
|
|
359
|
+
return text.replace(/[×✕✖]/g, "x").replace(/\s+/g, "").toLowerCase();
|
|
360
|
+
}
|
|
361
|
+
/** 规范化后的「宽x高」形态。 */
|
|
362
|
+
const SIZE_PATTERN = /^(\d{2,5})x(\d{2,5})$/;
|
|
363
|
+
/**
|
|
364
|
+
* 校验一个尺寸取值(`auto` 或「宽x高」,容忍大小写/全角乘号/空白)。
|
|
365
|
+
* @param value - 用户输入或预设值(提交/保存前校验)。
|
|
366
|
+
* @param rule - 尺寸约束(缺省 GPT Image 代规则)。
|
|
367
|
+
*/
|
|
368
|
+
function checkImageSize(value, rule = IMAGE_SIZE_RULE) {
|
|
369
|
+
const text = normalizeImageSize(value);
|
|
370
|
+
if (text === "auto") return OK;
|
|
371
|
+
const match = SIZE_PATTERN.exec(text);
|
|
372
|
+
if (match === null) return fail("需为「宽x高」(如 1024x1024)或 auto");
|
|
373
|
+
const width = Number(match[1]);
|
|
374
|
+
const height = Number(match[2]);
|
|
375
|
+
if (width % rule.multiple !== 0 || height % rule.multiple !== 0) return fail(`宽高需为 ${rule.multiple} 的倍数(当前 ${width}x${height})`);
|
|
376
|
+
const long = Math.max(width, height);
|
|
377
|
+
const short = Math.min(width, height);
|
|
378
|
+
if (long > rule.maxEdge) return fail(`长边不能超过 ${rule.maxEdge}px(当前 ${long}px)`);
|
|
379
|
+
if (long / short > rule.maxAspect) return fail(`宽高比不能超过 ${rule.maxAspect}:1`);
|
|
380
|
+
const pixels = width * height;
|
|
381
|
+
if (pixels < rule.minPixels) return fail(`总像素不能少于 ${rule.minPixels}(当前 ${pixels})`);
|
|
382
|
+
if (pixels > rule.maxPixels) return fail(`总像素不能超过 ${rule.maxPixels}(当前 ${pixels})`);
|
|
383
|
+
return OK;
|
|
384
|
+
}
|
|
385
|
+
function fail(message) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
message
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
//#endregion
|
|
226
392
|
//#region src/params/spec.ts
|
|
227
393
|
/** 归一化失败原因(边界校验用,端点转 invalid-request)。 */
|
|
228
394
|
var ParamValidationError = class extends Error {
|
|
@@ -240,7 +406,7 @@ function validateParamValue(key, value) {
|
|
|
240
406
|
switch (entry.kind) {
|
|
241
407
|
case "string":
|
|
242
408
|
if (typeof value !== "string" || value.length === 0) throw new ParamValidationError(key, "必须是非空字符串");
|
|
243
|
-
return value;
|
|
409
|
+
return validateRule(entry.rules, key, value);
|
|
244
410
|
case "enum":
|
|
245
411
|
if (typeof value !== "string" || !entry.values?.includes(value)) throw new ParamValidationError(key, `必须是 ${entry.values?.join(" | ") ?? "枚举值"} 之一`);
|
|
246
412
|
return value;
|
|
@@ -255,6 +421,18 @@ function validateParamValue(key, value) {
|
|
|
255
421
|
}
|
|
256
422
|
}
|
|
257
423
|
/**
|
|
424
|
+
* 结构化值域规则分派(目录声明 → 校验实现单一入口)。
|
|
425
|
+
* 无规则的参数原样返回;规则拒绝时错误消息即为面向用户的原因。
|
|
426
|
+
*/
|
|
427
|
+
function validateRule(rule, key, value) {
|
|
428
|
+
if (rule === "image-size") {
|
|
429
|
+
const checked = checkImageSize(value);
|
|
430
|
+
if (!checked.ok) throw new ParamValidationError(key, checked.message);
|
|
431
|
+
return normalizeImageSize(value);
|
|
432
|
+
}
|
|
433
|
+
return value;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
258
436
|
* 校验并归一化一份参数表:仅保留启用且适用于该端点的参数;
|
|
259
437
|
* model 是请求基础(提供商预设承载),不计入协议参数表。
|
|
260
438
|
* 任何禁用/未知/越界项直接抛 ParamValidationError(边界 fail-loud)。
|
|
@@ -355,7 +533,9 @@ function buildGenerationBody(request) {
|
|
|
355
533
|
/** 构造 edits multipart 请求体(纯函数;image 官方上限 ≤16 张由边界校验)。 */
|
|
356
534
|
function buildEditBody(request) {
|
|
357
535
|
const chunks = [];
|
|
358
|
-
const pushText = (text) =>
|
|
536
|
+
const pushText = (text) => {
|
|
537
|
+
chunks.push(Buffer.from(text, "utf8"));
|
|
538
|
+
};
|
|
359
539
|
pushText(formField("model", request.target.model));
|
|
360
540
|
pushText(formField("prompt", request.prompt));
|
|
361
541
|
pushText(formField("n", String(request.n)));
|
|
@@ -478,7 +658,10 @@ function upstreamErrorMessage(raw) {
|
|
|
478
658
|
try {
|
|
479
659
|
const doc = JSON.parse(trimmed);
|
|
480
660
|
if (typeof doc.error === "string" && doc.error.length > 0) return doc.error;
|
|
481
|
-
if (typeof doc.error
|
|
661
|
+
if (typeof doc.error === "object" && doc.error !== null) {
|
|
662
|
+
const message = doc.error.message;
|
|
663
|
+
if (typeof message === "string" && message.length > 0) return message;
|
|
664
|
+
}
|
|
482
665
|
if (typeof doc.message === "string" && doc.message.length > 0) return doc.message;
|
|
483
666
|
} catch {}
|
|
484
667
|
return trimmed.slice(0, 300);
|
|
@@ -554,6 +737,8 @@ function createProvider(id) {
|
|
|
554
737
|
//#endregion
|
|
555
738
|
//#region src/api.ts
|
|
556
739
|
const ROUTE_PREFIX = "/dsh-plus/image-studio";
|
|
740
|
+
/** 上传文件名长度上限(仅展示用途,超长截断)。 */
|
|
741
|
+
const UPLOAD_NAME_MAX = 200;
|
|
557
742
|
function sendJson(res, status, body) {
|
|
558
743
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
559
744
|
res.end(JSON.stringify(body));
|
|
@@ -566,6 +751,27 @@ function readBody(req) {
|
|
|
566
751
|
req.on("error", reject);
|
|
567
752
|
});
|
|
568
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* 读取二进制请求体(上传通道):超限即中止并抛 upload-too-large。
|
|
756
|
+
* 限额内的字节在内存中聚合(魔数嗅探需完整头部判定,单文件 ≤50MB;
|
|
757
|
+
* 超限后不再累积,内存占用有界)。导出供单测用小限额直接驱动。
|
|
758
|
+
*/
|
|
759
|
+
function readBinaryBody(req, maxBytes = UPLOAD_MAX_BYTES) {
|
|
760
|
+
return new Promise((resolve, reject) => {
|
|
761
|
+
const chunks = [];
|
|
762
|
+
let size = 0;
|
|
763
|
+
req.on("data", (chunk) => {
|
|
764
|
+
size += chunk.length;
|
|
765
|
+
if (size > maxBytes) {
|
|
766
|
+
reject(new ImageStudioError("upload-too-large", `文件超过 ${maxBytes} 字节上限`));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
chunks.push(chunk);
|
|
770
|
+
});
|
|
771
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
772
|
+
req.on("error", reject);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
569
775
|
/** 统一错误结算:ImageStudioError/ParamValidationError 按码映射,其余 500。 */
|
|
570
776
|
function sendError(res, error) {
|
|
571
777
|
if (error instanceof ImageStudioError) {
|
|
@@ -587,6 +793,12 @@ function sendError(res, error) {
|
|
|
587
793
|
message: error instanceof Error ? error.message : String(error)
|
|
588
794
|
});
|
|
589
795
|
}
|
|
796
|
+
/** 上传文件名(query 参数,仅展示用):去控制字符并截断。 */
|
|
797
|
+
function uploadNameOf(url) {
|
|
798
|
+
const raw = (url.searchParams.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
799
|
+
if (raw === "") return "upload";
|
|
800
|
+
return raw.slice(0, UPLOAD_NAME_MAX);
|
|
801
|
+
}
|
|
590
802
|
function buildHandlers(service) {
|
|
591
803
|
return {
|
|
592
804
|
async generate(_req, res, rest) {
|
|
@@ -661,13 +873,50 @@ function buildHandlers(service) {
|
|
|
661
873
|
sendJson(res, 404, { error: "not-found" });
|
|
662
874
|
return;
|
|
663
875
|
}
|
|
664
|
-
const mime = bytes.ext
|
|
876
|
+
const mime = mimeOfExt(bytes.ext);
|
|
665
877
|
res.writeHead(200, {
|
|
666
878
|
"content-type": mime,
|
|
667
879
|
"cache-control": "private, max-age=3600"
|
|
668
880
|
});
|
|
669
881
|
res.end(Buffer.from(bytes.data));
|
|
670
882
|
},
|
|
883
|
+
async uploads(req, res, rest, url) {
|
|
884
|
+
const uploadId = rest.replace(/\/$/, "");
|
|
885
|
+
if (method(req) === "POST") {
|
|
886
|
+
if (uploadId !== "") throw new ImageStudioError("invalid-request", "未知路径");
|
|
887
|
+
const data = new Uint8Array(await readBinaryBody(req));
|
|
888
|
+
const mime = detectImageMime(data);
|
|
889
|
+
if (mime === null) throw new ImageStudioError("unsupported-media", "只支持 PNG/JPEG/WebP/GIF 图片");
|
|
890
|
+
sendJson(res, 201, await service.saveUpload({
|
|
891
|
+
name: uploadNameOf(url),
|
|
892
|
+
mime,
|
|
893
|
+
data
|
|
894
|
+
}));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (method(req) !== "GET") {
|
|
898
|
+
sendJson(res, 405, { error: "GET or POST only" });
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (uploadId === "") {
|
|
902
|
+
const items = await service.listUploads();
|
|
903
|
+
sendJson(res, 200, {
|
|
904
|
+
items,
|
|
905
|
+
total: items.length
|
|
906
|
+
});
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const bytes = await service.uploadBytes(uploadId);
|
|
910
|
+
if (bytes === null) {
|
|
911
|
+
sendJson(res, 404, { error: "not-found" });
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
res.writeHead(200, {
|
|
915
|
+
"content-type": mimeOfExt(bytes.ext),
|
|
916
|
+
"cache-control": "private, max-age=3600"
|
|
917
|
+
});
|
|
918
|
+
res.end(Buffer.from(bytes.data));
|
|
919
|
+
},
|
|
671
920
|
async credentials(_req, res, rest) {
|
|
672
921
|
const refName = rest.replace(/\/$/, "");
|
|
673
922
|
if (refName.length === 0) {
|
|
@@ -731,7 +980,8 @@ function registerImageStudioApi(ctx, service) {
|
|
|
731
980
|
kind: "prefix",
|
|
732
981
|
path: ROUTE_PREFIX,
|
|
733
982
|
handler: (req, res) => {
|
|
734
|
-
const
|
|
983
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
984
|
+
const [head, ...tail] = url.pathname.slice(22).replace(/^\//, "").split("/");
|
|
735
985
|
const rest = tail.join("/");
|
|
736
986
|
(async () => {
|
|
737
987
|
if (head === void 0 || head === "") {
|
|
@@ -743,7 +993,7 @@ function registerImageStudioApi(ctx, service) {
|
|
|
743
993
|
sendJson(res, 404, { error: `unknown endpoint: ${head}` });
|
|
744
994
|
return;
|
|
745
995
|
}
|
|
746
|
-
await handler(req, res, rest);
|
|
996
|
+
await handler(req, res, rest, url);
|
|
747
997
|
})().catch((error) => sendError(res, error));
|
|
748
998
|
}
|
|
749
999
|
});
|
|
@@ -768,6 +1018,18 @@ function credentialRefNameOf(presetId) {
|
|
|
768
1018
|
return `${REF_PREFIX}${presetId.replaceAll("-", "_").toUpperCase()}`;
|
|
769
1019
|
}
|
|
770
1020
|
//#endregion
|
|
1021
|
+
//#region src/images/id.ts
|
|
1022
|
+
/**
|
|
1023
|
+
* 图片标识形态(uuid):画廊 imageId 与上传 uploadId 共用同一约束。
|
|
1024
|
+
* 既是路由参数白名单,也是路径拼接的穿越防线(单一事实源)。
|
|
1025
|
+
* @module image-studio/images/id
|
|
1026
|
+
*/
|
|
1027
|
+
const IMAGE_ID_RE = /^[a-f0-9-]{36}$/;
|
|
1028
|
+
/** 是否为合法图片标识(未知类型一律 false)。 */
|
|
1029
|
+
function isImageId(value) {
|
|
1030
|
+
return typeof value === "string" && IMAGE_ID_RE.test(value);
|
|
1031
|
+
}
|
|
1032
|
+
//#endregion
|
|
771
1033
|
//#region src/gallery/store.ts
|
|
772
1034
|
/**
|
|
773
1035
|
* 画廊存储:元数据 JSONL(追加)+ 图片落盘(原子写)。
|
|
@@ -782,29 +1044,29 @@ function credentialRefNameOf(presetId) {
|
|
|
782
1044
|
function galleryRoot() {
|
|
783
1045
|
return pluginDataPath("image-studio", "gallery");
|
|
784
1046
|
}
|
|
785
|
-
function indexFile() {
|
|
1047
|
+
function indexFile$1() {
|
|
786
1048
|
return join(galleryRoot(), "index.jsonl");
|
|
787
1049
|
}
|
|
788
1050
|
function imagesDir() {
|
|
789
1051
|
return join(galleryRoot(), "images");
|
|
790
1052
|
}
|
|
791
|
-
/** 图片扩展名由 MIME 推导(仅白名单格式)。 */
|
|
792
|
-
function extOfMime(mime) {
|
|
793
|
-
switch (mime) {
|
|
794
|
-
case "image/jpeg": return "jpg";
|
|
795
|
-
case "image/webp": return "webp";
|
|
796
|
-
default: return "png";
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
1053
|
/** 确保目录存在。 */
|
|
800
|
-
async function ensureDirs() {
|
|
1054
|
+
async function ensureDirs$1() {
|
|
801
1055
|
await mkdir(imagesDir(), { recursive: true });
|
|
802
1056
|
}
|
|
1057
|
+
/** 旧条目补齐新增字段(sourceUploads 为后加字段,历史 JSONL 里不存在)。 */
|
|
1058
|
+
function normalizeItem(item) {
|
|
1059
|
+
return {
|
|
1060
|
+
...item,
|
|
1061
|
+
sourceIds: item.sourceIds ?? [],
|
|
1062
|
+
sourceUploads: item.sourceUploads ?? []
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
803
1065
|
/** 解析 JSONL 全量元数据(坏行跳过并告警;空文件返回空表)。 */
|
|
804
1066
|
async function loadGallery(log) {
|
|
805
1067
|
let text;
|
|
806
1068
|
try {
|
|
807
|
-
text = await readFile(indexFile(), "utf8");
|
|
1069
|
+
text = await readFile(indexFile$1(), "utf8");
|
|
808
1070
|
} catch {
|
|
809
1071
|
return [];
|
|
810
1072
|
}
|
|
@@ -812,7 +1074,7 @@ async function loadGallery(log) {
|
|
|
812
1074
|
for (const line of text.split("\n")) {
|
|
813
1075
|
if (line.trim().length === 0) continue;
|
|
814
1076
|
try {
|
|
815
|
-
items.push(JSON.parse(line));
|
|
1077
|
+
items.push(normalizeItem(JSON.parse(line)));
|
|
816
1078
|
} catch {
|
|
817
1079
|
log(`画廊元数据坏行跳过:${line.slice(0, 80)}`);
|
|
818
1080
|
}
|
|
@@ -821,12 +1083,12 @@ async function loadGallery(log) {
|
|
|
821
1083
|
}
|
|
822
1084
|
/** 追加一条元数据(JSONL 原子性:单行小写入)。 */
|
|
823
1085
|
async function appendIndex(item) {
|
|
824
|
-
await ensureDirs();
|
|
825
|
-
await appendFile(indexFile(), `${JSON.stringify(item)}\n`, "utf8");
|
|
1086
|
+
await ensureDirs$1();
|
|
1087
|
+
await appendFile(indexFile$1(), `${JSON.stringify(item)}\n`, "utf8");
|
|
826
1088
|
}
|
|
827
1089
|
/** 图片文件绝对路径。 */
|
|
828
1090
|
function imagePathOf(imageId, ext) {
|
|
829
|
-
if (
|
|
1091
|
+
if (!isImageId(imageId) || !/^[a-z0-9]{2,4}$/.test(ext)) throw new Error(`非法画廊图片标识:${imageId}.${ext}`);
|
|
830
1092
|
return join(imagesDir(), `${imageId}.${ext}`);
|
|
831
1093
|
}
|
|
832
1094
|
/** 读取图片字节(未知 id 抛错由调用方映射 404)。 */
|
|
@@ -842,7 +1104,7 @@ async function readImageBytes(imageId) {
|
|
|
842
1104
|
}
|
|
843
1105
|
/** 画廊新条目:落图片字节 + 追加元数据(一个事务语义,图片先落)。 */
|
|
844
1106
|
async function saveGalleryItem(input) {
|
|
845
|
-
await ensureDirs();
|
|
1107
|
+
await ensureDirs$1();
|
|
846
1108
|
const imageIds = [];
|
|
847
1109
|
for (const image of input.images) {
|
|
848
1110
|
const imageId = randomUUID();
|
|
@@ -872,9 +1134,9 @@ async function deleteGalleryItem(itemId) {
|
|
|
872
1134
|
for (const name of files) if (name.startsWith(`${imageId}.`)) await rm(join(imagesDir(), name), { force: true });
|
|
873
1135
|
}
|
|
874
1136
|
const kept = items.filter((item) => item.id !== itemId);
|
|
875
|
-
const tmp = `${indexFile()}.tmp-${process.pid}-${Date.now()}`;
|
|
1137
|
+
const tmp = `${indexFile$1()}.tmp-${process.pid}-${Date.now()}`;
|
|
876
1138
|
await writeFile(tmp, kept.map((item) => JSON.stringify(item)).join("\n") + (kept.length > 0 ? "\n" : ""), "utf8");
|
|
877
|
-
await rename(tmp, indexFile());
|
|
1139
|
+
await rename(tmp, indexFile$1());
|
|
878
1140
|
return true;
|
|
879
1141
|
}
|
|
880
1142
|
//#endregion
|
|
@@ -1017,6 +1279,124 @@ function defaultIdCounter() {
|
|
|
1017
1279
|
};
|
|
1018
1280
|
}
|
|
1019
1281
|
//#endregion
|
|
1282
|
+
//#region src/uploads/store.ts
|
|
1283
|
+
/**
|
|
1284
|
+
* 上传暂存区:图生图源图/遮罩的本地文件上传(blobs + JSONL 索引,原子写)。
|
|
1285
|
+
* 与画廊分离——上传图片不属于任何画廊条目;生图成功后由条目的
|
|
1286
|
+
* sourceUploads 引用(衍生链可见),未被任何条目引用且超过 TTL 的由
|
|
1287
|
+
* pruneUploads 回收(见 docs/repo/插件存储规范.md)。
|
|
1288
|
+
* 落点 $DSH_HOME/dsh-plus/image-studio/uploads/{index.jsonl, blobs/}
|
|
1289
|
+
* @module image-studio/uploads/store
|
|
1290
|
+
*/
|
|
1291
|
+
/** 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试环境设置)。 */
|
|
1292
|
+
function uploadsRoot() {
|
|
1293
|
+
return pluginDataPath("image-studio", "uploads");
|
|
1294
|
+
}
|
|
1295
|
+
function indexFile() {
|
|
1296
|
+
return join(uploadsRoot(), "index.jsonl");
|
|
1297
|
+
}
|
|
1298
|
+
function blobsDir() {
|
|
1299
|
+
return join(uploadsRoot(), "blobs");
|
|
1300
|
+
}
|
|
1301
|
+
/** 上传 id 即文件名前缀:uuid 形态白名单,兼作路径穿越防线。 */
|
|
1302
|
+
function validateUploadId(id) {
|
|
1303
|
+
if (!isImageId(id)) throw new Error(`非法上传标识:${id}`);
|
|
1304
|
+
return id;
|
|
1305
|
+
}
|
|
1306
|
+
/** 图片文件绝对路径。 */
|
|
1307
|
+
function blobPathOf(id, ext) {
|
|
1308
|
+
validateUploadId(id);
|
|
1309
|
+
if (!/^[a-z0-9]{2,4}$/.test(ext)) throw new Error(`非法上传扩展名:${ext}`);
|
|
1310
|
+
return join(blobsDir(), `${id}.${ext}`);
|
|
1311
|
+
}
|
|
1312
|
+
async function ensureDirs() {
|
|
1313
|
+
await mkdir(blobsDir(), { recursive: true });
|
|
1314
|
+
}
|
|
1315
|
+
/** 解析 JSONL 全量索引(坏行跳过并告警;空文件返回空表)。 */
|
|
1316
|
+
async function loadUploads(log) {
|
|
1317
|
+
let text;
|
|
1318
|
+
try {
|
|
1319
|
+
text = await readFile(indexFile(), "utf8");
|
|
1320
|
+
} catch {
|
|
1321
|
+
return [];
|
|
1322
|
+
}
|
|
1323
|
+
const items = [];
|
|
1324
|
+
for (const line of text.split("\n")) {
|
|
1325
|
+
if (line.trim().length === 0) continue;
|
|
1326
|
+
try {
|
|
1327
|
+
items.push(JSON.parse(line));
|
|
1328
|
+
} catch {
|
|
1329
|
+
log(`上传索引坏行跳过:${line.slice(0, 80)}`);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
return items;
|
|
1333
|
+
}
|
|
1334
|
+
/** 落盘一张上传图片(临时文件 + rename;索引追加在字节落盘之后)。 */
|
|
1335
|
+
async function saveUpload(input) {
|
|
1336
|
+
await ensureDirs();
|
|
1337
|
+
const id = randomUUID();
|
|
1338
|
+
const ext = extOfMime(input.mime);
|
|
1339
|
+
const target = blobPathOf(id, ext);
|
|
1340
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
1341
|
+
await writeFile(tmp, input.data);
|
|
1342
|
+
await rename(tmp, target);
|
|
1343
|
+
const entry = {
|
|
1344
|
+
id,
|
|
1345
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1346
|
+
name: input.name,
|
|
1347
|
+
mime: input.mime,
|
|
1348
|
+
ext,
|
|
1349
|
+
size: input.data.byteLength
|
|
1350
|
+
};
|
|
1351
|
+
await appendFile(indexFile(), `${JSON.stringify(entry)}\n`, "utf8");
|
|
1352
|
+
return entry;
|
|
1353
|
+
}
|
|
1354
|
+
/** 读取上传图片字节(未知 id 抛错由调用方映射 404)。 */
|
|
1355
|
+
async function readUploadBytes(id) {
|
|
1356
|
+
const hit = (await readdir(blobsDir()).catch(() => [])).find((name) => name.startsWith(`${id}.`));
|
|
1357
|
+
if (hit === void 0) throw new Error(`unknown-upload: ${id}`);
|
|
1358
|
+
const ext = hit.slice(id.length + 1);
|
|
1359
|
+
const data = await readFile(blobPathOf(id, ext));
|
|
1360
|
+
return {
|
|
1361
|
+
data: new Uint8Array(data),
|
|
1362
|
+
ext
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
/** 删除单条(字节 + 索引行)。 */
|
|
1366
|
+
async function removeUpload(entry) {
|
|
1367
|
+
await rm(blobPathOf(entry.id, entry.ext), { force: true });
|
|
1368
|
+
}
|
|
1369
|
+
/** 索引导出为 JSONL(重写式删除的公共收尾)。 */
|
|
1370
|
+
async function rewriteIndex(kept) {
|
|
1371
|
+
const tmp = `${indexFile()}.tmp-${process.pid}-${Date.now()}`;
|
|
1372
|
+
await writeFile(tmp, kept.map((item) => JSON.stringify(item)).join("\n") + (kept.length > 0 ? "\n" : ""), "utf8");
|
|
1373
|
+
await rename(tmp, indexFile());
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* 回收孤儿上传:未被 keepIds 引用且创建时间早于 ttlMs 的条目删除。
|
|
1377
|
+
* @returns 删除条数。
|
|
1378
|
+
*/
|
|
1379
|
+
async function pruneUploads(options) {
|
|
1380
|
+
const { keepIds, ttlMs, log } = options;
|
|
1381
|
+
const now = options.now ?? Date.now();
|
|
1382
|
+
const items = await loadUploads(log);
|
|
1383
|
+
const kept = [];
|
|
1384
|
+
let removed = 0;
|
|
1385
|
+
for (const item of items) {
|
|
1386
|
+
const expired = now - Date.parse(item.createdAt) > ttlMs;
|
|
1387
|
+
if (!keepIds.has(item.id) && expired) {
|
|
1388
|
+
await removeUpload(item).catch((error) => {
|
|
1389
|
+
log(`上传回收失败(忽略):${item.id} ${String(error)}`);
|
|
1390
|
+
});
|
|
1391
|
+
removed += 1;
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
kept.push(item);
|
|
1395
|
+
}
|
|
1396
|
+
if (removed > 0) await rewriteIndex(kept);
|
|
1397
|
+
return removed;
|
|
1398
|
+
}
|
|
1399
|
+
//#endregion
|
|
1020
1400
|
//#region src/service.ts
|
|
1021
1401
|
/** 静态 schema 引用(installSection 用;避免循环 import config → service)。 */
|
|
1022
1402
|
var ImageStudioService = class extends Service {
|
|
@@ -1024,11 +1404,15 @@ var ImageStudioService = class extends Service {
|
|
|
1024
1404
|
current;
|
|
1025
1405
|
runner;
|
|
1026
1406
|
log;
|
|
1407
|
+
/** 进行中的孤儿回收(串行化:索引重写不能并发)。 */
|
|
1408
|
+
pruning;
|
|
1027
1409
|
constructor(ctx, config) {
|
|
1028
1410
|
super(ctx, "imageStudio");
|
|
1029
1411
|
this.current = () => config;
|
|
1030
1412
|
this.log = (message) => ctx.logger("image-studio").warn(message);
|
|
1413
|
+
this.pruning = null;
|
|
1031
1414
|
this.runner = new TaskRunner({ maxConcurrent: config.maxConcurrent });
|
|
1415
|
+
this.schedulePrune();
|
|
1032
1416
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
1033
1417
|
this.settingsRef = settingsCtx.settings;
|
|
1034
1418
|
settingsCtx.settings.installSection(ctx, SETTINGS_NS, Config, config, {
|
|
@@ -1134,7 +1518,7 @@ var ImageStudioService = class extends Service {
|
|
|
1134
1518
|
}
|
|
1135
1519
|
/**
|
|
1136
1520
|
* 任务准备(边界校验集中地):提供商预设/inline → target;
|
|
1137
|
-
* 参数预设 + 覆盖 → 归一化参数表;
|
|
1521
|
+
* 参数预设 + 覆盖 → 归一化参数表;sources/mask 引用 → 输入图字节。
|
|
1138
1522
|
*/
|
|
1139
1523
|
async prepareTask(request) {
|
|
1140
1524
|
const target = await this.resolveTarget(request);
|
|
@@ -1144,18 +1528,52 @@ var ImageStudioService = class extends Service {
|
|
|
1144
1528
|
const params = await this.resolveParams(request, endpoint);
|
|
1145
1529
|
const nValue = params.n;
|
|
1146
1530
|
const n = typeof nValue === "number" ? nValue : 1;
|
|
1147
|
-
const
|
|
1148
|
-
const
|
|
1149
|
-
const
|
|
1531
|
+
const refs = this.sourceRefsOf(request);
|
|
1532
|
+
const maskRef = this.maskRefOf(request);
|
|
1533
|
+
const sources = await this.loadSourceImages(refs, endpoint);
|
|
1534
|
+
const mask = maskRef === null ? null : await this.loadMaskImage(maskRef);
|
|
1150
1535
|
return {
|
|
1151
1536
|
target,
|
|
1152
1537
|
endpoint,
|
|
1153
1538
|
prompt: request.prompt,
|
|
1154
1539
|
n,
|
|
1155
|
-
images,
|
|
1540
|
+
images: sources.images,
|
|
1156
1541
|
mask,
|
|
1157
1542
|
params: stripHostParams(params),
|
|
1158
|
-
sourceIds
|
|
1543
|
+
sourceIds: sources.galleryIds,
|
|
1544
|
+
sourceUploads: sources.uploadIds
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
/** 源图引用规范化(sources 优先;旧 sourceIds 等价于全部 gallery 引用)。 */
|
|
1548
|
+
sourceRefsOf(request) {
|
|
1549
|
+
if (request.sources !== void 0) {
|
|
1550
|
+
if (!Array.isArray(request.sources)) throw new ImageStudioError("invalid-request", "sources 必须是数组");
|
|
1551
|
+
return request.sources.map((ref) => this.assertRef(ref));
|
|
1552
|
+
}
|
|
1553
|
+
const legacy = request.sourceIds ?? [];
|
|
1554
|
+
if (!Array.isArray(legacy)) throw new ImageStudioError("invalid-request", "sourceIds 必须是数组");
|
|
1555
|
+
return legacy.map((id) => this.assertRef({
|
|
1556
|
+
kind: "gallery",
|
|
1557
|
+
id
|
|
1558
|
+
}));
|
|
1559
|
+
}
|
|
1560
|
+
/** 遮罩引用规范化(mask 优先;旧 maskId 等价于 gallery 引用)。 */
|
|
1561
|
+
maskRefOf(request) {
|
|
1562
|
+
if (request.mask !== void 0) return this.assertRef(request.mask);
|
|
1563
|
+
if (request.maskId === void 0) return null;
|
|
1564
|
+
return this.assertRef({
|
|
1565
|
+
kind: "gallery",
|
|
1566
|
+
id: request.maskId
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
/** 引用形态校验(请求体来自浏览器,kind 封闭集合 + id uuid 形态)。 */
|
|
1570
|
+
assertRef(ref) {
|
|
1571
|
+
if (ref === null || typeof ref !== "object") throw new ImageStudioError("invalid-request", "源图引用必须是 { kind, id }");
|
|
1572
|
+
if (ref.kind !== "gallery" && ref.kind !== "upload") throw new ImageStudioError("invalid-request", `未知源图类型:${String(ref.kind)}`);
|
|
1573
|
+
if (!isImageId(ref.id)) throw new ImageStudioError("invalid-request", `非法源图标识:${String(ref.id)}`);
|
|
1574
|
+
return {
|
|
1575
|
+
kind: ref.kind,
|
|
1576
|
+
id: ref.id
|
|
1159
1577
|
};
|
|
1160
1578
|
}
|
|
1161
1579
|
/** 提供商预设 / inline → ProviderTarget(凭据即时 resolve)。 */
|
|
@@ -1227,28 +1645,49 @@ var ImageStudioService = class extends Service {
|
|
|
1227
1645
|
if (presetId === void 0) return "";
|
|
1228
1646
|
return this.providerPresets().find((item) => item.id === presetId)?.model ?? "";
|
|
1229
1647
|
}
|
|
1230
|
-
/**
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1648
|
+
/**
|
|
1649
|
+
* 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。
|
|
1650
|
+
* 按引用来源分流读取,并把两类 id 分开回传(画廊衍生链 / 上传暂存区)。
|
|
1651
|
+
*/
|
|
1652
|
+
async loadSourceImages(refs, endpoint) {
|
|
1653
|
+
if (endpoint === "generation") return {
|
|
1654
|
+
images: [],
|
|
1655
|
+
galleryIds: [],
|
|
1656
|
+
uploadIds: []
|
|
1657
|
+
};
|
|
1658
|
+
if (refs.length === 0) throw new ImageStudioError("invalid-request", "edit 端点必须提供至少一张源图(sources)");
|
|
1659
|
+
if (refs.length > 16) throw new ImageStudioError("invalid-request", `源图最多 16 张(官方 GPT Image 限制)`);
|
|
1235
1660
|
const images = [];
|
|
1236
|
-
|
|
1237
|
-
|
|
1661
|
+
const galleryIds = [];
|
|
1662
|
+
const uploadIds = [];
|
|
1663
|
+
for (const ref of refs) {
|
|
1664
|
+
images.push(await this.loadRefImage(ref));
|
|
1665
|
+
if (ref.kind === "gallery") galleryIds.push(ref.id);
|
|
1666
|
+
else uploadIds.push(ref.id);
|
|
1667
|
+
}
|
|
1668
|
+
return {
|
|
1669
|
+
images,
|
|
1670
|
+
galleryIds,
|
|
1671
|
+
uploadIds
|
|
1672
|
+
};
|
|
1238
1673
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1674
|
+
/** 单个引用 → 输入图字节(两个 id 空间分别查,未知一律 unknown-source)。 */
|
|
1675
|
+
async loadRefImage(ref) {
|
|
1676
|
+
const found = ref.kind === "gallery" ? await this.imageBytes(ref.id) : await this.uploadBytes(ref.id);
|
|
1677
|
+
if (found === null) throw new ImageStudioError("unknown-source", `源图不存在:${ref.kind}:${ref.id}`);
|
|
1678
|
+
return {
|
|
1679
|
+
data: found.data,
|
|
1680
|
+
mime: mimeOfExt(found.ext)
|
|
1681
|
+
};
|
|
1241
1682
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
throw new ImageStudioError("unknown-source", `源图不存在:${imageId}`);
|
|
1251
|
-
}
|
|
1683
|
+
/**
|
|
1684
|
+
* 遮罩加载:官方要求带透明通道的 PNG(其它格式上游必然拒绝),
|
|
1685
|
+
* 在边界提前给出可读原因,避免白跑一次计费请求。
|
|
1686
|
+
*/
|
|
1687
|
+
async loadMaskImage(ref) {
|
|
1688
|
+
const image = await this.loadRefImage(ref);
|
|
1689
|
+
if (image.mime !== "image/png") throw new ImageStudioError("unsupported-media", `遮罩必须是 PNG(当前 ${image.mime}):请上传带透明区域的 PNG`);
|
|
1690
|
+
return image;
|
|
1252
1691
|
}
|
|
1253
1692
|
/** 任务体:调协议适配器 → 成功入画廊。失败向上抛给 TaskRunner 记录。 */
|
|
1254
1693
|
async executeTask(snapshot, signal) {
|
|
@@ -1263,7 +1702,7 @@ var ImageStudioService = class extends Service {
|
|
|
1263
1702
|
mask: snapshot.mask,
|
|
1264
1703
|
params: snapshot.params
|
|
1265
1704
|
}, signal);
|
|
1266
|
-
|
|
1705
|
+
const item = await saveGalleryItem({
|
|
1267
1706
|
item: {
|
|
1268
1707
|
providerPresetId: this.presetIdOf(snapshot),
|
|
1269
1708
|
protocol: snapshot.target.protocol,
|
|
@@ -1271,14 +1710,17 @@ var ImageStudioService = class extends Service {
|
|
|
1271
1710
|
model: snapshot.target.model,
|
|
1272
1711
|
prompt: snapshot.prompt,
|
|
1273
1712
|
params: snapshot.params,
|
|
1274
|
-
sourceIds: snapshot.sourceIds
|
|
1713
|
+
sourceIds: snapshot.sourceIds,
|
|
1714
|
+
sourceUploads: snapshot.sourceUploads
|
|
1275
1715
|
},
|
|
1276
1716
|
images: result.images.map((image) => ({
|
|
1277
1717
|
data: image.data,
|
|
1278
1718
|
mime: image.mime,
|
|
1279
1719
|
revisedPrompt: image.revisedPrompt
|
|
1280
1720
|
}))
|
|
1281
|
-
})
|
|
1721
|
+
});
|
|
1722
|
+
this.schedulePrune();
|
|
1723
|
+
return { galleryItemId: item.id };
|
|
1282
1724
|
}
|
|
1283
1725
|
/** 提交时上下文里的预设 id(executeTask 无原请求,经 target 旁路携带)。 */
|
|
1284
1726
|
presetIdOf(snapshot) {
|
|
@@ -1300,6 +1742,44 @@ var ImageStudioService = class extends Service {
|
|
|
1300
1742
|
return null;
|
|
1301
1743
|
}
|
|
1302
1744
|
}
|
|
1745
|
+
/** 保存上传原图(端点边界已完成 MIME 嗅探与限额)。 */
|
|
1746
|
+
async saveUpload(input) {
|
|
1747
|
+
const entry = await saveUpload(input);
|
|
1748
|
+
this.schedulePrune();
|
|
1749
|
+
return entry;
|
|
1750
|
+
}
|
|
1751
|
+
/** 上传列表(索引序,最新在后;前端自行倒序展示)。 */
|
|
1752
|
+
async listUploads() {
|
|
1753
|
+
return loadUploads(this.log);
|
|
1754
|
+
}
|
|
1755
|
+
/** 读取上传图片字节(缩略图端点;未知 id 返回 null)。 */
|
|
1756
|
+
async uploadBytes(uploadId) {
|
|
1757
|
+
try {
|
|
1758
|
+
return await readUploadBytes(uploadId);
|
|
1759
|
+
} catch {
|
|
1760
|
+
return null;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
/** 触发一次孤儿回收(并发调用合并为一次,失败不影响主流程)。 */
|
|
1764
|
+
schedulePrune() {
|
|
1765
|
+
if (this.pruning !== null) return;
|
|
1766
|
+
this.pruning = this.pruneOrphans().catch((error) => {
|
|
1767
|
+
this.log(`上传回收失败(忽略):${error instanceof Error ? error.message : String(error)}`);
|
|
1768
|
+
}).finally(() => {
|
|
1769
|
+
this.pruning = null;
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
/** 保留被画廊条目引用的上传;其余超过 TTL 的回收(暂存区不无界增长)。 */
|
|
1773
|
+
async pruneOrphans() {
|
|
1774
|
+
const keepIds = /* @__PURE__ */ new Set();
|
|
1775
|
+
for (const item of await this.gallery()) for (const id of item.sourceUploads) keepIds.add(id);
|
|
1776
|
+
const removed = await pruneUploads({
|
|
1777
|
+
keepIds,
|
|
1778
|
+
ttlMs: this.current().uploadTtlHours * 36e5,
|
|
1779
|
+
log: this.log
|
|
1780
|
+
});
|
|
1781
|
+
if (removed > 0) this.log(`回收孤儿上传 ${removed} 个`);
|
|
1782
|
+
}
|
|
1303
1783
|
dispose() {}
|
|
1304
1784
|
};
|
|
1305
1785
|
/** 剔除 host 承载参数(model 不进协议参数表——openai-images 请求体单独拼)。 */
|
|
@@ -1315,4 +1795,4 @@ function apply(ctx, config) {
|
|
|
1315
1795
|
ctx.plugin(ImageStudioService, config);
|
|
1316
1796
|
}
|
|
1317
1797
|
//#endregion
|
|
1318
|
-
export { Config, ImageStudioError, ImageStudioService, PARAM_CATALOG, ParamValidationError, apply, credentialRefNameOf, inject, isValidPresetId, name, normalizeParamSpecs, paramEntryOf, paramKeysFor, validateParamSpecs };
|
|
1798
|
+
export { Config, IMAGE_SIZE_RULE, ImageStudioError, ImageStudioService, MAX_SOURCE_IMAGES, PARAM_CATALOG, ParamValidationError, SIZE_PRESETS, UPLOAD_MAX_BYTES, apply, checkImageSize, credentialRefNameOf, detectImageMime, extOfMime, inject, isImageId, isValidPresetId, mimeOfExt, name, normalizeParamSpecs, paramEntryOf, paramKeysFor, statusOfCode, validateParamSpecs };
|