Compare commits
No commits in common. "main" and "codex/canvas-read-revision-20261105" have entirely different histories.
main
...
codex/canv
@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
@ -70,6 +69,7 @@ const SCENE_FUSION_REQUEST_STAGGER_MS = 350;
|
||||
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
|
||||
const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
|
||||
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_THRESHOLD = 8;
|
||||
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
|
||||
@ -236,6 +236,41 @@ 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 {
|
||||
try {
|
||||
const parsed = new URL(input);
|
||||
@ -953,6 +988,102 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建缩略图
|
||||
*/
|
||||
@ -2305,13 +2436,12 @@ export async function POST(request: Request) {
|
||||
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
|
||||
}
|
||||
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
|
||||
const preprocessedProduct = await preprocessProductImageWithRmbg({
|
||||
const preprocessedProduct = await preprocessProductImageWithRmbg(
|
||||
request,
|
||||
requestId,
|
||||
userId: session.user.id,
|
||||
productImageUrl,
|
||||
preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled,
|
||||
});
|
||||
shouldForcePreRmbg ? true : config.preRmbgEnabled
|
||||
);
|
||||
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
|
||||
logger.info("pre_rmbg_resolved", {
|
||||
requestId,
|
||||
|
||||
@ -5196,7 +5196,6 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
try {
|
||||
const beforeRead = useCanvasStore.getState();
|
||||
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
@ -5216,6 +5215,7 @@ export default function CanvasEditorLegacyPage({
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeRead = useCanvasStore.getState();
|
||||
const result = await canvasService.getProject(id);
|
||||
const current = useCanvasStore.getState();
|
||||
if (
|
||||
|
||||
@ -918,9 +918,9 @@ export function ImageAnnotator({
|
||||
|
||||
setIsRemovingBg(true);
|
||||
try {
|
||||
const input = /^https?:\/\//i.test(originalImageUrl)
|
||||
? { imageUrl: originalImageUrl }
|
||||
: { image_base64: await imageUrlToBase64(originalImageUrl) };
|
||||
// 使用原图URL获取原图数据,而不是canvas的预览图
|
||||
const base64Data = await imageUrlToBase64(originalImageUrl);
|
||||
logger.debug("rmbg_source_prepared", { base64Length: base64Data.length });
|
||||
|
||||
// 调用抠图API
|
||||
const response = await fetch("/api/rmbg", {
|
||||
@ -929,7 +929,7 @@ export function ImageAnnotator({
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
image_base64: base64Data,
|
||||
prompt: "画图标注-抠图处理", // 可选:添加提示词
|
||||
}),
|
||||
});
|
||||
|
||||
@ -72,7 +72,6 @@ export async function processVideoCanvasImage(
|
||||
const result = await executeRmbgJob({
|
||||
userId: params.userId,
|
||||
imageBuffer: await fetchImageBufferFromUrl(imageUrl),
|
||||
inputImageUrl: imageUrl,
|
||||
sourceImageUrl: imageUrl,
|
||||
prompt: params.prompt,
|
||||
});
|
||||
|
||||
@ -36,8 +36,6 @@ export type ExecuteRmbgJobParams = {
|
||||
chatId?: string | null;
|
||||
messageId?: string | null;
|
||||
skipGallerySave?: boolean;
|
||||
// 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。
|
||||
inputImageUrl?: string | null;
|
||||
sourceImageUrl?: string | null;
|
||||
};
|
||||
|
||||
@ -380,7 +378,7 @@ async function processWithAosSegmentation(
|
||||
foregroundType?: "" | "person" | "object" | "stamp";
|
||||
crop?: 0 | 1;
|
||||
format?: "png" | "jpg";
|
||||
inputImageUrl?: string | null;
|
||||
sourceImageUrl?: string | null;
|
||||
requestId?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
|
||||
@ -395,9 +393,9 @@ async function processWithAosSegmentation(
|
||||
try {
|
||||
const createForm = new FormData();
|
||||
let publicSourceUrl: string | null = null;
|
||||
if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) {
|
||||
if (config.sourceImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) {
|
||||
try {
|
||||
const source = new URL(config.inputImageUrl);
|
||||
const source = new URL(config.sourceImageUrl);
|
||||
const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
|
||||
if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) {
|
||||
publicSourceUrl = source.toString();
|
||||
@ -656,7 +654,7 @@ export async function executeRmbgJob(
|
||||
params: ExecuteRmbgJobParams
|
||||
): Promise<ExecuteRmbgJobResult> {
|
||||
const {
|
||||
requestId = crypto.randomUUID(),
|
||||
requestId,
|
||||
userId,
|
||||
imageBuffer,
|
||||
imageBase64 = imageBuffer.toString("base64"),
|
||||
@ -665,13 +663,11 @@ export async function executeRmbgJob(
|
||||
chatId = null,
|
||||
messageId = null,
|
||||
skipGallerySave = false,
|
||||
inputImageUrl = null,
|
||||
sourceImageUrl = null,
|
||||
} = params;
|
||||
|
||||
const { provider, config } = await getValidatedRmbgConfig();
|
||||
logger.info("job_started", {
|
||||
requestId,
|
||||
hasChatId: Boolean(chatId),
|
||||
hasMessageId: Boolean(messageId),
|
||||
hasSourceImageUrl: Boolean(sourceImageUrl),
|
||||
@ -715,7 +711,7 @@ export async function executeRmbgJob(
|
||||
foregroundType: foregroundType ?? config.aosForegroundType ?? "",
|
||||
crop: config.aosCrop === 1 ? 1 : 0,
|
||||
format: config.aosFormat === "jpg" ? "jpg" : "png",
|
||||
inputImageUrl,
|
||||
sourceImageUrl,
|
||||
requestId,
|
||||
});
|
||||
|
||||
@ -853,7 +849,6 @@ export async function executeRmbgJob(
|
||||
}
|
||||
|
||||
logger.info("job_completed", {
|
||||
requestId,
|
||||
generatedImageId: savedImage.id,
|
||||
mediaAssetId,
|
||||
processingSeconds: Number(processingTime.toFixed(2)),
|
||||
@ -876,7 +871,6 @@ export async function executeRmbgJob(
|
||||
}
|
||||
|
||||
logger.info("job_completed", {
|
||||
requestId,
|
||||
processingSeconds: Number(processingTime.toFixed(2)),
|
||||
provider,
|
||||
savedToGallery: false,
|
||||
|
||||
@ -516,7 +516,6 @@ export async function executeDirectAgentImageEditTool(
|
||||
const result = await executeRmbgJob({
|
||||
userId: params.userId,
|
||||
imageBuffer: sourceBuffer,
|
||||
inputImageUrl: params.sourceImageUrl,
|
||||
prompt: params.requestTextContent || "抠图处理",
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
|
||||
@ -131,7 +131,6 @@ async function executeForTarget(
|
||||
requestId: command.requestId,
|
||||
userId: params.userId,
|
||||
imageBuffer,
|
||||
inputImageUrl: target.sourceImageUrl,
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
prompt: prompt || "抠图处理",
|
||||
skipGallerySave: false,
|
||||
|
||||
@ -9,11 +9,9 @@ import {
|
||||
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";
|
||||
|
||||
|
||||
export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) {
|
||||
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
|
||||
try {
|
||||
const session = { user: { id: trustedUserId } };
|
||||
if (!session?.user?.id) {
|
||||
@ -29,7 +27,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
let messageId: string | null = null;
|
||||
let skipGallerySave = false;
|
||||
let sourceImageUrl: string | null = null;
|
||||
let inputImageUrl: string | null = null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
@ -43,7 +40,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
|
||||
if (imageUrl) {
|
||||
sourceImageUrl = String(imageUrl);
|
||||
inputImageUrl = sourceImageUrl;
|
||||
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
|
||||
} else if (body.image_base64) {
|
||||
imageBase64 = String(body.image_base64);
|
||||
@ -98,7 +94,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
}
|
||||
|
||||
const result = await executeRmbgJob({
|
||||
requestId,
|
||||
userId: session.user.id,
|
||||
imageBuffer,
|
||||
imageBase64,
|
||||
@ -106,7 +101,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
chatId,
|
||||
messageId,
|
||||
skipGallerySave,
|
||||
inputImageUrl,
|
||||
sourceImageUrl,
|
||||
});
|
||||
|
||||
@ -120,29 +114,22 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requestId,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RmbgServiceError) {
|
||||
const failure = getRequestFailureDetails(error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: formatRequestFailure(failure, requestId),
|
||||
error: error.message,
|
||||
detail: error.detail || error.message,
|
||||
code: failure.code,
|
||||
failure,
|
||||
requestId,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[RMBG API] 处理错误:", error);
|
||||
const failure = getRequestFailureDetails(error, "抠图请求未能完成");
|
||||
return NextResponse.json(
|
||||
{ success: false, error: formatRequestFailure(failure, requestId), detail: error instanceof Error ? error.message : "未知错误", code: failure.code, failure, requestId },
|
||||
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,6 +40,22 @@ function clamp(value: number, min: number, max: number) {
|
||||
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(
|
||||
rect: CanvasScaleAdjustRect,
|
||||
sourceWidth: number,
|
||||
@ -204,7 +220,7 @@ async function buildPersonExclusionAlpha(params: {
|
||||
userId: string;
|
||||
}) {
|
||||
try {
|
||||
const person = await executeRmbgJob({
|
||||
const person = await executeScaleAdjustRmbgJob({
|
||||
foregroundType: "person",
|
||||
imageBuffer: params.sourceBuffer,
|
||||
prompt: "比例调整人物保护蒙版",
|
||||
@ -314,7 +330,7 @@ export async function buildScaleAdjustProductLockAssets(params: {
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
semanticSourceCropBuffers.push(cropBuffer);
|
||||
const subject = await executeRmbgJob({
|
||||
const subject = await executeScaleAdjustRmbgJob({
|
||||
imageBuffer: cropBuffer,
|
||||
prompt: "比例调整完整产品蒙版",
|
||||
skipGallerySave: true,
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@ -236,6 +236,8 @@ test.describe("canvas scale adjust product lock", () => {
|
||||
const rmbg = readSource("lib/ai/rmbg/service.ts");
|
||||
|
||||
expect(productLock).toContain('foregroundType: "person"');
|
||||
expect(productLock).toContain("attempt <= 3");
|
||||
expect(productLock).toContain("executeScaleAdjustRmbgJob({");
|
||||
expect(productLock).toContain("regionPersonExclusionAlpha");
|
||||
expect(productLock).toContain("overlapPixelCount >= overlapLimit");
|
||||
expect(productLock).toContain("目标产品被人物遮挡");
|
||||
|
||||
@ -33,7 +33,7 @@ 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" } });
|
||||
return new Response(image, { headers: { "content-type": "image/png" } });
|
||||
});
|
||||
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true });
|
||||
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true });
|
||||
expect(creates).toBe(1);
|
||||
expect(result.provider).toBe("aoscdn");
|
||||
expect(result.original_url).toContain(cdn);
|
||||
@ -55,85 +55,10 @@ test("non-CDN sources upload the original file without changing its bytes or dim
|
||||
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" } });
|
||||
});
|
||||
await service.executeRmbgJob({ userId: "test", imageBuffer: image, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true });
|
||||
await service.executeRmbgJob({ userId: "test", imageBuffer: image, 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();
|
||||
@ -168,7 +93,7 @@ test("AOS accepts 4096 pixels and 50 MB but rejects a larger file without submit
|
||||
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 });
|
||||
await service.executeRmbgJob({ userId: "test", imageBuffer: boundary, 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 }); }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user