Replace comfyui_projects repo with current local version

This commit is contained in:
Fred LeFevre
2025-10-21 11:45:36 -04:00
parent 612ee6d2ba
commit 71b6679465
18 changed files with 711 additions and 182 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv*/
.DS_Store
*.log
ComfyUI/models/
ComfyUI/input/
ComfyUI/output/
+31 -7
View File
@@ -7,20 +7,29 @@ Clone this repo into your comfyui folder.
## Projects Included
### 🧩 Custom Nodes: Comfyui_FSL_Nodes
- **sampler_config_hub.py**
One of two custom nodes to configure sampler and scheduler settings for workflows. Used to feed data to the meta node
- **sampler_scheduler_config.py**
Two of two custom nodes to configure sampler and scheduler settings for workflows. Used to feed data to the meta node
- **manual_alpha_mask_painter.py**
Custom node to manually convert black mask areas into alpha transparency. Useful with GRIPTAPE when inpainting and creating the mask by right clicking on the loaded image and select load mask editor. Griptape requires an alpha channel mask.
- **8WayImgSwitch**
Image switch with eight inputs
- **fsl_composite_with_mask_cropped**
- **fsl_composite_with_mask_cropped.py**
Designed to remove the Alpha Channel But Keep Transparent Parts as Background Color (not currently used)
- **fsl_save_and_strip_alpha**
Strips alpha channel from RGBA image. (not currently used)
- **How to use**
place this node folder into the custom_nodes folder - no additional action is necessary. Nodes can be found in the comfyui onscreen nodes folder by searching for FSL
- **FSLGeminiGenerateImageV6.py**
Allows for the use of Gemini API's to create image. This node also provide for whether or not the preceding image is used for iterative generation.
- **fsl_image_memory.py**
Provides 4 nodes. Image Memory Store and Image Memory Recall stores an image to the key entered in the 'key' field. Image memory recall recalls the image for subsequent use. Image Memory Clear is used to delete the stored image based on the entered key. Image Memory Clear All clears all keys that have been entered.
- **fsl_prompt_compose.py**
Node for Positive and Negative prompt as well as 'scene-lock'. When scene-lock is true -
When lock_scene: True, the node prepends a short instruction block before your positive text that tells the model to treat the provided image as fixed “scene layout” and only change what you explicitly ask. The included language (summarized) is:
- use the provided image as base;
- keep composition, subjects, positions, background, lighting, camera angle, clothing, colors unchanged;
- change only what you specify;
- do not crop, do not isolate a single subject, do not change the background.
- **fsl_ensure_nhwc_batch.py**
Node guarantees that any incoming image tensor is converted to the [N, H, W, C] (batch–height–width–channels) layout expected by downstream nodes, automatically re-ordering dimensions and normalizing types so all image data conform to a consistent format for safe processing.
- **FSLImageSaverWithMetadataV5.py**
Node stores metadata with image. Data can be easily read using the LoadImage-w-Metadata.json workflow
Located in the [`Comfyui_FSL_Nodes/`](https://github.com/fredlef/comfyui_projects/tree/main/custom_nodes/Comfyui_FSL_Nodes) directory.
@@ -28,13 +37,28 @@ Located in the [`Comfyui_FSL_Nodes/`](https://github.com/fredlef/comfyui_project
### 🎨 Workflow: T-shirt Designer
- **Tshirt-Designer-ver_2.0.json**
It is recommended not to use this workflow. A replacement workflow that is less complex will be uploaded as 3.0
Workflow designed for generating masked transparent images for T-shirt and object printing.
- Many corrections and updates
- Full workflow details and notes are available in [`tshirt_designer/`](https://github.com/fredlef/comfyui_projects/tree/main/workflows/tshirt_designer).
---
### 🎨 Workflow: Nano Banana
- **Nano Banana Iterative-Base.json**
Allows the user to create an image using Gemini-2.5 Flash-image-preview or Gemini-2.5 Flash. Once the initial image is created additional iterations of each succeeding image can be created just be entering a new prompt. Setup is included in a Note node in the workflow.
- **Nano Banana Iterative-Load 5 Images.json**
Provides for using up to 5 nodes to create an image with Nano Banana. The images can they be modified as in the Iterative-Base workflow. Basic instructions are provided in Notes nodes in the workflow.
- **Nano Banana w_Griptape w_Upscaler.json**
Uses Griptape Nodes as a front end prompt loader to Nano Banana. An excellent upscaler by Alex at ComfyUiStudio is included. An OpenAPI Key is required.
### Miscellaneous Workflows
- **LoadImage-w-Metadata.json**
Simple workflow to read the metadata created by the FSLImageSaverWithMetadataV5 node
## Acknowledgements
- **Thanks to Alex and ComfyuiStudio for allowing me to use his excellent upscaler**
- **Mycroft Holmes - AKA ChatGPT for excellent assistance and guidance in the creation of Custom Nodes**
## Author
@@ -0,0 +1,294 @@
import os
import time
import base64
import random
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import requests
try:
import torch
except Exception:
torch = None
try:
from PIL import Image
except Exception:
Image = None
# ---------------- Helpers ----------------
def _tensor_to_png_b64(t) -> str:
"""Convert torch.Tensor or np.ndarray image to base64 PNG."""
if torch is not None and isinstance(t, torch.Tensor):
x = t.detach().cpu().float()
if x.dim() == 4 and x.shape[0] > 0:
x = x[0]
if x.dim() == 3 and x.shape[0] in (1, 3, 4) and x.shape[-1] not in (3, 4):
x = x.permute(1, 2, 0)
arr = x.numpy()
elif isinstance(t, np.ndarray):
arr = t
else:
raise TypeError(f"Expected image as torch.Tensor or np.ndarray, got {type(t)}")
if arr.max() <= 1.5:
arr = arr * 255.0
arr = np.clip(arr, 0, 255).astype(np.uint8)
if Image is None:
raise RuntimeError("Pillow is required to encode PNG images.")
im = Image.fromarray(arr)
buf = BytesIO()
im.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
def _extract_base64_image(resp_json: Dict[str, Any]) -> Optional[str]:
"""Return first inline image payload from Gemini JSON response."""
def _walk(node):
if isinstance(node, dict):
for k, v in node.items():
yield k, v
yield from _walk(v)
elif isinstance(node, list):
for item in node:
yield from _walk(item)
for k, v in _walk(resp_json):
if k in ("inline_data", "inlineData") and isinstance(v, dict):
data = v.get("data")
mime = v.get("mime_type") or v.get("mimeType") or ""
if data and ("image/" in mime or mime == "" or mime is None):
return data
return None
def _post_with_backoff(url: str, headers: Dict[str, str], params: Dict[str, Any], payload: Dict[str, Any]):
delay = 1.0
last = None
for _ in range(4):
r = requests.post(url, headers=headers, params=params, json=payload, timeout=60)
last = r
if r.status_code != 429:
return r
time.sleep(delay)
delay *= 2
return last
MODEL_CHOICES = (
"gemini-2.5-flash-image",
"gemini-2.5-flash-image-preview",
"gemini-2.5-pro",
"gemini-2.0-flash-exp",
"gemini-2.0-pro-exp",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-1.0-pro-vision",
"gemini-2.5-flash-audio-preview",
"gemini-2.5-vision-preview",
)
class FSLGeminiGenerateImageV6:
"""Gemini image generator with iterative + external image toggles."""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": (MODEL_CHOICES, {"default": "gemini-2.5-flash-image-preview"}),
"width": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 64}),
"height": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 64}),
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
"seed": ("INT", {"default": -1, "min": -1, "max": 0x7FFFFFFF}),
"use_init_image": ("BOOLEAN", {"default": True}),
"use_images": ("BOOLEAN", {"default": False}),
"refresh_models": ("BOOLEAN", {"default": False}),
"output_prompt": ("BOOLEAN", {"default": False}),
},
"optional": {
"init_image": ("IMAGE",),
"images": ("IMAGE",),
"api_key": ("STRING", {"default": "", "multiline": False, "placeholder": "Leave blank to use GEMINI_API_KEY env var"}),
},
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "INT", "INT", "FLOAT", "INT", "STRING", "DICT")
RETURN_NAMES = (
"image",
"prompt",
"model",
"width",
"height",
"temperature",
"seed",
"seed_mode",
"metadata",
)
FUNCTION = "generate"
CATEGORY = "FSL"
# ---------- internals ----------
def _resolve_api_key(self, key_in: str) -> str:
k = (key_in or "").strip()
if not k:
k = os.environ.get("GEMINI_API_KEY", "").strip()
if not k:
raise RuntimeError("Gemini API key missing. Provide it in the node or set GEMINI_API_KEY env var.")
return k
def _append_tensor_as_part(self, img_like, out: List[Dict[str, Any]]):
b64 = _tensor_to_png_b64(img_like)
out.append({"inline_data": {"mime_type": "image/png", "data": b64}})
def _collect_reference_parts(
self,
use_init_image: bool,
init_image,
use_images: bool,
images,
) -> List[Dict[str, Any]]:
parts: List[Dict[str, Any]] = []
# init first (if enabled)
if use_init_image and init_image is not None:
if isinstance(init_image, (list, tuple)):
if len(init_image) > 0:
self._append_tensor_as_part(init_image[0], parts)
elif torch is not None and isinstance(init_image, torch.Tensor):
if init_image.dim() == 4 and init_image.shape[0] > 0:
self._append_tensor_as_part(init_image[0], parts)
else:
self._append_tensor_as_part(init_image, parts)
elif isinstance(init_image, np.ndarray):
self._append_tensor_as_part(init_image, parts)
# then all external images (if enabled)
if use_images and images is not None:
if isinstance(images, (list, tuple)):
for t in images:
self._append_tensor_as_part(t, parts)
elif torch is not None and isinstance(images, torch.Tensor):
if images.dim() == 4:
for i in range(images.shape[0]):
self._append_tensor_as_part(images[i], parts)
else:
self._append_tensor_as_part(images, parts)
elif isinstance(images, np.ndarray):
self._append_tensor_as_part(images, parts)
return parts
# ---------- main ----------
def generate(
self,
prompt: str,
model: str,
width: int,
height: int,
temperature: float,
seed: int,
use_init_image: bool,
refresh_models: bool,
output_prompt: bool,
use_images: bool = False,
init_image: Optional["torch.Tensor"] = None,
images: Optional["torch.Tensor"] = None,
api_key: str = "",
) -> Tuple["torch.Tensor", str, str, int, int, float, int, str, Dict[str, Any]]:
seed_mode = "fixed"
if seed == -1:
seed = random.randint(0, 0x7FFFFFFF)
seed_mode = "randomize"
api_key = self._resolve_api_key(api_key)
endpoint = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
headers = {"Content-Type": "application/json"}
parts: List[Dict[str, Any]] = [{"text": prompt}]
ref_parts = self._collect_reference_parts(use_init_image, init_image, use_images, images)
parts.extend(ref_parts)
payload: Dict[str, Any] = {
"contents": [{"role": "user", "parts": parts}],
"generationConfig": {
"temperature": float(temperature),
"seed": int(seed),
},
}
_ = refresh_models # UI no-op
resp = _post_with_backoff(endpoint, headers=headers, params={"key": api_key}, payload=payload)
# Parse response
try:
resp_json = resp.json()
except Exception as e:
raise RuntimeError(f"Gemini response was not JSON: {e}")
if isinstance(resp_json, dict) and "error" in resp_json:
err = resp_json["error"]
raise RuntimeError(f"Gemini error: {err.get('status')} - {err.get('message')}")
b64_img = _extract_base64_image(resp_json)
if not b64_img:
raise RuntimeError(f"Failed to find inline image in Gemini response. First 1k of JSON: {str(resp_json)[:1000]}")
try:
img_bytes = base64.b64decode(b64_img)
if Image is None:
raise RuntimeError("Pillow is required to decode returned image.")
pil = Image.open(BytesIO(img_bytes)).convert("RGBA")
except Exception as e:
raise RuntimeError(f"Failed to decode inline image: {e}")
np_img = np.array(pil).astype(np.float32) / 255.0
if torch is None:
raise RuntimeError("torch is required for IMAGE tensor output.")
image_tensor = torch.from_numpy(np_img)[None, ...]
out_prompt = prompt if output_prompt else ""
metadata_output = {
"prompt": prompt,
"model": model,
"width": width,
"height": height,
"temperature": temperature,
"seed": seed,
"seed_mode": seed_mode,
"use_init_image": bool(use_init_image and init_image is not None),
"use_images": bool(use_images and images is not None),
"init_image_connected": bool(init_image is not None),
"batch_images": (
int(images.shape[0]) if (torch is not None and isinstance(images, torch.Tensor) and images.dim() == 4)
else (len(images) if isinstance(images, (list, tuple)) else (1 if images is not None else 0))
),
}
return (
image_tensor,
out_prompt,
model,
width,
height,
temperature,
seed,
seed_mode,
metadata_output,
)
NODE_CLASS_MAPPINGS = {"FSLGeminiGenerateImageV6": FSLGeminiGenerateImageV6}
NODE_DISPLAY_NAME_MAPPINGS = {"FSLGeminiGenerateImageV6": "FSL Gemini Generate Image V6"}
@@ -0,0 +1,189 @@
import os
import json
from typing import Dict, Any, List, Tuple, Optional
import numpy as np
import torch
from PIL import Image, PngImagePlugin
import folder_paths
from datetime import datetime
class FSLImageSaverWithMetadataV5:
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"images": ("IMAGE",),
"filename": ("STRING", {"default": "gemini_out"}),
"save_to_subfolder": ("STRING", {"default": ""}),
},
"optional": {
"append_datetime": ("BOOLEAN", {"default": False}),
"model_string": ("STRING", {"default": ""}),
"modelname": ("STRING", {"default": ""}),
"seed_string": ("STRING", {"default": ""}),
"width_string": ("STRING", {"default": ""}),
"height_string": ("STRING", {"default": ""}),
"temperature_string": ("STRING", {"default": ""}),
"positive_string": ("STRING", {"default": ""}),
"negative_string": ("STRING", {"default": ""}),
"cfg_string": ("STRING", {"default": ""}),
"steps_string": ("STRING", {"default": ""}),
"sampler_string": ("STRING", {"default": ""}),
"scheduler_string": ("STRING", {"default": ""}),
"guidance_scale_string": ("STRING", {"default": ""}),
"watermark_string": ("STRING", {"default": ""}),
"external_metadata": ("DICT", {"default": {}}),
},
}
# Keep METADATA_RAW as output #1 for compatibility.
# Add IMAGE as output #2 so you can optionally preview.
RETURN_TYPES = ("METADATA_RAW", "IMAGE",)
RETURN_NAMES = ("metadata_raw", "image",)
FUNCTION = "save_files"
OUTPUT_NODE = True
CATEGORY = "FSL/Save"
def _to_numpy_image(self, t: torch.Tensor) -> np.ndarray:
"""Convert a tensor to HWC uint8, preserving alpha if present."""
if t.device.type != "cpu":
t = t.cpu()
t = t.detach().float()
# If CHW, convert to HWC
if t.dim() == 3 and t.shape[0] in (1, 3, 4) and t.shape[-1] not in (3, 4):
t = t.permute(1, 2, 0)
arr = (t.numpy() * 255.0).clip(0, 255).astype(np.uint8)
return arr
def save_files(
self,
images: torch.Tensor,
filename: str,
save_to_subfolder: str,
append_datetime: bool = False,
model_string: str = "",
modelname: str = "",
seed_string: str = "",
width_string: str = "",
height_string: str = "",
temperature_string: str = "",
positive_string: str = "",
negative_string: str = "",
cfg_string: str = "",
steps_string: str = "",
sampler_string: str = "",
scheduler_string: str = "",
guidance_scale_string: str = "",
watermark_string: str = "",
external_metadata: Optional[Dict[str, Any]] = None,
) -> Tuple[Dict[str, Any], torch.Tensor]:
base = folder_paths.get_output_directory()
sub = (save_to_subfolder or "").strip()
sub = os.path.normpath(sub).replace("\\", "/").lstrip("/")
outdir = os.path.join(base, sub) if sub else base
os.makedirs(outdir, exist_ok=True)
# Normalize batch
if images.dim() == 3:
images = images.unsqueeze(0)
# One canonical, timezone-aware timestamp used everywhere
now_local = datetime.now().astimezone()
ts_file = now_local.strftime("%Y%m%d_%H%M%S") # e.g. 20251014_163733
iso_human = now_local.isoformat(timespec="seconds") # e.g. 2025-10-14T16:37:33-04:00
if append_datetime:
filename = f"{filename}_{ts_file}"
model_value = (model_string or modelname).strip()
lines: List[str] = []
if model_value:
lines.append("model: " + model_value)
if seed_string:
lines.append("seed: " + seed_string)
if width_string or height_string:
lines.append(f"size: {width_string}x{height_string}")
if temperature_string:
lines.append("temperature: " + temperature_string)
if positive_string:
lines.append("positive: " + positive_string)
if negative_string:
lines.append("negative: " + negative_string)
parameters_text = "\n".join(lines)
pnginfo = PngImagePlugin.PngInfo()
if parameters_text:
pnginfo.add_text("parameters", parameters_text)
if external_metadata:
try:
pnginfo.add_text("fsl_metadata", json.dumps(external_metadata, ensure_ascii=False))
except Exception:
pnginfo.add_text("fsl_metadata", str(external_metadata))
metadata_raw_out: Dict[str, Any] = {
"fileinfo": {
"filename": "",
"resolution": "",
"date": "",
"size_bytes": 0,
"size_human": "",
"size": "",
"format": "PNG",
}
}
preview_tensor: Optional[torch.Tensor] = None
for i in range(images.shape[0]):
np_img = self._to_numpy_image(images[i])
h, w = np_img.shape[0], np_img.shape[1]
# Explicit mode preserves alpha if present
mode = "RGBA" if np_img.shape[-1] == 4 else ("RGB" if np_img.shape[-1] == 3 else None)
pil = Image.fromarray(np_img, mode=mode) if mode else Image.fromarray(np_img)
ret = folder_paths.get_save_image_path(filename, outdir, i)
# Known return: (full_output_folder, filename_prefix, counter, subfolder, filename_with_subfolder)
if isinstance(ret, (list, tuple)) and len(ret) >= 3:
full_output_folder, filename_prefix, counter = ret[0], ret[1], ret[2]
path = os.path.join(full_output_folder, f"{filename_prefix}_{counter:05}.png")
else:
path = os.path.join(outdir, f"{filename}_{i:05}.png")
pil.save(path, format="PNG", pnginfo=pnginfo)
# Align filesystem mtime with our canonical timestamp so all tools agree
epoch = now_local.timestamp()
os.utime(path, (epoch, epoch))
filesize = os.path.getsize(path)
size_human = f"{filesize / (1024*1024):.2f} MB"
metadata_raw_out["fileinfo"] = {
"filename": path,
"resolution": f"{w}x{h}",
"date": iso_human, # unified local, tz-aware
"size_bytes": filesize,
"size_human": size_human,
"size": size_human,
"format": "PNG",
}
# Keep first image as preview
if preview_tensor is None:
# Return normalized [1,H,W,C] float tensor
preview_tensor = torch.from_numpy(np_img.astype(np.float32) / 255.0)[None, ...]
# Safety: if nothing was saved (empty batch), provide a blank preview
if preview_tensor is None:
preview_tensor = torch.zeros((1, 64, 64, 4), dtype=torch.float32)
# Return METADATA first (compat), IMAGE second (for optional preview wiring)
return (metadata_raw_out, preview_tensor,)
NODE_CLASS_MAPPINGS = {"FSLImageSaverWithMetadataV5": FSLImageSaverWithMetadataV5}
NODE_DISPLAY_NAME_MAPPINGS = {"FSLImageSaverWithMetadataV5": "FSL Image Saver w/ Metadata V5"}
-10
View File
@@ -1,10 +0,0 @@
# Comfyui_FSL_Nodes
This folder contains custom nodes designed by Fred LeFevre for use with ComfyUI workflows. They are required for the tshirt_designer workflow.
Included nodes:
- `sampler_config_hub.py` — Custom sampler and scheduler configuration node
- `sampler_scheduler_config.py` - Custom sampler and scheduler configuration node
- `manual_alpha_mask_painter.py` — Manual alpha mask painter for converting black masks to transparency
These nodes are licensed under the main [repository LICENSE](../LICENSE).
@@ -1,69 +0,0 @@
---
title: T-Shirt and Object Masking Workflow Notes
author: Fred LeFevre
created: 2025-04-27
description: Documentation for the T-Shirt and Object Masking Workflow, including custom node usage, image source control, and metadata settings.
---
# T-Shirt and Object Masking Workflow — Testing & Direction Notes
This workflow was primarily designed to create masked images for T-shirts and other objects. It has grown beyond my initial expectations.
---
## 1. Pending Work
- **Add an upscaler.**
- I currently use **Topaz Photo AI** for upscaling and it does a great job.
---
## 2. Custom Nodes
- There are **three custom nodes** designed for this workflow.
- The **Sampler** and **Scheduler** nodes are included primarily to **feed settings to metadata**.
- Available fields (except for **Denoise** and **Control After Generate**) should be entered in the **custom nodes**, **not** the **KSampler**.
- **Denoise** and **Control After Generate** should still be entered directly inside the **KSampler**.
---
## 3. Image Input Options
- There are **four Griptape image nodes** plus a standard **Load Image** node that can feed images into the workflow.
- The image that is processed depends on the **5-Way Toggle Image Switch** inside the **Toggle Image Source Group**.
- **Griptape nodes** require **both OpenAI** and **Black Forest Labs API keys**.
- These keys **used to** be entered in a `.env` file.
- They are now entered in the **Griptape Settings window** inside ComfyUI.
- Instructions for obtaining the necessary API keys can easily be found **online** or in the **Griptape documentation**.
---
## 4. SDXL or Direct Path Group
- The **SDXL or Direct Path Group switch** determines:
- Whether the image is processed through the full **SDXL workflow**, or
- Whether it **goes directly** to the **Transparent Background Node**, bypassing SDXL processing.
---
## 5. Prompt vs Image Source Priority
- The **Toggle Prompt or Image Src Group** controls **whether the prompt or the image source takes precedence**.
- This works in conjunction with setting **Denoise** inside the **KSampler**:
- **Denoise = 0.1** → **Prompt** takes precedence.
- **Denoise = 0.5** → **Mix** of prompt and image.
- **Denoise = 0.9** → **Image** takes precedence.
### Choices Table:
| Toggle Prompt or Image Src | SDXL or Direct Path Group | Denoise Setting | Precedence | Notes |
|:---------------------------|:--------------------------|:----------------|:-----------|:------|
| 0 and 0 | (Doesn't matter) | 0.9 | Manual Prompt (Enter Positive Prompt Here) | |
| 0 and 0 | (Doesn't matter) | 0.1 | No Image | If SDXL or Direct Path Group switch is set to 1, image skips SDXL workflow |
| 1 and 0 | (Doesn't matter) | 0.9 | Griptape Prompt | |
| 1 and 0 | (Doesn't matter) | 0.1 | No Image | |
| 0 and 1 | (Doesn't matter) | 0.9 | Manual Prompt | |
| 0 and 1 | (Doesn't matter) | 0.5 | Mix of Manual Prompt and Image Source | |
| 0 and 1 | (Doesn't matter) | 0.1 | Image Source | |
| 1 and 1 | (Doesn't matter) | 0.9 | Manual Prompt | |
| 1 and 1 | (Doesn't matter) | 0.5 | Mix of Manual Prompt and Image Source | |
| 1 and 1 | (Doesn't matter) | 0.1 | Mix of Manual Prompt and Image Source | |
---
# End of Notes
@@ -0,0 +1,55 @@
# FSL / Ensure NHWC Batch (returns 1xH×W×3 float32)
import torch
class FSLEnsureNHWCBatch:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("IMAGE",)}}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image_nhwc",)
FUNCTION = "ensure_nhwc"
CATEGORY = "FSL/Utils"
def _to_nhwc(self, t: torch.Tensor) -> torch.Tensor:
if not isinstance(t, torch.Tensor):
raise TypeError(f"Expected torch.Tensor, got {type(t)}")
if t.dtype != torch.float32:
t = t.float()
# 3D cases: HWC / CHW / HCW
if t.ndim == 3:
if t.shape[-1] in (3, 4): # HWC already
a = t
elif t.shape[0] in (3, 4): # CHW -> HWC
a = t.permute(1, 2, 0).contiguous()
elif t.shape[1] in (3, 4): # HCW -> HWC
a = t.permute(0, 2, 1).contiguous()
else:
raise ValueError(f"3D tensor has unexpected shape {tuple(t.shape)}")
return a.unsqueeze(0).clamp(0.0, 1.0) # -> 1 x H x W x C
# 4D cases: NHWC / NCHW / NHCW
if t.ndim == 4:
if t.shape[-1] in (3, 4): # NHWC already
a = t
elif t.shape[1] in (3, 4): # NCHW -> NHWC
a = t.permute(0, 2, 3, 1).contiguous()
elif t.shape[2] in (3, 4): # NHCW -> NHWC
a = t.permute(0, 1, 3, 2).contiguous()
else:
raise ValueError(f"4D tensor has unexpected shape {tuple(t.shape)}")
return a.clamp(0.0, 1.0)
raise ValueError(f"Expected 3D/4D tensor, got {t.ndim}D")
def ensure_nhwc(self, image):
t = image[0] if isinstance(image, list) else image
t = self._to_nhwc(t)
# Drop alpha if present so esrgan-like models stay happy
if t.shape[-1] == 4:
t = t[..., :3]
return (t,)
NODE_CLASS_MAPPINGS = {"FSLEnsureNHWCBatch": FSLEnsureNHWCBatch}
NODE_DISPLAY_NAME_MAPPINGS = {"FSLEnsureNHWCBatch": "FSL / Ensure NHWC Batch"}
@@ -0,0 +1,130 @@
# ComfyUI/custom_nodes/comfyui_fsl_nodes/fsl_image_memory.py
# FSL / Image Memory: store & recall IMAGE tensors between runs.
import torch, time
# One in-process cache for IMAGEs: key -> list[HWC float tensors]
_MEM = {}
def _ensure_list_of_hwctensors(x):
"""Return a flat list of HWC float32 tensors in [0,1]."""
imgs = x if isinstance(x, list) else [x]
# unwrap single nested list: [[t]] -> [t]
if len(imgs) == 1 and isinstance(imgs[0], list):
imgs = imgs[0]
out = []
for t in imgs:
# normalize to torch tensor
if not torch.is_tensor(t):
t = torch.tensor(t)
# common incoming shapes:
# (H,W,C) OK; (C,H,W) -> (H,W,C); (1,C,H,W) -> (C,H,W)
if t.ndim == 4 and t.shape[0] == 1:
t = t.squeeze(0) # (1,C,H,W) -> (C,H,W)
if t.ndim == 3 and t.shape[0] in (1, 3) and t.shape[-1] not in (1, 3):
# likely CHW -> HWC
t = t.movedim(0, -1)
if t.ndim == 2:
# grayscale H,W -> H,W,1
t = t.unsqueeze(-1)
# dtype/range hygiene
if t.dtype != torch.float32:
t = t.float()
t = t.clamp(0, 1).contiguous()
out.append(t)
return out
def _first_shape(lst):
try:
t = lst[0]
return tuple(t.shape)
except Exception:
return "n/a"
class FSLImageMemoryStore:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("IMAGE",), "key": ("STRING", {"default": "last"})}}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "store"
CATEGORY = "FSL/Memory"
def store(self, image, key):
data = _ensure_list_of_hwctensors(image)
_MEM[key] = data
print(f"[FSLImageMemoryStore] stored key='{key}' ({len(data)} img) first={_first_shape(data)} mem_id={id(_MEM)}")
return (data,) # pass-through as IMAGE
class FSLImageMemoryRecallSafe:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"key": ("STRING", {"default": "last"})}}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "recall"
CATEGORY = "FSL/Memory"
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time()
def recall(self, key):
imgs = _MEM.get(key, None)
if imgs:
imgs = _ensure_list_of_hwctensors(imgs)
print(f"[FSLImageMemoryRecallSafe] key='{key}' ({len(imgs)} img) first={_first_shape(imgs)} mem_id={id(_MEM)}")
return (imgs,)
print(f"[FSLImageMemoryRecallSafe] WARNING: key='{key}' missing -> returning 1x1 black mem_id={id(_MEM)}")
t = torch.zeros((1, 1, 3), dtype=torch.float32) # HWC in [0,1]
return ([t],)
class FSLImageMemoryClear:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"key": ("STRING", {"default": "last"})}}
RETURN_TYPES = ()
FUNCTION = "clear"
OUTPUT_NODE = True
CATEGORY = "FSL/Memory"
def clear(self, key):
_MEM.pop(key, None)
print(f"[FSLImageMemoryClear] cleared key='{key}' mem_id={id(_MEM)}")
return ()
class FSLImageMemoryClearAll:
@classmethod
def INPUT_TYPES(cls):
return {}
RETURN_TYPES = ()
FUNCTION = "clear_all"
OUTPUT_NODE = True
CATEGORY = "FSL/Memory"
def clear_all(self):
global _MEM
_MEM.clear()
print(f"[FSLImageMemoryClearAll] cleared all memory, mem_id={id(_MEM)}")
return ()
NODE_CLASS_MAPPINGS = {
"FSLImageMemoryStore": FSLImageMemoryStore,
"FSLImageMemoryRecallSafe": FSLImageMemoryRecallSafe,
"FSLImageMemoryClear": FSLImageMemoryClear,
"FSLImageMemoryClearAll": FSLImageMemoryClearAll,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FSLImageMemoryStore": "FSL / Image Memory → Store",
"FSLImageMemoryRecallSafe": "FSL / Image Memory → Recall (Safe)",
"FSLImageMemoryClear": "FSL / Image Memory → Clear",
"FSLImageMemoryClearAll": "FSL / Image Memory → Clear All",
}
@@ -1,46 +0,0 @@
class SamplerConfigHub:
"""
Custom node that emits a unified sampler config dictionary.
This allows centralized control over sampler settings when using multiple
KSampler nodes in a workflow (e.g., for SDXL base and refiner).
"""
CATEGORY = "FSL Nodes/Utils" # Changed to match your preferred category
RETURN_TYPES = ("SAMPLER_CONFIG",)
RETURN_NAMES = ("sampler_config",)
FUNCTION = "build_config"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sampler_name": ([
"euler", "euler_ancestral", "heun",
"dpm_2", "dpm_2_ancestral", "lms",
"dpm_fast", "dpm_adaptive",
"dpmpp_2s_ancestral", "dpmpp_2m", "dpmpp_sde",
"dpmpp_2m_sde", "dpmpp_2m_sde_gpu", "dpmpp_3m_sde",
"ddim", "plms"
],),
"scheduler": (["normal", "karras", "exponential", "sgm_uniform"],),
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
"cfg": ("FLOAT", {"default": 7.5, "min": 0.0, "max": 20.0}),
"seed": ("INT", {"default": 42}),
}
}
def build_config(self, sampler_name, scheduler, steps, cfg, seed):
return ({
"sampler_name": sampler_name,
"scheduler": scheduler,
"steps": steps,
"cfg": cfg,
"seed": seed,
},)
NODE_CLASS_MAPPINGS = {
"FSL SamplerConfigHub": SamplerConfigHub, # Added prefix and comma
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FSL SamplerConfigHub": "🎛️ Sampler Config Hub (FSL)", # Added prefix
}
@@ -1,50 +0,0 @@
"""
FSL Sampler & Scheduler Config Node
Version: 1.0.0
Author: Fred LeFevre
Date: 2025-04-27
Description: Custom Sampler Node to feed settings to metadata.
"""
__version__ = "1.0.0"
class FSL_SamplerSchedulerConfig:
CATEGORY = "FSL Nodes/Utils"
RETURN_TYPES = ("SAMPLER", "SCHEDULER")
RETURN_NAMES = ("sampler", "scheduler")
FUNCTION = "bundle"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sampler_name": ([
"euler", "euler_cfg_pp", "euler_ancestral", "euler_ancestral_cfg_pp",
"heun", "heunpp2", "dpm_2", "dpm_2_ancestral", "lms", "dpm_fast",
"dpm_adaptive", "dpmpp_2s_ancestral", "dpmpp_2s_ancestral_cfg_pp",
"dpmpp_sde", "dpmpp_sde_gpu", "dpmpp_2m", "dpmpp_2m_cfg_pp",
"dpmpp_2m_sde", "dpmpp_2m_sde_gpu", "dpmpp_3m_sde",
"dpmpp_3m_sde_gpu", "ddpm", "lcm", "ipndm", "ipndm_v", "deis",
"res_multistep", "res_multistep_cfg_pp", "res_multistep_ancestral",
"res_multistep_ancestral_cfg_pp", "gradient_estimation", "er_sde",
"ddim", "uni_pc", "uni_pc_bh2"
], {"default": "euler"}),
"scheduler_name": ([
"normal", "karras", "exponential", "sgm_uniform",
"simple", "ddim_uniform", "beta", "linear_quadratic", "kl_optimal"
], {"default": "karras"})
}
}
def bundle(self, sampler_name, scheduler_name):
return (sampler_name, scheduler_name)
NODE_CLASS_MAPPINGS = {
"FSL Sampler & Scheduler Config": FSL_SamplerSchedulerConfig,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FSL Sampler & Scheduler Config": "🛠️ Sampler & Scheduler Config (FSL)",
}
@@ -0,0 +1 @@
{"id":"c58118a1-db97-4ce0-9bbb-a8a8d39cbdd5","revision":0,"last_node_id":7,"last_link_id":5,"nodes":[{"id":2,"type":"Show any to JSON [Crystools]","pos":[1213.032470703125,-177.2519073486328],"size":[691.7337036132812,430.23626708984375],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"any_value","name":"any_value","shape":7,"type":"*","link":1}],"outputs":[{"localized_name":"string","name":"string","type":"STRING","links":[]}],"properties":{"cnr_id":"comfyui-crystools","ver":"1.27.3","Node name for S&R":"Show any to JSON [Crystools]","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.2.2"}},"widgets_values":[]},{"id":3,"type":"LocalImageGalleryNode","pos":[610.5911865234375,399.3844909667969],"size":[800,670],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[2]},{"localized_name":"paths","name":"paths","type":"LMM_ALL_PATHS","links":null},{"localized_name":"path","name":"path","type":"STRING","links":[3]},{"localized_name":"info","name":"info","type":"STRING","links":[]}],"properties":{"cnr_id":"comfyui_local_image_gallery","ver":"1.1.1","Node name for S&R":"LocalImageGalleryNode","gallery_unique_id":"gallery-nbrbpsxuy","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.2.2"},"selection":"[]"},"widgets_values":["gallery-nbrbpsxuy","[]",""]},{"id":5,"type":"Show any to JSON [Crystools]","pos":[1441.543701171875,535.852783203125],"size":[691.7337036132812,430.23626708984375],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"any_value","name":"any_value","shape":7,"type":"*","link":4}],"outputs":[{"localized_name":"string","name":"string","type":"STRING","links":[]}],"properties":{"cnr_id":"comfyui-crystools","ver":"1.27.3","Node name for S&R":"Show any to JSON [Crystools]","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.2.2"}},"widgets_values":[]},{"id":4,"type":"FSLMetadataFromImage","pos":[1437.5640869140625,402.2993469238281],"size":[270,82],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":2},{"localized_name":"path","name":"path","type":"STRING","widget":{"name":"path"},"link":3},{"localized_name":"strict","name":"strict","shape":7,"type":"BOOLEAN","widget":{"name":"strict"},"link":null}],"outputs":[{"localized_name":"metadata_raw","name":"metadata_raw","type":"METADATA_RAW","links":[4]}],"properties":{"Node name for S&R":"FSLMetadataFromImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.2.2"}},"widgets_values":["",false]},{"id":1,"type":"Load image with metadata [Crystools]","pos":[606.4518432617188,-214.61428833007812],"size":[570.5427856445312,497.06268310546875],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":null},{"localized_name":"mask","name":"mask","type":"MASK","links":null},{"localized_name":"prompt","name":"prompt","type":"JSON","links":[]},{"localized_name":"Metadata RAW","name":"Metadata RAW","type":"METADATA_RAW","links":[1]}],"properties":{"cnr_id":"comfyui-crystools","ver":"1.27.3","Node name for S&R":"Load image with metadata [Crystools]","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.2.2"}},"widgets_values":["fred_lefevre_closeup_of_a_large_metallic_figure_wears_intrica_287bf5be-7120-4403-9da3-cb0f14b6bd1f_0.png","image"]},{"id":6,"type":"Fast Groups Bypasser (rgthree)","pos":[1980.3636474609375,-217.84588623046875],"size":[236.74765014648438,82],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[{"name":"OPT_CONNECTION","type":"*","links":null}],"properties":{"matchColors":"","matchTitle":"","showNav":true,"showAllGraphs":true,"sort":"position","customSortAlphabet":"","toggleRestriction":"default","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.2.2"}}}],"links":[[1,1,3,2,0,"*"],[2,3,0,4,0,"IMAGE"],[3,3,2,4,1,"STRING"],[4,4,0,5,0,"*"]],"groups":[{"id":1,"title":"Single Image","bounding":[596.4518432617188,-288.21417236328125,1318.3138427734375,580.6626586914062],"color":"#3f789e","font_size":24,"flags":{}},{"id":2,"title":"Batch Loader","bounding":[600.5911865234375,325.78448486328125,1542.685791015625,753.5999755859375],"color":"#3f789e","font_size":24,"flags":{}}],"config":{},"extra":{"ue_links":[],"ds":{"scale":0.5989729227890566,"offset":[427.8159465408812,391.05747207847645]},"links_added_by_ue":[]},"version":0.4}
Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB