From 055652cc910fe4c077a261b3ab47e058542462cb Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 7 Mar 2026 09:45:52 +0800 Subject: [PATCH] feat: add box cutout flow for canvas images --- app/api/canvas/box-cutout/route.ts | 220 +++++++ app/api/rmbg/route.ts | 4 + components/canvas/nodes/ImageNode.tsx | 560 ++++++++++++++++- docs/框选抠图功能开发方案-2026-03-07.md | 773 ++++++++++++++++++++++++ services/canvas/index.ts | 43 ++ tsconfig.tsbuildinfo | 2 +- types/canvas/index.ts | 2 +- 7 files changed, 1594 insertions(+), 10 deletions(-) create mode 100644 app/api/canvas/box-cutout/route.ts create mode 100644 docs/框选抠图功能开发方案-2026-03-07.md diff --git a/app/api/canvas/box-cutout/route.ts b/app/api/canvas/box-cutout/route.ts new file mode 100644 index 000000000..b754d7d70 --- /dev/null +++ b/app/api/canvas/box-cutout/route.ts @@ -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; + 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 | 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 } + ); + } +} diff --git a/app/api/rmbg/route.ts b/app/api/rmbg/route.ts index e53d2574d..f1384898b 100644 --- a/app/api/rmbg/route.ts +++ b/app/api/rmbg/route.ts @@ -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 参数" }, diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 66c3e8b24..0c4e0ae95 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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(null); + const [boxCutoutDragStart, setBoxCutoutDragStart] = useState(null); + const [boxCutoutDragEnd, setBoxCutoutDragEnd] = useState(null); + const [boxCutoutMetrics, setBoxCutoutMetrics] = useState(null); // 文件输入的 ref const fileInputRef = useRef(null); const containerRef = useRef(null); + const displayImageRef = useRef(null); const compareFrameRef = useRef(null); const toolbarRef = useRef(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) => { + 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) => { + 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) => { + 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) => { + 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) { ) : ( {displayTitle} { 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 && ( + <> +
{ + event.preventDefault(); + event.stopPropagation(); + }} + /> + {activeBoxCutoutRectStyle ? ( +
+ ) : ( +
+ )} +
+ {boxCutoutSelection + ? "已框选,可确认抠图" + : isBoxCutoutDrawing + ? "松开完成框选" + : "拖拽框选要抠出的元素"} +
+
{ + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + > + + + {boxCutoutSelection ? "确认后将仅抠出框选区域" : "在图片中拖拽矩形框"} + + {boxCutoutSelection ? ( + + ) : null} + +
+ + )} {/* 🆕 拾取模式:当前点击位置(正在识别) */} {isThisNodePicking && currentClick && ( @@ -1956,7 +2490,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
)} - {hasCompareSource && !isThisNodePicking && !isCompareMode && isSelected && !isMultiSelected && ( + {hasCompareSource && !isThisNodePicking && !isCompareMode && !boxCutoutMode && isSelected && !isMultiSelected && ( +