diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index adcf650..50b76df 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,142 +1,32 @@
-# ComfyUI-BawkNodes - AI Coding Instructions
+# ComfyUI-BawkNodes
-## Project Overview
-A ComfyUI custom node pack optimized for **FLUX** and **Z-Image** models. Each node combines multiple functions into clean, workflow-friendly tools. Integrates with ComfyUI's extension system via `NODE_CLASS_MAPPINGS` and `WEB_DIRECTORY`. Compatible with **Nodes 2.0** (Vue-based rendering).
+Custom node pack for ComfyUI focused only on FLUX models (FLUX.1 dev / Krea / Kontext, FLUX.2 dev / klein, GGUF builds of those). Goal: the fewest nodes needed for multi-LoRA FLUX workflows. FLUX has no negative prompt, so no node exposes one.
-## Architecture
+## Layout
-### Node Registration Flow
-```
-__init__.py โ nodes.py โ nodes/*.py (individual node classes)
-```
-- **Entry point**: `__init__.py` exports `NODE_CLASS_MAPPINGS`, `NODE_DISPLAY_NAME_MAPPINGS`, `WEB_DIRECTORY`
-- **Node definitions**: Each node is a class in `nodes/` with `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, `CATEGORY`
-- **Frontend**: `js/bawk_nodes.js` provides UI enhancements via ComfyUI's extension API
+- `__init__.py` exports `NODE_CLASS_MAPPINGS`, `NODE_DISPLAY_NAME_MAPPINGS`, `WEB_DIRECTORY`.
+- `nodes/` one file per node (V1 class schema: `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, `CATEGORY`, `DESCRIPTION`).
+- `nodes/lora_stack.py` also holds the shared LoRA helpers (`lora_slot_inputs`, `collect_lora_slots`, `apply_lora_stack`).
+- `js/bawk_nodes.js` node colors and prompt textarea sizing only.
+- `workflows/` example workflow.
-### Module Structure
-- `nodes/` - Individual node implementations (one class per file)
-- `modules/` - Shared utilities (`validation.py`, `cache_manager.py`, `model_utils.py`)
-- `js/` - Frontend UI enhancements and node-specific widgets
+## Nodes
-## Node Development Patterns
+| Node id | Purpose |
+| --- | --- |
+| `DiffusionModelLoader` | model + VAE + text encoder(s). 2 encoders = FLUX.1 (`CLIPType.FLUX`), 1 encoder = FLUX.2 (`CLIPType.FLUX2`). `.gguf` files go through ComfyUI-GGUF's node classes looked up in `nodes.NODE_CLASS_MAPPINGS` at run time. |
+| `BawkLoraStack` | 6 LoRA slots, chainable (`lora_stack` in/out), `enabled` master switch for set switching. |
+| `FluxWildcardEncode` | applies `lora_stack` + 6 inline slots, resolves `{a|b}` wildcards, encodes with `encode_from_tokens_scheduled`. |
+| `BawkSampler` | builds latent from `model.get_model_object("latent_format")` (16ch/8 for FLUX.1, 128ch/16 for FLUX.2), sets `guidance` in conditioning, optional `reference_latents` for Kontext / klein edit, `cfg` > 1 uses `CFGGuider` with zeroed negative (klein base only), FLUX.1 gets the resolution-dependent shift patch, FLUX.2 uses its own schedule when scheduler is `auto`. |
+| `FluxImageSaver` | `[model]-DD-MM-YYYY/` folders, PNG metadata written with `json.dumps` like core `SaveImage`, optional Discord webhook. |
+| `BawkImageLoader` | Load Image + resize / pad + size outputs. |
+| `BawkBatchProcessor` | one row of a CSV / JSON file -> prompt, seed, steps, guidance, resolution. |
-### Required Class Structure
-```python
-class MyNode:
- @classmethod
- def INPUT_TYPES(cls):
- return {
- "required": { ... },
- "optional": { ... },
- "hidden": { ... } # For PROMPT, EXTRA_PNGINFO
- }
-
- RETURN_TYPES = ("MODEL", "IMAGE", ...)
- RETURN_NAMES = ("model", "image", ...)
- FUNCTION = "main_method_name"
- CATEGORY = "BawkNodes/subcategory"
- DESCRIPTION = "๐ Emoji + tooltip description shown on hover"
- OUTPUT_NODE = True # Only for terminal nodes like savers
-
- def main_method_name(self, **inputs):
- # Implementation
- return (output1, output2, ...)
-```
+## Conventions
-### Input Type Conventions
-- Use `folder_paths.get_filename_list()` for model/file dropdowns
-- Add `"tooltip"` to input dicts for user guidance
-- Use `"forceInput": True` for inputs that must come from connections
-- **FLUX presets**: steps=30, guidance=3.5, max_shift=1.15, base_shift=0.5
-- **Z-Image presets**: steps=8, guidance=0.0 (distilled, no CFG needed)
-
-### Error Handling Pattern
-```python
-try:
- print(f"[NodeName] Status message")
- # Operation
-except Exception as e:
- print(f"[NodeName] โ Error: {str(e)}")
- return (safe_defaults,)
-```
-
-### Validation
-Use `VALIDATE_INPUTS` classmethod for pre-execution checks:
-```python
-@classmethod
-def VALIDATE_INPUTS(cls, **kwargs):
- errors = []
- if not valid: errors.append("message")
- return "; ".join(errors) if errors else True
-```
-
-## Model-Specific Parameters
-
-| Parameter | FLUX | Z-Image-Turbo |
-|-----------|------|---------------|
-| Steps | 30 (20 fast, 40 quality) | **8** |
-| Guidance | 3.5 (2.0-7.0 range) | **0.0** (no CFG) |
-| Resolution | 1024x1024 optimal | 1024x1024 optimal |
-| Shifts | max=1.15, base=0.5 | N/A |
-| CLIP type | `CLIPType.FLUX` | Built-in T5 |
-
-## Key Integrations
-
-### ComfyUI APIs Used
-- `comfy.sd` - Model/VAE/CLIP loading (`load_diffusion_model`, `load_clip`)
-- `comfy.samplers` - Sampler/scheduler lists
-- `comfy.model_management` - Memory management
-- `comfy.utils` - File loading (`load_torch_file`)
-- `folder_paths` - Standard directories (`get_filename_list`, `get_full_path_or_raise`)
-- `comfy_extras.nodes_custom_sampler` - Advanced sampling components
-- `comfy.supported_models` - Model detection (FLUX, ZImage/Lumina2, etc.)
-
-## Frontend Development (`js/`)
-
-### Extension Registration (Nodes 2.0 Compatible)
-```javascript
-import { app } from "/scripts/app.js";
-app.registerExtension({
- name: "bawk.nodes.feature",
- async beforeRegisterNodeDef(nodeType, nodeData, app) { ... },
- nodeCreated(node, app) { ... }, // Configure new instances
- getCustomWidgets() { return {} }, // Custom widget types
-});
-```
-
-### Key Hooks
-| Hook | Purpose |
-|------|---------|
-| `beforeRegisterNodeDef` | Modify node prototype/handlers |
-| `nodeCreated` | Configure new node instances |
-| `getCustomWidgets` | Add custom widget types |
-
-Check for Bawk nodes: compare `nodeData.name` against `isBawkNode()` list in `bawk_nodes.js`.
-
-## Testing & Workflows
-
-- Example workflow: `workflows/example_bawknodes_workflow.json`
-- Test in ComfyUI by placing in `custom_nodes/` directory
-- Startup messages print to console with colored formatting
-- Test with both Nodes 2.0 (Vue) and LiteGraph.js modes
-
-## Adding a New Node
-
-1. Create `nodes/my_new_node.py` with class following patterns above
-2. Add import to root `__init__.py`
-3. Add to `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS`
-4. Use emoji prefix in display name (๐, ๐ธ, ๐พ, โก, etc.)
-5. Add node name to `isBawkNode()` in `js/bawk_nodes.js`
-6. Add color scheme to `colorSchemes` object in `js/bawk_nodes.js`
-
-## Z-Image Integration
-
-Z-Image is **natively supported** in ComfyUI via `comfy.supported_models.ZImage` (extends Lumina2).
-Uses standard `comfy.sd.load_diffusion_model()` - works with BawkSampler like FLUX models.
-
-**Workflow**: ZImageModelLoader โ FluxWildcardEncode โ BawkSampler โ FluxImageSaver
-(Same as FLUX workflow, just swap the model loader)
-
-**Recommended settings**: steps=8, guidance=0.0 (distilled model, no CFG needed)
-
-Model files: Place in `ComfyUI/models/diffusion_models/` directory.
+- Only add inputs that matter for FLUX. Defaults follow the official Comfy templates (euler, 20 steps, guidance 3.5 / 2.5 Kontext / 4.0 FLUX.2, klein distilled 4 steps).
+- Raise exceptions on failure; do not return fake fallback tensors.
+- Use `logging`, not `print`. No startup banners.
+- Keep comments to non-obvious constraints only.
+- No `eval` / `exec`, no runtime `pip install` (registry rules).
+- When changing widget inputs, update `workflows/example_bawknodes_workflow.json` `widgets_values` to match the new order.
diff --git a/README.md b/README.md
index 0224853..a0af8bf 100644
--- a/README.md
+++ b/README.md
@@ -1,225 +1,87 @@
-
ComfyUI Bawk Nodes v2.3.0
+ComfyUI Bawk Nodes
-**The Ultimate FLUX & Z-Image Workflow Suite for ComfyUI**
+**Compact FLUX workflow nodes for ComfyUI**
-*Transform your AI image generation with powerful, easy-to-use nodes designed for FLUX and Z-Image models*
+FLUX.1 dev ยท Krea ยท Kontext ยท FLUX.2 dev ยท FLUX.2 klein 4B / 9B ยท GGUF
[](https://github.com/comfyanonymous/ComfyUI)
-[](https://blackforestlabs.ai/)
-[](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo)
-[]()
+[]()
----
+Five nodes cover a full multi-LoRA FLUX workflow:
-## **Why Choose Bawk Nodes?**
-
-### **All-in-One Workflow Solutions**
-- **No more node spaghetti!** Each Bawk Node combines multiple functions into clean, powerful tools
-- **FLUX & Z-Image optimized** - Nodes designed for both FLUX and Z-Image-Turbo models
-- **Professional results** with minimal setup time
-
-### **Perfect For:**
-- **Content Creators** - Instagram, TikTok, and social media workflows
-- **Artists & Designers** - Professional image generation and experimentation
-- **Hobbyists** - Easy-to-use tools without complicated setups
-- **Power Users** - Advanced features like batch processing and Discord integration
-
----
-
-## **What You Get**
-
-### **Bawk Image Loader**
-*Your gateway to img2img workflows*
-- **Click to browse** - No more typing file paths!
-- **Auto-rotation** - Handles phone photos perfectly
-- **Smart resizing** - Perfect dimensions every time
-- **Multiple formats** - JPG, PNG, WEBP, and more
-
-### **Bawk Wildcard Encoder**
-*Text prompts + LoRAs + AI enhancement made simple*
-- **6 LoRA slots** with smart recommendations
-- **Wildcard support** - Randomize your prompts
-- **Optional AI enhancement** - Claude, OpenAI & Grok integration for better prompts
-- **Helpful tooltips** - Know exactly what each setting does
-- **FLUX-optimized** text encoding
-
-### **Bawk Sampler**
-*The heart of your workflow*
-- **Text-to-Image & Image-to-Image** in one node
-- **Resolution presets** - Instagram, TikTok, 4K, and more
-- **Smart validation** - Helpful tips and warnings
-- **All-in-one** - Generates, samples, and decodes in one step
-
-### **Bawk Image Saver**
-*Save and share like a pro*
-- **Organized folders** by model and date
-- **Discord integration** - Auto-post your creations
-- **Batch support** - Upload multiple images at once
-- **Metadata saving** - Never lose your settings
-
-### **Bawk Batch Processor**
-*Automate your workflow*
-- **CSV/JSON support** - Process hundreds of prompts
-- **A/B testing** - Compare different settings easily
-- **Preview mode** - Check your files before processing
-- **Perfect for** content creation at scale
-
-### **Bawk ControlNet**
-*Guided generation made easy*
-- **Built-in preprocessing** - Canny, depth, pose, and more
-- **No external tools needed** - Everything works out of the box
-- **FLUX-optimized** control strength recommendations
-- **Multiple control types** in one node
-
-### **Bawk Model Loader**
-*Advanced model management*
-- **Smart caching** - Faster loading times
-- **Memory optimization** - Handle large models efficiently
-- **Validation** - Helpful warnings for compatibility issues
-- **FLUX-specific** optimizations
-
-### **Z-Image Loader**
-*Lightning-fast generation with Z-Image-Turbo*
-- **Native ComfyUI support** - Uses built-in Z-Image/Lumina2 architecture
-- **Only 8 steps** - State-of-the-art quality with minimal compute
-- **No CFG needed** - Guidance scale 0.0 (distilled model)
-- **Same workflow** - Drop-in replacement for FLUX loader
-
----
-
-## **Quick Start Guide**
-
-### **1. Basic Text-to-Image Workflow (FLUX)**
```
-Bawk Model Loader โ Bawk Wildcard Encoder โ Bawk Sampler โ Bawk Image Saver
- (30 steps, guidance 3.5)
+Bawk Model Loader โโฌโโบ Bawk Wildcard Encoder โโบ Bawk Sampler โโบ Bawk Image Saver
+Bawk LoRA Stack โโโโ
```
-### **2. Z-Image Turbo Workflow (8 steps!)**
-```
-Z-Image Loader โ Bawk Wildcard Encoder โ Bawk Sampler โ Bawk Image Saver
- (8 steps, guidance 0.0)
+FLUX models do not use negative prompts, so there is none anywhere in this pack.
+
+## Nodes
+
+### ๐ Bawk Model Loader
+Loads diffusion model, VAE and text encoder(s) in one node.
+
+| Model | `clip_name1` | `clip_name2` | `vae_name` |
+| --- | --- | --- | --- |
+| FLUX.1 dev / Krea / Kontext | `t5xxl_*.safetensors` | `clip_l.safetensors` | `ae.safetensors` |
+| FLUX.2 dev | `mistral_3_small_flux2_*.safetensors` | `none` | `flux2-vae.safetensors` |
+| FLUX.2 klein 4B | `qwen_3_4b.safetensors` | `none` | `flux2-vae.safetensors` |
+| FLUX.2 klein 9B | `qwen_3_8b_*.safetensors` | `none` | `flux2-vae.safetensors` |
+
+`.gguf` diffusion models and text encoders show up in the same dropdowns when [ComfyUI-GGUF](https://github.com/city96/ComfyUI-GGUF) is installed.
+
+### ๐ Bawk LoRA Stack
+Six LoRA slots per node. Chain stacks through the `lora_stack` input to combine sets, and flip `enabled` on a stack to switch a whole set on or off. Feed the result into the encoder's `lora_stack` input.
+
+### ๐ฒ Bawk Wildcard Encoder
+- Applies the incoming `lora_stack` plus six inline LoRA slots to MODEL and CLIP.
+- `{red|green|blue}` wildcards, seeded by `wildcard_seed` (nesting allowed).
+- Outputs the resolved prompt for the saver.
+
+### ๐ Bawk Sampler
+Latent, sampling and VAE decode in one node. Detects FLUX.1 vs FLUX.2 from the model and picks the right latent layout and schedule.
+
+| Input | Notes |
+| --- | --- |
+| `resolution` | Presets, `Match input / reference image`, or `Custom`. A linked `WIDTHxHEIGHT` string also works. |
+| `steps` / `guidance` | FLUX.1 dev: 20 / 3.5 ยท Kontext: 20 / 2.5 ยท FLUX.2 dev: 20 / 4.0 ยท klein distilled: 4 (guidance ignored) |
+| `scheduler` | `auto` = `simple` for FLUX.1, official FLUX.2 schedule for FLUX.2 |
+| `input_image` + `denoise` | img2img |
+| `reference_image` / `reference_image_2` | Kontext and FLUX.2 / klein image editing. Images are scaled to the model's preferred size and attached as reference latents. |
+| `cfg` | Leave at 1.0 for all distilled models. Klein **base** models use real CFG (~4-5) against an empty negative. |
+| `max_shift` / `base_shift` | FLUX.1 only |
+
+### ๐พ Bawk Image Saver
+Saves to `output/[model]-DD-MM-YYYY/`. PNG files embed the workflow (drag them back into ComfyUI); jpg / webp get a `.json` sidecar. Optional prompt `.txt` and Discord webhook upload.
+
+### ๐ธ Bawk Image Loader ยท ๐ Bawk Batch Processor
+Load Image with resize / pad-to-square and size outputs. Batch Processor reads one row of a CSV / JSON file (`prompt, seed, steps, guidance, resolution`) so `batch_index` on `increment` walks through a list of prompts.
+
+## Installation
+
+**ComfyUI Manager**: search for "Bawk Nodes" and install.
+
+**Manual**:
+```bash
+cd ComfyUI/custom_nodes
+git clone https://github.com/juddisjudd/ComfyUI-BawkNodes.git
```
-### **3. Image-to-Image Workflow**
-```
-Bawk Image Loader โ Bawk Sampler โ Bawk Image Saver
- โ (set denoise 0.6-0.8)
-```
+Requires a ComfyUI build with FLUX.2 support (late 2025 or newer). GGUF loading needs ComfyUI-GGUF.
-### **4. AI-Enhanced Prompt Workflow**
-```
-Bawk Wildcard Encoder โ Bawk Sampler โ Bawk Image Saver
- (LoRAs + wildcards (generate)
- + AI enhancement)
-```
+An example workflow is in [`workflows/example_bawknodes_workflow.json`](workflows/example_bawknodes_workflow.json).
-### **5. Batch Generation Workflow**
-```
-Bawk Batch Processor โ Bawk Wildcard Encoder โ Bawk Sampler โ Bawk Image Saver
-```
+## Changes in 3.0.0
----
+- FLUX.2 dev and klein support (latent layout, schedule, single text encoder), GGUF loading, reference images for Kontext / klein edit.
+- Fixed: guidance was never applied (wrong conditioning key), PNG metadata was not ComfyUI-loadable, image loader validation was disabled.
+- LoRA sets: new Bawk LoRA Stack (chainable, toggleable) replaces the broken dynamic stack + apply pair.
+- Removed Z-Image loader (use Bawk Model Loader), the placeholder ControlNet node, the unused AI prompt enhancement code and ~1,200 lines of dead utilities.
+- Widget layouts changed, so workflows saved with 2.x need their Bawk nodes re-added.
-## **Popular Use Cases**
+## License
-### **Social Media Content**
-- Use **Instagram presets** in Bawk Sampler (1080x1080, 1080x1920)
-- Set up **Discord webhooks** to auto-post to your content channels
-- **Batch process** multiple variations for A/B testing
-
-### **Art & Design**
-- Load reference images with **Bawk Image Loader**
-- Use **LoRA slots** for consistent character/style
-- Try **different denoise levels** for style transfer effects
-
-### **Workflow Automation**
-- Create **CSV files** with prompts and settings
-- Use **Bawk Batch Processor** for unattended generation
-- **Discord integration** notifies you when batches complete
-
-### **AI-Enhanced Prompting**
-- **Built into Wildcard Encoder** - Just enable AI enhancement and add your API key
-- Transform **simple prompts** into detailed, professional descriptions
-- **Multiple AI providers** - Claude (Anthropic), OpenAI (GPT-4/3.5), and Grok (xAI)
-- **8 enhancement styles** - Photography, cinematic, artistic, and more
-- **100% optional** - Works perfectly without AI enhancement enabled
-
----
-
-## **Pro Tips**
-
-### **LoRA Management**
-- **Slot 1**: Main character/style (strength 0.8-1.2)
-- **Slot 2**: Secondary effects (strength 0.6-1.0)
-- **Slot 3**: Clothing/objects (strength 0.4-0.8)
-- **Slots 4-6**: Fine details and adjustments (strength 0.2-0.6)
-
-### **Denoise Settings for Img2Img**
-- **0.3-0.5**: Subtle improvements, keep original structure
-- **0.6-0.7**: Style changes, good balance
-- **0.8-0.9**: Major transformations
-- **1.0**: Complete replacement (text2img mode)
-
-### **Batch Processing**
-Create a CSV file like this:
-```csv
-prompt,seed,steps,guidance,resolution
-"beautiful sunset",12345,30,3.5,"Instagram Square - 1080x1080 - 1.2MP"
-"city at night",67890,25,4.0,"Instagram Story - 1080x1920 - 2.1MP"
-```
-
-### **AI Enhancement (Built into Wildcard Encoder)**
-- **Enable in Wildcard Encoder**: Set ai_enhance_enabled to True and add your API key
-- **Start simple**: "cat on chair" โ Enhanced: "Elegant domestic cat with sleek fur sitting gracefully on vintage wooden chair, soft natural lighting, professional photography composition"
-- **Provider Comparison**: Claude (balanced, cost-effective), GPT-4 (high quality, expensive), GPT-3.5 (fast, budget), Grok (creative, experimental)
-- **API Key Security**: Keys are masked in logs and only used when enhancement is enabled
-- **Creativity Levels**: 0.3 for subtle enhancement, 0.7 for creative improvement, 1.0 for artistic interpretation
-
----
-
-## **What's New in v2.3.0**
-
-- **Z-Image-Turbo Support** - Native support for Z-Image models (8 steps, no CFG needed!)
-- **Z-Image Loader** - Drop-in replacement for FLUX workflow, same node chain
-- **Unified Sampler** - BawkSampler works with both FLUX and Z-Image models
-- **Example Workflows** - Included workflows for both FLUX and Z-Image
-
-### Previous Updates (v2.1.0)
-- **Integrated AI Enhancement** - Built directly into Bawk Wildcard Encoder (100% optional)
-- **Multi-Provider AI Support** - Claude (Anthropic), OpenAI (GPT-4/3.5), and Grok (xAI)
-- **8 Enhancement Styles** - Photography, cinematic, artistic, portrait, landscape, and more
-- **Simplified Workflow** - Single node handles wildcards, LoRAs, and AI enhancement
-
----
-
-## **Installation**
-
-### **Method 1: ComfyUI Manager (Recommended)**
-1. Open ComfyUI Manager
-2. Search for "Bawk Nodes"
-3. Click Install
-4. Restart ComfyUI
-
-### **Method 2: Manual Installation**
-1. Navigate to `ComfyUI/custom_nodes/`
-2. Clone this repository:
- ```bash
- git clone https://github.com/juddisjudd/ComfyUI-BawkNodes.git
- ```
-3. Restart ComfyUI
-
----
-
-## **Community & Support**
-
-- **Issues & Feature Requests**: [GitHub Issues](https://github.com/juddisjudd/ComfyUI-BawkNodes/issues)
-
----
-
-## **License**
-
-This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details.
+GPL-3.0 - see [LICENSE](LICENSE).
diff --git a/__init__.py b/__init__.py
index ec7b8b8..2c778ed 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,65 +1,37 @@
"""
-ComfyUI Bawk Nodes - Node Registration
-File: __init__.py
+ComfyUI Bawk Nodes - compact FLUX.1 / FLUX.2 workflow nodes.
"""
from .nodes import (
+ BawkBatchProcessor,
+ BawkImageLoader,
+ BawkLoraStack,
+ BawkSampler,
DiffusionModelLoader,
FluxImageSaver,
FluxWildcardEncode,
- BawkSampler,
- BawkBatchProcessor,
- BawkControlNet,
- BawkImageLoader
)
-from .nodes.flux_dynamic_lora import FluxDynamicLoraStack, FluxLoraStackApply
-from .nodes.zimage_loader import ZImageModelLoader
NODE_CLASS_MAPPINGS = {
"DiffusionModelLoader": DiffusionModelLoader,
- "FluxImageSaver": FluxImageSaver,
+ "BawkLoraStack": BawkLoraStack,
"FluxWildcardEncode": FluxWildcardEncode,
"BawkSampler": BawkSampler,
- "BawkBatchProcessor": BawkBatchProcessor,
- "BawkControlNet": BawkControlNet,
+ "FluxImageSaver": FluxImageSaver,
"BawkImageLoader": BawkImageLoader,
- "FluxDynamicLoraStack": FluxDynamicLoraStack,
- "FluxLoraStackApply": FluxLoraStackApply,
- "ZImageModelLoader": ZImageModelLoader,
+ "BawkBatchProcessor": BawkBatchProcessor,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DiffusionModelLoader": "๐ Bawk Model Loader",
- "FluxImageSaver": "๐พ Bawk Image Saver",
+ "BawkLoraStack": "๐ Bawk LoRA Stack",
"FluxWildcardEncode": "๐ฒ Bawk Wildcard Encoder",
"BawkSampler": "๐ Bawk Sampler",
- "BawkBatchProcessor": "๐ Bawk Batch Processor",
- "BawkControlNet": "๐๏ธ Bawk ControlNet",
+ "FluxImageSaver": "๐พ Bawk Image Saver",
"BawkImageLoader": "๐ธ Bawk Image Loader",
- "FluxDynamicLoraStack": "๐ Bawk LoRA Stack",
- "FluxLoraStackApply": "โก Bawk LoRA Apply",
- "ZImageModelLoader": "โก Z-Image Loader",
+ "BawkBatchProcessor": "๐ Bawk Batch Processor",
}
WEB_DIRECTORY = "./js"
-__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
-
-__version__ = "2.3.0"
-__author__ = "Bawk Nodes"
-__description__ = "FLUX & Z-Image optimized ComfyUI nodes for enhanced workflows"
-
-# Colored startup messages
-print(f"\033[1m\033[92m๐ ComfyUI Bawk Nodes v{__version__} loaded successfully!\033[0m")
-print(f"\033[1m\033[93m โญ FLUX.1 & Z-Image Optimized - Quality & Performance!\033[0m")
-print(f"\033[1m\033[96m Current nodes:\033[0m")
-print(f"\033[94m โข ๐ Bawk Model Loader - FLUX-optimized model loading\033[0m")
-print(f"\033[95m โข ๐ฒ Bawk Wildcard Encoder - Text encoding with 6 LoRA slots\033[0m")
-print(f"\033[92m โข ๐พ Bawk Image Saver - Organized saving with metadata & Discord webhooks\033[0m")
-print(f"\033[91m โข ๐ Bawk Sampler - All-in-one sampler for FLUX/Z-Image\033[0m")
-print(f"\033[93m โข ๐ Bawk Batch Processor - Process multiple prompts from CSV/JSON\033[0m")
-print(f"\033[96m โข ๐๏ธ Bawk ControlNet - FLUX-optimized ControlNet preprocessing\033[0m")
-print(f"\033[97m โข ๐ธ Bawk Image Loader - Enhanced image loading with preprocessing\033[0m")
-print(f"\033[33m โข ๐ Bawk LoRA Stack - Dynamic LoRA management (7+ LoRAs)\033[0m")
-print(f"\033[33m โข โก Bawk LoRA Apply - Apply LoRA stacks to model/CLIP\033[0m")
-print(f"\033[36m โข โก Z-Image Loader - Z-Image-Turbo model (8 steps, no CFG)\033[0m")
\ No newline at end of file
+__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
diff --git a/js/bawk_nodes.js b/js/bawk_nodes.js
index fd04a3f..8f37c6e 100644
--- a/js/bawk_nodes.js
+++ b/js/bawk_nodes.js
@@ -1,315 +1,32 @@
-/**
- * Bawk Nodes - UI Enhancements for ComfyUI
- * Improves textarea height, adds visual feedback, and enhances usability
- */
+import { app } from "../../scripts/app.js";
-import { app } from "/scripts/app.js";
-
-// Configuration
-const CONFIG = {
- // Default textarea rows for different nodes
- promptTextareaRows: 8, // Taller default for better visibility
- pathTextareaRows: 3,
-
- // Minimum heights for resizable textareas (in pixels)
- minHeight: 100,
-
- // Enable/disable features
- enableTallerTextareas: true,
- enableTooltipEnhancements: true,
- enableColorCoding: true,
+const NODE_COLORS = {
+ DiffusionModelLoader: ["#1a2332", "#4a90e2"],
+ BawkLoraStack: ["#32301a", "#f39c12"],
+ FluxWildcardEncode: ["#2a1a32", "#9b59b6"],
+ BawkSampler: ["#32261a", "#e67e22"],
+ FluxImageSaver: ["#1a3226", "#27ae60"],
+ BawkImageLoader: ["#1a3226", "#2ecc71"],
+ BawkBatchProcessor: ["#1a2a32", "#16a085"],
};
-/**
- * Main extension registration
- */
app.registerExtension({
- name: "bawk.nodes.ui.enhancements",
+ name: "bawk.nodes",
+ beforeRegisterNodeDef(nodeType, nodeData) {
+ const colors = NODE_COLORS[nodeData.name];
+ if (!colors) return;
- async setup() {
- console.log("[Bawk Nodes] UI enhancements loaded");
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ const result = onNodeCreated?.apply(this, arguments);
+ [this.bgcolor, this.color] = colors;
+ for (const widget of this.widgets ?? []) {
+ if (widget.name === "prompt" && widget.inputEl) {
+ widget.inputEl.style.minHeight = "120px";
+ widget.inputEl.placeholder = "Prompt... use {option1|option2} for wildcards";
+ }
+ }
+ return result;
+ };
},
-
- async beforeRegisterNodeDef(nodeType, nodeData, app) {
- // Only apply to Bawk nodes
- if (!nodeData.name || !isBawkNode(nodeData.name)) {
- return;
- }
-
- console.log(`[Bawk Nodes] Enhancing UI for: ${nodeData.name}`);
-
- // Hook into node creation
- const onNodeCreated = nodeType.prototype.onNodeCreated;
- nodeType.prototype.onNodeCreated = function() {
- const result = onNodeCreated?.apply(this, arguments);
-
- // Apply enhancements
- if (CONFIG.enableTallerTextareas) {
- enhanceTextareas(this, nodeData.name);
- }
-
- if (CONFIG.enableColorCoding) {
- applyNodeColorCoding(this, nodeData.name);
- }
-
- return result;
- };
-
- // Hook into widget value changes for dynamic behavior
- const onConfigure = nodeType.prototype.onConfigure;
- nodeType.prototype.onConfigure = function(config) {
- const result = onConfigure?.apply(this, arguments);
-
- // Reapply enhancements after configuration
- if (CONFIG.enableTallerTextareas) {
- enhanceTextareas(this, nodeData.name);
- }
-
- return result;
- };
- }
});
-
-/**
- * Check if a node is a Bawk node
- */
-function isBawkNode(nodeName) {
- const bawkNodes = [
- "FluxWildcardEncode",
- "BawkSampler",
- "BawkBatchProcessor",
- "FluxImageSaver",
- "BawkImageLoader",
- "BawkControlNet",
- "DiffusionModelLoader",
- "FluxDynamicLoraStack",
- "FluxLoraStackApply",
- "ZImageModelLoader"
- ];
- return bawkNodes.includes(nodeName);
-}
-
-/**
- * Enhance textarea widgets to be taller and more usable
- */
-function enhanceTextareas(node, nodeName) {
- if (!node.widgets) return;
-
- for (const widget of node.widgets) {
- // Only enhance STRING widgets with multiline
- if (widget.type !== "customtext" && widget.type !== "text") {
- continue;
- }
-
- // Determine appropriate height based on widget name and node type
- let rows = getAppropriateRows(widget.name, nodeName);
-
- // Apply to input element if it exists
- if (widget.inputEl) {
- widget.inputEl.rows = rows;
- widget.inputEl.style.minHeight = `${CONFIG.minHeight}px`;
-
- // Make textarea resizable
- widget.inputEl.style.resize = "vertical";
- widget.inputEl.style.overflow = "auto";
-
- // Add placeholder for prompt fields
- if (isPromptField(widget.name)) {
- widget.inputEl.placeholder = "Enter your prompt here... Use {option1|option2} for wildcards";
- }
-
- console.log(`[Bawk Nodes] Enhanced textarea: ${widget.name} with ${rows} rows`);
- }
-
- // Update widget's computed height
- if (widget.computeSize) {
- const originalComputeSize = widget.computeSize;
- widget.computeSize = function(width) {
- const size = originalComputeSize?.call(this, width) || [width, 20];
- // Ensure minimum height
- size[1] = Math.max(size[1], CONFIG.minHeight);
- return size;
- };
- }
- }
-
- // Request canvas redraw
- if (node.graph && node.graph.canvas) {
- node.setDirtyCanvas(true, true);
- }
-}
-
-/**
- * Determine appropriate number of rows for a textarea
- */
-function getAppropriateRows(widgetName, nodeName) {
- // Large textareas for prompts
- if (isPromptField(widgetName)) {
- return CONFIG.promptTextareaRows;
- }
-
- // Medium textareas for paths and URLs
- if (isPathField(widgetName)) {
- return CONFIG.pathTextareaRows;
- }
-
- // Default
- return 4;
-}
-
-/**
- * Check if a field is a prompt field
- */
-function isPromptField(fieldName) {
- const promptFields = ["prompt", "text", "positive", "negative"];
- return promptFields.some(f => fieldName.toLowerCase().includes(f));
-}
-
-/**
- * Check if a field is a path field
- */
-function isPathField(fieldName) {
- const pathFields = ["path", "file", "url", "webhook", "batch_file"];
- return pathFields.some(f => fieldName.toLowerCase().includes(f));
-}
-
-/**
- * Apply color coding to nodes for better visual organization
- */
-function applyNodeColorCoding(node, nodeName) {
- // Define color schemes for different node types
- const colorSchemes = {
- // Model loading nodes - Blue theme
- "DiffusionModelLoader": {
- bgcolor: "#1a2332",
- color: "#4a90e2"
- },
-
- // Text encoding nodes - Purple theme
- "FluxWildcardEncode": {
- bgcolor: "#2a1a32",
- color: "#9b59b6"
- },
-
- // Sampling nodes - Orange theme
- "BawkSampler": {
- bgcolor: "#32261a",
- color: "#e67e22"
- },
-
- // Image I/O nodes - Green theme
- "FluxImageSaver": {
- bgcolor: "#1a3226",
- color: "#27ae60"
- },
- "BawkImageLoader": {
- bgcolor: "#1a3226",
- color: "#2ecc71"
- },
-
- // Utility nodes - Teal theme
- "BawkBatchProcessor": {
- bgcolor: "#1a2a32",
- color: "#16a085"
- },
-
- // Control nodes - Red theme
- "BawkControlNet": {
- bgcolor: "#32201a",
- color: "#e74c3c"
- },
-
- // LoRA nodes - Gold theme
- "FluxDynamicLoraStack": {
- bgcolor: "#32301a",
- color: "#f39c12"
- },
- "FluxLoraStackApply": {
- bgcolor: "#32301a",
- color: "#f1c40f"
- },
-
- // Z-Image nodes - Cyan theme
- "ZImageModelLoader": {
- bgcolor: "#1a3232",
- color: "#00bcd4"
- }
- };
-
- const scheme = colorSchemes[nodeName];
- if (scheme) {
- node.bgcolor = scheme.bgcolor;
- node.color = scheme.color;
- }
-}
-
-/**
- * Additional utility: Auto-expand textarea on focus
- * This makes editing large prompts more comfortable
- */
-app.registerExtension({
- name: "bawk.nodes.textarea.autofocus",
-
- async beforeRegisterNodeDef(nodeType, nodeData, app) {
- if (!isBawkNode(nodeData.name)) return;
-
- const onNodeCreated = nodeType.prototype.onNodeCreated;
- nodeType.prototype.onNodeCreated = function() {
- const result = onNodeCreated?.apply(this, arguments);
-
- if (!this.widgets) return result;
-
- for (const widget of this.widgets) {
- if (widget.inputEl && isPromptField(widget.name)) {
- // Store original rows
- const normalRows = widget.inputEl.rows || 8;
- const expandedRows = normalRows * 2;
-
- // Expand on focus
- widget.inputEl.addEventListener('focus', function() {
- this.rows = expandedRows;
- this.style.zIndex = '1000'; // Bring to front
- });
-
- // Collapse on blur
- widget.inputEl.addEventListener('blur', function() {
- this.rows = normalRows;
- this.style.zIndex = '';
- });
- }
- }
-
- return result;
- };
- }
-});
-
-/**
- * Enhancement: Add helpful keyboard shortcuts
- */
-app.registerExtension({
- name: "bawk.nodes.keyboard.shortcuts",
-
- async setup() {
- // Add global keyboard shortcuts for Bawk nodes
- document.addEventListener('keydown', function(e) {
- // Ctrl+Shift+B: Find and focus on Bawk Wildcard Encoder prompt
- if (e.ctrlKey && e.shiftKey && e.key === 'B') {
- const nodes = app.graph?._nodes || [];
- const wildcardNode = nodes.find(n => n.type === "FluxWildcardEncode");
-
- if (wildcardNode) {
- const promptWidget = wildcardNode.widgets?.find(w => w.name === "prompt");
- if (promptWidget && promptWidget.inputEl) {
- promptWidget.inputEl.focus();
- e.preventDefault();
- }
- }
- }
- });
-
- console.log("[Bawk Nodes] Keyboard shortcuts enabled (Ctrl+Shift+B for prompt focus)");
- }
-});
-
-console.log("[Bawk Nodes] All UI extensions loaded successfully");
diff --git a/modules/__init__.py b/modules/__init__.py
deleted file mode 100644
index bce80fb..0000000
--- a/modules/__init__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""
-Modules package for ComfyUI Bawk Nodes
-File: modules/__init__.py
-"""
-
-from .model_utils import ModelUtils
-from .cache_manager import SmartCacheManager, CacheStats
-from .validation import ValidationMixin, ModelValidator
-
-__all__ = [
- "ModelUtils",
- "SmartCacheManager",
- "CacheStats",
- "ValidationMixin",
- "ModelValidator"
-]
-
-# Version info
-__version__ = "2.1.0"
-__author__ = "judd"
\ No newline at end of file
diff --git a/modules/cache_manager.py b/modules/cache_manager.py
deleted file mode 100644
index 2bbc707..0000000
--- a/modules/cache_manager.py
+++ /dev/null
@@ -1,400 +0,0 @@
-"""
-Smart Cache Manager for ComfyUI Diffusion Model Loader
-File: modules/cache_manager.py
-"""
-
-import time
-import threading
-import weakref
-from typing import Any, Optional, Dict, Tuple
-from collections import OrderedDict
-import comfy.model_management as mm
-
-
-class SmartCacheManager:
- """
- Advanced caching system with:
- - Memory-aware LRU eviction
- - Automatic cleanup
- - Thread-safe operations
- - Memory pressure detection
- """
-
- def __init__(self, max_memory_gb: float = 8.0, max_items: int = 10):
- """
- Initialize cache manager
-
- Args:
- max_memory_gb: Maximum memory to use for caching (GB)
- max_items: Maximum number of items to cache
- """
- self.max_memory = max_memory_gb * (1024**3) # Convert to bytes
- self.max_items = max_items
-
- # Thread-safe cache storage
- self._cache = OrderedDict()
- self._cache_sizes = {}
- self._access_times = {}
- self._current_memory = 0
- self._lock = threading.RLock()
-
- # Weak references for automatic cleanup
- self._weak_refs = {}
-
- # Statistics
- self._hits = 0
- self._misses = 0
- self._evictions = 0
-
- def get_cached(self, key: str) -> Optional[Any]:
- """
- Get item from cache if it exists
-
- Args:
- key: Cache key
-
- Returns:
- Cached item or None if not found
- """
- with self._lock:
- if key in self._cache:
- # Update access time and move to end (most recent)
- self._access_times[key] = time.time()
- self._cache.move_to_end(key)
- self._hits += 1
- return self._cache[key]
-
- self._misses += 1
- return None
-
- def cache_result(self, key: str, result: Any, estimated_size: int = 0) -> bool:
- """
- Cache a result with memory management
-
- Args:
- key: Cache key
- result: Result to cache
- estimated_size: Estimated memory size in bytes
-
- Returns:
- True if cached successfully, False otherwise
- """
- with self._lock:
- # Check if we need to evict items
- if not self._make_space(estimated_size):
- print(f"[CacheManager] Cannot cache item {key}: insufficient space")
- return False
-
- # Remove existing item if updating
- if key in self._cache:
- self._remove_item(key)
-
- # Add new item
- self._cache[key] = result
- self._cache_sizes[key] = estimated_size
- self._access_times[key] = time.time()
- self._current_memory += estimated_size
-
- # Set up weak reference for automatic cleanup
- try:
- self._weak_refs[key] = weakref.ref(result, lambda ref: self._cleanup_weak_ref(key))
- except TypeError:
- # Some objects can't have weak references
- pass
-
- return True
-
- def _make_space(self, needed_space: int) -> bool:
- """
- Make space in cache by evicting items if necessary
-
- Args:
- needed_space: Space needed in bytes
-
- Returns:
- True if space was made available, False otherwise
- """
- # Check if we have enough space
- if (self._current_memory + needed_space <= self.max_memory and
- len(self._cache) < self.max_items):
- return True
-
- # Sort items by access time (oldest first)
- items_by_age = sorted(
- self._access_times.items(),
- key=lambda x: x[1]
- )
-
- # Evict oldest items until we have enough space
- for key, _ in items_by_age:
- if key in self._cache:
- self._remove_item(key)
- self._evictions += 1
-
- # Check if we now have enough space
- if (self._current_memory + needed_space <= self.max_memory and
- len(self._cache) < self.max_items):
- return True
-
- # If we still don't have space after evicting everything, the item is too large
- return self._current_memory + needed_space <= self.max_memory
-
- def _remove_item(self, key: str):
- """Remove an item from all cache structures"""
- if key in self._cache:
- del self._cache[key]
-
- if key in self._cache_sizes:
- self._current_memory -= self._cache_sizes[key]
- del self._cache_sizes[key]
-
- if key in self._access_times:
- del self._access_times[key]
-
- if key in self._weak_refs:
- del self._weak_refs[key]
-
- def _cleanup_weak_ref(self, key: str):
- """Cleanup when a weak reference is garbage collected"""
- with self._lock:
- if key in self._cache:
- print(f"[CacheManager] Auto-cleaning up garbage collected item: {key}")
- self._remove_item(key)
-
- def invalidate(self, key: str) -> bool:
- """
- Manually invalidate a cache entry
-
- Args:
- key: Cache key to invalidate
-
- Returns:
- True if item was removed, False if not found
- """
- with self._lock:
- if key in self._cache:
- self._remove_item(key)
- return True
- return False
-
- def clear(self):
- """Clear all cached items"""
- with self._lock:
- self._cache.clear()
- self._cache_sizes.clear()
- self._access_times.clear()
- self._weak_refs.clear()
- self._current_memory = 0
- print("[CacheManager] Cache cleared")
-
- def get_memory_usage(self) -> Dict[str, Any]:
- """
- Get current memory usage statistics
-
- Returns:
- Dictionary with memory usage info
- """
- with self._lock:
- return {
- "current_memory_mb": self._current_memory / (1024**2),
- "max_memory_mb": self.max_memory / (1024**2),
- "memory_usage_percent": (self._current_memory / self.max_memory) * 100,
- "cached_items": len(self._cache),
- "max_items": self.max_items
- }
-
- def get_statistics(self) -> Dict[str, Any]:
- """
- Get cache performance statistics
-
- Returns:
- Dictionary with cache statistics
- """
- total_requests = self._hits + self._misses
- hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
-
- return {
- "hits": self._hits,
- "misses": self._misses,
- "hit_rate_percent": hit_rate,
- "evictions": self._evictions,
- "total_requests": total_requests
- }
-
- def cleanup_if_needed(self):
- """
- Perform cleanup if memory pressure is detected
- """
- with self._lock:
- # Check system memory pressure
- try:
- free_memory = mm.get_free_memory()
- total_memory = mm.get_total_memory()
-
- if free_memory / total_memory < 0.2: # Less than 20% free memory
- print("[CacheManager] Memory pressure detected, performing cleanup")
- self._evict_oldest_half()
- except Exception as e:
- print(f"[CacheManager] Error checking memory pressure: {e}")
-
- def _evict_oldest_half(self):
- """Evict the oldest half of cached items"""
- items_to_evict = len(self._cache) // 2
-
- if items_to_evict > 0:
- # Sort by access time and evict oldest
- oldest_items = sorted(
- self._access_times.items(),
- key=lambda x: x[1]
- )[:items_to_evict]
-
- for key, _ in oldest_items:
- if key in self._cache:
- self._remove_item(key)
- self._evictions += 1
-
- def get_cached_keys(self) -> list:
- """Get list of currently cached keys"""
- with self._lock:
- return list(self._cache.keys())
-
- def has_key(self, key: str) -> bool:
- """Check if key exists in cache"""
- with self._lock:
- return key in self._cache
-
- def get_item_info(self, key: str) -> Optional[Dict[str, Any]]:
- """
- Get information about a cached item
-
- Args:
- key: Cache key
-
- Returns:
- Dictionary with item info or None if not found
- """
- with self._lock:
- if key not in self._cache:
- return None
-
- return {
- "key": key,
- "size_mb": self._cache_sizes.get(key, 0) / (1024**2),
- "access_time": self._access_times.get(key, 0),
- "age_seconds": time.time() - self._access_times.get(key, 0)
- }
-
- def optimize_cache(self):
- """
- Optimize cache by removing unused items and defragmenting
- """
- with self._lock:
- print("[CacheManager] Optimizing cache...")
-
- # Remove items that have been garbage collected
- dead_refs = []
- for key, weak_ref in self._weak_refs.items():
- if weak_ref() is None:
- dead_refs.append(key)
-
- for key in dead_refs:
- self._remove_item(key)
-
- # Rebuild ordered dict to defragment
- if self._cache:
- items_by_access = sorted(
- self._access_times.items(),
- key=lambda x: x[1],
- reverse=True # Most recent first
- )
-
- new_cache = OrderedDict()
- for key, _ in items_by_access:
- if key in self._cache:
- new_cache[key] = self._cache[key]
-
- self._cache = new_cache
-
- print(f"[CacheManager] Cache optimized. Items: {len(self._cache)}, "
- f"Memory: {self._current_memory / (1024**2):.1f}MB")
-
- def set_memory_limit(self, max_memory_gb: float):
- """
- Update memory limit and evict items if necessary
-
- Args:
- max_memory_gb: New memory limit in GB
- """
- with self._lock:
- old_limit = self.max_memory
- self.max_memory = max_memory_gb * (1024**3)
-
- print(f"[CacheManager] Memory limit updated: "
- f"{old_limit / (1024**3):.1f}GB -> {max_memory_gb:.1f}GB")
-
- # Evict items if we're now over the limit
- if self._current_memory > self.max_memory:
- self._make_space(0) # This will evict until we're under the limit
-
-
-class CacheStats:
- """Helper class for cache statistics and monitoring"""
-
- def __init__(self, cache_manager: SmartCacheManager):
- self.cache_manager = cache_manager
-
- def print_summary(self):
- """Print a summary of cache performance"""
- stats = self.cache_manager.get_statistics()
- memory = self.cache_manager.get_memory_usage()
-
- print("\n=== Cache Performance Summary ===")
- print(f"Hit Rate: {stats['hit_rate_percent']:.1f}%")
- print(f"Total Requests: {stats['total_requests']}")
- print(f"Cache Hits: {stats['hits']}")
- print(f"Cache Misses: {stats['misses']}")
- print(f"Evictions: {stats['evictions']}")
- print(f"Memory Usage: {memory['current_memory_mb']:.1f}MB / {memory['max_memory_mb']:.1f}MB "
- f"({memory['memory_usage_percent']:.1f}%)")
- print(f"Cached Items: {memory['cached_items']} / {memory['max_items']}")
- print("================================\n")
-
- def get_detailed_report(self) -> Dict[str, Any]:
- """Get detailed cache report"""
- stats = self.cache_manager.get_statistics()
- memory = self.cache_manager.get_memory_usage()
-
- # Get per-item details
- items = []
- for key in self.cache_manager.get_cached_keys():
- item_info = self.cache_manager.get_item_info(key)
- if item_info:
- items.append(item_info)
-
- # Sort by access time (most recent first)
- items.sort(key=lambda x: x['access_time'], reverse=True)
-
- return {
- "performance": stats,
- "memory": memory,
- "items": items,
- "recommendations": self._generate_recommendations(stats, memory)
- }
-
- def _generate_recommendations(self, stats: Dict, memory: Dict) -> list:
- """Generate cache optimization recommendations"""
- recommendations = []
-
- if stats['hit_rate_percent'] < 50:
- recommendations.append("Low hit rate - consider increasing cache size or reviewing usage patterns")
-
- if memory['memory_usage_percent'] > 90:
- recommendations.append("High memory usage - consider reducing cache size or clearing old items")
-
- if stats['evictions'] > stats['hits']:
- recommendations.append("High eviction rate - cache size may be too small for workload")
-
- if memory['cached_items'] < memory['max_items'] // 2:
- recommendations.append("Low cache utilization - consider reducing cache size to free system memory")
-
- return recommendations
\ No newline at end of file
diff --git a/modules/model_utils.py b/modules/model_utils.py
deleted file mode 100644
index 94fdbaa..0000000
--- a/modules/model_utils.py
+++ /dev/null
@@ -1,316 +0,0 @@
-"""
-Model Utilities for ComfyUI Diffusion Model Loader
-File: modules/model_utils.py
-"""
-
-import os
-import json
-import torch
-from typing import Dict, Any, Optional, Union
-from pathlib import Path
-
-
-class ModelUtils:
- """
- Centralized utilities for model handling, metadata extraction,
- and memory estimation
- """
-
- def __init__(self):
- self._model_cache = {}
- self._memory_estimates = {}
-
- def estimate_model_memory(self, file_path: str) -> int:
- """
- Estimate memory usage for model file in bytes
-
- Args:
- file_path: Path to model file
-
- Returns:
- Estimated memory usage in bytes
- """
- if file_path in self._memory_estimates:
- return self._memory_estimates[file_path]
-
- try:
- file_size = os.path.getsize(file_path)
-
- # Different expansion factors based on file type
- if file_path.endswith('.safetensors'):
- # SafeTensors are more memory efficient
- expansion_factor = 1.6
- elif file_path.endswith('.ckpt') or file_path.endswith('.pth'):
- # Standard checkpoints expand more
- expansion_factor = 2.0
- else:
- # Default conservative estimate
- expansion_factor = 1.8
-
- # Additional overhead for FLUX models (they're typically larger)
- if self._is_likely_flux_model(file_path):
- expansion_factor *= 1.2
-
- estimated_memory = int(file_size * expansion_factor)
- self._memory_estimates[file_path] = estimated_memory
-
- return estimated_memory
-
- except (OSError, IOError) as e:
- print(f"Warning: Could not estimate memory for {file_path}: {e}")
- return 4 * 1024**3 # Default 4GB estimate
-
- def get_model_info(self, model_path: str) -> Dict[str, Any]:
- """
- Extract comprehensive model metadata and information
-
- Args:
- model_path: Path to model file
-
- Returns:
- Dictionary containing model information
- """
- cache_key = f"info_{model_path}"
- if cache_key in self._model_cache:
- return self._model_cache[cache_key]
-
- info = {
- "file_size": 0,
- "file_name": os.path.basename(model_path),
- "format": self._detect_format(model_path),
- "estimated_memory_mb": 0,
- "metadata": {},
- "model_type": "unknown",
- "architecture": "unknown"
- }
-
- try:
- # Basic file information
- info["file_size"] = os.path.getsize(model_path)
- info["estimated_memory_mb"] = self.estimate_model_memory(model_path) // (1024**2)
-
- # Extract metadata based on format
- if info["format"] == "safetensors":
- info["metadata"] = self._extract_safetensors_metadata(model_path)
- elif info["format"] == "checkpoint":
- info["metadata"] = self._extract_checkpoint_metadata(model_path)
-
- # Infer model type and architecture
- info["model_type"] = self._infer_model_type(model_path, info["metadata"])
- info["architecture"] = self._infer_architecture(model_path, info["metadata"])
-
- # Cache the result
- self._model_cache[cache_key] = info
-
- except Exception as e:
- print(f"Warning: Could not extract full info for {model_path}: {e}")
-
- return info
-
- def _detect_format(self, file_path: str) -> str:
- """Detect model file format"""
- extension = Path(file_path).suffix.lower()
-
- if extension == ".safetensors":
- return "safetensors"
- elif extension in [".ckpt", ".pth", ".pt"]:
- return "checkpoint"
- else:
- return "unknown"
-
- def _extract_safetensors_metadata(self, file_path: str) -> Dict[str, Any]:
- """Extract metadata from SafeTensors file"""
- try:
- from safetensors import safe_open
-
- with safe_open(file_path, framework="pt") as f:
- metadata = f.metadata() or {}
-
- # Convert to regular dict and handle JSON strings
- parsed_metadata = {}
- for key, value in metadata.items():
- try:
- # Try to parse JSON values
- parsed_metadata[key] = json.loads(value)
- except (json.JSONDecodeError, TypeError):
- # Keep as string if not JSON
- parsed_metadata[key] = value
-
- return parsed_metadata
-
- except ImportError:
- print("Warning: safetensors library not available for metadata extraction")
- return {}
- except Exception as e:
- print(f"Warning: Could not extract SafeTensors metadata: {e}")
- return {}
-
- def _extract_checkpoint_metadata(self, file_path: str) -> Dict[str, Any]:
- """Extract metadata from checkpoint file"""
- try:
- # Load only the metadata without the full model
- checkpoint = torch.load(file_path, map_location="cpu", weights_only=False)
-
- metadata = {}
-
- # Common metadata keys
- if isinstance(checkpoint, dict):
- for key in ["metadata", "meta", "info", "config"]:
- if key in checkpoint:
- metadata[key] = checkpoint[key]
-
- # Check for state dict structure info
- if "state_dict" in checkpoint:
- state_dict = checkpoint["state_dict"]
- metadata["num_parameters"] = sum(p.numel() for p in state_dict.values() if torch.is_tensor(p))
- metadata["state_dict_keys"] = list(state_dict.keys())[:10] # First 10 keys for inspection
-
- return metadata
-
- except Exception as e:
- print(f"Warning: Could not extract checkpoint metadata: {e}")
- return {}
-
- def _infer_model_type(self, file_path: str, metadata: Dict[str, Any]) -> str:
- """Infer model type from file name and metadata"""
- file_name = os.path.basename(file_path).lower()
-
- # Check file name patterns
- if "flux" in file_name:
- return "flux"
- elif "sdxl" in file_name:
- return "sdxl"
- elif "sd3" in file_name:
- return "sd3"
- elif any(term in file_name for term in ["sd_v1", "v1-5", "1.5"]):
- return "sd1.5"
- elif "sd_v2" in file_name or "v2" in file_name:
- return "sd2.x"
-
- # Check metadata for clues
- if metadata:
- metadata_str = str(metadata).lower()
- if "flux" in metadata_str:
- return "flux"
- elif "sdxl" in metadata_str:
- return "sdxl"
-
- return "unknown"
-
- def _infer_architecture(self, file_path: str, metadata: Dict[str, Any]) -> str:
- """Infer model architecture from metadata and file characteristics"""
- file_size_gb = os.path.getsize(file_path) / (1024**3)
-
- # Size-based inference
- if file_size_gb > 20:
- return "transformer_large" # Likely FLUX or similar
- elif file_size_gb > 6:
- return "unet_xl" # Likely SDXL
- elif file_size_gb > 3:
- return "unet_large" # Likely SD2.x
- elif file_size_gb > 1:
- return "unet_base" # Likely SD1.5
-
- return "unknown"
-
- def _is_likely_flux_model(self, file_path: str) -> bool:
- """Check if model is likely a FLUX model"""
- file_name = os.path.basename(file_path).lower()
- return "flux" in file_name or os.path.getsize(file_path) > 15 * 1024**3 # > 15GB
-
- def get_compatible_devices(self, model_path: str) -> list:
- """Get list of devices compatible with this model"""
- compatible_devices = ["cpu"]
-
- if torch.cuda.is_available():
- estimated_memory = self.estimate_model_memory(model_path)
-
- for i in range(torch.cuda.device_count()):
- device_memory = torch.cuda.get_device_properties(i).total_memory
- if estimated_memory < device_memory * 0.8: # Leave 20% headroom
- compatible_devices.append(f"cuda:{i}")
-
- return compatible_devices
-
- def optimize_loading_strategy(self, model_path: str, target_device: str) -> Dict[str, Any]:
- """
- Recommend optimal loading strategy based on model and hardware
-
- Returns:
- Dictionary with optimization recommendations
- """
- model_info = self.get_model_info(model_path)
- estimated_memory = model_info["estimated_memory_mb"] * 1024**2 # Convert to bytes
-
- strategy = {
- "recommended_dtype": "fp16",
- "use_cpu_offload": False,
- "chunk_loading": False,
- "memory_format": "channels_last"
- }
-
- if target_device.startswith("cuda"):
- try:
- device_id = int(target_device.split(":")[1]) if ":" in target_device else 0
- available_memory = torch.cuda.get_device_properties(device_id).total_memory
-
- # Adjust strategy based on memory pressure
- memory_ratio = estimated_memory / available_memory
-
- if memory_ratio > 0.9:
- strategy["recommended_dtype"] = "fp8_e4m3fn"
- strategy["use_cpu_offload"] = True
- strategy["chunk_loading"] = True
- elif memory_ratio > 0.7:
- strategy["recommended_dtype"] = "fp16"
- strategy["use_cpu_offload"] = True
- elif memory_ratio > 0.5:
- strategy["recommended_dtype"] = "fp16"
- else:
- strategy["recommended_dtype"] = "fp32"
-
- except Exception:
- pass # Fallback to default strategy
-
- return strategy
-
- @staticmethod
- def debug_directory_contents():
- """Debug function to print directory contents"""
- directories = [
- ("checkpoints", "checkpoints"),
- ("diffusion_models", "diffusion_models"),
- ("vae", "vae"),
- ("clip", "clip"),
- ("text_encoders", "text_encoders")
- ]
-
- print("\n=== ComfyUI Directory Contents Debug ===")
- for dir_name, folder_key in directories:
- try:
- files = folder_paths.get_filename_list(folder_key) or []
- print(f"{dir_name}: {len(files)} files")
- if files:
- for i, file in enumerate(files[:5]): # Show first 5 files
- print(f" - {file}")
- if len(files) > 5:
- print(f" ... and {len(files) - 5} more")
- else:
- print(f" (empty or not found)")
- # Try to get the actual path to see if directory exists
- try:
- folder_path = folder_paths.get_folder_paths(folder_key)
- if folder_path:
- print(f" Path: {folder_path[0] if isinstance(folder_path, list) else folder_path}")
- else:
- print(f" Path: Not configured")
- except:
- print(f" Path: Could not determine")
- except Exception as e:
- print(f"{dir_name}: Error accessing - {str(e)}")
- print("=========================================\n")
-
- def clear_cache(self):
- """Clear internal caches"""
- self._model_cache.clear()
- self._memory_estimates.clear()
\ No newline at end of file
diff --git a/modules/validation.py b/modules/validation.py
deleted file mode 100644
index 16f9e47..0000000
--- a/modules/validation.py
+++ /dev/null
@@ -1,424 +0,0 @@
-"""
-Validation utilities for ComfyUI Diffusion Model Loader
-File: modules/validation.py
-"""
-
-import os
-import torch
-from typing import List, Dict, Any, Union, Optional
-import folder_paths
-
-
-class ValidationMixin:
- """
- Mixin class providing comprehensive validation capabilities
- for model loading operations
- """
-
- @staticmethod
- def validate_dtype_compatibility(dtype_str: str, device: torch.device) -> bool:
- """
- Validate data type compatibility with target device
-
- Args:
- dtype_str: String representation of data type
- device: Target PyTorch device
-
- Returns:
- True if compatible, False otherwise
- """
- # Valid dtype mappings
- valid_dtypes = {
- "fp32": torch.float32,
- "fp16": torch.float16,
- "bf16": torch.bfloat16,
- "fp8_e4m3fn": getattr(torch, "float8_e4m3fn", None),
- "fp8_e5m2": getattr(torch, "float8_e5m2", None),
- "default": None # Auto-selection
- }
-
- if dtype_str not in valid_dtypes:
- return False
-
- target_dtype = valid_dtypes[dtype_str]
- if target_dtype is None: # Auto-selection or unsupported type
- return True
-
- # Check device-specific compatibility
- if device.type == "cpu":
- # CPU supports most types except FP8
- return target_dtype not in [
- getattr(torch, "float8_e4m3fn", None),
- getattr(torch, "float8_e5m2", None)
- ]
-
- elif device.type == "cuda":
- # Check CUDA capabilities
- try:
- # FP8 requires modern GPU architectures
- if target_dtype in [getattr(torch, "float8_e4m3fn", None),
- getattr(torch, "float8_e5m2", None)]:
- compute_capability = torch.cuda.get_device_capability(device)
- return compute_capability >= (8, 9) # Ada Lovelace or newer
-
- # bfloat16 support check
- if target_dtype == torch.bfloat16:
- return torch.cuda.is_bf16_supported()
-
- # fp16 is generally supported on modern CUDA devices
- if target_dtype == torch.float16:
- return True
-
- # fp32 is always supported
- if target_dtype == torch.float32:
- return True
-
- except Exception:
- # If we can't determine capability, assume fp16/fp32 work
- return target_dtype in [torch.float16, torch.float32]
-
- # For other device types, be conservative
- return target_dtype in [torch.float16, torch.float32]
-
- @staticmethod
- def validate_file_exists(file_path: str, file_type: str = "model") -> tuple[bool, str]:
- """
- Validate that a file exists and is accessible
-
- Args:
- file_path: Path to file
- file_type: Type of file for error messages
-
- Returns:
- Tuple of (is_valid, error_message)
- """
- if not file_path:
- return False, f"{file_type} path is empty"
-
- if not os.path.exists(file_path):
- return False, f"{file_type} file does not exist: {file_path}"
-
- if not os.path.isfile(file_path):
- return False, f"{file_type} path is not a file: {file_path}"
-
- # Check file size (basic corruption check)
- try:
- file_size = os.path.getsize(file_path)
- if file_size < 1024: # Less than 1KB
- return False, f"{file_type} file appears to be empty or corrupted"
- except OSError as e:
- return False, f"Cannot access {file_type} file: {str(e)}"
-
- # Check read permissions
- if not os.access(file_path, os.R_OK):
- return False, f"No read permission for {file_type} file: {file_path}"
-
- return True, ""
-
- @staticmethod
- def validate_model_format(file_path: str) -> tuple[bool, str]:
- """
- Validate model file format
-
- Args:
- file_path: Path to model file
-
- Returns:
- Tuple of (is_valid, error_message)
- """
- if not file_path:
- return False, "No file path provided"
-
- valid_extensions = {
- '.safetensors': 'SafeTensors',
- '.ckpt': 'Checkpoint',
- '.pth': 'PyTorch',
- '.pt': 'PyTorch',
- '.bin': 'Binary'
- }
-
- file_extension = os.path.splitext(file_path)[1].lower()
-
- if file_extension not in valid_extensions:
- valid_exts = ', '.join(valid_extensions.keys())
- return False, f"Unsupported file format '{file_extension}'. Supported: {valid_exts}"
-
- return True, f"Valid {valid_extensions[file_extension]} format"
-
- @staticmethod
- def validate_device_availability(device_str: str) -> tuple[bool, str]:
- """
- Validate device availability
-
- Args:
- device_str: Device string (e.g., 'cuda:0', 'cpu', 'auto')
-
- Returns:
- Tuple of (is_valid, error_message)
- """
- if device_str == "auto":
- return True, "Auto device selection"
-
- if device_str == "cpu":
- return True, "CPU device available"
-
- if device_str.startswith("cuda"):
- if not torch.cuda.is_available():
- return False, "CUDA not available on this system"
-
- # Extract device ID if specified
- if ":" in device_str:
- try:
- device_id = int(device_str.split(":")[1])
- if device_id >= torch.cuda.device_count():
- return False, f"CUDA device {device_id} not available (only {torch.cuda.device_count()} devices found)"
-
- # Check if device is accessible
- try:
- torch.cuda.get_device_properties(device_id)
- return True, f"CUDA device {device_id} available"
- except Exception as e:
- return False, f"Cannot access CUDA device {device_id}: {str(e)}"
-
- except ValueError:
- return False, f"Invalid CUDA device ID in '{device_str}'"
- else:
- return True, "CUDA device available"
-
- return False, f"Unknown device type: {device_str}"
-
- @staticmethod
- def validate_memory_requirements(model_path: str, device_str: str) -> tuple[bool, str]:
- """
- Validate memory requirements for model loading
-
- Args:
- model_path: Path to model file
- device_str: Target device string
-
- Returns:
- Tuple of (is_valid, error_message)
- """
- try:
- # Estimate model memory requirements
- file_size = os.path.getsize(model_path)
- estimated_memory = file_size * 2 # Conservative estimate
-
- if device_str == "cpu" or device_str == "auto":
- # For CPU/auto, we can't easily check system RAM, so assume it's OK
- return True, "Memory requirements assumed OK for CPU/auto"
-
- if device_str.startswith("cuda"):
- if not torch.cuda.is_available():
- return False, "CUDA not available"
-
- device_id = 0
- if ":" in device_str:
- try:
- device_id = int(device_str.split(":")[1])
- except ValueError:
- return False, f"Invalid device string: {device_str}"
-
- if device_id >= torch.cuda.device_count():
- return False, f"Device {device_id} not available"
-
- # Check VRAM
- try:
- device_props = torch.cuda.get_device_properties(device_id)
- total_memory = device_props.total_memory
-
- # Leave some headroom (20%)
- available_memory = total_memory * 0.8
-
- if estimated_memory > available_memory:
- required_gb = estimated_memory / (1024**3)
- available_gb = available_memory / (1024**3)
- return False, (f"Insufficient VRAM. Required: ~{required_gb:.1f}GB, "
- f"Available: ~{available_gb:.1f}GB")
-
- return True, "Sufficient VRAM available"
-
- except Exception as e:
- return False, f"Error checking VRAM: {str(e)}"
-
- return True, "Memory requirements check passed"
-
- except Exception as e:
- return False, f"Error validating memory requirements: {str(e)}"
-
-
-class ModelValidator:
- """
- Comprehensive model validation class with detailed checking
- """
-
- def __init__(self):
- self.validation_cache = {}
-
- def validate_model_inputs(self, **kwargs) -> Dict[str, Any]:
- """
- Comprehensive validation of all model inputs
-
- Returns:
- Dictionary with validation results
- """
- results = {
- "is_valid": True,
- "errors": [],
- "warnings": [],
- "info": []
- }
-
- # Validate model name
- model_name = kwargs.get("model_name")
- if model_name:
- self._validate_model_file(model_name, results)
- else:
- results["errors"].append("Model name is required")
- results["is_valid"] = False
-
- # Validate weight dtype
- weight_dtype = kwargs.get("weight_dtype", "default")
- device = kwargs.get("device", "auto")
- self._validate_dtype_and_device(weight_dtype, device, results)
-
- # Validate VAE
- vae_name = kwargs.get("vae_name", "baked VAE")
- if vae_name != "baked VAE":
- self._validate_vae_file(vae_name, results)
-
- # Validate CLIP models
- clip_name1 = kwargs.get("clip_name1", "none")
- clip_name2 = kwargs.get("clip_name2", "none")
- self._validate_clip_models(clip_name1, clip_name2, results)
-
- return results
-
- def _validate_model_file(self, model_name: str, results: Dict):
- """Validate main model file"""
- model_path = folder_paths.get_full_path("checkpoints", model_name)
-
- if model_path is None:
- results["errors"].append(f"Checkpoint '{model_name}' not found in checkpoints directory")
- results["is_valid"] = False
- return
-
- # Check file existence and format
- file_valid, file_msg = ValidationMixin.validate_file_exists(model_path, "Model")
- if not file_valid:
- results["errors"].append(file_msg)
- results["is_valid"] = False
- return
-
- format_valid, format_msg = ValidationMixin.validate_model_format(model_path)
- if not format_valid:
- results["errors"].append(format_msg)
- results["is_valid"] = False
- else:
- results["info"].append(format_msg)
-
- # Check file size and provide info
- try:
- file_size_gb = os.path.getsize(model_path) / (1024**3)
- results["info"].append(f"Model size: {file_size_gb:.1f}GB")
-
- if file_size_gb > 20:
- results["warnings"].append("Very large model - may require significant VRAM")
- elif file_size_gb < 1:
- results["warnings"].append("Small model file - ensure this is not a corrupted download")
- except Exception as e:
- results["warnings"].append(f"Could not determine model size: {str(e)}")
-
- def _validate_dtype_and_device(self, weight_dtype: str, device: str, results: Dict):
- """Validate data type and device compatibility"""
- # Validate device
- device_valid, device_msg = ValidationMixin.validate_device_availability(device)
- if not device_valid:
- results["errors"].append(device_msg)
- results["is_valid"] = False
- else:
- results["info"].append(device_msg)
-
- # Validate dtype compatibility
- if device_valid and weight_dtype != "default":
- try:
- torch_device = torch.device(device if device != "auto" else "cuda" if torch.cuda.is_available() else "cpu")
- dtype_valid = ValidationMixin.validate_dtype_compatibility(weight_dtype, torch_device)
-
- if not dtype_valid:
- results["warnings"].append(f"Data type '{weight_dtype}' may not be supported on {device}, will fall back to auto-selection")
- else:
- results["info"].append(f"Data type '{weight_dtype}' compatible with {device}")
- except Exception as e:
- results["warnings"].append(f"Could not validate dtype compatibility: {str(e)}")
-
- def _validate_vae_file(self, vae_name: str, results: Dict):
- """Validate VAE file"""
- vae_path = folder_paths.get_full_path("vae", vae_name)
-
- if vae_path is None:
- results["errors"].append(f"VAE '{vae_name}' not found in vae directory")
- results["is_valid"] = False
- return
-
- file_valid, file_msg = ValidationMixin.validate_file_exists(vae_path, "VAE")
- if not file_valid:
- results["errors"].append(file_msg)
- results["is_valid"] = False
- else:
- results["info"].append(f"VAE file validated: {vae_name}")
-
- def _validate_clip_models(self, clip_name1: str, clip_name2: str, results: Dict):
- """Validate CLIP model files"""
- clip_models = []
-
- if clip_name1 != "none":
- clip_models.append((clip_name1, "CLIP1"))
-
- if clip_name2 != "none":
- clip_models.append((clip_name2, "CLIP2"))
-
- for clip_name, clip_label in clip_models:
- clip_path = folder_paths.get_full_path("clip", clip_name)
-
- if clip_path is None:
- results["errors"].append(f"{clip_label} '{clip_name}' not found in clip directory")
- results["is_valid"] = False
- continue
-
- file_valid, file_msg = ValidationMixin.validate_file_exists(clip_path, clip_label)
- if not file_valid:
- results["errors"].append(file_msg)
- results["is_valid"] = False
- else:
- results["info"].append(f"{clip_label} file validated: {clip_name}")
-
- # Check for dual CLIP configuration consistency
- if clip_name1 != "none" and clip_name2 == "none":
- results["warnings"].append("Only one CLIP model specified - dual text encoder models typically require both")
- elif clip_name1 == "none" and clip_name2 != "none":
- results["warnings"].append("Only second CLIP model specified - consider specifying the first as well")
-
- def get_validation_summary(self, validation_results: Dict[str, Any]) -> str:
- """Generate human-readable validation summary"""
- if validation_results["is_valid"]:
- summary = "โ All validations passed"
- else:
- summary = "โ Validation failed"
-
- if validation_results["errors"]:
- summary += f"\nErrors ({len(validation_results['errors'])}):"
- for error in validation_results["errors"]:
- summary += f"\n - {error}"
-
- if validation_results["warnings"]:
- summary += f"\nWarnings ({len(validation_results['warnings'])}):"
- for warning in validation_results["warnings"]:
- summary += f"\n - {warning}"
-
- if validation_results["info"]:
- summary += f"\nInfo ({len(validation_results['info'])}):"
- for info in validation_results["info"]:
- summary += f"\n - {info}"
-
- return summary
\ No newline at end of file
diff --git a/nodes.py b/nodes.py
deleted file mode 100644
index 432e3e0..0000000
--- a/nodes.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""
-ComfyUI Bawk Nodes - Main nodes file (clean imports only)
-File: nodes.py
-"""
-
-from .nodes.diffusion_model_loader import DiffusionModelLoader
-from .nodes.flux_wildcard_encode import FluxWildcardEncode
-from .nodes.flux_image_saver import FluxImageSaver
-from .nodes.bawk_sampler import BawkSampler
-from .nodes.bawk_batch_processor import BawkBatchProcessor
-from .nodes.bawk_controlnet import BawkControlNet
-from .nodes.bawk_image_loader import BawkImageLoader
-
-__all__ = [
- "DiffusionModelLoader",
- "FluxWildcardEncode",
- "FluxImageSaver",
- "BawkSampler",
- "BawkBatchProcessor",
- "BawkControlNet",
- "BawkImageLoader"
-]
\ No newline at end of file
diff --git a/nodes/__init__.py b/nodes/__init__.py
index 2902897..9a7c891 100644
--- a/nodes/__init__.py
+++ b/nodes/__init__.py
@@ -1,22 +1,17 @@
-"""
-Nodes package initialization
-File: nodes/__init__.py
-"""
-
from .diffusion_model_loader import DiffusionModelLoader
+from .lora_stack import BawkLoraStack
from .flux_wildcard_encode import FluxWildcardEncode
-from .flux_image_saver import FluxImageSaver
from .bawk_sampler import BawkSampler
-from .bawk_batch_processor import BawkBatchProcessor
-from .bawk_controlnet import BawkControlNet
+from .flux_image_saver import FluxImageSaver
from .bawk_image_loader import BawkImageLoader
+from .bawk_batch_processor import BawkBatchProcessor
__all__ = [
"DiffusionModelLoader",
+ "BawkLoraStack",
"FluxWildcardEncode",
- "FluxImageSaver",
"BawkSampler",
+ "FluxImageSaver",
+ "BawkImageLoader",
"BawkBatchProcessor",
- "BawkControlNet",
- "BawkImageLoader"
-]
\ No newline at end of file
+]
diff --git a/nodes/bawk_batch_processor.py b/nodes/bawk_batch_processor.py
index 45025fc..e14f2cf 100644
--- a/nodes/bawk_batch_processor.py
+++ b/nodes/bawk_batch_processor.py
@@ -1,33 +1,51 @@
"""
-BawkBatchProcessor - Process multiple prompts from CSV/JSON files
-File: nodes/bawk_batch_processor.py
+BawkBatchProcessor - pull prompt + settings for one row of a CSV / JSON batch file.
"""
-import os
import csv
import json
-from typing import List, Dict, Any, Tuple
+import os
-# ComfyUI imports with fallback
-try:
- import folder_paths
-except ImportError:
- folder_paths = None
+PROMPT_KEYS = ("prompt", "text", "description")
+SEED_KEYS = ("seed", "noise_seed")
+STEPS_KEYS = ("steps", "sampling_steps")
+GUIDANCE_KEYS = ("guidance", "guidance_scale", "cfg")
+RESOLUTION_KEYS = ("resolution", "size", "preset")
-# Optional pandas import for advanced CSV handling
-try:
- import pandas as pd
- HAS_PANDAS = True
-except ImportError:
- HAS_PANDAS = False
+
+def _first(item, keys, cast, default):
+ for key in keys:
+ if key in item and item[key] not in (None, ""):
+ try:
+ return cast(item[key])
+ except (TypeError, ValueError):
+ continue
+ return default
+
+
+def _load_rows(path, file_format):
+ if file_format == "Auto-detect":
+ file_format = "JSON" if path.lower().endswith(".json") else "CSV"
+ with open(path, "r", encoding="utf-8-sig") as f:
+ if file_format == "JSON":
+ data = json.load(f)
+ if isinstance(data, dict):
+ for key in ("items", "prompts", "batch", "data"):
+ if isinstance(data.get(key), list):
+ data = data[key]
+ break
+ else:
+ data = [data]
+ return [row for row in data if isinstance(row, dict)]
+ rows = []
+ for row in csv.DictReader(f):
+ clean = {k.strip(): v.strip() for k, v in row.items() if k and isinstance(v, str)}
+ if clean:
+ rows.append(clean)
+ return rows
class BawkBatchProcessor:
- """
- Process multiple prompts and settings from CSV or JSON files.
- Supports batch generation with different parameters per image.
- """
-
@classmethod
def INPUT_TYPES(cls):
return {
@@ -35,266 +53,54 @@ class BawkBatchProcessor:
"batch_file": ("STRING", {
"default": "",
"dynamicPrompts": False,
- "tooltip": "Path to CSV or JSON file with batch settings. CSV columns: prompt, seed, steps, guidance, etc."
- }),
- "file_format": (["Auto-detect", "CSV", "JSON"], {
- "default": "Auto-detect",
- "tooltip": "File format - Auto-detect will guess from extension"
+ "tooltip": "Path to a CSV or JSON file. Columns / keys: prompt, seed, steps, guidance, resolution",
}),
+ "file_format": (["Auto-detect", "CSV", "JSON"], {"default": "Auto-detect"}),
"batch_index": ("INT", {
- "default": 0, "min": 0, "max": 999999,
- "tooltip": "Which row/item to process from the batch file (0-based index)"
- }),
- "preview_only": ("BOOLEAN", {
- "default": False,
- "tooltip": "Show file contents without processing - useful for checking batch files"
+ "default": 0, "min": 0, "max": 999999, "control_after_generate": True,
+ "tooltip": "Row to use (0-based). Set control to 'increment' to walk through the file",
}),
+ "preview_only": ("BOOLEAN", {"default": False, "tooltip": "Return a text preview of the file instead of a row"}),
},
"optional": {
- # Override defaults
- "default_resolution": ("STRING", {
- "default": "FHD 16:9 - 1920x1080 - 2.1MP",
- "dynamicPrompts": False,
- "tooltip": "Default resolution for items that don't specify one"
- }),
- "default_steps": ("INT", {
- "default": 30, "min": 1, "max": 100,
- "tooltip": "Default steps for items that don't specify them"
- }),
- "default_guidance": ("FLOAT", {
- "default": 3.5, "min": 0.0, "max": 20.0, "step": 0.1,
- "tooltip": "Default guidance for items that don't specify it"
- }),
- }
+ "default_resolution": ("STRING", {"default": "1024x1024", "dynamicPrompts": False}),
+ "default_steps": ("INT", {"default": 20, "min": 1, "max": 100}),
+ "default_guidance": ("FLOAT", {"default": 3.5, "min": 0.0, "max": 20.0, "step": 0.1}),
+ },
}
RETURN_TYPES = ("STRING", "INT", "INT", "FLOAT", "STRING", "STRING")
RETURN_NAMES = ("prompt", "seed", "steps", "guidance", "resolution", "batch_info")
FUNCTION = "process_batch"
CATEGORY = "BawkNodes/batch"
- DESCRIPTION = "๐ Batch processor - load prompts and settings from CSV/JSON files for automated generation workflows"
+ DESCRIPTION = "Read one row of a CSV / JSON batch file. Link 'resolution' into Bawk Sampler's resolution input."
- def process_batch(
- self,
- batch_file,
- file_format="Auto-detect",
- batch_index=0,
- preview_only=False,
- default_resolution="FHD 16:9 - 1920x1080 - 2.1MP",
- default_steps=30,
- default_guidance=3.5
- ):
- """
- Process a batch file and return settings for the specified index
- """
- try:
- print(f"[BawkBatchProcessor] Processing batch file: {batch_file}")
+ def process_batch(self, batch_file, file_format="Auto-detect", batch_index=0, preview_only=False,
+ default_resolution="1024x1024", default_steps=20, default_guidance=3.5):
+ if not batch_file or not os.path.isfile(batch_file):
+ raise FileNotFoundError(f"Batch file not found: {batch_file}")
+ rows = _load_rows(batch_file, file_format)
+ if not rows:
+ raise ValueError(f"No rows found in {batch_file}")
- if not batch_file or not os.path.exists(batch_file):
- raise ValueError(f"Batch file not found: {batch_file}")
+ if preview_only:
+ lines = [f"{len(rows)} items in {os.path.basename(batch_file)}", ""]
+ for i, row in enumerate(rows[:5]):
+ lines.append(f"[{i}] " + ", ".join(f"{k}={str(v)[:80]}" for k, v in row.items()))
+ if len(rows) > 5:
+ lines.append(f"... and {len(rows) - 5} more")
+ return ("\n".join(lines), 0, default_steps, default_guidance, default_resolution, "preview")
- # Load batch data
- batch_data = self._load_batch_file(batch_file, file_format)
+ if batch_index >= len(rows):
+ raise IndexError(f"batch_index {batch_index} out of range, file has {len(rows)} rows")
+ row = rows[batch_index]
- if preview_only:
- return self._preview_batch_file(batch_data)
-
- # Validate index
- if batch_index >= len(batch_data):
- raise ValueError(f"Batch index {batch_index} out of range. File contains {len(batch_data)} items.")
-
- # Get the specific batch item
- batch_item = batch_data[batch_index]
-
- # Extract settings with defaults
- settings = self._extract_settings(
- batch_item, default_resolution, default_steps, default_guidance
- )
-
- batch_info = f"Processing item {batch_index + 1}/{len(batch_data)} from {os.path.basename(batch_file)}"
- print(f"[BawkBatchProcessor] {batch_info}")
-
- return (
- settings["prompt"],
- settings["seed"],
- settings["steps"],
- settings["guidance"],
- settings["resolution"],
- batch_info
- )
-
- except Exception as e:
- error_msg = f"Batch processing failed: {str(e)}"
- print(f"[BawkBatchProcessor] Error: {error_msg}")
-
- # Return safe defaults on error
- return (
- f"Error: {error_msg}",
- 0,
- default_steps,
- default_guidance,
- default_resolution,
- f"Error processing batch file"
- )
-
- def _load_batch_file(self, file_path: str, file_format: str) -> List[Dict]:
- """Load batch data from CSV or JSON file"""
-
- # Auto-detect format
- if file_format == "Auto-detect":
- ext = os.path.splitext(file_path)[1].lower()
- if ext == '.csv':
- file_format = "CSV"
- elif ext == '.json':
- file_format = "JSON"
- else:
- raise ValueError(f"Cannot auto-detect format for extension: {ext}")
-
- if file_format == "CSV":
- return self._load_csv_file(file_path)
- elif file_format == "JSON":
- return self._load_json_file(file_path)
- else:
- raise ValueError(f"Unsupported file format: {file_format}")
-
- def _load_csv_file(self, file_path: str) -> List[Dict]:
- """Load batch data from CSV file"""
- try:
- batch_data = []
- with open(file_path, 'r', encoding='utf-8') as f:
- reader = csv.DictReader(f)
- for row in reader:
- # Clean up the row data
- clean_row = {k.strip(): v.strip() if isinstance(v, str) else v
- for k, v in row.items() if k and k.strip()}
- if clean_row: # Only add non-empty rows
- batch_data.append(clean_row)
-
- print(f"[BawkBatchProcessor] Loaded {len(batch_data)} items from CSV")
- return batch_data
-
- except Exception as e:
- raise ValueError(f"Failed to load CSV file: {str(e)}")
-
- def _load_json_file(self, file_path: str) -> List[Dict]:
- """Load batch data from JSON file"""
- try:
- with open(file_path, 'r', encoding='utf-8') as f:
- data = json.load(f)
-
- # Handle different JSON structures
- if isinstance(data, list):
- batch_data = data
- elif isinstance(data, dict):
- # Try common keys for batch data
- for key in ['items', 'prompts', 'batch', 'data']:
- if key in data and isinstance(data[key], list):
- batch_data = data[key]
- break
- else:
- # Single item wrapped in dict
- batch_data = [data]
- else:
- raise ValueError("JSON must contain a list or dict with batch data")
-
- print(f"[BawkBatchProcessor] Loaded {len(batch_data)} items from JSON")
- return batch_data
-
- except Exception as e:
- raise ValueError(f"Failed to load JSON file: {str(e)}")
-
- def _extract_settings(
- self, batch_item: Dict, default_resolution: str, default_steps: int, default_guidance: float
- ) -> Dict[str, Any]:
- """Extract and validate settings from a batch item"""
-
- settings = {}
-
- # Extract prompt (required)
- prompt_keys = ['prompt', 'text', 'description', 'input']
- settings['prompt'] = ""
- for key in prompt_keys:
- if key in batch_item and batch_item[key]:
- settings['prompt'] = str(batch_item[key])
- break
-
- if not settings['prompt']:
- raise ValueError(f"No prompt found in batch item. Looked for keys: {prompt_keys}")
-
- # Extract seed
- seed_keys = ['seed', 'random_seed', 'noise_seed']
- settings['seed'] = 0
- for key in seed_keys:
- if key in batch_item:
- try:
- settings['seed'] = int(float(batch_item[key]))
- break
- except (ValueError, TypeError):
- continue
-
- # Extract steps
- steps_keys = ['steps', 'sampling_steps', 'iterations']
- settings['steps'] = default_steps
- for key in steps_keys:
- if key in batch_item:
- try:
- steps = int(float(batch_item[key]))
- if 1 <= steps <= 100:
- settings['steps'] = steps
- break
- except (ValueError, TypeError):
- continue
-
- # Extract guidance
- guidance_keys = ['guidance', 'guidance_scale', 'cfg', 'cfg_scale']
- settings['guidance'] = default_guidance
- for key in guidance_keys:
- if key in batch_item:
- try:
- guidance = float(batch_item[key])
- if 0.0 <= guidance <= 20.0:
- settings['guidance'] = guidance
- break
- except (ValueError, TypeError):
- continue
-
- # Extract resolution
- resolution_keys = ['resolution', 'size', 'dimensions', 'preset']
- settings['resolution'] = default_resolution
- for key in resolution_keys:
- if key in batch_item and batch_item[key]:
- settings['resolution'] = str(batch_item[key])
- break
-
- return settings
-
- def _preview_batch_file(self, batch_data: List[Dict]) -> Tuple[str, int, int, float, str, str]:
- """Preview the contents of a batch file"""
-
- preview_info = []
- preview_info.append(f"๐ Batch File Preview - {len(batch_data)} items total")
- preview_info.append("")
-
- # Show first few items
- preview_count = min(5, len(batch_data))
- for i, item in enumerate(batch_data[:preview_count]):
- preview_info.append(f"๐น Item {i + 1}:")
-
- # Show key fields
- if 'prompt' in item:
- prompt_preview = item['prompt'][:100] + "..." if len(item['prompt']) > 100 else item['prompt']
- preview_info.append(f" Prompt: {prompt_preview}")
-
- for key in ['seed', 'steps', 'guidance', 'resolution']:
- if key in item:
- preview_info.append(f" {key.title()}: {item[key]}")
-
- preview_info.append("")
-
- if len(batch_data) > preview_count:
- preview_info.append(f"... and {len(batch_data) - preview_count} more items")
-
- preview_text = "\\n".join(preview_info)
- batch_info = f"Preview mode: {len(batch_data)} items loaded"
-
- return (preview_text, 0, 30, 3.5, "FHD 16:9 - 1920x1080 - 2.1MP", batch_info)
\ No newline at end of file
+ prompt = _first(row, PROMPT_KEYS, str, "")
+ if not prompt:
+ raise ValueError(f"Row {batch_index} has no prompt (looked for {PROMPT_KEYS})")
+ seed = _first(row, SEED_KEYS, lambda v: int(float(v)), 0)
+ steps = _first(row, STEPS_KEYS, lambda v: int(float(v)), default_steps)
+ guidance = _first(row, GUIDANCE_KEYS, float, default_guidance)
+ resolution = _first(row, RESOLUTION_KEYS, str, default_resolution)
+ info = f"Row {batch_index + 1}/{len(rows)} from {os.path.basename(batch_file)}"
+ return (prompt, seed, steps, guidance, resolution, info)
diff --git a/nodes/bawk_controlnet.py b/nodes/bawk_controlnet.py
deleted file mode 100644
index 531c130..0000000
--- a/nodes/bawk_controlnet.py
+++ /dev/null
@@ -1,330 +0,0 @@
-"""
-BawkControlNet - FLUX ControlNet Integration
-File: nodes/bawk_controlnet.py
-"""
-
-import torch
-import numpy as np
-from typing import Tuple, Any
-
-
-class BawkControlNet:
- """
- FLUX-optimized ControlNet preprocessing and integration.
- Handles common ControlNet types with smart preprocessing.
- """
-
- @classmethod
- def INPUT_TYPES(cls):
- return {
- "required": {
- "image": ("IMAGE", {
- "tooltip": "Input image for ControlNet processing"
- }),
- "control_type": ([
- "Canny Edge",
- "Depth Map",
- "Normal Map",
- "Pose/OpenPose",
- "Segmentation",
- "Scribble",
- "Lineart",
- "QR Code",
- "Custom/Raw"
- ], {
- "default": "Canny Edge",
- "tooltip": "Type of ControlNet preprocessing to apply"
- }),
- "strength": ("FLOAT", {
- "default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01,
- "tooltip": "ControlNet influence strength"
- }),
- "start_percent": ("FLOAT", {
- "default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01,
- "tooltip": "When to start applying ControlNet (0.0 = from beginning)"
- }),
- "end_percent": ("FLOAT", {
- "default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01,
- "tooltip": "When to stop applying ControlNet (1.0 = until end)"
- }),
- },
- "optional": {
- # Canny specific
- "canny_low_threshold": ("INT", {
- "default": 100, "min": 1, "max": 255,
- "tooltip": "Canny edge detection low threshold"
- }),
- "canny_high_threshold": ("INT", {
- "default": 200, "min": 1, "max": 255,
- "tooltip": "Canny edge detection high threshold"
- }),
-
- # Depth specific
- "depth_near": ("FLOAT", {
- "default": 0.1, "min": 0.01, "max": 10.0, "step": 0.01,
- "tooltip": "Near plane for depth normalization"
- }),
- "depth_far": ("FLOAT", {
- "default": 100.0, "min": 1.0, "max": 1000.0, "step": 1.0,
- "tooltip": "Far plane for depth normalization"
- }),
-
- # Advanced options
- "preprocessor_resolution": ("INT", {
- "default": 512, "min": 256, "max": 2048, "step": 64,
- "tooltip": "Resolution for preprocessing (will be resized back to original)"
- }),
- "auto_resize": ("BOOLEAN", {
- "default": True,
- "tooltip": "Automatically resize control image to match generation resolution"
- }),
- }
- }
-
- RETURN_TYPES = ("IMAGE", "CONDITIONING", "STRING")
- RETURN_NAMES = ("control_image", "control_conditioning", "control_info")
- FUNCTION = "process_controlnet"
- CATEGORY = "BawkNodes/control"
- DESCRIPTION = "๐๏ธ ControlNet preprocessing with Canny, depth, pose detection and more"
-
- def process_controlnet(
- self,
- image,
- control_type="Canny Edge",
- strength=1.0,
- start_percent=0.0,
- end_percent=1.0,
- canny_low_threshold=100,
- canny_high_threshold=200,
- depth_near=0.1,
- depth_far=100.0,
- preprocessor_resolution=512,
- auto_resize=True
- ):
- """
- Process image with ControlNet preprocessing
- """
- try:
- print(f"[BawkControlNet] Processing {control_type} with strength {strength}")
-
- # Validate parameters
- self._validate_parameters(strength, start_percent, end_percent)
-
- # Preprocess the control image
- control_image = self._preprocess_image(
- image, control_type,
- canny_low_threshold, canny_high_threshold,
- depth_near, depth_far, preprocessor_resolution
- )
-
- # Create control conditioning (placeholder for now - would integrate with actual ControlNet)
- control_conditioning = self._create_control_conditioning(
- control_image, strength, start_percent, end_percent
- )
-
- # Generate info string
- control_info = self._generate_control_info(
- control_type, strength, start_percent, end_percent, image.shape
- )
-
- print(f"[BawkControlNet] โ
{control_info}")
-
- return (control_image, control_conditioning, control_info)
-
- except Exception as e:
- error_msg = f"ControlNet processing failed: {str(e)}"
- print(f"[BawkControlNet] โ {error_msg}")
-
- # Return safe defaults
- return (image, [], error_msg)
-
- def _validate_parameters(self, strength: float, start_percent: float, end_percent: float):
- """Validate ControlNet parameters and provide feedback"""
-
- if strength > 1.5:
- print(f"[BawkControlNet] โ ๏ธ High strength ({strength}) may cause over-conditioning")
- elif strength < 0.3:
- print(f"[BawkControlNet] โน๏ธ Low strength ({strength}) may have minimal effect")
-
- if start_percent >= end_percent:
- print(f"[BawkControlNet] โ ๏ธ Start percent ({start_percent}) should be < end percent ({end_percent})")
-
- if end_percent - start_percent < 0.2:
- print(f"[BawkControlNet] โน๏ธ Short control duration ({end_percent - start_percent:.1%}) may have limited effect")
-
- def _preprocess_image(
- self,
- image,
- control_type: str,
- canny_low: int,
- canny_high: int,
- depth_near: float,
- depth_far: float,
- resolution: int
- ):
- """Preprocess image based on control type"""
-
- # Convert to numpy for processing
- if len(image.shape) == 4:
- img_np = image[0].cpu().numpy() # Take first image from batch
- else:
- img_np = image.cpu().numpy()
-
- # Convert from 0-1 to 0-255
- img_np = (img_np * 255).astype(np.uint8)
-
- if control_type == "Canny Edge":
- processed = self._apply_canny_edge(img_np, canny_low, canny_high)
- elif control_type == "Depth Map":
- processed = self._apply_depth_processing(img_np, depth_near, depth_far)
- elif control_type == "Normal Map":
- processed = self._apply_normal_map(img_np)
- elif control_type == "Pose/OpenPose":
- processed = self._apply_pose_detection(img_np)
- elif control_type == "Segmentation":
- processed = self._apply_segmentation(img_np)
- elif control_type == "Scribble":
- processed = self._apply_scribble_effect(img_np)
- elif control_type == "Lineart":
- processed = self._apply_lineart(img_np)
- elif control_type == "QR Code":
- processed = self._apply_qr_processing(img_np)
- else: # Custom/Raw
- processed = img_np
- print(f"[BawkControlNet] Using raw image without preprocessing")
-
- # Convert back to tensor
- processed_tensor = torch.from_numpy(processed.astype(np.float32) / 255.0)
-
- # Ensure correct shape [batch, height, width, channels]
- if len(processed_tensor.shape) == 3:
- processed_tensor = processed_tensor.unsqueeze(0)
-
- return processed_tensor
-
- def _apply_canny_edge(self, img_np: np.ndarray, low_thresh: int, high_thresh: int):
- """Apply Canny edge detection"""
- try:
- import cv2
-
- # Convert to grayscale if needed
- if len(img_np.shape) == 3:
- gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
- else:
- gray = img_np
-
- # Apply Canny edge detection
- edges = cv2.Canny(gray, low_thresh, high_thresh)
-
- # Convert back to 3-channel
- edges_rgb = np.stack([edges, edges, edges], axis=-1)
-
- print(f"[BawkControlNet] Applied Canny edge detection (thresholds: {low_thresh}, {high_thresh})")
- return edges_rgb
-
- except ImportError:
- print(f"[BawkControlNet] โ ๏ธ OpenCV not available, using simple edge detection")
- return self._simple_edge_detection(img_np)
-
- def _simple_edge_detection(self, img_np: np.ndarray):
- """Simple edge detection fallback without OpenCV"""
- # Convert to grayscale
- if len(img_np.shape) == 3:
- gray = np.mean(img_np, axis=-1)
- else:
- gray = img_np
-
- # Simple gradient-based edge detection
- grad_x = np.abs(np.diff(gray, axis=1, prepend=gray[:, :1]))
- grad_y = np.abs(np.diff(gray, axis=0, prepend=gray[:1, :]))
- edges = np.sqrt(grad_x**2 + grad_y**2)
-
- # Threshold and normalize
- edges = np.clip(edges * 3, 0, 255).astype(np.uint8)
-
- # Convert to 3-channel
- edges_rgb = np.stack([edges, edges, edges], axis=-1)
- return edges_rgb
-
- def _apply_depth_processing(self, img_np: np.ndarray, near: float, far: float):
- """Apply depth map processing (placeholder)"""
- # Simple luminance-based depth approximation
- if len(img_np.shape) == 3:
- depth = np.mean(img_np, axis=-1)
- else:
- depth = img_np
-
- # Normalize depth
- depth = np.clip((depth - near * 255) / ((far - near) * 255), 0, 1) * 255
- depth_rgb = np.stack([depth, depth, depth], axis=-1).astype(np.uint8)
-
- print(f"[BawkControlNet] Applied depth processing (range: {near}-{far})")
- return depth_rgb
-
- def _apply_normal_map(self, img_np: np.ndarray):
- """Apply normal map processing (placeholder)"""
- print(f"[BawkControlNet] Applied normal map processing (simplified)")
- return img_np # Placeholder - would need actual normal map generation
-
- def _apply_pose_detection(self, img_np: np.ndarray):
- """Apply pose detection (placeholder)"""
- print(f"[BawkControlNet] Applied pose detection (placeholder)")
- return np.zeros_like(img_np) # Placeholder - would need pose detection model
-
- def _apply_segmentation(self, img_np: np.ndarray):
- """Apply segmentation (placeholder)"""
- print(f"[BawkControlNet] Applied segmentation (placeholder)")
- return img_np # Placeholder - would need segmentation model
-
- def _apply_scribble_effect(self, img_np: np.ndarray):
- """Apply scribble effect"""
- # Simple edge-based scribble effect
- edges = self._simple_edge_detection(img_np)
- # Thin the edges for scribble effect
- scribble = np.where(edges > 128, 255, 0).astype(np.uint8)
- print(f"[BawkControlNet] Applied scribble effect")
- return scribble
-
- def _apply_lineart(self, img_np: np.ndarray):
- """Apply lineart processing"""
- # Similar to scribble but with cleaner lines
- edges = self._simple_edge_detection(img_np)
- lineart = np.where(edges > 100, 255, 0).astype(np.uint8)
- print(f"[BawkControlNet] Applied lineart processing")
- return lineart
-
- def _apply_qr_processing(self, img_np: np.ndarray):
- """Apply QR code processing"""
- # High contrast black and white
- if len(img_np.shape) == 3:
- gray = np.mean(img_np, axis=-1)
- else:
- gray = img_np
-
- qr = np.where(gray > 128, 255, 0).astype(np.uint8)
- qr_rgb = np.stack([qr, qr, qr], axis=-1)
- print(f"[BawkControlNet] Applied QR code processing")
- return qr_rgb
-
- def _create_control_conditioning(self, control_image, strength: float, start: float, end: float):
- """Create control conditioning (placeholder for actual ControlNet integration)"""
- # This would integrate with actual ControlNet models in a real implementation
- # For now, return empty conditioning as placeholder
-
- conditioning_info = {
- "control_image": control_image,
- "strength": strength,
- "start_percent": start,
- "end_percent": end
- }
-
- return [] # Placeholder - would return actual conditioning
-
- def _generate_control_info(self, control_type: str, strength: float, start: float, end: float, image_shape):
- """Generate informational string about the control setup"""
-
- height, width = image_shape[-2:]
- duration = end - start
-
- info = f"{control_type} | Strength: {strength:.2f} | Duration: {start:.0%}-{end:.0%} ({duration:.0%}) | Size: {width}x{height}"
- return info
\ No newline at end of file
diff --git a/nodes/bawk_image_loader.py b/nodes/bawk_image_loader.py
index c564d12..8542b9c 100644
--- a/nodes/bawk_image_loader.py
+++ b/nodes/bawk_image_loader.py
@@ -1,280 +1,94 @@
"""
-BawkImageLoader - Enhanced Image Loading with Preprocessing
-File: nodes/bawk_image_loader.py
+BawkImageLoader - Load Image with optional resize / pad-to-square and size outputs.
"""
+import hashlib
import os
+
import numpy as np
import torch
-from PIL import Image, ImageOps, ExifTags
-from typing import Tuple, Any
+import folder_paths
+import node_helpers
+from PIL import Image, ImageOps
-# ComfyUI imports with fallback
-try:
- import folder_paths
- from comfy.utils import common_upsampling_factor
-except ImportError:
- folder_paths = None
- common_upsampling_factor = None
+RESAMPLE = {
+ "lanczos": Image.Resampling.LANCZOS,
+ "bicubic": Image.Resampling.BICUBIC,
+ "bilinear": Image.Resampling.BILINEAR,
+ "nearest": Image.Resampling.NEAREST,
+}
class BawkImageLoader:
- """
- Enhanced image loader with file browser and preprocessing options.
- Compatible with ComfyUI's Load Image node but with additional features.
- """
-
@classmethod
def INPUT_TYPES(cls):
- # Get available images from ComfyUI input directory
- input_dir = folder_paths.get_input_directory() if folder_paths else "input"
- files = []
- if os.path.exists(input_dir):
- files = [f for f in os.listdir(input_dir)
- if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.gif'))]
-
+ input_dir = folder_paths.get_input_directory()
+ files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
+ files = folder_paths.filter_files_content_types(files, ["image"])
return {
"required": {
- "image": (sorted(files), {
- "image_upload": True,
- "tooltip": "Select image file or upload new one"
- }),
+ "image": (sorted(files), {"image_upload": True}),
},
"optional": {
- "auto_orient": ("BOOLEAN", {
- "default": True,
- "tooltip": "Automatically rotate image based on EXIF orientation data"
- }),
"target_size": (["Original", "512", "768", "1024", "1536", "2048"], {
"default": "Original",
- "tooltip": "Resize image to target size (maintains aspect ratio)"
+ "tooltip": "Resize so the longest side matches this (keeps aspect ratio)",
}),
- "resize_method": (["Lanczos", "Bilinear", "Bicubic", "Nearest"], {
- "default": "Lanczos",
- "tooltip": "Resampling method for resizing"
- }),
- "pad_to_square": ("BOOLEAN", {
- "default": False,
- "tooltip": "Pad image to square aspect ratio with black borders"
- }),
- "normalize_colors": ("BOOLEAN", {
- "default": True,
- "tooltip": "Normalize color values to 0-1 range for better FLUX compatibility"
- }),
- "create_mask": ("BOOLEAN", {
- "default": False,
- "tooltip": "Generate mask output (white = opaque, black = transparent)"
- }),
- }
+ "resize_method": (list(RESAMPLE), {"default": "lanczos"}),
+ "pad_to_square": ("BOOLEAN", {"default": False, "tooltip": "Pad with black to a square canvas"}),
+ },
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "INT", "INT")
RETURN_NAMES = ("image", "mask", "filename", "width", "height")
FUNCTION = "load_image"
CATEGORY = "BawkNodes/image"
- DESCRIPTION = "๐ธ Enhanced image loader with auto-rotation, smart resizing, and mask generation"
+ DESCRIPTION = "Load Image with EXIF auto-rotation, optional resize / pad-to-square, alpha mask and size outputs."
@classmethod
def IS_CHANGED(cls, image, **kwargs):
- """Check if image file has changed"""
- if folder_paths:
- image_path = folder_paths.get_annotated_filepath(image)
- if os.path.exists(image_path):
- return os.path.getmtime(image_path)
- return float("inf")
+ path = folder_paths.get_annotated_filepath(image)
+ digest = hashlib.sha256()
+ with open(path, "rb") as f:
+ digest.update(f.read())
+ return digest.hexdigest()
@classmethod
def VALIDATE_INPUTS(cls, image, **kwargs):
- """Validate that the image file exists"""
- if not folder_paths:
- return True
-
- if not image:
- return "No image selected"
-
- image_path = folder_paths.get_annotated_filepath(image)
- if not os.path.exists(image_path):
- return f"Image file does not exist: {image}"
-
+ if not folder_paths.exists_annotated_filepath(image):
+ return f"Invalid image file: {image}"
return True
- def load_image(
- self,
- image,
- auto_orient=True,
- target_size="Original",
- resize_method="Lanczos",
- pad_to_square=False,
- normalize_colors=True,
- create_mask=False
- ):
- """
- Load and preprocess image with various options
- """
- try:
- print(f"[BawkImageLoader] Loading image: {image}")
+ def load_image(self, image, target_size="Original", resize_method="lanczos", pad_to_square=False):
+ path = folder_paths.get_annotated_filepath(image)
+ pil_image = node_helpers.pillow(Image.open, path)
+ pil_image = node_helpers.pillow(ImageOps.exif_transpose, pil_image)
+ if pil_image.mode == "I":
+ pil_image = pil_image.point(lambda i: i * (1 / 255))
- # Get full path using ComfyUI's folder_paths
- if folder_paths:
- image_path = folder_paths.get_annotated_filepath(image)
- else:
- image_path = image
+ alpha = pil_image.getchannel("A") if "A" in pil_image.getbands() else None
+ rgb = pil_image.convert("RGB")
- if not os.path.exists(image_path):
- raise ValueError(f"Image file not found: {image_path}")
+ if target_size != "Original":
+ size = int(target_size)
+ w, h = rgb.size
+ scale = size / max(w, h)
+ new_size = (max(1, round(w * scale)), max(1, round(h * scale)))
+ rgb = rgb.resize(new_size, RESAMPLE[resize_method])
+ if alpha is not None:
+ alpha = alpha.resize(new_size, Image.Resampling.NEAREST)
- # Load image with PIL
- pil_image = Image.open(image_path)
- filename = os.path.basename(image_path)
+ if pad_to_square:
+ side = max(rgb.size)
+ rgb = ImageOps.pad(rgb, (side, side), color=(0, 0, 0))
+ if alpha is not None:
+ alpha = ImageOps.pad(alpha, (side, side), color=255)
- # Store original dimensions
- original_width, original_height = pil_image.size
- print(f"[BawkImageLoader] Original size: {original_width}x{original_height}")
-
- # Handle EXIF orientation
- if auto_orient:
- pil_image = self._auto_orient_image(pil_image)
- if pil_image.size != (original_width, original_height):
- print(f"[BawkImageLoader] Auto-rotated image to: {pil_image.size[0]}x{pil_image.size[1]}")
-
- # Convert to RGB if needed
- if pil_image.mode not in ('RGB', 'RGBA'):
- print(f"[BawkImageLoader] Converting from {pil_image.mode} to RGB")
- pil_image = pil_image.convert('RGB')
-
- # Resize if requested
- if target_size != "Original":
- pil_image = self._resize_image(pil_image, int(target_size), resize_method)
- print(f"[BawkImageLoader] Resized to: {pil_image.size[0]}x{pil_image.size[1]}")
-
- # Pad to square if requested
- if pad_to_square:
- pil_image = self._pad_to_square(pil_image)
- print(f"[BawkImageLoader] Padded to square: {pil_image.size[0]}x{pil_image.size[1]}")
-
- # Convert to tensor
- image_tensor = self._pil_to_tensor(pil_image, normalize_colors)
-
- # Create mask if requested
- mask_tensor = self._create_mask_tensor(pil_image) if create_mask else torch.zeros((1, pil_image.size[1], pil_image.size[0]), dtype=torch.float32)
-
- final_width, final_height = pil_image.size
-
- print(f"[BawkImageLoader] โ
Successfully loaded {filename} ({final_width}x{final_height})")
-
- return (image_tensor, mask_tensor, filename, final_width, final_height)
-
- except Exception as e:
- error_msg = f"Failed to load image: {str(e)}"
- print(f"[BawkImageLoader] โ {error_msg}")
-
- # Return a small black image as fallback
- fallback_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
- fallback_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
- return (fallback_image, fallback_mask, f"Error: {error_msg}", 64, 64)
-
- def _auto_orient_image(self, image: Image.Image) -> Image.Image:
- """Auto-rotate image based on EXIF orientation"""
- try:
- # Get EXIF data
- exif = image._getexif()
- if exif is not None:
- # Find orientation tag
- for tag, value in exif.items():
- if tag in ExifTags.TAGS and ExifTags.TAGS[tag] == 'Orientation':
- # Apply rotation based on orientation value
- if value == 3:
- image = image.rotate(180, expand=True)
- elif value == 6:
- image = image.rotate(270, expand=True)
- elif value == 8:
- image = image.rotate(90, expand=True)
- break
- except (AttributeError, KeyError, TypeError):
- # No EXIF data or orientation tag, use ImageOps fallback
- try:
- image = ImageOps.exif_transpose(image)
- except Exception:
- pass # Keep original orientation
-
- return image
-
- def _resize_image(self, image: Image.Image, target_size: int, method: str) -> Image.Image:
- """Resize image maintaining aspect ratio"""
- # Map method names to PIL constants
- method_map = {
- "Lanczos": Image.Resampling.LANCZOS,
- "Bilinear": Image.Resampling.BILINEAR,
- "Bicubic": Image.Resampling.BICUBIC,
- "Nearest": Image.Resampling.NEAREST
- }
-
- resample_method = method_map.get(method, Image.Resampling.LANCZOS)
-
- # Calculate new size maintaining aspect ratio
- width, height = image.size
- aspect_ratio = width / height
-
- if width > height:
- new_width = target_size
- new_height = int(target_size / aspect_ratio)
+ image_tensor = torch.from_numpy(np.array(rgb).astype(np.float32) / 255.0).unsqueeze(0)
+ if alpha is not None:
+ mask = 1.0 - torch.from_numpy(np.array(alpha).astype(np.float32) / 255.0)
else:
- new_height = target_size
- new_width = int(target_size * aspect_ratio)
+ mask = torch.zeros((64, 64), dtype=torch.float32)
- # Ensure dimensions are even numbers (better for some models)
- new_width = (new_width // 2) * 2
- new_height = (new_height // 2) * 2
-
- return image.resize((new_width, new_height), resample_method)
-
- def _pad_to_square(self, image: Image.Image) -> Image.Image:
- """Pad image to square with black borders"""
- width, height = image.size
- max_size = max(width, height)
-
- # Create new square image with black background
- square_image = Image.new('RGB', (max_size, max_size), (0, 0, 0))
-
- # Calculate position to center the original image
- x_offset = (max_size - width) // 2
- y_offset = (max_size - height) // 2
-
- # Paste original image onto square background
- square_image.paste(image, (x_offset, y_offset))
-
- return square_image
-
- def _pil_to_tensor(self, image: Image.Image, normalize: bool = True) -> torch.Tensor:
- """Convert PIL image to tensor format expected by ComfyUI"""
- # Convert to numpy array
- image_np = np.array(image)
-
- # Normalize to 0-1 range if requested
- if normalize:
- image_np = image_np.astype(np.float32) / 255.0
- else:
- image_np = image_np.astype(np.float32)
-
- # Convert to tensor and add batch dimension [batch, height, width, channels]
- image_tensor = torch.from_numpy(image_np).unsqueeze(0)
-
- return image_tensor
-
- def _create_mask_tensor(self, image: Image.Image) -> torch.Tensor:
- """Create mask tensor from image alpha channel or full white mask"""
- if image.mode == 'RGBA':
- # Use alpha channel as mask
- alpha = image.split()[-1] # Get alpha channel
- mask_np = np.array(alpha).astype(np.float32) / 255.0
- else:
- # Create full white mask (opaque)
- mask_np = np.ones((image.size[1], image.size[0]), dtype=np.float32)
-
- # Convert to tensor [batch, height, width]
- mask_tensor = torch.from_numpy(mask_np).unsqueeze(0)
- return mask_tensor
-
- def _get_supported_formats(self):
- """Get list of supported image formats"""
- return ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff', '.tif']
\ No newline at end of file
+ width, height = rgb.size
+ return (image_tensor, mask.unsqueeze(0), os.path.basename(path), width, height)
diff --git a/nodes/bawk_sampler.py b/nodes/bawk_sampler.py
index 971521e..78103fa 100644
--- a/nodes/bawk_sampler.py
+++ b/nodes/bawk_sampler.py
@@ -1,597 +1,258 @@
"""
-BawkSampler - Combined Latent Generator and Advanced Sampler for FLUX
-File: nodes/bawk_sampler.py
+BawkSampler - latent creation, FLUX sampling and VAE decode in one node.
+Handles FLUX.1 (dev / Krea / Kontext) and FLUX.2 (dev / klein) automatically.
"""
+import logging
+import math
+import re
+
import torch
-import math
-import time
-import logging
+import comfy.model_base
+import comfy.model_management
+import comfy.model_sampling
import comfy.samplers
-import comfy.model_management as mm
-from comfy.utils import ProgressBar
-from comfy_extras.nodes_custom_sampler import Noise_RandomNoise, BasicScheduler, BasicGuider, SamplerCustomAdvanced
-from comfy_extras.nodes_latent import LatentBatch
-from comfy_extras.nodes_model_advanced import ModelSamplingFlux, ModelSamplingAuraFlow
-from typing import Tuple, Dict, Any, List
+import comfy.utils
+import node_helpers
+from comfy_extras.nodes_custom_sampler import Guider_Basic, Noise_RandomNoise, SamplerCustomAdvanced
+
+try:
+ from comfy_extras.nodes_flux import get_schedule as flux2_schedule
+except ImportError:
+ flux2_schedule = None
+
+RESOLUTION_PRESETS = {
+ "1:1 Square - 1024x1024": (1024, 1024),
+ "1:1 Square Large - 1408x1408": (1408, 1408),
+ "4:3 Landscape - 1152x896": (1152, 896),
+ "3:4 Portrait - 896x1152": (896, 1152),
+ "3:2 Landscape - 1216x832": (1216, 832),
+ "2:3 Portrait - 832x1216": (832, 1216),
+ "16:9 Landscape - 1344x768": (1344, 768),
+ "9:16 Portrait - 768x1344": (768, 1344),
+ "1:1 Draft - 512x512": (512, 512),
+}
+MATCH_IMAGE = "Match input / reference image"
+CUSTOM = "Custom (width / height below)"
+AUTO_SCHEDULER = "auto"
+DIMS_RE = re.compile(r"(\d+)\s*x\s*(\d+)")
+
+KONTEXT_RESOLUTIONS = [
+ (672, 1568), (688, 1504), (720, 1456), (752, 1392), (800, 1328), (832, 1248), (880, 1184), (944, 1104),
+ (1024, 1024),
+ (1104, 944), (1184, 880), (1248, 832), (1328, 800), (1392, 752), (1456, 720), (1504, 688), (1568, 672),
+]
-def round_to_nearest_multiple(value, multiple):
- """Rounds a value to the nearest multiple of 'multiple'."""
- if multiple <= 0:
- return value
- return int(round(value / multiple) * multiple)
+def _model_family(model):
+ flux2_cls = getattr(comfy.model_base, "Flux2", None)
+ if flux2_cls is not None and isinstance(model.model, flux2_cls):
+ return "flux2"
+ if isinstance(model.model, comfy.model_base.Flux):
+ return "flux1"
+ return "other"
-def parse_string_to_list(input_string):
- """Parse comma/newline separated string to list of numbers"""
- try:
- if not input_string:
- return []
- items = input_string.replace('\n', ',').split(',')
- result = []
- for item in items:
- item = item.strip()
- if not item:
- continue
- try:
- num = float(item)
- if num.is_integer():
- num = int(num)
- result.append(num)
- except ValueError:
- continue
- return result
- except:
- return []
+def _round(value, multiple):
+ return max(multiple, int(round(value / multiple)) * multiple)
-def conditioning_set_values(conditioning, values):
- """Set conditioning values like guidance scale"""
- c = []
- for t in conditioning:
- n = [t[0], t[1].copy()]
- for k, v in values.items():
- if k == "guidance":
- n[1]['guidance_scale'] = v
- c.append(tuple(n))
- return c
+def _crop_to_multiple(image, multiple):
+ h, w = image.shape[1], image.shape[2]
+ nh, nw = h // multiple * multiple, w // multiple * multiple
+ if (nh, nw) == (h, w):
+ return image
+ y, x = (h - nh) // 2, (w - nw) // 2
+ return image[:, y:y + nh, x:x + nw, :]
+
+
+def _scale_image(image, width, height):
+ samples = comfy.utils.common_upscale(image.movedim(-1, 1), width, height, "lanczos", "center")
+ return samples.movedim(1, -1)
+
+
+def _reference_size(image, family, align):
+ h, w = image.shape[1], image.shape[2]
+ if family == "flux1":
+ _, rw, rh = min((abs(w / h - pw / ph), pw, ph) for pw, ph in KONTEXT_RESOLUTIONS)
+ return rw, rh
+ scale = math.sqrt(1_000_000 / (w * h))
+ return _round(w * scale, align), _round(h * scale, align)
+
+
+def _zero_out(conditioning):
+ out = []
+ for cond, opts in conditioning:
+ opts = opts.copy()
+ if opts.get("pooled_output") is not None:
+ opts["pooled_output"] = torch.zeros_like(opts["pooled_output"])
+ out.append([torch.zeros_like(cond), opts])
+ return out
+
+
+def _patch_flux1_shift(model, max_shift, base_shift, width, height):
+ x1, x2 = 256, 4096
+ mm = (max_shift - base_shift) / (x2 - x1)
+ b = base_shift - mm * x1
+ shift = (width * height / (8 * 8 * 2 * 2)) * mm + b
+
+ class ModelSamplingAdvanced(comfy.model_sampling.ModelSamplingFlux, comfy.model_sampling.CONST):
+ pass
+
+ model_sampling = ModelSamplingAdvanced(model.model.model_config)
+ model_sampling.set_parameters(shift=shift)
+ patched = model.clone()
+ patched.add_object_patch("model_sampling", model_sampling)
+ return patched
+
+
+def _sigmas(model, family, scheduler, steps, denoise, width, height):
+ if denoise <= 0.0:
+ return torch.FloatTensor([])
+ total_steps = steps if denoise >= 1.0 else int(steps / denoise)
+ if scheduler == AUTO_SCHEDULER:
+ if family == "flux2" and flux2_schedule is not None:
+ sigmas = torch.FloatTensor(flux2_schedule(total_steps, round(width * height / 256)))
+ return sigmas[-(steps + 1):]
+ scheduler = "simple"
+ sigmas = comfy.samplers.calculate_sigmas(model.get_model_object("model_sampling"), scheduler, total_steps).cpu()
+ return sigmas[-(steps + 1):]
class BawkSampler:
- """
- Combined latent generator and advanced sampler for FLUX models.
- Generates optimized latent dimensions, then performs advanced sampling.
- Designed to work with FluxWildcardEncode for prompt handling.
- """
-
@classmethod
def INPUT_TYPES(cls):
- # FLUX-optimized resolution presets based on official limits and community testing
- # Official FLUX range: 0.1MP (512x512) to 2.0MP (1408x1408)
- # For higher resolutions, generate at 1408x1408 then use FLUX upscaler
- resolution_presets = [
- # OPTIMAL SWEET SPOTS (Best quality/speed balance)
- "FLUX Optimal - 1024x1024 - 1.0MP โญ Recommended",
- "Fast Portrait - 768x1024 - 0.8MP (3:4)",
- "Fast Landscape - 1024x768 - 0.8MP (4:3)",
-
- # QUICK TESTING
- "Quick Test - 512x512 - 0.25MP (Draft mode)",
-
- # OFFICIAL MAXIMUM (Best for detail)
- "FLUX Max Quality - 1408x1408 - 2.0MP (Highest stable)",
-
- # SOCIAL MEDIA FORMATS
- "Instagram Square - 1024x1024 - 1.0MP (1:1)",
- "Instagram Portrait - 1024x1280 - 1.3MP (4:5)",
- "Twitter Post - 1200x675 - 0.8MP (16:9)",
-
- # STABLE ALTERNATIVES
- "Square Medium - 832x832 - 0.7MP",
- "Portrait Classic - 768x1024 - 0.8MP (3:4)",
- "Landscape Classic - 1024x768 - 0.8MP (4:3)",
- "Wide Screen - 1152x768 - 0.9MP (3:2)",
- "Print Quality - 1344x896 - 1.2MP (3:2)",
-
- # Custom option
- "Custom Resolution",
- ]
-
return {
"required": {
- # Core inputs
"model": ("MODEL",),
"conditioning": ("CONDITIONING",),
- "vae": ("VAE",), # Added VAE input for decoding
-
- # Latent generation
- "resolution": (resolution_presets, {
- "default": "FLUX Optimal - 1024x1024 - 1.0MP (Recommended)",
- "tooltip": "Resolution presets optimized for FLUX - 1024x1024 is the sweet spot for quality/speed"
- }),
- "batch_size": ("INT", {
- "default": 4, "min": 1, "max": 64, "step": 1,
- "tooltip": "Number of images to generate"
- }),
-
- # Sampling parameters
- "seed": ("INT", {
- "default": 0, "min": 0, "max": 0xffffffffffffffff,
- "tooltip": "Random seed for generation"
- }),
- "sampler": (comfy.samplers.KSampler.SAMPLERS, {
- "default": "euler",
- "tooltip": "Sampling method for generation"
- }),
- "scheduler": (comfy.samplers.KSampler.SCHEDULERS, {
- "default": "beta",
- "tooltip": "Noise scheduling method"
+ "vae": ("VAE",),
+ "resolution": (list(RESOLUTION_PRESETS) + [MATCH_IMAGE, CUSTOM], {
+ "default": "1:1 Square - 1024x1024",
+ "tooltip": "Output size. Any 'WIDTHxHEIGHT' string also works when linked from another node",
}),
+ "batch_size": ("INT", {"default": 1, "min": 1, "max": 64, "tooltip": "Images per run"}),
+ "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}),
"steps": ("INT", {
- "default": 30, "min": 1, "max": 100, "step": 1,
- "tooltip": "Sampling steps - FLUX optimal: 30 (20=fast, 40=quality)"
+ "default": 20, "min": 1, "max": 100,
+ "tooltip": "FLUX.1-dev / Krea / Kontext / FLUX.2-dev: 20-30. FLUX.2 klein distilled: 4",
}),
"guidance": ("FLOAT", {
"default": 3.5, "min": 0.0, "max": 20.0, "step": 0.1,
- "tooltip": "Guidance scale - FLUX recommended: 3.5 (balanced), 2.0 (creative), 7.0 (structured)"
+ "tooltip": "Distilled guidance. FLUX.1-dev 3.5, Kontext 2.5, FLUX.2-dev 4.0. Klein distilled ignores it",
}),
- "max_shift": ("FLOAT", {
- "default": 1.15, "min": 0.0, "max": 2.0, "step": 0.01,
- "tooltip": "Max shift for FLUX - 1.15 is optimal (higher=more coherent, lower=more varied)"
- }),
- "base_shift": ("FLOAT", {
- "default": 0.5, "min": 0.0, "max": 2.0, "step": 0.01,
- "tooltip": "Base shift for FLUX - 0.5 is optimal for most cases"
+ "sampler": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler"}),
+ "scheduler": ([AUTO_SCHEDULER] + comfy.samplers.KSampler.SCHEDULERS, {
+ "default": AUTO_SCHEDULER,
+ "tooltip": "auto = 'simple' for FLUX.1, the official FLUX.2 schedule for FLUX.2",
}),
"denoise": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01,
- "tooltip": "Denoise strength - Use 1.0 for text-to-image, 0.6-0.9 for image-to-image"
- }),
-
- # Custom resolution toggle
- "use_custom_resolution": ("BOOLEAN", {
- "default": False,
- "tooltip": "Enable custom width/height instead of presets"
+ "tooltip": "1.0 for text-to-image. 0.5-0.9 for img2img with input_image",
}),
},
"optional": {
- # Img2Img support
- "input_image": ("IMAGE", {
- "tooltip": "Input image for img2img generation. Leave empty for text-to-image mode."
+ "input_image": ("IMAGE", {"tooltip": "img2img source. Encoded, then denoised by 'denoise'"}),
+ "reference_image": ("IMAGE", {
+ "tooltip": "Edit / reference image for FLUX.1 Kontext and FLUX.2 (klein) edit models",
}),
-
- # Custom resolution (only used when use_custom_resolution=True)
- "custom_width": ("INT", {
- "default": 1920, "min": 64, "max": 4096, "step": 64,
- "tooltip": "Custom width (must be multiple of 64). Only used when custom resolution is enabled."
+ "reference_image_2": ("IMAGE", {"tooltip": "Second reference image (FLUX.2 / klein)"}),
+ "cfg": ("FLOAT", {
+ "default": 1.0, "min": 1.0, "max": 10.0, "step": 0.1,
+ "tooltip": "Real CFG against an empty negative. Only for FLUX.2 klein *base* models (~4-5). "
+ "Keep 1.0 for every distilled FLUX model",
}),
- "custom_height": ("INT", {
- "default": 1080, "min": 64, "max": 4096, "step": 64,
- "tooltip": "Custom height (must be multiple of 64). Only used when custom resolution is enabled."
+ "max_shift": ("FLOAT", {
+ "default": 1.15, "min": 0.0, "max": 10.0, "step": 0.01, "advanced": True,
+ "tooltip": "FLUX.1 timestep shift at high resolution. Ignored by FLUX.2",
}),
- }
+ "base_shift": ("FLOAT", {
+ "default": 0.5, "min": 0.0, "max": 10.0, "step": 0.01, "advanced": True,
+ "tooltip": "FLUX.1 timestep shift at low resolution. Ignored by FLUX.2",
+ }),
+ "custom_width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 16}),
+ "custom_height": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 16}),
+ },
}
-
+
RETURN_TYPES = ("IMAGE", "LATENT")
RETURN_NAMES = ("images", "latent")
- FUNCTION = "generate_sample_and_decode"
+ FUNCTION = "sample"
CATEGORY = "BawkNodes/sampling"
- DESCRIPTION = "๐ All-in-one FLUX sampler with txt2img/img2img, smart validation, and built-in VAE decoding"
-
- def generate_sample_and_decode(
- self,
- model, conditioning, vae,
- resolution="FHD 16:9 - 1920x1080 - 2.1MP", batch_size=4,
- seed=0, sampler="euler", scheduler="beta", steps=30,
- guidance=3.5, max_shift=0.5, base_shift=0.3, denoise=1.0,
- use_custom_resolution=False, input_image=None, custom_width=1920, custom_height=1080
- ):
- """
- Generate optimized latent, perform FLUX sampling, and decode to images
- """
- try:
- # Step 0: Smart validation with user feedback
- is_img2img = input_image is not None
- self._validate_parameters_with_feedback(
- model, batch_size, steps, guidance, max_shift, base_shift,
- resolution, use_custom_resolution, custom_width, custom_height, is_img2img, denoise
- )
+ DESCRIPTION = (
+ "All-in-one FLUX sampler: builds the latent (txt2img / img2img / reference edit), "
+ "samples and decodes. Auto-detects FLUX.1 vs FLUX.2."
+ )
- # Step 1: Generate or encode latent (img2img vs txt2img)
- if is_img2img:
- print(f"[BawkSampler] Using img2img mode with denoise strength: {denoise}")
- latent = self._encode_image_to_latent(vae, input_image, batch_size)
- else:
- print(f"[BawkSampler] Using text-to-image mode")
- latent = self._generate_optimized_latent(
- resolution, batch_size, use_custom_resolution, custom_width, custom_height
- )
+ @classmethod
+ def VALIDATE_INPUTS(cls, resolution):
+ if resolution in (MATCH_IMAGE, CUSTOM) or DIMS_RE.search(resolution):
+ return True
+ return f"Unknown resolution '{resolution}'. Use a preset or a 'WIDTHxHEIGHT' string"
- # Step 2: Perform sampling
- sampled_latent = self._perform_flux_sampling(
- model, conditioning, latent, seed, sampler, scheduler,
- steps, guidance, max_shift, base_shift, denoise
- )
+ def sample(self, model, conditioning, vae, resolution, batch_size, seed, steps, guidance, sampler, scheduler,
+ denoise, input_image=None, reference_image=None, reference_image_2=None, cfg=1.0,
+ max_shift=1.15, base_shift=0.5, custom_width=1024, custom_height=1024):
+ family = _model_family(model)
+ latent_format = model.get_model_object("latent_format")
+ downscale = latent_format.spacial_downscale_ratio
+ align = downscale * 2
+ references = [r for r in (reference_image, reference_image_2) if r is not None]
- # Step 3: Decode latent to images using VAE
- decoded_images = self._decode_latent_to_images(vae, sampled_latent)
-
- print(f"[BawkSampler] Successfully generated, sampled, and decoded {batch_size} images")
-
- return (decoded_images, sampled_latent)
-
- except Exception as e:
- error_msg, suggestion = self._get_error_with_solution(str(e))
- print(f"[BawkSampler] โ Error: {error_msg}")
- if suggestion:
- print(f"[BawkSampler] ๐ก Suggestion: {suggestion}")
- raise RuntimeError(f"{error_msg}\nSuggestion: {suggestion}" if suggestion else error_msg)
-
- def _generate_optimized_latent(
- self, resolution, batch_size, use_custom_resolution, custom_width, custom_height
- ) -> Dict:
- """Generate optimized empty latent for FLUX"""
-
- # Determine target dimensions
- if use_custom_resolution:
- # Use custom dimensions (ensure they're multiples of 64)
- target_width = round_to_nearest_multiple(custom_width, 64)
- target_height = round_to_nearest_multiple(custom_height, 64)
- print(f"[BawkSampler] Using custom resolution: {target_width}x{target_height}")
+ if input_image is not None:
+ pixels = _crop_to_multiple(input_image[:, :, :, :3], align)
+ samples = vae.encode(pixels)
+ if samples.shape[0] != batch_size:
+ samples = samples[:1].repeat(batch_size, 1, 1, 1)
+ latent = {"samples": samples}
+ width, height = pixels.shape[2], pixels.shape[1]
else:
- # Parse dimensions from preset string
- target_width, target_height = self._parse_resolution_preset(resolution)
- print(f"[BawkSampler] Using preset resolution: {resolution}")
-
- # FLUX-specific parameters
- vae_scale_factor = 8
- latent_channels = 16 # FLUX uses 16 channels
-
- # Calculate latent dimensions
- latent_width = target_width // vae_scale_factor
- latent_height = target_height // vae_scale_factor
-
- # Generate latent tensor
- try:
- latent_tensor = torch.zeros([batch_size, latent_channels, latent_height, latent_width])
- latent = {"samples": latent_tensor}
- except Exception as e:
- raise RuntimeError(f"Error creating FLUX latent tensor [{batch_size}, {latent_channels}, {latent_height}, {latent_width}]: {e}")
-
- print(f"[BawkSampler] Generated FLUX latent: {latent_width}x{latent_height} "
- f"(pixel: {target_width}x{target_height}, batch: {batch_size})")
-
- return latent
-
- def _parse_resolution_preset(self, resolution_preset):
- """Parse width and height from resolution preset string"""
- try:
- # Extract dimensions from strings like:
- # "FLUX Optimal - 1024x1024 - 1.0MP (Recommended)"
- # "Fast Portrait - 768x1024 - 0.8MP (3:4)"
- # "Pro Cinematic - 1920x1080 - 2.1MP (16:9 - Requires 12GB+ VRAM)"
+ width, height = self._target_size(resolution, custom_width, custom_height, references, family, align)
+ samples = torch.zeros(
+ [batch_size, latent_format.latent_channels, height // downscale, width // downscale],
+ device=comfy.model_management.intermediate_device(),
+ )
+ latent = {"samples": samples}
- # Split by " - " and take the dimensions part (middle section)
- parts = resolution_preset.split(" - ")
- if len(parts) < 2:
- raise ValueError(f"Invalid resolution format: {resolution_preset}")
+ cond = node_helpers.conditioning_set_values(conditioning, {"guidance": guidance})
+ for ref in references:
+ rw, rh = _reference_size(ref, family, align)
+ ref_latent = vae.encode(_scale_image(ref[:, :, :, :3], rw, rh))
+ cond = node_helpers.conditioning_set_values(cond, {"reference_latents": [ref_latent]}, append=True)
- # Handle new format "Name - WxH - MP info (notes)"
- dimensions_part = parts[1] # e.g., "1024x1024", "1920x1080"
+ work_model = _patch_flux1_shift(model, max_shift, base_shift, width, height) if family == "flux1" else model
- # Extract just the WxH part (ignore any MP info or notes)
- if "x" not in dimensions_part:
- raise ValueError(f"No dimensions found in: {dimensions_part}")
-
- width_str, height_str = dimensions_part.split("x")
-
- target_width = int(width_str.strip())
- target_height = int(height_str.strip())
-
- # Ensure dimensions are multiples of 64 for FLUX compatibility
- target_width = round_to_nearest_multiple(target_width, 64)
- target_height = round_to_nearest_multiple(target_height, 64)
-
- print(f"[BawkSampler] Parsed resolution: {target_width}x{target_height} from preset '{resolution_preset}'")
- return target_width, target_height
-
- except Exception as e:
- print(f"[BawkSampler] Warning: Could not parse resolution '{resolution_preset}': {e}")
- # Fallback to FLUX optimal resolution (within official 2.0MP limit)
- print(f"[BawkSampler] Using fallback: 1024x1024 (FLUX optimal)")
- return 1024, 1024
-
- def _perform_flux_sampling(
- self, model, conditioning, latent_image, seed, sampler, scheduler,
- steps, guidance, max_shift, base_shift, denoise
- ):
- """Perform FLUX-optimized sampling"""
-
- # Single parameter sampling (no multi-parameter sweeps for cleaner UX)
- noise_seed = seed
-
- # Initialize FLUX sampling components
- basicscheduler = BasicScheduler()
- basicguider = BasicGuider()
- samplercustomadvanced = SamplerCustomAdvanced()
- modelsamplingflux = ModelSamplingFlux()
- randnoise = Noise_RandomNoise(noise_seed)
-
- # Get dimensions for model sampling
- width = latent_image["samples"].shape[3] * 8
- height = latent_image["samples"].shape[2] * 8
-
- # Apply FLUX model sampling
- work_model = modelsamplingflux.patch(model, max_shift, base_shift, width, height)[0]
-
- # Set guidance in conditioning
- cond = conditioning_set_values(conditioning, {"guidance": guidance})
- guider = basicguider.get_guider(work_model, cond)[0]
-
- # Create sampler object
- samplerobj = comfy.samplers.sampler_object(sampler)
-
- # Generate sigmas
- sigmas = basicscheduler.get_sigmas(work_model, scheduler, steps, denoise)[0]
-
- logging.info(f"FLUX Sampling: seed={noise_seed}, {sampler}_{scheduler}, "
- f"steps={steps}, guidance={guidance}, max_shift={max_shift}, base_shift={base_shift}")
-
- # Perform FLUX sampling
- latent = samplercustomadvanced.sample(
- randnoise, guider, samplerobj, sigmas, latent_image
- )[1]
-
- print(f"[BawkSampler] Completed FLUX sampling with {sampler}_{scheduler}")
- return latent
-
- def _decode_latent_to_images(self, vae, latent):
- """Decode latent to images using the provided VAE"""
- try:
- print(f"[BawkSampler] Decoding latent to images...")
-
- # Decode latent using VAE
- decoded_images = vae.decode(latent["samples"])
-
- print(f"[BawkSampler] Successfully decoded latent to images, shape: {decoded_images.shape}")
- return decoded_images
-
- except Exception as e:
- error_msg = f"Failed to decode latent: {str(e)}"
- print(f"[BawkSampler] Error: {error_msg}")
- raise RuntimeError(error_msg)
-
- def _validate_parameters_with_feedback(
- self, model, batch_size, steps, guidance, max_shift, base_shift,
- resolution, use_custom_resolution, custom_width, custom_height, is_img2img=False, denoise=1.0
- ):
- """Smart validation with user-friendly feedback and recommendations"""
-
- # Validate batch size for performance
- if batch_size > 16:
- print(f"[BawkSampler] โ ๏ธ WARNING: Large batch size ({batch_size}) may cause memory issues. Consider reducing to 8-16 for better stability.")
- elif batch_size > 8:
- print(f"[BawkSampler] โน๏ธ INFO: Batch size {batch_size} is large. Monitor memory usage during generation.")
-
- # Validate steps for quality/performance balance (based on community research)
- if steps < 20:
- print(f"[BawkSampler] โ ๏ธ WARNING: Low step count ({steps}) may result in poor quality. Community consensus: 25-30 steps optimal.")
- elif steps > 50:
- print(f"[BawkSampler] โน๏ธ INFO: High step count ({steps}) will increase generation time. Sweet spot: 25-30 steps for quality/speed.")
- elif 25 <= steps <= 30:
- print(f"[BawkSampler] โ
OPTIMAL: {steps} steps is in the community-tested sweet spot (25-30) for best quality/time balance.")
- elif 20 <= steps <= 35:
- print(f"[BawkSampler] โ
GOOD: {steps} steps will produce high-quality results with reasonable generation time.")
-
- # Validate guidance scale for FLUX
- if guidance < 1.0:
- print(f"[BawkSampler] โ ๏ธ WARNING: Very low guidance ({guidance}) may ignore your prompt. Recommended: 3.0-7.0 for FLUX.")
- elif guidance > 10.0:
- print(f"[BawkSampler] โ ๏ธ WARNING: Very high guidance ({guidance}) may cause artifacts. Recommended: 3.0-7.0 for FLUX.")
-
- # Validate shift parameters for FLUX
- if max_shift > 1.5:
- print(f"[BawkSampler] โ ๏ธ WARNING: High max_shift ({max_shift}) may cause instability. Recommended: 0.3-1.0 for FLUX.")
- if base_shift > max_shift:
- print(f"[BawkSampler] โ ๏ธ WARNING: base_shift ({base_shift}) should be โค max_shift ({max_shift}). Consider adjusting values.")
-
- # Validate resolution against official FLUX limits and provide recommendations
- if use_custom_resolution:
- total_pixels = custom_width * custom_height
- megapixels = total_pixels / 1_000_000
-
- # Official FLUX stability zones
- if megapixels > 4.0:
- print(f"[BawkSampler] ๐จ EXTREME: Resolution ({custom_width}x{custom_height}, {megapixels:.1f}MP) far exceeds FLUX capabilities.")
- print(f"[BawkSampler] ๐ก RECOMMENDATION: Use 1408x1408 (2.0MP official max) + Flux-dev-Upscaler for better results.")
- elif megapixels > 2.0:
- print(f"[BawkSampler] โ ๏ธ BEYOND OFFICIAL LIMIT: Resolution ({custom_width}x{custom_height}, {megapixels:.1f}MP) exceeds FLUX's 2.0MP limit.")
- print(f"[BawkSampler] โ ๏ธ WARNING: Expect instability, blurriness, or artifacts. Consider post-processing upscaler.")
- print(f"[BawkSampler] ๐ก STABLE ALTERNATIVE: Use 1408x1408 (2.0MP) or 1024x1024 (1.0MP) for reliable results.")
- elif megapixels >= 1.8:
- print(f"[BawkSampler] โ ๏ธ NEAR LIMIT: Resolution ({custom_width}x{custom_height}, {megapixels:.1f}MP) approaches FLUX's 2.0MP stability limit.")
- print(f"[BawkSampler] โน๏ธ INFO: Monitor for quality issues. 1024x1024 (1.0MP) is more reliable.")
- elif megapixels < 0.15:
- print(f"[BawkSampler] โน๏ธ MINIMUM: Resolution ({custom_width}x{custom_height}, {megapixels:.1f}MP) near FLUX minimum (0.1MP).")
- print(f"[BawkSampler] โน๏ธ INFO: Good for rapid prototyping but limited detail.")
- elif 0.8 <= megapixels <= 1.2:
- print(f"[BawkSampler] โ
OPTIMAL ZONE: Resolution ({custom_width}x{custom_height}, {megapixels:.1f}MP) in FLUX sweet spot.")
-
- # Check if dimensions are 64-pixel aligned (critical for FLUX)
- if custom_width % 64 != 0 or custom_height % 64 != 0:
- print(f"[BawkSampler] โ ๏ธ WARNING: Dimensions not 64-pixel aligned. FLUX requires multiples of 64 for stability.")
- aligned_w = round_to_nearest_multiple(custom_width, 64)
- aligned_h = round_to_nearest_multiple(custom_height, 64)
- print(f"[BawkSampler] ๐ก CORRECTION: Use {aligned_w}x{aligned_h} for proper FLUX compatibility.")
+ if cfg > 1.0:
+ guider = comfy.samplers.CFGGuider(work_model)
+ guider.set_conds(cond, _zero_out(cond))
+ guider.set_cfg(cfg)
else:
- # Provide specific advice for preset choices based on official FLUX limits
- if "โ ๏ธ Extreme" in resolution:
- print(f"[BawkSampler] ๐จ EXTREME PRESET: This resolution exceeds FLUX capabilities and will likely produce poor results.")
- print(f"[BawkSampler] ๐ก BETTER APPROACH: Generate at 1408x1408 and use Flux-dev-Upscaler for higher resolution.")
- elif "โ ๏ธ Experimental" in resolution:
- print(f"[BawkSampler] โ ๏ธ EXPERIMENTAL: This resolution exceeds FLUX's 2.0MP official limit. Expect potential issues.")
- print(f"[BawkSampler] ๐ก STABLE ALTERNATIVE: Consider 'FLUX Max Square' (1408x1408) for reliable results.")
- elif "Official maximum" in resolution:
- print(f"[BawkSampler] โ
OFFICIAL MAX: Using FLUX's maximum stable resolution (2.0MP). Excellent quality expected.")
- elif "Near official limit" in resolution:
- print(f"[BawkSampler] โ ๏ธ NEAR LIMIT: Close to FLUX's stability boundary. Monitor for quality issues.")
- elif "Recommended" in resolution:
- print(f"[BawkSampler] โ
OPTIMAL: 1024x1024 is the proven sweet spot for FLUX (15-30s on RTX 4090).")
- elif "Draft mode" in resolution:
- print(f"[BawkSampler] โ
MINIMUM: Perfect for rapid prototyping (~5s generation, limited detail).")
- elif "0.8MP" in resolution and "Fast" in resolution:
- print(f"[BawkSampler] โ
FAST & STABLE: Good balance for quick generation (1-2min on mid-range GPUs).")
+ guider = Guider_Basic(work_model)
+ guider.set_conds(cond)
- # Img2Img specific validation
- if is_img2img:
- if denoise == 1.0:
- print(f"[BawkSampler] โน๏ธ IMG2IMG: Denoise at 1.0 will completely replace input image. Consider 0.6-0.9 for image modification.")
- elif denoise < 0.3:
- print(f"[BawkSampler] โน๏ธ IMG2IMG: Very low denoise ({denoise}) may result in minimal changes to input image.")
- elif denoise > 0.9:
- print(f"[BawkSampler] โน๏ธ IMG2IMG: High denoise ({denoise}) will heavily modify the input image.")
- else:
- print(f"[BawkSampler] โ
IMG2IMG: Good denoise strength ({denoise}) for image modification.")
+ sigmas = _sigmas(work_model, family, scheduler, steps, denoise, width, height)
+ sampler_object = comfy.samplers.sampler_object(sampler)
+ noise = Noise_RandomNoise(seed)
- # Memory estimation and recommendations
- self._estimate_memory_usage(batch_size, resolution, use_custom_resolution, custom_width, custom_height)
+ logging.info(
+ f"[BawkSampler] {family} {width}x{height} x{batch_size} seed={seed} steps={steps} "
+ f"guidance={guidance} cfg={cfg} {sampler}/{scheduler} denoise={denoise} refs={len(references)}"
+ )
+ out = SamplerCustomAdvanced().sample(noise, guider, sampler_object, sigmas, latent)[0]
- mode_str = "img2img" if is_img2img else "text-to-image"
- print(f"[BawkSampler] โ
Validation complete. Proceeding with {mode_str} generation...")
+ images = vae.decode(out["samples"])
+ if len(images.shape) == 5:
+ images = images.reshape(-1, images.shape[-3], images.shape[-2], images.shape[-1])
+ return (images, out)
- def _estimate_memory_usage(self, batch_size, resolution, use_custom_resolution, custom_width, custom_height):
- """Estimate and report memory usage"""
- try:
- # Get dimensions
- if use_custom_resolution:
- width, height = custom_width, custom_height
- else:
- width, height = self._parse_resolution_preset(resolution)
-
- # FLUX latent calculations (16 channels, 8x downscale)
- latent_width = width // 8
- latent_height = height // 8
-
- # Estimate memory usage (rough calculation)
- # Latent: batch_size * 16 * latent_height * latent_width * 4 bytes (fp32)
- latent_memory_mb = (batch_size * 16 * latent_height * latent_width * 4) / (1024 * 1024)
-
- # Final image: batch_size * 3 * height * width * 4 bytes (fp32)
- image_memory_mb = (batch_size * 3 * height * width * 4) / (1024 * 1024)
-
- total_estimated_mb = latent_memory_mb + image_memory_mb
-
- if total_estimated_mb > 2048: # > 2GB
- print(f"[BawkSampler] โ ๏ธ MEMORY: Estimated usage ~{total_estimated_mb:.0f}MB. Consider reducing batch size or resolution.")
- elif total_estimated_mb > 1024: # > 1GB
- print(f"[BawkSampler] โน๏ธ MEMORY: Estimated usage ~{total_estimated_mb:.0f}MB. Monitor VRAM during generation.")
- else:
- print(f"[BawkSampler] โ
MEMORY: Estimated usage ~{total_estimated_mb:.0f}MB. Should run smoothly.")
-
- except Exception as e:
- print(f"[BawkSampler] Could not estimate memory usage: {str(e)}")
-
- def _get_error_with_solution(self, error_msg: str):
- """Generate helpful error messages with suggested solutions"""
- error_lower = error_msg.lower()
-
- # Memory-related errors
- if "out of memory" in error_lower or "cuda out of memory" in error_lower:
- return (
- "GPU memory exhausted during generation",
- "Try reducing batch size, using a smaller resolution preset, or closing other GPU applications"
- )
-
- # Model loading errors
- if "model" in error_lower and ("load" in error_lower or "file" in error_lower):
- return (
- "Failed to load model",
- "Ensure the model file exists and is a valid FLUX checkpoint. Check file permissions and disk space"
- )
-
- # Dimension/tensor errors
- if "size mismatch" in error_lower or "dimension" in error_lower:
- return (
- "Tensor dimension mismatch",
- "This usually indicates model incompatibility. Ensure you're using a FLUX model, not SD1.5/SDXL"
- )
-
- # CUDA/device errors
- if "cuda" in error_lower and "device" in error_lower:
- return (
- "GPU device error",
- "Check CUDA installation, update GPU drivers, or switch to CPU mode if GPU issues persist"
- )
-
- # Resolution parsing errors
- if "resolution" in error_lower or "parse" in error_lower:
- return (
- "Resolution parsing failed",
- "Use a preset resolution or ensure custom dimensions are multiples of 64"
- )
-
- # VAE decoding errors
- if "vae" in error_lower or "decode" in error_lower:
- return (
- "VAE decoding failed",
- "Ensure VAE is compatible with FLUX. Try using the built-in FLUX VAE or check VAE file integrity"
- )
-
- # Sampling errors
- if "sampl" in error_lower or "noise" in error_lower:
- return (
- "Sampling process failed",
- "Try different sampler/scheduler combinations, reduce steps, or adjust guidance scale (3.0-7.0 recommended)"
- )
-
- # File permission errors
- if "permission" in error_lower or "access" in error_lower:
- return (
- "File access permission denied",
- "Check file permissions, ensure ComfyUI has write access to output directory, or run as administrator"
- )
-
- # Network/download errors
- if "download" in error_lower or "network" in error_lower or "connection" in error_lower:
- return (
- "Network or download error",
- "Check internet connection, verify URLs, or try downloading models manually"
- )
-
- # Generic fallback
- return (error_msg, "Check the console for detailed error information and ensure all inputs are valid")
-
- def _encode_image_to_latent(self, vae, input_image, batch_size):
- """Encode input image to latent for img2img processing"""
- try:
- print(f"[BawkSampler] Encoding input image to latent space...")
-
- # Handle batch size adjustment
- if len(input_image.shape) == 4: # Batch dimension exists
- image_batch_size = input_image.shape[0]
- if image_batch_size == 1 and batch_size > 1:
- # Repeat single image for batch
- input_image = input_image.repeat(batch_size, 1, 1, 1)
- print(f"[BawkSampler] Expanded single input image to batch size {batch_size}")
- elif image_batch_size != batch_size:
- # Use first image and repeat if needed
- input_image = input_image[0:1].repeat(batch_size, 1, 1, 1)
- print(f"[BawkSampler] Using first image from batch, expanded to batch size {batch_size}")
-
- # Encode image to latent using VAE
- latent_samples = vae.encode(input_image)
-
- # Create latent dictionary
- latent = {"samples": latent_samples}
-
- image_h, image_w = input_image.shape[-2:]
- latent_h, latent_w = latent_samples.shape[-2:]
- print(f"[BawkSampler] Encoded image {image_w}x{image_h} to latent {latent_w}x{latent_h}")
-
- return latent
-
- except Exception as e:
- error_msg = f"Failed to encode input image to latent: {str(e)}"
- print(f"[BawkSampler] Error: {error_msg}")
- raise RuntimeError(error_msg)
\ No newline at end of file
+ def _target_size(self, resolution, custom_width, custom_height, references, family, align):
+ if resolution == CUSTOM:
+ width, height = custom_width, custom_height
+ elif resolution == MATCH_IMAGE:
+ if not references:
+ raise ValueError(f"'{MATCH_IMAGE}' needs input_image or reference_image")
+ return _reference_size(references[0], family, align)
+ else:
+ match = DIMS_RE.search(resolution)
+ width, height = int(match.group(1)), int(match.group(2))
+ return _round(width, align), _round(height, align)
diff --git a/nodes/diffusion_model_loader.py b/nodes/diffusion_model_loader.py
index 8194635..d951a00 100644
--- a/nodes/diffusion_model_loader.py
+++ b/nodes/diffusion_model_loader.py
@@ -1,166 +1,112 @@
"""
-DiffusionModelLoader Node
-File: nodes/diffusion_model_loader.py
+DiffusionModelLoader - FLUX.1 / FLUX.2 model, VAE and text encoder loader.
+Supports .safetensors natively and .gguf through ComfyUI-GGUF when installed.
"""
import os
+
import torch
import comfy.sd
import comfy.utils
import folder_paths
-from typing import Tuple, Any
-# Try importing validation from parent modules
-try:
- from ..modules.validation import ValidationMixin
-except ImportError:
- # Fallback if modules structure is different
- class ValidationMixin:
- pass
+WEIGHT_DTYPES = {
+ "default": {},
+ "fp8_e4m3fn": {"dtype": torch.float8_e4m3fn},
+ "fp8_e4m3fn_fast": {"dtype": torch.float8_e4m3fn, "fp8_optimizations": True},
+ "fp8_e5m2": {"dtype": torch.float8_e5m2},
+}
-class DiffusionModelLoader(ValidationMixin):
- """
- Advanced Diffusion Model Loader with support for:
- - Multiple model formats (FLUX, SDXL, SD1.5)
- - Flexible weight data types (fp8, fp16, bf16, fp32)
- - Separate VAE and CLIP loading
- - Multiple directory support
- """
-
+def _file_list(folder, gguf_folder):
+ files = list(folder_paths.get_filename_list(folder))
+ if gguf_folder in folder_paths.folder_names_and_paths:
+ files += [f for f in folder_paths.get_filename_list(gguf_folder) if f not in files]
+ return files
+
+
+def _is_gguf(name):
+ return name.lower().endswith(".gguf")
+
+
+def _gguf_node(class_name):
+ import nodes as comfy_nodes
+ cls = comfy_nodes.NODE_CLASS_MAPPINGS.get(class_name)
+ if cls is None:
+ raise RuntimeError("Loading .gguf files needs the ComfyUI-GGUF custom node pack (city96/ComfyUI-GGUF)")
+ return cls()
+
+
+def _clip_type(dual):
+ if dual:
+ return comfy.sd.CLIPType.FLUX
+ clip_type = getattr(comfy.sd.CLIPType, "FLUX2", None)
+ if clip_type is None:
+ raise RuntimeError("FLUX.2 text encoders need a newer ComfyUI (CLIPType.FLUX2 missing)")
+ return clip_type
+
+
+class DiffusionModelLoader:
@classmethod
def INPUT_TYPES(cls):
+ text_encoders = _file_list("text_encoders", "clip_gguf")
return {
"required": {
- "model_name": (folder_paths.get_filename_list("diffusion_models"), {
- "tooltip": "FLUX diffusion model file from models/diffusion_models/"
+ "model_name": (_file_list("diffusion_models", "unet_gguf"), {
+ "tooltip": "FLUX diffusion model from models/diffusion_models (.safetensors or .gguf)",
}),
- "vae_name": (folder_paths.get_filename_list("vae") + ["baked VAE"], {
- "tooltip": "VAE model for encoding/decoding. Use 'baked VAE' if model includes VAE"
+ "vae_name": (folder_paths.get_filename_list("vae"), {
+ "tooltip": "ae.safetensors for FLUX.1, flux2-vae.safetensors for FLUX.2",
}),
- "clip_name1": (folder_paths.get_filename_list("text_encoders") + ["none"], {
- "tooltip": "Primary text encoder (T5-XXL for FLUX)"
+ "clip_name1": (text_encoders, {
+ "tooltip": "FLUX.1: t5xxl. FLUX.2-dev: mistral_3_small. FLUX.2-klein: qwen_3_4b (4B) / qwen_3_8b (9B)",
}),
- "clip_name2": (folder_paths.get_filename_list("text_encoders") + ["none"], {
- "tooltip": "Secondary text encoder (CLIP-L for FLUX)"
+ "clip_name2": (["none"] + text_encoders, {
+ "tooltip": "FLUX.1: clip_l. Leave 'none' for FLUX.2 models (single text encoder)",
}),
- "weight_dtype": (["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"], {
- "tooltip": "Model precision. FP8 reduces VRAM ~50% with minimal quality loss"
+ "weight_dtype": (list(WEIGHT_DTYPES), {
+ "tooltip": "Diffusion model precision. fp8 halves VRAM. Ignored for .gguf files",
}),
}
}
-
+
RETURN_TYPES = ("MODEL", "VAE", "CLIP", "STRING")
RETURN_NAMES = ("MODEL", "VAE", "CLIP", "MODEL_STRING")
- FUNCTION = "load_diffusion_model"
+ FUNCTION = "load"
CATEGORY = "BawkNodes/loaders"
- DESCRIPTION = "๐ Advanced FLUX model loader with separate VAE/CLIP loading and FP8 quantization support"
-
- @classmethod
- def VALIDATE_INPUTS(cls, **kwargs):
- """Simple validation matching the working loader pattern"""
- errors = []
-
- # Validate model exists in diffusion_models directory
- model_name = kwargs.get("model_name")
- if model_name:
- try:
- folder_paths.get_full_path_or_raise("diffusion_models", model_name)
- except:
- errors.append(f"Model '{model_name}' not found in diffusion_models directory")
-
- # Validate VAE if specified
- vae_name = kwargs.get("vae_name", "baked VAE")
- if vae_name and vae_name != "baked VAE":
- try:
- folder_paths.get_full_path_or_raise("vae", vae_name)
- except:
- errors.append(f"VAE '{vae_name}' not found in vae directory")
-
- # Validate CLIP models if specified
- clip1 = kwargs.get("clip_name1", "none")
- if clip1 and clip1 != "none":
- try:
- folder_paths.get_full_path_or_raise("text_encoders", clip1)
- except:
- errors.append(f"CLIP model '{clip1}' not found in text_encoders directory")
-
- clip2 = kwargs.get("clip_name2", "none")
- if clip2 and clip2 != "none":
- try:
- folder_paths.get_full_path_or_raise("text_encoders", clip2)
- except:
- errors.append(f"CLIP model '{clip2}' not found in text_encoders directory")
-
- return "; ".join(errors) if errors else True
-
- def load_diffusion_model(
- self,
- model_name: str,
- weight_dtype: str = "default",
- vae_name: str = "baked VAE",
- clip_name1: str = "none",
- clip_name2: str = "none"
- ) -> Tuple[Any, Any, Any, str]:
- """
- Main loading function for diffusion models
- """
- try:
- # Handle "none" and "baked VAE" values
- vae_name = vae_name if vae_name and vae_name != "baked VAE" else None
- clip_name1 = clip_name1 if clip_name1 and clip_name1 != "none" else None
- clip_name2 = clip_name2 if clip_name2 and clip_name2 != "none" else None
- weight_dtype = weight_dtype if weight_dtype else "default"
-
- # Set up model options based on weight_dtype
- model_options = {}
- if weight_dtype == "fp8_e4m3fn":
- model_options["dtype"] = torch.float8_e4m3fn
- elif weight_dtype == "fp8_e4m3fn_fast":
- model_options["dtype"] = torch.float8_e4m3fn
- model_options["fp8_optimizations"] = True
- elif weight_dtype == "fp8_e5m2":
- model_options["dtype"] = torch.float8_e5m2
-
- # Load diffusion model
- print(f"[DiffusionModelLoader] Loading diffusion model: {model_name}")
- model_path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
- model = comfy.sd.load_diffusion_model(model_path, model_options=model_options)
-
- if model is None:
- raise RuntimeError("Failed to load diffusion model - output was None")
-
- # Load VAE
- vae = None
- if vae_name:
- print(f"[DiffusionModelLoader] Loading VAE: {vae_name}")
- vae_path = folder_paths.get_full_path_or_raise("vae", vae_name)
- vae_sd = comfy.utils.load_torch_file(vae_path)
- vae = comfy.sd.VAE(sd=vae_sd)
-
- # Load CLIP models
- clip_paths = []
- if clip_name1:
- clip_paths.append(folder_paths.get_full_path_or_raise("text_encoders", clip_name1))
- if clip_name2:
- clip_paths.append(folder_paths.get_full_path_or_raise("text_encoders", clip_name2))
-
- clip = None
- if clip_paths:
- print(f"[DiffusionModelLoader] Loading CLIP models")
- clip = comfy.sd.load_clip(
- ckpt_paths=clip_paths,
- embedding_directory=folder_paths.get_folder_paths("embeddings"),
- clip_type=comfy.sd.CLIPType.FLUX
- )
-
- # Generate simple model name string
- model_string = os.path.splitext(model_name)[0]
-
- print(f"[DiffusionModelLoader] Successfully loaded diffusion model: {model_string}")
- return (model, vae, clip, model_string)
-
- except Exception as e:
- error_msg = f"Failed to load diffusion model '{model_name}': {str(e)}"
- print(f"[DiffusionModelLoader] Error: {error_msg}")
- raise RuntimeError(error_msg)
\ No newline at end of file
+ DESCRIPTION = "Load a FLUX.1 / FLUX.2 diffusion model, VAE and text encoder(s) in one node. GGUF supported via ComfyUI-GGUF."
+
+ def load(self, model_name, vae_name, clip_name1, clip_name2="none", weight_dtype="default"):
+ model = self._load_model(model_name, weight_dtype)
+ vae = self._load_vae(vae_name)
+ clip = self._load_clip(clip_name1, None if clip_name2 == "none" else clip_name2)
+ model_string = os.path.splitext(os.path.basename(model_name))[0]
+ return (model, vae, clip, model_string)
+
+ def _load_model(self, model_name, weight_dtype):
+ if _is_gguf(model_name):
+ return _gguf_node("UnetLoaderGGUF").load_unet(model_name)[0]
+ path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
+ model = comfy.sd.load_diffusion_model(path, model_options=dict(WEIGHT_DTYPES[weight_dtype]))
+ if model is None:
+ raise RuntimeError(f"Could not detect model type of '{model_name}'")
+ return model
+
+ def _load_vae(self, vae_name):
+ path = folder_paths.get_full_path_or_raise("vae", vae_name)
+ sd, metadata = comfy.utils.load_torch_file(path, return_metadata=True)
+ return comfy.sd.VAE(sd=sd, metadata=metadata)
+
+ def _load_clip(self, clip_name1, clip_name2):
+ names = [n for n in (clip_name1, clip_name2) if n]
+ dual = len(names) == 2
+ if any(_is_gguf(n) for n in names):
+ if dual:
+ return _gguf_node("DualCLIPLoaderGGUF").load_clip(names[0], names[1], "flux")[0]
+ return _gguf_node("CLIPLoaderGGUF").load_clip(names[0], "flux2")[0]
+ paths = [folder_paths.get_full_path_or_raise("text_encoders", n) for n in names]
+ return comfy.sd.load_clip(
+ ckpt_paths=paths,
+ embedding_directory=folder_paths.get_folder_paths("embeddings"),
+ clip_type=_clip_type(dual),
+ )
diff --git a/nodes/flux_dynamic_lora.py b/nodes/flux_dynamic_lora.py
deleted file mode 100644
index 1724895..0000000
--- a/nodes/flux_dynamic_lora.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""
-FluxDynamicLoraStack - Dynamic LoRA loading with add/remove controls
-File: nodes/flux_dynamic_lora.py
-"""
-
-import folder_paths
-from typing import Tuple, Any
-
-
-class FluxDynamicLoraStack:
- """
- Dynamic LoRA stack that allows adding/removing LoRA slots on demand.
- Outputs a list of (lora_name, strength) tuples that can be applied to a model.
- """
-
- @classmethod
- def INPUT_TYPES(cls):
- lora_list = folder_paths.get_filename_list("loras")
-
- return {
- "required": {
- "lora_count": ("INT", {
- "default": 1,
- "min": 0,
- "max": 20,
- "step": 1,
- "tooltip": "Number of LoRA slots (change this to add/remove slots dynamically)"
- }),
- },
- "optional": {
- # First LoRA (always present)
- "lora_1": (["None"] + lora_list, {"default": "None"}),
- "strength_1": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
- }
- }
-
- RETURN_TYPES = ("LORA_STACK",)
- RETURN_NAMES = ("lora_stack",)
- FUNCTION = "build_lora_stack"
- CATEGORY = "BawkNodes/loaders"
- DESCRIPTION = "๐ Dynamic LoRA stack builder - adjust lora_count to add/remove up to 20 LoRA slots on demand"
-
- def build_lora_stack(self, lora_count=1, **kwargs):
- """
- Build a list of (lora_name, strength) tuples from the dynamic inputs
- """
- lora_stack = []
-
- for i in range(1, lora_count + 1):
- lora_name = kwargs.get(f"lora_{i}", "None")
- strength = kwargs.get(f"strength_{i}", 1.0)
-
- if lora_name and lora_name != "None":
- lora_stack.append((lora_name, strength))
- print(f"[FluxDynamicLoraStack] Added LoRA {i}: {lora_name} @ {strength}")
-
- print(f"[FluxDynamicLoraStack] Built stack with {len(lora_stack)} LoRAs")
- return (lora_stack,)
-
-
-class FluxLoraStackApply:
- """
- Applies a LoRA stack to a model and CLIP
- """
-
- @classmethod
- def INPUT_TYPES(cls):
- return {
- "required": {
- "model": ("MODEL",),
- "clip": ("CLIP",),
- "lora_stack": ("LORA_STACK",),
- }
- }
-
- RETURN_TYPES = ("MODEL", "CLIP")
- RETURN_NAMES = ("MODEL", "CLIP")
- FUNCTION = "apply_lora_stack"
- CATEGORY = "BawkNodes/loaders"
- DESCRIPTION = "โก Apply LoRA stack to model and CLIP - processes all LoRAs from FluxDynamicLoraStack"
-
- def apply_lora_stack(self, model, clip, lora_stack):
- """
- Apply all LoRAs in the stack to the model and CLIP
- """
- if not lora_stack or len(lora_stack) == 0:
- print("[FluxLoraStackApply] No LoRAs in stack, returning unchanged")
- return (model, clip)
-
- # Import LoRA loading utilities
- import comfy.utils
- import comfy.sd
-
- for lora_name, strength in lora_stack:
- lora_path = folder_paths.get_full_path("loras", lora_name)
-
- if lora_path is None:
- print(f"[FluxLoraStackApply] WARNING: LoRA not found: {lora_name}")
- continue
-
- print(f"[FluxLoraStackApply] Loading LoRA: {lora_name} @ strength {strength}")
-
- # Load LoRA
- lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
-
- # Apply to model and clip
- model, clip = comfy.sd.load_lora_for_models(
- model, clip, lora, strength, strength
- )
-
- print(f"[FluxLoraStackApply] Applied {len(lora_stack)} LoRAs")
- return (model, clip)
-
-
-# Export nodes
-__all__ = ['FluxDynamicLoraStack', 'FluxLoraStackApply']
diff --git a/nodes/flux_image_saver.py b/nodes/flux_image_saver.py
index 0092704..6e91631 100644
--- a/nodes/flux_image_saver.py
+++ b/nodes/flux_image_saver.py
@@ -1,310 +1,156 @@
"""
-FluxImageSaver Node
-File: nodes/flux_image_saver.py
+FluxImageSaver - organized saving with embedded workflow metadata and optional Discord webhook.
"""
-import os
import json
+import logging
+import os
import re
-import numpy as np
-import folder_paths
-import requests
-import io
-import base64
from datetime import datetime
-from typing import Dict, Any, List
+
+import numpy as np
+import requests
+import folder_paths
+from comfy.cli_args import args
+from PIL import Image
+from PIL.PngImagePlugin import PngInfo
+
+CONTENT_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp"}
+
+
+def _clean_name(value):
+ value = re.sub(r"\.[^.]*$", "", value)
+ value = re.sub(r'[<>:"/\\|?*]', "_", value)
+ return re.sub(r"_{2,}", "_", value).strip("_")
+
+
+def _unique_path(directory, stem, ext):
+ path = os.path.join(directory, f"{stem}.{ext}")
+ counter = 1
+ while os.path.exists(path):
+ path = os.path.join(directory, f"{stem}_{counter:03d}.{ext}")
+ counter += 1
+ return path
class FluxImageSaver:
- """
- FLUX-optimized image saver with organized folder structure and metadata support
- """
-
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
- "model_string": ("STRING", {"forceInput": True}),
- "processed_prompt": ("STRING", {"forceInput": True}), # Connect from wildcard encoder
- "prefix": ("STRING", {
- "default": "flux_image",
- "dynamicPrompts": False
- }),
+ "prefix": ("STRING", {"default": "flux_image", "dynamicPrompts": False}),
"format": (["png", "jpg", "webp"], {"default": "png"}),
- "quality": ("INT", {"default": 95, "min": 1, "max": 100}),
- "save_metadata": ("BOOLEAN", {"default": True}),
- "save_prompt": ("BOOLEAN", {"default": True, "tooltip": "Save the processed prompt as a separate text file"}),
- "send_to_discord": ("BOOLEAN", {"default": False, "tooltip": "Send images to Discord via webhook"}),
- "discord_webhook_url": ("STRING", {
- "default": "",
- "dynamicPrompts": False,
- "tooltip": "Discord webhook URL (only used when send_to_discord is True)"
+ "quality": ("INT", {"default": 95, "min": 1, "max": 100, "tooltip": "jpg / webp quality"}),
+ "save_metadata": ("BOOLEAN", {
+ "default": True,
+ "tooltip": "Embed the workflow in PNG files (drag back into ComfyUI). jpg / webp get a .json sidecar",
}),
+ "save_prompt": ("BOOLEAN", {"default": True, "tooltip": "Also write the final prompt to a .txt file"}),
+ "send_to_discord": ("BOOLEAN", {"default": False, "tooltip": "Post the images to a Discord webhook"}),
+ "discord_webhook_url": ("STRING", {"default": "", "dynamicPrompts": False}),
},
- "hidden": {
- "prompt_hidden": "PROMPT",
- "extra_pnginfo": "EXTRA_PNGINFO"
- }
+ "optional": {
+ "model_string": ("STRING", {
+ "forceInput": True, "tooltip": "MODEL_STRING from Bawk Model Loader (used for the folder name)",
+ }),
+ "processed_prompt": ("STRING", {"forceInput": True, "tooltip": "PROMPT_OUT from Bawk Wildcard Encoder"}),
+ },
+ "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
-
+
RETURN_TYPES = ()
- RETURN_NAMES = ()
FUNCTION = "save_images"
CATEGORY = "BawkNodes/image"
OUTPUT_NODE = True
- DESCRIPTION = "๐พ Organized image saving with metadata, prompt files, and Discord webhook support"
-
- def __init__(self):
- self.output_dir = folder_paths.get_output_directory()
- self.type = "output"
-
- def save_images(self, images, model_string, processed_prompt, prefix="flux_image",
- format="png", quality=95, save_metadata=True, save_prompt=True,
- send_to_discord=False, discord_webhook_url="",
- prompt_hidden=None, extra_pnginfo=None):
- """
- Save images with FLUX-optimized organization and metadata
- """
- try:
- from PIL import Image
-
- print(f"[FluxImageSaver] Processing {len(images)} images")
-
- # Get current date for folder and filename
- now = datetime.now()
- date_str = now.strftime("%d-%m-%Y")
- datetime_str = now.strftime("%d-%m-%Y_%H-%M-%S")
-
- # Clean model string for folder name
- clean_model = self._clean_filename(model_string)
-
- # Create folder structure: [MODEL]-DD-MM-YYYY
- folder_name = f"[{clean_model}]-{date_str}"
- output_dir = os.path.join(folder_paths.get_output_directory(), folder_name)
- os.makedirs(output_dir, exist_ok=True)
-
- saved_paths = []
- results = [] # For UI image preview
-
- # Save the processed prompt as a text file if enabled
- if save_prompt and processed_prompt:
- self._save_prompt_file(output_dir, processed_prompt, datetime_str, prefix)
-
- for i, image_tensor in enumerate(images):
- print(f"[FluxImageSaver] Processing image {i+1}/{len(images)}, shape: {image_tensor.shape}")
-
- # Convert tensor to PIL Image
- image_np = (255.0 * image_tensor.cpu().numpy()).astype(np.uint8)
- pil_image = Image.fromarray(image_np)
-
- # Generate filename: prefix_DD-MM-YYYY_HH-MM-SS_001.ext
- batch_suffix = f"_{i+1:03d}" if len(images) > 1 else ""
- filename = f"{prefix}_{datetime_str}{batch_suffix}.{format}"
- filepath = os.path.join(output_dir, filename)
-
- # Check if file exists and add counter if necessary
- counter = 1
- original_filepath = filepath
- while os.path.exists(filepath):
- name_part = f"{prefix}_{datetime_str}{batch_suffix}_{counter:03d}"
- filename = f"{name_part}.{format}"
- filepath = os.path.join(output_dir, filename)
- counter += 1
-
- # Save image with format-specific options
- save_kwargs = {}
- if format.lower() == "jpg" or format.lower() == "jpeg":
- save_kwargs = {"quality": quality, "optimize": True}
- elif format.lower() == "webp":
- save_kwargs = {"quality": quality, "method": 6}
- elif format.lower() == "png":
- save_kwargs = {"optimize": True}
- # Add metadata to PNG
- if save_metadata and extra_pnginfo:
- from PIL.PngImagePlugin import PngInfo
- metadata = PngInfo()
- for key, value in extra_pnginfo.items():
- metadata.add_text(key, str(value))
- save_kwargs["pnginfo"] = metadata
-
- pil_image.save(filepath, format.upper(), **save_kwargs)
- saved_paths.append(filepath)
-
- # Add to results for UI preview (relative to output directory)
- results.append({
- "filename": filename,
- "subfolder": folder_name,
- "type": self.type
- })
-
- print(f"[FluxImageSaver] Saved: {filepath}")
-
- # Save metadata/prompt file if requested
- if save_metadata and prompt_hidden:
- self._save_metadata(filepath, prompt_hidden, extra_pnginfo,
- model_string, format, quality, processed_prompt)
-
- print(f"[FluxImageSaver] Returning {len(results)} images for preview")
+ DESCRIPTION = (
+ "Save images into [model]-date folders with embedded workflow metadata, prompt file and optional Discord upload."
+ )
- # Send to Discord if enabled
- if send_to_discord and discord_webhook_url.strip():
- try:
- self._send_to_discord(saved_paths, discord_webhook_url, processed_prompt, model_string)
- except Exception as e:
- print(f"[FluxImageSaver] Discord webhook failed: {str(e)}")
+ def save_images(self, images, prefix="flux_image", format="png", quality=95, save_metadata=True, save_prompt=True,
+ send_to_discord=False, discord_webhook_url="", model_string="", processed_prompt="",
+ prompt=None, extra_pnginfo=None):
+ now = datetime.now()
+ date_str = now.strftime("%d-%m-%Y")
+ stamp = now.strftime("%d-%m-%Y_%H-%M-%S")
- return {"ui": {"images": results}}
-
- except Exception as e:
- error_msg = f"Failed to save images: {str(e)}"
- print(f"[FluxImageSaver] Error: {error_msg}")
- import traceback
- traceback.print_exc()
- return {"ui": {"images": []}}
-
- def _clean_filename(self, filename):
- """Clean filename for use in folder/file names"""
- # Remove file extensions and clean up
- clean = re.sub(r'\.[^.]*$', '', filename) # Remove extension
- clean = re.sub(r'[<>:"/\\|?*]', '_', clean) # Replace invalid chars
- clean = re.sub(r'_{2,}', '_', clean) # Collapse multiple underscores
- return clean.strip('_')
-
- def _save_prompt_file(self, output_dir, processed_prompt, datetime_str, prefix):
- """Save the processed prompt as a text file"""
- try:
- # Create prompt filename
- prompt_filename = f"{prefix}_{datetime_str}_prompt.txt"
- prompt_filepath = os.path.join(output_dir, prompt_filename)
-
- # Check if file exists and add counter if necessary
- counter = 1
- while os.path.exists(prompt_filepath):
- prompt_filename = f"{prefix}_{datetime_str}_prompt_{counter:03d}.txt"
- prompt_filepath = os.path.join(output_dir, prompt_filename)
- counter += 1
-
- # Save the prompt
- with open(prompt_filepath, 'w', encoding='utf-8') as f:
+ folder_name = f"[{_clean_name(model_string)}]-{date_str}" if model_string else date_str
+ output_dir = os.path.join(folder_paths.get_output_directory(), folder_name)
+ os.makedirs(output_dir, exist_ok=True)
+ prefix = _clean_name(prefix) or "flux_image"
+
+ if save_prompt and processed_prompt:
+ with open(_unique_path(output_dir, f"{prefix}_{stamp}_prompt", "txt"), "w", encoding="utf-8") as f:
f.write(processed_prompt)
-
- print(f"[FluxImageSaver] Saved prompt: {prompt_filepath}")
-
- except Exception as e:
- print(f"[FluxImageSaver] Failed to save prompt file: {str(e)}")
-
- def _save_metadata(self, image_path, prompt_hidden, extra_pnginfo,
- model_string, format, quality, processed_prompt=None):
- """Save metadata as JSON file"""
- try:
- # Create metadata filename
- base_path = os.path.splitext(image_path)[0]
- metadata_path = f"{base_path}_metadata.json"
-
- # Collect metadata
- metadata = {
- "timestamp": datetime.now().isoformat(),
- "model": model_string,
- "processed_prompt": processed_prompt, # Add the processed prompt
- "format": format,
- "quality": quality if format != "png" else None,
- "workflow_prompt": prompt_hidden,
- "extra_info": extra_pnginfo
- }
-
- # Save as JSON
- with open(metadata_path, 'w', encoding='utf-8') as f:
- json.dump(metadata, f, indent=2, ensure_ascii=False)
-
- print(f"[FluxImageSaver] Metadata saved: {metadata_path}")
-
- except Exception as e:
- print(f"[FluxImageSaver] Failed to save metadata: {str(e)}")
- def _send_to_discord(self, image_paths: List[str], webhook_url: str, prompt: str, model: str):
- """Send images to Discord via webhook with batch support"""
- if not webhook_url or not image_paths:
- return
-
- print(f"[FluxImageSaver] Sending {len(image_paths)} images to Discord")
-
- # Prepare the embed with generation info
- embed = {
- "title": "๐จ New AI Generation",
- "description": f"**Model:** {model}\n**Prompt:** {prompt[:1000]}{'...' if len(prompt) > 1000 else ''}",
- "color": 0x5865F2, # Discord blurple
- "timestamp": datetime.now().isoformat(),
- "footer": {"text": "Generated with ComfyUI BawkNodes"}
- }
-
- # Discord has a limit of 10 files per message, so we'll batch them
- max_files_per_message = 10
- for i in range(0, len(image_paths), max_files_per_message):
- batch_paths = image_paths[i:i + max_files_per_message]
- self._send_discord_batch(webhook_url, batch_paths, embed if i == 0 else None, i // max_files_per_message + 1)
-
- def _send_discord_batch(self, webhook_url: str, image_paths: List[str], embed: dict = None, batch_num: int = 1):
- """Send a batch of images to Discord"""
- try:
- files = {}
- payload = {}
-
- # Add embed only to first batch
- if embed:
- payload["embeds"] = [embed]
-
- # Add batch info if multiple batches
- if batch_num > 1:
- payload["content"] = f"๐ Batch {batch_num} of images"
-
- # Prepare files for upload
- for idx, image_path in enumerate(image_paths):
- if not os.path.exists(image_path):
- print(f"[FluxImageSaver] Warning: Image file not found: {image_path}")
- continue
-
- try:
- with open(image_path, 'rb') as f:
- file_data = f.read()
-
- filename = os.path.basename(image_path)
- files[f'file{idx}'] = (filename, file_data, self._get_content_type(image_path))
-
- except Exception as e:
- print(f"[FluxImageSaver] Failed to read image {image_path}: {str(e)}")
- continue
-
- if not files:
- print(f"[FluxImageSaver] No valid images to send in batch {batch_num}")
- return
-
- # Send to Discord
- response = requests.post(
- webhook_url,
- data={"payload_json": json.dumps(payload)} if payload else None,
- files=files,
- timeout=30
- )
-
- if response.status_code == 200:
- print(f"[FluxImageSaver] Successfully sent batch {batch_num} with {len(files)} images to Discord")
+ pnginfo = None
+ sidecar = None
+ if save_metadata and not args.disable_metadata:
+ if format == "png":
+ pnginfo = PngInfo()
+ if prompt is not None:
+ pnginfo.add_text("prompt", json.dumps(prompt))
+ for key, value in (extra_pnginfo or {}).items():
+ pnginfo.add_text(key, json.dumps(value))
+ if processed_prompt:
+ pnginfo.add_text("bawk_prompt", processed_prompt)
+ if model_string:
+ pnginfo.add_text("bawk_model", model_string)
else:
- print(f"[FluxImageSaver] Discord webhook failed for batch {batch_num}: {response.status_code} - {response.text}")
+ sidecar = {
+ "timestamp": now.isoformat(),
+ "model": model_string,
+ "processed_prompt": processed_prompt,
+ "prompt": prompt,
+ "extra_pnginfo": extra_pnginfo,
+ }
- except requests.exceptions.RequestException as e:
- print(f"[FluxImageSaver] Network error sending to Discord: {str(e)}")
- except Exception as e:
- print(f"[FluxImageSaver] Unexpected error sending to Discord: {str(e)}")
+ saved_paths = []
+ results = []
+ for index, image in enumerate(images):
+ pil_image = Image.fromarray(np.clip(255.0 * image.cpu().numpy(), 0, 255).astype(np.uint8))
+ suffix = f"_{index + 1:03d}" if len(images) > 1 else ""
+ path = _unique_path(output_dir, f"{prefix}_{stamp}{suffix}", format)
- def _get_content_type(self, image_path: str) -> str:
- """Get the appropriate content type for the image"""
- ext = os.path.splitext(image_path)[1].lower()
- content_types = {
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.webp': 'image/webp',
- '.gif': 'image/gif'
+ if format == "png":
+ pil_image.save(path, pnginfo=pnginfo, compress_level=4)
+ elif format == "jpg":
+ pil_image.save(path, quality=quality, optimize=True)
+ else:
+ pil_image.save(path, quality=quality, method=6)
+
+ if sidecar is not None:
+ with open(os.path.splitext(path)[0] + ".json", "w", encoding="utf-8") as f:
+ json.dump(sidecar, f, indent=2, ensure_ascii=False)
+
+ saved_paths.append(path)
+ results.append({"filename": os.path.basename(path), "subfolder": folder_name, "type": "output"})
+
+ if send_to_discord and discord_webhook_url.strip():
+ try:
+ self._send_to_discord(saved_paths, discord_webhook_url.strip(), processed_prompt, model_string)
+ except Exception as e:
+ logging.warning(f"[FluxImageSaver] Discord webhook failed: {e}")
+
+ return {"ui": {"images": results}}
+
+ def _send_to_discord(self, paths, webhook_url, prompt, model):
+ description = f"**Model:** {model or 'unknown'}\n**Prompt:** {prompt[:1000]}{'...' if len(prompt) > 1000 else ''}"
+ embed = {
+ "title": "New generation",
+ "description": description,
+ "color": 0x5865F2,
+ "timestamp": datetime.now().isoformat(),
+ "footer": {"text": "ComfyUI BawkNodes"},
}
- return content_types.get(ext, 'image/png')
\ No newline at end of file
+ for batch_start in range(0, len(paths), 10): # Discord allows 10 files per message
+ batch = paths[batch_start:batch_start + 10]
+ payload = {"embeds": [embed]} if batch_start == 0 else {"content": f"Batch {batch_start // 10 + 1}"}
+ files = {}
+ for i, path in enumerate(batch):
+ with open(path, "rb") as f:
+ content_type = CONTENT_TYPES.get(os.path.splitext(path)[1], "image/png")
+ files[f"file{i}"] = (os.path.basename(path), f.read(), content_type)
+ response = requests.post(webhook_url, data={"payload_json": json.dumps(payload)}, files=files, timeout=30)
+ if not response.ok:
+ logging.warning(f"[FluxImageSaver] Discord returned {response.status_code}: {response.text}")
diff --git a/nodes/flux_wildcard_encode.py b/nodes/flux_wildcard_encode.py
index d978207..734db49 100644
--- a/nodes/flux_wildcard_encode.py
+++ b/nodes/flux_wildcard_encode.py
@@ -1,29 +1,33 @@
"""
-FluxWildcardEncode Node with LoRA Support
-File: nodes/flux_wildcard_encode.py
+FluxWildcardEncode - LoRA application + {a|b|c} wildcards + FLUX text encoding.
"""
-import os
-import re
import random
-import torch
-import folder_paths
-import requests
-import time
-from typing import Tuple, Any, Optional
+import re
+
+from .lora_stack import apply_lora_stack, collect_lora_slots, lora_slot_inputs
+
+WILDCARD_RE = re.compile(r"\{([^{}]*\|[^{}]*)\}")
+
+
+def process_wildcards(prompt, seed):
+ rng = random.Random(seed)
+
+ def pick(match):
+ options = [o.strip() for o in match.group(1).split("|")]
+ return rng.choice(options)
+
+ for _ in range(10): # inner groups resolve first, so nested wildcards work
+ resolved = WILDCARD_RE.sub(pick, prompt)
+ if resolved == prompt:
+ break
+ prompt = resolved
+ return prompt
class FluxWildcardEncode:
- """
- Enhanced FLUX wildcard text encoder with integrated LoRA loading (6 slots).
- """
-
@classmethod
def INPUT_TYPES(cls):
- # Get available LoRA files
- lora_list = folder_paths.get_filename_list("loras")
- lora_options = ["None"] + lora_list
-
return {
"required": {
"model": ("MODEL",),
@@ -32,466 +36,36 @@ class FluxWildcardEncode:
"multiline": True,
"default": "",
"dynamicPrompts": False,
- "tooltip": "Your prompt with {wildcard|syntax} support"
+ "tooltip": "Prompt. Use {red|green|blue} to pick one option at random",
}),
"wildcard_seed": ("INT", {
- "default": -1, "min": -1, "max": 0xffffffffffffffff,
- "tooltip": "Seed for wildcard selection. Use -1 to disable wildcards"
+ "default": 0, "min": 0, "max": 0xffffffffffffffff,
+ "control_after_generate": True,
+ "tooltip": "Seed for wildcard picks. Same seed = same picks",
}),
},
"optional": {
- # LoRA 1
- "lora_1_on": ("BOOLEAN", {"default": False}),
- "lora_1_name": (lora_options, {"default": "None"}),
- "lora_1_strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
-
- # LoRA 2
- "lora_2_on": ("BOOLEAN", {"default": False}),
- "lora_2_name": (lora_options, {"default": "None"}),
- "lora_2_strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
-
- # LoRA 3
- "lora_3_on": ("BOOLEAN", {"default": False}),
- "lora_3_name": (lora_options, {"default": "None"}),
- "lora_3_strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
-
- # LoRA 4
- "lora_4_on": ("BOOLEAN", {"default": False}),
- "lora_4_name": (lora_options, {"default": "None"}),
- "lora_4_strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
-
- # LoRA 5
- "lora_5_on": ("BOOLEAN", {"default": False}),
- "lora_5_name": (lora_options, {"default": "None"}),
- "lora_5_strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
-
- # LoRA 6
- "lora_6_on": ("BOOLEAN", {"default": False}),
- "lora_6_name": (lora_options, {"default": "None"}),
- "lora_6_strength": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
- }
+ "lora_stack": ("LORA_STACK", {
+ "tooltip": "LoRA set(s) from Bawk LoRA Stack. Applied before the slots below",
+ }),
+ **lora_slot_inputs(),
+ },
}
-
+
RETURN_TYPES = ("MODEL", "CLIP", "CONDITIONING", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "CONDITIONING", "PROMPT_OUT")
- FUNCTION = "encode_with_loras"
+ FUNCTION = "encode"
CATEGORY = "BawkNodes/conditioning"
- DESCRIPTION = "๐ฒ FLUX text encoder with {wildcard|syntax} support and 6 integrated LoRA slots"
+ DESCRIPTION = (
+ "Apply LoRAs, resolve {a|b|c} wildcards and encode the prompt for FLUX. "
+ "FLUX has no negative prompt; guidance is set in Bawk Sampler."
+ )
- def encode_with_loras(self, model, clip, prompt, wildcard_seed=-1, **kwargs):
- """
- Encode text prompt with wildcard processing and dynamic LoRA loading
- """
- try:
- print(f"[FluxWildcardEncode] Starting encode with dynamic LoRAs")
- print(f"[FluxWildcardEncode] Received kwargs: {list(kwargs.keys())}")
+ def encode(self, model, clip, prompt, wildcard_seed=0, lora_stack=None, **kwargs):
+ stack = list(lora_stack or []) + collect_lora_slots(kwargs)
+ model, clip = apply_lora_stack(model, clip, stack)
- # Clean up kwargs - handle ComfyUI's string conversion issues
- cleaned_kwargs = self._clean_kwargs(kwargs)
-
- # Step 1: Process wildcards if seed is provided
- final_prompt = prompt
- if wildcard_seed != -1:
- final_prompt = self._process_wildcards(prompt, wildcard_seed)
- if final_prompt != prompt:
- print(f"[FluxWildcardEncode] Processed wildcards in prompt")
-
- # Step 2: Apply dynamic LoRAs from kwargs
- working_model = model
- working_clip = clip
- ui_lora_count = 0
-
- # Process dynamic LoRA slots from kwargs
- # Look for patterns like: lora_1_on, lora_1_name, lora_1_strength
- lora_numbers = set()
- for key in cleaned_kwargs.keys():
- if key.startswith('lora_') and '_on' in key:
- # Extract the number from lora_N_on
- try:
- num = int(key.split('_')[1])
- lora_numbers.add(num)
- except (IndexError, ValueError):
- pass
-
- # Sort and process LoRAs in order
- for i in sorted(lora_numbers):
- on_key = f"lora_{i}_on"
- name_key = f"lora_{i}_name"
- strength_key = f"lora_{i}_strength"
-
- # Get values for this LoRA slot
- lora_enabled = cleaned_kwargs.get(on_key, False)
- lora_name = cleaned_kwargs.get(name_key, "None")
- lora_strength = cleaned_kwargs.get(strength_key, 1.0)
-
- if lora_enabled and lora_name and lora_name != "None":
- strength = lora_strength
-
- # Skip if strength is zero
- if strength == 0:
- continue
-
- try:
- from nodes import LoraLoader
- working_model, working_clip = LoraLoader().load_lora(
- working_model, working_clip, lora_name, strength, strength
- )
- ui_lora_count += 1
- print(f"[FluxWildcardEncode] Applied LoRA {i}: {lora_name} (strength: {strength})")
- except Exception as e:
- print(f"[FluxWildcardEncode] Failed to load LoRA {i} ({lora_name}): {e}")
-
- if ui_lora_count > 0:
- print(f"[FluxWildcardEncode] Applied {ui_lora_count} LoRAs")
- else:
- print(f"[FluxWildcardEncode] No LoRAs applied")
-
- # Step 3: Encode the final prompt
- if not final_prompt.strip():
- print("[FluxWildcardEncode] Warning: Empty final prompt")
- empty_conditioning = [[torch.zeros((1, 77, 768)), {"pooled_output": torch.zeros((1, 768))}]]
- return (working_model, working_clip, empty_conditioning, final_prompt)
-
- # Encode using the LoRA-modified CLIP
- tokens = working_clip.tokenize(final_prompt)
- conditioning, pooled = working_clip.encode_from_tokens(tokens, return_pooled=True)
- conditioning = [[conditioning, {"pooled_output": pooled}]]
-
- print(f"[FluxWildcardEncode] โ
Success: {ui_lora_count} LoRAs, conditioning shape: {conditioning[0][0].shape}")
- return (working_model, working_clip, conditioning, final_prompt)
-
- except Exception as e:
- error_msg = f"Failed to encode with LoRAs: {str(e)}"
- print(f"[FluxWildcardEncode] Error: {error_msg}")
- import traceback
- traceback.print_exc()
- empty_conditioning = [[torch.zeros((1, 77, 768)), {"pooled_output": torch.zeros((1, 768))}]]
- return (model, clip, empty_conditioning, prompt)
-
- def _process_wildcards(self, prompt, seed):
- """
- Process wildcard syntax in prompts
- Wildcard format: {option1|option2|option3}
- """
- # Set seed for consistent wildcard selection
- random.seed(seed)
-
- processed = prompt
-
- # Find and process wildcards in {option1|option2|option3} format
- wildcard_pattern = r'\{([^}]+)\}'
-
- def replace_wildcard(match):
- wildcard_content = match.group(1)
- if '|' in wildcard_content:
- options = [option.strip() for option in wildcard_content.split('|')]
- if options: # Make sure we have options
- return random.choice(options)
- return match.group(0) # Return original if no valid options
-
- # Replace all wildcards
- processed = re.sub(wildcard_pattern, replace_wildcard, processed)
-
- return processed
-
- def _get_lora_by_filename(self, file_path):
- """Find LoRA file with fuzzy matching (rgthree style)"""
- if not file_path:
- return None
-
- lora_paths = folder_paths.get_filename_list('loras')
-
- # Direct match
- if file_path in lora_paths:
- return file_path
-
- # Match without extension
- lora_paths_no_ext = [os.path.splitext(x)[0] for x in lora_paths]
- if file_path in lora_paths_no_ext:
- return lora_paths[lora_paths_no_ext.index(file_path)]
-
- # Force input without extension
- file_path_no_ext = os.path.splitext(file_path)[0]
- if file_path_no_ext in lora_paths_no_ext:
- return lora_paths[lora_paths_no_ext.index(file_path_no_ext)]
-
- # Basename matching
- lora_basenames = [os.path.basename(x) for x in lora_paths]
- if file_path in lora_basenames:
- return lora_paths[lora_basenames.index(file_path)]
-
- # Basename without extension
- file_basename = os.path.basename(file_path)
- if file_basename in lora_basenames:
- return lora_paths[lora_basenames.index(file_basename)]
-
- # Basename no extension matching
- lora_basenames_no_ext = [os.path.splitext(os.path.basename(x))[0] for x in lora_paths]
- file_basename_no_ext = os.path.splitext(os.path.basename(file_path))[0]
- if file_basename_no_ext in lora_basenames_no_ext:
- return lora_paths[lora_basenames_no_ext.index(file_basename_no_ext)]
-
- # Fuzzy partial match
- for lora_path in lora_paths:
- if file_path in lora_path:
- print(f"[FluxWildcardEncode] Fuzzy-matched '{file_path}' to '{lora_path}'")
- return lora_path
-
- print(f"[FluxWildcardEncode] LoRA '{file_path}' not found")
- return None
-
- def _validate_lora_config(self, slot_num, lora_name, strength, warnings_list):
- """Validate LoRA configuration and provide helpful feedback"""
-
- # Check strength values and provide recommendations
- if strength > 2.0:
- warnings_list.append(f"LoRA {slot_num}: Very high strength ({strength:.2f}) may cause artifacts or overfitting")
- print(f"[FluxWildcardEncode] โ ๏ธ LoRA {slot_num} strength ({strength:.2f}) is very high - consider reducing to 0.6-1.2")
- elif strength > 1.5:
- print(f"[FluxWildcardEncode] โน๏ธ LoRA {slot_num} strength ({strength:.2f}) is high - monitor for quality issues")
-
- if strength < 0.1 and strength > 0:
- print(f"[FluxWildcardEncode] โน๏ธ LoRA {slot_num} strength ({strength:.2f}) is very low - effect may be minimal")
- elif strength < 0:
- print(f"[FluxWildcardEncode] โน๏ธ LoRA {slot_num} using negative strength ({strength:.2f}) to reduce features")
-
- # Provide slot-specific recommendations based on typical usage patterns
- slot_recommendations = {
- 1: "Primary LoRA - Best for main character or style (recommended: 0.8-1.2)",
- 2: "Secondary LoRA - Good for lighting or pose adjustments (recommended: 0.6-1.0)",
- 3: "Detail LoRA - Perfect for clothing or objects (recommended: 0.5-0.8)",
- 4: "Texture LoRA - Use for material or surface details (recommended: 0.4-0.7)",
- 5: "Mood LoRA - Ideal for color grading or atmosphere (recommended: 0.3-0.6)",
- 6: "Polish LoRA - Final touches or subtle adjustments (recommended: 0.2-0.5)"
- }
-
- if slot_num <= 6:
- print(f"[FluxWildcardEncode] โ
LoRA {slot_num} loaded: {lora_name} @ {strength:.2f} ({slot_recommendations[slot_num]})")
-
- # Check for common LoRA naming patterns to suggest optimal strengths
- lora_lower = lora_name.lower()
- if "style" in lora_lower and strength < 0.6:
- print(f"[FluxWildcardEncode] ๐ก Style LoRAs typically work best at 0.8-1.2 strength")
- elif "character" in lora_lower and strength < 0.7:
- print(f"[FluxWildcardEncode] ๐ก Character LoRAs typically work best at 0.8-1.1 strength")
- elif "pose" in lora_lower and strength > 1.0:
- print(f"[FluxWildcardEncode] ๐ก Pose LoRAs often work better at 0.6-0.9 strength")
- elif "lighting" in lora_lower and strength > 0.8:
- print(f"[FluxWildcardEncode] ๐ก Lighting LoRAs typically work best at 0.4-0.7 strength")
-
-
- def _clean_kwargs(self, kwargs):
- """Clean kwargs to handle ComfyUI's string conversion issues"""
- cleaned = {}
-
- for key, value in kwargs.items():
- if key.endswith('_on'):
- # Boolean parameters
- if isinstance(value, str):
- cleaned[key] = value.lower() in ('true', '1', 'yes', 'on')
- else:
- cleaned[key] = bool(value)
- elif key.endswith('_strength'):
- # Float parameters
- if isinstance(value, str):
- if value.lower() in ('none', '', 'null'):
- cleaned[key] = 1.0 # Default strength
- else:
- try:
- cleaned[key] = float(value)
- except ValueError:
- cleaned[key] = 1.0 # Default on error
- else:
- cleaned[key] = float(value) if value is not None else 1.0
- elif key.endswith('_name'):
- # String parameters (LoRA names)
- if isinstance(value, str):
- if value.lower() in ('false', 'none', '', 'null'):
- cleaned[key] = "None"
- else:
- cleaned[key] = value
- else:
- cleaned[key] = str(value) if value is not None else "None"
- else:
- # Other parameters - pass through
- cleaned[key] = value
-
- return cleaned
-
- def _enhance_prompt_with_ai(self, prompt: str, provider: str, api_key: str, style: str, creativity: float) -> Optional[str]:
- """
- Enhance prompt using AI APIs (100% optional)
- """
- try:
- # Security: mask API key in logs
- safe_key_display = f"{api_key[:8]}..." if len(api_key) > 8 else "***"
- print(f"[FluxWildcardEncode] Enhancing with {provider} (key: {safe_key_display})")
-
- # Build enhancement system prompt
- system_prompt = self._build_ai_system_prompt(style, creativity)
-
- # Call appropriate API
- if provider == "Claude (Anthropic)":
- return self._call_claude_api(api_key, system_prompt, prompt)
- elif provider.startswith("OpenAI"):
- model = "gpt-4" if "GPT-4" in provider else "gpt-3.5-turbo"
- return self._call_openai_api(api_key, system_prompt, prompt, model, creativity)
- elif provider == "Grok (xAI)":
- return self._call_grok_api(api_key, system_prompt, prompt, creativity)
- else:
- print(f"[FluxWildcardEncode] Unsupported AI provider: {provider}")
- return None
-
- except Exception as e:
- print(f"[FluxWildcardEncode] AI enhancement error: {str(e)}")
- return None
-
- def _build_ai_system_prompt(self, style: str, creativity: float) -> str:
- """Build AI enhancement system prompt based on style and creativity"""
-
- base_prompt = """You are an expert prompt engineer for FLUX image generation. Transform the given prompt into a more detailed, descriptive version that will produce higher quality images.
-
-FLUX works best with:
-- Detailed, specific descriptions
-- Technical photography/artistic terms
-- Lighting and composition details
-- Clear subject descriptions
-- Specific style references
-
-IMPORTANT: Return ONLY the enhanced prompt, no explanations."""
-
- style_instructions = {
- "Detailed & Descriptive": "Add rich details, textures, lighting, and environmental context.",
- "Photography Style": "Include camera settings, lens types, lighting setups, and photographic techniques.",
- "Cinematic": "Add film techniques, dramatic lighting, camera angles, and mood descriptions.",
- "Artistic & Creative": "Include art movement references, creative techniques, and artistic mediums.",
- "Anime/Illustration": "Add anime-specific terminology, character details, and illustration styles.",
- "Professional Portrait": "Focus on portrait lighting, professional setups, and subject positioning.",
- "Landscape/Nature": "Add environmental details, natural lighting, and atmospheric effects.",
- "Abstract/Conceptual": "Include abstract concepts, symbolic elements, and artistic interpretation."
- }
-
- creativity_instruction = f"""
-Creativity Level: {creativity:.1f}
-- 0.1-0.3: Minimal changes, focus on clarity and technical accuracy
-- 0.4-0.6: Moderate enhancement with some creative additions
-- 0.7-0.9: Creative enhancement with significant artistic improvements
-- 1.0: Very creative, bold artistic interpretation
-"""
-
- style_specific = style_instructions.get(style, style_instructions["Detailed & Descriptive"])
-
- return f"{base_prompt}\n\nStyle Focus: {style_specific}\n{creativity_instruction}"
-
- def _call_claude_api(self, api_key: str, system_prompt: str, user_prompt: str) -> Optional[str]:
- """Call Claude API for prompt enhancement"""
- try:
- url = "https://api.anthropic.com/v1/messages"
- headers = {
- "Content-Type": "application/json",
- "x-api-key": api_key,
- "anthropic-version": "2023-06-01"
- }
-
- data = {
- "model": "claude-3-5-sonnet-20241022",
- "max_tokens": 500,
- "system": system_prompt,
- "messages": [{"role": "user", "content": f"Enhance this FLUX prompt: {user_prompt}"}]
- }
-
- response = requests.post(url, headers=headers, json=data, timeout=30)
-
- if response.status_code == 200:
- result = response.json()
- enhanced = result["content"][0]["text"].strip()
- return enhanced
- else:
- print(f"[FluxWildcardEncode] Claude API error {response.status_code}: {response.text}")
- return None
-
- except requests.exceptions.Timeout:
- print(f"[FluxWildcardEncode] Claude API timeout")
- return None
- except Exception as e:
- print(f"[FluxWildcardEncode] Claude API error: {str(e)}")
- return None
-
- def _call_openai_api(self, api_key: str, system_prompt: str, user_prompt: str, model: str, creativity: float) -> Optional[str]:
- """Call OpenAI API for prompt enhancement"""
- try:
- url = "https://api.openai.com/v1/chat/completions"
- headers = {
- "Content-Type": "application/json",
- "Authorization": f"Bearer {api_key}"
- }
-
- # Map creativity to temperature
- temperature = min(creativity * 1.2, 1.0)
-
- data = {
- "model": model,
- "messages": [
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": f"Enhance this FLUX prompt: {user_prompt}"}
- ],
- "max_tokens": 400,
- "temperature": temperature
- }
-
- response = requests.post(url, headers=headers, json=data, timeout=30)
-
- if response.status_code == 200:
- result = response.json()
- enhanced = result["choices"][0]["message"]["content"].strip()
- return enhanced
- else:
- print(f"[FluxWildcardEncode] OpenAI API error {response.status_code}: {response.text}")
- return None
-
- except requests.exceptions.Timeout:
- print(f"[FluxWildcardEncode] OpenAI API timeout")
- return None
- except Exception as e:
- print(f"[FluxWildcardEncode] OpenAI API error: {str(e)}")
- return None
-
- def _call_grok_api(self, api_key: str, system_prompt: str, user_prompt: str, creativity: float) -> Optional[str]:
- """Call Grok API for prompt enhancement"""
- try:
- url = "https://api.x.ai/v1/chat/completions"
- headers = {
- "Content-Type": "application/json",
- "Authorization": f"Bearer {api_key}"
- }
-
- # Map creativity to temperature (Grok supports 0.0-2.0)
- temperature = min(creativity * 1.5, 2.0)
-
- data = {
- "model": "grok-beta",
- "messages": [
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": f"Enhance this FLUX prompt: {user_prompt}"}
- ],
- "max_tokens": 500,
- "temperature": temperature,
- "stream": False
- }
-
- response = requests.post(url, headers=headers, json=data, timeout=45)
-
- if response.status_code == 200:
- result = response.json()
- enhanced = result["choices"][0]["message"]["content"].strip()
- return enhanced
- else:
- print(f"[FluxWildcardEncode] Grok API error {response.status_code}: {response.text}")
- return None
-
- except requests.exceptions.Timeout:
- print(f"[FluxWildcardEncode] Grok API timeout (45s)")
- return None
- except Exception as e:
- print(f"[FluxWildcardEncode] Grok API error: {str(e)}")
- return None
\ No newline at end of file
+ final_prompt = process_wildcards(prompt, wildcard_seed)
+ tokens = clip.tokenize(final_prompt)
+ conditioning = clip.encode_from_tokens_scheduled(tokens)
+ return (model, clip, conditioning, final_prompt)
diff --git a/nodes/lora_stack.py b/nodes/lora_stack.py
new file mode 100644
index 0000000..72949bc
--- /dev/null
+++ b/nodes/lora_stack.py
@@ -0,0 +1,74 @@
+"""
+BawkLoraStack - build LoRA sets that can be chained, toggled and applied in one go.
+"""
+
+import inspect
+import logging
+
+import comfy.sd
+import comfy.utils
+import folder_paths
+
+LORA_SLOTS = 6
+_LORA_METADATA_KW = "lora_metadata" in inspect.signature(comfy.sd.load_lora_for_models).parameters
+
+
+def lora_slot_inputs(slots=LORA_SLOTS):
+ loras = ["None"] + folder_paths.get_filename_list("loras")
+ inputs = {}
+ for i in range(1, slots + 1):
+ inputs[f"lora_{i}"] = (loras, {"default": "None"})
+ inputs[f"strength_{i}"] = ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01})
+ return inputs
+
+
+def collect_lora_slots(kwargs, slots=LORA_SLOTS):
+ stack = []
+ for i in range(1, slots + 1):
+ name = kwargs.get(f"lora_{i}", "None")
+ strength = float(kwargs.get(f"strength_{i}", 1.0))
+ if name and name != "None" and strength != 0.0:
+ stack.append((name, strength))
+ return stack
+
+
+def apply_lora_stack(model, clip, lora_stack):
+ for name, strength in lora_stack:
+ path = folder_paths.get_full_path_or_raise("loras", name)
+ lora, metadata = comfy.utils.load_torch_file(path, safe_load=True, return_metadata=True)
+ extra = {"lora_metadata": metadata} if _LORA_METADATA_KW else {}
+ model, clip = comfy.sd.load_lora_for_models(model, clip, lora, strength, strength, **extra)
+ logging.info(f"[BawkNodes] LoRA {name} @ {strength}")
+ return model, clip
+
+
+class BawkLoraStack:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "enabled": ("BOOLEAN", {
+ "default": True,
+ "tooltip": "Master switch for this set. Off passes the incoming stack through unchanged",
+ }),
+ },
+ "optional": {
+ "lora_stack": ("LORA_STACK", {"tooltip": "Chain another Bawk LoRA Stack to combine sets"}),
+ **lora_slot_inputs(),
+ },
+ }
+
+ RETURN_TYPES = ("LORA_STACK",)
+ RETURN_NAMES = ("lora_stack",)
+ FUNCTION = "build"
+ CATEGORY = "BawkNodes/loaders"
+ DESCRIPTION = (
+ "A set of up to 6 LoRAs. Chain several stacks and flip 'enabled' to switch between sets. "
+ "Plug into Bawk Wildcard Encoder's lora_stack input."
+ )
+
+ def build(self, enabled=True, lora_stack=None, **kwargs):
+ stack = list(lora_stack) if lora_stack else []
+ if enabled:
+ stack += collect_lora_slots(kwargs)
+ return (stack,)
diff --git a/nodes/zimage_loader.py b/nodes/zimage_loader.py
deleted file mode 100644
index c365e8b..0000000
--- a/nodes/zimage_loader.py
+++ /dev/null
@@ -1,151 +0,0 @@
-"""
-ZImageModelLoader - Load Z-Image models using native ComfyUI support
-File: nodes/zimage_loader.py
-
-Z-Image models are natively supported in ComfyUI via comfy.supported_models.ZImage.
-This loader provides a simplified interface focused on Z-Image-Turbo settings.
-"""
-
-import os
-import torch
-import comfy.sd
-import comfy.utils
-import folder_paths
-from typing import Tuple, Any
-
-
-class ZImageModelLoader:
- """
- Load Z-Image-Turbo model using native ComfyUI support.
- Z-Image uses a single-stream DiT architecture and works with standard samplers.
-
- Outputs standard MODEL, VAE, CLIP types that work with BawkSampler.
- Recommended settings: steps=8, guidance=0.0 (distilled model, no CFG needed)
- """
-
- @classmethod
- def INPUT_TYPES(cls):
- # Get available diffusion models, prioritize Z-Image models
- model_list = folder_paths.get_filename_list("diffusion_models")
- zimage_models = [m for m in model_list if "zimage" in m.lower() or "z-image" in m.lower()]
- all_models = zimage_models + [m for m in model_list if m not in zimage_models]
-
- return {
- "required": {
- "model_name": (all_models if all_models else ["None - No models found"], {
- "tooltip": "Z-Image model file from models/diffusion_models/"
- }),
- "vae_name": (folder_paths.get_filename_list("vae") + ["baked VAE"], {
- "tooltip": "VAE model. Z-Image typically uses FLUX VAE (ae.safetensors)"
- }),
- "clip_name": (folder_paths.get_filename_list("text_encoders") + ["none"], {
- "tooltip": "Text encoder for Z-Image (Gemma-based)"
- }),
- "weight_dtype": (["default", "bf16", "fp16", "fp8_e4m3fn"], {
- "default": "bf16",
- "tooltip": "Model precision. bf16 recommended for Z-Image (best quality/speed)"
- }),
- }
- }
-
- RETURN_TYPES = ("MODEL", "VAE", "CLIP", "STRING")
- RETURN_NAMES = ("MODEL", "VAE", "CLIP", "MODEL_STRING")
- FUNCTION = "load_zimage_model"
- CATEGORY = "BawkNodes/loaders"
- DESCRIPTION = "โก Load Z-Image-Turbo model - 6B params, 8 steps, state-of-the-art quality. Use with BawkSampler (steps=8, guidance=0.0)"
-
- @classmethod
- def VALIDATE_INPUTS(cls, **kwargs):
- """Validate model files exist"""
- errors = []
-
- model_name = kwargs.get("model_name", "")
- if not model_name or model_name == "None - No models found":
- errors.append("No Z-Image model selected. Place model in ComfyUI/models/diffusion_models/")
- elif model_name:
- try:
- folder_paths.get_full_path_or_raise("diffusion_models", model_name)
- except:
- errors.append(f"Model '{model_name}' not found in diffusion_models directory")
-
- vae_name = kwargs.get("vae_name", "baked VAE")
- if vae_name and vae_name != "baked VAE":
- try:
- folder_paths.get_full_path_or_raise("vae", vae_name)
- except:
- errors.append(f"VAE '{vae_name}' not found in vae directory")
-
- clip_name = kwargs.get("clip_name", "none")
- if clip_name and clip_name != "none":
- try:
- folder_paths.get_full_path_or_raise("text_encoders", clip_name)
- except:
- errors.append(f"Text encoder '{clip_name}' not found in text_encoders directory")
-
- return "; ".join(errors) if errors else True
-
- def load_zimage_model(
- self,
- model_name: str,
- vae_name: str = "baked VAE",
- clip_name: str = "none",
- weight_dtype: str = "bf16"
- ) -> Tuple[Any, Any, Any, str]:
- """
- Load Z-Image model using native ComfyUI support
- """
- try:
- # Handle "none" and "baked VAE" values
- load_vae = vae_name and vae_name != "baked VAE"
- load_clip = clip_name and clip_name != "none"
-
- # Set up model options based on weight_dtype
- model_options = {}
- if weight_dtype == "bf16":
- model_options["dtype"] = torch.bfloat16
- elif weight_dtype == "fp16":
- model_options["dtype"] = torch.float16
- elif weight_dtype == "fp8_e4m3fn":
- model_options["dtype"] = torch.float8_e4m3fn
-
- # Load diffusion model (ComfyUI auto-detects Z-Image)
- print(f"[ZImageModelLoader] Loading Z-Image model: {model_name}")
- model_path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
- model = comfy.sd.load_diffusion_model(model_path, model_options=model_options)
-
- if model is None:
- raise RuntimeError("Failed to load Z-Image model - output was None")
-
- # Load VAE
- vae = None
- if load_vae:
- print(f"[ZImageModelLoader] Loading VAE: {vae_name}")
- vae_path = folder_paths.get_full_path_or_raise("vae", vae_name)
- vae_sd = comfy.utils.load_torch_file(vae_path)
- vae = comfy.sd.VAE(sd=vae_sd)
-
- # Load CLIP/text encoder
- clip = None
- if load_clip:
- print(f"[ZImageModelLoader] Loading text encoder: {clip_name}")
- clip_path = folder_paths.get_full_path_or_raise("text_encoders", clip_name)
- clip = comfy.sd.load_clip(
- ckpt_paths=[clip_path],
- embedding_directory=folder_paths.get_folder_paths("embeddings")
- )
-
- # Generate model name string
- model_string = os.path.splitext(model_name)[0]
-
- print(f"[ZImageModelLoader] โ
Successfully loaded Z-Image model: {model_string}")
- print(f"[ZImageModelLoader] ๐ก Recommended: steps=8, guidance=0.0 (Turbo model)")
- return (model, vae, clip, model_string)
-
- except Exception as e:
- error_msg = f"Failed to load Z-Image model '{model_name}': {str(e)}"
- print(f"[ZImageModelLoader] โ {error_msg}")
- raise RuntimeError(error_msg)
-
-
-# Export
-__all__ = ['ZImageModelLoader']
diff --git a/pyproject.toml b/pyproject.toml
index 7aa9245..afd853d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,34 +1,18 @@
[project]
name = "comfyui-bawknodes"
-version = "2.2.1"
-description = "A complete collection of FLUX-optimized ComfyUI nodes for enhanced AI image generation workflows."
+version = "3.0.0"
+description = "Compact FLUX.1 / FLUX.2 workflow nodes for ComfyUI: model loader (safetensors + GGUF), switchable LoRA sets, wildcard encoder, all-in-one sampler and organized image saver."
readme = "README.md"
license = { file = "LICENSE" }
-requires-python = ">=3.8"
-authors = [
- { name = "judd", email = "" }
-]
-keywords = ["comfyui", "diffusion", "model-loading", "ai", "flux", "nodes", "bawk"]
+requires-python = ">=3.10"
+authors = [{ name = "judd" }]
+keywords = ["comfyui", "flux", "flux2", "klein", "kontext", "lora", "gguf", "bawk"]
classifiers = [
- "Development Status :: 5 - Production/Stable",
- "Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Programming Language :: Python :: 3.12",
- "Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Multimedia :: Graphics",
]
-
-dependencies = [
- "torch>=1.12.0",
- "torchvision>=0.13.0",
- "numpy>=1.21.0",
- "safetensors>=0.3.0",
-]
+dependencies = ["requests"]
[project.urls]
Repository = "https://github.com/juddisjudd/ComfyUI-BawkNodes"
@@ -38,14 +22,3 @@ Issues = "https://github.com/juddisjudd/ComfyUI-BawkNodes/issues"
PublisherId = "judd"
DisplayName = "Bawk Nodes Collection"
Icon = "https://raw.githubusercontent.com/juddisjudd/ComfyUI-BawkNodes/refs/heads/main/branding/bawknodes.png"
-
-[build-system]
-requires = ["setuptools>=45", "wheel"]
-build-backend = "setuptools.build_meta"
-
-[tool.setuptools.packages.find]
-where = ["."]
-include = ["modules*", "workflows*"]
-
-[tool.setuptools.package-data]
-"*" = ["*.json", "*.yaml", "*.yml", "*.md"]
\ No newline at end of file
diff --git a/pyrightconfig.json b/pyrightconfig.json
index 2da319a..51814b4 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -1,36 +1,9 @@
{
- // Pylance/Pyright configuration for ComfyUI custom nodes
- // ComfyUI-specific modules (folder_paths, comfy.*, etc.) are only available at runtime
- // So we suppress import warnings that would otherwise clutter the IDE
-
- "include": [
- "nodes",
- "modules",
- "utils"
- ],
-
- "exclude": [
- "**/__pycache__",
- ".git",
- ".venv",
- "venv",
- "build",
- "dist"
- ],
-
- // Suppress missing imports - ComfyUI modules aren't in the local environment
- "reportMissingImports": "none",
- "reportMissingModuleSource": "warning",
-
- "stubPath": "",
+ "include": ["__init__.py", "nodes"],
+ "exclude": ["**/__pycache__", ".git"],
"pythonVersion": "3.10",
- "pythonPlatform": "All",
"typeCheckingMode": "basic",
-
- // Relax type checking for dynamic ComfyUI interfaces
- "reportGeneralTypeIssues": "none",
- "reportOptionalMemberAccess": "none",
- "reportOptionalSubscript": "none",
- "reportPrivateImportUsage": "none",
+ "reportMissingImports": "none",
+ "reportMissingModuleSource": "none",
"reportAttributeAccessIssue": "none"
}
diff --git a/requirements.txt b/requirements.txt
index 16326e1..f229360 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,14 +1 @@
-# Core PyTorch dependencies
-torch>=1.12.0
-torchvision>=0.13.0
-
-# Model handling
-safetensors>=0.3.0
-numpy>=1.21.0
-
-# Bawk Prompt Enhancer dependencies
-requests>=2.25.0 # For API calls to Claude/OpenAI
-
-# Optional: Enhanced functionality
-# accelerate>=0.20.0 # For optimized loading
-# transformers>=4.21.0 # For advanced CLIP handling
\ No newline at end of file
+requests
diff --git a/utils/flexible_inputs.py b/utils/flexible_inputs.py
deleted file mode 100644
index 33f0de1..0000000
--- a/utils/flexible_inputs.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-Flexible Input Utilities for Dynamic UI (based on rgthree)
-File: utils/flexible_inputs.py
-"""
-
-from typing import Union
-
-
-class AnyType(str):
- """A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
-
- def __ne__(self, __value: object) -> bool:
- return False
-
-
-class FlexibleOptionalInputType(dict):
- """A special class to make flexible nodes that pass data to our python handlers.
-
- Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
- (like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
-
- Initially, ComfyUI only needed to return True for `__contains__` below, which told ComfyUI that
- our node will handle the input, regardless of what it is.
-
- However, after https://github.com/comfyanonymous/ComfyUI/pull/2666 ComfyUI's execution changed
- also checking the data for the key; specifically, the type which is the first tuple entry. This
- type is supplied to our FlexibleOptionalInputType and returned for any non-data key. This can be a
- real type, or use the AnyType for additional flexibility.
- """
-
- def __init__(self, type_func, data: Union[dict, None] = None):
- """Initializes the FlexibleOptionalInputType.
-
- Args:
- type_func: The flexible type to use when ComfyUI retrieves an unknown key (via `__getitem__`).
- data: An optional dict to use as the basis. This is stored both in a `data` attribute, so we
- can look it up without hitting our overrides, as well as iterated over and adding its key
- and values to our `self` keys. This way, when looked at, we will appear to represent this
- data. When used in an "optional" INPUT_TYPES, these are the starting optional node types.
- """
- super().__init__()
- self.data = data or {}
- self.type = type_func
- for key, value in self.data.items():
- self[key] = value
-
- def __contains__(self, key):
- """Return True for any key, allowing ComfyUI to pass any input to our node."""
- return True
-
- def __getitem__(self, key):
- """Return the type for any unknown key, or the stored value for known keys."""
- if key in self.data:
- return self.data[key]
- return self.type
-
-
-def any_type(s=None):
- """Helper function for any type inputs"""
- return AnyType("*")
\ No newline at end of file
diff --git a/workflows/example_bawknodes_workflow.json b/workflows/example_bawknodes_workflow.json
index 204d7f2..3041ae2 100644
--- a/workflows/example_bawknodes_workflow.json
+++ b/workflows/example_bawknodes_workflow.json
@@ -2,342 +2,126 @@
"id": "b4a7c8d9-e5f6-4a1b-9c2d-3e4f5a6b7c8d",
"revision": 0,
"last_node_id": 5,
- "last_link_id": 8,
+ "last_link_id": 9,
"nodes": [
{
"id": 1,
"type": "DiffusionModelLoader",
- "pos": [
- 100,
- 200
- ],
- "size": [
- 350,
- 170
- ],
+ "pos": [80, 200],
+ "size": [380, 180],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
- {
- "name": "MODEL",
- "type": "MODEL",
- "links": [
- 1
- ],
- "slot_index": 0
- },
- {
- "name": "VAE",
- "type": "VAE",
- "links": [
- 3
- ],
- "slot_index": 1
- },
- {
- "name": "CLIP",
- "type": "CLIP",
- "links": [
- 2
- ],
- "slot_index": 2
- },
- {
- "name": "MODEL_STRING",
- "type": "STRING",
- "links": [
- 6
- ],
- "slot_index": 3
- }
+ {"name": "MODEL", "type": "MODEL", "links": [1]},
+ {"name": "VAE", "type": "VAE", "links": [3]},
+ {"name": "CLIP", "type": "CLIP", "links": [2]},
+ {"name": "MODEL_STRING", "type": "STRING", "links": [7]}
],
- "properties": {
- "cnr_id": "comfyui-bawknodes",
- "Node name for S&R": "DiffusionModelLoader"
- },
- "widgets_values": [
- "flux1-dev.safetensors",
- "ae.safetensors",
- "t5xxl_fp16.safetensors",
- "clip_l.safetensors",
- "fp8_e4m3fn"
- ],
- "shape": 1
+ "properties": {"cnr_id": "comfyui-bawknodes", "Node name for S&R": "DiffusionModelLoader"},
+ "widgets_values": ["flux1-dev.safetensors", "ae.safetensors", "t5xxl_fp16.safetensors", "clip_l.safetensors", "fp8_e4m3fn"]
},
{
"id": 2,
- "type": "FluxWildcardEncode",
- "pos": [
- 500,
- 200
- ],
- "size": [
- 400,
- 550
- ],
+ "type": "BawkLoraStack",
+ "pos": [80, 440],
+ "size": [380, 400],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
- {
- "name": "model",
- "type": "MODEL",
- "link": 1
- },
- {
- "name": "clip",
- "type": "CLIP",
- "link": 2
- }
+ {"name": "lora_stack", "type": "LORA_STACK", "link": null}
],
"outputs": [
- {
- "name": "MODEL",
- "type": "MODEL",
- "links": [
- 4
- ],
- "slot_index": 0
- },
- {
- "name": "CLIP",
- "type": "CLIP",
- "links": null,
- "slot_index": 1
- },
- {
- "name": "CONDITIONING",
- "type": "CONDITIONING",
- "links": [
- 5
- ],
- "slot_index": 2
- },
- {
- "name": "PROMPT_OUT",
- "type": "STRING",
- "links": [
- 7
- ],
- "slot_index": 3
- }
+ {"name": "lora_stack", "type": "LORA_STACK", "links": [4]}
],
- "properties": {
- "cnr_id": "comfyui-bawknodes",
- "Node name for S&R": "FluxWildcardEncode"
- },
- "widgets_values": [
- "A stunning {portrait|landscape} photograph of a {mountain lake|forest clearing|coastal cliff} at {golden hour|blue hour|sunrise}, professional photography, 8K resolution, sharp focus, dramatic lighting",
- 42,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0
- ],
- "shape": 1
+ "properties": {"cnr_id": "comfyui-bawknodes", "Node name for S&R": "BawkLoraStack"},
+ "widgets_values": [true, "None", 1.0, "None", 1.0, "None", 1.0, "None", 1.0, "None", 1.0, "None", 1.0]
},
{
"id": 3,
- "type": "BawkSampler",
- "pos": [
- 950,
- 200
- ],
- "size": [
- 320,
- 550
- ],
+ "type": "FluxWildcardEncode",
+ "pos": [520, 200],
+ "size": [420, 560],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
- {
- "name": "model",
- "type": "MODEL",
- "link": 4
- },
- {
- "name": "conditioning",
- "type": "CONDITIONING",
- "link": 5
- },
- {
- "name": "vae",
- "type": "VAE",
- "link": 3
- },
- {
- "name": "input_image",
- "type": "IMAGE",
- "link": null
- }
+ {"name": "model", "type": "MODEL", "link": 1},
+ {"name": "clip", "type": "CLIP", "link": 2},
+ {"name": "lora_stack", "type": "LORA_STACK", "link": 4}
],
"outputs": [
- {
- "name": "images",
- "type": "IMAGE",
- "links": [
- 8
- ],
- "slot_index": 0
- },
- {
- "name": "latent",
- "type": "LATENT",
- "links": null,
- "slot_index": 1
- }
+ {"name": "MODEL", "type": "MODEL", "links": [5]},
+ {"name": "CLIP", "type": "CLIP", "links": []},
+ {"name": "CONDITIONING", "type": "CONDITIONING", "links": [6]},
+ {"name": "PROMPT_OUT", "type": "STRING", "links": [8]}
],
- "properties": {
- "cnr_id": "comfyui-bawknodes",
- "Node name for S&R": "BawkSampler"
- },
+ "properties": {"cnr_id": "comfyui-bawknodes", "Node name for S&R": "FluxWildcardEncode"},
"widgets_values": [
- "FLUX Optimal - 1024x1024 - 1.0MP โญ Recommended",
- 4,
- 12345,
- "randomize",
- "euler",
- "beta",
- 30,
- 3.5,
- 1.15,
- 0.5,
- 1.0,
- false,
- 1920,
- 1080
- ],
- "shape": 1
+ "A {cinematic|candid|editorial} photo of a rooster wearing a tiny leather jacket, golden hour light, shallow depth of field",
+ 0, "fixed",
+ "None", 1.0, "None", 1.0, "None", 1.0, "None", 1.0, "None", 1.0, "None", 1.0
+ ]
},
{
"id": 4,
- "type": "FluxImageSaver",
- "pos": [
- 1320,
- 200
- ],
- "size": [
- 380,
- 320
- ],
+ "type": "BawkSampler",
+ "pos": [1000, 200],
+ "size": [400, 520],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
- {
- "name": "images",
- "type": "IMAGE",
- "link": 8
- },
- {
- "name": "model_string",
- "type": "STRING",
- "link": 6
- },
- {
- "name": "processed_prompt",
- "type": "STRING",
- "link": 7
- }
+ {"name": "model", "type": "MODEL", "link": 5},
+ {"name": "conditioning", "type": "CONDITIONING", "link": 6},
+ {"name": "vae", "type": "VAE", "link": 3},
+ {"name": "input_image", "type": "IMAGE", "link": null},
+ {"name": "reference_image", "type": "IMAGE", "link": null},
+ {"name": "reference_image_2", "type": "IMAGE", "link": null}
],
- "outputs": [],
- "properties": {
- "cnr_id": "comfyui-bawknodes",
- "Node name for S&R": "FluxImageSaver"
- },
+ "outputs": [
+ {"name": "images", "type": "IMAGE", "links": [9]},
+ {"name": "latent", "type": "LATENT", "links": []}
+ ],
+ "properties": {"cnr_id": "comfyui-bawknodes", "Node name for S&R": "BawkSampler"},
"widgets_values": [
- "flux_output",
- "png",
- 95,
- true,
- true,
- false,
- ""
- ],
- "shape": 1
+ "1:1 Square - 1024x1024", 1, 0, "randomize", 20, 3.5, "euler", "auto", 1.0,
+ 1.0, 1.15, 0.5, 1024, 1024
+ ]
},
{
"id": 5,
- "type": "PreviewImage",
- "pos": [
- 1320,
- 570
- ],
- "size": [
- 380,
- 300
- ],
+ "type": "FluxImageSaver",
+ "pos": [1460, 200],
+ "size": [400, 300],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
- {
- "name": "images",
- "type": "IMAGE",
- "link": 8
- }
+ {"name": "images", "type": "IMAGE", "link": 9},
+ {"name": "model_string", "type": "STRING", "link": 7},
+ {"name": "processed_prompt", "type": "STRING", "link": 8}
],
"outputs": [],
- "properties": {
- "Node name for S&R": "PreviewImage"
- },
- "shape": 1
+ "properties": {"cnr_id": "comfyui-bawknodes", "Node name for S&R": "FluxImageSaver"},
+ "widgets_values": ["flux_image", "png", 95, true, true, false, ""]
}
],
"links": [
- [1, 1, 0, 2, 0, "MODEL"],
- [2, 1, 2, 2, 1, "CLIP"],
- [3, 1, 1, 3, 2, "VAE"],
- [4, 2, 0, 3, 0, "MODEL"],
- [5, 2, 2, 3, 1, "CONDITIONING"],
- [6, 1, 3, 4, 1, "STRING"],
- [7, 2, 3, 4, 2, "STRING"],
- [8, 3, 0, 4, 0, "IMAGE"]
- ],
- "groups": [
- {
- "id": 1,
- "title": "FLUX Text-to-Image Workflow",
- "bounding": [
- 50,
- 100,
- 1700,
- 800
- ],
- "color": "#32261a",
- "font_size": 24,
- "flags": {}
- }
+ [1, 1, 0, 3, 0, "MODEL"],
+ [2, 1, 2, 3, 1, "CLIP"],
+ [3, 1, 1, 4, 2, "VAE"],
+ [4, 2, 0, 3, 2, "LORA_STACK"],
+ [5, 3, 0, 4, 0, "MODEL"],
+ [6, 3, 2, 4, 1, "CONDITIONING"],
+ [7, 1, 3, 5, 1, "STRING"],
+ [8, 3, 3, 5, 2, "STRING"],
+ [9, 4, 0, 5, 0, "IMAGE"]
],
+ "groups": [],
"config": {},
- "extra": {
- "ds": {
- "scale": 0.9,
- "offset": [0, 0]
- },
- "info": {
- "name": "BawkNodes FLUX Example Workflow",
- "description": "Complete FLUX text-to-image workflow with wildcard support, optimized sampling, and organized image saving.",
- "author": "BawkNodes",
- "version": "2.2.0"
- }
- },
+ "extra": {},
"version": 0.4
}
diff --git a/workflows/example_zimage_workflow.json b/workflows/example_zimage_workflow.json
deleted file mode 100644
index 2c2d114..0000000
--- a/workflows/example_zimage_workflow.json
+++ /dev/null
@@ -1,342 +0,0 @@
-{
- "id": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
- "revision": 0,
- "last_node_id": 5,
- "last_link_id": 8,
- "nodes": [
- {
- "id": 1,
- "type": "ZImageModelLoader",
- "pos": [
- 100,
- 200
- ],
- "size": [
- 350,
- 150
- ],
- "flags": {},
- "order": 0,
- "mode": 0,
- "inputs": [],
- "outputs": [
- {
- "name": "MODEL",
- "type": "MODEL",
- "links": [
- 1
- ],
- "slot_index": 0
- },
- {
- "name": "VAE",
- "type": "VAE",
- "links": [
- 3
- ],
- "slot_index": 1
- },
- {
- "name": "CLIP",
- "type": "CLIP",
- "links": [
- 2
- ],
- "slot_index": 2
- },
- {
- "name": "MODEL_STRING",
- "type": "STRING",
- "links": [
- 6
- ],
- "slot_index": 3
- }
- ],
- "properties": {
- "cnr_id": "comfyui-bawknodes",
- "Node name for S&R": "ZImageModelLoader"
- },
- "widgets_values": [
- "z-image-turbo.safetensors",
- "ae.safetensors",
- "none",
- "bf16"
- ],
- "shape": 1
- },
- {
- "id": 2,
- "type": "FluxWildcardEncode",
- "pos": [
- 500,
- 200
- ],
- "size": [
- 400,
- 550
- ],
- "flags": {},
- "order": 1,
- "mode": 0,
- "inputs": [
- {
- "name": "model",
- "type": "MODEL",
- "link": 1
- },
- {
- "name": "clip",
- "type": "CLIP",
- "link": 2
- }
- ],
- "outputs": [
- {
- "name": "MODEL",
- "type": "MODEL",
- "links": [
- 4
- ],
- "slot_index": 0
- },
- {
- "name": "CLIP",
- "type": "CLIP",
- "links": null,
- "slot_index": 1
- },
- {
- "name": "CONDITIONING",
- "type": "CONDITIONING",
- "links": [
- 5
- ],
- "slot_index": 2
- },
- {
- "name": "PROMPT_OUT",
- "type": "STRING",
- "links": [
- 7
- ],
- "slot_index": 3
- }
- ],
- "properties": {
- "cnr_id": "comfyui-bawknodes",
- "Node name for S&R": "FluxWildcardEncode"
- },
- "widgets_values": [
- "A stunning {portrait|landscape} photograph of a {mountain lake|forest clearing|coastal cliff} at {golden hour|blue hour|sunrise}, professional photography, 8K resolution, sharp focus, dramatic lighting",
- 42,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0,
- false,
- "None",
- 1.0
- ],
- "shape": 1
- },
- {
- "id": 3,
- "type": "BawkSampler",
- "pos": [
- 950,
- 200
- ],
- "size": [
- 320,
- 550
- ],
- "flags": {},
- "order": 2,
- "mode": 0,
- "inputs": [
- {
- "name": "model",
- "type": "MODEL",
- "link": 4
- },
- {
- "name": "conditioning",
- "type": "CONDITIONING",
- "link": 5
- },
- {
- "name": "vae",
- "type": "VAE",
- "link": 3
- },
- {
- "name": "input_image",
- "type": "IMAGE",
- "link": null
- }
- ],
- "outputs": [
- {
- "name": "images",
- "type": "IMAGE",
- "links": [
- 8
- ],
- "slot_index": 0
- },
- {
- "name": "latent",
- "type": "LATENT",
- "links": null,
- "slot_index": 1
- }
- ],
- "properties": {
- "cnr_id": "comfyui-bawknodes",
- "Node name for S&R": "BawkSampler"
- },
- "widgets_values": [
- "FLUX Optimal - 1024x1024 - 1.0MP โญ Recommended",
- 4,
- 12345,
- "randomize",
- "euler",
- "beta",
- 8,
- 0.0,
- 1.15,
- 0.5,
- 1.0,
- false,
- 1920,
- 1080
- ],
- "shape": 1
- },
- {
- "id": 4,
- "type": "FluxImageSaver",
- "pos": [
- 1320,
- 200
- ],
- "size": [
- 380,
- 320
- ],
- "flags": {},
- "order": 3,
- "mode": 0,
- "inputs": [
- {
- "name": "images",
- "type": "IMAGE",
- "link": 8
- },
- {
- "name": "model_string",
- "type": "STRING",
- "link": 6
- },
- {
- "name": "processed_prompt",
- "type": "STRING",
- "link": 7
- }
- ],
- "outputs": [],
- "properties": {
- "cnr_id": "comfyui-bawknodes",
- "Node name for S&R": "FluxImageSaver"
- },
- "widgets_values": [
- "zimage_output",
- "png",
- 95,
- true,
- true,
- false,
- ""
- ],
- "shape": 1
- },
- {
- "id": 5,
- "type": "PreviewImage",
- "pos": [
- 1320,
- 570
- ],
- "size": [
- 380,
- 300
- ],
- "flags": {},
- "order": 4,
- "mode": 0,
- "inputs": [
- {
- "name": "images",
- "type": "IMAGE",
- "link": 8
- }
- ],
- "outputs": [],
- "properties": {
- "Node name for S&R": "PreviewImage"
- },
- "shape": 1
- }
- ],
- "links": [
- [1, 1, 0, 2, 0, "MODEL"],
- [2, 1, 2, 2, 1, "CLIP"],
- [3, 1, 1, 3, 2, "VAE"],
- [4, 2, 0, 3, 0, "MODEL"],
- [5, 2, 2, 3, 1, "CONDITIONING"],
- [6, 1, 3, 4, 1, "STRING"],
- [7, 2, 3, 4, 2, "STRING"],
- [8, 3, 0, 4, 0, "IMAGE"]
- ],
- "groups": [
- {
- "id": 1,
- "title": "Z-Image Turbo Text-to-Image Workflow (8 steps, no CFG)",
- "bounding": [
- 50,
- 100,
- 1700,
- 800
- ],
- "color": "#1a3232",
- "font_size": 24,
- "flags": {}
- }
- ],
- "config": {},
- "extra": {
- "ds": {
- "scale": 0.9,
- "offset": [0, 0]
- },
- "info": {
- "name": "BawkNodes Z-Image Example Workflow",
- "description": "Z-Image-Turbo text-to-image workflow - 8 steps, no CFG (guidance=0.0), same structure as FLUX workflow.",
- "author": "BawkNodes",
- "version": "2.3.0"
- }
- },
- "version": 0.4
-}