Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,79 @@
# ComfyUI-Floyo-Flux2-API-node
# Floyo FLUX.2 API Custom Node

A Floyo-ready ComfyUI custom node for the Black Forest Labs FLUX.2 API. The node mirrors Floyo's URL-first I/O pattern, supports both **flux-2-pro** and **flux-2-flex** variants, and polls the API's `polling_url` until an image URL is returned.

## Features
- URL/Base64 inputs for the base image plus up to eight additional reference images.
- Model selector for `flux-2-pro` (fast, production) or `flux-2-flex` (adjustable steps & guidance).
- Optional sizing, seed, safety tolerance, and output format controls.
- Flex-only parameters: guidance and steps.
- Configurable polling interval and timeout aligned with BFL recommendations.

## Installation
1. Copy the `custom_nodes/floyo_flux2_api_node` folder into your ComfyUI `custom_nodes` directory (keep the `__init__.py` + `flux2_node.py` pairing intact so the node is auto-discovered).
2. Ensure `requests` is available in your Python environment.
3. Provide your Black Forest Labs API key via either:
- Environment: `export BFL_API_KEY=your_key`
- Config file: update `custom_nodes/floyo_flux2_api_node/config.ini` (`[auth] api_key`)

## Configuration
`config.ini` lets you set defaults without changing the node code:

```ini
[auth]
api_key = YOUR_BFL_API_KEY

[api]
base_url = https://api.bfl.ai/v1
model = flux-2-pro
poll_interval = 0.5
max_wait_seconds = 120
```

> The node falls back to `BFL_API_KEY` if it is set. The placeholder value must be replaced.

## Node Inputs
- **prompt** (string, required): Edit or generation prompt.
- **input_image** (string, required): URL or base64 image to edit; also works for text-to-image prompts.
- **input_image_2 ... input_image_9** (string, optional): Additional reference images.
- **model** (`flux-2-pro` \| `flux-2-flex`): Selects the target endpoint.
- **width / height** (int, optional): Multiples of 16; defaults to matching input.
- **seed** (int, optional): `-1` for random.
- **safety_tolerance** (0-6): Moderation strictness.
- **output_format** (`jpeg` \| `png`).
- **guidance**, **steps**: Only applied when model = `flux-2-flex`.
- **poll_interval**, **max_wait_seconds**: Override the default polling cadence.

## Output
- **image_url**: The signed URL returned by the FLUX.2 API (`result.sample`). Download and re-serve the image within 10 minutes per BFL delivery rules.

## Workflow Tips
- Keep inputs as URLs to align with Floyo's storage and queueing behavior.
- Multi-reference editing: supply additional `input_image_*` fields to mix styles and content.
- For flex runs, tweak `guidance` (1.5-10) and `steps` (<=50) for quality/performance tradeoffs.
- Respect BFL rate limits and poll using the provided `polling_url` rather than a hardcoded endpoint.

## Example (Python)
```python
import os
import requests

api_key = os.environ["BFL_API_KEY"]
resp = requests.post(
"https://api.bfl.ai/v1/flux-2-pro",
headers={"x-key": api_key, "accept": "application/json"},
json={
"prompt": "Cinematic city sunset, 85mm lens",
"input_image": "https://example.com/source.jpg",
"output_format": "jpeg",
},
).json()

polling_url = resp["polling_url"]
result = requests.get(polling_url, headers={"x-key": api_key}).json()
print(result)
```

## Notes
- Delivery URLs expire after ~10 minutes and are not CORS-enabled; download and serve from your own storage.
- For regional routing, adjust `base_url` to `https://api.eu.bfl.ai/v1` or `https://api.us.bfl.ai/v1` in `config.ini`.
3 changes: 3 additions & 0 deletions custom_nodes/floyo_flux2_api_node/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .flux2_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS

__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
8 changes: 8 additions & 0 deletions custom_nodes/floyo_flux2_api_node/config.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[auth]
api_key = YOUR_BFL_API_KEY

[api]
base_url = https://api.bfl.ai/v1
model = flux-2-pro
poll_interval = 0.5
max_wait_seconds = 120
181 changes: 181 additions & 0 deletions custom_nodes/floyo_flux2_api_node/flux2_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import configparser
import os
import time
from pathlib import Path
from typing import Any, Dict

import requests

CONFIG_PATH = Path(__file__).parent / "config.ini"
DEFAULT_BASE_URL = "https://api.bfl.ai/v1"
DEFAULT_MODEL = "flux-2-pro"


class FloyoFlux2APINode:
"""
ComfyUI custom node that calls Black Forest Labs FLUX.2 API endpoints via URLs.

Inputs are URL/base64 strings to align with Floyo's storage model. The node polls
the `polling_url` returned by the API until the result is ready or a timeout occurs.
"""

RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("image_url",)
FUNCTION = "generate"
CATEGORY = "Floyo/API"

@classmethod
def INPUT_TYPES(cls):
required = {
"prompt": ("STRING", {"multiline": True, "default": "Describe the image you want"}),
"input_image": ("STRING", {"default": "https://example.com/your-image.jpg"}),
}

optional_images = {
f"input_image_{i}": ("STRING", {"default": ""}) for i in range(2, 10)
}

optional = {
**optional_images,
"model": ("STRING", {"default": DEFAULT_MODEL, "choices": ["flux-2-pro", "flux-2-flex"]}),
"width": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 16}),
"height": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 16}),
"seed": ("INT", {"default": -1, "min": -1, "max": 2**31 - 1}),
"safety_tolerance": ("INT", {"default": 2, "min": 0, "max": 6}),
"output_format": ("STRING", {"default": "jpeg", "choices": ["jpeg", "png"]}),
"guidance": ("FLOAT", {"default": 4.5, "min": 1.5, "max": 10.0, "step": 0.1}),
"steps": ("INT", {"default": 50, "min": 1, "max": 50}),
"poll_interval": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 5.0, "step": 0.1}),
"max_wait_seconds": ("INT", {"default": 120, "min": 5, "max": 600}),
}

return {"required": required, "optional": optional}

@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time()

def generate(
self,
prompt: str,
input_image: str,
model: str = DEFAULT_MODEL,
width: int = 0,
height: int = 0,
seed: int = -1,
safety_tolerance: int = 2,
output_format: str = "jpeg",
guidance: float = 4.5,
steps: int = 50,
poll_interval: float = 0.5,
max_wait_seconds: int = 120,
**kwargs: Any,
):
api_key, base_url, model, poll_interval, max_wait_seconds = self._load_config(
model, poll_interval, max_wait_seconds
)

payload: Dict[str, Any] = {
"prompt": prompt,
"input_image": input_image,
"safety_tolerance": safety_tolerance,
"output_format": output_format,
}

for key, value in kwargs.items():
if key.startswith("input_image_") and isinstance(value, str) and value.strip():
payload[key] = value

if width > 0:
payload["width"] = width
if height > 0:
payload["height"] = height
if seed >= 0:
payload["seed"] = seed

if model == "flux-2-flex":
payload["guidance"] = guidance
payload["steps"] = steps

endpoint = f"{base_url.rstrip('/')}/flux-2-{model.split('-')[-1]}"

response = requests.post(
endpoint,
headers={
"accept": "application/json",
"Content-Type": "application/json",
"x-key": api_key,
},
json=payload,
timeout=30,
)
response.raise_for_status()
data = response.json()

polling_url = data.get("polling_url")
if not polling_url:
raise RuntimeError("API response missing polling_url.")

image_url = self._poll_for_result(
polling_url=polling_url,
api_key=api_key,
poll_interval=poll_interval,
max_wait_seconds=max_wait_seconds,
)

return (image_url,)

def _load_config(self, model: str, poll_interval: float, max_wait_seconds: int):
config = configparser.ConfigParser()
if CONFIG_PATH.exists():
config.read(CONFIG_PATH)

api_key = os.getenv("BFL_API_KEY") or config.get("auth", "api_key", fallback=None)
if not api_key or "YOUR_BFL_API_KEY" in api_key:
raise RuntimeError("BFL API key not configured. Set BFL_API_KEY env or update config.ini.")

base_url = config.get("api", "base_url", fallback=DEFAULT_BASE_URL)
configured_model = config.get("api", "model", fallback=model)
effective_model = configured_model if configured_model in {"flux-2-pro", "flux-2-flex"} else model

configured_interval = config.getfloat("api", "poll_interval", fallback=poll_interval)
configured_timeout = config.getint("api", "max_wait_seconds", fallback=max_wait_seconds)
Comment on lines +138 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow UI overrides when config file is present

The node inputs for model, poll_interval, and max_wait_seconds are ignored whenever custom_nodes/floyo_flux2_api_node/config.ini exists, because _load_config always replaces the runtime arguments with the config values. Since the repo ships with that file populated, selecting flux-2-flex or changing polling cadence in the UI has no effect unless users manually edit the config file, which defeats the documented purpose of those inputs. Please treat config entries as defaults and honor explicit node selections.

Useful? React with 👍 / 👎.


return api_key, base_url, effective_model, configured_interval, configured_timeout

def _poll_for_result(
self,
polling_url: str,
api_key: str,
poll_interval: float,
max_wait_seconds: int,
) -> str:
start_time = time.time()
while True:
result = requests.get(
polling_url,
headers={"accept": "application/json", "x-key": api_key},
timeout=30,
)
result.raise_for_status()
payload = result.json()
status = payload.get("status")

if status == "Ready":
result_obj = payload.get("result", {})
image_url = result_obj.get("sample")
if not image_url:
raise RuntimeError("Result missing sample URL.")
return image_url

if status in {"Error", "Failed", "Content Moderated", "Request Moderated"}:
raise RuntimeError(f"Generation failed with status: {status} - {payload}")

if time.time() - start_time > max_wait_seconds:
raise TimeoutError("Polling timed out before result was ready.")

time.sleep(poll_interval)


NODE_CLASS_MAPPINGS = {"FloyoFlux2APINode": FloyoFlux2APINode}
NODE_DISPLAY_NAME_MAPPINGS = {"FloyoFlux2APINode": "Floyo FLUX.2 API"}