Merge pull request #45 from Mooshieblob1/master
Added Character Reference
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# ComfyUI_NAIDGenerator
|
||||
|
||||
A [ComfyUI](https://github.com/comfyanonymous/ComfyUI) extension for generating image via NovelAI API.
|
||||
A [ComfyUI](https://github.com/comfyanonymous/ComfyUI) extension for generating images via the NovelAI API.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -9,89 +9,116 @@ A [ComfyUI](https://github.com/comfyanonymous/ComfyUI) extension for generating
|
||||
|
||||
## Setting up NAI account
|
||||
|
||||
Before using the nodes, you should set NAI_ACCESS_TOKEN on `ComfyUI/.env` file.
|
||||
Before using the nodes, you should set `NAI_ACCESS_TOKEN` in a `.env` file located in your main `ComfyUI` directory.
|
||||
|
||||
`ComfyUI/.env`
|
||||
```
|
||||
NAI_ACCESS_TOKEN=<ACCESS_TOKEN>
|
||||
NAI_ACCESS_TOKEN=<YOUR_ACCESS_TOKEN>
|
||||
```
|
||||
|
||||
You can get persistent API token by **User Settings > Account > Get Persistent API Token** on NovelAI webpage.
|
||||
You can get a persistent API token by navigating to **User Settings > Account > Get Persistent API Token** on the NovelAI website.
|
||||
|
||||
Otherwise, you can get access token which is valid for 30 days using [novelai-api](https://github.com/Aedial/novelai-api).
|
||||
Otherwise, you can get an access token which is valid for 30 days using [novelai-api](https://github.com/Aedial/novelai-api).
|
||||
|
||||
## Usage
|
||||
|
||||
The nodes are located at `NovelAI` category.
|
||||
The nodes are located in the `NovelAI` category.
|
||||
|
||||

|
||||
|
||||
### Txt2img
|
||||
|
||||
Simply connect `GenerateNAID` node and `SaveImage` node.
|
||||
Simply connect the `GenerateNAID` node to a `SaveImage` node.
|
||||
|
||||

|
||||
|
||||
Note that all generated images via `GeneratedNAID` node are saved as `output/NAI_autosave_12345_.png` for keeping original metadata.
|
||||
**Note:** All generated images via the `GenerateNAID` node are automatically saved to `output/NAI_autosave/NAI_autosave_#####_.png` to preserve their original metadata.
|
||||
|
||||
### Img2img
|
||||
|
||||
Connect `Img2ImgOptionNAID` node to `GenerateNAID` node and put original image.
|
||||
Connect an `Img2ImgOptionNAID` node to the `option` input of the `GenerateNAID` node and provide a source image.
|
||||
|
||||

|
||||
|
||||
Note that width and height of the source image will be resized to generation size.
|
||||
**Note:** The width and height of the source image will be resized to the generation size.
|
||||
|
||||
### Inpainting
|
||||
|
||||
Connect `InpaintingOptionNAID` node to `GenerateNAID` node and put original image and mask image.
|
||||
Connect an `InpaintingOptionNAID` node to the `GenerateNAID` node and provide a source image and a mask.
|
||||
|
||||

|
||||
|
||||
Note that both source image and mask will be resized fit to generation size.
|
||||
|
||||
(You don't need `MaskImageToNAID` node to convert mask image to NAID mask image.)
|
||||
**Note:** Both the source image and mask will be automatically resized to fit the generation size.
|
||||
|
||||
### Vibe Transfer
|
||||
|
||||
Connect `VibeTransferOptionNAID` node to `GenerateNAID` node and put reference image.
|
||||
Connect a `VibeTransferOptionNAID` node to the `GenerateNAID` node and provide a reference image to transfer its style and feel.
|
||||
|
||||

|
||||
|
||||
You can also relay Img2ImgOption on it.
|
||||
You can also chain it with other options, like Img2Img.
|
||||
|
||||

|
||||
|
||||
Note that width and height of the source images will be resized to generation size. **This will change aspect ratio of source images.**
|
||||
|
||||
#### Multiple Vibe Transfer
|
||||
|
||||
Just connect multiple `VibeTransferOptionNAID` nodes to `GenerateNAID` node.
|
||||
Connect multiple `VibeTransferOptionNAID` nodes to combine their influences.
|
||||
|
||||

|
||||
|
||||
### Character Reference
|
||||
|
||||
Use the `CharacterReferenceOptionNAID` node to guide the generation using a single reference image for character identity and/or style.
|
||||
|
||||

|
||||
|
||||
- **style_aware:** If enabled, it attempts to copy both the character's features and the artistic style.
|
||||
- **fidelity:** Controls how strictly the generation should adhere to the reference image. The developers state that `primary_strength` is always kept at `1.0`, while `secondary_strength` is calculated as $1.0 - \text{fidelity}$.
|
||||
|
||||
**Note:** The reference image will be automatically letterboxed to an accepted NAI canvas size to preserve its aspect ratio.
|
||||
|
||||
### ModelOption
|
||||
|
||||
The default model of `GenerateNAID` node is `nai-diffusion-3`(NAI Diffusion Anime V3).
|
||||
The default model of the `GenerateNAID` node is `nai-diffusion-4-5-full`. To change the model, connect a `ModelOptionNAID` node.
|
||||
|
||||
If you want to change model, put `ModelOptionNAID` node to `GenerateNAID` node.
|
||||
Available V4+ models include:
|
||||
- `nai-diffusion-4-curated-preview`
|
||||
- `nai-diffusion-4-full`
|
||||
- `nai-diffusion-4-5-curated`
|
||||
- `nai-diffusion-4-5-full`
|
||||
|
||||

|
||||
|
||||
### NetworkOption
|
||||
|
||||
You can set timeout or retry option from `NetworkOption` node.
|
||||
Moreover, you can ignore error by `ignore_errors`. In that case, the result will be 1x1 size grayscale image.
|
||||
Without this node, the request never retry and wait response forever, and stop the queue when error occurs
|
||||
You can set timeout and retry options using the `NetworkOption` node. You can also set `ignore_errors` to prevent the queue from stopping on an API error; if an error occurs, a blank 1x1 image will be output.
|
||||
|
||||

|
||||
|
||||
**Note that if you set timeout too short, you may not get image but spend Anlas.**
|
||||
**Note:** If you set the timeout too short, you may not receive an image but could still be charged Anlas.
|
||||
|
||||
### Anlas Tracker
|
||||
|
||||
This extension now includes Anlas tracking to monitor your usage.
|
||||
|
||||
**1. Console Output (Automatic)**
|
||||
All generation and director tool nodes will automatically print your Anlas balance before and after the operation in the console where you launched ComfyUI.
|
||||
```
|
||||
[NovelAI] Anlas (pre-gen): 10000
|
||||
[NovelAI] Generation cost: 20 Anlas
|
||||
[NovelAI] Anlas (post-gen): 9980
|
||||
```
|
||||
|
||||
**2. Visual Node**
|
||||
A new node, **`Anlas Tracker ✒️🅝🅐🅘`**, is available in the `NovelAI/utils` category. You can use it to display your current Anlas balance directly in your workflow.
|
||||
|
||||
Connect its `anlas_string` output to a display node (e.g., "Show Text" from the [WAS Node Suite](https://github.com/WASasquatch/was-node-suite-comfyui)) to see the value. Use the `trigger` input to control when the balance is checked.
|
||||
|
||||
 <!-- Placeholder for actual image -->
|
||||
|
||||
### PromptToNAID
|
||||
|
||||
ComfyUI use `()` or `(word:weight)` for emphasis, but NovelAI use `{}` and `[]`. This node convert ComfyUI's prompt to NovelAI's.
|
||||
|
||||
Optionally, you can choose weight per brace. If you set `weight_per_brace` to 0.10, `(word:1.1)` will convert to `{word}` instead of `{{word}}`.
|
||||
ComfyUI uses `()` or `(word:weight)` for emphasis, while NovelAI uses `{}` and `[]`. This node, found in `NovelAI/utils`, converts ComfyUI's prompt syntax to NovelAI's.
|
||||
|
||||

|
||||
|
||||
@@ -99,91 +126,29 @@ Optionally, you can choose weight per brace. If you set `weight_per_brace` to 0.
|
||||
|
||||

|
||||
|
||||
You can find director tools like `LineArtNAID` or `EmotionNAID` on NovelAI > director_tools.
|
||||
You can find director tools like `LineArtNAID`, `EmotionNAID`, and `RemoveBGNAID` in the `NovelAI/director_tools` category.
|
||||
|
||||

|
||||
|
||||
### V4 Support (Preview)
|
||||
### V4 / V4.5 Support
|
||||
|
||||
The node now supports NAI's V4 architecture through the nai-diffusion-4-curated-preview model. This is a preview release of V4 with some limitations:
|
||||
|
||||
- **Important Notes:**
|
||||
- This is a preview version of V4 and some features are limited
|
||||
- Inpainting will automatically use V3 model (but works with V4-generated images)
|
||||
- Vibe transfer is not yet supported with V4 preview (will be available with full V4 release)
|
||||
- Full V4 feature support will come with the official V4 release
|
||||
|
||||
|
||||
### V4.5 Support (Curated Preview)
|
||||
|
||||
Support has been added for **NAI Diffusion 4.5 Curated Preview**, an updated version of V4 with further improvements in detail, contrast, and prompt responsiveness.
|
||||
|
||||
- **Model Name:**
|
||||
```python
|
||||
model = "nai-diffusion-4-5-curated-preview"
|
||||
```
|
||||
|
||||
- **Availability:**
|
||||
Selectable through `ModelOptionNAID` node under the name **NAI Diffusion 4.5 Curated Preview**.
|
||||
|
||||
- **Compatibility Notes:**
|
||||
- Works the same as V4 preview, with the same limitations:
|
||||
- Inpainting will still default to V4 backend
|
||||
- Vibe transfer is not yet supported
|
||||
- Prompt formatting remains the same as for V4 (`V4BasePrompt` and `V4NegativePrompt` nodes are compatible)
|
||||
|
||||
|
||||
#### New Model Option
|
||||
|
||||
NAI Diffusion V4 Curated Preview is now available in the ModelOptionNAID node:
|
||||
|
||||
```python
|
||||
model = "nai-diffusion-4-curated-preview"
|
||||
```
|
||||
The nodes fully support NAI's V4 and V4.5 model architecture.
|
||||
|
||||
#### V4 Prompt Handling
|
||||
|
||||
Two new nodes have been added for V4 prompt handling:
|
||||
Two new nodes have been added in `NovelAI/v4` for V4/V4.5 prompt handling:
|
||||
|
||||
##### V4BasePrompt
|
||||
- **`V4BasePrompt`**: Handles the positive prompt.
|
||||
- **`V4NegativePrompt`**: Handles the negative prompt.
|
||||
|
||||
A node for handling V4 positive prompts:
|
||||
#### Example V4 / V4.5 Workflow
|
||||
|
||||
Here's a basic setup for a V4/V4.5 model:
|
||||
|
||||
```
|
||||
V4BasePrompt -----> positive
|
||||
GenerateNAID
|
||||
```
|
||||
|
||||
##### V4NegativePrompt
|
||||
|
||||
A node for handling V4 negative prompts:
|
||||
|
||||
```
|
||||
GenerateNAID
|
||||
V4NegativePrompt -> negative
|
||||
GenerateNAID
|
||||
```
|
||||
|
||||
#### Example V4 Workflow
|
||||
|
||||
Here's a basic V4 setup:
|
||||
|
||||
```
|
||||
V4BasePrompt -----> positive
|
||||
V4NegativePrompt -> negative GenerateNAID
|
||||
ModelOption ------> option
|
||||
```
|
||||
|
||||
#### Work In Progress Features
|
||||
|
||||
The following V4 features are currently in development:
|
||||
|
||||
```python
|
||||
"""
|
||||
- V4PromptConfig: Advanced prompt configuration
|
||||
- Coordinate-based prompting
|
||||
- Order-based prompting
|
||||
- V4CharacterCaption: Character-specific prompting with positioning
|
||||
"""
|
||||
```
|
||||
|
||||
Note: Basic img2img functionality works with V4 preview. For inpainting, the node will automatically use V3 model but can still work on V4-generated images. Vibe transfer will be supported once V4 fully releases.
|
||||
**Note:** Basic `img2img`, `vibe transfer` and `inpainting` functionality works with V4/V4.5.
|
||||
|
||||
@@ -3,11 +3,93 @@ import io
|
||||
from pathlib import Path
|
||||
import folder_paths
|
||||
import zipfile
|
||||
import json as _json
|
||||
import copy as _copy
|
||||
|
||||
from .utils import *
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
|
||||
TOOLTIP_LIMIT_OPUS_FREE = "Limit image size and steps for free generation by Opus."
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper utilities
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Accepted canvas sizes (per CR guidance); we will letterbox/pad to one of these
|
||||
ACCEPTED_CR_SIZES = [(1024, 1536), (1536, 1024), (1472, 1472)]
|
||||
|
||||
def _get_user_data(access_token, timeout=120, retry=3):
|
||||
"""Fetches user data to check Anlas balance. Now a global helper."""
|
||||
USER_API_BASE_URL = "https://api.novelai.net"
|
||||
|
||||
req_mod = requests
|
||||
if retry is not None and retry > 1:
|
||||
retries = Retry(
|
||||
total=retry,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "POST"]
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
req_mod = session
|
||||
|
||||
response = req_mod.get(
|
||||
f"{USER_API_BASE_URL}/user/data",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _choose_cr_canvas(w, h):
|
||||
"""Select the accepted CR canvas size whose aspect ratio is closest to the source image."""
|
||||
aspect = w / h
|
||||
best = None
|
||||
best_diff = 9e9
|
||||
for cw, ch in ACCEPTED_CR_SIZES:
|
||||
diff = abs((cw / ch) - aspect)
|
||||
if diff < best_diff:
|
||||
best_diff = diff
|
||||
best = (cw, ch)
|
||||
return best
|
||||
|
||||
def pad_image_to_canvas(tensor_image, target_size):
|
||||
"""
|
||||
Letterbox the given tensor image [1,H,W,C] into target_size (W,H) with black padding,
|
||||
preserving aspect ratio.
|
||||
"""
|
||||
_, H, W, C = tensor_image.shape
|
||||
tw, th = target_size
|
||||
arr = (tensor_image[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
|
||||
mode = "RGBA" if (C == 4) else "RGB"
|
||||
pil = PILImage.fromarray(arr)
|
||||
|
||||
scale = min(tw / W, th / H)
|
||||
new_w = max(1, int(W * scale))
|
||||
new_h = max(1, int(H * scale))
|
||||
pil_resized = pil.resize((new_w, new_h), PILImage.LANCZOS)
|
||||
|
||||
if mode == "RGBA":
|
||||
canvas = PILImage.new("RGBA", (tw, th), (0, 0, 0, 0))
|
||||
else:
|
||||
canvas = PILImage.new("RGB", (tw, th), (0, 0, 0))
|
||||
offset = ((tw - new_w) // 2, (th - new_h) // 2)
|
||||
canvas.paste(pil_resized, offset)
|
||||
|
||||
out = np.array(canvas).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(out)[None,]
|
||||
|
||||
# -------------------------------------------------
|
||||
# Core simple prompt conversion / utility nodes
|
||||
# -------------------------------------------------
|
||||
|
||||
class PromptToNAID:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
@@ -41,7 +123,15 @@ class ModelOption:
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"model": (["nai-diffusion-2", "nai-diffusion-furry-3", "nai-diffusion-3", "nai-diffusion-4-curated-preview", "nai-diffusion-4-full", "nai-diffusion-4-5-curated", "nai-diffusion-4-5-full"], { "default": "nai-diffusion-4-5-full" }),
|
||||
"model": ([
|
||||
"nai-diffusion-2",
|
||||
"nai-diffusion-furry-3",
|
||||
"nai-diffusion-3",
|
||||
"nai-diffusion-4-curated-preview",
|
||||
"nai-diffusion-4-full",
|
||||
"nai-diffusion-4-5-curated",
|
||||
"nai-diffusion-4-5-full"
|
||||
], { "default": "nai-diffusion-4-5-full" }),
|
||||
},
|
||||
"optional": { "option": ("NAID_OPTION",) },
|
||||
}
|
||||
@@ -110,7 +200,6 @@ class VibeTransferOption:
|
||||
option = copy.deepcopy(option) if option else {}
|
||||
if "vibe" not in option:
|
||||
option["vibe"] = []
|
||||
|
||||
option["vibe"].append((image, information_extracted, strength))
|
||||
return (option,)
|
||||
|
||||
@@ -135,6 +224,39 @@ class NetworkOption:
|
||||
option["retry"] = retry
|
||||
return (option,)
|
||||
|
||||
# -------------------------------------------------
|
||||
# Character Reference (Single Image)
|
||||
# -------------------------------------------------
|
||||
|
||||
class CharacterReferenceOption:
|
||||
INFO_EXTRACT_DEFAULT = 1.0
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"style_aware": ("BOOLEAN", {"default": True, "tooltip": "Copy style along with identity."}),
|
||||
"fidelity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "number", "tooltip": "How strictly to match the character (and style if enabled)."}),
|
||||
},
|
||||
"optional": {"option": ("NAID_OPTION",),}
|
||||
}
|
||||
RETURN_TYPES = ("NAID_OPTION",)
|
||||
FUNCTION = "set_option"
|
||||
CATEGORY = "NovelAI"
|
||||
def set_option(self, image, style_aware, fidelity, option=None):
|
||||
option = copy.deepcopy(option) if option else {}
|
||||
fidelity = max(0.0, min(1.0, fidelity))
|
||||
option["character_reference_single"] = {
|
||||
"image": image,
|
||||
"style_aware": style_aware,
|
||||
"fidelity": fidelity,
|
||||
"info_extracted": self.INFO_EXTRACT_DEFAULT,
|
||||
}
|
||||
return (option,)
|
||||
|
||||
# -------------------------------------------------
|
||||
# Generation Node
|
||||
# -------------------------------------------------
|
||||
|
||||
class GenerateNAID:
|
||||
def __init__(self):
|
||||
@@ -169,55 +291,55 @@ class GenerateNAID:
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "NovelAI"
|
||||
|
||||
def generate(self, limit_opus_free, width, height, positive, negative, steps, cfg, decrisper, variety, smea, sampler, scheduler, seed, uncond_scale, cfg_rescale, keep_alpha, option=None):
|
||||
width, height = calculate_resolution(width*height, (width, height))
|
||||
@staticmethod
|
||||
def _post_image(access_token, prompt, model, action, parameters, timeout=None, retry=None):
|
||||
data = {"input": prompt, "model": model, "action": action, "parameters": parameters}
|
||||
|
||||
req_mod = requests
|
||||
if retry is not None and retry > 1:
|
||||
retries = Retry(total=retry, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["POST"])
|
||||
session = requests.Session()
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
req_mod = session
|
||||
|
||||
response = req_mod.post(f"{BASE_URL}/ai/generate-image", json=data, headers={"Authorization": f"Bearer {access_token}"}, timeout=timeout)
|
||||
|
||||
if response.status_code >= 400:
|
||||
print("RAW ERROR STATUS:", response.status_code)
|
||||
print("RAW ERROR BODY:", response.text)
|
||||
try:
|
||||
dbg = _copy.deepcopy(data)
|
||||
p = dbg.get("parameters", {})
|
||||
if "director_reference_images" in p: p["director_reference_images"] = [i[:60] + "...(trunc)" for i in p["director_reference_images"]]
|
||||
if "reference_image_multiple" in p: p["reference_image_multiple"] = [i[:60] + "...(trunc)" for i in p["reference_image_multiple"]]
|
||||
dbg["parameters"] = p
|
||||
print("OUTGOING PAYLOAD (sanitized):", _json.dumps(dbg)[:2000])
|
||||
except Exception as e:
|
||||
print("Payload debug failed:", e)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def generate(self, limit_opus_free, width, height, positive, negative,
|
||||
steps, cfg, decrisper, variety, smea, sampler, scheduler,
|
||||
seed, uncond_scale, cfg_rescale, keep_alpha, option=None):
|
||||
|
||||
width, height = calculate_resolution(width * height, (width, height))
|
||||
|
||||
# ref. novelai_api.ImagePreset
|
||||
params = {
|
||||
"params_version": 1,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"scale": cfg,
|
||||
"sampler": sampler,
|
||||
"steps": steps,
|
||||
"seed": seed,
|
||||
"n_samples": 1,
|
||||
"ucPreset": 3,
|
||||
"qualityToggle": False,
|
||||
"params_version": 1, "width": width, "height": height, "scale": cfg, "sampler": sampler, "steps": steps,
|
||||
"seed": seed, "n_samples": 1, "ucPreset": 3, "qualityToggle": False,
|
||||
"sm": (smea == "SMEA" or smea == "SMEA+DYN") and sampler != "ddim",
|
||||
"sm_dyn": smea == "SMEA+DYN" and sampler != "ddim",
|
||||
"dynamic_thresholding": decrisper,
|
||||
"skip_cfg_above_sigma": None,
|
||||
"controlnet_strength": 1.0,
|
||||
"legacy": False,
|
||||
"add_original_image": False,
|
||||
"cfg_rescale": cfg_rescale,
|
||||
"noise_schedule": scheduler,
|
||||
"legacy_v3_extend": False,
|
||||
"uncond_scale": uncond_scale,
|
||||
"negative_prompt": negative,
|
||||
"prompt": positive,
|
||||
"reference_image_multiple": [],
|
||||
"reference_information_extracted_multiple": [],
|
||||
"reference_strength_multiple": [],
|
||||
"sm_dyn": (smea == "SMEA+DYN") and sampler != "ddim",
|
||||
"dynamic_thresholding": decrisper, "controlnet_strength": 1.0, "legacy": False, "add_original_image": False,
|
||||
"cfg_rescale": cfg_rescale, "noise_schedule": scheduler, "legacy_v3_extend": False,
|
||||
"uncond_scale": uncond_scale, "negative_prompt": negative, "prompt": positive,
|
||||
"reference_image_multiple": [], "reference_information_extracted_multiple": [], "reference_strength_multiple": [],
|
||||
"extra_noise_seed": seed,
|
||||
"v4_prompt": {
|
||||
"use_coords": False,
|
||||
"use_order": False,
|
||||
"caption": {
|
||||
"base_caption": positive,
|
||||
"char_captions": []
|
||||
}
|
||||
},
|
||||
"v4_negative_prompt": {
|
||||
"use_coords": False,
|
||||
"use_order": False,
|
||||
"caption": {
|
||||
"base_caption": negative,
|
||||
"char_captions": []
|
||||
}
|
||||
}
|
||||
"v4_prompt": {"use_coords": False, "use_order": False, "caption": {"base_caption": positive, "char_captions": []}},
|
||||
"v4_negative_prompt": {"use_coords": False, "use_order": False, "caption": {"base_caption": negative, "char_captions": []}}
|
||||
}
|
||||
|
||||
model = "nai-diffusion-4-5-full"
|
||||
action = "generate"
|
||||
|
||||
@@ -241,106 +363,117 @@ class GenerateNAID:
|
||||
|
||||
if "vibe" in option:
|
||||
for vibe in option["vibe"]:
|
||||
image, information_extracted, strength = vibe
|
||||
params["reference_image_multiple"].append(image_to_base64(resize_image(image, (width, height))))
|
||||
vimg, information_extracted, strength = vibe
|
||||
params["reference_image_multiple"].append(image_to_base64(resize_image(vimg, (width, height))))
|
||||
params["reference_information_extracted_multiple"].append(information_extracted)
|
||||
params["reference_strength_multiple"].append(strength)
|
||||
|
||||
if "model" in option:
|
||||
model = option["model"]
|
||||
if "model" in option: model = option["model"]
|
||||
if "v4_prompt" in option: params["v4_prompt"].update(option["v4_prompt"])
|
||||
|
||||
# Handle V4 options
|
||||
if "v4_prompt" in option:
|
||||
params["v4_prompt"].update(option["v4_prompt"])
|
||||
if "character_reference_single" in option:
|
||||
ref = option["character_reference_single"]
|
||||
base_caption = "character&style" if ref["style_aware"] else "character"
|
||||
ref_img = ref["image"]
|
||||
_, h_raw, w_raw, _ = ref_img.shape
|
||||
canvas_w, canvas_h = _choose_cr_canvas(w_raw, h_raw)
|
||||
padded = pad_image_to_canvas(ref_img, (canvas_w, canvas_h))
|
||||
params["director_reference_images"] = [image_to_base64(padded)]
|
||||
params["director_reference_descriptions"] = [{"use_coords": False, "use_order": False, "legacy_uc": False, "caption": {"base_caption": base_caption, "char_captions": []}}]
|
||||
params["director_reference_strength_values"] = [1.0]
|
||||
params["director_reference_secondary_strength_values"] = [1.0 - ref["fidelity"]]
|
||||
params["director_reference_information_extracted"] = [1.0]
|
||||
|
||||
timeout = option["timeout"] if option and "timeout" in option else None
|
||||
retry = option["retry"] if option and "retry" in option else None
|
||||
timeout = option.get("timeout", 120) if option else 120
|
||||
retry = option.get("retry", 3) if option else 3
|
||||
|
||||
if limit_opus_free:
|
||||
pixel_limit = 1024*1024
|
||||
pixel_limit = 1024 * 1024
|
||||
if width * height > pixel_limit:
|
||||
max_width, max_height = calculate_resolution(pixel_limit, (width, height))
|
||||
params["width"] = max_width
|
||||
params["height"] = max_height
|
||||
if steps > 28:
|
||||
params["steps"] = 28
|
||||
params["width"], params["height"] = calculate_resolution(pixel_limit, (width, height))
|
||||
if steps > 28: params["steps"] = 28
|
||||
|
||||
if variety:
|
||||
params["skip_cfg_above_sigma"] = calculate_skip_cfg_above_sigma(params["width"], params["height"])
|
||||
|
||||
if sampler == "ddim" and model not in ("nai-diffusion-2"):
|
||||
params["sampler"] = "ddim_v3"
|
||||
|
||||
if action == "infill" and model not in ("nai-diffusion-2"):
|
||||
model = f"{model}-inpainting"
|
||||
if variety: params["skip_cfg_above_sigma"] = calculate_skip_cfg_above_sigma(params["width"], params["height"])
|
||||
if sampler == "ddim" and "nai-diffusion-2" not in model: params["sampler"] = "ddim_v3"
|
||||
if action == "infill" and "nai-diffusion-2" not in model: model = f"{model}-inpainting"
|
||||
|
||||
start_anlas = None
|
||||
try:
|
||||
user_data = _get_user_data(self.access_token, timeout, retry)
|
||||
start_anlas = user_data.get("subscription", {}).get("trainingStepsLeft")
|
||||
if start_anlas is not None: print(f"[NovelAI] Anlas (pre-gen): {start_anlas}")
|
||||
except Exception as e: print(f"[NovelAI] Anlas tracking failed (pre-gen): {e}")
|
||||
|
||||
image = blank_image()
|
||||
try:
|
||||
zipped_bytes = generate_image(self.access_token, positive, model, action, params, timeout, retry)
|
||||
zipped = zipfile.ZipFile(io.BytesIO(zipped_bytes))
|
||||
image_bytes = zipped.read(zipped.infolist()[0]) # only support one n_samples
|
||||
zipped_bytes = self._post_image(self.access_token, positive, model, action, params, timeout, retry)
|
||||
with zipfile.ZipFile(io.BytesIO(zipped_bytes)) as zipped:
|
||||
image_bytes = zipped.read(zipped.infolist()[0])
|
||||
|
||||
## save original png to comfy output dir
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path("NAI_autosave", self.output_dir)
|
||||
full_output_folder, filename, counter, _, _ = folder_paths.get_save_image_path("NAI_autosave", self.output_dir)
|
||||
file = f"{filename}_{counter:05}_.png"
|
||||
d = Path(full_output_folder)
|
||||
d.mkdir(exist_ok=True)
|
||||
(d / file).write_bytes(image_bytes)
|
||||
|
||||
if start_anlas is not None:
|
||||
try:
|
||||
user_data_final = _get_user_data(self.access_token, timeout, retry)
|
||||
final_anlas = user_data_final.get("subscription", {}).get("trainingStepsLeft")
|
||||
if final_anlas is not None:
|
||||
print(f"[NovelAI] Generation cost: {start_anlas - final_anlas} Anlas")
|
||||
print(f"[NovelAI] Anlas (post-gen): {final_anlas}")
|
||||
except Exception as e: print(f"[NovelAI] Anlas tracking failed (post-gen): {e}")
|
||||
|
||||
image = bytes_to_image(image_bytes, keep_alpha)
|
||||
except Exception as e:
|
||||
if "ignore_errors" in option and option["ignore_errors"]:
|
||||
print("ignore error:", e)
|
||||
else:
|
||||
raise e
|
||||
if option and option.get("ignore_errors", False): print("ignore error:", e)
|
||||
else: raise e
|
||||
|
||||
return (image,)
|
||||
|
||||
# -------------------------------------------------
|
||||
# Director Tool Augment Nodes
|
||||
# -------------------------------------------------
|
||||
|
||||
def base_augment(access_token, output_dir, limit_opus_free, ignore_errors, req_type, image, options=None):
|
||||
image = image.movedim(-1, 1)
|
||||
w, h = (image.shape[3], image.shape[2])
|
||||
image = image.movedim(1, -1)
|
||||
|
||||
if limit_opus_free:
|
||||
pixel_limit = 1024 * 1024
|
||||
if w * h > pixel_limit:
|
||||
w, h = calculate_resolution(pixel_limit, (w, h))
|
||||
w, h = image.shape[2], image.shape[1]
|
||||
if limit_opus_free and w * h > 1024 * 1024:
|
||||
w, h = calculate_resolution(1024 * 1024, (w, h))
|
||||
|
||||
start_anlas = None
|
||||
try:
|
||||
user_data = _get_user_data(access_token)
|
||||
start_anlas = user_data.get("subscription", {}).get("trainingStepsLeft")
|
||||
if start_anlas is not None: print(f"[NovelAI] Anlas (pre-augment): {start_anlas}")
|
||||
except Exception as e: print(f"[NovelAI] Anlas tracking failed (pre-augment): {e}")
|
||||
|
||||
base64_image = image_to_base64(resize_image(image, (w, h)))
|
||||
result_image = blank_image()
|
||||
try:
|
||||
# Build request based on NAI v4 API spec
|
||||
request = {
|
||||
"image": base64_image,
|
||||
"req_type": req_type,
|
||||
"width": w,
|
||||
"height": h
|
||||
}
|
||||
|
||||
# Add optional parameters if provided
|
||||
if options:
|
||||
if "defry" in options:
|
||||
request["defry"] = options["defry"]
|
||||
if "prompt" in options:
|
||||
request["prompt"] = options["prompt"]
|
||||
|
||||
zipped_bytes = augment_image(access_token, req_type, w, h, base64_image, options=options)
|
||||
zipped = zipfile.ZipFile(io.BytesIO(zipped_bytes))
|
||||
image_bytes = zipped.read(zipped.infolist()[0]) # only support one n_samples
|
||||
with zipfile.ZipFile(io.BytesIO(zipped_bytes)) as zipped:
|
||||
image_bytes = zipped.read(zipped.infolist()[0])
|
||||
|
||||
## save original png to comfy output dir
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path("NAI_autosave", output_dir)
|
||||
full_output_folder, filename, counter, _, _ = folder_paths.get_save_image_path("NAI_autosave", output_dir)
|
||||
file = f"{filename}_{counter:05}_.png"
|
||||
d = Path(full_output_folder)
|
||||
d.mkdir(exist_ok=True)
|
||||
(d / file).write_bytes(image_bytes)
|
||||
|
||||
if start_anlas is not None:
|
||||
try:
|
||||
user_data_final = _get_user_data(access_token)
|
||||
final_anlas = user_data_final.get("subscription", {}).get("trainingStepsLeft")
|
||||
if final_anlas is not None:
|
||||
print(f"[NovelAI] Augment cost: {start_anlas - final_anlas} Anlas")
|
||||
print(f"[NovelAI] Anlas (post-augment): {final_anlas}")
|
||||
except Exception as e: print(f"[NovelAI] Anlas tracking failed (post-augment): {e}")
|
||||
|
||||
result_image = bytes_to_image(image_bytes)
|
||||
except Exception as e:
|
||||
if ignore_errors:
|
||||
print("ignore error:", e)
|
||||
else:
|
||||
raise e
|
||||
if ignore_errors: print("ignore error:", e)
|
||||
else: raise e
|
||||
|
||||
return (result_image,)
|
||||
|
||||
@@ -350,13 +483,7 @@ class RemoveBGAugment:
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }),
|
||||
"ignore_errors": ("BOOLEAN", { "default": False }),
|
||||
},
|
||||
}
|
||||
return {"required": {"image": ("IMAGE",), "limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }), "ignore_errors": ("BOOLEAN", { "default": False }),}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "augment"
|
||||
CATEGORY = "NovelAI/director_tools"
|
||||
@@ -369,13 +496,7 @@ class LineArtAugment:
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }),
|
||||
"ignore_errors": ("BOOLEAN", { "default": False }),
|
||||
},
|
||||
}
|
||||
return {"required": {"image": ("IMAGE",), "limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }), "ignore_errors": ("BOOLEAN", { "default": False }),}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "augment"
|
||||
CATEGORY = "NovelAI/director_tools"
|
||||
@@ -388,13 +509,7 @@ class SketchAugment:
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }),
|
||||
"ignore_errors": ("BOOLEAN", { "default": False }),
|
||||
},
|
||||
}
|
||||
return {"required": {"image": ("IMAGE",), "limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }), "ignore_errors": ("BOOLEAN", { "default": False }),}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "augment"
|
||||
CATEGORY = "NovelAI/director_tools"
|
||||
@@ -407,15 +522,7 @@ class ColorizeAugment:
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }),
|
||||
"ignore_errors": ("BOOLEAN", { "default": False }),
|
||||
"defry": ("INT", { "default": 0, "min": 0, "max": 5, "step": 1, "display": "number" }),
|
||||
"prompt": ("STRING", { "default": "", "multiline": True, "dynamicPrompts": False }),
|
||||
},
|
||||
}
|
||||
return {"required": {"image": ("IMAGE",), "limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }), "ignore_errors": ("BOOLEAN", { "default": False }), "defry": ("INT", { "default": 0, "min": 0, "max": 5, "step": 1, "display": "number" }), "prompt": ("STRING", { "default": "", "multiline": True, "dynamicPrompts": False }),}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "augment"
|
||||
CATEGORY = "NovelAI/director_tools"
|
||||
@@ -426,24 +533,10 @@ class EmotionAugment:
|
||||
def __init__(self):
|
||||
self.access_token = get_access_token()
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
|
||||
strength_list = ["normal", "slightly_weak", "weak", "even_weaker", "very_weak", "weakest"]
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }),
|
||||
"ignore_errors": ("BOOLEAN", { "default": False }),
|
||||
"mood": (["neutral", "happy", "sad", "angry", "scared",
|
||||
"surprised", "tired", "excited", "nervous", "thinking",
|
||||
"confused", "shy", "disgusted", "smug", "bored",
|
||||
"laughing", "irritated", "aroused", "embarrassed", "worried",
|
||||
"love", "determined", "hurt", "playful"], { "default": "neutral" }),
|
||||
"strength": (s.strength_list, { "default": "normal" }),
|
||||
"prompt": ("STRING", { "default": "", "multiline": True, "dynamicPrompts": False }),
|
||||
},
|
||||
}
|
||||
return {"required": {"image": ("IMAGE",), "limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }), "ignore_errors": ("BOOLEAN", { "default": False }), "mood": (["neutral", "happy", "sad", "angry", "scared", "surprised", "tired", "excited", "nervous", "thinking", "confused", "shy", "disgusted", "smug", "bored", "laughing", "irritated", "aroused", "embarrassed", "worried", "love", "determined", "hurt", "playful"], { "default": "neutral" }), "strength": (s.strength_list, { "default": "normal" }), "prompt": ("STRING", { "default": "", "multiline": True, "dynamicPrompts": False }),}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "augment"
|
||||
CATEGORY = "NovelAI/director_tools"
|
||||
@@ -458,103 +551,72 @@ class DeclutterAugment:
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }),
|
||||
"ignore_errors": ("BOOLEAN", { "default": False }),
|
||||
},
|
||||
}
|
||||
return {"required": {"image": ("IMAGE",), "limit_opus_free": ("BOOLEAN", { "default": True, "tooltip": TOOLTIP_LIMIT_OPUS_FREE }), "ignore_errors": ("BOOLEAN", { "default": False }),}}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "augment"
|
||||
CATEGORY = "NovelAI/director_tools"
|
||||
def augment(self, image, limit_opus_free, ignore_errors):
|
||||
return base_augment(self.access_token, self.output_dir, limit_opus_free, ignore_errors, "declutter", image)
|
||||
|
||||
# -------------------------------------------------
|
||||
# Anlas Tracker (Visual Node)
|
||||
# -------------------------------------------------
|
||||
|
||||
class AnlasTrackerNAID:
|
||||
def __init__(self):
|
||||
self.access_token = get_access_token()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": { "trigger": ("*",) } # Allows chaining to control execution order
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "STRING",)
|
||||
RETURN_NAMES = ("anlas_int", "anlas_string",)
|
||||
FUNCTION = "get_anlas"
|
||||
CATEGORY = "NovelAI/utils"
|
||||
|
||||
def get_anlas(self, trigger=None):
|
||||
anlas_count = 0
|
||||
try:
|
||||
user_data = _get_user_data(self.access_token)
|
||||
anlas_count = user_data.get("subscription", {}).get("trainingStepsLeft", 0)
|
||||
print(f"[NovelAI] Current Anlas Balance: {anlas_count}")
|
||||
except Exception as e:
|
||||
print(f"[NovelAI] Failed to fetch Anlas balance: {e}")
|
||||
return (0, "Error fetching Anlas")
|
||||
|
||||
return (anlas_count, f"{anlas_count} Anlas")
|
||||
|
||||
# -------------------------------------------------
|
||||
# V4 Base / Negative Prompt nodes
|
||||
# -------------------------------------------------
|
||||
|
||||
class V4BasePrompt:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"base_caption": ("STRING", { "multiline": True }),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",) # Changed from NAID_OPTION to STRING
|
||||
FUNCTION = "convert" # Changed from set_option to convert
|
||||
return {"required": {"base_caption": ("STRING", { "multiline": True }),}}
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "convert"
|
||||
CATEGORY = "NovelAI/v4"
|
||||
def convert(self, base_caption):
|
||||
return (base_caption,) # Simply returns the caption as a string
|
||||
return (base_caption,)
|
||||
|
||||
"""class V4PromptConfig:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"use_coords": ("BOOLEAN", { "default": False }),
|
||||
"use_order": ("BOOLEAN", { "default": False }),
|
||||
},
|
||||
"optional": { "option": ("NAID_OPTION",) },
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("NAID_OPTION",)
|
||||
FUNCTION = "set_option"
|
||||
CATEGORY = "NovelAI/v4"
|
||||
def set_option(self, use_coords, use_order, option=None):
|
||||
option = copy.deepcopy(option) if option else {}
|
||||
if "v4_prompt" not in option:
|
||||
option["v4_prompt"] = {}
|
||||
option["v4_prompt"]["use_coords"] = use_coords
|
||||
option["v4_prompt"]["use_order"] = use_order
|
||||
return (option,)
|
||||
|
||||
class V4CharacterCaption:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"char_caption": ("STRING", { "multiline": True }),
|
||||
"x": ("FLOAT", { "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01 }),
|
||||
"y": ("FLOAT", { "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01 }),
|
||||
},
|
||||
"optional": { "option": ("NAID_OPTION",) },
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("NAID_OPTION",)
|
||||
FUNCTION = "set_option"
|
||||
CATEGORY = "NovelAI/v4"
|
||||
def set_option(self, char_caption, x, y, option=None):
|
||||
option = copy.deepcopy(option) if option else {}
|
||||
if "v4_prompt" not in option:
|
||||
option["v4_prompt"] = {
|
||||
"caption": {
|
||||
"base_caption": "",
|
||||
"char_captions": []
|
||||
}
|
||||
}
|
||||
|
||||
char_caption_obj = {
|
||||
"char_caption": char_caption,
|
||||
"centers": [{"x": x, "y": y}]
|
||||
}
|
||||
|
||||
option["v4_prompt"]["caption"]["char_captions"].append(char_caption_obj)
|
||||
return (option,)
|
||||
"""
|
||||
class V4NegativePrompt:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"negative_caption": ("STRING", { "multiline": True }),
|
||||
}
|
||||
}
|
||||
|
||||
return {"required": {"negative_caption": ("STRING", { "multiline": True }),}}
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "convert"
|
||||
CATEGORY = "NovelAI/v4"
|
||||
def convert(self, negative_caption):
|
||||
return (negative_caption,)
|
||||
|
||||
# -------------------------------------------------
|
||||
# Registration
|
||||
# -------------------------------------------------
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"GenerateNAID": GenerateNAID,
|
||||
@@ -563,6 +625,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
"InpaintingOptionNAID": InpaintingOption,
|
||||
"VibeTransferOptionNAID": VibeTransferOption,
|
||||
"NetworkOptionNAID": NetworkOption,
|
||||
"CharacterReferenceOptionNAID": CharacterReferenceOption,
|
||||
"AnlasTrackerNAID": AnlasTrackerNAID, # New node
|
||||
"MaskImageToNAID": ImageToNAIMask,
|
||||
"PromptToNAID": PromptToNAID,
|
||||
"RemoveBGNAID": RemoveBGAugment,
|
||||
@@ -582,6 +646,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"InpaintingOptionNAID": "InpaintingOption ✒️🅝🅐🅘",
|
||||
"VibeTransferOptionNAID": "VibeTransferOption ✒️🅝🅐🅘",
|
||||
"NetworkOptionNAID": "NetworkOption ✒️🅝🅐🅘",
|
||||
"CharacterReferenceOptionNAID": "Character Reference ✒️🅝🅐🅘",
|
||||
"AnlasTrackerNAID": "Anlas Tracker ✒️🅝🅐🅘", # New node
|
||||
"MaskImageToNAID": "Convert Mask Image ✒️🅝🅐🅘",
|
||||
"PromptToNAID": "Convert Prompt ✒️🅝🅐🅘",
|
||||
"RemoveBGNAID": "Remove BG ✒️🅝🅐🅘",
|
||||
|
||||
Reference in New Issue
Block a user