@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.
@@ -1,15 +1,20 @@
1
1
  /**
2
- * 画廊图片选择器(模态):源图多选(≤16)与遮罩单选共用。
3
- * 扁平化全部画廊条目的图片,倒序排列;底部确认条显示已选数量。
2
+ * 源图选择器(模态):源图多选(≤MAX_SOURCE_IMAGES)与遮罩单选共用。
3
+ * 两个来源分区——画廊(已生成图片)与本地上传(暂存区),可混合选择;
4
+ * 选中态以 SourceRef 记录(kind 区分 id 空间),底部确认条显示已选数量。
4
5
  * @module image-studio/client/panel/picker
5
6
  */
6
7
  import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
8
+ import { IconPlusOutline16 } from '@dsh-plus/shared/client'
7
9
  import { type ReactElement, useState } from 'react'
10
+ import type { SourceRef, UploadEntry } from '../../dto.ts'
8
11
  import type { GalleryItem } from '../../gallery/store.ts'
9
- import { imageUrl } from '../api.ts'
12
+ import { MAX_SOURCE_IMAGES } from '../../limits.ts'
13
+ import { imageUrl, uploadImageUrl } from '../api.ts'
10
14
  import type { Translate } from '../i18n.ts'
15
+ import { UploadButton, type UploadsApi } from './uploads.tsx'
11
16
 
12
- /** 扁平化的可选图片(imageId + 归属条目提示词)。 */
17
+ /** 扁平化的可选画廊图片(imageId + 归属条目提示词)。 */
13
18
  interface PickableImage {
14
19
  imageId: string
15
20
  prompt: string
@@ -20,11 +25,13 @@ interface ImagePickerProps {
20
25
  t: Translate
21
26
  title: string
22
27
  items: GalleryItem[]
28
+ /** 上传暂存区(列表 + 上传入口)。 */
29
+ uploads: UploadsApi
23
30
  /** 多选(源图)或单选(遮罩)。 */
24
31
  multiple: boolean
25
- /** 初始已选(源图回填)。 */
26
- initial: string[]
27
- onConfirm(ids: string[]): void
32
+ /** 初始已选(源图/遮罩回填)。 */
33
+ initial: SourceRef[]
34
+ onConfirm(refs: SourceRef[]): void
28
35
  onClose(): void
29
36
  }
30
37
 
@@ -38,60 +45,137 @@ export function flattenImages(items: GalleryItem[]): PickableImage[] {
38
45
  return out
39
46
  }
40
47
 
48
+ /** 引用键(选中判定与去重;两个 id 空间不重叠但不互相假设)。 */
49
+ export function refKey(ref: SourceRef): string {
50
+ return `${ref.kind}:${ref.id}`
51
+ }
52
+
53
+ /** 缩略图地址(画廊 / 上传两套端点)。 */
54
+ export function refUrl(ref: SourceRef): string {
55
+ return ref.kind === 'gallery' ? imageUrl(ref.id) : uploadImageUrl(ref.id)
56
+ }
57
+
41
58
  export function ImagePicker(props: ImagePickerProps): ReactElement {
42
- const { t, title, items, multiple, initial, onConfirm, onClose } = props
43
- const [selected, setSelected] = useState<string[]>(initial)
59
+ const { t, title, items, uploads, multiple, initial, onConfirm, onClose } = props
60
+ const [selected, setSelected] = useState<SourceRef[]>(initial)
61
+ const [section, setSection] = useState<'gallery' | 'uploads'>(
62
+ uploads.items.length > 0 && items.length === 0 ? 'uploads' : 'gallery',
63
+ )
44
64
  const images = flattenImages(items)
65
+ const uploadItems = [...uploads.items].reverse()
66
+ const selectedKeys = selected.map(refKey)
45
67
 
46
- const toggle = (imageId: string): void => {
68
+ const toggle = (ref: SourceRef): void => {
69
+ const key = refKey(ref)
47
70
  if (multiple) {
48
71
  setSelected((current) =>
49
- current.includes(imageId)
50
- ? current.filter((id) => id !== imageId)
51
- : current.length >= 16
72
+ current.some((item) => refKey(item) === key)
73
+ ? current.filter((item) => refKey(item) !== key)
74
+ : current.length >= MAX_SOURCE_IMAGES
52
75
  ? current
53
- : [...current, imageId],
76
+ : [...current, ref],
54
77
  )
55
78
  return
56
79
  }
57
- setSelected((current) => (current.includes(imageId) ? [] : [imageId]))
80
+ setSelected((current) => (current.some((item) => refKey(item) === key) ? [] : [ref]))
58
81
  }
59
82
 
83
+ /** 新上传条目直接选中(少一次点击;超限按剩余额度截断)。 */
84
+ const selectUploaded = (entries: UploadEntry[]): void => {
85
+ const refs = entries.map((entry) => ({ kind: 'upload' as const, id: entry.id }))
86
+ setSelected((current) => (multiple ? [...current, ...refs].slice(0, MAX_SOURCE_IMAGES) : refs))
87
+ setSection('uploads')
88
+ }
89
+
90
+ const grid = (cells: Array<{ ref: SourceRef; title: string }>, empty: string): ReactElement =>
91
+ cells.length === 0 ? (
92
+ <p className="ims-empty">{empty}</p>
93
+ ) : (
94
+ <div className="ims-pickerGrid" role="listbox" aria-multiselectable={multiple}>
95
+ {cells.map((cell) => {
96
+ const index = selectedKeys.indexOf(refKey(cell.ref))
97
+ const on = index >= 0
98
+ return (
99
+ <button
100
+ key={refKey(cell.ref)}
101
+ type="button"
102
+ role="option"
103
+ aria-selected={on}
104
+ className={`ims-pickerCell${on ? ' ims-pickerCellOn' : ''}`}
105
+ title={cell.title}
106
+ onClick={() => toggle(cell.ref)}
107
+ >
108
+ <img src={refUrl(cell.ref)} alt={cell.title} loading="lazy" />
109
+ {on ? <span className="ims-pickerMark">{index + 1}</span> : null}
110
+ </button>
111
+ )
112
+ })}
113
+ </div>
114
+ )
115
+
60
116
  // 官方 Modal 承载(嵌套 portal:层级高于工作台,遮罩/Escape 由 Modal 处理)。
61
117
  return (
62
118
  <Modal open onClose={onClose} title={title} headless className="ims-pickerModal">
63
119
  <div className="ims-sub">
64
120
  <div className="ims-subHead">
65
121
  <h3 className="ims-subTitle">{title}</h3>
122
+ <UploadButton
123
+ t={t}
124
+ uploads={uploads}
125
+ multiple={multiple}
126
+ // 遮罩只接受 PNG(官方要求带透明通道);源图不限格式。
127
+ accept={multiple ? undefined : 'image/png'}
128
+ onUploaded={selectUploaded}
129
+ className="ims-btn ims-btnPrimary ims-btnSmall"
130
+ >
131
+ <IconPlusOutline16 size={14} /> {t('upload.pick')}
132
+ </UploadButton>
66
133
  <button type="button" className="ims-btn ims-btnGhost" onClick={onClose}>
67
134
  {t('common.close')}
68
135
  </button>
69
136
  </div>
70
- {images.length === 0 ? (
71
- <p className="ims-empty">{t('picker.empty')}</p>
72
- ) : (
73
- <div className="ims-pickerGrid" role="listbox" aria-multiselectable={multiple}>
74
- {images.map((image) => {
75
- const on = selected.includes(image.imageId)
76
- return (
77
- <button
78
- key={image.imageId}
79
- type="button"
80
- role="option"
81
- aria-selected={on}
82
- className={`ims-pickerCell${on ? ' ims-pickerCellOn' : ''}`}
83
- title={image.prompt}
84
- onClick={() => toggle(image.imageId)}
85
- >
86
- <img src={imageUrl(image.imageId)} alt={image.prompt} loading="lazy" />
87
- {on ? (
88
- <span className="ims-pickerMark">{selected.indexOf(image.imageId) + 1}</span>
89
- ) : null}
90
- </button>
91
- )
92
- })}
93
- </div>
94
- )}
137
+ <div className="ims-seg">
138
+ <button
139
+ type="button"
140
+ className={`ims-segItem${section === 'gallery' ? ' ims-segItemOn' : ''}`}
141
+ aria-pressed={section === 'gallery'}
142
+ onClick={() => setSection('gallery')}
143
+ >
144
+ {t('picker.section.gallery')}
145
+ </button>
146
+ <button
147
+ type="button"
148
+ className={`ims-segItem${section === 'uploads' ? ' ims-segItemOn' : ''}`}
149
+ aria-pressed={section === 'uploads'}
150
+ onClick={() => {
151
+ uploads.reload()
152
+ setSection('uploads')
153
+ }}
154
+ >
155
+ {t('picker.section.uploads')}
156
+ </button>
157
+ </div>
158
+ {section === 'gallery'
159
+ ? grid(
160
+ images.map((image) => ({
161
+ ref: { kind: 'gallery', id: image.imageId },
162
+ title: image.prompt,
163
+ })),
164
+ t('picker.empty'),
165
+ )
166
+ : grid(
167
+ uploadItems.map((entry) => ({
168
+ ref: { kind: 'upload', id: entry.id },
169
+ title: entry.name,
170
+ })),
171
+ t('upload.empty'),
172
+ )}
173
+ {uploads.error !== null ? (
174
+ <p className="ims-status ims-statusError" role="alert">
175
+ {t('upload.failed')}
176
+ {uploads.error}
177
+ </p>
178
+ ) : null}
95
179
  <div className="ims-subFoot">
96
180
  <button
97
181
  type="button"
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 本地文件上传的浏览器半:列表/上传/错误态封装为 hook,控件供生图表单与
3
+ * 图片选择器复用。上传为串行(逐个文件),首个失败即中断并展示原因;
4
+ * 成功后条目即时并入本地列表(选择器无需刷新即可见)。
5
+ * @module image-studio/client/panel/uploads
6
+ */
7
+ import {
8
+ type ChangeEvent,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ useCallback,
12
+ useRef,
13
+ useState,
14
+ } from 'react'
15
+ import type { UploadEntry } from '../../dto.ts'
16
+ import { fetchUploads, uploadImage } from '../api.ts'
17
+ import type { Translate } from '../i18n.ts'
18
+
19
+ /** 可接受的上传类型(宿主端另有魔数嗅探兜底)。 */
20
+ const ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'
21
+
22
+ export interface UploadsApi {
23
+ /** 暂存区条目(索引序,最新在后)。 */
24
+ items: UploadEntry[]
25
+ /** 列表加载/上传进行中。 */
26
+ busy: boolean
27
+ /** 最近一次失败原因(成功即清空)。 */
28
+ error: string | null
29
+ /** 上传一批文件,返回成功条目(失败中断并置 error)。 */
30
+ add(files: FileList | File[]): Promise<UploadEntry[]>
31
+ /** 重新拉取列表。 */
32
+ reload(): void
33
+ }
34
+
35
+ /** 上传暂存区读写 hook(每个消费组件一份,互不共享状态)。 */
36
+ export function useUploads(): UploadsApi {
37
+ const [items, setItems] = useState<UploadEntry[]>([])
38
+ const [busy, setBusy] = useState(false)
39
+ const [error, setError] = useState<string | null>(null)
40
+
41
+ const reload = useCallback((): void => {
42
+ setBusy(true)
43
+ fetchUploads()
44
+ .then((res) => setItems(res.items))
45
+ .catch(() => setItems([]))
46
+ .finally(() => setBusy(false))
47
+ }, [])
48
+
49
+ const add = useCallback(async (files: FileList | File[]): Promise<UploadEntry[]> => {
50
+ const list = Array.from(files)
51
+ if (list.length === 0) return []
52
+ setBusy(true)
53
+ setError(null)
54
+ const saved: UploadEntry[] = []
55
+ try {
56
+ for (const file of list) {
57
+ const entry = await uploadImage(file)
58
+ saved.push(entry)
59
+ }
60
+ setItems((current) => [...current, ...saved])
61
+ return saved
62
+ } catch (err: unknown) {
63
+ setError(err instanceof Error ? err.message : String(err))
64
+ return saved
65
+ } finally {
66
+ setBusy(false)
67
+ }
68
+ }, [])
69
+
70
+ return { items, busy, error, add, reload }
71
+ }
72
+
73
+ interface UploadButtonProps {
74
+ t: Translate
75
+ uploads: UploadsApi
76
+ /** 上传成功回调(新条目已入列表;调用方按需自动选中)。 */
77
+ onUploaded(entries: UploadEntry[]): void
78
+ /** 允许多选(源图)或单选(遮罩)。 */
79
+ multiple: boolean
80
+ /** 允许的文件类型(缺省四类位图;遮罩场景传 image/png)。 */
81
+ accept?: string
82
+ disabled?: boolean
83
+ className?: string
84
+ children: ReactNode
85
+ }
86
+
87
+ /** 隐藏 file input + 触发按钮(移动端显式 accept 图像类型,避免退化为相册单选)。 */
88
+ export function UploadButton(props: UploadButtonProps): ReactElement {
89
+ const { t, uploads, onUploaded, multiple, disabled, className, children } = props
90
+ const inputRef = useRef<HTMLInputElement>(null)
91
+
92
+ const onChange = (event: ChangeEvent<HTMLInputElement>): void => {
93
+ const files = event.target.files
94
+ if (files !== null && files.length > 0) {
95
+ void uploads.add(files).then((entries) => {
96
+ if (entries.length > 0) onUploaded(entries)
97
+ })
98
+ }
99
+ // 复位以便连续选择同一文件。
100
+ event.target.value = ''
101
+ }
102
+
103
+ return (
104
+ <>
105
+ <button
106
+ type="button"
107
+ className={className ?? 'ims-btn ims-btnGhost'}
108
+ disabled={disabled === true || uploads.busy}
109
+ onClick={() => inputRef.current?.click()}
110
+ >
111
+ {children}
112
+ </button>
113
+ <input
114
+ ref={inputRef}
115
+ type="file"
116
+ hidden
117
+ multiple={multiple}
118
+ accept={props.accept ?? ACCEPT}
119
+ aria-label={t('upload.pick')}
120
+ onChange={onChange}
121
+ />
122
+ </>
123
+ )
124
+ }
@@ -235,6 +235,32 @@ export const imageStudioCss = `
235
235
  white-space: nowrap;
236
236
  }
237
237
  .ims-paramControl { width: 132px; flex: none; min-height: 30px; padding: 4px 8px; }
238
+ .ims-inputInvalid {
239
+ border-color: var(--dsw-alias-state-error-primary);
240
+ }
241
+ .ims-paramValue { display: grid; gap: 4px; justify-items: end; }
242
+ .ims-paramPresets { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
243
+ .ims-chip {
244
+ padding: 1px 6px;
245
+ border: 1px solid var(--dsw-alias-border-l2);
246
+ border-radius: 999px;
247
+ background: transparent;
248
+ color: var(--dsw-alias-label-secondary);
249
+ font-family: var(--ds-font-family-code);
250
+ font-size: 10px;
251
+ line-height: 16px;
252
+ cursor: pointer;
253
+ }
254
+ .ims-chip:hover { background: var(--dsw-alias-interactive-bg-hover); }
255
+ .ims-chipOn {
256
+ border-color: var(--dsw-alias-brand-primary);
257
+ color: var(--dsw-alias-brand-primary);
258
+ }
259
+ .ims-paramWarn {
260
+ margin-top: 2px;
261
+ color: var(--dsw-alias-state-error-primary);
262
+ font-size: 11px;
263
+ }
238
264
  .ims-adv { margin-top: 8px; }
239
265
  .ims-advSummary {
240
266
  cursor: pointer;
@@ -361,6 +387,30 @@ export const imageStudioCss = `
361
387
  }
362
388
  .ims-subFoot .ims-status { margin: 0 auto 0 0; }
363
389
 
390
+ /* 选择器来源切换(画廊 / 本地上传) */
391
+ .ims-seg {
392
+ display: flex;
393
+ flex: none;
394
+ gap: 4px;
395
+ padding: 8px 14px 0;
396
+ }
397
+ .ims-segItem {
398
+ padding: 4px 10px;
399
+ border: 1px solid transparent;
400
+ border-radius: 999px;
401
+ background: transparent;
402
+ color: var(--dsw-alias-label-secondary);
403
+ font-size: 12px;
404
+ cursor: pointer;
405
+ }
406
+ .ims-segItem:hover { background: var(--dsw-alias-interactive-bg-hover); }
407
+ .ims-segItemOn {
408
+ border-color: var(--dsw-alias-border-l2);
409
+ background: var(--dsw-alias-bg-layer-1);
410
+ color: var(--dsw-alias-label-primary);
411
+ font-weight: 600;
412
+ }
413
+
364
414
  /* 选择器网格 */
365
415
  .ims-pickerGrid {
366
416
  flex: 1;
package/src/config.ts CHANGED
@@ -59,6 +59,12 @@ export const Config = z.object({
59
59
  .max(10_000)
60
60
  .description('画廊保留上限(条,0 = 不清理;超限按最旧删除)')
61
61
  .default(500),
62
+ uploadTtlHours: z
63
+ .number()
64
+ .min(1)
65
+ .max(720)
66
+ .description('上传原图保留时长(小时;未被画廊条目引用的超期即回收)')
67
+ .default(24),
62
68
  })
63
69
 
64
70
  export type ImageStudioConfig = Schemastery.TypeT<typeof Config>
package/src/dto.ts CHANGED
@@ -8,6 +8,20 @@ import type { ParamPresetEntry, PromptPresetEntry, ProviderPresetEntry } from '.
8
8
  import type { GalleryItem } from './gallery/store.ts'
9
9
  import type { ImageEndpoint } from './provider/types.ts'
10
10
  import type { TaskState } from './task/runner.ts'
11
+ import type { UploadEntry } from './uploads/store.ts'
12
+
13
+ // GalleryItem 由 gallery/store 定义,但属 wire 视图的一部分(GalleryListWire 及
14
+ // 客户端半 fetchGalleryItem 都用它);在此转出,dto 保持「前后端共享类型单一门面」。
15
+ export type { GalleryItem, UploadEntry }
16
+
17
+ /**
18
+ * 源图引用:画廊已落盘图片(gallery)或本地文件上传的暂存图片(upload)。
19
+ * 两个 id 空间互不重叠,故引用必须显式带 kind(同一字符串在两边含义不同)。
20
+ */
21
+ export interface SourceRef {
22
+ kind: 'gallery' | 'upload'
23
+ id: string
24
+ }
11
25
 
12
26
  /** 任务提交请求体。 */
13
27
  export interface GenerateRequest {
@@ -25,9 +39,13 @@ export interface GenerateRequest {
25
39
  paramPresetId?: string
26
40
  /** 参数覆盖表(同 ParamSpecMap 形态)。 */
27
41
  paramSpecs?: Record<string, { enabled: boolean; value?: unknown }>
28
- /** 图生图源图(本画廊 imageId;edit 必填 ≥1)。 */
42
+ /** 图生图源图(edit 必填 ≥1,≤16;画廊图片与本地文件上传混用)。 */
43
+ sources?: SourceRef[]
44
+ /** 可选遮罩(官方要求 PNG;与源图同一引用形态)。 */
45
+ mask?: SourceRef
46
+ /** @deprecated 旧客户端兼容:等价于 kind='gallery' 的 sources。 */
29
47
  sourceIds?: string[]
30
- /** 可选 PNG mask(本画廊 imageId)。 */
48
+ /** @deprecated 旧客户端兼容:等价于 kind='gallery' 的 mask。 */
31
49
  maskId?: string
32
50
  }
33
51
 
package/src/errors.ts CHANGED
@@ -13,6 +13,8 @@ export const ERROR_CODES = [
13
13
  'task-not-runnable',
14
14
  'credential-unavailable',
15
15
  'settings-unavailable',
16
+ 'unsupported-media',
17
+ 'upload-too-large',
16
18
  'provider-error',
17
19
  ] as const
18
20
 
@@ -41,6 +43,10 @@ export function statusOfCode(code: string): number {
41
43
  case 'credential-unavailable':
42
44
  case 'settings-unavailable':
43
45
  return 409
46
+ case 'unsupported-media':
47
+ return 415
48
+ case 'upload-too-large':
49
+ return 413
44
50
  default:
45
51
  return 500
46
52
  }
@@ -11,6 +11,8 @@ import { appendFile, mkdir, readdir, readFile, rename, rm, writeFile } from 'nod
11
11
  import { join } from 'node:path'
12
12
 
13
13
  import { pluginDataPath } from '@dsh-plus/shared'
14
+ import { isImageId } from '../images/id.ts'
15
+ import { extOfMime } from '../images/mime.ts'
14
16
  import type { ImageEndpoint } from '../provider/types.ts'
15
17
 
16
18
  /** 画廊条目元数据(DTO 主体,端点原样投影)。 */
@@ -30,8 +32,10 @@ export interface GalleryItem {
30
32
  imageIds: string[]
31
33
  /** 每张图的 revised_prompt(与 imageIds 对齐,可为 null)。 */
32
34
  revisedPrompts: Array<string | null>
33
- /** 图生图源图(本画廊内的 imageId);文生图为空。 */
35
+ /** 图生图源图中的画廊图片(本画廊 imageId);文生图为空。 */
34
36
  sourceIds: string[]
37
+ /** 图生图源图中的本地文件上传(uploads 暂存区 id);上传被回收即失效。 */
38
+ sourceUploads: string[]
35
39
  }
36
40
 
37
41
  /**
@@ -49,23 +53,20 @@ function imagesDir(): string {
49
53
  return join(galleryRoot(), 'images')
50
54
  }
51
55
 
52
- /** 图片扩展名由 MIME 推导(仅白名单格式)。 */
53
- export function extOfMime(mime: string): string {
54
- switch (mime) {
55
- case 'image/jpeg':
56
- return 'jpg'
57
- case 'image/webp':
58
- return 'webp'
59
- default:
60
- return 'png'
61
- }
62
- }
63
-
64
56
  /** 确保目录存在。 */
65
57
  async function ensureDirs(): Promise<void> {
66
58
  await mkdir(imagesDir(), { recursive: true })
67
59
  }
68
60
 
61
+ /** 旧条目补齐新增字段(sourceUploads 为后加字段,历史 JSONL 里不存在)。 */
62
+ function normalizeItem(item: GalleryItem): GalleryItem {
63
+ return {
64
+ ...item,
65
+ sourceIds: item.sourceIds ?? [],
66
+ sourceUploads: item.sourceUploads ?? [],
67
+ }
68
+ }
69
+
69
70
  /** 解析 JSONL 全量元数据(坏行跳过并告警;空文件返回空表)。 */
70
71
  export async function loadGallery(log: (message: string) => void): Promise<GalleryItem[]> {
71
72
  let text: string
@@ -78,7 +79,7 @@ export async function loadGallery(log: (message: string) => void): Promise<Galle
78
79
  for (const line of text.split('\n')) {
79
80
  if (line.trim().length === 0) continue
80
81
  try {
81
- items.push(JSON.parse(line) as GalleryItem)
82
+ items.push(normalizeItem(JSON.parse(line) as GalleryItem))
82
83
  } catch {
83
84
  log(`画廊元数据坏行跳过:${line.slice(0, 80)}`)
84
85
  }
@@ -94,7 +95,7 @@ async function appendIndex(item: GalleryItem): Promise<void> {
94
95
 
95
96
  /** 图片文件绝对路径。 */
96
97
  function imagePathOf(imageId: string, ext: string): string {
97
- if (!/^[a-f0-9-]{36}$/.test(imageId) || !/^[a-z0-9]{2,4}$/.test(ext)) {
98
+ if (!isImageId(imageId) || !/^[a-z0-9]{2,4}$/.test(ext)) {
98
99
  throw new Error(`非法画廊图片标识:${imageId}.${ext}`)
99
100
  }
100
101
  return join(imagesDir(), `${imageId}.${ext}`)
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 图片标识形态(uuid):画廊 imageId 与上传 uploadId 共用同一约束。
3
+ * 既是路由参数白名单,也是路径拼接的穿越防线(单一事实源)。
4
+ * @module image-studio/images/id
5
+ */
6
+
7
+ const IMAGE_ID_RE = /^[a-f0-9-]{36}$/
8
+
9
+ /** 是否为合法图片标识(未知类型一律 false)。 */
10
+ export function isImageId(value: unknown): value is string {
11
+ return typeof value === 'string' && IMAGE_ID_RE.test(value)
12
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 图片 MIME 单一事实源:扩展名互转 + 魔数嗅探。
3
+ * 上传边界只认官方 images 端点接受的位图格式(png/jpeg/webp/gif),
4
+ * 不信任客户端声明的 content-type(防伪装文件落盘)。
5
+ * 纯函数零依赖,宿主两半均可安全引入。
6
+ * @module image-studio/images/mime
7
+ */
8
+
9
+ /** 魔数嗅探窗口(最长特征序列 + 偏移)。 */
10
+ const SNIFF_BYTES = 16
11
+
12
+ /** 落盘扩展名 ← MIME(白名单外一律按 png 处理)。 */
13
+ export function extOfMime(mime: string): string {
14
+ switch (mime) {
15
+ case 'image/jpeg':
16
+ return 'jpg'
17
+ case 'image/webp':
18
+ return 'webp'
19
+ case 'image/gif':
20
+ return 'gif'
21
+ default:
22
+ return 'png'
23
+ }
24
+ }
25
+
26
+ /** MIME ← 落盘扩展名(读取回程;与 extOfMime 互逆)。 */
27
+ export function mimeOfExt(ext: string): string {
28
+ switch (ext) {
29
+ case 'jpg':
30
+ case 'jpeg':
31
+ return 'image/jpeg'
32
+ case 'webp':
33
+ return 'image/webp'
34
+ case 'gif':
35
+ return 'image/gif'
36
+ default:
37
+ return 'image/png'
38
+ }
39
+ }
40
+
41
+ /** 前若干字节是否以 prefix 开头。 */
42
+ function startsWith(bytes: Uint8Array, prefix: readonly number[], offset = 0): boolean {
43
+ if (bytes.length < offset + prefix.length) return false
44
+ return prefix.every((value, index) => bytes[offset + index] === value)
45
+ }
46
+
47
+ /** ASCII 特征序列 → 字节数组('RIFF' / 'WEBP' 等容器标记)。 */
48
+ function ascii(text: string): number[] {
49
+ return [...text].map((char) => char.charCodeAt(0))
50
+ }
51
+
52
+ /**
53
+ * 魔数嗅探图片类型;非白名单格式返回 null。
54
+ * @param bytes - 文件字节(只需前 16 字节即可判定)。
55
+ */
56
+ export function detectImageMime(bytes: Uint8Array): string | null {
57
+ const head = bytes.subarray(0, SNIFF_BYTES)
58
+ if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png'
59
+ if (startsWith(head, [0xff, 0xd8, 0xff])) return 'image/jpeg'
60
+ if (startsWith(head, ascii('GIF87a')) || startsWith(head, ascii('GIF89a'))) return 'image/gif'
61
+ // WebP:RIFF 容器 + 第 8 字节起 'WEBP' 四字符码。
62
+ if (startsWith(head, ascii('RIFF')) && startsWith(head, ascii('WEBP'), 8)) return 'image/webp'
63
+ return null
64
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * dsh 插件:图像工作室。
3
- * OpenAI Images 协议文生图/图生图,官方全参数开关化(enabled=false 不进请求),
4
- * 双预设体系(提示词/参数/提供商),并发生图任务(FIFO + 可配置上限),
5
- * 画廊持久化(元数据 JSONL + 图片落盘)与二次编辑链(sourceIds)。
6
- * 服务经 ctx.imageStudio 暴露;前端接口见 docs/README.md「前端接入点」。
3
+ * OpenAI Images 协议文生图/图生图(源图可取画廊图片或本地上传),官方全参数
4
+ * 开关化(enabled=false 不进请求),双预设体系(提示词/参数/提供商),并发生图
5
+ * 任务(FIFO + 可配置上限),画廊持久化(元数据 JSONL + 图片落盘)与二次编辑链
6
+ * (sourceIds / sourceUploads)。服务经 ctx.imageStudio 暴露;
7
+ * 前端接口见 docs/README.md「前端接入点」。
7
8
  * @module @dsh-plus/image-studio
8
9
  */
9
10
  import type { Context } from '@deepseek-ai/cordis'
@@ -22,12 +23,18 @@ export type {
22
23
  GenerateRequest,
23
24
  PresetsWire,
24
25
  ProvidersWire,
26
+ SourceRef,
25
27
  TaskWire,
28
+ UploadEntry,
26
29
  } from './dto.ts'
27
- export { type ErrorCode, ImageStudioError } from './errors.ts'
30
+ export { type ErrorCode, ImageStudioError, statusOfCode } from './errors.ts'
28
31
  export type { GalleryItem } from './gallery/store.ts'
32
+ export { isImageId } from './images/id.ts'
33
+ export { detectImageMime, extOfMime, mimeOfExt } from './images/mime.ts'
34
+ export { MAX_SOURCE_IMAGES, UPLOAD_MAX_BYTES } from './limits.ts'
29
35
  export type { ParamEntry } from './params/catalog.ts'
30
36
  export { PARAM_CATALOG, paramEntryOf, paramKeysFor } from './params/catalog.ts'
37
+ export { checkImageSize, IMAGE_SIZE_RULE, SIZE_PRESETS } from './params/size.ts'
31
38
  export type { ParamSpec, ParamSpecMap } from './params/spec.ts'
32
39
  export { normalizeParamSpecs, ParamValidationError, validateParamSpecs } from './params/spec.ts'
33
40
  export { ImageStudioService } from './service.ts'
package/src/limits.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 跨半共享的数量/体积上限(单一事实源)。
3
+ * 宿主边界校验与浏览器半控件共用,避免两处各写一个数字而漂移。
4
+ * 纯常量零依赖,浏览器半可安全引入。
5
+ * @module image-studio/limits
6
+ */
7
+
8
+ /** 单次 edits 请求的源图上限(官方 GPT Image 限制)。 */
9
+ export const MAX_SOURCE_IMAGES = 16
10
+
11
+ /** 单张上传原图体积上限(50MB,对齐官方 images 端点与仓库上传约定)。 */
12
+ export const UPLOAD_MAX_BYTES = 50 * 1024 * 1024