@dsh-plus/image-studio 0.1.6 → 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 +171 -9
- package/lib/index.js +530 -55
- package/package.json +1 -1
- package/src/api.ts +83 -3
- package/src/client/api.ts +25 -0
- package/src/client/card.tsx +12 -0
- 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 +17 -3
- 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/service.ts +157 -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)。
|
|
@@ -559,6 +737,8 @@ function createProvider(id) {
|
|
|
559
737
|
//#endregion
|
|
560
738
|
//#region src/api.ts
|
|
561
739
|
const ROUTE_PREFIX = "/dsh-plus/image-studio";
|
|
740
|
+
/** 上传文件名长度上限(仅展示用途,超长截断)。 */
|
|
741
|
+
const UPLOAD_NAME_MAX = 200;
|
|
562
742
|
function sendJson(res, status, body) {
|
|
563
743
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
564
744
|
res.end(JSON.stringify(body));
|
|
@@ -571,6 +751,27 @@ function readBody(req) {
|
|
|
571
751
|
req.on("error", reject);
|
|
572
752
|
});
|
|
573
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
|
+
}
|
|
574
775
|
/** 统一错误结算:ImageStudioError/ParamValidationError 按码映射,其余 500。 */
|
|
575
776
|
function sendError(res, error) {
|
|
576
777
|
if (error instanceof ImageStudioError) {
|
|
@@ -592,6 +793,12 @@ function sendError(res, error) {
|
|
|
592
793
|
message: error instanceof Error ? error.message : String(error)
|
|
593
794
|
});
|
|
594
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
|
+
}
|
|
595
802
|
function buildHandlers(service) {
|
|
596
803
|
return {
|
|
597
804
|
async generate(_req, res, rest) {
|
|
@@ -666,13 +873,50 @@ function buildHandlers(service) {
|
|
|
666
873
|
sendJson(res, 404, { error: "not-found" });
|
|
667
874
|
return;
|
|
668
875
|
}
|
|
669
|
-
const mime = bytes.ext
|
|
876
|
+
const mime = mimeOfExt(bytes.ext);
|
|
670
877
|
res.writeHead(200, {
|
|
671
878
|
"content-type": mime,
|
|
672
879
|
"cache-control": "private, max-age=3600"
|
|
673
880
|
});
|
|
674
881
|
res.end(Buffer.from(bytes.data));
|
|
675
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
|
+
},
|
|
676
920
|
async credentials(_req, res, rest) {
|
|
677
921
|
const refName = rest.replace(/\/$/, "");
|
|
678
922
|
if (refName.length === 0) {
|
|
@@ -736,7 +980,8 @@ function registerImageStudioApi(ctx, service) {
|
|
|
736
980
|
kind: "prefix",
|
|
737
981
|
path: ROUTE_PREFIX,
|
|
738
982
|
handler: (req, res) => {
|
|
739
|
-
const
|
|
983
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
984
|
+
const [head, ...tail] = url.pathname.slice(22).replace(/^\//, "").split("/");
|
|
740
985
|
const rest = tail.join("/");
|
|
741
986
|
(async () => {
|
|
742
987
|
if (head === void 0 || head === "") {
|
|
@@ -748,7 +993,7 @@ function registerImageStudioApi(ctx, service) {
|
|
|
748
993
|
sendJson(res, 404, { error: `unknown endpoint: ${head}` });
|
|
749
994
|
return;
|
|
750
995
|
}
|
|
751
|
-
await handler(req, res, rest);
|
|
996
|
+
await handler(req, res, rest, url);
|
|
752
997
|
})().catch((error) => sendError(res, error));
|
|
753
998
|
}
|
|
754
999
|
});
|
|
@@ -773,6 +1018,18 @@ function credentialRefNameOf(presetId) {
|
|
|
773
1018
|
return `${REF_PREFIX}${presetId.replaceAll("-", "_").toUpperCase()}`;
|
|
774
1019
|
}
|
|
775
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
|
|
776
1033
|
//#region src/gallery/store.ts
|
|
777
1034
|
/**
|
|
778
1035
|
* 画廊存储:元数据 JSONL(追加)+ 图片落盘(原子写)。
|
|
@@ -787,29 +1044,29 @@ function credentialRefNameOf(presetId) {
|
|
|
787
1044
|
function galleryRoot() {
|
|
788
1045
|
return pluginDataPath("image-studio", "gallery");
|
|
789
1046
|
}
|
|
790
|
-
function indexFile() {
|
|
1047
|
+
function indexFile$1() {
|
|
791
1048
|
return join(galleryRoot(), "index.jsonl");
|
|
792
1049
|
}
|
|
793
1050
|
function imagesDir() {
|
|
794
1051
|
return join(galleryRoot(), "images");
|
|
795
1052
|
}
|
|
796
|
-
/** 图片扩展名由 MIME 推导(仅白名单格式)。 */
|
|
797
|
-
function extOfMime(mime) {
|
|
798
|
-
switch (mime) {
|
|
799
|
-
case "image/jpeg": return "jpg";
|
|
800
|
-
case "image/webp": return "webp";
|
|
801
|
-
default: return "png";
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
1053
|
/** 确保目录存在。 */
|
|
805
|
-
async function ensureDirs() {
|
|
1054
|
+
async function ensureDirs$1() {
|
|
806
1055
|
await mkdir(imagesDir(), { recursive: true });
|
|
807
1056
|
}
|
|
1057
|
+
/** 旧条目补齐新增字段(sourceUploads 为后加字段,历史 JSONL 里不存在)。 */
|
|
1058
|
+
function normalizeItem(item) {
|
|
1059
|
+
return {
|
|
1060
|
+
...item,
|
|
1061
|
+
sourceIds: item.sourceIds ?? [],
|
|
1062
|
+
sourceUploads: item.sourceUploads ?? []
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
808
1065
|
/** 解析 JSONL 全量元数据(坏行跳过并告警;空文件返回空表)。 */
|
|
809
1066
|
async function loadGallery(log) {
|
|
810
1067
|
let text;
|
|
811
1068
|
try {
|
|
812
|
-
text = await readFile(indexFile(), "utf8");
|
|
1069
|
+
text = await readFile(indexFile$1(), "utf8");
|
|
813
1070
|
} catch {
|
|
814
1071
|
return [];
|
|
815
1072
|
}
|
|
@@ -817,7 +1074,7 @@ async function loadGallery(log) {
|
|
|
817
1074
|
for (const line of text.split("\n")) {
|
|
818
1075
|
if (line.trim().length === 0) continue;
|
|
819
1076
|
try {
|
|
820
|
-
items.push(JSON.parse(line));
|
|
1077
|
+
items.push(normalizeItem(JSON.parse(line)));
|
|
821
1078
|
} catch {
|
|
822
1079
|
log(`画廊元数据坏行跳过:${line.slice(0, 80)}`);
|
|
823
1080
|
}
|
|
@@ -826,12 +1083,12 @@ async function loadGallery(log) {
|
|
|
826
1083
|
}
|
|
827
1084
|
/** 追加一条元数据(JSONL 原子性:单行小写入)。 */
|
|
828
1085
|
async function appendIndex(item) {
|
|
829
|
-
await ensureDirs();
|
|
830
|
-
await appendFile(indexFile(), `${JSON.stringify(item)}\n`, "utf8");
|
|
1086
|
+
await ensureDirs$1();
|
|
1087
|
+
await appendFile(indexFile$1(), `${JSON.stringify(item)}\n`, "utf8");
|
|
831
1088
|
}
|
|
832
1089
|
/** 图片文件绝对路径。 */
|
|
833
1090
|
function imagePathOf(imageId, ext) {
|
|
834
|
-
if (
|
|
1091
|
+
if (!isImageId(imageId) || !/^[a-z0-9]{2,4}$/.test(ext)) throw new Error(`非法画廊图片标识:${imageId}.${ext}`);
|
|
835
1092
|
return join(imagesDir(), `${imageId}.${ext}`);
|
|
836
1093
|
}
|
|
837
1094
|
/** 读取图片字节(未知 id 抛错由调用方映射 404)。 */
|
|
@@ -847,7 +1104,7 @@ async function readImageBytes(imageId) {
|
|
|
847
1104
|
}
|
|
848
1105
|
/** 画廊新条目:落图片字节 + 追加元数据(一个事务语义,图片先落)。 */
|
|
849
1106
|
async function saveGalleryItem(input) {
|
|
850
|
-
await ensureDirs();
|
|
1107
|
+
await ensureDirs$1();
|
|
851
1108
|
const imageIds = [];
|
|
852
1109
|
for (const image of input.images) {
|
|
853
1110
|
const imageId = randomUUID();
|
|
@@ -877,9 +1134,9 @@ async function deleteGalleryItem(itemId) {
|
|
|
877
1134
|
for (const name of files) if (name.startsWith(`${imageId}.`)) await rm(join(imagesDir(), name), { force: true });
|
|
878
1135
|
}
|
|
879
1136
|
const kept = items.filter((item) => item.id !== itemId);
|
|
880
|
-
const tmp = `${indexFile()}.tmp-${process.pid}-${Date.now()}`;
|
|
1137
|
+
const tmp = `${indexFile$1()}.tmp-${process.pid}-${Date.now()}`;
|
|
881
1138
|
await writeFile(tmp, kept.map((item) => JSON.stringify(item)).join("\n") + (kept.length > 0 ? "\n" : ""), "utf8");
|
|
882
|
-
await rename(tmp, indexFile());
|
|
1139
|
+
await rename(tmp, indexFile$1());
|
|
883
1140
|
return true;
|
|
884
1141
|
}
|
|
885
1142
|
//#endregion
|
|
@@ -1022,6 +1279,124 @@ function defaultIdCounter() {
|
|
|
1022
1279
|
};
|
|
1023
1280
|
}
|
|
1024
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
|
|
1025
1400
|
//#region src/service.ts
|
|
1026
1401
|
/** 静态 schema 引用(installSection 用;避免循环 import config → service)。 */
|
|
1027
1402
|
var ImageStudioService = class extends Service {
|
|
@@ -1029,11 +1404,15 @@ var ImageStudioService = class extends Service {
|
|
|
1029
1404
|
current;
|
|
1030
1405
|
runner;
|
|
1031
1406
|
log;
|
|
1407
|
+
/** 进行中的孤儿回收(串行化:索引重写不能并发)。 */
|
|
1408
|
+
pruning;
|
|
1032
1409
|
constructor(ctx, config) {
|
|
1033
1410
|
super(ctx, "imageStudio");
|
|
1034
1411
|
this.current = () => config;
|
|
1035
1412
|
this.log = (message) => ctx.logger("image-studio").warn(message);
|
|
1413
|
+
this.pruning = null;
|
|
1036
1414
|
this.runner = new TaskRunner({ maxConcurrent: config.maxConcurrent });
|
|
1415
|
+
this.schedulePrune();
|
|
1037
1416
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
1038
1417
|
this.settingsRef = settingsCtx.settings;
|
|
1039
1418
|
settingsCtx.settings.installSection(ctx, SETTINGS_NS, Config, config, {
|
|
@@ -1139,7 +1518,7 @@ var ImageStudioService = class extends Service {
|
|
|
1139
1518
|
}
|
|
1140
1519
|
/**
|
|
1141
1520
|
* 任务准备(边界校验集中地):提供商预设/inline → target;
|
|
1142
|
-
* 参数预设 + 覆盖 → 归一化参数表;
|
|
1521
|
+
* 参数预设 + 覆盖 → 归一化参数表;sources/mask 引用 → 输入图字节。
|
|
1143
1522
|
*/
|
|
1144
1523
|
async prepareTask(request) {
|
|
1145
1524
|
const target = await this.resolveTarget(request);
|
|
@@ -1149,18 +1528,52 @@ var ImageStudioService = class extends Service {
|
|
|
1149
1528
|
const params = await this.resolveParams(request, endpoint);
|
|
1150
1529
|
const nValue = params.n;
|
|
1151
1530
|
const n = typeof nValue === "number" ? nValue : 1;
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1154
|
-
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);
|
|
1155
1535
|
return {
|
|
1156
1536
|
target,
|
|
1157
1537
|
endpoint,
|
|
1158
1538
|
prompt: request.prompt,
|
|
1159
1539
|
n,
|
|
1160
|
-
images,
|
|
1540
|
+
images: sources.images,
|
|
1161
1541
|
mask,
|
|
1162
1542
|
params: stripHostParams(params),
|
|
1163
|
-
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
|
|
1164
1577
|
};
|
|
1165
1578
|
}
|
|
1166
1579
|
/** 提供商预设 / inline → ProviderTarget(凭据即时 resolve)。 */
|
|
@@ -1232,28 +1645,49 @@ var ImageStudioService = class extends Service {
|
|
|
1232
1645
|
if (presetId === void 0) return "";
|
|
1233
1646
|
return this.providerPresets().find((item) => item.id === presetId)?.model ?? "";
|
|
1234
1647
|
}
|
|
1235
|
-
/**
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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 限制)`);
|
|
1240
1660
|
const images = [];
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
+
};
|
|
1243
1673
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
+
};
|
|
1246
1682
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
throw new ImageStudioError("unknown-source", `源图不存在:${imageId}`);
|
|
1256
|
-
}
|
|
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;
|
|
1257
1691
|
}
|
|
1258
1692
|
/** 任务体:调协议适配器 → 成功入画廊。失败向上抛给 TaskRunner 记录。 */
|
|
1259
1693
|
async executeTask(snapshot, signal) {
|
|
@@ -1268,7 +1702,7 @@ var ImageStudioService = class extends Service {
|
|
|
1268
1702
|
mask: snapshot.mask,
|
|
1269
1703
|
params: snapshot.params
|
|
1270
1704
|
}, signal);
|
|
1271
|
-
|
|
1705
|
+
const item = await saveGalleryItem({
|
|
1272
1706
|
item: {
|
|
1273
1707
|
providerPresetId: this.presetIdOf(snapshot),
|
|
1274
1708
|
protocol: snapshot.target.protocol,
|
|
@@ -1276,14 +1710,17 @@ var ImageStudioService = class extends Service {
|
|
|
1276
1710
|
model: snapshot.target.model,
|
|
1277
1711
|
prompt: snapshot.prompt,
|
|
1278
1712
|
params: snapshot.params,
|
|
1279
|
-
sourceIds: snapshot.sourceIds
|
|
1713
|
+
sourceIds: snapshot.sourceIds,
|
|
1714
|
+
sourceUploads: snapshot.sourceUploads
|
|
1280
1715
|
},
|
|
1281
1716
|
images: result.images.map((image) => ({
|
|
1282
1717
|
data: image.data,
|
|
1283
1718
|
mime: image.mime,
|
|
1284
1719
|
revisedPrompt: image.revisedPrompt
|
|
1285
1720
|
}))
|
|
1286
|
-
})
|
|
1721
|
+
});
|
|
1722
|
+
this.schedulePrune();
|
|
1723
|
+
return { galleryItemId: item.id };
|
|
1287
1724
|
}
|
|
1288
1725
|
/** 提交时上下文里的预设 id(executeTask 无原请求,经 target 旁路携带)。 */
|
|
1289
1726
|
presetIdOf(snapshot) {
|
|
@@ -1305,6 +1742,44 @@ var ImageStudioService = class extends Service {
|
|
|
1305
1742
|
return null;
|
|
1306
1743
|
}
|
|
1307
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
|
+
}
|
|
1308
1783
|
dispose() {}
|
|
1309
1784
|
};
|
|
1310
1785
|
/** 剔除 host 承载参数(model 不进协议参数表——openai-images 请求体单独拼)。 */
|
|
@@ -1320,4 +1795,4 @@ function apply(ctx, config) {
|
|
|
1320
1795
|
ctx.plugin(ImageStudioService, config);
|
|
1321
1796
|
}
|
|
1322
1797
|
//#endregion
|
|
1323
|
-
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 };
|