@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.
@@ -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 是请求基础(提供商预设承载),不计入协议参数表。
@@ -6,8 +6,8 @@
6
6
  * 开发测试指向本地 mock,严禁真实调用产生费用。
7
7
  * @module image-studio/provider/openai-images
8
8
  */
9
- import { request as httpRequest } from 'node:http'
10
- import { request as httpsRequest, type IncomingMessage } from 'node:https'
9
+ import { request as httpRequest, type IncomingMessage } from 'node:http'
10
+ import { request as httpsRequest } from 'node:https'
11
11
  import { HttpsProxyAgent } from 'https-proxy-agent'
12
12
  import type { GeneratedImage, InputImage, NormalizedRequest, ProviderResult } from './types.ts'
13
13
  import { ProviderError } from './types.ts'
@@ -74,7 +74,9 @@ export function buildEditBody(request: NormalizedRequest): {
74
74
  body: Buffer
75
75
  } {
76
76
  const chunks: Buffer[] = []
77
- const pushText = (text: string): void => chunks.push(Buffer.from(text, 'utf8'))
77
+ const pushText = (text: string): void => {
78
+ chunks.push(Buffer.from(text, 'utf8'))
79
+ }
78
80
  pushText(formField('model', request.target.model))
79
81
  pushText(formField('prompt', request.prompt))
80
82
  pushText(formField('n', String(request.n)))
@@ -246,8 +248,10 @@ function upstreamErrorMessage(raw: string): string {
246
248
  message?: string
247
249
  }
248
250
  if (typeof doc.error === 'string' && doc.error.length > 0) return doc.error
249
- if (typeof doc.error?.message === 'string' && doc.error.message.length > 0) {
250
- return doc.error.message
251
+ // error 是 string | { message? } 联合:先收窄到对象再读 message(直接访问不合法)。
252
+ if (typeof doc.error === 'object' && doc.error !== null) {
253
+ const message = doc.error.message
254
+ if (typeof message === 'string' && message.length > 0) return message
251
255
  }
252
256
  if (typeof doc.message === 'string' && doc.message.length > 0) return doc.message
253
257
  } catch {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { ImageStudioError } from '../errors.ts'
7
7
  import { generateViaOpenAIImages } from './openai-images.ts'
8
- import type { ImageEndpoint, ImageProvider, NormalizedRequest } from './types.ts'
8
+ import type { ImageEndpoint, ImageProvider, NormalizedRequest, ProviderResult } from './types.ts'
9
9
 
10
10
  type GenerateFn = (request: NormalizedRequest, signal: AbortSignal) => Promise<ProviderResult>
11
11
 
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,12 +85,20 @@ 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
90
+ // settings 服务窄面(settingsCtx.inject 回调内落存)。declare:不参与类字段
91
+ // 初始化 emit,保持实例运行期形状与既有产物一致(不因声明而多出 undefined 字段)。
92
+ private declare settingsRef: SettingsLike | undefined
77
93
 
78
94
  constructor(ctx: ContextT, config: ImageStudioConfig) {
79
95
  super(ctx, 'imageStudio')
80
96
  this.current = () => config
81
97
  this.log = (message) => ctx.logger('image-studio').warn(message)
98
+ this.pruning = null
82
99
  this.runner = new TaskRunner<TaskSnapshot>({ maxConcurrent: config.maxConcurrent })
100
+ // 启动回收一次:上次进程遗留的孤儿上传(未被画廊引用且超 TTL)。
101
+ this.schedulePrune()
83
102
  ctx.inject(['settings'], (settingsCtx) => {
84
103
  this.settingsRef = settingsCtx.settings as unknown as SettingsLike
85
104
  settingsCtx.settings.installSection(ctx, SETTINGS_NS, configSchemaRef, config, {
@@ -217,7 +236,7 @@ export class ImageStudioService extends Service {
217
236
 
218
237
  /**
219
238
  * 任务准备(边界校验集中地):提供商预设/inline → target;
220
- * 参数预设 + 覆盖 → 归一化参数表;sourceIds → 输入图字节。
239
+ * 参数预设 + 覆盖 → 归一化参数表;sources/mask 引用 → 输入图字节。
221
240
  */
222
241
  private async prepareTask(request: GenerateRequest): Promise<{
223
242
  target: ProviderTarget
@@ -228,6 +247,7 @@ export class ImageStudioService extends Service {
228
247
  mask: InputImage | null
229
248
  params: Record<string, unknown>
230
249
  sourceIds: string[]
250
+ sourceUploads: string[]
231
251
  }> {
232
252
  const target = await this.resolveTarget(request)
233
253
  const endpoint = request.endpoint
@@ -240,19 +260,57 @@ export class ImageStudioService extends Service {
240
260
  const params = await this.resolveParams(request, endpoint)
241
261
  const nValue = params.n
242
262
  const n = typeof nValue === 'number' ? nValue : 1
243
- const sourceIds = request.sourceIds ?? []
244
- const images = await this.loadSourceImages(sourceIds, endpoint)
245
- 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)
246
267
  return {
247
268
  target,
248
269
  endpoint,
249
270
  prompt: request.prompt,
250
271
  n,
251
- images,
272
+ images: sources.images,
252
273
  mask,
253
274
  params: stripHostParams(params),
254
- 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 }')
255
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 }
256
314
  }
257
315
 
258
316
  /** 提供商预设 / inline → ProviderTarget(凭据即时 resolve)。 */
@@ -347,36 +405,58 @@ export class ImageStudioService extends Service {
347
405
  return this.providerPresets().find((item) => item.id === presetId)?.model ?? ''
348
406
  }
349
407
 
350
- /** 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。 */
408
+ /**
409
+ * 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。
410
+ * 按引用来源分流读取,并把两类 id 分开回传(画廊衍生链 / 上传暂存区)。
411
+ */
351
412
  private async loadSourceImages(
352
- sourceIds: string[],
413
+ refs: SourceRef[],
353
414
  endpoint: ImageEndpoint,
354
- ): Promise<InputImage[]> {
355
- if (endpoint === 'generation') return []
356
- if (sourceIds.length === 0) {
357
- 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)')
358
419
  }
359
- if (sourceIds.length > 16) {
360
- 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
+ )
361
425
  }
362
426
  const images: InputImage[] = []
363
- for (const imageId of sourceIds) {
364
- 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)
365
433
  }
366
- return images
434
+ return { images, galleryIds, uploadIds }
367
435
  }
368
436
 
369
- private async loadMask(maskId: string): Promise<InputImage> {
370
- 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) }
371
445
  }
372
446
 
373
- private async loadOneImage(imageId: string): Promise<InputImage> {
374
- try {
375
- const { data, ext } = await readImageBytes(imageId)
376
- return { data, mime: ext === 'jpg' ? 'image/jpeg' : `image/${ext}` }
377
- } catch {
378
- 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
+ )
379
458
  }
459
+ return image
380
460
  }
381
461
 
382
462
  /** 任务体:调协议适配器 → 成功入画廊。失败向上抛给 TaskRunner 记录。 */
@@ -412,6 +492,7 @@ export class ImageStudioService extends Service {
412
492
  prompt: snapshot.prompt,
413
493
  params: snapshot.params,
414
494
  sourceIds: snapshot.sourceIds,
495
+ sourceUploads: snapshot.sourceUploads,
415
496
  },
416
497
  images: result.images.map((image) => ({
417
498
  data: image.data,
@@ -419,6 +500,8 @@ export class ImageStudioService extends Service {
419
500
  revisedPrompt: image.revisedPrompt,
420
501
  })),
421
502
  })
503
+ // 入库后回收一次:本次引用的上传已被条目引用(保留),其余过期孤儿清理。
504
+ this.schedulePrune()
422
505
  return { galleryItemId: item.id }
423
506
  }
424
507
 
@@ -448,6 +531,55 @@ export class ImageStudioService extends Service {
448
531
  }
449
532
  }
450
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
+
451
583
  dispose(): void {
452
584
  // 进行中任务随进程终止(AbortController 无需逐一 abort——进程退出即断)。
453
585
  }