@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.
- package/LICENSE +201 -201
- package/README.md +203 -182
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1103 -837
- package/lib/client.js.map +1 -1
- package/lib/index.js +265 -135
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -418
- package/src/client/ImageGenPanel.tsx +1699 -1508
- package/src/client/SettingsCard.tsx +936 -957
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -263
- package/src/client/controller.ts +46 -46
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/css-modules.d.ts +5 -5
- package/src/client/helpers.ts +33 -33
- package/src/client/image-toolview.module.css +73 -73
- package/src/client/image-toolview.tsx +169 -158
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -594
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -1023
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -298
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -478
- package/src/gallery-store.ts +286 -286
- package/src/generation-runtime.ts +79 -75
- package/src/history-store.ts +250 -244
- package/src/image-format.ts +11 -11
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -318
- package/src/model-catalog.ts +115 -98
- package/src/presets.ts +71 -63
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -326
- package/src/routes.ts +916 -906
- package/src/task-queue.ts +113 -103
- package/src/templates/cases.json +10196 -10196
- package/src/templates-store.ts +278 -278
- 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
|
|
83
|
-
function
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
'
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
'
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
*/
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
):
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
let
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
+
}
|