UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

293 lines (251 loc) 9.52 kB
import { LobeChatDatabase } from '@lobechat/database'; import { parseDataUri } from '@lobechat/model-runtime'; import debug from 'debug'; import { sha256 } from 'js-sha256'; import mime from 'mime'; import { IMAGE_GENERATION_CONFIG } from 'model-bank'; import { nanoid } from 'nanoid'; import sharp from 'sharp'; import { FileService } from '@/server/services/file'; import { calculateThumbnailDimensions } from '@/utils/number'; import { getYYYYmmddHHMMss } from '@/utils/time'; import { inferFileExtensionFromImageUrl } from '@/utils/url'; const log = debug('lobe-image:generation-service'); /** * Fetch image buffer and MIME type from URL or base64 data * @param url - Image URL or base64 data URI * @param fetchHeaders - Optional headers for authentication * @returns Object containing buffer and MIME type */ export async function fetchImageFromUrl( url: string, fetchHeaders?: Record<string, string>, ): Promise<{ buffer: Buffer; mimeType: string; }> { if (url.startsWith('data:')) { const { base64, mimeType, type } = parseDataUri(url); if (type !== 'base64' || !base64 || !mimeType) { throw new Error(`Invalid data URI format: ${url}`); } try { const buffer = Buffer.from(base64, 'base64'); return { buffer, mimeType }; } catch (error) { throw new Error( `Failed to decode base64 data: ${error instanceof Error ? error.message : String(error)}`, ); } } else { const response = await fetch(url, { headers: fetchHeaders }); if (!response.ok) { throw new Error( `Failed to fetch image from ${url}: ${response.status} ${response.statusText}`, ); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const mimeType = response.headers.get('content-type') || 'application/octet-stream'; return { buffer, mimeType }; } } interface ImageForGeneration { buffer: Buffer; extension: string; hash: string; height: number; mime: string; size: number; width: number; } /** * 图片生成服务 * 负责处理AI生成图片的转换、上传和封面创建 */ export class GenerationService { private fileService: FileService; constructor(db: LobeChatDatabase, userId: string) { this.fileService = new FileService(db, userId); } /** * Generate width 512px image as thumbnail when width > 512, end with _512.webp */ async transformImageForGeneration( url: string, fetchHeaders?: Record<string, string>, ): Promise<{ image: ImageForGeneration; thumbnailImage: ImageForGeneration; }> { log('Starting image transformation for:', url.startsWith('data:') ? 'base64 data' : url); // Fetch image buffer and MIME type using utility function const { buffer: originalImageBuffer, mimeType: originalMimeType } = await fetchImageFromUrl( url, fetchHeaders, ); // Calculate hash for original image const originalHash = sha256(originalImageBuffer); const sharpInstance = sharp(originalImageBuffer); const { format, width, height } = await sharpInstance.metadata(); log('Image metadata:', { format, height, width }); if (!width || !height) { throw new Error(`Invalid image format: ${format}, url: ${url}`); } const { shouldResize: shouldResizeBySize, thumbnailWidth, thumbnailHeight, } = calculateThumbnailDimensions(width, height); const shouldResize = shouldResizeBySize || format !== 'webp'; log('Thumbnail processing decision:', { format, shouldResize, shouldResizeBySize, thumbnailHeight, thumbnailWidth, }); const thumbnailBuffer = shouldResize ? await sharpInstance.resize(thumbnailWidth, thumbnailHeight).webp().toBuffer() : originalImageBuffer; // Calculate hash for thumbnail const thumbnailHash = sha256(thumbnailBuffer); log('Image transformation completed successfully'); // Determine extension using url utility let extension: string; if (url.startsWith('data:')) { const mimeExtension = mime.getExtension(originalMimeType); if (!mimeExtension) { throw new Error(`Unable to determine file extension for MIME type: ${originalMimeType}`); } extension = mimeExtension; } else { // Try to get extension from URL path first extension = inferFileExtensionFromImageUrl(url); // For ComfyUI URLs, check filename in query parameters if (!extension && url.includes('filename=')) { try { const urlObj = new URL(url); const filename = urlObj.searchParams.get('filename'); if (filename) { extension = inferFileExtensionFromImageUrl(filename); } } catch { // Ignore URL parsing errors } } // If still no extension, try to get from MIME type if (!extension && originalMimeType && originalMimeType !== 'application/octet-stream') { const mimeExtension = mime.getExtension(originalMimeType); if (mimeExtension) { extension = mimeExtension; } } if (!extension) { throw new Error(`Unable to determine file extension from URL: ${url}`); } } return { image: { buffer: originalImageBuffer, extension, hash: originalHash, height, mime: originalMimeType, size: originalImageBuffer.length, width, }, thumbnailImage: { buffer: thumbnailBuffer, extension: 'webp', hash: thumbnailHash, height: thumbnailHeight, mime: 'image/webp', size: thumbnailBuffer.length, width: thumbnailWidth, }, }; } async uploadImageForGeneration(image: ImageForGeneration, thumbnail: ImageForGeneration) { log('Starting image upload for generation'); const generationImagesFolder = 'generations/images'; const uuid = nanoid(); const dateTime = getYYYYmmddHHMMss(new Date()); const imageKey = `${generationImagesFolder}/${uuid}_${image.width}x${image.height}_${dateTime}_raw.${image.extension}`; const thumbnailKey = `${generationImagesFolder}/${uuid}_${thumbnail.width}x${thumbnail.height}_${dateTime}_thumb.${thumbnail.extension}`; log('Generated paths:', { imagePath: imageKey, thumbnailPath: thumbnailKey }); // Check if image and thumbnail buffers are identical const isIdenticalBuffer = image.buffer.equals(thumbnail.buffer); log('Buffer comparison:', { imageSize: image.buffer.length, isIdenticalBuffer, thumbnailSize: thumbnail.buffer.length, }); if (isIdenticalBuffer) { log('Buffers are identical, uploading single image'); // If buffers are identical, only upload once const result = await this.fileService.uploadMedia(imageKey, image.buffer); log('Single image uploaded successfully:', result.key); // Use the same key for both image and thumbnail return { imageUrl: result.key, thumbnailImageUrl: result.key, }; } else { log('Buffers are different, uploading both images'); // If buffers are different, upload both const [imageResult, thumbnailResult] = await Promise.all([ this.fileService.uploadMedia(imageKey, image.buffer), this.fileService.uploadMedia(thumbnailKey, thumbnail.buffer), ]); log('Both images uploaded successfully:', { imageUrl: imageResult.key, thumbnailImageUrl: thumbnailResult.key, }); return { imageUrl: imageResult.key, thumbnailImageUrl: thumbnailResult.key, }; } } /** * Create a cover image from a given URL and upload * @param coverUrl - The source image URL (can be base64 or HTTP URL) * @returns The key of the uploaded cover image */ async createCoverFromUrl(coverUrl: string): Promise<string> { log('Creating cover image from URL:', coverUrl.startsWith('data:') ? 'base64 data' : coverUrl); // Fetch image buffer using utility function const { buffer: originalImageBuffer } = await fetchImageFromUrl(coverUrl); // Get image metadata to calculate proper cover dimensions const sharpInstance = sharp(originalImageBuffer); const { width, height } = await sharpInstance.metadata(); if (!width || !height) { throw new Error('Invalid image format for cover creation'); } // Calculate cover dimensions maintaining aspect ratio with configurable max size const { thumbnailWidth, thumbnailHeight } = calculateThumbnailDimensions( width, height, IMAGE_GENERATION_CONFIG.COVER_MAX_SIZE, ); log('Processing cover image with dimensions:', { cover: { height: thumbnailHeight, width: thumbnailWidth }, original: { height, width }, }); const coverBuffer = await sharpInstance .resize(thumbnailWidth, thumbnailHeight) .webp() .toBuffer(); log('Cover image processed, final size:', coverBuffer.length); // Upload using FileService const coverFolder = 'generations/covers'; const uuid = nanoid(); const dateTime = getYYYYmmddHHMMss(new Date()); const coverKey = `${coverFolder}/${uuid}_${thumbnailWidth}x${thumbnailHeight}_${dateTime}_cover.webp`; log('Uploading cover image:', coverKey); const result = await this.fileService.uploadMedia(coverKey, coverBuffer); log('Cover image uploaded successfully:', result.key); return result.key; } }