1131 lines
41 KiB
Python
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)
|
|
|