Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 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
ed5e81f
fix: Support node-only graphs in Workflow Builder Play flow
leszko Apr 7, 2026
c9c1ae1
fix: Audio-only node graphs work without a Sink node
leszko Apr 7, 2026
bb37407
fix: Sink node accepts audio connections for audio-only workflows
leszko Apr 7, 2026
44d946f
fix: Force produces_audio=true when graph has custom nodes, preserve …
leszko Apr 7, 2026
5b0921a
fix: Auto-unmute Sink node for audio-only workflows
leszko Apr 7, 2026
0d9ebe5
fix: Add dedicated <audio> element for audio-only WebRTC streams
leszko Apr 7, 2026
59a4c01
fix: Persistent <audio> element with explicit play() for WebRTC audio
leszko Apr 7, 2026
b9b9945
handle wav formats
ryanontheinside Apr 13, 2026
528532a
fix(sink): apply mute state to persistent audio element
leszko Apr 14, 2026
0103422
refactor(stream): drop frontend produces_audio override for custom nodes
leszko Apr 14, 2026
f944ef2
refactor(webrtc): drop dead produces_audio hint fallback
leszko Apr 14, 2026
bf6ae69
feat(node-processor): replay cached inputs on live parameter update
leszko Apr 14, 2026
f0c6a50
feat(custom-node): send live parameter updates to backend
leszko Apr 14, 2026
107726e
feat(node-processor): latch-fallback on non-continuous inputs
leszko Apr 14, 2026
6bd5e01
fix(audio-source): emit once per file in mode=full
leszko Apr 14, 2026
ba34455
feat(audio-source): add loop parameter
leszko Apr 14, 2026
39f35ae
fix(audio): eliminate dropped and stalled audio in custom-node graphs
leszko Apr 15, 2026
8de586f
feat(audio-source): add downstream pacing mode
leszko Apr 15, 2026
82dbdf5
feat(node-processor): fire once per upstream cycle
leszko Apr 15, 2026
982b6c5
buf
ryanontheinside Apr 15, 2026
5de6a19
fix(audio): upcast bfloat16/float16 tensors before numpy() in AudioPr…
leszko Apr 16, 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
102 changes: 87 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,45 @@ 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), so we filter
// them out of the plugin listing here to avoid duplication.
const items: NodeCatalogItem[] = (data.nodes ?? [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((n: any) => n.pipeline_meta == null)
// 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 +449,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 @@ -44,7 +44,8 @@ type NodeTypeKey =
| "tempo"
| "prompt_list"
| "prompt_blend"
| "scheduler";
| "scheduler"
| "custom_node";

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

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

Expand Down Expand Up @@ -597,6 +609,12 @@ export function useNodeFactories({
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);
}
Expand Down
Loading
Loading