@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.
@@ -11,6 +11,9 @@ import type { ImageEndpoint } from '../provider/types.ts'
11
11
  /** 参数值类型(校验与前端控件形态的依据)。 */
12
12
  export type ParamKind = 'string' | 'integer' | 'enum' | 'boolean'
13
13
 
14
+ /** 值域规则 id(目录声明、边界校验与前端提示共用同一实现)。 */
15
+ export type ParamRuleId = 'image-size'
16
+
14
17
  /** 单个参数的目录条目。 */
15
18
  export interface ParamEntry {
16
19
  /** 请求体字段名(官方 API 参数名)。 */
@@ -23,6 +26,8 @@ export interface ParamEntry {
23
26
  min?: number
24
27
  /** integer 时的上界(含)。 */
25
28
  max?: number
29
+ /** 结构化值域规则(如尺寸的倍数/比例/像素约束);无则仅按 kind 校验。 */
30
+ rules?: ParamRuleId
26
31
  /** 适用端点(new-array 是目录实现细节,不外泄)。 */
27
32
  applies: readonly ImageEndpoint[]
28
33
  /** 适用模型代际:current(GPT Image)| dall-e-3 | dall-e-2 | all。 */
@@ -56,20 +61,21 @@ const CATALOG_RAW: readonly ParamEntry[] = [
56
61
  {
57
62
  key: 'size',
58
63
  kind: 'string',
64
+ rules: 'image-size',
59
65
  applies: ['generation', 'edit'],
60
66
  generation: 'current',
61
67
  advanced: false,
62
68
  description:
63
- '尺寸,如 1024x1024 / 1536x1024 / 1024x1536 / auto;宽高需为 16 的倍数、最大边 ≤3840、比例 ≤3:1(依模型限制)',
69
+ '尺寸:auto 或「宽x高」。推荐 1024x1024 / 1536x1024 / 1024x1536;自定义需宽高为 16 的倍数、长边 ≤3840、比例 ≤3:1、总像素 655360-8294400',
64
70
  },
65
71
  {
66
72
  key: 'quality',
67
73
  kind: 'enum',
68
- values: ['auto', 'low', 'medium', 'high'],
74
+ values: ['auto', 'low', 'medium', 'high', 'xhigh', 'max'],
69
75
  applies: ['generation', 'edit'],
70
76
  generation: 'current',
71
77
  advanced: false,
72
- description: '质量档位',
78
+ description: '质量档位(xhigh/max 为 image 2.5 新增高档位)',
73
79
  },
74
80
  {
75
81
  key: 'background',
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 官方图像尺寸规则与预设尺寸(口径取 gpt-image 系列当前代文档):
3
+ * - 快捷预设:auto + 三档推荐尺寸 + 常见宽屏/竖屏/2K/4K;
4
+ * - 自定义尺寸:宽高均为 16 的倍数、长边 ≤3840、比例 1:3~3:1、
5
+ * 总像素 655,360–8,294,400;
6
+ * - 自由输入始终保留:预设只是快捷入口,非白名单。
7
+ * 同一份规则同时服务宿主边界校验(params/spec)与浏览器半即时提示(参数表单)。
8
+ * 纯函数零依赖,浏览器半可安全引入。
9
+ * @module image-studio/params/size
10
+ */
11
+
12
+ /** 自定义尺寸约束(数值即官方文档口径)。 */
13
+ export interface SizeRule {
14
+ /** 宽高须为其整数倍。 */
15
+ multiple: number
16
+ /** 长边上限(px)。 */
17
+ maxEdge: number
18
+ /** 总像素下限/上限。 */
19
+ minPixels: number
20
+ maxPixels: number
21
+ /** 长边与短边的比例上限(3 即 3:1)。 */
22
+ maxAspect: number
23
+ }
24
+
25
+ /** GPT Image 代自定义尺寸规则。 */
26
+ export const IMAGE_SIZE_RULE: SizeRule = Object.freeze({
27
+ multiple: 16,
28
+ maxEdge: 3840,
29
+ minPixels: 655_360,
30
+ maxPixels: 8_294_400,
31
+ maxAspect: 3,
32
+ })
33
+
34
+ /** 免校验取值:实际尺寸由模型按提示词决定。 */
35
+ export const SIZE_AUTO = 'auto'
36
+
37
+ /** 预设尺寸(快捷选择项,非合法值白名单)。 */
38
+ export interface SizePreset {
39
+ value: string
40
+ /** 面向用户的说明(比例/用途)。 */
41
+ label: string
42
+ }
43
+
44
+ /** 预设尺寸表(顺序即展示序:常用在前;全部满足 16 倍数约束)。 */
45
+ export const SIZE_PRESETS: readonly SizePreset[] = Object.freeze([
46
+ Object.freeze({ value: SIZE_AUTO, label: 'auto(由模型决定)' }),
47
+ Object.freeze({ value: '1024x1024', label: '1024×1024 方形 1:1' }),
48
+ Object.freeze({ value: '1536x1024', label: '1536×1024 横向 3:2' }),
49
+ Object.freeze({ value: '1024x1536', label: '1024×1536 纵向 2:3' }),
50
+ Object.freeze({ value: '2048x1152', label: '2048×1152 宽屏 16:9' }),
51
+ Object.freeze({ value: '1152x2048', label: '1152×2048 竖屏 9:16' }),
52
+ Object.freeze({ value: '3840x2160', label: '3840×2160 4K 16:9' }),
53
+ Object.freeze({ value: '2160x3840', label: '2160×3840 4K 9:16' }),
54
+ ])
55
+
56
+ /** 校验结论:通过或一条面向用户的原因(中文,直接展示)。 */
57
+ export type SizeCheck = { ok: true } | { ok: false; message: string }
58
+
59
+ const OK: SizeCheck = { ok: true }
60
+
61
+ /** 「宽x高」规范形态(小写 x、无空白;`auto` 原样)。 */
62
+ export function normalizeImageSize(value: string): string {
63
+ const text = value.trim()
64
+ if (text === SIZE_AUTO) return SIZE_AUTO
65
+ return text.replace(/[×✕✖]/g, 'x').replace(/\s+/g, '').toLowerCase()
66
+ }
67
+
68
+ /** 规范化后的「宽x高」形态。 */
69
+ const SIZE_PATTERN = /^(\d{2,5})x(\d{2,5})$/
70
+
71
+ /**
72
+ * 校验一个尺寸取值(`auto` 或「宽x高」,容忍大小写/全角乘号/空白)。
73
+ * @param value - 用户输入或预设值(提交/保存前校验)。
74
+ * @param rule - 尺寸约束(缺省 GPT Image 代规则)。
75
+ */
76
+ export function checkImageSize(value: string, rule: SizeRule = IMAGE_SIZE_RULE): SizeCheck {
77
+ const text = normalizeImageSize(value)
78
+ if (text === SIZE_AUTO) return OK
79
+ const match = SIZE_PATTERN.exec(text)
80
+ if (match === null) return fail('需为「宽x高」(如 1024x1024)或 auto')
81
+ const width = Number(match[1])
82
+ const height = Number(match[2])
83
+ if (width % rule.multiple !== 0 || height % rule.multiple !== 0) {
84
+ return fail(`宽高需为 ${rule.multiple} 的倍数(当前 ${width}x${height})`)
85
+ }
86
+ const long = Math.max(width, height)
87
+ const short = Math.min(width, height)
88
+ if (long > rule.maxEdge) return fail(`长边不能超过 ${rule.maxEdge}px(当前 ${long}px)`)
89
+ if (long / short > rule.maxAspect) return fail(`宽高比不能超过 ${rule.maxAspect}:1`)
90
+ const pixels = width * height
91
+ if (pixels < rule.minPixels) return fail(`总像素不能少于 ${rule.minPixels}(当前 ${pixels})`)
92
+ if (pixels > rule.maxPixels) return fail(`总像素不能超过 ${rule.maxPixels}(当前 ${pixels})`)
93
+ return OK
94
+ }
95
+
96
+ function fail(message: string): SizeCheck {
97
+ return { ok: false, message }
98
+ }
@@ -5,7 +5,8 @@
5
5
  * @module image-studio/params/spec
6
6
  */
7
7
  import type { ImageEndpoint } from '../provider/types.ts'
8
- import { paramEntryOf } from './catalog.ts'
8
+ import { type ParamRuleId, paramEntryOf } from './catalog.ts'
9
+ import { checkImageSize, normalizeImageSize } from './size.ts'
9
10
 
10
11
  /** 单参数的开关 + 自定义值。 */
11
12
  export interface ParamSpec {
@@ -36,7 +37,7 @@ export function validateParamValue(key: string, value: unknown): unknown {
36
37
  if (typeof value !== 'string' || value.length === 0) {
37
38
  throw new ParamValidationError(key, '必须是非空字符串')
38
39
  }
39
- return value
40
+ return validateRule(entry.rules, key, value)
40
41
  case 'enum':
41
42
  if (typeof value !== 'string' || !entry.values?.includes(value)) {
42
43
  throw new ParamValidationError(key, `必须是 ${entry.values?.join(' | ') ?? '枚举值'} 之一`)
@@ -60,6 +61,20 @@ export function validateParamValue(key: string, value: unknown): unknown {
60
61
  }
61
62
  }
62
63
 
64
+ /**
65
+ * 结构化值域规则分派(目录声明 → 校验实现单一入口)。
66
+ * 无规则的参数原样返回;规则拒绝时错误消息即为面向用户的原因。
67
+ */
68
+ function validateRule(rule: ParamRuleId | undefined, key: string, value: string): string {
69
+ if (rule === 'image-size') {
70
+ const checked = checkImageSize(value)
71
+ if (!checked.ok) throw new ParamValidationError(key, checked.message)
72
+ // 规范化落库/出站:小写 x、无空白(上游按字面解析尺寸串)。
73
+ return normalizeImageSize(value)
74
+ }
75
+ return value
76
+ }
77
+
63
78
  /**
64
79
  * 校验并归一化一份参数表:仅保留启用且适用于该端点的参数;
65
80
  * model 是请求基础(提供商预设承载),不计入协议参数表。
package/src/service.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * image-studio 服务主体:
3
- * - settings installSection(三组预设 + 并发/超时/代理/画廊上限);
3
+ * - settings installSection(三组预设 + 并发/超时/代理/画廊上限/上传保留时长);
4
4
  * - TaskRunner 并发生图:提交即排队,成功自动入画廊(元数据 + 图片落盘);
5
5
  * - 提供商预设按 id 即时 resolve credentials(不缓存);
6
- * - 端点:generate/tasks/gallery/images/credentials/providers(api.ts 注册)。
6
+ * - 上传暂存区:本地文件上传的源图/遮罩,孤儿按 TTL 回收;
7
+ * - 端点:generate/tasks/gallery/images/uploads/credentials/providers(api.ts 注册)。
7
8
  * 红线:开发测试仅指向本地 mock,严禁真实调用产生费用。
8
9
  * @module image-studio/service
9
10
  */
@@ -20,7 +21,7 @@ import {
20
21
  SETTINGS_NS,
21
22
  } from './config.ts'
22
23
  import { credentialRefNameOf, isValidPresetId } from './credentials.ts'
23
- import type { GenerateRequest, TaskWire } from './dto.ts'
24
+ import type { GenerateRequest, SourceRef, TaskWire } from './dto.ts'
24
25
  import { ImageStudioError } from './errors.ts'
25
26
  import type { GalleryItem } from './gallery/store.ts'
26
27
  import {
@@ -29,12 +30,22 @@ import {
29
30
  deleteGalleryItem as removeGalleryItem,
30
31
  saveGalleryItem,
31
32
  } from './gallery/store.ts'
33
+ import { isImageId } from './images/id.ts'
34
+ import { mimeOfExt } from './images/mime.ts'
35
+ import { MAX_SOURCE_IMAGES } from './limits.ts'
32
36
  import type { ParamSpecMap } from './params/spec.ts'
33
37
  import { normalizeParamSpecs, validateParamSpecs } from './params/spec.ts'
34
38
  import { createProvider } from './provider/registry.ts'
35
39
  import type { ImageEndpoint, InputImage, ProviderTarget } from './provider/types.ts'
36
40
  import type { TaskRecord } from './task/runner.ts'
37
41
  import { TaskRunner } from './task/runner.ts'
42
+ import type { UploadEntry } from './uploads/store.ts'
43
+ import {
44
+ loadUploads,
45
+ pruneUploads,
46
+ readUploadBytes,
47
+ saveUpload as saveUploadEntry,
48
+ } from './uploads/store.ts'
38
49
 
39
50
  interface SettingsLike {
40
51
  installSection(
@@ -74,6 +85,8 @@ export class ImageStudioService extends Service {
74
85
  private current: () => ImageStudioConfig
75
86
  private readonly runner: TaskRunner<TaskSnapshot>
76
87
  private readonly log: (message: string) => void
88
+ /** 进行中的孤儿回收(串行化:索引重写不能并发)。 */
89
+ private pruning: Promise<void> | null
77
90
  // settings 服务窄面(settingsCtx.inject 回调内落存)。declare:不参与类字段
78
91
  // 初始化 emit,保持实例运行期形状与既有产物一致(不因声明而多出 undefined 字段)。
79
92
  private declare settingsRef: SettingsLike | undefined
@@ -82,7 +95,10 @@ export class ImageStudioService extends Service {
82
95
  super(ctx, 'imageStudio')
83
96
  this.current = () => config
84
97
  this.log = (message) => ctx.logger('image-studio').warn(message)
98
+ this.pruning = null
85
99
  this.runner = new TaskRunner<TaskSnapshot>({ maxConcurrent: config.maxConcurrent })
100
+ // 启动回收一次:上次进程遗留的孤儿上传(未被画廊引用且超 TTL)。
101
+ this.schedulePrune()
86
102
  ctx.inject(['settings'], (settingsCtx) => {
87
103
  this.settingsRef = settingsCtx.settings as unknown as SettingsLike
88
104
  settingsCtx.settings.installSection(ctx, SETTINGS_NS, configSchemaRef, config, {
@@ -220,7 +236,7 @@ export class ImageStudioService extends Service {
220
236
 
221
237
  /**
222
238
  * 任务准备(边界校验集中地):提供商预设/inline → target;
223
- * 参数预设 + 覆盖 → 归一化参数表;sourceIds → 输入图字节。
239
+ * 参数预设 + 覆盖 → 归一化参数表;sources/mask 引用 → 输入图字节。
224
240
  */
225
241
  private async prepareTask(request: GenerateRequest): Promise<{
226
242
  target: ProviderTarget
@@ -231,6 +247,7 @@ export class ImageStudioService extends Service {
231
247
  mask: InputImage | null
232
248
  params: Record<string, unknown>
233
249
  sourceIds: string[]
250
+ sourceUploads: string[]
234
251
  }> {
235
252
  const target = await this.resolveTarget(request)
236
253
  const endpoint = request.endpoint
@@ -243,19 +260,57 @@ export class ImageStudioService extends Service {
243
260
  const params = await this.resolveParams(request, endpoint)
244
261
  const nValue = params.n
245
262
  const n = typeof nValue === 'number' ? nValue : 1
246
- const sourceIds = request.sourceIds ?? []
247
- const images = await this.loadSourceImages(sourceIds, endpoint)
248
- const mask = request.maskId !== undefined ? await this.loadMask(request.maskId) : null
263
+ const refs = this.sourceRefsOf(request)
264
+ const maskRef = this.maskRefOf(request)
265
+ const sources = await this.loadSourceImages(refs, endpoint)
266
+ const mask = maskRef === null ? null : await this.loadMaskImage(maskRef)
249
267
  return {
250
268
  target,
251
269
  endpoint,
252
270
  prompt: request.prompt,
253
271
  n,
254
- images,
272
+ images: sources.images,
255
273
  mask,
256
274
  params: stripHostParams(params),
257
- sourceIds,
275
+ sourceIds: sources.galleryIds,
276
+ sourceUploads: sources.uploadIds,
277
+ }
278
+ }
279
+
280
+ /** 源图引用规范化(sources 优先;旧 sourceIds 等价于全部 gallery 引用)。 */
281
+ private sourceRefsOf(request: GenerateRequest): SourceRef[] {
282
+ if (request.sources !== undefined) {
283
+ if (!Array.isArray(request.sources)) {
284
+ throw new ImageStudioError('invalid-request', 'sources 必须是数组')
285
+ }
286
+ return request.sources.map((ref) => this.assertRef(ref))
287
+ }
288
+ const legacy = request.sourceIds ?? []
289
+ if (!Array.isArray(legacy)) {
290
+ throw new ImageStudioError('invalid-request', 'sourceIds 必须是数组')
291
+ }
292
+ return legacy.map((id) => this.assertRef({ kind: 'gallery', id }))
293
+ }
294
+
295
+ /** 遮罩引用规范化(mask 优先;旧 maskId 等价于 gallery 引用)。 */
296
+ private maskRefOf(request: GenerateRequest): SourceRef | null {
297
+ if (request.mask !== undefined) return this.assertRef(request.mask)
298
+ if (request.maskId === undefined) return null
299
+ return this.assertRef({ kind: 'gallery', id: request.maskId })
300
+ }
301
+
302
+ /** 引用形态校验(请求体来自浏览器,kind 封闭集合 + id uuid 形态)。 */
303
+ private assertRef(ref: SourceRef): SourceRef {
304
+ if (ref === null || typeof ref !== 'object') {
305
+ throw new ImageStudioError('invalid-request', '源图引用必须是 { kind, id }')
258
306
  }
307
+ if (ref.kind !== 'gallery' && ref.kind !== 'upload') {
308
+ throw new ImageStudioError('invalid-request', `未知源图类型:${String(ref.kind)}`)
309
+ }
310
+ if (!isImageId(ref.id)) {
311
+ throw new ImageStudioError('invalid-request', `非法源图标识:${String(ref.id)}`)
312
+ }
313
+ return { kind: ref.kind, id: ref.id }
259
314
  }
260
315
 
261
316
  /** 提供商预设 / inline → ProviderTarget(凭据即时 resolve)。 */
@@ -350,36 +405,58 @@ export class ImageStudioService extends Service {
350
405
  return this.providerPresets().find((item) => item.id === presetId)?.model ?? ''
351
406
  }
352
407
 
353
- /** 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。 */
408
+ /**
409
+ * 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。
410
+ * 按引用来源分流读取,并把两类 id 分开回传(画廊衍生链 / 上传暂存区)。
411
+ */
354
412
  private async loadSourceImages(
355
- sourceIds: string[],
413
+ refs: SourceRef[],
356
414
  endpoint: ImageEndpoint,
357
- ): Promise<InputImage[]> {
358
- if (endpoint === 'generation') return []
359
- if (sourceIds.length === 0) {
360
- throw new ImageStudioError('invalid-request', 'edit 端点必须提供至少一张源图(sourceIds)')
415
+ ): Promise<{ images: InputImage[]; galleryIds: string[]; uploadIds: string[] }> {
416
+ if (endpoint === 'generation') return { images: [], galleryIds: [], uploadIds: [] }
417
+ if (refs.length === 0) {
418
+ throw new ImageStudioError('invalid-request', 'edit 端点必须提供至少一张源图(sources)')
361
419
  }
362
- if (sourceIds.length > 16) {
363
- throw new ImageStudioError('invalid-request', '源图最多 16 张(官方 GPT Image 限制)')
420
+ if (refs.length > MAX_SOURCE_IMAGES) {
421
+ throw new ImageStudioError(
422
+ 'invalid-request',
423
+ `源图最多 ${MAX_SOURCE_IMAGES} 张(官方 GPT Image 限制)`,
424
+ )
364
425
  }
365
426
  const images: InputImage[] = []
366
- for (const imageId of sourceIds) {
367
- images.push(await this.loadOneImage(imageId))
427
+ const galleryIds: string[] = []
428
+ const uploadIds: string[] = []
429
+ for (const ref of refs) {
430
+ images.push(await this.loadRefImage(ref))
431
+ if (ref.kind === 'gallery') galleryIds.push(ref.id)
432
+ else uploadIds.push(ref.id)
368
433
  }
369
- return images
434
+ return { images, galleryIds, uploadIds }
370
435
  }
371
436
 
372
- private async loadMask(maskId: string): Promise<InputImage> {
373
- return this.loadOneImage(maskId)
437
+ /** 单个引用 → 输入图字节(两个 id 空间分别查,未知一律 unknown-source)。 */
438
+ private async loadRefImage(ref: SourceRef): Promise<InputImage> {
439
+ const found =
440
+ ref.kind === 'gallery' ? await this.imageBytes(ref.id) : await this.uploadBytes(ref.id)
441
+ if (found === null) {
442
+ throw new ImageStudioError('unknown-source', `源图不存在:${ref.kind}:${ref.id}`)
443
+ }
444
+ return { data: found.data, mime: mimeOfExt(found.ext) }
374
445
  }
375
446
 
376
- private async loadOneImage(imageId: string): Promise<InputImage> {
377
- try {
378
- const { data, ext } = await readImageBytes(imageId)
379
- return { data, mime: ext === 'jpg' ? 'image/jpeg' : `image/${ext}` }
380
- } catch {
381
- throw new ImageStudioError('unknown-source', `源图不存在:${imageId}`)
447
+ /**
448
+ * 遮罩加载:官方要求带透明通道的 PNG(其它格式上游必然拒绝),
449
+ * 在边界提前给出可读原因,避免白跑一次计费请求。
450
+ */
451
+ private async loadMaskImage(ref: SourceRef): Promise<InputImage> {
452
+ const image = await this.loadRefImage(ref)
453
+ if (image.mime !== 'image/png') {
454
+ throw new ImageStudioError(
455
+ 'unsupported-media',
456
+ `遮罩必须是 PNG(当前 ${image.mime}):请上传带透明区域的 PNG`,
457
+ )
382
458
  }
459
+ return image
383
460
  }
384
461
 
385
462
  /** 任务体:调协议适配器 → 成功入画廊。失败向上抛给 TaskRunner 记录。 */
@@ -415,6 +492,7 @@ export class ImageStudioService extends Service {
415
492
  prompt: snapshot.prompt,
416
493
  params: snapshot.params,
417
494
  sourceIds: snapshot.sourceIds,
495
+ sourceUploads: snapshot.sourceUploads,
418
496
  },
419
497
  images: result.images.map((image) => ({
420
498
  data: image.data,
@@ -422,6 +500,8 @@ export class ImageStudioService extends Service {
422
500
  revisedPrompt: image.revisedPrompt,
423
501
  })),
424
502
  })
503
+ // 入库后回收一次:本次引用的上传已被条目引用(保留),其余过期孤儿清理。
504
+ this.schedulePrune()
425
505
  return { galleryItemId: item.id }
426
506
  }
427
507
 
@@ -451,6 +531,55 @@ export class ImageStudioService extends Service {
451
531
  }
452
532
  }
453
533
 
534
+ // ---------- 上传暂存区 ----------
535
+
536
+ /** 保存上传原图(端点边界已完成 MIME 嗅探与限额)。 */
537
+ async saveUpload(input: { name: string; mime: string; data: Uint8Array }): Promise<UploadEntry> {
538
+ const entry = await saveUploadEntry(input)
539
+ this.schedulePrune()
540
+ return entry
541
+ }
542
+
543
+ /** 上传列表(索引序,最新在后;前端自行倒序展示)。 */
544
+ async listUploads(): Promise<UploadEntry[]> {
545
+ return loadUploads(this.log)
546
+ }
547
+
548
+ /** 读取上传图片字节(缩略图端点;未知 id 返回 null)。 */
549
+ async uploadBytes(uploadId: string): Promise<{ data: Uint8Array; ext: string } | null> {
550
+ try {
551
+ return await readUploadBytes(uploadId)
552
+ } catch {
553
+ return null
554
+ }
555
+ }
556
+
557
+ /** 触发一次孤儿回收(并发调用合并为一次,失败不影响主流程)。 */
558
+ private schedulePrune(): void {
559
+ if (this.pruning !== null) return
560
+ this.pruning = this.pruneOrphans()
561
+ .catch((error: unknown) => {
562
+ this.log(`上传回收失败(忽略):${error instanceof Error ? error.message : String(error)}`)
563
+ })
564
+ .finally(() => {
565
+ this.pruning = null
566
+ })
567
+ }
568
+
569
+ /** 保留被画廊条目引用的上传;其余超过 TTL 的回收(暂存区不无界增长)。 */
570
+ private async pruneOrphans(): Promise<void> {
571
+ const keepIds = new Set<string>()
572
+ for (const item of await this.gallery()) {
573
+ for (const id of item.sourceUploads) keepIds.add(id)
574
+ }
575
+ const removed = await pruneUploads({
576
+ keepIds,
577
+ ttlMs: this.current().uploadTtlHours * 3_600_000,
578
+ log: this.log,
579
+ })
580
+ if (removed > 0) this.log(`回收孤儿上传 ${removed} 个`)
581
+ }
582
+
454
583
  dispose(): void {
455
584
  // 进行中任务随进程终止(AbortController 无需逐一 abort——进程退出即断)。
456
585
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * 上传暂存区:图生图源图/遮罩的本地文件上传(blobs + JSONL 索引,原子写)。
3
+ * 与画廊分离——上传图片不属于任何画廊条目;生图成功后由条目的
4
+ * sourceUploads 引用(衍生链可见),未被任何条目引用且超过 TTL 的由
5
+ * pruneUploads 回收(见 docs/repo/插件存储规范.md)。
6
+ * 落点 $DSH_HOME/dsh-plus/image-studio/uploads/{index.jsonl, blobs/}
7
+ * @module image-studio/uploads/store
8
+ */
9
+
10
+ import { randomUUID } from 'node:crypto'
11
+ import { appendFile, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+
14
+ import { pluginDataPath } from '@dsh-plus/shared'
15
+ import { isImageId } from '../images/id.ts'
16
+ import { extOfMime } from '../images/mime.ts'
17
+
18
+ /** 上传记录元数据(wire 视图原样投影)。 */
19
+ export interface UploadEntry {
20
+ id: string
21
+ createdAt: string
22
+ /** 原始文件名(仅作展示,不参与路径拼接)。 */
23
+ name: string
24
+ mime: string
25
+ ext: string
26
+ /** 字节数。 */
27
+ size: number
28
+ }
29
+
30
+ /** 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试环境设置)。 */
31
+ function uploadsRoot(): string {
32
+ return pluginDataPath('image-studio', 'uploads')
33
+ }
34
+
35
+ function indexFile(): string {
36
+ return join(uploadsRoot(), 'index.jsonl')
37
+ }
38
+
39
+ function blobsDir(): string {
40
+ return join(uploadsRoot(), 'blobs')
41
+ }
42
+
43
+ /** 上传 id 即文件名前缀:uuid 形态白名单,兼作路径穿越防线。 */
44
+ function validateUploadId(id: string): string {
45
+ if (!isImageId(id)) throw new Error(`非法上传标识:${id}`)
46
+ return id
47
+ }
48
+
49
+ /** 图片文件绝对路径。 */
50
+ function blobPathOf(id: string, ext: string): string {
51
+ validateUploadId(id)
52
+ if (!/^[a-z0-9]{2,4}$/.test(ext)) throw new Error(`非法上传扩展名:${ext}`)
53
+ return join(blobsDir(), `${id}.${ext}`)
54
+ }
55
+
56
+ async function ensureDirs(): Promise<void> {
57
+ await mkdir(blobsDir(), { recursive: true })
58
+ }
59
+
60
+ /** 解析 JSONL 全量索引(坏行跳过并告警;空文件返回空表)。 */
61
+ export async function loadUploads(log: (message: string) => void): Promise<UploadEntry[]> {
62
+ let text: string
63
+ try {
64
+ text = await readFile(indexFile(), 'utf8')
65
+ } catch {
66
+ return []
67
+ }
68
+ const items: UploadEntry[] = []
69
+ for (const line of text.split('\n')) {
70
+ if (line.trim().length === 0) continue
71
+ try {
72
+ items.push(JSON.parse(line) as UploadEntry)
73
+ } catch {
74
+ log(`上传索引坏行跳过:${line.slice(0, 80)}`)
75
+ }
76
+ }
77
+ return items
78
+ }
79
+
80
+ /** 单条上传记录查询(未知 id 返回 null)。 */
81
+ export async function findUpload(
82
+ id: string,
83
+ log: (message: string) => void,
84
+ ): Promise<UploadEntry | null> {
85
+ if (!isImageId(id)) return null
86
+ return (await loadUploads(log)).find((item) => item.id === id) ?? null
87
+ }
88
+
89
+ /** 落盘一张上传图片(临时文件 + rename;索引追加在字节落盘之后)。 */
90
+ export async function saveUpload(input: {
91
+ name: string
92
+ mime: string
93
+ data: Uint8Array
94
+ }): Promise<UploadEntry> {
95
+ await ensureDirs()
96
+ const id = randomUUID()
97
+ const ext = extOfMime(input.mime)
98
+ const target = blobPathOf(id, ext)
99
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`
100
+ await writeFile(tmp, input.data)
101
+ await rename(tmp, target)
102
+ const entry: UploadEntry = {
103
+ id,
104
+ createdAt: new Date().toISOString(),
105
+ name: input.name,
106
+ mime: input.mime,
107
+ ext,
108
+ size: input.data.byteLength,
109
+ }
110
+ await appendFile(indexFile(), `${JSON.stringify(entry)}\n`, 'utf8')
111
+ return entry
112
+ }
113
+
114
+ /** 读取上传图片字节(未知 id 抛错由调用方映射 404)。 */
115
+ export async function readUploadBytes(id: string): Promise<{ data: Uint8Array; ext: string }> {
116
+ const files = await readdir(blobsDir()).catch(() => [] as string[])
117
+ const hit = files.find((name) => name.startsWith(`${id}.`))
118
+ if (hit === undefined) throw new Error(`unknown-upload: ${id}`)
119
+ const ext = hit.slice(id.length + 1)
120
+ const data = await readFile(blobPathOf(id, ext))
121
+ return { data: new Uint8Array(data), ext }
122
+ }
123
+
124
+ /** 删除单条(字节 + 索引行)。 */
125
+ async function removeUpload(entry: UploadEntry): Promise<void> {
126
+ await rm(blobPathOf(entry.id, entry.ext), { force: true })
127
+ }
128
+
129
+ /** 索引导出为 JSONL(重写式删除的公共收尾)。 */
130
+ async function rewriteIndex(kept: UploadEntry[]): Promise<void> {
131
+ const tmp = `${indexFile()}.tmp-${process.pid}-${Date.now()}`
132
+ await writeFile(
133
+ tmp,
134
+ kept.map((item) => JSON.stringify(item)).join('\n') + (kept.length > 0 ? '\n' : ''),
135
+ 'utf8',
136
+ )
137
+ await rename(tmp, indexFile())
138
+ }
139
+
140
+ /**
141
+ * 回收孤儿上传:未被 keepIds 引用且创建时间早于 ttlMs 的条目删除。
142
+ * @returns 删除条数。
143
+ */
144
+ export async function pruneUploads(options: {
145
+ keepIds: ReadonlySet<string>
146
+ ttlMs: number
147
+ now?: number
148
+ log: (message: string) => void
149
+ }): Promise<number> {
150
+ const { keepIds, ttlMs, log } = options
151
+ const now = options.now ?? Date.now()
152
+ const items = await loadUploads(log)
153
+ const kept: UploadEntry[] = []
154
+ let removed = 0
155
+ for (const item of items) {
156
+ const expired = now - Date.parse(item.createdAt) > ttlMs
157
+ if (!keepIds.has(item.id) && expired) {
158
+ await removeUpload(item).catch((error: unknown) => {
159
+ log(`上传回收失败(忽略):${item.id} ${String(error)}`)
160
+ })
161
+ removed += 1
162
+ continue
163
+ }
164
+ kept.push(item)
165
+ }
166
+ if (removed > 0) await rewriteIndex(kept)
167
+ return removed
168
+ }