Compare commits

..

16 Commits

Author SHA1 Message Date
1f5528331d Merge pull request '发布切换等待共享抠图服务完成' (#47) from codex/rmbg-active-job-drain-20261105 into main 2026-11-05 15:36:16 +08:00
535ea1aee4 发布切换等待所有正在执行的抠图任务 2026-11-05 15:35:38 +08:00
8cbc8b1ab8 Merge pull request '框选抠图接入原任务恢复和画布回填' (#46) from codex/box-cutout-task-recovery-20261105 into main 2026-11-05 15:27:35 +08:00
ae9354f747 让框选抠图恢复原任务并回填画布 2026-11-05 15:27:01 +08:00
c68bf9a824 Merge pull request '按实际抠图输入提交并移除重复任务调用' (#45) from codex/rmbg-input-contract-20261105 into main 2026-11-05 14:25:06 +08:00
21b47751d6 抠图按实际输入提交并避免预处理重复创建任务 2026-11-05 14:24:32 +08:00
c219f5b10b Merge pull request '保留画布后台查询期间的本地编辑' (#44) from codex/canvas-refresh-snapshot-20261105 into main 2026-11-05 14:04:02 +08:00
672c1368e0 在画布版本查询前记录本地编辑状态 2026-11-05 14:03:39 +08:00
c60585aeca Merge pull request '加载画布内容后同步版本并保留本地编辑' (#43) from codex/canvas-read-revision-20261105 into main 2026-11-05 13:59:44 +08:00
2ee8bd4dea 画布读取与加载同步版本并保留加载期间的编辑 2026-11-05 13:59:08 +08:00
85ff680b65 Merge pull request '按 AOS 抠图规则校验原图并返回明确错误' (#42) from codex/rmbg-provider-limits-20261105 into main 2026-11-05 13:23:59 +08:00
751cffae05 抠图输入超限返回明确的请求错误状态 2026-11-05 13:23:23 +08:00
6e4350fe2b 按上游抠图限制校验原图并返回具体失败原因 2026-11-05 13:22:03 +08:00
3f52e24076 Merge pull request '抠图取结果中断时重试原任务读取' (#41) from codex/rmbg-result-recovery-20261105 into main 2026-11-05 12:04:22 +08:00
0b74e71a33 Retry background removal result reads without recreating tasks 2026-11-05 12:04:11 +08:00
4f8c1a5d5d Merge pull request '减少抠图大图上传并保留超时原因' (#40) from codex/rmbg-large-image-timeout-20261105 into main 2026-11-05 11:33:30 +08:00
24 changed files with 856 additions and 445 deletions

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command"; import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context"; import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry"; import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
@ -90,7 +91,7 @@ export async function POST(
const failure = getRequestFailureDetails(error, "执行画布能力失败"); const failure = getRequestFailureDetails(error, "执行画布能力失败");
return NextResponse.json( return NextResponse.json(
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId }, { success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
{ status: 500 } { status: error instanceof RmbgServiceError ? error.status : 500 }
); );
} }
} }

View File

@ -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);
} }

View File

@ -6,6 +6,7 @@
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -69,7 +70,6 @@ const SCENE_FUSION_REQUEST_STAGGER_MS = 350;
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000; const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
const SCENE_FUSION_AGENT_MAX_WORKERS = 3; const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4; const SCENE_FUSION_PROCESS_MAX_WORKERS = 4;
const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000;
const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000; const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000; const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
@ -236,41 +236,6 @@ function isSceneFusionReviewRejectError(message: string): boolean {
); );
} }
function buildPreRmbgCandidateUrls(request: Request): string[] {
const urls: string[] = [];
const requestUrl = new URL(request.url);
const requestHost = requestUrl.hostname.toLowerCase();
const requestProtocol = requestUrl.protocol.replace(":", "");
if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) {
urls.push(new URL("/api/rmbg", request.url).toString());
}
const host = request.headers.get("host")?.trim();
const protoHint = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", "");
if (host) {
const normalizedHost = host.split(":")[0]?.toLowerCase() || "";
const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1";
if (!(protocol === "https" && isLocalHost)) {
urls.push(`${protocol}://${host}/api/rmbg`);
}
if (isLocalHost) {
urls.push(`http://${host}/api/rmbg`);
}
}
const localPort = requestUrl.port || process.env.PORT || "3125";
urls.push(`http://127.0.0.1:${localPort}/api/rmbg`);
urls.push(`http://localhost:${localPort}/api/rmbg`);
return Array.from(new Set(urls));
}
function normalizeUrlForCompare(input: string): string { function normalizeUrlForCompare(input: string): string {
try { try {
const parsed = new URL(input); const parsed = new URL(input);
@ -988,102 +953,6 @@ async function getSceneFusionConfig() {
}; };
} }
async function preprocessProductImageWithRmbg(
request: Request,
requestId: string,
productImageUrl: string,
preRmbgEnabled: boolean
): Promise<{
productImageUrl: string;
usedPreRmbg: boolean;
provider?: string;
reason?: string;
}> {
if (!preRmbgEnabled) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "disabled_by_config",
};
}
const cookie = request.headers.get("cookie");
if (!cookie) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "missing_cookie",
};
}
const candidateUrls = buildPreRmbgCandidateUrls(request);
let lastReason = "no_candidate_url";
logger.info("pre_rmbg_started", {
requestId,
candidateCount: candidateUrls.length,
});
for (const rmbgUrl of candidateUrls) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SCENE_FUSION_PRE_RMBG_TIMEOUT_MS);
try {
const response = await fetch(rmbgUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie,
},
body: JSON.stringify({
imageUrl: productImageUrl,
prompt: "场景裂变预处理-抠图",
skipGallerySave: true,
}),
signal: controller.signal,
});
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
if (!response.ok) {
lastReason = `request_failed_${response.status}`;
logger.warn("pre_rmbg_candidate_failed", {
requestId,
status: response.status,
detail: payload?.detail || payload?.error || "unknown",
});
continue;
}
const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim();
if (!cleanedUrl || cleanedUrl === productImageUrl) {
lastReason = cleanedUrl ? "same_as_source" : "empty_result";
logger.warn("pre_rmbg_candidate_invalid", {
requestId,
reason: lastReason,
});
continue;
}
return {
productImageUrl: cleanedUrl,
usedPreRmbg: true,
provider: String(payload?.provider || "unknown"),
};
} catch (error) {
lastReason = error instanceof Error ? error.message : "exception";
logger.warn("pre_rmbg_candidate_exception", error, { requestId });
} finally {
clearTimeout(timeoutId);
}
}
return {
productImageUrl,
usedPreRmbg: false,
reason: lastReason,
};
}
/** /**
* 创建缩略图 * 创建缩略图
*/ */
@ -2436,12 +2305,13 @@ export async function POST(request: Request) {
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product"); productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
} }
const shouldForcePreRmbg = resolvedSceneMode === "random-bg"; const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
const preprocessedProduct = await preprocessProductImageWithRmbg( const preprocessedProduct = await preprocessProductImageWithRmbg({
request, request,
requestId, requestId,
userId: session.user.id,
productImageUrl, productImageUrl,
shouldForcePreRmbg ? true : config.preRmbgEnabled preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled,
); });
const fusionProductImageUrl = preprocessedProduct.productImageUrl; const fusionProductImageUrl = preprocessedProduct.productImageUrl;
logger.info("pre_rmbg_resolved", { logger.info("pre_rmbg_resolved", {
requestId, requestId,

View File

@ -5196,6 +5196,7 @@ export default function CanvasEditorLegacyPage({
} }
try { try {
const beforeRead = useCanvasStore.getState();
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, { const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
cache: "no-store", cache: "no-store",
}); });
@ -5216,7 +5217,11 @@ export default function CanvasEditorLegacyPage({
} }
const result = await canvasService.getProject(id); const result = await canvasService.getProject(id);
if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return; const current = useCanvasStore.getState();
if (
cancelled || canvasRemoteOperationActive.current || !result.success || !result.data ||
current.projectId !== id || current.nodes !== beforeRead.nodes || current.edges !== beforeRead.edges
) return;
useCanvasStore.getState().markAgentOperationRefresh(); useCanvasStore.getState().markAgentOperationRefresh();
hydrateCanvasProject(result.data); hydrateCanvasProject(result.data);
lastLocalCanvasMutationAtRef.current = Date.now(); lastLocalCanvasMutationAtRef.current = Date.now();

View File

@ -48,8 +48,13 @@ export default function CanvasStyleProPage() {
const canvasData = result.data.canvasData; const canvasData = result.data.canvasData;
const latestState = useCanvasStore.getState(); const latestState = useCanvasStore.getState();
setProject(result.data.id, result.data.title); setProject(result.data.id, result.data.title);
if (latestState.nodes.length === 0 && latestState.edges.length === 0) { if (
latestState.projectId !== id ||
(latestState.nodes.length === 0 && latestState.edges.length === 0)
) {
loadCanvas(canvasData); loadCanvas(canvasData);
const revision = String(result.data.updatedAt || "");
useCanvasStore.getState().setCanvasRevision(revision, revision);
return; return;
} }

View File

@ -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) => {

View File

@ -918,9 +918,9 @@ export function ImageAnnotator({
setIsRemovingBg(true); setIsRemovingBg(true);
try { try {
// 使用原图URL获取原图数据,而不是canvas的预览图 const input = /^https?:\/\//i.test(originalImageUrl)
const base64Data = await imageUrlToBase64(originalImageUrl); ? { imageUrl: originalImageUrl }
logger.debug("rmbg_source_prepared", { base64Length: base64Data.length }); : { image_base64: await imageUrlToBase64(originalImageUrl) };
// 调用抠图API // 调用抠图API
const response = await fetch("/api/rmbg", { const response = await fetch("/api/rmbg", {
@ -929,7 +929,7 @@ export function ImageAnnotator({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
image_base64: base64Data, ...input,
prompt: "画图标注-抠图处理", // 可选:添加提示词 prompt: "画图标注-抠图处理", // 可选:添加提示词
}), }),
}); });

View File

@ -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"),

View File

@ -72,6 +72,7 @@ export async function processVideoCanvasImage(
const result = await executeRmbgJob({ const result = await executeRmbgJob({
userId: params.userId, userId: params.userId,
imageBuffer: await fetchImageBufferFromUrl(imageUrl), imageBuffer: await fetchImageBufferFromUrl(imageUrl),
inputImageUrl: imageUrl,
sourceImageUrl: imageUrl, sourceImageUrl: imageUrl,
prompt: params.prompt, prompt: params.prompt,
}); });

View File

@ -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");
@ -36,6 +37,8 @@ export type ExecuteRmbgJobParams = {
chatId?: string | null; chatId?: string | null;
messageId?: string | null; messageId?: string | null;
skipGallerySave?: boolean; skipGallerySave?: boolean;
// 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。
inputImageUrl?: string | null;
sourceImageUrl?: string | null; sourceImageUrl?: string | null;
}; };
@ -62,7 +65,11 @@ export class RmbgServiceError extends Error {
} }
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// https://picwish.cn/background-removal-api-doc
const AOS_MAX_RESOLUTION = 4096; const AOS_MAX_RESOLUTION = 4096;
const AOS_MAX_INPUT_BYTES = 50 * 1024 * 1024;
// The shared AOS state -5 documents a separate limit for image_url downloads.
const AOS_MAX_URL_INPUT_BYTES = 30 * 1024 * 1024;
const AOS_CREATE_TIMEOUT_MS = 120_000; const AOS_CREATE_TIMEOUT_MS = 120_000;
const AOS_POLL_TIMEOUT_MS = 60_000; const AOS_POLL_TIMEOUT_MS = 60_000;
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000; const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
@ -148,6 +155,18 @@ const withTimeout = async <T,>(
} }
}; };
async function readAosResult<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await withTimeout(read, timeoutMs);
} catch (error) {
const name = error instanceof Error ? error.name : "";
if (attempt >= 2 || (name !== "TypeError" && name !== "AbortError")) throw error;
await sleep(1000 * (attempt + 1));
}
}
}
function getAosFriendlyErrorMessage(detail: string): string | null { function getAosFriendlyErrorMessage(detail: string): string | null {
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i); const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
if (resolutionMatch) { if (resolutionMatch) {
@ -164,53 +183,43 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
async function prepareImageForAos( async function prepareImageForAos(
imageBuffer: Buffer imageBuffer: Buffer
): Promise<{ buffer: Buffer; mimeType: string; filename: string; resized: boolean }> { ): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
if (imageBuffer.length > AOS_MAX_INPUT_BYTES) {
throw new RmbgServiceError(
`抠图参考图文件大小超限(${(imageBuffer.length / 1024 / 1024).toFixed(2)} MB),当前 AOS 上游最大支持 50 MB。`,
400
);
}
let metadata: import("sharp").Metadata;
try { try {
const sharp = await import("sharp"); const sharp = await import("sharp");
const image = sharp.default(imageBuffer); metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata();
const metadata = await image.metadata();
const width = metadata.width;
const height = metadata.height;
const format = (metadata.format || "png").toLowerCase();
const ext = format === "jpeg" ? "jpg" : format;
const filenameExt =
ext === "jpg" || ext === "png" || ext === "webp" || ext === "tiff" ? ext : "png";
const mimeType =
ext === "jpg"
? "image/jpeg"
: ext === "webp"
? "image/webp"
: ext === "tiff"
? "image/tiff"
: "image/png";
if (!width || !height || (width <= AOS_MAX_RESOLUTION && height <= AOS_MAX_RESOLUTION)) {
return {
buffer: imageBuffer,
mimeType,
filename: `image.${filenameExt}`,
resized: false,
};
}
const resizedBuffer = await image
.resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, {
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
return {
buffer: resizedBuffer,
mimeType,
filename: `image.${filenameExt}`,
resized: true,
};
} catch (error) { } catch (error) {
logger.warn("aos_image_preprocess_failed", error); // AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify.
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png", resized: true }; logger.warn("aos_image_metadata_unavailable", error);
return { buffer: imageBuffer, mimeType: "application/octet-stream", filename: "image" };
} }
const { width, height } = metadata;
if (width && height && (width > AOS_MAX_RESOLUTION || height > AOS_MAX_RESOLUTION)) {
throw new RmbgServiceError(
`抠图参考图分辨率超限(${width}×${height} 像素),当前 AOS 上游要求宽、高均不超过 ${AOS_MAX_RESOLUTION} 像素。`,
400
);
}
const format = metadata.format || "";
if (["gif", "svg", "heif", "avif", "jp2", "jxl"].includes(format)) {
const name = format === "heif" ? "HEIF/AVIF" : format.toUpperCase();
throw new RmbgServiceError(`抠图参考图格式不支持(${name}),当前 AOS 上游不支持此格式。`, 400);
}
const ext = format === "jpeg" ? "jpg" : format;
return {
buffer: imageBuffer,
mimeType: format ? `image/${format}` : "application/octet-stream",
filename: ext ? `image.${ext}` : "image",
};
} }
async function processWithRMBG( async function processWithRMBG(
@ -372,7 +381,7 @@ async function processWithAosSegmentation(
foregroundType?: "" | "person" | "object" | "stamp"; foregroundType?: "" | "person" | "object" | "stamp";
crop?: 0 | 1; crop?: 0 | 1;
format?: "png" | "jpg"; format?: "png" | "jpg";
sourceImageUrl?: string | null; inputImageUrl?: string | null;
requestId?: string; requestId?: string;
} }
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> { ): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
@ -382,14 +391,14 @@ async function processWithAosSegmentation(
const headers = { "X-API-KEY": config.apiKey }; const headers = { "X-API-KEY": config.apiKey };
let stage = "create"; let stage = "create";
let providerTaskId: string | undefined; let providerTaskId: string | undefined;
const sourceImage = await prepareImageForAos(imageBuffer);
try { try {
const normalizedImage = await prepareImageForAos(imageBuffer);
const createForm = new FormData(); const createForm = new FormData();
let publicSourceUrl: string | null = null; let publicSourceUrl: string | null = null;
if (!normalizedImage.resized && config.sourceImageUrl) { if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) {
try { try {
const source = new URL(config.sourceImageUrl); const source = new URL(config.inputImageUrl);
const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || ""); const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) { if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) {
publicSourceUrl = source.toString(); publicSourceUrl = source.toString();
@ -399,10 +408,10 @@ async function processWithAosSegmentation(
if (publicSourceUrl) { if (publicSourceUrl) {
createForm.append("image_url", publicSourceUrl); createForm.append("image_url", publicSourceUrl);
} else { } else {
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], { const imageBlob = new Blob([new Uint8Array(sourceImage.buffer)], {
type: normalizedImage.mimeType, type: sourceImage.mimeType,
}); });
createForm.append("image_file", imageBlob, normalizedImage.filename); createForm.append("image_file", imageBlob, sourceImage.filename);
} }
createForm.append("sync", "0"); createForm.append("sync", "0");
createForm.append("return_type", "1"); createForm.append("return_type", "1");
@ -414,7 +423,7 @@ async function processWithAosSegmentation(
} }
const createStartedAt = Date.now(); const createStartedAt = Date.now();
logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : normalizedImage.buffer.length }); logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : sourceImage.buffer.length });
const createResponse = await withTimeout( const createResponse = await withTimeout(
(signal) => (signal) =>
@ -435,7 +444,7 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`, error: `AOS任务创建失败(HTTP ${createResponse.status}): ${createPayload?.message || createResponse.statusText}`,
}; };
} }
@ -449,17 +458,23 @@ async function processWithAosSegmentation(
await sleep(1000); await sleep(1000);
stage = "poll"; stage = "poll";
const pollResponse = await withTimeout( const pollResponse = await readAosResult(
(signal) => async (signal) => {
fetch(taskUrl, { const response = await fetch(taskUrl, {
method: "GET", method: "GET",
headers, headers,
signal, signal,
}), });
const payload = await response.json().catch((error) => {
if (error instanceof Error && error.name !== "SyntaxError") throw error;
return null;
});
return { ok: response.ok, status: response.status, statusText: response.statusText, payload };
},
AOS_POLL_TIMEOUT_MS AOS_POLL_TIMEOUT_MS
); );
const pollPayload = await pollResponse.json().catch(() => null); const pollPayload = pollResponse.payload;
if (!pollResponse.ok || !pollPayload?.data) { if (!pollResponse.ok || !pollPayload?.data) {
logger.warn("aos_task_poll_failed", { logger.warn("aos_task_poll_failed", {
hasPayload: pollPayload != null, hasPayload: pollPayload != null,
@ -467,7 +482,7 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`, error: `AOS任务查询失败(HTTP ${pollResponse.status},任务 ${taskId}): ${pollPayload?.message || pollResponse.statusText}`,
}; };
} }
@ -482,12 +497,14 @@ async function processWithAosSegmentation(
taskId, taskId,
}); });
stage = "download"; stage = "download";
const imageResponse = await withTimeout( const imageResponse = await readAosResult(
(signal) => async (signal) => {
fetch(imageUrl, { const response = await fetch(imageUrl, {
method: "GET", method: "GET",
signal, signal,
}), });
return { ok: response.ok, status: response.status, statusText: response.statusText, buffer: response.ok ? Buffer.from(await response.arrayBuffer()) : null };
},
AOS_DOWNLOAD_TIMEOUT_MS AOS_DOWNLOAD_TIMEOUT_MS
); );
@ -498,11 +515,11 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`, error: `AOS结果下载失败(HTTP ${imageResponse.status},任务 ${taskId}): ${imageResponse.statusText}`,
}; };
} }
const resultBuffer = Buffer.from(await imageResponse.arrayBuffer()); const resultBuffer = imageResponse.buffer!;
return { return {
success: true, success: true,
imageBuffer: resultBuffer, imageBuffer: resultBuffer,
@ -518,13 +535,13 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`, error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`,
}; };
} }
} }
logger.warn("aos_task_timeout", { maxPollCount }); logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount });
return { success: false, error: "AOS任务超时(最多60秒)" }; return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` };
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length }); logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length });
@ -535,8 +552,13 @@ async function processWithAosSegmentation(
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`; : `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
return { success: false, error: message }; return { success: false, error: message };
} }
logger.warn("aos_provider_request_error", error); logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId });
return { success: false, error: error instanceof Error ? error.message : "未知错误" }; const detail = error instanceof Error ? error.message : "未知错误";
return { success: false, error: stage === "create"
? `AOS创建任务连接失败,结果尚未确认:${detail}`
: stage === "poll"
? `AOS查询任务连接失败,结果尚未确认(任务 ${providerTaskId}):${detail}`
: `AOS结果下载连接失败,任务已完成(任务 ${providerTaskId}):${detail}` };
} }
} }
@ -633,9 +655,15 @@ 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, requestId = crypto.randomUUID(),
userId, userId,
imageBuffer, imageBuffer,
imageBase64 = imageBuffer.toString("base64"), imageBase64 = imageBuffer.toString("base64"),
@ -644,11 +672,13 @@ export async function executeRmbgJob(
chatId = null, chatId = null,
messageId = null, messageId = null,
skipGallerySave = false, skipGallerySave = false,
inputImageUrl = null,
sourceImageUrl = null, sourceImageUrl = null,
} = params; } = params;
const { provider, config } = await getValidatedRmbgConfig(); const { provider, config } = await getValidatedRmbgConfig();
logger.info("job_started", { logger.info("job_started", {
requestId,
hasChatId: Boolean(chatId), hasChatId: Boolean(chatId),
hasMessageId: Boolean(messageId), hasMessageId: Boolean(messageId),
hasSourceImageUrl: Boolean(sourceImageUrl), hasSourceImageUrl: Boolean(sourceImageUrl),
@ -692,7 +722,7 @@ export async function executeRmbgJob(
foregroundType: foregroundType ?? config.aosForegroundType ?? "", foregroundType: foregroundType ?? config.aosForegroundType ?? "",
crop: config.aosCrop === 1 ? 1 : 0, crop: config.aosCrop === 1 ? 1 : 0,
format: config.aosFormat === "jpg" ? "jpg" : "png", format: config.aosFormat === "jpg" ? "jpg" : "png",
sourceImageUrl, inputImageUrl,
requestId, requestId,
}); });
@ -830,6 +860,7 @@ export async function executeRmbgJob(
} }
logger.info("job_completed", { logger.info("job_completed", {
requestId,
generatedImageId: savedImage.id, generatedImageId: savedImage.id,
mediaAssetId, mediaAssetId,
processingSeconds: Number(processingTime.toFixed(2)), processingSeconds: Number(processingTime.toFixed(2)),
@ -852,6 +883,7 @@ export async function executeRmbgJob(
} }
logger.info("job_completed", { logger.info("job_completed", {
requestId,
processingSeconds: Number(processingTime.toFixed(2)), processingSeconds: Number(processingTime.toFixed(2)),
provider, provider,
savedToGallery: false, savedToGallery: false,

View File

@ -516,6 +516,7 @@ export async function executeDirectAgentImageEditTool(
const result = await executeRmbgJob({ const result = await executeRmbgJob({
userId: params.userId, userId: params.userId,
imageBuffer: sourceBuffer, imageBuffer: sourceBuffer,
inputImageUrl: params.sourceImageUrl,
prompt: params.requestTextContent || "抠图处理", prompt: params.requestTextContent || "抠图处理",
chatId: params.chatId, chatId: params.chatId,
messageId: params.messageId, messageId: params.messageId,

View File

@ -1,16 +1,15 @@
import {
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 {
@ -131,6 +130,7 @@ async function executeForTarget(
requestId: command.requestId, requestId: command.requestId,
userId: params.userId, userId: params.userId,
imageBuffer, imageBuffer,
inputImageUrl: target.sourceImageUrl,
sourceImageUrl: target.sourceImageUrl, sourceImageUrl: target.sourceImageUrl,
prompt: prompt || "抠图处理", prompt: prompt || "抠图处理",
skipGallerySave: false, skipGallerySave: false,
@ -186,25 +186,35 @@ async function executeForTarget(
} }
if (command.capabilityId === "image.box_cutout") { if (command.capabilityId === "image.box_cutout") {
const result = await executeBoxCutoutJob({ const response = await executeBoxCutoutPOST(new Request(params.requestUrl, {
userId: params.userId, method: "POST",
imageUrl: target.sourceImageUrl, headers: { "Content-Type": "application/json", "X-Request-ID": command.requestId },
sourceNodeId: target.nodeId, body: JSON.stringify({
cropRect: { imageUrl: target.sourceImageUrl,
x: asFiniteNumber(command.args.x), sourceNodeId: target.nodeId,
y: asFiniteNumber(command.args.y), cropRect: {
width: asFiniteNumber(command.args.width), x: asFiniteNumber(command.args.x),
height: asFiniteNumber(command.args.height), y: asFiniteNumber(command.args.y),
}, width: asFiniteNumber(command.args.width),
paddingRatio: asFiniteNumber(command.args.paddingRatio), height: asFiniteNumber(command.args.height),
prompt: prompt || "框选抠图", },
}); paddingRatio: asFiniteNumber(command.args.paddingRatio),
prompt: prompt || "框选抠图",
}),
}), params.userId);
const result = await response.json();
if (!response.ok || !result.success) {
throw new RmbgServiceError(result.detail || result.message || result.error || "框选抠图未能完成", response.status);
}
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,

View 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 });
}
}

View File

@ -9,9 +9,11 @@ import {
import { getPointsSystemConfig } from "@/lib/db/queries"; import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config"; import { getImageToolCreditCost } from "@/lib/points/config";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) { export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) {
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
try { try {
const session = { user: { id: trustedUserId } }; const session = { user: { id: trustedUserId } };
if (!session?.user?.id) { if (!session?.user?.id) {
@ -27,6 +29,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
let messageId: string | null = null; let messageId: string | null = null;
let skipGallerySave = false; let skipGallerySave = false;
let sourceImageUrl: string | null = null; let sourceImageUrl: string | null = null;
let inputImageUrl: string | null = null;
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const body = await request.json(); const body = await request.json();
@ -40,6 +43,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
if (imageUrl) { if (imageUrl) {
sourceImageUrl = String(imageUrl); sourceImageUrl = String(imageUrl);
inputImageUrl = sourceImageUrl;
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl); imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
} else if (body.image_base64) { } else if (body.image_base64) {
imageBase64 = String(body.image_base64); imageBase64 = String(body.image_base64);
@ -94,6 +98,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
} }
const result = await executeRmbgJob({ const result = await executeRmbgJob({
requestId,
userId: session.user.id, userId: session.user.id,
imageBuffer, imageBuffer,
imageBase64, imageBase64,
@ -101,6 +106,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
chatId, chatId,
messageId, messageId,
skipGallerySave, skipGallerySave,
inputImageUrl,
sourceImageUrl, sourceImageUrl,
}); });
@ -114,22 +120,29 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
requestId,
...result, ...result,
}); });
} catch (error) { } catch (error) {
if (error instanceof RmbgServiceError) { if (error instanceof RmbgServiceError) {
const failure = getRequestFailureDetails(error);
return NextResponse.json( return NextResponse.json(
{ {
error: error.message, success: false,
error: formatRequestFailure(failure, requestId),
detail: error.detail || error.message, detail: error.detail || error.message,
code: failure.code,
failure,
requestId,
}, },
{ status: error.status } { status: error.status }
); );
} }
console.error("[RMBG API] 处理错误:", error); console.error("[RMBG API] 处理错误:", error);
const failure = getRequestFailureDetails(error, "抠图请求未能完成");
return NextResponse.json( return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" }, { success: false, error: formatRequestFailure(failure, requestId), detail: error instanceof Error ? error.message : "未知错误", code: failure.code, failure, requestId },
{ status: 500 } { status: 500 }
); );
} }

View File

@ -40,22 +40,6 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
async function executeScaleAdjustRmbgJob(
params: Parameters<typeof executeRmbgJob>[0]
) {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await executeRmbgJob(params);
} catch (error) {
lastError = error;
if (attempt === 3) break;
await new Promise((resolve) => setTimeout(resolve, attempt * 800));
}
}
throw lastError;
}
function rectToPixelBox( function rectToPixelBox(
rect: CanvasScaleAdjustRect, rect: CanvasScaleAdjustRect,
sourceWidth: number, sourceWidth: number,
@ -220,7 +204,7 @@ async function buildPersonExclusionAlpha(params: {
userId: string; userId: string;
}) { }) {
try { try {
const person = await executeScaleAdjustRmbgJob({ const person = await executeRmbgJob({
foregroundType: "person", foregroundType: "person",
imageBuffer: params.sourceBuffer, imageBuffer: params.sourceBuffer,
prompt: "比例调整人物保护蒙版", prompt: "比例调整人物保护蒙版",
@ -330,7 +314,7 @@ export async function buildScaleAdjustProductLockAssets(params: {
.png({ compressionLevel: 6 }) .png({ compressionLevel: 6 })
.toBuffer(); .toBuffer();
semanticSourceCropBuffers.push(cropBuffer); semanticSourceCropBuffers.push(cropBuffer);
const subject = await executeScaleAdjustRmbgJob({ const subject = await executeRmbgJob({
imageBuffer: cropBuffer, imageBuffer: cropBuffer,
prompt: "比例调整完整产品蒙版", prompt: "比例调整完整产品蒙版",
skipGallerySave: true, skipGallerySave: true,

View File

@ -228,15 +228,68 @@ export function getRequestFailureDetails(
const status = getRequestErrorStatus(error); const status = getRequestErrorStatus(error);
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") => const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
({ code, message, recommendation, outcome }); ({ code, message, recommendation, outcome });
if (/抠图参考图文件大小超限/.test(text)) {
return detail("RMBG_INPUT_TOO_LARGE", getRequestErrorMessage(error) || "抠图参考图超过 AOS 的 50 MB 上限。", "请手动压缩至 50 MB 以内后重新提交。本次未提交上游抠图任务。");
}
if (/抠图参考图分辨率超限/.test(text)) {
return detail("RMBG_INPUT_RESOLUTION_EXCEEDED", getRequestErrorMessage(error) || "抠图参考图超过 AOS 的 4096 像素上限。", "请手动将宽、高均调整至 4096 像素以内,再重新提交。本次未提交上游抠图任务。");
}
if (/抠图参考图格式不支持/.test(text)) {
return detail("RMBG_INPUT_FORMAT_UNSUPPORTED", getRequestErrorMessage(error) || "AOS 不支持此参考图格式。", "请导出为 JPG、PNG 或 WebP 后重新提交。本次未提交上游抠图任务。");
}
const aosResolution = text.match(/(?:resolution excess,\s*width=(\d+),\s*height=(\d+)|图片分辨率超限((\d+)x(\d+)))/i);
if (aosResolution) {
return detail("RMBG_PROVIDER_RESOLUTION_EXCEEDED", `AOS 拒绝了参考图:分辨率为 ${aosResolution[1] || aosResolution[3]}×${aosResolution[2] || aosResolution[4]} 像素,宽、高均须不超过 4096 像素。`, "请手动调整尺寸后重新提交。");
}
if (/AOS.*invalid file|图片文件无法识别/i.test(text)) {
return detail("RMBG_PROVIDER_INVALID_FILE", "AOS 无法识别参考图,文件可能损坏或格式不受支持。", "请确认原图可以正常打开,并导出为 JPG、PNG 或 WebP 后重新提交。");
}
const aosState = text.match(/AOS任务失败(状态 (-\d+),/);
if (aosState) {
const state = Number(aosState[1]);
if (state === -5) return detail("RMBG_PROVIDER_URL_TOO_LARGE", "AOS 拒绝读取参考图:链接取图大小超过上游的 30 MB 上限。", "请联系管理员核对文件提交方式;不要连续点击。上游文件上传接口允许最大 50 MB。");
if (state === -7) return detail("RMBG_PROVIDER_INVALID_FILE", "AOS 无法识别参考图,文件可能损坏或格式不受支持。", "请确认原图可以正常打开,并导出为 JPG、PNG 或 WebP 后重新提交。");
if (state === -3) return detail("RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED", "AOS 下载参考图失败,未能读取原图。", "请确认原图链接可打开;若原图正常,请联系管理员检查上游取图线路,并提供请求编号。");
if (state === -2) return detail("RMBG_PROVIDER_RESULT_STORAGE_FAILED", "AOS 已完成处理,但上游保存结果图片失败。", "请联系管理员核对原任务结果,避免立即重复抠图。", "unknown");
if (state === -8) return detail("RMBG_PROVIDER_PROCESSING_TIMEOUT", "AOS 抠图处理超时,上游已返回失败状态。", "请稍后重试;若持续发生,请联系管理员并提供请求编号。");
if (state === -11) return detail("RMBG_PROVIDER_EMPTY_RESULT", "AOS 抠图结果为空,未能得到有效主体。", "请检查图片中的主体是否清晰,尝试使用主体更明确的图片。");
if ([-17, -16, -14, -10].includes(state)) return detail("RMBG_PROVIDER_INPUT_REJECTED", `AOS 拒绝处理参考图,图片内容未通过上游检查(状态 ${state})。`, "请更换符合上游要求的参考图;如认为判断有误,请联系管理员核对请求编号。");
if (state === -15) return detail("RMBG_PROVIDER_RESOURCE_UNAVAILABLE", "AOS 处理资源不足,未能完成本次抠图。", "请稍后再试;持续失败时联系管理员检查上游服务。");
return detail("RMBG_PROVIDER_TASK_FAILED", getRequestErrorMessage(error) || `AOS 抠图任务失败(状态 ${state})。`, "请联系管理员并提供请求编号,核对上游任务详情。");
}
if (/AOS任务创建失败(HTTP 413)/.test(text)) {
return detail("RMBG_PROVIDER_FILE_TOO_LARGE", "AOS 拒绝了参考图:文件超过上游大小限制(文件上传最大 50 MB)。", "请手动压缩文件后重新提交;若文件已小于限制,请联系管理员核对上游返回。");
}
if (/AOS任务创建失败(HTTP (400|401|404))/.test(text)) {
return detail("RMBG_PROVIDER_REQUEST_REJECTED", getRequestErrorMessage(error) || "AOS 拒绝了抠图请求。", "请联系管理员核对上游参数、服务权限及接口配置,并提供请求编号。");
}
if (/AOS任务创建失败(HTTP (200|5\d\d))/.test(text)) {
return detail("RMBG_PROVIDER_CREATE_UNCONFIRMED", getRequestErrorMessage(error) || "AOS 未能确认抠图任务是否创建成功。", "请联系管理员核对原请求,避免连续提交造成重复处理。", "unknown");
}
const aosReadFailure = text.match(/AOS(任务查询|结果下载)失败(HTTP (\d+)/);
if (aosReadFailure) {
return aosReadFailure[1] === "任务查询"
? detail("RMBG_PROVIDER_POLL_FAILED", `AOS 查询抠图任务失败(HTTP ${aosReadFailure[2]}),原任务结果尚未确认。`, "请联系管理员核对原任务,不要重复提交。", "unknown")
: detail("RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", `抠图已完成,但结果图片下载失败(HTTP ${aosReadFailure[2]})。`, "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
}
if (isGatewayResponseTimeout(error)) { if (isGatewayResponseTimeout(error)) {
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown"); return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
} }
if (/AOS创建任务超时/.test(text)) { if (/AOS创建任务超时/.test(text)) {
return detail("RMBG_CREATE_TIMEOUT", "抠图服务创建任务超时(120秒),结果尚未确认。", "请先查看原任务或联系管理员核对;不要连续点击,以免重复处理。", "unknown"); return detail("RMBG_CREATE_TIMEOUT", "抠图服务创建任务超时(120秒),结果尚未确认。", "请先查看原任务或联系管理员核对;不要连续点击,以免重复处理。", "unknown");
} }
if (/AOS创建任务连接失败/.test(text)) {
return detail("RMBG_CREATE_CONNECTION_FAILED", "抠图服务创建任务时连接中断,结果尚未确认。", "请先查看原任务或联系管理员核对;不要连续点击,以免重复处理。", "unknown");
}
if (/AOS查询任务连接失败/.test(text)) {
return detail("RMBG_POLL_CONNECTION_FAILED", "抠图服务查询任务时连接中断,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "unknown");
}
if (/AOS查询任务超时/.test(text)) { if (/AOS查询任务超时/.test(text)) {
return detail("RMBG_POLL_TIMEOUT", "抠图服务查询任务超时,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "unknown"); return detail("RMBG_POLL_TIMEOUT", "抠图服务查询任务超时,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "unknown");
} }
if (/AOS结果下载连接失败/.test(text)) {
return detail("RMBG_RESULT_DOWNLOAD_CONNECTION_FAILED", "抠图已完成,但结果图片传输中断。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
}
if (/AOS结果下载超时/.test(text)) { if (/AOS结果下载超时/.test(text)) {
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown"); return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
} }

View File

@ -0,0 +1,37 @@
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("scene-fusion");
export async function preprocessProductImageWithRmbg(params: {
request: Request;
requestId: string;
userId: string;
productImageUrl: string;
preRmbgEnabled: boolean;
}): Promise<{ productImageUrl: string; usedPreRmbg: boolean; provider?: string; reason?: string }> {
const { request, requestId, userId, productImageUrl, preRmbgEnabled } = params;
if (!preRmbgEnabled) return { productImageUrl, usedPreRmbg: false, reason: "disabled_by_config" };
logger.info("pre_rmbg_started", { requestId });
try {
const response = await executeRemoveBackgroundPOST(new Request(new URL("/api/rmbg", request.url), {
method: "POST",
headers: { "Content-Type": "application/json", "X-Request-ID": `${requestId}:pre-rmbg` },
body: JSON.stringify({ imageUrl: productImageUrl, prompt: "场景裂变预处理-抠图", skipGallerySave: true }),
}), userId);
const payload = await response.json();
if (!response.ok) {
logger.warn("pre_rmbg_failed", { requestId, status: response.status, code: payload?.failure?.code });
return { productImageUrl, usedPreRmbg: false, reason: String(payload?.error || `request_failed_${response.status}`) };
}
const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim();
if (!cleanedUrl || cleanedUrl === productImageUrl) {
return { productImageUrl, usedPreRmbg: false, reason: cleanedUrl ? "same_as_source" : "empty_result" };
}
return { productImageUrl: cleanedUrl, usedPreRmbg: true, provider: String(payload?.provider || "unknown") };
} catch (error) {
logger.warn("pre_rmbg_failed", error, { requestId });
return { productImageUrl, usedPreRmbg: false, reason: error instanceof Error ? error.message : "exception" };
}
}

View File

@ -95,18 +95,19 @@ function normalizeRevisionText(value: unknown) {
return String(value || "").trim(); return String(value || "").trim();
} }
function syncCanvasStoreRevisionFromUpdatedAt(updatedAt: unknown): void { function syncCanvasStoreRevisionFromUpdatedAt(projectId: string, updatedAt: unknown): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const revision = normalizeRevisionText(updatedAt); const revision = normalizeRevisionText(updatedAt);
if (!revision) return; if (!revision) return;
useCanvasStore.getState().setCanvasRevision(revision, revision); const current = useCanvasStore.getState();
if (current.projectId === projectId) current.setCanvasRevision(revision, revision);
} }
function syncCanvasStoreRevisionFromProject( function syncCanvasStoreRevisionFromProject(
project?: { canvasData?: CanvasState | null; updatedAt?: Date | string } | null project?: { id: string; canvasData?: CanvasState | null; updatedAt?: Date | string } | null
): void { ): void {
if (!project?.canvasData) return; if (!project?.canvasData) return;
syncCanvasStoreRevisionFromUpdatedAt(project.updatedAt); syncCanvasStoreRevisionFromUpdatedAt(project.id, project.updatedAt);
} }
export interface NormalizedCropRect { export interface NormalizedCropRect {
@ -203,9 +204,6 @@ export const canvasService = {
try { try {
const response = await fetch(`/api/canvas/${id}`); const response = await fetch(`/api/canvas/${id}`);
const data = await response.json(); const data = await response.json();
if (data?.success) {
syncCanvasStoreRevisionFromProject(data.data);
}
return data; return data;
} catch (error) { } catch (error) {
return { success: false, error: '获取画布失败' }; return { success: false, error: '获取画布失败' };
@ -335,7 +333,7 @@ export const canvasService = {
}; };
} }
if (data?.success) { if (data?.success) {
syncCanvasStoreRevisionFromUpdatedAt(data?.data?.updatedAt); syncCanvasStoreRevisionFromUpdatedAt(id, data?.data?.updatedAt);
} }
return data as ApiResponse; return data as ApiResponse;
} catch (error) { } catch (error) {

View File

@ -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/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 },

View File

@ -808,6 +808,7 @@ test.describe("canvas chat pure helpers", () => {
}); });
test("canvas service save uses store revision fallback when options are omitted", async () => { test("canvas service save uses store revision fallback when options are omitted", async () => {
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
const canvasData = { const canvasData = {
nodes: [], nodes: [],
edges: [], edges: [],
@ -885,7 +886,7 @@ test.describe("canvas chat pure helpers", () => {
}); });
}); });
test("canvas service keeps store revision aligned after project load and save", async () => { test("canvas revision advances after applying the fetched snapshot and saving the current project", async () => {
const canvasData = { const canvasData = {
nodes: [], nodes: [],
edges: [], edges: [],
@ -915,6 +916,7 @@ test.describe("canvas chat pure helpers", () => {
}, },
]; ];
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
useCanvasStore.getState().setCanvasRevision(null, null); useCanvasStore.getState().setCanvasRevision(null, null);
Object.defineProperty(globalThis, "window", { Object.defineProperty(globalThis, "window", {
configurable: true, configurable: true,
@ -929,7 +931,11 @@ test.describe("canvas chat pure helpers", () => {
}) as Response) as typeof fetch; }) as Response) as typeof fetch;
try { try {
await canvasService.getProject("11111111-1111-4111-8111-111111111111"); const result = await canvasService.getProject("11111111-1111-4111-8111-111111111111");
expect(useCanvasStore.getState().canvasRevision).toBeNull();
useCanvasStore.getState().loadCanvas(result.data!.canvasData);
const revision = String(result.data!.updatedAt);
useCanvasStore.getState().setCanvasRevision(revision, revision);
expect(useCanvasStore.getState().canvasRevision).toBe( expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:01:00.000Z" "2026-06-22T00:01:00.000Z"
); );

View File

@ -236,8 +236,6 @@ test.describe("canvas scale adjust product lock", () => {
const rmbg = readSource("lib/ai/rmbg/service.ts"); const rmbg = readSource("lib/ai/rmbg/service.ts");
expect(productLock).toContain('foregroundType: "person"'); expect(productLock).toContain('foregroundType: "person"');
expect(productLock).toContain("attempt <= 3");
expect(productLock).toContain("executeScaleAdjustRmbgJob({");
expect(productLock).toContain("regionPersonExclusionAlpha"); expect(productLock).toContain("regionPersonExclusionAlpha");
expect(productLock).toContain("overlapPixelCount >= overlapLimit"); expect(productLock).toContain("overlapPixelCount >= overlapLimit");
expect(productLock).toContain("目标产品被人物遮挡"); expect(productLock).toContain("目标产品被人物遮挡");

View File

@ -4,6 +4,7 @@ import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capa
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { canvasService } from "@/services/canvas"; import { canvasService } from "@/services/canvas";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 }, const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 },
@ -86,6 +87,49 @@ test("a lost submission response polls the original task and never resubmits", a
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source", "result"]); expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source", "result"]);
}); });
test("a recovered matting rejection shows its dimensions and advice without changing the canvas or resubmitting", async () => {
const submitted = createCanvasCapabilityClientCommand({ projectId: "project-a", capabilityId: "image.remove_background", nodeIds: ["source"], source: "toolbar" });
const failure = getRequestFailureDetails("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。");
const message = formatRequestFailure(failure, submitted.requestId);
let posts = 0;
globalThis.fetch = (async (url, options) => {
if (options?.method === "POST") { posts++; return new Response(null, { status: 504 }); }
expect(String(url)).toContain(encodeURIComponent(submitted.requestId));
return Response.json({ success: true, task: { status: "failed", failure, error: message } });
}) as typeof fetch;
await expect(executeCanvasToolbarCapability(submitted)).rejects.toThrow(message);
expect(posts).toBe(1);
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]);
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")) {
@ -119,6 +163,71 @@ test("metadata changes never adopt an unseen remote canvas revision", async () =
expect(useCanvasStore.getState().canvasRevision).toBe("r1"); expect(useCanvasStore.getState().canvasRevision).toBe("r1");
}); });
test("reading a result without applying it cannot authorize an old snapshot to overwrite the result", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
let remoteNodes = [node("source"), node("result")];
globalThis.fetch = (async (_url, options) => {
if (options?.method === "PUT") {
const body = JSON.parse(String(options.body));
if (body.baseCanvasRevision !== "r2") return Response.json({ success: false, error: "画布已更新" }, { status: 409 });
remoteNodes = body.canvasData.nodes;
return Response.json({ success: true, data: { updatedAt: "r3" } });
}
return Response.json({ success: true, data: { id: "project-a", updatedAt: "r2", canvasData: { nodes: remoteNodes, edges: [] } } });
}) as typeof fetch;
try {
await canvasService.getProject("project-a");
const saved = await canvasService.saveCanvasState("project-a", useCanvasStore.getState().getCanvasState());
expect(saved).toMatchObject({ success: false, status: 409 });
expect(remoteNodes.map(n => n.id)).toEqual(["source", "result"]);
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("a delayed read cannot change the revision of a newly selected project", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
let complete!: (response: Response) => void;
globalThis.fetch = (() => new Promise<Response>(resolve => { complete = resolve; })) as typeof fetch;
try {
const pending = canvasService.getProject("project-a");
useCanvasStore.getState().setProject("project-b");
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
useCanvasStore.getState().setCanvasRevision("r9", "r9");
complete(Response.json({ success: true, data: { id: "project-a", updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } }));
await pending;
expect(useCanvasStore.getState().canvasRevision).toBe("r9");
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("a delayed save cannot change the revision of a newly selected project", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
let complete!: (response: Response) => void;
globalThis.fetch = (() => new Promise<Response>(resolve => { complete = resolve; })) as typeof fetch;
try {
const pending = canvasService.saveCanvasState("project-a", useCanvasStore.getState().getCanvasState());
useCanvasStore.getState().setProject("project-b");
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
useCanvasStore.getState().setCanvasRevision("r9", "r9");
complete(Response.json({ success: true, data: { updatedAt: "r2" } }));
await pending;
expect(useCanvasStore.getState().canvasRevision).toBe("r9");
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("a result merge conflict retains both the local edit and its original revision", async () => { test("a result merge conflict retains both the local edit and its original revision", async () => {
globalThis.fetch = (async (url) => { globalThis.fetch = (async (url) => {
if (String(url).endsWith("/capabilities")) { if (String(url).endsWith("/capabilities")) {

View File

@ -5,14 +5,100 @@ 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/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", 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);
});
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 === 1000 ? 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");
} }
@ -33,30 +119,206 @@ test("AOS uses a public Qiniu original URL without uploading the image file agai
if (url.endsWith("/task-url")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } }); if (url.endsWith("/task-url")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
return new Response(image, { headers: { "content-type": "image/png" } }); return new Response(image, { headers: { "content-type": "image/png" } });
}); });
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true }); const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true });
expect(creates).toBe(1); expect(creates).toBe(1);
expect(result.provider).toBe("aoscdn"); expect(result.provider).toBe("aoscdn");
expect(result.original_url).toContain(cdn); expect(result.original_url).toContain(cdn);
}); });
test("oversized images and non-CDN sources keep the normalized file upload", async () => { test("non-CDN sources upload the original file without changing its bytes or dimensions", async () => {
for (const [width, source] of [[5000, `${cdn}/large.png`], [1000, "http://127.0.0.1/private.png"], [1000, "https://other.example/image.png"]] as const) { for (const [width, source] of [[4096, "http://127.0.0.1/private.png"], [1000, "https://other.example/image.png"]] as const) {
const image = await sharp({ create: { width, height: 20, channels: 3, background: "#abcdef" } }).png().toBuffer(); const image = await sharp({ create: { width, height: 20, channels: 3, background: "#abcdef" } }).png().toBuffer();
const service = rmbgLoader(async (input, options) => { const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { if (options?.method === "POST") {
const form = options.body as FormData; const form = options.body as FormData;
expect(form.has("image_url")).toBe(false); expect(form.has("image_url")).toBe(false);
const file = form.get("image_file") as File; const file = form.get("image_file") as File;
expect((await sharp(Buffer.from(await file.arrayBuffer())).metadata()).width).toBe(Math.min(width, 4096)); const submitted = Buffer.from(await file.arrayBuffer());
expect(submitted.equals(image)).toBe(true);
expect((await sharp(submitted).metadata()).width).toBe(width);
return Response.json({ data: { task_id: "task-file" } }); return Response.json({ data: { task_id: "task-file" } });
} }
if (String(input).endsWith("/task-file")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } }); if (String(input).endsWith("/task-file")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
return new Response(image, { headers: { "content-type": "image/png" } }); return new Response(image, { headers: { "content-type": "image/png" } });
}); });
await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true }); await service.executeRmbgJob({ userId: "test", imageBuffer: image, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true });
} }
}); });
test("a cropped input uses its own bytes instead of the original provenance URL", async () => {
const original = await sharp({ create: { width: 120, height: 80, channels: 3, background: "#abcdef" } }).png().toBuffer();
const cropped = await sharp(original).extract({ left: 20, top: 10, width: 40, height: 20 }).png().toBuffer();
let submitted!: FormData;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { submitted = options.body as FormData; return Response.json({ data: { task_id: "cropped-task" } }); }
if (String(input).endsWith("/cropped-task")) return Response.json({ data: { state: 1, image: "https://output.example/cropped.png" } });
return new Response(cropped, { headers: { "content-type": "image/png" } });
});
await service.executeRmbgJob({ userId: "test", imageBuffer: cropped, sourceImageUrl: `${cdn}/original.png`, skipGallerySave: true });
expect(submitted.has("image_url")).toBe(false);
const bytes = Buffer.from(await (submitted.get("image_file") as File).arrayBuffer());
expect(bytes.equals(cropped)).toBe(true);
expect(await sharp(bytes).metadata()).toMatchObject({ width: 40, height: 20 });
});
test("scale adjustment does not start a new matting job after an unconfirmed result", async () => {
let creates = 0;
const image = await sharp({ create: { width: 64, height: 64, channels: 3, background: "white" } }).png().toBuffer();
const scale = serverLoader({
"@/lib/ai/rmbg/service": { executeRmbgJob: async () => { creates++; throw new Error("AOS查询任务超时,结果尚未确认"); } },
setTimeout: (fn: () => void) => setTimeout(fn, 0),
})("lib/canvas/scale-adjust-product-lock.ts");
await expect(scale.buildScaleAdjustProductLockAssets({ userId: "test", sourceBuffer: image, sourceWidth: 64, sourceHeight: 64, sourceImageUrl: `${cdn}/source.png`, regions: [{ index: 0, rect: { x: 0.1, y: 0.1, width: 0.5, height: 0.5 }, targetRect: { x: 0.2, y: 0.2, width: 0.5, height: 0.5 } }] })).rejects.toThrow("结果尚未确认");
expect(creates).toBe(1);
});
test("scene preprocessing invokes matting once even when its result is unconfirmed or the call is interrupted", async () => {
for (const interrupted of [false, true]) {
let calls = 0, networkCalls = 0;
const scene = serverLoader({
"@/lib/canvas/operations/rmbg": { executeRemoveBackgroundPOST: async (request: Request, userId: string) => {
calls++;
expect(userId).toBe("user-a");
expect(request.headers.get("x-request-id")).toBe("scene-a:pre-rmbg");
expect(await request.json()).toMatchObject({ imageUrl: `${cdn}/source.png`, skipGallerySave: true });
if (interrupted) throw new TypeError("fetch failed");
return Response.json({ error: "抠图已完成,但结果图片下载失败,请核对原任务。", failure: { outcome: "unknown" } }, { status: 500 });
} },
fetch: async () => { networkCalls++; throw new Error("Unexpected HTTP resubmission"); },
})("lib/scene-fusion/preprocess-product.ts");
const result = await scene.preprocessProductImageWithRmbg({ request: new Request("https://example.com/api/scene-fusion"), requestId: "scene-a", userId: "user-a", productImageUrl: `${cdn}/source.png`, preRmbgEnabled: true });
expect(result).toMatchObject({ productImageUrl: `${cdn}/source.png`, usedPreRmbg: false });
expect({ calls, networkCalls }).toEqual({ calls: 1, networkCalls: 0 });
}
});
test("scene preprocessing retains the disabled setting and delivers a successful original result", async () => {
let calls = 0;
const scene = serverLoader({ "@/lib/canvas/operations/rmbg": { executeRemoveBackgroundPOST: async () => { calls++; return Response.json({ original_url: `${cdn}/result.png`, provider: "aoscdn" }); } } })("lib/scene-fusion/preprocess-product.ts");
const params = { request: new Request("https://example.com/api/scene-fusion"), requestId: "scene-a", userId: "user-a", productImageUrl: `${cdn}/source.png` };
expect(await scene.preprocessProductImageWithRmbg({ ...params, preRmbgEnabled: false })).toMatchObject({ usedPreRmbg: false });
expect(calls).toBe(0);
expect(await scene.preprocessProductImageWithRmbg({ ...params, preRmbgEnabled: true })).toMatchObject({ usedPreRmbg: true, productImageUrl: `${cdn}/result.png` });
expect(calls).toBe(1);
});
test("the standalone matting API returns the limit, recommendation and request ID", async () => {
class InputError extends Error { status = 400; }
let submitted: any;
const operation = serverLoader({
"@/lib/ai/rmbg/service": { RmbgServiceError: InputError, fetchImageBufferFromUrl: async () => Buffer.from("original"), executeRmbgJob: async (params: any) => { submitted = params; throw new InputError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。"); } },
"@/lib/db/queries": { getPointsSystemConfig: async () => ({ enabled: false }) },
"@/lib/points/feature-charge": { prepareFeaturePointsCharge: async () => ({ ok: true, chargeable: false }) },
})("lib/canvas/operations/rmbg.ts");
const response = await operation.executeRemoveBackgroundPOST(new Request("https://example.com/api/rmbg", { method: "POST", headers: { "Content-Type": "application/json", "X-Request-ID": "request-a" }, body: JSON.stringify({ imageUrl: `${cdn}/source.png` }) }), "user-a");
const result = await response.json();
expect(response.status).toBe(400);
expect(submitted).toMatchObject({ requestId: "request-a", inputImageUrl: `${cdn}/source.png` });
expect(result).toMatchObject({ success: false, requestId: "request-a", failure: { code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" } });
expect(result.error).toContain("5056×3392");
expect(result.error).toContain("本次未提交上游抠图任务");
expect(result.error).toContain("请求编号:request-a");
});
test("AOS rejects either oversized dimension before creating a task and gives the exact dimensions", async () => {
for (const [width, height] of [[4097, 20], [20, 4097]]) {
const image = await sharp({ create: { width, height, channels: 3, background: "white" } }).png().toBuffer();
let creates = 0;
const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); });
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/large.png`, skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect(creates).toBe(0);
expect(failure).toMatchObject({ status: 400 });
expect(failure?.message).toContain(`${width}×${height}`);
const details = getRequestFailureDetails(failure);
expect(details).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
expect(details.message).toContain("4096");
expect(details.recommendation).toContain("本次未提交上游抠图任务");
}
});
test("AOS accepts 4096 pixels and 50 MB but rejects a larger file without submitting it", async () => {
const output = await sharp({ create: { width: 4096, height: 20, channels: 3, background: "white" } }).png().toBuffer();
const boundary = Buffer.alloc(50 * 1024 * 1024);
output.copy(boundary);
let creates = 0;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") {
creates++;
const form = options.body as FormData;
expect(form.has("image_url")).toBe(false);
expect((form.get("image_file") as File).size).toBe(boundary.length);
return Response.json({ data: { task_id: "boundary-task" } });
}
if (String(input).endsWith("/boundary-task")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
return new Response(output, { headers: { "content-type": "image/png" } });
});
await service.executeRmbgJob({ userId: "test", imageBuffer: boundary, inputImageUrl: `${cdn}/boundary.png`, sourceImageUrl: `${cdn}/boundary.png`, skipGallerySave: true });
expect(creates).toBe(1);
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: Buffer.alloc(boundary.length + 1), skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect(creates).toBe(1);
expect(failure).toMatchObject({ status: 400 });
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_INPUT_TOO_LARGE", outcome: "failed" });
expect(getRequestFailureDetails(failure).message).toContain("50 MB");
});
test("formats unsupported by AOS are rejected explicitly without converting or submitting", async () => {
for (const format of ["gif", "avif"] as const) {
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).toFormat(format).toBuffer();
let creates = 0;
const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); });
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect(creates).toBe(0);
const details = getRequestFailureDetails(failure);
expect(details).toMatchObject({ code: "RMBG_INPUT_FORMAT_UNSUPPORTED", outcome: "failed" });
expect(details.message).toContain(format.toUpperCase());
expect(details.recommendation).toContain("JPG、PNG 或 WebP");
}
});
test("AOS input rejections retain their cause instead of becoming a generic processing failure", () => {
const resolution = getRequestFailureDetails("抠图处理失败:图片分辨率超限(5000x3000),AOS要求宽高都不超过 4096px。请缩小后重试。");
expect(resolution).toMatchObject({ code: "RMBG_PROVIDER_RESOLUTION_EXCEEDED", outcome: "failed" });
expect(resolution.message).toContain("5000×3000");
expect(getRequestFailureDetails("抠图处理失败:图片文件无法识别,请尝试转换为 JPG/PNG 后重试。")).toMatchObject({ code: "RMBG_PROVIDER_INVALID_FILE", outcome: "failed" });
expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 413): Payload Too Large")).toMatchObject({ code: "RMBG_PROVIDER_FILE_TOO_LARGE", outcome: "failed" });
for (const [state, code] of [[-3, "RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED"], [-5, "RMBG_PROVIDER_URL_TOO_LARGE"], [-7, "RMBG_PROVIDER_INVALID_FILE"], [-8, "RMBG_PROVIDER_PROCESSING_TIMEOUT"], [-11, "RMBG_PROVIDER_EMPTY_RESULT"]] as const) {
const details = getRequestFailureDetails(`抠图处理失败:AOS任务失败(状态 ${state},任务 original-task): Failed`);
expect(details).toMatchObject({ code, outcome: "failed" });
expect(details.recommendation).not.toBe("");
}
});
test("the canvas API returns input-limit errors as HTTP 400 and preserves the same advice when polling", async () => {
const service = rmbgLoader(async () => { throw new Error("Unexpected provider call"); });
const error = new service.RmbgServiceError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。", 400);
const route = serverLoader({
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-a" } }) },
"@/lib/ai/rmbg/service": { RmbgServiceError: service.RmbgServiceError },
"@/lib/canvas/capabilities/client-command": { canvasCapabilityClientCommandSchema: { parse: (v: unknown) => v } },
"@/lib/canvas/capabilities/context": { CanvasCapabilityRuntimeError: class extends Error {} },
"@/lib/canvas/agent-mutations": { CanvasAgentMutationError: class extends Error {} },
"@/lib/canvas/capabilities/registry": { getCanvasCapabilityManifest: () => [] },
"@/lib/canvas/capabilities/runtime": { executeCanvasCapabilityRuntime: async () => { throw error; } },
"@/lib/canvas/capabilities/task-store": { getCanvasCapabilityTask: async () => ({ id: "task-a", status: "failed", error: error.message }) },
})("app/api/canvas/[id]/capabilities/route.ts");
const params = { params: Promise.resolve({ id: "project-a" }) };
const response = await route.POST(new Request("https://example.com/api/canvas/project-a/capabilities", { method: "POST", body: JSON.stringify({ requestId: "request-a" }) }), params);
expect(response.status).toBe(400);
const submitted = await response.json();
expect(submitted.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
expect(submitted.error).toContain("5056×3392");
expect(submitted.error).toContain("本次未提交上游抠图任务");
const polled = await (await route.GET(new Request("https://example.com/api/canvas/project-a/capabilities?requestId=request-a"), params)).json();
expect(polled.task.failure).toEqual(submitted.failure);
expect(polled.task.error).toContain(submitted.failure.recommendation);
});
test("a create timeout preserves its cause and never creates another paid task", async () => { test("a create timeout preserves its cause and never creates another paid task", async () => {
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer(); const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
let creates = 0; let creates = 0;
@ -79,4 +341,44 @@ test("poll and result download timeouts retain their original task and distinct
expect(poll).toMatchObject({ code: "RMBG_POLL_TIMEOUT", outcome: "unknown" }); expect(poll).toMatchObject({ code: "RMBG_POLL_TIMEOUT", outcome: "unknown" });
expect(download).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_TIMEOUT", outcome: "unknown" }); expect(download).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_TIMEOUT", outcome: "unknown" });
expect(download.recommendation).toContain("取回原任务结果"); expect(download.recommendation).toContain("取回原任务结果");
expect(getRequestFailureDetails("抠图处理失败:AOS任务查询失败(HTTP 404,任务 task-1): Not Found")).toMatchObject({ code: "RMBG_PROVIDER_POLL_FAILED", outcome: "unknown" });
expect(getRequestFailureDetails("抠图处理失败:AOS结果下载失败(HTTP 500,任务 task-1): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", outcome: "unknown" });
expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 500): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_CREATE_UNCONFIRMED", outcome: "unknown" });
});
test("network and body interruptions retry only the original task reads", async () => {
const image = await sharp({ create: { width: 32, height: 32, channels: 4, background: "#abcdef00" } }).png().toBuffer();
let creates = 0, polls = 0, downloads = 0;
let networkError: Error;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { creates++; return Response.json({ data: { task_id: "original-task" } }); }
if (String(input).endsWith("/original-task")) {
if (++polls === 1) throw networkError;
return Response.json({ data: { state: 1, image: "https://output.example/original.png" } });
}
if (++downloads === 1) return new Response(new ReadableStream({ start(controller) { controller.error(networkError); } }));
return new Response(image, { headers: { "content-type": "image/png" } });
});
networkError = new service.RmbgServiceError("fetch failed"); networkError.name = "TypeError";
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true });
expect(result.original_url).toContain(cdn);
expect({ creates, polls, downloads }).toEqual({ creates: 1, polls: 2, downloads: 2 });
});
test("exhausted result reads preserve the completed task and never re-create it", async () => {
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
let creates = 0, downloads = 0;
let networkError: Error;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { creates++; return Response.json({ data: { task_id: "completed-task" } }); }
if (String(input).endsWith("/completed-task")) return Response.json({ data: { state: 1, image: "https://output.example/original.png" } });
downloads++; throw networkError;
});
networkError = new service.RmbgServiceError("fetch failed"); networkError.name = "TypeError";
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect({ creates, downloads }).toEqual({ creates: 1, downloads: 3 });
expect(failure?.message).toContain("completed-task");
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_CONNECTION_FAILED", outcome: "unknown" });
}); });

View File

@ -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]);
});