Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions frontend/src/components/graph/GraphEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { createDaydreamImportSession } from "../../lib/daydreamExport";
import { openExternalUrl } from "../../lib/openExternal";
import { buildPaneMenuItems, buildNodeMenuItems } from "./contextMenuItems";
import type { FlowNodeData } from "../../lib/graphUtils";
import { validateGraphForStream } from "../../lib/graphUtils";
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -138,6 +139,8 @@ const edgeTypes = {

export interface GraphEditorHandle {
refreshGraph: () => void;
/** Validate the current graph before streaming. Returns an error string or null if valid. */
validateForStream: () => string | null;
getCurrentGraphConfig: () => import("../../lib/api").GraphConfig;
getGraphNodePrompts: () => Array<{ nodeId: string; text: string }>;
getGraphVaceSettings: () => Array<{
Expand Down Expand Up @@ -369,6 +372,7 @@ export const GraphEditor = forwardRef<GraphEditorHandle, GraphEditorProps>(
ref,
() => ({
refreshGraph,
validateForStream: () => validateGraphForStream(nodes, edges),
getCurrentGraphConfig,
getGraphNodePrompts,
getGraphVaceSettings,
Expand All @@ -387,6 +391,8 @@ export const GraphEditor = forwardRef<GraphEditorHandle, GraphEditorProps>(
}),
[
refreshGraph,
nodes,
edges,
getCurrentGraphConfig,
getGraphNodePrompts,
getGraphVaceSettings,
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/graph/nodes/OutputNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const typeLabel =
OUTPUT_TYPE_OPTIONS.find(o => o.value === sinkType)?.label ?? sinkType;

// Check if this is the only output node and it's disabled — we can't tell
// from within the node itself, so we just show a warning when disabled.
const showDisabledWarning = !enabled;

return (
<NodeCard selected={selected} collapsed={collapsed}>
<NodeHeader
Expand All @@ -83,6 +87,12 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
/>
{!collapsed && (
<div className="px-2 py-1.5 flex flex-col gap-1.5">
{showDisabledWarning && (
<div className="mx-2 px-2 py-1 rounded text-[10px] leading-tight bg-amber-500/15 text-amber-400 border border-amber-500/30">
⚠ Disabled — stream will not output unless a Preview (Sink) node
is also connected.
</div>
)}
<div className="px-2">
<NodeParamRow label="Type">
<NodePillSelect
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/lib/graphUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1817,5 +1817,74 @@ export function workflowToGraphConfig(
};
}

/**
* Validate a flow graph before submitting it to the backend for streaming.
* Returns a user-friendly error message if invalid, or null if valid.
*
* This catches cases the backend would reject with a cryptic error, such as:
* - Graph has no sink node because all Output nodes are disabled
* - Graph has no pipeline nodes
*/
export function validateGraphForStream(
nodes: Node<FlowNodeData>[],
edges: Edge[]
): string | null {
const flatNodes = nodes;

// Check for pipeline nodes
const hasPipeline = flatNodes.some(n => n.data.nodeType === "pipeline");
if (!hasPipeline) {
return "Your graph has no pipeline nodes. Add at least one pipeline to your graph before starting.";
}

// Check for sink nodes (type === "sink" in backend, nodeType === "sink" in frontend)
const hasSinkNode = flatNodes.some(n => n.data.nodeType === "sink");

// Check for enabled output nodes (these become backend sink nodes)
const hasEnabledOutput = flatNodes.some(
n =>
n.data.nodeType === "output" &&
((n.data.outputSinkEnabled as boolean) ?? false)
);

// Check if there are any output nodes at all (even disabled)
const hasDisabledOutputOnly =
!hasSinkNode &&
!hasEnabledOutput &&
flatNodes.some(n => n.data.nodeType === "output");

if (hasDisabledOutputOnly) {
return "Your graph has no active output. Enable an Output node (Spout/NDI/Syphon) or add a Preview (Sink) node to see results.";
}

if (!hasSinkNode && !hasEnabledOutput) {
return "Your graph has no output node. Add a Preview (Sink) node or an Output node to your graph before starting.";
}

// Check that at least one pipeline feeds into a sink
const pipelineIds = new Set(
flatNodes.filter(n => n.data.nodeType === "pipeline").map(n => n.id)
);
const sinkIds = new Set([
...flatNodes.filter(n => n.data.nodeType === "sink").map(n => n.id),
...flatNodes
.filter(
n =>
n.data.nodeType === "output" &&
((n.data.outputSinkEnabled as boolean) ?? false)
)
.map(n => n.id),
]);

const connectedToSink = edges.some(
e => pipelineIds.has(e.source) && sinkIds.has(e.target)
);
if (pipelineIds.size > 0 && sinkIds.size > 0 && !connectedToSink) {
return "Your pipeline is not connected to an output node. Connect a pipeline to a Preview or Output node before starting.";
}

return null;
}

// Default node dimensions for reference
export { NODE_WIDTH, NODE_HEIGHT };
9 changes: 9 additions & 0 deletions frontend/src/pages/StreamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
applyHardwareInputSourceToLinearGraph,
linearGraphFromSettings,
stripUIFields,
validateGraphForStream,

Check failure on line 78 in frontend/src/pages/StreamPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend Linting (ESLint + Prettier)

'validateGraphForStream' is defined but never used
} from "../lib/graphUtils";
import { resolveLoRAPath } from "../lib/workflowSettings";
import { useLoRAsContext } from "../contexts/LoRAsContext";
Expand Down Expand Up @@ -2255,6 +2256,14 @@

if (graphMode || nonLinearGraph) {
try {
// Validate graph structure before sending to backend
const graphValidationError =
graphEditorRef.current?.validateForStream();
if (graphValidationError) {
toast.error(graphValidationError);
return false;
}

// Read graph from frontend React state (always up-to-date)
const frontendGraph = graphEditorRef.current?.getCurrentGraphConfig();
if (frontendGraph) {
Expand Down
2 changes: 2 additions & 0 deletions src/scope/core/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .hookspecs import hookimpl
from .manager import (
FailedPluginInfo,
PluginBundledError,
PluginDependencyError,
PluginInstallError,
PluginInUseError,
Expand Down Expand Up @@ -32,4 +33,5 @@
"PluginNameCollisionError",
"PluginDependencyError",
"PluginInstallError",
"PluginBundledError",
]
8 changes: 7 additions & 1 deletion src/scope/core/plugins/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ class PluginInstallError(Exception):
pass


class PluginBundledError(PluginInstallError):
"""Attempted to uninstall a bundled (built-in) plugin."""

pass


@dataclass(frozen=True)
class FailedPluginInfo:
"""Information about a plugin entry point that failed to load."""
Expand Down Expand Up @@ -1360,7 +1366,7 @@ def _uninstall_plugin_sync(

# Prevent uninstalling bundled plugins
if plugin_info.get("bundled"):
raise PluginInstallError(
raise PluginBundledError(
f"Plugin '{name}' is bundled and cannot be uninstalled"
)

Expand Down
7 changes: 7 additions & 0 deletions src/scope/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2957,6 +2957,7 @@ async def uninstall_plugin(
cloud-hosted scope backend.
"""
from scope.core.plugins import (
PluginBundledError,
PluginInstallError,
PluginNotFoundError,
get_plugin_manager,
Expand Down Expand Up @@ -2991,6 +2992,12 @@ async def uninstall_plugin(
status_code=404,
detail=f"Plugin '{name}' not found",
) from e
except PluginBundledError as e:
logger.warning(f"Plugin uninstall rejected (bundled plugin): {name} - {e}")
raise HTTPException(
status_code=400,
detail=str(e),
) from e
except PluginInstallError as e:
logger.error(f"Plugin uninstall failed: {name} - {e}")
raise HTTPException(
Expand Down
5 changes: 4 additions & 1 deletion src/scope/server/graph_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ def validate_structure(self) -> list[str]:

# At least one sink
if not self.get_sink_node_ids():
errors.append("Graph must have at least one sink node")
errors.append(
"Graph must have at least one sink node. "
"Add a Preview (Sink) node to your graph, or enable an Output node (Spout/NDI/Syphon)."
)

# Pipeline nodes must have pipeline_id
for node in self.nodes:
Expand Down
100 changes: 100 additions & 0 deletions tests/test_graph_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests for graph_schema.GraphConfig.validate_structure()."""
import pytest
from scope.server.graph_schema import GraphConfig, GraphNode, GraphEdge


def _make_graph(nodes: list[GraphNode], edges: list[GraphEdge] | None = None) -> GraphConfig:
return GraphConfig(nodes=nodes, edges=edges or [])


def _pipeline_node(node_id: str = "p1", pipeline_id: str = "pipe-1") -> GraphNode:
return GraphNode(id=node_id, type="pipeline", pipeline_id=pipeline_id)


def _sink_node(node_id: str = "sink") -> GraphNode:
return GraphNode(id=node_id, type="sink")


def _source_node(node_id: str = "source") -> GraphNode:
return GraphNode(id=node_id, type="source")


def _edge(from_node: str, to_node: str) -> GraphEdge:
return GraphEdge(**{"from": from_node, "from_port": "video", "to_node": to_node, "to_port": "video", "kind": "stream"})


class TestValidateStructure:
def test_valid_minimal_graph(self):
graph = _make_graph(
nodes=[_pipeline_node(), _sink_node()],
edges=[_edge("p1", "sink")],
)
assert graph.validate_structure() == []

def test_valid_with_source(self):
graph = _make_graph(
nodes=[_source_node(), _pipeline_node(), _sink_node()],
edges=[_edge("source", "p1"), _edge("p1", "sink")],
)
assert graph.validate_structure() == []

def test_no_sink_node_returns_error(self):
graph = _make_graph(nodes=[_pipeline_node()])
errors = graph.validate_structure()
assert len(errors) == 1
assert "sink node" in errors[0]

def test_no_sink_error_message_is_user_friendly(self):
"""Error message should hint at how to fix, not just describe the problem."""
graph = _make_graph(nodes=[_pipeline_node()])
errors = graph.validate_structure()
msg = errors[0]
# Should mention the fix (add a Preview or Output node)
assert "Preview" in msg or "Output" in msg or "output" in msg

def test_duplicate_node_ids(self):
graph = _make_graph(
nodes=[_pipeline_node("p1"), _pipeline_node("p1"), _sink_node()],
)
errors = graph.validate_structure()
assert any("Duplicate" in e for e in errors)

def test_pipeline_missing_pipeline_id(self):
graph = _make_graph(
nodes=[GraphNode(id="p1", type="pipeline"), _sink_node()],
)
errors = graph.validate_structure()
assert any("missing pipeline_id" in e for e in errors)

def test_edge_references_nonexistent_source(self):
graph = _make_graph(
nodes=[_pipeline_node(), _sink_node()],
edges=[_edge("nonexistent", "sink")],
)
errors = graph.validate_structure()
assert any("nonexistent" in e for e in errors)

def test_edge_references_nonexistent_target(self):
graph = _make_graph(
nodes=[_pipeline_node(), _sink_node()],
edges=[_edge("p1", "does-not-exist")],
)
errors = graph.validate_structure()
assert any("does-not-exist" in e for e in errors)

def test_sink_with_sink_mode_counts_as_sink(self):
"""An output node (Spout/NDI/Syphon) with sink_mode set should count as a valid sink."""
output_node = GraphNode(id="spout-out", type="sink", sink_mode="spout")
graph = _make_graph(
nodes=[_pipeline_node(), output_node],
edges=[_edge("p1", "spout-out")],
)
assert graph.validate_structure() == []

def test_multiple_errors_returned(self):
"""Multiple structural errors should all be reported."""
graph = _make_graph(
nodes=[GraphNode(id="p1", type="pipeline")], # no pipeline_id, no sink
)
errors = graph.validate_structure()
assert len(errors) >= 2
Loading