Compare commits
@@ -0,0 +1,28 @@
|
|||||||
|
name: Publish to Comfy registry
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- "pyproject.toml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-node:
|
||||||
|
name: Publish Custom Node to registry
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.repository_owner == 'SatadalAI' }}
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
- name: Publish Custom Node
|
||||||
|
uses: Comfy-Org/publish-node-action@v1
|
||||||
|
with:
|
||||||
|
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||||
|
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# SATA Utility Node Suite for ComfyUI
|
# SATA Utility Node Suite for ComfyUI
|
||||||
|
|
||||||
**Author:** Satadal
|
**Author:** Satadal
|
||||||
**Version:** 1.2.0
|
**Version:** 1.5.0
|
||||||
**License:** MIT
|
**License:** MIT
|
||||||
**Repository:** https://github.com/SatadalAI/SATA_UtilityNode
|
**Repository:** https://github.com/SatadalAI/SATA_UtilityNode
|
||||||
|
|
||||||
@@ -12,12 +12,15 @@
|
|||||||
SATA Utility Node Suite is a collection of custom nodes for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) designed to enhance your image processing and workflow experience.
|
SATA Utility Node Suite is a collection of custom nodes for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) designed to enhance your image processing and workflow experience.
|
||||||
It includes:
|
It includes:
|
||||||
|
|
||||||
- **Upscale Machine:** High-quality model-based image upscaling with aspect-ratio preserving rescale.
|
- **Upscale Machine:** High-quality model-based image upscaling with Frequency-Split SR and Blue Noise injection.
|
||||||
- **Prompt Machine:** Easy prompt selection from a CSV for positive/negative prompt workflows.
|
- **Prompt Machine (Six-Slot):** Professional structured prompting with JSON output support.
|
||||||
|
- **Prompt Style Machine:** Easy prompt selection from a CSV for positive/negative prompt workflows.
|
||||||
- **Touchpad Pan & Zoom:** Seamless navigation for large images in the ComfyUI preview panel.
|
- **Touchpad Pan & Zoom:** Seamless navigation for large images in the ComfyUI preview panel.
|
||||||
- **Resolution Machine:** Quick selection of model-specific image resolutions for popular models.
|
- **Resolution Machine:** Quick selection of model-specific image resolutions with intelligent Megapixel scaling.
|
||||||
- **Save Image w/Metadata:** saving images with meta data and with different file formats.
|
- **Save Image w/Metadata:** Saving images with metadata and with different file formats.
|
||||||
- **Prompt Autocomplete:** Text widget with autocompletion, random selection, and preview capabilities.
|
- **Prompt Autocomplete:** Text widget with highly optimized subsequence fuzzy autocompletion.
|
||||||
|
- **Latent Machine:** Advanced noise generation (Power-Law, Plasma, Blue, Offset) supporting both 4-channel and 16-channel modern architectures.
|
||||||
|
- **Preview Machine:** Privacy-focused image preview node with auto-hide and hover-to-reveal functionality.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,17 +29,23 @@ It includes:
|
|||||||
### 🖼️ Upscale Machine
|
### 🖼️ Upscale Machine
|
||||||
|
|
||||||
- **Model-based Upscaling:** Uses custom or pre-trained models from the `upscale_models` folder.
|
- **Model-based Upscaling:** Uses custom or pre-trained models from the `upscale_models` folder.
|
||||||
|
- **Frequency-Split SR:** Separates image into base colors (low frequencies) and sharp edges (high frequencies). The neural network only upscales edges, while a bicubic algorithm scales colors, eliminating "plastic/waxy" looks and edge halos.
|
||||||
|
- **Blue Noise Injection:** Adds high-frequency micro-textures (`inject_noise_for_realism`) during upscaling. Automatically activates when using a chained model to fix the "smoothness" problem in low-step/distilled models like SDXL Turbo.
|
||||||
- **Aspect Ratio Preserving Rescale:** Output size is always original size × rescale factor.
|
- **Aspect Ratio Preserving Rescale:** Output size is always original size × rescale factor.
|
||||||
- **CPU-based Rescaling:** Efficient resizing using OpenCV.
|
- **CPU-based Rescaling:** Efficient resizing using OpenCV.
|
||||||
- **Supersampling:** Optional detail preservation for downscaling/fractional resizing.
|
- **Rounding Modulus:** Ensures output dimensions seamlessly align with UNet architectural constraints.
|
||||||
- **Rounding Modulus:** Ensures output dimensions align with model/UI constraints.
|
|
||||||
- **Dual Output:** Returns both the processed image and a help/documentation link.
|
|
||||||
|
|
||||||
### 💬 Prompt Machine
|
### 💬 Prompt Machine (Six-Slot Framework)
|
||||||
|
|
||||||
|
- **Structured Prompting:** Dedicated input boxes for `subject`, `style`, `lighting`, `composition`, `mood`, and `technical` details to prevent "word salad".
|
||||||
|
- **JSON Output Mode:** Toggle `json_output` to compile the slots into a strict JSON string, perfect for piping into LLM rewriting nodes or API pipelines.
|
||||||
|
- **Autocomplete Ready:** Fully integrated with the autocomplete system—just type `#` in any slot to trigger the smart tag popup!
|
||||||
|
- **Natural Language & Comma Separated:** Dynamically formats outputs for FLUX (Natural Language) or SDXL (Comma Separated).
|
||||||
|
|
||||||
|
### 💬 Prompt Style Machine (Legacy)
|
||||||
|
|
||||||
- **CSV-based Prompt Selection:** Select prompt sets from a CSV file (`prompts.csv`).
|
- **CSV-based Prompt Selection:** Select prompt sets from a CSV file (`prompts.csv`).
|
||||||
- **Positive/Negative Output:** Outputs both positive and negative prompts as strings.
|
- **Clean Output:** Outputs the positive prompt string with a live, enlarged text preview inside the node.
|
||||||
- **Easy Integration:** Designed for use in prompt-driven workflows.
|
|
||||||
|
|
||||||
### 🖱️ Touchpad Pan & Zoom
|
### 🖱️ Touchpad Pan & Zoom
|
||||||
|
|
||||||
@@ -45,31 +54,43 @@ It includes:
|
|||||||
|
|
||||||
### 📏 Resolution Machine
|
### 📏 Resolution Machine
|
||||||
|
|
||||||
- **Model-aware Resolution Selection:** Choose from a list of recommended resolutions for each supported model.
|
- **Intelligent Megapixel Scaling:** Uses a target Megapixel slider to automatically calculate the exact width/height required to hit the target while perfectly preserving your chosen aspect ratio. Rounds to the nearest multiple of 8.
|
||||||
- **Auto-populate Width/Height:** Selecting a resolution automatically fills in the correct width and height.
|
- **Model-aware Resolution Selection:** Choose from a list of recommended resolutions for each supported model (SD1.5, SDXL, Flux.1 & Flux.2, SD3 & SD3.5, Ideogram 4, Lumina, Qwen-Image, Z-Image Turbo, Hunyuan-DiT, and Video Models like Wan2.2, HunyuanVideo, Mochi-1, LTX-Video).
|
||||||
- **Manual Override:** You can manually set width/height, which clears the preset selection.
|
|
||||||
- **Supports Many Models:** Includes presets for SD1.5, SDXL, Illustrious XL, Pony XL, Flux.1 Dev, Qwen-Image, Wan2.2-S2V, SD3.5 Large, Lumina-Image 2.0, and more.
|
|
||||||
- **UI Integration:** Intuitive dropdowns for model and resolution selection in the ComfyUI interface.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 💾 Save Image w/Metadata
|
### 💾 Save Image w/Metadata
|
||||||
|
|
||||||
- **Single path+filename input:** Provide a single required `path_and_filename` string (examples: `comfy`, `Test/comfy`, `SDXL/Test/comfy`). The last path segment is used as the filename; preceding segments become folders under the ComfyUI output directory.
|
- **Single path+filename input:** Provide a single required `path_and_filename` string.
|
||||||
- **Supported formats:** PNG, JPEG (saved as .jpg), WEBP (choose with the `extension` dropdown).
|
- **Supported formats:** PNG, JPEG (saved as .jpg), WEBP (choose with the `extension` dropdown).
|
||||||
- **Automatic folder creation:** Any missing folders in the provided path are created automatically.
|
- **Automatic folder creation:** Any missing folders in the provided path are created automatically.
|
||||||
- **Metadata embedding:** PNG files receive tEXt entries (parameters/prompt). JPEG/WEBP receive EXIF UserComment where possible.
|
- **Metadata embedding:** PNG files receive tEXt entries (parameters/prompt). JPEG/WEBP receive EXIF UserComment where possible.
|
||||||
- **Placeholder resolution:** Filenames and paths accept `%...%` placeholders which are resolved from the node's `prompt` dictionary (best-effort). Example: if `prompt` contains `{"title": "MyTitle"}`, using `%title%/img` will save under `MyTitle/img.png`.
|
- **Placeholder resolution:** Filenames and paths accept `%...%` placeholders which are resolved from the node's `prompt` dictionary.
|
||||||
- **Collision-safe saving:** If a file already exists the node will append `_0001`, `_0002`, ... to the base filename to avoid overwriting.
|
- **Collision-safe saving:** Automatically appends numerical suffixes to avoid overwriting.
|
||||||
|
|
||||||
### ✍️ Prompt Autocomplete
|
### ✍️ Prompt Autocomplete
|
||||||
|
|
||||||
- **Autocompletion:** Type `#` (configurable) to trigger a popup with suggestions from CSV/JSON files in the `prompt` folder.
|
- **Subsequence Fuzzy Matching:** Ultra-fast, highly optimized fuzzy search with rank-based scoring.
|
||||||
|
- **Autocompletion:** Type `#` (configurable) to trigger a popup with suggestions from CSV/JSON files in the `prompt` folder. Supports strict 100-item render limits to prevent UI lag.
|
||||||
- **Random Selection:** Select `🎲 Random` to insert a random item from a category.
|
- **Random Selection:** Select `🎲 Random` to insert a random item from a category.
|
||||||
- **Preview:** View full text of long snippets before inserting.
|
|
||||||
- **Global Mode:** Optional setting to enable autocompletion on ALL text widgets in ComfyUI.
|
- **Global Mode:** Optional setting to enable autocompletion on ALL text widgets in ComfyUI.
|
||||||
- **Custom Data:** Add your own `.csv` or `.json` files to the `prompt` folder to extend the library.
|
|
||||||
|
|
||||||
|
### 🌌 Latent Machine
|
||||||
|
|
||||||
|
- **Advanced Noise Generation:** Initialize latents with specific noise patterns:
|
||||||
|
- **Gaussian (White):** Standard noise, good for sharp details.
|
||||||
|
- **Blue (High-Frequency):** Enhances detail and textures.
|
||||||
|
- **Pink (1/f) / Brown (1/f²):** Photorealism, nature, anime backgrounds.
|
||||||
|
- **Perlin / Plasma:** Fluid landscapes, sci-fi, abstract terrains.
|
||||||
|
- **High-Contrast Offset Noise:** Toggle `high_contrast` to inject a `0.1` mean shift to the starting noise, allowing the model to generate pure blacks (pitch-black nights) and blown-out whites (snowstorms).
|
||||||
|
- **Auto 16-Channel Detection:** Automatically provisions the correct latent depth for modern architectures (Flux, SD3, Lumina, Z-Image).
|
||||||
|
- **Reproducibility:** Full seed support for consistent noise generation.
|
||||||
|
|
||||||
|
### 👁️ Preview Machine
|
||||||
|
|
||||||
|
- **Privacy-Focused Preview:** Image preview node that hides content by default.
|
||||||
|
- **Hover to Reveal / Auto-Hide:** Simply hover over the node to temporarily reveal the image.
|
||||||
|
- **Global Hide Mode:** Optional setting to hide ALL preview nodes in the workflow (Settings → SATA Utility: Global Hide Previews).
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
|
|||||||
+16
-10
@@ -1,28 +1,34 @@
|
|||||||
# __init__.py
|
|
||||||
|
|
||||||
# Import all the node classes from the nodes directory
|
# Import all the node classes from the nodes directory
|
||||||
from .nodes.prompt_machine import Prompt_Machine
|
from .nodes.prompt_machine import Prompt_Machine, Prompt_Style_Machine
|
||||||
from .nodes.resolution_machine import Resolution_Machine
|
from .nodes.resolution_machine import Resolution_Machine
|
||||||
from .nodes.save_image_metadata import ImageSaveWithMetadata
|
from .nodes.save_machine import Save_Machine
|
||||||
from .nodes.upscale_machine import Upscale_Machine
|
from .nodes.upscale_machine import Upscale_Machine
|
||||||
from .nodes.prompt_autocomplete import PromptAutocomplete
|
from .nodes.prompt_autocomplete import PromptAutocomplete
|
||||||
|
from .nodes.latent_machine import Latent_Machine
|
||||||
|
from .nodes.preview_machine import Preview_Machine
|
||||||
|
|
||||||
# A dictionary that maps class names to class objects
|
# A dictionary that maps class names to class objects
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"Prompt_Machine": Prompt_Machine,
|
"Prompt_Machine": Prompt_Machine,
|
||||||
|
"Prompt_Style_Machine": Prompt_Style_Machine,
|
||||||
"Resolution_Machine": Resolution_Machine,
|
"Resolution_Machine": Resolution_Machine,
|
||||||
"ImageSaveWithMetadata": ImageSaveWithMetadata,
|
"Save_Machine": Save_Machine,
|
||||||
"Upscale_Machine": Upscale_Machine,
|
"Upscale_Machine": Upscale_Machine,
|
||||||
"PromptAutocomplete": PromptAutocomplete,
|
"PromptAutocomplete": PromptAutocomplete,
|
||||||
|
"Latent_Machine": Latent_Machine,
|
||||||
|
"Preview_Machine": Preview_Machine,
|
||||||
}
|
}
|
||||||
|
|
||||||
# A dictionary that maps display names to class names
|
# A dictionary that maps display names to class names
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"Prompt Machine": "Prompt_Machine",
|
"Prompt_Machine": "Prompt Machine",
|
||||||
"Resolution Machine": "Resolution_Machine",
|
"Prompt_Style_Machine": "Prompt Style Machine",
|
||||||
"Save Image w/Metadata": "ImageSaveWithMetadata",
|
"Resolution_Machine": "Resolution Machine",
|
||||||
"Upscale Machine": "Upscale_Machine",
|
"Save_Machine": "Save Machine",
|
||||||
"Prompt Autocomplete": "PromptAutocomplete",
|
"Upscale_Machine": "Upscale Machine",
|
||||||
|
"PromptAutocomplete": "Prompt Autocomplete",
|
||||||
|
"Latent_Machine": "Latent Machine",
|
||||||
|
"Preview_Machine": "Preview Machine",
|
||||||
}
|
}
|
||||||
|
|
||||||
# A dictionary that contains web directory information
|
# A dictionary that contains web directory information
|
||||||
|
|||||||
+359
-57
@@ -1,61 +1,363 @@
|
|||||||
{
|
{
|
||||||
"SD 1.5": {
|
"resolutions": {
|
||||||
"Square (1:1) 512x512": { "width": 512, "height": 512 },
|
"512": {
|
||||||
"Square high-res (1:1) 768x768": { "width": 768, "height": 768 },
|
"Square": {
|
||||||
"Landscape (3:2) 768x512": { "width": 768, "height": 512 },
|
"Square (1:1) 512x512": {
|
||||||
"Portrait (2:3) 512x768": { "width": 512, "height": 768 },
|
"width": 512,
|
||||||
"Landscape (4:3) 768x576": { "width": 768, "height": 576 },
|
"height": 512
|
||||||
"Portrait (3:4) 576x768": { "width": 576, "height": 768 },
|
}
|
||||||
"Widescreen (16:9) 912x512": { "width": 912, "height": 512 },
|
},
|
||||||
"Tall (9:16) 512x912": { "width": 512, "height": 912 },
|
"Landscape": {
|
||||||
"Custom (manual)": { "width": 0, "height": 0 }
|
"Photography (3:2) 768x512": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 512
|
||||||
|
},
|
||||||
|
"Widescreen (16:9) 912x512": {
|
||||||
|
"width": 912,
|
||||||
|
"height": 512
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Portrait": {
|
||||||
|
"Photo Portrait (2:3) 512x768": {
|
||||||
|
"width": 512,
|
||||||
|
"height": 768
|
||||||
|
},
|
||||||
|
"Vertical (9:16) 512x912": {
|
||||||
|
"width": 512,
|
||||||
|
"height": 912
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"768": {
|
||||||
|
"Square": {
|
||||||
|
"Square (1:1) 768x768": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 768
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Landscape": {
|
||||||
|
"Cinematic (21:9) 1344x576": {
|
||||||
|
"width": 1344,
|
||||||
|
"height": 576
|
||||||
|
},
|
||||||
|
"Photography (3:2) 768x512": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 512
|
||||||
|
},
|
||||||
|
"Photo Landscape (4:3) 768x576": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 576
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Portrait": {
|
||||||
|
"Tall (9:21) 576x1344": {
|
||||||
|
"width": 576,
|
||||||
|
"height": 1344
|
||||||
|
},
|
||||||
|
"Photo Portrait (2:3) 512x768": {
|
||||||
|
"width": 512,
|
||||||
|
"height": 768
|
||||||
|
},
|
||||||
|
"Portrait (3:4) 576x768": {
|
||||||
|
"width": 576,
|
||||||
|
"height": 768
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1024": {
|
||||||
|
"Square": {
|
||||||
|
"Square (1:1) 1024x1024": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Landscape": {
|
||||||
|
"Cinematic (21:9) 1536x640": {
|
||||||
|
"width": 1536,
|
||||||
|
"height": 640
|
||||||
|
},
|
||||||
|
"Cinematic (21:9) 1680x720": {
|
||||||
|
"width": 1680,
|
||||||
|
"height": 720
|
||||||
|
},
|
||||||
|
"Ultra Wide (2:1) 1536x768": {
|
||||||
|
"width": 1536,
|
||||||
|
"height": 768
|
||||||
|
},
|
||||||
|
"Widescreen (16:9) 1280x720": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720
|
||||||
|
},
|
||||||
|
"Widescreen (16:9) 1344x768": {
|
||||||
|
"width": 1344,
|
||||||
|
"height": 768
|
||||||
|
},
|
||||||
|
"Widescreen (16:9) 1536x864": {
|
||||||
|
"width": 1536,
|
||||||
|
"height": 864
|
||||||
|
},
|
||||||
|
"Wide Landscape (10:7) 1280x896": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 896
|
||||||
|
},
|
||||||
|
"Photo Landscape (4:3) 1152x864": {
|
||||||
|
"width": 1152,
|
||||||
|
"height": 864
|
||||||
|
},
|
||||||
|
"Photo Landscape (4:3) 1152x896": {
|
||||||
|
"width": 1152,
|
||||||
|
"height": 896
|
||||||
|
},
|
||||||
|
"Photography (3:2) 1152x768": {
|
||||||
|
"width": 1152,
|
||||||
|
"height": 768
|
||||||
|
},
|
||||||
|
"Photography (3:2) 1248x832": {
|
||||||
|
"width": 1248,
|
||||||
|
"height": 832
|
||||||
|
},
|
||||||
|
"Photography (3:2) 1280x832": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 832
|
||||||
|
},
|
||||||
|
"Balanced Landscape (19:13) 1216x832": {
|
||||||
|
"width": 1216,
|
||||||
|
"height": 832
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Portrait": {
|
||||||
|
"Tall (9:21) 640x1536": {
|
||||||
|
"width": 640,
|
||||||
|
"height": 1536
|
||||||
|
},
|
||||||
|
"Tall (9:21) 720x1680": {
|
||||||
|
"width": 720,
|
||||||
|
"height": 1680
|
||||||
|
},
|
||||||
|
"Extra Tall (1:2) 768x1536": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 1536
|
||||||
|
},
|
||||||
|
"Vertical (9:16) 720x1280": {
|
||||||
|
"width": 720,
|
||||||
|
"height": 1280
|
||||||
|
},
|
||||||
|
"Vertical (9:16) 768x1344": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 1344
|
||||||
|
},
|
||||||
|
"Vertical (9:16) 864x1536": {
|
||||||
|
"width": 864,
|
||||||
|
"height": 1536
|
||||||
|
},
|
||||||
|
"Tall Portrait (7:10) 896x1280": {
|
||||||
|
"width": 896,
|
||||||
|
"height": 1280
|
||||||
|
},
|
||||||
|
"Portrait (3:4) 864x1152": {
|
||||||
|
"width": 864,
|
||||||
|
"height": 1152
|
||||||
|
},
|
||||||
|
"Portrait (3:4) 896x1152": {
|
||||||
|
"width": 896,
|
||||||
|
"height": 1152
|
||||||
|
},
|
||||||
|
"Photo Portrait (2:3) 768x1152": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 1152
|
||||||
|
},
|
||||||
|
"Photo Portrait (2:3) 832x1248": {
|
||||||
|
"width": 832,
|
||||||
|
"height": 1248
|
||||||
|
},
|
||||||
|
"Photo Portrait (2:3) 832x1280": {
|
||||||
|
"width": 832,
|
||||||
|
"height": 1280
|
||||||
|
},
|
||||||
|
"Balanced Portrait (13:19) 832x1216": {
|
||||||
|
"width": 832,
|
||||||
|
"height": 1216
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1280": {
|
||||||
|
"Square": {
|
||||||
|
"Square (1:1) 1280x1280": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 1280
|
||||||
|
},
|
||||||
|
"Square (1:1) 1328x1328": {
|
||||||
|
"width": 1328,
|
||||||
|
"height": 1328
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Landscape": {
|
||||||
|
"Cinematic (21:9) 2016x864": {
|
||||||
|
"width": 2016,
|
||||||
|
"height": 864
|
||||||
|
},
|
||||||
|
"Wide Medium (12:7) 1536x896": {
|
||||||
|
"width": 1536,
|
||||||
|
"height": 896
|
||||||
|
},
|
||||||
|
"Widescreen (16:9) 1664x928": {
|
||||||
|
"width": 1664,
|
||||||
|
"height": 928
|
||||||
|
},
|
||||||
|
"Balanced Landscape (9:7) 1440x1120": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 1120
|
||||||
|
},
|
||||||
|
"Photo Landscape (4:3) 1472x1104": {
|
||||||
|
"width": 1472,
|
||||||
|
"height": 1104
|
||||||
|
},
|
||||||
|
"Photo Landscape (4:3) 1472x1140": {
|
||||||
|
"width": 1472,
|
||||||
|
"height": 1140
|
||||||
|
},
|
||||||
|
"Photography (3:2) 1536x1024": {
|
||||||
|
"width": 1536,
|
||||||
|
"height": 1024
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Portrait": {
|
||||||
|
"Tall (9:21) 864x2016": {
|
||||||
|
"width": 864,
|
||||||
|
"height": 2016
|
||||||
|
},
|
||||||
|
"Vertical (9:16) 928x1664": {
|
||||||
|
"width": 928,
|
||||||
|
"height": 1664
|
||||||
|
},
|
||||||
|
"Balanced Portrait (7:9) 1120x1440": {
|
||||||
|
"width": 1120,
|
||||||
|
"height": 1440
|
||||||
|
},
|
||||||
|
"Portrait (3:4) 1104x1472": {
|
||||||
|
"width": 1104,
|
||||||
|
"height": 1472
|
||||||
|
},
|
||||||
|
"Portrait (3:4) 1140x1472": {
|
||||||
|
"width": 1140,
|
||||||
|
"height": 1472
|
||||||
|
},
|
||||||
|
"Photo Portrait (2:3) 1024x1536": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1536
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1536": {
|
||||||
|
"Square": {
|
||||||
|
"Square (1:1) 1536x1536": {
|
||||||
|
"width": 1536,
|
||||||
|
"height": 1536
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Landscape": {
|
||||||
|
"Widescreen (16:9) 1920x1088": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1088
|
||||||
|
},
|
||||||
|
"Widescreen (16:9) 2048x1152": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 1152
|
||||||
|
},
|
||||||
|
"Balanced Landscape (9:7) 1728x1344": {
|
||||||
|
"width": 1728,
|
||||||
|
"height": 1344
|
||||||
|
},
|
||||||
|
"Photo Landscape (4:3) 1728x1296": {
|
||||||
|
"width": 1728,
|
||||||
|
"height": 1296
|
||||||
|
},
|
||||||
|
"Photography (3:2) 1872x1248": {
|
||||||
|
"width": 1872,
|
||||||
|
"height": 1248
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Portrait": {
|
||||||
|
"Vertical (9:16) 1152x2048": {
|
||||||
|
"width": 1152,
|
||||||
|
"height": 2048
|
||||||
|
},
|
||||||
|
"Balanced Portrait (7:9) 1344x1728": {
|
||||||
|
"width": 1344,
|
||||||
|
"height": 1728
|
||||||
|
},
|
||||||
|
"Portrait (3:4) 1296x1728": {
|
||||||
|
"width": 1296,
|
||||||
|
"height": 1728
|
||||||
|
},
|
||||||
|
"Photo Portrait (2:3) 1248x1872": {
|
||||||
|
"width": 1248,
|
||||||
|
"height": 1872
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Video": {
|
||||||
|
"Square": {},
|
||||||
|
"Landscape": {
|
||||||
|
"Widescreen (16:9) 1280x720": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Portrait": {
|
||||||
|
"Vertical (9:16) 720x1280": {
|
||||||
|
"width": 720,
|
||||||
|
"height": 1280
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
"models": {
|
||||||
"SDXL_Illustrious_Pony": {
|
"SD 1.5 Family": [
|
||||||
"Square (1:1) 1024x1024": { "width": 1024, "height": 1024 },
|
"512",
|
||||||
"Landscape (3:2) 1280x832": { "width": 1280, "height": 832 },
|
"768"
|
||||||
"Portrait (2:3) 832x1280": { "width": 832, "height": 1280 },
|
],
|
||||||
"Photography (4:3) 1152x896": { "width": 1152, "height": 896 },
|
"SDXL Family (Pony, Juggernaut)": [
|
||||||
"Portrait Insta (3:4) 896x1152": { "width": 896, "height": 1152 },
|
"768",
|
||||||
"Widescreen (16:9) 1344x768": { "width": 1344, "height": 768 },
|
"1024"
|
||||||
"Vertical (9:16) 768x1344": { "width": 768, "height": 1344 },
|
],
|
||||||
"Cinematic (21:9) 1536x640": { "width": 1536, "height": 640 },
|
"FLUX Family (FLUX.1, FLUX.2)": [
|
||||||
"Cinematic Portrait (9:21) 640x1536": { "width": 640, "height": 1536 },
|
"1024",
|
||||||
"Best Landscape (19:13) 1216x832": { "width": 1216, "height": 832 },
|
"1280",
|
||||||
"Best Portrait (13:19) 832x1216": { "width": 832, "height": 1216 },
|
"1536"
|
||||||
"Mini Portrait (768x1152)": { "width": 768, "height": 1152 },
|
],
|
||||||
"Mini Landscape (1152x768)": { "width": 1152, "height": 768 },
|
"SD3 Family (SD3, SD3.5)": [
|
||||||
"Custom (manual)": { "width": 0, "height": 0 }
|
"1024",
|
||||||
},
|
"1280",
|
||||||
|
"1536"
|
||||||
"Flux": {
|
],
|
||||||
"High-res (16:9) 1920x1088": { "width": 1920, "height": 1088 },
|
"Z-Image Family": [
|
||||||
"Square Medium (1:1) 768x768": { "width": 768, "height": 768 },
|
"1024",
|
||||||
"Portrait Small (3:4) 768x1024": { "width": 768, "height": 1024 },
|
"1280",
|
||||||
"Landscape Small (4:3) 1024x768": { "width": 1024, "height": 768 },
|
"1536"
|
||||||
"Wide Small (3:2) 1152x768": { "width": 1152, "height": 768 },
|
],
|
||||||
"Portrait Medium (2:3) 768x1152": { "width": 768, "height": 1152 },
|
"Hunyuan-DiT": [
|
||||||
"Ultra Wide Small (2:1) 1536x768": { "width": 1536, "height": 768 },
|
"1024",
|
||||||
"Portrait Large (1:2) 768x1536": { "width": 768, "height": 1536 },
|
"1280"
|
||||||
"Square Large (1:1) 1024x1024": { "width": 1024, "height": 1024 },
|
],
|
||||||
"Portrait Small HD (7:10) 896x1280": { "width": 896, "height": 1280 },
|
"Qwen-Image": [
|
||||||
"Landscape Small HD (10:7) 1280x896": { "width": 1280, "height": 896 },
|
"1024",
|
||||||
"Wide Medium (12:7) 1536x896": { "width": 1536, "height": 896 },
|
"1280"
|
||||||
"Portrait HD (7:12) 896x1536": { "width": 896, "height": 1536 },
|
],
|
||||||
"Custom (manual)": { "width": 0, "height": 0 }
|
"Lumina": [
|
||||||
},
|
"1024",
|
||||||
|
"1280"
|
||||||
"Qwen-Image": {
|
],
|
||||||
"Square (1:1)": { "width": 1328, "height": 1328 },
|
"Ideogram 4": [
|
||||||
"Widescreen (16:9)": { "width": 1664, "height": 928 },
|
"1024",
|
||||||
"Tall (9:16)": { "width": 928, "height": 1664 },
|
"1280",
|
||||||
"Landscape (4:3)": { "width": 1472, "height": 1140 },
|
"1536"
|
||||||
"Portrait (3:4)": { "width": 1140, "height": 1472 },
|
],
|
||||||
"Custom (manual)": { "width": 0, "height": 0 }
|
"AuraFlow": [
|
||||||
},
|
"1024"
|
||||||
|
],
|
||||||
"Wan2.2": {
|
"Video Models (Wan, Hunyuan, Mochi, LTX)": [
|
||||||
"720p HD video": { "width": 1280, "height": 720 },
|
"Video"
|
||||||
"Custom (manual)": { "width": 0, "height": 0 }
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,3 +47,309 @@ Velvet,"{prompt} in smooth velvet fabric, gorgeous exquisite cloth, highly detai
|
|||||||
"Wool, Fine knit","{prompt} in wool cloth, highly detailed, gorgeous, fine knit cashmere wool fabric","anime, graphic, text, painting, crayon, graphite, glitch, cartoon, ugly, deformed, noisy, blurry, distorted",
|
"Wool, Fine knit","{prompt} in wool cloth, highly detailed, gorgeous, fine knit cashmere wool fabric","anime, graphic, text, painting, crayon, graphite, glitch, cartoon, ugly, deformed, noisy, blurry, distorted",
|
||||||
"Wool, Heavy knit","{prompt} in chunky heavy wool yarn, highly detailed, heavy knit large wool fabric, sailor's wool","anime, graphic, text, painting, crayon, graphite, glitch, cartoon, ugly, deformed, noisy, blurry, distorted",
|
"Wool, Heavy knit","{prompt} in chunky heavy wool yarn, highly detailed, heavy knit large wool fabric, sailor's wool","anime, graphic, text, painting, crayon, graphite, glitch, cartoon, ugly, deformed, noisy, blurry, distorted",
|
||||||
"Wool, Cable knit","{prompt} in chunky heavy cable knit wool yarn, highly detailed, cable knit large wool fabric","anime, graphic, text, painting, crayon, graphite, glitch, cartoon, ugly, deformed, noisy, blurry, distorted",
|
"Wool, Cable knit","{prompt} in chunky heavy cable knit wool yarn, highly detailed, cable knit large wool fabric","anime, graphic, text, painting, crayon, graphite, glitch, cartoon, ugly, deformed, noisy, blurry, distorted",
|
||||||
|
80s Dark Fantasy Photo,"YOUR CONTEXT:
|
||||||
|
You are a photographer working on the set of a dark fantasy film reminiscent of the 1980s (think *Excalibur*, *Legend*, or *Conan the Barbarian*).
|
||||||
|
Your photograph showcases {$spicy-content-with} a heavily stylized composition with practical effects, matte paintings, and the dramatic lighting of that era. The image is shot with soft focus. Lighting is theatrical with saturated colors: deep blues, fiery oranges, and sickly greens. Emphasis is placed on creating a sense of scale and grandeur through forced perspective and miniature work. Post-processing involves adding subtle film grain, bloom effects around light sources, and a slight color shift.
|
||||||
|
YOUR PHOTOGRAPHY:
|
||||||
|
{prompt}",,
|
||||||
|
Anime,"YOUR CONTEXT:
|
||||||
|
You are a Japanese anime artist.
|
||||||
|
Your Japanese animation includes {$spicy-content-with} an ominous atmosphere, clean lines, and dramatic facial expressions. The artwork uses cool and dark tones along with dramatic lighting and shadows that emphasize the central figure and create a somber, intense mood.
|
||||||
|
YOUR DRAWING:
|
||||||
|
{prompt}",,
|
||||||
|
Casual Photo,"YOUR CONTEXT:
|
||||||
|
You are an amateur documentary photographer taking low quality photos.
|
||||||
|
Your photographs exhibit {$spicy-content-with} sharp backgrounds, unpolished realism with natural lighting, and candid friendship‑level moments that feel immediate and authentic.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Classic Film Photo,"YOUR CONTEXT:
|
||||||
|
You are a professional cinematographer of iconic classic mid‑century cinema (1950s‑1960s Hollywood golden age).
|
||||||
|
Your photographs are on 35mm film with grain, displaying {$spicy-content-with} a warm color temperature, and an anamorphic lens that creates imperfections and blurry edges.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Comic 1,"YOUR CONTEXT:
|
||||||
|
You are an American artist.
|
||||||
|
Your illustration has a comic book cover style, with vibrant colors and outlined shapes, featuring {$spicy-content-with} realistic and epic details in the style of modern .
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Comic 2,"YOUR CONTEXT:
|
||||||
|
You are an Italian artist.
|
||||||
|
Your illustration has a European comic book style, featuring {$spicy-content-with} black hand-drawn outlines, solid colors, realistic anatomical proportions, and cross-hatching for shading.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Comic 3,"YOUR CONTEXT:
|
||||||
|
You are a European artist creating fiction worlds.
|
||||||
|
Your image is a classic ligne claire futurist illustration, featuring {$spicy-content-with} exceptionally clean lines that define each element with the utmost precision. The composition highlights an intense atmosphere with complex technology. The color palette is vibrant, with flat colors and intricate lines and design.
|
||||||
|
YOUR IMAGE:
|
||||||
|
{prompt}",,
|
||||||
|
Comic Book Cover,"YOUR CONTEXT:
|
||||||
|
You are a celebrated comic book cover artist, renowned for hyper-realistic action and monumental heroics.
|
||||||
|
Your cover art is a Dynamic Heroic Realism illustration, characterized by {$spicy-content-with} intricate anatomical accuracy, meticulously rendered textures (fabric, metal, skin), and an explosive sense of motion. The composition often features dramatic foreshortening and a heroic, powerful pose for the central figure, surrounded by dynamic environmental destruction or energy effects. Lighting is grand and directional, sculpting forms with sharp highlights and deep shadows, ensuring every detail contributes to an overwhelming sense of epic scale and immediate impact.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Cottagecore Pastoral Photo,"YOUR CONTEXT:
|
||||||
|
You are a lifestyle photographer capturing the ""Cottagecore"" aesthetic, celebrating a nostalgic, rural life.
|
||||||
|
Your photograph showcases {$spicy-content-with} a soft-focus, romantic composition bathed in warm, hazy afternoon light. The color palette is soft and organic, featuring sage green, dried rose, lavender, and warm ochre. The atmosphere is cozy and rustic, filled with natural textures like linen, dried flowers, and weathered wood in a peaceful countryside setting.
|
||||||
|
YOUR PHOTOGRAPHY:
|
||||||
|
{prompt}",,
|
||||||
|
Cyberpunk Rain Photo,"YOUR CONTEXT:
|
||||||
|
You are a cinematic photographer specializing in the Neon Noir aesthetic of a futuristic night city.
|
||||||
|
Your photograph showcases {$spicy-content-with} high-contrast lighting with deep shadows and glowing neon accents. The color palette is dominated by electric cyan, hot magenta, and deep violet. The atmosphere is moody and atmospheric, featuring wet surfaces, rain reflections, and hazy mist.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Digital Cyberpunk,"YOUR CONTEXT:
|
||||||
|
You are a digital artist working in a dystopian, technologically advanced future.
|
||||||
|
Your illustration is a Cyberpunk Noir scene, characterized by {$spicy-content-with} harsh, dramatic lighting that casts long, sharp shadows, reflecting the grimy underbelly of a futuristic scene. It employs a limited color palette dominated by deep blues, neon greens, and stark oranges, giving a mysterious aesthetic.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Drone PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are an aerial photographer who enjoys using wide-angle drone cams.
|
||||||
|
Your photographs exhibit {$spicy-content-with} panoramic scenes captured from afar, high up with elevated perspectives and intense colors.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Electric Blue Outline,"YOUR CONTEXT:
|
||||||
|
Your image showcases {$spicy-content-with} a digital cyberpunk draw, characterized by vibrant and saturated colors with solid backgrounds and blue outlines.
|
||||||
|
YOUR IMAGE:
|
||||||
|
{prompt}",,
|
||||||
|
Epic Greg,"YOUR CONTEXT:
|
||||||
|
You are a master digital painter of heroic sagas.
|
||||||
|
Your illustration is a grand-scale digital oil painting that includes {$spicy-content-with} characterized by masterful, sweeping brushwork and thick impasto textures. It features dramatic, cinematic lighting with intense chiaroscuro, where light sources like embers or magical energy pierce through atmospheric haze, smoke, and swirling clouds. The composition is heroic and classically inspired, using a rich, moody palette of deep earth tones accented by vibrant, glowing highlights for a legendary and immersive feel.
|
||||||
|
YOUR PAINTING:
|
||||||
|
{prompt}",,
|
||||||
|
Flash 90s PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are an amateur photographer, taking photos with point‑and‑shoot cam-quality, in the early 1990s.
|
||||||
|
Your photographs exhibit {$spicy-content-with} a rough, low-resolution quality, washed-out colors, including a harsh built-in flash, grainy texture, and overexposed highlights, capturing a casual atmosphere that evokes nostalgia.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Folk-Art Mosaic,"YOUR CONTEXT:
|
||||||
|
You are a traditional artisan celebrating the vibrant colors and symbolic imagery of folk art.
|
||||||
|
Your artwork is a Folk Art Mosaic, composed of {$spicy-content-with} small, tessellated pieces of vibrant, solid colors, forming bold patterns and stylized representations of everyday life or mythical creatures. The aesthetic is naive and handcrafted, with a rich texture and a joyful, earthy color palette.
|
||||||
|
YOUR ARTWORK:
|
||||||
|
{prompt}",,
|
||||||
|
Glass Encased Advertising,"YOUR CONTEXT:
|
||||||
|
An eye-level, realistic photo of a glass-encased advertising poster (mupi) located on a clean city sidewalk. There are subtle reflections of nearby buildings and trees on the glass surface. The lighting is natural overcast daylight.
|
||||||
|
WHAT'S BEHIND THE REFLECTIVE GLASS:
|
||||||
|
A faded and sun-faded, low quality printed advertisement, showing: {prompt}",,
|
||||||
|
Gothic Engraving,"YOUR CONTEXT:
|
||||||
|
You are an artist inspired by the macabre aesthetics of 19th-century gothic literature and medieval manuscripts.
|
||||||
|
Your illustration is a Neo-Gothic engraving, executed with intricate linework. It features {$spicy-content-with} dramatic chiaroscuro and a sense of brooding mystery. The color palette is muted, favoring deep indigos, charcoal grays, and touches of crimson, evoking a sense of antique dread.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
High-Key Fashion PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a fashion photographer who creates ultra-bright photos for luxury productions.
|
||||||
|
Your photograph showcases {$spicy-content-with} a high‑key composition shot flooded with soft studio lighting. The exposure is over‑lit with white background, eliminating shadows and delivering a crisp, polished look. Colors are saturated yet controlled (vivid reds, cobalt blues, and crisp whites) while the photo has immaculate details thanks to high‑resolution capture and minimal post‑processing to maintain the airy, glamorous aesthetic.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Ink Draw,"YOUR CONTEXT:
|
||||||
|
You are an ink drawing artist.
|
||||||
|
Your illustration is a realistic ink drawing featuring {$spicy-content-with} fine cross-hatching and stippling techniques. These methods create deep shadows and highlights using only black lines. The image includes large areas of white space and Imagine the colors as all shades of gray.
|
||||||
|
The colors mentioned are actually shades of gray.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Ink Frenzy,"YOUR CONTEXT:
|
||||||
|
You are an underground street-art comic illustrator.
|
||||||
|
Your image is a high-energy, chaotic ink drawing featuring {$spicy-content-with} aggressive brush strokes, intentional ink splatters, and ""controlled mess"" textures. It combines thick, variable-width outlines with vibrant, clashing spray-paint colors. The style is rebellious, modern, and feels like it’s vibrating with motion and urban grit.
|
||||||
|
YOUR IMAGE:
|
||||||
|
{prompt}",,
|
||||||
|
Kawaii Pop Photo,"YOUR CONTEXT:
|
||||||
|
You are a fashion photographer specializing in the bright, playful aesthetic of Kawaii Pop.
|
||||||
|
Your photograph showcases {$spicy-content-with} a high-key composition flooded with vibrant, saturated colors. The exposure is bright and cheerful, with a focus on pastel pinks, electric blues, and sunny yellows. Scene is styled in over-the-top Kawaii fashion with playful poses. The background is simple and colorful, with elements like balloons, confetti, or cartoonish graphics.
|
||||||
|
YOUR PHOTOGRAPHY:
|
||||||
|
{prompt}",,
|
||||||
|
Light Painting Photo,"YOUR CONTEXT:
|
||||||
|
You are an experimental photographer who enjoys creating light painting images with long exposures and LED sources.
|
||||||
|
Your photographs exhibit {$spicy-content-with} luminous trails, vibrant colors, and elements that are strikingly illuminated by the long exposure.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Light and Airy PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a photographer who loves bright, ethereal, and romantic images.
|
||||||
|
Your photograph is bathed in soft, diffused light, creating a bright and airy aesthetic. Colors are pastel and delicate, with a focus on whites, creams, and light blues. The image features {$spicy-content-with} a shallow depth of field, blurring the background and drawing attention to the subject. Post-processing emphasizes highlights and reduces shadows, resulting in a clean, luminous look.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
LomographyPhoto,"YOUR CONTEXT:
|
||||||
|
You are an adventurous experimental photographer exploring the world through a lomographic lens.
|
||||||
|
Your photographs exhibit {$spicy-content-with} film grain, colorful lens flares, soft focus with motion blur, and analog filter effects, trying to capture emotions.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Low-Poly Render,"YOUR CONTEXT:
|
||||||
|
Your unity engine render shows {$spicy-content-with} a low-poly 3d platformer level made from low-poly meshes and low-quality textures.
|
||||||
|
YOUR UNITY ENGINE RENDER:
|
||||||
|
{prompt}",,
|
||||||
|
LowRes Pixel Art,"YOUR CONTEXT:
|
||||||
|
You are a pixel artist.
|
||||||
|
Your illustration is a low-resolution pixel art featuring {$spicy-content-with} chunky pixels and vibrant intense colors, arcade game style.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Manga,"YOUR CONTEXT:
|
||||||
|
You are a Japanese manga artist.
|
||||||
|
Your illustration features {$spicy-content-with} black and white manga-style that includes manga screentone patterns with high-contrast black and white drawing, in a violent atmosphere. Always imagine colors as shades of gray.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Minimalist Digital Illustration,"YOUR CONTEXT:
|
||||||
|
You are a cover artist known for powerful, minimalist designs.
|
||||||
|
Your cover art is a bold graphic iconography piece, featuring {$spicy-content-with} stark, impactful silhouettes and clean, sharp lines that define forms with absolute clarity. The color palette is vibrant, employing large fields of solid color with subtle gradients. Most importantly, the proportions are exaggerated, and the perspective is extremely distorted, creating a sense of vertigo and action.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Minimalist PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a photographer who prefers serene and tranquil images.
|
||||||
|
Your photographs exhibit {$spicy-content-with} high contrast and clean, minimalist compositions that emphasize empty space to highlight negative space.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Modern Impressionism,"YOUR CONTEXT:
|
||||||
|
Your image showcases {$spicy-content-with} a loose and highly expressive brushstroke, characterized by a rich texture created through visible layers of paint and quick, broad strokes reminiscent of modern Impressionism. This technique employing a limited yet impactful color palette with strong contrasts of light and shadow and a focus on warm and cool tones to create a dynamic and energetic feel.
|
||||||
|
YOUR IMAGE:
|
||||||
|
{prompt}",,
|
||||||
|
Modern Ukiyo-e Print,"YOUR CONTEXT:
|
||||||
|
You are a master of traditional Japanese woodblock printing adapted to a modern narrative.
|
||||||
|
Your illustration features {$spicy-content-with} bold, flowing outlines and flat areas of color with subtle wood-grain textures. The composition uses dramatic perspective typical of ""Ukiyo-e"" art, with stylized waves, clouds, or smoke, and a color palette of deep indigo, vermillion, and ochre on an aged washi paper background.
|
||||||
|
YOUR DRAWING:
|
||||||
|
{prompt}",,
|
||||||
|
Noir PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a cinematographer who works in dark movies.
|
||||||
|
Your photographs exhibit {$spicy-content-with} intense side lighting and gobo‑crafted patterns to sculpt deep, sharply defined shadows, along with a muted Ektachrome palette that evokes film noir.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Nostalgic 90s Photo,"YOUR CONTEXT:
|
||||||
|
You are a street photographer capturing the raw, rebellious energy of the 1990s underground scene.
|
||||||
|
Your photograph showcases {$spicy-content-with} a low-fidelity, grainy analog film aesthetic. The exposure features harsh camera flash, slight motion blur, and light leaks. The colors are slightly de-saturated with warm skin tones and heavy shadows, giving it a candid, ""snapshot"" feel.
|
||||||
|
YOUR PHOTOGRAPHY:
|
||||||
|
{prompt}",,
|
||||||
|
Orthochromatic Spirit PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a photographer inspired by the look of early photographic processes, specifically orthochromatic and colorized films.
|
||||||
|
Your photograph showcases {$spicy-content-with} a scene with a high-contrast, blue-sensitive aesthetic. Reds appear very dark, while blues and greens are bright. The image is characterized by a stark, graphic quality. Post-processing focuses on enhancing the contrast and emphasizing the tonal separation.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Paper-Cut Diorama,"YOUR CONTEXT:
|
||||||
|
You are a conceptual artist creating a tactile, handcrafted world.
|
||||||
|
Your illustration is a digital ""paper-cut"" style including {$spicy-content-with} distinct layered shapes with subtle drop shadows to create depth. Each element looks like it was cut from different textures of colored construction paper. The style is geometric, clean, and vibrant, making the comic look like a 3D physical diorama.
|
||||||
|
YOUR IMAGE:
|
||||||
|
{prompt}",,
|
||||||
|
Phone Photo,"YOUR CONTEXT:
|
||||||
|
Your photographs has android phone cam-quality.
|
||||||
|
Your photographs exhibit {$spicy-content-with} surprising compositions, sharp complex backgrounds, natural lighting, and candid moments that feel immediate and authentic.
|
||||||
|
Your photographs are actual gritty candid photographic background.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Pop PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a contemporary pop artist known for your vibrant, oversaturated palettes and energetic, graphic compositions.
|
||||||
|
Your photographs exhibit {$spicy-content-with} exaggerated colors and solid backgrounds using Ben-Day dots, achieving an unconventional and clearly pop art effect.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Pop-Art,"YOUR CONTEXT:
|
||||||
|
You are a bold artist.
|
||||||
|
Your illustration features {$spicy-content-with} Pop Art style, characterized by bold vibrant colors. The colors are executed using the pointillist technique, where dots of color come together to create the overall picture. Dazzling patterns and comic style characters. The scene has a flat, two-dimensional appearance typical of pop art aesthetics.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Portra Film PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a photographer who appreciates the classic aesthetic of Kodak Portra film.
|
||||||
|
Your photograph emulates that look, known for its soft colors, fine grain, and natural skin tones. The image features {$spicy-content-with} a subtle warmth, with a focus on accurate color rendition and a gentle, diffused glow. Highlights are smooth and creamy, while shadows retain detail. Minimal post-processing is applied to preserve the organic, film-like quality.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Post-Processed Artistry,"YOUR CONTEXT:
|
||||||
|
You are a digital artist.
|
||||||
|
Your image is a masterpiece featuring {$spicy-content-with} heavy chromatic aberration, and digital intense color. The aesthetic is ultra-modern and fragmented, using a high-contrast palette of neon purples and cyans, creating a sense of a digital reality.
|
||||||
|
YOUR IMAGE:
|
||||||
|
{prompt}",,
|
||||||
|
Production PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a Hollywood filmmaker making a high-budget film.
|
||||||
|
Your photographs exhibit {$spicy-content-with} atmospheric composition that emphasize intense emotions using selective focus, and warm and cold colors with high quality studio lighting.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Quiet Luxury Photo,"YOUR CONTEXT:
|
||||||
|
You are an editorial fashion photographer specializing in the Old Money aesthetic.
|
||||||
|
Your photograph showcases {$spicy-content-with} a sophisticated, clean composition using natural, soft morning light. The color palette is muted and neutral, focusing on beige, cream, navy blue, and forest green. The scene feels expensive and timeless, set in a luxury location.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Retro 80s ,"YOUR CONTEXT:
|
||||||
|
Your illustration is made with brightly colored bold lines and touches of cracked paint, while having {$spicy-content-with} a 1980s graphic design aesthetic.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Risograph Print,"YOUR CONTEXT:
|
||||||
|
You are an underground indie press printer.
|
||||||
|
Your illustration is a tactile Risograph-style print, featuring {$spicy-content-with} vibrant fluorescent ink and a heavy grainy texture. It uses a punchy palette of neon pink, intense yellow, and deep teal.
|
||||||
|
YOUR MULTI SUBJECT PRINT:
|
||||||
|
{prompt}",,
|
||||||
|
Shadow Fantasy Illustration,"YOUR CONTEXT:
|
||||||
|
You are a dark fantasy comic book artist.
|
||||||
|
Your drawing is a high-contrast illustration characterized by {$spicy-content-with} massive areas of solid black ink and minimal white highlights. Inspired by ""Mignola-esque"" aesthetics, it uses chunky, angular silhouettes and geometric shadows to define form rather than internal lines. The atmosphere is ancient, heavy, and supernatural.
|
||||||
|
YOUR DRAWING:
|
||||||
|
{prompt}",,
|
||||||
|
Simple 3D Render,"YOUR CONTEXT:
|
||||||
|
You are a 3d artist.
|
||||||
|
Your render is a high-gloss, illustration featuring {$spicy-content-with} ultra-reflective surfaces. The style is dark with dark backgrounds and iridescent highlights.
|
||||||
|
YOUR RENDER:
|
||||||
|
{prompt}",,
|
||||||
|
Spotlight Stage Photo,"YOUR CONTEXT:
|
||||||
|
You are a professional stage and theater photographer capturing dramatic performances.
|
||||||
|
Your photographs exhibit {$spicy-content-with} a single, high-intensity overhead spotlight that creates a sharp circular pool of light, dramatic chiaroscuro contrast that isolates the subject against a pitch-black background, and visible volumetric light beams with subtle dust motes for an authentic theatrical atmosphere.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Street Documentary PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a documentary photographer chronicling urban life.
|
||||||
|
Your photograph showcases {$spicy-content-with} candid moments in natural street environments. The image employs a natural color balance, modest contrast, and a shallow depth of field to isolate subjects within bustling scenes. Emphasis is placed on authentic emotion, body language, and the interplay of ambient city light (neon signs, street lamps). Minimal post‑processing preserves the raw, honest feel—only slight exposure adjustments and noise reduction are applied to maintain the immediacy of the moment.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Studio Anime,"YOUR CONTEXT:
|
||||||
|
You are a Japanese anime artist.
|
||||||
|
Your Japanese animation includes {$spicy-content-with} warm artwork inspired by Studio Ghibli, with detailed characters and a whimsical aesthetic, full of emotion.
|
||||||
|
YOUR DRAWING:
|
||||||
|
{prompt}",,
|
||||||
|
Synthwave Photo,"YOUR CONTEXT:
|
||||||
|
You are a photographer inspired by the retro-futuristic visuals of the 1980s.
|
||||||
|
Your photograph showcases {$spicy-content-with} Synthwave aesthetic, characterized by neon colors and geometric shapes, it includes vibrant pinks, purples, and blues, with a glowing effect. Grids, scanlines, and a retro-futuristic atmosphere. The photograph feels like a scene from a classic 80s sci-fi film.
|
||||||
|
Real people against a computer-generated background.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Teal and Orange PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a color specialist photographer.
|
||||||
|
Your photograph showcases {$spicy-content-with} a satured teal and orange colors. The image features cool teal tones in the shadows and highlights, balanced by warm orange tones in the midtones and skin tones, creating a vibrant photography with high contrast and vivid colors.
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Tilt Shift / Toy PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are a photographer who enjoys using tilt‑shift lenses to turn real‑world scenes into miniature toy models.
|
||||||
|
Your photographs exhibit {$spicy-content-with} crisp, high‑resolution details captured in an exaggerated perspective, with a very narrow focus plane, and a dreamy, toy‑like atmosphere that creates the classic “miniature” illusion.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Unsettling,"YOUR CONTEXT:
|
||||||
|
You are an illustrator of dark and disturbing themes.
|
||||||
|
Your illustration includes {$spicy-content-with} stippling textures, dramatic raking side lighting, and a dread-filled atmosphere that is oppressive and claustrophobic. High contrast emphasizes form and shadow, employing cinematic chiaroscuro for depth.
|
||||||
|
YOUR DRAWING:
|
||||||
|
{prompt}",,
|
||||||
|
Vintage Comic,"YOUR CONTEXT:
|
||||||
|
You are a 1950s artist.
|
||||||
|
Your illustration in a worn sheet of paper features {$spicy-content-with} a vintage 1950s comic book style made with hand-drawn lines and painted in solid white, red, blue, and black.
|
||||||
|
YOUR ILLUSTRATION:
|
||||||
|
{prompt}",,
|
||||||
|
Vintage Illustration,"YOUR CONTEXT:
|
||||||
|
You are a sci-fi pulp magazine illustrator from the 1940s.
|
||||||
|
Your drawing is a lush, painted-style comic cover including {$spicy-content-with} dramatic, heroic lighting and a vivid ""Technicolor"" palette of saturated oranges, deep teals, and bright yellows. It features soft airbrushed textures, muscular anatomy, and a grainy film-stock finish that evokes a sense of retro-futuristic adventure.
|
||||||
|
YOUR DRAWING:
|
||||||
|
{prompt}",,
|
||||||
|
Vintage PhotoPhoto,"YOUR CONTEXT:
|
||||||
|
You are an 80s photographer who enjoys informal shots.
|
||||||
|
Your worn vintage photographs exhibit {$spicy-content-with} a minimalist, amateurish composition, warm desaturated tones and a soft focus that creates a cozy atmosphere.
|
||||||
|
YOUR PHOTO:
|
||||||
|
{prompt}",,
|
||||||
|
Vintage VGA Monitor,"YOUR CONTEXT:
|
||||||
|
You have an old VGA CRT monitor sitting on a wooden desk, and a window is reflected on its screen. The monitor's casing has a raised black inscription in the upper left corner that reads ""VGA"". The screen has a slight curve and sweep lines typical of CRT displays.
|
||||||
|
WHAT'S INSIDE YOUR MONITOR'S SCREEN:
|
||||||
|
A low-resolution, 2D pixel art platform videogame form early 1990s, featuring: {prompt}",,
|
||||||
|
Vintage Polaroid Photo,"YOUR CONTEXT:
|
||||||
|
You are a nostalgic photographer who embraces instant film’s tactile charm.
|
||||||
|
Your Polaroid photograph is using vintage‑style film. The photograph features the iconic square format including {$spicy-content-with} a soft focus, a subtle vignetting, and the characteristic pastel color shift of instant film. Showing candid moments or intimate still‑lifes. The photograph retains the authentic border, emulsion texture, and occasional “film‑like” imperfections (light leaks, scratches).
|
||||||
|
YOUR PHOTOGRAPH:
|
||||||
|
{prompt}",,
|
||||||
|
Whimsical Watercolor,"YOUR CONTEXT:
|
||||||
|
You are an illustrator specializing in gentle, enchanting narratives for children's books.
|
||||||
|
Your drawing is a whimsical watercolor image, featuring {$spicy-content-with} loose, fluid watercolor washes, delicate linework, and playful, anthropomorphic elements within a lush natural setting. The style is soft and inviting, with a vibrant yet gentle color palette that captures the magic of innocence.
|
||||||
|
YOUR DRAWING:
|
||||||
|
{prompt}",,
|
||||||
|
Wide Angle / Peephole,"YOUR CONTEXT:
|
||||||
|
Your peephole shows {$spicy-content-with} a shot from a high angle, inside a fisheye lens distortion glass circle, highly distorted with a wide angle.
|
||||||
|
YOUR PEEPHOLE:
|
||||||
|
{prompt}",,
|
||||||
|
Witchcore Photo,"YOUR CONTEXT:
|
||||||
|
You are a photographer drawn to the mystical and earthy aesthetic of Witchcore.
|
||||||
|
Your photograph showcases {$spicy-content-with} a moody, atmospheric scene steeped in natural elements. The image employs a natural color balance, with a focus on deep greens, browns, and blacks. Emphasis is placed on textures: moss, wood, crystals, and flowing fabrics. The photograph has a raw, organic feel, with mystical ambiance.
|
||||||
|
YOUR PHOTOGRAPHY:
|
||||||
|
{prompt}",,
|
||||||
|
|||||||
|
File diff suppressed because one or more lines are too long
@@ -0,0 +1,248 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
|
||||||
|
// CONSTANTS
|
||||||
|
const GLOBAL_HIDE_SETTING_ID = "SATA_UtilityNode.GlobalPreviewHide";
|
||||||
|
const PREVIEW_MACHINE_CLASS = "Preview_Machine";
|
||||||
|
const SAVE_MACHINE_CLASS = "Save_Machine";
|
||||||
|
|
||||||
|
// Track hover state
|
||||||
|
const hoveredNodeIds = new Set();
|
||||||
|
|
||||||
|
// Helper to get settings for both ComfyUI V1 and V2
|
||||||
|
function getSettingValue(id, defaultValue) {
|
||||||
|
if (app.extensionManager && app.extensionManager.setting) {
|
||||||
|
const val = app.extensionManager.setting.get(id);
|
||||||
|
return val !== undefined ? val : defaultValue;
|
||||||
|
} else if (app.ui && app.ui.settings) {
|
||||||
|
return app.ui.settings.getSettingValue(id, defaultValue);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "SATA_UtilityNode.Preview_Machine",
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: GLOBAL_HIDE_SETTING_ID,
|
||||||
|
name: "SATA Utility: Global Hide Previews",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: false,
|
||||||
|
onChange: (value) => {
|
||||||
|
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
async setup() {
|
||||||
|
setupGlobalMouseTracker();
|
||||||
|
},
|
||||||
|
|
||||||
|
async nodeCreated(node) {
|
||||||
|
if (isTargetNode(node)) {
|
||||||
|
setupNodeHider(node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadedGraphNode(node) {
|
||||||
|
if (isTargetNode(node)) {
|
||||||
|
setupNodeHider(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTargetNode(node) {
|
||||||
|
return node.comfyClass === "PreviewImage" ||
|
||||||
|
node.comfyClass === "SaveImage" ||
|
||||||
|
node.comfyClass === PREVIEW_MACHINE_CLASS ||
|
||||||
|
node.comfyClass === SAVE_MACHINE_CLASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHideNode(node) {
|
||||||
|
const globalHide = getSettingValue(GLOBAL_HIDE_SETTING_ID, false);
|
||||||
|
|
||||||
|
if (node.comfyClass === PREVIEW_MACHINE_CLASS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.comfyClass === SAVE_MACHINE_CLASS) {
|
||||||
|
const w = node.widgets?.find(w => w.name === "hide_preview");
|
||||||
|
if (w) {
|
||||||
|
if (w.value === true) return true;
|
||||||
|
if (w.value === false && !globalHide) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalHide;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMouseOverNode(node) {
|
||||||
|
if (!app.canvas || !app.canvas.graph_mouse) return false;
|
||||||
|
|
||||||
|
const mouse = app.canvas.graph_mouse;
|
||||||
|
const x = mouse[0];
|
||||||
|
const y = mouse[1];
|
||||||
|
|
||||||
|
return x >= node.pos[0] &&
|
||||||
|
x <= (node.pos[0] + node.size[0]) &&
|
||||||
|
y >= node.pos[1] &&
|
||||||
|
y <= (node.pos[1] + node.size[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGlobalMouseTracker() {
|
||||||
|
let rafPending = false;
|
||||||
|
|
||||||
|
const checkAndUpdate = () => {
|
||||||
|
if (!app.graph || !app.graph._nodes) return;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const node of app.graph._nodes) {
|
||||||
|
if (!isTargetNode(node)) continue;
|
||||||
|
|
||||||
|
const currentlyHovered = isMouseOverNode(node);
|
||||||
|
const wasHovered = hoveredNodeIds.has(node.id);
|
||||||
|
|
||||||
|
if (currentlyHovered !== wasHovered) {
|
||||||
|
if (currentlyHovered) {
|
||||||
|
hoveredNodeIds.add(node.id);
|
||||||
|
} else {
|
||||||
|
hoveredNodeIds.delete(node.id);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
app.graph.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", () => {
|
||||||
|
if (!rafPending) {
|
||||||
|
rafPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rafPending = false;
|
||||||
|
checkAndUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("mouseout", (e) => {
|
||||||
|
if (e.relatedTarget === null) {
|
||||||
|
hoveredNodeIds.clear();
|
||||||
|
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupNodeHider(node) {
|
||||||
|
if (node._sata_hider_v6) return;
|
||||||
|
node._sata_hider_v6 = true;
|
||||||
|
|
||||||
|
// Function to check if node should be hidden right now
|
||||||
|
const shouldHideNow = () => {
|
||||||
|
const shouldHide = shouldHideNode(node);
|
||||||
|
const hovered = hoveredNodeIds.has(node.id);
|
||||||
|
return shouldHide && !hovered;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find and patch all image-related widgets on the node
|
||||||
|
const patchWidgets = () => {
|
||||||
|
if (!node.widgets) return;
|
||||||
|
|
||||||
|
for (const widget of node.widgets) {
|
||||||
|
// Check if this is an image widget (they typically have draw/drawWidget methods)
|
||||||
|
if (widget._sata_patched) continue;
|
||||||
|
|
||||||
|
// Patch the draw method if it exists
|
||||||
|
if (widget.draw && typeof widget.draw === 'function') {
|
||||||
|
const origDraw = widget.draw.bind(widget);
|
||||||
|
widget.draw = function (ctx, node, widgetWidth, y, widgetHeight) {
|
||||||
|
if (shouldHideNow()) {
|
||||||
|
// Draw hidden placeholder instead
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = "#1a1a1a";
|
||||||
|
ctx.fillRect(0, y, widgetWidth, widgetHeight);
|
||||||
|
|
||||||
|
ctx.fillStyle = "#888";
|
||||||
|
ctx.font = "12px Arial";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText("Hidden", widgetWidth / 2, y + widgetHeight / 2 - 8);
|
||||||
|
ctx.font = "9px Arial";
|
||||||
|
ctx.fillStyle = "#666";
|
||||||
|
ctx.fillText("(Hover to Reveal)", widgetWidth / 2, y + widgetHeight / 2 + 8);
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return origDraw(ctx, node, widgetWidth, y, widgetHeight);
|
||||||
|
};
|
||||||
|
widget._sata_patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also patch drawWidget if it exists (some widgets use this)
|
||||||
|
if (widget.drawWidget && typeof widget.drawWidget === 'function') {
|
||||||
|
const origDrawWidget = widget.drawWidget.bind(widget);
|
||||||
|
widget.drawWidget = function (...args) {
|
||||||
|
if (shouldHideNow()) {
|
||||||
|
// Skip drawing - the onDrawForeground will handle the overlay
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return origDrawWidget(...args);
|
||||||
|
};
|
||||||
|
widget._sata_patched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patch widgets initially and on any update
|
||||||
|
patchWidgets();
|
||||||
|
|
||||||
|
// Also intercept onDrawBackground to catch widget additions
|
||||||
|
const origOnDrawBackground = node.onDrawBackground;
|
||||||
|
node.onDrawBackground = function (ctx) {
|
||||||
|
patchWidgets(); // Re-check for new widgets
|
||||||
|
if (origOnDrawBackground) {
|
||||||
|
origOnDrawBackground.apply(this, arguments);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw overlay in onDrawForeground as backup
|
||||||
|
const origOnDrawForeground = node.onDrawForeground;
|
||||||
|
node.onDrawForeground = function (ctx) {
|
||||||
|
if (origOnDrawForeground) {
|
||||||
|
origOnDrawForeground.apply(this, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldHideNow()) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = "#1a1a1a";
|
||||||
|
ctx.fillRect(0, 0, this.size[0], this.size[1]);
|
||||||
|
|
||||||
|
ctx.fillStyle = "#888";
|
||||||
|
ctx.font = "bold 14px Arial";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText("Hidden", this.size[0] / 2, this.size[1] / 2 - 10);
|
||||||
|
|
||||||
|
ctx.font = "10px Arial";
|
||||||
|
ctx.fillStyle = "#666";
|
||||||
|
ctx.fillText("(Hover to Reveal)", this.size[0] / 2, this.size[1] / 2 + 10);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Widget callback for Save_Machine
|
||||||
|
if (node.comfyClass === SAVE_MACHINE_CLASS) {
|
||||||
|
const hideWidget = node.widgets?.find(w => w.name === "hide_preview");
|
||||||
|
if (hideWidget) {
|
||||||
|
const origCallback = hideWidget.callback;
|
||||||
|
hideWidget.callback = function (value) {
|
||||||
|
if (origCallback) origCallback.call(this, value);
|
||||||
|
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-46
@@ -1,30 +1,63 @@
|
|||||||
import { app } from "../../../scripts/app.js";
|
import { app } from "../../../scripts/app.js";
|
||||||
|
|
||||||
|
// Helper to get settings for both ComfyUI V1 and V2
|
||||||
|
function getSettingValue(id, defaultValue) {
|
||||||
|
if (app.extensionManager && app.extensionManager.setting) {
|
||||||
|
const val = app.extensionManager.setting.get(id);
|
||||||
|
return val !== undefined ? val : defaultValue;
|
||||||
|
} else if (app.ui && app.ui.settings) {
|
||||||
|
return app.ui.settings.getSettingValue(id, defaultValue);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuzzyMatch(text, query) {
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
const queryLower = query.toLowerCase();
|
||||||
|
if (!queryLower) return { match: true, score: 0 };
|
||||||
|
|
||||||
|
let queryIdx = 0;
|
||||||
|
let score = 0;
|
||||||
|
let lastMatchIdx = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < textLower.length; i++) {
|
||||||
|
if (textLower[i] === queryLower[queryIdx]) {
|
||||||
|
if (lastMatchIdx !== -1) {
|
||||||
|
score += (i - lastMatchIdx - 1);
|
||||||
|
}
|
||||||
|
lastMatchIdx = i;
|
||||||
|
queryIdx++;
|
||||||
|
if (queryIdx === queryLower.length) {
|
||||||
|
score += (textLower.length - i - 1) * 0.1;
|
||||||
|
return { match: true, score: score };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { match: false, score: Infinity };
|
||||||
|
}
|
||||||
|
|
||||||
// Register extension
|
// Register extension
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "SATA_UtilityNode.PromptAutocomplete",
|
name: "SATA_UtilityNode.PromptAutocomplete",
|
||||||
|
|
||||||
async setup() {
|
settings: [
|
||||||
// Add settings
|
{
|
||||||
app.ui.settings.addSetting({
|
id: "SATA_UtilityNode.PromptAutocomplete.Trigger",
|
||||||
id: "SATA.PromptAutocomplete.Trigger",
|
|
||||||
name: "Prompt Autocomplete Trigger Character",
|
name: "Prompt Autocomplete Trigger Character",
|
||||||
type: "text",
|
type: "text",
|
||||||
defaultValue: "#",
|
defaultValue: "#",
|
||||||
});
|
},
|
||||||
|
{
|
||||||
app.ui.settings.addSetting({
|
id: "SATA_UtilityNode.PromptAutocomplete.Global",
|
||||||
id: "SATA.PromptAutocomplete.Global",
|
|
||||||
name: "Prompt Autocomplete Global Mode (All Text Widgets)",
|
name: "Prompt Autocomplete Global Mode (All Text Widgets)",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
});
|
}
|
||||||
},
|
],
|
||||||
|
|
||||||
async nodeCreated(node) {
|
async initAutocompleteForNode(node) {
|
||||||
// Check if we should attach to this node
|
const isTargetNode = node.comfyClass === "PromptAutocomplete" || node.comfyClass === "Prompt_Machine";
|
||||||
const isTargetNode = node.comfyClass === "PromptAutocomplete";
|
const isGlobal = getSettingValue("SATA_UtilityNode.PromptAutocomplete.Global", false);
|
||||||
const isGlobal = app.ui.settings.getSettingValue("SATA.PromptAutocomplete.Global", false);
|
|
||||||
|
|
||||||
if (!isTargetNode && !isGlobal) return;
|
if (!isTargetNode && !isGlobal) return;
|
||||||
|
|
||||||
@@ -42,6 +75,14 @@ app.registerExtension({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async nodeCreated(node) {
|
||||||
|
this.initAutocompleteForNode(node);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadedGraphNode(node) {
|
||||||
|
this.initAutocompleteForNode(node);
|
||||||
|
},
|
||||||
|
|
||||||
snippetsCache: null,
|
snippetsCache: null,
|
||||||
|
|
||||||
async loadSnippets() {
|
async loadSnippets() {
|
||||||
@@ -59,7 +100,7 @@ app.registerExtension({
|
|||||||
const contentData = await contentResp.json();
|
const contentData = await contentResp.json();
|
||||||
this.snippetsCache[file] = contentData.items || [];
|
this.snippetsCache[file] = contentData.items || [];
|
||||||
}
|
}
|
||||||
console.log("[PromptAutocomplete] Loaded snippets:", Object.keys(this.snippetsCache));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[PromptAutocomplete] Failed to load snippets:", err);
|
console.error("[PromptAutocomplete] Failed to load snippets:", err);
|
||||||
}
|
}
|
||||||
@@ -96,15 +137,33 @@ app.registerExtension({
|
|||||||
if (widget.inputEl) {
|
if (widget.inputEl) {
|
||||||
setupListener(widget.inputEl);
|
setupListener(widget.inputEl);
|
||||||
} else {
|
} else {
|
||||||
// If not, it might be created later. We can hook `onInput` or similar,
|
// If checking widget.draw:
|
||||||
// but `inputEl` is usually created when the node is drawn or added.
|
// Standard LiteGraph widgets might not have an instance-level draw method, relying on the class prototype or default.
|
||||||
// Let's poll or hook draw? Hooking draw is safer to catch it.
|
// If we blindly assign widget.draw = ..., we obscure the default behavior (disappearing widget).
|
||||||
widget.draw = function(ctx, node, widgetWidth, y, widgetHeight) {
|
// So we only wrap if originalDraw exists (or is on the prototype? widget points to instance).
|
||||||
const result = originalDraw?.apply(this, arguments);
|
|
||||||
if (this.inputEl) {
|
// Safer approach: check if draw is defined on the instance or try to get it from prototype?
|
||||||
setupListener(this.inputEl);
|
// Actually, if we just want to catch inputEl creation, maybe polling is safer for standard widgets
|
||||||
|
// if we suspect they don't have a direct 'draw' method we can wrap nicely.
|
||||||
|
|
||||||
|
if (originalDraw) {
|
||||||
|
widget.draw = function (ctx, node, widgetWidth, y, widgetHeight) {
|
||||||
|
const result = originalDraw.apply(this, arguments);
|
||||||
|
if (this.inputEl) {
|
||||||
|
setupListener(this.inputEl);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
return result;
|
} else {
|
||||||
|
// Fallback: poll for inputEl a few times
|
||||||
|
const poll = setInterval(() => {
|
||||||
|
if (widget.inputEl) {
|
||||||
|
setupListener(widget.inputEl);
|
||||||
|
clearInterval(poll);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
// Stop polling after 5 seconds to save resources
|
||||||
|
setTimeout(() => clearInterval(poll), 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -119,7 +178,7 @@ app.registerExtension({
|
|||||||
triggerChar: "#",
|
triggerChar: "#",
|
||||||
|
|
||||||
handleInput(e, input, widget) {
|
handleInput(e, input, widget) {
|
||||||
const trigger = app.ui.settings.getSettingValue("SATA.PromptAutocomplete.Trigger", "#");
|
const trigger = getSettingValue("SATA_UtilityNode.PromptAutocomplete.Trigger", "#");
|
||||||
this.triggerChar = trigger;
|
this.triggerChar = trigger;
|
||||||
|
|
||||||
const val = input.value;
|
const val = input.value;
|
||||||
@@ -192,17 +251,17 @@ app.registerExtension({
|
|||||||
} else {
|
} else {
|
||||||
// No category selected yet
|
// No category selected yet
|
||||||
// Check for colon
|
// Check for colon
|
||||||
const parts = query.split(":");
|
const parts = query.split(":");
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
// Maybe user typed "style:" manually?
|
// Maybe user typed "style:" manually?
|
||||||
const catName = parts[0];
|
const catName = parts[0];
|
||||||
let key = Object.keys(this.snippetsCache).find(k => k.replace(/\.(csv|json)$/, "") === catName);
|
let key = Object.keys(this.snippetsCache).find(k => k.replace(/\.(csv|json)$/, "") === catName);
|
||||||
if (key) {
|
if (key) {
|
||||||
this.currentCategory = key;
|
this.currentCategory = key;
|
||||||
this.updateItems(parts.slice(1).join(":"));
|
this.updateItems(parts.slice(1).join(":"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateCategories(query);
|
this.updateCategories(query);
|
||||||
}
|
}
|
||||||
@@ -274,14 +333,22 @@ app.registerExtension({
|
|||||||
|
|
||||||
updateCategories(query) {
|
updateCategories(query) {
|
||||||
const files = Object.keys(this.snippetsCache || {});
|
const files = Object.keys(this.snippetsCache || {});
|
||||||
// Filter by query
|
const matches = [];
|
||||||
// We display stripped names (no extension)
|
files.forEach(f => {
|
||||||
this.filteredItems = files.map(f => ({
|
const display = f.replace(/\.(csv|json)$/, "");
|
||||||
type: "category",
|
const res = fuzzyMatch(display, query);
|
||||||
value: f,
|
if (res.match) {
|
||||||
display: f.replace(/\.(csv|json)$/, "")
|
matches.push({
|
||||||
})).filter(item => item.display.toLowerCase().includes(query.toLowerCase()));
|
type: "category",
|
||||||
|
value: f,
|
||||||
|
display: display,
|
||||||
|
score: res.score
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
matches.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
|
this.filteredItems = matches;
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
this.renderPopup();
|
this.renderPopup();
|
||||||
},
|
},
|
||||||
@@ -290,13 +357,20 @@ app.registerExtension({
|
|||||||
if (!this.currentCategory) return;
|
if (!this.currentCategory) return;
|
||||||
const items = this.snippetsCache[this.currentCategory] || [];
|
const items = this.snippetsCache[this.currentCategory] || [];
|
||||||
|
|
||||||
// Filter
|
const matches = [];
|
||||||
let matches = items.filter(i => i.toLowerCase().includes(query.toLowerCase()));
|
items.forEach(i => {
|
||||||
|
const res = fuzzyMatch(i, query);
|
||||||
|
if (res.match) {
|
||||||
|
matches.push({ value: i, score: res.score });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
matches.sort((a, b) => a.score - b.score);
|
||||||
|
const sortedItems = matches.map(m => m.value).slice(0, 100);
|
||||||
|
|
||||||
// Add "Random" option at top
|
// Add "Random" option at top
|
||||||
this.filteredItems = [
|
this.filteredItems = [
|
||||||
{ type: "random", value: "RANDOM", display: "🎲 Random" },
|
{ type: "random", value: "RANDOM", display: "🎲 Random" },
|
||||||
...matches.map(i => ({ type: "item", value: i, display: i }))
|
...sortedItems.map(i => ({ type: "item", value: i, display: i }))
|
||||||
];
|
];
|
||||||
|
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
@@ -385,6 +459,7 @@ app.registerExtension({
|
|||||||
},
|
},
|
||||||
|
|
||||||
selectItem(input) {
|
selectItem(input) {
|
||||||
|
if (!input) return;
|
||||||
const item = this.filteredItems[this.selectedIndex];
|
const item = this.filteredItems[this.selectedIndex];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
@@ -403,7 +478,7 @@ app.registerExtension({
|
|||||||
// Don't close popup, just update to items
|
// Don't close popup, just update to items
|
||||||
// We need to update the input value first
|
// We need to update the input value first
|
||||||
|
|
||||||
// Replace everything from trigger to cursor with trigger + textToInsert
|
// Replace everything from trigger to cursor with trigger + textToInsert
|
||||||
const before = val.substring(0, lastTriggerIndex + 1); // includes trigger
|
const before = val.substring(0, lastTriggerIndex + 1); // includes trigger
|
||||||
const after = val.substring(cursor);
|
const after = val.substring(cursor);
|
||||||
input.value = before + textToInsert + after;
|
input.value = before + textToInsert + after;
|
||||||
|
|||||||
+65
-6
@@ -4,7 +4,7 @@ import { app } from "../../../scripts/app.js";
|
|||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "SATA_UtilityNode.PromptMachineFrontend",
|
name: "SATA_UtilityNode.PromptMachineFrontend",
|
||||||
async nodeCreated(node) {
|
async nodeCreated(node) {
|
||||||
if (node.comfyClass !== "Prompt_Machine") return;
|
if (node.comfyClass !== "Prompt_Style_Machine") return;
|
||||||
|
|
||||||
// Widgets
|
// Widgets
|
||||||
const csvWidget = node.widgets.find(w => w.name === "csv_file");
|
const csvWidget = node.widgets.find(w => w.name === "csv_file");
|
||||||
@@ -19,11 +19,17 @@ app.registerExtension({
|
|||||||
const index = node.widgets.indexOf(oldNameWidget);
|
const index = node.widgets.indexOf(oldNameWidget);
|
||||||
node.widgets.splice(index, 1);
|
node.widgets.splice(index, 1);
|
||||||
|
|
||||||
// Add proper dropdown
|
// Add proper dropdown (this appends to the end)
|
||||||
const nameWidget = node.addWidget("combo", "name", "None", () => {}, {
|
const nameWidget = node.addWidget("combo", "name", "None", () => { }, {
|
||||||
values: ["None"]
|
values: ["None"]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Move the new widget back to the original position
|
||||||
|
// Remove from the end (where addWidget put it)
|
||||||
|
node.widgets.splice(node.widgets.indexOf(nameWidget), 1);
|
||||||
|
// Insert at the correct index
|
||||||
|
node.widgets.splice(index, 0, nameWidget);
|
||||||
|
|
||||||
// Store available names
|
// Store available names
|
||||||
let namesConfig = [];
|
let namesConfig = [];
|
||||||
|
|
||||||
@@ -34,7 +40,7 @@ app.registerExtension({
|
|||||||
const resp = await fetch(`/sata/prompt_machine/names?csv=${encodeURIComponent(csvValue)}`);
|
const resp = await fetch(`/sata/prompt_machine/names?csv=${encodeURIComponent(csvValue)}`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
namesConfig = data.names || ["None"];
|
namesConfig = data.names || ["None"];
|
||||||
console.log("[PromptMachineFrontend] Loaded names:", namesConfig);
|
|
||||||
refreshNames();
|
refreshNames();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[PromptMachineFrontend] Failed to load names:", err);
|
console.error("[PromptMachineFrontend] Failed to load names:", err);
|
||||||
@@ -50,17 +56,70 @@ app.registerExtension({
|
|||||||
nameWidget.value = available[0]; // safe fallback
|
nameWidget.value = available[0]; // safe fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force update previews for the new name
|
||||||
|
updatePreviews(nameWidget.value);
|
||||||
|
|
||||||
app.graph.change();
|
app.graph.change();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook CSV change
|
// Hook CSV change
|
||||||
const oldCsvCallback = csvWidget.callback;
|
const oldCsvCallback = csvWidget.callback;
|
||||||
csvWidget.callback = function(value) {
|
csvWidget.callback = function (value) {
|
||||||
if (oldCsvCallback) oldCsvCallback(value);
|
if (oldCsvCallback) oldCsvCallback(value);
|
||||||
loadNames(value); // refresh dynamically
|
loadNames(value); // refresh dynamically
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Preview Logic ---
|
||||||
|
const previewEl = document.createElement("div");
|
||||||
|
Object.assign(previewEl.style, {
|
||||||
|
marginTop: "10px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "rgba(0,0,0,0.4)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
color: "#ddd",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
maxHeight: "400px",
|
||||||
|
overflowY: "auto"
|
||||||
|
});
|
||||||
|
|
||||||
|
node.addDOMWidget("preview_text", "html", previewEl, {
|
||||||
|
getValue: () => previewEl.innerHTML,
|
||||||
|
setValue: (v) => { previewEl.innerHTML = v; }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updatePreviews(name) {
|
||||||
|
const csv = csvWidget.value;
|
||||||
|
if (!csv || !name || name === "None") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/sata/prompt_machine/get?csv=${encodeURIComponent(csv)}&name=${encodeURIComponent(name)}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
let html = "";
|
||||||
|
if (data.positive) html += `${data.positive}`;
|
||||||
|
|
||||||
|
previewEl.innerHTML = html || "<i>No preview available</i>";
|
||||||
|
|
||||||
|
// Adjust node size to fit content
|
||||||
|
node.setSize(node.computeSize());
|
||||||
|
|
||||||
|
app.graph.change();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[PromptMachineFrontend] Failed to load preview:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook Name change
|
||||||
|
const oldNameCallback = nameWidget.callback;
|
||||||
|
nameWidget.callback = function (value) {
|
||||||
|
if (oldNameCallback) oldNameCallback(value);
|
||||||
|
updatePreviews(value);
|
||||||
|
};
|
||||||
|
|
||||||
// Initial sync
|
// Initial sync
|
||||||
loadNames(csvWidget.value);
|
loadNames(csvWidget.value).then(() => {
|
||||||
|
updatePreviews(nameWidget.value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+106
-38
@@ -4,15 +4,16 @@ app.registerExtension({
|
|||||||
name: "SATA_UtilityNode.Resolution_Machine",
|
name: "SATA_UtilityNode.Resolution_Machine",
|
||||||
|
|
||||||
async nodeCreated(node) {
|
async nodeCreated(node) {
|
||||||
if (node.comfyClass !== "Resolution_Machine") return;
|
if (node.comfyClass !== "Resolution_Machine" && node.comfyClass !== "Latent_Machine") return;
|
||||||
|
|
||||||
// Find widgets
|
// Find widgets
|
||||||
const modelWidget = node.widgets.find(w => w.name === "model");
|
const modelWidget = node.widgets.find(w => w.name === "model");
|
||||||
|
const dimensionWidget = node.widgets.find(w => w.name === "dimension");
|
||||||
const resolutionWidget = node.widgets.find(w => w.name === "resolution");
|
const resolutionWidget = node.widgets.find(w => w.name === "resolution");
|
||||||
const widthWidget = node.widgets.find(w => w.name === "custom_width");
|
const widthWidget = node.widgets.find(w => w.name === "width") || node.widgets.find(w => w.name === "custom_width");
|
||||||
const heightWidget = node.widgets.find(w => w.name === "custom_height");
|
const heightWidget = node.widgets.find(w => w.name === "height") || node.widgets.find(w => w.name === "custom_height");
|
||||||
|
|
||||||
if (!modelWidget || !resolutionWidget || !widthWidget || !heightWidget) {
|
if (!modelWidget || !dimensionWidget || !resolutionWidget || !widthWidget || !heightWidget) {
|
||||||
console.warn("[Resolution_Machine] Missing widgets, check backend definition.");
|
console.warn("[Resolution_Machine] Missing widgets, check backend definition.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -25,7 +26,7 @@ app.registerExtension({
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch("/SATA_UtilityNode/resolutions_config");
|
const resp = await fetch("/SATA_UtilityNode/resolutions_config");
|
||||||
resolutionsConfig = await resp.json();
|
resolutionsConfig = await resp.json();
|
||||||
console.log("[Resolution_Machine] Loaded config:", resolutionsConfig);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Resolution_Machine] Failed to load config:", err);
|
console.error("[Resolution_Machine] Failed to load config:", err);
|
||||||
}
|
}
|
||||||
@@ -33,11 +34,27 @@ app.registerExtension({
|
|||||||
|
|
||||||
await loadConfig();
|
await loadConfig();
|
||||||
|
|
||||||
// --- Utility: refresh resolution dropdown when model changes ---
|
// --- Utility: refresh resolution dropdown when model/dimension changes ---
|
||||||
function refreshResolutions(selectedModel) {
|
function refreshResolutions() {
|
||||||
const available = resolutionsConfig[selectedModel]
|
const selectedModel = modelWidget.value;
|
||||||
? Object.keys(resolutionsConfig[selectedModel])
|
const selectedDimension = dimensionWidget.value;
|
||||||
: ["Custom (manual)"];
|
|
||||||
|
let available = [];
|
||||||
|
|
||||||
|
if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) {
|
||||||
|
const buckets = resolutionsConfig.models[selectedModel];
|
||||||
|
buckets.forEach(bucket => {
|
||||||
|
// Check if bucket exists and has the selected dimension
|
||||||
|
if (resolutionsConfig.resolutions && resolutionsConfig.resolutions[bucket] && resolutionsConfig.resolutions[bucket][selectedDimension]) {
|
||||||
|
available.push(...Object.keys(resolutionsConfig.resolutions[bucket][selectedDimension]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include "Custom" option so that switching model/dimension dropdowns doesn't kick the user out of custom mode
|
||||||
|
if (!available.includes("Custom")) {
|
||||||
|
available.push("Custom");
|
||||||
|
}
|
||||||
|
|
||||||
resolutionWidget.options.values = available;
|
resolutionWidget.options.values = available;
|
||||||
|
|
||||||
@@ -46,52 +63,103 @@ app.registerExtension({
|
|||||||
resolutionWidget.value = available[0];
|
resolutionWidget.value = available[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Resolution_Machine] Resolutions for ${selectedModel}:`, available);
|
// If we switched to a preset (not Custom), lock fields
|
||||||
app.graph.change();
|
// If we defaulted to Custom (e.g. empty available), unlock
|
||||||
applyResolution(); // auto-apply width/height on refresh
|
checkCustomMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Utility: apply resolution to width/height ---
|
function checkCustomMode() {
|
||||||
function applyResolution() {
|
if (resolutionWidget.value === "Custom") {
|
||||||
const selectedModel = modelWidget.value;
|
widthWidget.type = "INT";
|
||||||
const selectedResolution = resolutionWidget.value;
|
heightWidget.type = "INT";
|
||||||
|
delete widthWidget.computeSize;
|
||||||
if (!resolutionsConfig[selectedModel]) return;
|
delete heightWidget.computeSize;
|
||||||
|
|
||||||
const resolutionData = resolutionsConfig[selectedModel][selectedResolution];
|
|
||||||
|
|
||||||
if (selectedResolution !== "Custom (manual)" && resolutionData) {
|
|
||||||
// Auto-set width/height
|
|
||||||
widthWidget.value = resolutionData.width;
|
|
||||||
heightWidget.value = resolutionData.height;
|
|
||||||
|
|
||||||
// Lock fields
|
|
||||||
widthWidget.disabled = true;
|
|
||||||
heightWidget.disabled = true;
|
|
||||||
} else {
|
|
||||||
// Allow manual editing
|
|
||||||
widthWidget.disabled = false;
|
widthWidget.disabled = false;
|
||||||
heightWidget.disabled = false;
|
heightWidget.disabled = false;
|
||||||
|
} else {
|
||||||
|
widthWidget.type = "hidden";
|
||||||
|
heightWidget.type = "hidden";
|
||||||
|
widthWidget.computeSize = () => [0, -4];
|
||||||
|
heightWidget.computeSize = () => [0, -4];
|
||||||
|
applyResolutionPreset();
|
||||||
|
}
|
||||||
|
node.setSize(node.computeSize());
|
||||||
|
app.graph.change();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utility: apply resolution preset to width/height ---
|
||||||
|
function applyResolutionPreset() {
|
||||||
|
const selectedModel = modelWidget.value;
|
||||||
|
const selectedDimension = dimensionWidget.value;
|
||||||
|
const selectedResolution = resolutionWidget.value;
|
||||||
|
|
||||||
|
if (selectedResolution === "Custom") {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.graph.change();
|
let resolutionData = null;
|
||||||
|
|
||||||
|
// Lookup
|
||||||
|
if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) {
|
||||||
|
const buckets = resolutionsConfig.models[selectedModel];
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
if (resolutionsConfig.resolutions[bucket] &&
|
||||||
|
resolutionsConfig.resolutions[bucket][selectedDimension] &&
|
||||||
|
resolutionsConfig.resolutions[bucket][selectedDimension][selectedResolution]) {
|
||||||
|
|
||||||
|
resolutionData = resolutionsConfig.resolutions[bucket][selectedDimension][selectedResolution];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolutionData) {
|
||||||
|
widthWidget.value = resolutionData.width;
|
||||||
|
heightWidget.value = resolutionData.height;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Hook model change ---
|
// --- Hook model change ---
|
||||||
const oldModelCallback = modelWidget.callback;
|
const oldModelCallback = modelWidget.callback;
|
||||||
modelWidget.callback = function(value) {
|
modelWidget.callback = function (value) {
|
||||||
if (oldModelCallback) oldModelCallback(value);
|
if (oldModelCallback) oldModelCallback(value);
|
||||||
refreshResolutions(value);
|
refreshResolutions();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Hook dimension change ---
|
||||||
|
const oldDimensionCallback = dimensionWidget.callback;
|
||||||
|
dimensionWidget.callback = function (value) {
|
||||||
|
if (oldDimensionCallback) oldDimensionCallback(value);
|
||||||
|
refreshResolutions();
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Hook resolution change ---
|
// --- Hook resolution change ---
|
||||||
const oldResolutionCallback = resolutionWidget.callback;
|
const oldResolutionCallback = resolutionWidget.callback;
|
||||||
resolutionWidget.callback = function(value) {
|
resolutionWidget.callback = function (value) {
|
||||||
if (oldResolutionCallback) oldResolutionCallback(value);
|
if (oldResolutionCallback) oldResolutionCallback(value);
|
||||||
applyResolution();
|
checkCustomMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Add Custom Button ---
|
||||||
|
// Rename "Swap Dimensions" -> "Custom"
|
||||||
|
// Note: Node logic adds this button. Previous JS added it. We are replacing previous JS logic.
|
||||||
|
|
||||||
|
node.addWidget("button", "Custom", null, () => {
|
||||||
|
// Unlock widgets
|
||||||
|
widthWidget.disabled = false;
|
||||||
|
heightWidget.disabled = false;
|
||||||
|
|
||||||
|
// Ensure "Custom" option exists and is selected
|
||||||
|
if (!resolutionWidget.options.values.includes("Custom")) {
|
||||||
|
resolutionWidget.options.values.push("Custom");
|
||||||
|
}
|
||||||
|
resolutionWidget.value = "Custom";
|
||||||
|
|
||||||
|
checkCustomMode();
|
||||||
|
app.graph.change();
|
||||||
|
});
|
||||||
|
|
||||||
// --- Initial sync ---
|
// --- Initial sync ---
|
||||||
refreshResolutions(modelWidget.value);
|
refreshResolutions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+96
-1
@@ -1,11 +1,106 @@
|
|||||||
|
import { app } from "../../../scripts/app.js";
|
||||||
|
|
||||||
|
const SETTING_ID = "SATA_UtilityNode.EnableTouchpadPanZoom";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "SATA_UtilityNode.TouchpadPanZoom",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: SETTING_ID,
|
||||||
|
name: "Enable Touchpad Pan/Zoom (SATA UtilityNode)",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: true,
|
||||||
|
onChange: (value) => {
|
||||||
|
const btn = document.getElementById("sata-touchpad-toggle-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = value ? "Touchpad: ON" : "Touchpad: OFF";
|
||||||
|
btn.style.opacity = value ? "1.0" : "0.5";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
async setup() {
|
||||||
|
// Create a toggle button in the ComfyUI menu
|
||||||
|
const menu = document.querySelector(".comfy-menu");
|
||||||
|
if (menu) {
|
||||||
|
const toggleBtn = document.createElement("button");
|
||||||
|
toggleBtn.id = "sata-touchpad-toggle-btn";
|
||||||
|
|
||||||
|
// Function to get current value securely
|
||||||
|
const getVal = () => {
|
||||||
|
if (app.extensionManager && app.extensionManager.setting) {
|
||||||
|
const val = app.extensionManager.setting.get(SETTING_ID);
|
||||||
|
return val !== undefined ? val : true;
|
||||||
|
} else if (app.ui && app.ui.settings) {
|
||||||
|
return app.ui.settings.getSettingValue(SETTING_ID, true);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to set value
|
||||||
|
const setVal = (newVal) => {
|
||||||
|
if (app.extensionManager && app.extensionManager.setting) {
|
||||||
|
app.extensionManager.setting.set(SETTING_ID, newVal);
|
||||||
|
} else if (app.ui && app.ui.settings) {
|
||||||
|
app.ui.settings.setSettingValue(SETTING_ID, newVal);
|
||||||
|
}
|
||||||
|
// Update button visuals
|
||||||
|
toggleBtn.textContent = newVal ? "Touchpad: ON" : "Touchpad: OFF";
|
||||||
|
toggleBtn.style.opacity = newVal ? "1.0" : "0.5";
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialVal = getVal();
|
||||||
|
toggleBtn.textContent = initialVal ? "Touchpad: ON" : "Touchpad: OFF";
|
||||||
|
toggleBtn.style.opacity = initialVal ? "1.0" : "0.5";
|
||||||
|
|
||||||
|
// Basic styling to match comfy UI buttons if classes aren't enough
|
||||||
|
toggleBtn.className = "comfy-settings-btn";
|
||||||
|
|
||||||
|
toggleBtn.onclick = () => {
|
||||||
|
const currentVal = getVal();
|
||||||
|
setVal(!currentVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to place it nicely in the menu
|
||||||
|
const settingsBtn = document.getElementById("comfy-settings-button");
|
||||||
|
if (settingsBtn && settingsBtn.parentNode === menu) {
|
||||||
|
menu.insertBefore(toggleBtn, settingsBtn);
|
||||||
|
} else {
|
||||||
|
menu.appendChild(toggleBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced smooth scrolling and zooming for touchpad gestures
|
* Enhanced smooth scrolling and zooming for touchpad gestures
|
||||||
* Overrides LGraphCanvas.prototype.processMouseWheel
|
* Overrides LGraphCanvas.prototype.processMouseWheel
|
||||||
*/
|
*/
|
||||||
|
const originalProcessMouseWheel = LGraphCanvas.prototype.processMouseWheel;
|
||||||
|
|
||||||
LGraphCanvas.prototype.processMouseWheel = function (/** @type {WheelEvent} */ event) {
|
LGraphCanvas.prototype.processMouseWheel = function (/** @type {WheelEvent} */ event) {
|
||||||
|
let isEnabled = true;
|
||||||
|
|
||||||
|
if (app.extensionManager && app.extensionManager.setting) {
|
||||||
|
const val = app.extensionManager.setting.get(SETTING_ID);
|
||||||
|
if (val !== undefined) isEnabled = val;
|
||||||
|
} else if (app.ui && app.ui.settings) {
|
||||||
|
isEnabled = app.ui.settings.getSettingValue(SETTING_ID, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnabled === false) {
|
||||||
|
if (originalProcessMouseWheel) {
|
||||||
|
return originalProcessMouseWheel.apply(this, arguments);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.graph || !this.allow_dragcanvas) return;
|
if (!this.graph || !this.allow_dragcanvas) return;
|
||||||
|
|
||||||
const { clientX: x, clientY: y, deltaX, deltaY, ctrlKey, metaKey } = event;
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
const { deltaX, deltaY, ctrlKey, metaKey } = event;
|
||||||
|
|
||||||
// Check if the event occurred inside the canvas viewport
|
// Check if the event occurred inside the canvas viewport
|
||||||
if (this.viewport) {
|
if (this.viewport) {
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import torch
|
||||||
|
import torch.fft
|
||||||
|
import random
|
||||||
|
import comfy.model_management
|
||||||
|
|
||||||
|
NODE_NAME = "Latent_Machine"
|
||||||
|
|
||||||
|
# Path to JSON config (root of SATA_UtilityNode folder)
|
||||||
|
CONFIG_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(__file__)), "asset", "resolutions.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
if not os.path.exists(CONFIG_PATH):
|
||||||
|
raise FileNotFoundError(f"[{NODE_NAME}] resolutions.json not found at {CONFIG_PATH}")
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8-sig") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
class Latent_Machine:
|
||||||
|
"""
|
||||||
|
Creates an empty latent initialized with Power-Law (1/f) noise, Perlin-like noise, or Plasma noise.
|
||||||
|
Supports 4-channel (SD1.5/SDXL) and 16-channel (Flux/SD3) latents.
|
||||||
|
Supports recommended model resolutions out of the box.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
# all models
|
||||||
|
models = list(config["models"].keys())
|
||||||
|
default_model = models[0] if models else "Unknown"
|
||||||
|
|
||||||
|
# Collect ALL possible resolutions to pass validation
|
||||||
|
all_resolutions = set(["Custom"])
|
||||||
|
if "resolutions" in config:
|
||||||
|
for bucket in config["resolutions"].values():
|
||||||
|
for dim in bucket.values():
|
||||||
|
all_resolutions.update(dim.keys())
|
||||||
|
|
||||||
|
resolutions_list = sorted(list(all_resolutions))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"model": (models, {"default": default_model}),
|
||||||
|
"dimension": (["Square", "Portrait", "Landscape"],),
|
||||||
|
"resolution": (resolutions_list,),
|
||||||
|
"width": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
|
||||||
|
"height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
|
||||||
|
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64}),
|
||||||
|
"noise_type": ([
|
||||||
|
"Gaussian (White): Sharp Architecture, Text, intricate mechanics",
|
||||||
|
"Pink (1/f): Photorealism, Portraits, Nature",
|
||||||
|
"Brown (1/f²): Anime, Digital Art, Bokeh/Backgrounds",
|
||||||
|
"Blue (High-Frequency): High-Detail, Textures, low-step/distilled models",
|
||||||
|
"Perlin : Fantasy landscapes, Fluids, Hair/Fabric",
|
||||||
|
"Plasma : Sci-Fi, Abstract, Alien terrain"
|
||||||
|
],),
|
||||||
|
"intensity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1}),
|
||||||
|
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
||||||
|
"high_contrast": ("BOOLEAN", {"default": False}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("LATENT", "IMAGE")
|
||||||
|
RETURN_NAMES = ("LATENT", "IMAGE")
|
||||||
|
FUNCTION = "generate_noise"
|
||||||
|
CATEGORY = "SATA_UtilityNode"
|
||||||
|
|
||||||
|
def get_resolution_dimensions(self, model, dimension, resolution, width, height):
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
except Exception:
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
if model not in config.get("models", {}):
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
if resolution == "Custom":
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
buckets = config["models"][model]
|
||||||
|
|
||||||
|
resolution_data = None
|
||||||
|
for bucket in buckets:
|
||||||
|
if bucket in config.get("resolutions", {}):
|
||||||
|
if dimension in config["resolutions"][bucket]:
|
||||||
|
if resolution in config["resolutions"][bucket][dimension]:
|
||||||
|
resolution_data = config["resolutions"][bucket][dimension][resolution]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not resolution_data:
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
return (resolution_data["width"], resolution_data["height"])
|
||||||
|
|
||||||
|
def generate_noise(self, model="SD 1.5", dimension="Square", resolution="Custom", width=512, height=512, batch_size=1, noise_type="Gaussian (White): Sharp Architecture, Text, intricate mechanics", intensity=1.0, seed=0, high_contrast=False):
|
||||||
|
# Check if this is an old node layout (shifted arguments due to ComfyUI's positional widget serialization)
|
||||||
|
if isinstance(model, int):
|
||||||
|
actual_width = model
|
||||||
|
actual_height = dimension
|
||||||
|
actual_batch_size = resolution
|
||||||
|
actual_model = width # The 4th argument 'width' holds the old 'model_type' (str)
|
||||||
|
actual_noise_type = height
|
||||||
|
actual_intensity = batch_size
|
||||||
|
actual_seed = noise_type
|
||||||
|
actual_high_contrast = False
|
||||||
|
else:
|
||||||
|
actual_model = model
|
||||||
|
actual_width, actual_height = self.get_resolution_dimensions(model, dimension, resolution, width, height)
|
||||||
|
actual_batch_size = batch_size
|
||||||
|
actual_noise_type = noise_type
|
||||||
|
actual_intensity = intensity
|
||||||
|
actual_seed = seed
|
||||||
|
actual_high_contrast = high_contrast
|
||||||
|
|
||||||
|
self.device = comfy.model_management.get_torch_device()
|
||||||
|
# Set seed for reproducibility
|
||||||
|
torch.manual_seed(actual_seed)
|
||||||
|
random.seed(actual_seed)
|
||||||
|
|
||||||
|
# Determine channels based on model
|
||||||
|
model_lower = actual_model.lower()
|
||||||
|
if any(k in model_lower for k in ["flux", "qwen", "sd3", "lumina", "wan", "auraflow", "z-image", "mochi", "ltx", "hunyuanvideo"]):
|
||||||
|
c = 16
|
||||||
|
else:
|
||||||
|
c = 4
|
||||||
|
|
||||||
|
# Latent dimensions (compressed by 8)
|
||||||
|
h = actual_height // 8
|
||||||
|
w = actual_width // 8
|
||||||
|
|
||||||
|
# Generate base noise
|
||||||
|
if "Gaussian" in actual_noise_type:
|
||||||
|
noise = torch.randn((actual_batch_size, c, h, w), device=self.device)
|
||||||
|
|
||||||
|
elif "Perlin" in actual_noise_type:
|
||||||
|
# Multi-Octave Value Noise Approximation
|
||||||
|
noise = self.generate_perlin_approx(actual_batch_size, c, h, w)
|
||||||
|
|
||||||
|
elif "Blue" in actual_noise_type:
|
||||||
|
# FFT-based Blue Noise (High-Frequency)
|
||||||
|
noise = self.generate_blue_noise(actual_batch_size, c, h, w)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# FFT-based Power Law Noise (Pink, Brown, Plasma)
|
||||||
|
if "Pink" in actual_noise_type:
|
||||||
|
alpha = 1.0
|
||||||
|
elif "Brown" in actual_noise_type:
|
||||||
|
alpha = 2.0
|
||||||
|
elif "Plasma" in actual_noise_type:
|
||||||
|
alpha = 3.0 # Very smooth
|
||||||
|
else:
|
||||||
|
alpha = 0.0 # Fallback to white
|
||||||
|
|
||||||
|
noise = self.generate_power_law_noise(actual_batch_size, c, h, w, alpha)
|
||||||
|
|
||||||
|
# Normalize standard deviation to match expected latent variance (approx 1.0 for standard Gaussian)
|
||||||
|
# This ensures intensity works consistently across different noise types
|
||||||
|
current_std = noise.std()
|
||||||
|
if current_std > 1e-6:
|
||||||
|
noise = noise / current_std
|
||||||
|
|
||||||
|
# Apply offset noise for high contrast
|
||||||
|
if actual_high_contrast:
|
||||||
|
noise = noise + 0.1
|
||||||
|
|
||||||
|
# Apply intensity
|
||||||
|
noise = noise * actual_intensity
|
||||||
|
|
||||||
|
# Prepare Image output for visualization/masking
|
||||||
|
# Slice the first 3 channels and normalize to [0, 1]
|
||||||
|
img_tensor = noise[:, :3, :, :]
|
||||||
|
if img_tensor.shape[1] < 3:
|
||||||
|
# If for some weird reason it's less than 3 channels, pad it
|
||||||
|
padding = torch.zeros((actual_batch_size, 3 - img_tensor.shape[1], h, w), device=self.device)
|
||||||
|
img_tensor = torch.cat([img_tensor, padding], dim=1)
|
||||||
|
|
||||||
|
# Normalize min-max
|
||||||
|
img_min = img_tensor.min()
|
||||||
|
img_max = img_tensor.max()
|
||||||
|
if img_max > img_min:
|
||||||
|
img_tensor = (img_tensor - img_min) / (img_max - img_min)
|
||||||
|
else:
|
||||||
|
img_tensor = torch.zeros_like(img_tensor)
|
||||||
|
|
||||||
|
# Move to (B, H, W, C) for ComfyUI Image format
|
||||||
|
img_tensor = img_tensor.movedim(1, -1)
|
||||||
|
|
||||||
|
return ({"samples": noise}, img_tensor)
|
||||||
|
|
||||||
|
def generate_power_law_noise(self, batch_size, c, h, w, alpha):
|
||||||
|
# Generate White Noise (Standard Gaussian)
|
||||||
|
white_noise = torch.randn((batch_size, c, h, w), device=self.device)
|
||||||
|
|
||||||
|
# Convert to Frequency Domain (FFT)
|
||||||
|
fft_noise = torch.fft.fft2(white_noise)
|
||||||
|
|
||||||
|
# Create Frequency Grid
|
||||||
|
y = torch.fft.fftfreq(h, device=self.device)
|
||||||
|
x = torch.fft.fftfreq(w, device=self.device)
|
||||||
|
dy, dx = torch.meshgrid(y, x, indexing='ij')
|
||||||
|
|
||||||
|
# Calculate distance from center (frequency magnitude)
|
||||||
|
# Add epsilon to avoid division by zero at DC component
|
||||||
|
frequency_magnitude = torch.sqrt(dy**2 + dx**2) + 1e-8
|
||||||
|
|
||||||
|
# Apply Power Law Scaling: Amplitude ~ 1 / f^(alpha/2)
|
||||||
|
scale = 1.0 / (frequency_magnitude ** (alpha / 2.0))
|
||||||
|
|
||||||
|
# Zero out the DC component (mean brightness) to keep it neutral
|
||||||
|
scale[0, 0] = 0
|
||||||
|
|
||||||
|
fft_structured = fft_noise * scale
|
||||||
|
|
||||||
|
# Convert back to Spatial Domain (Inverse FFT)
|
||||||
|
structured_noise = torch.fft.ifft2(fft_structured).real
|
||||||
|
|
||||||
|
return structured_noise
|
||||||
|
|
||||||
|
def generate_perlin_approx(self, batch_size, c, h, w):
|
||||||
|
"""
|
||||||
|
Generates a Perlin-like noise by blending upsampled noise from multiple octaves.
|
||||||
|
This is faster than calculating actual Perlin noise per pixel on the CPU/GPU.
|
||||||
|
"""
|
||||||
|
noise = torch.zeros((batch_size, c, h, w), device=self.device)
|
||||||
|
|
||||||
|
# Octaves: scale down, generate noise, scale up
|
||||||
|
octaves = [2, 4, 8, 16]
|
||||||
|
weights = [0.5, 0.25, 0.125, 0.0625] # 1/f amplitude falloff
|
||||||
|
|
||||||
|
total_weight = 0
|
||||||
|
|
||||||
|
for scale, weight in zip(octaves, weights):
|
||||||
|
# Calculate small dimensions
|
||||||
|
sh, sw = max(1, h // scale), max(1, w // scale)
|
||||||
|
|
||||||
|
# Generate random noise at this scale
|
||||||
|
small_noise = torch.randn((batch_size, c, sh, sw), device=self.device)
|
||||||
|
|
||||||
|
# Upsample to full size using bicubic interpolation for smoothness
|
||||||
|
upsampled = torch.nn.functional.interpolate(
|
||||||
|
small_noise, size=(h, w), mode='bicubic', align_corners=False
|
||||||
|
)
|
||||||
|
|
||||||
|
noise += upsampled * weight
|
||||||
|
total_weight += weight
|
||||||
|
|
||||||
|
# Add a bit of fine-grained white noise for texture
|
||||||
|
noise += torch.randn((batch_size, c, h, w), device=self.device) * 0.05
|
||||||
|
|
||||||
|
return noise
|
||||||
|
|
||||||
|
def generate_blue_noise(self, batch_size, c, h, w, beta=1.5):
|
||||||
|
# Generate White Noise (Standard Gaussian)
|
||||||
|
white_noise = torch.randn((batch_size, c, h, w), device=self.device)
|
||||||
|
|
||||||
|
# Convert to Frequency Domain (FFT)
|
||||||
|
fft_noise = torch.fft.fft2(white_noise)
|
||||||
|
|
||||||
|
# Create Frequency Grid
|
||||||
|
y = torch.fft.fftfreq(h, device=self.device)
|
||||||
|
x = torch.fft.fftfreq(w, device=self.device)
|
||||||
|
dy, dx = torch.meshgrid(y, x, indexing='ij')
|
||||||
|
|
||||||
|
# Calculate distance from center (frequency magnitude)
|
||||||
|
frequency_magnitude = torch.sqrt(dy**2 + dx**2)
|
||||||
|
|
||||||
|
# Apply High-Pass Scaling: Amplitude ~ f^(beta/2)
|
||||||
|
scale = frequency_magnitude ** (beta / 2.0)
|
||||||
|
|
||||||
|
# Zero out DC component
|
||||||
|
scale[0, 0] = 0
|
||||||
|
|
||||||
|
fft_structured = fft_noise * scale
|
||||||
|
|
||||||
|
# Convert back to Spatial Domain (Inverse FFT)
|
||||||
|
blue_noise = torch.fft.ifft2(fft_structured).real
|
||||||
|
|
||||||
|
return blue_noise
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from nodes import PreviewImage
|
||||||
|
|
||||||
|
class Preview_Machine(PreviewImage):
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"images": ("IMAGE",),
|
||||||
|
},
|
||||||
|
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ()
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
CATEGORY = "SATA_UtilityNode"
|
||||||
|
FUNCTION = "save_images"
|
||||||
|
|
||||||
|
def save_images(self, images, prompt=None, extra_pnginfo=None):
|
||||||
|
return super().save_images(images, filename_prefix="Preview_Machine", prompt=prompt, extra_pnginfo=extra_pnginfo)
|
||||||
@@ -10,6 +10,7 @@ PROMPT_DIR = os.path.join(os.path.dirname(__file__), "..", "prompt")
|
|||||||
def list_prompt_files():
|
def list_prompt_files():
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(PROMPT_DIR):
|
if not os.path.exists(PROMPT_DIR):
|
||||||
|
os.makedirs(PROMPT_DIR, exist_ok=True)
|
||||||
return []
|
return []
|
||||||
return sorted([f for f in os.listdir(PROMPT_DIR) if f.endswith(".csv") or f.endswith(".json")])
|
return sorted([f for f in os.listdir(PROMPT_DIR) if f.endswith(".csv") or f.endswith(".json")])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+56
-8
@@ -49,7 +49,9 @@ def read_names_from_csv(filename):
|
|||||||
def read_prompt_row(csv_file, name):
|
def read_prompt_row(csv_file, name):
|
||||||
"""Return (positive, negative, note) for a given csv_file and name.
|
"""Return (positive, negative, note) for a given csv_file and name.
|
||||||
Matching is whitespace-trimmed and exact on content (case-sensitive by default)."""
|
Matching is whitespace-trimmed and exact on content (case-sensitive by default)."""
|
||||||
if not csv_file or csv_file == "None" or not name or name == "None":
|
if not csv_file or not isinstance(csv_file, str) or csv_file == "None":
|
||||||
|
return ("", "", "")
|
||||||
|
if not name or not isinstance(name, str) or name == "None":
|
||||||
return ("", "", "")
|
return ("", "", "")
|
||||||
path = os.path.join(CSV_DIR, csv_file)
|
path = os.path.join(CSV_DIR, csv_file)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
@@ -85,7 +87,7 @@ def read_prompt_row(csv_file, name):
|
|||||||
return ("", "", "")
|
return ("", "", "")
|
||||||
|
|
||||||
|
|
||||||
class Prompt_Machine:
|
class Prompt_Style_Machine:
|
||||||
"""
|
"""
|
||||||
NOTE: name is a free STRING (not a static dropdown) to avoid 'Value not in list' validation errors.
|
NOTE: name is a free STRING (not a static dropdown) to avoid 'Value not in list' validation errors.
|
||||||
The frontend helper will still provide dropdown-like selection and write the chosen name into this STRING.
|
The frontend helper will still provide dropdown-like selection and write the chosen name into this STRING.
|
||||||
@@ -107,19 +109,65 @@ class Prompt_Machine:
|
|||||||
}),
|
}),
|
||||||
# make 'name' a free text field (STRING) — avoids list validation problems
|
# make 'name' a free text field (STRING) — avoids list validation problems
|
||||||
"name": ("STRING", {"default": default_name}),
|
"name": ("STRING", {"default": default_name}),
|
||||||
}
|
},
|
||||||
|
"optional": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
RETURN_TYPES = ("STRING",)
|
||||||
RETURN_NAMES = ("positive", "negative", "note")
|
RETURN_NAMES = ("prompt",)
|
||||||
FUNCTION = "get_prompts"
|
FUNCTION = "get_prompts"
|
||||||
CATEGORY = "SATA_UtilityNode"
|
CATEGORY = "SATA_UtilityNode"
|
||||||
|
|
||||||
def get_prompts(self, csv_file, name):
|
def get_prompts(self, csv_file, name):
|
||||||
print(f"[PromptMachine] get_prompts - csv_file={csv_file}, name={name}")
|
|
||||||
pos, neg, note = read_prompt_row(csv_file, name)
|
pos, neg, note = read_prompt_row(csv_file, name)
|
||||||
print(f"[PromptMachine] get_prompts -> pos_len={len(pos)}, neg_len={len(neg)}, note_len={len(note)}")
|
|
||||||
return (pos, neg, note)
|
return (pos,)
|
||||||
|
|
||||||
|
|
||||||
|
class Prompt_Machine:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"subject": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
|
||||||
|
"style": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
|
||||||
|
"lighting": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
|
||||||
|
"composition": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
|
||||||
|
"mood": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
|
||||||
|
"technical": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
|
||||||
|
"json_output": ("BOOLEAN", {"default": False}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("STRING",)
|
||||||
|
RETURN_NAMES = ("prompt",)
|
||||||
|
FUNCTION = "get_prompt"
|
||||||
|
CATEGORY = "SATA_UtilityNode"
|
||||||
|
|
||||||
|
def get_prompt(self, subject, style, lighting, composition, mood, technical, json_output):
|
||||||
|
if json_output:
|
||||||
|
import json
|
||||||
|
data = {
|
||||||
|
"subject": subject.strip(),
|
||||||
|
"style": style.strip(),
|
||||||
|
"lighting": lighting.strip(),
|
||||||
|
"composition": composition.strip(),
|
||||||
|
"mood": mood.strip(),
|
||||||
|
"technical": technical.strip()
|
||||||
|
}
|
||||||
|
# Remove empty fields to keep JSON clean
|
||||||
|
data = {k: v for k, v in data.items() if v}
|
||||||
|
return (json.dumps(data, indent=2),)
|
||||||
|
else:
|
||||||
|
parts = []
|
||||||
|
if subject.strip(): parts.append(subject.strip())
|
||||||
|
if style.strip(): parts.append(style.strip())
|
||||||
|
if lighting.strip(): parts.append(lighting.strip())
|
||||||
|
if composition.strip(): parts.append(composition.strip())
|
||||||
|
if mood.strip(): parts.append(mood.strip())
|
||||||
|
if technical.strip(): parts.append(technical.strip())
|
||||||
|
return (", ".join(parts),)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- REST API ----------------
|
# ---------------- REST API ----------------
|
||||||
|
|||||||
+58
-25
@@ -13,7 +13,7 @@ CONFIG_PATH = os.path.join(
|
|||||||
def load_config():
|
def load_config():
|
||||||
if not os.path.exists(CONFIG_PATH):
|
if not os.path.exists(CONFIG_PATH):
|
||||||
raise FileNotFoundError(f"[{NODE_NAME}] resolutions.json not found at {CONFIG_PATH}")
|
raise FileNotFoundError(f"[{NODE_NAME}] resolutions.json not found at {CONFIG_PATH}")
|
||||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
with open(CONFIG_PATH, "r", encoding="utf-8-sig") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
@@ -23,24 +23,29 @@ class Resolution_Machine:
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
# all models
|
# all models
|
||||||
models = list(config.keys())
|
models = list(config["models"].keys())
|
||||||
default_model = models[0] if models else "Unknown"
|
default_model = models[0] if models else "Unknown"
|
||||||
|
|
||||||
# union of all resolutions across all models
|
# Collect ALL possible resolutions to pass validation
|
||||||
all_resolutions = set()
|
all_resolutions = set(["Custom"])
|
||||||
for model_res in config.values():
|
if "resolutions" in config:
|
||||||
all_resolutions.update(model_res.keys())
|
for bucket in config["resolutions"].values():
|
||||||
resolutions = sorted(list(all_resolutions))
|
for dim in bucket.values():
|
||||||
|
all_resolutions.update(dim.keys())
|
||||||
|
|
||||||
default_resolution = list(config[default_model].keys())[0]
|
# We can't sort mixed types effortlessly if any, but they are strings.
|
||||||
|
# Sorting helps UI consistency if fallback happens, though JS overrides it.
|
||||||
|
resolutions_list = sorted(list(all_resolutions))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"model": (models, {"default": default_model}),
|
"model": (models, {"default": default_model}),
|
||||||
"resolution": (resolutions, {"default": default_resolution}),
|
"dimension": (["Square", "Portrait", "Landscape"],),
|
||||||
|
"resolution": (resolutions_list,),
|
||||||
"custom_width": ("INT", {"default": 512, "min": 1, "max": 8192}),
|
"custom_width": ("INT", {"default": 512, "min": 1, "max": 8192}),
|
||||||
"custom_height": ("INT", {"default": 512, "min": 1, "max": 8192}),
|
"custom_height": ("INT", {"default": 512, "min": 1, "max": 8192}),
|
||||||
}
|
"megapixel": (["None", "0.5MP", "1.0MP", "1.5MP", "2.0MP", "4.0MP"], {"default": "None"}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("INT", "INT")
|
RETURN_TYPES = ("INT", "INT")
|
||||||
@@ -48,27 +53,55 @@ class Resolution_Machine:
|
|||||||
FUNCTION = "get_resolution"
|
FUNCTION = "get_resolution"
|
||||||
CATEGORY = "SATA_UtilityNode"
|
CATEGORY = "SATA_UtilityNode"
|
||||||
|
|
||||||
def get_resolution(self, model, resolution, custom_width, custom_height):
|
def get_resolution(self, model, dimension, resolution, custom_width, custom_height, megapixel="None"):
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if model not in config:
|
if model not in config["models"]:
|
||||||
raise ValueError(f"[{NODE_NAME}] Unknown model: {model}")
|
raise ValueError(f"[{NODE_NAME}] Unknown model: {model}")
|
||||||
|
|
||||||
resolutions = config[model]
|
if resolution == "Custom":
|
||||||
|
w, h = custom_width, custom_height
|
||||||
if resolution not in resolutions:
|
|
||||||
# fallback to first available resolution for safety
|
|
||||||
resolution = list(resolutions.keys())[0]
|
|
||||||
print(f"[{NODE_NAME}] Warning: invalid resolution, defaulting to {resolution}")
|
|
||||||
|
|
||||||
if resolution == "Custom (manual)":
|
|
||||||
width, height = custom_width, custom_height
|
|
||||||
else:
|
else:
|
||||||
entry = resolutions[resolution]
|
# Search for resolution string in the specific bucket/dimension
|
||||||
width, height = entry["width"], entry["height"]
|
# Iterate over tiers to find the bucket that has this model?
|
||||||
|
# Actually config["models"][model] gives list of buckets.
|
||||||
|
buckets = config["models"][model]
|
||||||
|
|
||||||
print(f"[{NODE_NAME}] Model={model}, Resolution={resolution}, Output=({width}, {height})")
|
resolution_data = None
|
||||||
return (width, height)
|
for bucket in buckets:
|
||||||
|
# Check if this bucket has the selected dimension
|
||||||
|
if bucket in config["resolutions"]:
|
||||||
|
if dimension in config["resolutions"][bucket]:
|
||||||
|
if resolution in config["resolutions"][bucket][dimension]:
|
||||||
|
resolution_data = config["resolutions"][bucket][dimension][resolution]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not resolution_data:
|
||||||
|
# Fallback: maybe the user switched model and the resolution is now invalid, or it is custom manual
|
||||||
|
# If obscure mismatch, default to custom values provided
|
||||||
|
print(f"[{NODE_NAME}] Warning: resolution '{resolution}' not found for model '{model}' dim '{dimension}', using Custom")
|
||||||
|
w, h = custom_width, custom_height
|
||||||
|
else:
|
||||||
|
w, h = resolution_data["width"], resolution_data["height"]
|
||||||
|
|
||||||
|
if megapixel != "None":
|
||||||
|
mp_map = {
|
||||||
|
"0.5MP": 500000,
|
||||||
|
"1.0MP": 1000000,
|
||||||
|
"1.5MP": 1500000,
|
||||||
|
"2.0MP": 2000000,
|
||||||
|
"4.0MP": 4000000,
|
||||||
|
}
|
||||||
|
if megapixel in mp_map:
|
||||||
|
P = mp_map[megapixel]
|
||||||
|
R = float(w) / float(h)
|
||||||
|
import math
|
||||||
|
w_new = int(round(math.sqrt(P * R) / 8.0) * 8.0)
|
||||||
|
h_new = int(round(math.sqrt(P / R) / 8.0) * 8.0)
|
||||||
|
w = max(8, w_new)
|
||||||
|
h = max(8, h_new)
|
||||||
|
|
||||||
|
return (w, h)
|
||||||
|
|
||||||
|
|
||||||
# --- REST endpoint for frontend config fetch ---
|
# --- REST endpoint for frontend config fetch ---
|
||||||
|
|||||||
@@ -11,23 +11,8 @@ import folder_paths
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
def parse_name(ckpt_name):
|
|
||||||
path = ckpt_name
|
|
||||||
filename = path.split("/")[-1]
|
|
||||||
filename = filename.split(".")[:-1]
|
|
||||||
filename = ".".join(filename)
|
|
||||||
return filename
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sha256(file_path):
|
|
||||||
sha256_hash = hashlib.sha256()
|
|
||||||
|
|
||||||
with open(file_path, "rb") as f:
|
|
||||||
for byte_block in iter(lambda: f.read(4096), b""):
|
|
||||||
sha256_hash.update(byte_block)
|
|
||||||
|
|
||||||
return sha256_hash.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_whitespace(string: str):
|
def handle_whitespace(string: str):
|
||||||
return string.strip().replace("\n", " ").replace("\r", " ").replace("\t", " ")
|
return string.strip().replace("\n", " ").replace("\r", " ").replace("\t", " ")
|
||||||
@@ -56,7 +41,7 @@ def make_filename(template: str):
|
|||||||
return get_timestamp("%Y-%m-%d-%H%M%S") if filename == "" else filename
|
return get_timestamp("%Y-%m-%d-%H%M%S") if filename == "" else filename
|
||||||
|
|
||||||
|
|
||||||
def resolve_placeholders(template: str, prompt=None) -> str:
|
def resolve_placeholders(template: str, prompt=None, extra_pnginfo=None) -> str:
|
||||||
"""Resolve placeholders of the form %node_name/field% by looking into extra_pnginfo and prompt.
|
"""Resolve placeholders of the form %node_name/field% by looking into extra_pnginfo and prompt.
|
||||||
|
|
||||||
This is a best-effort resolver: it attempts several lookup strategies and falls back to leaving
|
This is a best-effort resolver: it attempts several lookup strategies and falls back to leaving
|
||||||
@@ -65,18 +50,58 @@ def resolve_placeholders(template: str, prompt=None) -> str:
|
|||||||
if not template or '%' not in template:
|
if not template or '%' not in template:
|
||||||
return template
|
return template
|
||||||
|
|
||||||
|
# Build title/type to node ID mapping from extra_pnginfo
|
||||||
|
node_title_to_id = {}
|
||||||
|
node_type_to_ids = {}
|
||||||
|
if extra_pnginfo and isinstance(extra_pnginfo, dict) and "workflow" in extra_pnginfo:
|
||||||
|
workflow = extra_pnginfo["workflow"]
|
||||||
|
if isinstance(workflow, dict) and "nodes" in workflow:
|
||||||
|
for node in workflow["nodes"]:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
node_id = str(node.get("id"))
|
||||||
|
node_title = node.get("title")
|
||||||
|
node_type = node.get("type")
|
||||||
|
if node_title:
|
||||||
|
node_title_to_id[node_title] = node_id
|
||||||
|
if node_type:
|
||||||
|
if node_type not in node_type_to_ids:
|
||||||
|
node_type_to_ids[node_type] = []
|
||||||
|
node_type_to_ids[node_type].append(node_id)
|
||||||
|
|
||||||
def lookup(key: str):
|
def lookup(key: str):
|
||||||
# key is like 'node_name/field' or 'field'
|
|
||||||
if not key:
|
if not key:
|
||||||
return None
|
return None
|
||||||
# Try prompt mapping
|
|
||||||
|
node_ref = None
|
||||||
|
field = key
|
||||||
|
if '/' in key:
|
||||||
|
node_ref, field = key.split('/', 1)
|
||||||
|
|
||||||
|
node_id = None
|
||||||
|
if node_ref:
|
||||||
|
if prompt and node_ref in prompt:
|
||||||
|
node_id = node_ref
|
||||||
|
elif node_ref in node_title_to_id:
|
||||||
|
node_id = node_title_to_id[node_ref]
|
||||||
|
elif node_ref in node_type_to_ids:
|
||||||
|
node_id = node_type_to_ids[node_ref][0]
|
||||||
|
|
||||||
|
if node_id and prompt and node_id in prompt:
|
||||||
|
node_data = prompt[node_id]
|
||||||
|
if isinstance(node_data, dict) and "inputs" in node_data:
|
||||||
|
inputs = node_data["inputs"]
|
||||||
|
if field in inputs:
|
||||||
|
return inputs[field]
|
||||||
|
|
||||||
|
# Fallback: check if the key is directly in prompt or if it is just a field name in some node
|
||||||
if prompt and isinstance(prompt, dict):
|
if prompt and isinstance(prompt, dict):
|
||||||
if key in prompt:
|
if key in prompt:
|
||||||
return prompt[key]
|
return prompt[key]
|
||||||
if '/' in key:
|
for nid, ndata in prompt.items():
|
||||||
_, field = key.split('/', 1)
|
if isinstance(ndata, dict) and "inputs" in ndata:
|
||||||
if field in prompt:
|
inputs = ndata.get("inputs", {})
|
||||||
return prompt[field]
|
if field in inputs:
|
||||||
|
return inputs[field]
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -98,7 +123,7 @@ def resolve_placeholders(template: str, prompt=None) -> str:
|
|||||||
return pattern.sub(repl, template)
|
return pattern.sub(repl, template)
|
||||||
|
|
||||||
|
|
||||||
class ImageSaveWithMetadata:
|
class Save_Machine:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.output_dir = folder_paths.output_directory
|
self.output_dir = folder_paths.output_directory
|
||||||
|
|
||||||
@@ -107,29 +132,25 @@ class ImageSaveWithMetadata:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"images": ("IMAGE", ),
|
"images": ("IMAGE", ),
|
||||||
"path_and_filename": ("STRING", {"default": "%time", "multiline": False}),
|
"path_and_filename": ("STRING", {"default": "%time", "tooltip": "Supports %date, %time, and custom prompt %placeholders%"}),
|
||||||
"extension": ((['png', 'jpeg', 'webp']),),
|
"extension": ((['png', 'jpeg', 'webp']),),
|
||||||
},
|
},
|
||||||
"optional": {
|
|
||||||
"custom_string": ("STRING", {"default": '', "multiline": True}),
|
|
||||||
"lossless_webp": ("BOOLEAN", {"default": True}),
|
|
||||||
"quality_jpeg_or_webp": ("INT", {"default": 100, "min": 1, "max": 100}),
|
|
||||||
},
|
|
||||||
"hidden": {
|
"hidden": {
|
||||||
"prompt": "PROMPT",
|
"prompt": "PROMPT",
|
||||||
|
"extra_pnginfo": "EXTRA_PNGINFO"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ()
|
RETURN_TYPES = ("STRING",)
|
||||||
|
RETURN_NAMES = ("file_path",)
|
||||||
FUNCTION = "save_files"
|
FUNCTION = "save_files"
|
||||||
OUTPUT_NODE = True
|
OUTPUT_NODE = True
|
||||||
CATEGORY = "SATA_UtilityNode"
|
CATEGORY = "SATA_UtilityNode"
|
||||||
|
|
||||||
def save_files(self, images, path_and_filename, extension, custom_string=None,
|
def save_files(self, images, path_and_filename, extension, prompt=None, extra_pnginfo=None):
|
||||||
quality_jpeg_or_webp=100, lossless_webp=True, prompt=None):
|
|
||||||
# Resolve placeholders first (uses prompt)
|
# Resolve placeholders first (uses prompt)
|
||||||
try:
|
try:
|
||||||
resolved = resolve_placeholders(str(path_and_filename), prompt)
|
resolved = resolve_placeholders(str(path_and_filename), prompt, extra_pnginfo)
|
||||||
except Exception:
|
except Exception:
|
||||||
resolved = str(path_and_filename)
|
resolved = str(path_and_filename)
|
||||||
|
|
||||||
@@ -152,8 +173,6 @@ class ImageSaveWithMetadata:
|
|||||||
os.makedirs(output_path, exist_ok=True)
|
os.makedirs(output_path, exist_ok=True)
|
||||||
|
|
||||||
comment_parts = []
|
comment_parts = []
|
||||||
if custom_string:
|
|
||||||
comment_parts.append(handle_whitespace(custom_string))
|
|
||||||
if prompt is not None:
|
if prompt is not None:
|
||||||
try:
|
try:
|
||||||
prompt_text = json.dumps(prompt)
|
prompt_text = json.dumps(prompt)
|
||||||
@@ -165,20 +184,25 @@ class ImageSaveWithMetadata:
|
|||||||
|
|
||||||
# Resolve any %node/field% placeholders in filename and path using prompt
|
# Resolve any %node/field% placeholders in filename and path using prompt
|
||||||
try:
|
try:
|
||||||
filename = resolve_placeholders(filename, prompt)
|
filename = resolve_placeholders(filename, prompt, extra_pnginfo)
|
||||||
path = resolve_placeholders(path, prompt)
|
path = resolve_placeholders(path, prompt, extra_pnginfo)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Best-effort: if resolution fails, continue with original templates
|
# Best-effort: if resolution fails, continue with original templates
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# We always use quality=100 and lossless_webp=True
|
||||||
filenames = self.save_images(images, output_path, filename, comment, extension,
|
filenames = self.save_images(images, output_path, filename, comment, extension,
|
||||||
quality_jpeg_or_webp, lossless_webp, prompt)
|
100, True, prompt, extra_pnginfo)
|
||||||
|
|
||||||
subfolder = os.path.normpath(path)
|
subfolder = os.path.normpath(path)
|
||||||
ui_images = [ {"filename": fn, "subfolder": subfolder if subfolder != '.' else '', "type": 'output'} for fn in filenames ]
|
ui_images = [ {"filename": fn, "subfolder": subfolder if subfolder != '.' else '', "type": 'output'} for fn in filenames ]
|
||||||
return {"ui": {"images": ui_images}}
|
|
||||||
|
|
||||||
def save_images(self, images, output_path, filename_prefix, comment, extension, quality_jpeg_or_webp, lossless_webp, prompt=None) -> list:
|
# Return the absolute path of the first saved image (or output directory if none)
|
||||||
|
first_file_path = os.path.join(output_path, filenames[0]) if filenames else output_path
|
||||||
|
|
||||||
|
return {"ui": {"images": ui_images}, "result": (first_file_path,)}
|
||||||
|
|
||||||
|
def save_images(self, images, output_path, filename_prefix, comment, extension, quality_jpeg_or_webp, lossless_webp, prompt=None, extra_pnginfo=None) -> list:
|
||||||
img_count = 1
|
img_count = 1
|
||||||
paths = []
|
paths = []
|
||||||
|
|
||||||
@@ -238,7 +262,9 @@ class ImageSaveWithMetadata:
|
|||||||
except Exception:
|
except Exception:
|
||||||
metadata.add_text("prompt", str(prompt))
|
metadata.add_text("prompt", str(prompt))
|
||||||
|
|
||||||
# No extra_pnginfo support: only include the prompt (handled above)
|
if extra_pnginfo is not None:
|
||||||
|
for x in extra_pnginfo:
|
||||||
|
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||||
|
|
||||||
outname = find_unique_name(base_name, 'png')
|
outname = find_unique_name(base_name, 'png')
|
||||||
img.save(os.path.join(output_path, outname), pnginfo=metadata, optimize=True)
|
img.save(os.path.join(output_path, outname), pnginfo=metadata, optimize=True)
|
||||||
@@ -254,11 +280,19 @@ class ImageSaveWithMetadata:
|
|||||||
img.save(file_path, optimize=True, quality=quality_jpeg_or_webp, lossless=lossless_webp if ext_name=='webp' else False)
|
img.save(file_path, optimize=True, quality=quality_jpeg_or_webp, lossless=lossless_webp if ext_name=='webp' else False)
|
||||||
|
|
||||||
# Insert EXIF user comment with our metadata
|
# Insert EXIF user comment with our metadata
|
||||||
if comment:
|
if comment or extra_pnginfo or prompt:
|
||||||
try:
|
try:
|
||||||
|
exif_dict = {}
|
||||||
|
if comment:
|
||||||
|
exif_dict["comment"] = comment
|
||||||
|
if prompt:
|
||||||
|
exif_dict["prompt"] = prompt
|
||||||
|
if extra_pnginfo:
|
||||||
|
exif_dict["workflow"] = extra_pnginfo.get("workflow", {})
|
||||||
|
|
||||||
exif_bytes = piexif.dump({
|
exif_bytes = piexif.dump({
|
||||||
"Exif": {
|
"Exif": {
|
||||||
piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(comment, encoding="unicode")
|
piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(json.dumps(exif_dict), encoding="unicode")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
piexif.insert(exif_bytes, file_path)
|
piexif.insert(exif_bytes, file_path)
|
||||||
+267
-122
@@ -1,52 +1,129 @@
|
|||||||
import os
|
|
||||||
import torch
|
import torch
|
||||||
import cv2
|
import torch.nn.functional as F
|
||||||
import numpy as np
|
|
||||||
import folder_paths
|
import folder_paths
|
||||||
import comfy.utils
|
import comfy.utils
|
||||||
from comfy import model_management
|
from comfy import model_management
|
||||||
from spandrel import ModelLoader
|
from spandrel import ModelLoader
|
||||||
|
|
||||||
|
|
||||||
def resize_tensor_opencv(tensor_chw, target_width, target_height, supersample='true', factor=2.0):
|
def generate_blue_noise(batch_size, c, h, w, device, beta=1.5):
|
||||||
|
# Generate White Noise (Standard Gaussian)
|
||||||
|
white_noise = torch.randn((batch_size, c, h, w), device=device)
|
||||||
|
|
||||||
|
# Convert to Frequency Domain (FFT)
|
||||||
|
fft_noise = torch.fft.fft2(white_noise)
|
||||||
|
|
||||||
|
# Create Frequency Grid
|
||||||
|
y = torch.fft.fftfreq(h, device=device)
|
||||||
|
x = torch.fft.fftfreq(w, device=device)
|
||||||
|
dy, dx = torch.meshgrid(y, x, indexing='ij')
|
||||||
|
|
||||||
|
# Calculate distance from center (frequency magnitude)
|
||||||
|
frequency_magnitude = torch.sqrt(dy**2 + dx**2)
|
||||||
|
|
||||||
|
# Apply High-Pass Scaling: Amplitude ~ f^(beta/2)
|
||||||
|
scale = frequency_magnitude ** (beta / 2.0)
|
||||||
|
|
||||||
|
# Zero out DC component
|
||||||
|
scale[0, 0] = 0
|
||||||
|
|
||||||
|
fft_structured = fft_noise * scale
|
||||||
|
|
||||||
|
# Convert back to Spatial Domain (Inverse FFT)
|
||||||
|
blue_noise = torch.fft.ifft2(fft_structured).real
|
||||||
|
|
||||||
|
# Normalize standard deviation to ~1.0
|
||||||
|
std = blue_noise.std()
|
||||||
|
if std > 1e-6:
|
||||||
|
blue_noise = blue_noise / std
|
||||||
|
|
||||||
|
return blue_noise
|
||||||
|
|
||||||
|
|
||||||
|
def _make_gaussian_kernel(kernel_size, sigma, device):
|
||||||
|
"""Build a separable 2D Gaussian kernel tensor (1,1,k,k) on device."""
|
||||||
|
k = kernel_size
|
||||||
|
coords = torch.arange(k, dtype=torch.float32, device=device) - k // 2
|
||||||
|
gauss_1d = torch.exp(-0.5 * (coords / sigma) ** 2)
|
||||||
|
gauss_1d = gauss_1d / gauss_1d.sum()
|
||||||
|
kernel_2d = gauss_1d[:, None] * gauss_1d[None, :]
|
||||||
|
return kernel_2d.view(1, 1, k, k)
|
||||||
|
|
||||||
|
|
||||||
|
def fast_gaussian_blur_bchw(tensor_bchw, kernel_size=9, sigma=3.0):
|
||||||
"""
|
"""
|
||||||
CPU-based resizing using OpenCV for 'rescale' mode.
|
Fast per-channel GPU Gaussian blur via depthwise convolution.
|
||||||
Input: tensor_chw -> torch.FloatTensor (C,H,W) in [0,1]
|
Input/Output: BCHW float tensor. Kernel is always created on the same device.
|
||||||
Output: torch.FloatTensor (C,H,W) in [0,1], guaranteed 3-channel RGB
|
|
||||||
"""
|
"""
|
||||||
new_width = max(1, int(target_width))
|
device = tensor_bchw.device
|
||||||
new_height = max(1, int(target_height))
|
k = kernel_size
|
||||||
|
kernel = _make_gaussian_kernel(k, sigma, device) # on same device
|
||||||
|
C = tensor_bchw.shape[1]
|
||||||
|
kernel_c = kernel.expand(C, 1, k, k).to(device) # guarantee same device
|
||||||
|
pad = k // 2
|
||||||
|
return F.conv2d(tensor_bchw.contiguous(), kernel_c, padding=pad, groups=C)
|
||||||
|
|
||||||
interp = cv2.INTER_LANCZOS4 if factor > 1.0 else cv2.INTER_AREA
|
|
||||||
|
|
||||||
np_img = tensor_chw.permute(1, 2, 0).detach().cpu().numpy()
|
def resize_bchw(tensor_bchw, target_h, target_w):
|
||||||
np_img = (np.clip(np_img, 0.0, 1.0) * 255.0).astype(np.uint8)
|
"""
|
||||||
|
Fast GPU resize — stays on device, no antialias overhead.
|
||||||
|
Bilinear for downscale (smoother), bicubic for upscale (sharper).
|
||||||
|
"""
|
||||||
|
in_h, in_w = tensor_bchw.shape[2], tensor_bchw.shape[3]
|
||||||
|
if in_h == target_h and in_w == target_w:
|
||||||
|
return tensor_bchw
|
||||||
|
downscaling = (target_h < in_h) or (target_w < in_w)
|
||||||
|
mode = 'bilinear' if downscaling else 'bicubic'
|
||||||
|
return F.interpolate(tensor_bchw, size=(target_h, target_w), mode=mode, align_corners=False)
|
||||||
|
|
||||||
if np_img.ndim == 2:
|
|
||||||
np_img = cv2.cvtColor(np_img, cv2.COLOR_GRAY2RGB)
|
|
||||||
elif np_img.shape[2] == 1:
|
|
||||||
np_img = cv2.cvtColor(np_img, cv2.COLOR_GRAY2RGB)
|
|
||||||
elif np_img.shape[2] == 4:
|
|
||||||
np_img = cv2.cvtColor(np_img, cv2.COLOR_RGBA2RGB)
|
|
||||||
elif np_img.shape[2] > 3:
|
|
||||||
np_img = np_img[:, :, :3]
|
|
||||||
|
|
||||||
if np_img.shape[0] == 0 or np_img.shape[1] == 0:
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
raise ValueError("Input image for OpenCV resize has zero width or height.")
|
# Architecture optimization profiles for spandrel models.
|
||||||
|
# Keys match the inner nn.Module class name (type(model.model).__name__).
|
||||||
|
#
|
||||||
|
# tile : starting tile size for tiled inference (OOM fallback halves it)
|
||||||
|
# fp16_model : cast model weights to FP16 before inference (Tensor Core boost)
|
||||||
|
# compile : torch.compile mode — 'reduce-overhead' for fixed-shape tiling
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
ARCH_PROFILES = {
|
||||||
|
# ── Fast CNN / RRDB models — full FP16 support, large tiles ──────────────
|
||||||
|
"RRDBNet": {"tile": 768, "fp16_model": True, "compile": "reduce-overhead"}, # ESRGAN, Real-ESRGAN
|
||||||
|
"SRVGGNetCompact": {"tile": 896, "fp16_model": True, "compile": "reduce-overhead"}, # Real-ESRGAN Compact
|
||||||
|
"SPAN": {"tile": 896, "fp16_model": True, "compile": "reduce-overhead"}, # Swift, very fast
|
||||||
|
"PLKSR": {"tile": 768, "fp16_model": False, "compile": "reduce-overhead"}, # Lightweight
|
||||||
|
"RealPLKSR": {"tile": 768, "fp16_model": False, "compile": "reduce-overhead"}, # Lightweight photo
|
||||||
|
"RCAN": {"tile": 768, "fp16_model": True, "compile": "reduce-overhead"}, # Residual channel attn
|
||||||
|
"SAFMN": {"tile": 896, "fp16_model": True, "compile": "reduce-overhead"}, # Very fast
|
||||||
|
"DITN": {"tile": 768, "fp16_model": True, "compile": "reduce-overhead"},
|
||||||
|
"CRAFT": {"tile": 768, "fp16_model": True, "compile": "reduce-overhead"},
|
||||||
|
"NAFNet": {"tile": 768, "fp16_model": True, "compile": "reduce-overhead"},
|
||||||
|
"OmniSR": {"tile": 640, "fp16_model": True, "compile": "reduce-overhead"},
|
||||||
|
"RGT": {"tile": 640, "fp16_model": True, "compile": "reduce-overhead"},
|
||||||
|
# ── Transformer / attention models — autocast only, conservative tiles ────
|
||||||
|
"DAT": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"}, # Dual Attention Transformer
|
||||||
|
"DAT_S": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"},
|
||||||
|
"HAT": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"}, # Hybrid Attention
|
||||||
|
"HAT_L": {"tile": 480, "fp16_model": False, "compile": "reduce-overhead"}, # HAT Large — very heavy
|
||||||
|
"SwinIR": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"}, # Swin Transformer IR
|
||||||
|
"Swin2SR": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"},
|
||||||
|
"SAN": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"}, # Second-order Attn
|
||||||
|
"DRCT": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"},
|
||||||
|
"ATD": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"},
|
||||||
|
"GRL": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"},
|
||||||
|
"FDAT": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"},
|
||||||
|
"AuraSR": {"tile": 512, "fp16_model": False, "compile": "reduce-overhead"},
|
||||||
|
}
|
||||||
|
|
||||||
if supersample == 'true':
|
# Fallback profile for any architecture not listed above.
|
||||||
ss_width = max(1, new_width * 8)
|
# Uses supports_half from spandrel for fp16_model, 512 tile as safe default.
|
||||||
ss_height = max(1, new_height * 8)
|
_DEFAULT_PROFILE = {"tile": 512, "fp16_model": None, "compile": "reduce-overhead"}
|
||||||
np_img = cv2.resize(np_img, (ss_width, ss_height), interpolation=interp)
|
|
||||||
if np_img.shape[0] == 0 or np_img.shape[1] == 0:
|
|
||||||
raise ValueError("Image became empty after supersample resize.")
|
|
||||||
|
|
||||||
resized = cv2.resize(np_img, (new_width, new_height), interpolation=interp)
|
|
||||||
tensor_out = torch.from_numpy(resized).float().div(255.0).permute(2, 0, 1)
|
|
||||||
return tensor_out
|
|
||||||
|
|
||||||
|
|
||||||
class Upscale_Machine:
|
class Upscale_Machine:
|
||||||
|
# Session-level cache: model object id -> torch.compile'd inner module
|
||||||
|
# Avoids recompiling the same model on repeated runs in the same session.
|
||||||
|
_compiled_cache: dict = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
@@ -55,8 +132,7 @@ class Upscale_Machine:
|
|||||||
"upscale_model": (folder_paths.get_filename_list("upscale_models"), {"default": None}),
|
"upscale_model": (folder_paths.get_filename_list("upscale_models"), {"default": None}),
|
||||||
"chained_model": (["None"] + folder_paths.get_filename_list("upscale_models"), {"default": "None"}),
|
"chained_model": (["None"] + folder_paths.get_filename_list("upscale_models"), {"default": "None"}),
|
||||||
"rescale_factor": ("FLOAT", {"default": 2.0, "min": 0.01, "max": 16.0, "step": 0.01}),
|
"rescale_factor": ("FLOAT", {"default": 2.0, "min": 0.01, "max": 16.0, "step": 0.01}),
|
||||||
"supersample": (["true", "false"],),
|
"frequency_split": ("BOOLEAN", {"default": True}),
|
||||||
"rounding_modulus": ("INT", {"default": 8, "min": 1, "max": 1024, "step": 1}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +142,7 @@ class Upscale_Machine:
|
|||||||
CATEGORY = "SATA_UtilityNode"
|
CATEGORY = "SATA_UtilityNode"
|
||||||
|
|
||||||
def load_model(self, model_name):
|
def load_model(self, model_name):
|
||||||
"""Load ESRGAN/RealESRGAN/AESRGAN from the project's upscale_models folder.
|
"""Load ESRGAN/RealESRGAN/AESRGAN from the project's upscale_models folder."""
|
||||||
|
|
||||||
Only legacy PyTorch/spandrel models are supported by this simplified node.
|
|
||||||
"""
|
|
||||||
if not model_name:
|
if not model_name:
|
||||||
raise ValueError("No upscale model selected or provided.")
|
raise ValueError("No upscale model selected or provided.")
|
||||||
|
|
||||||
@@ -83,44 +156,119 @@ class Upscale_Machine:
|
|||||||
model.eval()
|
model.eval()
|
||||||
return model
|
return model
|
||||||
|
|
||||||
def upscale_with_model(self, upscale_model, image_bhwc):
|
def _get_arch_profile(self, upscale_model):
|
||||||
"""
|
"""
|
||||||
Run tiled upscale with spandrel model.
|
Detects the model architecture and returns its optimisation profile.
|
||||||
Input: image_bhwc -> (B,H,W,C) float [0,1]
|
Detection priority:
|
||||||
Output: (B,C,H',W') float [0,1]
|
1. Inner nn.Module class name (most specific)
|
||||||
|
2. spandrel supports_half flag (fallback for unknown architectures)
|
||||||
"""
|
"""
|
||||||
device = model_management.get_torch_device()
|
inner_cls = type(upscale_model.model).__name__
|
||||||
upscale_model.to(device)
|
profile = ARCH_PROFILES.get(inner_cls)
|
||||||
|
if profile is None:
|
||||||
|
# Unknown architecture — use spandrel flag for fp16 decision
|
||||||
|
fp16_ok = getattr(upscale_model, "supports_half", False)
|
||||||
|
profile = {**_DEFAULT_PROFILE, "fp16_model": fp16_ok}
|
||||||
|
return inner_cls, profile
|
||||||
|
|
||||||
in_img = image_bhwc.movedim(-1, -3).to(device)
|
def _get_compiled_model(self, upscale_model, compile_mode):
|
||||||
|
"""
|
||||||
tile = 512
|
Returns a torch.compile()'d version of the model's inner nn.Module.
|
||||||
overlap = 32
|
Cached by object id — compiles once per session per loaded model.
|
||||||
oom = True
|
Falls back silently to eager mode if compile fails or Triton is missing.
|
||||||
while oom:
|
"""
|
||||||
|
inner = upscale_model.model
|
||||||
|
cache_key = id(inner)
|
||||||
|
if cache_key not in Upscale_Machine._compiled_cache:
|
||||||
|
# Check for triton before attempting compile, as torch.compile is lazy
|
||||||
|
# and will otherwise crash on the first tile execution on Windows.
|
||||||
try:
|
try:
|
||||||
steps = in_img.shape[0] * comfy.utils.get_tiled_scale_steps(
|
import triton
|
||||||
in_img.shape[3], in_img.shape[2], tile_x=tile, tile_y=tile, overlap=overlap
|
has_triton = True
|
||||||
)
|
except ImportError:
|
||||||
pbar = comfy.utils.ProgressBar(steps)
|
has_triton = False
|
||||||
s = comfy.utils.tiled_scale(
|
|
||||||
in_img,
|
|
||||||
lambda a: upscale_model(a),
|
|
||||||
tile_x=tile,
|
|
||||||
tile_y=tile,
|
|
||||||
overlap=overlap,
|
|
||||||
upscale_amount=getattr(upscale_model, "scale", 4),
|
|
||||||
pbar=pbar
|
|
||||||
)
|
|
||||||
oom = False
|
|
||||||
except model_management.OOM_EXCEPTION as e:
|
|
||||||
tile //= 2
|
|
||||||
if tile < 128:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
upscale_model.cpu()
|
if has_triton:
|
||||||
s = torch.clamp(s, min=0.0, max=1.0)
|
try:
|
||||||
return s
|
compiled = torch.compile(inner, mode=compile_mode, dynamic=False)
|
||||||
|
Upscale_Machine._compiled_cache[cache_key] = compiled
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"[Upscale_Machine] torch.compile unavailable ({ex}), using eager mode.")
|
||||||
|
Upscale_Machine._compiled_cache[cache_key] = inner
|
||||||
|
else:
|
||||||
|
print("[Upscale_Machine] Triton not installed (Windows), skipping torch.compile.")
|
||||||
|
Upscale_Machine._compiled_cache[cache_key] = inner
|
||||||
|
|
||||||
|
return Upscale_Machine._compiled_cache[cache_key]
|
||||||
|
|
||||||
|
def upscale_with_model(self, upscale_model, image_bchw, device, pbar=None):
|
||||||
|
"""
|
||||||
|
Architecture-aware tiled upscale:
|
||||||
|
- Detects model type and loads optimisation profile
|
||||||
|
- FP16 weights (Tensor Cores) for CNN models that support it
|
||||||
|
- torch.autocast for mixed-precision on every tile (all architectures)
|
||||||
|
- torch.compile fused graph (cached per session, falls back safely)
|
||||||
|
Input: image_bchw -> (B,C,H,W) float32 [0,1]
|
||||||
|
Output: (B,C,H',W') float32 [0,1]
|
||||||
|
"""
|
||||||
|
arch_name, profile = self._get_arch_profile(upscale_model)
|
||||||
|
spandrel_supports_half = getattr(upscale_model, "supports_half", False)
|
||||||
|
use_fp16 = profile["fp16_model"] and spandrel_supports_half
|
||||||
|
tile = profile["tile"]
|
||||||
|
comp_mode = profile["compile"]
|
||||||
|
device_type = device.type if hasattr(device, "type") else str(device).split(":")[0]
|
||||||
|
|
||||||
|
print(f"[Upscale_Machine] {arch_name} | "
|
||||||
|
f"tile={tile} | fp16={'yes' if use_fp16 else 'no'} | compile={comp_mode}")
|
||||||
|
|
||||||
|
upscale_model.to(device)
|
||||||
|
if use_fp16:
|
||||||
|
upscale_model.half()
|
||||||
|
in_tensor = image_bchw.to(device).half()
|
||||||
|
else:
|
||||||
|
in_tensor = image_bchw.to(device).float()
|
||||||
|
|
||||||
|
# Compile inner nn.Module once per session
|
||||||
|
compiled_fn = self._get_compiled_model(upscale_model, comp_mode)
|
||||||
|
|
||||||
|
try:
|
||||||
|
overlap = 32
|
||||||
|
oom = True
|
||||||
|
while oom:
|
||||||
|
try:
|
||||||
|
if not pbar:
|
||||||
|
steps = in_tensor.shape[0] * comfy.utils.get_tiled_scale_steps(
|
||||||
|
in_tensor.shape[3], in_tensor.shape[2], tile_x=tile, tile_y=tile, overlap=overlap
|
||||||
|
)
|
||||||
|
local_pbar = comfy.utils.ProgressBar(steps)
|
||||||
|
else:
|
||||||
|
local_pbar = pbar
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
# If model doesn't support fp16 (like DAT/HAT), autocast to fp16 will also cause NaNs.
|
||||||
|
# We only autocast if use_fp16 is True to be perfectly safe.
|
||||||
|
ctx = torch.autocast(device_type=device_type, dtype=torch.float16) if use_fp16 else contextlib.nullcontext()
|
||||||
|
|
||||||
|
with ctx:
|
||||||
|
s = comfy.utils.tiled_scale(
|
||||||
|
in_tensor,
|
||||||
|
lambda a: compiled_fn(a),
|
||||||
|
tile_x=tile,
|
||||||
|
tile_y=tile,
|
||||||
|
overlap=overlap,
|
||||||
|
upscale_amount=getattr(upscale_model, "scale", 4),
|
||||||
|
pbar=local_pbar
|
||||||
|
)
|
||||||
|
oom = False
|
||||||
|
except model_management.OOM_EXCEPTION as e:
|
||||||
|
model_management.soft_empty_cache()
|
||||||
|
tile //= 2
|
||||||
|
if tile < 128:
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
upscale_model.cpu().float() # Always restore to FP32 on CPU
|
||||||
|
|
||||||
|
return torch.clamp(s.float(), min=0.0, max=1.0)
|
||||||
|
|
||||||
def _round_to_modulus(self, value, modulus):
|
def _round_to_modulus(self, value, modulus):
|
||||||
if modulus is None or modulus <= 1:
|
if modulus is None or modulus <= 1:
|
||||||
@@ -128,68 +276,65 @@ class Upscale_Machine:
|
|||||||
return max(modulus, int(round(value / modulus)) * modulus)
|
return max(modulus, int(round(value / modulus)) * modulus)
|
||||||
|
|
||||||
def upscale(self, image, upscale_model, chained_model="None", rounding_modulus=8, supersample='true',
|
def upscale(self, image, upscale_model, chained_model="None", rounding_modulus=8, supersample='true',
|
||||||
rescale_factor=2.0):
|
rescale_factor=2.0, frequency_split=True):
|
||||||
|
|
||||||
if image.ndim != 4:
|
if image.ndim != 4:
|
||||||
raise ValueError("Expected IMAGE tensor with 4 dims (B,H,W,C).")
|
raise ValueError("Expected IMAGE tensor with 4 dims (B,H,W,C).")
|
||||||
|
|
||||||
|
device = model_management.get_torch_device()
|
||||||
|
|
||||||
original_height = int(image.shape[1])
|
original_height = int(image.shape[1])
|
||||||
original_width = int(image.shape[2])
|
original_width = int(image.shape[2])
|
||||||
target_width = self._round_to_modulus(original_width * rescale_factor, rounding_modulus)
|
target_w = self._round_to_modulus(original_width * rescale_factor, rounding_modulus)
|
||||||
target_height = self._round_to_modulus(original_height * rescale_factor, rounding_modulus)
|
target_h = self._round_to_modulus(original_height * rescale_factor, rounding_modulus)
|
||||||
|
|
||||||
current_bhwc = image
|
# Move to GPU once as BCHW — stay there the entire pipeline
|
||||||
|
current_bchw = image.movedim(-1, 1).to(device)
|
||||||
|
if current_bchw.shape[1] == 1:
|
||||||
|
current_bchw = current_bchw.repeat(1, 3, 1, 1)
|
||||||
|
elif current_bchw.shape[1] > 3:
|
||||||
|
current_bchw = current_bchw[:, :3, :, :]
|
||||||
|
|
||||||
# First upscale
|
# Keep a clean GPU copy of original for frequency split baseline
|
||||||
|
original_bchw = current_bchw.clone()
|
||||||
|
|
||||||
|
# ── First upscale model ───────────────────────────────────────────────
|
||||||
if upscale_model:
|
if upscale_model:
|
||||||
up_model = self.load_model(upscale_model)
|
up_model = self.load_model(upscale_model)
|
||||||
up_bchw = self.upscale_with_model(up_model, current_bhwc)
|
current_bchw = self.upscale_with_model(up_model, current_bchw, device)
|
||||||
current_bhwc = up_bchw.movedim(1, -1).contiguous()
|
# tiled_scale may return CPU tensor — pin back to GPU
|
||||||
|
current_bchw = current_bchw.to(device)
|
||||||
|
current_bchw = resize_bchw(current_bchw, target_h, target_w)
|
||||||
|
|
||||||
# Downscale to target size before chaining
|
# ── Chained model ─────────────────────────────────────────────────────
|
||||||
batch_downscaled = []
|
has_chain = chained_model and chained_model != "None"
|
||||||
for i in range(current_bhwc.shape[0]):
|
if has_chain:
|
||||||
chw = current_bhwc[i].movedim(-1, 0) # (H,W,C) -> (C,H,W)
|
|
||||||
chw = torch.clamp(chw, 0.0, 1.0)
|
|
||||||
resized_chw = resize_tensor_opencv(
|
|
||||||
chw, target_width=target_width, target_height=target_height,
|
|
||||||
supersample=supersample, factor=rescale_factor
|
|
||||||
)
|
|
||||||
if resized_chw.shape[0] == 1:
|
|
||||||
resized_chw = resized_chw.repeat(3, 1, 1)
|
|
||||||
elif resized_chw.shape[0] > 3:
|
|
||||||
resized_chw = resized_chw[:3, :, :]
|
|
||||||
batch_downscaled.append(resized_chw.unsqueeze(0))
|
|
||||||
current_bhwc = torch.cat(batch_downscaled, dim=0)
|
|
||||||
current_bhwc = current_bhwc.movedim(1, -1).contiguous()
|
|
||||||
|
|
||||||
# Second upscale (chained model) if selected
|
|
||||||
if chained_model and chained_model != "None":
|
|
||||||
# chained model is a model name
|
|
||||||
chain_model = self.load_model(chained_model)
|
chain_model = self.load_model(chained_model)
|
||||||
chain_bchw = self.upscale_with_model(chain_model, current_bhwc)
|
current_bchw = self.upscale_with_model(chain_model, current_bchw, device)
|
||||||
current_bhwc = chain_bchw.movedim(1, -1).contiguous()
|
# tiled_scale may return CPU tensor — pin back to GPU
|
||||||
|
current_bchw = current_bchw.to(device)
|
||||||
|
current_bchw = resize_bchw(current_bchw, target_h, target_w)
|
||||||
|
|
||||||
# If no chained model, ensure output is at target size
|
images_out = current_bchw
|
||||||
if not (chained_model and chained_model != "None"):
|
|
||||||
# Already downscaled after first upscale, so just output
|
|
||||||
images_out = current_bhwc
|
|
||||||
else:
|
|
||||||
# After chained model, do a final resize to target size
|
|
||||||
batch_out = []
|
|
||||||
for i in range(current_bhwc.shape[0]):
|
|
||||||
chw = current_bhwc[i].movedim(-1, 0)
|
|
||||||
chw = torch.clamp(chw, 0.0, 1.0)
|
|
||||||
resized_chw = resize_tensor_opencv(
|
|
||||||
chw, target_width=target_width, target_height=target_height,
|
|
||||||
supersample=supersample, factor=rescale_factor
|
|
||||||
)
|
|
||||||
if resized_chw.shape[0] == 1:
|
|
||||||
resized_chw = resized_chw.repeat(3, 1, 1)
|
|
||||||
elif resized_chw.shape[0] > 3:
|
|
||||||
resized_chw = resized_chw[:3, :, :]
|
|
||||||
batch_out.append(resized_chw.unsqueeze(0))
|
|
||||||
out_bchw = torch.cat(batch_out, dim=0)
|
|
||||||
images_out = out_bchw.movedim(1, -1).contiguous()
|
|
||||||
|
|
||||||
return (images_out,)
|
# ── Frequency-Split SR ────────────────────────────────────────────────
|
||||||
|
if frequency_split and upscale_model:
|
||||||
|
# Ensure both tensors are on the same device before any arithmetic
|
||||||
|
bicubic_bchw = resize_bchw(original_bchw.to(device), target_h, target_w)
|
||||||
|
images_out = images_out.to(device)
|
||||||
|
|
||||||
|
sr_low = fast_gaussian_blur_bchw(images_out)
|
||||||
|
sr_high = images_out - sr_low
|
||||||
|
|
||||||
|
bic_low = fast_gaussian_blur_bchw(bicubic_bchw)
|
||||||
|
images_out = torch.clamp(bic_low + sr_high, 0.0, 1.0)
|
||||||
|
|
||||||
|
# ── Blue Noise realism (chained only) ─────────────────────────────────
|
||||||
|
if has_chain:
|
||||||
|
images_out = images_out.to(device)
|
||||||
|
B, C, H, W = images_out.shape
|
||||||
|
noise = generate_blue_noise(B, C, H, W, device)
|
||||||
|
images_out = torch.clamp(images_out + noise * 0.15, 0.0, 1.0)
|
||||||
|
|
||||||
|
# Convert back to BHWC for ComfyUI
|
||||||
|
return (images_out.movedim(1, -1).contiguous(),)
|
||||||
+9
-5
@@ -1,12 +1,16 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui_sata_utilitynode"
|
name = "sata_utilitynode"
|
||||||
description = "ComfyUI utility nodes collection providing features like prompt machine, resolution machine, and upscale machine to enhance workflow efficiency."
|
description = "A collection of utility nodes including image upscale,touchpad support and CSV-driven prompt machine for ComfyUI."
|
||||||
version = "1.2.0"
|
version = "1.5.0"
|
||||||
license = "MIT"
|
license = {file = "LICENSE"}
|
||||||
|
|
||||||
|
dependencies = ["opencv-python>=4.5.0", "spandrel", "piexif"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "https://github.com/SatadalAI/SATA_UtilityNode"
|
Repository = "https://github.com/SatadalAI/SATA_UtilityNode"
|
||||||
# Used by Comfy Registry https://comfyregistry.org
|
# Used by Comfy Registry https://registry.comfy.org
|
||||||
|
Documentation = "https://github.com/SatadalAI/SATA_UtilityNode/wiki"
|
||||||
|
"Bug Tracker" = "https://github.com/SatadalAI/SATA_UtilityNode/issues"
|
||||||
|
|
||||||
[tool.comfy]
|
[tool.comfy]
|
||||||
PublisherId = "SatadalAI"
|
PublisherId = "SatadalAI"
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,3 @@
|
|||||||
opencv-python>=4.5.0
|
opencv-python>=4.5.0
|
||||||
spandrel
|
spandrel
|
||||||
nvidia-tensorrt>=8.0
|
piexif
|
||||||
onnx>=1.12.0
|
|
||||||
onnxruntime-gpu>=1.12.0
|
|
||||||
Reference in New Issue
Block a user