mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-03-02 22:29:01 +00:00
Compare commits
6 Commits
jk/optiona
...
feat/savev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2952d4634 | ||
|
|
13a4008735 | ||
|
|
bb7c582b58 | ||
|
|
dcbe072e8a | ||
|
|
86317e32b8 | ||
|
|
36b9673a33 |
@@ -7,7 +7,14 @@ from comfy_api.internal.singleton import ProxiedSingleton
|
||||
from comfy_api.internal.async_to_sync import create_sync_class
|
||||
from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput
|
||||
from ._input_impl import VideoFromFile, VideoFromComponents
|
||||
from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL
|
||||
from ._util import (
|
||||
VideoCodec,
|
||||
VideoContainer,
|
||||
VideoComponents,
|
||||
VideoSpeedPreset,
|
||||
MESH,
|
||||
VOXEL,
|
||||
)
|
||||
from . import _io_public as io
|
||||
from . import _ui_public as ui
|
||||
from comfy_execution.utils import get_executing_context
|
||||
@@ -45,7 +52,9 @@ class ComfyAPI_latest(ComfyAPIBase):
|
||||
raise ValueError("node_id must be provided if not in executing context")
|
||||
|
||||
# Convert preview_image to PreviewImageTuple if needed
|
||||
to_display: PreviewImageTuple | Image.Image | ImageInput | None = preview_image
|
||||
to_display: PreviewImageTuple | Image.Image | ImageInput | None = (
|
||||
preview_image
|
||||
)
|
||||
if to_display is not None:
|
||||
# First convert to PIL Image if needed
|
||||
if isinstance(to_display, ImageInput):
|
||||
@@ -75,6 +84,7 @@ class ComfyAPI_latest(ComfyAPIBase):
|
||||
|
||||
execution: Execution
|
||||
|
||||
|
||||
class ComfyExtension(ABC):
|
||||
async def on_load(self) -> None:
|
||||
"""
|
||||
@@ -88,6 +98,7 @@ class ComfyExtension(ABC):
|
||||
Returns a list of nodes that this extension provides.
|
||||
"""
|
||||
|
||||
|
||||
class Input:
|
||||
Image = ImageInput
|
||||
Audio = AudioInput
|
||||
@@ -95,17 +106,21 @@ class Input:
|
||||
Latent = LatentInput
|
||||
Video = VideoInput
|
||||
|
||||
|
||||
class InputImpl:
|
||||
VideoFromFile = VideoFromFile
|
||||
VideoFromComponents = VideoFromComponents
|
||||
|
||||
|
||||
class Types:
|
||||
VideoCodec = VideoCodec
|
||||
VideoContainer = VideoContainer
|
||||
VideoComponents = VideoComponents
|
||||
VideoSpeedPreset = VideoSpeedPreset
|
||||
MESH = MESH
|
||||
VOXEL = VOXEL
|
||||
|
||||
|
||||
ComfyAPI = ComfyAPI_latest
|
||||
|
||||
# Create a synchronous version of the API
|
||||
|
||||
@@ -10,7 +10,13 @@ import json
|
||||
import numpy as np
|
||||
import math
|
||||
import torch
|
||||
from .._util import VideoContainer, VideoCodec, VideoComponents
|
||||
from .._util import (
|
||||
VideoContainer,
|
||||
VideoCodec,
|
||||
VideoComponents,
|
||||
VideoSpeedPreset,
|
||||
quality_to_crf,
|
||||
)
|
||||
|
||||
|
||||
def container_to_output_format(container_format: str | None) -> str | None:
|
||||
@@ -82,9 +88,9 @@ class VideoFromFile(VideoInput):
|
||||
"""
|
||||
if isinstance(self.__file, io.BytesIO):
|
||||
self.__file.seek(0) # Reset the BytesIO object to the beginning
|
||||
with av.open(self.__file, mode='r') as container:
|
||||
with av.open(self.__file, mode="r") as container:
|
||||
for stream in container.streams:
|
||||
if stream.type == 'video':
|
||||
if stream.type == "video":
|
||||
assert isinstance(stream, av.VideoStream)
|
||||
return stream.width, stream.height
|
||||
raise ValueError(f"No video stream found in file '{self.__file}'")
|
||||
@@ -138,7 +144,9 @@ class VideoFromFile(VideoInput):
|
||||
# 2. Try to estimate from duration and average_rate using only metadata
|
||||
if container.duration is not None and video_stream.average_rate:
|
||||
duration_seconds = float(container.duration / av.time_base)
|
||||
estimated_frames = int(round(duration_seconds * float(video_stream.average_rate)))
|
||||
estimated_frames = int(
|
||||
round(duration_seconds * float(video_stream.average_rate))
|
||||
)
|
||||
if estimated_frames > 0:
|
||||
return estimated_frames
|
||||
|
||||
@@ -148,7 +156,9 @@ class VideoFromFile(VideoInput):
|
||||
and video_stream.average_rate
|
||||
):
|
||||
duration_seconds = float(video_stream.duration * video_stream.time_base)
|
||||
estimated_frames = int(round(duration_seconds * float(video_stream.average_rate)))
|
||||
estimated_frames = int(
|
||||
round(duration_seconds * float(video_stream.average_rate))
|
||||
)
|
||||
if estimated_frames > 0:
|
||||
return estimated_frames
|
||||
|
||||
@@ -160,7 +170,9 @@ class VideoFromFile(VideoInput):
|
||||
frame_count += 1
|
||||
|
||||
if frame_count == 0:
|
||||
raise ValueError(f"Could not determine frame count for file '{self.__file}'")
|
||||
raise ValueError(
|
||||
f"Could not determine frame count for file '{self.__file}'"
|
||||
)
|
||||
return frame_count
|
||||
|
||||
def get_frame_rate(self) -> Fraction:
|
||||
@@ -181,7 +193,9 @@ class VideoFromFile(VideoInput):
|
||||
if video_stream.frames and container.duration:
|
||||
duration_seconds = float(container.duration / av.time_base)
|
||||
if duration_seconds > 0:
|
||||
return Fraction(video_stream.frames / duration_seconds).limit_denominator()
|
||||
return Fraction(
|
||||
video_stream.frames / duration_seconds
|
||||
).limit_denominator()
|
||||
|
||||
# Last resort: match get_components_internal default
|
||||
return Fraction(1)
|
||||
@@ -195,53 +209,69 @@ class VideoFromFile(VideoInput):
|
||||
"""
|
||||
if isinstance(self.__file, io.BytesIO):
|
||||
self.__file.seek(0)
|
||||
with av.open(self.__file, mode='r') as container:
|
||||
with av.open(self.__file, mode="r") as container:
|
||||
return container.format.name
|
||||
|
||||
def get_components_internal(self, container: InputContainer) -> VideoComponents:
|
||||
# Get video frames
|
||||
frames = []
|
||||
for frame in container.decode(video=0):
|
||||
img = frame.to_ndarray(format='rgb24') # shape: (H, W, 3)
|
||||
img = frame.to_ndarray(format="rgb24") # shape: (H, W, 3)
|
||||
img = torch.from_numpy(img) / 255.0 # shape: (H, W, 3)
|
||||
frames.append(img)
|
||||
|
||||
images = torch.stack(frames) if len(frames) > 0 else torch.zeros(0, 3, 0, 0)
|
||||
|
||||
# Get frame rate
|
||||
video_stream = next(s for s in container.streams if s.type == 'video')
|
||||
frame_rate = Fraction(video_stream.average_rate) if video_stream and video_stream.average_rate else Fraction(1)
|
||||
video_stream = next(s for s in container.streams if s.type == "video")
|
||||
frame_rate = (
|
||||
Fraction(video_stream.average_rate)
|
||||
if video_stream and video_stream.average_rate
|
||||
else Fraction(1)
|
||||
)
|
||||
|
||||
# Get audio if available
|
||||
audio = None
|
||||
try:
|
||||
container.seek(0) # Reset the container to the beginning
|
||||
for stream in container.streams:
|
||||
if stream.type != 'audio':
|
||||
if stream.type != "audio":
|
||||
continue
|
||||
assert isinstance(stream, av.AudioStream)
|
||||
audio_frames = []
|
||||
for packet in container.demux(stream):
|
||||
for frame in packet.decode():
|
||||
assert isinstance(frame, av.AudioFrame)
|
||||
audio_frames.append(frame.to_ndarray()) # shape: (channels, samples)
|
||||
audio_frames.append(
|
||||
frame.to_ndarray()
|
||||
) # shape: (channels, samples)
|
||||
if len(audio_frames) > 0:
|
||||
audio_data = np.concatenate(audio_frames, axis=1) # shape: (channels, total_samples)
|
||||
audio_tensor = torch.from_numpy(audio_data).unsqueeze(0) # shape: (1, channels, total_samples)
|
||||
audio = AudioInput({
|
||||
"waveform": audio_tensor,
|
||||
"sample_rate": int(stream.sample_rate) if stream.sample_rate else 1,
|
||||
})
|
||||
audio_data = np.concatenate(
|
||||
audio_frames, axis=1
|
||||
) # shape: (channels, total_samples)
|
||||
audio_tensor = torch.from_numpy(audio_data).unsqueeze(
|
||||
0
|
||||
) # shape: (1, channels, total_samples)
|
||||
audio = AudioInput(
|
||||
{
|
||||
"waveform": audio_tensor,
|
||||
"sample_rate": int(stream.sample_rate)
|
||||
if stream.sample_rate
|
||||
else 1,
|
||||
}
|
||||
)
|
||||
except StopIteration:
|
||||
pass # No audio stream
|
||||
|
||||
metadata = container.metadata
|
||||
return VideoComponents(images=images, audio=audio, frame_rate=frame_rate, metadata=metadata)
|
||||
return VideoComponents(
|
||||
images=images, audio=audio, frame_rate=frame_rate, metadata=metadata
|
||||
)
|
||||
|
||||
def get_components(self) -> VideoComponents:
|
||||
if isinstance(self.__file, io.BytesIO):
|
||||
self.__file.seek(0) # Reset the BytesIO object to the beginning
|
||||
with av.open(self.__file, mode='r') as container:
|
||||
with av.open(self.__file, mode="r") as container:
|
||||
return self.get_components_internal(container)
|
||||
raise ValueError(f"No video stream found in file '{self.__file}'")
|
||||
|
||||
@@ -250,17 +280,37 @@ class VideoFromFile(VideoInput):
|
||||
path: str | io.BytesIO,
|
||||
format: VideoContainer = VideoContainer.AUTO,
|
||||
codec: VideoCodec = VideoCodec.AUTO,
|
||||
metadata: Optional[dict] = None
|
||||
metadata: Optional[dict] = None,
|
||||
quality: Optional[int] = None,
|
||||
speed: Optional[VideoSpeedPreset] = None,
|
||||
profile: Optional[str] = None,
|
||||
tune: Optional[str] = None,
|
||||
row_mt: bool = True,
|
||||
tile_columns: Optional[int] = None,
|
||||
):
|
||||
if isinstance(self.__file, io.BytesIO):
|
||||
self.__file.seek(0) # Reset the BytesIO object to the beginning
|
||||
with av.open(self.__file, mode='r') as container:
|
||||
self.__file.seek(0)
|
||||
with av.open(self.__file, mode="r") as container:
|
||||
container_format = container.format.name
|
||||
video_encoding = container.streams.video[0].codec.name if len(container.streams.video) > 0 else None
|
||||
video_encoding = (
|
||||
container.streams.video[0].codec.name
|
||||
if len(container.streams.video) > 0
|
||||
else None
|
||||
)
|
||||
reuse_streams = True
|
||||
if format != VideoContainer.AUTO and format not in container_format.split(","):
|
||||
if format != VideoContainer.AUTO and format not in container_format.split(
|
||||
","
|
||||
):
|
||||
reuse_streams = False
|
||||
if codec != VideoCodec.AUTO and codec != video_encoding and video_encoding is not None:
|
||||
if (
|
||||
codec != VideoCodec.AUTO
|
||||
and codec != video_encoding
|
||||
and video_encoding is not None
|
||||
):
|
||||
reuse_streams = False
|
||||
if quality is not None or speed is not None:
|
||||
reuse_streams = False
|
||||
if profile is not None or tune is not None or tile_columns is not None:
|
||||
reuse_streams = False
|
||||
|
||||
if not reuse_streams:
|
||||
@@ -270,7 +320,13 @@ class VideoFromFile(VideoInput):
|
||||
path,
|
||||
format=format,
|
||||
codec=codec,
|
||||
metadata=metadata
|
||||
metadata=metadata,
|
||||
quality=quality,
|
||||
speed=speed,
|
||||
profile=profile,
|
||||
tune=tune,
|
||||
row_mt=row_mt,
|
||||
tile_columns=tile_columns,
|
||||
)
|
||||
|
||||
streams = container.streams
|
||||
@@ -293,8 +349,12 @@ class VideoFromFile(VideoInput):
|
||||
# Add streams to the new container
|
||||
stream_map = {}
|
||||
for stream in streams:
|
||||
if isinstance(stream, (av.VideoStream, av.AudioStream, SubtitleStream)):
|
||||
out_stream = output_container.add_stream_from_template(template=stream, opaque=True)
|
||||
if isinstance(
|
||||
stream, (av.VideoStream, av.AudioStream, SubtitleStream)
|
||||
):
|
||||
out_stream = output_container.add_stream_from_template(
|
||||
template=stream, opaque=True
|
||||
)
|
||||
stream_map[stream] = out_stream
|
||||
|
||||
# Write packets to the new container
|
||||
@@ -322,7 +382,7 @@ class VideoFromComponents(VideoInput):
|
||||
return VideoComponents(
|
||||
images=self.__components.images,
|
||||
audio=self.__components.audio,
|
||||
frame_rate=self.__components.frame_rate
|
||||
frame_rate=self.__components.frame_rate,
|
||||
)
|
||||
|
||||
def save_to(
|
||||
@@ -330,54 +390,137 @@ class VideoFromComponents(VideoInput):
|
||||
path: str,
|
||||
format: VideoContainer = VideoContainer.AUTO,
|
||||
codec: VideoCodec = VideoCodec.AUTO,
|
||||
metadata: Optional[dict] = None
|
||||
metadata: Optional[dict] = None,
|
||||
quality: Optional[int] = None,
|
||||
speed: Optional[VideoSpeedPreset] = None,
|
||||
profile: Optional[str] = None,
|
||||
tune: Optional[str] = None,
|
||||
row_mt: bool = True,
|
||||
tile_columns: Optional[int] = None,
|
||||
):
|
||||
if format != VideoContainer.AUTO and format != VideoContainer.MP4:
|
||||
raise ValueError("Only MP4 format is supported for now")
|
||||
if codec != VideoCodec.AUTO and codec != VideoCodec.H264:
|
||||
raise ValueError("Only H264 codec is supported for now")
|
||||
extra_kwargs = {}
|
||||
if isinstance(format, VideoContainer) and format != VideoContainer.AUTO:
|
||||
extra_kwargs["format"] = format.value
|
||||
with av.open(path, mode='w', options={'movflags': 'use_metadata_tags'}, **extra_kwargs) as output:
|
||||
# Add metadata before writing any streams
|
||||
"""
|
||||
Save video to file with optional encoding parameters.
|
||||
|
||||
Args:
|
||||
path: Output file path
|
||||
format: Container format (mp4, webm, or auto)
|
||||
codec: Video codec (h264, vp9, or auto)
|
||||
metadata: Optional metadata dict to embed
|
||||
quality: Quality percentage 0-100 (100=best). Maps to CRF internally.
|
||||
speed: Encoding speed preset. Slower = better compression.
|
||||
profile: H.264 profile (baseline, main, high)
|
||||
tune: H.264 tune option (film, animation, grain, etc.)
|
||||
row_mt: VP9 row-based multi-threading
|
||||
tile_columns: VP9 tile columns (power of 2)
|
||||
"""
|
||||
resolved_format = format
|
||||
resolved_codec = codec
|
||||
|
||||
if resolved_format == VideoContainer.AUTO:
|
||||
resolved_format = VideoContainer.MP4
|
||||
if resolved_codec == VideoCodec.AUTO:
|
||||
if resolved_format == VideoContainer.WEBM:
|
||||
resolved_codec = VideoCodec.VP9
|
||||
else:
|
||||
resolved_codec = VideoCodec.H264
|
||||
|
||||
if resolved_format == VideoContainer.WEBM and resolved_codec == VideoCodec.H264:
|
||||
raise ValueError("H264 codec is not supported with WebM container")
|
||||
if resolved_format == VideoContainer.MP4 and resolved_codec == VideoCodec.VP9:
|
||||
raise ValueError("VP9 codec is not supported with MP4 container")
|
||||
|
||||
codec_map = {
|
||||
VideoCodec.H264: "libx264",
|
||||
VideoCodec.VP9: "libvpx-vp9",
|
||||
}
|
||||
if resolved_codec not in codec_map:
|
||||
raise ValueError(f"Unsupported codec: {resolved_codec}")
|
||||
ffmpeg_codec = codec_map[resolved_codec]
|
||||
|
||||
extra_kwargs = {"format": resolved_format.value}
|
||||
|
||||
container_options = {}
|
||||
if resolved_format == VideoContainer.MP4:
|
||||
container_options["movflags"] = "use_metadata_tags"
|
||||
|
||||
with av.open(
|
||||
path, mode="w", options=container_options, **extra_kwargs
|
||||
) as output:
|
||||
if metadata is not None:
|
||||
for key, value in metadata.items():
|
||||
output.metadata[key] = json.dumps(value)
|
||||
|
||||
frame_rate = Fraction(round(self.__components.frame_rate * 1000), 1000)
|
||||
# Create a video stream
|
||||
video_stream = output.add_stream('h264', rate=frame_rate)
|
||||
video_stream = output.add_stream(ffmpeg_codec, rate=frame_rate)
|
||||
video_stream.width = self.__components.images.shape[2]
|
||||
video_stream.height = self.__components.images.shape[1]
|
||||
video_stream.pix_fmt = 'yuv420p'
|
||||
|
||||
# Create an audio stream
|
||||
video_stream.pix_fmt = "yuv420p"
|
||||
if resolved_codec == VideoCodec.VP9:
|
||||
video_stream.bit_rate = 0
|
||||
|
||||
if quality is not None:
|
||||
crf = quality_to_crf(quality, ffmpeg_codec)
|
||||
video_stream.options["crf"] = str(crf)
|
||||
|
||||
if speed is not None and speed != VideoSpeedPreset.AUTO:
|
||||
if isinstance(speed, str):
|
||||
speed = VideoSpeedPreset(speed)
|
||||
preset = speed.to_ffmpeg_preset(ffmpeg_codec)
|
||||
if resolved_codec == VideoCodec.VP9:
|
||||
video_stream.options["cpu-used"] = preset
|
||||
else:
|
||||
video_stream.options["preset"] = preset
|
||||
|
||||
# H.264-specific options
|
||||
if resolved_codec == VideoCodec.H264:
|
||||
if profile is not None:
|
||||
video_stream.options["profile"] = profile
|
||||
if tune is not None:
|
||||
video_stream.options["tune"] = tune
|
||||
|
||||
# VP9-specific options
|
||||
if resolved_codec == VideoCodec.VP9:
|
||||
if row_mt:
|
||||
video_stream.options["row-mt"] = "1"
|
||||
if tile_columns is not None:
|
||||
video_stream.options["tile-columns"] = str(tile_columns)
|
||||
|
||||
audio_sample_rate = 1
|
||||
audio_stream: Optional[av.AudioStream] = None
|
||||
if self.__components.audio:
|
||||
audio_sample_rate = int(self.__components.audio['sample_rate'])
|
||||
audio_stream = output.add_stream('aac', rate=audio_sample_rate)
|
||||
audio_sample_rate = int(self.__components.audio["sample_rate"])
|
||||
audio_codec = (
|
||||
"libopus" if resolved_format == VideoContainer.WEBM else "aac"
|
||||
)
|
||||
audio_stream = output.add_stream(audio_codec, rate=audio_sample_rate)
|
||||
|
||||
# Encode video
|
||||
for i, frame in enumerate(self.__components.images):
|
||||
img = (frame * 255).clamp(0, 255).byte().cpu().numpy() # shape: (H, W, 3)
|
||||
frame = av.VideoFrame.from_ndarray(img, format='rgb24')
|
||||
frame = frame.reformat(format='yuv420p') # Convert to YUV420P as required by h264
|
||||
packet = video_stream.encode(frame)
|
||||
img = (frame * 255).clamp(0, 255).byte().cpu().numpy()
|
||||
video_frame = av.VideoFrame.from_ndarray(img, format="rgb24")
|
||||
video_frame = video_frame.reformat(format="yuv420p")
|
||||
packet = video_stream.encode(video_frame)
|
||||
output.mux(packet)
|
||||
|
||||
# Flush video
|
||||
packet = video_stream.encode(None)
|
||||
output.mux(packet)
|
||||
|
||||
if audio_stream and self.__components.audio:
|
||||
waveform = self.__components.audio['waveform']
|
||||
waveform = waveform[:, :, :math.ceil((audio_sample_rate / frame_rate) * self.__components.images.shape[0])]
|
||||
frame = av.AudioFrame.from_ndarray(waveform.movedim(2, 1).reshape(1, -1).float().numpy(), format='flt', layout='mono' if waveform.shape[1] == 1 else 'stereo')
|
||||
frame.sample_rate = audio_sample_rate
|
||||
frame.pts = 0
|
||||
output.mux(audio_stream.encode(frame))
|
||||
|
||||
# Flush encoder
|
||||
waveform = self.__components.audio["waveform"]
|
||||
waveform = waveform[
|
||||
:,
|
||||
:,
|
||||
: math.ceil(
|
||||
(audio_sample_rate / frame_rate)
|
||||
* self.__components.images.shape[0]
|
||||
),
|
||||
]
|
||||
audio_frame = av.AudioFrame.from_ndarray(
|
||||
waveform.movedim(2, 1).reshape(1, -1).float().numpy(),
|
||||
format="flt",
|
||||
layout="mono" if waveform.shape[1] == 1 else "stereo",
|
||||
)
|
||||
audio_frame.sample_rate = audio_sample_rate
|
||||
audio_frame.pts = 0
|
||||
output.mux(audio_stream.encode(audio_frame))
|
||||
output.mux(audio_stream.encode(None))
|
||||
|
||||
@@ -153,7 +153,7 @@ class Input(_IO_V3):
|
||||
'''
|
||||
Base class for a V3 Input.
|
||||
'''
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
super().__init__()
|
||||
self.id = id
|
||||
self.display_name = display_name
|
||||
@@ -162,6 +162,7 @@ class Input(_IO_V3):
|
||||
self.lazy = lazy
|
||||
self.extra_dict = extra_dict if extra_dict is not None else {}
|
||||
self.rawLink = raw_link
|
||||
self.advanced = advanced
|
||||
|
||||
def as_dict(self):
|
||||
return prune_dict({
|
||||
@@ -170,6 +171,7 @@ class Input(_IO_V3):
|
||||
"tooltip": self.tooltip,
|
||||
"lazy": self.lazy,
|
||||
"rawLink": self.rawLink,
|
||||
"advanced": self.advanced,
|
||||
}) | prune_dict(self.extra_dict)
|
||||
|
||||
def get_io_type(self):
|
||||
@@ -184,8 +186,8 @@ class WidgetInput(Input):
|
||||
'''
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
|
||||
default: Any=None,
|
||||
socketless: bool=None, widget_type: str=None, force_input: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link)
|
||||
socketless: bool=None, widget_type: str=None, force_input: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link, advanced)
|
||||
self.default = default
|
||||
self.socketless = socketless
|
||||
self.widget_type = widget_type
|
||||
@@ -242,8 +244,8 @@ class Boolean(ComfyTypeIO):
|
||||
'''Boolean input.'''
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
|
||||
default: bool=None, label_on: str=None, label_off: str=None,
|
||||
socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link)
|
||||
socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link, advanced)
|
||||
self.label_on = label_on
|
||||
self.label_off = label_off
|
||||
self.default: bool
|
||||
@@ -262,8 +264,8 @@ class Int(ComfyTypeIO):
|
||||
'''Integer input.'''
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
|
||||
default: int=None, min: int=None, max: int=None, step: int=None, control_after_generate: bool=None,
|
||||
display_mode: NumberDisplay=None, socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link)
|
||||
display_mode: NumberDisplay=None, socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link, advanced)
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.step = step
|
||||
@@ -288,8 +290,8 @@ class Float(ComfyTypeIO):
|
||||
'''Float input.'''
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
|
||||
default: float=None, min: float=None, max: float=None, step: float=None, round: float=None,
|
||||
display_mode: NumberDisplay=None, socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link)
|
||||
display_mode: NumberDisplay=None, socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link, advanced)
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.step = step
|
||||
@@ -314,8 +316,8 @@ class String(ComfyTypeIO):
|
||||
'''String input.'''
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
|
||||
multiline=False, placeholder: str=None, default: str=None, dynamic_prompts: bool=None,
|
||||
socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link)
|
||||
socketless: bool=None, force_input: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link, advanced)
|
||||
self.multiline = multiline
|
||||
self.placeholder = placeholder
|
||||
self.dynamic_prompts = dynamic_prompts
|
||||
@@ -350,12 +352,13 @@ class Combo(ComfyTypeIO):
|
||||
socketless: bool=None,
|
||||
extra_dict=None,
|
||||
raw_link: bool=None,
|
||||
advanced: bool=None,
|
||||
):
|
||||
if isinstance(options, type) and issubclass(options, Enum):
|
||||
options = [v.value for v in options]
|
||||
if isinstance(default, Enum):
|
||||
default = default.value
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, None, extra_dict, raw_link)
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, None, extra_dict, raw_link, advanced)
|
||||
self.multiselect = False
|
||||
self.options = options
|
||||
self.control_after_generate = control_after_generate
|
||||
@@ -387,8 +390,8 @@ class MultiCombo(ComfyTypeI):
|
||||
class Input(Combo.Input):
|
||||
def __init__(self, id: str, options: list[str], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
|
||||
default: list[str]=None, placeholder: str=None, chip: bool=None, control_after_generate: bool=None,
|
||||
socketless: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
super().__init__(id, options, display_name, optional, tooltip, lazy, default, control_after_generate, socketless=socketless, extra_dict=extra_dict, raw_link=raw_link)
|
||||
socketless: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
super().__init__(id, options, display_name, optional, tooltip, lazy, default, control_after_generate, socketless=socketless, extra_dict=extra_dict, raw_link=raw_link, advanced=advanced)
|
||||
self.multiselect = True
|
||||
self.placeholder = placeholder
|
||||
self.chip = chip
|
||||
@@ -421,9 +424,9 @@ class Webcam(ComfyTypeIO):
|
||||
Type = str
|
||||
def __init__(
|
||||
self, id: str, display_name: str=None, optional=False,
|
||||
tooltip: str=None, lazy: bool=None, default: str=None, socketless: bool=None, extra_dict=None, raw_link: bool=None
|
||||
tooltip: str=None, lazy: bool=None, default: str=None, socketless: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None
|
||||
):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, None, extra_dict, raw_link)
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, None, extra_dict, raw_link, advanced)
|
||||
|
||||
|
||||
@comfytype(io_type="MASK")
|
||||
@@ -776,7 +779,7 @@ class MultiType:
|
||||
'''
|
||||
Input that permits more than one input type; if `id` is an instance of `ComfyType.Input`, then that input will be used to create a widget (if applicable) with overridden values.
|
||||
'''
|
||||
def __init__(self, id: str | Input, types: list[type[_ComfyType] | _ComfyType], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
def __init__(self, id: str | Input, types: list[type[_ComfyType] | _ComfyType], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
# if id is an Input, then use that Input with overridden values
|
||||
self.input_override = None
|
||||
if isinstance(id, Input):
|
||||
@@ -789,7 +792,7 @@ class MultiType:
|
||||
# if is a widget input, make sure widget_type is set appropriately
|
||||
if isinstance(self.input_override, WidgetInput):
|
||||
self.input_override.widget_type = self.input_override.get_io_type()
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link)
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link, advanced)
|
||||
self._io_types = types
|
||||
|
||||
@property
|
||||
@@ -843,8 +846,8 @@ class MatchType(ComfyTypeIO):
|
||||
|
||||
class Input(Input):
|
||||
def __init__(self, id: str, template: MatchType.Template,
|
||||
display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link)
|
||||
display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link, advanced)
|
||||
self.template = template
|
||||
|
||||
def as_dict(self):
|
||||
@@ -1119,8 +1122,8 @@ class ImageCompare(ComfyTypeI):
|
||||
|
||||
class Input(WidgetInput):
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
|
||||
socketless: bool=True):
|
||||
super().__init__(id, display_name, optional, tooltip, None, None, socketless)
|
||||
socketless: bool=True, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, None, None, socketless, None, None, None, None, advanced)
|
||||
|
||||
def as_dict(self):
|
||||
return super().as_dict()
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
from .video_types import VideoContainer, VideoCodec, VideoComponents
|
||||
from .video_types import (
|
||||
VideoContainer,
|
||||
VideoCodec,
|
||||
VideoComponents,
|
||||
VideoSpeedPreset,
|
||||
quality_to_crf,
|
||||
)
|
||||
from .geometry_types import VOXEL, MESH
|
||||
from .image_types import SVG
|
||||
|
||||
@@ -7,6 +13,8 @@ __all__ = [
|
||||
"VideoContainer",
|
||||
"VideoCodec",
|
||||
"VideoComponents",
|
||||
"VideoSpeedPreset",
|
||||
"quality_to_crf",
|
||||
"VOXEL",
|
||||
"MESH",
|
||||
"SVG",
|
||||
|
||||
@@ -5,9 +5,11 @@ from fractions import Fraction
|
||||
from typing import Optional
|
||||
from .._input import ImageInput, AudioInput
|
||||
|
||||
|
||||
class VideoCodec(str, Enum):
|
||||
AUTO = "auto"
|
||||
H264 = "h264"
|
||||
VP9 = "vp9"
|
||||
|
||||
@classmethod
|
||||
def as_input(cls) -> list[str]:
|
||||
@@ -16,9 +18,11 @@ class VideoCodec(str, Enum):
|
||||
"""
|
||||
return [member.value for member in cls]
|
||||
|
||||
|
||||
class VideoContainer(str, Enum):
|
||||
AUTO = "auto"
|
||||
MP4 = "mp4"
|
||||
WEBM = "webm"
|
||||
|
||||
@classmethod
|
||||
def as_input(cls) -> list[str]:
|
||||
@@ -36,8 +40,73 @@ class VideoContainer(str, Enum):
|
||||
value = cls(value)
|
||||
if value == VideoContainer.MP4 or value == VideoContainer.AUTO:
|
||||
return "mp4"
|
||||
if value == VideoContainer.WEBM:
|
||||
return "webm"
|
||||
return ""
|
||||
|
||||
|
||||
class VideoSpeedPreset(str, Enum):
|
||||
"""Encoding speed presets - slower = better compression at same quality."""
|
||||
|
||||
AUTO = "auto"
|
||||
FASTEST = "Fastest"
|
||||
FAST = "Fast"
|
||||
BALANCED = "Balanced"
|
||||
QUALITY = "Quality"
|
||||
BEST = "Best"
|
||||
|
||||
@classmethod
|
||||
def as_input(cls) -> list[str]:
|
||||
return [member.value for member in cls]
|
||||
|
||||
def to_ffmpeg_preset(self, codec: str = "h264") -> str:
|
||||
"""Convert to FFmpeg preset string for the given codec."""
|
||||
h264_map = {
|
||||
VideoSpeedPreset.FASTEST: "ultrafast",
|
||||
VideoSpeedPreset.FAST: "veryfast",
|
||||
VideoSpeedPreset.BALANCED: "medium",
|
||||
VideoSpeedPreset.QUALITY: "slow",
|
||||
VideoSpeedPreset.BEST: "veryslow",
|
||||
VideoSpeedPreset.AUTO: "medium",
|
||||
}
|
||||
vp9_map = {
|
||||
VideoSpeedPreset.FASTEST: "0",
|
||||
VideoSpeedPreset.FAST: "1",
|
||||
VideoSpeedPreset.BALANCED: "2",
|
||||
VideoSpeedPreset.QUALITY: "3",
|
||||
VideoSpeedPreset.BEST: "4",
|
||||
VideoSpeedPreset.AUTO: "2",
|
||||
}
|
||||
if codec in ("vp9", "libvpx-vp9"):
|
||||
return vp9_map.get(self, "2")
|
||||
return h264_map.get(self, "medium")
|
||||
|
||||
|
||||
def quality_to_crf(quality: int, codec: str = "h264") -> int:
|
||||
"""
|
||||
Map 0-100 quality percentage to codec-appropriate CRF value.
|
||||
|
||||
Args:
|
||||
quality: 0-100 where 100 is best quality
|
||||
codec: The codec being used (h264, vp9, etc.)
|
||||
|
||||
Returns:
|
||||
CRF value appropriate for the codec
|
||||
"""
|
||||
quality = max(0, min(100, quality))
|
||||
|
||||
if codec in ("h264", "libx264"):
|
||||
# h264: CRF 0-51 (lower = better), typical range 12-40
|
||||
# quality 100 → CRF 12, quality 0 → CRF 40
|
||||
return int(40 - (quality / 100) * 28)
|
||||
elif codec in ("vp9", "libvpx-vp9"):
|
||||
# vp9: CRF 0-63 (lower = better), typical range 15-50
|
||||
# quality 100 → CRF 15, quality 0 → CRF 50
|
||||
return int(50 - (quality / 100) * 35)
|
||||
# Default fallback
|
||||
return 23
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoComponents:
|
||||
"""
|
||||
@@ -48,5 +117,3 @@ class VideoComponents:
|
||||
frame_rate: Fraction
|
||||
audio: Optional[AudioInput] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import av
|
||||
import torch
|
||||
import folder_paths
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import av
|
||||
import folder_paths
|
||||
import torch
|
||||
from typing_extensions import override
|
||||
from fractions import Fraction
|
||||
from comfy_api.latest import ComfyExtension, io, ui, Input, InputImpl, Types
|
||||
from comfy.cli_args import args
|
||||
|
||||
|
||||
class SaveWEBM(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
@@ -23,7 +26,14 @@ class SaveWEBM(io.ComfyNode):
|
||||
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."),
|
||||
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.",
|
||||
),
|
||||
],
|
||||
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
|
||||
is_output_node=True,
|
||||
@@ -31,8 +41,13 @@ class SaveWEBM(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, images, codec, fps, filename_prefix, crf) -> 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],
|
||||
)
|
||||
)
|
||||
|
||||
file = f"{filename}_{counter:05}_.webm"
|
||||
@@ -46,51 +61,196 @@ class SaveWEBM(io.ComfyNode):
|
||||
container.metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
|
||||
|
||||
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)
|
||||
)
|
||||
stream.width = images.shape[-2]
|
||||
stream.height = images.shape[-3]
|
||||
stream.pix_fmt = "yuv420p10le" if codec == "av1" else "yuv420p"
|
||||
stream.bit_rate = 0
|
||||
stream.options = {'crf': str(crf)}
|
||||
stream.options = {"crf": str(crf)}
|
||||
if codec == "av1":
|
||||
stream.options["preset"] = "6"
|
||||
|
||||
for frame in images:
|
||||
frame = av.VideoFrame.from_ndarray(torch.clamp(frame[..., :3] * 255, min=0, max=255).to(device=torch.device("cpu"), dtype=torch.uint8).numpy(), format="rgb24")
|
||||
frame = av.VideoFrame.from_ndarray(
|
||||
torch.clamp(frame[..., :3] * 255, min=0, max=255)
|
||||
.to(device=torch.device("cpu"), dtype=torch.uint8)
|
||||
.numpy(),
|
||||
format="rgb24",
|
||||
)
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
container.mux(stream.encode())
|
||||
container.close()
|
||||
|
||||
return io.NodeOutput(ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))
|
||||
return io.NodeOutput(
|
||||
ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)])
|
||||
)
|
||||
|
||||
|
||||
class SaveVideo(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
# H264-specific inputs
|
||||
h264_quality = io.Int.Input(
|
||||
"quality",
|
||||
default=80,
|
||||
min=0,
|
||||
max=100,
|
||||
step=1,
|
||||
display_name="Quality",
|
||||
tooltip="Output quality (0-100). Higher = better quality, larger files. "
|
||||
"Internally maps to CRF: 100→CRF 12, 50→CRF 23, 0→CRF 40.",
|
||||
)
|
||||
h264_speed = io.Combo.Input(
|
||||
"speed",
|
||||
options=Types.VideoSpeedPreset.as_input(),
|
||||
default="auto",
|
||||
display_name="Encoding Speed",
|
||||
tooltip="Encoding speed preset. Slower = better compression at same quality. "
|
||||
"Maps to FFmpeg presets: Fastest=ultrafast, Balanced=medium, Best=veryslow.",
|
||||
)
|
||||
h264_profile = io.Combo.Input(
|
||||
"profile",
|
||||
options=["auto", "baseline", "main", "high"],
|
||||
default="auto",
|
||||
display_name="Profile",
|
||||
tooltip="H.264 profile. 'baseline' for max compatibility (older devices), "
|
||||
"'main' for standard use, 'high' for best quality/compression.",
|
||||
advanced=True,
|
||||
)
|
||||
h264_tune = io.Combo.Input(
|
||||
"tune",
|
||||
options=[
|
||||
"auto",
|
||||
"film",
|
||||
"animation",
|
||||
"grain",
|
||||
"stillimage",
|
||||
"fastdecode",
|
||||
"zerolatency",
|
||||
],
|
||||
default="auto",
|
||||
display_name="Tune",
|
||||
tooltip="Optimize encoding for specific content types. "
|
||||
"'film' for live action, 'animation' for cartoons/anime, 'grain' to preserve film grain.",
|
||||
advanced=True,
|
||||
)
|
||||
|
||||
# VP9-specific inputs
|
||||
vp9_quality = io.Int.Input(
|
||||
"quality",
|
||||
default=80,
|
||||
min=0,
|
||||
max=100,
|
||||
step=1,
|
||||
display_name="Quality",
|
||||
tooltip="Output quality (0-100). Higher = better quality, larger files. "
|
||||
"Internally maps to CRF: 100→CRF 15, 50→CRF 33, 0→CRF 50.",
|
||||
)
|
||||
vp9_speed = io.Combo.Input(
|
||||
"speed",
|
||||
options=Types.VideoSpeedPreset.as_input(),
|
||||
default="auto",
|
||||
display_name="Encoding Speed",
|
||||
tooltip="Encoding speed. Slower = better compression. "
|
||||
"Maps to VP9 cpu-used: Fastest=0, Balanced=2, Best=4.",
|
||||
)
|
||||
vp9_row_mt = io.Boolean.Input(
|
||||
"row_mt",
|
||||
default=True,
|
||||
display_name="Row Multi-threading",
|
||||
tooltip="Enable row-based multi-threading for faster encoding on multi-core CPUs.",
|
||||
advanced=True,
|
||||
)
|
||||
vp9_tile_columns = io.Combo.Input(
|
||||
"tile_columns",
|
||||
options=["auto", "0", "1", "2", "3", "4"],
|
||||
default="auto",
|
||||
display_name="Tile Columns",
|
||||
tooltip="Number of tile columns (as power of 2). More tiles = faster encoding "
|
||||
"but slightly worse compression. 'auto' picks based on resolution.",
|
||||
advanced=True,
|
||||
)
|
||||
|
||||
return io.Schema(
|
||||
node_id="SaveVideo",
|
||||
display_name="Save Video",
|
||||
category="image/video",
|
||||
description="Saves the input images to your ComfyUI output directory.",
|
||||
description="Saves video to the output directory. "
|
||||
"When format/codec/quality differ from source, the video is re-encoded.",
|
||||
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=Types.VideoContainer.as_input(), default="auto", tooltip="The format to save the video as."),
|
||||
io.Combo.Input("codec", options=Types.VideoCodec.as_input(), default="auto", tooltip="The codec to use for the video."),
|
||||
io.String.Input(
|
||||
"filename_prefix",
|
||||
default="video/ComfyUI",
|
||||
tooltip="The prefix for the file to save. "
|
||||
"Supports formatting like %date:yyyy-MM-dd%.",
|
||||
),
|
||||
io.DynamicCombo.Input(
|
||||
"codec",
|
||||
options=[
|
||||
io.DynamicCombo.Option("auto", []),
|
||||
io.DynamicCombo.Option(
|
||||
"h264", [h264_quality, h264_speed, h264_profile, h264_tune]
|
||||
),
|
||||
io.DynamicCombo.Option(
|
||||
"vp9",
|
||||
[vp9_quality, vp9_speed, vp9_row_mt, vp9_tile_columns],
|
||||
),
|
||||
],
|
||||
tooltip="Video codec. 'auto' preserves source when possible. "
|
||||
"h264 outputs MP4, vp9 outputs WebM.",
|
||||
),
|
||||
],
|
||||
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
|
||||
is_output_node=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, video: Input.Video, filename_prefix, format: str, codec) -> io.NodeOutput:
|
||||
def execute(
|
||||
cls, video: Input.Video, filename_prefix: str, codec: dict
|
||||
) -> io.NodeOutput:
|
||||
selected_codec = codec.get("codec", "auto")
|
||||
quality = codec.get("quality")
|
||||
speed_str = codec.get("speed", "auto")
|
||||
|
||||
# H264-specific options
|
||||
profile = codec.get("profile", "auto")
|
||||
tune = codec.get("tune", "auto")
|
||||
|
||||
# VP9-specific options
|
||||
row_mt = codec.get("row_mt", True)
|
||||
tile_columns = codec.get("tile_columns", "auto")
|
||||
|
||||
if selected_codec == "auto":
|
||||
resolved_format = Types.VideoContainer.AUTO
|
||||
resolved_codec = Types.VideoCodec.AUTO
|
||||
elif selected_codec == "h264":
|
||||
resolved_format = Types.VideoContainer.MP4
|
||||
resolved_codec = Types.VideoCodec.H264
|
||||
elif selected_codec == "vp9":
|
||||
resolved_format = Types.VideoContainer.WEBM
|
||||
resolved_codec = Types.VideoCodec.VP9
|
||||
else:
|
||||
resolved_format = Types.VideoContainer.AUTO
|
||||
resolved_codec = Types.VideoCodec.AUTO
|
||||
|
||||
speed = None
|
||||
if speed_str:
|
||||
try:
|
||||
speed = Types.VideoSpeedPreset(speed_str)
|
||||
except (ValueError, TypeError):
|
||||
logging.warning(f"Invalid speed preset '{speed_str}', using default")
|
||||
|
||||
width, height = video.get_dimensions()
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix,
|
||||
folder_paths.get_output_directory(),
|
||||
width,
|
||||
height
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||
folder_paths.get_save_image_path(
|
||||
filename_prefix, folder_paths.get_output_directory(), width, height
|
||||
)
|
||||
)
|
||||
|
||||
saved_metadata = None
|
||||
if not args.disable_metadata:
|
||||
metadata = {}
|
||||
@@ -100,15 +260,25 @@ class SaveVideo(io.ComfyNode):
|
||||
metadata["prompt"] = cls.hidden.prompt
|
||||
if len(metadata) > 0:
|
||||
saved_metadata = metadata
|
||||
file = f"{filename}_{counter:05}_.{Types.VideoContainer.get_extension(format)}"
|
||||
|
||||
extension = Types.VideoContainer.get_extension(resolved_format)
|
||||
file = f"{filename}_{counter:05}_.{extension}"
|
||||
video.save_to(
|
||||
os.path.join(full_output_folder, file),
|
||||
format=Types.VideoContainer(format),
|
||||
codec=codec,
|
||||
metadata=saved_metadata
|
||||
format=resolved_format,
|
||||
codec=resolved_codec,
|
||||
metadata=saved_metadata,
|
||||
quality=quality,
|
||||
speed=speed,
|
||||
profile=profile if profile != "auto" else None,
|
||||
tune=tune if tune != "auto" else None,
|
||||
row_mt=row_mt,
|
||||
tile_columns=int(tile_columns) if tile_columns != "auto" else None,
|
||||
)
|
||||
|
||||
return io.NodeOutput(ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))
|
||||
return io.NodeOutput(
|
||||
ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)])
|
||||
)
|
||||
|
||||
|
||||
class CreateVideo(io.ComfyNode):
|
||||
@@ -122,7 +292,9 @@ class CreateVideo(io.ComfyNode):
|
||||
inputs=[
|
||||
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."),
|
||||
io.Audio.Input(
|
||||
"audio", optional=True, tooltip="The audio to add to the video."
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
io.Video.Output(),
|
||||
@@ -130,11 +302,18 @@ class CreateVideo(io.ComfyNode):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, images: Input.Image, fps: float, audio: Optional[Input.Audio] = None) -> io.NodeOutput:
|
||||
def execute(
|
||||
cls, images: Input.Image, fps: float, audio: Optional[Input.Audio] = None
|
||||
) -> io.NodeOutput:
|
||||
return io.NodeOutput(
|
||||
InputImpl.VideoFromComponents(Types.VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps)))
|
||||
InputImpl.VideoFromComponents(
|
||||
Types.VideoComponents(
|
||||
images=images, audio=audio, frame_rate=Fraction(fps)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class GetVideoComponents(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
@@ -144,7 +323,9 @@ class GetVideoComponents(io.ComfyNode):
|
||||
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."),
|
||||
io.Video.Input(
|
||||
"video", tooltip="The video to extract components from."
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
io.Image.Output(display_name="images"),
|
||||
@@ -156,21 +337,29 @@ class GetVideoComponents(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, video: Input.Video) -> io.NodeOutput:
|
||||
components = video.get_components()
|
||||
return io.NodeOutput(components.images, components.audio, float(components.frame_rate))
|
||||
return io.NodeOutput(
|
||||
components.images, components.audio, float(components.frame_rate)
|
||||
)
|
||||
|
||||
|
||||
class LoadVideo(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
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"])
|
||||
return io.Schema(
|
||||
node_id="LoadVideo",
|
||||
display_name="Load Video",
|
||||
category="image/video",
|
||||
inputs=[
|
||||
io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video),
|
||||
io.Combo.Input(
|
||||
"file", options=sorted(files), upload=io.UploadType.video
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
io.Video.Output(),
|
||||
@@ -209,5 +398,6 @@ class VideoExtension(ComfyExtension):
|
||||
LoadVideo,
|
||||
]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> VideoExtension:
|
||||
return VideoExtension()
|
||||
|
||||
@@ -6,7 +6,7 @@ import av
|
||||
import io
|
||||
from fractions import Fraction
|
||||
from comfy_api.input_impl.video_types import VideoFromFile, VideoFromComponents
|
||||
from comfy_api.util.video_types import VideoComponents
|
||||
from comfy_api.util.video_types import VideoComponents, VideoSpeedPreset, quality_to_crf
|
||||
from comfy_api.input.basic_types import AudioInput
|
||||
from av.error import InvalidDataError
|
||||
|
||||
@@ -237,3 +237,71 @@ def test_duration_consistency(video_components):
|
||||
manual_duration = float(components.images.shape[0] / components.frame_rate)
|
||||
|
||||
assert duration == pytest.approx(manual_duration)
|
||||
|
||||
|
||||
class TestVideoSpeedPreset:
|
||||
"""Tests for VideoSpeedPreset enum and its methods."""
|
||||
|
||||
def test_as_input_returns_all_values(self):
|
||||
"""as_input() returns all preset values"""
|
||||
values = VideoSpeedPreset.as_input()
|
||||
assert values == ["auto", "Fastest", "Fast", "Balanced", "Quality", "Best"]
|
||||
|
||||
def test_to_ffmpeg_preset_h264(self):
|
||||
"""H.264 presets map correctly"""
|
||||
assert VideoSpeedPreset.FASTEST.to_ffmpeg_preset("h264") == "ultrafast"
|
||||
assert VideoSpeedPreset.FAST.to_ffmpeg_preset("h264") == "veryfast"
|
||||
assert VideoSpeedPreset.BALANCED.to_ffmpeg_preset("h264") == "medium"
|
||||
assert VideoSpeedPreset.QUALITY.to_ffmpeg_preset("h264") == "slow"
|
||||
assert VideoSpeedPreset.BEST.to_ffmpeg_preset("h264") == "veryslow"
|
||||
assert VideoSpeedPreset.AUTO.to_ffmpeg_preset("h264") == "medium"
|
||||
|
||||
def test_to_ffmpeg_preset_vp9(self):
|
||||
"""VP9 presets map correctly"""
|
||||
assert VideoSpeedPreset.FASTEST.to_ffmpeg_preset("vp9") == "0"
|
||||
assert VideoSpeedPreset.FAST.to_ffmpeg_preset("vp9") == "1"
|
||||
assert VideoSpeedPreset.BALANCED.to_ffmpeg_preset("vp9") == "2"
|
||||
assert VideoSpeedPreset.QUALITY.to_ffmpeg_preset("vp9") == "3"
|
||||
assert VideoSpeedPreset.BEST.to_ffmpeg_preset("vp9") == "4"
|
||||
assert VideoSpeedPreset.AUTO.to_ffmpeg_preset("vp9") == "2"
|
||||
|
||||
def test_to_ffmpeg_preset_libvpx_vp9(self):
|
||||
"""libvpx-vp9 codec string also maps to VP9 presets"""
|
||||
assert VideoSpeedPreset.BALANCED.to_ffmpeg_preset("libvpx-vp9") == "2"
|
||||
|
||||
def test_to_ffmpeg_preset_default_to_h264(self):
|
||||
"""Unknown codecs default to H.264 mapping"""
|
||||
assert VideoSpeedPreset.BALANCED.to_ffmpeg_preset("unknown") == "medium"
|
||||
|
||||
|
||||
class TestQualityToCrf:
|
||||
"""Tests for quality_to_crf helper function."""
|
||||
|
||||
def test_h264_quality_boundaries(self):
|
||||
"""H.264 quality maps to correct CRF range (12-40)"""
|
||||
assert quality_to_crf(100, "h264") == 12
|
||||
assert quality_to_crf(0, "h264") == 40
|
||||
assert quality_to_crf(50, "h264") == 26
|
||||
|
||||
def test_h264_libx264_alias(self):
|
||||
"""libx264 codec string uses H.264 mapping"""
|
||||
assert quality_to_crf(100, "libx264") == 12
|
||||
|
||||
def test_vp9_quality_boundaries(self):
|
||||
"""VP9 quality maps to correct CRF range (15-50)"""
|
||||
assert quality_to_crf(100, "vp9") == 15
|
||||
assert quality_to_crf(0, "vp9") == 50
|
||||
assert quality_to_crf(50, "vp9") == 32
|
||||
|
||||
def test_vp9_libvpx_alias(self):
|
||||
"""libvpx-vp9 codec string uses VP9 mapping"""
|
||||
assert quality_to_crf(100, "libvpx-vp9") == 15
|
||||
|
||||
def test_quality_clamping(self):
|
||||
"""Quality values outside 0-100 are clamped"""
|
||||
assert quality_to_crf(150, "h264") == 12
|
||||
assert quality_to_crf(-50, "h264") == 40
|
||||
|
||||
def test_unknown_codec_fallback(self):
|
||||
"""Unknown codecs return default CRF 23"""
|
||||
assert quality_to_crf(50, "unknown_codec") == 23
|
||||
|
||||
Reference in New Issue
Block a user