diff --git a/README.md b/README.md index 21e27c2..d66373a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,13 @@ The idea came from the frustration users experienced with the many limitations a By solving these problems, my tool makes node management more intuitive, stable, and efficient, allowing you to focus on what matters: creating. ## 🚀 Updates +**2025.03.26** + + - 💪 Live update of workflow when modifying a subworkflow, just 1 second after saving the subworkflow. But you need to back and forth to see the changes. if you work on one screen no problem, but if you work on two screens you need to back and forth to see the changes. + - ↕️ Capability to order inputs and outputs in the workflow node. Order depends on the order of the input/output from top to bottom in the subworkflow. + - 🛠️ Fixed some instabilities. + - 🆙 Must work with ComfyUI 1.11.8 and above. + **2025.03.23** First of all, I apologize for not maintaining the code regularly. ComfyUI has evolved significantly since my last version, so I've completely revised the code to ensure better integration with ComfyUI and avoid future issues. diff --git a/py/endpoints.py b/py/endpoints.py index d082fa9..7c49aa8 100644 --- a/py/endpoints.py +++ b/py/endpoints.py @@ -42,13 +42,13 @@ def get_workflow_data(workflow_file): @server.PromptServer.instance.routes.get("/flowchain/workflows") async def workflows(request): user = UserManager().get_request_user_id(request) - json_path = folder_paths.user_directory + "/" + user + "/workflows/" + json_path = os.path.join(folder_paths.user_directory, user, "workflows") result = {} # Vérifier si le répertoire principal existe if os.path.exists(json_path): # Utiliser os.walk pour parcourir récursivement tous les sous-répertoires - for root, dirs, files in os.walk(json_path): + for root, dirs, files in os.walk(str(json_path)): for file in files: # Ne traiter que les fichiers JSON if file.lower().endswith('.json'): @@ -57,7 +57,7 @@ async def workflows(request): with open(file_path, "r", encoding="utf-8") as f: json_content = json.load(f) - relative_path = os.path.relpath(file_path, json_path) + relative_path = os.path.relpath(file_path, str(json_path)) result[relative_path] = get_workflow_data(json_content) except json.JSONDecodeError: diff --git a/web/js/jsnodes.js b/web/js/jsnodes.js index 5c30e4a..81aa919 100644 --- a/web/js/jsnodes.js +++ b/web/js/jsnodes.js @@ -1,10 +1,10 @@ import { app } from "../../../scripts/app.js"; import { api } from '../../../scripts/api.js' -import { setupWorkflowNode } from './workflow.js'; -import { setupInputNode } from "./input.js"; -import { setupContinueNode } from "./continue.js"; -import { setupOutputNode } from "./output.js"; -import { setupLipSyncNode } from "./lipSync.js"; +import { setupWorkflowNode } from './nodetype_workflow.js'; +import { setupInputNode } from "./nodetype_input.js"; +import { setupContinueNode } from "./nodetype_continue.js"; +import { setupOutputNode } from "./nodetype_output.js"; +import { setupLipSyncNode } from "./nodetype_lipSync.js"; app.registerExtension({ name: "FlowChain.jsnodes", diff --git a/web/js/nodetype_continue.js b/web/js/nodetype_continue.js new file mode 100644 index 0000000..bc03e00 --- /dev/null +++ b/web/js/nodetype_continue.js @@ -0,0 +1,107 @@ +import { app } from "../../../scripts/app.js"; +import {chainCallback} from "./utils.js"; +import {clearInputs} from "./inputs.js"; +import {addWidgets} from "./widgets.js"; +import {colors, bg_colors, node_type_list} from "./constants.js"; + +function initialisation(node) { + node.widgets[0].callback = ( value ) => { + clearInputs(node); + node.addOutput("output", value); + node.addInput("input", value); + node.color = colors[node_type_list.indexOf(value)]; + node.bgcolor = bg_colors[node_type_list.indexOf(value)]; + }; + node.color = colors[node_type_list.indexOf("none")]; + node.bgcolor = bg_colors[node_type_list.indexOf("none")]; +} + +function configure(info) { + let widgetDict = info.widgets_values + if (info.widgets_values.length == undefined) { + + for (let w of this.widgets) { + if (w.name in widgetDict) { + w.value = widgetDict[w.name].value; + } + } + // check if widgetDict in this.widgets + for (let [key, value] of Object.entries(widgetDict)) { + let widget = this.widgets.find(w => w.name === key); + let type = this.widgets.find(w => w.name === "type"); + if(!widget){ + addWidgets(this, key, value, app); + widget = this.widgets.find(w => w.name === key); + } + //this.widgets.push(value); + widget.options = info.widgets_values[key].options; + widget.value = info.widgets_values[key].value; + //if value exists in inputs + + for (let input of this.inputs) + if (input.name == key){ + //find if key exists in inputs array in inputs.Name + if (info.widgets_values[key].type != "converted-widget"){ + this.removeInput(this.inputs.indexOf(input)); + } + break; + } + } + } + + if (info.outputs_values != undefined){ + // deep copy outputs + if(this.id == -1){ + this.outputs[0] = {links: null, name: info.outputs_values.name, type: info.outputs_values.type}; + }else{ + this.outputs[0] = {...info.outputs_values}; + } + } + this.setSize(info.size); +}; + + +function serialize(info) { + info.widgets_values = {}; + if (!this.widgets) { + return; + } + + for (let w of this.widgets) { + info.widgets_values[w.name] = {name: w.name, options : w.options, value: w.value, type: w.type, origType: w.origType, last_y: w.last_y}; + } + for (let w of this.inputs){ + // if w.name exists in info.widgets_values + if (info.widgets_values[w.name]){ + if(info.widgets_values[w.name].type == "converted-widget"){ + if(info.widgets_values[w.name].origType == "toggle"){ + w.type = "BOOLEAN"; + }else if(info.widgets_values[w.name].origType == "combo"){ + w.type = "COMBO"; + } + } + } + } + for (let w of this.inputs){ + if (w.name=="input"){ + w.type = info.widgets_values.type.value; + } + } + if (this.outputs.length > 0){ + if (this.outputs[0].links == null){ + info.outputs_values = {links: null, name: this.outputs[0].name, type: this.outputs[0].type}; + }else{ + info.outputs_values = {links: [...this.outputs[0].links], name: this.outputs[0].name, slot_index: this.outputs[0].slot_index, type: this.outputs[0].type}; + } + } + this.setSize(info.size); +} + + +export function setupContinueNode(nodeType, nodeData, app) { + nodeType.prototype.onNodeCreated = function() { + chainCallback(this, "onConfigure", configure); + chainCallback(this, "onSerialize", serialize); + initialisation(this); + } +} diff --git a/web/js/nodetype_input.js b/web/js/nodetype_input.js new file mode 100644 index 0000000..7216e63 --- /dev/null +++ b/web/js/nodetype_input.js @@ -0,0 +1,85 @@ +import { chainCallback } from "./utils.js"; +import { addInputs, cleanInputs, clearInputs } from "./inputs.js"; +import { colors, bg_colors, node_type_list } from "./constants.js"; + +export function setupInputNode(nodeType, nodeData, app) { + nodeType.prototype.onNodeCreated = function() { + chainCallback(this, "onConfigure", function(info) { + let widgetDict = info.widgets_values; + const inputs = {}; + + inputs["default"] = { + inputs: ["default", info.widgets_values[1], info.widgets_values[2]] + }; + + addInputs(this, inputs, info.widgets_values); + }); + chainCallback(this, "onSerialize", function(info) { + let widgetDict = info.widgets_values; + + for (let inp of this.inputs){ + // if w.name exists in info.widgets_values + if (inp.widget){ + if (inp.type != this.local_input_defs.required[inp.name][0]){ + inp.type = this.local_input_defs.required[inp.name][0]; + const wid = this.widgets.find(w => w.name == inp.name); + if (wid && wid.origType != this.local_input_defs.required[inp.name][0]) + wid.origType = this.local_input_defs.required[inp.name][0]; + } + } + } + }); + + this.widgets[1].callback = ( value ) => { + cleanInputs(this); + + switch(value){ + case "STRING": + this.addOutput("output", "STRING"); + ComfyWidgets.STRING( + this, + "default", + ["STRING",{default: "",},], + app, + ) + + break; + case "INT": + this.addOutput("output", "INT"); + ComfyWidgets.INT( + this, + "default", + //['',{default: 0, "min": 0, "max": 18446744073709551616, "step": 1},], + ['',{default: 0},], + app, + ) + break; + case "FLOAT": + this.addOutput("output", "FLOAT"); + ComfyWidgets.FLOAT( + this, + "default", + ['',{default: 0, "min": 0.00, "max": 2048.00, "step": 0.01},], + app, + ) + break; + case "BOOLEAN": + this.addOutput("output", "BOOLEAN"); + this.addWidget("toggle", "default", false, ()=>{}); + break; + case "none": + break; + default: + this.addOutput("output", value); + this.addInput("default", value); + break; + } + this.color = colors[node_type_list.indexOf(value)]; + this.bgcolor = bg_colors[node_type_list.indexOf(value)]; + }; + if (this.widgets[1].value == "none") + clearInputs(this); + this.color = colors[node_type_list.indexOf("none")]; + this.bgcolor = bg_colors[node_type_list.indexOf("none")]; + } +} \ No newline at end of file diff --git a/web/js/nodetype_lipSync.js b/web/js/nodetype_lipSync.js new file mode 100644 index 0000000..eef042b --- /dev/null +++ b/web/js/nodetype_lipSync.js @@ -0,0 +1,35 @@ +import { useKVState } from "./utils.js"; +import { chainCallback } from "./utils.js"; +import { addLoadVideoCommon } from "./videoPreview.js"; + +export function setupLipSyncNode(nodeType, nodeData, app) { + useKVState(nodeType); + chainCallback(nodeType.prototype, "onNodeCreated", function () { + let new_widgets = [] + if (this.widgets) { + for (let w of this.widgets) { + let input = this.constructor.nodeData.input + let config = input?.required[w.name] ?? input.optional[w.name] + if (!config) { + continue + } + if (w?.type == "text" && config[1].vhs_path_extensions) { + new_widgets.push(app.widgets.VHSPATH({}, w.name, ["VHSPATH", config[1]])); + } else { + new_widgets.push(w) + } + } + this.widgets = new_widgets; + } + }); + addLoadVideoCommon(nodeType, nodeData); + const onGetImageSizeExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function(message) { + const r = onGetImageSizeExecuted? onGetImageSizeExecuted.apply(this,arguments): undefined + let video = message["video_path"][0]; + if(video){ + this.updateParameters({format: "video/mp4", filename: message["video_path"][0], subfolder: message["video_path"][1], "type": "output"}); + } + return r + } +} \ No newline at end of file diff --git a/web/js/nodetype_output.js b/web/js/nodetype_output.js new file mode 100644 index 0000000..4cd5696 --- /dev/null +++ b/web/js/nodetype_output.js @@ -0,0 +1,125 @@ +import { chainCallback } from "./utils.js"; +import { clearInputs } from "./inputs.js"; +import { addWidgets } from "./widgets.js"; +import { colors, bg_colors, node_type_list } from "./constants.js"; + +export function setupOutputNode(nodeType, nodeData, app) { + + nodeType.prototype.onNodeCreated = function() { + chainCallback(this, "onConfigure", function(info) { + let widgetDict = info.widgets_values + if (info.widgets_values.length == undefined) { + + for (let w of this.widgets) { + if (w.name in widgetDict) { + w.value = widgetDict[w.name].value; + } + } + // check if widgetDict in this.widgets + for (let [key, value] of Object.entries(widgetDict)) { + let widget = this.widgets.find(w => w.name === key); + let type = this.widgets.find(w => w.name === "type"); + if(!widget){ + addWidgets(this, key, value, app); + widget = this.widgets.find(w => w.name === key); + } + //this.widgets.push(value); + widget.options = info.widgets_values[key].options; + widget.value = info.widgets_values[key].value; + //if value exists in inputs + + for (let input of this.inputs){ + if (input.name == key){ + //find if key exists in inputs array in inputs.Name + if (info.widgets_values[key].type != "converted-widget"){ + this.removeInput(this.inputs.indexOf(input)); + } + break; + } + } + + } + } + + for (let w of this.inputs){ + if (w.name=="default"){ + w.type = info.widgets_values.type.value; + } + } + if (info.outputs_values != undefined){ + // deep copy outputs + if(this.id == -1){ + this.outputs[0] = {links: null, name: info.outputs_values.name, type: info.outputs_values.type}; + }else{ + this.outputs[0] = {...info.outputs_values}; + } + } + + }); + chainCallback(this, "onSerialize", function(info) { + info.widgets_values = {}; + if (!this.widgets) { + return; + } + + for (let w of this.widgets) { + info.widgets_values[w.name] = {name: w.name, options : w.options, value: w.value, type: w.type, origType: w.origType, last_y: w.last_y}; + } + + + for (let w of this.inputs){ + // if w.name exists in info.widgets_values + if (info.widgets_values[w.name]){ + if(info.widgets_values[w.name].type == "converted-widget"){ + if(info.widgets_values[w.name].origType == "toggle"){ + w.type = "BOOLEAN"; + }else if(info.widgets_values[w.name].origType == "text"){ + w.type = "STRING"; + } + } + } + } + if (this.outputs.length > 0){ + if (this.outputs[0].links == null){ + info.outputs_values = {links: null, name: this.outputs[0].name, type: this.outputs[0].type}; + }else{ + info.outputs_values = {links: [...this.outputs[0].links], name: this.outputs[0].name, slot_index: this.outputs[0].slot_index, type: this.outputs[0].type}; + } + } + this.setSize(info.size); + }); + this.widgets[1].callback = ( value ) => { + // D'abord, déconnecter tous les liens existants + for (let i = 0; i < this.outputs.length; i++) { + const output = this.outputs[i]; + if (output.links && output.links.length) { + const links = output.links.slice(); + for (const linkId of links) { + this.graph.removeLink(linkId); + } + } + } + + for (let i = 0; i < this.inputs.length; i++) { + const input = this.inputs[i]; + if (input.link) { + this.graph.removeLink(input.link); + } + } + clearInputs(this); + switch(value){ + case "none": + break; + default: + this.addOutput("output", value); + this.addInput("default", value); + break; + } + this.color = colors[node_type_list.indexOf(value)]; + this.bgcolor = bg_colors[node_type_list.indexOf(value)]; + }; + if (this.widgets[1].value == "none") clearInputs(this); + this.color = colors[node_type_list.indexOf("none")]; + this.bgcolor = bg_colors[node_type_list.indexOf("none")]; + } +} \ No newline at end of file diff --git a/web/js/nodetype_workflow.js b/web/js/nodetype_workflow.js new file mode 100644 index 0000000..cad9fe2 --- /dev/null +++ b/web/js/nodetype_workflow.js @@ -0,0 +1,71 @@ +import { chainCallback, fitHeight } from "./utils.js"; +import { addInputs, cleanInputs, removeInputs } from "./inputs.js"; +import { importWorkflow } from "./workflows.js"; +import { addOutputs } from "./inputs.js"; +import { hideWidget } from "./widgets.js"; + +export function setupWorkflowNode(nodeType, nodeData, app) { + nodeType.prototype.onNodeCreated = function() { + chainCallback(this, "onConfigure", function(info) { + if (info.widgets_values[0] != "None"){ + const inputs = app.lipsync_studio[info.widgets_values[0]].inputs; + + addInputs(this, inputs, info.widgets_values); + addOutputs(this, info.widgets_values[0]); + removeInputs(this, inputs, info.widgets_values); + fitHeight(this); + importWorkflow(this, info.widgets_values[0], app, nodeData) + .then(data => { + if (data){ + this.widgets[1].value = data; + const inputs = app.lipsync_studio[info.widgets_values[0]].inputs; + + addInputs(this, inputs, info.widgets_values); + addOutputs(this, info.widgets_values[0]); + removeInputs(this, inputs, info.widgets_values); + fitHeight(this); + //importWorkflow(this, info.widgets_values[0], app, nodeData) + } + }) + .catch(error => { + console.error('Erreur lors de l\'importation:', error); + }); + } + }); + + chainCallback(this, "onSerialize", function(info) { + for (let inp of this.inputs){ + if (inp.widget){ + if (inp.type != this.local_input_defs.required[inp.name][0]) + inp.type = this.local_input_defs.required[inp.name][0]; + } + } + if(this.widgets[0].options.values == "COMBO"){ + this.widgets[0].options.values = ["None", ...Object.keys(app.lipsync_studio)]; + } + }); + const workflow_reload = this.title.startsWith("Workflow: ")?true:false; + this.widgets[0].options.values = ["None", ...Object.keys(app.lipsync_studio)] + this.widgets[0].callback = ( value ) => { + cleanInputs(this); + if (value == "None"){ + this.title = "Workflow (FlowChain ⛓️)"; + }else{ + this.widgets[1].value = importWorkflow(this, value, app, nodeData); + const inputs = app.lipsync_studio[value].inputs; + addInputs(this, inputs, {}, true); + addOutputs(this, value); + fitHeight(this); + } + }; + if (!workflow_reload){ + this.widgets[0].value = "None"; + this.widgets[1].value = ""; + } + hideWidget(this, this.widgets[1], { holdSpace: false }) + cleanInputs(this); + this.color = "#004670"; + this.bgcolor = "#002942"; + + } +} \ No newline at end of file diff --git a/workflow.py b/workflow.py index 716fc49..298fa3d 100644 --- a/workflow.py +++ b/workflow.py @@ -77,9 +77,43 @@ class Workflow(SaveImage): OUTPUT_NODE = True @classmethod - def IS_CHANGED(s, workflows, **kworgs): + def IS_CHANGED(s, workflows, workflow, **kwargs): m = hashlib.sha256() m.update(workflows.encode()) + + # Ajouter le contenu du workflow au hash pour détecter les changements de structure + if workflow: + workflow_data = json.loads(workflow) + + # Extraire les nœuds de sortie avec leurs positions/types/connexions + outputs = {} + for k, v in workflow_data.items(): + if v.get('class_type') == 'WorkflowOutput': + # Capturer le nom, type et la source de données (connexions entrantes) + output_info = { + 'name': v['inputs']['Name'], + 'type': v['inputs']['type'], + 'position': v.get('_meta', {}).get('position', [0, 0]), + } + + # Ajouter les connexions d'entrée pour tracer la provenance des données + for input_name, input_value in v['inputs'].items(): + if isinstance(input_value, list) and len(input_value) > 0: + # Stocker les IDs des nœuds connectés à cette sortie + output_info[input_name + '_source'] = input_value + + outputs[k] = output_info + + # Être sûr de préserver l'ordre des sorties dans le hash + # en les triant par position verticale + sorted_outputs = dict(sorted( + outputs.items(), + key=lambda item: item[1].get('position', [0, 0])[1] + )) + + # Ajouter l'information des sorties au hash + m.update(json.dumps(sorted_outputs, sort_keys=True).encode()) + return m.digest().hex() def generate(self, workflows, workflow, **kwargs): @@ -100,7 +134,7 @@ class Workflow(SaveImage): if "default" not in value["inputs"]: workflow[key]["inputs"]["default"] = torch.tensor([]) else: - if not value["inputs"]["default"]: + if value["inputs"]["default"].numel() == 0: workflow[key]["inputs"]["default"] = torch.tensor([]) return workflow @@ -338,7 +372,42 @@ class Workflow(SaveImage): workflow, _ = get_recursive_workflow(workflow, 5000) workflow, workflow_outputs = clean_workflow(workflow, original_inputs, kwargs) - workflow_outputs_id = [k for k, v in workflow.items() if v["class_type"] == "WorkflowOutput"] + + # Accéder au fichier JSON original pour obtenir les positions correctes + workflow_file_path = os.path.join(folder_paths.user_directory, "default", "workflows", workflows) + original_positions = {} + + # Récupérer les positions des noeuds de sortie depuis le fichier original + + if os.path.exists(workflow_file_path): + try: + with open(workflow_file_path, "r", encoding="utf-8") as f: + original_workflow = json.load(f) + + # Créer un mapping node_id -> position pour les noeuds WorkflowOutput + if "nodes" in original_workflow: + for node in original_workflow["nodes"]: + if node.get("type") == "WorkflowOutput": + node_id = str(node.get("id", "unknown")) + pos_y = node.get("pos", [0, 0])[1] + node_name = node.get("widgets_values", "")["Name"]["value"] + original_positions[node_name] = pos_y + except Exception as e: + print(f"Erreur lors de la lecture du fichier workflow original: {str(e)}") + + # Récupérer les nœuds de sortie et les trier par position Y + workflow_outputs_with_position = [] + for k, v in workflow_outputs.items(): + output_name = v["inputs"]["Name"] + # Utiliser la position du fichier original si disponible, sinon utiliser une position par défaut + y_position = original_positions.get(output_name, 999999) + workflow_outputs_with_position.append((k, y_position)) + + # Trier par position Y croissante + workflow_outputs_with_position.sort(key=lambda x: x[1]) + + # Extraire seulement les IDs dans l'ordre trié + workflow_outputs_id = [k for k, _ in workflow_outputs_with_position] prompt_id = str(uuid.uuid4()) @@ -359,14 +428,15 @@ class Workflow(SaveImage): comfy.model_management.unload_all_models() gc.collect() + # Remplacer la boucle de génération d'output qui ne respecte pas l'ordre output = [] - for id_node, node in workflow_outputs.items(): + for id_node in workflow_outputs_id: # Utiliser l'ordre trié des IDs if id_node in history_result["outputs"]: result_value = history_result["outputs"][id_node]["default"] # Apply formatting based on the expected output type - # formatted_value = format_output_value(result_value, node["inputs"]["type"]) output.append(result_value[0]) else: + node = workflow_outputs[id_node] # Récupérer le nœud correspondant à l'ID if node["inputs"]["type"] == "IMAGE" or node["inputs"]["type"] == "MASK": black_image_np = np.zeros((255, 255, 3), dtype=np.uint8) black_image_pil = Image.fromarray(black_image_np)