Files
niknah 312e285e47 A long fix for the warning message when running with image input/output
nodes
It removes the nodes inside the image input/output nodes before running.
2026-08-16 22:27:05 +10:00

1131 lines
41 KiB
Python

from comfy_execution.graph_utils import GraphBuilder, is_link
from comfy_execution.graph import ExecutionBlocker
from comfy_api.latest import io
from nodes import NODE_CLASS_MAPPINGS
from server import PromptServer
import sys
import csv
import hashlib
import base64
import pickle
from io import StringIO
import numpy as np
from PIL import Image
from pathlib import Path
import folder_paths
import random
import logging
import torch
import torchaudio
import re
import os
import time
import datetime
import glob
import pandas as pd
class S2VComfy():
@classmethod
def get_prompt_id(cls):
queue = PromptServer.instance.prompt_queue.get_current_queue_volatile()
return list(list(queue)[0])[0][1]
@classmethod
def get_prompt_dir(cls):
temp_dir = Path(folder_paths.get_temp_directory()) / f"Spreadsheet2Video_{cls.get_prompt_id()}"
temp_dir.mkdir(exist_ok=True)
return temp_dir
@classmethod
def get_all_files(cls, prefix):
prompt_dir = cls.get_prompt_dir()
# this could be quicker if we kept track
files = [
entry for entry in prompt_dir.iterdir()
if entry.name.startswith(prefix)
]
files.sort()
return files
@classmethod
def get_next_file(cls, prefix):
prompt_dir = cls.get_prompt_dir()
max_num = 0
# this could be quicker if we kept track
for entry in prompt_dir.iterdir():
m = re.search(r"(\d+)$", entry.stem)
if m is not None:
num = int(m.group(1))
if num > max_num:
max_num = num
return cls.get_prompt_dir() / ("%s%06d" % (prefix, max_num+1) )
# Add empty columns if the row does not have the same amount of columns as the header
@classmethod
def fill_empty_columns(cls, headerRow, from_rows):
for from_row in from_rows:
if len(from_row) < len(headerRow):
from_row += [None] * (len(headerRow) - len(from_row))
last_processed_time = None
# Executed after each row
class Spreadsheet2VideoProcessImage(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="Spreadsheet2VideoProcessImage",
display_name="Internal S2V Process Image",
description="internal node, ignore",
category="S2V Internal",
inputs=[
io.Int.Input("previous"),
io.Image.Input("images", optional=True),
io.Audio.Input("audio", optional=True),
],
outputs=[
io.Int.Output(),
io.Image.Output(),
io.Audio.Output(),
],
hidden=[],
)
@classmethod
def execute(cls, previous, images=None, audio=None) -> io.NodeOutput:
global last_processed_time
if images is not None:
for (batch_number, image) in enumerate(images):
next_file = S2VComfy.get_next_file("v_")
next_file = next_file.with_suffix(".png")
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
logging.info(f"S2V: save img: {str(next_file)}")
img.save(str(next_file), compress_level=9)
if (images.shape[0] == 0):
raise Exception(f"S2VProcessImage. No image shape: {str(images.shape)}")
if audio is not None:
next_file = S2VComfy.get_next_file("a_")
next_file = next_file.with_suffix(".flac")
logging.info(f"S2V: save audio: {str(next_file)}")
torchaudio.save(next_file, audio["waveform"][0], audio["sample_rate"], format="flac")
output_image = None if images is None else images[-1:]
current_time = time.time()
if last_processed_time is not None:
time_len = current_time - last_processed_time
logging.info(f"S2V: time taken for row: {datetime.timedelta(time_len/86400)}")
last_processed_time = current_time
return io.NodeOutput(
# images[images.shape[0]-1,]
# images
previous,
output_image,
audio
)
@classmethod
def fingerprint_inputs(s, previous=1, images=None, audio=None):
# always run because the other nodes are not a part of this execution pth
return str(random.random())
# The original nodes are replaced with this one so they won't do anything.
# class Spreadsheet2VideoDoNothing(io.ComfyNode):
# @classmethod
# def define_schema(cls) -> io.Schema:
# return io.Schema(
# node_id="Spreadsheet2VideoDoNothing",
# display_name="Internal S2V Do nothing",
# description="internal node, ignore",
# category="S2V Internal",
# inputs=[
# ],
# outputs=[
# ],
# hidden=[],
# )
# @classmethod
# def execute(cls, **kwargs) -> io.NodeOutput:
# return io.NodeOutput(
# ExecutionBlocker(None)
# )
# def check_lazy_status(self):
# return []
# Not executed
class Spreadsheet2VideoOutputImage(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="Spreadsheet2VideoOutputImage",
display_name="Spreadsheet2Video Output Image",
category="Spreadsheet2Video",
search_aliases=["spreadsheet", "loop", "output"],
inputs=[
io.Image.Input("images", lazy=True, tooltip="Link the image or video output here."),
io.Audio.Input("audio", lazy=True, optional=True, tooltip="Link the audio output here."),
],
outputs=[
],
hidden=[],
)
@classmethod
def execute(cls, name) -> io.NodeOutput:
# raise Error(f"This node should not be executed")
return io.NodeOutput(
ExecutionBlocker(None)
)
def check_lazy_status(cls, images=None):
return []
# Not executed
class Spreadsheet2VideoInputImage(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="Spreadsheet2VideoInputImage",
display_name="Spreadsheet2Video Input Image",
category="Spreadsheet2Video",
search_aliases=["spreadsheet", "loop", "output"],
inputs=[
io.String.Input("COLUMN1", lazy=True, tooltip="Name of this part of the workflow. Use the same name in the spreadsheet's first column."),
],
outputs=[
io.Image.Output(),
# io.String.Output("COLUMN2"),
# io.String.Output("COLUMN3"),
# io.String.Output("COLUMN4"),
# io.String.Output("COLUMN5"),
# io.String.Output("COLUMN6"),
# io.String.Output("COLUMN7"),
# io.String.Output("COLUMN8"),
# TODO: error. Is there an "Any" type for output?
# [ERROR] Failed to validate prompt for output 4:
# [ERROR] * Text Overlay 21:
# [ERROR] - Exception when validating inner node: list index out of range
# [ERROR] Output will be ignored
],
hidden=[],
)
@classmethod
def execute(cls, COLUMN1=None) -> io.NodeOutput:
return io.NodeOutput(
ExecutionBlocker(None)
)
@classmethod
def check_lazy_status(cls, COLUMN1=None):
return []
# Disabled: If this is enabled the workflow will validate.
# But it will run the original nodes that are not in any loop
# Hacky fix for node not validating
# RETURN_TYPES=("IMAGE","*","*","*","*","*","*","*","*","*","*","*","*","*","*")
class GroupInfo():
def __init__(self, first_input_node_id, group_name):
self.orig_node_ids = {}
self.group_name = group_name
self.first_input_node_id = first_input_node_id
self.output_image_link = None
self.restored_nodes = {}
def change_out_node(self, graph):
# output_image_node_id = None
found_node = None
for node_id in graph.nodes.keys():
node = graph.nodes.get(node_id)
if node.class_type == "Spreadsheet2VideoOutputImage":
node.class_type = "Spreadsheet2VideoProcessImage"
# output_image_node_id = node_id
self.output_image_link = node.out(1) # [node_id, 1]
found_node = node
# if output_image_node_id is None:
# logging.warning("S2V: Could not find output image node")
return found_node
def add_orig_node_ids(
self,
prompt,
children,
child_node_ids,
group_name_by_output_id,
parent_is_out,
depth
):
for (child_id, node_is_out) in ([ [i,False] for i in children["in"]] + [ [i,True] for i in children["out"]]):
must_be_group = group_name_by_output_id[child_id] if child_id in group_name_by_output_id else None
is_out = parent_is_out and node_is_out
class_type = prompt[child_id]["class_type"]
if (
(depth > 0 and
class_type == "Spreadsheet2VideoInputImage"
) or
class_type == "Spreadsheet2Video" or
(must_be_group is not None and must_be_group != self.group_name)
):
pass
elif(child_id not in self.orig_node_ids):
self.orig_node_ids[child_id] = is_out
if child_id in child_node_ids:
self.add_orig_node_ids(
prompt,
child_node_ids[child_id],
child_node_ids,
group_name_by_output_id,
is_out,
depth+1
)
elif(is_out is True):
# if the node is on the output path ever. It is out.
# Cause the path is not processed in any order.
self.orig_node_ids[child_id] = is_out
def get_orig_node_ids_count(self):
return len(self.orig_node_ids.keys())
def copy_nodes(self, prompt, graph):
contained = self.orig_node_ids.keys()
node_id_map = {}
for node_id in contained:
if node_id in prompt:
original_node = prompt[node_id]
else:
original_node = self.restored_nodes[node_id]
new_node = graph.node(original_node["class_type"])
new_node.set_override_display_id(node_id)
node_id_map[node_id] = (original_node, new_node)
for node_id in contained:
(original_node, new_node) = node_id_map[node_id]
self.copy_node_inputs(node_id, original_node, node_id_map)
self.node_id_map = node_id_map
return node_id_map
def get_new_node(self, output_id):
(_output_original_node, output_new_node) = self.node_id_map[output_id]
return output_new_node
def copy_node_inputs(self, original_node_id, original_node, node_id_map):
"""
Args:
original_node_id: id of original node
original_node: the original node
node_id_map: map from original_node_id to (original_node, new_node)
"""
(input_original_node, input_new_node) = node_id_map[original_node_id]
for k, v in original_node["inputs"].items():
if is_link(v):
from_output_node_id = v[0]
if self.first_input_node_id == from_output_node_id:
input_new_node.set_input(k, [from_output_node_id, v[1]])
continue # ignore: first link. Handled by link_to_new_input_node
if from_output_node_id not in node_id_map:
print(f"copy_node_inputs: Huh? {from_output_node_id} not in node_id_map. From node:{original_node_id}")
continue
(output_original_node, output_new_node) = node_id_map[from_output_node_id]
input_new_node.set_input(k, [output_new_node.id, v[1]])
else:
input_new_node.set_input(k, v)
# convert node input type from string to int, float, boolean
def convert_new_node_input_value(self, node_class, key, val):
input_types = node_class.INPUT_TYPES()
input_type = None
if "required" in input_types:
input_type = input_types["required"].get(key)
if input_type is None and "optional" in input_types:
input_type = input_types["optional"].get(key)
if input_type is not None:
try:
if(input_type[0] == "INT"):
val = int(re.match(r"^-?\d+", val).group(0)) # convert float to int if needed
elif(input_type[0] == "FLOAT"):
val = float(val)
elif(input_type[0] == "BOOLEAN"):
val = val.lower().strip() in ("yes", "true", "t", "1")
except Exception as e:
raise ValueError(f"Bad value: {val} for type {input_type[0]}") from e
return val
def link_to_new_input_node(
self, graph, link_to_input, row
):
foundImageInput = False
for node_id in graph.nodes.keys():
node = graph.nodes.get(node_id)
class_type = node.class_type
if class_type in NODE_CLASS_MAPPINGS:
node_class = NODE_CLASS_MAPPINGS[class_type]
node_inputs = node.inputs
for k, v in node_inputs.items():
if is_link(v):
if v[0] == self.first_input_node_id:
if v[1] == 0:
# link to image
node_inputs[k] = link_to_input
foundImageInput = True
else:
# replace link with row value
if v[1] < len(row):
node_inputs[k]= self.convert_new_node_input_value(node_class, k, row[v[1]])
else:
logging.warning(f"S2V: not enough columns in row, column: {v[1]}, row: {','.join(row)}")
return foundImageInput
# def replace_with_do_nothing(self, prompt):
# for remove_node_id in self.orig_node_ids.keys():
# node = prompt[remove_node_id]
# node['class_type']="Spreadsheet2VideoDoNothing"
# node['inputs']={}
@classmethod
def restore_group_infos_from_prompt(cls, prompt):
by_group_name = {}
for node_id, node in prompt.items():
if "_meta" not in node:
continue
meta = node["_meta"]
if "__group_info" in meta:
group_info = pickle.loads(base64.b64decode(meta["__group_info"]))
if "__removed" in meta:
group_info.restored_nodes = meta["__removed"]
by_group_name[group_info.group_name] = group_info
if(len(by_group_name.keys()) == 0):
return None
return by_group_name
def remove_nodes_from_prompt(self, prompt, exclude_ids):
removed = {}
for node_id, is_out in self.orig_node_ids.items():
if node_id in prompt and node_id not in exclude_ids:
removed[node_id]= prompt[node_id]
del prompt[node_id]
return removed
@classmethod
def remove_nodes_in_group_infos(cls, prompt, by_group_name, exclude_ids):
for name, group_info in by_group_name.items():
input_node = prompt[group_info.first_input_node_id]
raw_bytes = pickle.dumps(group_info)
# base64 encodes the binary data into a clean, portable text string
if "_meta" not in input_node:
input_node["_meta"] = {}
input_node["_meta"]["__group_info"] = base64.b64encode(raw_bytes).decode("utf-8")
removed = group_info.remove_nodes_from_prompt(prompt, exclude_ids)
input_node["_meta"]["__removed"] = removed
class MapOutputs():
def __init__(self):
self.child_node_ids = {}
self.input_node_ids = []
def map_children(self, prompt):
input_node_ids = []
child_node_ids = {}
for node_id, node in prompt.items():
if "inputs" not in node or "class_type" not in node:
continue
for k, v in node["inputs"].items():
if not is_link(v):
continue
parent_node_id = v[0]
if parent_node_id not in child_node_ids:
child_node_ids[parent_node_id] = {"in":[], "out":[]}
child_node_ids[parent_node_id]["out"].append(node_id)
if node_id not in child_node_ids:
child_node_ids[node_id] = {"in":[], "out":[]}
child_node_ids[node_id]["in"].append(parent_node_id)
if node["class_type"] == "Spreadsheet2VideoInputImage":
input_node_ids.append(node_id)
self.child_node_ids = child_node_ids
self.input_node_ids = input_node_ids
return (child_node_ids,input_node_ids)
def get_group_name_from_node(self, inputImageNode):
if inputImageNode is None or "inputs" not in inputImageNode:
return None
group_name = inputImageNode["inputs"]["COLUMN1"].strip()
if group_name == "":
return None
return group_name
def add_map_output_by_group_name(self, group_name_by_output_id, child_node_ids, children, group_name):
for child_id in children["out"]:
if child_id in group_name_by_output_id:
existing_group_name = group_name_by_output_id[child_id]
if existing_group_name != group_name:
raise ValueError(f"S2V: Node is in both column1 groups, node id:{child_id}, names:{existing_group_name}, {group_name}")
group_name_by_output_id[child_id] = group_name
if child_id not in child_node_ids:
logging.error(f"S2V: Cannot find output node id: {child_id}")
continue
self.add_map_output_by_group_name(
group_name_by_output_id,
child_node_ids,
child_node_ids[child_id],
group_name
)
def map_output_by_group_name(self, group_name_by_output_id, prompt):
(child_node_ids, input_node_ids) = self.map_children(prompt)
for input_node_id in input_node_ids:
inputImageNode = prompt[input_node_id]
group_name = self.get_group_name_from_node(inputImageNode)
group_name_by_output_id[input_node_id] = group_name
self.add_map_output_by_group_name(
group_name_by_output_id,
child_node_ids,
child_node_ids[input_node_id],
group_name
)
return group_name_by_output_id
def map_outputs(self, prompt):
(child_node_ids, input_node_ids) = self.map_children(prompt)
group_name_by_output_id = {}
self.map_output_by_group_name(group_name_by_output_id, prompt)
by_group_name = {}
for input_node_id in input_node_ids:
inputImageNode = prompt[input_node_id]
group_name = self.get_group_name_from_node(inputImageNode)
if group_name is None:
continue
if group_name in by_group_name:
raise ValueError(f"S2V: Duplicate group names: {group_name}")
group_info = GroupInfo(input_node_id, group_name)
by_group_name[group_name] = group_info
group_info.add_orig_node_ids(
prompt,
child_node_ids[input_node_id],
child_node_ids,
group_name_by_output_id,
True,
0
)
logging.info(f"S2V: group: {group_name}, nodes: {group_info.get_orig_node_ids_count()}")
return by_group_name
def mark_parent_nodes(self, mark_dict, node):
for parent_id in node["in"]:
if parent_id in mark_dict:
continue
mark_dict[parent_id] = True
for node_id in self.child_node_ids:
node = self.child_node_ids[node_id]
self.mark_parent_nodes(mark_dict, node)
def nodes_used_by_other_nodes(self, prompt, by_group_name) -> dict[str, bool]:
used_by_s2v_ids : dict[str, bool] = {}
not_used_by_s2v_ids : dict[str, bool] = {}
used_by_other_nodes : dict[str, bool] = {}
for name, group_info in by_group_name.items():
for node_id in group_info.orig_node_ids.keys():
used_by_s2v_ids[node_id] = True
not_used_by_s2v_ids = { k: True for k in prompt.keys() if k not in used_by_s2v_ids }
for node_id in not_used_by_s2v_ids:
for node_id in self.child_node_ids:
node = self.child_node_ids[node_id]
self.mark_parent_nodes(used_by_other_nodes, node)
return used_by_other_nodes
class Spreadsheet2VideoFinalVideo(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="Spreadsheet2VideoFinalVideo",
display_name="Internal S2V Final Video",
description="internal node, ignore",
category="S2V Internal",
inputs=[
io.Int.Input("previous"),
# io.Image.Input("images"),
],
outputs=[
io.Image.Output(),
io.Audio.Output(),
],
hidden=[],
)
@classmethod
def execute(cls, previous) -> io.NodeOutput:
# TODO: ffmpeg to save memory?
all_files = S2VComfy.get_all_files("v_")
output_images = []
# skip first image, it maybe a different size to the video
for file in all_files:
image = Image.open(file)
if image.mode == 'I':
image = image.point(lambda i: i * (1 / 255))
image = image.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
output_images.append(image)
errors = []
if len(output_images) > 0:
first_shape = output_images[0].shape
if len(output_images) > 1 and first_shape != output_images[1].shape:
# sometimes the first image from LoadImage is a different resolution as the process that makes the image that get send to S2VOutputImage node.
output_images = output_images[1:]
first_shape = output_images[0].shape
nth = 0
for output_image in output_images:
shape = output_image.shape
if (
shape[1] != first_shape[1] or
shape[2] != first_shape[2]
):
err = f'S2V: All images must be the same size. This output: {output_image.shape}, nth image:{nth}, first output:{first_shape}'
logging.error(err)
logging.error('Some subgraphs / workflows use various "resize" nodes that change the output.')
logging.error('Put a resize node before S2VOutputImage to resize it back.')
errors.append(err)
nth += 1
if len(errors) > 0:
raise Exception("\n".join(errors))
logging.info(f"S2V: final image count:{len(output_images)}")
if len(output_images)==0:
output_tensor = torch.tensor([])
else:
output_tensor = torch.cat(output_images, dim=0)
# Put audio together
all_audio_files = S2VComfy.get_all_files("a_")
output_audio = {}
waveforms = []
for audio_file in all_audio_files:
waveform, sample_rate = torchaudio.load(audio_file)
waveforms.append(waveform)
if "sample_rate" in output_audio:
previous_sample_rate = output_audio["sample_rate"]
if previous_sample_rate != sample_rate:
logging.error(f"S2V: audio has a different sample_rate: previous:{previous_sample_rate} != current:{sample_rate}, {audio_file}")
else:
output_audio["sample_rate"] = sample_rate
if len(waveforms)==0:
output_audio["waveform"] = torch.empty(1, 1, 0)
output_audio["sample_rate"] = 44100
else:
output_audio["waveform"] = torch.cat(waveforms, dim=1).unsqueeze(0)
return io.NodeOutput(
output_tensor,
output_audio
)
class Spreadsheet2VideoSequence(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="Spreadsheet2VideoSequence",
display_name="Spreadsheet2Video Sequence",
category="Spreadsheet2Video",
description="Make a sequence of numbers. Use with Spreadsheet2Video Multiply Spreadsheet to add to another spreadsheet ",
search_aliases=["spreadsheet", "loop", "load", "column"],
inputs=[
io.Float.Input("start", default=0, min=-sys.maxsize, max=sys.maxsize, tooltip="Start number"),
io.Float.Input("end", default=10, min=-sys.maxsize, max=sys.maxsize, tooltip='End "before" number. Does not include this number.'),
io.Float.Input("step", default=1, min=-sys.maxsize, max=sys.maxsize, tooltip="Number to increase/decrease each step"),
],
outputs=[
io.String.Output(),
],
)
@classmethod
def execute(cls, start, end, step) -> io.NodeOutput:
n = start
if step == 0:
raise Exception("S2VSequence. step must not be zero")
if step < 0 and not (start > end):
raise Exception(f"S2VSequence. start must be more than end when step is going backwards(negative). {start} to {end}. Step:{step}")
if step > 0 and not (start < end):
raise Exception(f"S2VSequence. start must be less than end when step is going forwards(positive). {start} to {end}. Step:{step}")
nums = ['sequence']
while True:
if step < 0:
if(n <= end):
break
elif(n >= end):
break
nums.append(str(n))
n += step
return io.NodeOutput(
"\n".join(nums)
)
class Spreadsheet2VideoFilesList(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="Spreadsheet2VideoFilesList",
display_name="Spreadsheet2Video Files list",
category="Spreadsheet2Video",
description="Get a list of files relative to the input folder",
search_aliases=["spreadsheet", "loop", "load", "files"],
inputs=[
io.String.Input("directory", default="input", tooltip="Directory. example: input, output"),
io.Boolean.Input("regex", default=False, tooltip="Regex(on) or glob(off)"),
io.String.Input("find", default="*", tooltip="Glob / Regex"),
],
outputs=[
io.String.Output(),
],
)
@classmethod
def execute(cls, directory, regex, find) -> io.NodeOutput:
found = []
if regex:
findRe = re.compile(find, re.IGNORECASE)
for dirpath, dirnames, filenames in os.walk( directory):
for filename in filenames:
# Check if the regex pattern matches the filename
file = Path(os.path.join(dirpath, filename))
if findRe.search(str(file)):
found.append( file )
else:
found = list(glob.iglob( str(Path(directory) / find), recursive=True))
found = [ Path(f) for f in found ]
input_folder = Path(folder_paths.get_input_directory())
found[:] = [ str(f.absolute().relative_to(input_folder)) for f in found]
return io.NodeOutput(
"\n".join(['filename'] + found)
)
class Spreadsheet2VideoAddColumnSpreadsheet(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="Spreadsheet2VideoAddColumnSpreadsheet",
display_name="Spreadsheet2Video Add column to Spreadsheet",
category="Spreadsheet2Video",
description="Adds columns to a spreadsheet. Repeats rows if there are not enough. Maximum rows to add is the source spreadsheet",
search_aliases=["spreadsheet", "loop", "add", "column"],
inputs=[
io.String.Input("spreadsheet", multiline=True, tooltip="Comma separated values with a header row. Can use Spreadsheet2Video Load Spreadsheet node."),
io.String.Input("spreadsheet_to_add", multiline=True, tooltip="Comma separated values with a header row. The columns to add"),
],
outputs=[
io.String.Output(),
],
)
@classmethod
def execute(cls, spreadsheet, spreadsheet_to_add) -> io.NodeOutput:
f = StringIO(spreadsheet)
reader = csv.reader(f, delimiter=',')
headerRow = next(reader)
from_rows = list(reader)
S2VComfy.fill_empty_columns(headerRow, from_rows)
f = StringIO(spreadsheet_to_add)
reader = csv.reader(f, delimiter=',')
output = StringIO()
writer = csv.writer(output)
addHeaderRow = next(reader)
add_rows = list(reader)
writer.writerow(headerRow + addHeaderRow)
add_row_upto = 0
for from_row in from_rows:
add_row_upto = add_row_upto % len(add_rows)
add_row = add_rows[add_row_upto]
writer.writerow(from_row + add_row)
add_row_upto += 1
return io.NodeOutput(
output.getvalue()
)
class Spreadsheet2VideoMultiplySpreadsheet(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="Spreadsheet2VideoMultiplySpreadsheet",
display_name="Spreadsheet2Video Multiply Spreadsheet",
category="Spreadsheet2Video",
description="Adds columns to a spreadsheet. Duplicating it by the spreadsheet_to_add",
search_aliases=["spreadsheet", "loop", "multiply", "load", "column"],
inputs=[
io.String.Input("spreadsheet", multiline=True, tooltip="Comma separated values with a header row. Can use Spreadsheet2Video Load Spreadsheet node."),
io.String.Input("spreadsheet_to_add", multiline=True, tooltip="Comma separated values with a header row. The columns to add"),
],
outputs=[
io.String.Output(),
],
)
@classmethod
def execute(cls, spreadsheet, spreadsheet_to_add) -> io.NodeOutput:
f = StringIO(spreadsheet)
reader = csv.reader(f, delimiter=',')
headerRow = next(reader)
from_rows = list(reader)
S2VComfy.fill_empty_columns(headerRow, from_rows)
f = StringIO(spreadsheet_to_add)
reader = csv.reader(f, delimiter=',')
output = StringIO()
writer = csv.writer(output)
addHeaderRow = next(reader)
writer.writerow(headerRow + addHeaderRow)
for row_to_add in reader:
for from_row in from_rows:
writer.writerow(from_row + row_to_add)
# writer.close()
return io.NodeOutput(
output.getvalue()
)
class Spreadsheet2VideoLoadText(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
input_path = Path(folder_paths.get_input_directory())
input_dirs = [
"",
"csv"
]
options = []
for input_dir in input_dirs:
input_full_dir = input_path / input_dir
if input_full_dir.is_dir():
input_short_dir = Path(input_dir)
for d in input_full_dir.iterdir():
full_path = input_full_dir / d.name
if full_path.is_file():
options.append(str(input_short_dir / d.name))
# files = folder_paths.filter_files_content_types(files, ["text", "application"])
return io.Schema(
node_id="Spreadsheet2VideoLoadText",
display_name="Spreadsheet2Video Load Spreadsheet",
category="Spreadsheet2Video",
description="Loads spreadsheet or .csv into text for input into main Spreadsheet2Video node",
search_aliases=["spreadsheet", "loop", "output", "load", "csv", "xlsx", "ods"],
inputs=[
io.Combo.Input("text_file", options=options),
io.String.Input("sheet_name", default="", tooltip="Leave blank for first sheet", optional=True),
],
outputs=[
io.String.Output(),
],
)
@classmethod
def ods_to_csv(cls, ods_path: str, sheet_name):
"""Convert all sheets in an ODS file to separate CSV files."""
ods_path = Path(ods_path)
# Read all sheets from the ODS file
df = pd.read_excel(ods_path, sheet_name)
buffer = StringIO()
df.to_csv(buffer, index=False)
return io.NodeOutput(
buffer.getvalue()
)
@classmethod
def execute(cls, text_file, sheet_name=None) -> io.NodeOutput:
input_dir = folder_paths.get_input_directory()
text_file_path = Path(input_dir) / text_file
if (text_file_path.suffix == '.csv'
or text_file_path.suffix == '.csv'
):
return io.NodeOutput(
text_file_path.read_text(encoding='utf-8')
)
else:
if sheet_name == "" or sheet_name is None:
sheet_name = 0
return cls.ods_to_csv(str(text_file_path), sheet_name)
@classmethod
def fingerprint_inputs(s, text_file, sheet_name=None):
input_dir = folder_paths.get_input_directory()
text_file_path = Path(input_dir) / text_file
st=Path(text_file_path).stat()
stat_str = "\n".join([
text_file,
f"{st.st_size}",
f"{st.st_mtime}",
str(sheet_name)
])
# Hash it
return hashlib.md5(stat_str.encode()).hexdigest()
class Spreadsheet2VideoNode(io.ComfyNode):
"""
An example node
Class methods
-------------
define_schema (io.Schema):
Tell the main program the metadata, input, output parameters of nodes.
fingerprint_inputs:
optional method to control when the node is re executed.
check_lazy_status:
optional method to control list of input names that need to be evaluated.
"""
blank_image_shape=[1,1,1,1]
@classmethod
def define_schema(cls) -> io.Schema:
"""
Return a schema which contains all information about the node.
Some types: "Model", "Vae", "Clip", "Conditioning", "Latent", "Image", "Int", "String", "Float", "Combo".
For outputs the "io.Model.Output" should be used, for inputs the "io.Model.Input" can be used.
The type can be a "Combo" - this will be a list for selection.
"""
return io.Schema(
node_id="Spreadsheet2Video",
display_name="Spreadsheet2Video",
category="Spreadsheet2Video",
search_aliases=["spreadsheet", "loop", "output"],
inputs=[
io.String.Input("spreadsheet", multiline=True, tooltip="Comma separated values with a header row. Can use Spreadsheet2Video Load Spreadsheet node."),
io.Image.Input("first_image", optional=True, tooltip="First image to send to the S2VInputImage node. Use EmptyImage if you don't need it"),
],
outputs=[
io.Image.Output(),
io.Audio.Output(),
],
hidden=[io.Hidden.unique_id, io.Hidden.dynprompt, io.Hidden.prompt],
enable_expand=True,
is_output_node=True,
)
@classmethod
def check_lazy_status(cls, spreadsheet="", first_image=None):
return []
@classmethod
def execute(cls, spreadsheet="", first_image=None) -> io.NodeOutput:
global last_processed_time
last_processed_time = None
graph = GraphBuilder()
# Use StringIO to treat the string as a file
f = StringIO(spreadsheet)
by_group_name = GroupInfo.restore_group_infos_from_prompt(cls.hidden.prompt)
if by_group_name is None:
by_group_name = MapOutputs().map_outputs(cls.hidden.prompt)
used_groups = {}
reader = csv.reader(f, delimiter=',')
headerRow = next(reader) # skip header
processImageNode = graph.node("Spreadsheet2VideoProcessImage",
previous = 1, images = first_image)
lastProcessImageNode = processImageNode
rows_done = 0
for row in reader:
if(len(headerRow) < len(row)):
logging.warning(
"S2V: csv header is not long enough for row: header:{}, row:{}, {}".format(
len(headerRow), len(row), " , ".join(row)
))
previous_image_output = processImageNode.out(1)
name = row[0].strip()
if name == "":
logging.error(f"S2V: No : {row}")
continue
if name not in by_group_name:
raise ValueError(f"S2V: Could not find Spreadsheet2VideoInputImage node with name: {name}")
continue
group_info = by_group_name[name]
used_groups[name] = True
group_info.copy_nodes(cls.hidden.prompt, graph)
processImageNode = group_info.change_out_node(graph)
if processImageNode is None:
processImageNode = graph.node('Spreadsheet2VideoProcessImage')
if previous_image_output is not None:
link_to_input = previous_image_output
else:
link_to_input = first_image
if lastProcessImageNode:
processImageNode.set_input(
"previous",
lastProcessImageNode.out(0)
)
lastProcessImageNode = processImageNode
# foundImageInput =
group_info.link_to_new_input_node(
graph,
link_to_input,
row
)
#if not foundImageInput:
# logging.warning(f"S2V: No image input node, ignore if this is a non image workflow, column1: {name}")
previous_image_output = group_info.output_image_link
rows_done += 1
if rows_done == 0:
logging.error("S2V: No rows done, did you put in a header row?")
finalVideoNode = graph.node(
"Spreadsheet2VideoFinalVideo",
previous = lastProcessImageNode.out(0)
)
# Disabled: If we make the original nodes that are not in the loop DoNothing
# Some workflows won't work with this.
# for group_name in used_groups:
# used_group = by_group_name[group_name]
# used_group.replace_with_do_nothing(cls.hidden.prompt)
return io.NodeOutput(
finalVideoNode.out(0),
finalVideoNode.out(1),
expand=graph.finalize(),
)
"""
The node will always be re executed if any of the inputs change but
this method can be used to force the node to execute again even when the inputs don't change.
You can make this node return a number or a string. This value will be compared to the one returned the last time the node was
executed, if it is different the node will be executed again.
This method is used in the core repo for the LoadImage node where they return the image hash as a string, if the image hash
changes between executions the LoadImage node is executed again.
"""
@classmethod
def fingerprint_inputs(s, spreadsheet="", first_image=None):
# always run because the other nodes are not a part of this execution pth
return str(random.random())
def prompt_handler(json_data):
try:
prompt = json_data["prompt"]
map_outputs = MapOutputs()
by_group_name = map_outputs.map_outputs(prompt)
# remove
used_by_other_nodes = map_outputs.nodes_used_by_other_nodes(prompt, by_group_name)
GroupInfo.remove_nodes_in_group_infos(prompt, by_group_name, used_by_other_nodes)
except Exception:
logging.exception("Problem with prompt")
return json_data
return json_data
PromptServer.instance.add_on_prompt_handler(prompt_handler)