mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-26 00:14:30 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b05d759f20 | |||
| 6d3b89c842 | |||
| 51df099936 | |||
| 96cca4d97c | |||
| 75d5af5d8c | |||
| 8a51cc609f |
@@ -2,6 +2,15 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.2.4 - 2026-06-04
|
||||
|
||||
+ [新增] 新增图片反推提示词功能。
|
||||
|
||||
## v0.2.3 - 2026-06-04
|
||||
|
||||
+ [新增] 新增图片蒙版局部修改功能。
|
||||
+ [优化] 优化配置节点@图片功能。
|
||||
|
||||
## v0.2.2 - 2026-06-04
|
||||
|
||||
+ [新增] 新增图片放大工具。
|
||||
|
||||
@@ -5,8 +5,11 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。
|
||||
- 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。
|
||||
- 图片节点悬浮工具栏新增“反推提示词”入口;点击后会在原图右侧创建包含反推预设提示词的文本节点,并继续创建一个已切到“文本”模式的生成配置节点,图片节点和文本节点都会自动连到该配置节点,配置节点会预填包含原图和文本节点引用的组装提示词并自动打开,用户可自行点击配置节点开始生成。
|
||||
- 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。
|
||||
- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框、节点底部 prompt 面板和生成配置预览文本编辑框输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。
|
||||
- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框和节点底部 prompt 面板输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。
|
||||
- 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。
|
||||
- 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。
|
||||
- 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证本地直连和云端渠道的生成、重试、下载和刷新恢复。
|
||||
|
||||
@@ -24,11 +24,13 @@ import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size";
|
||||
import { App, Button, Dropdown, Modal } from "antd";
|
||||
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
|
||||
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
|
||||
import { CanvasConfigComposer } from "../components/canvas-config-composer";
|
||||
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
|
||||
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";
|
||||
@@ -40,7 +42,7 @@ import { CanvasToolbar } from "../components/canvas-toolbar";
|
||||
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
|
||||
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
|
||||
import { useCanvasStore } from "../stores/use-canvas-store";
|
||||
import { buildCanvasResourceReferences, buildInputMentionReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
|
||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
|
||||
import {
|
||||
CanvasNodeType,
|
||||
type CanvasAssistantImage,
|
||||
@@ -87,6 +89,12 @@ const CONNECTION_NODE_HIT_PADDING = 32;
|
||||
const NODE_STATUS_LOADING = "loading" as const;
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const NODE_STATUS_ERROR = "error" as const;
|
||||
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
|
||||
|
||||
要求:
|
||||
1. 只输出提示词正文,不要解释。
|
||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||
|
||||
function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
const spec = getNodeSpec(type);
|
||||
@@ -273,6 +281,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 +585,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;
|
||||
@@ -635,12 +645,6 @@ function InfiniteCanvasPage() {
|
||||
nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections)));
|
||||
return map;
|
||||
}, [connections, nodes]);
|
||||
const configMentionReferencesById = useMemo(() => {
|
||||
const map = new Map<string, ReturnType<typeof buildInputMentionReferences>>();
|
||||
configInputsById.forEach((inputs, nodeId) => map.set(nodeId, buildInputMentionReferences(inputs)));
|
||||
return map;
|
||||
}, [configInputsById]);
|
||||
|
||||
const createNode = useCallback(
|
||||
(type: CanvasNodeType, position?: Position) => {
|
||||
const targetPosition = position || getCanvasCenter();
|
||||
@@ -698,6 +702,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 +735,7 @@ function InfiniteCanvasPage() {
|
||||
setConnections([]);
|
||||
setInfoNodeId(null);
|
||||
setCropNodeId(null);
|
||||
setMaskEditNodeId(null);
|
||||
setAngleNodeId(null);
|
||||
setPreviewNodeId(null);
|
||||
setRunningNodeId(null);
|
||||
@@ -1226,7 +1232,8 @@ function InfiniteCanvasPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return;
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
const isModifierShortcut = event.metaKey || event.ctrlKey;
|
||||
@@ -1285,6 +1292,7 @@ function InfiniteCanvasPage() {
|
||||
setEditingNodeId(null);
|
||||
setInfoNodeId(null);
|
||||
setCropNodeId(null);
|
||||
setMaskEditNodeId(null);
|
||||
setPendingConnectionCreate(null);
|
||||
}
|
||||
};
|
||||
@@ -1439,6 +1447,53 @@ function InfiniteCanvasPage() {
|
||||
[addAsset, message],
|
||||
);
|
||||
|
||||
const createImageReversePromptNodes = useCallback(
|
||||
(node: CanvasNodeData) => {
|
||||
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
|
||||
message.warning("图片节点为空,无法反推提示词");
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = 96;
|
||||
const textSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
|
||||
const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config];
|
||||
const centerY = node.position.y + node.height / 2;
|
||||
const textNode = {
|
||||
...createCanvasNode(
|
||||
CanvasNodeType.Text,
|
||||
{ x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY },
|
||||
{ content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 },
|
||||
),
|
||||
title: "反推提示词",
|
||||
};
|
||||
const configNode = {
|
||||
...createCanvasNode(
|
||||
CanvasNodeType.Config,
|
||||
{ x: textNode.position.x + textNode.width + gap + configSpec.width / 2, y: centerY },
|
||||
{
|
||||
generationMode: "text",
|
||||
model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel,
|
||||
count: 1,
|
||||
composerContent: `参考图片:@[node:${node.id}]\n任务说明:@[node:${textNode.id}]`,
|
||||
},
|
||||
),
|
||||
title: "反推提示词配置",
|
||||
};
|
||||
|
||||
setNodes((prev) => [...prev, textNode, configNode]);
|
||||
setConnections((prev) => [
|
||||
...prev,
|
||||
{ id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id },
|
||||
{ id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id },
|
||||
]);
|
||||
setSelectedNodeIds(new Set([configNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(configNode.id);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[effectiveConfig.model, effectiveConfig.textModel, message],
|
||||
);
|
||||
|
||||
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
|
||||
if (!node.metadata?.content) return;
|
||||
const cropped = await cropDataUrl(node.metadata.content, crop);
|
||||
@@ -1464,6 +1519,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);
|
||||
@@ -1682,12 +1784,13 @@ function InfiniteCanvasPage() {
|
||||
);
|
||||
const effectivePrompt = generationContext.prompt.trim();
|
||||
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
|
||||
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
|
||||
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
let pendingChildIds: string[] = [];
|
||||
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
|
||||
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
|
||||
|
||||
try {
|
||||
if (mode === "image") {
|
||||
@@ -1751,7 +1854,7 @@ function InfiniteCanvasPage() {
|
||||
? isConfigNode
|
||||
? {
|
||||
...node,
|
||||
metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
|
||||
metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
|
||||
}
|
||||
: isEmptyImageNode
|
||||
? {
|
||||
@@ -1902,16 +2005,16 @@ function InfiniteCanvasPage() {
|
||||
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
|
||||
id,
|
||||
type: CanvasNodeType.Text,
|
||||
title: prompt.slice(0, 32) || "Generated Text",
|
||||
title: effectivePrompt.slice(0, 32) || "Generated Text",
|
||||
position: {
|
||||
x: parentPosition.x + parentConfig.width + 96,
|
||||
y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36),
|
||||
},
|
||||
width: textConfig.width,
|
||||
height: textConfig.height,
|
||||
metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 },
|
||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 },
|
||||
}));
|
||||
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
|
||||
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
|
||||
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
|
||||
}
|
||||
|
||||
@@ -2240,32 +2343,39 @@ function InfiniteCanvasPage() {
|
||||
showImageInfo={showImageInfo}
|
||||
resourceLabel={resourceReferenceByNodeId.get(node.id)}
|
||||
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
|
||||
renderPanel={(panelNode) => (
|
||||
<CanvasNodePromptPanel
|
||||
node={panelNode}
|
||||
isRunning={runningNodeId === panelNode.id}
|
||||
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || []}
|
||||
onPromptChange={handleNodePromptChange}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onGenerate={handleGenerateNode}
|
||||
onImageSettingsOpenChange={(open) => {
|
||||
setNodeImageSettingsOpen(open);
|
||||
if (open) setToolbarNodeId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderPanel={(panelNode) =>
|
||||
panelNode.type === CanvasNodeType.Config ? (
|
||||
<CanvasConfigComposer
|
||||
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
|
||||
inputs={configInputsById.get(panelNode.id) || []}
|
||||
onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })}
|
||||
onClose={() => setDialogNodeId(null)}
|
||||
/>
|
||||
) : (
|
||||
<CanvasNodePromptPanel
|
||||
node={panelNode}
|
||||
isRunning={runningNodeId === panelNode.id}
|
||||
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || []}
|
||||
onPromptChange={handleNodePromptChange}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onGenerate={handleGenerateNode}
|
||||
onImageSettingsOpenChange={(open) => {
|
||||
setNodeImageSettingsOpen(open);
|
||||
if (open) setToolbarNodeId(null);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
renderNodeContent={(contentNode) => (
|
||||
<CanvasConfigNodePanel
|
||||
node={contentNode}
|
||||
isRunning={runningNodeId === contentNode.id}
|
||||
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
|
||||
inputs={configInputsById.get(contentNode.id) || []}
|
||||
mentionReferences={configMentionReferencesById.get(contentNode.id) || []}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onTextInputChange={handleNodeContentChange}
|
||||
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
|
||||
onGenerate={(nodeId) => {
|
||||
const target = nodesRef.current.find((item) => item.id === nodeId);
|
||||
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.prompt || "");
|
||||
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -2324,11 +2434,13 @@ 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)}
|
||||
onAngle={(node) => setAngleNodeId(node.id)}
|
||||
onViewImage={(node) => setPreviewNodeId(node.id)}
|
||||
onReversePrompt={createImageReversePromptNodes}
|
||||
onRetry={(node) => void handleRetryNode(node)}
|
||||
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
|
||||
onDelete={(node) => deleteNodes(new Set([node.id]))}
|
||||
@@ -2393,6 +2505,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)}>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
|
||||
import { Button, Image } from "antd";
|
||||
import { FileText, Image as ImageIcon, Music2, Video, X } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||||
|
||||
type CanvasConfigComposerProps = {
|
||||
value: string;
|
||||
inputs: NodeGenerationInput[];
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type Token =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "reference"; nodeId: string };
|
||||
|
||||
type MentionState = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
|
||||
|
||||
export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const composingRef = useRef(false);
|
||||
const [mention, setMention] = useState<MentionState | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const tokens = useMemo(() => parseComposerTokens(value), [value]);
|
||||
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
|
||||
const candidates = useMemo(() => {
|
||||
if (!mention) return [];
|
||||
const query = (mention.query || "").trim().toLowerCase();
|
||||
if (!query) return inputs;
|
||||
return inputs.filter((input) => `${resourceLabel(input, inputs)} ${input.title} ${input.text || ""}`.toLowerCase().includes(query));
|
||||
}, [inputs, mention]);
|
||||
|
||||
useEffect(() => {
|
||||
if (document.activeElement === editorRef.current) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.textContent = "";
|
||||
tokens.forEach((token) => {
|
||||
if (token.type === "text") {
|
||||
editor.append(document.createTextNode(token.value));
|
||||
return;
|
||||
}
|
||||
const input = referenceById.get(token.nodeId);
|
||||
if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview));
|
||||
});
|
||||
}, [inputs, referenceById, theme, tokens]);
|
||||
|
||||
const syncFromEditor = () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const next = serializeEditor(editor);
|
||||
onChange(next);
|
||||
syncMention();
|
||||
};
|
||||
|
||||
const syncMention = () => {
|
||||
const text = textBeforeCaret();
|
||||
const match = /@([^\s@]*)$/.exec(text);
|
||||
if (!match || !inputs.length) {
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
setMention({ query: match[1] || "" });
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const closeMention = () => {
|
||||
setMention(null);
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const insertReference = (input: NodeGenerationInput) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
removeActiveMention();
|
||||
const chip = createReferenceChip(input, inputs, theme, setImagePreview);
|
||||
const space = document.createTextNode(" ");
|
||||
const selection = window.getSelection();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (range) {
|
||||
range.insertNode(space);
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(space);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
} else {
|
||||
editor.append(chip, space);
|
||||
placeCaretAtEnd(editor);
|
||||
}
|
||||
closeMention();
|
||||
onChange(serializeEditor(editor));
|
||||
};
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onMouseDown={stopCanvasInteraction}
|
||||
onPointerDown={stopCanvasInteraction}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<div className="shrink-0 text-xs font-semibold">组装提示词</div>
|
||||
<div className="truncate text-[11px] opacity-55">@ 引用已连接素材,发送前按当前连接重新编号</div>
|
||||
</div>
|
||||
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||||
</div>
|
||||
<div className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接的图片或文本</div> : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="thin-scrollbar min-h-28 w-full overflow-y-auto whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
|
||||
style={{ color: theme.node.text }}
|
||||
onInput={() => {
|
||||
if (!composingRef.current) syncFromEditor();
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
syncFromEditor();
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
|
||||
event.preventDefault();
|
||||
requestAnimationFrame(syncFromEditor);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(syncMention);
|
||||
}}
|
||||
onBlur={() => window.setTimeout(closeMention, 120)}
|
||||
/>
|
||||
{mention && candidates.length ? <MentionMenu inputs={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null}
|
||||
</div>
|
||||
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
function MentionMenu({ inputs, allInputs, activeIndex, theme, onSelect }: { inputs: NodeGenerationInput[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (input: NodeGenerationInput) => void }) {
|
||||
const selectedRef = useRef(false);
|
||||
const selectInput = (input: NodeGenerationInput) => {
|
||||
if (selectedRef.current) return;
|
||||
selectedRef.current = true;
|
||||
onSelect(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-2 top-[calc(100%+6px)] z-[90] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
|
||||
{inputs.map((input, index) => (
|
||||
<button
|
||||
key={input.nodeId}
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
|
||||
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectInput(input);
|
||||
}}
|
||||
>
|
||||
<ResourcePreview input={input} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{resourceLabel(input, allInputs)}</span>
|
||||
<span className="block truncate opacity-65">{input.text || input.title}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourcePreview({ input }: { input: NodeGenerationInput }) {
|
||||
if (input.type === "image" && input.image) return <img src={input.image.dataUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||||
if (input.type === "video" && input.video) return <video src={input.video.url} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||||
const Icon = input.type === "audio" ? Music2 : input.type === "video" ? Video : input.type === "image" ? ImageIcon : FileText;
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationInput[], theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.contentEditable = "false";
|
||||
wrapper.dataset.referenceNodeId = input.nodeId;
|
||||
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
|
||||
Object.assign(wrapper.style, chipStyle(theme));
|
||||
if (input.type === "image" && input.image) {
|
||||
const image = document.createElement("img");
|
||||
image.src = input.image.dataUrl;
|
||||
image.alt = input.title;
|
||||
image.className = "size-6 rounded object-cover";
|
||||
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
|
||||
wrapper.appendChild(image);
|
||||
wrapper.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onImagePreview(input.image?.dataUrl || "");
|
||||
});
|
||||
} else {
|
||||
wrapper.title = input.text || input.title;
|
||||
const text = document.createElement("span");
|
||||
text.className = "block truncate";
|
||||
text.textContent = input.type === "text" ? input.text || input.title : input.title;
|
||||
wrapper.appendChild(text);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function serializeEditor(editor: HTMLElement) {
|
||||
return serializeNodes(editor.childNodes).replace(/\uFEFF/g, "");
|
||||
}
|
||||
|
||||
function serializeNodes(nodes: NodeListOf<ChildNode>) {
|
||||
let result = "";
|
||||
nodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
const nodeId = node.dataset.referenceNodeId;
|
||||
if (nodeId) result += `@[node:${nodeId}]`;
|
||||
else if (node.tagName === "BR") result += "\n";
|
||||
else result += serializeNodes(node.childNodes);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function removeActiveMention() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = textBeforeCaret();
|
||||
const match = /@([^\s@]*)$/.exec(text);
|
||||
if (!match) return;
|
||||
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
function deleteAdjacentReference(key: string) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount || !selection.isCollapsed) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = adjacentReferenceNode(range, key);
|
||||
if (!target) return false;
|
||||
const nextCaretNode = document.createTextNode("");
|
||||
target.replaceWith(nextCaretNode);
|
||||
range.setStart(nextCaretNode, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function adjacentReferenceNode(range: Range, key: string) {
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
const previous = key === "Backspace";
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
const text = container.textContent || "";
|
||||
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
|
||||
return findReferenceSibling(container, previous);
|
||||
}
|
||||
const children = Array.from(container.childNodes);
|
||||
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
|
||||
}
|
||||
|
||||
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
|
||||
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
|
||||
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
|
||||
return current instanceof HTMLElement && current.dataset.referenceNodeId ? current : null;
|
||||
}
|
||||
|
||||
function textBeforeCaret() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return "";
|
||||
const range = selection.getRangeAt(0).cloneRange();
|
||||
const editor = closestEditor(range.startContainer);
|
||||
if (!editor) return "";
|
||||
range.setStart(editor, 0);
|
||||
return range.toString();
|
||||
}
|
||||
|
||||
function closestEditor(node: Node) {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
return element?.closest("[contenteditable='true']") || null;
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(element: HTMLElement) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function parseComposerTokens(value: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) {
|
||||
if (match.index === undefined) continue;
|
||||
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
|
||||
tokens.push({ type: "reference", nodeId: match[1] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
|
||||
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
||||
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
|
||||
if (input.type === "image") return `图片${index + 1}`;
|
||||
if (input.type === "video") return `视频${index + 1}`;
|
||||
if (input.type === "audio") return `音频${index + 1}`;
|
||||
return `文本${index + 1}`;
|
||||
}
|
||||
|
||||
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
|
||||
return { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
}
|
||||
@@ -1,41 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Video } from "lucide-react";
|
||||
import { App, Button, Empty, Modal, Segmented } from "antd";
|
||||
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react";
|
||||
import { Button, Segmented } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
|
||||
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
|
||||
|
||||
type CanvasConfigNodePanelProps = {
|
||||
node: CanvasNodeData;
|
||||
isRunning: boolean;
|
||||
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
|
||||
inputs: NodeGenerationInput[];
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
|
||||
onTextInputChange: (nodeId: string, content: string) => void;
|
||||
onGenerate: (nodeId: string) => void;
|
||||
onComposerToggle: () => void;
|
||||
};
|
||||
|
||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, mentionReferences = [], onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) {
|
||||
const { message } = App.useApp();
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [editingTextId, setEditingTextId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle }: CanvasConfigNodePanelProps) {
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
@@ -45,37 +33,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, m
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 });
|
||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
const textInputs = inputs.filter((input) => input.type === "text");
|
||||
const imageInputs = inputs.filter((input) => input.type === "image");
|
||||
const videoInputs = inputs.filter((input) => input.type === "video");
|
||||
const audioInputs = inputs.filter((input) => input.type === "audio");
|
||||
const editingMentionReferences = editingTextId ? mentionReferences.filter((reference) => reference.nodeId !== editingTextId) : mentionReferences;
|
||||
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
|
||||
const canGenerate = mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput;
|
||||
|
||||
const moveInput = (input: NodeGenerationInput, offset: number) => {
|
||||
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
||||
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
|
||||
const targetInput = sameTypeInputs[sameTypeIndex + offset];
|
||||
if (!targetInput) return;
|
||||
const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
|
||||
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
|
||||
const next = [...inputs];
|
||||
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
||||
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
|
||||
message.success("已调整输入顺序");
|
||||
};
|
||||
const startTextEdit = (input: NodeGenerationInput) => {
|
||||
setEditingTextId(input.nodeId);
|
||||
setEditingText(input.text || "");
|
||||
};
|
||||
const saveTextEdit = () => {
|
||||
if (!editingTextId) return;
|
||||
onTextInputChange(editingTextId, editingText);
|
||||
setEditingText("");
|
||||
setEditingTextId(null);
|
||||
message.success("已保存文本提示词");
|
||||
};
|
||||
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
|
||||
const canGenerate = hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
@@ -134,9 +94,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, m
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={() => setPreviewOpen(true)}>
|
||||
<Eye className="size-3.5" />
|
||||
预览
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
|
||||
<Settings2 className="size-3.5" />
|
||||
组装提示词
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -167,238 +127,6 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, m
|
||||
<span>开始生成</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Modal
|
||||
title="输入预览"
|
||||
open={previewOpen}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
footer={null}
|
||||
centered
|
||||
width={860}
|
||||
mask={{ closable: true }}
|
||||
keyboard
|
||||
destroyOnHidden
|
||||
modalRender={(modal) => (
|
||||
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{modal}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
|
||||
{inputs.length ? (
|
||||
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
|
||||
<div className="shrink-0">
|
||||
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
|
||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||
{imageInputs.map((input, index) => (
|
||||
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PreviewSection title="参考视频" count={videoInputs.length} empty="暂无参考视频">
|
||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||
{videoInputs.map((input, index) => (
|
||||
<VideoSortCard key={input.nodeId} input={input} videoIndex={index} videoTotal={videoInputs.length} theme={theme} onMove={moveInput} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PreviewSection title="参考音频" count={audioInputs.length} empty="暂无参考音频">
|
||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||
{audioInputs.map((input, index) => (
|
||||
<AudioSortCard key={input.nodeId} input={input} audioIndex={index} audioTotal={audioInputs.length} theme={theme} onMove={moveInput} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
|
||||
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
|
||||
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
|
||||
<div className="space-y-1.5">
|
||||
{textInputs.map((input, index) => (
|
||||
<TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
{editingTextId ? (
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">编辑文本提示词</div>
|
||||
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>
|
||||
收起
|
||||
</Button>
|
||||
</div>
|
||||
<CanvasResourceMentionTextarea containerClassName="min-h-0 flex-1" className="thin-scrollbar h-full w-full resize-none rounded-md border px-2 py-1 text-xs leading-5 outline-none" style={{ background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }} value={editingText} references={editingMentionReferences} onChange={setEditingText} />
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button size="small" onClick={() => setEditingTextId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={saveTextEdit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
|
||||
<Edit3 className="mx-auto mb-2 size-5" />
|
||||
选择一条文本后在这里编辑
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
|
||||
<div className="text-xs font-semibold">{title}</div>
|
||||
<div className="text-[11px] opacity-50">{count} 个</div>
|
||||
</div>
|
||||
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TextSortCard({
|
||||
input,
|
||||
textIndex,
|
||||
textTotal,
|
||||
inputs,
|
||||
theme,
|
||||
onMove,
|
||||
onEdit,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
textIndex: number;
|
||||
textTotal: number;
|
||||
inputs: NodeGenerationInput[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
onEdit: (input: NodeGenerationInput) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[10px] font-medium opacity-50">文本 {textIndex + 1}</div>
|
||||
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
|
||||
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSortCard({
|
||||
input,
|
||||
imageIndex,
|
||||
imageTotal,
|
||||
inputs,
|
||||
theme,
|
||||
onMove,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
imageIndex: number;
|
||||
imageTotal: number;
|
||||
inputs: NodeGenerationInput[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
}) {
|
||||
if (!input.image) return null;
|
||||
return (
|
||||
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative">
|
||||
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageReferenceLabel(imageIndex)}</span>
|
||||
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoSortCard({
|
||||
input,
|
||||
videoIndex,
|
||||
videoTotal,
|
||||
theme,
|
||||
onMove,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
videoIndex: number;
|
||||
videoTotal: number;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
}) {
|
||||
if (!input.video) return null;
|
||||
return (
|
||||
<div className="w-32 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative">
|
||||
<video src={input.video.url} className="aspect-video w-full bg-black object-cover" muted preload="metadata" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{seedanceReferenceLabel("video", videoIndex)}</span>
|
||||
<HorizontalOrderButtons index={videoIndex} total={videoTotal} onMove={(offset) => onMove(input, offset)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioSortCard({
|
||||
input,
|
||||
audioIndex,
|
||||
audioTotal,
|
||||
theme,
|
||||
onMove,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
audioIndex: number;
|
||||
audioTotal: number;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
}) {
|
||||
if (!input.audio) return null;
|
||||
return (
|
||||
<div className="w-48 shrink-0 rounded-lg border p-2" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 text-[11px] opacity-70">
|
||||
<Music2 className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{input.title}</span>
|
||||
</div>
|
||||
<audio src={input.audio.url} controls className="h-8 w-full" preload="metadata" />
|
||||
<div className="mt-1 flex justify-between">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowLeft className="size-3" />} disabled={audioIndex <= 0} onClick={() => onMove(input, -1)} />
|
||||
<span className="text-[10px] opacity-45">{seedanceReferenceLabel("audio", audioIndex)}</span>
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowRight className="size-3" />} disabled={audioIndex >= audioTotal - 1} onClick={() => onMove(input, 1)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
||||
return (
|
||||
<div className="absolute inset-x-1 bottom-1 flex justify-between">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Camera, Copy, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react";
|
||||
import { Brush, Camera, Copy, FileText, 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" | "reversePrompt" | "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;
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
onCopyPrompt: (node: CanvasNodeData) => void;
|
||||
onReversePrompt: (node: CanvasNodeData) => void;
|
||||
};
|
||||
|
||||
export type ImageToolDefinition = {
|
||||
@@ -35,7 +37,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-v5";
|
||||
|
||||
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"];
|
||||
|
||||
@@ -49,6 +51,15 @@ export const imageToolDefinitions: ImageToolDefinition[] = [
|
||||
icon: () => <Copy className="size-4" />,
|
||||
run: (node, handlers) => handlers.onCopyPrompt(node),
|
||||
},
|
||||
{
|
||||
id: "reversePrompt",
|
||||
defaultVisible: true,
|
||||
panelLabel: "反推提示词",
|
||||
label: "反推提示词",
|
||||
title: "创建反推提示词的文本和配置节点",
|
||||
icon: () => <FileText className="size-4" />,
|
||||
run: (node, handlers) => handlers.onReversePrompt(node),
|
||||
},
|
||||
{
|
||||
id: "replace",
|
||||
defaultVisible: true,
|
||||
@@ -68,6 +79,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,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ChatCompletionMessage } from "@/services/api/image";
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
|
||||
@@ -27,6 +29,11 @@ export type NodeGenerationInput = {
|
||||
|
||||
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
|
||||
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
||||
const sourceNode = nodes.find((node) => node.id === nodeId);
|
||||
if (sourceNode?.type === CanvasNodeType.Config && Boolean(sourceNode.metadata?.composerContent?.trim())) {
|
||||
return buildComposerGenerationContext(inputs, prompt);
|
||||
}
|
||||
|
||||
const upstreamText = inputs
|
||||
.map((input) => input.text)
|
||||
.filter(Boolean)
|
||||
@@ -47,6 +54,65 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
|
||||
};
|
||||
}
|
||||
|
||||
function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: string): NodeGenerationContext {
|
||||
const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input]));
|
||||
const selectedInputs: NodeGenerationInput[] = [];
|
||||
const labelByNodeId = new Map<string, string>();
|
||||
const textBlocks: string[] = [];
|
||||
const counts = { image: 0, video: 0, audio: 0, text: 0 };
|
||||
let hasToken = false;
|
||||
let lastIndex = 0;
|
||||
let nextPrompt = "";
|
||||
|
||||
for (const match of prompt.matchAll(/@\[node:([^\]]+)\]/g)) {
|
||||
if (match.index === undefined) continue;
|
||||
hasToken = true;
|
||||
nextPrompt += prompt.slice(lastIndex, match.index);
|
||||
const input = inputByNodeId.get(match[1]);
|
||||
if (input) {
|
||||
let label = labelByNodeId.get(input.nodeId);
|
||||
if (!label) {
|
||||
label = generationLabel(input.type, counts[input.type]++);
|
||||
labelByNodeId.set(input.nodeId, label);
|
||||
if (input.type === "text") textBlocks.push(`【${label}】\n${input.text || ""}`);
|
||||
else selectedInputs.push(input);
|
||||
}
|
||||
nextPrompt += input.type === "text" ? `【${label}】` : label;
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
nextPrompt += prompt.slice(lastIndex);
|
||||
if (textBlocks.length) nextPrompt = `${nextPrompt.trim()}\n\n${textBlocks.join("\n\n")}`;
|
||||
const referenceImages = selectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
||||
const referenceVideos = selectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
|
||||
const referenceAudios = selectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
|
||||
|
||||
if (!hasToken) {
|
||||
return {
|
||||
prompt,
|
||||
referenceImages: [],
|
||||
referenceVideos: [],
|
||||
referenceAudios: [],
|
||||
textCount: 0,
|
||||
imageCount: 0,
|
||||
videoCount: 0,
|
||||
audioCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: nextPrompt,
|
||||
referenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
textCount: counts.text,
|
||||
imageCount: referenceImages.length,
|
||||
videoCount: referenceVideos.length,
|
||||
audioCount: referenceAudios.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
|
||||
return getGenerationResourceNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
||||
const image = readReferenceImage(node);
|
||||
@@ -84,6 +150,13 @@ function readNodeTextInput(node: CanvasNodeData) {
|
||||
return node.metadata?.prompt || "";
|
||||
}
|
||||
|
||||
function generationLabel(type: NodeGenerationInput["type"], index: number) {
|
||||
if (type === "image") return imageReferenceLabel(index);
|
||||
if (type === "video") return seedanceReferenceLabel("video", index);
|
||||
if (type === "audio") return seedanceReferenceLabel("audio", index);
|
||||
return `文本${index + 1}`;
|
||||
}
|
||||
|
||||
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
|
||||
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
|
||||
return {
|
||||
|
||||
@@ -26,11 +26,13 @@ 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;
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
onReversePrompt: (node: CanvasNodeData) => void;
|
||||
onRetry: (node: CanvasNodeData) => void;
|
||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||
onDelete: (node: CanvasNodeData) => void;
|
||||
@@ -60,11 +62,13 @@ export function CanvasNodeHoverToolbar({
|
||||
onUpload,
|
||||
onDownload,
|
||||
onSaveAsset,
|
||||
onMaskEdit,
|
||||
onCrop,
|
||||
onUpscale,
|
||||
onSuperResolve,
|
||||
onAngle,
|
||||
onViewImage,
|
||||
onReversePrompt,
|
||||
onRetry,
|
||||
onToggleFreeResize,
|
||||
onDelete,
|
||||
@@ -117,7 +121,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, onReversePrompt });
|
||||
|
||||
function openImageToolSettings() {
|
||||
onKeep(node.id);
|
||||
@@ -137,7 +141,7 @@ export function CanvasNodeHoverToolbar({
|
||||
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
||||
...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
|
||||
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onInfo(node) }] : []),
|
||||
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
|
||||
...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
|
||||
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -322,7 +322,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
|
||||
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
|
||||
|
||||
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
{showPanel && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export type CanvasImageGenerationType = "generation" | "edit";
|
||||
|
||||
export type CanvasNodeMetadata = {
|
||||
content?: string;
|
||||
composerContent?: string;
|
||||
prompt?: string;
|
||||
status?: CanvasNodeStatus;
|
||||
errorDetails?: string;
|
||||
@@ -51,7 +52,6 @@ export type CanvasNodeMetadata = {
|
||||
batchUsesReferenceImages?: boolean;
|
||||
primaryImageId?: string;
|
||||
imageBatchExpanded?: boolean;
|
||||
inputOrder?: string[];
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
|
||||
@@ -15,16 +15,6 @@ export type CanvasResourceReference = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type MentionInput = {
|
||||
nodeId: string;
|
||||
type: CanvasResourceKind;
|
||||
title: string;
|
||||
text?: string;
|
||||
image?: { dataUrl: string };
|
||||
video?: { url: string };
|
||||
audio?: { url: string };
|
||||
};
|
||||
|
||||
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null) {
|
||||
const contextNodes = contextNodeId ? getMentionResourceNodes(contextNodeId, nodes, connections) : [];
|
||||
const globalReferences = labelResourceNodes(nodes.filter(isResourceNode), false);
|
||||
@@ -36,23 +26,6 @@ export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNo
|
||||
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
|
||||
}
|
||||
|
||||
export function buildInputMentionReferences(inputs: MentionInput[]) {
|
||||
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0 };
|
||||
return inputs.map((input): CanvasResourceReference => {
|
||||
const index = counts[input.type]++;
|
||||
return {
|
||||
id: input.nodeId,
|
||||
nodeId: input.nodeId,
|
||||
kind: input.type,
|
||||
label: labelForKind(input.type, index),
|
||||
title: input.title || labelForKind(input.type, index),
|
||||
previewUrl: input.image?.dataUrl || input.video?.url || input.audio?.url,
|
||||
text: input.text,
|
||||
active: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
|
||||
if (configInputs.length) return configInputs;
|
||||
@@ -71,13 +44,10 @@ export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData
|
||||
}
|
||||
|
||||
function getContextResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const target = nodes.find((node) => node.id === nodeId);
|
||||
const upstreamNodes = connections
|
||||
return connections
|
||||
.filter((connection) => connection.toNodeId === nodeId)
|
||||
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node && isResourceNode(node)));
|
||||
const order = target?.metadata?.inputOrder || [];
|
||||
return [...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)), ...upstreamNodes.filter((node) => !order.includes(node.id))];
|
||||
}
|
||||
|
||||
function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user