mirror of
https://git.datalinker.icu/comfyanonymous/ComfyUI
synced 2026-09-02 14:07:07 +08:00
- 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>
562 lines
18 KiB
JavaScript
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; |