|
|
@ -30,8 +30,19 @@ import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHan |
|
|
import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount"; |
|
|
import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount"; |
|
|
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule"; |
|
|
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule"; |
|
|
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal"; |
|
|
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal"; |
|
|
|
|
|
import { MediaCropOverlay, type MediaCropAspectRatio, type MediaCropConfirmPayload } from "@/components/media/MediaCropOverlay"; |
|
|
|
|
|
import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar"; |
|
|
|
|
|
import { createCroppedImageBlob } from "@/components/media/imageCropUtils"; |
|
|
|
|
|
import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; |
|
|
|
|
|
|
|
|
const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600]; |
|
|
const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600]; |
|
|
|
|
|
const IMAGE_MEDIA_EDIT_TOOLS: MediaEditToolOption[] = [ |
|
|
|
|
|
{ key: MEDIA_EDIT_TOOL.crop, label: "裁剪" }, |
|
|
|
|
|
{ key: MEDIA_EDIT_TOOL.splitGrid, label: "九宫格" }, |
|
|
|
|
|
{ key: MEDIA_EDIT_TOOL.repaint, label: "重绘" }, |
|
|
|
|
|
{ key: MEDIA_EDIT_TOOL.erase, label: "擦除" }, |
|
|
|
|
|
{ key: MEDIA_EDIT_TOOL.cutout, label: "抠图" }, |
|
|
|
|
|
]; |
|
|
|
|
|
|
|
|
function isSameSelectedModel(a: SelectedModel | undefined, b: SelectedModel | undefined): boolean { |
|
|
function isSameSelectedModel(a: SelectedModel | undefined, b: SelectedModel | undefined): boolean { |
|
|
return Boolean(a?.provider && b?.provider && a.provider === b.provider && a.modelId === b.modelId); |
|
|
return Boolean(a?.provider && b?.provider && a.provider === b.provider && a.modelId === b.modelId); |
|
|
@ -55,6 +66,10 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV |
|
|
const adaptiveOutputImage = useAdaptiveImageSrc(displayImage, id); |
|
|
const adaptiveOutputImage = useAdaptiveImageSrc(displayImage, id); |
|
|
const outputImageSrc = adaptiveOutputImage; |
|
|
const outputImageSrc = adaptiveOutputImage; |
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); |
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); |
|
|
|
|
|
const addNode = useWorkflowStore((state) => state.addNode); |
|
|
|
|
|
const onConnect = useWorkflowStore((state) => state.onConnect); |
|
|
|
|
|
const getNodeById = useWorkflowStore((state) => state.getNodeById); |
|
|
|
|
|
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); |
|
|
const openInlinePanel = useNodeInlinePanelStore((state) => state.openPanel); |
|
|
const openInlinePanel = useNodeInlinePanelStore((state) => state.openPanel); |
|
|
const updateImagePreference = useGenerationPreferenceStore((state) => state.updateImagePreference); |
|
|
const updateImagePreference = useGenerationPreferenceStore((state) => state.updateImagePreference); |
|
|
const popiserverImageModels = useModelStore((state) => state.byKind.image); |
|
|
const popiserverImageModels = useModelStore((state) => state.byKind.image); |
|
|
@ -62,10 +77,18 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV |
|
|
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); |
|
|
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); |
|
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false); |
|
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false); |
|
|
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false); |
|
|
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false); |
|
|
|
|
|
const [isCropping, setIsCropping] = useState(false); |
|
|
|
|
|
const [activeMediaTool, setActiveMediaTool] = useState<MediaEditTool>(MEDIA_EDIT_TOOL.crop); |
|
|
|
|
|
const [cropAspectRatio, setCropAspectRatio] = useState<MediaCropAspectRatio>("original"); |
|
|
|
|
|
const [cropPayload, setCropPayload] = useState<MediaCropConfirmPayload | null>(null); |
|
|
|
|
|
const [isCropUploading, setIsCropUploading] = useState(false); |
|
|
|
|
|
const [cropImageDimensions, setCropImageDimensions] = useState<{ width: number; height: number } | null>(null); |
|
|
|
|
|
const imageRef = useRef<HTMLImageElement>(null); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
useEffect(() => { |
|
|
setDisplayFallbackImage(null); |
|
|
setDisplayFallbackImage(null); |
|
|
setImageRetryNonce(0); |
|
|
setImageRetryNonce(0); |
|
|
|
|
|
setCropImageDimensions(null); |
|
|
}, [preferredDisplayImage]); |
|
|
}, [preferredDisplayImage]); |
|
|
|
|
|
|
|
|
// Register browse callback for floating header button
|
|
|
// Register browse callback for floating header button
|
|
|
@ -166,6 +189,65 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV |
|
|
}); |
|
|
}); |
|
|
}, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, updateNodeData]); |
|
|
}, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, updateNodeData]); |
|
|
|
|
|
|
|
|
|
|
|
const handleConfirmCrop = useCallback(async (payload: MediaCropConfirmPayload) => { |
|
|
|
|
|
const sourceImage = nodeData.outputImage; |
|
|
|
|
|
if (!sourceImage) return; |
|
|
|
|
|
|
|
|
|
|
|
let blob: Blob | null = null; |
|
|
|
|
|
try { |
|
|
|
|
|
blob = await createCroppedImageBlob(sourceImage, payload); |
|
|
|
|
|
} catch { |
|
|
|
|
|
alert(t("imageInput.cropFailed")); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (!blob) { |
|
|
|
|
|
alert(t("imageInput.cropFailed")); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
setIsCropUploading(true); |
|
|
|
|
|
try { |
|
|
|
|
|
const filename = `crop-${Date.now()}.png`; |
|
|
|
|
|
const uploaded = await uploadBlobToGatewayMedia(blob, filename, "image/png", "image"); |
|
|
|
|
|
const currentNode = getNodeById(id); |
|
|
|
|
|
const currentPosition = currentNode?.position ?? { x: 0, y: 0 }; |
|
|
|
|
|
const currentWidth = typeof currentNode?.width === "number" |
|
|
|
|
|
? currentNode.width |
|
|
|
|
|
: typeof currentNode?.style?.width === "number" |
|
|
|
|
|
? currentNode.style.width |
|
|
|
|
|
: 300; |
|
|
|
|
|
const nextNodeId = addNode("smartImage", { |
|
|
|
|
|
x: currentPosition.x + currentWidth + 220, |
|
|
|
|
|
y: currentPosition.y, |
|
|
|
|
|
}, { |
|
|
|
|
|
image: uploaded.url, |
|
|
|
|
|
imageRef: undefined, |
|
|
|
|
|
previewImage: uploaded.previewUrl, |
|
|
|
|
|
filename, |
|
|
|
|
|
dimensions: { width: payload.outputWidth, height: payload.outputHeight }, |
|
|
|
|
|
label: t("imageInput.crop"), |
|
|
|
|
|
derivedImage: { |
|
|
|
|
|
sourceNodeId: id, |
|
|
|
|
|
operation: "crop", |
|
|
|
|
|
}, |
|
|
|
|
|
disablePreviewModal: true, |
|
|
|
|
|
}); |
|
|
|
|
|
onConnect({ |
|
|
|
|
|
source: id, |
|
|
|
|
|
sourceHandle: SINGLE_OUTPUT_HANDLE_ID, |
|
|
|
|
|
target: nextNodeId, |
|
|
|
|
|
targetHandle: SINGLE_INPUT_HANDLE_ID, |
|
|
|
|
|
}); |
|
|
|
|
|
selectSingleNode(nextNodeId); |
|
|
|
|
|
setIsCropping(false); |
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
alert(error instanceof Error ? error.message : t("imageInput.cropFailed")); |
|
|
|
|
|
} finally { |
|
|
|
|
|
setIsCropUploading(false); |
|
|
|
|
|
} |
|
|
|
|
|
}, [addNode, getNodeById, id, nodeData.outputImage, onConnect, selectSingleNode, t]); |
|
|
|
|
|
|
|
|
const handleCarouselPrevious = useCallback(async () => { |
|
|
const handleCarouselPrevious = useCallback(async () => { |
|
|
const history = nodeData.imageHistory || []; |
|
|
const history = nodeData.imageHistory || []; |
|
|
if (history.length === 0 || isLoadingCarouselImage) return; |
|
|
if (history.length === 0 || isLoadingCarouselImage) return; |
|
|
@ -290,8 +372,28 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV |
|
|
|
|
|
|
|
|
const selectedNodeCount = useSelectedNodeCount(); |
|
|
const selectedNodeCount = useSelectedNodeCount(); |
|
|
const showSelectedActions = Boolean(selected && selectedNodeCount === 1 && nodeData.outputImage); |
|
|
const showSelectedActions = Boolean(selected && selectedNodeCount === 1 && nodeData.outputImage); |
|
|
|
|
|
const renderedImageSrc = isCropping ? nodeData.outputImage : outputImageSrc; |
|
|
const selectedActions: NodeActionCapsuleAction[] = nodeData.outputImage |
|
|
const selectedActions: NodeActionCapsuleAction[] = nodeData.outputImage |
|
|
? [ |
|
|
? [ |
|
|
|
|
|
{ |
|
|
|
|
|
key: "crop", |
|
|
|
|
|
icon: ( |
|
|
|
|
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> |
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 3v12a3 3 0 0 0 3 3h12" /> |
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 6h12a3 3 0 0 1 3 3v12" /> |
|
|
|
|
|
</svg> |
|
|
|
|
|
), |
|
|
|
|
|
label: t("imageInput.crop"), |
|
|
|
|
|
onClick: (event) => { |
|
|
|
|
|
event.preventDefault(); |
|
|
|
|
|
event.stopPropagation(); |
|
|
|
|
|
setActiveMediaTool(MEDIA_EDIT_TOOL.crop); |
|
|
|
|
|
setCropAspectRatio("original"); |
|
|
|
|
|
setCropPayload(null); |
|
|
|
|
|
setCropImageDimensions(null); |
|
|
|
|
|
setIsCropping(true); |
|
|
|
|
|
}, |
|
|
|
|
|
}, |
|
|
{ |
|
|
{ |
|
|
key: "multi-angle", |
|
|
key: "multi-angle", |
|
|
icon: ( |
|
|
icon: ( |
|
|
@ -392,7 +494,26 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV |
|
|
handleType="image" |
|
|
handleType="image" |
|
|
/> |
|
|
/> |
|
|
|
|
|
|
|
|
{showSelectedActions && <NodeActionCapsule actions={selectedActions} />} |
|
|
{showSelectedActions && !isCropping && <NodeActionCapsule actions={selectedActions} />} |
|
|
|
|
|
|
|
|
|
|
|
{isCropping && ( |
|
|
|
|
|
<MediaEditToolbar |
|
|
|
|
|
activeTool={activeMediaTool} |
|
|
|
|
|
tools={IMAGE_MEDIA_EDIT_TOOLS} |
|
|
|
|
|
busy={isCropUploading} |
|
|
|
|
|
onCancel={() => setIsCropping(false)} |
|
|
|
|
|
context={{ |
|
|
|
|
|
value: cropAspectRatio, |
|
|
|
|
|
onChange: setCropAspectRatio, |
|
|
|
|
|
onConfirm: () => { |
|
|
|
|
|
if (cropPayload) void handleConfirmCrop(cropPayload); |
|
|
|
|
|
}, |
|
|
|
|
|
}} |
|
|
|
|
|
toolComponents={{ |
|
|
|
|
|
[MEDIA_EDIT_TOOL.crop]: CropRatioSelect, |
|
|
|
|
|
}} |
|
|
|
|
|
/> |
|
|
|
|
|
)} |
|
|
|
|
|
|
|
|
<div |
|
|
<div |
|
|
className="relative w-full h-full min-h-0 overflow-hidden rounded-lg" |
|
|
className="relative w-full h-full min-h-0 overflow-hidden rounded-lg" |
|
|
@ -402,10 +523,17 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV |
|
|
{displayImage ? ( |
|
|
{displayImage ? ( |
|
|
<> |
|
|
<> |
|
|
<img |
|
|
<img |
|
|
|
|
|
ref={imageRef} |
|
|
key={`${displayImage ?? "empty"}-${imageRetryNonce}`} |
|
|
key={`${displayImage ?? "empty"}-${imageRetryNonce}`} |
|
|
src={outputImageSrc ?? undefined} |
|
|
src={renderedImageSrc ?? undefined} |
|
|
alt="Generated" |
|
|
alt="Generated" |
|
|
onLoad={() => setImageRetryNonce(0)} |
|
|
onLoad={(event) => { |
|
|
|
|
|
setImageRetryNonce(0); |
|
|
|
|
|
const image = event.currentTarget; |
|
|
|
|
|
if (image.naturalWidth > 0 && image.naturalHeight > 0) { |
|
|
|
|
|
setCropImageDimensions({ width: image.naturalWidth, height: image.naturalHeight }); |
|
|
|
|
|
} |
|
|
|
|
|
}} |
|
|
onError={() => { |
|
|
onError={() => { |
|
|
if (nodeData.previewImg && displayImage === nodeData.previewImg && nodeData.outputImage && nodeData.outputImage !== nodeData.previewImg) { |
|
|
if (nodeData.previewImg && displayImage === nodeData.previewImg && nodeData.outputImage && nodeData.outputImage !== nodeData.previewImg) { |
|
|
setDisplayFallbackImage(nodeData.outputImage); |
|
|
setDisplayFallbackImage(nodeData.outputImage); |
|
|
@ -428,6 +556,17 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV |
|
|
}} |
|
|
}} |
|
|
className="w-full h-full object-contain cursor-zoom-in" |
|
|
className="w-full h-full object-contain cursor-zoom-in" |
|
|
/> |
|
|
/> |
|
|
|
|
|
{isCropping && cropImageDimensions && ( |
|
|
|
|
|
<MediaCropOverlay |
|
|
|
|
|
kind="image" |
|
|
|
|
|
mediaElementRef={imageRef} |
|
|
|
|
|
naturalWidth={cropImageDimensions.width} |
|
|
|
|
|
naturalHeight={cropImageDimensions.height} |
|
|
|
|
|
aspectRatio={cropAspectRatio} |
|
|
|
|
|
busy={isCropUploading} |
|
|
|
|
|
onCropChange={setCropPayload} |
|
|
|
|
|
/> |
|
|
|
|
|
)} |
|
|
{/* Loading overlay for generation */} |
|
|
{/* Loading overlay for generation */} |
|
|
{nodeData.status === "loading" && ( |
|
|
{nodeData.status === "loading" && ( |
|
|
<div className="absolute inset-0 bg-neutral-900/70 flex items-center justify-center"> |
|
|
<div className="absolute inset-0 bg-neutral-900/70 flex items-center justify-center"> |
|
|
|