diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 4c29dbd..d64ad93 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip"; import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; @@ -82,6 +83,38 @@ const sanitizeFileName = (name: string) => { return safe || "image"; }; +const COMMON_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +] as const; + +function guessNearestAspectRatio(width: number, height: number): string { + if (!width || !height) return "auto"; + const target = width / height; + let best = "auto"; + let minDiff = Number.POSITIVE_INFINITY; + + for (const ratio of COMMON_ASPECT_RATIOS) { + const [w, h] = ratio.split(":").map(Number); + const value = w / h; + const diff = Math.abs(target - value); + if (diff < minDiff) { + minDiff = diff; + best = ratio; + } + } + + return best; +} + /** * 在图片上绘制标记点,返回带标记的图片 base64 */ @@ -177,8 +210,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const [angleDialogOpen, setAngleDialogOpen] = useState(false); const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); const [resizeDialogOpen, setResizeDialogOpen] = useState(false); + const [promptDialogOpen, setPromptDialogOpen] = useState(false); const [resizeWidth, setResizeWidth] = useState(1024); const [resizeHeight, setResizeHeight] = useState(1024); + const [regeneratePrompt, setRegeneratePrompt] = useState(""); const [isUploading, setIsUploading] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); @@ -235,6 +270,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { if (!isSelected) { setTextEditPanelOpen(false); setIsEditingTitle(false); + setPromptDialogOpen(false); } }, [isSelected]); @@ -243,6 +279,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { setDraftTitle(getImageNodeTitle(data, node.id)); } }, [data, isEditingTitle, node.id]); + + useEffect(() => { + if (promptDialogOpen) { + setRegeneratePrompt(data.prompt || ""); + } + }, [data.prompt, promptDialogOpen]); // 🆕 处理图片点击标注 const handlePickerClick = useCallback(async (e: React.MouseEvent) => { @@ -668,6 +710,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { 'scene-fusion': '详情替换', 'annotate': '图片标注', 'text-edit': '编辑文字', + 'prompt-regenerate': '提示词重绘', }; return names[action] || action; }; @@ -704,6 +747,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { await handleOpenTextEdit(); return; } + if (action === "prompt-regenerate") { + setRegeneratePrompt(data.prompt || ""); + setPromptDialogOpen(true); + return; + } const actionName = getActionName(action); @@ -955,6 +1003,92 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { } }; + // 基于当前图片+新提示词重绘 + const handlePromptRegenerate = async () => { + if (!data.src) return; + if (!isHttpImageUrl(data.src)) { + toast.error("仅支持 URL 图片,不允许 base64 传输"); + return; + } + const promptText = regeneratePrompt.trim(); + if (!promptText) { + toast.error("请输入提示词"); + return; + } + if (isProcessing) { + toast.info("正在处理中,请稍候..."); + return; + } + + setPromptDialogOpen(false); + setIsProcessing(true); + + const newNodeId = addNode({ + type: "image", + position: { + x: node.position.x + node.size.width + 20, + y: node.position.y, + }, + size: { width: node.size.width, height: node.size.height }, + data: { + src: "", + status: "generating", + prompt: promptText, + title: `${displayTitle}-重绘`, + originalSrc: data.src, + }, + connections: [node.id], + }); + + try { + const aspectRatio = guessNearestAspectRatio(node.size.width, node.size.height); + const result = await imageGenerationService.generateImage({ + prompt: promptText, + referenceImage: data.src, + aspectRatio, + imageCount: 1, + }); + + const image = result.data?.images?.[0]; + if (result.success && image?.originalUrl) { + updateNode(newNodeId, { + data: { + src: image.originalUrl, + status: "completed", + prompt: promptText, + title: `${promptText.slice(0, 24) || displayTitle}-重绘`, + originalSrc: data.src, + }, + }); + toast.success("已根据新提示词生成图片"); + } else { + updateNode(newNodeId, { + data: { + src: "", + status: "failed", + prompt: "提示词重绘失败", + title: `${displayTitle}-重绘失败`, + originalSrc: data.src, + }, + }); + toast.error(result.error || "重绘失败"); + } + } catch (error) { + updateNode(newNodeId, { + data: { + src: "", + status: "failed", + prompt: "提示词重绘失败", + title: `${displayTitle}-重绘失败`, + originalSrc: data.src, + }, + }); + toast.error("重绘失败"); + } finally { + setIsProcessing(false); + } + }; + // 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转) const handleDownload = async () => { if (!data.src) return; @@ -1188,6 +1322,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { 详情替换 + handleAction('prompt-regenerate')}> + + 提示词重绘 + handleAction('text-edit')}> 编辑文字 @@ -1290,6 +1428,41 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { + + {/* 提示词重绘对话框 */} + + + + + + 提示词重绘 + + +
+

+ 在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。 +

+
+ +