Merge branch 'v3-definition' into v3-definition-wip

This commit is contained in:
kosinkadink1@gmail.com 2025-07-15 14:30:29 -05:00
commit 59e2d47cfc
12 changed files with 760 additions and 154 deletions

0
comfy_api/v3/__init__.py Normal file
View File

View File

@ -1,4 +1,4 @@
from typing import Optional, Callable
from typing import Callable, Optional
def first_real_override(cls: type, name: str, *, base: type) -> Optional[Callable]:

View File

@ -1,27 +1,30 @@
from __future__ import annotations
from typing import Any, Literal, TypeVar, Callable, TypedDict
from typing_extensions import NotRequired
from enum import Enum
from abc import ABC, abstractmethod
from dataclasses import dataclass, asdict
from collections import Counter
from comfy_execution.graph import ExecutionBlocker
from comfy_api.internal import ComfyNodeInternal
from comfy_api.v3.resources import Resources, ResourcesLocal
import copy
from abc import ABC, abstractmethod
from collections import Counter
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Any, Callable, Literal, TypedDict, TypeVar
# used for type hinting
import torch
from spandrel import ImageModelDescriptor
from comfy.model_patcher import ModelPatcher
from comfy.samplers import Sampler, CFGGuider
from comfy.sd import CLIP
from comfy.controlnet import ControlNet
from comfy.sd import VAE
from comfy.sd import StyleModel as StyleModel_
from typing_extensions import NotRequired
from comfy.clip_vision import ClipVisionModel
from comfy.clip_vision import Output as ClipVisionOutput_
from comfy_api.input import VideoInput
from comfy.controlnet import ControlNet
from comfy.hooks import HookGroup, HookKeyframeGroup
from comfy.model_patcher import ModelPatcher
from comfy.samplers import CFGGuider, Sampler
from comfy.sd import CLIP, VAE
from comfy.sd import StyleModel as StyleModel_
from comfy_api.input import VideoInput
from comfy_api.internal import ComfyNodeInternal
from comfy_api.v3.resources import Resources, ResourcesLocal
from comfy_execution.graph import ExecutionBlocker
# from comfy_extras.nodes_images import SVG as SVG_ # NOTE: needs to be moved before can be imported due to circular reference
@ -1191,7 +1194,7 @@ class ComfyNodeV3(ComfyNodeInternal):
@classmethod
def GET_NODE_INFO_V3(cls) -> dict[str, Any]:
schema = cls.GET_SCHEMA()
# schema = cls.GET_SCHEMA()
# TODO: finish
return None
@ -1237,84 +1240,84 @@ class ComfyNodeV3(ComfyNodeInternal):
#--------------------------------------------
_DESCRIPTION = None
@classproperty
def DESCRIPTION(cls):
def DESCRIPTION(cls): # noqa
if cls._DESCRIPTION is None:
cls.GET_SCHEMA()
return cls._DESCRIPTION
_CATEGORY = None
@classproperty
def CATEGORY(cls):
def CATEGORY(cls): # noqa
if cls._CATEGORY is None:
cls.GET_SCHEMA()
return cls._CATEGORY
_EXPERIMENTAL = None
@classproperty
def EXPERIMENTAL(cls):
def EXPERIMENTAL(cls): # noqa
if cls._EXPERIMENTAL is None:
cls.GET_SCHEMA()
return cls._EXPERIMENTAL
_DEPRECATED = None
@classproperty
def DEPRECATED(cls):
def DEPRECATED(cls): # noqa
if cls._DEPRECATED is None:
cls.GET_SCHEMA()
return cls._DEPRECATED
_API_NODE = None
@classproperty
def API_NODE(cls):
def API_NODE(cls): # noqa
if cls._API_NODE is None:
cls.GET_SCHEMA()
return cls._API_NODE
_OUTPUT_NODE = None
@classproperty
def OUTPUT_NODE(cls):
def OUTPUT_NODE(cls): # noqa
if cls._OUTPUT_NODE is None:
cls.GET_SCHEMA()
return cls._OUTPUT_NODE
_INPUT_IS_LIST = None
@classproperty
def INPUT_IS_LIST(cls):
def INPUT_IS_LIST(cls): # noqa
if cls._INPUT_IS_LIST is None:
cls.GET_SCHEMA()
return cls._INPUT_IS_LIST
_OUTPUT_IS_LIST = None
@classproperty
def OUTPUT_IS_LIST(cls):
def OUTPUT_IS_LIST(cls): # noqa
if cls._OUTPUT_IS_LIST is None:
cls.GET_SCHEMA()
return cls._OUTPUT_IS_LIST
_RETURN_TYPES = None
@classproperty
def RETURN_TYPES(cls):
def RETURN_TYPES(cls): # noqa
if cls._RETURN_TYPES is None:
cls.GET_SCHEMA()
return cls._RETURN_TYPES
_RETURN_NAMES = None
@classproperty
def RETURN_NAMES(cls):
def RETURN_NAMES(cls): # noqa
if cls._RETURN_NAMES is None:
cls.GET_SCHEMA()
return cls._RETURN_NAMES
_OUTPUT_TOOLTIPS = None
@classproperty
def OUTPUT_TOOLTIPS(cls):
def OUTPUT_TOOLTIPS(cls): # noqa
if cls._OUTPUT_TOOLTIPS is None:
cls.GET_SCHEMA()
return cls._OUTPUT_TOOLTIPS
_NOT_IDEMPOTENT = None
@classproperty
def NOT_IDEMPOTENT(cls):
def NOT_IDEMPOTENT(cls): # noqa
if cls._NOT_IDEMPOTENT is None:
cls.GET_SCHEMA()
return cls._NOT_IDEMPOTENT
@ -1494,36 +1497,36 @@ class TestNode(ComfyNodeV3):
def execute(cls, **kwargs):
pass
if __name__ == "__main__":
print("hello there")
inputs: list[InputV3] = [
Int.Input("tessfes", widgetType=String.io_type),
Int.Input("my_int"),
Custom("XYZ").Input("xyz"),
Custom("MODEL_M").Input("model1"),
Image.Input("my_image"),
Float.Input("my_float"),
MultiType.Input("my_inputs", [String, Custom("MODEL_M"), Custom("XYZ")]),
]
Custom("XYZ").Input()
outputs: list[OutputV3] = [
Image.Output("image"),
Custom("XYZ").Output("xyz"),
]
for c in inputs:
if isinstance(c, MultiType):
print(f"{c}, {type(c)}, {type(c).io_type}, {c.id}, {[x.io_type for x in c.io_types]}")
print(c.get_io_type_V1())
else:
print(f"{c}, {type(c)}, {type(c).io_type}, {c.id}")
for c in outputs:
print(f"{c}, {type(c)}, {type(c).io_type}, {c.id}")
zz = TestNode()
print(zz.GET_NODE_INFO_V1())
# aa = NodeInfoV1()
# print(asdict(aa))
# print(as_pruned_dict(aa))
# if __name__ == "__main__":
# print("hello there")
# inputs: list[InputV3] = [
# Int.Input("tessfes", widgetType=String.io_type),
# Int.Input("my_int"),
# Custom("XYZ").Input("xyz"),
# Custom("MODEL_M").Input("model1"),
# Image.Input("my_image"),
# Float.Input("my_float"),
# MultiType.Input("my_inputs", [String, Custom("MODEL_M"), Custom("XYZ")]),
# ]
# Custom("XYZ").Input()
# outputs: list[OutputV3] = [
# Image.Output("image"),
# Custom("XYZ").Output("xyz"),
# ]
#
# for c in inputs:
# if isinstance(c, MultiType):
# print(f"{c}, {type(c)}, {type(c).io_type}, {c.id}, {[x.io_type for x in c.io_types]}")
# print(c.get_io_type_V1())
# else:
# print(f"{c}, {type(c)}, {type(c).io_type}, {c.id}")
#
# for c in outputs:
# print(f"{c}, {type(c)}, {type(c).io_type}, {c.id}")
#
# zz = TestNode()
# print(zz.GET_NODE_INFO_V1())
#
# # aa = NodeInfoV1()
# # print(asdict(aa))
# # print(as_pruned_dict(aa))

View File

@ -1,15 +1,21 @@
from __future__ import annotations
from comfy_api.v3.io import Image, FolderType, _UIOutput, ComfyNodeV3
# used for image preview
from comfy.cli_args import args
import folder_paths
import json
import os
import random
from io import BytesIO
import av
import numpy as np
import torchaudio
from PIL import Image as PILImage
from PIL.PngImagePlugin import PngInfo
import os
import json
import numpy as np
import folder_paths
# used for image preview
from comfy.cli_args import args
from comfy_api.v3.io import ComfyNodeV3, FolderType, Image, _UIOutput
class SavedResult(dict):
@ -63,11 +69,13 @@ class PreviewImage(_UIOutput):
"animated": (self.animated,)
}
class PreviewMask(PreviewImage):
def __init__(self, mask: PreviewMask.Type, animated: bool=False, cls: ComfyNodeV3=None, **kwargs):
preview = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3)
super().__init__(preview, animated, cls, **kwargs)
# class UILatent(_UIOutput):
# def __init__(self, values: list[SavedResult | dict], **kwargs):
# output_dir = folder_paths.get_temp_directory()
@ -115,13 +123,113 @@ class PreviewMask(PreviewImage):
# "latents": self.values,
# }
class PreviewAudio(_UIOutput):
def __init__(self, values: list[SavedResult | dict], **kwargs):
self.values = values
def __init__(self, audio, cls: ComfyNodeV3=None, **kwargs):
quality = "128k"
format = "flac"
filename_prefix = "ComfyUI_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_temp_directory()
)
# Prepare metadata dictionary
metadata = {}
if not args.disable_metadata and cls is not None:
if cls.hidden.prompt is not None:
metadata["prompt"] = json.dumps(cls.hidden.prompt)
if cls.hidden.extra_pnginfo is not None:
for x in cls.hidden.extra_pnginfo:
metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
# Opus supported sample rates
OPUS_RATES = [8000, 12000, 16000, 24000, 48000]
results = []
for (batch_number, waveform) in enumerate(audio["waveform"].cpu()):
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{format}"
output_path = os.path.join(full_output_folder, file)
# Use original sample rate initially
sample_rate = audio["sample_rate"]
# Handle Opus sample rate requirements
if format == "opus":
if sample_rate > 48000:
sample_rate = 48000
elif sample_rate not in OPUS_RATES:
# Find the next highest supported rate
for rate in sorted(OPUS_RATES):
if rate > sample_rate:
sample_rate = rate
break
if sample_rate not in OPUS_RATES: # Fallback if still not supported
sample_rate = 48000
# Resample if necessary
if sample_rate != audio["sample_rate"]:
waveform = torchaudio.functional.resample(waveform, audio["sample_rate"], sample_rate)
# Create output with specified format
output_buffer = BytesIO()
output_container = av.open(output_buffer, mode='w', format=format)
# Set metadata on the container
for key, value in metadata.items():
output_container.metadata[key] = value
# Set up the output stream with appropriate properties
if format == "opus":
out_stream = output_container.add_stream("libopus", rate=sample_rate)
if quality == "64k":
out_stream.bit_rate = 64000
elif quality == "96k":
out_stream.bit_rate = 96000
elif quality == "128k":
out_stream.bit_rate = 128000
elif quality == "192k":
out_stream.bit_rate = 192000
elif quality == "320k":
out_stream.bit_rate = 320000
elif format == "mp3":
out_stream = output_container.add_stream("libmp3lame", rate=sample_rate)
if quality == "V0":
# TODO i would really love to support V3 and V5 but there doesn't seem to be a way to set the qscale level, the property below is a bool
out_stream.codec_context.qscale = 1
elif quality == "128k":
out_stream.bit_rate = 128000
elif quality == "320k":
out_stream.bit_rate = 320000
else: # format == "flac":
out_stream = output_container.add_stream("flac", rate=sample_rate)
frame = av.AudioFrame.from_ndarray(waveform.movedim(0, 1).reshape(1, -1).float().numpy(), format='flt',
layout='mono' if waveform.shape[0] == 1 else 'stereo')
frame.sample_rate = sample_rate
frame.pts = 0
output_container.mux(out_stream.encode(frame))
# Flush encoder
output_container.mux(out_stream.encode(None))
# Close containers
output_container.close()
# Write the output to file
output_buffer.seek(0)
with open(output_path, 'wb') as f:
f.write(output_buffer.getbuffer())
results.append(SavedResult(file, subfolder, FolderType.temp))
counter += 1
self.values = results
def as_dict(self):
return {"audio": self.values}
class PreviewUI3D(_UIOutput):
def __init__(self, values: list[SavedResult | dict], **kwargs):
self.values = values
@ -129,6 +237,7 @@ class PreviewUI3D(_UIOutput):
def as_dict(self):
return {"3d": self.values}
class PreviewText(_UIOutput):
def __init__(self, value: str, **kwargs):
self.value = value

View File

@ -0,0 +1,347 @@
from __future__ import annotations
import hashlib
import json
import os
from io import BytesIO
import av
import torch
import torchaudio
import comfy.model_management
import folder_paths
import node_helpers
from comfy.cli_args import args
from comfy_api.v3 import io, ui
class ConditioningStableAudio_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="ConditioningStableAudio_V3",
category="conditioning",
inputs=[
io.Conditioning.Input(id="positive"),
io.Conditioning.Input(id="negative"),
io.Float.Input(id="seconds_start", default=0.0, min=0.0, max=1000.0, step=0.1),
io.Float.Input(id="seconds_total", default=47.0, min=0.0, max=1000.0, step=0.1),
],
outputs=[
io.Conditioning.Output(id="positive_out", display_name="positive"),
io.Conditioning.Output(id="negative_out", display_name="negative"),
],
)
@classmethod
def execute(cls, positive, negative, seconds_start, seconds_total) -> io.NodeOutput:
return io.NodeOutput(
node_helpers.conditioning_set_values(
positive, {"seconds_start": seconds_start, "seconds_total": seconds_total}
),
node_helpers.conditioning_set_values(
negative, {"seconds_start": seconds_start, "seconds_total": seconds_total}
),
)
class EmptyLatentAudio_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="EmptyLatentAudio_V3",
category="latent/audio",
inputs=[
io.Float.Input(id="seconds", default=47.6, min=1.0, max=1000.0, step=0.1),
io.Int.Input(
id="batch_size", default=1, min=1, max=4096, tooltip="The number of latent images in the batch."
),
],
outputs=[io.Latent.Output()],
)
@classmethod
def execute(cls, seconds, batch_size) -> io.NodeOutput:
length = round((seconds * 44100 / 2048) / 2) * 2
latent = torch.zeros([batch_size, 64, length], device=comfy.model_management.intermediate_device())
return io.NodeOutput({"samples": latent, "type": "audio"})
class LoadAudio_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="LoadAudio_V3", # frontend expects "LoadAudio" to work
display_name="Load Audio _V3", # frontend ignores "display_name" for this node
category="audio",
inputs=[
io.Combo.Input("audio", upload=io.UploadType.audio, options=cls.get_files_options()),
],
outputs=[io.Audio.Output()],
)
@classmethod
def get_files_options(cls) -> list[str]:
input_dir = folder_paths.get_input_directory()
return sorted(folder_paths.filter_files_content_types(os.listdir(input_dir), ["audio", "video"]))
@classmethod
def execute(cls, audio) -> io.NodeOutput:
waveform, sample_rate = torchaudio.load(folder_paths.get_annotated_filepath(audio))
return io.NodeOutput({"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate})
@classmethod
def fingerprint_inputs(s, audio):
image_path = folder_paths.get_annotated_filepath(audio)
m = hashlib.sha256()
with open(image_path, "rb") as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def validate_inputs(s, audio):
if not folder_paths.exists_annotated_filepath(audio):
return "Invalid audio file: {}".format(audio)
return True
class PreviewAudio_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="PreviewAudio_V3", # frontend expects "PreviewAudio" to work
display_name="Preview Audio _V3", # frontend ignores "display_name" for this node
category="audio",
inputs=[
io.Audio.Input("audio"),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio) -> io.NodeOutput:
return io.NodeOutput(ui=ui.PreviewAudio(audio, cls=cls))
class SaveAudioMP3_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="SaveAudioMP3_V3", # frontend expects "SaveAudioMP3" to work
display_name="Save Audio(MP3) _V3", # frontend ignores "display_name" for this node
category="audio",
inputs=[
io.Audio.Input("audio"),
io.String.Input("filename_prefix", default="audio/ComfyUI"),
io.Combo.Input("quality", options=["V0", "128k", "320k"], default="V0"),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(self, audio, filename_prefix="ComfyUI", format="mp3", quality="V0") -> io.NodeOutput:
return _save_audio(self, audio, filename_prefix, format, quality)
class SaveAudioOpus_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="SaveAudioOpus_V3", # frontend expects "SaveAudioOpus" to work
display_name="Save Audio(Opus) _V3", # frontend ignores "display_name" for this node
category="audio",
inputs=[
io.Audio.Input("audio"),
io.String.Input("filename_prefix", default="audio/ComfyUI"),
io.Combo.Input("quality", options=["64k", "96k", "128k", "192k", "320k"], default="128k"),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(self, audio, filename_prefix="ComfyUI", format="opus", quality="128k") -> io.NodeOutput:
return _save_audio(self, audio, filename_prefix, format, quality)
class SaveAudio_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="SaveAudio_V3", # frontend expects "SaveAudio" to work
display_name="Save Audio _V3", # frontend ignores "display_name" for this node
category="audio",
inputs=[
io.Audio.Input("audio"),
io.String.Input("filename_prefix", default="audio/ComfyUI"),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio, filename_prefix="ComfyUI", format="flac") -> io.NodeOutput:
return _save_audio(cls, audio, filename_prefix, format)
class VAEDecodeAudio_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="VAEDecodeAudio_V3",
category="latent/audio",
inputs=[
io.Latent.Input(id="samples"),
io.Vae.Input(id="vae"),
],
outputs=[io.Audio.Output()],
)
@classmethod
def execute(cls, vae, samples) -> io.NodeOutput:
audio = vae.decode(samples["samples"]).movedim(-1, 1)
std = torch.std(audio, dim=[1, 2], keepdim=True) * 5.0
std[std < 1.0] = 1.0
audio /= std
return io.NodeOutput({"waveform": audio, "sample_rate": 44100})
class VAEEncodeAudio_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="VAEEncodeAudio_V3",
category="latent/audio",
inputs=[
io.Audio.Input(id="audio"),
io.Vae.Input(id="vae"),
],
outputs=[io.Latent.Output()],
)
@classmethod
def execute(cls, vae, audio) -> io.NodeOutput:
sample_rate = audio["sample_rate"]
if 44100 != sample_rate:
waveform = torchaudio.functional.resample(audio["waveform"], sample_rate, 44100)
else:
waveform = audio["waveform"]
return io.NodeOutput({"samples": vae.encode(waveform.movedim(1, -1))})
def _save_audio(cls, audio, filename_prefix="ComfyUI", format="flac", quality="128k") -> io.NodeOutput:
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory()
)
# Prepare metadata dictionary
metadata = {}
if not args.disable_metadata:
if cls.hidden.prompt is not None:
metadata["prompt"] = json.dumps(cls.hidden.prompt)
if cls.hidden.extra_pnginfo is not None:
for x in cls.hidden.extra_pnginfo:
metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
# Opus supported sample rates
OPUS_RATES = [8000, 12000, 16000, 24000, 48000]
results = []
for batch_number, waveform in enumerate(audio["waveform"].cpu()):
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{format}"
output_path = os.path.join(full_output_folder, file)
# Use original sample rate initially
sample_rate = audio["sample_rate"]
# Handle Opus sample rate requirements
if format == "opus":
if sample_rate > 48000:
sample_rate = 48000
elif sample_rate not in OPUS_RATES:
# Find the next highest supported rate
for rate in sorted(OPUS_RATES):
if rate > sample_rate:
sample_rate = rate
break
if sample_rate not in OPUS_RATES: # Fallback if still not supported
sample_rate = 48000
# Resample if necessary
if sample_rate != audio["sample_rate"]:
waveform = torchaudio.functional.resample(waveform, audio["sample_rate"], sample_rate)
# Create output with specified format
output_buffer = BytesIO()
output_container = av.open(output_buffer, mode="w", format=format)
# Set metadata on the container
for key, value in metadata.items():
output_container.metadata[key] = value
# Set up the output stream with appropriate properties
if format == "opus":
out_stream = output_container.add_stream("libopus", rate=sample_rate)
if quality == "64k":
out_stream.bit_rate = 64000
elif quality == "96k":
out_stream.bit_rate = 96000
elif quality == "128k":
out_stream.bit_rate = 128000
elif quality == "192k":
out_stream.bit_rate = 192000
elif quality == "320k":
out_stream.bit_rate = 320000
elif format == "mp3":
out_stream = output_container.add_stream("libmp3lame", rate=sample_rate)
if quality == "V0":
# TODO i would really love to support V3 and V5 but there doesn't seem to be a way to set the qscale level, the property below is a bool
out_stream.codec_context.qscale = 1
elif quality == "128k":
out_stream.bit_rate = 128000
elif quality == "320k":
out_stream.bit_rate = 320000
else: # format == "flac":
out_stream = output_container.add_stream("flac", rate=sample_rate)
frame = av.AudioFrame.from_ndarray(
waveform.movedim(0, 1).reshape(1, -1).float().numpy(),
format="flt",
layout="mono" if waveform.shape[0] == 1 else "stereo",
)
frame.sample_rate = sample_rate
frame.pts = 0
output_container.mux(out_stream.encode(frame))
# Flush encoder
output_container.mux(out_stream.encode(None))
# Close containers
output_container.close()
# Write the output to file
output_buffer.seek(0)
with open(output_path, "wb") as f:
f.write(output_buffer.getbuffer())
results.append(ui.SavedResult(file, subfolder, io.FolderType.output))
counter += 1
return io.NodeOutput(ui={"audio": results})
NODES_LIST: list[type[io.ComfyNodeV3]] = [
ConditioningStableAudio_V3,
EmptyLatentAudio_V3,
LoadAudio_V3,
PreviewAudio_V3,
SaveAudioMP3_V3,
SaveAudioOpus_V3,
SaveAudio_V3,
VAEDecodeAudio_V3,
VAEEncodeAudio_V3,
]

View File

@ -1,5 +1,5 @@
from comfy.cldm.control_types import UNION_CONTROLNET_TYPES
import comfy.utils
from comfy.cldm.control_types import UNION_CONTROLNET_TYPES
from comfy_api.v3 import io
@ -27,11 +27,13 @@ class ControlNetApplyAdvanced_V3(io.ComfyNodeV3):
)
@classmethod
def execute(cls, positive, negative, control_net, image, strength, start_percent, end_percent, vae=None, extra_concat=[]) -> io.NodeOutput:
def execute(
cls, positive, negative, control_net, image, strength, start_percent, end_percent, vae=None, extra_concat=[]
) -> io.NodeOutput:
if strength == 0:
return io.NodeOutput(positive, negative)
control_hint = image.movedim(-1,1)
control_hint = image.movedim(-1, 1)
cnets = {}
out = []
@ -40,16 +42,18 @@ class ControlNetApplyAdvanced_V3(io.ComfyNodeV3):
for t in conditioning:
d = t[1].copy()
prev_cnet = d.get('control', None)
prev_cnet = d.get("control", None)
if prev_cnet in cnets:
c_net = cnets[prev_cnet]
else:
c_net = control_net.copy().set_cond_hint(control_hint, strength, (start_percent, end_percent), vae=vae, extra_concat=extra_concat)
c_net = control_net.copy().set_cond_hint(
control_hint, strength, (start_percent, end_percent), vae=vae, extra_concat=extra_concat
)
c_net.set_previous_controlnet(prev_cnet)
cnets[prev_cnet] = c_net
d['control'] = c_net
d['control_apply_to_uncond'] = False
d["control"] = c_net
d["control_apply_to_uncond"] = False
n = [t[0], d]
c.append(n)
out.append(c)
@ -107,7 +111,9 @@ class ControlNetInpaintingAliMamaApply_V3(ControlNetApplyAdvanced_V3):
)
@classmethod
def execute(cls, positive, negative, control_net, vae, image, mask, strength, start_percent, end_percent) -> io.NodeOutput:
def execute(
cls, positive, negative, control_net, vae, image, mask, strength, start_percent, end_percent
) -> io.NodeOutput:
extra_concat = []
if control_net.concat_mask:
mask = 1.0 - mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1]))
@ -115,7 +121,17 @@ class ControlNetInpaintingAliMamaApply_V3(ControlNetApplyAdvanced_V3):
image = image * mask_apply.movedim(1, -1).repeat(1, 1, 1, image.shape[3])
extra_concat = [mask]
return super().execute(positive, negative, control_net, image, strength, start_percent, end_percent, vae=vae, extra_concat=extra_concat)
return super().execute(
positive,
negative,
control_net,
image,
strength,
start_percent,
end_percent,
vae=vae,
extra_concat=extra_concat,
)
NODES_LIST: list[type[io.ComfyNodeV3]] = [

View File

@ -1,16 +1,16 @@
import hashlib
import json
import os
import torch
import hashlib
import numpy as np
import torch
from PIL import Image, ImageOps, ImageSequence
from PIL.PngImagePlugin import PngInfo
from comfy_api.v3 import io, ui
from comfy.cli_args import args
import folder_paths
import node_helpers
from comfy.cli_args import args
from comfy_api.v3 import io, ui
class SaveImage_V3(io.ComfyNodeV3):
@ -29,7 +29,8 @@ class SaveImage_V3(io.ComfyNodeV3):
io.String.Input(
"filename_prefix",
default="ComfyUI",
tooltip="The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.",
tooltip="The prefix for the file to save. This may include formatting information "
"such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.",
),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
@ -42,8 +43,8 @@ class SaveImage_V3(io.ComfyNodeV3):
filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0]
)
results = []
for (batch_number, image) in enumerate(images):
i = 255. * image.cpu().numpy()
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = None
if not args.disable_metadata:
@ -82,13 +83,13 @@ class SaveAnimatedPNG_V3(io.ComfyNodeV3):
@classmethod
def execute(cls, images, fps, compress_level, filename_prefix="ComfyUI") -> io.NodeOutput:
full_output_folder, filename, counter, subfolder, filename_prefix = (
folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0])
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0]
)
results = []
pil_images = []
for image in images:
img = Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
img = Image.fromarray(np.clip(255.0 * image.cpu().numpy(), 0, 255).astype(np.uint8))
pil_images.append(img)
metadata = None
@ -96,19 +97,34 @@ class SaveAnimatedPNG_V3(io.ComfyNodeV3):
metadata = PngInfo()
if cls.hidden.prompt is not None:
metadata.add(
b"comf", "prompt".encode("latin-1", "strict") + b"\0" + json.dumps(cls.hidden.prompt).encode("latin-1", "strict"), after_idat=True
b"comf",
"prompt".encode("latin-1", "strict")
+ b"\0"
+ json.dumps(cls.hidden.prompt).encode("latin-1", "strict"),
after_idat=True,
)
if cls.hidden.extra_pnginfo is not None:
for x in cls.hidden.extra_pnginfo:
metadata.add(
b"comf", x.encode("latin-1", "strict") + b"\0" + json.dumps(cls.hidden.extra_pnginfo[x]).encode("latin-1", "strict"), after_idat=True
b"comf",
x.encode("latin-1", "strict")
+ b"\0"
+ json.dumps(cls.hidden.extra_pnginfo[x]).encode("latin-1", "strict"),
after_idat=True,
)
file = f"{filename}_{counter:05}_.png"
pil_images[0].save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=compress_level, save_all=True, duration=int(1000.0/fps), append_images=pil_images[1:])
pil_images[0].save(
os.path.join(full_output_folder, file),
pnginfo=metadata,
compress_level=compress_level,
save_all=True,
duration=int(1000.0 / fps),
append_images=pil_images[1:],
)
results.append(ui.SavedResult(file, subfolder, io.FolderType.output))
return io.NodeOutput(ui={"images": results, "animated": (True,) })
return io.NodeOutput(ui={"images": results, "animated": (True,)})
class SaveAnimatedWEBP_V3(io.ComfyNodeV3):
@ -136,11 +152,13 @@ class SaveAnimatedWEBP_V3(io.ComfyNodeV3):
@classmethod
def execute(cls, images, fps, filename_prefix, lossless, quality, method, num_frames=0) -> io.NodeOutput:
method = cls.COMPRESS_METHODS.get(method)
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0])
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0]
)
results = []
pil_images = []
for image in images:
img = Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
img = Image.fromarray(np.clip(255.0 * image.cpu().numpy(), 0, 255).astype(np.uint8))
pil_images.append(img)
metadata = pil_images[0].getexif()
@ -148,7 +166,7 @@ class SaveAnimatedWEBP_V3(io.ComfyNodeV3):
if cls.hidden.prompt is not None:
metadata[0x0110] = "prompt:{}".format(json.dumps(cls.hidden.prompt))
if cls.hidden.extra_pnginfo is not None:
inital_exif = 0x010f
inital_exif = 0x010F
for x in cls.hidden.extra_pnginfo:
metadata[inital_exif] = "{}:{}".format(x, json.dumps(cls.hidden.extra_pnginfo[x]))
inital_exif -= 1
@ -160,8 +178,9 @@ class SaveAnimatedWEBP_V3(io.ComfyNodeV3):
file = f"{filename}_{counter:05}_.webp"
pil_images[i].save(
os.path.join(full_output_folder, file),
save_all=True, duration=int(1000.0/fps),
append_images=pil_images[i + 1:i + num_frames],
save_all=True,
duration=int(1000.0 / fps),
append_images=pil_images[i + 1 : i + num_frames],
exif=metadata,
lossless=lossless,
quality=quality,
@ -228,12 +247,12 @@ class LoadImage_V3(io.ComfyNodeV3):
output_masks = []
w, h = None, None
excluded_formats = ['MPO']
excluded_formats = ["MPO"]
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
if i.mode == "I":
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
@ -246,14 +265,14 @@ class LoadImage_V3(io.ComfyNodeV3):
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
elif i.mode == 'P' and 'transparency' in i.info:
mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
if "A" in i.getbands():
mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
elif i.mode == "P" and "transparency" in i.info:
mask = np.array(i.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
else:
mask = torch.zeros((64,64), dtype=torch.float32, device="cpu")
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image)
output_masks.append(mask.unsqueeze(0))
@ -270,7 +289,7 @@ class LoadImage_V3(io.ComfyNodeV3):
def fingerprint_inputs(s, image):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
with open(image_path, "rb") as f:
m.update(f.read())
return m.digest().hex()
@ -288,8 +307,8 @@ class LoadImageOutput_V3(io.ComfyNodeV3):
node_id="LoadImageOutput_V3",
display_name="Load Image (from Outputs) _V3",
description="Load an image from the output folder. "
"When the refresh button is clicked, the node will update the image list "
"and automatically select the first image, allowing for easy iteration.",
"When the refresh button is clicked, the node will update the image list "
"and automatically select the first image, allowing for easy iteration.",
category="image",
inputs=[
io.Combo.Input(
@ -317,12 +336,12 @@ class LoadImageOutput_V3(io.ComfyNodeV3):
output_masks = []
w, h = None, None
excluded_formats = ['MPO']
excluded_formats = ["MPO"]
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
if i.mode == "I":
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
@ -335,12 +354,12 @@ class LoadImageOutput_V3(io.ComfyNodeV3):
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
elif i.mode == 'P' and 'transparency' in i.info:
mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
if "A" in i.getbands():
mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
elif i.mode == "P" and "transparency" in i.info:
mask = np.array(i.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image)
@ -359,7 +378,7 @@ class LoadImageOutput_V3(io.ComfyNodeV3):
def fingerprint_inputs(s, image):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
with open(image_path, "rb") as f:
m.update(f.read())
return m.digest().hex()

View File

@ -0,0 +1,104 @@
from __future__ import annotations
import sys
from comfy_api.v3 import io
class String_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="PrimitiveString_V3",
display_name="String _V3",
category="utils/primitive",
inputs=[
io.String.Input("value"),
],
outputs=[io.String.Output()],
)
@classmethod
def execute(cls, value: str) -> io.NodeOutput:
return io.NodeOutput(value)
class StringMultiline_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="PrimitiveStringMultiline_V3",
display_name="String (Multiline) _V3",
category="utils/primitive",
inputs=[
io.String.Input("value", multiline=True),
],
outputs=[io.String.Output()],
)
@classmethod
def execute(cls, value: str) -> io.NodeOutput:
return io.NodeOutput(value)
class Int_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="PrimitiveInt_V3",
display_name="Int _V3",
category="utils/primitive",
inputs=[
io.Int.Input("value", min=-sys.maxsize, max=sys.maxsize, control_after_generate=True),
],
outputs=[io.Int.Output()],
)
@classmethod
def execute(cls, value: int) -> io.NodeOutput:
return io.NodeOutput(value)
class Float_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="PrimitiveFloat_V3",
display_name="Float _V3",
category="utils/primitive",
inputs=[
io.Float.Input("value", min=-sys.maxsize, max=sys.maxsize),
],
outputs=[io.Float.Output()],
)
@classmethod
def execute(cls, value: float) -> io.NodeOutput:
return io.NodeOutput(value)
class Boolean_V3(io.ComfyNodeV3):
@classmethod
def DEFINE_SCHEMA(cls):
return io.SchemaV3(
node_id="PrimitiveBoolean_V3",
display_name="Boolean _V3",
category="utils/primitive",
inputs=[
io.Boolean.Input("value"),
],
outputs=[io.Boolean.Output()],
)
@classmethod
def execute(cls, value: bool) -> io.NodeOutput:
return io.NodeOutput(value)
NODES_LIST: list[type[io.ComfyNodeV3]] = [
String_V3,
StringMultiline_V3,
Int_V3,
Float_V3,
Boolean_V3,
]

View File

@ -1,25 +1,25 @@
"""
This file is part of ComfyUI.
Copyright (C) 2024 Stability AI
This file is part of ComfyUI.
Copyright (C) 2024 Stability AI
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import torch
import nodes
import comfy.utils
import comfy.utils
import nodes
from comfy_api.v3 import io
@ -30,7 +30,7 @@ class StableCascade_EmptyLatentImage_V3(io.ComfyNodeV3):
node_id="StableCascade_EmptyLatentImage_V3",
category="latent/stable_cascade",
inputs=[
io.Int.Input("width", default=1024,min=256,max=nodes.MAX_RESOLUTION, step=8),
io.Int.Input("width", default=1024, min=256, max=nodes.MAX_RESOLUTION, step=8),
io.Int.Input("height", default=1024, min=256, max=nodes.MAX_RESOLUTION, step=8),
io.Int.Input("compression", default=42, min=4, max=128, step=1),
io.Int.Input("batch_size", default=1, min=1, max=4096),
@ -72,9 +72,9 @@ class StableCascade_StageC_VAEEncode_V3(io.ComfyNodeV3):
out_width = (width // compression) * vae.downscale_ratio
out_height = (height // compression) * vae.downscale_ratio
s = comfy.utils.common_upscale(image.movedim(-1,1), out_width, out_height, "bicubic", "center").movedim(1,-1)
s = comfy.utils.common_upscale(image.movedim(-1, 1), out_width, out_height, "bicubic", "center").movedim(1, -1)
c_latent = vae.encode(s[:,:,:,:3])
c_latent = vae.encode(s[:, :, :, :3])
b_latent = torch.zeros([c_latent.shape[0], 4, (height // 8) * 2, (width // 8) * 2])
return io.NodeOutput({"samples": c_latent}, {"samples": b_latent})
@ -90,7 +90,7 @@ class StableCascade_StageB_Conditioning_V3(io.ComfyNodeV3):
io.Latent.Input("stage_c"),
],
outputs=[
io.Conditioning.Output(),
io.Conditioning.Output(),
],
)
@ -99,7 +99,7 @@ class StableCascade_StageB_Conditioning_V3(io.ComfyNodeV3):
c = []
for t in conditioning:
d = t[1].copy()
d['stable_cascade_prior'] = stage_c['samples']
d["stable_cascade_prior"] = stage_c["samples"]
n = [t[0], d]
c.append(n)
return io.NodeOutput(c)
@ -128,7 +128,7 @@ class StableCascade_SuperResolutionControlnet_V3(io.ComfyNodeV3):
width = image.shape[-2]
height = image.shape[-3]
batch_size = image.shape[0]
controlnet_input = vae.encode(image[:,:,:,:3]).movedim(1, -1)
controlnet_input = vae.encode(image[:, :, :, :3]).movedim(1, -1)
c_latent = torch.zeros([batch_size, 16, height // 16, width // 16])
b_latent = torch.zeros([batch_size, 4, height // 2, width // 2])

View File

@ -1,14 +1,13 @@
import hashlib
import torch
import numpy as np
import torch
from PIL import Image, ImageOps, ImageSequence
from comfy_api.v3 import io
import nodes
import folder_paths
import node_helpers
import nodes
from comfy_api.v3 import io
MAX_RESOLUTION = nodes.MAX_RESOLUTION
@ -51,12 +50,12 @@ class WebcamCapture_V3(io.ComfyNodeV3):
output_masks = []
w, h = None, None
excluded_formats = ['MPO']
excluded_formats = ["MPO"]
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
if i.mode == "I":
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
@ -69,12 +68,12 @@ class WebcamCapture_V3(io.ComfyNodeV3):
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
elif i.mode == 'P' and 'transparency' in i.info:
mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
if "A" in i.getbands():
mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
elif i.mode == "P" and "transparency" in i.info:
mask = np.array(i.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image)
@ -93,7 +92,7 @@ class WebcamCapture_V3(io.ComfyNodeV3):
def fingerprint_inputs(s, image, width, height, capture_on_queue):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
with open(image_path, "rb") as f:
m.update(f.read())
return m.digest().hex()

View File

@ -2299,9 +2299,11 @@ def init_builtin_extra_nodes():
"nodes_tcfg.py",
"nodes_v3_test.py",
"nodes_v1_test.py",
"v3/nodes_audio.py",
"v3/nodes_controlnet.py",
"v3/nodes_images.py",
"v3/nodes_mask.py",
"v3/nodes_primitive.py",
"v3/nodes_webcam.py",
"v3/nodes_stable_cascade.py",
]

View File

@ -12,6 +12,8 @@ documentation = "https://docs.comfy.org/"
[tool.ruff]
lint.select = [
"E", # pycodestyle errors
"I", # isort
"N805", # invalid-first-argument-name-for-method
"S307", # suspicious-eval-usage
"S102", # exec
@ -22,3 +24,8 @@ lint.select = [
"F",
]
exclude = ["*.ipynb"]
line-length = 120
lint.pycodestyle.ignore-overlong-task-comments = true
[tool.ruff.lint.per-file-ignores]
"!comfy_extras/v3/*" = ["E", "I"] # enable these rules only for V3 nodes