Browse Source

句柄修改

feature/handle
TianYun 1 month ago
parent
commit
d0f4f93095
  1. 49
      src/components/ConnectionDropMenu.tsx
  2. 1
      src/components/GenerateNodeMenu.tsx
  3. 17
      src/components/GlobalImageHistory.tsx
  4. 42
      src/components/WorkflowCanvas.tsx
  5. 12
      src/components/__tests__/ConnectionDropMenu.test.tsx
  6. 5
      src/components/__tests__/FloatingActionBar.test.tsx
  7. 24
      src/components/nodeMenuVisibility.ts
  8. 116
      src/utils/nodeConnectionRegistry.ts
  9. 111
      src/utils/nodeHandles.ts

49
src/components/ConnectionDropMenu.tsx

@ -5,11 +5,10 @@ import { NodeType } from "@/types";
import { useI18n } from "@/i18n";
import { GenerateNodeIcon, VISIBLE_NODE_MENU_ITEMS } from "./GenerateNodeMenu";
import {
NODE_OUTPUT_MEDIA_TYPE_BY_NODE_TYPE,
getNodeAcceptedInputMediaTypes,
type NodeInputMediaType,
type NodeOutputMediaType,
} from "@/utils/nodeHandles";
import { CONNECTION_GROUP_SOURCE_NODES } from "@/utils/nodeConnectionRegistry";
// Actions are special menu items that trigger behavior instead of creating a node
export type MenuAction = "splitGridImmediate" | "multiAngleImage";
@ -58,37 +57,16 @@ const OUTPUT_HANDLE_MENU_ORDER: Partial<Record<ConnectionHandleType, QuickAddOpt
"multiAngleImage",
"smartText",
],
video: ["videoStitch", "easeCurve", "videoFrameGrab", "smartVideo", "smartText"],
video: ["videoStitch", "easeCurve", "videoTrim", "videoFrameGrab", "smartVideo", "smartText"],
audio: ["smartVideo", "videoStitch"],
};
const VISIBLE_CONNECTION_NODE_TYPES = VISIBLE_NODE_MENU_ITEMS.map((item) => item.type);
const NODE_INPUT_MEDIA_OVERRIDES: Partial<Record<NodeType, NodeInputMediaType[]>> = {
smartText: ["text", "image", "video", "audio"],
smartImage: ["text", "image"],
smartVideo: ["text", "image", "video", "audio"],
smartAudio: ["text"],
promptConstructor: ["text"],
array: ["text"],
annotation: ["image"],
splitGrid: ["image"],
imageCompare: ["image"],
videoStitch: ["video", "audio"],
videoTrim: ["video"],
videoFrameGrab: ["video"],
easeCurve: ["video", "easeCurve"],
glbViewer: ["3d"],
};
const NODE_OUTPUT_MEDIA_OVERRIDES: Partial<Record<NodeType, NodeOutputMediaType | "easeCurve" | null>> = {
easeCurve: "video",
};
const CONNECTION_MENU_ORDER: Partial<Record<ConnectionHandleType, QuickAddOptionType[]>> = {
image: ["annotation", "smartImage", "smartVideo", "splitGrid", "splitGridImmediate", "imageCompare", "multiAngleImage", "smartText"],
text: ["smartImage", "smartVideo", "smartAudio", "smartText", "promptConstructor", "array"],
video: ["videoStitch", "easeCurve", "videoFrameGrab", "smartVideo", "smartText"],
video: ["videoStitch", "easeCurve", "videoTrim", "videoFrameGrab", "smartVideo", "smartText"],
audio: ["smartVideo", "videoStitch"],
"3d": ["glbViewer"],
easeCurve: ["easeCurve"],
@ -104,17 +82,13 @@ const uniqueOptions = (options: MenuOption[]): MenuOption[] => {
};
const getAcceptedInputTypes = (nodeType: NodeType): NodeInputMediaType[] => {
return NODE_INPUT_MEDIA_OVERRIDES[nodeType] ?? getNodeAcceptedInputMediaTypes({
return getNodeAcceptedInputMediaTypes({
id: `connection-menu-${nodeType}`,
type: nodeType,
data: {},
});
};
const getProducedOutputType = (nodeType: NodeType): NodeOutputMediaType | "easeCurve" | null => {
return NODE_OUTPUT_MEDIA_OVERRIDES[nodeType] ?? NODE_OUTPUT_MEDIA_TYPE_BY_NODE_TYPE[nodeType] ?? null;
};
const createVisibleNodeOption = (type: NodeType): MenuOption => ({
type,
label: type,
@ -171,15 +145,16 @@ const getVisibleConnectionOptions = (
if (orderedOutputOptions) return uniqueOptions(orderedOutputOptions);
}
const options = VISIBLE_CONNECTION_NODE_TYPES.flatMap((nodeType) => {
if (connectionType === "source") {
return getAcceptedInputTypes(nodeType).some((type) => handleTypeSet.has(type as ConnectionHandleType))
? [createVisibleNodeOption(nodeType)]
: [];
if (connectionType === "target") {
const groupedSourceNodes = handleTypes.flatMap((handleType) => (
handleType === "easeCurve" ? ["easeCurve" as const] : CONNECTION_GROUP_SOURCE_NODES[handleType as keyof typeof CONNECTION_GROUP_SOURCE_NODES] ?? []
));
const visibleSourceNodes = groupedSourceNodes.filter((nodeType) => VISIBLE_CONNECTION_NODE_TYPES.includes(nodeType));
return sortOptionsForHandle(uniqueOptions(visibleSourceNodes.map(createVisibleNodeOption)), handleTypes);
}
const producedType = getProducedOutputType(nodeType);
return producedType && handleTypeSet.has(producedType)
const options = VISIBLE_CONNECTION_NODE_TYPES.flatMap((nodeType) => {
return getAcceptedInputTypes(nodeType).some((type) => handleTypeSet.has(type as ConnectionHandleType))
? [createVisibleNodeOption(nodeType)]
: [];
});

1
src/components/GenerateNodeMenu.tsx

@ -47,7 +47,6 @@ const OTHER_NODE_MENU_CATEGORIES: { labelKey: TranslationKey; nodes: GenerateNod
{ type: "videoTrim", labelKey: "node.videoTrim" },
{ type: "videoFrameGrab", labelKey: "node.frameGrab" },
{ type: "easeCurve", labelKey: "node.easeCurve" },
{ type: "glbViewer", labelKey: "node.glbViewer" },
],
},
];

17
src/components/GlobalImageHistory.tsx

@ -216,7 +216,15 @@ function HistorySidebar({
);
}
export function GlobalImageHistory() {
type GlobalImageHistoryProps = {
placement?: "floating" | "inline";
className?: string;
};
export function GlobalImageHistory({
placement = "floating",
className = "",
}: GlobalImageHistoryProps) {
const [isOpen, setIsOpen] = useState(false);
const [showSidebar, setShowSidebar] = useState(false);
const [previewItem, setPreviewItem] = useState<{ item: ImageHistoryItem; alt: string } | null>(null);
@ -308,8 +316,13 @@ export function GlobalImageHistory() {
if (history.length === 0) return null;
const placementClass =
placement === "inline"
? "relative z-10"
: "absolute bottom-4 right-10 z-30";
return (
<div ref={drawerRef} className="absolute bottom-4 right-64 z-10">
<div ref={drawerRef} className={`nodrag nopan nowheel ${placementClass} ${className}`}>
{/* Trigger Button */}
<button
ref={triggerRef}

42
src/components/WorkflowCanvas.tsx

@ -132,6 +132,7 @@ import {
SINGLE_OUTPUT_HANDLE_ID,
type ConcreteHandleType,
} from "@/utils/nodeHandles";
import { getNodeAcceptedConnectionGroups } from "@/utils/nodeConnectionRegistry";
const nodeTypes: NodeTypes = {
group: GroupNode,
@ -234,45 +235,26 @@ interface ConnectionDropState {
sourceHandleId: string | null;
}
const MODEL_BACKED_CONNECTION_NODE_TYPES = new Set<NodeType>([
"smartImage",
"smartVideo",
"smartAudio",
"smartText",
"nanoBanana",
"generateVideo",
"generateAudio",
"generate3d",
"llmGenerate",
]);
function uniqueConnectionMediaTypes(mediaTypes: ConcreteHandleType[]): ConcreteHandleType[] {
return mediaTypes.filter((mediaType, index) => mediaTypes.indexOf(mediaType) === index);
}
function modelSupportsInputMedia(model: SelectedModel | null | undefined, mediaType: ConcreteHandleType): boolean {
if (mediaType === "text") return true;
if (mediaType === "3d" || mediaType === "easeCurve") return false;
const metadata = model?.metadata;
if (!metadata) return true;
if (mediaType === "image") return metadata.isSupportImages !== false;
if (mediaType === "video") return metadata.isSupportVideos === true;
return metadata.isSupportAudios === true || metadata.isSupportAudio === true;
}
function getInputHandleTypesForMenu(node: Node): ConcreteHandleType[] {
const nodeType = node.type as NodeType | undefined;
if (!nodeType) return [];
if (MODEL_BACKED_CONNECTION_NODE_TYPES.has(nodeType)) {
const data = node.data as { selectedModel?: SelectedModel | null; selectedLLMModel?: SelectedModel | null };
const selectedModel = data.selectedModel ?? data.selectedLLMModel ?? null;
return uniqueConnectionMediaTypes([
"text",
...(["image", "video", "audio"] as const).filter((mediaType) => modelSupportsInputMedia(selectedModel, mediaType)),
]);
const configuredTypes = getNodeAcceptedConnectionGroups(nodeType, selectedModel);
if (configuredTypes.length > 0) {
return configuredTypes.filter((mediaType): mediaType is ConcreteHandleType => (
mediaType === "text" ||
mediaType === "image" ||
mediaType === "video" ||
mediaType === "audio" ||
mediaType === "3d" ||
mediaType === "easeCurve"
));
}
const acceptedTypes = getNodeAcceptedInputMediaTypes(node).filter((mediaType): mediaType is ConcreteHandleType => (
@ -284,7 +266,7 @@ function getInputHandleTypesForMenu(node: Node): ConcreteHandleType[] {
mediaType === "easeCurve"
));
return uniqueConnectionMediaTypes(["text", ...acceptedTypes]);
return uniqueConnectionMediaTypes(acceptedTypes);
}
function resolveConnectionIntent(

12
src/components/__tests__/ConnectionDropMenu.test.tsx

@ -146,12 +146,12 @@ describe("ConnectionDropMenu", () => {
expect(getButtonLabels()).toEqual([
"Video Stitch",
"Ease Curve",
"Video Trim",
"Frame Grab",
"Video",
"Text",
]);
expect(screen.queryByText("Output")).not.toBeInTheDocument();
expect(screen.queryByText("Video Trim")).not.toBeInTheDocument();
expect(screen.queryByText("Video Input")).not.toBeInTheDocument();
expect(screen.queryByText("Annotate")).not.toBeInTheDocument();
});
@ -164,11 +164,11 @@ describe("ConnectionDropMenu", () => {
expect(screen.queryByText("Audio")).not.toBeInTheDocument();
});
it("shows visible 3D-compatible target nodes", () => {
it("hides 3D viewer target nodes", () => {
render(<ConnectionDropMenu {...defaultProps} handleType="3d" connectionType="source" />);
expect(screen.getByText("Add 3D node")).toBeInTheDocument();
expect(screen.getByText("3D Viewer")).toBeInTheDocument();
expect(screen.queryByText("Add 3D node")).not.toBeInTheDocument();
expect(screen.queryByText("3D Viewer")).not.toBeInTheDocument();
expect(screen.queryByText("Video")).not.toBeInTheDocument();
expect(screen.queryByText("Generate Image")).not.toBeInTheDocument();
});
@ -274,8 +274,8 @@ describe("ConnectionDropMenu", () => {
expect(screen.getByText("Image")).toBeInTheDocument();
expect(screen.getByText("Annotate")).toBeInTheDocument();
expect(screen.getByText("3D Viewer")).toBeInTheDocument();
expect(screen.getByText("Frame Grab")).toBeInTheDocument();
expect(screen.queryByText("3D Viewer")).not.toBeInTheDocument();
expect(screen.queryByText("Prompt")).not.toBeInTheDocument();
});
@ -298,7 +298,9 @@ describe("ConnectionDropMenu", () => {
expect(screen.getByText("Add text node")).toBeInTheDocument();
expect(screen.getByText("Text")).toBeInTheDocument();
expect(screen.getByText("Prompt Constructor")).toBeInTheDocument();
expect(screen.getByText("Array")).toBeInTheDocument();
expect(screen.getByText("Image")).toBeInTheDocument();
expect(screen.getByText("Frame Grab")).toBeInTheDocument();
expect(screen.getByText("Annotate")).toBeInTheDocument();
expect(screen.queryByText("Video")).not.toBeInTheDocument();
});

5
src/components/__tests__/FloatingActionBar.test.tsx

@ -244,7 +244,7 @@ describe("FloatingActionBar", () => {
expect(mockSetEdgeStyle).toHaveBeenCalledWith("curved");
});
it("opens generate menu with five primary nodes and an Other submenu", () => {
it("opens generate menu with primary nodes and an Other submenu", () => {
render(
<TestWrapper>
<FloatingActionBar />
@ -257,7 +257,6 @@ describe("FloatingActionBar", () => {
expect(screen.getByText("Image", { selector: "button.w-full" })).toBeInTheDocument();
expect(screen.getByText("Video", { selector: "button.w-full" })).toBeInTheDocument();
expect(screen.getByText("Audio", { selector: "button.w-full" })).toBeInTheDocument();
expect(screen.getByText("Text (LLM)", { selector: "button.w-full" })).toBeInTheDocument();
const otherButton = screen.getByText("Other").closest("button");
expect(otherButton).toBeInTheDocument();
expect(screen.queryByText("Annotate")).not.toBeInTheDocument();
@ -273,7 +272,7 @@ describe("FloatingActionBar", () => {
expect(screen.getByText("Video Trim")).toBeInTheDocument();
expect(screen.getByText("Frame Grab")).toBeInTheDocument();
expect(screen.getByText("Ease Curve")).toBeInTheDocument();
expect(screen.getByText("3D Viewer")).toBeInTheDocument();
expect(screen.queryByText("3D Viewer")).not.toBeInTheDocument();
expect(screen.queryByText("Image Input")).not.toBeInTheDocument();
expect(screen.queryByText("Video Input")).not.toBeInTheDocument();
expect(screen.queryByText("Audio Input")).not.toBeInTheDocument();

24
src/components/nodeMenuVisibility.ts

@ -1,24 +0,0 @@
import { NodeType } from "@/types";
export const HIDDEN_SIDEBAR_NODE_TYPES = new Set<NodeType>([
"smartImage",
"smartVideo",
"prompt",
"output",
"outputGallery",
"router",
"switch",
"conditionalSwitch",
"generate3d",
"glbViewer",
]);
export const HIDDEN_CONNECTION_MENU_NODE_TYPES = new Set<NodeType>([
"output",
"outputGallery",
"router",
"switch",
"conditionalSwitch",
"generate3d",
"glbViewer",
]);

116
src/utils/nodeConnectionRegistry.ts

@ -0,0 +1,116 @@
import type { NodeType, SelectedModel } from "@/types";
export type ConnectionMediaGroup = "text" | "image" | "video" | "audio" | "3d";
export type ConnectionInputGroup = ConnectionMediaGroup | "easeCurve";
export const CONNECTION_GROUP_SOURCE_NODES = {
text: ["smartText", "promptConstructor", "array"],
image: ["smartImage", "videoFrameGrab", "annotation"],
video: ["smartVideo", "videoStitch", "easeCurve", "videoTrim"],
audio: ["smartAudio"],
"3d": [],
} as const satisfies Record<ConnectionMediaGroup, readonly NodeType[]>;
export const NODE_OUTPUT_GROUPS: Partial<Record<NodeType, ConnectionMediaGroup>> = {
imageInput: "image",
smartImage: "image",
nanoBanana: "image",
annotation: "image",
splitGrid: "image",
videoFrameGrab: "image",
glbViewer: "image",
videoInput: "video",
smartVideo: "video",
generateVideo: "video",
videoStitch: "video",
easeCurve: "video",
videoTrim: "video",
audioInput: "audio",
smartAudio: "audio",
generateAudio: "audio",
prompt: "text",
smartText: "text",
llmGenerate: "text",
array: "text",
promptConstructor: "text",
switch: "text",
conditionalSwitch: "text",
generate3d: "3d",
};
export const NODE_INPUT_GROUPS: Partial<Record<NodeType, readonly ConnectionInputGroup[]>> = {
imageInput: ["image", "text"],
videoInput: ["video", "text"],
audioInput: ["audio", "text"],
prompt: ["text"],
promptConstructor: ["text"],
array: ["text"],
annotation: ["image"],
splitGrid: ["image"],
imageCompare: ["image"],
videoStitch: ["video", "audio"],
videoTrim: ["video"],
videoFrameGrab: ["video"],
easeCurve: ["video"],
glbViewer: ["3d"],
output: ["image", "text", "video", "audio", "3d"],
outputGallery: ["image", "text", "video", "audio"],
router: ["image", "text", "video", "audio", "3d"],
switch: ["text"],
conditionalSwitch: ["text"],
};
export const MODEL_BACKED_CONNECTION_NODE_TYPES = new Set<NodeType>([
"smartImage",
"smartVideo",
"smartAudio",
"smartText",
"nanoBanana",
"generateVideo",
"generateAudio",
"generate3d",
"llmGenerate",
]);
export function selectedModelSupportsConnectionGroup(
model: SelectedModel | null | undefined,
group: ConnectionInputGroup
): boolean {
if (group === "text") return true;
if (group === "3d" || group === "easeCurve") return false;
const metadata = model?.metadata;
if (!metadata) return true;
if (group === "image") return metadata.isSupportImages !== false;
if (group === "video") return metadata.isSupportVideos === true;
return metadata.isSupportAudios === true || metadata.isSupportAudio === true;
}
export function getModelBackedInputGroups(
model: SelectedModel | null | undefined
): ConnectionInputGroup[] {
return [
"text",
...(["image", "video", "audio"] as const).filter((group) => selectedModelSupportsConnectionGroup(model, group)),
];
}
export function getNodeAcceptedConnectionGroups(
nodeType: NodeType,
selectedModel?: SelectedModel | null
): ConnectionInputGroup[] {
if (MODEL_BACKED_CONNECTION_NODE_TYPES.has(nodeType)) {
return getModelBackedInputGroups(selectedModel);
}
return [...(NODE_INPUT_GROUPS[nodeType] ?? [])];
}

111
src/utils/nodeHandles.ts

@ -3,42 +3,18 @@ import { deriveSmartAudioMode } from "@/utils/smartAudioMode";
import { deriveSmartImageMode } from "@/utils/smartImageMode";
import { deriveSmartTextMode } from "@/utils/smartTextMode";
import { deriveSmartVideoMode } from "@/utils/smartVideoMode";
import {
NODE_OUTPUT_GROUPS,
getNodeAcceptedConnectionGroups,
selectedModelSupportsConnectionGroup,
} from "@/utils/nodeConnectionRegistry";
export type ConcreteHandleType = "image" | "text" | "video" | "audio" | "3d" | "easeCurve";
export type NodeOutputMediaType = "image" | "video" | "audio" | "text" | "3d";
export type NodeInputMediaType = NodeOutputMediaType | "easeCurve";
export const SINGLE_INPUT_HANDLE_ID = "input";
export const SINGLE_OUTPUT_HANDLE_ID = "output";
export const NODE_OUTPUT_MEDIA_TYPE_BY_NODE_TYPE: Partial<Record<NodeType, NodeOutputMediaType>> = {
imageInput: "image",
smartImage: "image",
nanoBanana: "image",
annotation: "image",
splitGrid: "image",
videoFrameGrab: "image",
glbViewer: "image",
videoInput: "video",
smartVideo: "video",
generateVideo: "video",
videoStitch: "video",
easeCurve: "video",
videoTrim: "video",
audioInput: "audio",
smartAudio: "audio",
generateAudio: "audio",
prompt: "text",
smartText: "text",
llmGenerate: "text",
array: "text",
promptConstructor: "text",
switch: "text",
conditionalSwitch: "text",
generate3d: "3d",
};
export const NODE_OUTPUT_MEDIA_TYPE_BY_NODE_TYPE = NODE_OUTPUT_GROUPS;
export type NodeHandleCapability = {
inputs: string[];
@ -69,6 +45,7 @@ type CapabilityNode = {
type ModelBackedNodeData = {
selectedModel?: SelectedModel | null;
selectedLLMModel?: SelectedModel | null;
};
export function getHandleType(handleId: string | null | undefined): ConcreteHandleType | null {
@ -157,64 +134,8 @@ export function getNodeOutputMediaType(node: CapabilityNode): NodeOutputMediaTyp
export function getNodeAcceptedInputMediaTypes(node: CapabilityNode): NodeInputMediaType[] {
const nodeType = node.type as NodeType | undefined;
if (!nodeType) return [];
switch (nodeType) {
case "imageInput":
case "smartImage":
case "nanoBanana":
case "annotation":
case "splitGrid":
case "imageCompare":
return ["image", "text"];
case "videoInput":
case "smartVideo":
case "generateVideo":
return ["image", "text", "video", "audio"];
case "videoStitch":
return ["video", "audio", "text"];
case "easeCurve":
case "videoTrim":
case "videoFrameGrab":
return ["video", "text"];
case "audioInput":
return ["audio", "text"];
case "smartAudio":
case "generateAudio":
return ["text"];
case "prompt":
case "smartText":
case "llmGenerate":
case "array":
case "promptConstructor":
case "switch":
case "conditionalSwitch":
return ["text", "image", "video", "audio"];
case "generate3d":
return ["image", "text"];
case "output":
case "outputGallery":
return ["image", "text", "video", "audio", "3d"];
case "router":
return ["image", "text", "video", "audio", "3d"];
case "glbViewer":
return ["3d", "image", "text"];
case "group":
return [];
default:
return [];
}
const data = node.data as ModelBackedNodeData;
return getNodeAcceptedConnectionGroups(nodeType, data.selectedModel ?? data.selectedLLMModel ?? null);
}
export function getNodeConnectionCapability(node: CapabilityNode, edges: WorkflowEdge[]): NodeConnectionCapability {
@ -237,19 +158,7 @@ export function getPrimaryOutputHandleType(node: CapabilityNode, _edges: Workflo
}
export function selectedModelSupportsInputMedia(model: SelectedModel | null | undefined, mediaType: NodeOutputMediaType): boolean {
if (mediaType === "text") return true;
if (mediaType === "3d") return false;
const metadata = model?.metadata;
if (!metadata) return true;
if (mediaType === "image") return metadata.isSupportImages !== false;
if (mediaType === "video") return metadata.isSupportVideos === true;
if (mediaType === "audio") {
return metadata.isSupportAudios === true || metadata.isSupportAudio === true;
}
return false;
return selectedModelSupportsConnectionGroup(model, mediaType);
}
export function canConnectOutputMediaToNode(sourceMediaType: NodeOutputMediaType | null, targetNode: CapabilityNode): boolean {

Loading…
Cancel
Save