Add several new nodes:

- DecodeTranscriptNode to parse transcript text
- YoutubeUploadNode to upload videos to YouTube
- Audio metadata and file list nodes
- Image stack blending and stripping alpha channel
- Sampler dataset creation node
- Utils for deep copying tensors and arrays

Also includes:

- Testing for new utils module
- Git nodes marked with base class
- Renamed nodes.py to avoid conflicts
- Import refactoring to handle naming

Specifics:

- descript.py: New transcript parsing node
- youtube_nodes.py: New YouTube upload node
- audio.py: New audio metadata and file list nodes
- utils.py: Deep copy utility functions
- image.py: New image stack blending and alpha stripping
- tests/test_utils.py: Tests for new utils
- git.py: Marked git nodes with base class
- __init__.py: Refactored imports and mappings
- requirements.txt: Added YouTube dependencies
- functional.py: Minor tweaks
- modules/youtube.py: Helper for upload node
- video.py: Minor cleanup
- tests/test_youtube_nodes.py: Test for YouTube upload
This commit is contained in:
Tasha Upchurch
2023-08-05 15:19:21 -04:00
parent 4b55e7203b
commit 57e514b0b6
13 changed files with 1029 additions and 135 deletions
+22 -24
View File
@@ -29,7 +29,7 @@ OLD_NODE_CLASS_MAPPINGS = {
OLD_NODE_CLASS_MAPPINGS = {v: k for k, v in OLD_NODE_CLASS_MAPPINGS.items()}
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
# Try importing optional dependencies
@@ -44,35 +44,33 @@ else: # if not a unit test
print("ETK> Failed to import comfy.samplers, assuming no comfyui skipping SD related nodes")
# Load classes from modules in the custom_nodes.EternalKernelLiteGraphNodes package
package_dir = os.path.dirname(os.path.abspath(__file__))
package_name = "custom_nodes.EternalKernelLiteGraphNodes"
for module_file in os.listdir(package_dir):
if module_file.endswith('.py') and not module_file.startswith('__'):
module_name = package_name + '.' + module_file[:-3] # Removing .py extension
try:
# import directly from the file
full_path = os.path.join(package_dir, module_file)
with open(full_path, 'r') as f:
code = f.read()
exec(code, globals(), locals())
for class_name in dir():
class_obj = locals()[class_name]
if isinstance(class_obj, type) and hasattr(class_obj, 'INPUT_TYPES'):
NODE_CLASS_MAPPINGS[class_name] = class_obj
folder = os.path.dirname(os.path.abspath(__file__))
# module = importlib.import_module(module_name)
# for class_name in dir(module):
# class_obj = getattr(module, class_name)
# if isinstance(class_obj, type) and hasattr(class_obj, 'INPUT_TYPES'): # Checking if it's a class and has INPUT_TYPES attribute
# NODE_CLASS_MAPPINGS[class_name] = class_obj
for file in os.listdir(folder):
if file == "nodes.py":
raise ValueError("nodes.py is not an allowed name in this folder")
if file.endswith(".py") and file[0] != "_":
# import the NODE_CLASS_MAPPINGS from the file
try:
module = importlib.import_module(f".{file[:-3]}", __name__)
except ImportError:
if not use_generic_nodes:
print(f"Failed to import {module_name}, it may require comfy.samplers. Skipping...")
try:
module = importlib.import_module(f"{file[:-3]}") # needed for testing?
except ModuleNotFoundError:
module = importlib.import_module(f".{file[:-3]}", __name__)
if hasattr(module, "NODE_CLASS_MAPPINGS"):
mappings = module.NODE_CLASS_MAPPINGS
NODE_CLASS_MAPPINGS.update(mappings)
print(f"ETK: updated with {mappings}")
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS"):
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
# use OLD_NODE_CLASS_MAPPINGS to add the old names to the new nodes
new_node_class_mappings = {}
for k, v in OLD_NODE_CLASS_MAPPINGS.items():
if NODE_CLASS_MAPPINGS[k]:
if k in NODE_CLASS_MAPPINGS:
new_node_class_mappings[v] = NODE_CLASS_MAPPINGS[k]
# mark it for removeal
NODE_CLASS_MAPPINGS[k] = None
+73
View File
@@ -0,0 +1,73 @@
import copy
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def ETK_audio_base(cls):
cls.CATEGORY = "ETK/audio"
NODE_CLASS_MAPPINGS[cls.__name__] = cls
return cls
@ETK_audio_base
class GetAudioMetaData:
""" Returns a dictionary of audio metadata """
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"path": ("STRING", {"multiline": False, "default": ""}),
}
}
RETURN_TYPES = ("DICT",)
FUNCTION = 'get_audio_metadata'
CATEGORY = 'ETK/audio'
def get_audio_metadata(self, **kwargs):
import audioread
kwargs = copy.deepcopy(kwargs)
path = kwargs.get('path')
metadata = {}
with audioread.audio_open(path) as f:
metadata['duration'] = f.duration
metadata['samplerate'] = f.samplerate
metadata['channels'] = f.channels
return (metadata,)
@ETK_audio_base
class AudioFileListFromPathPattern:
""" Returns a list of audio files from a path pattern """
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"path": ("STRING", {"multiline": False, "default": ""}),
}
}
RETURN_TYPES = ("LIST", "LIST", "LIST",)
RETURN_NAMES = ("list_of_fpaths", "list_of_bytes", "list_of_metadata")
FUNCTION = 'get_audio_file_list_from_path_pattern'
CATEGORY = 'ETK/audio'
def get_audio_file_list_from_path_pattern(self, **kwargs):
import glob
kwargs = copy.deepcopy(kwargs)
path = kwargs.get('path')
l_fpaths = glob.glob(path)
l_bytes = []
l_metadata = []
for fpath in l_fpaths:
with open(fpath, "rb") as f:
l_bytes.append(f.read())
l_metadata.append(GetAudioMetaData().get_audio_metadata(path=fpath)[0])
return (l_fpaths, l_bytes, l_metadata,)
+18 -19
View File
@@ -1,6 +1,7 @@
import copy
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def ETK_descript_base(cls):
@@ -9,6 +10,7 @@ def ETK_descript_base(cls):
NODE_CLASS_MAPPINGS[cls.__name__] = cls
return cls
@ETK_descript_base
class DecodeTranscriptNode:
"""
@@ -22,13 +24,13 @@ class DecodeTranscriptNode:
return {
"required": {
"string": ("STRING", {"multiline": True,
"default": ""
}),
"default": ""
}),
}
}
RETURN_TYPES = ("LIST","LIST","LIST")
RETURN_NAMES = ("speakers","durations","paragraphs")
RETURN_TYPES = ("LIST", "LIST", "LIST")
RETURN_NAMES = ("speakers", "durations", "paragraphs")
OUTPUT_NODE = True
def func(self, string):
@@ -51,23 +53,23 @@ class DecodeTranscriptNode:
continue
# first find the first ] and split on that
part1 = para.find("]")
time_part = para[:part1+1]
remainder = para[part1+1:]
time_part = para[:part1 + 1]
remainder = para[part1 + 1:]
# now find first : in the remainder
part2 = remainder.find(":")
speaker_part = remainder[:part2+1].replace(":","").strip()
paragraph_part = remainder[part2+1:]
speaker_part = remainder[:part2 + 1].replace(":", "").strip()
paragraph_part = remainder[part2 + 1:]
speakers.append(speaker_part.strip())
# now just remove [ and ] from time_part
time_part = time_part.replace("[","").replace("]","").strip()
time_part = time_part.replace("[", "").replace("]", "").strip()
#split it on :
# split it on :
time_part = time_part.split(":")
# now convert to seconds
time_part = int(time_part[0])*60*60 + int(time_part[1])*60 + int(time_part[2])
time_part = int(time_part[0]) * 60 * 60 + int(time_part[1]) * 60 + int(time_part[2])
# save here for post-processing later
durations.append(time_part)
@@ -76,13 +78,10 @@ class DecodeTranscriptNode:
import copy
start_seconds = copy.copy(durations)
durations=[]
for i in range(len(start_seconds)-1):
durations.append(start_seconds[i+1] - start_seconds[i])
durations = []
for i in range(len(start_seconds) - 1):
durations.append(start_seconds[i + 1] - start_seconds[i])
# now append the last value
durations.append(int(sum(durations)/len(durations)))
return (speakers,durations,paragraphs,)
durations.append(int(sum(durations) / len(durations)))
return (speakers, durations, paragraphs,)
+46
View File
@@ -1,9 +1,12 @@
import hashlib
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
_list = {"list": ("LIST",)}
_func = {"func": ("FUNC", {"default": None})}
_func_extra = {"func_extra": ("FUNC",)}
_text = {"code": ("STRING", {"multiline": True, "default": "y=x()"})}
_image = {"image": ("IMAGE",)}
_string = {"string": ("STRING", {"default": None})}
optional = lambda x: {"optional": {k: v for k, v in x.items()}}
@@ -12,6 +15,14 @@ both = lambda a, b: {**a, **b}
many = lambda *dicts: {k: v for d in dicts for k, v in d.items()} # non tested
def ETK_functional_base(cls):
cls.FUNCTION = "func"
cls.CATEGORY = "ETK/functional"
NODE_CLASS_MAPPINGS[cls.__name__] = cls
return cls
@ETK_functional_base
class FuncBase:
"""
Base class for func nodes
@@ -68,6 +79,7 @@ class FuncBase:
return (self.func,)
@ETK_functional_base
class FuncRender:
"""
as input takes a function and a string of code
@@ -122,6 +134,7 @@ class FuncRender:
return (locals["y_float"], locals["y_string"], locals["y_int"], locals["y_list"],)
@ETK_functional_base
class FuncRenderImage:
def __init__(self):
from custom_nodes.EternalKernelLiteGraphNodes.image import common_ksampler, TinyTxtToImg
@@ -157,6 +170,7 @@ class FuncRenderImage:
return (locals["y"],)
@ETK_functional_base
class ExecWidget:
import torch
@@ -271,6 +285,7 @@ class ExecWidget:
return m.digest().hex()
@ETK_functional_base
class FuncListToList:
"""runs eval on the given text, with a list as input and output"""
@@ -306,6 +321,7 @@ class FuncListToList:
return (my_locals["y_list"],)
@ETK_functional_base
class FuncStrToStr:
"""runs eval on the given text, with a string as input and outputs a string"""
@@ -323,6 +339,7 @@ class FuncStrToStr:
def func(self, **kwargs):
text = kwargs.get("string", None)
x = text
code = kwargs.get("code", None)
my_globals = globals()
my_locals = locals()
@@ -332,6 +349,7 @@ class FuncStrToStr:
return (my_locals["y"],)
@ETK_functional_base
class FuncStrToList:
def __init__(self):
pass
@@ -348,6 +366,7 @@ class FuncStrToList:
def func(self, **kwargs):
text = kwargs.get("string", None)
code = kwargs.get("code", None)
x = text
my_globals = globals()
my_locals = locals()
@@ -356,6 +375,33 @@ class FuncStrToList:
return (my_locals["y_list"],)
@ETK_functional_base
class FuncListToStr:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
req = required(both(_list, _text))
return req
CATEGORY = "ETK/func"
RETURN_TYPES = ("STRING",)
FUNCTION = "func"
def func(self, **kwargs):
text = kwargs.get("list", None)
code = kwargs.get("code", None)
x = text
my_globals = globals()
my_locals = locals()
exec(code, my_globals, my_locals)
return (my_locals["y"],)
@ETK_functional_base
class GetFirstCodeBlock():
""" returns the first code block in the text which is surrounded with ``` or
```python or
+17 -2
View File
@@ -1,10 +1,20 @@
from git import Repo, InvalidGitRepositoryError
import os
import copy
import re
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def ETK_git_base(cls):
cls.FUNCTION = "func"
cls.CATEGORY = "ETK/git"
NODE_CLASS_MAPPINGS[cls.__name__] = cls
return cls
@ETK_git_base
class PublicGitRepo:
@classmethod
def INPUT_TYPES(cls):
@@ -33,6 +43,7 @@ def validate_git_repo_url(self, **kwargs):
return match is not None and match.group() == repo_url
@ETK_git_base
class CloneRepoNode:
@classmethod
def INPUT_TYPES(cls):
@@ -45,7 +56,7 @@ class CloneRepoNode:
}
RETURN_TYPES = ("STRING", "STRING", "LIST",)
RETURN_NAMES = ("path_passthrough","file_structure", "file_list",)
RETURN_NAMES = ("path_passthrough", "file_structure", "file_list",)
FUNCTION = 'clone_repo'
CATEGORY = 'ETK/git'
@@ -61,6 +72,7 @@ class CloneRepoNode:
return ("".join(ld), ld,)
@ETK_git_base
class PullRepoNode:
@classmethod
def INPUT_TYPES(cls):
@@ -89,6 +101,7 @@ def pull_repo(self, **kwargs):
# origin.pull()
@ETK_git_base
class PushRepoNode:
@classmethod
def INPUT_TYPES(cls):
@@ -114,6 +127,7 @@ def push_repo(self, **kwargs):
origin.push()
@ETK_git_base
class SendCommitNode:
@classmethod
def INPUT_TYPES(cls):
@@ -139,6 +153,7 @@ def send_commit(self, **kwargs):
repo.index.commit(commit_message)
@ETK_git_base
class CreateRepoNode:
@classmethod
def INPUT_TYPES(cls):
+359 -69
View File
@@ -1,5 +1,10 @@
import os
try:
from utils import etk_deep_copy as deepcopy
except ImportError as e:
from custom_nodes.EternalKernelLiteGraphNodes.utils import etk_deep_copy as deepcopy
testing = os.environ.get("ETERNAL_KERNEL_LITEGRAPH_NODES_TEST", None)
if testing == "True":
testing = True
@@ -55,6 +60,15 @@ all image inputs are (B,W,H,C)
avoid numpy and PIL as much as possible
"""
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def ETK_image_base(cls):
# cls.FUNCTION = "func"
cls.CATEGORY = "ETK/Image"
NODE_CLASS_MAPPINGS[cls.__name__] = cls
return cls
def get_fonts():
@@ -137,24 +151,26 @@ def torch_image_show(image):
# try to rearrange the channels using np
image = np.moveaxis(image, 0, -1)
#image.permute(1, 2, 0)
# image.permute(1, 2, 0)
image = Image.fromarray(image) # Create a PIL image
image.show()
@ETK_image_base
class TinyTxtToImg:
"""small text to image generator"""
share_clip = None
share_mdl = None
share_vae = None
def __init__(self):
print("ETK> TinyTxtToImg init")
import random
self.mdl = None
self.clp = None
self.vae = None
self.vision = None
self.seed = random.randint(0, 2 ** 32 - 1)
self.steps = 10
self.cfg = 8
self.sampler_name = comfy.samplers.KSampler.SAMPLERS[0]
@@ -168,7 +184,6 @@ class TinyTxtToImg:
self.latent_image = None
self.samples = None
@classmethod
def INPUT_TYPES(cls):
return {"required":
@@ -179,7 +194,7 @@ class TinyTxtToImg:
"token_normalization": (["none", "mean", "length", "length+mean"],),
"weight_interpretation": (["comfy", "A1111", "compel", "comfy++"],),
"ckpt_name": (folder_paths.get_filename_list("checkpoints"),),
"one_seed": ([True, False], {"default": False}),
},
"optional": {
@@ -194,29 +209,44 @@ class TinyTxtToImg:
CATEGORY = "ETK"
RETURN_TYPES = ("IMAGE", "FUNC",)
RETURN_TYPES = ("IMAGE", "FUNC", "LATENT", "CONDITIONING", "CONDITIONING",)
RETURN_NAMES = ("image", "FUNC", "latent", "positive", "negative",)
FUNCTION = "tinytxt2img"
def tinytxt2img(self, prompt, neg_prompt, name="tinytxt2img", overrides: str = "",
clip_encoder="comfy -ignore below",
token_normalization="length+mean",
weight_interpretation="comfy++",
FUNC: callable = None,
ckpt_name="v1-5-pruned-emaonly.safetensors",
):
def tinytxt2img(self, **kwargs):
""" use the imports from nodes to generate an image from text """
import random
import json
if not hasattr( TinyTxtToImg, "share_mdl"):
# kwargs = deepcopy(kwargs)
prompt = kwargs.get("prompt", "")
neg_prompt = kwargs.get("neg_prompt", "")
clip_encoder = kwargs.get("clip_encoder", "comfy -ignore below")
token_normalization = kwargs.get("token_normalization", "length+mean")
weight_interpretation = kwargs.get("weight_interpretation", "comfy++")
ckpt_name = kwargs.get("ckpt_name", "v1-5-pruned-emaonly.safetensors")
overrides = kwargs.get("overrides", "")
name = kwargs.get("name", "tinytxt2img")
FUNC = kwargs.get("FUNC", None)
self.latent_image = kwargs.get("latent", None)
self.pos_cond = kwargs.get("pos_cond", None)
self.neg_cond = kwargs.get("neg_cond", None)
self.seed = kwargs.get("seed", random.randint(0, 2 ** 32 - 1))
self.one_seed_per_batch = kwargs.get("one_seed", False)
self.denoise = kwargs.get("denoise", 1.0)
if not hasattr(TinyTxtToImg, "share_mdl"):
TinyTxtToImg.share_mdl = None
TinyTxtToImg.share_mdl_ckpt_name = None
if TinyTxtToImg.share_mdl is None:
self.mdl, self.clp, self.vae, self.vision = \
CheckpointLoaderSimple.load_checkpoint(None,
ckpt_name=ckpt_name
)
TinyTxtToImg.share_mdl_ckpt_name = ckpt_name
CSL = TinyTxtToImg
CSL.share_mdl = self.mdl
CSL.share_clip = self.clp
@@ -224,12 +254,25 @@ class TinyTxtToImg:
CSL.share_vision = self.vision
else:
self.mdl, self.clp, self.vae = (TinyTxtToImg.share_mdl,
TinyTxtToImg.share_clip,
TinyTxtToImg.share_vae
)
# check if the checkpoint is the same
if TinyTxtToImg.share_mdl_ckpt_name != ckpt_name:
self.mdl, self.clp, self.vae, self.vision = \
CheckpointLoaderSimple.load_checkpoint(None,
ckpt_name=ckpt_name
)
TinyTxtToImg.share_mdl_ckpt_name = ckpt_name
CSL = TinyTxtToImg
CSL.share_mdl = self.mdl
CSL.share_clip = self.clp
CSL.share_vae = self.vae
CSL.share_vision = self.vision
else:
self.mdl, self.clp, self.vae = (TinyTxtToImg.share_mdl,
TinyTxtToImg.share_clip,
TinyTxtToImg.share_vae
)
self.seed = random.randint(0, 2 ** 32 - 1)
self.steps = 10
self.cfg = 8
self.sampler_name = comfy.samplers.KSampler.SAMPLERS[0]
@@ -239,8 +282,8 @@ class TinyTxtToImg:
self.width = 512
self.height = 512
self.batch_size = 1
self.denoise = 1.0
self.one_seed_per_batch = False
if not self.denoise:
self.denoise = 1.0
if overrides:
if overrides != "":
@@ -256,45 +299,61 @@ class TinyTxtToImg:
## TODO: probably need to prepare the execution better to make sure that applying the incomming func can change everything
if FUNC is not None:
self.__dict__.update(FUNC(self.__dict__))
if clip_encoder == "comfy -ignore below":
self.pos_cond = CLIPTextEncode.encode(None, self.clp, self.positive)[0]
self.neg_cond = CLIPTextEncode.encode(None, self.clp, self.negative)[0]
elif clip_encoder == "advanced":
try:
self.pos_cond = CLIPTextEncodeAdvanced.encode(None,
self.clp,
self.positive,
token_normalization,
weight_interpretation)[0]
self.neg_cond = CLIPTextEncodeAdvanced.encode(None,
self.clp,
self.negative,
token_normalization,
weight_interpretation)[0]
except Exception as e:
print(e)
raise ValueError("advanced clip encoder failed")
if not self.pos_cond:
if clip_encoder == "comfy -ignore below":
self.clp_encode = lambda x: CLIPTextEncode.encode(None, self.clp, x)[0]
self.pos_cond = self.clp_encode(self.positive)
self.neg_cond = self.clp_encode(self.negative)
self.latent_image = EmptyLatentImage.generate(None, self.width, self.height, self.batch_size)[0]
elif clip_encoder == "advanced":
self.clp_encode = lambda x: CLIPTextEncodeAdvanced.encode(None,
self.clp,
x,
token_normalization,
weight_interpretation)[0]
try:
self.pos_cond = self.clp_encode(self.positive)
self.neg_cond = self.clp_encode(self.negative)
samples = KSampler.sample(None,
self.mdl,
self.seed, self.steps, self.cfg, self.sampler_name, self.scheduler,
self.pos_cond, self.neg_cond,
self.latent_image,
denoise=self.denoise,
one_seed_per_batch=self.one_seed_per_batch)[0]
except Exception as e:
print(e)
raise ValueError("advanced clip encoder failed")
image = VAEDecode.decode(None, self.vae, samples)[0]
if not self.latent_image:
self.latent_image = EmptyLatentImage.generate(None, self.width, self.height, self.batch_size)[0]
self.KSampler = KSampler()
self.samples = self.KSampler.sample(
model=self.mdl,
seed=self.seed,
steps=self.steps,
cfg=self.cfg,
sampler_name=self.sampler_name,
scheduler=self.scheduler,
latent_image=self.latent_image,
pos_cond=self.pos_cond,
neg_cond=self.neg_cond,
positive=self.pos_cond,
negative=self.neg_cond,
denoise=self.denoise,
one_seed_per_batch=self.one_seed_per_batch)[0]
# self.vae.offload_device = torch.device("cuda")
image = VAEDecode.decode(None, self.vae, self.samples)[0]
image = image.detach().cpu()
self.FUNC = self.tinytxt2img
self.ARGS = (prompt, neg_prompt, name, overrides, clip_encoder, token_normalization, weight_interpretation, FUNC)
return (image,
self
,)
self.ARGS = kwargs
ret = (image,
self,
self.samples,
self.pos_cond,
self.neg_cond
,)
# ret = deepcopy(ret)
return ret
@ETK_image_base
class PreviewImageTest(SaveImage):
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
@@ -327,8 +386,7 @@ class PreviewImageTest(SaveImage):
return my_ret
@ETK_image_base
class PromptTemplate:
"""replaces the text in the given text string with a given other text at some key positions"""
@@ -354,6 +412,7 @@ class PromptTemplate:
return (text.replace(key1, replacement),)
@ETK_image_base
class LoadImage:
@classmethod
def INPUT_TYPES(s):
@@ -452,6 +511,7 @@ class LoadImage:
return m.digest().hex()
@ETK_image_base
class SelectFromRGBSimilarity:
"""
selects pixel positions based on similarity to a given color
@@ -556,6 +616,7 @@ class SelectFromRGBSimilarity:
return (out,)
@ETK_image_base
class Quantize:
def __init__(self):
pass
@@ -626,6 +687,7 @@ class Quantize:
return (result,)
@ETK_image_base
class select_from_batch:
"""selects a single image from a batch"""
@@ -686,6 +748,7 @@ class select_from_batch:
return (oimg, olat,)
@ETK_image_base
class ImageDistanceMask:
def __init__(self):
pass
@@ -773,6 +836,7 @@ class ImageDistanceMask:
return (dist,)
@ETK_image_base
class rgba_lower_clip:
"""Clip RGBA values below a threshold"""
@@ -844,6 +908,65 @@ class rgba_lower_clip:
return (image,)
@ETK_image_base
class ImageStackBlendByMask:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"stack a": ("IMAGE",),
"stack b": ("IMAGE",),
"img mask": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE", "IMAGE",)
RETURN_NAMES = ("image", "image rgba")
FUNCTION = "stack_mask"
CATEGORY = "ETK/Image"
def stack_mask(self, **kwargs):
import torch
# use torch to blend two images by a mask
# stacks are already (B,W,H,C)
stack_a = kwargs["stack a"]
stack_b = kwargs["stack b"]
img_mask = kwargs["img mask"]
# check if they all have alpha
if stack_a.shape[3] == 3:
alpha = torch.ones_like(stack_a[:, :, :, 0])
stack_a = torch.cat((stack_a, alpha.unsqueeze(3)), dim=3)
if stack_b.shape[3] == 3:
alpha = torch.ones_like(stack_b[:, :, :, 0])
stack_b = torch.cat((stack_b, alpha.unsqueeze(3)), dim=3)
# allocate new image
new_image = torch.zeros_like(stack_a)
# blend rgb channels
for i in range(3):
new_image[:, :, :, i] = \
(stack_a[:, :, :, i] * img_mask[:, :, :, 0]) \
+ \
(stack_b[:, :, :, i] * (1 - img_mask[:, :, :, 0]))
# alpha should be the mean of the two alphas
new_image[:, :, :, 3] = (stack_a[:, :, :, 3] + stack_b[:, :, :, 3]) / 2
rgba = new_image
rgb = new_image[:, :, :, 0:3]
return (rgb, rgba,)
@ETK_image_base
class rgba_upper_clip:
"""Clip RGBA values above a threshold"""
@@ -915,6 +1038,7 @@ class rgba_upper_clip:
return (image,)
@ETK_image_base
class RGBA_MOD:
"""provide sliders to modify RGBA values"""
@@ -975,6 +1099,7 @@ class RGBA_MOD:
return (image,)
@ETK_image_base
class ImageBC:
"""Normalize an images brightness,droping lower and higher values of n perctile"""
@@ -1046,6 +1171,7 @@ class ImageBC:
return (image,)
@ETK_image_base
class PadToMatch:
""" a node that will pad the smaller of two images to match the size of the larger one """
@@ -1124,6 +1250,7 @@ class PadToMatch:
return (padded1, padded2)
@ETK_image_base
class StackImages:
""" a node that will stack two images on top of each other """
@@ -1139,6 +1266,9 @@ class StackImages:
"optional": {
"image2": ("IMAGE",),
"number_of_images": ("INT", {"default": 2, "min": 2, "max": 100}),
# an optionbox with "all" or "upto", model after "one seed per batch" in the sampler
"mode": (["all", "upto"], {"default": "all"}),
}, }
RETURN_TYPES = ("IMAGE",)
@@ -1146,7 +1276,7 @@ class StackImages:
CATEGORY = "ETK/image"
def stackme(self, image1, image2=None, number_of_images=2):
def stackme(self, image1, image2=None, number_of_images=2, mode="all"):
"""
stacks two images on top of each other
images are (B, W, H, C) torch tensors
@@ -1159,15 +1289,21 @@ class StackImages:
else:
stacked = torch.cat((image1, image1), dim=0)
if number_of_images > 2:
while stacked.shape[0] < number_of_images:
stacked = torch.cat((stacked, stacked), dim=0)
print(stacked.shape)
out = stacked[:number_of_images, :, :, :]
print(out.shape)
if mode == "all":
if number_of_images > 2:
while stacked.shape[0] < number_of_images:
stacked = torch.cat((stacked, stacked), dim=0)
print(stacked.shape)
out = stacked[:number_of_images, :, :, :]
else:
# stack them all together regardless of the first dimension
out = torch.cat((image1, image2), dim=0)
print(stacked.shape)
return (out,)
@ETK_image_base
class rgba_merge:
"""merges the channels of an RGBA image"""
@@ -1215,6 +1351,7 @@ class rgba_merge:
return (torch.cat((r, g, b, a), dim=3),)
@ETK_image_base
class rgba_split:
"""splits the channels of an RGBA image"""
@@ -1266,6 +1403,73 @@ def get_image_size(image):
return (width * height)
@ETK_image_base
class KSamplerCreateDataset:
""" use KSampler to create a dataset """
@classmethod
def INPUT_TYPES(cls):
inps = KSampler.INPUT_TYPES()
# make sure to set a default
inps["required"]["dataset_name"] = ("STRING", {"default": "dataset"})
return inps
CATEGORY = "ETK/training"
RETURN_TYPES = ("LATENT", "STRING",)
RETURN_NAMES = ("LATENT", "dataset_path",)
FUNCTION = "create_dataset"
def create_dataset(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise, one_seed_per_batch, dataset_name):
"""
creates a dataset using KSampler
:param model:
:param seed:
:param steps:
:param cfg:
:param sampler_name:
:param scheduler:
:param positive:
:param negative:
:param latent_image:
:param denoise:
:param dataset_name:
:return:
"""
# create the sampler
sampler = KSampler()
x = {"model": model, "seed": seed, "steps": steps, "cfg": cfg, "sampler_name": sampler_name,
"scheduler": scheduler, "positive": positive, "negative": negative, "latent_image": latent_image,
"denoise": denoise, "one_seed_per_batch": one_seed_per_batch}
# now save x
# just use torch .save?
import torch
# create a random name
import hashlib
import time
# create a random name
name = hashlib.md5(str(time.time()).encode()).hexdigest()[0:8]
name = dataset_name + "_" + name
o = folder_paths.get_output_directory()
import os
dataset_path = os.path.join(o, name)
torch.save(x, dataset_path + "_x.pt")
results = sampler.sample(**x)
# save the results
torch.save(results[0], dataset_path + "_y.pt")
# return the dataset path
return (results[0], dataset_path,)
@ETK_image_base
class KSampler:
@classmethod
def INPUT_TYPES(s):
@@ -1283,20 +1487,38 @@ class KSampler:
"one_seed_per_batch": ([True, False], {"default": False}),
}}
RETURN_TYPES = ("LATENT",)
RETURN_TYPES = ("LATENT", "FUNC",)
FUNCTION = "sample"
CATEGORY = "sampling"
def sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=1.0,
one_seed_per_batch=False):
try:
return common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise,one_seed_per_batch=one_seed_per_batch)
except TypeError as e:
return common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise)
def sample(self, **kwargs):
kwargs = deepcopy(kwargs)
model = kwargs.get("model", None)
seed = kwargs.get("seed", None)
steps = kwargs.get("steps", None)
cfg = kwargs.get("cfg", None)
sampler_name = kwargs.get("sampler_name", None)
scheduler = kwargs.get("scheduler", None)
positive = kwargs.get("positive", None)
negative = kwargs.get("negative", None)
latent_image = kwargs.get("latent_image", None)
denoise = kwargs.get("denoise", None)
one_seed_per_batch = kwargs.get("one_seed_per_batch", None)
self.FUNC = lambda x: KSampler.sample(self, **x)
self.ARGS = kwargs
try:
ret = common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise, one_seed_per_batch=one_seed_per_batch)
except TypeError as e:
ret = common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise)
ret = (ret[0], self,)
return ret
from PIL import Image, ImageDraw, ImageFont
@@ -1304,6 +1526,7 @@ import torch
import numpy as np
@ETK_image_base
class TextRender:
"""
renders text to an image
@@ -1357,6 +1580,7 @@ class TextRender:
CATEGORY = "ETK/text"
RETURN_TYPES = ("IMAGE", "IMAGE",)
RETURN_NAMES = ("image", "image rgba",)
FUNCTION = "render_text"
@@ -1476,6 +1700,70 @@ class TextRender:
return y_offset
@ETK_image_base
class StripAlphaChannel:
"""
This function strips the alpha channel from the image.
"""
@classmethod
def INPUT_TYPES(cls):
reqd = {"required": {"image": ("IMAGE",)}}
return reqd
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "strip_alpha_channel"
def strip_alpha_channel(self, image):
"""
This function strips the alpha channel from the image.
>>> sac = StripAlphaChannel()
>>> result = sac.strip_alpha_channel(image)
>>> torch_image_show(result[0])
"""
return (deepcopy(image[..., :3]),)
@ETK_image_base
class FuncImageStackToImageStack:
""" runs exec on giving the user the input image stack and the ability to define the output image stack"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
req = {"required": {
"image": ("IMAGE",),
"code": ("STRING", {"multiline": True, "default": "y=x()"})
}}
return req
CATEGORY = "ETK/func"
RETURN_TYPES = ("IMAGE",)
FUNCTION = "func"
def func(self, **kwargs):
func = kwargs.get("func", None)
code = kwargs.get("code", None)
my_globals = globals()
my_locals = locals()
image_x = kwargs.get("image", None)
gs = image_x.mean(dim=3).repeat(1, 1, 1, 3)
my_locals["x"] = func
my_locals["x_image"] = kwargs.get("image", None)
exec(code, my_globals, my_locals)
return (my_locals["y_image"],)
if __name__ == "__main__":
# test TextRender
tr = TextRender()
@@ -1483,8 +1771,10 @@ if __name__ == "__main__":
torch_image_show(result)
print(result.shape)
def main():
pass
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
import httplib2
import os
import random
import sys
import time
from apiclient.discovery import build
from apiclient.errors import HttpError
from apiclient.http import MediaFileUpload
from oauth2client.client import flow_from_clientsecrets
from oauth2client.file import Storage
from oauth2client.tools import argparser, run_flow
# Explicitly tell the underlying HTTP transport library not to retry, since
# we are handling retry logic ourselves.
httplib2.RETRIES = 1
# Maximum number of times to retry before giving up.
MAX_RETRIES = 10
# Always retry when these exceptions are raised.
RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError)
# Always retry when an apiclient.errors.HttpError with one of these status
# codes is raised.
RETRIABLE_STATUS_CODES = [500, 502, 503, 504]
# The CLIENT_SECRETS_FILE variable specifies the name of a file that contains
# the OAuth 2.0 information for this application, including its client_id and
# client_secret. You can acquire an OAuth 2.0 client ID and client secret from
# the Google API Console at
# https://console.cloud.google.com/.
# Please ensure that you have enabled the YouTube Data API for your project.
# For more information about using OAuth2 to access the YouTube Data API, see:
# https://developers.google.com/youtube/v3/guides/authentication
# For more information about the client_secrets.json file format, see:
# https://developers.google.com/api-client-library/python/guide/aaa_client_secrets
try:
import comfy
except ModuleNotFoundError:
print("comfy not found, using fallback")
comfy = lambda: None
comfy.__path__ = lambda: None
comfy.__path__._path = ["."]
comfy_dir = comfy.__path__._path[0]
# the base dir we need is one level down use os.path.dirname to get it
base_dir = os.path.dirname(comfy_dir)
# now we can use os.path.join to get the full path to the file
CLIENT_SECRETS_FILE = os.path.join(base_dir, "google-credentials.json")
# This OAuth 2.0 access scope allows an application to upload files to the
# authenticated user's YouTube channel, but doesn't allow other types of access.
YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload"
YOUTUBE_API_SERVICE_NAME = "youtube"
YOUTUBE_API_VERSION = "v3"
# This variable defines a message to display if the CLIENT_SECRETS_FILE is
# missing.
MISSING_CLIENT_SECRETS_MESSAGE = """
WARNING: Please configure OAuth 2.0
To make this sample run you will need to populate the client_secrets.json file
found at:
%s
with information from the API Console
https://console.cloud.google.com/
For more information about the client_secrets.json file format, please visit:
https://developers.google.com/api-client-library/python/guide/aaa_client_secrets
""" % os.path.abspath(os.path.join(os.path.dirname(__file__),
CLIENT_SECRETS_FILE))
VALID_PRIVACY_STATUSES = ("public", "private", "unlisted")
def get_authenticated_service(args):
flow = flow_from_clientsecrets(CLIENT_SECRETS_FILE,
scope=YOUTUBE_UPLOAD_SCOPE,
message=MISSING_CLIENT_SECRETS_MESSAGE)
storage = Storage("%s-oauth2.json" % sys.argv[0])
credentials = storage.get()
if credentials is None or credentials.invalid:
credentials = run_flow(flow, storage, args)
return build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION,
http=credentials.authorize(httplib2.Http()))
def initialize_upload(youtube, options):
tags = None
if options.keywords:
tags = options.keywords.split(",")
body = dict(
snippet=dict(
title=options.title,
description=options.description,
tags=tags,
categoryId=options.category
),
status=dict(
privacyStatus=options.privacyStatus
)
)
# Call the API's videos.insert method to create and upload the video.
insert_request = youtube.videos().insert(
part=",".join(body.keys()),
body=body,
# The chunksize parameter specifies the size of each chunk of data, in
# bytes, that will be uploaded at a time. Set a higher value for
# reliable connections as fewer chunks lead to faster uploads. Set a lower
# value for better recovery on less reliable connections.
#
# Setting "chunksize" equal to -1 in the code below means that the entire
# file will be uploaded in a single HTTP request. (If the upload fails,
# it will still be retried where it left off.) This is usually a best
# practice, but if you're using Python older than 2.6 or if you're
# running on App Engine, you should set the chunksize to something like
# 1024 * 1024 (1 megabyte).
media_body=MediaFileUpload(options.file, chunksize=-1, resumable=True)
)
resumable_upload(insert_request)
# This method implements an exponential backoff strategy to resume a
# failed upload.
def resumable_upload(insert_request):
response = None
error = None
retry = 0
while response is None:
try:
print("Uploading file...")
status, response = insert_request.next_chunk()
if response is not None:
if 'id' in response:
print("Video id '%s' was successfully uploaded." %
response['id'])
else:
exit("The upload failed with an unexpected response: %s" % response)
except HttpError as e:
if e.resp.status in RETRIABLE_STATUS_CODES:
error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status,
e.content)
else:
raise
except RETRIABLE_EXCEPTIONS as e:
error = "A retriable error occurred: %s" % e
if error is not None:
print(error)
retry += 1
if retry > MAX_RETRIES:
exit("No longer attempting to retry.")
max_sleep = 2 ** retry
sleep_seconds = random.random() * max_sleep
print("Sleeping %f seconds and then retrying..." % sleep_seconds)
time.sleep(sleep_seconds)
if __name__ == '__main__':
argparser.add_argument("--file", required=True,
help="Video file to upload")
argparser.add_argument("--title", help="Video title", default="Test Title")
argparser.add_argument("--description", help="Video description",
default="Test Description")
argparser.add_argument("--category", default="22",
help="Numeric video category. " +
"See https://developers.google.com/youtube/v3/docs/videoCategories/list")
argparser.add_argument("--keywords", help="Video keywords, comma separated",
default="")
argparser.add_argument("--privacyStatus", choices=VALID_PRIVACY_STATUSES,
default=VALID_PRIVACY_STATUSES[0], help="Video privacy status.")
args = argparser.parse_args()
if not os.path.exists(args.file):
exit("Please specify a valid file using the --file= parameter.")
youtube = get_authenticated_service(args)
try:
initialize_upload(youtube, args)
except HttpError as e:
print("An HTTP error %d occurred:\n%s" % (e.resp.status, e.content))
+5
View File
@@ -0,0 +1,5 @@
google-api-python-client
google-auth-oauthlib
google-auth-httplib2
oauth2client
httplib2
+41
View File
@@ -0,0 +1,41 @@
import torch
import numpy as np
from custom_nodes.EternalKernelLiteGraphNodes.utils import etk_deep_copy
def test_etk_deep_copy():
original = {
"list": [1, 2, 3, [4, 5, 6], {"inner_list": [7, 8, 9]}],
"dict": {"inner_dict": {"key": "value"}}
}
torch_test = {"torch_tensor": torch.tensor([1, 2, 3])}
np_test = {"np_array": np.array([1, 2, 3])}
test(original)
test(torch_test)
test(np_test)
def test(original):
# Creating a deep copy
copy = etk_deep_copy(original)
# Modifying the original structure
if "list" in original:
original["list"][0] = 100
if isinstance(original["list"][3], list):
original["list"][3][0] = 200
if "inner_list" in original["list"][4]:
original["list"][4]["inner_list"][0] = 300
if "dict" in original and "inner_dict" in original["dict"]:
original["dict"]["inner_dict"]["key"] = "modified"
if "torch_tensor" in original:
original["torch_tensor"][0] = 100
if "np_array" in original:
original["np_array"][0] = 200
# Returning both the original and the copy for inspection
return original, copy
+17
View File
@@ -0,0 +1,17 @@
import os
os.environ["UNIT_TEST"] = "True"
from custom_nodes.EternalKernelLiteGraphNodes.youtube_nodes import YoutubeUploadNode
def test_upload():
node = YoutubeUploadNode()
output = node.upload_video(
file_path=os.path.join("tests", "example.mp4"),
category=22,
title="Test Video",
description="This is a test video for the Youtube Upload Node",
keywords="test, youtube, node",
privacy_status="unlisted"
)
print (output)
+25
View File
@@ -0,0 +1,25 @@
import copy
from collections import OrderedDict
def etk_deep_copy(x, verbose=False):
if type(x).__module__ == "numpy":
return x.copy()
elif type(x).__module__ == "torch":
return x.clone()
elif isinstance(x, list):
return [etk_deep_copy(item) for item in x]
elif isinstance(x, tuple):
return tuple(etk_deep_copy(item) for item in x)
elif isinstance(x, dict):
return {k: etk_deep_copy(v) for k, v in x.items()}
elif isinstance(x, set):
return set(etk_deep_copy(item) for item in x)
elif isinstance(x, OrderedDict):
return OrderedDict((k, etk_deep_copy(v)) for k, v in x.items())
else:
if verbose:
print(f"copying {x}, of type {type(x)}")
return copy.deepcopy(x)
+140 -21
View File
@@ -352,7 +352,6 @@ class ImageStackToVideoFile(metaclass=WidgetMetaclass):
except OSError:
self.CODE = "Unable to get source code"
self.FUNC = self.handler
try:
@@ -541,22 +540,21 @@ class TemporalOpticalFlowSmoothing(metaclass=WidgetMetaclass):
# Create intermediary frames
frames = []
for i in range(k):
factor = i / k
#factor = factor * 2 - 1
factor = i / k
# factor = factor * 2 - 1
#normalize flow
# normalize flow
n_flow = flow.clone()
print(n_flow.min(), n_flow.max(), n_flow.dtype)
#n_flow[:,0,:,:] = n_flow[:,0,:,:] / (n_flow[:,0,:,:].max() - n_flow[:,0,:,:].min())
#n_flow[:,1,:,:] = n_flow[:,1,:,:] / (n_flow[:,1,:,:].max() - n_flow[:,1,:,:].min())
#print(n_flow.min(), n_flow.max(), n_flow.dtype)
#n_flow[:, 0, :, :] = n_flow[:, 0, :, :] - n_flow[:, 0, :, :].min()
#n_flow[:, 1, :, :] = n_flow[:, 1, :, :] - n_flow[:, 1, :, :].min()
#print (n_flow.min(), n_flow.max(),n_flow.dtype)
n_flow= n_flow/n_flow.shape[2]
# n_flow[:,0,:,:] = n_flow[:,0,:,:] / (n_flow[:,0,:,:].max() - n_flow[:,0,:,:].min())
# n_flow[:,1,:,:] = n_flow[:,1,:,:] / (n_flow[:,1,:,:].max() - n_flow[:,1,:,:].min())
# print(n_flow.min(), n_flow.max(), n_flow.dtype)
# n_flow[:, 0, :, :] = n_flow[:, 0, :, :] - n_flow[:, 0, :, :].min()
# n_flow[:, 1, :, :] = n_flow[:, 1, :, :] - n_flow[:, 1, :, :].min()
# print (n_flow.min(), n_flow.max(),n_flow.dtype)
n_flow = n_flow / n_flow.shape[2]
print(n_flow.min(), n_flow.max(), n_flow.dtype)
flow_interpolated = n_flow * factor
grid = TemporalOpticalFlowSmoothing.make_grid(flow_interpolated)
grid = grid.permute(0, 2, 3, 1)
@@ -567,7 +565,7 @@ class TemporalOpticalFlowSmoothing(metaclass=WidgetMetaclass):
return frames
@staticmethod
def calculate_optical_flow(image_stack, device,k):
def calculate_optical_flow(image_stack, device, k):
# from torch.multiprocessing import Pool, set_start_method
# set_start_method('spawn') # set the start method to spawn
@@ -594,8 +592,7 @@ class TemporalOpticalFlowSmoothing(metaclass=WidgetMetaclass):
flows.append(keys[0].cpu())
for f in flow:
flows.append(f[0].cpu())
#flows.append(keys[1].cpu())
# flows.append(keys[1].cpu())
return flows
@@ -608,7 +605,6 @@ class TemporalOpticalFlowSmoothing(metaclass=WidgetMetaclass):
image_stack = image_stack.permute(0, 2, 1, 3)
# make sure image_stack is a torch tensor and has the right dimensions
if not torch.is_tensor(image_stack):
image_stack = torch.tensor(image_stack, dtype=torch.float32)
@@ -620,17 +616,140 @@ class TemporalOpticalFlowSmoothing(metaclass=WidgetMetaclass):
image_stack = image_stack.to(device)
# Calculate optical flow
flows = self.calculate_optical_flow(image_stack, device,k)
flows = self.calculate_optical_flow(image_stack, device, k)
stack = torch.stack(flows, dim=0)
# Convert optical flows to images for visualization
# flow_images = [flow_to_image(flow) for flow in flows]
#change back to (b or t,w,h,c)
# change back to (b or t,w,h,c)
stack = stack.permute(0, 2, 1, 3)
return (stack,)
if __name__ == "__main__":
import doctest
# TW: Inherit for code reuse, consistency
import torch
from torchvision.utils import draw_keypoints
doctest.testmod()
class MovingCircle(metaclass=WidgetMetaclass):
# VideoWidget class definition
def calculate_start(self, x1, y1, width, height):
# Convert to 0-1 range
start_x = x1 * width
start_y = y1 * height
return (start_x, start_y)
def calculate_end(self, x2, y2, width, height):
# Convert to 0-1 range
end_x = x2 * width
end_y = y2 * height
return (end_x, end_y)
def generate_frames(self, num_frames, width, height):
# Match tensor shape from example
frames = torch.zeros(num_frames, width, height, 3)
return frames
def draw_circle_or_oval(self, kp, width, height, radius, aspect_ratio, color=(255, 255, 255)):
import torch.nn.functional as F
# Initialize black background
img = torch.zeros((3, height, width), dtype=torch.uint8)
# Draw circle as keypoints
keypoints = torch.tensor([[kp[0], kp[1]]]).unsqueeze(0)
img = draw_keypoints(img, keypoints, colors=color, radius=radius)
# convert to (w,h,c)
#img = img.permute(1, 2, 0).unsqueeze(0)
img=img.unsqueeze(0)
# Calculate new size
if aspect_ratio < 1:
new_size = (height, round(width / aspect_ratio))
else:
new_size = (round(height * aspect_ratio), width)
# Stretch the image
img_stretched = F.interpolate(img, size=new_size, mode='bilinear', align_corners=False)
# Shrink it back
img_shrunk = F.interpolate(img_stretched, size=(height, width), mode='bilinear', align_corners=False)
# Convert back to float values
img_shrunk = img_shrunk.float() / 255
img_shrunk = img_shrunk.permute(0, 2, 3, 1)
return img_shrunk
def draw_circle(self, kp, width, height, radius, color=(255, 255, 255)):
# Initialize black background
img = torch.zeros((3, height, width), dtype=torch.uint8)
# Draw circle as keypoints
keypoints = torch.tensor([[kp[0], kp[1]]]).unsqueeze(0)
img = draw_keypoints(img, keypoints, colors=color, radius=radius)
# convert to (w,h,c)
img = img.permute(1, 2, 0).unsqueeze(0)
# convert back to float values
img = img.float() / 255
return img
# Metadata for documentation
RETURN_TYPES = ("IMAGE",)
CATEGORY = "ETK/video"
FUNCTION = "handler"
# Match parent class method
@classmethod
def INPUT_TYPES(cls):
# Validate types, ranges
return {"required":
{
"x1": ("FLOAT", {"min": 0, "max": 1, "step": 0.1}),
"y1": ("FLOAT", {"min": 0, "max": 1, "step": 0.1}),
"x2": ("FLOAT", {"min": 0, "max": 1, "step": 0.1}),
"y2": ("FLOAT", {"min": 0, "max": 1, "step": 0.1}),
"num_frames": ("INT", {"min": 1}),
"width": ("INT", {"min": 1}),
"height": ("INT", {"min": 1}),
"radius": ("INT", {"min": 1}),
"color": ("STRING", {"default": "255 255 255"}),
"aspect_ratio": ("FLOAT", {"min": 0.1, "max": 10})
}}
def handler(self, x1, y1, x2, y2, num_frames, width, height, radius, color="255 255 255", aspect_ratio=1):
import torch
# Avoid errors on bad values
assert 0 <= x1 <= 1
assert 0 <= y1 <= 1
# Split calculations
start = torch.tensor(self.calculate_start(x1, y1, width, height))
# Split calculations
end = torch.tensor(self.calculate_end(x2, y2, width, height))
# color is supposed to be a tuple
color = tuple(map(lambda x:int(x), color.split()))
# Initialize for compositing
frames = []
for i in range(num_frames):
# Leverage PyTorch
cur_pos = start + (end - start) * i / num_frames
circle = self.draw_circle_or_oval(cur_pos, width, height, radius, aspect_ratio, color)
# Use broadcasting to overlay
frames.append(circle)
# cat
frames = torch.cat(frames, dim=0)
return (frames,)
if __name__ == "__main__":
import doctest
doctest.testmod()
+73
View File
@@ -0,0 +1,73 @@
import subprocess
import os
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def ETK_youtube_base(cls):
cls.CATEGORY = "ETK/Youtube"
NODE_CLASS_MAPPINGS[cls.__name__] = cls
return cls
@ETK_youtube_base
class YoutubeUploadNode:
VALID_PRIVACY_STATUSES = ["public", "private", "unlisted"]
VALID_CATEGORIES = [1, 2, 10, 15, 17, 19, 20, 22, 23, 24, 25, 26, 27, 28, 29]
CATEGORY_NAMES = ['Film & Animation', 'Autos & Vehicles', 'Music', 'Pets & Animals', 'Sports', 'Travel & Events',
'Gaming', 'People & Blogs', 'Comedy', 'Entertainment', 'News & Politics', 'Howto & Style',
'Education', 'Science & Technology', 'Nonprofits & Activism']
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {"description": "Path to the video file to upload"}),
"category": (cls.CATEGORY_NAMES,),
"title": ("STRING", {"default": "My video", "description": "Title of the video"}),
"description": ("STRING", {"default": "My video description", "multiline": True,
"description": "Description of the video"}),
"keywords": ("STRING", {"default": "tag1, tag2, tag3", "description": "Keywords for the video"}),
"privacy_status": (cls.VALID_PRIVACY_STATUSES,)
}
}
RETURN_TYPES = ('STRING',)
FUNCTION = "upload_video"
CATEGORY = "ETK/Youtube"
def upload_video(self, file_path, category, title, description, keywords, privacy_status):
import sys
my_python_exe = sys.executable
category = self.CATEGORY_NAMES.index(category)
args = ["--file", file_path, "--category", str(category), "--title", title, "--description", description,
"--keywords", keywords, "--privacyStatus", privacy_status]
current_dir = os.getcwd()
os.chdir(os.path.dirname(os.path.realpath(__file__)))
# result = subprocess.run([my_python_exe, "./modules/youtube.py"] + args,
# capture_output=True,
# text=True)
# the above does not capture the output, so we use the below instead
result = subprocess.run([my_python_exe, "./modules/youtube.py"] + args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True)
os.chdir(current_dir)
error = result.stderr
if error:
raise Exception(error)
return (result.stdout,)
if __name__ == "__main__":
pass