@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/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)。
@@ -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 === "jpg" ? "image/jpeg" : `image/${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 [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("/");
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 (!/^[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}`);
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
- * 参数预设 + 覆盖 → 归一化参数表;sourceIds → 输入图字节。
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 sourceIds = request.sourceIds ?? [];
1153
- const images = await this.loadSourceImages(sourceIds, endpoint);
1154
- 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);
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
- /** 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。 */
1236
- async loadSourceImages(sourceIds, endpoint) {
1237
- if (endpoint === "generation") return [];
1238
- if (sourceIds.length === 0) throw new ImageStudioError("invalid-request", "edit 端点必须提供至少一张源图(sourceIds)");
1239
- 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 限制)`);
1240
1660
  const images = [];
1241
- for (const imageId of sourceIds) images.push(await this.loadOneImage(imageId));
1242
- 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
+ };
1243
1673
  }
1244
- async loadMask(maskId) {
1245
- 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
+ };
1246
1682
  }
1247
- async loadOneImage(imageId) {
1248
- try {
1249
- const { data, ext } = await readImageBytes(imageId);
1250
- return {
1251
- data,
1252
- mime: ext === "jpg" ? "image/jpeg" : `image/${ext}`
1253
- };
1254
- } catch {
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
- return { galleryItemId: (await saveGalleryItem({
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
- })).id };
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 };