Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
87b6ec3
feat(backend): add node abstraction base classes and discovery
leszko Apr 10, 2026
fa24295
feat(frontend): add custom node catalog and generic renderer
leszko Apr 10, 2026
739cede
refactor: unify Pipeline and Node under BaseNode
leszko Apr 10, 2026
89c3517
refactor: unify node discovery endpoint with pipeline_meta
leszko Apr 13, 2026
19c44f6
refactor: NodeParam widget hints in generic `ui` dict
leszko Apr 13, 2026
3244e51
refactor: tighten NodeRegistry error surfaces
leszko Apr 13, 2026
69c5db7
refactor: review fixes for custom node abstraction
leszko Apr 13, 2026
200d639
feat(backend): graph-executor node runtime and audio built-ins
leszko Apr 10, 2026
6ee9f5b
feat(frontend): enable node-only graph streams in StreamPage
leszko Apr 10, 2026
b62be96
refactor: drop AudioSinkNode, route audio edges directly to Sink
leszko Apr 14, 2026
fa708c5
chore(node-processor): name worker threads by node_id
leszko Apr 14, 2026
394d1de
fix(node-processor): squeeze 3D batch dim in _route_audio
leszko Apr 14, 2026
42224dc
refactor(node-base): drop unused IS_CHANGED cache-key hook
leszko Apr 14, 2026
43e3c10
refactor(node-base): make BaseNode.execute abstract, push stub to Pip…
leszko Apr 14, 2026
c7a6a33
refactor(stream): drop frontend produces_audio override for custom nodes
leszko Apr 14, 2026
f68f1e7
chore(stream): drop redundant comment about produces_audio
leszko Apr 14, 2026
d383e1b
refactor(nodes): remove unused ComfyUI-style local loader
leszko Apr 14, 2026
81201f8
Remove execute() from pipeline interface
leszko Apr 14, 2026
3f9d9fe
Remove execute() from pipeline interface
leszko Apr 14, 2026
476c901
refactor(graph-executor): drop speculative no-Sink fallback
leszko Apr 14, 2026
5967de3
refactor(node-base): restore default execute() body on BaseNode
leszko Apr 14, 2026
da9111c
refactor: simplify audio_io and node processor, trim dead code
leszko Apr 14, 2026
53744bf
feat: add scheduler built-in node
leszko Apr 10, 2026
d282931
fix(nodes): wire scheduler into backend graph execution
leszko Apr 14, 2026
06b161d
fix(scheduler): capture first pulse, broadcast pause, harden auto-start
leszko Apr 14, 2026
b012b66
Merge branch 'main' into rafal/node-backend-execution-2-execution-sch…
leszko Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 92 additions & 15 deletions frontend/src/components/graph/AddNodeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useState, useMemo, useRef, useEffect } from "react";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../ui/dialog";
import type { FlowNodeData } from "../../lib/graphUtils";

interface AddNodeModalProps {
open: boolean;
Expand Down Expand Up @@ -36,8 +37,10 @@ interface AddNodeModalProps {
| "tempo"
| "prompt_list"
| "prompt_blend"
| "scheduler",
subType?: string
| "scheduler"
| "custom_node",
subType?: string,
extraData?: Partial<FlowNodeData>
) => void;
}

Expand Down Expand Up @@ -67,12 +70,15 @@ interface NodeCatalogItem {
| "tempo"
| "prompt_list"
| "prompt_blend"
| "scheduler";
| "scheduler"
| "custom_node";
subType?: string;
name: string;
description: string;
color: string;
category: string;
/** Full definition for custom nodes (inputs/outputs/params). */
customNodeDef?: Record<string, unknown>;
}

const NODE_CATALOG: NodeCatalogItem[] = [
Expand All @@ -85,8 +91,8 @@ const NODE_CATALOG: NodeCatalogItem[] = [
},
{
type: "pipeline",
name: "Pipeline",
description: "Processing pipeline node",
name: "Node",
description: "Video processing node (pick a model after dropping it)",
color: "#60a5fa",
category: "I/O",
},
Expand Down Expand Up @@ -294,7 +300,15 @@ const NODE_CATALOG: NodeCatalogItem[] = [
},
];

const CATEGORIES = ["All", "I/O", "Values", "Controls", "UI", "Utility"];
const CATEGORIES = [
"All",
"I/O",
"Values",
"Controls",
"UI",
"Utility",
"Plugins",
];

interface TooltipState {
text: string;
Expand Down Expand Up @@ -388,10 +402,50 @@ export function AddNodeModal({
}: AddNodeModalProps) {
const [searchText, setSearchText] = useState("");
const [activeCategory, setActiveCategory] = useState("All");
const [customNodes, setCustomNodes] = useState<NodeCatalogItem[]>([]);

useEffect(() => {
if (!open) return;
fetch("/api/v1/nodes/definitions")
.then(r => r.json())
.then(data => {
// The unified endpoint returns both pipelines (pipeline_meta != null)
// and plain custom nodes. Pipelines are still added via the hardcoded
// "Pipeline" catalog entry (placeholder + dropdown); the scheduler
// has its own catalog entry with a bespoke widget. Filter both out
// of the plugin listing to avoid duplication.
const items: NodeCatalogItem[] = (data.nodes ?? [])

.filter(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(n: any) =>
n.pipeline_meta == null && n.node_type_id !== "scheduler"
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((n: any) => ({
type: "custom_node" as const,
subType: n.node_type_id,
name: n.display_name || n.node_type_id,
description: n.description || "",
color: "#9ca3af",
category: "Plugins",
customNodeDef: n,
}));
setCustomNodes(items);
})
.catch(() => {
/* ignore — custom nodes just won't appear */
});
}, [open]);

const fullCatalog = useMemo(
() => [...NODE_CATALOG, ...customNodes],
[customNodes]
);

const filteredItems = useMemo(() => {
const lowerSearch = searchText.toLowerCase();
return NODE_CATALOG.filter(item => {
return fullCatalog.filter(item => {
const matchesSearch =
!lowerSearch ||
item.name.toLowerCase().includes(lowerSearch) ||
Expand All @@ -400,14 +454,37 @@ export function AddNodeModal({
activeCategory === "All" || item.category === activeCategory;
return matchesSearch && matchesCategory;
});
}, [searchText, activeCategory]);
}, [searchText, activeCategory, fullCatalog]);

const handleSelect = (item: NodeCatalogItem) => {
onSelectNodeType(item.type, item.subType);
onClose();
setSearchText("");
setActiveCategory("All");
};
const handleSelect = useCallback(
(item: NodeCatalogItem) => {
if (item.type === "custom_node" && item.customNodeDef) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const def = item.customNodeDef as any;
onSelectNodeType("custom_node", item.subType, {
customNodeTypeId: def.node_type_id,
customNodeDisplayName: def.display_name || def.node_type_id,
customNodeCategory: def.category || "",
customNodeInputs: def.inputs || [],
customNodeOutputs: def.outputs || [],
customNodeParamDefs: def.params || [],
customNodeParams: Object.fromEntries(
(def.params || [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((p: any) => p.default != null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((p: any) => [p.name, p.default])
),
});
} else {
onSelectNodeType(item.type, item.subType);
}
onClose();
setSearchText("");
setActiveCategory("All");
},
[onSelectNodeType, onClose]
);

const handleClose = () => {
onClose();
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/graph/GraphEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { TempoNode } from "./nodes/TempoNode";
import { PromptListNode } from "./nodes/PromptListNode";
import { PromptBlendNode } from "./nodes/PromptBlendNode";
import { SchedulerNode } from "./nodes/SchedulerNode";
import { CustomNode } from "./nodes/CustomNode";
import { CustomEdge } from "./CustomEdge";
import { ContextMenu } from "./ContextMenu";
import { AddNodeModal } from "./AddNodeModal";
Expand Down Expand Up @@ -130,6 +131,7 @@ const nodeTypes = {
prompt_list: PromptListNode,
prompt_blend: PromptBlendNode,
scheduler: SchedulerNode,
custom_node: CustomNode,
};

const edgeTypes = {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/graph/contextMenuItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ export function buildPaneMenuItems(deps: {
keywords: ["input", "camera", "video"],
},
{
label: "Pipeline",
label: "Node",
icon: <Workflow />,
onClick: () => handleNodeTypeSelect("pipeline"),
keywords: ["process", "effect", "filter"],
keywords: ["process", "effect", "filter", "pipeline"],
},
{
label: "Sink",
Expand Down
95 changes: 93 additions & 2 deletions frontend/src/components/graph/hooks/graph/useGraphPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
parseHandleId,
} from "../../../../lib/graphUtils";
import type { FlowNodeData } from "../../../../lib/graphUtils";
import type { PluginInfo } from "../../../../lib/api";
import { resolveWorkflow } from "../../../../lib/api";
import type { PluginInfo, NodeDefinitionDto } from "../../../../lib/api";
import { resolveWorkflow, fetchNodeDefinitions } from "../../../../lib/api";
import type {
ScopeWorkflow,
WorkflowResolutionPlan,
Expand Down Expand Up @@ -65,6 +65,72 @@ function clearGraphFromLocalStorage(): void {
}
}

/**
* After loading or importing a workflow, fetch `/api/v1/nodes/definitions`
* and hydrate each custom_node flow node with its inputs/outputs/params/
* display metadata. Saved workflows only persist `node_type_id` and
* `params`, so the port definitions have to be re-attached before render.
* User-supplied param values override the defaults from the definition.
*
* Accepts an AbortSignal so rapid reloads / unmounts can cancel an
* in-flight fetch before its setNodes callback stomps on newer state.
*/
function hydrateCustomNodeDefinitions(
nodes: Node<FlowNodeData>[],
setNodes: React.Dispatch<React.SetStateAction<Node<FlowNodeData>[]>>,
signal: AbortSignal
): void {
const customFlowNodes = nodes.filter(
n => n.data.nodeType === "custom_node" && !n.data.customNodeInputs
);
if (customFlowNodes.length === 0) return;
fetchNodeDefinitions({ signal })
.then(data => {
if (signal.aborted) return;
const defMap = new Map<string, NodeDefinitionDto>(
data.nodes.map(d => [d.node_type_id, d])
);
setNodes(prev => {
if (signal.aborted) return prev;
return prev.map(n => {
if (
n.data.nodeType !== "custom_node" ||
!n.data.customNodeTypeId ||
n.data.customNodeInputs
) {
return n;
}
const def = defMap.get(n.data.customNodeTypeId);
if (!def) return n;
return {
...n,
data: {
...n.data,
customNodeDisplayName: def.display_name,
customNodeCategory: def.category,
customNodeInputs: def.inputs ?? [],
customNodeOutputs: def.outputs ?? [],
customNodeParamDefs: def.params ?? [],
customNodeParams: {
...Object.fromEntries(
(def.params ?? [])
.filter(p => p.default != null)
.map(p => [p.name, p.default] as const)
),
// User-edited values take precedence over definition defaults
...(n.data.customNodeParams || {}),
},
},
};
});
});
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
// custom nodes just won't be hydrated; render falls back to placeholders
});
}

interface UseGraphPersistenceArgs {
nodes: Node<FlowNodeData>[];
edges: Edge[];
Expand Down Expand Up @@ -129,6 +195,25 @@ export function useGraphPersistence({
// to localStorage so we skip the expensive save when nothing changed.
const lastSavedJsonRef = useRef<string>("");

// AbortController for in-flight custom-node hydration fetches. Aborted
// before each new hydrate and on unmount so a stale /api/v1/nodes/definitions
// response can't overwrite newer nodes state.
const hydrateAbortRef = useRef<AbortController | null>(null);
const startHydrate = useCallback(
(initialNodes: Node<FlowNodeData>[]) => {
hydrateAbortRef.current?.abort();
const controller = new AbortController();
hydrateAbortRef.current = controller;
hydrateCustomNodeDefinitions(initialNodes, setNodes, controller.signal);
},
[setNodes]
);
useEffect(() => {
return () => {
hydrateAbortRef.current?.abort();
};
}, []);

const loadGraph = useCallback(() => {
if (Object.keys(portsMap).length === 0) return;
resetNavigationRef.current?.();
Expand Down Expand Up @@ -182,6 +267,8 @@ export function useGraphPersistence({
}, 0);
}

startHydrate(enriched);

// Allow async side-effects (e.g. source mode restore) to settle
// before re-enabling change notifications.
setTimeout(() => {
Expand All @@ -202,6 +289,7 @@ export function useGraphPersistence({
setEdges,
setNodeParams,
setNodes,
startHydrate,
]);

useEffect(() => {
Expand Down Expand Up @@ -374,6 +462,8 @@ export function useGraphPersistence({
}
}, 0);
}

startHydrate(enriched);
},
[
portsMap,
Expand All @@ -384,6 +474,7 @@ export function useGraphPersistence({
setNodeParams,
enrichDepsRef,
resetNavigationRef,
startHydrate,
]
);

Expand Down
24 changes: 21 additions & 3 deletions frontend/src/components/graph/hooks/node/useNodeFactories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
| "tempo"
| "prompt_list"
| "prompt_blend"
| "scheduler";
| "scheduler"
| "custom_node";

interface NodeDefaults {
/** The React Flow node `type` */
Expand Down Expand Up @@ -471,6 +472,15 @@
],
},
},
custom_node: {
type: "custom_node",
idPrefix: "custom",
defaultX: 300,
data: {
label: "Custom Node",
nodeType: "custom_node" as const,
},
},
};

interface UseNodeFactoriesArgs {
Expand Down Expand Up @@ -566,8 +576,10 @@
| "tempo"
| "prompt_list"
| "prompt_blend"
| "scheduler",
subType?: string
| "scheduler"
| "custom_node",
subType?: string,
extraData?: Partial<FlowNodeData>
) => {
if (!pendingNodePosition) return;

Expand Down Expand Up @@ -598,13 +610,19 @@
outputSinkType: defaultType,
outputSinkName: defaultNames[defaultType] || "Scope",
});
} else if (type === "custom_node") {
addNode("custom_node", pendingNodePosition, {
customNodeTypeId: subType,
label: subType || "Custom Node",
...extraData,
});
} else {
addNode(type as NodeTypeKey, pendingNodePosition);
}

setPendingNodePosition(null);
},
[

Check warning on line 625 in frontend/src/components/graph/hooks/node/useNodeFactories.ts

View workflow job for this annotation

GitHub Actions / Frontend Linting (ESLint + Prettier)

React Hook useCallback has an unnecessary dependency: 'nodes'. Either exclude it or remove the dependency array
nodes,
pendingNodePosition,
addNode,
Expand Down
Loading
Loading