feat: add box cutout flow for canvas images
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-07 09:45:52 +08:00
parent 5f5d1b5850
commit 055652cc91
7 changed files with 1594 additions and 10 deletions

View File

@ -0,0 +1,220 @@
import { NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
import { auth } from "@/app/(auth)/auth";
export const runtime = "nodejs";
export const maxDuration = 60;
type NormalizedCropRect = {
x: number;
y: number;
width: number;
height: number;
};
type BoxCutoutRequest = {
imageUrl?: string;
cropRect?: Partial<NormalizedCropRect>;
paddingRatio?: number;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
sourceNodeId?: string;
};
const MIN_CROP_EDGE_PX = 24;
const DEFAULT_PADDING_RATIO = 0.08;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function sanitizeNormalizedCropRect(input: Partial<NormalizedCropRect> | undefined): NormalizedCropRect | null {
if (!input) return null;
const x = Number(input.x);
const y = Number(input.y);
const width = Number(input.width);
const height = Number(input.height);
if (![x, y, width, height].every((value) => Number.isFinite(value))) {
return null;
}
const normalized: NormalizedCropRect = {
x: clamp(x, 0, 1),
y: clamp(y, 0, 1),
width: clamp(width, 0, 1),
height: clamp(height, 0, 1),
};
if (normalized.width <= 0 || normalized.height <= 0) {
return null;
}
normalized.width = Math.min(normalized.width, 1 - normalized.x);
normalized.height = Math.min(normalized.height, 1 - normalized.y);
if (normalized.width <= 0 || normalized.height <= 0) {
return null;
}
return normalized;
}
function normalizePaddingRatio(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_PADDING_RATIO;
return clamp(numeric, 0, 0.25);
}
function toPixelRect(
rect: NormalizedCropRect,
sourceWidth: number,
sourceHeight: number
) {
const left = clamp(Math.round(rect.x * sourceWidth), 0, Math.max(sourceWidth - 1, 0));
const top = clamp(Math.round(rect.y * sourceHeight), 0, Math.max(sourceHeight - 1, 0));
const width = clamp(Math.round(rect.width * sourceWidth), 1, sourceWidth - left);
const height = clamp(Math.round(rect.height * sourceHeight), 1, sourceHeight - top);
return { left, top, width, height };
}
function applyPadding(
rect: NormalizedCropRect,
paddingRatio: number
): NormalizedCropRect {
const padX = rect.width * paddingRatio;
const padY = rect.height * paddingRatio;
const x = clamp(rect.x - padX, 0, 1);
const y = clamp(rect.y - padY, 0, 1);
const right = clamp(rect.x + rect.width + padX, 0, 1);
const bottom = clamp(rect.y + rect.height + padY, 0, 1);
return {
x,
y,
width: clamp(right - x, 0, 1),
height: clamp(bottom - y, 0, 1),
};
}
async function fetchImageBuffer(imageUrl: string) {
const response = await fetch(imageUrl, { cache: "no-store" });
if (!response.ok) {
throw new Error(`无法获取图片: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
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();
const cropRect = sanitizeNormalizedCropRect(body.cropRect);
if (!rawImageUrl) {
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
}
if (!cropRect) {
return NextResponse.json({ success: false, error: "缺少有效的 cropRect 参数" }, { status: 400 });
}
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
const paddingRatio = normalizePaddingRatio(body.paddingRatio);
const sourceBuffer = await sharp(await fetchImageBuffer(resolvedImageUrl), { failOn: "none" })
.rotate()
.toBuffer();
const sourceImage = sharp(sourceBuffer, { failOn: "none" });
const sourceMetadata = await sourceImage.metadata();
const sourceWidth = Number(sourceMetadata.width || 0);
const sourceHeight = Number(sourceMetadata.height || 0);
if (sourceWidth <= 0 || sourceHeight <= 0) {
return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 });
}
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
return NextResponse.json(
{ success: false, error: "框选区域过小,请重新框选" },
{ status: 400 }
);
}
const paddedCropRect = applyPadding(cropRect, paddingRatio);
const paddedCropRectPixels = toPixelRect(paddedCropRect, sourceWidth, sourceHeight);
const croppedBuffer = await sourceImage
.extract({
left: paddedCropRectPixels.left,
top: paddedCropRectPixels.top,
width: paddedCropRectPixels.width,
height: paddedCropRectPixels.height,
})
.toBuffer();
const croppedBase64 = croppedBuffer.toString("base64");
const rmbgUrl = new URL("/api/rmbg", request.url);
const rmbgResponse = await fetch(rmbgUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: request.headers.get("cookie") || "",
...(request.headers.get("authorization")
? { authorization: request.headers.get("authorization") as string }
: {}),
},
cache: "no-store",
body: JSON.stringify({
image_base64: croppedBase64,
sourceImageUrl: resolvedImageUrl,
prompt: body.prompt || "框选抠图",
chatId: body.chatId || null,
messageId: body.messageId || null,
skipGallerySave: body.skipGallerySave === true,
}),
});
const rmbgResult = await rmbgResponse.json().catch(() => null);
if (!rmbgResponse.ok || !rmbgResult?.success) {
return NextResponse.json(
{
success: false,
error: rmbgResult?.error || rmbgResult?.detail || "框选抠图失败",
},
{ status: rmbgResponse.status || 500 }
);
}
return NextResponse.json({
...rmbgResult,
cropRectApplied: cropRect,
paddedCropRectApplied: paddedCropRect,
cropRectPixels,
paddedCropRectPixels,
sourceNodeId: body.sourceNodeId || null,
});
} catch (error) {
console.error("[BoxCutout] 处理失败:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "框选抠图失败",
},
{ status: 500 }
);
}
}

View File

@ -516,6 +516,7 @@ export async function POST(request: NextRequest) {
messageId = body.messageId || null;
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
const imageUrl = body.imageUrl || body.image_url;
const explicitSourceImageUrl = body.sourceImageUrl || body.source_image_url;
if (imageUrl) {
sourceImageUrl = String(imageUrl);
@ -542,6 +543,9 @@ export async function POST(request: NextRequest) {
} else if (body.image_base64) {
imageBase64 = body.image_base64;
imageBuffer = Buffer.from(imageBase64, "base64");
if (explicitSourceImageUrl) {
sourceImageUrl = String(explicitSourceImageUrl);
}
} else {
return NextResponse.json(
{ error: "缺少 imageUrl 或 image_base64 参数" },

View File

@ -2,7 +2,7 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote } from "lucide-react";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote, Scan } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -25,7 +25,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/comp
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { imageGenerationService } from "@/services/canvas";
import type { CanvasTextEditPayloadItem } from "@/services/canvas";
import type { CanvasTextEditPayloadItem, NormalizedCropRect } from "@/services/canvas";
import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas";
import { cn } from "@/lib/utils";
import { inferOriginalImageUrl } from "@/lib/image-url";
@ -44,6 +44,43 @@ interface PickedMarker {
index: number;
}
interface ImageDisplayMetrics {
offsetX: number;
offsetY: number;
renderedWidth: number;
renderedHeight: number;
naturalWidth: number;
naturalHeight: number;
}
interface BoxCutoutPoint {
x: number;
y: number;
}
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
const BOX_CUTOUT_MIN_EDGE_PX = 24;
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const createNormalizedRect = (
start: BoxCutoutPoint,
end: BoxCutoutPoint
): NormalizedCropRect => {
const x = Math.min(start.x, end.x);
const y = Math.min(start.y, end.y);
const right = Math.max(start.x, end.x);
const bottom = Math.max(start.y, end.y);
return {
x,
y,
width: clampNumber(right - x, 0, 1),
height: clampNumber(bottom - y, 0, 1),
};
};
const normalizeTextEditItems = (items: ImageTextEditItem[] | undefined): ImageTextEditItem[] => {
if (!items || items.length === 0) return [];
@ -287,10 +324,17 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [isCompareMode, setIsCompareMode] = useState(false);
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
const [boxCutoutMode, setBoxCutoutMode] = useState(false);
const [isBoxCutoutDrawing, setIsBoxCutoutDrawing] = useState(false);
const [boxCutoutSelection, setBoxCutoutSelection] = useState<NormalizedCropRect | null>(null);
const [boxCutoutDragStart, setBoxCutoutDragStart] = useState<BoxCutoutPoint | null>(null);
const [boxCutoutDragEnd, setBoxCutoutDragEnd] = useState<BoxCutoutPoint | null>(null);
const [boxCutoutMetrics, setBoxCutoutMetrics] = useState<ImageDisplayMetrics | null>(null);
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const displayImageRef = useRef<HTMLImageElement>(null);
const compareFrameRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
@ -416,6 +460,120 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
if (!source) return "";
return `${source.width} x ${source.height}`;
}, [loadedResolution, metadataResolution]);
const boxCutoutDraftRect = useMemo(() => {
if (!boxCutoutMode || !boxCutoutDragStart || !boxCutoutDragEnd) {
return null;
}
return createNormalizedRect(boxCutoutDragStart, boxCutoutDragEnd);
}, [boxCutoutDragEnd, boxCutoutDragStart, boxCutoutMode]);
const activeBoxCutoutRect = boxCutoutSelection || boxCutoutDraftRect;
const activeBoxCutoutRectStyle = useMemo(() => {
if (!boxCutoutMetrics || !activeBoxCutoutRect) return null;
return {
left: boxCutoutMetrics.offsetX + activeBoxCutoutRect.x * boxCutoutMetrics.renderedWidth,
top: boxCutoutMetrics.offsetY + activeBoxCutoutRect.y * boxCutoutMetrics.renderedHeight,
width: activeBoxCutoutRect.width * boxCutoutMetrics.renderedWidth,
height: activeBoxCutoutRect.height * boxCutoutMetrics.renderedHeight,
};
}, [activeBoxCutoutRect, boxCutoutMetrics]);
const resetBoxCutoutState = useCallback(() => {
setBoxCutoutMode(false);
setIsBoxCutoutDrawing(false);
setBoxCutoutSelection(null);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
}, []);
const refreshBoxCutoutMetrics = useCallback(() => {
const imageElement = displayImageRef.current;
if (!imageElement) {
setBoxCutoutMetrics(null);
return;
}
const naturalWidth = imageElement.naturalWidth || metadataResolution?.width || loadedResolution?.width || 0;
const naturalHeight = imageElement.naturalHeight || metadataResolution?.height || loadedResolution?.height || 0;
const boxWidth = imageElement.clientWidth;
const boxHeight = imageElement.clientHeight;
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
setBoxCutoutMetrics(null);
return;
}
const displayScale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight);
const renderedWidth = naturalWidth * displayScale;
const renderedHeight = naturalHeight * displayScale;
setBoxCutoutMetrics({
offsetX: (boxWidth - renderedWidth) / 2,
offsetY: (boxHeight - renderedHeight) / 2,
renderedWidth,
renderedHeight,
naturalWidth,
naturalHeight,
});
}, [loadedResolution, metadataResolution]);
const getBoxCutoutPointerPoint = useCallback(
(clientX: number, clientY: number) => {
const imageElement = displayImageRef.current;
if (!imageElement) return null;
const currentMetrics = (() => {
if (
boxCutoutMetrics &&
boxCutoutMetrics.renderedWidth > 0 &&
boxCutoutMetrics.renderedHeight > 0
) {
return boxCutoutMetrics;
}
const naturalWidth = imageElement.naturalWidth || metadataResolution?.width || loadedResolution?.width || 0;
const naturalHeight = imageElement.naturalHeight || metadataResolution?.height || loadedResolution?.height || 0;
const boxWidth = imageElement.clientWidth;
const boxHeight = imageElement.clientHeight;
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
return null;
}
const displayScale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight);
return {
offsetX: (boxWidth - naturalWidth * displayScale) / 2,
offsetY: (boxHeight - naturalHeight * displayScale) / 2,
renderedWidth: naturalWidth * displayScale,
renderedHeight: naturalHeight * displayScale,
naturalWidth,
naturalHeight,
} satisfies ImageDisplayMetrics;
})();
if (!currentMetrics) return null;
const rect = imageElement.getBoundingClientRect();
const rawX = clientX - rect.left - currentMetrics.offsetX;
const rawY = clientY - rect.top - currentMetrics.offsetY;
const clampedX = clampNumber(rawX, 0, currentMetrics.renderedWidth);
const clampedY = clampNumber(rawY, 0, currentMetrics.renderedHeight);
return {
point: {
x: currentMetrics.renderedWidth > 0 ? clampedX / currentMetrics.renderedWidth : 0,
y: currentMetrics.renderedHeight > 0 ? clampedY / currentMetrics.renderedHeight : 0,
} satisfies BoxCutoutPoint,
insideImage:
rawX >= 0 &&
rawX <= currentMetrics.renderedWidth &&
rawY >= 0 &&
rawY <= currentMetrics.renderedHeight,
metrics: currentMetrics,
};
},
[boxCutoutMetrics, loadedResolution, metadataResolution]
);
// 退出拾取模式时清空状态
useEffect(() => {
@ -437,8 +595,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
setTextEditPanelOpen(false);
setIsEditingTitle(false);
setPromptDialogOpen(false);
resetBoxCutoutState();
}
}, [isSelected]);
}, [isSelected, resetBoxCutoutState]);
useEffect(() => {
if (!hasCompareSource || isThisNodePicking) {
@ -446,6 +605,36 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}
}, [hasCompareSource, isThisNodePicking]);
useEffect(() => {
if ((isThisNodePicking || isCompareMode || sceneFusionDialogOpen || promptDialogOpen || notePanelOpen) && boxCutoutMode) {
resetBoxCutoutState();
}
}, [
boxCutoutMode,
isCompareMode,
isThisNodePicking,
notePanelOpen,
promptDialogOpen,
resetBoxCutoutState,
sceneFusionDialogOpen,
]);
useEffect(() => {
if (!boxCutoutMode || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
resetBoxCutoutState();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [boxCutoutMode, resetBoxCutoutState]);
useEffect(() => {
setLoadedResolution(null);
if (!isSelected || !data.src || metadataResolution) return;
@ -472,6 +661,41 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
};
}, [data.src, isSelected, metadataResolution]);
useEffect(() => {
if (typeof window === "undefined") return;
let rafId: number | null = null;
const scheduleRefresh = () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
};
scheduleRefresh();
window.addEventListener("resize", scheduleRefresh);
const resizeObserver =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleRefresh) : null;
if (containerRef.current) {
resizeObserver?.observe(containerRef.current);
}
if (displayImageRef.current) {
resizeObserver?.observe(displayImageRef.current);
}
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
window.removeEventListener("resize", scheduleRefresh);
resizeObserver?.disconnect();
};
}, [data.src, refreshBoxCutoutMetrics]);
const updateCompareSplitByClientX = useCallback((clientX: number) => {
const frame = compareFrameRef.current;
if (!frame) return;
@ -633,6 +857,243 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const handleCancelPicking = useCallback(() => {
setPickerMode(false);
}, [setPickerMode]);
const handleEnterBoxCutoutMode = useCallback(
(event?: React.MouseEvent | React.PointerEvent) => {
event?.stopPropagation();
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
if (isThisNodePicking) {
setPickerMode(false);
}
setIsCompareMode(false);
setBoxCutoutSelection(null);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
setIsBoxCutoutDrawing(false);
setBoxCutoutMode(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
},
[data.src, isProcessing, isThisNodePicking, refreshBoxCutoutMetrics, setPickerMode]
);
const handleBoxCutoutPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode || isProcessing) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(event.clientX, event.clientY);
if (!pointerResult?.insideImage) {
return;
}
setBoxCutoutSelection(null);
setBoxCutoutDragStart(pointerResult.point);
setBoxCutoutDragEnd(pointerResult.point);
setIsBoxCutoutDrawing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[boxCutoutMode, getBoxCutoutPointerPoint, isProcessing]
);
const handleBoxCutoutPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode || !isBoxCutoutDrawing) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(event.clientX, event.clientY);
if (!pointerResult) return;
setBoxCutoutDragEnd(pointerResult.point);
},
[boxCutoutMode, getBoxCutoutPointerPoint, isBoxCutoutDrawing]
);
const finalizeBoxCutoutSelection = useCallback(
(clientX: number, clientY: number) => {
if (!boxCutoutMode || !boxCutoutDragStart) {
setIsBoxCutoutDrawing(false);
return;
}
const pointerResult = getBoxCutoutPointerPoint(clientX, clientY);
if (!pointerResult) {
setIsBoxCutoutDrawing(false);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
return;
}
const nextRect = createNormalizedRect(boxCutoutDragStart, pointerResult.point);
const nextWidthPx = nextRect.width * pointerResult.metrics.naturalWidth;
const nextHeightPx = nextRect.height * pointerResult.metrics.naturalHeight;
if (nextWidthPx >= BOX_CUTOUT_MIN_EDGE_PX && nextHeightPx >= BOX_CUTOUT_MIN_EDGE_PX) {
setBoxCutoutSelection(nextRect);
} else {
setBoxCutoutSelection(null);
}
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
setIsBoxCutoutDrawing(false);
},
[boxCutoutDragStart, boxCutoutMode, getBoxCutoutPointerPoint]
);
const handleBoxCutoutPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode) return;
event.preventDefault();
event.stopPropagation();
finalizeBoxCutoutSelection(event.clientX, event.clientY);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[boxCutoutMode, finalizeBoxCutoutSelection]
);
const handleBoxCutoutPointerCancel = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode) return;
event.preventDefault();
event.stopPropagation();
setIsBoxCutoutDrawing(false);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[boxCutoutMode]
);
const handleConfirmBoxCutout = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!boxCutoutSelection) {
toast.info("请先框选要抠出的元素");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessing(true);
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 {
const result = await imageGenerationService.boxCutout({
imageUrl: data.src,
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
sourceNodeId: node.id,
});
if (result.success && result.data?.url) {
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}失败,请重试`);
} finally {
setIsProcessing(false);
resetBoxCutoutState();
}
}, [
addNode,
boxCutoutSelection,
data.src,
displayTitle,
isProcessing,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetBoxCutoutState,
updateNode,
]);
const animateAddToChat = useCallback(async () => {
if (!containerRef.current || !data.src || typeof window === "undefined") {
return false;
@ -1310,6 +1771,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
'text-edit': '改字',
'note': '备注',
'prompt-regenerate': '重绘',
'box-cutout': '框选抠图',
};
return names[action] || action;
};
@ -1338,6 +1800,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
setResizeAnnotatorOpen(true);
return;
}
if (action === "box-cutout") {
handleEnterBoxCutoutMode();
return;
}
if (action === 'annotate') {
setAnnotatorOpen(true);
return;
@ -1897,29 +2363,97 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</div>
) : (
<img
ref={displayImageRef}
src={displayImageSrc}
alt={displayTitle}
className={cn(
"w-full h-full object-contain bg-muted/30",
isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
boxCutoutMode ? "cursor-crosshair" : isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
)}
draggable={false}
loading="lazy"
decoding="async"
onLoad={refreshBoxCutoutMetrics}
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
onClick={isThisNodePicking ? handlePickerClick : undefined}
onClick={isThisNodePicking && !boxCutoutMode ? handlePickerClick : undefined}
onDoubleClick={(e) => {
if (!isThisNodePicking) {
if (!isThisNodePicking && !boxCutoutMode) {
e.stopPropagation();
setFullscreenPreviewOpen(true);
}
}}
/>
)}
{boxCutoutMode && !isThisNodePicking && !isCompareMode && (
<>
<div
className="absolute inset-0 z-[140] cursor-crosshair"
data-canvas-no-drag="true"
onPointerDown={handleBoxCutoutPointerDown}
onPointerMove={handleBoxCutoutPointerMove}
onPointerUp={handleBoxCutoutPointerUp}
onPointerCancel={handleBoxCutoutPointerCancel}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
/>
{activeBoxCutoutRectStyle ? (
<div
className="pointer-events-none absolute z-[145] border-2 border-[#27a8ff] bg-transparent shadow-[0_0_0_9999px_rgba(15,23,42,0.35)]"
style={{
left: activeBoxCutoutRectStyle.left,
top: activeBoxCutoutRectStyle.top,
width: Math.max(activeBoxCutoutRectStyle.width, 1),
height: Math.max(activeBoxCutoutRectStyle.height, 1),
}}
/>
) : (
<div className="pointer-events-none absolute inset-0 z-[141] bg-slate-950/18" />
)}
<div className="pointer-events-none absolute left-2 top-2 z-[150] rounded-md bg-slate-950/74 px-2 py-1 text-[11px] text-white shadow">
{boxCutoutSelection
? "已框选,可确认抠图"
: isBoxCutoutDrawing
? "松开完成框选"
: "拖拽框选要抠出的元素"}
</div>
<div
className="absolute bottom-2 left-1/2 z-[150] flex -translate-x-1/2 items-center gap-2 rounded-full bg-white px-3 py-1.5 shadow-lg"
data-canvas-no-drag="true"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
}}
>
<Scan className="h-4 w-4 text-[#27a8ff]" />
<span className="text-xs font-medium text-slate-700">
{boxCutoutSelection ? "确认后将仅抠出框选区域" : "在图片中拖拽矩形框"}
</span>
{boxCutoutSelection ? (
<Button size="sm" className="h-7 rounded-full px-3" onClick={() => void handleConfirmBoxCutout()}>
<Check className="mr-1 h-3.5 w-3.5" />
确认抠图
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
onClick={() => resetBoxCutoutState()}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</>
)}
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
{isThisNodePicking && currentClick && (
@ -1956,7 +2490,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</div>
)}
{hasCompareSource && !isThisNodePicking && !isCompareMode && isSelected && !isMultiSelected && (
{hasCompareSource && !isThisNodePicking && !isCompareMode && !boxCutoutMode && isSelected && !isMultiSelected && (
<button
type="button"
className="pointer-events-auto absolute top-1/2 z-[220] inline-flex -translate-y-1/2 items-center justify-center rounded-full text-white shadow-[0_8px_16px_rgba(0,0,0,0.22)] transition-all"
@ -2194,7 +2728,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{isSelected && !isMultiSelected && data.src && !isEditingTitle && !sceneFusionDialogOpen && !promptDialogOpen && !notePanelOpen && typeof document !== 'undefined' && createPortal(
{isSelected && !isMultiSelected && data.src && !isEditingTitle && !sceneFusionDialogOpen && !promptDialogOpen && !notePanelOpen && !boxCutoutMode && typeof document !== 'undefined' && createPortal(
<TooltipProvider delayDuration={200}>
<div
ref={toolbarRef}
@ -2249,6 +2783,16 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
抠图
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={(event) => handleEnterBoxCutoutMode(event)}
>
<Scan className="mr-1.5 h-4 w-4" />
框选抠图
</Button>
<Button
variant="ghost"
disabled={isProcessing}

View File

@ -0,0 +1,773 @@
# 无限画布「框选抠图」功能开发方案
## 1. 功能目标
在无限画布中,用户选中一张图片后,工具栏新增一个按钮:`框选抠图`。
用户点击后,可以直接在当前选中的图片内部拖拽一个矩形框,框出其中某个元素。系统随后需要:
1. 根据框选区域,从原图中裁出该区域;
2. 对裁切后的局部图调用现有抠图能力;
3. 将抠图结果作为一张新的图片节点回写到当前画布;
4. 原图不被覆盖,结果图与原图建立来源关联。
---
## 2. 现状评估
### 2.1 当前已有能力
项目里已经有几块能力可直接复用:
- `components/canvas/nodes/ImageNode.tsx`
- 已有选中图片后的浮动工具栏;
- 已有图片处理动作分发逻辑:`抠图 / 去水印 / 高清增强 / 扩图 / 视角生成 / 重绘 / 备注 / 尺寸调整`;
- 已有“处理中新建节点 -> 处理成功更新节点”的结果回写模式。
- `services/canvas/index.ts`
- 已封装 `removeBackground(imageUrl)`,当前直接调 `/api/rmbg`。
- `app/api/rmbg/route.ts`
- 已支持两种入参:
- `imageUrl / image_url`
- `image_base64`
- 当前服务端收到 `imageUrl` 后,会在后端下载原图,再进入抠图流程。
- `components/canvas/ObjectPickerOverlay.tsx` 与 `ImageNode.tsx`
- 已有图片内点击坐标换算经验;
- 已有“在图片内部交互但不影响整个画布”的模式基础。
### 2.2 当前最适合复用的链路
从现有实现看,最合适的方式不是前端把用户框中的图裁出来再转成 base64 上传,而是:
- 前端只负责:
- 进入框选模式;
- 记录用户框选区域;
- 把框选区域以**归一化矩形坐标**发给后端。
- 后端负责:
- 拉取原图;
- 按框选区域精确裁切;
- 调用现有抠图流程;
- 保存结果并返回 URL。
这是当前架构下最稳妥的方案。
---
## 3. 为什么不建议前端直接裁切再上传
不建议走“前端裁切 -> base64 -> `/api/rmbg`”的主路径,原因有四个:
### 3.1 会重复下载和重复编码
浏览器先下载一遍图片,裁完再转 base64 上传;服务端再解码处理。链路更长,耗时更高。
### 3.2 会引入跨域和画布污染问题
图片如果来自 OSS、CDN、第三方 URL,浏览器 canvas 很容易遇到 tainted canvas 风险。
### 3.3 会损失原图精度
前端通常拿到的是展示图、代理图或压缩图,不一定是原始大图。
框选抠图最好基于原图像素做裁切,不应基于展示层尺寸。
### 3.4 会增加前端内存压力
大图在浏览器中裁切、转 base64、传输,容易造成内存抖动和卡顿,尤其在无限画布场景里更明显。
### 结论
**最佳实践是:前端只发框选坐标,后端基于原图裁切后再调用现有抠图能力。**
---
## 4. 推荐实现方案
## 4.1 方案概述
推荐新增一条专门的服务端编排接口,例如:
- `POST /api/canvas/box-cutout`
它负责做三件事:
1. 拉原图并按框选区域裁切;
2. 调用现有抠图能力;
3. 返回抠图结果并记录来源关系。
前端 `ImageNode` 新增 `框选抠图` 按钮后,调用该接口即可。
---
## 4.2 前端交互方案
### 交互入口
在选中图片后的浮动工具栏中,在 `抠图` 附近增加一个新按钮:
- 文案:`框选抠图`
建议位置:
- 放在 `抠图` 右侧;
- 属于“局部编辑类”动作,和整图 `抠图` 相邻最符合用户心智。
### 推荐交互流程
1. 用户选中一张图片;
2. 点击 `框选抠图`;
3. 图片节点进入“框选模式”;
4. 鼠标在图片内部拖拽出矩形框;
5. 松手后显示:
- 框选框;
- `确认抠图`;
- `取消`;
6. 点击确认后进入处理态;
7. 右侧新增一张“处理中”节点;
8. 成功后替换为抠图结果;
9. 自动退出框选模式。
### 交互细节建议
- 支持 `Esc` 退出框选模式;
- 支持重新拖拽覆盖上一次框选;
- 框选时,画布拖拽、节点拖拽、缩放手柄全部临时禁用;
- 框选区域外增加半透明遮罩,强调当前选择区域;
- 框选框最小尺寸限制,例如 `24 x 24` 像素对应的归一化尺寸;
- 加一个 `padding` 默认策略,避免用户框得太紧导致元素边缘被切掉。
---
## 4.3 前端状态设计
不建议一开始把这个功能做成全局 store 模式。
### MVP 建议
先在 `components/canvas/nodes/ImageNode.tsx` 本地状态实现:
- `boxCutoutMode: boolean`
- `boxCutoutDraftRect: { x1: number; y1: number; x2: number; y2: number } | null`
- `boxCutoutRect: { x: number; y: number; width: number; height: number } | null`
- `boxCutoutLoading: boolean`
原因:
- 该交互只作用于当前被选中的单张图片;
- 不需要全局共享;
- 本地状态更简单,落地更快;
- 避免和现有 `isPickerMode` 语义混淆。
### 为什么不建议直接复用 `isPickerMode`
当前 `isPickerMode` 的语义是“点击识物”,核心是单点坐标。
`框选抠图` 的语义是“矩形区域编辑”,交互模式完全不同。
如果强行复用,会带来:
- store 语义混乱;
- 后续维护困难;
- 同一节点内两种交互模式耦合。
### 结论
**MVP 用 `ImageNode` 本地状态独立实现更合理。**
---
## 4.4 坐标计算方式
这是这个功能里最关键的技术点之一。
当前图片节点使用的是 `object-contain` 展示,因此用户拖拽的矩形不能直接按节点容器宽高换算,必须按**图片真实显示区域**换算。
### 正确计算流程
假设:
- 容器尺寸:`containerWidth / containerHeight`
- 原图尺寸:`imageWidth / imageHeight`
则:
1. 计算展示缩放比:
- `displayScale = min(containerWidth / imageWidth, containerHeight / imageHeight)`
2. 计算图片真实显示尺寸:
- `renderedWidth = imageWidth * displayScale`
- `renderedHeight = imageHeight * displayScale`
3. 计算图片在容器中的偏移:
- `offsetX = (containerWidth - renderedWidth) / 2`
- `offsetY = (containerHeight - renderedHeight) / 2`
4. 鼠标坐标减去偏移,再除以展示尺寸,得到归一化坐标:
- `normalizedX = (pointerX - offsetX) / renderedWidth`
- `normalizedY = (pointerY - offsetY) / renderedHeight`
5. 最后 clamp 到 `0 ~ 1`
### 推荐传给后端的数据格式
```ts
{
x: 0.12,
y: 0.23,
width: 0.31,
height: 0.28
}
```
全部使用相对坐标,后端再根据原图真实像素做裁切。
这样:
- 与前端展示尺寸无关;
- 与当前画布缩放倍率无关;
- 与节点尺寸变化无关;
- 精度最稳定。
---
## 5. 后端实现方案
## 5.1 推荐新增独立接口
### 推荐路由
- `POST /api/canvas/box-cutout`
### 不建议直接把逻辑塞进 `/api/rmbg`
虽然也可以给 `/api/rmbg` 增加 `cropRect` 参数,但不建议第一版就这么做。
原因:
- `/api/rmbg` 现在职责单一:整图抠图;
- `框选抠图` 实际是“裁切 + 抠图”的编排能力;
- 单独接口更清晰,也更利于后续扩展成“框选扩图 / 框选去水印 / 框选重绘”。
### 更好的工程化方式
从 `app/api/rmbg/route.ts` 中抽出共享逻辑到类似:
- `lib/ai/rmbg/service.ts`
供这两个入口共同复用:
- `/api/rmbg`
- `/api/canvas/box-cutout`
这样可以避免复制 provider、存图、相册落库、媒体资产登记等逻辑。
---
## 5.2 请求参数设计
```ts
type BoxCutoutRequest = {
imageUrl: string;
cropRect: {
x: number; // 0~1
y: number; // 0~1
width: number; // 0~1
height: number; // 0~1
};
paddingRatio?: number; // 默认 0.08
prompt?: string; // 默认 "框选抠图"
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
sourceNodeId?: string;
};
```
---
## 5.3 返回值设计
```ts
type BoxCutoutResponse = {
success: boolean;
original_url?: string;
thumbnail_url?: string;
image_base64?: string;
processing_time?: number;
cropRectApplied?: {
x: number;
y: number;
width: number;
height: number;
};
paddedCropRectApplied?: {
x: number;
y: number;
width: number;
height: number;
};
error?: string;
};
```
---
## 5.4 服务端处理流程
### Step 1:拉取原图
后端根据 `imageUrl` 获取原图 buffer。
### Step 2:解析原图尺寸
使用 `sharp(metadata)` 读取:
- `width`
- `height`
### Step 3:把归一化坐标转成像素坐标
```ts
left = round(x * width)
top = round(y * height)
cropWidth = round(widthRatio * width)
cropHeight = round(heightRatio * height)
```
同时做边界保护:
- 不小于 0;
- 不超过原图边界;
- 宽高至少为最小值。
### Step 4:自动留白 padding
建议默认:
- `paddingRatio = 0.08`
即在用户框选基础上,向四周自动扩 8%。
原因:
- 用户经常会框得偏紧;
- 留白能避免抠图把边缘、阴影、轮廓切掉;
- 对小物体尤其重要。
### Step 5:裁切局部图
使用 `sharp.extract({ left, top, width, height })`
得到局部 buffer。
### Step 6:调用现有抠图能力
将裁切后的局部图作为新的输入,走现有 RMBG provider 链路。
这里有两种实现方式:
#### 方式 A:快速落地版
新接口内部把裁切后的局部图转 base64,再直接调用当前 `/api/rmbg` 的内部处理逻辑。
优点:
- 开发快;
- 复用现有稳定能力。
缺点:
- 若直接通过 HTTP 再请求一次 `/api/rmbg`,会多一层内部调用。
#### 方式 B:更推荐版
把 `/api/rmbg` 的核心逻辑抽到公共 service,再供新接口复用。
优点:
- 单一职责更清晰;
- 没有重复链路;
- 后续扩展更容易。
### Step 7:保存结果并回写来源关系
结果图建议继续复用现有:
- 相册保存;
- `GeneratedImage` 记录;
- `MediaAsset` 登记;
- 与源图建立 relation。
### 推荐增加的 metadata
```ts
{
sourceNodeId,
sourceImageUrl,
cropRectNormalized,
cropRectPixels,
paddingRatio,
mode: "box-cutout"
}
```
---
## 6. 更好的实现建议
## 6.1 建议一:服务端裁切,而不是前端裁切
这是本功能最重要的建议,优先级最高。
### 原因
- 精度最好;
- 稳定性最高;
- 避免浏览器跨域和大图内存问题;
- 能保证基于原图处理而不是展示图。
---
## 6.2 建议二:矩形框选后自动扩边
不要按用户框的矩形直接硬裁。
### 推荐默认策略
- 四周自动扩边 `6% ~ 10%`
- 默认先取 `8%`
### 原因
- 用户框得太紧是高频场景;
- 不扩边时,耳朵、把手、阴影、叶片、轮廓容易被切断;
- 扩边对抠图效果改善非常明显。
---
## 6.3 建议三:第一版先做“矩形框选 + 局部抠图”,不要直接上智能分割
从产品角度看,最容易想到的是“既然框选了,为什么不直接智能分割到边缘”。
但从工程节奏看,不建议第一版就上太复杂的语义分割链路。
### 第一版推荐
- 矩形框选
- 服务端裁切
- 调现有 RMBG
### 第二版可升级
在裁切后、抠图前,增加一个“局部前景细化”步骤,例如:
- 基于轻量分割模型自动修正前景边界;
- 或在裁切区域内部做一次前景检测;
- 或对抠图结果再做一次自动裁边。
### 原因
第一版要先解决的是:
- 用户是否能稳定框中目标;
- 结果是否能稳定回写到画布;
- 整个交互是否顺手。
先把主链路做通,再优化精度最合理。
---
## 6.4 建议四:不要复用全屏 Overlay,优先做节点内局部模式
现有 `ObjectPickerOverlay` 已经退化为不启用状态,当前交互主线在 `ImageNode` 内部。
### 推荐
框选抠图也保持同样原则:
- 直接在被选中的图片节点内绘制框选层;
- 不做全屏遮罩;
- 不打断用户对无限画布的空间感知。
### 好处
- 更符合当前产品形态;
- 交互更轻;
- 状态更简单;
- 不会和画布右侧对话区、底部工具栏形成过强冲突。
---
## 6.5 建议五:结果图默认与原图并排生成
直接复用当前 `ImageNode` 图片处理动作的结果生成模式:
- 在原图右侧新建一个处理中节点;
- 完成后更新为结果图;
- `originalSrc` 指回原图。
### 这样做的好处
- 用户认知成本低;
- 和现有抠图、扩图、高清增强一致;
- 易于后续启用“对比显示”。
---
## 7. 推荐改动点
## 7.1 前端
### `components/canvas/nodes/ImageNode.tsx`
需要新增:
- `框选抠图` 按钮;
- 框选模式本地状态;
- 鼠标拖拽生成矩形框;
- 确认 / 取消 UI;
- 调用新接口;
- 新建处理中节点并更新结果。
### `services/canvas/index.ts`
建议新增:
```ts
async boxCutout(params: {
imageUrl: string;
cropRect: { x: number; y: number; width: number; height: number };
paddingRatio?: number;
}): Promise<ApiResponse<{ url: string }>>
```
---
## 7.2 后端
### 新增
- `app/api/canvas/box-cutout/route.ts`
### 建议抽取
- `lib/ai/rmbg/service.ts`
- `lib/image/crop.ts`
用途:
- 统一做图片下载、尺寸解析、裁切、边界 clamp;
- 统一复用 RMBG 核心调用逻辑。
---
## 7.3 类型
### `types/canvas/index.ts`
建议补充编辑动作类型:
```ts
type ImageEditAction =
| "rmbg"
| "enhance"
| "extend"
| "resize"
| "angle-edit"
| "scene-fusion"
| "annotate"
| "text-edit"
| "box-cutout";
```
如果第一版只是局部功能,也可以先不改类型,把 metadata 先记下来。
---
## 8. 数据落库建议
第一版不是必须新增表结构。
### 建议直接复用现有图片记录链路
- `GeneratedImage`
- `MediaAsset`
- `MediaAssetRelation`
### 推荐 relation 语义
如果现有 relationType 可自由写字符串,建议新增:
- `cropped_from`
- `removed_background_from`
如果暂时不想扩 relationType,也可以先用现有:
- `edited_from`
并在 `metadata.mode = "box-cutout"` 中区分。
---
## 9. 边界情况与容错
### 9.1 用户框得过小
处理建议:
- 前端最小框选尺寸限制;
- 后端再次做最小尺寸校验;
- 小于阈值时直接报错:
- `框选区域过小,请重新框选`
### 9.2 用户框选超出图片显示区
处理建议:
- 前端坐标直接 clamp;
- 只允许在真实图片显示区域内绘制。
### 9.3 用户框了整张图的大部分区域
处理建议:
- 如果裁切区域面积超过整图 `85%`,可直接退化为普通抠图;
- 避免“几乎整图裁切”带来的多余处理。
### 9.4 原图拉取失败
错误提示:
- `原图获取失败,请稍后重试`
### 9.5 裁切后抠图失败
错误提示:
- `框选抠图失败,请重新框选或稍后重试`
---
## 10. 验收标准
满足以下条件即可认为 MVP 可交付:
1. 选中图片后能看到 `框选抠图` 按钮;
2. 点击后可在图片内部拖拽矩形框;
3. 框选区域外有遮罩;
4. 确认后右侧生成一个“处理中”图片节点;
5. 处理完成后节点更新为透明背景抠图结果;
6. 结果图与原图保持来源关系;
7. 原图不被覆盖;
8. 失败时能正确提示且不污染已有节点;
9. 框选模式可取消、可重选、可按 `Esc` 退出。
---
## 11. 测试清单
### 11.1 交互测试
- 单张图片正常框选;
- 连续多次重新框选;
- 按 `Esc` 退出;
- 点击空白取消;
- 画布缩放后再框选;
- 图片被缩放节点尺寸后再框选。
### 11.2 数据正确性测试
- 框选左上角物体;
- 框选右下角物体;
- 目标贴边;
- 目标很小;
- 目标透明或半透明;
- 目标带阴影;
- 原图是长图 / 高图 / 方图。
### 11.3 稳定性测试
- CDN 图片;
- OSS 图片;
- 大图;
- 慢网;
- 抠图 provider 失败;
- 后端存图失败。
---
## 12. 开发优先级建议
## Phase 1:MVP(推荐先做)
- 工具栏新增 `框选抠图`
- 节点内矩形框选
- 服务端裁切 + 复用 RMBG
- 结果回写画布
## Phase 2:体验优化
- 自动 padding 可配置
- 框选确认前局部预览
- 热键支持
- 框选后回显裁切预览
## Phase 3:精度优化
- 局部分割细化
- 自动贴边修正
- 更智能的小物体保护
---
## 13. 最终建议
如果以“最快可上线且效果稳定”为目标,建议采用下面这条方案:
### 最推荐方案
1. 前端在 `ImageNode` 上新增 `框选抠图` 按钮;
2. 用户在节点内拖拽矩形框;
3. 前端只上传**归一化框选坐标**;
4. 后端新增 `POST /api/canvas/box-cutout`;
5. 后端基于原图裁切局部图,并自动加 `8%` padding;
6. 后端复用现有 `/api/rmbg` 核心能力做抠图;
7. 结果图按现有模式回写到画布右侧。
### 为什么这是当前最优解
- 改动范围可控;
- 不会破坏现有 `/api/rmbg` 主链路;
- 用户体验足够直观;
- 效果比整图抠图更符合“抠局部元素”的预期;
- 后续还能平滑升级到更智能的局部分割方案。
---
## 14. 预计改造工作量
如果只做 MVP:
- 前端交互:0.5 ~ 1 天
- 后端接口:0.5 ~ 1 天
- 联调与修正:0.5 天
### 合计
- 约 `1.5 ~ 2.5 天`
如果加上“局部分割细化”和“框选预览”:
- 再增加 `1 ~ 2 天`
---
## 15. 建议结论
这项功能值得做,而且与你当前无限画布的使用方式高度匹配。
从工程角度看,**最好的落地方式不是前端裁图,而是服务端基于原图裁切后复用现有抠图链路**。
第一版不建议上复杂语义分割,先把:
- `框选`
- `裁切`
- `抠图`
- `回写画布`
这条主链路做稳,是性价比最高的方案。

View File

@ -8,6 +8,13 @@ interface ApiResponse<T = any> {
error?: string;
}
export interface NormalizedCropRect {
x: number;
y: number;
width: number;
height: number;
}
export interface CanvasTextDetectItem {
id: string;
text: string;
@ -208,6 +215,42 @@ export const imageGenerationService = {
}
},
async boxCutout(params: {
imageUrl: string;
cropRect: NormalizedCropRect;
paddingRatio?: number;
sourceNodeId?: string;
}): Promise<
ApiResponse<{
url: string;
cropRectApplied?: NormalizedCropRect;
paddedCropRectApplied?: NormalizedCropRect;
}>
> {
try {
const response = await fetch('/api/canvas/box-cutout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
if (data.success && data.original_url) {
return {
success: true,
data: {
url: data.original_url,
cropRectApplied: data.cropRectApplied,
paddedCropRectApplied: data.paddedCropRectApplied,
},
};
}
return { success: false, error: data.error || '框选抠图失败' };
} catch (error) {
console.error('[Canvas] 框选抠图失败:', error);
return { success: false, error: '框选抠图失败' };
}
},
// 去水印 - 使用图片URL
async removeWatermark(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
try {

File diff suppressed because one or more lines are too long

View File

@ -160,7 +160,7 @@ export interface Attachment {
// 图片编辑操作
export interface ImageEditAction {
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate' | 'text-edit';
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate' | 'text-edit' | 'box-cutout';
timestamp: Date;
params?: Record<string, any>;
resultUrl?: string;