@dickpy/dsh-imagegen 1.3.0 → 1.4.0

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.
Files changed (45) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +203 -182
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +1103 -837
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +265 -135
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -418
  10. package/src/client/ImageGenPanel.tsx +1699 -1508
  11. package/src/client/SettingsCard.tsx +936 -957
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -263
  15. package/src/client/controller.ts +46 -46
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/css-modules.d.ts +5 -5
  18. package/src/client/helpers.ts +33 -33
  19. package/src/client/image-toolview.module.css +73 -73
  20. package/src/client/image-toolview.tsx +169 -158
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -594
  23. package/src/client/mount.tsx +185 -96
  24. package/src/client/panel.module.css +1713 -1445
  25. package/src/client/settings-card.module.css +1023 -1023
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -298
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -478
  31. package/src/gallery-store.ts +286 -286
  32. package/src/generation-runtime.ts +79 -75
  33. package/src/history-store.ts +250 -244
  34. package/src/image-format.ts +11 -11
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -318
  37. package/src/model-catalog.ts +115 -98
  38. package/src/presets.ts +71 -63
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -326
  41. package/src/routes.ts +916 -906
  42. package/src/task-queue.ts +113 -103
  43. package/src/templates/cases.json +10196 -10196
  44. package/src/templates-store.ts +278 -278
  45. package/src/updater.ts +117 -117
package/src/engine.ts CHANGED
@@ -1,478 +1,520 @@
1
- /**
2
- * Upstream proxy engine: forwards a generate request to the configured
3
- * OpenAI-compatible image endpoint (/images/generations for text-to-image,
4
- * /images/edits for image-to-image) and normalizes the response to base64
5
- * images so the browser never fetches the upstream itself.
6
- *
7
- * Framework-free (no cordis imports) so the route layer and tests can drive
8
- * it directly.
9
- */
10
-
11
- import type { GeneratedImage, GenerateRequest, GenerateResult } from './protocol.ts'
12
- import { detectImageMime } from './image-format.ts'
13
- import { modelFamily } from './model-catalog.ts'
14
-
15
- /** The upstream credentials the panel's settings card configures. */
16
- export interface UpstreamConfig {
17
- /** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
18
- apiUrl: string
19
- /** Bearer API key. */
20
- apiKey: string
21
- }
22
-
23
- /** A generation failure with a user-presentable message. */
24
- export class ImageGenError extends Error {
25
- /** Stable wire code. */
26
- readonly code: string
27
-
28
- constructor(message: string, code = 'generate-failed') {
29
- super(message)
30
- this.name = 'ImageGenError'
31
- this.code = code
32
- }
33
- }
34
-
35
- /** Total budget for the upstream generation call (image models are slow). */
36
- const UPSTREAM_TIMEOUT_MS = 240_000
37
-
38
- /** Budget for downloading one result image URL. */
39
- const IMAGE_FETCH_TIMEOUT_MS = 60_000
40
-
41
- /** Cap on the reference image payload (edit mode), in bytes. */
42
- const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024
43
-
44
- /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
45
- const DALLE3_SIZES = new Set(['1024x1024', '1792x1024', '1024x1792'])
46
-
47
- /** The wire model id for a request: `upstream` (host-filled alias mapping)
48
- * wins, then the alias, then the family default. */
49
- function wireModel(request: GenerateRequest): string {
50
- const upstream = request.upstream?.trim()
51
- if (upstream !== undefined && upstream !== '') return upstream
52
- const alias = request.model.trim()
53
- return alias === '' ? 'gpt-image-2' : alias
54
- }
55
-
56
- /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
57
- * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
58
- * and exposes its own aspect-ratio / response-format knobs instead of the
59
- * OpenAI size/quality/detail passthrough. */
60
- function isGrokImagine(model: string): boolean {
61
- return modelFamily(model) === 'grok'
62
- }
63
-
64
- /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
65
- * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
66
- * gateways expose). OpenAI-compatible gateways serve these with their own
67
- * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
68
- * passthrough. */
69
- function isNanoBanana(model: string): boolean {
70
- return modelFamily(model) === 'nanobanana'
71
- }
72
-
73
- /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
74
- * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
75
- * serve Seedream through a unified generate-and-edit architecture:
76
- * generation AND editing both go to /images/generations and reference images
77
- * are a JSON URL / data-URL array. */
78
- function isSeedream(model: string): boolean {
79
- return modelFamily(model) === 'seedream'
80
- }
81
-
82
- /** Whether this is the official Volcengine Ark model naming convention. */
83
- function isVolcSeedream(model: string): boolean {
84
- return /^doubao-seedream(?:-|$)/i.test(model.trim())
85
- }
86
-
87
- /** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
88
- function seedreamSize(quality: string): string {
89
- // Seedream 5.0 Pro currently caps at 2K; keep 4K requests valid by
90
- // degrading them to the highest supported tier instead of sending 4K.
91
- if (quality === '1k') return '1K'
92
- return '2K'
93
- }
94
-
95
- /** The panel's aspect ratios mapped to the closest OpenAI pixel size
96
- * (gpt-image-2 / generic OpenAI-compatible endpoints). */
97
- const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
98
- '1:1': '1024x1024',
99
- '3:4': '1024x1536',
100
- '4:3': '1536x1024',
101
- '9:16': '1024x1792',
102
- '2:3': '1024x1536',
103
- '3:2': '1536x1024',
104
- '16:9': '1792x1024',
105
- '21:9': '1792x1024',
106
- }
107
-
108
- /** Panel ratios that need renaming for a model's vocabulary. Grok documents
109
- * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
110
- const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
111
- '21:9': '20:9',
112
- }
113
-
114
- /**
115
- * One request-scoped timeout that is cleared as soon as its fetch settles.
116
- * AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
117
- * task queue leaves an otherwise idle Node process holding every timeout.
118
- */
119
- function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
120
- const controller = new AbortController()
121
- const abortFromSource = () => { controller.abort(source?.reason) }
122
- if (source?.aborted === true) abortFromSource()
123
- else source?.addEventListener('abort', abortFromSource, { once: true })
124
- const timeout = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs)
125
- timeout.unref()
126
- return {
127
- signal: controller.signal,
128
- dispose: () => {
129
- clearTimeout(timeout)
130
- source?.removeEventListener('abort', abortFromSource)
131
- },
132
- }
133
- }
134
-
135
- /** Content-type extension hints for URL-fetched images. */
136
- function mimeOfExtension(path: string): string | undefined {
137
- const match = /\.([a-z0-9]+)$/i.exec(path)
138
- if (match === null) return undefined
139
- switch (match[1]!.toLowerCase()) {
140
- case 'png': return 'image/png'
141
- case 'jpg':
142
- case 'jpeg': return 'image/jpeg'
143
- case 'webp': return 'image/webp'
144
- case 'gif': return 'image/gif'
145
- default: return undefined
146
- }
147
- }
148
-
149
- /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
150
- function parseDataUrl(dataUrl: string): { mime: string; base64: string } | undefined {
151
- const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim())
152
- if (match === null || match[3] === undefined) return undefined
153
- if (match[2] === undefined) {
154
- // Plain (non-base64) data URLs are not supported for reference images.
155
- return undefined
156
- }
157
- return { mime: match[1] ?? 'application/octet-stream', base64: match[3] }
158
- }
159
-
160
- /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
161
- function bareBase64(value: string): string {
162
- const parsed = parseDataUrl(value)
163
- return parsed !== undefined && parsed.base64 !== undefined ? parsed.base64 : value
164
- }
165
-
166
- /** Clamp the requested image count into the API-accepted range. */
167
- function clampCount(n: number): number {
168
- if (!Number.isFinite(n)) return 1
169
- return Math.min(4, Math.max(1, Math.round(n)))
170
- }
171
-
172
- /** Pick the effective per-model request parameters. Never includes `n`: the
173
- * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
174
- * so the count is satisfied by parallel single-image requests instead. */
175
- function effectiveParams(request: GenerateRequest): {
176
- model: string
177
- size?: string
178
- quality?: string
179
- detail?: string
180
- aspect_ratio?: string
181
- image_size?: string
182
- resolution?: string
183
- response_format?: string
184
- } {
185
- const model = wireModel(request)
186
- // dall-e-3 has no quality/detail knobs and only produces one image.
187
- if (model === 'dall-e-3') {
188
- const pixel = OPENAI_SIZE_BY_RATIO[request.size]
189
- const size = (pixel !== undefined && DALLE3_SIZES.has(pixel)) ? pixel : '1024x1024'
190
- return { model, size }
191
- }
192
- // Grok Imagine: the panel's aspect ratios are sent as-is (21:9 aliased to
193
- // the documented 20:9), the clarity tiers become the resolution parameter
194
- // (the API documents 1k / 2k only, so 4k falls back to 2k), and base64
195
- // output keeps the temporary signed result URLs from expiring before the
196
- // host downloads them.
197
- if (isGrokImagine(model)) {
198
- return {
199
- model,
200
- ...request.size !== '' && request.size !== 'auto'
201
- ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size }
202
- : {},
203
- ...request.quality !== '' && request.quality !== 'auto'
204
- ? { resolution: request.quality === '4k' ? '2k' : request.quality }
205
- : {},
206
- response_format: 'b64_json',
207
- }
208
- }
209
- // Google Nano Banana: the panel's aspect ratios are sent as-is (the family
210
- // documents 1:1 … 21:9 natively), the clarity tiers become image_size
211
- // (1K / 2K / 4K — Gen 1 and 2-Lite are 1K-only upstream, but which gateway
212
- // rejects higher tiers is its own call), and base64 output keeps any signed
213
- // result URLs from expiring before the host downloads them.
214
- if (isNanoBanana(model)) {
215
- return {
216
- model,
217
- ...request.size !== '' && request.size !== 'auto'
218
- ? { aspect_ratio: request.size }
219
- : {},
220
- ...request.quality !== '' && request.quality !== 'auto'
221
- ? { image_size: request.quality.toUpperCase() }
222
- : {},
223
- response_format: 'b64_json',
224
- }
225
- }
226
- // ByteDance Seedream: the official Volcengine Ark API uses `size` for the
227
- // resolution tier (1K / 2K), not the panel's aspect-ratio value. It returns
228
- // temporary URLs, so ask Ark for URL output and let the host download it.
229
- // Other compatible gateways retain the base64 response fallback.
230
- if (isSeedream(model)) {
231
- return {
232
- model,
233
- size: seedreamSize(request.quality),
234
- response_format: isVolcSeedream(model) ? 'url' : 'b64_json',
235
- }
236
- }
237
- // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
238
- // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
239
- return {
240
- model,
241
- ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
242
- ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
243
- : {},
244
- ...request.quality === '1k' ? { quality: 'low' } : {},
245
- ...request.quality === '2k' ? { quality: 'medium' } : {},
246
- ...request.quality === '4k' ? { quality: 'high' } : {},
247
- ...request.detail !== '' ? { detail: request.detail } : {},
248
- }
249
- }
250
-
251
- /** How many single-image requests to issue for the requested image count. */
252
- function effectiveCount(request: GenerateRequest): number {
253
- const model = wireModel(request)
254
- if (model === 'dall-e-3') return 1
255
- return clampCount(request.n)
256
- }
257
-
258
- /** Normalize one upstream data item into a base64 image. */
259
- async function normalizeItem(
260
- item: Record<string, unknown>,
261
- upstream: UpstreamConfig,
262
- ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
263
- const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
264
- if (typeof item.b64_json === 'string') {
265
- const b64 = bareBase64(item.b64_json)
266
- return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
267
- }
268
- if (typeof item.url !== 'string' || item.url === '') {
269
- throw new ImageGenError('upstream image item has neither b64_json nor url')
270
- }
271
- const url = item.url
272
- if (url.startsWith('data:')) {
273
- const parsed = parseDataUrl(url)
274
- if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
275
- return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
276
- }
277
- const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
278
- let response: Response
279
- try {
280
- response = await fetch(url, {
281
- headers: {
282
- ...upstream.apiKey === '' ? {} : { authorization: `Bearer ${upstream.apiKey}` },
283
- },
284
- signal: budget.signal,
285
- })
286
- } catch (error) {
287
- throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
288
- } finally {
289
- budget.dispose()
290
- }
291
- if (!response.ok) {
292
- throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
293
- }
294
- const buffer = Buffer.from(await response.arrayBuffer())
295
- const contentType = response.headers.get('content-type')
296
- const mime = detectImageMime(buffer)
297
- ?? (contentType !== null && contentType !== ''
298
- ? contentType.split(';')[0]!.trim()
299
- : mimeOfExtension(url) ?? 'image/png')
300
- return { b64: buffer.toString('base64'), mime, revisedPrompt }
301
- }
302
-
303
- /**
304
- * Issue one single-image request (never sends `n`). The response is kept as a
305
- * list so a gateway that happens to return several images per call still works.
306
- */
307
- async function requestOneImage(
308
- baseUrl: string,
309
- upstream: UpstreamConfig,
310
- request: GenerateRequest,
311
- params: ReturnType<typeof effectiveParams>,
312
- signal?: AbortSignal,
313
- ): Promise<GeneratedImage[]> {
314
- const headers: Record<string, string> = {
315
- authorization: `Bearer ${upstream.apiKey.trim()}`,
316
- }
317
- let body: BodyInit
318
- if (request.mode === 'edit') {
319
- if (typeof request.image !== 'string' || request.image === '') {
320
- throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
321
- }
322
- const parsed = parseDataUrl(request.image)
323
- if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
324
- let bytes: Buffer
325
- try {
326
- bytes = Buffer.from(parsed.base64, 'base64')
327
- } catch {
328
- throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
329
- }
330
- if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
331
- throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
332
- }
333
- // Grok Imagine /images/edits takes a JSON image_url object (a base64 data
334
- // URI is accepted) instead of OpenAI's multipart form-data upload.
335
- if (isGrokImagine(params.model)) {
336
- headers['content-type'] = 'application/json'
337
- body = JSON.stringify({
338
- model: params.model,
339
- prompt: request.prompt,
340
- image: { url: request.image, type: 'image_url' },
341
- ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
342
- response_format: 'b64_json',
343
- })
344
- } else if (isNanoBanana(params.model)) {
345
- // Nano Banana OpenAI-compatible gateways accept the standard multipart
346
- // edit upload, with the family's own aspect_ratio / image_size knobs.
347
- const form = new FormData()
348
- form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
349
- form.append('prompt', request.prompt)
350
- form.append('model', params.model)
351
- if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
352
- if (params.image_size !== undefined) form.append('image_size', params.image_size)
353
- body = form
354
- } else if (isSeedream(params.model)) {
355
- // Seedream unifies generation and editing on /images/generations; the
356
- // reference image is a JSON URL / data-URL array, never multipart.
357
- headers['content-type'] = 'application/json'
358
- body = JSON.stringify({
359
- model: params.model,
360
- prompt: request.prompt,
361
- image: [request.image],
362
- ...params.size !== undefined ? { size: params.size } : {},
363
- ...params.resolution !== undefined ? { resolution: params.resolution } : {},
364
- response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
365
- })
366
- } else {
367
- const form = new FormData()
368
- form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
369
- form.append('prompt', request.prompt)
370
- form.append('model', params.model)
371
- if (params.size !== undefined) form.append('size', params.size)
372
- if (params.quality !== undefined) form.append('quality', params.quality)
373
- if (params.detail !== undefined) form.append('detail', params.detail)
374
- body = form
375
- }
376
- } else {
377
- headers['content-type'] = 'application/json'
378
- body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
379
- }
380
-
381
- const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
382
- let response: Response
383
- try {
384
- // Seedream has no /images/edits endpoint: both modes hit generations.
385
- const endpoint = request.mode === 'edit' && !isSeedream(params.model)
386
- ? '/images/edits'
387
- : '/images/generations'
388
- response = await fetch(`${baseUrl}${endpoint}`, {
389
- method: 'POST',
390
- headers,
391
- body,
392
- signal: budget.signal,
393
- })
394
- } catch (error) {
395
- const message = error instanceof Error ? error.message : String(error)
396
- if (/aborter/i.test(message) || /timeout/i.test(message)) {
397
- throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
398
- }
399
- throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
400
- } finally {
401
- budget.dispose()
402
- }
403
-
404
- let payload: unknown
405
- try {
406
- payload = await response.json()
407
- } catch {
408
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
409
- }
410
- if (!response.ok || payload === null || typeof payload !== 'object') {
411
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
412
- }
413
-
414
- const record = payload as Record<string, unknown>
415
- const data = Array.isArray(record.data)
416
- ? record.data
417
- : Array.isArray(record.images)
418
- ? record.images
419
- : Array.isArray(record.output)
420
- ? record.output
421
- : undefined
422
- if (data === undefined) {
423
- throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
424
- }
425
- if (data.length === 0) {
426
- throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
427
- }
428
- return Promise.all(data.map(async (entry) => {
429
- if (entry === null || typeof entry !== 'object') {
430
- throw new ImageGenError('上游响应包含无效的图片条目', 'upstream-invalid')
431
- }
432
- return normalizeItem(entry as Record<string, unknown>, upstream)
433
- }))
434
- }
435
-
436
- /**
437
- * Forward one generate request to the configured endpoint. The requested image
438
- * count is satisfied with N parallel single-image requests (the `n` batch
439
- * parameter is never sent, because Responses-API-based gateways reject it as
440
- * `tools[0].n`), then the results are flattened in order.
441
- */
442
- export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest, options: { signal?: AbortSignal } = {}): Promise<GenerateResult> {
443
- const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
444
- if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
445
- if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
446
- const params = effectiveParams(request)
447
- const count = effectiveCount(request)
448
- const batches = await Promise.all(
449
- Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)),
450
- )
451
- return { images: batches.flat() }
452
- }
453
-
454
- /** Human-readable failure message from an upstream error payload. */
455
- function upstreamMessage(payload: unknown, status: number): string {
456
- if (payload !== null && typeof payload === 'object') {
457
- const record = payload as Record<string, unknown>
458
- const error = record.error
459
- if (error !== null && typeof error === 'object') {
460
- const message = (error as Record<string, unknown>).message
461
- if (typeof message === 'string' && message !== '') return message
462
- }
463
- if (typeof record.message === 'string' && record.message !== '') return record.message
464
- if (typeof record.error === 'string' && record.error !== '') return record.error
465
- }
466
- return `上游接口拒绝请求(HTTP ${status})`
467
- }
468
-
469
- /** File extension for a MIME type (multipart reference image). */
470
- function extensionOf(mime: string): string {
471
- switch (mime.split(';')[0]!.trim()) {
472
- case 'image/jpeg': return 'jpg'
473
- case 'image/webp': return 'webp'
474
- case 'image/gif': return 'gif'
475
- case 'image/png':
476
- default: return 'png'
477
- }
478
- }
1
+ /**
2
+ * Upstream proxy engine: forwards a generate request to the configured
3
+ * OpenAI-compatible image endpoint (/images/generations for text-to-image,
4
+ * /images/edits for image-to-image) and normalizes the response to base64
5
+ * images so the browser never fetches the upstream itself.
6
+ *
7
+ * Framework-free (no cordis imports) so the route layer and tests can drive
8
+ * it directly.
9
+ */
10
+
11
+ import type { GeneratedImage, GenerateRequest, GenerateResult } from './protocol.ts'
12
+ import { detectImageMime } from './image-format.ts'
13
+ import { modelFamily } from './model-catalog.ts'
14
+
15
+ /** The upstream credentials the panel's settings card configures. */
16
+ export interface UpstreamConfig {
17
+ /** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
18
+ apiUrl: string
19
+ /** Bearer API key. */
20
+ apiKey: string
21
+ }
22
+
23
+ /** A generation failure with a user-presentable message. */
24
+ export class ImageGenError extends Error {
25
+ /** Stable wire code. */
26
+ readonly code: string
27
+
28
+ constructor(message: string, code = 'generate-failed') {
29
+ super(message)
30
+ this.name = 'ImageGenError'
31
+ this.code = code
32
+ }
33
+ }
34
+
35
+ /** Total budget for the upstream generation call (image models are slow). */
36
+ const UPSTREAM_TIMEOUT_MS = 240_000
37
+
38
+ /** Budget for downloading one result image URL. */
39
+ const IMAGE_FETCH_TIMEOUT_MS = 60_000
40
+
41
+ /** Cap on the reference image payload (edit mode), in bytes. */
42
+ const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024
43
+
44
+ /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
45
+ const DALLE3_SIZES = new Set(['1024x1024', '1792x1024', '1024x1792'])
46
+
47
+ /** The wire model id for a request: `upstream` (host-filled alias mapping)
48
+ * wins, then the alias, then the family default. */
49
+ function wireModel(request: GenerateRequest): string {
50
+ const upstream = request.upstream?.trim()
51
+ if (upstream !== undefined && upstream !== '') return upstream
52
+ const alias = request.model.trim()
53
+ return alias === '' ? 'gpt-image-2' : alias
54
+ }
55
+
56
+ /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
57
+ * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
58
+ * and exposes its own aspect-ratio / response-format knobs instead of the
59
+ * OpenAI size/quality/detail passthrough. */
60
+ function isGrokImagine(model: string): boolean {
61
+ return modelFamily(model) === 'grok'
62
+ }
63
+
64
+ /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
65
+ * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
66
+ * gateways expose). OpenAI-compatible gateways serve these with their own
67
+ * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
68
+ * passthrough. */
69
+ function isNanoBanana(model: string): boolean {
70
+ return modelFamily(model) === 'nanobanana'
71
+ }
72
+
73
+ /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
74
+ * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
75
+ * serve Seedream through a unified generate-and-edit architecture:
76
+ * generation AND editing both go to /images/generations and reference images
77
+ * are a JSON URL / data-URL array. */
78
+ function isSeedream(model: string): boolean {
79
+ return modelFamily(model) === 'seedream'
80
+ }
81
+
82
+ /** Whether the model uses the official Zhipu image-generation contract. */
83
+ function isZhipuImage(model: string): boolean {
84
+ return modelFamily(model) === 'zhipu'
85
+ }
86
+
87
+ function isGlmImage(model: string): boolean {
88
+ return /^glm-image(?:-|$)/i.test(model.trim())
89
+ }
90
+
91
+ /** Whether this is the official Volcengine Ark model naming convention. */
92
+ function isVolcSeedream(model: string): boolean {
93
+ return /^doubao-seedream(?:-|$)/i.test(model.trim())
94
+ }
95
+
96
+ /** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
97
+ function seedreamSize(quality: string): string {
98
+ // Seedream 5.0 Pro currently caps at 2K; keep 4K requests valid by
99
+ // degrading them to the highest supported tier instead of sending 4K.
100
+ if (quality === '1k') return '1K'
101
+ return '2K'
102
+ }
103
+
104
+ /** The panel's aspect ratios mapped to the closest OpenAI pixel size
105
+ * (gpt-image-2 / generic OpenAI-compatible endpoints). */
106
+ const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
107
+ '1:1': '1024x1024',
108
+ '3:4': '1024x1536',
109
+ '4:3': '1536x1024',
110
+ '9:16': '1024x1792',
111
+ '2:3': '1024x1536',
112
+ '3:2': '1536x1024',
113
+ '16:9': '1792x1024',
114
+ '21:9': '1792x1024',
115
+ }
116
+
117
+ /** Panel ratios that need renaming for a model's vocabulary. Grok documents
118
+ * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
119
+ const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
120
+ '21:9': '20:9',
121
+ }
122
+
123
+ /**
124
+ * One request-scoped timeout that is cleared as soon as its fetch settles.
125
+ * AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
126
+ * task queue leaves an otherwise idle Node process holding every timeout.
127
+ */
128
+ function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
129
+ const controller = new AbortController()
130
+ const abortFromSource = () => { controller.abort(source?.reason) }
131
+ if (source?.aborted === true) abortFromSource()
132
+ else source?.addEventListener('abort', abortFromSource, { once: true })
133
+ const timeout = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs)
134
+ timeout.unref()
135
+ return {
136
+ signal: controller.signal,
137
+ dispose: () => {
138
+ clearTimeout(timeout)
139
+ source?.removeEventListener('abort', abortFromSource)
140
+ },
141
+ }
142
+ }
143
+
144
+ /** Content-type extension hints for URL-fetched images. */
145
+ function mimeOfExtension(path: string): string | undefined {
146
+ const match = /\.([a-z0-9]+)$/i.exec(path)
147
+ if (match === null) return undefined
148
+ switch (match[1]!.toLowerCase()) {
149
+ case 'png': return 'image/png'
150
+ case 'jpg':
151
+ case 'jpeg': return 'image/jpeg'
152
+ case 'webp': return 'image/webp'
153
+ case 'gif': return 'image/gif'
154
+ default: return undefined
155
+ }
156
+ }
157
+
158
+ /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
159
+ function parseDataUrl(dataUrl: string): { mime: string; base64: string } | undefined {
160
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim())
161
+ if (match === null || match[3] === undefined) return undefined
162
+ if (match[2] === undefined) {
163
+ // Plain (non-base64) data URLs are not supported for reference images.
164
+ return undefined
165
+ }
166
+ return { mime: match[1] ?? 'application/octet-stream', base64: match[3] }
167
+ }
168
+
169
+ /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
170
+ function bareBase64(value: string): string {
171
+ const parsed = parseDataUrl(value)
172
+ return parsed !== undefined && parsed.base64 !== undefined ? parsed.base64 : value
173
+ }
174
+
175
+ /** Whether a result URL carries cloud-storage signing credentials. */
176
+ function isPresignedUrl(value: string): boolean {
177
+ let url: URL
178
+ try {
179
+ url = new URL(value)
180
+ } catch {
181
+ return false
182
+ }
183
+ const params = new Set(Array.from(url.searchParams.keys(), key => key.toLowerCase()))
184
+ if (params.has('x-goog-signature') || params.has('x-goog-credential')) return true
185
+ if (params.has('x-amz-signature') || params.has('x-amz-credential')) return true
186
+ return params.has('signature') && (
187
+ params.has('expires') || params.has('googleaccessid') || params.has('awsaccesskeyid')
188
+ )
189
+ }
190
+
191
+ /** Clamp the requested image count into the API-accepted range. */
192
+ function clampCount(n: number): number {
193
+ if (!Number.isFinite(n)) return 1
194
+ return Math.min(4, Math.max(1, Math.round(n)))
195
+ }
196
+
197
+ /** Pick the effective per-model request parameters. Never includes `n`: the
198
+ * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
199
+ * so the count is satisfied by parallel single-image requests instead. */
200
+ function effectiveParams(request: GenerateRequest): {
201
+ model: string
202
+ size?: string
203
+ quality?: string
204
+ detail?: string
205
+ aspect_ratio?: string
206
+ image_size?: string
207
+ resolution?: string
208
+ response_format?: string
209
+ } {
210
+ const model = wireModel(request)
211
+ // dall-e-3 has no quality/detail knobs and only produces one image.
212
+ if (model === 'dall-e-3') {
213
+ const pixel = OPENAI_SIZE_BY_RATIO[request.size]
214
+ const size = (pixel !== undefined && DALLE3_SIZES.has(pixel)) ? pixel : '1024x1024'
215
+ return { model, size }
216
+ }
217
+ // Grok Imagine: the panel's aspect ratios are sent as-is (21:9 aliased to
218
+ // the documented 20:9), the clarity tiers become the resolution parameter
219
+ // (the API documents 1k / 2k only, so 4k falls back to 2k), and base64
220
+ // output keeps the temporary signed result URLs from expiring before the
221
+ // host downloads them.
222
+ if (isGrokImagine(model)) {
223
+ return {
224
+ model,
225
+ ...request.size !== '' && request.size !== 'auto'
226
+ ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size }
227
+ : {},
228
+ ...request.quality !== '' && request.quality !== 'auto'
229
+ ? { resolution: request.quality === '4k' ? '2k' : request.quality }
230
+ : {},
231
+ response_format: 'b64_json',
232
+ }
233
+ }
234
+ // Google Nano Banana: the panel's aspect ratios are sent as-is (the family
235
+ // documents 1:1 … 21:9 natively), the clarity tiers become image_size
236
+ // (1K / 2K / 4K — Gen 1 and 2-Lite are 1K-only upstream, but which gateway
237
+ // rejects higher tiers is its own call), and base64 output keeps any signed
238
+ // result URLs from expiring before the host downloads them.
239
+ if (isNanoBanana(model)) {
240
+ return {
241
+ model,
242
+ ...request.size !== '' && request.size !== 'auto'
243
+ ? { aspect_ratio: request.size }
244
+ : {},
245
+ ...request.quality !== '' && request.quality !== 'auto'
246
+ ? { image_size: request.quality.toUpperCase() }
247
+ : {},
248
+ response_format: 'b64_json',
249
+ }
250
+ }
251
+ // ByteDance Seedream: the official Volcengine Ark API uses `size` for the
252
+ // resolution tier (1K / 2K), not the panel's aspect-ratio value. It returns
253
+ // temporary URLs, so ask Ark for URL output and let the host download it.
254
+ // Other compatible gateways retain the base64 response fallback.
255
+ if (isSeedream(model)) {
256
+ return {
257
+ model,
258
+ size: seedreamSize(request.quality),
259
+ response_format: isVolcSeedream(model) ? 'url' : 'b64_json',
260
+ }
261
+ }
262
+ // Zhipu's official image API accepts OpenAI-style JSON but uses its own
263
+ // quality vocabulary. GLM-Image currently supports hd only; CogView uses
264
+ // the standard tier. Size remains a valid custom pixel size for both.
265
+ if (isZhipuImage(model)) {
266
+ return {
267
+ model,
268
+ ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
269
+ ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
270
+ : {},
271
+ quality: isGlmImage(model) ? 'hd' : 'standard',
272
+ }
273
+ }
274
+ // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
275
+ // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
276
+ return {
277
+ model,
278
+ ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
279
+ ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
280
+ : {},
281
+ ...request.quality === '1k' ? { quality: 'low' } : {},
282
+ ...request.quality === '2k' ? { quality: 'medium' } : {},
283
+ ...request.quality === '4k' ? { quality: 'high' } : {},
284
+ ...request.detail !== '' ? { detail: request.detail } : {},
285
+ }
286
+ }
287
+
288
+ /** How many single-image requests to issue for the requested image count. */
289
+ function effectiveCount(request: GenerateRequest): number {
290
+ const model = wireModel(request)
291
+ if (model === 'dall-e-3') return 1
292
+ return clampCount(request.n)
293
+ }
294
+
295
+ /** Normalize one upstream data item into a base64 image. */
296
+ async function normalizeItem(
297
+ item: Record<string, unknown>,
298
+ upstream: UpstreamConfig,
299
+ ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
300
+ const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
301
+ if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
302
+ const b64 = bareBase64(item.b64_json)
303
+ if (b64.trim() !== '') {
304
+ return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
305
+ }
306
+ }
307
+ if (typeof item.url !== 'string' || item.url === '') {
308
+ throw new ImageGenError('upstream image item has neither b64_json nor url')
309
+ }
310
+ const url = item.url
311
+ if (url.startsWith('data:')) {
312
+ const parsed = parseDataUrl(url)
313
+ if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
314
+ return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
315
+ }
316
+ const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
317
+ let response: Response
318
+ try {
319
+ response = await fetch(url, {
320
+ ...isPresignedUrl(url) || upstream.apiKey === ''
321
+ ? {}
322
+ : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
323
+ signal: budget.signal,
324
+ })
325
+ } catch (error) {
326
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
327
+ } finally {
328
+ budget.dispose()
329
+ }
330
+ if (!response.ok) {
331
+ throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
332
+ }
333
+ const buffer = Buffer.from(await response.arrayBuffer())
334
+ const contentType = response.headers.get('content-type')
335
+ const mime = detectImageMime(buffer)
336
+ ?? (contentType !== null && contentType !== ''
337
+ ? contentType.split(';')[0]!.trim()
338
+ : mimeOfExtension(url) ?? 'image/png')
339
+ return { b64: buffer.toString('base64'), mime, revisedPrompt }
340
+ }
341
+
342
+ /**
343
+ * Issue one single-image request (never sends `n`). The response is kept as a
344
+ * list so a gateway that happens to return several images per call still works.
345
+ */
346
+ async function requestOneImage(
347
+ baseUrl: string,
348
+ upstream: UpstreamConfig,
349
+ request: GenerateRequest,
350
+ params: ReturnType<typeof effectiveParams>,
351
+ signal?: AbortSignal,
352
+ ): Promise<GeneratedImage[]> {
353
+ const headers: Record<string, string> = {
354
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
355
+ }
356
+ let body: BodyInit
357
+ if (request.mode === 'edit') {
358
+ if (typeof request.image !== 'string' || request.image === '') {
359
+ throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
360
+ }
361
+ const parsed = parseDataUrl(request.image)
362
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
363
+ let bytes: Buffer
364
+ try {
365
+ bytes = Buffer.from(parsed.base64, 'base64')
366
+ } catch {
367
+ throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
368
+ }
369
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
370
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
371
+ }
372
+ // Grok Imagine /images/edits takes a JSON image_url object (a base64 data
373
+ // URI is accepted) instead of OpenAI's multipart form-data upload.
374
+ if (isGrokImagine(params.model)) {
375
+ headers['content-type'] = 'application/json'
376
+ body = JSON.stringify({
377
+ model: params.model,
378
+ prompt: request.prompt,
379
+ image: { url: request.image, type: 'image_url' },
380
+ ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
381
+ response_format: 'b64_json',
382
+ })
383
+ } else if (isNanoBanana(params.model)) {
384
+ // Nano Banana OpenAI-compatible gateways accept the standard multipart
385
+ // edit upload, with the family's own aspect_ratio / image_size knobs.
386
+ const form = new FormData()
387
+ form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
388
+ form.append('prompt', request.prompt)
389
+ form.append('model', params.model)
390
+ if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
391
+ if (params.image_size !== undefined) form.append('image_size', params.image_size)
392
+ body = form
393
+ } else if (isSeedream(params.model)) {
394
+ // Seedream unifies generation and editing on /images/generations; the
395
+ // reference image is a JSON URL / data-URL array, never multipart.
396
+ headers['content-type'] = 'application/json'
397
+ body = JSON.stringify({
398
+ model: params.model,
399
+ prompt: request.prompt,
400
+ image: [request.image],
401
+ ...params.size !== undefined ? { size: params.size } : {},
402
+ ...params.resolution !== undefined ? { resolution: params.resolution } : {},
403
+ response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
404
+ })
405
+ } else {
406
+ const form = new FormData()
407
+ form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
408
+ form.append('prompt', request.prompt)
409
+ form.append('model', params.model)
410
+ if (params.size !== undefined) form.append('size', params.size)
411
+ if (params.quality !== undefined) form.append('quality', params.quality)
412
+ if (params.detail !== undefined) form.append('detail', params.detail)
413
+ body = form
414
+ }
415
+ } else {
416
+ headers['content-type'] = 'application/json'
417
+ body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
418
+ }
419
+
420
+ const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
421
+ let response: Response
422
+ try {
423
+ // Seedream has no /images/edits endpoint: both modes hit generations.
424
+ const endpoint = request.mode === 'edit' && !isSeedream(params.model)
425
+ ? '/images/edits'
426
+ : '/images/generations'
427
+ response = await fetch(`${baseUrl}${endpoint}`, {
428
+ method: 'POST',
429
+ headers,
430
+ body,
431
+ signal: budget.signal,
432
+ })
433
+ } catch (error) {
434
+ const message = error instanceof Error ? error.message : String(error)
435
+ if (/aborter/i.test(message) || /timeout/i.test(message)) {
436
+ throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
437
+ }
438
+ throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
439
+ } finally {
440
+ budget.dispose()
441
+ }
442
+
443
+ let payload: unknown
444
+ try {
445
+ payload = await response.json()
446
+ } catch {
447
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
448
+ }
449
+ if (!response.ok || payload === null || typeof payload !== 'object') {
450
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
451
+ }
452
+
453
+ const record = payload as Record<string, unknown>
454
+ const data = Array.isArray(record.data)
455
+ ? record.data
456
+ : Array.isArray(record.images)
457
+ ? record.images
458
+ : Array.isArray(record.output)
459
+ ? record.output
460
+ : undefined
461
+ if (data === undefined) {
462
+ throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
463
+ }
464
+ if (data.length === 0) {
465
+ throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
466
+ }
467
+ return Promise.all(data.map(async (entry) => {
468
+ if (entry === null || typeof entry !== 'object') {
469
+ throw new ImageGenError('上游响应包含无效的图片条目', 'upstream-invalid')
470
+ }
471
+ return normalizeItem(entry as Record<string, unknown>, upstream)
472
+ }))
473
+ }
474
+
475
+ /**
476
+ * Forward one generate request to the configured endpoint. The requested image
477
+ * count is satisfied with N parallel single-image requests (the `n` batch
478
+ * parameter is never sent, because Responses-API-based gateways reject it as
479
+ * `tools[0].n`), then the results are flattened in order.
480
+ */
481
+ export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest, options: { signal?: AbortSignal } = {}): Promise<GenerateResult> {
482
+ const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
483
+ if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
484
+ if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
485
+ if (request.mode === 'edit' && isZhipuImage(wireModel(request))) {
486
+ throw new ImageGenError('智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型', 'edit-unsupported')
487
+ }
488
+ const params = effectiveParams(request)
489
+ const count = effectiveCount(request)
490
+ const batches = await Promise.all(
491
+ Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)),
492
+ )
493
+ return { images: batches.flat() }
494
+ }
495
+
496
+ /** Human-readable failure message from an upstream error payload. */
497
+ function upstreamMessage(payload: unknown, status: number): string {
498
+ if (payload !== null && typeof payload === 'object') {
499
+ const record = payload as Record<string, unknown>
500
+ const error = record.error
501
+ if (error !== null && typeof error === 'object') {
502
+ const message = (error as Record<string, unknown>).message
503
+ if (typeof message === 'string' && message !== '') return message
504
+ }
505
+ if (typeof record.message === 'string' && record.message !== '') return record.message
506
+ if (typeof record.error === 'string' && record.error !== '') return record.error
507
+ }
508
+ return `上游接口拒绝请求(HTTP ${status})`
509
+ }
510
+
511
+ /** File extension for a MIME type (multipart reference image). */
512
+ function extensionOf(mime: string): string {
513
+ switch (mime.split(';')[0]!.trim()) {
514
+ case 'image/jpeg': return 'jpg'
515
+ case 'image/webp': return 'webp'
516
+ case 'image/gif': return 'gif'
517
+ case 'image/png':
518
+ default: return 'png'
519
+ }
520
+ }