import { createMediaAssetRelation, getMediaAssetsByUserAndUrls, upsertMediaAsset, } from "@/lib/db/queries"; import { db } from "@/lib/db"; import { generatedImage } from "@/lib/db/schema"; import { uploadFile } from "@/lib/storage/service"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; export interface ResizeCanvasOptions { canvasWidth: number; canvasHeight: number; backgroundColor?: string; transparentBackground?: boolean; mimeType?: string; } export interface ExecuteResizeJobParams extends ResizeCanvasOptions { userId: string; imageUrl?: string | null; imageBuffer?: Buffer | null; prompt?: string; chatId?: string | null; messageId?: string | null; skipGallerySave?: boolean; sourceImageUrl?: string | null; } export interface ExecuteResizeJobResult { image_base64: string; thumbnail_url: string; original_url: string; canvas_width: number; canvas_height: number; mediaAssetId?: string | null; generatedImageId?: string | null; mimeType: string; } export class ResizeServiceError extends Error { status: number; detail?: string; constructor(message: string, status = 500, detail?: string) { super(message); this.name = "ResizeServiceError"; this.status = status; this.detail = detail; } } const COMMON_COLOR_MAP: Record = { 白: "#FFFFFF", 白色: "#FFFFFF", 黑: "#000000", 黑色: "#000000", 灰: "#D1D5DB", 灰色: "#D1D5DB", 浅灰: "#E5E7EB", 深灰: "#6B7280", 红: "#EF4444", 红色: "#EF4444", 蓝: "#3B82F6", 蓝色: "#3B82F6", 绿色: "#22C55E", 绿: "#22C55E", 黄色: "#FACC15", 黄: "#FACC15", }; function normalizeHexColor(color?: string | null): string | undefined { const raw = String(color || "").trim(); if (!raw) return undefined; if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(raw)) { return raw.length === 4 ? `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}` : raw; } return COMMON_COLOR_MAP[raw]; } function normalizeMimeType( mimeType: string | undefined, transparentBackground: boolean, ): "image/png" | "image/jpeg" | "image/webp" { if (transparentBackground) return "image/png"; const normalized = String(mimeType || "").toLowerCase().trim(); if (normalized === "image/png" || normalized === "image/jpeg" || normalized === "image/webp") { return normalized; } return "image/jpeg"; } async function createThumbnail(buffer: Buffer, mimeType: string): Promise { try { const sharp = await import("sharp"); const pipeline = sharp.default(buffer) .resize(500, null, { fit: "inside", withoutEnlargement: true, }); if (mimeType === "image/png") return await pipeline.png({ quality: 80 }).toBuffer(); if (mimeType === "image/webp") return await pipeline.webp({ quality: 80 }).toBuffer(); return await pipeline.jpeg({ quality: 80 }).toBuffer(); } catch (error) { console.error("[Resize Service] Failed to create thumbnail:", error); return buffer; } } async function uploadBufferPairToS3( buffer: Buffer, mimeType: string, ): Promise<{ thumbnail: string; original: string }> { try { const ext = mimeType.split("/")[1] || "png"; const timestamp = Date.now(); const randomStr = Math.random().toString(36).slice(2, 8); const thumbnailFilename = `resize-thumb-${timestamp}-${randomStr}.${ext}`; const originalFilename = `resize-orig-${timestamp}-${randomStr}.${ext}`; const thumbnailBuffer = await createThumbnail(buffer, mimeType); const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice( thumbnailBuffer.byteOffset, thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength, ) as ArrayBuffer; const originalArrayBuffer = buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength, ) as ArrayBuffer; const [thumbnailResult, originalResult] = await Promise.all([ uploadFile(thumbnailFilename, thumbnailArrayBuffer), uploadFile(originalFilename, originalArrayBuffer), ]); return { thumbnail: thumbnailResult.url, original: originalResult.url, }; } catch (error) { console.error("[Resize Service] Failed to upload to S3:", error); throw new ResizeServiceError( "图片上传到存储桶失败", 500, error instanceof Error ? error.message : "未知错误", ); } } async function renderResizedImage(params: { imageBuffer: Buffer; canvasWidth: number; canvasHeight: number; transparentBackground: boolean; backgroundColor?: string; mimeType: "image/png" | "image/jpeg" | "image/webp"; }): Promise { const sharp = await import("sharp"); const background = params.transparentBackground ? { r: 255, g: 255, b: 255, alpha: 0 } : params.backgroundColor ? params.backgroundColor : "#FFFFFF"; let pipeline = sharp.default(params.imageBuffer) .rotate() .resize({ width: params.canvasWidth, height: params.canvasHeight, fit: "contain", background, withoutEnlargement: false, }); if (params.mimeType === "image/png") { pipeline = pipeline.png({ quality: 95 }); } else if (params.mimeType === "image/webp") { pipeline = pipeline.webp({ quality: 95 }); } else { pipeline = pipeline.jpeg({ quality: 95 }); } return pipeline.toBuffer(); } export function inferResizeOptionsFromText( text: string, ): Partial { const normalizedText = String(text || "").toLowerCase(); const dimensionMatch = normalizedText.match(/(\d{2,5})\s*[x×*]\s*(\d{2,5})/i); if (dimensionMatch) { return { canvasWidth: Number(dimensionMatch[1]), canvasHeight: Number(dimensionMatch[2]), transparentBackground: /透明/.test(normalizedText), backgroundColor: normalizeHexColor( normalizedText.match(/(白色|白底|黑色|黑底|灰色|灰底|红色|蓝色|绿色|黄色)/)?.[1] || "", ), }; } const ratioMap: Record = { "1:1": { width: 1080, height: 1080 }, "4:5": { width: 1080, height: 1350 }, "3:4": { width: 1080, height: 1440 }, "4:3": { width: 1440, height: 1080 }, "3:2": { width: 1440, height: 960 }, "2:3": { width: 960, height: 1440 }, "16:9": { width: 1600, height: 900 }, "9:16": { width: 900, height: 1600 }, }; const ratioMatch = normalizedText.match(/\b(1:1|4:5|3:4|4:3|3:2|2:3|16:9|9:16)\b/); const ratioSize = ratioMatch ? ratioMap[ratioMatch[1]] : undefined; return { canvasWidth: ratioSize?.width, canvasHeight: ratioSize?.height, transparentBackground: /透明/.test(normalizedText), backgroundColor: normalizeHexColor( normalizedText.match(/(白色|白底|黑色|黑底|灰色|灰底|红色|蓝色|绿色|黄色)/)?.[1] || "", ), }; } export async function executeResizeJob( params: ExecuteResizeJobParams, ): Promise { const { userId, imageUrl = null, imageBuffer = null, prompt = "尺寸调整处理", chatId = null, messageId = null, canvasWidth, canvasHeight, skipGallerySave = false, sourceImageUrl = imageUrl || null, } = params; if (!canvasWidth || !canvasHeight) { throw new ResizeServiceError("缺少 canvasWidth 或 canvasHeight 参数", 400); } const transparentBackground = params.transparentBackground === true; const mimeType = normalizeMimeType(params.mimeType, transparentBackground); const backgroundColor = normalizeHexColor(params.backgroundColor) || "#FFFFFF"; const sourceBuffer = imageBuffer || (imageUrl ? await fetchImageBufferFromUrl(imageUrl) : null); if (!sourceBuffer) { throw new ResizeServiceError("缺少可处理的图片输入", 400); } const outputBuffer = await renderResizedImage({ imageBuffer: sourceBuffer, canvasWidth, canvasHeight, transparentBackground, backgroundColor, mimeType, }); const imageBase64 = outputBuffer.toString("base64"); const uploadResult = await uploadBufferPairToS3(outputBuffer, mimeType); let mediaAssetId: string | null = null; let generatedImageId: string | null = null; if (!skipGallerySave) { try { const [savedImage] = await db .insert(generatedImage) .values({ userId, chatId, messageId, thumbnailUrl: uploadResult.thumbnail, originalUrl: uploadResult.original, imageSize: `${canvasWidth}x${canvasHeight}`, prompt, modelId: "resize-1.0", }) .returning(); generatedImageId = savedImage?.id || null; const sourceAssets = sourceImageUrl ? await getMediaAssetsByUserAndUrls({ userId, urls: [sourceImageUrl], }) : []; const media = await upsertMediaAsset({ userId, chatId, mediaType: "image", sourceType: "edited", status: "ready", originalUrl: uploadResult.original, previewUrl: uploadResult.original, thumbnailUrl: uploadResult.thumbnail, displayName: prompt.slice(0, 80), prompt, modelId: "resize-1.0", sourceMessageId: messageId, sourceTable: "GeneratedImage", sourceRowId: generatedImageId, primaryParentAssetId: sourceAssets[0]?.id ?? null, metadata: { imageSize: `${canvasWidth}x${canvasHeight}`, canvasWidth, canvasHeight, transparentBackground, backgroundColor: transparentBackground ? undefined : backgroundColor, }, }); mediaAssetId = media?.id || null; if (media?.id && sourceAssets.length > 0) { for (const sourceAsset of sourceAssets) { await createMediaAssetRelation({ userId, chatId, fromAssetId: sourceAsset.id, toAssetId: media.id, relationType: "edited_from", sourceMessageId: messageId, metadata: { prompt, tool: "resize", }, }); } } } catch (error) { console.error("[Resize Service] Failed to save result:", error); throw new ResizeServiceError( "尺寸调整成功但保存结果失败", 500, error instanceof Error ? error.message : "未知错误", ); } } return { image_base64: imageBase64, thumbnail_url: uploadResult.thumbnail, original_url: uploadResult.original, canvas_width: canvasWidth, canvas_height: canvasHeight, mediaAssetId, generatedImageId, mimeType, }; }