diff --git a/CHANGELOG.md b/CHANGELOG.md index e032394..f56cb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] 新增图片蒙版局部修改功能。 + ## v0.2.2 - 2026-06-04 + [新增] 新增图片放大工具。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 176b67f..ce5682f 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。 - 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。 - Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框、节点底部 prompt 面板和生成配置预览文本编辑框输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。 - 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 0745806..1b9bf1b 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -29,6 +29,7 @@ import { CanvasAssistantPanel } from "../components/canvas-assistant-panel"; import { CanvasNodeContextMenu } from "../components/canvas-context-menu"; import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog"; import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog"; +import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "../components/canvas-node-mask-edit-dialog"; import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "../components/canvas-node-upscale-dialog"; import { buildNodeChatMessages, buildNodeGenerationContext, buildNodeGenerationInputs, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation"; import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar"; @@ -273,6 +274,7 @@ function InfiniteCanvasPage() { const [editRequestNonce, setEditRequestNonce] = useState(0); const [infoNodeId, setInfoNodeId] = useState(null); const [cropNodeId, setCropNodeId] = useState(null); + const [maskEditNodeId, setMaskEditNodeId] = useState(null); const [upscaleNodeId, setUpscaleNodeId] = useState(null); const [superResolveNodeId, setSuperResolveNodeId] = useState(null); const [angleNodeId, setAngleNodeId] = useState(null); @@ -576,6 +578,7 @@ function InfiniteCanvasPage() { const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null; const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null; const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null; + const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null; const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null; const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null; const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null; @@ -698,6 +701,7 @@ function InfiniteCanvasPage() { setEditingNodeId((current) => (current && allIds.has(current) ? null : current)); setInfoNodeId((current) => (current && allIds.has(current) ? null : current)); setCropNodeId((current) => (current && allIds.has(current) ? null : current)); + setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current)); setAngleNodeId((current) => (current && allIds.has(current) ? null : current)); setPreviewNodeId((current) => (current && allIds.has(current) ? null : current)); setRunningNodeId((current) => (current && allIds.has(current) ? null : current)); @@ -730,6 +734,7 @@ function InfiniteCanvasPage() { setConnections([]); setInfoNodeId(null); setCropNodeId(null); + setMaskEditNodeId(null); setAngleNodeId(null); setPreviewNodeId(null); setRunningNodeId(null); @@ -1285,6 +1290,7 @@ function InfiniteCanvasPage() { setEditingNodeId(null); setInfoNodeId(null); setCropNodeId(null); + setMaskEditNodeId(null); setPendingConnectionCreate(null); } }; @@ -1464,6 +1470,53 @@ function InfiniteCanvasPage() { setCropNodeId(null); }, []); + const maskEditImageNode = useCallback( + async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => { + if (!node.metadata?.content) return; + const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" }; + if (!isAiConfigReady(generationConfig, generationConfig.model)) { + openConfigDialog(true); + return; + } + const userPrompt = payload.prompt.trim(); + const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`; + const childId = nanoid(); + const source = { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }; + const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]); + setMaskEditNodeId(null); + setRunningNodeId(childId); + setNodes((prev) => [ + ...prev, + { + id: childId, + type: CanvasNodeType.Image, + title: userPrompt.slice(0, 32) || "局部编辑结果", + position: { x: node.position.x + node.width + 96, y: node.position.y }, + width: node.width, + height: node.height, + metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata }, + }, + ]); + setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); + setSelectedNodeIds(new Set([childId])); + setSelectedConnectionId(null); + setDialogNodeId(childId); + try { + const image = await requestEdit(generationConfig, prompt, [source], { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl }).then((items) => items[0]); + const uploaded = await uploadImage(image.dataUrl); + const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height); + setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item))); + } catch (error) { + const errorDetails = error instanceof Error ? error.message : "局部修改失败"; + message.error(errorDetails); + setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); + } finally { + setRunningNodeId(null); + } + }, + [effectiveConfig, isAiConfigReady, message, openConfigDialog], + ); + const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => { if (!node.metadata?.content) return; setUpscaleNodeId(null); @@ -2324,6 +2377,7 @@ function InfiniteCanvasPage() { onUpload={(node) => handleUploadRequest(node.id)} onDownload={downloadNodeImage} onSaveAsset={(node) => void saveNodeAsset(node)} + onMaskEdit={(node) => setMaskEditNodeId(node.id)} onCrop={(node) => setCropNodeId(node.id)} onUpscale={(node) => setUpscaleNodeId(node.id)} onSuperResolve={(node) => setSuperResolveNodeId(node.id)} @@ -2393,6 +2447,8 @@ function InfiniteCanvasPage() { {cropNode?.metadata?.content ? setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null} + {maskEditNode?.metadata?.content ? setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> : null} + {upscaleNode?.metadata?.content ? setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null} setSuperResolveNodeId(null)}> diff --git a/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx b/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx index 619f1c7..2cbee7d 100644 --- a/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx +++ b/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx @@ -1,16 +1,17 @@ "use client"; import type { ReactNode } from "react"; -import { Camera, Copy, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react"; +import { Brush, Camera, Copy, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react"; import type { CanvasNodeData } from "../types"; -export type ImageNodeActionToolId = "copyPrompt" | "replace" | "resize" | "crop" | "upscale" | "superResolve" | "angle" | "view"; +export type ImageNodeActionToolId = "copyPrompt" | "replace" | "resize" | "maskEdit" | "crop" | "upscale" | "superResolve" | "angle" | "view"; export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId; export type ImageToolHandlers = { onUpload: (node: CanvasNodeData) => void; onToggleFreeResize: (node: CanvasNodeData) => void; + onMaskEdit: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void; onUpscale: (node: CanvasNodeData) => void; onSuperResolve: (node: CanvasNodeData) => void; @@ -35,7 +36,7 @@ export type ImageQuickToolsConfig = { showLabels: boolean; }; -export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v3"; +export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v4"; const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"]; @@ -68,6 +69,15 @@ export const imageToolDefinitions: ImageToolDefinition[] = [ active: (node) => Boolean(node.metadata?.freeResize), run: (node, handlers) => handlers.onToggleFreeResize(node), }, + { + id: "maskEdit", + defaultVisible: true, + panelLabel: "局部编辑", + label: "局部编辑", + title: "添加蒙版遮罩后局部修改", + icon: () => , + run: (node, handlers) => handlers.onMaskEdit(node), + }, { id: "crop", defaultVisible: true, diff --git a/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx b/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx index ddab7b2..514513f 100644 --- a/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx +++ b/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx @@ -26,6 +26,7 @@ type CanvasNodeHoverToolbarProps = { onUpload: (node: CanvasNodeData) => void; onDownload: (node: CanvasNodeData) => void; onSaveAsset: (node: CanvasNodeData) => void; + onMaskEdit: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void; onUpscale: (node: CanvasNodeData) => void; onSuperResolve: (node: CanvasNodeData) => void; @@ -60,6 +61,7 @@ export function CanvasNodeHoverToolbar({ onUpload, onDownload, onSaveAsset, + onMaskEdit, onCrop, onUpscale, onSuperResolve, @@ -117,7 +119,7 @@ export function CanvasNodeHoverToolbar({ } copyText(prompt, "提示词已复制"); }; - const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onCrop, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt }); + const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt }); function openImageToolSettings() { onKeep(node.id); diff --git a/web/src/app/(user)/canvas/components/canvas-node-mask-edit-dialog.tsx b/web/src/app/(user)/canvas/components/canvas-node-mask-edit-dialog.tsx new file mode 100644 index 0000000..7367d93 --- /dev/null +++ b/web/src/app/(user)/canvas/components/canvas-node-mask-edit-dialog.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { Button, Input, Modal, Slider } from "antd"; +import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react"; + +import { readImageMeta } from "@/lib/image-utils"; + +export type CanvasImageMaskEditPayload = { + prompt: string; + maskDataUrl: string; +}; + +type DrawMode = "paint" | "erase"; + +const defaultBrushSize = 100; +const maskFillColor = "rgba(37, 99, 235, .38)"; +const maskBorderColor = "rgba(255, 255, 255, .72)"; + +export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) { + const maskCanvasRef = useRef(null); + const previewCanvasRef = useRef(null); + const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null }); + const [image, setImage] = useState<{ width: number; height: number } | null>(null); + const [prompt, setPrompt] = useState(""); + const [brushSize, setBrushSize] = useState(defaultBrushSize); + const [mode, setMode] = useState("paint"); + const [error, setError] = useState(""); + + useEffect(() => { + if (!open) return; + setPrompt(""); + setBrushSize(defaultBrushSize); + setMode("paint"); + setError(""); + void readImageMeta(dataUrl).then(setImage); + }, [dataUrl, open]); + + useEffect(() => { + clearCanvas(maskCanvasRef.current); + clearCanvas(previewCanvasRef.current); + }, [image]); + + const draw = (event: ReactPointerEvent) => { + const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY); + const maskCanvas = maskCanvasRef.current; + const context = maskCanvas?.getContext("2d"); + if (!context) return; + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = brushSize; + context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out"; + context.strokeStyle = "#000"; + context.fillStyle = "#000"; + if (!drawingRef.current.last) { + drawMaskStroke(context, point, point, brushSize); + } else { + drawMaskStroke(context, drawingRef.current.last, point, brushSize); + } + renderMaskPreview(maskCanvas, previewCanvasRef.current); + drawingRef.current.last = point; + if (mode === "paint") { + setError(""); + } + }; + + const startDraw = (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + drawingRef.current = { active: true, last: null }; + if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current); + draw(event); + }; + + const moveDraw = (event: ReactPointerEvent) => { + if (!drawingRef.current.active) return; + event.preventDefault(); + draw(event); + }; + + const stopDraw = () => { + drawingRef.current = { active: false, last: null }; + const maskCanvas = maskCanvasRef.current; + if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas)); + }; + + const resetMask = () => { + clearCanvas(maskCanvasRef.current); + clearCanvas(previewCanvasRef.current); + setError(""); + }; + + const submit = () => { + const nextPrompt = prompt.trim(); + const canvas = maskCanvasRef.current; + if (!nextPrompt) return setError("请输入修改要求"); + if (!canvas) return; + if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域"); + onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) }); + }; + + return ( + +
+
+
+ + {image ? ( + <> + + + + ) : null} +
+
+ +
+
+

局部遮罩编辑

+
{image ? `${image.width} x ${image.height}px` : "读取中"}
+
+ +
+ + +
+ +
+
+ 笔刷大小 + {brushSize}px +
+ +
+ +
+
修改要求
+ { + setPrompt(event.target.value); + setError(""); + }} + /> + {error ?
{error}
: null} +
+ +
+ +
+ + +
+
+
+
+
+ ); +} + +function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: number) { + const rect = canvas.getBoundingClientRect(); + return { + x: ((clientX - rect.left) / Math.max(1, rect.width)) * canvas.width, + y: ((clientY - rect.top) / Math.max(1, rect.height)) * canvas.height, + }; +} + +function clearCanvas(canvas: HTMLCanvasElement | null) { + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + context.clearRect(0, 0, canvas.width, canvas.height); +} + +function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, size: number) { + if (from.x === to.x && from.y === to.y) { + context.beginPath(); + context.arc(to.x, to.y, size / 2, 0, Math.PI * 2); + context.fill(); + return; + } + context.beginPath(); + context.moveTo(from.x, from.y); + context.lineTo(to.x, to.y); + context.stroke(); +} + +function canvasHasPaint(canvas: HTMLCanvasElement) { + const context = canvas.getContext("2d"); + if (!context) return false; + const data = context.getImageData(0, 0, canvas.width, canvas.height).data; + for (let index = 3; index < data.length; index += 4) { + if (data[index] > 0) return true; + } + return false; +} + +function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) { + const context = previewCanvas?.getContext("2d"); + if (!previewCanvas || !context) return; + context.clearRect(0, 0, previewCanvas.width, previewCanvas.height); + context.fillStyle = maskFillColor; + context.fillRect(0, 0, previewCanvas.width, previewCanvas.height); + context.globalCompositeOperation = "destination-in"; + context.drawImage(maskCanvas, 0, 0); + context.globalCompositeOperation = "source-over"; + if (withBorder) drawDashedMaskBorder(context, maskCanvas); +} + +function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) { + const maskContext = maskCanvas.getContext("2d"); + if (!maskContext) return; + const { width, height } = maskCanvas; + const data = maskContext.getImageData(0, 0, width, height).data; + const step = Math.max(1, Math.round(Math.max(width, height) / 1200)); + const dash = step * 8; + const gap = step * 5; + const period = dash + gap; + + context.save(); + context.fillStyle = maskBorderColor; + context.shadowColor = "rgba(0, 0, 0, .24)"; + context.shadowBlur = step * 1.5; + for (let y = step; y < height - step; y += step) { + for (let x = step; x < width - step; x += step) { + const offset = (y * width + x) * 4 + 3; + if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue; + if ((x + y) % period > dash) continue; + context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step)); + } + } + context.restore(); +} + +function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) { + return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0; +} + +function buildEditMask(selectionCanvas: HTMLCanvasElement) { + const canvas = document.createElement("canvas"); + canvas.width = selectionCanvas.width; + canvas.height = selectionCanvas.height; + const context = canvas.getContext("2d"); + if (!context) return selectionCanvas.toDataURL("image/png"); + const selectionContext = selectionCanvas.getContext("2d"); + context.fillStyle = "#fff"; + context.fillRect(0, 0, canvas.width, canvas.height); + if (!selectionContext) return canvas.toDataURL("image/png"); + const selection = selectionContext.getImageData(0, 0, canvas.width, canvas.height); + const mask = context.getImageData(0, 0, canvas.width, canvas.height); + for (let index = 3; index < mask.data.length; index += 4) { + if (selection.data[index] > 0) mask.data[index] = 0; + } + context.putImageData(mask, 0, 0); + return canvas.toDataURL("image/png"); +} diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 54f2fe5..c5a6c77 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -222,7 +222,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) { } } -export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[]) { +export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage) { const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const quality = normalizeQuality(config.quality); const requestSize = resolveRequestSize(quality, config.size); @@ -241,6 +241,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: } const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) }))); files.forEach((file) => formData.append("image", file)); + if (mask) formData.set("mask", dataUrlToFile(mask)); try { const response = await axios.post(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });