convert Video nodes to V3 schema (#9489)

This commit is contained in:
Alexander Piskun
2025-08-31 06:19:54 +03:00
committed by GitHub
parent f949094b3c
commit fea9ea8268

View File

@@ -5,52 +5,49 @@ import av
import torch import torch
import folder_paths import folder_paths
import json import json
from typing import Optional, Literal from typing import Optional
from typing_extensions import override
from fractions import Fraction from fractions import Fraction
from comfy.comfy_types import IO, FileLocator, ComfyNodeABC from comfy_api.input import AudioInput, ImageInput, VideoInput
from comfy_api.latest import Input, InputImpl, Types from comfy_api.input_impl import VideoFromComponents, VideoFromFile
from comfy_api.util import VideoCodec, VideoComponents, VideoContainer
from comfy_api.latest import ComfyExtension, io, ui
from comfy.cli_args import args from comfy.cli_args import args
class SaveWEBM: class SaveWEBM(io.ComfyNode):
def __init__(self): @classmethod
self.output_dir = folder_paths.get_output_directory() def define_schema(cls):
self.type = "output" return io.Schema(
self.prefix_append = "" node_id="SaveWEBM",
category="image/video",
is_experimental=True,
inputs=[
io.Image.Input("images"),
io.String.Input("filename_prefix", default="ComfyUI"),
io.Combo.Input("codec", options=["vp9", "av1"]),
io.Float.Input("fps", default=24.0, min=0.01, max=1000.0, step=0.01),
io.Float.Input("crf", default=32.0, min=0, max=63.0, step=1, tooltip="Higher crf means lower quality with a smaller file size, lower crf means higher quality higher filesize."),
],
outputs=[],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod @classmethod
def INPUT_TYPES(s): def execute(cls, images, codec, fps, filename_prefix, crf) -> io.NodeOutput:
return {"required": full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
{"images": ("IMAGE", ), filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0]
"filename_prefix": ("STRING", {"default": "ComfyUI"}), )
"codec": (["vp9", "av1"],),
"fps": ("FLOAT", {"default": 24.0, "min": 0.01, "max": 1000.0, "step": 0.01}),
"crf": ("FLOAT", {"default": 32.0, "min": 0, "max": 63.0, "step": 1, "tooltip": "Higher crf means lower quality with a smaller file size, lower crf means higher quality higher filesize."}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "image/video"
EXPERIMENTAL = True
def save_images(self, images, codec, fps, filename_prefix, crf, prompt=None, extra_pnginfo=None):
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0])
file = f"{filename}_{counter:05}_.webm" file = f"{filename}_{counter:05}_.webm"
container = av.open(os.path.join(full_output_folder, file), mode="w") container = av.open(os.path.join(full_output_folder, file), mode="w")
if prompt is not None: if cls.hidden.prompt is not None:
container.metadata["prompt"] = json.dumps(prompt) container.metadata["prompt"] = json.dumps(cls.hidden.prompt)
if extra_pnginfo is not None: if cls.hidden.extra_pnginfo is not None:
for x in extra_pnginfo: for x in cls.hidden.extra_pnginfo:
container.metadata[x] = json.dumps(extra_pnginfo[x]) container.metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
codec_map = {"vp9": "libvpx-vp9", "av1": "libsvtav1"} codec_map = {"vp9": "libvpx-vp9", "av1": "libsvtav1"}
stream = container.add_stream(codec_map[codec], rate=Fraction(round(fps * 1000), 1000)) stream = container.add_stream(codec_map[codec], rate=Fraction(round(fps * 1000), 1000))
@@ -69,63 +66,46 @@ class SaveWEBM:
container.mux(stream.encode()) container.mux(stream.encode())
container.close() container.close()
results: list[FileLocator] = [{ return io.NodeOutput(ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))
"filename": file,
"subfolder": subfolder,
"type": self.type
}]
return {"ui": {"images": results, "animated": (True,)}} # TODO: frontend side class SaveVideo(io.ComfyNode):
@classmethod
class SaveVideo(ComfyNodeABC): def define_schema(cls):
def __init__(self): return io.Schema(
self.output_dir = folder_paths.get_output_directory() node_id="SaveVideo",
self.type: Literal["output"] = "output" display_name="Save Video",
self.prefix_append = "" category="image/video",
description="Saves the input images to your ComfyUI output directory.",
inputs=[
io.Video.Input("video", tooltip="The video to save."),
io.String.Input("filename_prefix", default="video/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."),
io.Combo.Input("format", options=VideoContainer.as_input(), default="auto", tooltip="The format to save the video as."),
io.Combo.Input("codec", options=VideoCodec.as_input(), default="auto", tooltip="The codec to use for the video."),
],
outputs=[],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod @classmethod
def INPUT_TYPES(cls): def execute(cls, video: VideoInput, filename_prefix, format, codec) -> io.NodeOutput:
return {
"required": {
"video": (IO.VIDEO, {"tooltip": "The video to save."}),
"filename_prefix": ("STRING", {"default": "video/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."}),
"format": (Types.VideoContainer.as_input(), {"default": "auto", "tooltip": "The format to save the video as."}),
"codec": (Types.VideoCodec.as_input(), {"default": "auto", "tooltip": "The codec to use for the video."}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO"
},
}
RETURN_TYPES = ()
FUNCTION = "save_video"
OUTPUT_NODE = True
CATEGORY = "image/video"
DESCRIPTION = "Saves the input images to your ComfyUI output directory."
def save_video(self, video: Input.Video, filename_prefix, format, codec, prompt=None, extra_pnginfo=None):
filename_prefix += self.prefix_append
width, height = video.get_dimensions() width, height = video.get_dimensions()
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, filename_prefix,
self.output_dir, folder_paths.get_output_directory(),
width, width,
height height
) )
results: list[FileLocator] = list()
saved_metadata = None saved_metadata = None
if not args.disable_metadata: if not args.disable_metadata:
metadata = {} metadata = {}
if extra_pnginfo is not None: if cls.hidden.extra_pnginfo is not None:
metadata.update(extra_pnginfo) metadata.update(cls.hidden.extra_pnginfo)
if prompt is not None: if cls.hidden.prompt is not None:
metadata["prompt"] = prompt metadata["prompt"] = cls.hidden.prompt
if len(metadata) > 0: if len(metadata) > 0:
saved_metadata = metadata saved_metadata = metadata
file = f"{filename}_{counter:05}_.{Types.VideoContainer.get_extension(format)}" file = f"{filename}_{counter:05}_.{VideoContainer.get_extension(format)}"
video.save_to( video.save_to(
os.path.join(full_output_folder, file), os.path.join(full_output_folder, file),
format=format, format=format,
@@ -133,83 +113,82 @@ class SaveVideo(ComfyNodeABC):
metadata=saved_metadata metadata=saved_metadata
) )
results.append({ return io.NodeOutput(ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))
"filename": file,
"subfolder": subfolder,
"type": self.type
})
counter += 1
return { "ui": { "images": results, "animated": (True,) } }
class CreateVideo(ComfyNodeABC): class CreateVideo(io.ComfyNode):
@classmethod @classmethod
def INPUT_TYPES(cls): def define_schema(cls):
return { return io.Schema(
"required": { node_id="CreateVideo",
"images": (IO.IMAGE, {"tooltip": "The images to create a video from."}), display_name="Create Video",
"fps": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 1.0}), category="image/video",
}, description="Create a video from images.",
"optional": { inputs=[
"audio": (IO.AUDIO, {"tooltip": "The audio to add to the video."}), io.Image.Input("images", tooltip="The images to create a video from."),
} io.Float.Input("fps", default=30.0, min=1.0, max=120.0, step=1.0),
} io.Audio.Input("audio", optional=True, tooltip="The audio to add to the video."),
],
RETURN_TYPES = (IO.VIDEO,) outputs=[
FUNCTION = "create_video" io.Video.Output(),
],
CATEGORY = "image/video"
DESCRIPTION = "Create a video from images."
def create_video(self, images: Input.Image, fps: float, audio: Optional[Input.Audio] = None):
return (InputImpl.VideoFromComponents(
Types.VideoComponents(
images=images,
audio=audio,
frame_rate=Fraction(fps),
) )
),)
class GetVideoComponents(ComfyNodeABC):
@classmethod @classmethod
def INPUT_TYPES(cls): def execute(cls, images: ImageInput, fps: float, audio: Optional[AudioInput] = None) -> io.NodeOutput:
return { return io.NodeOutput(
"required": { VideoFromComponents(VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps)))
"video": (IO.VIDEO, {"tooltip": "The video to extract components from."}), )
}
}
RETURN_TYPES = (IO.IMAGE, IO.AUDIO, IO.FLOAT)
RETURN_NAMES = ("images", "audio", "fps")
FUNCTION = "get_components"
CATEGORY = "image/video" class GetVideoComponents(io.ComfyNode):
DESCRIPTION = "Extracts all components from a video: frames, audio, and framerate." @classmethod
def define_schema(cls):
return io.Schema(
node_id="GetVideoComponents",
display_name="Get Video Components",
category="image/video",
description="Extracts all components from a video: frames, audio, and framerate.",
inputs=[
io.Video.Input("video", tooltip="The video to extract components from."),
],
outputs=[
io.Image.Output(display_name="images"),
io.Audio.Output(display_name="audio"),
io.Float.Output(display_name="fps"),
],
)
def get_components(self, video: Input.Video): @classmethod
def execute(cls, video: VideoInput) -> io.NodeOutput:
components = video.get_components() components = video.get_components()
return (components.images, components.audio, float(components.frame_rate)) return io.NodeOutput(components.images, components.audio, float(components.frame_rate))
class LoadVideo(ComfyNodeABC): class LoadVideo(io.ComfyNode):
@classmethod @classmethod
def INPUT_TYPES(cls): def define_schema(cls):
input_dir = folder_paths.get_input_directory() input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
files = folder_paths.filter_files_content_types(files, ["video"]) files = folder_paths.filter_files_content_types(files, ["video"])
return {"required": return io.Schema(
{"file": (sorted(files), {"video_upload": True})}, node_id="LoadVideo",
} display_name="Load Video",
category="image/video",
CATEGORY = "image/video" inputs=[
io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video),
RETURN_TYPES = (IO.VIDEO,) ],
FUNCTION = "load_video" outputs=[
def load_video(self, file): io.Video.Output(),
video_path = folder_paths.get_annotated_filepath(file) ],
return (InputImpl.VideoFromFile(video_path),) )
@classmethod @classmethod
def IS_CHANGED(cls, file): def execute(cls, file) -> io.NodeOutput:
video_path = folder_paths.get_annotated_filepath(file)
return io.NodeOutput(VideoFromFile(video_path))
@classmethod
def fingerprint_inputs(s, file):
video_path = folder_paths.get_annotated_filepath(file) video_path = folder_paths.get_annotated_filepath(file)
mod_time = os.path.getmtime(video_path) mod_time = os.path.getmtime(video_path)
# Instead of hashing the file, we can just use the modification time to avoid # Instead of hashing the file, we can just use the modification time to avoid
@@ -217,24 +196,23 @@ class LoadVideo(ComfyNodeABC):
return mod_time return mod_time
@classmethod @classmethod
def VALIDATE_INPUTS(cls, file): def validate_inputs(s, file):
if not folder_paths.exists_annotated_filepath(file): if not folder_paths.exists_annotated_filepath(file):
return "Invalid video file: {}".format(file) return "Invalid video file: {}".format(file)
return True return True
NODE_CLASS_MAPPINGS = {
"SaveWEBM": SaveWEBM,
"SaveVideo": SaveVideo,
"CreateVideo": CreateVideo,
"GetVideoComponents": GetVideoComponents,
"LoadVideo": LoadVideo,
}
NODE_DISPLAY_NAME_MAPPINGS = { class VideoExtension(ComfyExtension):
"SaveVideo": "Save Video", @override
"CreateVideo": "Create Video", async def get_node_list(self) -> list[type[io.ComfyNode]]:
"GetVideoComponents": "Get Video Components", return [
"LoadVideo": "Load Video", SaveWEBM,
} SaveVideo,
CreateVideo,
GetVideoComponents,
LoadVideo,
]
async def comfy_entrypoint() -> VideoExtension:
return VideoExtension()