feat(toolbar): add mask editing feature to image node toolbar for localized modifications

This commit is contained in:
HouYunFei
2026-06-04 16:12:38 +08:00
parent 0adf547db9
commit 8a51cc609f
7 changed files with 356 additions and 5 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
+ [新增] 新增图片蒙版局部修改功能。
## v0.2.2 - 2026-06-04
+ [新增] 新增图片放大工具。
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。
- 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。
- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框、节点底部 prompt 面板和生成配置预览文本编辑框输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。
- 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。
@@ -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<string | null>(null);
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
const [angleNodeId, setAngleNodeId] = useState<string | null>(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 ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open={Boolean(maskEditNode)} onClose={() => setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> : null}
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open={Boolean(upscaleNode)} onClose={() => setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null}
<Modal title="AI 超分" open={Boolean(superResolveNode?.metadata?.content)} centered footer={null} onCancel={() => setSuperResolveNodeId(null)}>
@@ -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: () => <Brush className="size-4" />,
run: (node, handlers) => handlers.onMaskEdit(node),
},
{
id: "crop",
defaultVisible: true,
@@ -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);
@@ -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<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(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<DrawMode>("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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden>
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
<div className="flex min-h-[360px] items-center justify-center rounded-xl border border-black/10 bg-transparent p-0 dark:border-white/10">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
{image ? (
<>
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
<canvas
ref={previewCanvasRef}
width={image.width}
height={image.height}
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
onPointerDown={startDraw}
onPointerMove={moveDraw}
onPointerUp={stopDraw}
onPointerCancel={stopDraw}
/>
</>
) : null}
</div>
</div>
<div className="flex min-h-[360px] flex-col gap-5">
<div>
<h2 className="text-xl font-semibold"></h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
</Button>
<Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75"></span>
<span className="font-semibold">{brushSize}px</span>
</div>
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75"></div>
<Input.TextArea
rows={6}
value={prompt}
status={error && !prompt.trim() ? "error" : undefined}
placeholder="例如:把选中区域改成金属材质,保持原图光影"
onChange={(event) => {
setPrompt(event.target.value);
setError("");
}}
/>
{error ? <div className="text-xs font-medium text-[#ef4444]">{error}</div> : null}
</div>
<div className="mt-auto flex items-center justify-between gap-2">
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
</Button>
<div className="flex items-center gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>
</Button>
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
AI
</Button>
</div>
</div>
</div>
</div>
</Modal>
);
}
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");
}
+2 -1
View File
@@ -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<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });