diff --git a/frontend/src/components/graph/GraphEditor.tsx b/frontend/src/components/graph/GraphEditor.tsx index 15446cc80..e3d6808a4 100644 --- a/frontend/src/components/graph/GraphEditor.tsx +++ b/frontend/src/components/graph/GraphEditor.tsx @@ -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, @@ -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<{ @@ -369,6 +372,7 @@ export const GraphEditor = forwardRef( ref, () => ({ refreshGraph, + validateForStream: () => validateGraphForStream(nodes, edges), getCurrentGraphConfig, getGraphNodePrompts, getGraphVaceSettings, @@ -387,6 +391,8 @@ export const GraphEditor = forwardRef( }), [ refreshGraph, + nodes, + edges, getCurrentGraphConfig, getGraphNodePrompts, getGraphVaceSettings, diff --git a/frontend/src/components/graph/nodes/OutputNode.tsx b/frontend/src/components/graph/nodes/OutputNode.tsx index 9cea9f48f..d3b11b871 100644 --- a/frontend/src/components/graph/nodes/OutputNode.tsx +++ b/frontend/src/components/graph/nodes/OutputNode.tsx @@ -73,6 +73,10 @@ export function OutputNode({ id, data, selected }: NodeProps) { 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 ( ) { /> {!collapsed && (
+ {showDisabledWarning && ( +
+ ⚠ Disabled — stream will not output unless a Preview (Sink) node + is also connected. +
+ )}
[], + 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 }; diff --git a/frontend/src/pages/StreamPage.tsx b/frontend/src/pages/StreamPage.tsx index c9db65363..09f2cf682 100644 --- a/frontend/src/pages/StreamPage.tsx +++ b/frontend/src/pages/StreamPage.tsx @@ -75,6 +75,7 @@ import { applyHardwareInputSourceToLinearGraph, linearGraphFromSettings, stripUIFields, + validateGraphForStream, } from "../lib/graphUtils"; import { resolveLoRAPath } from "../lib/workflowSettings"; import { useLoRAsContext } from "../contexts/LoRAsContext"; @@ -2255,6 +2256,14 @@ export function StreamPage() { 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) { diff --git a/src/scope/core/plugins/__init__.py b/src/scope/core/plugins/__init__.py index a681707d7..fcf208413 100644 --- a/src/scope/core/plugins/__init__.py +++ b/src/scope/core/plugins/__init__.py @@ -3,6 +3,7 @@ from .hookspecs import hookimpl from .manager import ( FailedPluginInfo, + PluginBundledError, PluginDependencyError, PluginInstallError, PluginInUseError, @@ -32,4 +33,5 @@ "PluginNameCollisionError", "PluginDependencyError", "PluginInstallError", + "PluginBundledError", ] diff --git a/src/scope/core/plugins/manager.py b/src/scope/core/plugins/manager.py index 87ee5a48a..399bea71e 100644 --- a/src/scope/core/plugins/manager.py +++ b/src/scope/core/plugins/manager.py @@ -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.""" @@ -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" ) diff --git a/src/scope/server/app.py b/src/scope/server/app.py index 470771551..564a392a1 100644 --- a/src/scope/server/app.py +++ b/src/scope/server/app.py @@ -2957,6 +2957,7 @@ async def uninstall_plugin( cloud-hosted scope backend. """ from scope.core.plugins import ( + PluginBundledError, PluginInstallError, PluginNotFoundError, get_plugin_manager, @@ -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( diff --git a/src/scope/server/graph_schema.py b/src/scope/server/graph_schema.py index 26f95341f..40b70f4e9 100644 --- a/src/scope/server/graph_schema.py +++ b/src/scope/server/graph_schema.py @@ -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: diff --git a/tests/test_graph_schema.py b/tests/test_graph_schema.py new file mode 100644 index 000000000..5dd3c8b47 --- /dev/null +++ b/tests/test_graph_schema.py @@ -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