354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
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,
|
||
};
|
||
}
|