diff --git a/README.md b/README.md index 4c10255..473f5c4 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/custom_nodes/floyo_flux2_api_node/__init__.py b/custom_nodes/floyo_flux2_api_node/__init__.py new file mode 100644 index 0000000..0ecb2e9 --- /dev/null +++ b/custom_nodes/floyo_flux2_api_node/__init__.py @@ -0,0 +1,3 @@ +from .flux2_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/custom_nodes/floyo_flux2_api_node/config.ini b/custom_nodes/floyo_flux2_api_node/config.ini new file mode 100644 index 0000000..bad0bed --- /dev/null +++ b/custom_nodes/floyo_flux2_api_node/config.ini @@ -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 diff --git a/custom_nodes/floyo_flux2_api_node/flux2_node.py b/custom_nodes/floyo_flux2_api_node/flux2_node.py new file mode 100644 index 0000000..2c49066 --- /dev/null +++ b/custom_nodes/floyo_flux2_api_node/flux2_node.py @@ -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) + + 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"}