Compare commits

..

46 Commits

Author SHA1 Message Date
810265e011 Ruff fix 2026-01-29 18:09:37 -08:00
0b1b234d90 Add unit tests for _prune_orphaned_assets
Tests cover:

- Orphaned seed assets pruned when file removed

- Seed assets with valid files survive

- Hashed assets not pruned even without file

- Multi-root pruning

- SQL LIKE escape handling for %, _, spaces

Amp-Thread-ID: https://ampcode.com/threads/T-019c0c7a-5c8a-7548-b6c3-823e9829ce74
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:05:40 -08:00
eb2b38458c Add unit tests for pruning 2026-01-29 18:02:20 -08:00
03ddcaa3fa Refactor _prune_orphaned_assets for readability
Amp-Thread-ID: https://ampcode.com/threads/T-019c0917-0dc3-75ab-870d-a32b3fdc1927
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:45:27 -08:00
e7bebcc8d0 Simplify _prune_orphaned_assets: merge functions, use list comprehensions
Amp-Thread-ID: https://ampcode.com/threads/T-019c0917-0dc3-75ab-870d-a32b3fdc1927
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 17:45:27 -08:00
b2f6532b30 Not sure about this one, but try removing assets from old sessions. 2026-01-29 17:45:27 -08:00
612893018c Use windows-latest runner for test-assets 2026-01-29 17:37:16 -08:00
c0e26b93cc Added test-assets.yml to github workflows, added a requirements.txt to test-assets (blake3 can eventually be removed from there when it becomes a core dependency) 2026-01-29 17:33:21 -08:00
11da0e6c46 Satisfy ruff 2026-01-29 17:00:52 -08:00
1e622d3923 Fixed issues in manager.py that had to do with creating a result after closing the db session 2026-01-29 16:58:48 -08:00
eb78ea0cff Added @ROUTES.post("/api/assets/seed") for now to help with tests 2026-01-29 16:57:37 -08:00
6840ad0bbe Added tests, rewritten from the ones present in the asset-management branch 2026-01-29 16:56:39 -08:00
2f0db0e680 Order the tags by when they were added (Ends up being directory depth order) 2026-01-28 22:17:52 -08:00
69f6c37868 Leave the preview_url blank, don't serialize it as null 2026-01-28 21:49:14 -08:00
f484d66eb0 Merge branch 'master' into assets-redo-part2 2026-01-28 19:15:32 -08:00
25f83d7401 Fixed resolve_asset_content_for_download accessing asset outside of session with statement 2026-01-28 18:57:54 -08:00
2aafb71388 Add node for custom node authors in routes.py 2026-01-28 17:01:29 -08:00
902e84d7ad Remove tags from body of @ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}"), add note about blake3 requirement to test out 2026-01-28 16:04:19 -08:00
d5e6e2a81f Fixed inconsistent spacing in routes.py 2026-01-28 15:39:08 -08:00
e735a8fd85 Satisfy ruff 2026-01-28 15:34:19 -08:00
32ce7a70a7 Removed 501 early returns on endpoints intended to be released, removed @ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}/preview") and @ROUTES.post("/api/assets/scan/seed") and their related schema_in objects 2026-01-28 15:31:06 -08:00
cf950e47ab Merge branch 'master' into assets-redo-part2 2026-01-28 15:05:24 -08:00
724145fb55 Merge branch 'master' into assets-redo-part2 2026-01-27 16:40:19 -08:00
32d4888d99 Fix import for currently unused upload_asset_from_temp_path function 2026-01-27 16:28:05 -08:00
b16390c2fd Made some routes returmn 501's while functionality is worked on 2026-01-26 21:02:05 -08:00
4866bbfd8c Comment out import for commented out code 2026-01-26 20:30:20 -08:00
e17542b5c7 Comment out @ROUTES.post("/api/assets/scan/seed") 2026-01-26 20:25:57 -08:00
0bb6d3a3e9 Merge branch 'master' into assets-redo-part2 2026-01-26 20:17:32 -08:00
6a450a8070 Revert seed_assets to only do models root, remove blake3 requirement for now, make posting assets endpoint inaccessible with a 501 2026-01-26 19:28:00 -08:00
702cfcde3a Merge branch 'master' into assets-redo-part2 2026-01-26 14:38:18 -08:00
8e9c801940 Add input + output roots to scans 2026-01-24 16:26:42 -08:00
facda426b4 Remove extra whitespace at end of routes.py 2026-01-16 01:04:26 -08:00
65a5992f2d Remove unnecessary logging statement used for testing 2026-01-16 01:02:40 -08:00
287da646e5 Finished @ROUTES.post("/api/assets/scan/seed") 2026-01-16 01:01:49 -08:00
63f9f1b11b Finish @ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}/tags") 2026-01-16 00:50:13 -08:00
9e3f559189 Finished @ROUTES.post(f"/api/assets/{{id:{UUID_RE}}}/tags") 2026-01-16 00:45:36 -08:00
63c98d0c75 Finished @ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}") 2026-01-16 00:31:06 -08:00
e69a5aa1be Finished @ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}/preview") 2026-01-16 00:14:03 -08:00
e0c063f93e Finished @ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}") 2026-01-15 23:57:23 -08:00
6db4f4e3f1 Finished @ROUTES.post("/api/assets") 2026-01-15 23:41:19 -08:00
41d364030b Finished @ROUTES.post("/api/assets/from-hash") 2026-01-15 23:09:54 -08:00
fab9b71f5d Finished @ROUTES.head("/api/assets/hash/{hash}") 2026-01-15 21:13:34 -08:00
e5c1de4777 Finished @ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}/content") 2026-01-15 21:00:35 -08:00
a5ed151e51 Merge branch 'master' into assets-redo-part2 2026-01-15 20:34:44 -08:00
e527b72b09 more progress 2026-01-15 18:16:00 -08:00
f14129947c in progress GET /api/assets/{uuid}/content endpoint support 2026-01-14 22:54:21 -08:00
36 changed files with 165 additions and 959 deletions

30
.github/workflows/test-assets.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: Assets Tests
on:
push:
branches: [ main, master, release/** ]
pull_request:
branches: [ main, master, release/** ]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install requirements
run: |
python -m pip install --upgrade pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements.txt
- name: Run Assets Tests
run: |
pip install -r tests-assets/requirements.txt
python -m pytest tests-assets -v

View File

@ -29,7 +29,7 @@ on:
description: 'python patch version'
required: true
type: string
default: "11"
default: "9"
# push:
# branches:
# - master

View File

@ -13,7 +13,6 @@ from torchvision import transforms
import comfy.patcher_extension
from comfy.ldm.modules.attention import optimized_attention
import comfy.ldm.common_dit
def apply_rotary_pos_emb(
t: torch.Tensor,
@ -836,8 +835,6 @@ class MiniTrainDIT(nn.Module):
padding_mask: Optional[torch.Tensor] = None,
**kwargs,
):
orig_shape = list(x.shape)
x = comfy.ldm.common_dit.pad_to_patch_size(x, (self.patch_temporal, self.patch_spatial, self.patch_spatial))
x_B_C_T_H_W = x
timesteps_B_T = timesteps
crossattn_emb = context
@ -885,5 +882,5 @@ class MiniTrainDIT(nn.Module):
)
x_B_T_H_W_O = self.final_layer(x_B_T_H_W_D, t_embedding_B_T_D, adaln_lora_B_T_3D=adaln_lora_B_T_3D)
x_B_C_Tt_Hp_Wp = self.unpatchify(x_B_T_H_W_O)[:, :, :orig_shape[-3], :orig_shape[-2], :orig_shape[-1]]
x_B_C_Tt_Hp_Wp = self.unpatchify(x_B_T_H_W_O)
return x_B_C_Tt_Hp_Wp

View File

@ -1,7 +1,7 @@
import torch
import torch.nn as nn
from dataclasses import dataclass
from typing import Optional, Any, Tuple
from typing import Optional, Any
import math
from comfy.ldm.modules.attention import optimized_attention_for_device
@ -32,7 +32,6 @@ class Llama2Config:
k_norm = None
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Mistral3Small24BConfig:
@ -55,7 +54,6 @@ class Mistral3Small24BConfig:
k_norm = None
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Qwen25_3BConfig:
@ -78,7 +76,6 @@ class Qwen25_3BConfig:
k_norm = None
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Qwen3_06BConfig:
@ -101,7 +98,6 @@ class Qwen3_06BConfig:
k_norm = "gemma3"
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Qwen3_4BConfig:
@ -124,7 +120,6 @@ class Qwen3_4BConfig:
k_norm = "gemma3"
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Qwen3_8BConfig:
@ -147,7 +142,6 @@ class Qwen3_8BConfig:
k_norm = "gemma3"
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Ovis25_2BConfig:
@ -170,7 +164,6 @@ class Ovis25_2BConfig:
k_norm = "gemma3"
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Qwen25_7BVLI_Config:
@ -193,7 +186,6 @@ class Qwen25_7BVLI_Config:
k_norm = None
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Gemma2_2B_Config:
@ -217,7 +209,6 @@ class Gemma2_2B_Config:
sliding_attention = None
rope_scale = None
final_norm: bool = True
lm_head: bool = False
@dataclass
class Gemma3_4B_Config:
@ -241,7 +232,6 @@ class Gemma3_4B_Config:
sliding_attention = [1024, 1024, 1024, 1024, 1024, False]
rope_scale = [8.0, 1.0]
final_norm: bool = True
lm_head: bool = False
@dataclass
class Gemma3_12B_Config:
@ -265,7 +255,6 @@ class Gemma3_12B_Config:
sliding_attention = [1024, 1024, 1024, 1024, 1024, False]
rope_scale = [8.0, 1.0]
final_norm: bool = True
lm_head: bool = False
vision_config = {"num_channels": 3, "hidden_act": "gelu_pytorch_tanh", "hidden_size": 1152, "image_size": 896, "intermediate_size": 4304, "model_type": "siglip_vision_model", "num_attention_heads": 16, "num_hidden_layers": 27, "patch_size": 14}
mm_tokens_per_image = 256
@ -367,7 +356,6 @@ class Attention(nn.Module):
attention_mask: Optional[torch.Tensor] = None,
freqs_cis: Optional[torch.Tensor] = None,
optimized_attention=None,
past_key_value: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
):
batch_size, seq_length, _ = hidden_states.shape
xq = self.q_proj(hidden_states)
@ -385,30 +373,11 @@ class Attention(nn.Module):
xq, xk = apply_rope(xq, xk, freqs_cis=freqs_cis)
present_key_value = None
if past_key_value is not None:
index = 0
num_tokens = xk.shape[2]
if len(past_key_value) > 0:
past_key, past_value, index = past_key_value
if past_key.shape[2] >= (index + num_tokens):
past_key[:, :, index:index + xk.shape[2]] = xk
past_value[:, :, index:index + xv.shape[2]] = xv
xk = past_key[:, :, :index + xk.shape[2]]
xv = past_value[:, :, :index + xv.shape[2]]
present_key_value = (past_key, past_value, index + num_tokens)
else:
xk = torch.cat((past_key[:, :, :index], xk), dim=2)
xv = torch.cat((past_value[:, :, :index], xv), dim=2)
present_key_value = (xk, xv, index + num_tokens)
else:
present_key_value = (xk, xv, index + num_tokens)
xk = xk.repeat_interleave(self.num_heads // self.num_kv_heads, dim=1)
xv = xv.repeat_interleave(self.num_heads // self.num_kv_heads, dim=1)
output = optimized_attention(xq, xk, xv, self.num_heads, mask=attention_mask, skip_reshape=True)
return self.o_proj(output), present_key_value
return self.o_proj(output)
class MLP(nn.Module):
def __init__(self, config: Llama2Config, device=None, dtype=None, ops: Any = None):
@ -439,17 +408,15 @@ class TransformerBlock(nn.Module):
attention_mask: Optional[torch.Tensor] = None,
freqs_cis: Optional[torch.Tensor] = None,
optimized_attention=None,
past_key_value: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
):
# Self Attention
residual = x
x = self.input_layernorm(x)
x, present_key_value = self.self_attn(
x = self.self_attn(
hidden_states=x,
attention_mask=attention_mask,
freqs_cis=freqs_cis,
optimized_attention=optimized_attention,
past_key_value=past_key_value,
)
x = residual + x
@ -459,7 +426,7 @@ class TransformerBlock(nn.Module):
x = self.mlp(x)
x = residual + x
return x, present_key_value
return x
class TransformerBlockGemma2(nn.Module):
def __init__(self, config: Llama2Config, index, device=None, dtype=None, ops: Any = None):
@ -484,7 +451,6 @@ class TransformerBlockGemma2(nn.Module):
attention_mask: Optional[torch.Tensor] = None,
freqs_cis: Optional[torch.Tensor] = None,
optimized_attention=None,
past_key_value: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
):
if self.transformer_type == 'gemma3':
if self.sliding_attention:
@ -502,12 +468,11 @@ class TransformerBlockGemma2(nn.Module):
# Self Attention
residual = x
x = self.input_layernorm(x)
x, present_key_value = self.self_attn(
x = self.self_attn(
hidden_states=x,
attention_mask=attention_mask,
freqs_cis=freqs_cis,
optimized_attention=optimized_attention,
past_key_value=past_key_value,
)
x = self.post_attention_layernorm(x)
@ -520,7 +485,7 @@ class TransformerBlockGemma2(nn.Module):
x = self.post_feedforward_layernorm(x)
x = residual + x
return x, present_key_value
return x
class Llama2_(nn.Module):
def __init__(self, config, device=None, dtype=None, ops=None):
@ -551,10 +516,9 @@ class Llama2_(nn.Module):
else:
self.norm = None
if config.lm_head:
self.lm_head = ops.Linear(config.hidden_size, config.vocab_size, bias=False, device=device, dtype=dtype)
# self.lm_head = ops.Linear(config.hidden_size, config.vocab_size, bias=False, device=device, dtype=dtype)
def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermediate_output=None, final_layer_norm_intermediate=True, dtype=None, position_ids=None, embeds_info=[], past_key_values=None):
def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermediate_output=None, final_layer_norm_intermediate=True, dtype=None, position_ids=None, embeds_info=[]):
if embeds is not None:
x = embeds
else:
@ -563,13 +527,8 @@ class Llama2_(nn.Module):
if self.normalize_in:
x *= self.config.hidden_size ** 0.5
seq_len = x.shape[1]
past_len = 0
if past_key_values is not None and len(past_key_values) > 0:
past_len = past_key_values[0][2]
if position_ids is None:
position_ids = torch.arange(past_len, past_len + seq_len, device=x.device).unsqueeze(0)
position_ids = torch.arange(0, x.shape[1], device=x.device).unsqueeze(0)
freqs_cis = precompute_freqs_cis(self.config.head_dim,
position_ids,
@ -580,16 +539,14 @@ class Llama2_(nn.Module):
mask = None
if attention_mask is not None:
mask = 1.0 - attention_mask.to(x.dtype).reshape((attention_mask.shape[0], 1, -1, attention_mask.shape[-1])).expand(attention_mask.shape[0], 1, seq_len, attention_mask.shape[-1])
mask = 1.0 - attention_mask.to(x.dtype).reshape((attention_mask.shape[0], 1, -1, attention_mask.shape[-1])).expand(attention_mask.shape[0], 1, attention_mask.shape[-1], attention_mask.shape[-1])
mask = mask.masked_fill(mask.to(torch.bool), float("-inf"))
if seq_len > 1:
causal_mask = torch.empty(past_len + seq_len, past_len + seq_len, dtype=x.dtype, device=x.device).fill_(float("-inf")).triu_(1)
if mask is not None:
mask += causal_mask
else:
mask = causal_mask
causal_mask = torch.empty(x.shape[1], x.shape[1], dtype=x.dtype, device=x.device).fill_(float("-inf")).triu_(1)
if mask is not None:
mask += causal_mask
else:
mask = causal_mask
optimized_attention = optimized_attention_for_device(x.device, mask=mask is not None, small_input=True)
intermediate = None
@ -605,27 +562,16 @@ class Llama2_(nn.Module):
elif intermediate_output < 0:
intermediate_output = len(self.layers) + intermediate_output
next_key_values = []
for i, layer in enumerate(self.layers):
if all_intermediate is not None:
if only_layers is None or (i in only_layers):
all_intermediate.append(x.unsqueeze(1).clone())
past_kv = None
if past_key_values is not None:
past_kv = past_key_values[i] if len(past_key_values) > 0 else []
x, current_kv = layer(
x = layer(
x=x,
attention_mask=mask,
freqs_cis=freqs_cis,
optimized_attention=optimized_attention,
past_key_value=past_kv,
)
if current_kv is not None:
next_key_values.append(current_kv)
if i == intermediate_output:
intermediate = x.clone()
@ -642,10 +588,7 @@ class Llama2_(nn.Module):
if intermediate is not None and final_layer_norm_intermediate and self.norm is not None:
intermediate = self.norm(intermediate)
if len(next_key_values) > 0:
return x, intermediate, next_key_values
else:
return x, intermediate
return x, intermediate
class Gemma3MultiModalProjector(torch.nn.Module):

View File

@ -1146,20 +1146,6 @@ class ImageCompare(ComfyTypeI):
def as_dict(self):
return super().as_dict()
@comfytype(io_type="COLOR")
class Color(ComfyTypeIO):
Type = str
class Input(WidgetInput):
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
socketless: bool=True, advanced: bool=None, default: str="#ffffff"):
super().__init__(id, display_name, optional, tooltip, None, default, socketless, None, None, None, None, advanced)
self.default: str
def as_dict(self):
return super().as_dict()
DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]] = {}
def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]):
DYNAMIC_INPUT_LOOKUP[io_type] = func
@ -1248,7 +1234,6 @@ class Hidden(str, Enum):
class NodeInfoV1:
input: dict=None
input_order: dict[str, list[str]]=None
is_input_list: bool=None
output: list[str]=None
output_is_list: list[bool]=None
output_name: list[str]=None
@ -1267,6 +1252,23 @@ class NodeInfoV1:
price_badge: dict | None = None
search_aliases: list[str]=None
@dataclass
class NodeInfoV3:
input: dict=None
output: dict=None
hidden: list[str]=None
name: str=None
display_name: str=None
description: str=None
python_module: Any = None
category: str=None
output_node: bool=None
deprecated: bool=None
experimental: bool=None
dev_only: bool=None
api_node: bool=None
price_badge: dict | None = None
@dataclass
class PriceBadgeDepends:
@ -1475,7 +1477,6 @@ class Schema:
info = NodeInfoV1(
input=input,
input_order={key: list(value.keys()) for (key, value) in input.items()},
is_input_list=self.is_input_list,
output=output,
output_is_list=output_is_list,
output_name=output_name,
@ -1496,6 +1497,40 @@ class Schema:
)
return info
def get_v3_info(self, cls) -> NodeInfoV3:
input_dict = {}
output_dict = {}
hidden_list = []
# TODO: make sure dynamic types will be handled correctly
if self.inputs:
for input in self.inputs:
add_to_dict_v3(input, input_dict)
if self.outputs:
for output in self.outputs:
add_to_dict_v3(output, output_dict)
if self.hidden:
for hidden in self.hidden:
hidden_list.append(hidden.value)
info = NodeInfoV3(
input=input_dict,
output=output_dict,
hidden=hidden_list,
name=self.node_id,
display_name=self.display_name,
description=self.description,
category=self.category,
output_node=self.is_output_node,
deprecated=self.is_deprecated,
experimental=self.is_experimental,
dev_only=self.is_dev_only,
api_node=self.is_api_node,
python_module=getattr(cls, "RELATIVE_PYTHON_MODULE", "nodes"),
price_badge=self.price_badge.as_dict(self.inputs) if self.price_badge is not None else None,
)
return info
def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], include_hidden=False) -> tuple[dict[str, Any], V3Data]:
out_dict = {
"required": {},
@ -1550,6 +1585,9 @@ def add_to_dict_v1(i: Input, d: dict):
as_dict.pop("optional", None)
d.setdefault(key, {})[i.id] = (i.get_io_type(), as_dict)
def add_to_dict_v3(io: Input | Output, d: dict):
d[io.id] = (io.get_io_type(), io.as_dict())
class DynamicPathsDefaultValue:
EMPTY_DICT = "empty_dict"
@ -1710,6 +1748,13 @@ class _ComfyNodeBaseInternal(_ComfyNodeInternal):
# set hidden
type_clone.hidden = HiddenHolder.from_v3_data(v3_data)
return type_clone
@final
@classmethod
def GET_NODE_INFO_V3(cls) -> dict[str, Any]:
schema = cls.GET_SCHEMA()
info = schema.get_v3_info(cls)
return asdict(info)
#############################################
# V1 Backwards Compatibility code
#--------------------------------------------
@ -2054,7 +2099,6 @@ __all__ = [
"AnyType",
"MultiType",
"Tracks",
"Color",
# Dynamic Types
"MatchType",
"DynamicCombo",
@ -2063,10 +2107,12 @@ __all__ = [
"HiddenHolder",
"Hidden",
"NodeInfoV1",
"NodeInfoV3",
"Schema",
"ComfyNode",
"NodeOutput",
"add_to_dict_v1",
"add_to_dict_v3",
"V3Data",
"ImageCompare",
"PriceBadgeDepends",

View File

@ -146,7 +146,8 @@ class ImageSaveHelper:
metadata = ImageSaveHelper._create_png_metadata(cls)
for batch_number, image_tensor in enumerate(images):
img = ImageSaveHelper._convert_tensor_to_pil(image_tensor)
file = folder_paths.format_output_filename(filename, counter, "png", batch_num=str(batch_number))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.png"
img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=compress_level)
results.append(SavedResult(file, subfolder, folder_type))
counter += 1
@ -175,7 +176,7 @@ class ImageSaveHelper:
)
pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images]
metadata = ImageSaveHelper._create_animated_png_metadata(cls)
file = folder_paths.format_output_filename(filename, counter, "png")
file = f"{filename}_{counter:05}_.png"
save_path = os.path.join(full_output_folder, file)
pil_images[0].save(
save_path,
@ -219,7 +220,7 @@ class ImageSaveHelper:
)
pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images]
pil_exif = ImageSaveHelper._create_webp_metadata(pil_images[0], cls)
file = folder_paths.format_output_filename(filename, counter, "webp")
file = f"{filename}_{counter:05}_.webp"
pil_images[0].save(
os.path.join(full_output_folder, file),
save_all=True,
@ -283,7 +284,8 @@ class AudioSaveHelper:
results = []
for batch_number, waveform in enumerate(audio["waveform"].cpu()):
file = folder_paths.format_output_filename(filename, counter, format, batch_num=str(batch_number))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{format}"
output_path = os.path.join(full_output_folder, file)
# Use original sample rate initially

View File

@ -1,8 +1,11 @@
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, conint, confloat
class RecraftColor:
@ -226,24 +229,24 @@ class RecraftColorObject(BaseModel):
class RecraftControlsObject(BaseModel):
colors: list[RecraftColorObject] | None = Field(None, description='An array of preferable colors')
background_color: RecraftColorObject | None = Field(None, description='Use given color as a desired background color')
no_text: bool | None = Field(None, description='Do not embed text layouts')
artistic_level: int | None = Field(None, description='Defines artistic tone of your image. At a simple level, the person looks straight at the camera in a static and clean style. Dynamic and eccentric levels introduce movement and creativity. The value should be in range [0..5].')
colors: Optional[list[RecraftColorObject]] = Field(None, description='An array of preferable colors')
background_color: Optional[RecraftColorObject] = Field(None, description='Use given color as a desired background color')
no_text: Optional[bool] = Field(None, description='Do not embed text layouts')
artistic_level: Optional[conint(ge=0, le=5)] = Field(None, description='Defines artistic tone of your image. At a simple level, the person looks straight at the camera in a static and clean style. Dynamic and eccentric levels introduce movement and creativity. The value should be in range [0..5].')
class RecraftImageGenerationRequest(BaseModel):
prompt: str = Field(..., description='The text prompt describing the image to generate')
size: RecraftImageSize | None = Field(None, description='The size of the generated image (e.g., "1024x1024")')
n: int = Field(..., description='The number of images to generate')
negative_prompt: str | None = Field(None, description='A text description of undesired elements on an image')
model: RecraftModel | None = Field(RecraftModel.recraftv3, description='The model to use for generation (e.g., "recraftv3")')
style: str | None = Field(None, description='The style to apply to the generated image (e.g., "digital_illustration")')
substyle: str | None = Field(None, description='The substyle to apply to the generated image, depending on the style input')
controls: RecraftControlsObject | None = Field(None, description='A set of custom parameters to tweak generation process')
style_id: str | None = Field(None, description='Use a previously uploaded style as a reference; UUID')
strength: float | None = Field(None, description='Defines the difference with the original image, should lie in [0, 1], where 0 means almost identical, and 1 means miserable similarity')
random_seed: int | None = Field(None, description="Seed for video generation")
size: Optional[RecraftImageSize] = Field(None, description='The size of the generated image (e.g., "1024x1024")')
n: conint(ge=1, le=6) = Field(..., description='The number of images to generate')
negative_prompt: Optional[str] = Field(None, description='A text description of undesired elements on an image')
model: Optional[RecraftModel] = Field(RecraftModel.recraftv3, description='The model to use for generation (e.g., "recraftv3")')
style: Optional[str] = Field(None, description='The style to apply to the generated image (e.g., "digital_illustration")')
substyle: Optional[str] = Field(None, description='The substyle to apply to the generated image, depending on the style input')
controls: Optional[RecraftControlsObject] = Field(None, description='A set of custom parameters to tweak generation process')
style_id: Optional[str] = Field(None, description='Use a previously uploaded style as a reference; UUID')
strength: Optional[confloat(ge=0.0, le=1.0)] = Field(None, description='Defines the difference with the original image, should lie in [0, 1], where 0 means almost identical, and 1 means miserable similarity')
random_seed: Optional[int] = Field(None, description="Seed for video generation")
# text_layout
@ -255,13 +258,5 @@ class RecraftReturnedObject(BaseModel):
class RecraftImageGenerationResponse(BaseModel):
created: int = Field(..., description='Unix timestamp when the generation was created')
credits: int = Field(..., description='Number of credits used for the generation')
data: list[RecraftReturnedObject] | None = Field(None, description='Array of generated image information')
image: RecraftReturnedObject | None = Field(None, description='Single generated image')
class RecraftCreateStyleRequest(BaseModel):
style: str = Field(..., description="realistic_image, digital_illustration, vector_illustration, or icon")
class RecraftCreateStyleResponse(BaseModel):
id: str = Field(..., description="UUID of the created style")
data: Optional[list[RecraftReturnedObject]] = Field(None, description='Array of generated image information')
image: Optional[RecraftReturnedObject] = Field(None, description='Single generated image')

View File

@ -6,30 +6,6 @@ class SubjectReference(BaseModel):
images: list[str] = Field(...)
class FrameSetting(BaseModel):
prompt: str = Field(...)
key_image: str = Field(...)
duration: int = Field(...)
class TaskMultiFrameCreationRequest(BaseModel):
model: str = Field(...)
seed: int = Field(..., ge=0, le=2147483647)
resolution: str = Field(...)
start_image: str = Field(...)
image_settings: list[FrameSetting] = Field(...)
class TaskExtendCreationRequest(BaseModel):
model: str = Field(...)
prompt: str = Field(..., max_length=2000)
duration: int = Field(...)
seed: int = Field(..., ge=0, le=2147483647)
resolution: str = Field(...)
images: list[str] | None = Field(None, description="Base64 encoded string or image URL")
video_url: str = Field(..., description="URL of the video to extend")
class TaskCreationRequest(BaseModel):
model: str = Field(...)
prompt: str = Field(..., max_length=2000)

View File

@ -12,8 +12,6 @@ from comfy_api_nodes.apis.recraft import (
RecraftColor,
RecraftColorChain,
RecraftControls,
RecraftCreateStyleRequest,
RecraftCreateStyleResponse,
RecraftImageGenerationRequest,
RecraftImageGenerationResponse,
RecraftImageSize,
@ -325,75 +323,6 @@ class RecraftStyleInfiniteStyleLibrary(IO.ComfyNode):
return IO.NodeOutput(RecraftStyle(style_id=style_id))
class RecraftCreateStyleNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="RecraftCreateStyleNode",
display_name="Recraft Create Style",
category="api node/image/Recraft",
description="Create a custom style from reference images. "
"Upload 1-5 images to use as style references. "
"Total size of all images is limited to 5 MB.",
inputs=[
IO.Combo.Input(
"style",
options=["realistic_image", "digital_illustration"],
tooltip="The base style of the generated images.",
),
IO.Autogrow.Input(
"images",
template=IO.Autogrow.TemplatePrefix(
IO.Image.Input("image"),
prefix="image",
min=1,
max=5,
),
),
],
outputs=[
IO.String.Output(display_name="style_id"),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=IO.PriceBadge(
expr="""{"type":"usd","usd": 0.04}""",
),
)
@classmethod
async def execute(
cls,
style: str,
images: IO.Autogrow.Type,
) -> IO.NodeOutput:
files = []
total_size = 0
max_total_size = 5 * 1024 * 1024 # 5 MB limit
for i, img in enumerate(list(images.values())):
file_bytes = tensor_to_bytesio(img, total_pixels=2048 * 2048, mime_type="image/webp").read()
total_size += len(file_bytes)
if total_size > max_total_size:
raise Exception("Total size of all images exceeds 5 MB limit.")
files.append((f"file{i + 1}", file_bytes))
response = await sync_op(
cls,
endpoint=ApiEndpoint(path="/proxy/recraft/styles", method="POST"),
response_model=RecraftCreateStyleResponse,
files=files,
data=RecraftCreateStyleRequest(style=style),
content_type="multipart/form-data",
max_retries=1,
)
return IO.NodeOutput(response.id)
class RecraftTextToImageNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
@ -466,7 +395,7 @@ class RecraftTextToImageNode(IO.ComfyNode):
negative_prompt: str = None,
recraft_controls: RecraftControls = None,
) -> IO.NodeOutput:
validate_string(prompt, strip_whitespace=False, min_length=1, max_length=1000)
validate_string(prompt, strip_whitespace=False, max_length=1000)
default_style = RecraftStyle(RecraftStyleV3.realistic_image)
if recraft_style is None:
recraft_style = default_style
@ -1095,7 +1024,6 @@ class RecraftExtension(ComfyExtension):
RecraftStyleV3DigitalIllustrationNode,
RecraftStyleV3LogoRasterNode,
RecraftStyleInfiniteStyleLibrary,
RecraftCreateStyleNode,
RecraftColorRGBNode,
RecraftControlsNode,
]

View File

@ -2,12 +2,9 @@ from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input
from comfy_api_nodes.apis.vidu import (
FrameSetting,
SubjectReference,
TaskCreationRequest,
TaskCreationResponse,
TaskExtendCreationRequest,
TaskMultiFrameCreationRequest,
TaskResult,
TaskStatusResponse,
)
@ -17,14 +14,11 @@ from comfy_api_nodes.util import (
get_number_of_images,
poll_op,
sync_op,
upload_image_to_comfyapi,
upload_images_to_comfyapi,
upload_video_to_comfyapi,
validate_image_aspect_ratio,
validate_image_dimensions,
validate_images_aspect_ratio_closeness,
validate_string,
validate_video_duration,
)
VIDU_TEXT_TO_VIDEO = "/proxy/vidu/text2video"
@ -37,8 +31,7 @@ VIDU_GET_GENERATION_STATUS = "/proxy/vidu/tasks/%s/creations"
async def execute_task(
cls: type[IO.ComfyNode],
vidu_endpoint: str,
payload: TaskCreationRequest | TaskExtendCreationRequest | TaskMultiFrameCreationRequest,
max_poll_attempts: int = 320,
payload: TaskCreationRequest,
) -> list[TaskResult]:
task_creation_response = await sync_op(
cls,
@ -54,7 +47,7 @@ async def execute_task(
response_model=TaskStatusResponse,
status_extractor=lambda r: r.state,
progress_extractor=lambda r: r.progress,
max_poll_attempts=max_poll_attempts,
max_poll_attempts=320,
)
if not response.creations:
raise RuntimeError(
@ -947,540 +940,6 @@ class Vidu2StartEndToVideoNode(IO.ComfyNode):
return IO.NodeOutput(await download_url_to_video_output(results[0].url))
class ViduExtendVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="ViduExtendVideoNode",
display_name="Vidu Video Extension",
category="api node/video/Vidu",
description="Extend an existing video by generating additional frames.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option(
"viduq2-pro",
[
IO.Int.Input(
"duration",
default=4,
min=1,
max=7,
step=1,
display_mode=IO.NumberDisplay.slider,
tooltip="Duration of the extended video in seconds.",
),
IO.Combo.Input(
"resolution",
options=["720p", "1080p"],
tooltip="Resolution of the output video.",
),
],
),
IO.DynamicCombo.Option(
"viduq2-turbo",
[
IO.Int.Input(
"duration",
default=4,
min=1,
max=7,
step=1,
display_mode=IO.NumberDisplay.slider,
tooltip="Duration of the extended video in seconds.",
),
IO.Combo.Input(
"resolution",
options=["720p", "1080p"],
tooltip="Resolution of the output video.",
),
],
),
],
tooltip="Model to use for video extension.",
),
IO.Video.Input(
"video",
tooltip="The source video to extend.",
),
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="An optional text prompt for the extended video (max 2000 characters).",
),
IO.Int.Input(
"seed",
default=1,
min=0,
max=2147483647,
step=1,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
),
IO.Image.Input("end_frame", optional=True),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.duration", "model.resolution"]),
expr="""
(
$m := widgets.model;
$d := $lookup(widgets, "model.duration");
$res := $lookup(widgets, "model.resolution");
$contains($m, "pro")
? (
$base := $lookup({"720p": 0.15, "1080p": 0.3}, $res);
$perSec := $lookup({"720p": 0.05, "1080p": 0.075}, $res);
{"type":"usd","usd": $base + $perSec * ($d - 1)}
)
: (
$base := $lookup({"720p": 0.075, "1080p": 0.2}, $res);
$perSec := $lookup({"720p": 0.025, "1080p": 0.05}, $res);
{"type":"usd","usd": $base + $perSec * ($d - 1)}
)
)
""",
),
)
@classmethod
async def execute(
cls,
model: dict,
video: Input.Video,
prompt: str,
seed: int,
end_frame: Input.Image | None = None,
) -> IO.NodeOutput:
validate_string(prompt, max_length=2000)
validate_video_duration(video, min_duration=4, max_duration=55)
image_url = None
if end_frame is not None:
validate_image_aspect_ratio(end_frame, (1, 4), (4, 1))
validate_image_dimensions(end_frame, min_width=128, min_height=128)
image_url = await upload_image_to_comfyapi(cls, end_frame, wait_label="Uploading end frame")
results = await execute_task(
cls,
"/proxy/vidu/extend",
TaskExtendCreationRequest(
model=model["model"],
prompt=prompt,
duration=model["duration"],
seed=seed,
resolution=model["resolution"],
video_url=await upload_video_to_comfyapi(cls, video, wait_label="Uploading video"),
images=[image_url] if image_url else None,
),
max_poll_attempts=480,
)
return IO.NodeOutput(await download_url_to_video_output(results[0].url))
def _generate_frame_inputs(count: int) -> list:
"""Generate input widgets for a given number of frames."""
inputs = []
for i in range(1, count + 1):
inputs.extend(
[
IO.String.Input(
f"prompt{i}",
multiline=True,
default="",
tooltip=f"Text prompt for frame {i} transition.",
),
IO.Image.Input(
f"end_image{i}",
tooltip=f"End frame image for segment {i}. Aspect ratio must be between 1:4 and 4:1.",
),
IO.Int.Input(
f"duration{i}",
default=4,
min=2,
max=7,
step=1,
display_mode=IO.NumberDisplay.slider,
tooltip=f"Duration for segment {i} in seconds.",
),
]
)
return inputs
class ViduMultiFrameVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="ViduMultiFrameVideoNode",
display_name="Vidu Multi-Frame Video Generation",
category="api node/video/Vidu",
description="Generate a video with multiple keyframe transitions.",
inputs=[
IO.Combo.Input("model", options=["viduq2-pro", "viduq2-turbo"]),
IO.Image.Input(
"start_image",
tooltip="The starting frame image. Aspect ratio must be between 1:4 and 4:1.",
),
IO.Int.Input(
"seed",
default=1,
min=0,
max=2147483647,
step=1,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
),
IO.Combo.Input("resolution", options=["720p", "1080p"]),
IO.DynamicCombo.Input(
"frames",
options=[
IO.DynamicCombo.Option("2", _generate_frame_inputs(2)),
IO.DynamicCombo.Option("3", _generate_frame_inputs(3)),
IO.DynamicCombo.Option("4", _generate_frame_inputs(4)),
IO.DynamicCombo.Option("5", _generate_frame_inputs(5)),
IO.DynamicCombo.Option("6", _generate_frame_inputs(6)),
IO.DynamicCombo.Option("7", _generate_frame_inputs(7)),
IO.DynamicCombo.Option("8", _generate_frame_inputs(8)),
IO.DynamicCombo.Option("9", _generate_frame_inputs(9)),
],
tooltip="Number of keyframe transitions (2-9).",
),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(
widgets=[
"model",
"resolution",
"frames",
"frames.duration1",
"frames.duration2",
"frames.duration3",
"frames.duration4",
"frames.duration5",
"frames.duration6",
"frames.duration7",
"frames.duration8",
"frames.duration9",
]
),
expr="""
(
$m := widgets.model;
$n := $number(widgets.frames);
$is1080 := widgets.resolution = "1080p";
$d1 := $lookup(widgets, "frames.duration1");
$d2 := $lookup(widgets, "frames.duration2");
$d3 := $n >= 3 ? $lookup(widgets, "frames.duration3") : 0;
$d4 := $n >= 4 ? $lookup(widgets, "frames.duration4") : 0;
$d5 := $n >= 5 ? $lookup(widgets, "frames.duration5") : 0;
$d6 := $n >= 6 ? $lookup(widgets, "frames.duration6") : 0;
$d7 := $n >= 7 ? $lookup(widgets, "frames.duration7") : 0;
$d8 := $n >= 8 ? $lookup(widgets, "frames.duration8") : 0;
$d9 := $n >= 9 ? $lookup(widgets, "frames.duration9") : 0;
$totalDuration := $d1 + $d2 + $d3 + $d4 + $d5 + $d6 + $d7 + $d8 + $d9;
$contains($m, "pro")
? (
$base := $is1080 ? 0.3 : 0.15;
$perSec := $is1080 ? 0.075 : 0.05;
{"type":"usd","usd": $n * $base + $perSec * $totalDuration}
)
: (
$base := $is1080 ? 0.2 : 0.075;
$perSec := $is1080 ? 0.05 : 0.025;
{"type":"usd","usd": $n * $base + $perSec * $totalDuration}
)
)
""",
),
)
@classmethod
async def execute(
cls,
model: str,
start_image: Input.Image,
seed: int,
resolution: str,
frames: dict,
) -> IO.NodeOutput:
validate_image_aspect_ratio(start_image, (1, 4), (4, 1))
frame_count = int(frames["frames"])
image_settings: list[FrameSetting] = []
for i in range(1, frame_count + 1):
validate_image_aspect_ratio(frames[f"end_image{i}"], (1, 4), (4, 1))
validate_string(frames[f"prompt{i}"], max_length=2000)
start_image_url = await upload_image_to_comfyapi(
cls,
start_image,
mime_type="image/png",
wait_label="Uploading start image",
)
for i in range(1, frame_count + 1):
image_settings.append(
FrameSetting(
prompt=frames[f"prompt{i}"],
key_image=await upload_image_to_comfyapi(
cls,
frames[f"end_image{i}"],
mime_type="image/png",
wait_label=f"Uploading end image({i})",
),
duration=frames[f"duration{i}"],
)
)
results = await execute_task(
cls,
"/proxy/vidu/multiframe",
TaskMultiFrameCreationRequest(
model=model,
seed=seed,
resolution=resolution,
start_image=start_image_url,
image_settings=image_settings,
),
max_poll_attempts=480 * frame_count,
)
return IO.NodeOutput(await download_url_to_video_output(results[0].url))
class Vidu3TextToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="Vidu3TextToVideoNode",
display_name="Vidu Q3 Text-to-Video Generation",
category="api node/video/Vidu",
description="Generate video from a text prompt.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option(
"viduq3-pro",
[
IO.Combo.Input(
"aspect_ratio",
options=["16:9", "9:16", "3:4", "4:3", "1:1"],
tooltip="The aspect ratio of the output video.",
),
IO.Combo.Input(
"resolution",
options=["720p", "1080p"],
tooltip="Resolution of the output video.",
),
IO.Int.Input(
"duration",
default=5,
min=1,
max=16,
step=1,
display_mode=IO.NumberDisplay.slider,
tooltip="Duration of the output video in seconds.",
),
IO.Boolean.Input(
"audio",
default=False,
tooltip="When enabled, outputs video with sound "
"(including dialogue and sound effects).",
),
],
),
],
tooltip="Model to use for video generation.",
),
IO.String.Input(
"prompt",
multiline=True,
tooltip="A textual description for video generation, with a maximum length of 2000 characters.",
),
IO.Int.Input(
"seed",
default=1,
min=0,
max=2147483647,
step=1,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model.duration", "model.resolution"]),
expr="""
(
$res := $lookup(widgets, "model.resolution");
$base := $lookup({"720p": 0.075, "1080p": 0.1}, $res);
$perSec := $lookup({"720p": 0.025, "1080p": 0.05}, $res);
{"type":"usd","usd": $base + $perSec * ($lookup(widgets, "model.duration") - 1)}
)
""",
),
)
@classmethod
async def execute(
cls,
model: dict,
prompt: str,
seed: int,
) -> IO.NodeOutput:
validate_string(prompt, min_length=1, max_length=2000)
results = await execute_task(
cls,
VIDU_TEXT_TO_VIDEO,
TaskCreationRequest(
model=model["model"],
prompt=prompt,
duration=model["duration"],
seed=seed,
aspect_ratio=model["aspect_ratio"],
resolution=model["resolution"],
audio=model["audio"],
),
max_poll_attempts=640,
)
return IO.NodeOutput(await download_url_to_video_output(results[0].url))
class Vidu3ImageToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="Vidu3ImageToVideoNode",
display_name="Vidu Q3 Image-to-Video Generation",
category="api node/video/Vidu",
description="Generate a video from an image and an optional prompt.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option(
"viduq3-pro",
[
IO.Combo.Input(
"resolution",
options=["720p", "1080p", "2K"],
tooltip="Resolution of the output video.",
),
IO.Int.Input(
"duration",
default=5,
min=1,
max=16,
step=1,
display_mode=IO.NumberDisplay.slider,
tooltip="Duration of the output video in seconds.",
),
IO.Boolean.Input(
"audio",
default=False,
tooltip="When enabled, outputs video with sound "
"(including dialogue and sound effects).",
),
],
),
],
tooltip="Model to use for video generation.",
),
IO.Image.Input(
"image",
tooltip="An image to be used as the start frame of the generated video.",
),
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="An optional text prompt for video generation (max 2000 characters).",
),
IO.Int.Input(
"seed",
default=1,
min=0,
max=2147483647,
step=1,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model.duration", "model.resolution"]),
expr="""
(
$res := $lookup(widgets, "model.resolution");
$base := $lookup({"720p": 0.075, "1080p": 0.275, "2k": 0.35}, $res);
$perSec := $lookup({"720p": 0.05, "1080p": 0.075, "2k": 0.075}, $res);
{"type":"usd","usd": $base + $perSec * ($lookup(widgets, "model.duration") - 1)}
)
""",
),
)
@classmethod
async def execute(
cls,
model: dict,
image: Input.Image,
prompt: str,
seed: int,
) -> IO.NodeOutput:
validate_image_aspect_ratio(image, (1, 4), (4, 1))
validate_string(prompt, max_length=2000)
results = await execute_task(
cls,
VIDU_IMAGE_TO_VIDEO,
TaskCreationRequest(
model=model["model"],
prompt=prompt,
duration=model["duration"],
seed=seed,
resolution=model["resolution"],
audio=model["audio"],
images=[await upload_image_to_comfyapi(cls, image)],
),
max_poll_attempts=720,
)
return IO.NodeOutput(await download_url_to_video_output(results[0].url))
class ViduExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
@ -1493,10 +952,6 @@ class ViduExtension(ComfyExtension):
Vidu2ImageToVideoNode,
Vidu2ReferenceVideoNode,
Vidu2StartEndToVideoNode,
ViduExtendVideoNode,
ViduMultiFrameVideoNode,
Vidu3TextToVideoNode,
Vidu3ImageToVideoNode,
]

View File

@ -171,10 +171,9 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
continue
for item in items:
count += 1
if not isinstance(item, dict):
continue
count += 1
if preview_output is None and is_previewable(media_type, item):
enriched = {

View File

@ -1,42 +0,0 @@
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
class ColorToRGBInt(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="ColorToRGBInt",
display_name="Color to RGB Int",
category="utils",
description="Convert a color to a RGB integer value.",
inputs=[
io.Color.Input("color"),
],
outputs=[
io.Int.Output(display_name="rgb_int"),
],
)
@classmethod
def execute(
cls,
color: str,
) -> io.NodeOutput:
# expect format #RRGGBB
if len(color) != 7 or color[0] != "#":
raise ValueError("Color must be in format #RRGGBB")
r = int(color[1:3], 16)
g = int(color[3:5], 16)
b = int(color[5:7], 16)
return io.NodeOutput(r * 256 * 256 + g * 256 + b)
class ColorExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [ColorToRGBInt]
async def comfy_entrypoint() -> ColorExtension:
return ColorExtension()

View File

@ -56,7 +56,7 @@ class EmptyHunyuanLatentVideo(io.ComfyNode):
@classmethod
def execute(cls, width, height, length, batch_size=1) -> io.NodeOutput:
latent = torch.zeros([batch_size, 16, ((length - 1) // 4) + 1, height // 8, width // 8], device=comfy.model_management.intermediate_device())
return io.NodeOutput({"samples": latent, "downscale_ratio_spacial": 8})
return io.NodeOutput({"samples":latent})
generate = execute # TODO: remove
@ -73,7 +73,7 @@ class EmptyHunyuanVideo15Latent(EmptyHunyuanLatentVideo):
def execute(cls, width, height, length, batch_size=1) -> io.NodeOutput:
# Using scale factor of 16 instead of 8
latent = torch.zeros([batch_size, 32, ((length - 1) // 4) + 1, height // 16, width // 16], device=comfy.model_management.intermediate_device())
return io.NodeOutput({"samples": latent, "downscale_ratio_spacial": 16})
return io.NodeOutput({"samples": latent})
class HunyuanVideo15ImageToVideo(io.ComfyNode):

View File

@ -642,7 +642,7 @@ class SaveGLB(IO.ComfyNode):
metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
for i in range(mesh.vertices.shape[0]):
f = folder_paths.format_output_filename(filename, counter, "glb")
f = f"{filename}_{counter:05}_.glb"
save_glb(mesh.vertices[i], mesh.faces[i], os.path.join(full_output_folder, f), metadata)
results.append({
"filename": f,

View File

@ -460,7 +460,8 @@ class SaveSVGNode(IO.ComfyNode):
for batch_number, svg_bytes in enumerate(svg.data):
file = folder_paths.format_output_filename(filename, counter, "svg", batch_num=str(batch_number))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.svg"
# Read SVG content
svg_bytes.seek(0)

View File

@ -115,7 +115,7 @@ class LoraSave(io.ComfyNode):
if text_encoder_diff is not None:
output_sd = calc_lora_model(text_encoder_diff.patcher, rank, "", "text_encoders.", output_sd, lora_type, bias_diff=bias_diff)
output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors")
output_checkpoint = f"{filename}_{counter:05}_.safetensors"
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
comfy.utils.save_torch_file(output_sd, output_checkpoint, metadata=None)

View File

@ -221,7 +221,7 @@ def save_checkpoint(model, clip=None, vae=None, clip_vision=None, filename_prefi
for x in extra_pnginfo:
metadata[x] = json.dumps(extra_pnginfo[x])
output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors")
output_checkpoint = f"{filename}_{counter:05}_.safetensors"
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
comfy.sd.save_checkpoint(output_checkpoint, model, clip, vae, clip_vision, metadata=metadata, extra_keys=extra_keys)
@ -297,7 +297,7 @@ class CLIPSave:
full_output_folder, filename, counter, subfolder, filename_prefix_ = folder_paths.get_save_image_path(filename_prefix_, self.output_dir)
output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors")
output_checkpoint = f"{filename}_{counter:05}_.safetensors"
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
current_clip_sd = comfy.utils.state_dict_prefix_replace(current_clip_sd, replace_prefix)
@ -333,7 +333,7 @@ class VAESave:
for x in extra_pnginfo:
metadata[x] = json.dumps(extra_pnginfo[x])
output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors")
output_checkpoint = f"{filename}_{counter:05}_.safetensors"
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
comfy.utils.save_torch_file(vae.get_sd(), output_checkpoint, metadata=metadata)

View File

@ -1221,9 +1221,9 @@ class SaveLoRA(io.ComfyNode):
folder_paths.get_save_image_path(prefix, output_dir)
)
if steps is None:
output_checkpoint = folder_paths.format_output_filename(filename, counter, "safetensors")
output_checkpoint = f"{filename}_{counter:05}_.safetensors"
else:
output_checkpoint = folder_paths.format_output_filename(f"{filename}_{steps}_steps", counter, "safetensors")
output_checkpoint = f"{filename}_{steps}_steps_{counter:05}_.safetensors"
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
safetensors.torch.save_file(lora, output_checkpoint)
return io.NodeOutput()

View File

@ -36,7 +36,7 @@ class SaveWEBM(io.ComfyNode):
filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0]
)
file = folder_paths.format_output_filename(filename, counter, "webm")
file = f"{filename}_{counter:05}_.webm"
container = av.open(os.path.join(full_output_folder, file), mode="w")
if cls.hidden.prompt is not None:
@ -102,7 +102,7 @@ class SaveVideo(io.ComfyNode):
metadata["prompt"] = cls.hidden.prompt
if len(metadata) > 0:
saved_metadata = metadata
file = folder_paths.format_output_filename(filename, counter, Types.VideoContainer.get_extension(format))
file = f"{filename}_{counter:05}_.{Types.VideoContainer.get_extension(format)}"
video.save_to(
os.path.join(full_output_folder, file),
format=Types.VideoContainer(format),

View File

@ -1,3 +1,3 @@
# This file is automatically generated by the build process when version is
# updated in pyproject.toml.
__version__ = "0.11.1"
__version__ = "0.11.0"

View File

@ -4,7 +4,6 @@ import os
import time
import mimetypes
import logging
from datetime import datetime, timezone
from typing import Literal, List
from collections.abc import Collection
@ -12,46 +11,6 @@ from comfy.cli_args import args
supported_pt_extensions: set[str] = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'}
def get_timestamp() -> str:
"""Generate a filesystem-safe timestamp string for output filenames.
Returns a UTC timestamp in the format YYYYMMDD-HHMMSS-ffffff (microseconds)
which is human-readable, lexicographically sortable, and Windows-safe.
"""
now = datetime.now(timezone.utc)
return now.strftime("%Y%m%d-%H%M%S-%f")
def format_output_filename(
filename: str,
counter: int,
ext: str,
*,
batch_num: str | None = None,
timestamp: str | None = None,
) -> str:
"""Format an output filename with counter and timestamp for cache-busting.
Args:
filename: The base filename prefix
counter: The numeric counter for uniqueness
ext: The file extension (with or without leading dot)
batch_num: Optional batch number to replace %batch_num% placeholder
timestamp: Optional timestamp string (defaults to current UTC time)
Returns:
Formatted filename like: filename_00001_20260131-123456-789012_.ext
"""
ext = ext.lstrip(".")
if timestamp is None:
timestamp = get_timestamp()
if batch_num is not None:
filename = filename.replace("%batch_num%", batch_num)
return f"{filename}_{counter:05}_{timestamp}_.{ext}"
folder_names_and_paths: dict[str, tuple[list[str], set[str]]] = {}
# --base-directory - Resets all default paths configured in folder_paths with a new base path

View File

@ -508,7 +508,7 @@ class SaveLatent:
for x in extra_pnginfo:
metadata[x] = json.dumps(extra_pnginfo[x])
file = folder_paths.format_output_filename(filename, counter, "latent")
file = f"{filename}_{counter:05}_.latent"
results: list[FileLocator] = []
results.append({
@ -1667,7 +1667,8 @@ class SaveImage:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
file = folder_paths.format_output_filename(filename, counter, "png", batch_num=str(batch_number))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.png"
img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level)
results.append({
"filename": file,
@ -2431,8 +2432,7 @@ async def init_builtin_extra_nodes():
"nodes_wanmove.py",
"nodes_image_compare.py",
"nodes_zimage.py",
"nodes_lora_debug.py",
"nodes_color.py"
"nodes_lora_debug.py"
]
import_failed = []

View File

@ -1,6 +1,6 @@
[project]
name = "ComfyUI"
version = "0.11.1"
version = "0.11.0"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"

View File

@ -656,7 +656,6 @@ class PromptServer():
info = {}
info['input'] = obj_class.INPUT_TYPES()
info['input_order'] = {key: list(value.keys()) for (key, value) in obj_class.INPUT_TYPES().items()}
info['is_input_list'] = getattr(obj_class, "INPUT_IS_LIST", False)
info['output'] = obj_class.RETURN_TYPES
info['output_is_list'] = obj_class.OUTPUT_IS_LIST if hasattr(obj_class, 'OUTPUT_IS_LIST') else [False] * len(obj_class.RETURN_TYPES)
info['output_name'] = obj_class.RETURN_NAMES if hasattr(obj_class, 'RETURN_NAMES') else info['output']

View File

@ -98,7 +98,7 @@ def comfy_url_and_proc(comfy_tmp_base_dir: Path, request: pytest.FixtureRequest)
out_log = open(logs_dir / "stdout.log", "w", buffering=1)
err_log = open(logs_dir / "stderr.log", "w", buffering=1)
comfy_root = Path(__file__).resolve().parent.parent.parent
comfy_root = Path(__file__).resolve().parent.parent
if not (comfy_root / "main.py").is_file():
raise FileNotFoundError(f"main.py not found under {comfy_root}")

View File

@ -0,0 +1,2 @@
pytest>=7.8.0
blake3

View File

@ -1,83 +0,0 @@
"""Tests for folder_paths.format_output_filename and get_timestamp functions."""
import sys
import os
import unittest
# Add the ComfyUI root to the path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import folder_paths
class TestGetTimestamp(unittest.TestCase):
"""Tests for get_timestamp function."""
def test_returns_string(self):
"""Should return a string."""
result = folder_paths.get_timestamp()
self.assertIsInstance(result, str)
def test_format_matches_expected_pattern(self):
"""Should return format YYYYMMDD-HHMMSS-ffffff."""
result = folder_paths.get_timestamp()
# Pattern: 8 digits, hyphen, 6 digits, hyphen, 6 digits
pattern = r"^\d{8}-\d{6}-\d{6}$"
self.assertRegex(result, pattern)
def test_is_filesystem_safe(self):
"""Should not contain characters that are unsafe for filenames."""
result = folder_paths.get_timestamp()
unsafe_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ']
for char in unsafe_chars:
self.assertNotIn(char, result)
class TestFormatOutputFilename(unittest.TestCase):
"""Tests for format_output_filename function."""
def test_basic_format(self):
"""Should format filename with counter and timestamp."""
result = folder_paths.format_output_filename("test", 1, "png")
# Pattern: test_00001_YYYYMMDD-HHMMSS-ffffff_.png
pattern = r"^test_00001_\d{8}-\d{6}-\d{6}_\.png$"
self.assertRegex(result, pattern)
def test_counter_padding(self):
"""Should pad counter to 5 digits."""
result = folder_paths.format_output_filename("test", 42, "png")
self.assertIn("_00042_", result)
def test_extension_with_leading_dot(self):
"""Should handle extension with leading dot."""
result = folder_paths.format_output_filename("test", 1, ".png")
self.assertTrue(result.endswith("_.png"))
self.assertNotIn("..png", result)
def test_extension_without_leading_dot(self):
"""Should handle extension without leading dot."""
result = folder_paths.format_output_filename("test", 1, "webm")
self.assertTrue(result.endswith("_.webm"))
def test_batch_num_replacement(self):
"""Should replace %batch_num% placeholder."""
result = folder_paths.format_output_filename("test_%batch_num%", 1, "png", batch_num="3")
self.assertIn("test_3_", result)
self.assertNotIn("%batch_num%", result)
def test_custom_timestamp(self):
"""Should use provided timestamp instead of generating one."""
custom_ts = "20260101-120000-000000"
result = folder_paths.format_output_filename("test", 1, "png", timestamp=custom_ts)
self.assertIn(custom_ts, result)
def test_different_extensions(self):
"""Should work with various extensions."""
extensions = ["png", "webp", "webm", "svg", "glb", "safetensors", "latent"]
for ext in extensions:
result = folder_paths.format_output_filename("test", 1, ext)
self.assertTrue(result.endswith(f"_.{ext}"))
if __name__ == "__main__":
unittest.main()

View File

@ -2,4 +2,3 @@ pytest>=7.8.0
pytest-aiohttp
pytest-asyncio
websocket-client
blake3