-
Notifications
You must be signed in to change notification settings - Fork 157
Expand file tree
/
Copy pathimage-gen.ts
More file actions
328 lines (294 loc) · 7.75 KB
/
Copy pathimage-gen.ts
File metadata and controls
328 lines (294 loc) · 7.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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
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
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
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
318
319
320
321
322
323
324
325
326
327
328
export interface GeneratedImage {
base64: string;
mediaType: string;
}
export interface GalleryItem {
id: string;
prompt: string;
timestamp: number;
// Organization context active when the generation was started. Captured up
// front so the saved item is attributed to the right org even if the user
// switches organizations while the generation is in flight.
organizationId?: string;
inputImages?: { dataUrl: string; mediaType: string }[];
// Sidebar thumbnail URL for items loaded from the lightweight history
// list, which carries no base64 image data.
thumbnailUrl?: string | null;
models: {
modelId: string;
modelName: string;
images: GeneratedImage[];
// Number of stored images for history items whose image data hasn't
// been fetched yet (images stays empty until the detail query resolves).
imageCount?: number;
error?: string;
isLoading: boolean;
}[];
}
export type AspectRatio =
| "auto"
| "1:1"
| "9:16"
| "16:9"
| "3:4"
| "4:3"
| "3:2"
| "2:3"
| "5:4"
| "4:5"
| "21:9"
| "1:4"
| "4:1"
| "1:8"
| "8:1";
// Common gpt-image-2 sizes shown as presets in the playground. OpenAI also
// accepts arbitrary WxH sizes (both sides divisible by 16, max edge 3840) —
// this list is just for the dropdown UI.
export const GPT_IMAGE_SIZES = [
"auto",
"1024x1024",
"1536x1024",
"1024x1536",
"2048x2048",
"2048x1152",
"3840x2160",
"2160x3840",
] as const;
const REVE_ASPECT_RATIOS: AspectRatio[] = [
"auto",
"1:1",
"16:9",
"9:16",
"3:2",
"2:3",
"4:3",
];
export function getModelImageConfig(model: string) {
const lower = model.toLowerCase();
const isGptImage = lower.includes("gpt-image");
const isReve = lower.includes("reve");
const usesPixelDimensions =
isGptImage ||
lower.includes("alibaba") ||
lower.includes("qwen-image") ||
lower.includes("zai") ||
lower.includes("cogview");
const isSeedream =
lower.includes("seedream") || lower.includes("bytedance/seedream");
// Seedream 5.0 Pro tops out at ~2K (no 4K preset at launch) and adds a 1K
// tier, unlike the other Seedream image models which run 2K/4K.
const isSeedreamPro = lower.includes("seedream-5-0-pro");
const isGemini31FlashImage = lower.includes("gemini-3.1-flash-image");
const isGemini31FlashLiteImage = lower.includes(
"gemini-3.1-flash-lite-image",
);
const availableSizes = isGptImage
? GPT_IMAGE_SIZES
: isReve
? (["2K"] as const)
: isSeedreamPro
? (["1K", "2K"] as const)
: isSeedream
? (["2K", "4K"] as const)
: isGemini31FlashLiteImage
? (["1K"] as const)
: isGemini31FlashImage
? (["0.5K", "1K", "2K", "4K"] as const)
: (["1K", "2K", "4K"] as const);
const defaultSize = isGptImage
? "1024x1024"
: isReve
? "2K"
: isSeedream
? "2K"
: "1K";
const supportsQuality = isGptImage;
const availableQualities = isGptImage
? (["auto", "low", "medium", "high"] as const)
: ([] as readonly string[]);
const defaultQuality: string | undefined = isGptImage ? "low" : undefined;
const maxInputImages = getMaxInputImages(lower);
const supportedAspectRatios: AspectRatio[] | undefined = isReve
? REVE_ASPECT_RATIOS
: undefined;
return {
usesPixelDimensions,
isSeedream,
isGemini31FlashImage,
isGptImage,
isReve,
availableSizes,
defaultSize,
supportsQuality,
availableQualities,
defaultQuality,
maxInputImages,
supportedAspectRatios,
};
}
function getMaxInputImages(lowerModel: string): number {
// xAI Grok Imagine only supports a single reference image per generation.
if (lowerModel.includes("grok-imagine")) {
return 1;
}
// Google Gemini image models (Nano Banana family) support up to 3 reference images.
if (
lowerModel.includes("gemini") &&
(lowerModel.includes("-image") || lowerModel.includes("flash-image"))
) {
return 3;
}
// ByteDance Seedream 4.x accepts up to 10 reference images.
if (lowerModel.includes("seedream")) {
return 10;
}
// Alibaba Qwen-Image-Edit (plus/max) supports multi-image editing.
if (lowerModel.includes("qwen-image-edit")) {
return 5;
}
// ZAI CogView / GLM-Image: single-image conditioning at most.
if (lowerModel.includes("cogview") || lowerModel.includes("glm-image")) {
return 1;
}
return 4;
}
export async function parseImageStream(
response: Response,
): Promise<GeneratedImage[]> {
const images: GeneratedImage[] = [];
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
// AI SDK stream protocol: lines are prefixed with "0:", "2:", etc.
const colonIndex = trimmed.indexOf(":");
if (colonIndex < 0) {
continue;
}
const jsonStr = trimmed.slice(colonIndex + 1);
try {
const event = JSON.parse(jsonStr);
if (event.type === "file" && event.url && event.mediaType) {
const comma = event.url.indexOf(",");
const base64 = comma >= 0 ? event.url.slice(comma + 1) : "";
if (base64) {
images.push({
base64,
mediaType: event.mediaType,
});
}
}
} catch {
// skip non-JSON lines
}
}
}
// Process remaining buffer
if (buffer.trim()) {
const colonIndex = buffer.indexOf(":");
if (colonIndex >= 0) {
try {
const event = JSON.parse(buffer.slice(colonIndex + 1));
if (event.type === "file" && event.url && event.mediaType) {
const comma = event.url.indexOf(",");
const base64 = comma >= 0 ? event.url.slice(comma + 1) : "";
if (base64) {
images.push({
base64,
mediaType: event.mediaType,
});
}
}
} catch {
// skip
}
}
}
return images;
}
export async function streamImageParts(
response: Response,
onImage: (image: GeneratedImage) => void,
): Promise<void> {
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const colonIndex = trimmed.indexOf(":");
if (colonIndex < 0) {
continue;
}
const jsonStr = trimmed.slice(colonIndex + 1);
try {
const event = JSON.parse(jsonStr);
if (event.type === "file" && event.url && event.mediaType) {
const comma = event.url.indexOf(",");
const base64 = comma >= 0 ? event.url.slice(comma + 1) : "";
if (base64) {
onImage({ base64, mediaType: event.mediaType });
}
}
} catch {
// skip non-JSON lines
}
}
}
if (buffer.trim()) {
const colonIndex = buffer.indexOf(":");
if (colonIndex >= 0) {
try {
const event = JSON.parse(buffer.slice(colonIndex + 1));
if (event.type === "file" && event.url && event.mediaType) {
const comma = event.url.indexOf(",");
const base64 = comma >= 0 ? event.url.slice(comma + 1) : "";
if (base64) {
onImage({ base64, mediaType: event.mediaType });
}
}
} catch {
// skip
}
}
}
}
export function downloadImage(image: GeneratedImage, filename?: string) {
const dataUrl = `data:${image.mediaType};base64,${image.base64}`;
const ext = image.mediaType.split("/")[1] ?? "png";
const name = filename ?? `image-${Date.now()}.${ext}`;
const a = document.createElement("a");
a.href = dataUrl;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}