Align LibTV add node modules
This commit is contained in:
parent
d348be844b
commit
46cd4a5899
@ -12,16 +12,15 @@ import {
|
||||
Image as ImageIcon,
|
||||
Layers,
|
||||
Loader2,
|
||||
MousePointer2,
|
||||
Music2,
|
||||
Plus,
|
||||
Play,
|
||||
Save,
|
||||
Search,
|
||||
Scissors,
|
||||
Settings2,
|
||||
Share2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserRound,
|
||||
Video,
|
||||
X,
|
||||
@ -179,15 +178,8 @@ type ComposerSettings = {
|
||||
resolution: string;
|
||||
};
|
||||
|
||||
type CanvasCreateKind =
|
||||
| "image-input"
|
||||
| "video-input"
|
||||
| "audio-input"
|
||||
| "text-input"
|
||||
| "image-model"
|
||||
| "video-model"
|
||||
| "audio-model"
|
||||
| "text-model";
|
||||
type AddModuleKind = "text" | "image" | "video" | "video-clip" | "audio" | "script";
|
||||
type CanvasCreateKind = AddModuleKind;
|
||||
|
||||
type LiblibVideoCanvasReplicaProps = {
|
||||
canvasId: string;
|
||||
@ -209,7 +201,20 @@ const TOOL_TYPE_META: Record<
|
||||
compose: { label: "组合", icon: GitBranch, accent: "#ffb8c8" },
|
||||
};
|
||||
|
||||
const PANEL_TABS = ["全部", "图片", "视频", "文本", "音频", "组合"] as const;
|
||||
const ADD_NODE_MODULES: Array<{
|
||||
badge?: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
kind: AddModuleKind;
|
||||
label: string;
|
||||
}> = [
|
||||
{ kind: "text", label: "文本", description: "剧本、广告词、品牌文案", icon: FileText },
|
||||
{ kind: "image", label: "图片", description: "海报、分镜、角色设计", icon: ImageIcon },
|
||||
{ kind: "video", label: "视频", description: "创意广告、动画、电影", icon: Video },
|
||||
{ kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" },
|
||||
{ kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 },
|
||||
{ kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "Beta" },
|
||||
];
|
||||
|
||||
const STARTER_CARDS = [
|
||||
{
|
||||
@ -645,6 +650,152 @@ function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: numb
|
||||
};
|
||||
}
|
||||
|
||||
function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index: number): CloneNode {
|
||||
if (kind === "script") {
|
||||
return {
|
||||
id: makeId("script"),
|
||||
kind: "script",
|
||||
title: `脚本 ${index}`,
|
||||
subtitle: "脚本生成",
|
||||
inputType: "text",
|
||||
x,
|
||||
y,
|
||||
width: 520,
|
||||
height: 360,
|
||||
accent: "#f0d06b",
|
||||
draftValues: {
|
||||
action: "SCRIPT_GENERATE",
|
||||
generatorType: "DEFAULT",
|
||||
nodeType: "script",
|
||||
rows: [],
|
||||
scriptRole: "generator",
|
||||
viewMode: "table",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "video-clip") {
|
||||
return {
|
||||
id: makeId("video-clip"),
|
||||
kind: "model",
|
||||
title: `视频合成 ${index}`,
|
||||
subtitle: "视频合成",
|
||||
inputType: "video",
|
||||
x,
|
||||
y,
|
||||
width: 210,
|
||||
height: 116,
|
||||
accent: "#ffb8c8",
|
||||
draftValues: {
|
||||
action: "VIDEO_CLIP_RESOURCE",
|
||||
nodeType: "video-clip",
|
||||
params: {},
|
||||
url: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Record<Exclude<AddModuleKind, "script" | "video-clip">, {
|
||||
action: string;
|
||||
accent: string;
|
||||
kind: CloneNodeKind;
|
||||
nodeType: NonNullable<CloneNode["inputType"]>;
|
||||
subtitle: string;
|
||||
title: string;
|
||||
}> = {
|
||||
audio: {
|
||||
action: "AUDIO_GENERATE",
|
||||
accent: "#9ee6bb",
|
||||
kind: "audio",
|
||||
nodeType: "audio",
|
||||
subtitle: "音频生成",
|
||||
title: `音频节点 ${index}`,
|
||||
},
|
||||
image: {
|
||||
action: "IMAGE_GENERATE",
|
||||
accent: "#8b96ff",
|
||||
kind: "model",
|
||||
nodeType: "image",
|
||||
subtitle: "图片生成",
|
||||
title: `图片节点 ${index}`,
|
||||
},
|
||||
text: {
|
||||
action: "TEXT_GENERATE",
|
||||
accent: "#f0d06b",
|
||||
kind: "text",
|
||||
nodeType: "text",
|
||||
subtitle: "文本生成",
|
||||
title: `文本节点 ${index}`,
|
||||
},
|
||||
video: {
|
||||
action: "VIDEO_GENERATE",
|
||||
accent: "#8b96ff",
|
||||
kind: "model",
|
||||
nodeType: "video",
|
||||
subtitle: "视频生成",
|
||||
title: `视频节点 ${index}`,
|
||||
},
|
||||
};
|
||||
const item = meta[kind];
|
||||
return {
|
||||
id: makeId(kind),
|
||||
kind: item.kind,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
inputType: item.nodeType,
|
||||
x,
|
||||
y,
|
||||
width: item.nodeType === "text" ? 210 : 194,
|
||||
height: item.nodeType === "text" ? 122 : 116,
|
||||
accent: item.accent,
|
||||
draftValues: {
|
||||
action: item.action,
|
||||
alt: item.nodeType === "image" ? `图片 ${index}` : undefined,
|
||||
content: item.nodeType === "text" ? [] : undefined,
|
||||
generatorType: "DEFAULT",
|
||||
nodeType: item.nodeType,
|
||||
poster: item.nodeType === "video" ? "" : undefined,
|
||||
url: item.nodeType === "text" ? undefined : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createUploadedMediaNode(file: File, mediaUrl: string, x: number, y: number, index: number): CloneNode {
|
||||
const mediaType: NonNullable<CloneNode["inputType"]> = file.type.startsWith("video/")
|
||||
? "video"
|
||||
: file.type.startsWith("audio/")
|
||||
? "audio"
|
||||
: "image";
|
||||
const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片";
|
||||
const fallbackName = `${label}节点 ${index}`;
|
||||
const title = file.name.replace(/\.[^.]+$/, "").trim() || fallbackName;
|
||||
|
||||
return {
|
||||
id: makeId(mediaType),
|
||||
kind: "media",
|
||||
title,
|
||||
subtitle: `${label}资源`,
|
||||
inputType: mediaType,
|
||||
x,
|
||||
y,
|
||||
width: mediaType === "audio" ? 430 : 420,
|
||||
height: mediaType === "audio" ? 170 : 280,
|
||||
accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
|
||||
draftValues: {
|
||||
action: `${mediaType.toUpperCase()}_RESOURCE`,
|
||||
alt: file.name,
|
||||
generated: false,
|
||||
mediaType,
|
||||
mediaUrl,
|
||||
nodeType: mediaType,
|
||||
resourceMeta: {
|
||||
items: [{ name: file.name, size: file.size, type: file.type }],
|
||||
},
|
||||
url: [mediaUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getDraftString(node: CloneNode | null | undefined, key: string) {
|
||||
const value = node?.draftValues?.[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
@ -893,8 +1044,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const [draftTitle, setDraftTitle] = useState("未命名");
|
||||
const [isTitleEditing, setIsTitleEditing] = useState(false);
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
|
||||
const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部");
|
||||
const [toolSearch, setToolSearch] = useState("");
|
||||
const [isProjectLoading, setIsProjectLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [, setShareEnabled] = useState(false);
|
||||
@ -912,27 +1061,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
[nodeContextMenu?.nodeId, nodes]
|
||||
);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
const search = toolSearch.trim().toLowerCase();
|
||||
return capturedTools.filter((tool) => {
|
||||
const type = getToolType(tool);
|
||||
const tabMatched =
|
||||
toolTab === "全部" ||
|
||||
(toolTab === "图片" && type === "image") ||
|
||||
(toolTab === "视频" && type === "video") ||
|
||||
(toolTab === "文本" && type === "text") ||
|
||||
(toolTab === "音频" && type === "audio") ||
|
||||
(toolTab === "组合" && type === "compose");
|
||||
if (!tabMatched) return false;
|
||||
if (!search) return true;
|
||||
return [tool.modelName, tool.vendor, tool.scene, tool.modelKey, tool.toolKey, ...(tool.modes || [])]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(search);
|
||||
});
|
||||
}, [toolSearch, toolTab]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
@ -1008,30 +1136,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
if (last) setSelectedNodeId(last.id);
|
||||
}, []);
|
||||
|
||||
const addToolFlow = useCallback(
|
||||
(tool: CapturedTool) => {
|
||||
const type = getToolType(tool);
|
||||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||||
const origin = { x: 420 + nodes.length * 18, y: 330 + nodes.length * 10 };
|
||||
const newNodes: CloneNode[] = [];
|
||||
const newEdges: CloneEdge[] = [];
|
||||
|
||||
if (meta.inputType && type !== "text") {
|
||||
const input = createInputNode(meta.inputType, origin.x, origin.y);
|
||||
const model = createToolNode(tool, origin.x + 300, origin.y + 18);
|
||||
newNodes.push(input, model);
|
||||
newEdges.push({ id: makeId("edge"), source: input.id, target: model.id });
|
||||
} else {
|
||||
newNodes.push(createToolNode(tool, origin.x + 120, origin.y + 12));
|
||||
}
|
||||
|
||||
addNodes(newNodes, newEdges);
|
||||
setActivePanel(null);
|
||||
toast.success(`已添加 ${getToolLabel(tool)}`);
|
||||
},
|
||||
[addNodes, nodes.length]
|
||||
);
|
||||
|
||||
const addWorkflowPreset = useCallback(
|
||||
(workflow: WorkflowPreset, index = 0) => {
|
||||
const node = createWorkflowNode(workflow, 440 + index * 24, 350 + index * 18);
|
||||
@ -1050,6 +1154,57 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
[viewport.x, viewport.y, viewport.zoom]
|
||||
);
|
||||
|
||||
const addTargetModule = useCallback(
|
||||
(kind: AddModuleKind, point?: { x: number; y: number }) => {
|
||||
const center = point || toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
|
||||
const node = {
|
||||
...created,
|
||||
x: Math.round(center.x - created.width / 2),
|
||||
y: Math.round(center.y - created.height / 2),
|
||||
};
|
||||
addNodes([node], []);
|
||||
setActivePanel(null);
|
||||
setCanvasCommand(null);
|
||||
toast.success(`已添加 ${node.title}`);
|
||||
},
|
||||
[addNodes, nodes.length, toCanvasPoint]
|
||||
);
|
||||
|
||||
const addUploadedFilesAtPoint = useCallback(
|
||||
(files: File[], center: { x: number; y: number }) => {
|
||||
if (files.length === 0) return;
|
||||
const newNodes = files.slice(0, 10).map((file, index) => {
|
||||
const created = createUploadedMediaNode(file, URL.createObjectURL(file), 0, 0, nodes.length + index + 1);
|
||||
return {
|
||||
...created,
|
||||
x: Math.round(center.x - created.width / 2 + index * 450),
|
||||
y: Math.round(center.y - created.height / 2),
|
||||
};
|
||||
});
|
||||
addNodes(newNodes, []);
|
||||
setActivePanel(null);
|
||||
setCanvasCommand(null);
|
||||
toast.success(files.length === 1 ? `已添加 ${newNodes[0]?.title || "素材"}` : `已添加 ${newNodes.length} 个素材`);
|
||||
},
|
||||
[addNodes, nodes.length]
|
||||
);
|
||||
|
||||
const addUploadedFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
addUploadedFilesAtPoint(files, toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2));
|
||||
},
|
||||
[addUploadedFilesAtPoint, toCanvasPoint]
|
||||
);
|
||||
|
||||
const addCanvasCommandUploadedFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (!canvasCommand) return;
|
||||
addUploadedFilesAtPoint(files, { x: canvasCommand.canvasX, y: canvasCommand.canvasY });
|
||||
},
|
||||
[addUploadedFilesAtPoint, canvasCommand]
|
||||
);
|
||||
|
||||
const openCanvasCommand = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
|
||||
@ -1083,26 +1238,17 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const createFreeNode = useCallback(
|
||||
(kind: CanvasCreateKind) => {
|
||||
if (!canvasCommand) return;
|
||||
const createAtPoint = (node: CloneNode) => ({
|
||||
...node,
|
||||
x: Math.round(canvasCommand.canvasX - node.width / 2),
|
||||
y: Math.round(canvasCommand.canvasY - node.height / 2),
|
||||
});
|
||||
const type = kind.split("-")[0] as NonNullable<CloneNode["inputType"]>;
|
||||
const tool = capturedTools.find((item) => getToolType(item) === type);
|
||||
const node =
|
||||
kind.endsWith("input")
|
||||
? createAtPoint(createInputNode(type, canvasCommand.canvasX, canvasCommand.canvasY))
|
||||
: createAtPoint(
|
||||
tool
|
||||
? createToolNode(tool, canvasCommand.canvasX, canvasCommand.canvasY)
|
||||
: createSimpleNode(`${TOOL_TYPE_META[type]?.label || "工具"}生成`, type === "text" ? "text" : "model", canvasCommand.canvasX, canvasCommand.canvasY)
|
||||
);
|
||||
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
|
||||
const node = {
|
||||
...created,
|
||||
x: Math.round(canvasCommand.canvasX - created.width / 2),
|
||||
y: Math.round(canvasCommand.canvasY - created.height / 2),
|
||||
};
|
||||
addNodes([node], []);
|
||||
setCanvasCommand(null);
|
||||
toast.success(`已添加 ${node.title}`);
|
||||
},
|
||||
[addNodes, canvasCommand]
|
||||
[addNodes, canvasCommand, nodes.length]
|
||||
);
|
||||
|
||||
const configureNode = useCallback((nodeId: string) => {
|
||||
@ -1422,10 +1568,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
state={canvasCommand}
|
||||
onClose={() => setCanvasCommand(null)}
|
||||
onCreate={createFreeNode}
|
||||
onOpenAdd={() => {
|
||||
setActivePanel("add");
|
||||
onGallerySelect={() => {
|
||||
setActivePanel("assets");
|
||||
setCanvasCommand(null);
|
||||
}}
|
||||
onUploadFiles={addCanvasCommandUploadedFiles}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -1475,13 +1622,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
|
||||
{activePanel === "add" ? (
|
||||
<AddModulePanel
|
||||
filteredTools={filteredTools}
|
||||
search={toolSearch}
|
||||
tab={toolTab}
|
||||
onAddTool={addToolFlow}
|
||||
onAddModule={addTargetModule}
|
||||
onClose={() => setActivePanel(null)}
|
||||
onSearchChange={setToolSearch}
|
||||
onTabChange={setToolTab}
|
||||
onGallerySelect={() => setActivePanel("assets")}
|
||||
onUploadFiles={addUploadedFiles}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -3014,80 +3158,107 @@ function ComposerControl({
|
||||
function CanvasCommandPalette({
|
||||
onClose,
|
||||
onCreate,
|
||||
onOpenAdd,
|
||||
onGallerySelect,
|
||||
onUploadFiles,
|
||||
state,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreate: (kind: CanvasCreateKind) => void;
|
||||
onOpenAdd: () => void;
|
||||
onGallerySelect: () => void;
|
||||
onUploadFiles: (files: File[]) => void;
|
||||
state: NonNullable<CanvasCommandState>;
|
||||
}) {
|
||||
const items: Array<{ kind: CanvasCreateKind; icon: LucideIcon; label: string; note: string }> = [
|
||||
{ kind: "image-input", icon: ImageIcon, label: "图片输入", note: "上传参考图/角色图" },
|
||||
{ kind: "video-input", icon: Video, label: "视频输入", note: "上传视频素材" },
|
||||
{ kind: "audio-input", icon: Music2, label: "音频输入", note: "上传音乐/语音" },
|
||||
{ kind: "text-input", icon: FileText, label: "文本输入", note: "脚本/提示词" },
|
||||
{ kind: "image-model", icon: Sparkles, label: "图片生成", note: "调用图片模型" },
|
||||
{ kind: "video-model", icon: Play, label: "视频生成", note: "调用视频模型" },
|
||||
{ kind: "audio-model", icon: Music2, label: "音频生成", note: "调用音频模型" },
|
||||
{ kind: "text-model", icon: FileText, label: "脚本生成", note: "调用文本模型" },
|
||||
];
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[90] w-[270px] rounded-[8px] border border-white/10 bg-[#202020]/98 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[10px]"
|
||||
className="fixed z-[90] flex w-[240px] flex-col gap-2 rounded-[16px] border border-white/10 bg-[#202020]/96 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[32px]"
|
||||
data-canvas-overlay="true"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
left: `min(${state.screenX}px, calc(100vw - 286px))`,
|
||||
top: `min(${state.screenY}px, calc(100vh - 384px))`,
|
||||
left: `min(${state.screenX}px, calc(100vw - 256px))`,
|
||||
top: `min(${state.screenY}px, calc(100vh - 540px))`,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
accept="image/*,video/*,audio/*"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) onUploadFiles(files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<div className="mb-2 flex items-center justify-between px-2 py-1">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold text-white/88">
|
||||
<MousePointer2 className="h-4 w-4 text-white/58" />
|
||||
自由生成节点
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-white/36">
|
||||
X {state.canvasX} / Y {state.canvasY}
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="text-[14px] font-medium leading-5 text-white/60">添加节点</h4>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-[8px] text-white/48 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-[8px] px-2 py-2 text-left transition-colors hover:bg-white/8"
|
||||
key={item.kind}
|
||||
onClick={() => onCreate(item.kind)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border border-white/8 bg-white/7 text-white/72">
|
||||
<Icon className="h-4 w-4" />
|
||||
{ADD_NODE_MODULES.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||||
key={item.kind}
|
||||
onClick={() => onCreate(item.kind)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||||
<span className="flex items-center gap-1.5 text-[14px] font-medium leading-5">
|
||||
{item.label}
|
||||
{item.badge ? <span className="rounded-[4px] bg-white/10 px-1.5 py-0.5 text-[10px] leading-3 text-white/50">{item.badge}</span> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] font-medium text-white/86">{item.label}</span>
|
||||
<span className="block truncate text-[11px] text-white/36">{item.note}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="my-2 h-px bg-white/8" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<h4 className="px-2 py-1 text-[14px] font-medium leading-5 text-white/60">添加资源</h4>
|
||||
<button
|
||||
className="flex h-9 w-full items-center justify-center gap-2 rounded-[8px] bg-white text-[13px] font-semibold text-black transition hover:bg-white/90"
|
||||
onClick={onOpenAdd}
|
||||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
打开全部模块
|
||||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||||
<Upload className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||||
<span className="text-[14px] font-medium leading-5">上传</span>
|
||||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||||
可上传图片、视频、音频文件
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||||
onClick={onGallerySelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||||
<Layers className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||||
<span className="text-[14px] font-medium leading-5">从图库选择</span>
|
||||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||||
从历史生成中选择素材
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@ -3221,7 +3392,8 @@ function CloneCanvasNode({
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon;
|
||||
const nodeType = getDraftString(node, "nodeType");
|
||||
const Icon = nodeType === "video-clip" ? Scissors : node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon;
|
||||
const isInput = node.kind === "input";
|
||||
const isWarm = node.kind === "text" || node.kind === "audio";
|
||||
const previewUrl = getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
|
||||
@ -3480,97 +3652,110 @@ function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected:
|
||||
}
|
||||
|
||||
function AddModulePanel({
|
||||
filteredTools,
|
||||
onAddTool,
|
||||
onAddModule,
|
||||
onClose,
|
||||
onSearchChange,
|
||||
onTabChange,
|
||||
search,
|
||||
tab,
|
||||
onGallerySelect,
|
||||
onUploadFiles,
|
||||
}: {
|
||||
filteredTools: CapturedTool[];
|
||||
onAddTool: (tool: CapturedTool) => void;
|
||||
onAddModule: (kind: AddModuleKind) => void;
|
||||
onClose: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onTabChange: (tab: (typeof PANEL_TABS)[number]) => void;
|
||||
search: string;
|
||||
tab: (typeof PANEL_TABS)[number];
|
||||
onGallerySelect: () => void;
|
||||
onUploadFiles: (files: File[]) => void;
|
||||
}) {
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const triggerUpload = () => {
|
||||
uploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="absolute left-[88px] top-[110px] z-50 flex h-[min(720px,calc(100vh-140px))] w-[408px] flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.48)]"
|
||||
className="fixed left-[88px] top-[300px] z-50 flex w-[240px] flex-col gap-2 rounded-[16px] border border-white/10 bg-[#202020]/96 p-2 text-white shadow-[0_24px_80px_rgba(0,0,0,0.52)] backdrop-blur-[32px]"
|
||||
data-canvas-overlay="true"
|
||||
>
|
||||
<div className="border-white/8 border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-[16px] text-white">添加节点</div>
|
||||
<div className="mt-1 text-[12px] text-white/42">工具 {capturedCounts.tools || capturedTools.length} / 工作流 {capturedCounts.workflows || capturedWorkflows.length}</div>
|
||||
</div>
|
||||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 rounded-[8px] border border-white/10 bg-black/24 px-3 py-2">
|
||||
<Search className="h-4 w-4 text-white/38" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] text-white outline-none placeholder:text-white/30"
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="搜索模型、厂商、模式"
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{PANEL_TABS.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-[8px] border border-white/8 px-2.5 py-1 text-[12px] text-white/58 hover:bg-white/8 hover:text-white",
|
||||
tab === item && "border-white/20 bg-white text-black hover:bg-white hover:text-black"
|
||||
)}
|
||||
key={item}
|
||||
onClick={() => onTabChange(item)}
|
||||
type="button"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
accept="image/*,video/*,audio/*"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) onUploadFiles(files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<h4 className="text-[14px] font-medium leading-5 text-white/60">添加节点</h4>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-[8px] text-white/42 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2">
|
||||
{filteredTools.map((tool) => {
|
||||
const type = getToolType(tool);
|
||||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<button
|
||||
className="group rounded-[8px] border border-white/8 bg-[#2a2a2a] p-3 text-left transition hover:border-white/18 hover:bg-[#323232]"
|
||||
key={`${tool.toolKey || tool.modelKey}-${tool.modelName}`}
|
||||
onClick={() => onAddTool(tool)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-white text-black">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-[14px] text-white">{getToolLabel(tool)}</span>
|
||||
<span className="mt-1 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-[6px] bg-white/8 px-1.5 py-0.5 text-[11px] text-white/54">{tool.vendor || meta.label}</span>
|
||||
{getToolModes(tool).map((mode) => (
|
||||
<span className="rounded-[6px] bg-white/8 px-1.5 py-0.5 text-[11px] text-white/46" key={mode}>
|
||||
{mode}
|
||||
</span>
|
||||
))}
|
||||
{ADD_NODE_MODULES.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||||
key={item.kind}
|
||||
onClick={() => onAddModule(item.kind)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||||
<span className="flex items-center gap-1.5 text-[14px] font-medium leading-5">
|
||||
{item.label}
|
||||
{item.badge ? (
|
||||
<span className="rounded-[4px] bg-white/10 px-1.5 py-0.5 text-[10px] leading-3 text-white/50">
|
||||
{item.badge}
|
||||
</span>
|
||||
</span>
|
||||
<Plus className="mt-1 h-4 w-4 text-white/34 group-hover:text-white" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<h4 className="px-2 py-1 text-[14px] font-medium leading-5 text-white/60">添加资源</h4>
|
||||
<button
|
||||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||||
onClick={triggerUpload}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||||
<Upload className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||||
<span className="text-[14px] font-medium leading-5">上传</span>
|
||||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||||
可上传图片、视频、音频文件
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||||
onClick={onGallerySelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||||
<Layers className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||||
<span className="text-[14px] font-medium leading-5">从图库选择</span>
|
||||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||||
从历史生成中选择素材
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user