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:
Çağan Kiraz
2026-05-25 17:50:35 +03:00
parent ba3a4964db
commit 42654d6e56
8 changed files with 313 additions and 34 deletions
+21
View File
@@ -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. |
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+240
View File
@@ -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
View File
@@ -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
View File
@@ -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