feat: add flux erase and outpaint tool nodes
adds FluxErase (POST flux-tools/erase-v1) and FluxOutpaint (POST flux-tools/outpainting-v1) in a new nodes/flux_tools.py module. these wrap the two new BFL tool endpoints from May 2026. also: - ImageToBase64_BFL gains an image_format dropdown (jpeg / png) so masks can be encoded losslessly - BaseFlux.generate_image moves check_multiple_of_32 inside the try/except so non-multiple-of-32 dims return a blank image instead of crashing the workflow - bump version to 1.1.0; start CHANGELOG.md fresh at 1.1.0
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project from v1.1.0 onward are documented in this file. Earlier history lives in `git log`.
|
||||
|
||||
## [1.1.0] — 2026-05-25
|
||||
|
||||
### Added
|
||||
|
||||
| Node / Feature | Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| Flux Erase (BFL) | `POST /v1/flux-tools/erase-v1` | Object removal via base64 image + binary mask. White pixels in the mask are erased; black pixels are kept. Knobs: `dilate_pixels` (0–25, default 10), `safety_tolerance` (0–5), `output_format` (png / jpeg), `seed`, `webhook_url`, `webhook_secret`. |
|
||||
| Flux Outpaint (BFL) | `POST /v1/flux-tools/outpainting-v1` | Image extension to a target canvas. Knobs: `width` / `height` (≥64, step 32), `center_reference` (toggle), `reference_offset_x` / `reference_offset_y`, optional `prompt`, `auto_crop`, `output_format` (png / jpeg). |
|
||||
| `image_format` on Image to Base64 (BFL) | — | New optional dropdown: `jpeg` (default, backward-compatible) or `png` (lossless, recommended for masks fed into Flux Erase / Flux Pro Fill). |
|
||||
|
||||
### Fixed
|
||||
|
||||
| Issue | Detail |
|
||||
|---|---|
|
||||
| Workflow crash on non-multiple-of-32 width/height | `BaseFlux.generate_image` now catches `check_multiple_of_32`'s `ValueError` inside its try/except and returns a blank image, matching the rest of the graceful-failure path. |
|
||||
| `FluxErase` `image` / `mask` defaulted to `None` | Defaults are now `""` — prevents JSON-null serialization when the socket is left unwired. Consistent with `FluxOutpaint`'s `input_image` default. |
|
||||
| Whitespace-only prompts on Flux Outpaint | Prompts that contain only whitespace are no longer forwarded — `if prompt and prompt.strip():` strips the no-op case. |
|
||||
@@ -46,6 +46,8 @@ You can either use `config.ini` for a global API key, or connect a **Flux Config
|
||||
| Flux Dev (BFL) | Text-to-image with Flux Dev |
|
||||
| Flux Pro Fill (BFL) | Inpainting / outpainting |
|
||||
| Flux Pro Expand (BFL) | Outpainting with directional padding |
|
||||
| Flux Erase (BFL) | Object removal via binary mask (`flux-tools/erase-v1`) |
|
||||
| Flux Outpaint (BFL) | Image extension via target canvas + reference offsets (`flux-tools/outpainting-v1`) |
|
||||
| Flux Kontext Pro (BFL) | Image editing with context (up to 4 images) |
|
||||
| Flux Kontext Max (BFL) | Image editing with context, max quality |
|
||||
| Flux 2 Max (BFL) | Flux 2 Max generation |
|
||||
@@ -75,12 +77,16 @@ You can either use `config.ini` for a global API key, or connect a **Flux Config
|
||||
### Utils
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| Image to Base64 (BFL) | Convert a ComfyUI IMAGE to a base64 data URI for use as image input |
|
||||
| Image to Base64 (BFL) | Convert a ComfyUI IMAGE to base64 — choose `jpeg` (default) or `png` (lossless, recommended for masks) |
|
||||
|
||||
## Workflow
|
||||
|
||||
Example workflows are available in the `workflows` folder.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Contributors
|
||||
|
||||
- [@pleberer](https://github.com/pleberer)
|
||||
|
||||
+7
-6
@@ -1,11 +1,12 @@
|
||||
import importlib.util
|
||||
import importlib
|
||||
import importlib.util
|
||||
|
||||
node_list = [
|
||||
"api_node",
|
||||
"finetune",
|
||||
"config_node",
|
||||
"utils",
|
||||
"api_node",
|
||||
"finetune",
|
||||
"flux_tools",
|
||||
"config_node",
|
||||
"utils",
|
||||
]
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
@@ -20,4 +21,4 @@ for module_name in node_list:
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
+26
-16
@@ -1,16 +1,17 @@
|
||||
import requests
|
||||
from PIL import Image
|
||||
import io
|
||||
import numpy as np
|
||||
import torch
|
||||
import os
|
||||
import time
|
||||
from .status import Status
|
||||
from .config import config_loader
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from .config_node import get_config_loader
|
||||
from .status import Status
|
||||
|
||||
REQUEST_TIMEOUT = 300 # seconds for connect + read
|
||||
|
||||
|
||||
class BaseFlux:
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate_image"
|
||||
@@ -18,7 +19,7 @@ class BaseFlux:
|
||||
|
||||
def process_result(self, result, output_format="jpeg"):
|
||||
try:
|
||||
sample_url = result['result']['sample']
|
||||
sample_url = result["result"]["sample"]
|
||||
img_response = requests.get(sample_url)
|
||||
img = Image.open(io.BytesIO(img_response.content))
|
||||
|
||||
@@ -38,7 +39,7 @@ class BaseFlux:
|
||||
return self.create_blank_image()
|
||||
|
||||
def create_blank_image(self):
|
||||
blank_img = Image.new('RGB', (512, 512), color='black')
|
||||
blank_img = Image.new("RGB", (512, 512), color="black")
|
||||
img_array = np.array(blank_img).astype(np.float32) / 255.0
|
||||
img_tensor = torch.from_numpy(img_array)[None,]
|
||||
return (img_tensor,)
|
||||
@@ -90,7 +91,10 @@ class BaseFlux:
|
||||
print(f"[BFL] Poll response: {result_response.status_code}")
|
||||
|
||||
if result_response.status_code != 200:
|
||||
print(f"[BFL] HTTP error on attempt {attempt}/{max_attempts}: {result_response.status_code}, {result_response.text}")
|
||||
print(
|
||||
f"[BFL] HTTP error on attempt {attempt}/{max_attempts}: "
|
||||
f"{result_response.status_code}, {result_response.text}"
|
||||
)
|
||||
attempt += 1
|
||||
if attempt <= max_attempts:
|
||||
time.sleep(5)
|
||||
@@ -129,22 +133,28 @@ class BaseFlux:
|
||||
time.sleep(5)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[BFL] All {max_attempts} attempts exhausted for task {task_id} after {elapsed:.1f}s — returning blank image.")
|
||||
print(
|
||||
f"[BFL] All {max_attempts} attempts exhausted for task {task_id} "
|
||||
f"after {elapsed:.1f}s — returning blank image."
|
||||
)
|
||||
return self.create_blank_image()
|
||||
|
||||
def generate_image(self, url_path, arguments, config_override=None):
|
||||
if "width" in arguments and "height" in arguments:
|
||||
self.check_multiple_of_32(arguments["width"], arguments["height"])
|
||||
|
||||
try:
|
||||
if "width" in arguments and "height" in arguments:
|
||||
self.check_multiple_of_32(arguments["width"], arguments["height"])
|
||||
|
||||
task_id = self.post_request(url_path, arguments, config_override)
|
||||
if task_id:
|
||||
print(f"Task ID '{task_id}'")
|
||||
return self.get_result(task_id, output_format=arguments.get("output_format", "jpeg"), config_override=config_override)
|
||||
return self.get_result(
|
||||
task_id, output_format=arguments.get("output_format", "jpeg"), config_override=config_override
|
||||
)
|
||||
return self.create_blank_image()
|
||||
except Exception as e:
|
||||
print(f"Error generating image: {str(e)}")
|
||||
return self.create_blank_image()
|
||||
|
||||
|
||||
class BaseFinetuneFlux(BaseFlux):
|
||||
CATEGORY = "BFL/Finetune"
|
||||
CATEGORY = "BFL/Finetune"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
from .base import BaseFlux
|
||||
|
||||
|
||||
class FluxErase(BaseFlux):
|
||||
CATEGORY = "BFL"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": (
|
||||
"STRING",
|
||||
{"default": "", "tooltip": "Input image (base64-encoded string)."},
|
||||
),
|
||||
"mask": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Black/white mask (base64-encoded string). White (255) = remove, black (0) = keep. "
|
||||
"Must match the input image dimensions."
|
||||
),
|
||||
},
|
||||
),
|
||||
"dilate_pixels": (
|
||||
"INT",
|
||||
{
|
||||
"default": 10,
|
||||
"min": 0,
|
||||
"max": 25,
|
||||
"tooltip": (
|
||||
"Pixels to dilate the mask before removal. Range 0-25, default 10. "
|
||||
"Helps the model fully cover object edges."
|
||||
),
|
||||
},
|
||||
),
|
||||
"safety_tolerance": (
|
||||
"INT",
|
||||
{
|
||||
"default": 2,
|
||||
"min": 0,
|
||||
"max": 5,
|
||||
"tooltip": (
|
||||
"Tolerance level for input and output moderation. "
|
||||
"Between 0 and 5, 0 being most strict, 5 being least strict."
|
||||
),
|
||||
},
|
||||
),
|
||||
"output_format": (
|
||||
["png", "jpeg"],
|
||||
{"default": "png", "tooltip": "png (default) or jpeg."},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"seed": (
|
||||
"INT",
|
||||
{"default": -1, "tooltip": "Optional seed for reproducibility. -1 = random."},
|
||||
),
|
||||
"webhook_url": (
|
||||
"STRING",
|
||||
{"default": "", "tooltip": "URL to receive webhook notifications."},
|
||||
),
|
||||
"webhook_secret": (
|
||||
"STRING",
|
||||
{"default": "", "tooltip": "Optional secret for webhook signature verification."},
|
||||
),
|
||||
"config": (
|
||||
"BFL_CONFIG",
|
||||
{"tooltip": "Optional Flux Config (BFL) override for x-key, base URL, and region."},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def generate_image(
|
||||
self,
|
||||
image,
|
||||
mask,
|
||||
dilate_pixels,
|
||||
safety_tolerance,
|
||||
output_format,
|
||||
seed=-1,
|
||||
webhook_url="",
|
||||
webhook_secret="",
|
||||
config=None,
|
||||
):
|
||||
arguments = {
|
||||
"image": image,
|
||||
"mask": mask,
|
||||
"dilate_pixels": dilate_pixels,
|
||||
"safety_tolerance": safety_tolerance,
|
||||
"output_format": output_format,
|
||||
}
|
||||
if seed != -1:
|
||||
arguments["seed"] = seed
|
||||
if webhook_url:
|
||||
arguments["webhook_url"] = webhook_url
|
||||
if webhook_secret:
|
||||
arguments["webhook_secret"] = webhook_secret
|
||||
return super().generate_image("flux-tools/erase-v1", arguments, config)
|
||||
|
||||
|
||||
class FluxOutpaint(BaseFlux):
|
||||
CATEGORY = "BFL"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_image": (
|
||||
"STRING",
|
||||
{"default": "", "tooltip": "Reference image to expand (base64-encoded string)."},
|
||||
),
|
||||
"width": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 64,
|
||||
"max": 4096,
|
||||
"step": 32,
|
||||
"tooltip": "Target canvas width in pixels (>=64).",
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 64,
|
||||
"max": 4096,
|
||||
"step": 32,
|
||||
"tooltip": "Target canvas height in pixels (>=64).",
|
||||
},
|
||||
),
|
||||
"output_format": (
|
||||
["png", "jpeg"],
|
||||
{"default": "png", "tooltip": "png (default) or jpeg."},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": (
|
||||
"Experimental: optional text guidance for the outpainted region. "
|
||||
"The model may not strictly follow this prompt; the visual content of the input image "
|
||||
"is the primary signal. Leave unset for default behavior."
|
||||
),
|
||||
},
|
||||
),
|
||||
"center_reference": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"When True (default), the server centers the reference image on the canvas "
|
||||
"(reference_offset_x/y are omitted from the request). "
|
||||
"When False, the reference_offset_x/y values below are sent explicitly."
|
||||
),
|
||||
},
|
||||
),
|
||||
"reference_offset_x": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": -8192,
|
||||
"max": 8192,
|
||||
"tooltip": (
|
||||
"Left offset (px) of the reference image's top-left corner on the canvas. "
|
||||
"Negative values allowed. None = center horizontally."
|
||||
),
|
||||
},
|
||||
),
|
||||
"reference_offset_y": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": -8192,
|
||||
"max": 8192,
|
||||
"tooltip": (
|
||||
"Top offset (px) of the reference image's top-left corner on the canvas. "
|
||||
"Negative values allowed. None = center vertically."
|
||||
),
|
||||
},
|
||||
),
|
||||
"auto_crop": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"If true, crop the reference image to the canvas bounds when it extends beyond the edges. "
|
||||
"Defaults to false (out-of-bounds placements return 422)."
|
||||
),
|
||||
},
|
||||
),
|
||||
"config": (
|
||||
"BFL_CONFIG",
|
||||
{"tooltip": "Optional Flux Config (BFL) override for x-key, base URL, and region."},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def generate_image(
|
||||
self,
|
||||
input_image,
|
||||
width,
|
||||
height,
|
||||
output_format,
|
||||
prompt="",
|
||||
center_reference=True,
|
||||
reference_offset_x=0,
|
||||
reference_offset_y=0,
|
||||
auto_crop=False,
|
||||
config=None,
|
||||
):
|
||||
arguments = {
|
||||
"input_image": input_image,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"output_format": output_format,
|
||||
}
|
||||
if not center_reference:
|
||||
arguments["reference_offset_x"] = reference_offset_x
|
||||
arguments["reference_offset_y"] = reference_offset_y
|
||||
if prompt and prompt.strip():
|
||||
arguments["prompt"] = prompt
|
||||
if auto_crop:
|
||||
arguments["auto_crop"] = auto_crop
|
||||
return super().generate_image("flux-tools/outpainting-v1", arguments, config)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FluxErase_BFL": FluxErase,
|
||||
"FluxOutpaint_BFL": FluxOutpaint,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FluxErase_BFL": "Flux Erase (BFL)",
|
||||
"FluxOutpaint_BFL": "Flux Outpaint (BFL)",
|
||||
}
|
||||
+10
-10
@@ -1,5 +1,6 @@
|
||||
import io
|
||||
import base64
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
@@ -10,31 +11,30 @@ class ImageToBase64:
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"image_format": (["jpeg", "png"], {"default": "jpeg"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "convert"
|
||||
CATEGORY = "BFL/Utils"
|
||||
|
||||
def convert(self, image):
|
||||
def convert(self, image, image_format="jpeg"):
|
||||
img_array = (image[0].numpy() * 255).astype(np.uint8)
|
||||
pil_image = Image.fromarray(img_array)
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
pil_image.save(buffer, format="JPEG")
|
||||
pil_image.save(buffer, format=image_format.upper())
|
||||
|
||||
b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
|
||||
return (b64,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ImageToBase64_BFL": ImageToBase64
|
||||
}
|
||||
NODE_CLASS_MAPPINGS = {"ImageToBase64_BFL": ImageToBase64}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ImageToBase64_BFL": "Image to Base64 (BFL)"
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"ImageToBase64_BFL": "Image to Base64 (BFL)"}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-flux-bfl-api"
|
||||
description = "Custom nodes for integrating Flux models with the BFL API."
|
||||
version = "1.0.13"
|
||||
version = "1.1.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["torch"]
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user