refactor: split image node presentation sections
This commit is contained in:
parent
c79724eefa
commit
91a98083a1
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Blend,
|
||||
Box,
|
||||
Camera,
|
||||
@ -51,6 +50,17 @@ import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecution
|
||||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { ImageNodeAdjustOverlays } from "@/components/canvas/nodes/image-node/image-node-adjust-overlays";
|
||||
import {
|
||||
ImageNodeDeferredLoadState,
|
||||
ImageNodeEmptyState,
|
||||
ImageNodeProcessingOverlay,
|
||||
} from "@/components/canvas/nodes/image-node/image-node-empty-state";
|
||||
import {
|
||||
PromptRegenerateDialog,
|
||||
ThreeDGenerationDialog,
|
||||
} from "@/components/canvas/nodes/image-node/image-node-dialogs";
|
||||
import { ImageNodeSelectedTitle } from "@/components/canvas/nodes/image-node/image-node-title";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -70,13 +80,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@ -327,9 +330,6 @@ const ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS = [
|
||||
];
|
||||
const ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58];
|
||||
const DEFAULT_CROP_PRESET_ID = "1:1";
|
||||
const IMAGE_ADJUST_NOISE_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.18' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(#n)' opacity='1'/></svg>"
|
||||
)}`;
|
||||
const DEFAULT_IMAGE_ADJUSTMENTS: ImageAdjustmentState = {
|
||||
light: 0,
|
||||
exposure: 0,
|
||||
@ -1772,12 +1772,11 @@ export function ImageNode({
|
||||
);
|
||||
const selectedTitleStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
top: `${-28 * nodeContentScale}px`,
|
||||
transform: `scale(${nodeContentScale})`,
|
||||
transformOrigin: "bottom left",
|
||||
width: `${100 / nodeContentScale}%`,
|
||||
fontSize: 12,
|
||||
lineHeight: "16px",
|
||||
top: -28,
|
||||
}),
|
||||
[nodeContentScale]
|
||||
[]
|
||||
);
|
||||
const detailProgressLabel =
|
||||
typeof data.metadata?.detailProgressLabel === "string"
|
||||
@ -7582,107 +7581,12 @@ export function ImageNode({
|
||||
}, [boxCutoutMetrics]);
|
||||
|
||||
const renderImageAdjustmentOverlays = useCallback(() => {
|
||||
if (!hasActiveImageAdjustments) return null;
|
||||
|
||||
const temperatureOpacity = Math.min(
|
||||
0.38,
|
||||
Math.abs(activeImageAdjustments.temperature) / 260
|
||||
);
|
||||
const tintOpacity = Math.min(
|
||||
0.28,
|
||||
Math.abs(activeImageAdjustments.tint) / 300
|
||||
);
|
||||
const grainOpacity = Math.min(0.28, activeImageAdjustments.grain / 340);
|
||||
const vignetteOpacity = Math.min(
|
||||
0.42,
|
||||
activeImageAdjustments.vignette / 180
|
||||
);
|
||||
const softLightOpacity = Math.min(
|
||||
0.24,
|
||||
activeImageAdjustments.softLight / 320
|
||||
);
|
||||
const glowOpacity = Math.min(0.22, activeImageAdjustments.glow / 280);
|
||||
|
||||
return (
|
||||
<>
|
||||
{temperatureOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...imageAdjustOverlayStyle,
|
||||
background:
|
||||
activeImageAdjustments.temperature >= 0
|
||||
? "linear-gradient(135deg, rgba(255,188,109,0.9), rgba(255,232,194,0.42))"
|
||||
: "linear-gradient(135deg, rgba(109,180,255,0.86), rgba(219,240,255,0.36))",
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: temperatureOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{tintOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...imageAdjustOverlayStyle,
|
||||
background:
|
||||
activeImageAdjustments.tint >= 0
|
||||
? "linear-gradient(135deg, rgba(244,105,214,0.78), rgba(255,203,236,0.28))"
|
||||
: "linear-gradient(135deg, rgba(76,214,141,0.76), rgba(194,255,221,0.28))",
|
||||
mixBlendMode: "overlay",
|
||||
opacity: tintOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{softLightOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...imageAdjustOverlayStyle,
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,247,229,0.84) 0%, rgba(255,255,255,0.08) 34%, rgba(255,219,168,0.42) 100%)",
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: softLightOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{glowOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...imageAdjustOverlayStyle,
|
||||
boxShadow:
|
||||
"inset 0 0 72px rgba(255,255,255,0.78), inset 0 0 24px rgba(255,236,201,0.66)",
|
||||
mixBlendMode: "screen",
|
||||
opacity: glowOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{vignetteOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...imageAdjustOverlayStyle,
|
||||
background:
|
||||
"radial-gradient(circle at center, rgba(0,0,0,0) 38%, rgba(0,0,0,0.06) 58%, rgba(0,0,0,0.34) 100%)",
|
||||
mixBlendMode: "multiply",
|
||||
opacity: vignetteOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{grainOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...imageAdjustOverlayStyle,
|
||||
backgroundImage: `url("${IMAGE_ADJUST_NOISE_DATA_URI}")`,
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundSize: "140px 140px",
|
||||
mixBlendMode: "overlay",
|
||||
opacity: grainOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
<ImageNodeAdjustOverlays
|
||||
adjustments={activeImageAdjustments}
|
||||
hasActiveAdjustments={hasActiveImageAdjustments}
|
||||
style={imageAdjustOverlayStyle}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
activeImageAdjustments,
|
||||
@ -7776,54 +7680,17 @@ export function ImageNode({
|
||||
<>
|
||||
{/* 选中时在图片上方显示标题 */}
|
||||
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
|
||||
<div
|
||||
className="-top-7 absolute right-0 left-0 z-10 flex items-center justify-between px-1 text-[#2f7df6] text-xs"
|
||||
<ImageNodeSelectedTitle
|
||||
displayTitle={displayTitle}
|
||||
draftTitle={draftTitle}
|
||||
isEditingTitle={isEditingTitle}
|
||||
onCancelEditing={() => setIsEditingTitle(false)}
|
||||
onDraftTitleChange={setDraftTitle}
|
||||
onSaveTitle={handleSaveTitle}
|
||||
onStartEditing={() => setIsEditingTitle(true)}
|
||||
resolutionLabel={resolutionLabel}
|
||||
style={selectedTitleStyle}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<ImagePlus className="h-3 w-3" />
|
||||
{isEditingTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-6 w-[160px] px-2 py-0 text-xs"
|
||||
data-canvas-no-drag="true"
|
||||
onBlur={handleSaveTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSaveTitle();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setDraftTitle(displayTitle);
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
value={draftTitle}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="max-w-[150px] cursor-text truncate"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDraftTitle(displayTitle);
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
title="双击重命名"
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{resolutionLabel ? (
|
||||
<span className="ml-2 shrink-0 tabular-nums tracking-wide">
|
||||
{resolutionLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
@ -7843,12 +7710,7 @@ export function ImageNode({
|
||||
{data.src ? (
|
||||
<div className="relative h-full w-full">
|
||||
{!shouldRequestDisplayImage ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-slate-50 text-slate-400 text-xs dark:bg-zinc-900 dark:text-zinc-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>图片即将进入视口时加载</span>
|
||||
</div>
|
||||
</div>
|
||||
<ImageNodeDeferredLoadState />
|
||||
) : isCompareMode && hasCompareSource ? (
|
||||
<div
|
||||
className="relative h-full w-full cursor-col-resize select-none overflow-hidden bg-slate-50 dark:bg-zinc-900"
|
||||
@ -8614,66 +8476,19 @@ export function ImageNode({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : data.status === "generating" ? (
|
||||
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
<span className="select-none px-3 text-sm font-medium text-[#3f4b63]">
|
||||
{detailProgressLabel || "生成中..."}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-[#7a8395]">图片处理中</span>
|
||||
</div>
|
||||
) : data.status === "failed" ? (
|
||||
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="select-none text-sm font-medium text-[#c84b5e]">
|
||||
生成失败
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-[#b77480]">请重新上传或再试一次</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="select-none text-sm font-medium text-[#3f4b63]">
|
||||
上传图片
|
||||
</span>
|
||||
<span className="mb-4 mt-1 select-none text-xs text-[#7a8395]">
|
||||
添加参考图、角色图或关键帧素材
|
||||
</span>
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
{/* 选择文件按钮 */}
|
||||
<button
|
||||
className="cursor-pointer rounded-[14px] border border-[#d7dff1] bg-white px-4 py-2 text-sm font-medium text-[#3954db] shadow-[0_0_0_1px_rgba(219,226,238,0.24)] transition-colors hover:bg-[#f4f7ff]"
|
||||
onClick={handleSelectFile}
|
||||
type="button"
|
||||
>
|
||||
选择文件
|
||||
</button>
|
||||
</div>
|
||||
<ImageNodeEmptyState
|
||||
detailProgressLabel={detailProgressLabel}
|
||||
fileInputRef={fileInputRef}
|
||||
onImageUpload={handleImageUpload}
|
||||
onSelectFile={handleSelectFile}
|
||||
status={data.status}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-[#f6f8ff] text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[#3f4b63]">处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<ImageNodeProcessingOverlay />
|
||||
)}
|
||||
|
||||
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
||||
@ -8924,159 +8739,30 @@ export function ImageNode({
|
||||
) : null}
|
||||
|
||||
{/* 提示词重绘对话框 */}
|
||||
<Dialog onOpenChange={setPromptDialogOpen} open={promptDialogOpen}>
|
||||
<DialogContent
|
||||
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
|
||||
overlayClassName="bg-black"
|
||||
>
|
||||
<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-muted-foreground text-sm">
|
||||
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`prompt-regenerate-${node.id}`}>新提示词</Label>
|
||||
<Textarea
|
||||
className="resize-y border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
|
||||
id={`prompt-regenerate-${node.id}`}
|
||||
onChange={(e) => setRegeneratePrompt(e.target.value)}
|
||||
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
|
||||
rows={6}
|
||||
value={regeneratePrompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setPromptDialogOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessing || !regeneratePrompt.trim()}
|
||||
onClick={handlePromptRegenerate}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
开始重绘
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PromptRegenerateDialog
|
||||
isProcessing={isProcessing}
|
||||
nodeId={node.id}
|
||||
onConfirm={handlePromptRegenerate}
|
||||
onOpenChange={setPromptDialogOpen}
|
||||
onPromptChange={setRegeneratePrompt}
|
||||
open={promptDialogOpen}
|
||||
prompt={regeneratePrompt}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setThreedGenerationDialogMode(null);
|
||||
}
|
||||
}}
|
||||
open={threedGenerationDialogMode !== null}
|
||||
>
|
||||
<DialogContent
|
||||
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
|
||||
overlayClassName="bg-black"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{threedGenerationDialogMode === "world" ? (
|
||||
<Globe2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Box className="h-5 w-5" />
|
||||
)}
|
||||
{threedGenerationDialogMode === "world"
|
||||
? "选择 3D 世界模型"
|
||||
: "选择 3D 模型"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{threedGenerationDialogMode === "world"
|
||||
? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。"
|
||||
: "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{threedGenerationDialogMode === "world" ? "世界模型" : "模型版本"}
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (threedGenerationDialogMode === "world") {
|
||||
setSelectedWorldModelVersion(value);
|
||||
return;
|
||||
}
|
||||
setSelectedTripoModelVersion(value);
|
||||
}}
|
||||
value={
|
||||
threedGenerationDialogMode === "world"
|
||||
? selectedWorldModelVersion
|
||||
: selectedTripoModelVersion
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-950">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950">
|
||||
{(threedGenerationDialogMode === "world"
|
||||
? worldModelOptions
|
||||
: tripoModelOptions
|
||||
).map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{threedGenerationDialogMode === "world"
|
||||
? selectedWorldModelEntry?.displayName || "未选择模型"
|
||||
: selectedTripoModelEntry?.displayName || "未选择模型"}
|
||||
</div>
|
||||
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
|
||||
{threedGenerationDialogMode === "world"
|
||||
? selectedWorldModelEntry?.description || "当前模型暂无说明。"
|
||||
: selectedTripoModelEntry?.description || "当前模型暂无说明。"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setThreedGenerationDialogMode(null)}
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
isProcessing ||
|
||||
(threedGenerationDialogMode === "world"
|
||||
? !selectedWorldModelEntry
|
||||
: !selectedTripoModelEntry)
|
||||
}
|
||||
onClick={() => void handleConfirmThreedGeneration()}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : threedGenerationDialogMode === "world" ? (
|
||||
<Globe2 className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Box className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
开始生成
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ThreeDGenerationDialog
|
||||
isProcessing={isProcessing}
|
||||
mode={threedGenerationDialogMode}
|
||||
onConfirm={handleConfirmThreedGeneration}
|
||||
onModeChange={setThreedGenerationDialogMode}
|
||||
onTripoModelChange={setSelectedTripoModelVersion}
|
||||
onWorldModelChange={setSelectedWorldModelVersion}
|
||||
selectedTripoModelEntry={selectedTripoModelEntry}
|
||||
selectedTripoModelVersion={selectedTripoModelVersion}
|
||||
selectedWorldModelEntry={selectedWorldModelEntry}
|
||||
selectedWorldModelVersion={selectedWorldModelVersion}
|
||||
tripoModelOptions={tripoModelOptions}
|
||||
worldModelOptions={worldModelOptions}
|
||||
/>
|
||||
|
||||
{localEditMode &&
|
||||
!isMultiSelected &&
|
||||
|
||||
@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
|
||||
const IMAGE_ADJUST_NOISE_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.18' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(#n)' opacity='1'/></svg>"
|
||||
)}`;
|
||||
|
||||
export interface ImageAdjustmentOverlayState {
|
||||
temperature: number;
|
||||
tint: number;
|
||||
grain: number;
|
||||
vignette: number;
|
||||
softLight: number;
|
||||
glow: number;
|
||||
}
|
||||
|
||||
interface ImageNodeAdjustOverlaysProps {
|
||||
adjustments: ImageAdjustmentOverlayState;
|
||||
hasActiveAdjustments: boolean;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function ImageNodeAdjustOverlays({
|
||||
adjustments,
|
||||
hasActiveAdjustments,
|
||||
style,
|
||||
}: ImageNodeAdjustOverlaysProps) {
|
||||
if (!hasActiveAdjustments) return null;
|
||||
|
||||
const temperatureOpacity = Math.min(
|
||||
0.38,
|
||||
Math.abs(adjustments.temperature) / 260
|
||||
);
|
||||
const tintOpacity = Math.min(0.28, Math.abs(adjustments.tint) / 300);
|
||||
const grainOpacity = Math.min(0.28, adjustments.grain / 340);
|
||||
const vignetteOpacity = Math.min(0.42, adjustments.vignette / 180);
|
||||
const softLightOpacity = Math.min(0.24, adjustments.softLight / 320);
|
||||
const glowOpacity = Math.min(0.22, adjustments.glow / 280);
|
||||
|
||||
return (
|
||||
<>
|
||||
{temperatureOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...style,
|
||||
background:
|
||||
adjustments.temperature >= 0
|
||||
? "linear-gradient(135deg, rgba(255,188,109,0.9), rgba(255,232,194,0.42))"
|
||||
: "linear-gradient(135deg, rgba(109,180,255,0.86), rgba(219,240,255,0.36))",
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: temperatureOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{tintOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...style,
|
||||
background:
|
||||
adjustments.tint >= 0
|
||||
? "linear-gradient(135deg, rgba(244,105,214,0.78), rgba(255,203,236,0.28))"
|
||||
: "linear-gradient(135deg, rgba(76,214,141,0.76), rgba(194,255,221,0.28))",
|
||||
mixBlendMode: "overlay",
|
||||
opacity: tintOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{softLightOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...style,
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,247,229,0.84) 0%, rgba(255,255,255,0.08) 34%, rgba(255,219,168,0.42) 100%)",
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: softLightOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{glowOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...style,
|
||||
boxShadow:
|
||||
"inset 0 0 72px rgba(255,255,255,0.78), inset 0 0 24px rgba(255,236,201,0.66)",
|
||||
mixBlendMode: "screen",
|
||||
opacity: glowOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{vignetteOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...style,
|
||||
background:
|
||||
"radial-gradient(circle at center, rgba(0,0,0,0) 38%, rgba(0,0,0,0.06) 58%, rgba(0,0,0,0.34) 100%)",
|
||||
mixBlendMode: "multiply",
|
||||
opacity: vignetteOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{grainOpacity > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...style,
|
||||
backgroundImage: `url("${IMAGE_ADJUST_NOISE_DATA_URI}")`,
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundSize: "140px 140px",
|
||||
mixBlendMode: "overlay",
|
||||
opacity: grainOpacity,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
215
components/canvas/nodes/image-node/image-node-dialogs.tsx
Normal file
215
components/canvas/nodes/image-node/image-node-dialogs.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Globe2, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface ThreeDModelOption {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
type ThreeDGenerationDialogMode = "model" | "world" | null;
|
||||
|
||||
interface PromptRegenerateDialogProps {
|
||||
isProcessing: boolean;
|
||||
nodeId: string;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPromptChange: (value: string) => void;
|
||||
open: boolean;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface ThreeDGenerationDialogProps {
|
||||
isProcessing: boolean;
|
||||
mode: ThreeDGenerationDialogMode;
|
||||
onConfirm: () => void;
|
||||
onModeChange: (mode: ThreeDGenerationDialogMode) => void;
|
||||
onTripoModelChange: (value: string) => void;
|
||||
onWorldModelChange: (value: string) => void;
|
||||
selectedTripoModelEntry?: ThreeDModelOption | null;
|
||||
selectedTripoModelVersion: string;
|
||||
selectedWorldModelEntry?: ThreeDModelOption | null;
|
||||
selectedWorldModelVersion: string;
|
||||
tripoModelOptions: ThreeDModelOption[];
|
||||
worldModelOptions: ThreeDModelOption[];
|
||||
}
|
||||
|
||||
export function PromptRegenerateDialog({
|
||||
isProcessing,
|
||||
nodeId,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onPromptChange,
|
||||
open,
|
||||
prompt,
|
||||
}: PromptRegenerateDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
|
||||
overlayClassName="bg-black"
|
||||
>
|
||||
<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-muted-foreground text-sm">
|
||||
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`prompt-regenerate-${nodeId}`}>新提示词</Label>
|
||||
<Textarea
|
||||
className="resize-y border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
|
||||
id={`prompt-regenerate-${nodeId}`}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
|
||||
rows={6}
|
||||
value={prompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isProcessing || !prompt.trim()} onClick={onConfirm}>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
开始重绘
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreeDGenerationDialog({
|
||||
isProcessing,
|
||||
mode,
|
||||
onConfirm,
|
||||
onModeChange,
|
||||
onTripoModelChange,
|
||||
onWorldModelChange,
|
||||
selectedTripoModelEntry,
|
||||
selectedTripoModelVersion,
|
||||
selectedWorldModelEntry,
|
||||
selectedWorldModelVersion,
|
||||
tripoModelOptions,
|
||||
worldModelOptions,
|
||||
}: ThreeDGenerationDialogProps) {
|
||||
const isWorldMode = mode === "world";
|
||||
const selectedEntry = isWorldMode
|
||||
? selectedWorldModelEntry
|
||||
: selectedTripoModelEntry;
|
||||
const options = isWorldMode ? worldModelOptions : tripoModelOptions;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onModeChange(null);
|
||||
}
|
||||
}}
|
||||
open={mode !== null}
|
||||
>
|
||||
<DialogContent
|
||||
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
|
||||
overlayClassName="bg-black"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isWorldMode ? (
|
||||
<Globe2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Box className="h-5 w-5" />
|
||||
)}
|
||||
{isWorldMode ? "选择 3D 世界模型" : "选择 3D 模型"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isWorldMode
|
||||
? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。"
|
||||
: "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label>{isWorldMode ? "世界模型" : "模型版本"}</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (isWorldMode) {
|
||||
onWorldModelChange(value);
|
||||
return;
|
||||
}
|
||||
onTripoModelChange(value);
|
||||
}}
|
||||
value={
|
||||
isWorldMode ? selectedWorldModelVersion : selectedTripoModelVersion
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-950">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950">
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{selectedEntry?.displayName || "未选择模型"}
|
||||
</div>
|
||||
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
|
||||
{selectedEntry?.description || "当前模型暂无说明。"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onModeChange(null)} variant="outline">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessing || !selectedEntry}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : isWorldMode ? (
|
||||
<Globe2 className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Box className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
开始生成
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
100
components/canvas/nodes/image-node/image-node-empty-state.tsx
Normal file
100
components/canvas/nodes/image-node/image-node-empty-state.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
interface ImageNodeEmptyStateProps {
|
||||
detailProgressLabel?: string;
|
||||
fileInputRef: React.Ref<HTMLInputElement>;
|
||||
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function ImageNodeDeferredLoadState() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-slate-50 text-slate-400 text-xs dark:bg-zinc-900 dark:text-zinc-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>图片即将进入视口时加载</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageNodeProcessingOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-[#f6f8ff] text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[#3f4b63]">处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageNodeEmptyState({
|
||||
detailProgressLabel,
|
||||
fileInputRef,
|
||||
onImageUpload,
|
||||
onSelectFile,
|
||||
status,
|
||||
}: ImageNodeEmptyStateProps) {
|
||||
if (status === "generating") {
|
||||
return (
|
||||
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
<span className="select-none px-3 text-sm font-medium text-[#3f4b63]">
|
||||
{detailProgressLabel || "生成中..."}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-[#7a8395]">图片处理中</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="select-none text-sm font-medium text-[#c84b5e]">
|
||||
生成失败
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-[#b77480]">请重新上传或再试一次</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="select-none text-sm font-medium text-[#3f4b63]">
|
||||
上传图片
|
||||
</span>
|
||||
<span className="mb-4 mt-1 select-none text-xs text-[#7a8395]">
|
||||
添加参考图、角色图或关键帧素材
|
||||
</span>
|
||||
<input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onImageUpload}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<button
|
||||
className="cursor-pointer rounded-[14px] border border-[#d7dff1] bg-white px-4 py-2 text-sm font-medium text-[#3954db] shadow-[0_0_0_1px_rgba(219,226,238,0.24)] transition-colors hover:bg-[#f4f7ff]"
|
||||
onClick={onSelectFile}
|
||||
type="button"
|
||||
>
|
||||
选择文件
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
components/canvas/nodes/image-node/image-node-title.tsx
Normal file
80
components/canvas/nodes/image-node/image-node-title.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { ImagePlus } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ImageNodeSelectedTitleProps {
|
||||
displayTitle: string;
|
||||
draftTitle: string;
|
||||
isEditingTitle: boolean;
|
||||
onDraftTitleChange: (value: string) => void;
|
||||
onCancelEditing: () => void;
|
||||
onSaveTitle: () => void;
|
||||
onStartEditing: () => void;
|
||||
resolutionLabel?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function ImageNodeSelectedTitle({
|
||||
displayTitle,
|
||||
draftTitle,
|
||||
isEditingTitle,
|
||||
onCancelEditing,
|
||||
onDraftTitleChange,
|
||||
onSaveTitle,
|
||||
onStartEditing,
|
||||
resolutionLabel,
|
||||
style,
|
||||
}: ImageNodeSelectedTitleProps) {
|
||||
return (
|
||||
<div
|
||||
className="-top-7 absolute right-0 left-0 z-10 flex items-center justify-between px-1 text-[#2f7df6] text-xs"
|
||||
style={style}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<ImagePlus className="h-3 w-3" />
|
||||
{isEditingTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-6 w-[160px] px-2 py-0 text-xs"
|
||||
data-canvas-no-drag="true"
|
||||
onBlur={onSaveTitle}
|
||||
onChange={(event) => onDraftTitleChange(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onSaveTitle();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onDraftTitleChange(displayTitle);
|
||||
onCancelEditing();
|
||||
}
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
value={draftTitle}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="max-w-[150px] cursor-text truncate"
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDraftTitleChange(displayTitle);
|
||||
onStartEditing();
|
||||
}}
|
||||
title="双击重命名"
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{resolutionLabel ? (
|
||||
<span className="ml-2 shrink-0 tabular-nums tracking-wide">
|
||||
{resolutionLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user