feat: add box cutout flow for canvas images
This commit is contained in:
parent
5f5d1b5850
commit
055652cc91
220
app/api/canvas/box-cutout/route.ts
Normal file
220
app/api/canvas/box-cutout/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 参数" },
|
||||
|
||||
@ -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,23 +2363,25 @@ 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);
|
||||
}
|
||||
@ -1921,6 +2389,72 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<div
|
||||
@ -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}
|
||||
|
||||
773
docs/框选抠图功能开发方案-2026-03-07.md
Normal file
773
docs/框选抠图功能开发方案-2026-03-07.md
Normal 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. 建议结论
|
||||
|
||||
这项功能值得做,而且与你当前无限画布的使用方式高度匹配。
|
||||
从工程角度看,**最好的落地方式不是前端裁图,而是服务端基于原图裁切后复用现有抠图链路**。
|
||||
|
||||
第一版不建议上复杂语义分割,先把:
|
||||
|
||||
- `框选`
|
||||
- `裁切`
|
||||
- `抠图`
|
||||
- `回写画布`
|
||||
|
||||
这条主链路做稳,是性价比最高的方案。
|
||||
@ -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
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user