ComfyUI/web_mobile/graph-linearization.js
Lauri Gates e8fa85ce5e feat: add mobile interface for touch-optimized workflow management
- Implement responsive mobile-first UI with card-based workflow pipeline
- Add touch-native interactions: tap-to-connect, long-press context menus
- Create graph linearization engine to convert 2D workflows to 1D sequences
- Add WebSocket/HTTP API client for real-time backend communication
- Implement mobile route handler at /mobile with static file serving
- Add comprehensive test suite with pytest for mobile interface validation
- Support node editing, execution monitoring, and workflow management
- Include responsive design with dark mode and gesture support

The mobile interface transforms ComfyUI's 2D canvas into a scrollable
pipeline optimized for mobile devices, enabling full workflow creation
and execution on phones and tablets.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 12:39:13 +03:00

562 lines
18 KiB
JavaScript

/**
* ComfyUI Mobile Interface - Graph Linearization
* Converts the 2D workflow graph into a linear execution sequence
*/
class GraphLinearization {
constructor() {
this.nodeCache = new Map();
this.dependencyCache = new Map();
}
/**
* Linearize a workflow graph using topological sort
* @param {Object} workflow - The workflow object containing nodes
* @returns {Array} Array of nodes in execution order
*/
linearizeWorkflow(workflow) {
if (!workflow || !workflow.nodes || Object.keys(workflow.nodes).length === 0) {
return [];
}
try {
// Clear caches
this.nodeCache.clear();
this.dependencyCache.clear();
// Build dependency graph
const dependencyGraph = this.buildDependencyGraph(workflow);
// Perform topological sort
const sortedNodes = this.topologicalSort(dependencyGraph);
// Convert to linear array with additional metadata
return this.enrichLinearizedNodes(sortedNodes, workflow);
} catch (error) {
console.error('Graph linearization error:', error);
// Fallback to simple node order
return this.fallbackLinearization(workflow);
}
}
/**
* Build dependency graph from workflow
* @param {Object} workflow - The workflow object
* @returns {Map} Dependency graph
*/
buildDependencyGraph(workflow) {
const graph = new Map();
const nodes = workflow.nodes;
// Initialize all nodes in the graph
for (const nodeId in nodes) {
graph.set(nodeId, {
node: nodes[nodeId],
dependencies: new Set(),
dependents: new Set()
});
}
// Build dependencies based on connections
for (const nodeId in nodes) {
const node = nodes[nodeId];
if (node.inputs) {
for (const inputName in node.inputs) {
const input = node.inputs[inputName];
// Check if input is connected to another node
if (Array.isArray(input) && input.length >= 2) {
const sourceNodeId = input[0];
const sourceOutputIndex = input[1];
if (graph.has(sourceNodeId)) {
// Add dependency
graph.get(nodeId).dependencies.add(sourceNodeId);
graph.get(sourceNodeId).dependents.add(nodeId);
}
}
}
}
}
return graph;
}
/**
* Perform topological sort on dependency graph
* @param {Map} graph - Dependency graph
* @returns {Array} Sorted node IDs
*/
topologicalSort(graph) {
const result = [];
const inDegree = new Map();
const queue = [];
// Calculate in-degrees
for (const [nodeId, nodeData] of graph) {
inDegree.set(nodeId, nodeData.dependencies.size);
if (nodeData.dependencies.size === 0) {
queue.push(nodeId);
}
}
// Process nodes with no dependencies first
while (queue.length > 0) {
const currentNodeId = queue.shift();
result.push(currentNodeId);
// Reduce in-degree of dependent nodes
const currentNode = graph.get(currentNodeId);
for (const dependentId of currentNode.dependents) {
const newInDegree = inDegree.get(dependentId) - 1;
inDegree.set(dependentId, newInDegree);
if (newInDegree === 0) {
queue.push(dependentId);
}
}
}
// Check for cycles
if (result.length !== graph.size) {
console.warn('Cycle detected in workflow graph, using partial sort');
// Add remaining nodes
for (const [nodeId] of graph) {
if (!result.includes(nodeId)) {
result.push(nodeId);
}
}
}
return result;
}
/**
* Enrich linearized nodes with additional metadata
* @param {Array} sortedNodeIds - Sorted node IDs
* @param {Object} workflow - Original workflow
* @returns {Array} Enriched node array
*/
enrichLinearizedNodes(sortedNodeIds, workflow) {
const enrichedNodes = [];
const nodeTypes = this.getNodeTypes();
for (let i = 0; i < sortedNodeIds.length; i++) {
const nodeId = sortedNodeIds[i];
const originalNode = workflow.nodes[nodeId];
if (!originalNode) continue;
const enrichedNode = {
id: nodeId,
type: originalNode.type || 'unknown',
title: this.getNodeTitle(originalNode),
executionOrder: i + 1,
inputs: this.processNodeInputs(originalNode, workflow.nodes),
outputs: this.processNodeOutputs(originalNode),
widgets: this.processNodeWidgets(originalNode),
status: 'idle',
category: this.getNodeCategory(originalNode.type, nodeTypes),
description: this.getNodeDescription(originalNode.type, nodeTypes),
position: originalNode.pos || [0, 0],
size: originalNode.size || [200, 100],
flags: originalNode.flags || {},
mode: originalNode.mode || 0,
originalNode: originalNode
};
enrichedNodes.push(enrichedNode);
}
return enrichedNodes;
}
/**
* Process node inputs and resolve connections
* @param {Object} node - Node object
* @param {Object} allNodes - All nodes in the workflow
* @returns {Array} Processed inputs
*/
processNodeInputs(node, allNodes) {
const inputs = [];
if (node.inputs) {
for (const inputName in node.inputs) {
const input = node.inputs[inputName];
const inputInfo = {
name: inputName,
type: this.getInputType(node.type, inputName),
connected: false,
connection: null,
value: null
};
// Check if input is connected
if (Array.isArray(input) && input.length >= 2) {
const sourceNodeId = input[0];
const sourceOutputIndex = input[1];
const sourceNode = allNodes[sourceNodeId];
if (sourceNode) {
inputInfo.connected = true;
inputInfo.connection = {
sourceNodeId: sourceNodeId,
sourceNodeTitle: this.getNodeTitle(sourceNode),
sourceOutputIndex: sourceOutputIndex,
sourceOutputName: this.getOutputName(sourceNode, sourceOutputIndex)
};
}
} else {
// Static value
inputInfo.value = input;
}
inputs.push(inputInfo);
}
}
return inputs;
}
/**
* Process node outputs
* @param {Object} node - Node object
* @returns {Array} Processed outputs
*/
processNodeOutputs(node) {
const outputs = [];
const nodeType = node.type;
const outputInfo = this.getNodeOutputInfo(nodeType);
if (outputInfo && outputInfo.length > 0) {
outputInfo.forEach((output, index) => {
outputs.push({
name: output.name || `Output ${index + 1}`,
type: output.type || 'unknown',
index: index
});
});
} else {
// Default output if no specific info available
outputs.push({
name: 'Output',
type: 'unknown',
index: 0
});
}
return outputs;
}
/**
* Process node widgets (editable parameters)
* @param {Object} node - Node object
* @returns {Array} Processed widgets
*/
processNodeWidgets(node) {
const widgets = [];
if (node.widgets_values && node.widgets_values.length > 0) {
const widgetInfo = this.getNodeWidgetInfo(node.type);
node.widgets_values.forEach((value, index) => {
const widget = {
name: widgetInfo[index]?.name || `Widget ${index + 1}`,
type: widgetInfo[index]?.type || 'text',
value: value,
index: index,
options: widgetInfo[index]?.options || {}
};
widgets.push(widget);
});
}
return widgets;
}
/**
* Get node title for display
* @param {Object} node - Node object
* @returns {string} Node title
*/
getNodeTitle(node) {
if (node.title) return node.title;
if (node.type) return this.formatNodeType(node.type);
return 'Unknown Node';
}
/**
* Format node type for display
* @param {string} type - Node type
* @returns {string} Formatted type
*/
formatNodeType(type) {
return type.replace(/([A-Z])/g, ' $1').trim();
}
/**
* Get node category based on type
* @param {string} type - Node type
* @param {Object} nodeTypes - Node types definition
* @returns {string} Category
*/
getNodeCategory(type, nodeTypes) {
if (nodeTypes && nodeTypes[type] && nodeTypes[type].category) {
return nodeTypes[type].category;
}
// Fallback categorization based on type name
if (type.includes('Load')) return 'loaders';
if (type.includes('Save')) return 'output';
if (type.includes('Sample')) return 'sampling';
if (type.includes('Encode') || type.includes('Decode')) return 'conditioning';
if (type.includes('Image')) return 'image';
if (type.includes('Model')) return 'models';
return 'misc';
}
/**
* Get node description
* @param {string} type - Node type
* @param {Object} nodeTypes - Node types definition
* @returns {string} Description
*/
getNodeDescription(type, nodeTypes) {
if (nodeTypes && nodeTypes[type] && nodeTypes[type].description) {
return nodeTypes[type].description;
}
return `${this.formatNodeType(type)} node`;
}
/**
* Get input type for a node input
* @param {string} nodeType - Node type
* @param {string} inputName - Input name
* @returns {string} Input type
*/
getInputType(nodeType, inputName) {
const nodeTypes = this.getNodeTypes();
if (nodeTypes && nodeTypes[nodeType] && nodeTypes[nodeType].input) {
const required = nodeTypes[nodeType].input.required || {};
const optional = nodeTypes[nodeType].input.optional || {};
if (required[inputName]) {
return Array.isArray(required[inputName]) ? required[inputName][0] : required[inputName];
}
if (optional[inputName]) {
return Array.isArray(optional[inputName]) ? optional[inputName][0] : optional[inputName];
}
}
return 'unknown';
}
/**
* Get output name for a node output
* @param {Object} node - Node object
* @param {number} outputIndex - Output index
* @returns {string} Output name
*/
getOutputName(node, outputIndex) {
const outputs = this.getNodeOutputInfo(node.type);
if (outputs && outputs[outputIndex]) {
return outputs[outputIndex].name;
}
return `Output ${outputIndex + 1}`;
}
/**
* Get node output information
* @param {string} nodeType - Node type
* @returns {Array} Output information
*/
getNodeOutputInfo(nodeType) {
const nodeTypes = this.getNodeTypes();
if (nodeTypes && nodeTypes[nodeType] && nodeTypes[nodeType].output) {
return nodeTypes[nodeType].output.map((type, index) => ({
name: Array.isArray(type) ? type[0] : type,
type: Array.isArray(type) ? type[0] : type
}));
}
return [];
}
/**
* Get node widget information
* @param {string} nodeType - Node type
* @returns {Array} Widget information
*/
getNodeWidgetInfo(nodeType) {
// This would be populated from the actual ComfyUI node definitions
// For now, return empty array as placeholder
return [];
}
/**
* Get node types definition (placeholder)
* @returns {Object} Node types
*/
getNodeTypes() {
// This would be fetched from the ComfyUI API
// For now, return empty object as placeholder
return {};
}
/**
* Fallback linearization when topological sort fails
* @param {Object} workflow - Workflow object
* @returns {Array} Simple node array
*/
fallbackLinearization(workflow) {
const nodes = [];
for (const nodeId in workflow.nodes) {
const node = workflow.nodes[nodeId];
nodes.push({
id: nodeId,
type: node.type || 'unknown',
title: this.getNodeTitle(node),
executionOrder: parseInt(nodeId),
inputs: [],
outputs: [],
widgets: [],
status: 'idle',
category: 'misc',
description: 'Node',
position: node.pos || [0, 0],
size: node.size || [200, 100],
flags: node.flags || {},
mode: node.mode || 0,
originalNode: node
});
}
// Sort by node ID as fallback
nodes.sort((a, b) => parseInt(a.id) - parseInt(b.id));
return nodes;
}
/**
* Get connection compatibility between two sockets
* @param {string} outputType - Output socket type
* @param {string} inputType - Input socket type
* @returns {boolean} True if compatible
*/
areSocketsCompatible(outputType, inputType) {
// Basic compatibility check - can be extended
if (outputType === inputType) return true;
// Special compatibility rules
const compatibilityRules = {
'MODEL': ['MODEL'],
'CLIP': ['CLIP'],
'VAE': ['VAE'],
'CONDITIONING': ['CONDITIONING'],
'LATENT': ['LATENT'],
'IMAGE': ['IMAGE'],
'MASK': ['MASK'],
'INT': ['INT', 'FLOAT'],
'FLOAT': ['FLOAT', 'INT'],
'STRING': ['STRING'],
'BOOLEAN': ['BOOLEAN'],
'*': ['*'] // Wildcard type
};
const outputCompatible = compatibilityRules[outputType] || [];
return outputCompatible.includes(inputType) || inputType === '*' || outputType === '*';
}
/**
* Find potential connections for a socket
* @param {Object} socket - Socket object
* @param {Array} allNodes - All nodes in the workflow
* @param {string} direction - 'input' or 'output'
* @returns {Array} Compatible sockets
*/
findCompatibleSockets(socket, allNodes, direction) {
const compatibleSockets = [];
for (const node of allNodes) {
const sockets = direction === 'input' ? node.outputs : node.inputs;
for (const targetSocket of sockets) {
const isCompatible = direction === 'input'
? this.areSocketsCompatible(socket.type, targetSocket.type)
: this.areSocketsCompatible(targetSocket.type, socket.type);
if (isCompatible && node.id !== socket.nodeId) {
compatibleSockets.push({
nodeId: node.id,
nodeTitle: node.title,
socket: targetSocket,
compatible: true
});
}
}
}
return compatibleSockets;
}
/**
* Validate workflow for common issues
* @param {Array} linearizedNodes - Linearized nodes array
* @returns {Array} Validation issues
*/
validateWorkflow(linearizedNodes) {
const issues = [];
// Check for unconnected required inputs
for (const node of linearizedNodes) {
for (const input of node.inputs) {
if (input.required && !input.connected && input.value === null) {
issues.push({
type: 'error',
nodeId: node.id,
message: `Required input '${input.name}' is not connected`,
severity: 'high'
});
}
}
}
// Check for isolated nodes
for (const node of linearizedNodes) {
const hasConnectedInputs = node.inputs.some(input => input.connected);
const hasConnectedOutputs = this.hasConnectedOutputs(node, linearizedNodes);
if (!hasConnectedInputs && !hasConnectedOutputs && node.type !== 'SaveImage') {
issues.push({
type: 'warning',
nodeId: node.id,
message: `Node '${node.title}' appears to be isolated`,
severity: 'medium'
});
}
}
return issues;
}
/**
* Check if node has connected outputs
* @param {Object} node - Node to check
* @param {Array} allNodes - All nodes in the workflow
* @returns {boolean} True if has connected outputs
*/
hasConnectedOutputs(node, allNodes) {
for (const otherNode of allNodes) {
for (const input of otherNode.inputs) {
if (input.connected && input.connection.sourceNodeId === node.id) {
return true;
}
}
}
return false;
}
}
// Export for use in other files
window.GraphLinearization = GraphLinearization;