2025-11-27 08:18:08 +08:00
import logging
import os
import json
2026-07-21 12:02:54 +08:00
import av
2025-11-27 08:18:08 +08:00
import numpy as np
import torch
from PIL import Image
from typing_extensions import override
import folder_paths
import node_helpers
2026-07-21 12:02:54 +08:00
from comfy_api . latest import ComfyExtension , io , Input , InputImpl , Types
2025-11-27 08:18:08 +08:00
def load_and_process_images ( image_files , input_dir ) :
""" Utility function to load and process a list of images.
Args :
image_files : List of image filenames
input_dir : Base directory containing the images
resize_method : How to handle images of different sizes ( " None " , " Stretch " , " Crop " , " Pad " )
Returns :
torch . Tensor : Batch of processed images
"""
if not image_files :
raise ValueError ( " No valid images found in input " )
output_images = [ ]
for file in image_files :
image_path = os . path . join ( input_dir , file )
img = node_helpers . pillow ( Image . open , image_path )
if img . mode == " I " :
img = img . point ( lambda i : i * ( 1 / 255 ) )
img = img . convert ( " RGB " )
img_array = np . array ( img ) . astype ( np . float32 ) / 255.0
img_tensor = torch . from_numpy ( img_array ) [ None , ]
output_images . append ( img_tensor )
return output_images
2026-07-29 07:03:04 +08:00
def secure_subfolder_path ( base_dir , folder_name ) :
""" Resolve folder_name inside base_dir, rejecting anything that escapes it.
Blocks ' .. ' , absolute paths , drive letters and symlink escapes using the
same realpath containment check as the core file endpoints .
"""
target = os . path . abspath ( os . path . join ( base_dir , folder_name ) )
if not folder_paths . is_within_directory ( base_dir , target ) :
raise ValueError ( f " Invalid folder name { folder_name !r} : resolves outside of { base_dir } " )
return target
def list_dataset_folders ( ) :
""" Relative paths of dataset folders found under all dataset roots.
Any subfolder containing a metadata . json or * . safetensors shard counts as
a dataset ; the walk doesn ' t descend into matched folders.
Symlinked directories are followed , but symlink loops are avoided .
"""
found = set ( )
for root in folder_paths . get_folder_paths ( " datasets " ) :
if not os . path . isdir ( root ) :
continue
root = os . path . abspath ( root )
seen_dirs = set ( )
for dirpath , subdirs , filenames in os . walk ( root , followlinks = True ) :
try :
st = os . stat ( dirpath ) # follows symlinks
except OSError :
subdirs [ : ] = [ ]
continue
dir_key = ( st . st_dev , st . st_ino )
if dir_key in seen_dirs :
subdirs [ : ] = [ ]
continue
seen_dirs . add ( dir_key )
if dirpath != root and (
" metadata.json " in filenames
or any ( f . endswith ( " .safetensors " ) for f in filenames )
) :
found . add ( os . path . relpath ( dirpath , root ) . replace ( os . sep , " / " ) )
subdirs [ : ] = [ ]
continue
kept_subdirs = [ ]
for name in subdirs :
child = os . path . join ( dirpath , name )
try :
child_st = os . stat ( child ) # follows symlinks
except OSError :
continue
child_key = ( child_st . st_dev , child_st . st_ino )
if child_key not in seen_dirs :
kept_subdirs . append ( name )
subdirs [ : ] = kept_subdirs
return sorted ( found )
def get_dataset_save_dir ( folder_name ) :
""" Resolve the folder to save a new dataset into, inside the default root.
The folder is not created here ; callers makedirs after validation .
"""
root = folder_paths . get_folder_paths ( " datasets " ) [ 0 ]
target = secure_subfolder_path ( root , folder_name )
if os . path . realpath ( target ) == os . path . realpath ( root ) :
raise ValueError ( " folder_name must name a subfolder of the datasets directory, e.g. ' my_dataset ' . " )
return target
def get_dataset_dir ( folder_name ) :
""" Find an existing dataset folder by relative name across all dataset roots. """
roots = folder_paths . get_folder_paths ( " datasets " )
for root in roots :
target = secure_subfolder_path ( root , folder_name )
if os . path . realpath ( target ) == os . path . realpath ( root ) :
raise ValueError ( " folder_name must name a subfolder of the datasets directory, e.g. ' my_dataset ' . " )
if os . path . isdir ( target ) :
return target
raise ValueError ( f " Dataset folder { folder_name !r} not found in: { ' , ' . join ( roots ) } " )
2026-07-21 12:02:54 +08:00
VALID_VIDEO_EXTENSIONS = [ " .mp4 " , " .avi " , " .mov " , " .webm " , " .mkv " , " .flv " ]
def _decode_selected_frames ( video : Input . Video , indices : list [ int ] ) - > Input . Video :
""" Decode only the requested frame indices from a video.
Opens the underlying container once , decodes frames in presentation order ,
keeps only the ones whose index is in ` ` indices ` ` , and returns the result
wrapped in a VideoFromComponents so it still satisfies the VideoInput
contract for downstream nodes .
"""
indices_sorted = sorted ( set ( indices ) )
max_idx = indices_sorted [ - 1 ]
source = video . get_stream_source ( )
frames_by_idx : dict [ int , torch . Tensor ] = { }
with av . open ( source , mode = " r " ) as container :
stream = container . streams . video [ 0 ]
wanted = set ( indices_sorted )
for frame_idx , frame in enumerate ( container . decode ( stream ) ) :
if frame_idx in wanted :
img = frame . to_ndarray ( format = " rgb24 " )
frames_by_idx [ frame_idx ] = torch . from_numpy ( img . copy ( ) ) . float ( ) / 255.0
if frame_idx > = max_idx :
break
stacked = torch . stack ( [ frames_by_idx [ i ] for i in indices ] )
return InputImpl . VideoFromComponents (
Types . VideoComponents ( images = stacked , frame_rate = video . get_frame_rate ( ) )
)
2025-11-27 08:18:08 +08:00
class LoadImageDataSetFromFolderNode ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LoadImageDataSetFromFolder " ,
2026-05-21 15:14:16 +08:00
search_aliases = [ " load folder " , " load from folder " , " load dataset " , " load images " , " import dataset " ] ,
display_name = " Load Image (from Folder) " ,
category = " image " ,
description = " Load a dataset of images from a specified folder and return a list of images. Supported formats: PNG, JPG, JPEG, WEBP. " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
inputs = [
io . Combo . Input (
" folder " ,
options = folder_paths . get_input_subfolders ( ) ,
tooltip = " The folder to load images from. " ,
)
] ,
outputs = [
io . Image . Output (
display_name = " images " ,
is_output_list = True ,
tooltip = " List of loaded images " ,
)
] ,
)
@classmethod
def execute ( cls , folder ) :
2026-08-03 20:55:13 -07:00
sub_input_dir = secure_subfolder_path ( folder_paths . get_input_directory ( ) , folder )
2025-11-27 08:18:08 +08:00
valid_extensions = [ " .png " , " .jpg " , " .jpeg " , " .webp " ]
image_files = [
f
for f in os . listdir ( sub_input_dir )
if any ( f . lower ( ) . endswith ( ext ) for ext in valid_extensions )
]
output_tensor = load_and_process_images ( image_files , sub_input_dir )
return io . NodeOutput ( output_tensor )
class LoadImageTextDataSetFromFolderNode ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LoadImageTextDataSetFromFolder " ,
2026-05-21 15:14:16 +08:00
search_aliases = [ " load folder " , " load from folder " , " load dataset " , " load images " , " import dataset " ] ,
display_name = " Load Image-Text (from Folder) " ,
category = " image " ,
description = " Load a dataset of pairs of images and text captions from a specified folder and return them as a list. Supported formats: PNG, JPG, JPEG, WEBP. " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
inputs = [
io . Combo . Input (
" folder " ,
options = folder_paths . get_input_subfolders ( ) ,
2026-05-21 15:14:16 +08:00
tooltip = " The folder to load images and text captions from. " ,
2025-11-27 08:18:08 +08:00
)
] ,
outputs = [
io . Image . Output (
display_name = " images " ,
is_output_list = True ,
tooltip = " List of loaded images " ,
) ,
io . String . Output (
display_name = " texts " ,
is_output_list = True ,
tooltip = " List of text captions " ,
) ,
] ,
)
@classmethod
def execute ( cls , folder ) :
logging . info ( f " Loading images from folder: { folder } " )
2026-08-03 20:55:13 -07:00
sub_input_dir = secure_subfolder_path ( folder_paths . get_input_directory ( ) , folder )
2025-11-27 08:18:08 +08:00
valid_extensions = [ " .png " , " .jpg " , " .jpeg " , " .webp " ]
image_files = [ ]
for item in os . listdir ( sub_input_dir ) :
path = os . path . join ( sub_input_dir , item )
if any ( item . lower ( ) . endswith ( ext ) for ext in valid_extensions ) :
image_files . append ( path )
elif os . path . isdir ( path ) :
# Support kohya-ss/sd-scripts folder structure
repeat = 1
if item . split ( " _ " ) [ 0 ] . isdigit ( ) :
repeat = int ( item . split ( " _ " ) [ 0 ] )
image_files . extend (
[
os . path . join ( path , f )
for f in os . listdir ( path )
if any ( f . lower ( ) . endswith ( ext ) for ext in valid_extensions )
]
* repeat
)
caption_file_path = [
f . replace ( os . path . splitext ( f ) [ 1 ] , " .txt " ) for f in image_files
]
captions = [ ]
for caption_file in caption_file_path :
caption_path = os . path . join ( sub_input_dir , caption_file )
if os . path . exists ( caption_path ) :
with open ( caption_path , " r " , encoding = " utf-8 " ) as f :
caption = f . read ( ) . strip ( )
captions . append ( caption )
else :
captions . append ( " " )
output_tensor = load_and_process_images ( image_files , sub_input_dir )
logging . info ( f " Loaded { len ( output_tensor ) } images from { sub_input_dir } . " )
return io . NodeOutput ( output_tensor , captions )
2026-07-21 12:02:54 +08:00
class LoadVideoDataSetFromFolderNode ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LoadVideoDataSetFromFolder " ,
search_aliases = [ " load folder " , " load from folder " , " load dataset " , " load videos " , " import dataset " ] ,
display_name = " Load Video (from Folder) " ,
category = " video " ,
description = " Load a dataset of videos from a specified folder and return a list of videos. Supported formats: MP4, AVI, MOV, WEBM, MKV, FLV. " ,
is_experimental = True ,
inputs = [
io . Combo . Input (
" folder " ,
options = folder_paths . get_input_subfolders ( ) ,
tooltip = " The folder containing video files. " ,
) ,
] ,
outputs = [
io . Video . Output (
display_name = " videos " ,
is_output_list = True ,
tooltip = " Lazy video references; frames are decoded only when needed downstream. " ,
) ,
] ,
)
@classmethod
def execute ( cls , folder ) :
2026-08-03 20:55:13 -07:00
sub_input_dir = secure_subfolder_path ( folder_paths . get_input_directory ( ) , folder )
2026-07-21 12:02:54 +08:00
video_files = sorted ( [
f for f in os . listdir ( sub_input_dir )
if any ( f . lower ( ) . endswith ( ext ) for ext in VALID_VIDEO_EXTENSIONS )
] )
if not video_files :
raise ValueError ( f " No video files found in { sub_input_dir } " )
videos = [ InputImpl . VideoFromFile ( os . path . join ( sub_input_dir , f ) ) for f in video_files ]
logging . info ( f " Loaded { len ( videos ) } lazy video references from { sub_input_dir } " )
return io . NodeOutput ( videos )
class LoadVideoTextDataSetFromFolderNode ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LoadVideoTextDataSetFromFolder " ,
search_aliases = [ " load folder " , " load from folder " , " load dataset " , " load videos " , " import dataset " ] ,
display_name = " Load Video-Text (from Folder) " ,
category = " video " ,
description = " Load a dataset of pairs of videos and text captions from a specified folder and return them as a list. Supported formats: MP4, AVI, MOV, WEBM, MKV, FLV. " ,
is_experimental = True ,
inputs = [
io . Combo . Input (
" folder " ,
options = folder_paths . get_input_subfolders ( ) ,
tooltip = " The folder containing video files and .txt captions. " ,
) ,
] ,
outputs = [
io . Video . Output (
display_name = " videos " ,
is_output_list = True ,
tooltip = " Lazy video references; frames are decoded only when needed downstream. " ,
) ,
io . String . Output (
display_name = " texts " ,
is_output_list = True ,
tooltip = " List of text captions. " ,
) ,
] ,
)
@classmethod
def execute ( cls , folder ) :
2026-08-03 20:55:13 -07:00
sub_input_dir = secure_subfolder_path ( folder_paths . get_input_directory ( ) , folder )
2026-07-21 12:02:54 +08:00
video_files = [ ]
for item in sorted ( os . listdir ( sub_input_dir ) ) :
path = os . path . join ( sub_input_dir , item )
if any ( item . lower ( ) . endswith ( ext ) for ext in VALID_VIDEO_EXTENSIONS ) :
video_files . append ( path )
elif os . path . isdir ( path ) :
# Support kohya-ss/sd-scripts folder structure: {repeat}_{desc}/
repeat = 1
if item . split ( " _ " ) [ 0 ] . isdigit ( ) :
repeat = int ( item . split ( " _ " ) [ 0 ] )
video_files . extend ( [
os . path . join ( path , f )
for f in sorted ( os . listdir ( path ) )
if any ( f . lower ( ) . endswith ( ext ) for ext in VALID_VIDEO_EXTENSIONS )
] * repeat )
if not video_files :
raise ValueError ( f " No video files found in { sub_input_dir } " )
captions = [ ]
for vf in video_files :
caption_path = os . path . splitext ( vf ) [ 0 ] + " .txt "
if os . path . exists ( caption_path ) :
with open ( caption_path , " r " , encoding = " utf-8 " ) as f :
captions . append ( f . read ( ) . strip ( ) )
else :
captions . append ( " " )
videos = [ InputImpl . VideoFromFile ( vf ) for vf in video_files ]
logging . info ( f " Loaded { len ( videos ) } lazy video references with captions from { sub_input_dir } " )
return io . NodeOutput ( videos , captions )
2026-05-29 01:12:04 +08:00
def save_images_to_folder ( image_list , output_dir , prefix = " image " , overwrite = True ) :
2025-11-27 08:18:08 +08:00
""" Utility function to save a list of image tensors to disk.
Args :
image_list : List of image tensors ( each [ 1 , H , W , C ] or [ H , W , C ] or [ C , H , W ] )
output_dir : Directory to save images to
prefix : Filename prefix
Returns :
List of saved filenames
"""
os . makedirs ( output_dir , exist_ok = True )
saved_files = [ ]
for idx , img_tensor in enumerate ( image_list ) :
# Handle different tensor shapes
if isinstance ( img_tensor , torch . Tensor ) :
# Remove batch dimension if present [1, H, W, C] -> [H, W, C]
if img_tensor . dim ( ) == 4 and img_tensor . shape [ 0 ] == 1 :
img_tensor = img_tensor . squeeze ( 0 )
# If tensor is [C, H, W], permute to [H, W, C]
if img_tensor . dim ( ) == 3 and img_tensor . shape [ 0 ] in [ 1 , 3 , 4 ] :
if (
img_tensor . shape [ 0 ] < = 4
and img_tensor . shape [ 1 ] > 4
and img_tensor . shape [ 2 ] > 4
) :
img_tensor = img_tensor . permute ( 1 , 2 , 0 )
# Convert to numpy and scale to 0-255
img_array = img_tensor . cpu ( ) . numpy ( )
img_array = np . clip ( img_array * 255.0 , 0 , 255 ) . astype ( np . uint8 )
# Convert to PIL Image
img = Image . fromarray ( img_array )
else :
raise ValueError ( f " Expected torch.Tensor, got { type ( img_tensor ) } " )
# Save image
2026-05-29 01:12:04 +08:00
if overwrite :
filename = f " { prefix } _ { idx : 05d } .png "
else :
_ , _ , counter , _ , resolved_prefix = folder_paths . get_save_image_path ( prefix , output_dir )
filename = f " { resolved_prefix } _ { counter : 05 } _ { idx : 05d } .png "
2025-11-27 08:18:08 +08:00
filepath = os . path . join ( output_dir , filename )
img . save ( filepath )
saved_files . append ( filename )
return saved_files
class SaveImageDataSetToFolderNode ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " SaveImageDataSetToFolder " ,
2026-05-21 15:14:16 +08:00
search_aliases = [ " save folder " , " save to folder " , " save dataset " , " save images " , " export dataset " ] ,
display_name = " Save Image (to Folder) (DEPRECATED) " ,
category = " image " ,
description = " Save a dataset of images to a specified folder. Supported formats: PNG. " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
is_output_node = True ,
is_input_list = True , # Receive images as list
inputs = [
io . Image . Input ( " images " , tooltip = " List of images to save. " ) ,
io . String . Input (
" folder_name " ,
default = " dataset " ,
tooltip = " Name of the folder to save images to (inside output directory). " ,
) ,
io . String . Input (
" filename_prefix " ,
default = " image " ,
tooltip = " Prefix for saved image filenames. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-11-27 08:18:08 +08:00
) ,
2026-05-29 01:12:04 +08:00
io . Combo . Input (
" mode " ,
default = " overwrite " ,
options = [ " overwrite " , " increment " ] ,
tooltip = " Whether to overwrite existing files or increment filenames to avoid overwriting. "
) ,
2025-11-27 08:18:08 +08:00
] ,
outputs = [ ] ,
2026-05-21 15:14:16 +08:00
is_deprecated = True , # This node is redundant and superseded by existing Save Image nodes where the target folder can be specified in the filename_prefix
2025-11-27 08:18:08 +08:00
)
@classmethod
2026-05-29 01:12:04 +08:00
def execute ( cls , images , folder_name , filename_prefix , mode ) :
2025-11-27 08:18:08 +08:00
# Extract scalar values
folder_name = folder_name [ 0 ]
filename_prefix = filename_prefix [ 0 ]
2026-05-29 01:12:04 +08:00
mode = mode [ 0 ]
2025-11-27 08:18:08 +08:00
2026-07-29 07:03:04 +08:00
output_dir = secure_subfolder_path ( folder_paths . get_output_directory ( ) , folder_name )
2026-05-29 01:12:04 +08:00
saved_files = save_images_to_folder ( images , output_dir , filename_prefix , mode == ' overwrite ' )
2025-11-27 08:18:08 +08:00
logging . info ( f " Saved { len ( saved_files ) } images to { output_dir } . " )
return io . NodeOutput ( )
class SaveImageTextDataSetToFolderNode ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " SaveImageTextDataSetToFolder " ,
2026-05-21 15:14:16 +08:00
search_aliases = [ " save folder " , " save to folder " , " save dataset " , " save images " , " save text " , " export dataset " ] ,
display_name = " Save Image-Text (to Folder) " ,
category = " image " ,
description = " Save a dataset of pairs of images and text captions to a specified folder. Images are saved as PNG files and captions are saved as TXT files with the same filename_prefix. " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
is_output_node = True ,
is_input_list = True , # Receive both images and texts as lists
inputs = [
io . Image . Input ( " images " , tooltip = " List of images to save. " ) ,
2026-05-21 15:14:16 +08:00
io . String . Input ( " texts " ,
optional = True ,
force_input = True ,
tooltip = " List of text captions to save. "
) ,
2025-11-27 08:18:08 +08:00
io . String . Input (
" folder_name " ,
default = " dataset " ,
tooltip = " Name of the folder to save images to (inside output directory). " ,
) ,
io . String . Input (
" filename_prefix " ,
default = " image " ,
tooltip = " Prefix for saved image filenames. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-11-27 08:18:08 +08:00
) ,
2026-05-29 01:12:04 +08:00
io . Combo . Input (
" mode " ,
default = " overwrite " ,
options = [ " overwrite " , " increment " ] ,
tooltip = " Whether to overwrite existing files or increment filenames to avoid overwriting. "
) ,
2025-11-27 08:18:08 +08:00
] ,
outputs = [ ] ,
)
@classmethod
2026-05-29 01:12:04 +08:00
def execute ( cls , images , folder_name , filename_prefix , mode , texts = None ) :
2025-11-27 08:18:08 +08:00
# Extract scalar values
folder_name = folder_name [ 0 ]
filename_prefix = filename_prefix [ 0 ]
2026-05-29 01:12:04 +08:00
mode = mode [ 0 ]
2025-11-27 08:18:08 +08:00
2026-07-29 07:03:04 +08:00
output_dir = secure_subfolder_path ( folder_paths . get_output_directory ( ) , folder_name )
2026-05-29 01:12:04 +08:00
saved_files = save_images_to_folder ( images , output_dir , filename_prefix , mode == ' overwrite ' )
2025-11-27 08:18:08 +08:00
# Save captions
2026-05-21 15:14:16 +08:00
if texts :
for idx , ( filename , caption ) in enumerate ( zip ( saved_files , texts ) ) :
caption_filename = filename . replace ( " .png " , " .txt " )
caption_path = os . path . join ( output_dir , caption_filename )
with open ( caption_path , " w " , encoding = " utf-8 " ) as f :
f . write ( caption )
2025-11-27 08:18:08 +08:00
logging . info ( f " Saved { len ( saved_files ) } images and captions to { output_dir } . " )
return io . NodeOutput ( )
# ========== Helper Functions for Transform Nodes ==========
def tensor_to_pil ( img_tensor ) :
""" Convert tensor to PIL Image. """
if img_tensor . dim ( ) == 4 and img_tensor . shape [ 0 ] == 1 :
img_tensor = img_tensor . squeeze ( 0 )
img_array = ( img_tensor . cpu ( ) . numpy ( ) * 255 ) . clip ( 0 , 255 ) . astype ( np . uint8 )
return Image . fromarray ( img_array )
def pil_to_tensor ( img ) :
""" Convert PIL Image to tensor. """
img_array = np . array ( img ) . astype ( np . float32 ) / 255.0
return torch . from_numpy ( img_array ) [ None , ]
# ========== Base Classes for Transform Nodes ==========
class ImageProcessingNode ( io . ComfyNode ) :
""" Base class for image processing nodes that operate on images.
Child classes should set :
node_id : Unique node identifier ( required )
2026-05-21 15:14:16 +08:00
search_aliases : List of search aliases ( optional )
2025-11-27 08:18:08 +08:00
display_name : Display name ( optional , defaults to node_id )
description : Node description ( optional )
extra_inputs : List of additional io . Input objects beyond " images " ( optional )
is_group_process : None ( auto - detect ) , True ( group ) , or False ( individual ) ( optional )
is_output_list : True ( list output ) or False ( single output ) ( optional , default True )
2026-05-21 15:14:16 +08:00
is_deprecated : True if the node is deprecated ( optional , default False )
2025-11-27 08:18:08 +08:00
Child classes must implement ONE of :
_process ( cls , image , * * kwargs ) - > tensor ( for single - item processing )
_group_process ( cls , images , * * kwargs ) - > list [ tensor ] ( for group processing )
"""
node_id = None
2026-05-21 15:14:16 +08:00
search_aliases = [ ]
2025-11-27 08:18:08 +08:00
display_name = None
description = None
extra_inputs = [ ]
is_group_process = None # None = auto-detect, True/False = explicit
is_output_list = None # None = auto-detect based on processing mode
2026-05-21 15:14:16 +08:00
is_deprecated = False
2025-11-27 08:18:08 +08:00
@classmethod
def _detect_processing_mode ( cls ) :
""" Detect whether this node uses group or individual processing.
Returns :
bool : True if group processing , False if individual processing
"""
# Explicit setting takes precedence
if cls . is_group_process is not None :
return cls . is_group_process
# Check which method is overridden by looking at the defining class in MRO
base_class = ImageProcessingNode
# Find which class in MRO defines _process
process_definer = None
for klass in cls . __mro__ :
if " _process " in klass . __dict__ :
process_definer = klass
break
# Find which class in MRO defines _group_process
group_definer = None
for klass in cls . __mro__ :
if " _group_process " in klass . __dict__ :
group_definer = klass
break
# Check what was overridden (not defined in base class)
has_process = process_definer is not None and process_definer is not base_class
has_group = group_definer is not None and group_definer is not base_class
if has_process and has_group :
raise ValueError (
f " { cls . __name__ } : Cannot override both _process and _group_process. "
" Override only one, or set is_group_process explicitly. "
)
if not has_process and not has_group :
raise ValueError (
f " { cls . __name__ } : Must override either _process or _group_process "
)
return has_group
2026-06-04 02:03:32 +03:00
@classmethod
def _ensure_image_list ( cls , images ) :
""" Normalize to a flat list of [1, H, W, C] tensors. """
if isinstance ( images , torch . Tensor ) :
if images . ndim != 4 :
raise ValueError ( f " Expected 4D image tensor, got shape { tuple ( images . shape ) } " )
return [ images [ i : i + 1 ] for i in range ( images . shape [ 0 ] ) ]
flat = [ ]
for item in images :
if not isinstance ( item , torch . Tensor ) or item . ndim != 4 :
raise ValueError ( f " Expected 4D image tensor, got { type ( item ) . __name__ } shape { getattr ( item , ' shape ' , None ) } " )
flat . extend ( [ item [ i : i + 1 ] for i in range ( item . shape [ 0 ] ) ] )
return flat
2025-11-27 08:18:08 +08:00
@classmethod
def define_schema ( cls ) :
if cls . node_id is None :
raise NotImplementedError ( f " { cls . __name__ } must set node_id class variable " )
is_group = cls . _detect_processing_mode ( )
# Auto-detect is_output_list if not explicitly set
# Single processing: False (backend collects results into list)
# Group processing: True by default (can be False for single-output nodes)
output_is_list = (
cls . is_output_list if cls . is_output_list is not None else is_group
)
inputs = [
io . Image . Input (
" images " ,
tooltip = (
" List of images to process. " if is_group else " Image to process. "
) ,
)
]
inputs . extend ( cls . extra_inputs )
return io . Schema (
node_id = cls . node_id ,
2026-05-21 15:14:16 +08:00
search_aliases = cls . search_aliases ,
2025-11-27 08:18:08 +08:00
display_name = cls . display_name or cls . node_id ,
2026-05-21 15:14:16 +08:00
category = cls . category ,
description = cls . description ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
is_input_list = is_group , # True for group, False for individual
inputs = inputs ,
outputs = [
io . Image . Output (
display_name = " images " ,
is_output_list = output_is_list ,
tooltip = " Processed images " ,
)
] ,
)
@classmethod
def execute ( cls , images , * * kwargs ) :
2026-07-21 12:02:54 +08:00
""" Execute the node. Routes to _process or _group_process based on mode.
For individual processing ( _process ) , automatically handles multi - frame
inputs ( video tensors [ T , H , W , C ] ) by applying _process per - frame and
concatenating the results . This allows all spatial transform nodes to
work with video without modification . Nodes that natively handle batched
tensors ( e . g . pure tensor math ) can set per_frame_process = False to
skip the per - frame loop .
"""
2025-11-27 08:18:08 +08:00
is_group = cls . _detect_processing_mode ( )
2026-06-04 02:03:32 +03:00
if is_group :
images = cls . _ensure_image_list ( images )
2025-11-27 08:18:08 +08:00
# Extract scalar values from lists for parameters
params = { }
for k , v in kwargs . items ( ) :
if isinstance ( v , list ) and len ( v ) == 1 :
params [ k ] = v [ 0 ]
else :
params [ k ] = v
if is_group :
# Group processing: images is list, call _group_process
result = cls . _group_process ( images , * * params )
else :
# Individual processing: images is single item, call _process
2026-07-21 12:02:54 +08:00
# Auto-loop over frames for multi-frame inputs (video [T, H, W, C])
# so that PIL-based spatial transforms work per-frame automatically.
if images . shape [ 0 ] > 1 and getattr ( cls , ' per_frame_process ' , True ) :
results = [ ]
for i in range ( images . shape [ 0 ] ) :
frame_result = cls . _process ( images [ i : i + 1 ] , * * params )
results . append ( frame_result )
result = torch . cat ( results , dim = 0 )
else :
result = cls . _process ( images , * * params )
2025-11-27 08:18:08 +08:00
return io . NodeOutput ( result )
@classmethod
def _process ( cls , image , * * kwargs ) :
""" Override this method for single-item processing.
Args :
image : tensor - Single image tensor
* * kwargs : Additional parameters ( already extracted from lists )
Returns :
tensor - Processed image
"""
raise NotImplementedError ( f " { cls . __name__ } must implement _process method " )
@classmethod
def _group_process ( cls , images , * * kwargs ) :
""" Override this method for group processing.
Args :
images : list [ tensor ] - List of image tensors
* * kwargs : Additional parameters ( already extracted from lists )
Returns :
list [ tensor ] - Processed images
"""
raise NotImplementedError (
f " { cls . __name__ } must implement _group_process method "
)
class TextProcessingNode ( io . ComfyNode ) :
""" Base class for text processing nodes that operate on texts.
Child classes should set :
node_id : Unique node identifier ( required )
2026-05-21 15:14:16 +08:00
search_aliases : List of search aliases ( optional )
2025-11-27 08:18:08 +08:00
display_name : Display name ( optional , defaults to node_id )
description : Node description ( optional )
extra_inputs : List of additional io . Input objects beyond " texts " ( optional )
is_group_process : None ( auto - detect ) , True ( group ) , or False ( individual ) ( optional )
is_output_list : True ( list output ) or False ( single output ) ( optional , default True )
2026-05-21 15:14:16 +08:00
is_deprecated : True if the node is deprecated ( optional , default False )
2025-11-27 08:18:08 +08:00
Child classes must implement ONE of :
_process ( cls , text , * * kwargs ) - > str ( for single - item processing )
_group_process ( cls , texts , * * kwargs ) - > list [ str ] ( for group processing )
"""
node_id = None
2026-05-21 15:14:16 +08:00
search_aliases = [ ]
2025-11-27 08:18:08 +08:00
display_name = None
description = None
extra_inputs = [ ]
is_group_process = None # None = auto-detect, True/False = explicit
is_output_list = None # None = auto-detect based on processing mode
2026-05-21 15:14:16 +08:00
is_deprecated = False
2025-11-27 08:18:08 +08:00
@classmethod
def _detect_processing_mode ( cls ) :
""" Detect whether this node uses group or individual processing.
Returns :
bool : True if group processing , False if individual processing
"""
# Explicit setting takes precedence
if cls . is_group_process is not None :
return cls . is_group_process
# Check which method is overridden by looking at the defining class in MRO
base_class = TextProcessingNode
# Find which class in MRO defines _process
process_definer = None
for klass in cls . __mro__ :
if " _process " in klass . __dict__ :
process_definer = klass
break
# Find which class in MRO defines _group_process
group_definer = None
for klass in cls . __mro__ :
if " _group_process " in klass . __dict__ :
group_definer = klass
break
# Check what was overridden (not defined in base class)
has_process = process_definer is not None and process_definer is not base_class
has_group = group_definer is not None and group_definer is not base_class
if has_process and has_group :
raise ValueError (
f " { cls . __name__ } : Cannot override both _process and _group_process. "
" Override only one, or set is_group_process explicitly. "
)
if not has_process and not has_group :
raise ValueError (
f " { cls . __name__ } : Must override either _process or _group_process "
)
return has_group
@classmethod
def define_schema ( cls ) :
if cls . node_id is None :
raise NotImplementedError ( f " { cls . __name__ } must set node_id class variable " )
is_group = cls . _detect_processing_mode ( )
inputs = [
io . String . Input (
" texts " ,
tooltip = " List of texts to process. " if is_group else " Text to process. " ,
)
]
inputs . extend ( cls . extra_inputs )
return io . Schema (
node_id = cls . node_id ,
display_name = cls . display_name or cls . node_id ,
2026-05-27 17:43:33 -07:00
category = " text " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
is_input_list = is_group , # True for group, False for individual
inputs = inputs ,
outputs = [
io . String . Output (
display_name = " texts " ,
is_output_list = cls . is_output_list ,
tooltip = " Processed texts " ,
)
] ,
)
@classmethod
def execute ( cls , texts , * * kwargs ) :
""" Execute the node. Routes to _process or _group_process based on mode. """
is_group = cls . _detect_processing_mode ( )
# Extract scalar values from lists for parameters
params = { }
for k , v in kwargs . items ( ) :
if isinstance ( v , list ) and len ( v ) == 1 :
params [ k ] = v [ 0 ]
else :
params [ k ] = v
if is_group :
# Group processing: texts is list, call _group_process
result = cls . _group_process ( texts , * * params )
else :
# Individual processing: texts is single item, call _process
result = cls . _process ( texts , * * params )
# Wrap result based on is_output_list
if cls . is_output_list :
# Result should already be a list (or will be for individual)
return io . NodeOutput ( result if is_group else [ result ] )
else :
# Single output - wrap in list for NodeOutput
return io . NodeOutput ( [ result ] )
@classmethod
def _process ( cls , text , * * kwargs ) :
""" Override this method for single-item processing.
Args :
text : str - Single text string
* * kwargs : Additional parameters ( already extracted from lists )
Returns :
str - Processed text
"""
raise NotImplementedError ( f " { cls . __name__ } must implement _process method " )
@classmethod
def _group_process ( cls , texts , * * kwargs ) :
""" Override this method for group processing.
Args :
texts : list [ str ] - List of text strings
* * kwargs : Additional parameters ( already extracted from lists )
Returns :
list [ str ] - Processed texts
"""
raise NotImplementedError (
f " { cls . __name__ } must implement _group_process method "
)
# ========== Image Transform Nodes ==========
class ResizeImagesByShorterEdgeNode ( ImageProcessingNode ) :
node_id = " ResizeImagesByShorterEdge "
2026-05-21 15:14:16 +08:00
display_name = " Resize Images by Shorter Edge (DEPRECATED) "
category = " image/transform "
description = " Resize images so that the shorter edge matches the specified dimension while preserving aspect ratio. "
is_deprecated = True # This node is superseded by Resize Image/Mask with resize_type = scale shorter dimension
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . Int . Input (
" shorter_edge " ,
default = 512 ,
min = 1 ,
max = 8192 ,
2026-05-21 15:14:16 +08:00
tooltip = " Target dimension for the shorter edge. " ,
2025-11-27 08:18:08 +08:00
) ,
]
@classmethod
def _process ( cls , image , shorter_edge ) :
img = tensor_to_pil ( image )
w , h = img . size
if w < h :
new_w = shorter_edge
new_h = int ( h * ( shorter_edge / w ) )
else :
new_h = shorter_edge
new_w = int ( w * ( shorter_edge / h ) )
img = img . resize ( ( new_w , new_h ) , Image . Resampling . LANCZOS )
return pil_to_tensor ( img )
class ResizeImagesByLongerEdgeNode ( ImageProcessingNode ) :
node_id = " ResizeImagesByLongerEdge "
2026-05-21 15:14:16 +08:00
display_name = " Resize Images by Longer Edge (DEPRECATED) "
category = " image/transform "
description = " Resize images so that the longer edge matches the specified dimension while preserving aspect ratio. "
is_deprecated = True # This node is superseded by Resize Image/Mask with resize_type = scale longer dimension
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . Int . Input (
" longer_edge " ,
default = 1024 ,
min = 1 ,
max = 8192 ,
2026-05-21 15:14:16 +08:00
tooltip = " Target dimension for the longer edge. " ,
2025-11-27 08:18:08 +08:00
) ,
]
@classmethod
def _process ( cls , image , longer_edge ) :
2025-12-30 03:07:29 +02:00
resized_images = [ ]
for image_i in image :
img = tensor_to_pil ( image_i )
w , h = img . size
if w > h :
new_w = longer_edge
new_h = int ( h * ( longer_edge / w ) )
else :
new_h = longer_edge
new_w = int ( w * ( longer_edge / h ) )
img = img . resize ( ( new_w , new_h ) , Image . Resampling . LANCZOS )
resized_images . append ( pil_to_tensor ( img ) )
return torch . cat ( resized_images , dim = 0 )
2025-11-27 08:18:08 +08:00
class CenterCropImagesNode ( ImageProcessingNode ) :
node_id = " CenterCropImages "
2026-05-21 15:14:16 +08:00
search_aliases = [ " crop " , " cut " , " trim " ]
display_name = " Crop Image (Center) "
category = " image/transform "
description = " Center crop an image to the specified dimensions. "
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . Int . Input ( " width " , default = 512 , min = 1 , max = 8192 , tooltip = " Crop width. " ) ,
io . Int . Input ( " height " , default = 512 , min = 1 , max = 8192 , tooltip = " Crop height. " ) ,
]
@classmethod
def _process ( cls , image , width , height ) :
img = tensor_to_pil ( image )
left = max ( 0 , ( img . width - width ) / / 2 )
top = max ( 0 , ( img . height - height ) / / 2 )
right = min ( img . width , left + width )
bottom = min ( img . height , top + height )
img = img . crop ( ( left , top , right , bottom ) )
return pil_to_tensor ( img )
class RandomCropImagesNode ( ImageProcessingNode ) :
node_id = " RandomCropImages "
2026-05-21 15:14:16 +08:00
search_aliases = [ " crop " , " cut " , " trim " ]
display_name = " Crop Image (Random) "
category = " image/transform "
description = " Randomly crop an image to the specified dimensions. "
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . Int . Input ( " width " , default = 512 , min = 1 , max = 8192 , tooltip = " Crop width. " ) ,
io . Int . Input ( " height " , default = 512 , min = 1 , max = 8192 , tooltip = " Crop height. " ) ,
io . Int . Input (
" seed " , default = 0 , min = 0 , max = 0xFFFFFFFFFFFFFFFF , tooltip = " Random seed. "
) ,
]
@classmethod
def _process ( cls , image , width , height , seed ) :
np . random . seed ( seed % ( 2 * * 32 - 1 ) )
img = tensor_to_pil ( image )
max_left = max ( 0 , img . width - width )
max_top = max ( 0 , img . height - height )
left = np . random . randint ( 0 , max_left + 1 ) if max_left > 0 else 0
top = np . random . randint ( 0 , max_top + 1 ) if max_top > 0 else 0
right = min ( img . width , left + width )
bottom = min ( img . height , top + height )
img = img . crop ( ( left , top , right , bottom ) )
return pil_to_tensor ( img )
class NormalizeImagesNode ( ImageProcessingNode ) :
node_id = " NormalizeImages "
2026-05-21 15:14:16 +08:00
search_aliases = [ " normalize " , " normalize colors " ]
display_name = " Normalize Image Colors "
category = " image/color "
2025-11-27 08:18:08 +08:00
description = " Normalize images using mean and standard deviation. "
2026-07-21 12:02:54 +08:00
per_frame_process = False # Pure tensor math, handles any batch size
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . Float . Input (
" mean " ,
default = 0.5 ,
min = 0.0 ,
max = 1.0 ,
tooltip = " Mean value for normalization. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-11-27 08:18:08 +08:00
) ,
io . Float . Input (
" std " ,
default = 0.5 ,
min = 0.001 ,
max = 1.0 ,
tooltip = " Standard deviation for normalization. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-11-27 08:18:08 +08:00
) ,
]
@classmethod
def _process ( cls , image , mean , std ) :
return ( image - mean ) / std
class AdjustBrightnessNode ( ImageProcessingNode ) :
node_id = " AdjustBrightness "
2026-05-21 15:14:16 +08:00
search_aliases = [ " brightness " ]
2025-11-27 08:18:08 +08:00
display_name = " Adjust Brightness "
2026-05-21 15:14:16 +08:00
category = " image/adjustments "
description = " Adjust the brightness of an image. "
2026-07-21 12:02:54 +08:00
per_frame_process = False # Pure tensor math, handles any batch size
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . Float . Input (
" factor " ,
default = 1.0 ,
min = 0.0 ,
max = 2.0 ,
tooltip = " Brightness factor. 1.0 = no change, <1.0 = darker, >1.0 = brighter. " ,
) ,
]
@classmethod
def _process ( cls , image , factor ) :
return ( image * factor ) . clamp ( 0.0 , 1.0 )
class AdjustContrastNode ( ImageProcessingNode ) :
node_id = " AdjustContrast "
2026-05-21 15:14:16 +08:00
search_aliases = [ " contrast " ]
2025-11-27 08:18:08 +08:00
display_name = " Adjust Contrast "
2026-05-21 15:14:16 +08:00
category = " image/adjustments "
description = " Adjust the contrast of an image. "
2026-07-21 12:02:54 +08:00
per_frame_process = False # Pure tensor math, handles any batch size
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . Float . Input (
" factor " ,
default = 1.0 ,
min = 0.0 ,
max = 2.0 ,
tooltip = " Contrast factor. 1.0 = no change, <1.0 = less contrast, >1.0 = more contrast. " ,
) ,
]
@classmethod
def _process ( cls , image , factor ) :
return ( ( image - 0.5 ) * factor + 0.5 ) . clamp ( 0.0 , 1.0 )
class ShuffleDatasetNode ( ImageProcessingNode ) :
node_id = " ShuffleDataset "
2026-05-21 15:14:16 +08:00
search_aliases = [ " shuffle " , " randomize " , " mix " ]
display_name = " Shuffle Images List "
category = " image/batch "
description = " Randomly shuffle the order of images in a list. "
2025-11-27 08:18:08 +08:00
is_group_process = True # Requires full list to shuffle
extra_inputs = [
io . Int . Input (
" seed " , default = 0 , min = 0 , max = 0xFFFFFFFFFFFFFFFF , tooltip = " Random seed. "
) ,
]
@classmethod
def _group_process ( cls , images , seed ) :
np . random . seed ( seed % ( 2 * * 32 - 1 ) )
indices = np . random . permutation ( len ( images ) )
return [ images [ i ] for i in indices ]
class ShuffleImageTextDatasetNode ( io . ComfyNode ) :
""" Special node that shuffles both images and texts together. """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " ShuffleImageTextDataset " ,
2026-05-21 15:14:16 +08:00
search_aliases = [ " shuffle " , " randomize " , " mix " ] ,
display_name = " Shuffle Pairs of Image-Text " ,
category = " image/batch " ,
description = " Randomly shuffle the order of pairs of image-text in a list. " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
is_input_list = True ,
inputs = [
io . Image . Input ( " images " , tooltip = " List of images to shuffle. " ) ,
2026-05-21 15:14:16 +08:00
io . String . Input ( " texts " , tooltip = " List of texts to shuffle. " , force_input = True ) ,
2025-11-27 08:18:08 +08:00
io . Int . Input (
" seed " ,
default = 0 ,
min = 0 ,
max = 0xFFFFFFFFFFFFFFFF ,
tooltip = " Random seed. " ,
) ,
] ,
outputs = [
io . Image . Output (
display_name = " images " ,
is_output_list = True ,
tooltip = " Shuffled images " ,
) ,
io . String . Output (
display_name = " texts " , is_output_list = True , tooltip = " Shuffled texts "
) ,
] ,
)
@classmethod
def execute ( cls , images , texts , seed ) :
seed = seed [ 0 ] # Extract scalar
np . random . seed ( seed % ( 2 * * 32 - 1 ) )
indices = np . random . permutation ( len ( images ) )
shuffled_images = [ images [ i ] for i in indices ]
shuffled_texts = [ texts [ i ] for i in indices ]
return io . NodeOutput ( shuffled_images , shuffled_texts )
2026-07-21 12:02:54 +08:00
# ========== Video Processing Nodes ==========
class VideoFrameSampleNode ( io . ComfyNode ) :
""" Sample a fixed number of frames from a video using various strategies.
For contiguous strategies ( " head " / " tail " ) the result is a fully lazy
VideoInput ( no frames decoded ) . For non - contiguous strategies
( " uniform " / " random " ) only the selected indices are decoded .
"""
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " VideoFrameSample " ,
search_aliases = [ " sample frames " , " extract frames " ] ,
display_name = " Sample Video Frame " ,
category = " video " ,
description = " Sample a fixed number of frames from a video using various strategies. " ,
is_experimental = True ,
inputs = [
io . Video . Input ( " video " , tooltip = " Input video. " ) ,
io . Int . Input (
" num_frames " ,
default = 16 ,
min = 1 ,
max = 9999 ,
tooltip = " Number of frames to sample. " ,
) ,
io . Combo . Input (
" strategy " ,
options = [ " uniform " , " head " , " tail " , " random " ] ,
default = " uniform " ,
tooltip = " uniform: evenly spaced, head: first N, tail: last N, random: random sorted. " ,
) ,
io . Int . Input (
" seed " ,
default = 0 ,
min = 0 ,
max = 0xFFFFFFFFFFFFFFFF ,
tooltip = " Random seed (only used with ' random ' strategy). " ,
) ,
] ,
outputs = [
io . Video . Output ( display_name = " video " , tooltip = " Sampled video. " ) ,
] ,
)
@classmethod
def execute ( cls , video , num_frames , strategy , seed ) :
total_frames = video . get_frame_count ( )
num_frames = min ( num_frames , total_frames )
fps = float ( video . get_frame_rate ( ) )
if strategy == " head " :
return io . NodeOutput (
video . as_trimmed ( 0.0 , num_frames / fps , strict_duration = False )
)
if strategy == " tail " :
start_t = ( total_frames - num_frames ) / fps
return io . NodeOutput (
video . as_trimmed ( start_t , num_frames / fps , strict_duration = False )
)
if strategy == " uniform " :
if num_frames == 1 :
indices = [ total_frames / / 2 ]
else :
indices = [ round ( i * ( total_frames - 1 ) / ( num_frames - 1 ) ) for i in range ( num_frames ) ]
elif strategy == " random " :
rng = np . random . RandomState ( seed % ( 2 * * 32 - 1 ) )
indices = sorted ( rng . choice ( total_frames , size = num_frames , replace = False ) . tolist ( ) )
else :
raise ValueError ( f " Unknown strategy: { strategy } " )
return io . NodeOutput ( _decode_selected_frames ( video , indices ) )
class VideoTemporalCropNode ( io . ComfyNode ) :
""" Crop a continuous range of frames from a video (fully lazy). """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " VideoTemporalCrop " ,
search_aliases = [ " crop " , " crop video " , " temporal crop " , " truncate video " ] ,
display_name = " Crop Video (Temporal) " ,
category = " video/transform " ,
description = " Crop a continuous range of frames from a video. " ,
is_experimental = True ,
inputs = [
io . Video . Input ( " video " , tooltip = " Input video. " ) ,
io . Int . Input (
" start_frame " ,
default = 0 ,
min = 0 ,
max = 99999 ,
tooltip = " Starting frame index. " ,
) ,
io . Int . Input (
" length " ,
default = 16 ,
min = 1 ,
max = 99999 ,
tooltip = " Number of frames to keep. " ,
) ,
] ,
outputs = [
io . Video . Output ( display_name = " video " , tooltip = " Cropped video (lazy). " ) ,
] ,
)
@classmethod
def execute ( cls , video , start_frame , length ) :
total_frames = video . get_frame_count ( )
fps = float ( video . get_frame_rate ( ) )
start_frame = min ( start_frame , max ( total_frames - 1 , 0 ) )
length = min ( length , total_frames - start_frame )
return io . NodeOutput (
video . as_trimmed ( start_frame / fps , length / fps , strict_duration = False )
)
class VideoRandomTemporalCropNode ( io . ComfyNode ) :
""" Randomly crop a continuous range of frames from a video (fully lazy). """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " VideoRandomTemporalCrop " ,
search_aliases = [ " crop " , " crop video " , " temporal crop " , " truncate video " , " random crop " ] ,
display_name = " Crop Video (Temporal Random) " ,
category = " video/transform " ,
description = " Randomly crop a continuous range of frames from a video. " ,
is_experimental = True ,
inputs = [
io . Video . Input ( " video " , tooltip = " Input video. " ) ,
io . Int . Input (
" length " ,
default = 16 ,
min = 1 ,
max = 99999 ,
tooltip = " Number of frames to keep. " ,
) ,
io . Int . Input (
" seed " ,
default = 0 ,
min = 0 ,
max = 0xFFFFFFFFFFFFFFFF ,
tooltip = " Random seed. " ,
) ,
] ,
outputs = [
io . Video . Output ( display_name = " video " , tooltip = " Cropped video (lazy). " ) ,
] ,
)
@classmethod
def execute ( cls , video , length , seed ) :
total_frames = video . get_frame_count ( )
fps = float ( video . get_frame_rate ( ) )
length = min ( length , total_frames )
max_start = total_frames - length
rng = np . random . RandomState ( seed % ( 2 * * 32 - 1 ) )
start = rng . randint ( 0 , max_start + 1 ) if max_start > 0 else 0
return io . NodeOutput (
video . as_trimmed ( start / fps , length / fps , strict_duration = False )
)
class ShuffleVideoDatasetNode ( io . ComfyNode ) :
""" Randomly shuffle the order of videos in the dataset. """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " ShuffleVideoDataset " ,
search_aliases = [ " shuffle " , " randomize " , " mix " ] ,
display_name = " Shuffle Videos List " ,
category = " video/batch " ,
description = " Randomly shuffle the order of videos in a list. " ,
is_experimental = True ,
is_input_list = True ,
inputs = [
io . Video . Input ( " videos " , tooltip = " List of videos to shuffle. " ) ,
io . Int . Input (
" seed " , default = 0 , min = 0 , max = 0xFFFFFFFFFFFFFFFF , tooltip = " Random seed. "
) ,
] ,
outputs = [
io . Video . Output (
display_name = " videos " ,
is_output_list = True ,
tooltip = " Shuffled videos " ,
) ,
] ,
)
@classmethod
def execute ( cls , videos , seed ) :
seed = seed [ 0 ] if isinstance ( seed , list ) else seed
np . random . seed ( seed % ( 2 * * 32 - 1 ) )
indices = np . random . permutation ( len ( videos ) )
return io . NodeOutput ( [ videos [ i ] for i in indices ] )
class ShuffleVideoTextDatasetNode ( io . ComfyNode ) :
""" Shuffle videos and their captions together, preserving pairs. """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " ShuffleVideoTextDataset " ,
search_aliases = [ " shuffle " , " randomize " , " mix " ] ,
display_name = " Shuffle Pairs of Video-Text " ,
category = " dataset/video " ,
description = " Randomly shuffle the order of pairs of video-text in a list. " ,
is_experimental = True ,
is_input_list = True ,
inputs = [
io . Video . Input ( " videos " , tooltip = " List of videos to shuffle. " ) ,
io . String . Input ( " texts " , tooltip = " List of texts to shuffle. " ) ,
io . Int . Input (
" seed " ,
default = 0 ,
min = 0 ,
max = 0xFFFFFFFFFFFFFFFF ,
tooltip = " Random seed. " ,
) ,
] ,
outputs = [
io . Video . Output (
display_name = " videos " ,
is_output_list = True ,
tooltip = " Shuffled videos " ,
) ,
io . String . Output (
display_name = " texts " ,
is_output_list = True ,
tooltip = " Shuffled texts " ,
) ,
] ,
)
@classmethod
def execute ( cls , videos , texts , seed ) :
seed = seed [ 0 ] if isinstance ( seed , list ) else seed
np . random . seed ( seed % ( 2 * * 32 - 1 ) )
indices = np . random . permutation ( len ( videos ) )
return io . NodeOutput (
[ videos [ i ] for i in indices ] ,
[ texts [ i ] for i in indices ] ,
)
2025-11-27 08:18:08 +08:00
# ========== Text Transform Nodes ==========
class TextToLowercaseNode ( TextProcessingNode ) :
node_id = " TextToLowercase "
2026-05-21 15:14:16 +08:00
search_aliases = [ " lowercase " ]
display_name = " Convert Text to Lowercase (DEPRECATED) "
category = " text "
description = " Convert text to lowercase. "
is_deprecated = True # This node is superseded by the Convert Text Case node
2025-11-27 08:18:08 +08:00
@classmethod
def _process ( cls , text ) :
return text . lower ( )
class TextToUppercaseNode ( TextProcessingNode ) :
node_id = " TextToUppercase "
2026-05-21 15:14:16 +08:00
search_aliases = [ " uppercase " ]
display_name = " Convert Text to Uppercase (DEPRECATED) "
category = " text "
description = " Convert text to uppercase. "
is_deprecated = True # This node is superseded by the Convert Text Case node
2025-11-27 08:18:08 +08:00
@classmethod
def _process ( cls , text ) :
return text . upper ( )
class TruncateTextNode ( TextProcessingNode ) :
node_id = " TruncateText "
2026-05-21 15:14:16 +08:00
search_aliases = [ " truncate " , " cut " , " shorten " ]
2025-11-27 08:18:08 +08:00
display_name = " Truncate Text "
2026-05-21 15:14:16 +08:00
category = " text "
description = " Truncate text to a maximum length. "
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . Int . Input (
" max_length " , default = 77 , min = 1 , max = 10000 , tooltip = " Maximum text length. "
) ,
]
@classmethod
def _process ( cls , text , max_length ) :
return text [ : max_length ]
class AddTextPrefixNode ( TextProcessingNode ) :
node_id = " AddTextPrefix "
2026-05-21 15:14:16 +08:00
display_name = " Add Text Prefix (DEPRECATED) "
category = " text "
2025-11-27 08:18:08 +08:00
description = " Add a prefix to all texts. "
2026-05-21 15:14:16 +08:00
is_deprecated = True # This node is superseded by the Concatenate Text node
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . String . Input ( " prefix " , default = " " , tooltip = " Prefix to add. " ) ,
]
@classmethod
def _process ( cls , text , prefix ) :
return prefix + text
class AddTextSuffixNode ( TextProcessingNode ) :
node_id = " AddTextSuffix "
2026-05-21 15:14:16 +08:00
display_name = " Add Text Suffix (DEPRECATED) "
category = " text "
2025-11-27 08:18:08 +08:00
description = " Add a suffix to all texts. "
2026-05-21 15:14:16 +08:00
is_deprecated = True # This node is superseded by the Concatenate Text node
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . String . Input ( " suffix " , default = " " , tooltip = " Suffix to add. " ) ,
]
@classmethod
def _process ( cls , text , suffix ) :
return text + suffix
class ReplaceTextNode ( TextProcessingNode ) :
node_id = " ReplaceText "
2026-05-21 15:14:16 +08:00
display_name = " Replace Text (DEPRECATED) "
category = " text "
2025-11-27 08:18:08 +08:00
description = " Replace text in all texts. "
2026-05-21 15:14:16 +08:00
is_deprecated = True # This node is superseded by the other Replace Text node
2025-11-27 08:18:08 +08:00
extra_inputs = [
io . String . Input ( " find " , default = " " , tooltip = " Text to find. " ) ,
io . String . Input ( " replace " , default = " " , tooltip = " Text to replace with. " ) ,
]
@classmethod
def _process ( cls , text , find , replace ) :
return text . replace ( find , replace )
class StripWhitespaceNode ( TextProcessingNode ) :
node_id = " StripWhitespace "
2026-05-21 15:14:16 +08:00
display_name = " Strip Whitespace (DEPRECATED) "
category = " text "
2025-11-27 08:18:08 +08:00
description = " Strip leading and trailing whitespace from all texts. "
2026-05-21 15:14:16 +08:00
is_deprecated = True # This node is superseded by the Trim Text node
2025-11-27 08:18:08 +08:00
@classmethod
def _process ( cls , text ) :
return text . strip ( )
# ========== Group Processing Example Nodes ==========
class ImageDeduplicationNode ( ImageProcessingNode ) :
2026-05-21 15:14:16 +08:00
""" Remove duplicate or very similar images from a list using perceptual hashing. """
2025-11-27 08:18:08 +08:00
node_id = " ImageDeduplication "
2026-05-21 15:14:16 +08:00
search_aliases = [ " deduplicate " , " remove duplicates " , " similarity filter " ]
display_name = " Deduplicate Images "
category = " image/batch "
description = " Remove duplicate or very similar images from a list. "
2025-11-27 08:18:08 +08:00
is_group_process = True # Requires full list to compare images
extra_inputs = [
io . Float . Input (
" similarity_threshold " ,
default = 0.95 ,
min = 0.0 ,
max = 1.0 ,
tooltip = " Similarity threshold (0-1). Higher means more similar. Images above this threshold are considered duplicates. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-11-27 08:18:08 +08:00
) ,
]
@classmethod
def _group_process ( cls , images , similarity_threshold ) :
""" Remove duplicate images using perceptual hashing. """
if len ( images ) == 0 :
return [ ]
# Compute simple perceptual hash for each image
def compute_hash ( img_tensor ) :
""" Compute a simple perceptual hash by resizing to 8x8 and comparing to average. """
img = tensor_to_pil ( img_tensor )
# Resize to 8x8
img_small = img . resize ( ( 8 , 8 ) , Image . Resampling . LANCZOS ) . convert ( " L " )
# Get pixels
pixels = list ( img_small . getdata ( ) )
# Compute average
avg = sum ( pixels ) / len ( pixels )
# Create hash (1 if above average, 0 otherwise)
hash_bits = " " . join ( " 1 " if p > avg else " 0 " for p in pixels )
return hash_bits
def hamming_distance ( hash1 , hash2 ) :
""" Compute Hamming distance between two hash strings. """
return sum ( c1 != c2 for c1 , c2 in zip ( hash1 , hash2 ) )
# Compute hashes for all images
hashes = [ compute_hash ( img ) for img in images ]
# Find duplicates
keep_indices = [ ]
for i in range ( len ( images ) ) :
is_duplicate = False
for j in keep_indices :
# Compare hashes
distance = hamming_distance ( hashes [ i ] , hashes [ j ] )
similarity = 1.0 - ( distance / 64.0 ) # 64 bits total
if similarity > = similarity_threshold :
is_duplicate = True
logging . info (
f " Image { i } is similar to image { j } (similarity: { similarity : .3f } ), skipping "
)
break
if not is_duplicate :
keep_indices . append ( i )
# Return only unique images
unique_images = [ images [ i ] for i in keep_indices ]
logging . info (
f " Deduplication: kept { len ( unique_images ) } out of { len ( images ) } images "
)
return unique_images
class ImageGridNode ( ImageProcessingNode ) :
""" Combine multiple images into a single grid/collage. """
node_id = " ImageGrid "
2026-05-21 15:14:16 +08:00
search_aliases = [ " grid " , " collage " , " combine " ]
display_name = " Make Image Grid "
category = " image/batch "
2025-11-27 08:18:08 +08:00
description = " Arrange multiple images into a grid layout. "
is_group_process = True # Requires full list to create grid
is_output_list = False # Outputs single grid image
extra_inputs = [
io . Int . Input (
" columns " ,
default = 4 ,
min = 1 ,
max = 20 ,
tooltip = " Number of columns in the grid. " ,
) ,
io . Int . Input (
" cell_width " ,
default = 256 ,
min = 32 ,
max = 2048 ,
tooltip = " Width of each cell in the grid. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-11-27 08:18:08 +08:00
) ,
io . Int . Input (
" cell_height " ,
default = 256 ,
min = 32 ,
max = 2048 ,
tooltip = " Height of each cell in the grid. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-11-27 08:18:08 +08:00
) ,
io . Int . Input (
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
" padding " , default = 4 , min = 0 , max = 50 , tooltip = " Padding between images. " , advanced = True
2025-11-27 08:18:08 +08:00
) ,
]
@classmethod
def _group_process ( cls , images , columns , cell_width , cell_height , padding ) :
""" Arrange images into a grid. """
if len ( images ) == 0 :
raise ValueError ( " Cannot create grid from empty image list " )
# Calculate grid dimensions
num_images = len ( images )
rows = ( num_images + columns - 1 ) / / columns # Ceiling division
# Calculate total grid size
grid_width = columns * cell_width + ( columns - 1 ) * padding
grid_height = rows * cell_height + ( rows - 1 ) * padding
# Create blank grid
grid = Image . new ( " RGB " , ( grid_width , grid_height ) , ( 0 , 0 , 0 ) )
# Place images
for idx , img_tensor in enumerate ( images ) :
row = idx / / columns
col = idx % columns
# Convert to PIL and resize to cell size
img = tensor_to_pil ( img_tensor )
img = img . resize ( ( cell_width , cell_height ) , Image . Resampling . LANCZOS )
# Calculate position
x = col * ( cell_width + padding )
y = row * ( cell_height + padding )
# Paste into grid
grid . paste ( img , ( x , y ) )
logging . info (
f " Created { columns } x { rows } grid with { num_images } images ( { grid_width } x { grid_height } ) "
)
return pil_to_tensor ( grid )
class MergeImageListsNode ( ImageProcessingNode ) :
""" Merge multiple image lists into a single list. """
node_id = " MergeImageLists "
2026-05-21 15:14:16 +08:00
search_aliases = [ " list " , " merge list " , " make list " ]
display_name = " Merge Image Lists (DEPRECATED) "
category = " image/batch "
2025-11-27 08:18:08 +08:00
description = " Concatenate multiple image lists into one. "
is_group_process = True # Receives images as list
2026-05-21 15:14:16 +08:00
is_deprecated = True # This node is superseded by the Create List node
2025-11-27 08:18:08 +08:00
@classmethod
def _group_process ( cls , images ) :
""" Simply return the images list (already merged by input handling). """
# When multiple list inputs are connected, they're concatenated
# For now, this is a simple pass-through
logging . info ( f " Merged image list contains { len ( images ) } images " )
return images
class MergeTextListsNode ( TextProcessingNode ) :
""" Merge multiple text lists into a single list. """
node_id = " MergeTextLists "
2026-05-21 15:14:16 +08:00
display_name = " Merge Text Lists (DEPRECATED) "
category = " text "
2025-11-27 08:18:08 +08:00
description = " Concatenate multiple text lists into one. "
is_group_process = True # Receives texts as list
2026-05-21 15:14:16 +08:00
is_deprecated = True # This node is superseded by the Create List node
2025-11-27 08:18:08 +08:00
@classmethod
def _group_process ( cls , texts ) :
""" Simply return the texts list (already merged by input handling). """
# When multiple list inputs are connected, they're concatenated
# For now, this is a simple pass-through
logging . info ( f " Merged text list contains { len ( texts ) } texts " )
return texts
# ========== Training Dataset Nodes ==========
2025-12-18 11:15:27 +08:00
class ResolutionBucket ( io . ComfyNode ) :
""" Bucket latents and conditions by resolution for efficient batch training. """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " ResolutionBucket " ,
2026-05-21 15:14:16 +08:00
search_aliases = [ " bucket by resolution " , " group by resolution " , " batch by resolution " ] ,
2025-12-18 11:15:27 +08:00
display_name = " Resolution Bucket " ,
2026-05-27 17:43:33 -07:00
category = " model/training " ,
2026-05-21 15:14:16 +08:00
description = " Group latents and conditionings into buckets " ,
2025-12-18 11:15:27 +08:00
is_experimental = True ,
is_input_list = True ,
inputs = [
io . Latent . Input (
" latents " ,
tooltip = " List of latent dicts to bucket by resolution. " ,
) ,
io . Conditioning . Input (
" conditioning " ,
tooltip = " List of conditioning lists (must match latents length). " ,
) ,
] ,
outputs = [
io . Latent . Output (
display_name = " latents " ,
is_output_list = True ,
tooltip = " List of batched latent dicts, one per resolution bucket. " ,
) ,
io . Conditioning . Output (
display_name = " conditioning " ,
is_output_list = True ,
tooltip = " List of condition lists, one per resolution bucket. " ,
) ,
] ,
)
@classmethod
def execute ( cls , latents , conditioning ) :
# latents: list[{"samples": tensor}] where tensor is (B, C, H, W), typically B=1
# conditioning: list[list[cond]]
# Validate lengths match
if len ( latents ) != len ( conditioning ) :
raise ValueError (
f " Number of latents ( { len ( latents ) } ) does not match number of conditions ( { len ( conditioning ) } ). "
)
# Flatten latents and conditions to individual samples
flat_latents = [ ] # list of (C, H, W) tensors
flat_conditions = [ ] # list of condition lists
for latent_dict , cond in zip ( latents , conditioning ) :
samples = latent_dict [ " samples " ] # (B, C, H, W)
batch_size = samples . shape [ 0 ]
# cond is a list of conditions with length == batch_size
for i in range ( batch_size ) :
flat_latents . append ( samples [ i ] ) # (C, H, W)
flat_conditions . append ( cond [ i ] ) # single condition
# Group by resolution (H, W)
buckets = { } # (H, W) -> {"latents": list, "conditions": list}
for latent , cond in zip ( flat_latents , flat_conditions ) :
# latent shape is (..., H, W) (B, C, H, W) or (B, T, C, H ,W)
h , w = latent . shape [ - 2 ] , latent . shape [ - 1 ]
key = ( h , w )
if key not in buckets :
buckets [ key ] = { " latents " : [ ] , " conditions " : [ ] }
buckets [ key ] [ " latents " ] . append ( latent )
buckets [ key ] [ " conditions " ] . append ( cond )
# Convert buckets to output format
output_latents = [ ] # list[{"samples": tensor}] where tensor is (Bi, ..., H, W)
output_conditions = [ ] # list[list[cond]] where each inner list has Bi conditions
for ( h , w ) , bucket_data in buckets . items ( ) :
# Stack latents into batch: list of (..., H, W) -> (Bi, ..., H, W)
stacked_latents = torch . stack ( bucket_data [ " latents " ] , dim = 0 )
output_latents . append ( { " samples " : stacked_latents } )
# Conditions stay as list of condition lists
output_conditions . append ( bucket_data [ " conditions " ] )
logging . info (
f " Resolution bucket ( { h } x { w } ): { len ( bucket_data [ ' latents ' ] ) } samples "
)
logging . info ( f " Created { len ( buckets ) } resolution buckets from { len ( flat_latents ) } samples " )
return io . NodeOutput ( output_latents , output_conditions )
2025-11-27 08:18:08 +08:00
class MakeTrainingDataset ( io . ComfyNode ) :
""" Encode images with VAE and texts with CLIP to create a training dataset. """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " MakeTrainingDataset " ,
add search aliases to all nodes (#12035)
* feat: Add search_aliases field to node schema
Adds `search_aliases` field to improve node discoverability. Users can define alternative search terms for nodes (e.g., "text concat" → StringConcatenate).
Changes:
- Add `search_aliases: list[str]` to V3 Schema
- Add `SEARCH_ALIASES` support for V1 nodes
- Include field in `/object_info` response
- Add aliases to high-priority core nodes
V1 usage:
```python
class MyNode:
SEARCH_ALIASES = ["alt name", "synonym"]
```
V3 usage:
```python
io.Schema(
node_id="MyNode",
search_aliases=["alt name", "synonym"],
...
)
```
## Related PRs
- Frontend: Comfy-Org/ComfyUI_frontend#XXXX (draft - merge after this)
- Docs: Comfy-Org/docs#XXXX (draft - merge after stable)
* Propagate search_aliases through V3 Schema.get_v1_info to NodeInfoV1
* feat: add SEARCH_ALIASES for core nodes (#12016)
Add search aliases to 22 core nodes in nodes.py to improve node discoverability:
- Checkpoint/model loaders: CheckpointLoader, DiffusersLoader
- Conditioning nodes: ConditioningAverage, ConditioningSetArea, ConditioningSetMask, ConditioningZeroOut
- Style nodes: StyleModelApply
- Image nodes: LoadImageMask, LoadImageOutput, ImageBatch, ImageInvert, ImagePadForOutpaint
- Latent nodes: LoadLatent, SaveLatent, LatentBlend, LatentComposite, LatentCrop, LatentFlip, LatentFromBatch, LatentUpscale, LatentUpscaleBy, RepeatLatentBatch
* feat: add SEARCH_ALIASES for image, mask, and string nodes (#12017)
Add search aliases to nodes in comfy_extras for better discoverability:
- nodes_mask.py: mask manipulation nodes
- nodes_images.py: image processing nodes
- nodes_post_processing.py: post-processing effect nodes
- nodes_string.py: string manipulation nodes
- nodes_compositing.py: compositing nodes
- nodes_morphology.py: morphological operation nodes
- nodes_latent.py: latent space nodes
Uses search_aliases parameter in io.Schema() for v3 nodes.
* feat: add SEARCH_ALIASES for audio and video nodes (#12018)
Add search aliases to audio and video nodes for better discoverability:
- nodes_audio.py: audio loading, saving, and processing nodes
- nodes_video.py: video loading and processing nodes
- nodes_wan.py: WAN model nodes
Uses search_aliases parameter in io.Schema() for v3 nodes.
* feat: add SEARCH_ALIASES for model and misc nodes (#12019)
Add search aliases to model-related and miscellaneous nodes:
- Model nodes: nodes_model_merging.py, nodes_model_advanced.py, nodes_lora_extract.py
- Sampler nodes: nodes_custom_sampler.py, nodes_align_your_steps.py
- Control nodes: nodes_controlnet.py, nodes_attention_multiply.py, nodes_hooks.py
- Training nodes: nodes_train.py, nodes_dataset.py
- Utility nodes: nodes_logic.py, nodes_canny.py, nodes_differential_diffusion.py
- Architecture-specific: nodes_sd3.py, nodes_pixart.py, nodes_lumina2.py, nodes_kandinsky5.py, nodes_hidream.py, nodes_fresca.py, nodes_hunyuan3d.py
- Media nodes: nodes_load_3d.py, nodes_webcam.py, nodes_preview_any.py, nodes_wanmove.py
Uses search_aliases parameter in io.Schema() for v3 nodes, SEARCH_ALIASES class attribute for legacy nodes.
2026-01-22 18:36:58 -08:00
search_aliases = [ " encode dataset " ] ,
2025-11-27 08:18:08 +08:00
display_name = " Make Training Dataset " ,
2026-05-27 17:43:33 -07:00
category = " model/training " ,
2026-05-21 15:14:16 +08:00
description = " Encode images with VAE and texts with CLIP to create a training dataset of latents and conditionings. " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
is_input_list = True , # images and texts as lists
inputs = [
io . Image . Input ( " images " , tooltip = " List of images to encode. " ) ,
io . Vae . Input (
" vae " , tooltip = " VAE model for encoding images to latents. "
) ,
io . Clip . Input (
" clip " , tooltip = " CLIP model for encoding text to conditioning. "
) ,
io . String . Input (
" texts " ,
optional = True ,
tooltip = " List of text captions. Can be length n (matching images), 1 (repeated for all), or omitted (uses empty string). " ,
2026-05-21 15:14:16 +08:00
force_input = True
2025-11-27 08:18:08 +08:00
) ,
] ,
outputs = [
io . Latent . Output (
display_name = " latents " ,
is_output_list = True ,
tooltip = " List of latent dicts " ,
) ,
io . Conditioning . Output (
display_name = " conditioning " ,
is_output_list = True ,
tooltip = " List of conditioning lists " ,
) ,
] ,
)
@classmethod
def execute ( cls , images , vae , clip , texts = None ) :
# Extract scalars (vae and clip are single values wrapped in lists)
vae = vae [ 0 ]
clip = clip [ 0 ]
# Handle text list
num_images = len ( images )
if texts is None or len ( texts ) == 0 :
# Treat as [""] for unconditional training
texts = [ " " ]
if len ( texts ) == 1 and num_images > 1 :
# Repeat single text for all images
texts = texts * num_images
elif len ( texts ) != num_images :
raise ValueError (
f " Number of texts ( { len ( texts ) } ) does not match number of images ( { num_images } ). "
f " Text list should have length { num_images } , 1, or 0. "
)
# Encode images with VAE
logging . info ( f " Encoding { num_images } images with VAE... " )
latents_list = [ ] # list[{"samples": tensor}]
for img_tensor in images :
# img_tensor is [1, H, W, 3]
latent_tensor = vae . encode ( img_tensor [ : , : , : , : 3 ] )
latents_list . append ( { " samples " : latent_tensor } )
# Encode texts with CLIP
logging . info ( f " Encoding { len ( texts ) } texts with CLIP... " )
conditioning_list = [ ] # list[list[cond]]
for text in texts :
if text == " " :
cond = clip . encode_from_tokens_scheduled ( clip . tokenize ( " " ) )
else :
tokens = clip . tokenize ( text )
cond = clip . encode_from_tokens_scheduled ( tokens )
conditioning_list . append ( cond )
logging . info (
f " Created dataset with { len ( latents_list ) } latents and { len ( conditioning_list ) } conditioning. "
)
return io . NodeOutput ( latents_list , conditioning_list )
class SaveTrainingDataset ( io . ComfyNode ) :
""" Save encoded training dataset (latents + conditioning) to disk. """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " SaveTrainingDataset " ,
2026-05-21 15:14:16 +08:00
search_aliases = [ " export dataset " , " save dataset " ] ,
2025-11-27 08:18:08 +08:00
display_name = " Save Training Dataset " ,
2026-05-27 17:43:33 -07:00
category = " model/training " ,
2026-05-21 15:14:16 +08:00
description = " Save encoded training dataset (latents + conditioning) to disk for efficient loading during training. " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
is_output_node = True ,
is_input_list = True , # Receive lists
inputs = [
io . Latent . Input (
" latents " ,
tooltip = " List of latent dicts from MakeTrainingDataset. " ,
) ,
io . Conditioning . Input (
" conditioning " ,
tooltip = " List of conditioning lists from MakeTrainingDataset. " ,
) ,
io . String . Input (
" folder_name " ,
default = " training_dataset " ,
2026-07-29 07:03:04 +08:00
tooltip = " Name of folder to save the dataset into, inside the datasets directory. Subfolders like ' project/run1 ' are allowed. " ,
2025-11-27 08:18:08 +08:00
) ,
io . Int . Input (
" shard_size " ,
default = 1000 ,
min = 1 ,
max = 100000 ,
tooltip = " Number of samples per shard file. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-11-27 08:18:08 +08:00
) ,
] ,
outputs = [ ] ,
)
@classmethod
def execute ( cls , latents , conditioning , folder_name , shard_size ) :
# Extract scalars
folder_name = folder_name [ 0 ]
shard_size = shard_size [ 0 ]
# latents: list[{"samples": tensor}]
# conditioning: list[list[cond]]
# Validate lengths match
if len ( latents ) != len ( conditioning ) :
raise ValueError (
f " Number of latents ( { len ( latents ) } ) does not match number of conditions ( { len ( conditioning ) } ). "
f " Something went wrong in dataset preparation. "
)
2026-07-29 07:03:04 +08:00
# Create output directory (inside the datasets root, traversal-safe)
output_dir = get_dataset_save_dir ( folder_name )
2025-11-27 08:18:08 +08:00
os . makedirs ( output_dir , exist_ok = True )
# Prepare data pairs
num_samples = len ( latents )
num_shards = ( num_samples + shard_size - 1 ) / / shard_size # Ceiling division
logging . info (
f " Saving { num_samples } samples to { num_shards } shards in { output_dir } ... "
)
# Save data in shards
for shard_idx in range ( num_shards ) :
start_idx = shard_idx * shard_size
end_idx = min ( start_idx + shard_size , num_samples )
# Get shard data (list of latent dicts and conditioning lists)
shard_data = {
" latents " : latents [ start_idx : end_idx ] ,
" conditioning " : conditioning [ start_idx : end_idx ] ,
}
# Save shard
shard_filename = f " shard_ { shard_idx : 04d } .pkl "
shard_path = os . path . join ( output_dir , shard_filename )
with open ( shard_path , " wb " ) as f :
torch . save ( shard_data , f )
logging . info (
f " Saved shard { shard_idx + 1 } / { num_shards } : { shard_filename } ( { end_idx - start_idx } samples) "
)
# Save metadata
metadata = {
" num_samples " : num_samples ,
" num_shards " : num_shards ,
" shard_size " : shard_size ,
}
metadata_path = os . path . join ( output_dir , " metadata.json " )
with open ( metadata_path , " w " ) as f :
json . dump ( metadata , f , indent = 2 )
logging . info ( f " Successfully saved { num_samples } samples to { output_dir } . " )
return io . NodeOutput ( )
class LoadTrainingDataset ( io . ComfyNode ) :
""" Load encoded training dataset from disk. """
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LoadTrainingDataset " ,
add search aliases to all nodes (#12035)
* feat: Add search_aliases field to node schema
Adds `search_aliases` field to improve node discoverability. Users can define alternative search terms for nodes (e.g., "text concat" → StringConcatenate).
Changes:
- Add `search_aliases: list[str]` to V3 Schema
- Add `SEARCH_ALIASES` support for V1 nodes
- Include field in `/object_info` response
- Add aliases to high-priority core nodes
V1 usage:
```python
class MyNode:
SEARCH_ALIASES = ["alt name", "synonym"]
```
V3 usage:
```python
io.Schema(
node_id="MyNode",
search_aliases=["alt name", "synonym"],
...
)
```
## Related PRs
- Frontend: Comfy-Org/ComfyUI_frontend#XXXX (draft - merge after this)
- Docs: Comfy-Org/docs#XXXX (draft - merge after stable)
* Propagate search_aliases through V3 Schema.get_v1_info to NodeInfoV1
* feat: add SEARCH_ALIASES for core nodes (#12016)
Add search aliases to 22 core nodes in nodes.py to improve node discoverability:
- Checkpoint/model loaders: CheckpointLoader, DiffusersLoader
- Conditioning nodes: ConditioningAverage, ConditioningSetArea, ConditioningSetMask, ConditioningZeroOut
- Style nodes: StyleModelApply
- Image nodes: LoadImageMask, LoadImageOutput, ImageBatch, ImageInvert, ImagePadForOutpaint
- Latent nodes: LoadLatent, SaveLatent, LatentBlend, LatentComposite, LatentCrop, LatentFlip, LatentFromBatch, LatentUpscale, LatentUpscaleBy, RepeatLatentBatch
* feat: add SEARCH_ALIASES for image, mask, and string nodes (#12017)
Add search aliases to nodes in comfy_extras for better discoverability:
- nodes_mask.py: mask manipulation nodes
- nodes_images.py: image processing nodes
- nodes_post_processing.py: post-processing effect nodes
- nodes_string.py: string manipulation nodes
- nodes_compositing.py: compositing nodes
- nodes_morphology.py: morphological operation nodes
- nodes_latent.py: latent space nodes
Uses search_aliases parameter in io.Schema() for v3 nodes.
* feat: add SEARCH_ALIASES for audio and video nodes (#12018)
Add search aliases to audio and video nodes for better discoverability:
- nodes_audio.py: audio loading, saving, and processing nodes
- nodes_video.py: video loading and processing nodes
- nodes_wan.py: WAN model nodes
Uses search_aliases parameter in io.Schema() for v3 nodes.
* feat: add SEARCH_ALIASES for model and misc nodes (#12019)
Add search aliases to model-related and miscellaneous nodes:
- Model nodes: nodes_model_merging.py, nodes_model_advanced.py, nodes_lora_extract.py
- Sampler nodes: nodes_custom_sampler.py, nodes_align_your_steps.py
- Control nodes: nodes_controlnet.py, nodes_attention_multiply.py, nodes_hooks.py
- Training nodes: nodes_train.py, nodes_dataset.py
- Utility nodes: nodes_logic.py, nodes_canny.py, nodes_differential_diffusion.py
- Architecture-specific: nodes_sd3.py, nodes_pixart.py, nodes_lumina2.py, nodes_kandinsky5.py, nodes_hidream.py, nodes_fresca.py, nodes_hunyuan3d.py
- Media nodes: nodes_load_3d.py, nodes_webcam.py, nodes_preview_any.py, nodes_wanmove.py
Uses search_aliases parameter in io.Schema() for v3 nodes, SEARCH_ALIASES class attribute for legacy nodes.
2026-01-22 18:36:58 -08:00
search_aliases = [ " import dataset " , " training data " ] ,
2025-11-27 08:18:08 +08:00
display_name = " Load Training Dataset " ,
2026-05-27 17:43:33 -07:00
category = " model/training " ,
2026-05-21 15:14:16 +08:00
description = " Load encoded training dataset (latents + conditioning) from disk for use in training. " ,
2025-11-27 08:18:08 +08:00
is_experimental = True ,
inputs = [
2026-07-29 07:03:04 +08:00
io . Combo . Input (
2025-11-27 08:18:08 +08:00
" folder_name " ,
2026-07-29 07:03:04 +08:00
options = list_dataset_folders ( ) ,
tooltip = " Saved dataset to load, from the datasets directory. " ,
2025-11-27 08:18:08 +08:00
) ,
] ,
outputs = [
io . Latent . Output (
display_name = " latents " ,
is_output_list = True ,
tooltip = " List of latent dicts " ,
) ,
io . Conditioning . Output (
display_name = " conditioning " ,
is_output_list = True ,
tooltip = " List of conditioning lists " ,
) ,
] ,
)
@classmethod
def execute ( cls , folder_name ) :
2026-07-29 07:03:04 +08:00
# Get dataset directory (searched across all dataset roots, traversal-safe)
dataset_dir = get_dataset_dir ( folder_name )
2025-11-27 08:18:08 +08:00
# Find all shard files
shard_files = sorted (
[
f
for f in os . listdir ( dataset_dir )
if f . startswith ( " shard_ " ) and f . endswith ( " .pkl " )
]
)
if not shard_files :
raise ValueError ( f " No shard files found in { dataset_dir } " )
logging . info ( f " Loading { len ( shard_files ) } shards from { dataset_dir } ... " )
# Load all shards
all_latents = [ ] # list[{"samples": tensor}]
all_conditioning = [ ] # list[list[cond]]
for shard_file in shard_files :
shard_path = os . path . join ( dataset_dir , shard_file )
with open ( shard_path , " rb " ) as f :
2026-06-18 12:30:57 -07:00
shard_data = torch . load ( f , weights_only = True )
2025-11-27 08:18:08 +08:00
all_latents . extend ( shard_data [ " latents " ] )
all_conditioning . extend ( shard_data [ " conditioning " ] )
logging . info ( f " Loaded { shard_file } : { len ( shard_data [ ' latents ' ] ) } samples " )
logging . info (
f " Successfully loaded { len ( all_latents ) } samples from { dataset_dir } . "
)
return io . NodeOutput ( all_latents , all_conditioning )
# ========== Extension Setup ==========
class DatasetExtension ( ComfyExtension ) :
@override
async def get_node_list ( self ) - > list [ type [ io . ComfyNode ] ] :
return [
# Data loading/saving nodes
LoadImageDataSetFromFolderNode ,
LoadImageTextDataSetFromFolderNode ,
SaveImageDataSetToFolderNode ,
SaveImageTextDataSetToFolderNode ,
2026-07-21 12:02:54 +08:00
# Video data loading nodes
LoadVideoDataSetFromFolderNode ,
LoadVideoTextDataSetFromFolderNode ,
# Image transform nodes (auto-handle video via per-frame processing)
2025-11-27 08:18:08 +08:00
ResizeImagesByShorterEdgeNode ,
ResizeImagesByLongerEdgeNode ,
CenterCropImagesNode ,
RandomCropImagesNode ,
NormalizeImagesNode ,
AdjustBrightnessNode ,
AdjustContrastNode ,
ShuffleDatasetNode ,
ShuffleImageTextDatasetNode ,
2026-07-21 12:02:54 +08:00
# Video processing nodes (lazy VideoInput in/out)
VideoFrameSampleNode ,
VideoTemporalCropNode ,
VideoRandomTemporalCropNode ,
ShuffleVideoDatasetNode ,
ShuffleVideoTextDatasetNode ,
2025-11-27 08:18:08 +08:00
# Text transform nodes
TextToLowercaseNode ,
TextToUppercaseNode ,
TruncateTextNode ,
AddTextPrefixNode ,
AddTextSuffixNode ,
ReplaceTextNode ,
StripWhitespaceNode ,
# Group processing examples
ImageDeduplicationNode ,
ImageGridNode ,
MergeImageListsNode ,
MergeTextListsNode ,
# Training dataset nodes
MakeTrainingDataset ,
SaveTrainingDataset ,
LoadTrainingDataset ,
2025-12-18 11:15:27 +08:00
ResolutionBucket ,
2025-11-27 08:18:08 +08:00
]
async def comfy_entrypoint ( ) - > DatasetExtension :
return DatasetExtension ( )