Compare commits
7 Commits
codex/rmbg
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aea18a7fc6 | |||
| e9921cae98 | |||
| 1f5528331d | |||
| 535ea1aee4 | |||
| 8cbc8b1ab8 | |||
| ae9354f747 | |||
| c68bf9a824 |
@ -1,100 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import {
|
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
|
||||||
BoxCutoutServiceError,
|
|
||||||
executeBoxCutoutJob,
|
|
||||||
} from "@/lib/ai/box-cutout/service";
|
|
||||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
|
||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
|
||||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
type BoxCutoutRequest = {
|
export async function POST(request: Request) {
|
||||||
imageUrl?: string;
|
const session = await auth();
|
||||||
cropRect?: {
|
if (!session?.user?.id) {
|
||||||
x?: number;
|
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
||||||
y?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
};
|
|
||||||
paddingRatio?: number;
|
|
||||||
prompt?: string;
|
|
||||||
chatId?: string | null;
|
|
||||||
messageId?: string | null;
|
|
||||||
skipGallerySave?: boolean;
|
|
||||||
sourceNodeId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await request.json()) as BoxCutoutRequest;
|
|
||||||
const rawImageUrl = String(body.imageUrl || "").trim();
|
|
||||||
if (!rawImageUrl) {
|
|
||||||
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
|
||||||
const pointsCharge = await prepareFeaturePointsCharge({
|
|
||||||
userId: session.user.id,
|
|
||||||
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
|
|
||||||
reason: "image_box_cutout",
|
|
||||||
modelId: "box-cutout",
|
|
||||||
note: "框选抠图消耗积分",
|
|
||||||
pointsConfig,
|
|
||||||
});
|
|
||||||
if (!pointsCharge.ok) {
|
|
||||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await executeBoxCutoutJob({
|
|
||||||
userId: session.user.id,
|
|
||||||
imageUrl: resolvedImageUrl,
|
|
||||||
cropRect: body.cropRect || {},
|
|
||||||
paddingRatio: body.paddingRatio,
|
|
||||||
prompt: body.prompt || "框选抠图",
|
|
||||||
chatId: body.chatId || null,
|
|
||||||
messageId: body.messageId || null,
|
|
||||||
skipGallerySave: body.skipGallerySave === true,
|
|
||||||
sourceNodeId: body.sourceNodeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pointsCharge.chargeable) {
|
|
||||||
try {
|
|
||||||
await pointsCharge.consume();
|
|
||||||
} catch (pointsError) {
|
|
||||||
console.error("[Points] Failed to consume box cutout points:", pointsError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error.detail || error.message,
|
|
||||||
},
|
|
||||||
{ status: error.status },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("[BoxCutout] 处理失败:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "框选抠图失败",
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return executeBoxCutoutPOST(request, session.user.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4683,106 +4683,44 @@ export function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCreatingDetachedResultNode(true);
|
if (!projectId || !runCanvasRemoteOperation) {
|
||||||
|
toast.error("画布还在加载,请稍后再试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingDetachedResultNode(false);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
const actionName = "框选抠图";
|
const actionName = "框选抠图";
|
||||||
const newNodeId = addNode({
|
|
||||||
type: "image",
|
|
||||||
position: {
|
|
||||||
x: node.position.x + node.size.width + 20,
|
|
||||||
y: node.position.y,
|
|
||||||
},
|
|
||||||
size: { width: node.size.width, height: node.size.height },
|
|
||||||
data: {
|
|
||||||
src: "",
|
|
||||||
status: "generating",
|
|
||||||
prompt: `${actionName}处理中...`,
|
|
||||||
title: `${displayTitle}-${actionName}`,
|
|
||||||
originalSrc: data.src,
|
|
||||||
metadata: {
|
|
||||||
cropRect: boxCutoutSelection,
|
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connections: [node.id],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await imageGenerationService.boxCutout({
|
const command = createCanvasCapabilityClientCommand({
|
||||||
imageUrl: data.src,
|
capabilityId: "image.box_cutout",
|
||||||
cropRect: boxCutoutSelection,
|
nodeIds: [node.id],
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
projectId,
|
||||||
sourceNodeId: node.id,
|
source: "toolbar",
|
||||||
});
|
args: {
|
||||||
|
...boxCutoutSelection,
|
||||||
if (result.success && result.data?.url) {
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
updateNode(newNodeId, {
|
|
||||||
data: {
|
|
||||||
src: result.data.url,
|
|
||||||
status: "completed",
|
|
||||||
prompt: `${actionName}结果`,
|
|
||||||
title: `${displayTitle}-${actionName}`,
|
|
||||||
originalSrc: data.src,
|
|
||||||
metadata: {
|
|
||||||
cropRect: boxCutoutSelection,
|
|
||||||
cropRectApplied: result.data.cropRectApplied,
|
|
||||||
paddedCropRectApplied: result.data.paddedCropRectApplied,
|
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
|
||||||
editMode: "box-cutout",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast.success(`${actionName}完成`);
|
|
||||||
} else {
|
|
||||||
updateNode(newNodeId, {
|
|
||||||
data: {
|
|
||||||
src: "",
|
|
||||||
status: "failed",
|
|
||||||
prompt: `${actionName}失败`,
|
|
||||||
title: `${displayTitle}-${actionName}-失败`,
|
|
||||||
originalSrc: data.src,
|
|
||||||
metadata: {
|
|
||||||
cropRect: boxCutoutSelection,
|
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast.error(result.error || `${actionName}失败`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("框选抠图失败:", error);
|
|
||||||
updateNode(newNodeId, {
|
|
||||||
data: {
|
|
||||||
src: "",
|
|
||||||
status: "failed",
|
|
||||||
prompt: `${actionName}失败`,
|
|
||||||
title: `${displayTitle}-${actionName}-失败`,
|
|
||||||
originalSrc: data.src,
|
|
||||||
metadata: {
|
|
||||||
cropRect: boxCutoutSelection,
|
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.error(`${actionName}失败,请重试`);
|
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
||||||
|
toast.success(`${actionName}完成`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("框选抠图失败:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : `${actionName}失败`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingDetachedResultNode(false);
|
setIsCreatingDetachedResultNode(false);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
resetBoxCutoutState();
|
resetBoxCutoutState();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
addNode,
|
|
||||||
boxCutoutSelection,
|
boxCutoutSelection,
|
||||||
data.src,
|
data.src,
|
||||||
displayTitle,
|
|
||||||
isProcessing,
|
isProcessing,
|
||||||
node.id,
|
node.id,
|
||||||
node.position.x,
|
projectId,
|
||||||
node.position.y,
|
|
||||||
node.size.height,
|
|
||||||
node.size.width,
|
|
||||||
resetBoxCutoutState,
|
resetBoxCutoutState,
|
||||||
updateNode,
|
runCanvasRemoteOperation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import {
|
|||||||
executeRmbgJob,
|
executeRmbgJob,
|
||||||
fetchImageBufferFromUrl,
|
fetchImageBufferFromUrl,
|
||||||
} from "@/lib/ai/rmbg/service";
|
} from "@/lib/ai/rmbg/service";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("box-cutout-service");
|
||||||
|
|
||||||
export type NormalizedCropRect = {
|
export type NormalizedCropRect = {
|
||||||
x: number;
|
x: number;
|
||||||
@ -13,6 +16,7 @@ export type NormalizedCropRect = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ExecuteBoxCutoutJobParams {
|
export interface ExecuteBoxCutoutJobParams {
|
||||||
|
requestId?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
cropRect: Partial<NormalizedCropRect>;
|
cropRect: Partial<NormalizedCropRect>;
|
||||||
@ -143,6 +147,7 @@ function applyPadding(
|
|||||||
export async function executeBoxCutoutJob(
|
export async function executeBoxCutoutJob(
|
||||||
params: ExecuteBoxCutoutJobParams,
|
params: ExecuteBoxCutoutJobParams,
|
||||||
): Promise<ExecuteBoxCutoutJobResult> {
|
): Promise<ExecuteBoxCutoutJobResult> {
|
||||||
|
const requestId = params.requestId || crypto.randomUUID();
|
||||||
const imageUrl = String(params.imageUrl || "").trim();
|
const imageUrl = String(params.imageUrl || "").trim();
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
|
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
|
||||||
@ -150,7 +155,7 @@ export async function executeBoxCutoutJob(
|
|||||||
|
|
||||||
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
||||||
if (!cropRect) {
|
if (!cropRect) {
|
||||||
throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
|
throw new BoxCutoutServiceError("框选范围无效,请重新框选需要抠出的区域后提交。", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
||||||
@ -170,7 +175,7 @@ export async function executeBoxCutoutJob(
|
|||||||
|
|
||||||
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
|
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
|
||||||
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
|
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
|
||||||
throw new BoxCutoutServiceError("框选区域过小,请重新框选", 400);
|
throw new BoxCutoutServiceError("框选区域过小,宽、高均须至少 24 像素,请扩大框选范围后重新提交。", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
||||||
@ -184,7 +189,18 @@ export async function executeBoxCutoutJob(
|
|||||||
})
|
})
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
|
logger.info("crop_prepared", {
|
||||||
|
requestId,
|
||||||
|
sourceNodeId: params.sourceNodeId,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight,
|
||||||
|
cropRectPixels,
|
||||||
|
paddedCropRectPixels,
|
||||||
|
inputBytes: croppedBuffer.length,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await executeRmbgJob({
|
const result = await executeRmbgJob({
|
||||||
|
requestId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
imageBuffer: croppedBuffer,
|
imageBuffer: croppedBuffer,
|
||||||
imageBase64: croppedBuffer.toString("base64"),
|
imageBase64: croppedBuffer.toString("base64"),
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
const logger = createLogger("rmbg-service");
|
const logger = createLogger("rmbg-service");
|
||||||
@ -654,6 +655,12 @@ export async function fetchImageBufferFromUrl(imageUrl: string): Promise<Buffer>
|
|||||||
|
|
||||||
export async function executeRmbgJob(
|
export async function executeRmbgJob(
|
||||||
params: ExecuteRmbgJobParams
|
params: ExecuteRmbgJobParams
|
||||||
|
): Promise<ExecuteRmbgJobResult> {
|
||||||
|
return trackRuntimeWork(() => runRmbgJob(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRmbgJob(
|
||||||
|
params: ExecuteRmbgJobParams
|
||||||
): Promise<ExecuteRmbgJobResult> {
|
): Promise<ExecuteRmbgJobResult> {
|
||||||
const {
|
const {
|
||||||
requestId = crypto.randomUUID(),
|
requestId = crypto.randomUUID(),
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import {
|
import { executeBoxCutoutJob } from "@/lib/ai/box-cutout/service";
|
||||||
executeBoxCutoutJob,
|
|
||||||
} from "@/lib/ai/box-cutout/service";
|
|
||||||
import { executeExtendJob } from "@/lib/ai/extend/service";
|
import { executeExtendJob } from "@/lib/ai/extend/service";
|
||||||
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
|
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
|
||||||
import { executeResizeJob } from "@/lib/ai/resize/service";
|
import { executeResizeJob } from "@/lib/ai/resize/service";
|
||||||
import {
|
import {
|
||||||
executeRmbgJob,
|
executeRmbgJob,
|
||||||
fetchImageBufferFromUrl,
|
fetchImageBufferFromUrl,
|
||||||
|
RmbgServiceError,
|
||||||
} from "@/lib/ai/rmbg/service";
|
} from "@/lib/ai/rmbg/service";
|
||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
|
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
|
||||||
import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint";
|
import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint";
|
||||||
|
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
|
||||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||||
import {
|
import {
|
||||||
@ -187,8 +187,7 @@ async function executeForTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command.capabilityId === "image.box_cutout") {
|
if (command.capabilityId === "image.box_cutout") {
|
||||||
const result = await executeBoxCutoutJob({
|
const job = {
|
||||||
userId: params.userId,
|
|
||||||
imageUrl: target.sourceImageUrl,
|
imageUrl: target.sourceImageUrl,
|
||||||
sourceNodeId: target.nodeId,
|
sourceNodeId: target.nodeId,
|
||||||
cropRect: {
|
cropRect: {
|
||||||
@ -199,13 +198,30 @@ async function executeForTarget(
|
|||||||
},
|
},
|
||||||
paddingRatio: asFiniteNumber(command.args.paddingRatio),
|
paddingRatio: asFiniteNumber(command.args.paddingRatio),
|
||||||
prompt: prompt || "框选抠图",
|
prompt: prompt || "框选抠图",
|
||||||
});
|
};
|
||||||
|
let result;
|
||||||
|
if (command.source === "toolbar") {
|
||||||
|
const response = await executeBoxCutoutPOST(new Request(params.requestUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-Request-ID": command.requestId },
|
||||||
|
body: JSON.stringify(job),
|
||||||
|
}), params.userId);
|
||||||
|
result = await response.json();
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new RmbgServiceError(result.detail || result.message || result.error || "框选抠图未能完成", response.status);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await executeBoxCutoutJob({ ...job, requestId: command.requestId, userId: params.userId });
|
||||||
|
}
|
||||||
return [{
|
return [{
|
||||||
...common,
|
...common,
|
||||||
assetId: result.mediaAssetId || undefined,
|
assetId: result.mediaAssetId || undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
|
editMode: "box-cutout",
|
||||||
cropRectApplied: result.cropRectApplied,
|
cropRectApplied: result.cropRectApplied,
|
||||||
paddedCropRectApplied: result.paddedCropRectApplied,
|
paddedCropRectApplied: result.paddedCropRectApplied,
|
||||||
|
cropRectPixels: result.cropRectPixels,
|
||||||
|
paddedCropRectPixels: result.paddedCropRectPixels,
|
||||||
},
|
},
|
||||||
title: `${target.title}-框选抠图`,
|
title: `${target.title}-框选抠图`,
|
||||||
url: result.original_url,
|
url: result.original_url,
|
||||||
|
|||||||
96
lib/canvas/operations/box-cutout.ts
Normal file
96
lib/canvas/operations/box-cutout.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BoxCutoutServiceError,
|
||||||
|
executeBoxCutoutJob,
|
||||||
|
} from "@/lib/ai/box-cutout/service";
|
||||||
|
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||||
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
|
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
|
||||||
|
type BoxCutoutRequest = {
|
||||||
|
imageUrl?: string;
|
||||||
|
cropRect?: {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
paddingRatio?: number;
|
||||||
|
prompt?: string;
|
||||||
|
chatId?: string | null;
|
||||||
|
messageId?: string | null;
|
||||||
|
skipGallerySave?: boolean;
|
||||||
|
sourceNodeId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeBoxCutoutPOST(request: Request, userId: string) {
|
||||||
|
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as BoxCutoutRequest;
|
||||||
|
const rawImageUrl = String(body.imageUrl || "").trim();
|
||||||
|
if (!rawImageUrl) {
|
||||||
|
throw new BoxCutoutServiceError("缺少原图链接,请重新选择图片后提交。", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
userId,
|
||||||
|
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
|
||||||
|
reason: "image_box_cutout",
|
||||||
|
modelId: "box-cutout",
|
||||||
|
note: "框选抠图消耗积分",
|
||||||
|
pointsConfig,
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeBoxCutoutJob({
|
||||||
|
requestId,
|
||||||
|
userId,
|
||||||
|
imageUrl: resolvedImageUrl,
|
||||||
|
cropRect: body.cropRect || {},
|
||||||
|
paddingRatio: body.paddingRatio,
|
||||||
|
prompt: body.prompt || "框选抠图",
|
||||||
|
chatId: body.chatId || null,
|
||||||
|
messageId: body.messageId || null,
|
||||||
|
skipGallerySave: body.skipGallerySave === true,
|
||||||
|
sourceNodeId: body.sourceNodeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pointsCharge.chargeable) {
|
||||||
|
try {
|
||||||
|
await pointsCharge.consume();
|
||||||
|
} catch (pointsError) {
|
||||||
|
console.error("[Points] Failed to consume box cutout points:", pointsError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ...result, requestId });
|
||||||
|
} catch (error) {
|
||||||
|
const failure = getRequestFailureDetails(error, "框选抠图未能完成");
|
||||||
|
const payload = {
|
||||||
|
success: false,
|
||||||
|
error: formatRequestFailure(failure, requestId),
|
||||||
|
detail: error instanceof Error ? error.message : "框选抠图未能完成",
|
||||||
|
code: failure.code,
|
||||||
|
failure,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
|
||||||
|
return NextResponse.json(payload, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[BoxCutout] 处理失败:", error);
|
||||||
|
return NextResponse.json(payload, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -102,7 +102,7 @@ test("mixed-model batches bill and label only the delivered images using their a
|
|||||||
test("headless image features cannot substitute their default when a pinned model disappears", async () => {
|
test("headless image features cannot substitute their default when a pinned model disappears", async () => {
|
||||||
let generated = 0;
|
let generated = 0;
|
||||||
const mocks: Record<string, any> = {
|
const mocks: Record<string, any> = {
|
||||||
"@/lib/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {},
|
"@/lib/canvas/operations/box-cutout": {}, "@/lib/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {},
|
||||||
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
|
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
|
||||||
"@/lib/canvas/generate-image": { generateCanvasImage: async () => { generated++; return { images: [] }; } },
|
"@/lib/canvas/generate-image": { generateCanvasImage: async () => { generated++; return { images: [] }; } },
|
||||||
"@/lib/style-pro/model-options": { getStyleProImageModelEntries: async () => [{ model: { id: "default", modelId: "default", isDefault: true } }], sortStyleProImageModelEntries: (entries: any[]) => entries, findStyleProImageModelByRef: () => null },
|
"@/lib/style-pro/model-options": { getStyleProImageModelEntries: async () => [{ model: { id: "default", modelId: "default", isDefault: true } }], sortStyleProImageModelEntries: (entries: any[]) => entries, findStyleProImageModelByRef: () => null },
|
||||||
|
|||||||
@ -103,6 +103,33 @@ test("a recovered matting rejection shows its dimensions and advice without chan
|
|||||||
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a lost box-cutout response recovers the same selection into its original canvas with one submission", async () => {
|
||||||
|
const submitted = createCanvasCapabilityClientCommand({ projectId: "project-a", capabilityId: "image.box_cutout", nodeIds: ["source"], source: "toolbar", args: { x: 0.25, y: 0.2, width: 0.4, height: 0.5, paddingRatio: 0.08 } });
|
||||||
|
let posts = 0, polls = 0;
|
||||||
|
const selected = { ...node("cutout"), data: { src: "https://example.com/cutout.png", metadata: { editMode: "box-cutout", cropRectApplied: submitted.args } } };
|
||||||
|
globalThis.fetch = (async (url, options) => {
|
||||||
|
if (options?.method === "POST") {
|
||||||
|
posts++;
|
||||||
|
expect(JSON.parse(String(options.body))).toMatchObject({ capabilityId: "image.box_cutout", requestId: submitted.requestId, args: submitted.args });
|
||||||
|
useCanvasStore.getState().moveNode("source", { x: 20, y: 30 });
|
||||||
|
return new Response(null, { status: 524 });
|
||||||
|
}
|
||||||
|
if (String(url).includes("/capabilities?")) {
|
||||||
|
polls++;
|
||||||
|
expect(String(url)).toContain(encodeURIComponent(submitted.requestId));
|
||||||
|
return Response.json({ success: true, task: { status: "succeeded" } });
|
||||||
|
}
|
||||||
|
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("source"), selected], edges: [] } } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
await executeCanvasToolbarCapability(submitted);
|
||||||
|
expect({ posts, polls }).toEqual({ posts: 1, polls: 1 });
|
||||||
|
const state = useCanvasStore.getState();
|
||||||
|
expect(state.projectId).toBe("project-a");
|
||||||
|
expect(state.nodes.map(n => n.id)).toEqual(["source", "cutout"]);
|
||||||
|
expect(state.nodes[0].position).toEqual({ x: 20, y: 30 });
|
||||||
|
expect(state.nodes[1].data).toEqual(selected.data);
|
||||||
|
});
|
||||||
|
|
||||||
test("switching projects during processing does not load the old result into the new project", async () => {
|
test("switching projects during processing does not load the old result into the new project", async () => {
|
||||||
globalThis.fetch = (async (url) => {
|
globalThis.fetch = (async (url) => {
|
||||||
if (String(url).endsWith("/capabilities")) {
|
if (String(url).endsWith("/capabilities")) {
|
||||||
|
|||||||
@ -5,12 +5,118 @@ import { getRequestFailureDetails } from "@/lib/request-error";
|
|||||||
|
|
||||||
const cdn = "https://scqnycdn.jiansun.vip";
|
const cdn = "https://scqnycdn.jiansun.vip";
|
||||||
|
|
||||||
|
test("box cutout submits exactly the padded selection and keeps the original URL as provenance", async () => {
|
||||||
|
const pixels = Buffer.alloc(120 * 80 * 3);
|
||||||
|
for (let y = 0; y < 80; y++) for (let x = 0; x < 120; x++) {
|
||||||
|
const offset = (y * 120 + x) * 3;
|
||||||
|
pixels[offset] = x * 2; pixels[offset + 1] = y * 3; pixels[offset + 2] = x + y;
|
||||||
|
}
|
||||||
|
const original = await sharp(pixels, { raw: { width: 120, height: 80, channels: 3 } }).png().toBuffer();
|
||||||
|
let submitted: any;
|
||||||
|
const box = serverLoader({
|
||||||
|
"@/lib/ai/rmbg/service": {
|
||||||
|
fetchImageBufferFromUrl: async () => original,
|
||||||
|
executeRmbgJob: async (params: any) => { submitted = params; return { original_url: `${cdn}/selected.png` }; },
|
||||||
|
},
|
||||||
|
})("lib/ai/box-cutout/service.ts");
|
||||||
|
const result = await box.executeBoxCutoutJob({ userId: "user-a", requestId: "box-a", sourceNodeId: "source", imageUrl: `${cdn}/original.png`, cropRect: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }, paddingRatio: 0.1 });
|
||||||
|
expect(result.cropRectPixels).toEqual({ left: 30, top: 20, width: 60, height: 40 });
|
||||||
|
expect(result.paddedCropRectPixels).toEqual({ left: 24, top: 16, width: 72, height: 48 });
|
||||||
|
expect(submitted).toMatchObject({ requestId: "box-a", sourceImageUrl: `${cdn}/original.png` });
|
||||||
|
expect(submitted.inputImageUrl).toBeUndefined();
|
||||||
|
const expected = await sharp(original).extract(result.paddedCropRectPixels).raw().toBuffer();
|
||||||
|
expect((await sharp(submitted.imageBuffer).raw().toBuffer()).equals(expected)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("box cutout shares the configured charge once after success and never charges a rejected input", async () => {
|
||||||
|
class InputError extends Error { status = 400; }
|
||||||
|
for (const rejected of [false, true]) {
|
||||||
|
let creates = 0, charges = 0;
|
||||||
|
const operation = serverLoader({
|
||||||
|
"@/lib/ai/box-cutout/service": { BoxCutoutServiceError: InputError, executeBoxCutoutJob: async (params: any) => {
|
||||||
|
creates++;
|
||||||
|
expect(params.requestId).toBe("box-a");
|
||||||
|
if (rejected) throw new InputError("抠图参考图分辨率超限(5000×3000 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。");
|
||||||
|
return { success: true, original_url: `${cdn}/selected.png` };
|
||||||
|
} },
|
||||||
|
"@/lib/ai/rmbg/service": { RmbgServiceError: InputError },
|
||||||
|
"@/lib/db/queries": { getPointsSystemConfig: async () => ({ enabled: true, creditCostPerImageBoxCutout: 7 }) },
|
||||||
|
"@/lib/points/feature-charge": { prepareFeaturePointsCharge: async (params: any) => {
|
||||||
|
expect(params).toMatchObject({ userId: "user-a", unitCost: 7, reason: "image_box_cutout", modelId: "box-cutout" });
|
||||||
|
return { ok: true, chargeable: true, consume: async () => { charges++; } };
|
||||||
|
} },
|
||||||
|
})("lib/canvas/operations/box-cutout.ts");
|
||||||
|
const response = await operation.executeBoxCutoutPOST(new Request("https://example.com/api/canvas/box-cutout", { method: "POST", headers: { "Content-Type": "application/json", "X-Request-ID": "box-a" }, body: JSON.stringify({ imageUrl: `${cdn}/original.png`, cropRect: { x: 0, y: 0, width: 0.5, height: 0.5 } }) }), "user-a");
|
||||||
|
const result = await response.json();
|
||||||
|
expect({ creates, charges }).toEqual({ creates: 1, charges: rejected ? 0 : 1 });
|
||||||
|
expect(response.status).toBe(rejected ? 400 : 200);
|
||||||
|
if (rejected) {
|
||||||
|
expect(result.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
|
||||||
|
expect(result.error).toContain("5000×3000");
|
||||||
|
expect(result.error).toContain("本次未提交上游抠图任务");
|
||||||
|
expect(result.error).toContain("请求编号:box-a");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("headless box cutout keeps its task identity, pixel bounds and input-rejection status", async () => {
|
||||||
|
class InputError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status: number) { super(message); this.status = status; }
|
||||||
|
}
|
||||||
|
let calls = 0;
|
||||||
|
let rejectInput = false;
|
||||||
|
const bounds = { left: 20, top: 30, width: 200, height: 300 };
|
||||||
|
const executor = serverLoader({
|
||||||
|
"@/lib/ai/box-cutout/service": {},
|
||||||
|
"@/lib/canvas/operations/box-cutout": { executeBoxCutoutPOST: async (request: Request, userId: string) => {
|
||||||
|
calls++;
|
||||||
|
expect(userId).toBe("user-a");
|
||||||
|
expect(request.headers.get("x-request-id")).toBe("box-a");
|
||||||
|
expect(await request.json()).toMatchObject({ sourceNodeId: "source", cropRect: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, paddingRatio: 0.08 });
|
||||||
|
return rejectInput ? Response.json({ success: false, detail: "抠图参考图分辨率超限(5000×3000 像素)" }, { status: 400 })
|
||||||
|
: Response.json({ success: true, original_url: `${cdn}/cutout.png`, paddedCropRectPixels: bounds });
|
||||||
|
} },
|
||||||
|
"@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {},
|
||||||
|
"@/lib/ai/rmbg/service": { RmbgServiceError: InputError },
|
||||||
|
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
|
||||||
|
"@/lib/canvas/generate-image": {}, "@/lib/style-pro/model-options": {},
|
||||||
|
})("lib/canvas/capabilities/headless-executor.ts");
|
||||||
|
const params = { userId: "user-a", requestUrl: "https://example.com/api/canvas/project-a/capabilities", requestHeaders: new Headers(), command: { requestId: "box-a", capabilityId: "image.box_cutout", source: "toolbar", args: { x: 0.1, y: 0.2, width: 0.3, height: 0.4, paddingRatio: 0.08 } }, targets: [{ nodeId: "source", title: "原图", sourceImageUrl: `${cdn}/original.png` }] };
|
||||||
|
const results = await executor.executeCanvasCapabilityHeadlessly(params);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(results).toMatchObject([{ sourceNodeId: "source", url: `${cdn}/cutout.png`, metadata: { editMode: "box-cutout", paddedCropRectPixels: bounds } }]);
|
||||||
|
rejectInput = true;
|
||||||
|
await expect(executor.executeCanvasCapabilityHeadlessly(params)).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Agent and MCP box cutouts retain their existing billing path", async () => {
|
||||||
|
let jobs = 0, featureCharges = 0;
|
||||||
|
const executor = serverLoader({
|
||||||
|
"@/lib/ai/box-cutout/service": { executeBoxCutoutJob: async (params: any) => {
|
||||||
|
jobs++;
|
||||||
|
expect(params).toMatchObject({ userId: "user-a", requestId: "box-agent", sourceNodeId: "source" });
|
||||||
|
return { original_url: `${cdn}/cutout.png` };
|
||||||
|
} },
|
||||||
|
"@/lib/canvas/operations/box-cutout": { executeBoxCutoutPOST: async () => { featureCharges++; throw new Error("Unexpected toolbar charge"); } },
|
||||||
|
"@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {},
|
||||||
|
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
|
||||||
|
"@/lib/canvas/generate-image": {}, "@/lib/style-pro/model-options": {},
|
||||||
|
})("lib/canvas/capabilities/headless-executor.ts");
|
||||||
|
for (const source of ["agent", "mcp", "system"]) {
|
||||||
|
await executor.executeCanvasCapabilityHeadlessly({ userId: "user-a", command: { requestId: "box-agent", capabilityId: "image.box_cutout", source, args: { x: 0, y: 0, width: 0.5, height: 0.5 } }, targets: [{ nodeId: "source", title: "原图", sourceImageUrl: `${cdn}/source.png` }] });
|
||||||
|
}
|
||||||
|
expect({ jobs, featureCharges }).toEqual({ jobs: 3, featureCharges: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
function rmbgLoader(fetch: typeof globalThis.fetch, timer?: typeof setTimeout) {
|
function rmbgLoader(fetch: typeof globalThis.fetch, timer?: typeof setTimeout) {
|
||||||
return serverLoader({
|
return serverLoader({
|
||||||
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "aoscdn", aosApiKey: "test-key" } }) },
|
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "aoscdn", aosApiKey: "test-key" } }) },
|
||||||
"@/lib/db": { db: {} },
|
"@/lib/db": { db: {} },
|
||||||
"@/lib/db/schema": { generatedImage: {} },
|
"@/lib/db/schema": { generatedImage: {} },
|
||||||
"@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `${cdn}/${name}` }) },
|
"@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `${cdn}/${name}` }) },
|
||||||
|
"@/lib/observability/active-work": { trackRuntimeWork: async (execute: () => unknown) => execute() },
|
||||||
fetch,
|
fetch,
|
||||||
setTimeout: timer || ((callback: () => void, ms: number) => setTimeout(callback, ms <= 2000 ? 0 : ms)),
|
setTimeout: timer || ((callback: () => void, ms: number) => setTimeout(callback, ms <= 2000 ? 0 : ms)),
|
||||||
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn })("lib/ai/rmbg/service.ts");
|
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn })("lib/ai/rmbg/service.ts");
|
||||||
|
|||||||
@ -28,3 +28,24 @@ test("durable archive handoff is separate from unfinished original generation",
|
|||||||
complete(); await original;
|
complete(); await original;
|
||||||
expect(records.at(-1)).toMatchObject({ active: 0, durable: 0 });
|
expect(records.at(-1)).toMatchObject({ active: 0, durable: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("direct matting stays visible to release draining until the provider attempt fails", async () => {
|
||||||
|
const counts: number[] = [];
|
||||||
|
let rejectProvider!: (error: Error) => void;
|
||||||
|
let providerStarted!: () => void;
|
||||||
|
const started = new Promise<void>(resolve => { providerStarted = resolve; });
|
||||||
|
const service = serverLoader({
|
||||||
|
"node:fs": { writeFileSync: (_path: string, value: string) => counts.push(JSON.parse(value).active), unlinkSync() {} },
|
||||||
|
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "rmbg", apiUrl: "https://provider.example/remove", apiKey: "test" } }) },
|
||||||
|
"@/lib/db": { db: {} }, "@/lib/db/schema": { generatedImage: {} },
|
||||||
|
"@/lib/storage/service": {},
|
||||||
|
fetch: async () => { providerStarted(); return new Promise<Response>((_resolve, reject) => { rejectProvider = reject; }); },
|
||||||
|
})("lib/ai/rmbg/service.ts");
|
||||||
|
const pending = service.executeRmbgJob({ userId: "user-a", imageBuffer: Buffer.from("image"), skipGallerySave: true });
|
||||||
|
const assertion = expect(pending).rejects.toThrow();
|
||||||
|
await started;
|
||||||
|
expect(counts).toEqual([1]);
|
||||||
|
rejectProvider(new Error("provider unavailable"));
|
||||||
|
await assertion;
|
||||||
|
expect(counts).toEqual([1, 0]);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user