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 [![ComfyUI](https://img.shields.io/badge/ComfyUI-Compatible-brightgreen)](https://github.com/comfyanonymous/ComfyUI) -[![FLUX](https://img.shields.io/badge/FLUX-Optimized-blue)](https://blackforestlabs.ai/) -[![Z-Image](https://img.shields.io/badge/Z--Image-Supported-cyan)](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) -[![Version](https://img.shields.io/badge/Version-2.3.0-orange)]() +[![Version](https://img.shields.io/badge/Version-3.0.0-orange)]()
---- +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 -}