画布图片新增提示词重绘对话框
This commit is contained in:
parent
6cf0597227
commit
6b87adc622
@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
@ -82,6 +83,38 @@ const sanitizeFileName = (name: string) => {
|
|||||||
return safe || "image";
|
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
|
* 在图片上绘制标记点,返回带标记的图片 base64
|
||||||
*/
|
*/
|
||||||
@ -177,8 +210,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
|
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
|
||||||
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
||||||
const [resizeDialogOpen, setResizeDialogOpen] = useState(false);
|
const [resizeDialogOpen, setResizeDialogOpen] = useState(false);
|
||||||
|
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
||||||
const [resizeWidth, setResizeWidth] = useState(1024);
|
const [resizeWidth, setResizeWidth] = useState(1024);
|
||||||
const [resizeHeight, setResizeHeight] = useState(1024);
|
const [resizeHeight, setResizeHeight] = useState(1024);
|
||||||
|
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||||
@ -235,6 +270,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
if (!isSelected) {
|
if (!isSelected) {
|
||||||
setTextEditPanelOpen(false);
|
setTextEditPanelOpen(false);
|
||||||
setIsEditingTitle(false);
|
setIsEditingTitle(false);
|
||||||
|
setPromptDialogOpen(false);
|
||||||
}
|
}
|
||||||
}, [isSelected]);
|
}, [isSelected]);
|
||||||
|
|
||||||
@ -243,6 +279,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
setDraftTitle(getImageNodeTitle(data, node.id));
|
setDraftTitle(getImageNodeTitle(data, node.id));
|
||||||
}
|
}
|
||||||
}, [data, isEditingTitle, node.id]);
|
}, [data, isEditingTitle, node.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (promptDialogOpen) {
|
||||||
|
setRegeneratePrompt(data.prompt || "");
|
||||||
|
}
|
||||||
|
}, [data.prompt, promptDialogOpen]);
|
||||||
|
|
||||||
// 🆕 处理图片点击标注
|
// 🆕 处理图片点击标注
|
||||||
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
|
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
|
||||||
@ -668,6 +710,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
'scene-fusion': '详情替换',
|
'scene-fusion': '详情替换',
|
||||||
'annotate': '图片标注',
|
'annotate': '图片标注',
|
||||||
'text-edit': '编辑文字',
|
'text-edit': '编辑文字',
|
||||||
|
'prompt-regenerate': '提示词重绘',
|
||||||
};
|
};
|
||||||
return names[action] || action;
|
return names[action] || action;
|
||||||
};
|
};
|
||||||
@ -704,6 +747,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
await handleOpenTextEdit();
|
await handleOpenTextEdit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "prompt-regenerate") {
|
||||||
|
setRegeneratePrompt(data.prompt || "");
|
||||||
|
setPromptDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const actionName = getActionName(action);
|
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 获取图片数据实现真正的下载(避免跨域问题导致跳转)
|
// 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转)
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (!data.src) return;
|
if (!data.src) return;
|
||||||
@ -1188,6 +1322,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
<Layers className="h-4 w-4 mr-2" />
|
<Layers className="h-4 w-4 mr-2" />
|
||||||
详情替换
|
详情替换
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
提示词重绘
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleAction('text-edit')}>
|
<DropdownMenuItem onClick={() => handleAction('text-edit')}>
|
||||||
<Type className="h-4 w-4 mr-2" />
|
<Type className="h-4 w-4 mr-2" />
|
||||||
编辑文字
|
编辑文字
|
||||||
@ -1290,6 +1428,41 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 提示词重绘对话框 */}
|
||||||
|
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="h-5 w-5" />
|
||||||
|
提示词重绘
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`prompt-regenerate-${node.id}`}>新提示词</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`prompt-regenerate-${node.id}`}
|
||||||
|
value={regeneratePrompt}
|
||||||
|
onChange={(e) => setRegeneratePrompt(e.target.value)}
|
||||||
|
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
|
||||||
|
rows={6}
|
||||||
|
className="resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPromptDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handlePromptRegenerate} disabled={isProcessing || !regeneratePrompt.trim()}>
|
||||||
|
{isProcessing ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||||
|
开始重绘
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
||||||
{isSelected && data.src && typeof document !== 'undefined' && createPortal(
|
{isSelected && data.src && typeof document !== 'undefined' && createPortal(
|
||||||
@ -1428,6 +1601,22 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className="text-xs">编辑文字</TooltipContent>
|
<TooltipContent side="bottom" className="text-xs">编辑文字</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* 提示词重绘 */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||||
|
onClick={() => handleAction('prompt-regenerate')}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">提示词重绘</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* 角度编辑 */}
|
{/* 角度编辑 */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user