ai-chatbot/lib/ai/resize/service.ts
Your Name 864b6d41e3
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: upgrade agent executor and canvas sync
2026-03-07 15:58:03 +08:00

354 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string> = {
白: "#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<Buffer> {
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<Buffer> {
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<ResizeCanvasOptions> {
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<string, { width: number; height: number }> = {
"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<ExecuteResizeJobResult> {
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,
};
}