Your Name 449dd19d5f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add specialist direct executor
2026-03-07 16:59:16 +08:00

211 lines
5.7 KiB
TypeScript

import sharp from "sharp";
import {
executeRmbgJob,
fetchImageBufferFromUrl,
} from "@/lib/ai/rmbg/service";
export type NormalizedCropRect = {
x: number;
y: number;
width: number;
height: number;
};
export interface ExecuteBoxCutoutJobParams {
userId: string;
imageUrl: string;
cropRect: Partial<NormalizedCropRect>;
paddingRatio?: number;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
sourceNodeId?: string;
}
export interface ExecuteBoxCutoutJobResult {
success: true;
original_url: string;
thumbnail_url?: string | null;
generatedImageId?: string | null;
mediaAssetId?: string | null;
cropRectApplied: NormalizedCropRect;
paddedCropRectApplied: NormalizedCropRect;
cropRectPixels: {
left: number;
top: number;
width: number;
height: number;
};
paddedCropRectPixels: {
left: number;
top: number;
width: number;
height: number;
};
sourceNodeId?: string | null;
}
export class BoxCutoutServiceError extends Error {
status: number;
detail?: string;
constructor(message: string, status = 400, detail?: string) {
super(message);
this.name = "BoxCutoutServiceError";
this.status = status;
this.detail = detail;
}
}
const MIN_CROP_EDGE_PX = 24;
const DEFAULT_PADDING_RATIO = 0.08;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function sanitizeNormalizedCropRect(
input: Partial<NormalizedCropRect> | undefined,
): NormalizedCropRect | null {
if (!input) return null;
const x = Number(input.x);
const y = Number(input.y);
const width = Number(input.width);
const height = Number(input.height);
if (![x, y, width, height].every((value) => Number.isFinite(value))) {
return null;
}
const normalized: NormalizedCropRect = {
x: clamp(x, 0, 1),
y: clamp(y, 0, 1),
width: clamp(width, 0, 1),
height: clamp(height, 0, 1),
};
if (normalized.width <= 0 || normalized.height <= 0) {
return null;
}
normalized.width = Math.min(normalized.width, 1 - normalized.x);
normalized.height = Math.min(normalized.height, 1 - normalized.y);
if (normalized.width <= 0 || normalized.height <= 0) {
return null;
}
return normalized;
}
export function normalizeBoxCutoutPaddingRatio(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_PADDING_RATIO;
return clamp(numeric, 0, 0.25);
}
function toPixelRect(
rect: NormalizedCropRect,
sourceWidth: number,
sourceHeight: number,
) {
const left = clamp(Math.round(rect.x * sourceWidth), 0, Math.max(sourceWidth - 1, 0));
const top = clamp(Math.round(rect.y * sourceHeight), 0, Math.max(sourceHeight - 1, 0));
const width = clamp(Math.round(rect.width * sourceWidth), 1, sourceWidth - left);
const height = clamp(Math.round(rect.height * sourceHeight), 1, sourceHeight - top);
return { left, top, width, height };
}
function applyPadding(
rect: NormalizedCropRect,
paddingRatio: number,
): NormalizedCropRect {
const padX = rect.width * paddingRatio;
const padY = rect.height * paddingRatio;
const x = clamp(rect.x - padX, 0, 1);
const y = clamp(rect.y - padY, 0, 1);
const right = clamp(rect.x + rect.width + padX, 0, 1);
const bottom = clamp(rect.y + rect.height + padY, 0, 1);
return {
x,
y,
width: clamp(right - x, 0, 1),
height: clamp(bottom - y, 0, 1),
};
}
export async function executeBoxCutoutJob(
params: ExecuteBoxCutoutJobParams,
): Promise<ExecuteBoxCutoutJobResult> {
const imageUrl = String(params.imageUrl || "").trim();
if (!imageUrl) {
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
}
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
if (!cropRect) {
throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
}
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
const sourceBuffer = await sharp(await fetchImageBufferFromUrl(imageUrl), {
failOn: "none",
})
.rotate()
.toBuffer();
const sourceImage = sharp(sourceBuffer, { failOn: "none" });
const sourceMetadata = await sourceImage.metadata();
const sourceWidth = Number(sourceMetadata.width || 0);
const sourceHeight = Number(sourceMetadata.height || 0);
if (sourceWidth <= 0 || sourceHeight <= 0) {
throw new BoxCutoutServiceError("无法识别原图尺寸", 400);
}
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
throw new BoxCutoutServiceError("框选区域过小,请重新框选", 400);
}
const paddedCropRect = applyPadding(cropRect, paddingRatio);
const paddedCropRectPixels = toPixelRect(paddedCropRect, sourceWidth, sourceHeight);
const croppedBuffer = await sourceImage
.extract({
left: paddedCropRectPixels.left,
top: paddedCropRectPixels.top,
width: paddedCropRectPixels.width,
height: paddedCropRectPixels.height,
})
.toBuffer();
const result = await executeRmbgJob({
userId: params.userId,
imageBuffer: croppedBuffer,
imageBase64: croppedBuffer.toString("base64"),
sourceImageUrl: imageUrl,
prompt: params.prompt || "框选抠图",
chatId: params.chatId || null,
messageId: params.messageId || null,
skipGallerySave: params.skipGallerySave === true,
});
return {
success: true,
original_url: result.original_url,
thumbnail_url: result.thumbnail_url,
generatedImageId: result.generatedImageId,
mediaAssetId: result.mediaAssetId,
cropRectApplied: cropRect,
paddedCropRectApplied: paddedCropRect,
cropRectPixels,
paddedCropRectPixels,
sourceNodeId: params.sourceNodeId || null,
};
}