@lobehub/chat 1.104.0 → 1.104.1
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/.cursor/rules/code-review.mdc +2 -0
- package/.cursor/rules/typescript.mdc +3 -1
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/src/app/[variants]/(main)/image/features/GenerationFeed/BatchItem.tsx +6 -1
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/ErrorState.tsx +3 -2
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/LoadingState.tsx +27 -24
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/SuccessState.tsx +14 -3
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/index.tsx +4 -7
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/types.ts +3 -0
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.test.ts +600 -0
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +126 -7
- package/src/const/imageGeneration.ts +18 -0
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +3 -0
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +7 -5
- package/src/libs/model-runtime/utils/streams/openai/openai.ts +8 -4
- package/src/libs/model-runtime/utils/usageConverter.test.ts +45 -1
- package/src/libs/model-runtime/utils/usageConverter.ts +6 -2
- package/src/server/services/generation/index.test.ts +848 -0
- package/src/server/services/generation/index.ts +90 -69
- package/src/utils/number.test.ts +101 -1
- package/src/utils/number.ts +42 -0
@@ -4,12 +4,54 @@ import mime from 'mime';
|
|
4
4
|
import { nanoid } from 'nanoid';
|
5
5
|
import sharp from 'sharp';
|
6
6
|
|
7
|
+
import { IMAGE_GENERATION_CONFIG } from '@/const/imageGeneration';
|
7
8
|
import { LobeChatDatabase } from '@/database/type';
|
9
|
+
import { parseDataUri } from '@/libs/model-runtime/utils/uriParser';
|
8
10
|
import { FileService } from '@/server/services/file';
|
11
|
+
import { calculateThumbnailDimensions } from '@/utils/number';
|
9
12
|
import { getYYYYmmddHHMMss } from '@/utils/time';
|
13
|
+
import { inferFileExtensionFromImageUrl } from '@/utils/url';
|
10
14
|
|
11
15
|
const log = debug('lobe-image:generation-service');
|
12
16
|
|
17
|
+
/**
|
18
|
+
* Fetch image buffer and MIME type from URL or base64 data
|
19
|
+
* @param url - Image URL or base64 data URI
|
20
|
+
* @returns Object containing buffer and MIME type
|
21
|
+
*/
|
22
|
+
export async function fetchImageFromUrl(url: string): Promise<{
|
23
|
+
buffer: Buffer;
|
24
|
+
mimeType: string;
|
25
|
+
}> {
|
26
|
+
if (url.startsWith('data:')) {
|
27
|
+
const { base64, mimeType, type } = parseDataUri(url);
|
28
|
+
|
29
|
+
if (type !== 'base64' || !base64 || !mimeType) {
|
30
|
+
throw new Error(`Invalid data URI format: ${url}`);
|
31
|
+
}
|
32
|
+
|
33
|
+
try {
|
34
|
+
const buffer = Buffer.from(base64, 'base64');
|
35
|
+
return { buffer, mimeType };
|
36
|
+
} catch (error) {
|
37
|
+
throw new Error(
|
38
|
+
`Failed to decode base64 data: ${error instanceof Error ? error.message : String(error)}`,
|
39
|
+
);
|
40
|
+
}
|
41
|
+
} else {
|
42
|
+
const response = await fetch(url);
|
43
|
+
if (!response.ok) {
|
44
|
+
throw new Error(
|
45
|
+
`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`,
|
46
|
+
);
|
47
|
+
}
|
48
|
+
const arrayBuffer = await response.arrayBuffer();
|
49
|
+
const buffer = Buffer.from(arrayBuffer);
|
50
|
+
const mimeType = response.headers.get('content-type') || 'application/octet-stream';
|
51
|
+
return { buffer, mimeType };
|
52
|
+
}
|
53
|
+
}
|
54
|
+
|
13
55
|
interface ImageForGeneration {
|
14
56
|
buffer: Buffer;
|
15
57
|
extension: string;
|
@@ -40,33 +82,12 @@ export class GenerationService {
|
|
40
82
|
}> {
|
41
83
|
log('Starting image transformation for:', url.startsWith('data:') ? 'base64 data' : url);
|
42
84
|
|
43
|
-
//
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
if (url.startsWith('data:')) {
|
48
|
-
log('Processing base64 image data');
|
49
|
-
// Extract the MIME type and base64 data part
|
50
|
-
const [mimeTypePart, base64Data] = url.split(',');
|
51
|
-
originalMimeType = mimeTypePart.split(':')[1].split(';')[0];
|
52
|
-
originalImageBuffer = Buffer.from(base64Data, 'base64');
|
53
|
-
} else {
|
54
|
-
log('Fetching image from URL:', url);
|
55
|
-
const response = await fetch(url);
|
56
|
-
if (!response.ok) {
|
57
|
-
throw new Error(
|
58
|
-
`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`,
|
59
|
-
);
|
60
|
-
}
|
61
|
-
const arrayBuffer = await response.arrayBuffer();
|
62
|
-
originalImageBuffer = Buffer.from(arrayBuffer);
|
63
|
-
originalMimeType = response.headers.get('content-type') || 'application/octet-stream';
|
64
|
-
log('Successfully fetched image, buffer size:', originalImageBuffer.length);
|
65
|
-
}
|
85
|
+
// Fetch image buffer and MIME type using utility function
|
86
|
+
const { buffer: originalImageBuffer, mimeType: originalMimeType } =
|
87
|
+
await fetchImageFromUrl(url);
|
66
88
|
|
67
89
|
// Calculate hash for original image
|
68
90
|
const originalHash = sha256(originalImageBuffer);
|
69
|
-
log('Original image hash calculated:', originalHash);
|
70
91
|
|
71
92
|
const sharpInstance = sharp(originalImageBuffer);
|
72
93
|
const { format, width, height } = await sharpInstance.metadata();
|
@@ -76,19 +97,20 @@ export class GenerationService {
|
|
76
97
|
throw new Error(`Invalid image format: ${format}, url: ${url}`);
|
77
98
|
}
|
78
99
|
|
79
|
-
const
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
100
|
+
const {
|
101
|
+
shouldResize: shouldResizeBySize,
|
102
|
+
thumbnailWidth,
|
103
|
+
thumbnailHeight,
|
104
|
+
} = calculateThumbnailDimensions(width, height);
|
105
|
+
const shouldResize = shouldResizeBySize || format !== 'webp';
|
106
|
+
|
107
|
+
log('Thumbnail processing decision:', {
|
108
|
+
format,
|
109
|
+
shouldResize,
|
110
|
+
shouldResizeBySize,
|
111
|
+
thumbnailHeight,
|
112
|
+
thumbnailWidth,
|
113
|
+
});
|
92
114
|
|
93
115
|
const thumbnailBuffer = shouldResize
|
94
116
|
? await sharpInstance.resize(thumbnailWidth, thumbnailHeight).webp().toBuffer()
|
@@ -96,11 +118,10 @@ export class GenerationService {
|
|
96
118
|
|
97
119
|
// Calculate hash for thumbnail
|
98
120
|
const thumbnailHash = sha256(thumbnailBuffer);
|
99
|
-
log('Thumbnail image hash calculated:', thumbnailHash);
|
100
121
|
|
101
122
|
log('Image transformation completed successfully');
|
102
123
|
|
103
|
-
// Determine extension
|
124
|
+
// Determine extension using url utility
|
104
125
|
let extension: string;
|
105
126
|
if (url.startsWith('data:')) {
|
106
127
|
const mimeExtension = mime.getExtension(originalMimeType);
|
@@ -109,11 +130,10 @@ export class GenerationService {
|
|
109
130
|
}
|
110
131
|
extension = mimeExtension;
|
111
132
|
} else {
|
112
|
-
|
113
|
-
if (!
|
133
|
+
extension = inferFileExtensionFromImageUrl(url);
|
134
|
+
if (!extension) {
|
114
135
|
throw new Error(`Unable to determine file extension from URL: ${url}`);
|
115
136
|
}
|
116
|
-
extension = urlExtension;
|
117
137
|
}
|
118
138
|
|
119
139
|
return {
|
@@ -144,9 +164,8 @@ export class GenerationService {
|
|
144
164
|
const generationImagesFolder = 'generations/images';
|
145
165
|
const uuid = nanoid();
|
146
166
|
const dateTime = getYYYYmmddHHMMss(new Date());
|
147
|
-
const
|
148
|
-
const
|
149
|
-
const thumbnailKey = `${pathPrefix}_thumb.${thumbnail.extension}`;
|
167
|
+
const imageKey = `${generationImagesFolder}/${uuid}_${image.width}x${image.height}_${dateTime}_raw.${image.extension}`;
|
168
|
+
const thumbnailKey = `${generationImagesFolder}/${uuid}_${thumbnail.width}x${thumbnail.height}_${dateTime}_thumb.${thumbnail.extension}`;
|
150
169
|
|
151
170
|
log('Generated paths:', { imagePath: imageKey, thumbnailPath: thumbnailKey });
|
152
171
|
|
@@ -189,37 +208,39 @@ export class GenerationService {
|
|
189
208
|
}
|
190
209
|
|
191
210
|
/**
|
192
|
-
* Create a
|
211
|
+
* Create a cover image from a given URL and upload
|
193
212
|
* @param coverUrl - The source image URL (can be base64 or HTTP URL)
|
194
213
|
* @returns The key of the uploaded cover image
|
195
214
|
*/
|
196
215
|
async createCoverFromUrl(coverUrl: string): Promise<string> {
|
197
216
|
log('Creating cover image from URL:', coverUrl.startsWith('data:') ? 'base64 data' : coverUrl);
|
198
217
|
|
199
|
-
//
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
const response = await fetch(coverUrl);
|
209
|
-
if (!response.ok) {
|
210
|
-
throw new Error(
|
211
|
-
`Failed to fetch cover image from ${coverUrl}: ${response.status} ${response.statusText}`,
|
212
|
-
);
|
213
|
-
}
|
214
|
-
const arrayBuffer = await response.arrayBuffer();
|
215
|
-
originalImageBuffer = Buffer.from(arrayBuffer);
|
216
|
-
log('Successfully fetched cover image, buffer size:', originalImageBuffer.length);
|
218
|
+
// Fetch image buffer using utility function
|
219
|
+
const { buffer: originalImageBuffer } = await fetchImageFromUrl(coverUrl);
|
220
|
+
|
221
|
+
// Get image metadata to calculate proper cover dimensions
|
222
|
+
const sharpInstance = sharp(originalImageBuffer);
|
223
|
+
const { width, height } = await sharpInstance.metadata();
|
224
|
+
|
225
|
+
if (!width || !height) {
|
226
|
+
throw new Error('Invalid image format for cover creation');
|
217
227
|
}
|
218
228
|
|
219
|
-
//
|
220
|
-
|
221
|
-
|
222
|
-
|
229
|
+
// Calculate cover dimensions maintaining aspect ratio with configurable max size
|
230
|
+
const { thumbnailWidth, thumbnailHeight } = calculateThumbnailDimensions(
|
231
|
+
width,
|
232
|
+
height,
|
233
|
+
IMAGE_GENERATION_CONFIG.COVER_MAX_SIZE,
|
234
|
+
);
|
235
|
+
|
236
|
+
log('Processing cover image with dimensions:', {
|
237
|
+
cover: { height: thumbnailHeight, width: thumbnailWidth },
|
238
|
+
original: { height, width },
|
239
|
+
});
|
240
|
+
|
241
|
+
const coverBuffer = await sharpInstance
|
242
|
+
.resize(thumbnailWidth, thumbnailHeight)
|
243
|
+
.webp()
|
223
244
|
.toBuffer();
|
224
245
|
|
225
246
|
log('Cover image processed, final size:', coverBuffer.length);
|
@@ -228,7 +249,7 @@ export class GenerationService {
|
|
228
249
|
const coverFolder = 'generations/covers';
|
229
250
|
const uuid = nanoid();
|
230
251
|
const dateTime = getYYYYmmddHHMMss(new Date());
|
231
|
-
const coverKey = `${coverFolder}/${uuid}
|
252
|
+
const coverKey = `${coverFolder}/${uuid}_${thumbnailWidth}x${thumbnailHeight}_${dateTime}_cover.webp`;
|
232
253
|
|
233
254
|
log('Uploading cover image:', coverKey);
|
234
255
|
const result = await this.fileService.uploadMedia(coverKey, coverBuffer);
|
package/src/utils/number.test.ts
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
2
2
|
|
3
|
-
import { MAX_SEED, generateUniqueSeeds } from './number';
|
3
|
+
import { MAX_SEED, calculateThumbnailDimensions, generateUniqueSeeds } from './number';
|
4
4
|
|
5
5
|
describe('number utilities', () => {
|
6
6
|
describe('MAX_SEED constant', () => {
|
@@ -102,4 +102,104 @@ describe('number utilities', () => {
|
|
102
102
|
vi.useRealTimers();
|
103
103
|
});
|
104
104
|
});
|
105
|
+
|
106
|
+
describe('calculateThumbnailDimensions', () => {
|
107
|
+
it('should not resize when both dimensions are within max size', () => {
|
108
|
+
const result = calculateThumbnailDimensions(400, 300);
|
109
|
+
|
110
|
+
expect(result).toEqual({
|
111
|
+
shouldResize: false,
|
112
|
+
thumbnailWidth: 400,
|
113
|
+
thumbnailHeight: 300,
|
114
|
+
});
|
115
|
+
});
|
116
|
+
|
117
|
+
it('should not resize when dimensions equal max size', () => {
|
118
|
+
const result = calculateThumbnailDimensions(512, 512);
|
119
|
+
|
120
|
+
expect(result).toEqual({
|
121
|
+
shouldResize: false,
|
122
|
+
thumbnailWidth: 512,
|
123
|
+
thumbnailHeight: 512,
|
124
|
+
});
|
125
|
+
});
|
126
|
+
|
127
|
+
it('should resize when width exceeds max size (landscape)', () => {
|
128
|
+
const result = calculateThumbnailDimensions(1024, 768);
|
129
|
+
|
130
|
+
expect(result).toEqual({
|
131
|
+
shouldResize: true,
|
132
|
+
thumbnailWidth: 512,
|
133
|
+
thumbnailHeight: 384, // Math.round((768 * 512) / 1024)
|
134
|
+
});
|
135
|
+
});
|
136
|
+
|
137
|
+
it('should resize when height exceeds max size (portrait)', () => {
|
138
|
+
const result = calculateThumbnailDimensions(768, 1024);
|
139
|
+
|
140
|
+
expect(result).toEqual({
|
141
|
+
shouldResize: true,
|
142
|
+
thumbnailWidth: 384, // Math.round((768 * 512) / 1024)
|
143
|
+
thumbnailHeight: 512,
|
144
|
+
});
|
145
|
+
});
|
146
|
+
|
147
|
+
it('should resize square images correctly', () => {
|
148
|
+
const result = calculateThumbnailDimensions(1024, 1024);
|
149
|
+
|
150
|
+
expect(result).toEqual({
|
151
|
+
shouldResize: true,
|
152
|
+
thumbnailWidth: 512,
|
153
|
+
thumbnailHeight: 512,
|
154
|
+
});
|
155
|
+
});
|
156
|
+
|
157
|
+
it('should handle very large images', () => {
|
158
|
+
const result = calculateThumbnailDimensions(2048, 1536);
|
159
|
+
|
160
|
+
expect(result).toEqual({
|
161
|
+
shouldResize: true,
|
162
|
+
thumbnailWidth: 512,
|
163
|
+
thumbnailHeight: 384, // Math.round((1536 * 512) / 2048)
|
164
|
+
});
|
165
|
+
});
|
166
|
+
|
167
|
+
it('should handle very tall images', () => {
|
168
|
+
const result = calculateThumbnailDimensions(800, 2400);
|
169
|
+
|
170
|
+
expect(result).toEqual({
|
171
|
+
shouldResize: true,
|
172
|
+
thumbnailWidth: 171, // Math.round((800 * 512) / 2400)
|
173
|
+
thumbnailHeight: 512,
|
174
|
+
});
|
175
|
+
});
|
176
|
+
|
177
|
+
it('should work with custom max size', () => {
|
178
|
+
const result = calculateThumbnailDimensions(1000, 800, 256);
|
179
|
+
|
180
|
+
expect(result).toEqual({
|
181
|
+
shouldResize: true,
|
182
|
+
thumbnailWidth: 256,
|
183
|
+
thumbnailHeight: 205, // Math.round((800 * 256) / 1000)
|
184
|
+
});
|
185
|
+
});
|
186
|
+
|
187
|
+
it('should handle edge case with very small dimensions', () => {
|
188
|
+
const result = calculateThumbnailDimensions(50, 100);
|
189
|
+
|
190
|
+
expect(result).toEqual({
|
191
|
+
shouldResize: false,
|
192
|
+
thumbnailWidth: 50,
|
193
|
+
thumbnailHeight: 100,
|
194
|
+
});
|
195
|
+
});
|
196
|
+
|
197
|
+
it('should maintain aspect ratio correctly', () => {
|
198
|
+
const result = calculateThumbnailDimensions(1600, 900);
|
199
|
+
const originalRatio = 1600 / 900;
|
200
|
+
const thumbnailRatio = result.thumbnailWidth / result.thumbnailHeight;
|
201
|
+
|
202
|
+
expect(Math.abs(originalRatio - thumbnailRatio)).toBeLessThan(0.01);
|
203
|
+
});
|
204
|
+
});
|
105
205
|
});
|
package/src/utils/number.ts
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
import prand from 'pure-rand';
|
2
2
|
|
3
|
+
import { IMAGE_GENERATION_CONFIG } from '@/const/imageGeneration';
|
4
|
+
|
3
5
|
export const MAX_SEED = 2 ** 31 - 1;
|
4
6
|
export function generateUniqueSeeds(seedCount: number): number[] {
|
5
7
|
// Use current timestamp as the initial seed
|
@@ -23,3 +25,43 @@ export function generateUniqueSeeds(seedCount: number): number[] {
|
|
23
25
|
|
24
26
|
return Array.from(seeds);
|
25
27
|
}
|
28
|
+
|
29
|
+
/**
|
30
|
+
* Calculate thumbnail dimensions
|
31
|
+
* Generate thumbnail with configurable max edge size
|
32
|
+
*/
|
33
|
+
export function calculateThumbnailDimensions(
|
34
|
+
originalWidth: number,
|
35
|
+
originalHeight: number,
|
36
|
+
maxSize: number = IMAGE_GENERATION_CONFIG.THUMBNAIL_MAX_SIZE,
|
37
|
+
): {
|
38
|
+
shouldResize: boolean;
|
39
|
+
thumbnailHeight: number;
|
40
|
+
thumbnailWidth: number;
|
41
|
+
} {
|
42
|
+
const shouldResize = originalWidth > maxSize || originalHeight > maxSize;
|
43
|
+
|
44
|
+
if (!shouldResize) {
|
45
|
+
return {
|
46
|
+
shouldResize: false,
|
47
|
+
thumbnailHeight: originalHeight,
|
48
|
+
thumbnailWidth: originalWidth,
|
49
|
+
};
|
50
|
+
}
|
51
|
+
|
52
|
+
const thumbnailWidth =
|
53
|
+
originalWidth > originalHeight
|
54
|
+
? maxSize
|
55
|
+
: Math.round((originalWidth * maxSize) / originalHeight);
|
56
|
+
|
57
|
+
const thumbnailHeight =
|
58
|
+
originalHeight > originalWidth
|
59
|
+
? maxSize
|
60
|
+
: Math.round((originalHeight * maxSize) / originalWidth);
|
61
|
+
|
62
|
+
return {
|
63
|
+
shouldResize: true,
|
64
|
+
thumbnailHeight,
|
65
|
+
thumbnailWidth,
|
66
|
+
};
|
67
|
+
}
|