From ece94a5896dc0478cab179e690bac9761a30a12b Mon Sep 17 00:00:00 2001 From: yun Date: Mon, 13 Jul 2026 10:53:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=BB=E7=AC=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/MediaInpaintingOverlay.tsx | 283 +++++++++--------- .../media/MediaInpaintingToolbar.tsx | 6 +- .../image-edit/InpaintingEditSession.tsx | 18 +- src/components/nodes/GenerateImageNode.tsx | 6 + src/components/nodes/ImageInputNode.tsx | 6 + 5 files changed, 164 insertions(+), 155 deletions(-) diff --git a/src/components/media/MediaInpaintingOverlay.tsx b/src/components/media/MediaInpaintingOverlay.tsx index 5cb33565..0f1679cd 100644 --- a/src/components/media/MediaInpaintingOverlay.tsx +++ b/src/components/media/MediaInpaintingOverlay.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import type { RefObject } from "react"; export interface MediaInpaintingFrameRect { @@ -16,6 +16,10 @@ export interface MediaInpaintingMaskPayload { height: number; } +export interface MediaInpaintingOverlayHandle { + exportMask: () => Promise; +} + export type MediaInpaintingDrawTool = "brush" | "rectangle"; interface DisplayRect { @@ -36,12 +40,16 @@ interface MediaInpaintingOverlayProps { clearSignal?: number; undoSignal?: number; redoSignal?: number; - onMaskChange?: (payload: MediaInpaintingMaskPayload | null) => void; onFrameChange?: (rect: MediaInpaintingFrameRect | null) => void; } const MAX_HISTORY = 30; +interface MaskHistoryEntry { + visible: ImageData; + mask: ImageData; +} + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -69,16 +77,6 @@ async function canvasToPngBlob(canvas: HTMLCanvasElement): Promise { }); } -function canvasHasVisiblePixels(canvas: HTMLCanvasElement): boolean { - const context = canvas.getContext("2d"); - if (!context) return false; - const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; - for (let index = 3; index < pixels.length; index += 4) { - if (pixels[index] > 0) return true; - } - return false; -} - function createCheckerPattern(context: CanvasRenderingContext2D) { const size = 40; const tile = document.createElement("canvas"); @@ -95,7 +93,7 @@ function createCheckerPattern(context: CanvasRenderingContext2D) { return context.createPattern(tile, "repeat"); } -export function MediaInpaintingOverlay({ +export const MediaInpaintingOverlay = forwardRef(function MediaInpaintingOverlay({ mediaElementRef, naturalWidth, naturalHeight, @@ -106,26 +104,48 @@ export function MediaInpaintingOverlay({ clearSignal = 0, undoSignal = 0, redoSignal = 0, - onMaskChange, onFrameChange, -}: MediaInpaintingOverlayProps) { +}, ref) { const overlayRef = useRef(null); const canvasRef = useRef(null); - const previewCanvasRef = useRef(null); + const maskCanvasRef = useRef(null); + const cursorRef = useRef(null); const drawingRef = useRef(false); const lastPointRef = useRef<{ x: number; y: number } | null>(null); const rectangleStartRef = useRef<{ x: number; y: number } | null>(null); - const historyRef = useRef([]); - const redoRef = useRef([]); + const historyRef = useRef([]); + const redoRef = useRef([]); + const checkerPatternRef = useRef(null); const [bounds, setBounds] = useState(null); - const [cursorPoint, setCursorPoint] = useState<{ x: number; y: number } | null>(null); const [rectanglePreview, setRectanglePreview] = useState<{ start: { x: number; y: number }; end: { x: number; y: number } } | null>(null); const [naturalDimensions, setNaturalDimensions] = useState<{ width: number; height: number } | null>(() => validDimensions(naturalWidth, naturalHeight) ); - const [hasMask, setHasMask] = useState(false); const activeDrawTool = eraseMode ? "brush" : drawTool; + const resetCanvases = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !naturalDimensions) return; + canvas.width = naturalDimensions.width; + canvas.height = naturalDimensions.height; + canvas.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); + + const maskCanvas = maskCanvasRef.current ?? document.createElement("canvas"); + maskCanvas.width = naturalDimensions.width; + maskCanvas.height = naturalDimensions.height; + maskCanvas.getContext("2d")?.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + maskCanvasRef.current = maskCanvas; + + historyRef.current = []; + redoRef.current = []; + checkerPatternRef.current = null; + }, [naturalDimensions]); + + const setCanvasElement = useCallback((canvas: HTMLCanvasElement | null) => { + canvasRef.current = canvas; + if (canvas) resetCanvases(); + }, [resetCanvases]); + useEffect(() => { const explicitDimensions = validDimensions(naturalWidth, naturalHeight); if (explicitDimensions) { @@ -184,87 +204,45 @@ export function MediaInpaintingOverlay({ }, [measureBounds, onFrameChange]); useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || !naturalDimensions) return; - canvas.width = naturalDimensions.width; - canvas.height = naturalDimensions.height; - const context = canvas.getContext("2d"); - context?.clearRect(0, 0, canvas.width, canvas.height); - const previewCanvas = previewCanvasRef.current; - if (previewCanvas) { - previewCanvas.width = naturalDimensions.width; - previewCanvas.height = naturalDimensions.height; - previewCanvas.getContext("2d")?.clearRect(0, 0, previewCanvas.width, previewCanvas.height); - } - historyRef.current = []; - redoRef.current = []; - setHasMask(false); - onMaskChange?.(null); - }, [naturalDimensions, onMaskChange]); - - const renderPreview = useCallback(() => { - const maskCanvas = canvasRef.current; - const previewCanvas = previewCanvasRef.current; - const context = previewCanvas?.getContext("2d"); - if (!maskCanvas || !previewCanvas || !context) return; - if (previewCanvas.width !== maskCanvas.width || previewCanvas.height !== maskCanvas.height) { - previewCanvas.width = maskCanvas.width; - previewCanvas.height = maskCanvas.height; - } - - context.clearRect(0, 0, previewCanvas.width, previewCanvas.height); - if (!canvasHasVisiblePixels(maskCanvas)) return; - - const pattern = createCheckerPattern(context); - context.save(); - context.fillStyle = pattern ?? "rgba(230,233,238,0.9)"; - context.fillRect(0, 0, previewCanvas.width, previewCanvas.height); - context.globalCompositeOperation = "destination-in"; - context.drawImage(maskCanvas, 0, 0); - context.globalCompositeOperation = "source-atop"; - context.fillStyle = "rgba(255,255,255,0.28)"; - context.fillRect(0, 0, previewCanvas.width, previewCanvas.height); - context.restore(); - }, []); + resetCanvases(); + }, [resetCanvases]); const exportMask = useCallback(async () => { - const sourceCanvas = canvasRef.current; - if (!sourceCanvas || !naturalDimensions || !canvasHasVisiblePixels(sourceCanvas)) { - setHasMask(false); - onMaskChange?.(null); - return; - } + const sourceCanvas = maskCanvasRef.current; + if (!sourceCanvas || !naturalDimensions) return null; const exportCanvas = document.createElement("canvas"); exportCanvas.width = naturalDimensions.width; exportCanvas.height = naturalDimensions.height; const context = exportCanvas.getContext("2d"); - if (!context) { - onMaskChange?.(null); - return; - } + if (!context) return null; context.fillStyle = "#000"; context.fillRect(0, 0, exportCanvas.width, exportCanvas.height); context.drawImage(sourceCanvas, 0, 0); const blob = await canvasToPngBlob(exportCanvas); - setHasMask(true); - onMaskChange?.({ + return { blob, width: exportCanvas.width, height: exportCanvas.height, - }); - }, [naturalDimensions, onMaskChange]); + }; + }, [naturalDimensions]); - useEffect(() => { - void exportMask(); - }, [exportMask]); + useImperativeHandle(ref, () => ({ exportMask }), [exportMask]); const pushHistory = useCallback(() => { const canvas = canvasRef.current; + const maskCanvas = maskCanvasRef.current; const context = canvas?.getContext("2d"); - if (!canvas || !context) return; - historyRef.current = [...historyRef.current.slice(-(MAX_HISTORY - 1)), context.getImageData(0, 0, canvas.width, canvas.height)]; + const maskContext = maskCanvas?.getContext("2d"); + if (!canvas || !maskCanvas || !context || !maskContext) return; + historyRef.current = [ + ...historyRef.current.slice(-(MAX_HISTORY - 1)), + { + visible: context.getImageData(0, 0, canvas.width, canvas.height), + mask: maskContext.getImageData(0, 0, maskCanvas.width, maskCanvas.height), + }, + ]; redoRef.current = []; }, []); @@ -295,22 +273,48 @@ export function MediaInpaintingOverlay({ }; }, [bounds]); - const withBrushComposite = useCallback((context: CanvasRenderingContext2D, draw: () => void) => { + const updateCursor = useCallback((point: { x: number; y: number } | null) => { + const cursor = cursorRef.current; + if (!cursor) return; + if (!point) { + cursor.style.display = "none"; + return; + } + cursor.style.display = "block"; + cursor.style.transform = `translate(${point.x}px, ${point.y}px) translate(-50%, -50%)`; + }, []); + + const withBrushComposite = useCallback((draw: (context: CanvasRenderingContext2D) => void) => { + const canvas = canvasRef.current; + const maskCanvas = maskCanvasRef.current; + const context = canvas?.getContext("2d"); + const maskContext = maskCanvas?.getContext("2d"); + if (!canvas || !maskCanvas || !context || !maskContext) return; + context.save(); context.globalCompositeOperation = eraseMode ? "destination-out" : "source-over"; - context.fillStyle = "#fff"; - draw(); + if (eraseMode) { + draw(context); + } else { + if (!checkerPatternRef.current) { + checkerPatternRef.current = createCheckerPattern(context); + } + context.fillStyle = checkerPatternRef.current ?? "rgba(230,233,238,0.9)"; + draw(context); + context.fillStyle = "rgba(255,255,255,0.28)"; + draw(context); + } context.restore(); - setHasMask(true); - renderPreview(); - }, [eraseMode, renderPreview]); - const stampBrush = useCallback((point: { x: number; y: number }) => { - const canvas = canvasRef.current; - const context = canvas?.getContext("2d"); - if (!canvas || !context) return; + maskContext.save(); + maskContext.globalCompositeOperation = eraseMode ? "destination-out" : "source-over"; + maskContext.fillStyle = "#fff"; + draw(maskContext); + maskContext.restore(); + }, [eraseMode]); - withBrushComposite(context, () => { + const stampBrush = useCallback((point: { x: number; y: number }) => { + withBrushComposite((context) => { const halfSize = brushSize / 2; context.beginPath(); context.arc(point.x, point.y, halfSize, 0, Math.PI * 2); @@ -319,17 +323,13 @@ export function MediaInpaintingOverlay({ }, [brushSize, withBrushComposite]); const fillRectangle = useCallback((start: { x: number; y: number }, end: { x: number; y: number }) => { - const canvas = canvasRef.current; - const context = canvas?.getContext("2d"); - if (!canvas || !context) return; - const left = Math.min(start.x, end.x); const top = Math.min(start.y, end.y); const width = Math.abs(end.x - start.x); const height = Math.abs(end.y - start.y); if (width <= 0 || height <= 0) return; - withBrushComposite(context, () => { + withBrushComposite((context) => { context.fillRect(left, top, width, height); }); }, [withBrushComposite]); @@ -355,7 +355,7 @@ export function MediaInpaintingOverlay({ const point = pointFromEvent(event); if (!point) return; const displayPoint = displayPointFromEvent(event); - if (displayPoint) setCursorPoint(displayPoint); + if (displayPoint) updateCursor(displayPoint); event.preventDefault(); event.stopPropagation(); pushHistory(); @@ -371,7 +371,7 @@ export function MediaInpaintingOverlay({ const handlePointerMove = useCallback((event: React.PointerEvent) => { const displayPoint = displayPointFromEvent(event); - if (displayPoint) setCursorPoint(displayPoint); + if (displayPoint) updateCursor(displayPoint); if (!drawingRef.current || busy) return; const point = pointFromEvent(event); if (!point) return; @@ -383,17 +383,17 @@ export function MediaInpaintingOverlay({ return; } drawTo(point); - }, [activeDrawTool, busy, displayPointFromEvent, drawTo, pointFromEvent]); + }, [activeDrawTool, busy, displayPointFromEvent, drawTo, pointFromEvent, updateCursor]); const handlePointerEnter = useCallback((event: React.PointerEvent) => { const displayPoint = displayPointFromEvent(event); - if (displayPoint) setCursorPoint(displayPoint); - }, [displayPointFromEvent]); + if (displayPoint) updateCursor(displayPoint); + }, [displayPointFromEvent, updateCursor]); const handlePointerLeave = useCallback(() => { if (drawingRef.current) return; - setCursorPoint(null); - }, []); + updateCursor(null); + }, [updateCursor]); const stopDrawing = useCallback((event: React.PointerEvent) => { if (!drawingRef.current) return; @@ -408,9 +408,8 @@ export function MediaInpaintingOverlay({ lastPointRef.current = null; rectangleStartRef.current = null; const displayPoint = displayPointFromEvent(event); - if (displayPoint) setCursorPoint(displayPoint); - void exportMask(); - }, [activeDrawTool, displayPointFromEvent, exportMask, fillRectangle, pointFromEvent]); + if (displayPoint) updateCursor(displayPoint); + }, [activeDrawTool, displayPointFromEvent, fillRectangle, pointFromEvent, updateCursor]); const displayBrushSize = bounds && naturalDimensions ? Math.max(8, brushSize * (bounds.width / naturalDimensions.width)) @@ -427,39 +426,46 @@ export function MediaInpaintingOverlay({ useEffect(() => { if (clearSignal <= 0) return; const canvas = canvasRef.current; + const maskCanvas = maskCanvasRef.current; const context = canvas?.getContext("2d"); - if (!canvas || !context) return; + const maskContext = maskCanvas?.getContext("2d"); + if (!canvas || !maskCanvas || !context || !maskContext) return; pushHistory(); context.clearRect(0, 0, canvas.width, canvas.height); - renderPreview(); - setHasMask(false); - onMaskChange?.(null); - }, [clearSignal, onMaskChange, pushHistory, renderPreview]); + maskContext.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + }, [clearSignal, pushHistory]); useEffect(() => { if (undoSignal <= 0) return; const canvas = canvasRef.current; + const maskCanvas = maskCanvasRef.current; const context = canvas?.getContext("2d"); + const maskContext = maskCanvas?.getContext("2d"); const previous = historyRef.current.pop(); - if (!canvas || !context || !previous) return; - redoRef.current.push(context.getImageData(0, 0, canvas.width, canvas.height)); - context.putImageData(previous, 0, 0); - renderPreview(); - void exportMask(); - }, [exportMask, renderPreview, undoSignal]); + if (!canvas || !maskCanvas || !context || !maskContext || !previous) return; + redoRef.current.push({ + visible: context.getImageData(0, 0, canvas.width, canvas.height), + mask: maskContext.getImageData(0, 0, maskCanvas.width, maskCanvas.height), + }); + context.putImageData(previous.visible, 0, 0); + maskContext.putImageData(previous.mask, 0, 0); + }, [undoSignal]); useEffect(() => { if (redoSignal <= 0) return; const canvas = canvasRef.current; + const maskCanvas = maskCanvasRef.current; const context = canvas?.getContext("2d"); + const maskContext = maskCanvas?.getContext("2d"); const next = redoRef.current.pop(); - if (!canvas || !context || !next) return; - historyRef.current.push(context.getImageData(0, 0, canvas.width, canvas.height)); - context.putImageData(next, 0, 0); - renderPreview(); - setHasMask(true); - void exportMask(); - }, [exportMask, redoSignal, renderPreview]); + if (!canvas || !maskCanvas || !context || !maskContext || !next) return; + historyRef.current.push({ + visible: context.getImageData(0, 0, canvas.width, canvas.height), + mask: maskContext.getImageData(0, 0, maskCanvas.width, maskCanvas.height), + }); + context.putImageData(next.visible, 0, 0); + maskContext.putImageData(next.mask, 0, 0); + }, [redoSignal]); return (
{bounds && naturalDimensions && ( )} - {bounds && naturalDimensions && ( - - )} - {cursorPoint && bounds && naturalDimensions && !busy && activeDrawTool === "brush" ? ( + {bounds && naturalDimensions && !busy && activeDrawTool === "brush" ? (
@@ -518,4 +513,4 @@ export function MediaInpaintingOverlay({ ) : null}
); -} +}); diff --git a/src/components/media/MediaInpaintingToolbar.tsx b/src/components/media/MediaInpaintingToolbar.tsx index 5d5d3e8d..a9d0dc8e 100644 --- a/src/components/media/MediaInpaintingToolbar.tsx +++ b/src/components/media/MediaInpaintingToolbar.tsx @@ -11,12 +11,11 @@ import { UndoOutlined, } from "@ant-design/icons"; import { GenerateActionIcon } from "./MediaToolbarIcons"; -import type { MediaInpaintingDrawTool, MediaInpaintingMaskPayload } from "./MediaInpaintingOverlay"; +import type { MediaInpaintingDrawTool } from "./MediaInpaintingOverlay"; export type InpaintingBatchSize = 1 | 2 | 4; interface MediaInpaintingToolbarProps { - mask: MediaInpaintingMaskPayload | null; modelName?: string; prompt: string; batchSize: InpaintingBatchSize; @@ -44,7 +43,6 @@ const BATCH_SIZE_OPTIONS = [ ]; export function MediaInpaintingToolbar({ - mask, modelName, prompt, batchSize, @@ -64,7 +62,7 @@ export function MediaInpaintingToolbar({ onCancel, onConfirm, }: MediaInpaintingToolbarProps) { - const canConfirm = Boolean(mask) && !busy; + const canConfirm = !busy; if (!frameRect) return null; const centerX = frameRect.left + frameRect.width / 2; diff --git a/src/components/media/image-edit/InpaintingEditSession.tsx b/src/components/media/image-edit/InpaintingEditSession.tsx index 91553e75..3a7f1e30 100644 --- a/src/components/media/image-edit/InpaintingEditSession.tsx +++ b/src/components/media/image-edit/InpaintingEditSession.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import type { ProviderModel } from "@/lib/providers/types"; import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; -import { MediaInpaintingOverlay, type MediaInpaintingDrawTool, type MediaInpaintingFrameRect, type MediaInpaintingMaskPayload } from "@/components/media/MediaInpaintingOverlay"; +import { MediaInpaintingOverlay, type MediaInpaintingDrawTool, type MediaInpaintingFrameRect, type MediaInpaintingOverlayHandle } from "@/components/media/MediaInpaintingOverlay"; import { MediaInpaintingToolbar, type InpaintingBatchSize } from "@/components/media/MediaInpaintingToolbar"; import { useImageDerivedNodeActions } from "./useImageDerivedNodeActions"; import type { ImageEditSessionCommonProps } from "./types"; @@ -20,7 +20,7 @@ export function InpaintingEditSession({ model, onClose, }: InpaintingEditSessionProps) { - const [mask, setMask] = useState(null); + const overlayRef = useRef(null); const [prompt, setPrompt] = useState(""); const [batchSize, setBatchSize] = useState(1); const [brushSize, setBrushSize] = useState(90); @@ -37,10 +37,15 @@ export function InpaintingEditSession({ }); const handleConfirm = () => { - if (!model || !mask || busy) return; + if (!model || busy) return; setBusy(true); - onClose(); void (async () => { + const mask = await overlayRef.current?.exportMask(); + if (!mask) { + setBusy(false); + return; + } + onClose(); const uploadedMask = await uploadBlobToGatewayMedia( mask.blob, `inpainting-mask-${Date.now()}.png`, @@ -61,6 +66,7 @@ export function InpaintingEditSession({ return ( <> , + label: "擦除", + disabled: !inpaintingModel, + }, { key: "crop", icon: , diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index 8aa34a13..e59fe9a0 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -414,6 +414,12 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal label: "扩图", disabled: !outpaintingModel, }, + { + key: "inpainting", + icon: , + label: "擦除", + disabled: !inpaintingModel, + }, { key: "crop", icon: ,