Compare commits

...

6 Commits

Author SHA1 Message Date
bymyself
f2952d4634 fix: resolve linting issues in SaveVideo codec options
- Add missing 'logging' import used in speed preset error handling
- Apply ruff format to all PR-changed files

Amp-Thread-ID: https://ampcode.com/threads/T-019ca1cb-0150-7549-8b1b-6713060d3408
2026-02-27 17:15:06 -08:00
bymyself
13a4008735 refactor: simplify redundant code in video encoding
- Remove redundant pix_fmt assignment (set once instead of in both branches)
- Remove unnecessary VideoContainer.AUTO check (format already normalized)
2026-01-20 12:55:34 -08:00
bymyself
bb7c582b58 fix: add fail-fast validation for unsupported codecs
Replace silent fallback to libx264 with explicit validation that raises
ValueError for unknown codec types. Prevents silent codec substitution.
2026-01-20 12:55:17 -08:00
bymyself
dcbe072e8a fix: add error handling for invalid speed preset values
Wrap VideoSpeedPreset conversion in try/except to gracefully handle
invalid speed_str values instead of raising unhandled ValueError.
Logs warning and falls back to None (default) on failure.
2026-01-19 21:17:02 -08:00
bymyself
86317e32b8 feat: enhance SaveVideo with DynamicCombo for codec-specific options
Major changes:
- SaveVideo now uses DynamicCombo to show codec-specific encoding options
- When selecting h264 or vp9, quality and speed inputs appear dynamically
- Each codec has unique ADVANCED options (shown in 'Show Advanced' section):
  - h264: Profile (baseline/main/high), Tune (film/animation/grain/etc.)
  - vp9: Row Multi-threading, Tile Columns
- Quality maps to CRF internally with codec-appropriate ranges
- Speed presets map to FFmpeg presets

New types:
- VideoSpeedPreset enum with user-friendly names
- quality_to_crf() helper function

This demonstrates both DynamicCombo (codec-specific inputs) and advanced
widgets (profile/tune/row_mt/tile_columns hidden by default).

Amp-Thread-ID: https://ampcode.com/threads/T-019bce44-e743-7349-8bad-d3027d456fb1
Co-authored-by: Amp <amp@ampcode.com>
2026-01-17 17:09:19 -08:00
bymyself
36b9673a33 feat: add advanced parameter to Input classes for advanced widgets support
Add 'advanced' boolean parameter to Input and WidgetInput base classes
and propagate to all typed Input subclasses (Boolean, Int, Float, String,
Combo, MultiCombo, Webcam, MultiType, MatchType, ImageCompare).

When set to True, the frontend will hide these inputs by default in a
collapsible 'Advanced Inputs' section in the right side panel, reducing
visual clutter for power-user options.

This enables nodes to expose advanced configuration options (like encoding
parameters, quality settings, etc.) without overwhelming typical users.

Frontend support: ComfyUI_frontend PR #7812
2026-01-17 16:13:16 -08:00
7 changed files with 616 additions and 122 deletions

View File

@@ -7,7 +7,14 @@ from comfy_api.internal.singleton import ProxiedSingleton
from comfy_api.internal.async_to_sync import create_sync_class from comfy_api.internal.async_to_sync import create_sync_class
from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput
from ._input_impl import VideoFromFile, VideoFromComponents 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 _io_public as io
from . import _ui_public as ui from . import _ui_public as ui
from comfy_execution.utils import get_executing_context 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") raise ValueError("node_id must be provided if not in executing context")
# Convert preview_image to PreviewImageTuple if needed # 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: if to_display is not None:
# First convert to PIL Image if needed # First convert to PIL Image if needed
if isinstance(to_display, ImageInput): if isinstance(to_display, ImageInput):
@@ -75,6 +84,7 @@ class ComfyAPI_latest(ComfyAPIBase):
execution: Execution execution: Execution
class ComfyExtension(ABC): class ComfyExtension(ABC):
async def on_load(self) -> None: async def on_load(self) -> None:
""" """
@@ -88,6 +98,7 @@ class ComfyExtension(ABC):
Returns a list of nodes that this extension provides. Returns a list of nodes that this extension provides.
""" """
class Input: class Input:
Image = ImageInput Image = ImageInput
Audio = AudioInput Audio = AudioInput
@@ -95,17 +106,21 @@ class Input:
Latent = LatentInput Latent = LatentInput
Video = VideoInput Video = VideoInput
class InputImpl: class InputImpl:
VideoFromFile = VideoFromFile VideoFromFile = VideoFromFile
VideoFromComponents = VideoFromComponents VideoFromComponents = VideoFromComponents
class Types: class Types:
VideoCodec = VideoCodec VideoCodec = VideoCodec
VideoContainer = VideoContainer VideoContainer = VideoContainer
VideoComponents = VideoComponents VideoComponents = VideoComponents
VideoSpeedPreset = VideoSpeedPreset
MESH = MESH MESH = MESH
VOXEL = VOXEL VOXEL = VOXEL
ComfyAPI = ComfyAPI_latest ComfyAPI = ComfyAPI_latest
# Create a synchronous version of the API # Create a synchronous version of the API

View File

@@ -10,7 +10,13 @@ import json
import numpy as np import numpy as np
import math import math
import torch 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: def container_to_output_format(container_format: str | None) -> str | None:
@@ -82,9 +88,9 @@ class VideoFromFile(VideoInput):
""" """
if isinstance(self.__file, io.BytesIO): if isinstance(self.__file, io.BytesIO):
self.__file.seek(0) # Reset the BytesIO object to the beginning 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: for stream in container.streams:
if stream.type == 'video': if stream.type == "video":
assert isinstance(stream, av.VideoStream) assert isinstance(stream, av.VideoStream)
return stream.width, stream.height return stream.width, stream.height
raise ValueError(f"No video stream found in file '{self.__file}'") 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 # 2. Try to estimate from duration and average_rate using only metadata
if container.duration is not None and video_stream.average_rate: if container.duration is not None and video_stream.average_rate:
duration_seconds = float(container.duration / av.time_base) 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: if estimated_frames > 0:
return estimated_frames return estimated_frames
@@ -148,7 +156,9 @@ class VideoFromFile(VideoInput):
and video_stream.average_rate and video_stream.average_rate
): ):
duration_seconds = float(video_stream.duration * video_stream.time_base) 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: if estimated_frames > 0:
return estimated_frames return estimated_frames
@@ -160,7 +170,9 @@ class VideoFromFile(VideoInput):
frame_count += 1 frame_count += 1
if frame_count == 0: 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 return frame_count
def get_frame_rate(self) -> Fraction: def get_frame_rate(self) -> Fraction:
@@ -181,7 +193,9 @@ class VideoFromFile(VideoInput):
if video_stream.frames and container.duration: if video_stream.frames and container.duration:
duration_seconds = float(container.duration / av.time_base) duration_seconds = float(container.duration / av.time_base)
if duration_seconds > 0: 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 # Last resort: match get_components_internal default
return Fraction(1) return Fraction(1)
@@ -195,53 +209,69 @@ class VideoFromFile(VideoInput):
""" """
if isinstance(self.__file, io.BytesIO): if isinstance(self.__file, io.BytesIO):
self.__file.seek(0) 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 return container.format.name
def get_components_internal(self, container: InputContainer) -> VideoComponents: def get_components_internal(self, container: InputContainer) -> VideoComponents:
# Get video frames # Get video frames
frames = [] frames = []
for frame in container.decode(video=0): 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) img = torch.from_numpy(img) / 255.0 # shape: (H, W, 3)
frames.append(img) frames.append(img)
images = torch.stack(frames) if len(frames) > 0 else torch.zeros(0, 3, 0, 0) images = torch.stack(frames) if len(frames) > 0 else torch.zeros(0, 3, 0, 0)
# Get frame rate # Get frame rate
video_stream = next(s for s in container.streams if s.type == 'video') 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) frame_rate = (
Fraction(video_stream.average_rate)
if video_stream and video_stream.average_rate
else Fraction(1)
)
# Get audio if available # Get audio if available
audio = None audio = None
try: try:
container.seek(0) # Reset the container to the beginning container.seek(0) # Reset the container to the beginning
for stream in container.streams: for stream in container.streams:
if stream.type != 'audio': if stream.type != "audio":
continue continue
assert isinstance(stream, av.AudioStream) assert isinstance(stream, av.AudioStream)
audio_frames = [] audio_frames = []
for packet in container.demux(stream): for packet in container.demux(stream):
for frame in packet.decode(): for frame in packet.decode():
assert isinstance(frame, av.AudioFrame) 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: if len(audio_frames) > 0:
audio_data = np.concatenate(audio_frames, axis=1) # shape: (channels, total_samples) audio_data = np.concatenate(
audio_tensor = torch.from_numpy(audio_data).unsqueeze(0) # shape: (1, channels, total_samples) audio_frames, axis=1
audio = AudioInput({ ) # shape: (channels, total_samples)
"waveform": audio_tensor, audio_tensor = torch.from_numpy(audio_data).unsqueeze(
"sample_rate": int(stream.sample_rate) if stream.sample_rate else 1, 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: except StopIteration:
pass # No audio stream pass # No audio stream
metadata = container.metadata 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: def get_components(self) -> VideoComponents:
if isinstance(self.__file, io.BytesIO): if isinstance(self.__file, io.BytesIO):
self.__file.seek(0) # Reset the BytesIO object to the beginning 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) return self.get_components_internal(container)
raise ValueError(f"No video stream found in file '{self.__file}'") raise ValueError(f"No video stream found in file '{self.__file}'")
@@ -250,17 +280,37 @@ class VideoFromFile(VideoInput):
path: str | io.BytesIO, path: str | io.BytesIO,
format: VideoContainer = VideoContainer.AUTO, format: VideoContainer = VideoContainer.AUTO,
codec: VideoCodec = VideoCodec.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): if isinstance(self.__file, io.BytesIO):
self.__file.seek(0) # Reset the BytesIO object to the beginning self.__file.seek(0)
with av.open(self.__file, mode='r') as container: with av.open(self.__file, mode="r") as container:
container_format = container.format.name 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 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 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 reuse_streams = False
if not reuse_streams: if not reuse_streams:
@@ -270,7 +320,13 @@ class VideoFromFile(VideoInput):
path, path,
format=format, format=format,
codec=codec, 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 streams = container.streams
@@ -293,8 +349,12 @@ class VideoFromFile(VideoInput):
# Add streams to the new container # Add streams to the new container
stream_map = {} stream_map = {}
for stream in streams: for stream in streams:
if isinstance(stream, (av.VideoStream, av.AudioStream, SubtitleStream)): if isinstance(
out_stream = output_container.add_stream_from_template(template=stream, opaque=True) stream, (av.VideoStream, av.AudioStream, SubtitleStream)
):
out_stream = output_container.add_stream_from_template(
template=stream, opaque=True
)
stream_map[stream] = out_stream stream_map[stream] = out_stream
# Write packets to the new container # Write packets to the new container
@@ -322,7 +382,7 @@ class VideoFromComponents(VideoInput):
return VideoComponents( return VideoComponents(
images=self.__components.images, images=self.__components.images,
audio=self.__components.audio, audio=self.__components.audio,
frame_rate=self.__components.frame_rate frame_rate=self.__components.frame_rate,
) )
def save_to( def save_to(
@@ -330,54 +390,137 @@ class VideoFromComponents(VideoInput):
path: str, path: str,
format: VideoContainer = VideoContainer.AUTO, format: VideoContainer = VideoContainer.AUTO,
codec: VideoCodec = VideoCodec.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") Save video to file with optional encoding parameters.
if codec != VideoCodec.AUTO and codec != VideoCodec.H264:
raise ValueError("Only H264 codec is supported for now") Args:
extra_kwargs = {} path: Output file path
if isinstance(format, VideoContainer) and format != VideoContainer.AUTO: format: Container format (mp4, webm, or auto)
extra_kwargs["format"] = format.value codec: Video codec (h264, vp9, or auto)
with av.open(path, mode='w', options={'movflags': 'use_metadata_tags'}, **extra_kwargs) as output: metadata: Optional metadata dict to embed
# Add metadata before writing any streams 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: if metadata is not None:
for key, value in metadata.items(): for key, value in metadata.items():
output.metadata[key] = json.dumps(value) output.metadata[key] = json.dumps(value)
frame_rate = Fraction(round(self.__components.frame_rate * 1000), 1000) frame_rate = Fraction(round(self.__components.frame_rate * 1000), 1000)
# Create a video stream video_stream = output.add_stream(ffmpeg_codec, rate=frame_rate)
video_stream = output.add_stream('h264', rate=frame_rate)
video_stream.width = self.__components.images.shape[2] video_stream.width = self.__components.images.shape[2]
video_stream.height = self.__components.images.shape[1] 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_sample_rate = 1
audio_stream: Optional[av.AudioStream] = None audio_stream: Optional[av.AudioStream] = None
if self.__components.audio: if self.__components.audio:
audio_sample_rate = int(self.__components.audio['sample_rate']) audio_sample_rate = int(self.__components.audio["sample_rate"])
audio_stream = output.add_stream('aac', rate=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): for i, frame in enumerate(self.__components.images):
img = (frame * 255).clamp(0, 255).byte().cpu().numpy() # shape: (H, W, 3) img = (frame * 255).clamp(0, 255).byte().cpu().numpy()
frame = av.VideoFrame.from_ndarray(img, format='rgb24') video_frame = av.VideoFrame.from_ndarray(img, format="rgb24")
frame = frame.reformat(format='yuv420p') # Convert to YUV420P as required by h264 video_frame = video_frame.reformat(format="yuv420p")
packet = video_stream.encode(frame) packet = video_stream.encode(video_frame)
output.mux(packet) output.mux(packet)
# Flush video
packet = video_stream.encode(None) packet = video_stream.encode(None)
output.mux(packet) output.mux(packet)
if audio_stream and self.__components.audio: if audio_stream and self.__components.audio:
waveform = self.__components.audio['waveform'] waveform = self.__components.audio["waveform"]
waveform = waveform[:, :, :math.ceil((audio_sample_rate / frame_rate) * self.__components.images.shape[0])] waveform = waveform[
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 : math.ceil(
output.mux(audio_stream.encode(frame)) (audio_sample_rate / frame_rate)
* self.__components.images.shape[0]
# Flush encoder ),
]
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)) output.mux(audio_stream.encode(None))

View File

@@ -153,7 +153,7 @@ class Input(_IO_V3):
''' '''
Base class for a V3 Input. 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__() super().__init__()
self.id = id self.id = id
self.display_name = display_name self.display_name = display_name
@@ -162,6 +162,7 @@ class Input(_IO_V3):
self.lazy = lazy self.lazy = lazy
self.extra_dict = extra_dict if extra_dict is not None else {} self.extra_dict = extra_dict if extra_dict is not None else {}
self.rawLink = raw_link self.rawLink = raw_link
self.advanced = advanced
def as_dict(self): def as_dict(self):
return prune_dict({ return prune_dict({
@@ -170,6 +171,7 @@ class Input(_IO_V3):
"tooltip": self.tooltip, "tooltip": self.tooltip,
"lazy": self.lazy, "lazy": self.lazy,
"rawLink": self.rawLink, "rawLink": self.rawLink,
"advanced": self.advanced,
}) | prune_dict(self.extra_dict) }) | prune_dict(self.extra_dict)
def get_io_type(self): 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, def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
default: Any=None, default: Any=None,
socketless: bool=None, widget_type: str=None, force_input: bool=None, extra_dict=None, raw_link: bool=None): 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) super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link, advanced)
self.default = default self.default = default
self.socketless = socketless self.socketless = socketless
self.widget_type = widget_type self.widget_type = widget_type
@@ -242,8 +244,8 @@ class Boolean(ComfyTypeIO):
'''Boolean input.''' '''Boolean input.'''
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, 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, 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): 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) 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_on = label_on
self.label_off = label_off self.label_off = label_off
self.default: bool self.default: bool
@@ -262,8 +264,8 @@ class Int(ComfyTypeIO):
'''Integer input.''' '''Integer input.'''
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, 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, 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): 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) super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link, advanced)
self.min = min self.min = min
self.max = max self.max = max
self.step = step self.step = step
@@ -288,8 +290,8 @@ class Float(ComfyTypeIO):
'''Float input.''' '''Float input.'''
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, 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, 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): 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) super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link, advanced)
self.min = min self.min = min
self.max = max self.max = max
self.step = step self.step = step
@@ -314,8 +316,8 @@ class String(ComfyTypeIO):
'''String input.''' '''String input.'''
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, 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, 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): 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) super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, None, force_input, extra_dict, raw_link, advanced)
self.multiline = multiline self.multiline = multiline
self.placeholder = placeholder self.placeholder = placeholder
self.dynamic_prompts = dynamic_prompts self.dynamic_prompts = dynamic_prompts
@@ -350,12 +352,13 @@ class Combo(ComfyTypeIO):
socketless: bool=None, socketless: bool=None,
extra_dict=None, extra_dict=None,
raw_link: bool=None, raw_link: bool=None,
advanced: bool=None,
): ):
if isinstance(options, type) and issubclass(options, Enum): if isinstance(options, type) and issubclass(options, Enum):
options = [v.value for v in options] options = [v.value for v in options]
if isinstance(default, Enum): if isinstance(default, Enum):
default = default.value 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.multiselect = False
self.options = options self.options = options
self.control_after_generate = control_after_generate self.control_after_generate = control_after_generate
@@ -387,8 +390,8 @@ class MultiCombo(ComfyTypeI):
class Input(Combo.Input): class Input(Combo.Input):
def __init__(self, id: str, options: list[str], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, 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, 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): 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) 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.multiselect = True
self.placeholder = placeholder self.placeholder = placeholder
self.chip = chip self.chip = chip
@@ -421,9 +424,9 @@ class Webcam(ComfyTypeIO):
Type = str Type = str
def __init__( def __init__(
self, id: str, display_name: str=None, optional=False, 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") @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. 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 # if id is an Input, then use that Input with overridden values
self.input_override = None self.input_override = None
if isinstance(id, Input): if isinstance(id, Input):
@@ -789,7 +792,7 @@ class MultiType:
# if is a widget input, make sure widget_type is set appropriately # if is a widget input, make sure widget_type is set appropriately
if isinstance(self.input_override, WidgetInput): if isinstance(self.input_override, WidgetInput):
self.input_override.widget_type = self.input_override.get_io_type() 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 self._io_types = types
@property @property
@@ -843,8 +846,8 @@ class MatchType(ComfyTypeIO):
class Input(Input): class Input(Input):
def __init__(self, id: str, template: MatchType.Template, 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): 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) super().__init__(id, display_name, optional, tooltip, lazy, extra_dict, raw_link, advanced)
self.template = template self.template = template
def as_dict(self): def as_dict(self):
@@ -1119,8 +1122,8 @@ class ImageCompare(ComfyTypeI):
class Input(WidgetInput): class Input(WidgetInput):
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
socketless: bool=True): socketless: bool=True, advanced: bool=None):
super().__init__(id, display_name, optional, tooltip, None, None, socketless) super().__init__(id, display_name, optional, tooltip, None, None, socketless, None, None, None, None, advanced)
def as_dict(self): def as_dict(self):
return super().as_dict() return super().as_dict()

View File

@@ -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 .geometry_types import VOXEL, MESH
from .image_types import SVG from .image_types import SVG
@@ -7,6 +13,8 @@ __all__ = [
"VideoContainer", "VideoContainer",
"VideoCodec", "VideoCodec",
"VideoComponents", "VideoComponents",
"VideoSpeedPreset",
"quality_to_crf",
"VOXEL", "VOXEL",
"MESH", "MESH",
"SVG", "SVG",

View File

@@ -5,9 +5,11 @@ from fractions import Fraction
from typing import Optional from typing import Optional
from .._input import ImageInput, AudioInput from .._input import ImageInput, AudioInput
class VideoCodec(str, Enum): class VideoCodec(str, Enum):
AUTO = "auto" AUTO = "auto"
H264 = "h264" H264 = "h264"
VP9 = "vp9"
@classmethod @classmethod
def as_input(cls) -> list[str]: def as_input(cls) -> list[str]:
@@ -16,9 +18,11 @@ class VideoCodec(str, Enum):
""" """
return [member.value for member in cls] return [member.value for member in cls]
class VideoContainer(str, Enum): class VideoContainer(str, Enum):
AUTO = "auto" AUTO = "auto"
MP4 = "mp4" MP4 = "mp4"
WEBM = "webm"
@classmethod @classmethod
def as_input(cls) -> list[str]: def as_input(cls) -> list[str]:
@@ -36,8 +40,73 @@ class VideoContainer(str, Enum):
value = cls(value) value = cls(value)
if value == VideoContainer.MP4 or value == VideoContainer.AUTO: if value == VideoContainer.MP4 or value == VideoContainer.AUTO:
return "mp4" return "mp4"
if value == VideoContainer.WEBM:
return "webm"
return "" 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 @dataclass
class VideoComponents: class VideoComponents:
""" """
@@ -48,5 +117,3 @@ class VideoComponents:
frame_rate: Fraction frame_rate: Fraction
audio: Optional[AudioInput] = None audio: Optional[AudioInput] = None
metadata: Optional[dict] = None metadata: Optional[dict] = None

View File

@@ -1,16 +1,19 @@
from __future__ import annotations from __future__ import annotations
import os
import av
import torch
import folder_paths
import json import json
import logging
import os
from typing import Optional from typing import Optional
import av
import folder_paths
import torch
from typing_extensions import override from typing_extensions import override
from fractions import Fraction from fractions import Fraction
from comfy_api.latest import ComfyExtension, io, ui, Input, InputImpl, Types from comfy_api.latest import ComfyExtension, io, ui, Input, InputImpl, Types
from comfy.cli_args import args from comfy.cli_args import args
class SaveWEBM(io.ComfyNode): class SaveWEBM(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
@@ -23,7 +26,14 @@ class SaveWEBM(io.ComfyNode):
io.String.Input("filename_prefix", default="ComfyUI"), io.String.Input("filename_prefix", default="ComfyUI"),
io.Combo.Input("codec", options=["vp9", "av1"]), 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("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], hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
@@ -31,8 +41,13 @@ class SaveWEBM(io.ComfyNode):
@classmethod @classmethod
def execute(cls, images, codec, fps, filename_prefix, crf) -> io.NodeOutput: 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( full_output_folder, filename, counter, subfolder, filename_prefix = (
filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0] 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" file = f"{filename}_{counter:05}_.webm"
@@ -46,51 +61,196 @@ class SaveWEBM(io.ComfyNode):
container.metadata[x] = json.dumps(cls.hidden.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)
)
stream.width = images.shape[-2] stream.width = images.shape[-2]
stream.height = images.shape[-3] stream.height = images.shape[-3]
stream.pix_fmt = "yuv420p10le" if codec == "av1" else "yuv420p" stream.pix_fmt = "yuv420p10le" if codec == "av1" else "yuv420p"
stream.bit_rate = 0 stream.bit_rate = 0
stream.options = {'crf': str(crf)} stream.options = {"crf": str(crf)}
if codec == "av1": if codec == "av1":
stream.options["preset"] = "6" stream.options["preset"] = "6"
for frame in images: 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): for packet in stream.encode(frame):
container.mux(packet) container.mux(packet)
container.mux(stream.encode()) container.mux(stream.encode())
container.close() 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): class SaveVideo(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): 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( return io.Schema(
node_id="SaveVideo", node_id="SaveVideo",
display_name="Save Video", display_name="Save Video",
category="image/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=[ inputs=[
io.Video.Input("video", tooltip="The video to save."), 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.String.Input(
io.Combo.Input("format", options=Types.VideoContainer.as_input(), default="auto", tooltip="The format to save the video as."), "filename_prefix",
io.Combo.Input("codec", options=Types.VideoCodec.as_input(), default="auto", tooltip="The codec to use for the video."), 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], hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
) )
@classmethod @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() 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 = (
filename_prefix, folder_paths.get_save_image_path(
folder_paths.get_output_directory(), filename_prefix, folder_paths.get_output_directory(), width, height
width, )
height
) )
saved_metadata = None saved_metadata = None
if not args.disable_metadata: if not args.disable_metadata:
metadata = {} metadata = {}
@@ -100,15 +260,25 @@ class SaveVideo(io.ComfyNode):
metadata["prompt"] = cls.hidden.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)}"
extension = Types.VideoContainer.get_extension(resolved_format)
file = f"{filename}_{counter:05}_.{extension}"
video.save_to( video.save_to(
os.path.join(full_output_folder, file), os.path.join(full_output_folder, file),
format=Types.VideoContainer(format), format=resolved_format,
codec=codec, codec=resolved_codec,
metadata=saved_metadata 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): class CreateVideo(io.ComfyNode):
@@ -122,7 +292,9 @@ class CreateVideo(io.ComfyNode):
inputs=[ inputs=[
io.Image.Input("images", tooltip="The images to create a video from."), 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.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=[ outputs=[
io.Video.Output(), io.Video.Output(),
@@ -130,11 +302,18 @@ class CreateVideo(io.ComfyNode):
) )
@classmethod @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( 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): class GetVideoComponents(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
@@ -144,7 +323,9 @@ class GetVideoComponents(io.ComfyNode):
category="image/video", category="image/video",
description="Extracts all components from a video: frames, audio, and framerate.", description="Extracts all components from a video: frames, audio, and framerate.",
inputs=[ inputs=[
io.Video.Input("video", tooltip="The video to extract components from."), io.Video.Input(
"video", tooltip="The video to extract components from."
),
], ],
outputs=[ outputs=[
io.Image.Output(display_name="images"), io.Image.Output(display_name="images"),
@@ -156,21 +337,29 @@ class GetVideoComponents(io.ComfyNode):
@classmethod @classmethod
def execute(cls, video: Input.Video) -> io.NodeOutput: def execute(cls, video: Input.Video) -> io.NodeOutput:
components = video.get_components() 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): class LoadVideo(io.ComfyNode):
@classmethod @classmethod
def define_schema(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 io.Schema( return io.Schema(
node_id="LoadVideo", node_id="LoadVideo",
display_name="Load Video", display_name="Load Video",
category="image/video", category="image/video",
inputs=[ inputs=[
io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video), io.Combo.Input(
"file", options=sorted(files), upload=io.UploadType.video
),
], ],
outputs=[ outputs=[
io.Video.Output(), io.Video.Output(),
@@ -209,5 +398,6 @@ class VideoExtension(ComfyExtension):
LoadVideo, LoadVideo,
] ]
async def comfy_entrypoint() -> VideoExtension: async def comfy_entrypoint() -> VideoExtension:
return VideoExtension() return VideoExtension()

View File

@@ -6,7 +6,7 @@ import av
import io import io
from fractions import Fraction from fractions import Fraction
from comfy_api.input_impl.video_types import VideoFromFile, VideoFromComponents 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 comfy_api.input.basic_types import AudioInput
from av.error import InvalidDataError 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) manual_duration = float(components.images.shape[0] / components.frame_rate)
assert duration == pytest.approx(manual_duration) 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