@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/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: "尺寸,如 1024x1024 / 1536x1024 / 1024x1536 / auto;宽高需为 16 的倍数、最大边 ≤3840、比例 ≤3:1(依模型限制)"
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) => chunks.push(Buffer.from(text, "utf8"));
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?.message === "string" && doc.error.message.length > 0) return doc.error.message;
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 === "jpg" ? "image/jpeg" : `image/${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 [head, ...tail] = new URL(req.url ?? "/", "http://localhost").pathname.slice(22).replace(/^\//, "").split("/");
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 (!/^[a-f0-9-]{36}$/.test(imageId) || !/^[a-z0-9]{2,4}$/.test(ext)) throw new Error(`非法画廊图片标识:${imageId}.${ext}`);
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
- * 参数预设 + 覆盖 → 归一化参数表;sourceIds → 输入图字节。
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 sourceIds = request.sourceIds ?? [];
1148
- const images = await this.loadSourceImages(sourceIds, endpoint);
1149
- const mask = request.maskId !== void 0 ? await this.loadMask(request.maskId) : null;
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
- /** 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。 */
1231
- async loadSourceImages(sourceIds, endpoint) {
1232
- if (endpoint === "generation") return [];
1233
- if (sourceIds.length === 0) throw new ImageStudioError("invalid-request", "edit 端点必须提供至少一张源图(sourceIds)");
1234
- if (sourceIds.length > 16) throw new ImageStudioError("invalid-request", "源图最多 16 张(官方 GPT Image 限制)");
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
- for (const imageId of sourceIds) images.push(await this.loadOneImage(imageId));
1237
- return images;
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
- async loadMask(maskId) {
1240
- return this.loadOneImage(maskId);
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
- async loadOneImage(imageId) {
1243
- try {
1244
- const { data, ext } = await readImageBytes(imageId);
1245
- return {
1246
- data,
1247
- mime: ext === "jpg" ? "image/jpeg" : `image/${ext}`
1248
- };
1249
- } catch {
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
- return { galleryItemId: (await saveGalleryItem({
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
- })).id };
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 };