Add live update workflow node from sub workflow inputs/outputs

This commit is contained in:
NumZ
2025-03-26 05:52:24 +01:00
parent 38f0b23246
commit a5d6ecee15
9 changed files with 513 additions and 13 deletions
+7
View File
@@ -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.
+3 -3
View File
@@ -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:
+5 -5
View File
@@ -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",
+107
View File
@@ -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);
}
}
+85
View File
@@ -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")];
}
}
+35
View File
@@ -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
}
}
+125
View File
@@ -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")];
}
}
+71
View File
@@ -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";
}
}
+75 -5
View File
@@ -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)