934 lines
35 KiB
Python
934 lines
35 KiB
Python
import asyncio
|
|
import math
|
|
import os
|
|
import re
|
|
import time
|
|
|
|
import torch
|
|
import requests
|
|
import base64
|
|
import numpy as np
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
|
|
FAL_RUN_URL_PREFIX = "https://fal.run/"
|
|
FAL_QUEUE_URL_PREFIX = "https://queue.fal.run/"
|
|
FAL_HTTP_TIMEOUT_SECONDS = 60
|
|
FAL_QUEUE_SUBMIT_TIMEOUT_SECONDS = 300
|
|
FAL_QUEUE_POLL_INTERVAL_SECONDS = 1.0
|
|
FAL_MAX_CONCURRENT_RUNS = 8
|
|
FAL_MAX_CONCURRENT_SUBMISSIONS = 4
|
|
FAL_QUEUE_SUBMIT_MAX_ATTEMPTS = 2
|
|
FAL_QUEUE_SUBMIT_RETRY_BASE_SECONDS = 2.0
|
|
FAL_RETRYABLE_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504}
|
|
|
|
_fal_submission_semaphore = None
|
|
_fal_submission_semaphore_loop = None
|
|
|
|
GPT_IMAGE_25_EDIT_URLS = {
|
|
"gpt_image_2_5_flare_edit": "https://fal.run/openai/gpt-image-2.5/flare/edit",
|
|
"gpt_image_2_5_sunburst_edit": "https://fal.run/openai/gpt-image-2.5/sunburst/edit",
|
|
}
|
|
|
|
GPT_IMAGE_EDIT_MODELS = {"gpt_image_2_edit", *GPT_IMAGE_25_EDIT_URLS}
|
|
|
|
GPT_RESOLUTION_MAX_EDGE = {
|
|
"0.5K": 512,
|
|
"1K": 1024,
|
|
"2K": 2048,
|
|
"4K": 3840,
|
|
}
|
|
|
|
GPT_MAX_OUTPUT_PIXELS = 8_294_400
|
|
GPT_MAX_ASPECT_RATIO = 3.0
|
|
|
|
GPT_IMAGE_SIZE_OPTIONS = [
|
|
"auto",
|
|
"square_hd",
|
|
"square",
|
|
"portrait_4_3",
|
|
"portrait_16_9",
|
|
"landscape_4_3",
|
|
"landscape_16_9",
|
|
"1920x1080",
|
|
"2560x1440",
|
|
"3840x2160",
|
|
]
|
|
|
|
GPT_IMAGE_SIZE_DIMENSIONS = {
|
|
"square_hd": {"width": 1024, "height": 1024},
|
|
"square": {"width": 512, "height": 512},
|
|
"portrait_4_3": {"width": 768, "height": 1024},
|
|
"portrait_16_9": {"width": 576, "height": 1024},
|
|
"landscape_4_3": {"width": 1024, "height": 768},
|
|
"landscape_16_9": {"width": 1024, "height": 576},
|
|
"1920x1080": {"width": 1920, "height": 1080},
|
|
"2560x1440": {"width": 2560, "height": 1440},
|
|
"3840x2160": {"width": 3840, "height": 2160},
|
|
}
|
|
|
|
SEEDREAM_5_AUTO_IMAGE_SIZE_BY_RESOLUTION = {
|
|
"seedream_5": {
|
|
"0.5K": "auto_1K",
|
|
"1K": "auto_1K",
|
|
"2K": "auto_2K",
|
|
"4K": "auto_2K",
|
|
},
|
|
"seedream_5_pro": {
|
|
"0.5K": "auto_1K",
|
|
"1K": "auto_1K",
|
|
"2K": "auto_2K",
|
|
"4K": "auto_2K",
|
|
},
|
|
"seedream_5_lite": {
|
|
"0.5K": "auto_2K",
|
|
"1K": "auto_2K",
|
|
"2K": "auto_2K",
|
|
"4K": "auto_4K",
|
|
},
|
|
}
|
|
|
|
SEEDREAM_5_RESOLUTION_PIXELS = {
|
|
"seedream_5": {
|
|
"0.5K": 1024 * 1024,
|
|
"1K": 1024 * 1024,
|
|
"2K": 2048 * 2048,
|
|
"4K": 2048 * 2048,
|
|
},
|
|
"seedream_5_pro": {
|
|
"0.5K": 1024 * 1024,
|
|
"1K": 1024 * 1024,
|
|
"2K": 2048 * 2048,
|
|
"4K": 2048 * 2048,
|
|
},
|
|
"seedream_5_lite": {
|
|
"0.5K": 2560 * 1440,
|
|
"1K": 2560 * 1440,
|
|
"2K": 2048 * 2048,
|
|
"4K": 4096 * 4096,
|
|
},
|
|
}
|
|
|
|
|
|
def _round_to_multiple(value, multiple=8):
|
|
return max(multiple, int(round(value / multiple)) * multiple)
|
|
|
|
|
|
def seedream_5_image_size(aspect_ratio, resolution, model="seedream_5"):
|
|
auto_sizes = SEEDREAM_5_AUTO_IMAGE_SIZE_BY_RESOLUTION.get(
|
|
model,
|
|
SEEDREAM_5_AUTO_IMAGE_SIZE_BY_RESOLUTION["seedream_5"],
|
|
)
|
|
resolution_pixels = SEEDREAM_5_RESOLUTION_PIXELS.get(
|
|
model,
|
|
SEEDREAM_5_RESOLUTION_PIXELS["seedream_5"],
|
|
)
|
|
|
|
if aspect_ratio == "auto":
|
|
return auto_sizes.get(resolution, auto_sizes["2K"])
|
|
|
|
width_ratio, height_ratio = [int(part) for part in aspect_ratio.split(":", 1)]
|
|
ratio = width_ratio / height_ratio
|
|
target_pixels = resolution_pixels.get(resolution, resolution_pixels["2K"])
|
|
|
|
width = (target_pixels * ratio) ** 0.5
|
|
height = target_pixels / width
|
|
|
|
max_dimension = 4096
|
|
if width > max_dimension:
|
|
scale = max_dimension / width
|
|
width *= scale
|
|
height *= scale
|
|
if height > max_dimension:
|
|
scale = max_dimension / height
|
|
width *= scale
|
|
height *= scale
|
|
|
|
return {
|
|
"width": _round_to_multiple(width),
|
|
"height": _round_to_multiple(height),
|
|
}
|
|
|
|
|
|
# Helper function to convert ComfyUI tensor (B=1, H, W, C) to PIL Image (RGB)
|
|
def tensor2pil(image_tensor):
|
|
if image_tensor is None or image_tensor.shape[0] == 0:
|
|
return None
|
|
i = 255. * image_tensor[0].cpu().numpy() # (H, W, C)
|
|
image = np.clip(i, 0, 255).astype(np.uint8)
|
|
|
|
c = image.shape[-1]
|
|
if c == 1:
|
|
image = np.repeat(image, 3, axis=-1)
|
|
elif c == 3:
|
|
pass
|
|
elif c == 4:
|
|
image = image[..., :3]
|
|
else:
|
|
raise ValueError(f"Unsupported channels: {c}. Expected 1, 3, or 4.")
|
|
|
|
return Image.fromarray(image, mode='RGB')
|
|
|
|
# Helper function to convert PIL Image (RGB) back to ComfyUI tensor (B=1, H, W, C)
|
|
def pil2tensor(pil_image):
|
|
if pil_image is None:
|
|
return None
|
|
arr = np.array(pil_image).astype(np.float32) / 255.0
|
|
arr = arr[np.newaxis, ...]
|
|
return torch.from_numpy(arr)
|
|
|
|
def tensor2data_uri(image_tensor):
|
|
pil_image = tensor2pil(image_tensor)
|
|
if pil_image is None:
|
|
return None
|
|
|
|
return pil2data_uri(pil_image)
|
|
|
|
def pil2data_uri(pil_image):
|
|
buffer = BytesIO()
|
|
pil_image.save(buffer, format="PNG")
|
|
img_str = base64.b64encode(buffer.getvalue()).decode()
|
|
return f"data:image/png;base64,{img_str}"
|
|
|
|
|
|
def _prepare_image_data_uris(images, resize_to=None):
|
|
data_uris = []
|
|
for image_tensor in images:
|
|
pil_image = tensor2pil(image_tensor)
|
|
if pil_image is None:
|
|
continue
|
|
if resize_to is not None:
|
|
pil_image = pil_image.resize(resize_to, Image.LANCZOS)
|
|
data_uris.append(pil2data_uri(pil_image))
|
|
return data_uris
|
|
|
|
|
|
def _error_image_tensor():
|
|
error_tensor = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
|
error_tensor[:, :, :, 0] = 0.75
|
|
return error_tensor
|
|
|
|
|
|
def _gpt_source_dimensions(image_tensor, width, height, image_size):
|
|
if width > 0 and height > 0:
|
|
return width, height
|
|
|
|
preset = GPT_IMAGE_SIZE_DIMENSIONS.get(image_size)
|
|
if preset is not None:
|
|
return preset["width"], preset["height"]
|
|
|
|
shape = image_tensor.shape
|
|
if len(shape) == 4:
|
|
return int(shape[2]), int(shape[1])
|
|
if len(shape) == 3:
|
|
return int(shape[1]), int(shape[0])
|
|
raise ValueError("GPT image input must use ComfyUI IMAGE tensor dimensions.")
|
|
|
|
|
|
def _resolve_gpt_image_size(image_tensor, resolution, width, height, image_size):
|
|
max_edge = GPT_RESOLUTION_MAX_EDGE.get(resolution)
|
|
if max_edge is None:
|
|
raise ValueError(f"Unsupported GPT resolution: {resolution!r}")
|
|
|
|
source_width, source_height = _gpt_source_dimensions(
|
|
image_tensor,
|
|
width,
|
|
height,
|
|
image_size,
|
|
)
|
|
if source_width <= 0 or source_height <= 0:
|
|
raise ValueError("GPT image dimensions must be greater than zero.")
|
|
|
|
is_landscape = source_width >= source_height
|
|
long_edge = max(source_width, source_height)
|
|
short_edge = min(source_width, source_height)
|
|
aspect_ratio = min(long_edge / short_edge, GPT_MAX_ASPECT_RATIO)
|
|
ideal_long_edge = min(
|
|
max_edge,
|
|
math.sqrt(GPT_MAX_OUTPUT_PIXELS * aspect_ratio),
|
|
)
|
|
ideal_short_edge = ideal_long_edge / aspect_ratio
|
|
|
|
long_edge_candidates = {
|
|
max(16, math.floor(ideal_long_edge / 16) * 16),
|
|
max(16, math.ceil(ideal_long_edge / 16) * 16),
|
|
}
|
|
short_edge_candidates = {
|
|
max(16, math.floor(ideal_short_edge / 16) * 16),
|
|
max(16, math.ceil(ideal_short_edge / 16) * 16),
|
|
}
|
|
valid_sizes = [
|
|
(candidate_long_edge, candidate_short_edge)
|
|
for candidate_long_edge in long_edge_candidates
|
|
for candidate_short_edge in short_edge_candidates
|
|
if candidate_long_edge <= max_edge
|
|
and candidate_short_edge <= max_edge
|
|
and candidate_long_edge * candidate_short_edge <= GPT_MAX_OUTPUT_PIXELS
|
|
and candidate_long_edge / candidate_short_edge <= GPT_MAX_ASPECT_RATIO
|
|
]
|
|
if not valid_sizes:
|
|
raise ValueError("Unable to calculate a valid GPT image size.")
|
|
|
|
output_long_edge, output_short_edge = max(
|
|
valid_sizes,
|
|
key=lambda size: (
|
|
size[0] * size[1],
|
|
-abs((size[0] / size[1]) - aspect_ratio),
|
|
),
|
|
)
|
|
|
|
if is_landscape:
|
|
output_width = output_long_edge
|
|
output_height = output_short_edge
|
|
else:
|
|
output_width = output_short_edge
|
|
output_height = output_long_edge
|
|
|
|
return {"width": output_width, "height": output_height}
|
|
|
|
|
|
def _safe_error_detail(detail):
|
|
detail = re.sub(
|
|
r"data:[^,\s\"']+,[A-Za-z0-9+/=]+",
|
|
"data:[redacted]",
|
|
str(detail or ""),
|
|
).strip()
|
|
if len(detail) > 1000:
|
|
return f"{detail[:1000]}..."
|
|
return detail
|
|
|
|
|
|
def _response_json(response, action):
|
|
if not 200 <= response.status_code < 300:
|
|
detail = _safe_error_detail(response.text)
|
|
suffix = f": {detail}" if detail else ""
|
|
raise ValueError(f"{action} failed with HTTP {response.status_code}{suffix}")
|
|
|
|
try:
|
|
return response.json()
|
|
except ValueError as error:
|
|
raise ValueError(f"{action} returned invalid JSON.") from error
|
|
|
|
|
|
def _queue_endpoint(run_url):
|
|
if not run_url.startswith(FAL_RUN_URL_PREFIX):
|
|
raise ValueError(f"Unsupported fal.ai endpoint: {run_url}")
|
|
return f"{FAL_QUEUE_URL_PREFIX}{run_url[len(FAL_RUN_URL_PREFIX):]}"
|
|
|
|
|
|
def _queue_operation_url(value, fallback):
|
|
if not value:
|
|
return fallback
|
|
if value.startswith("http://") or value.startswith("https://"):
|
|
return value
|
|
return f"{FAL_QUEUE_URL_PREFIX.rstrip('/')}/{value.lstrip('/')}"
|
|
|
|
|
|
def _get_fal_submission_semaphore():
|
|
global _fal_submission_semaphore
|
|
global _fal_submission_semaphore_loop
|
|
|
|
loop = asyncio.get_running_loop()
|
|
if _fal_submission_semaphore is None or _fal_submission_semaphore_loop is not loop:
|
|
_fal_submission_semaphore = asyncio.Semaphore(FAL_MAX_CONCURRENT_SUBMISSIONS)
|
|
_fal_submission_semaphore_loop = loop
|
|
return _fal_submission_semaphore
|
|
|
|
|
|
def _fal_retry_delay(response, attempt):
|
|
if response is not None:
|
|
retry_after = response.headers.get("Retry-After")
|
|
if retry_after:
|
|
try:
|
|
return min(max(float(retry_after), 0.0), 30.0)
|
|
except ValueError:
|
|
pass
|
|
return FAL_QUEUE_SUBMIT_RETRY_BASE_SECONDS * (2 ** (attempt - 1))
|
|
|
|
|
|
async def _post_fal_queue_request(queue_url, payload, headers, run_label):
|
|
for attempt in range(1, FAL_QUEUE_SUBMIT_MAX_ATTEMPTS + 1):
|
|
submit_response = None
|
|
try:
|
|
async with _get_fal_submission_semaphore():
|
|
submit_response = await asyncio.to_thread(
|
|
requests.post,
|
|
queue_url,
|
|
json=payload,
|
|
headers=headers,
|
|
timeout=FAL_QUEUE_SUBMIT_TIMEOUT_SECONDS,
|
|
)
|
|
except (requests.ConnectionError, requests.Timeout) as error:
|
|
if attempt >= FAL_QUEUE_SUBMIT_MAX_ATTEMPTS:
|
|
raise ValueError(
|
|
f"fal.ai queue submission failed after {attempt} attempts: {error}"
|
|
) from error
|
|
delay = _fal_retry_delay(None, attempt)
|
|
print(
|
|
f"NanoSeed: queue upload {run_label} failed; retrying in {delay:g}s "
|
|
f"(attempt {attempt + 1}/{FAL_QUEUE_SUBMIT_MAX_ATTEMPTS})."
|
|
)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
except requests.RequestException as error:
|
|
raise ValueError(f"fal.ai queue submission failed: {error}") from error
|
|
|
|
if (
|
|
submit_response.status_code in FAL_RETRYABLE_STATUS_CODES
|
|
and attempt < FAL_QUEUE_SUBMIT_MAX_ATTEMPTS
|
|
):
|
|
delay = _fal_retry_delay(submit_response, attempt)
|
|
submit_response.close()
|
|
print(
|
|
f"NanoSeed: fal.ai returned HTTP {submit_response.status_code} for "
|
|
f"queue upload {run_label}; retrying in {delay:g}s "
|
|
f"(attempt {attempt + 1}/{FAL_QUEUE_SUBMIT_MAX_ATTEMPTS})."
|
|
)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
|
|
return submit_response
|
|
|
|
raise ValueError("fal.ai queue submission exhausted its retry attempts.")
|
|
|
|
|
|
async def _cancel_queue_request(cancel_url, headers):
|
|
try:
|
|
await asyncio.to_thread(
|
|
requests.put,
|
|
cancel_url,
|
|
headers=headers,
|
|
timeout=10,
|
|
)
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
|
|
async def _submit_fal_queue_request(run_url, payload, headers, queue_timeout, run_index, run_count):
|
|
queue_url = _queue_endpoint(run_url)
|
|
run_label = f"{run_index + 1}/{run_count}"
|
|
submit_response = await _post_fal_queue_request(
|
|
queue_url,
|
|
payload,
|
|
headers,
|
|
run_label,
|
|
)
|
|
|
|
submit_data = _response_json(submit_response, "fal.ai queue submission")
|
|
request_id = submit_data.get("request_id")
|
|
if not request_id:
|
|
raise ValueError("fal.ai queue submission returned no request_id.")
|
|
|
|
request_base_url = f"{queue_url}/requests/{request_id}"
|
|
status_url = _queue_operation_url(
|
|
submit_data.get("status_url"),
|
|
f"{request_base_url}/status",
|
|
)
|
|
response_url = _queue_operation_url(
|
|
submit_data.get("response_url"),
|
|
request_base_url,
|
|
)
|
|
cancel_url = _queue_operation_url(
|
|
submit_data.get("cancel_url"),
|
|
f"{request_base_url}/cancel",
|
|
)
|
|
deadline = time.monotonic() + queue_timeout
|
|
print(f"NanoSeed: queued async run {run_label} ({request_id}).")
|
|
|
|
try:
|
|
while True:
|
|
if time.monotonic() >= deadline:
|
|
await _cancel_queue_request(cancel_url, headers)
|
|
raise TimeoutError(
|
|
f"fal.ai queue run {run_label} timed out after {queue_timeout} seconds "
|
|
f"(request_id: {request_id})."
|
|
)
|
|
|
|
try:
|
|
status_response = await asyncio.to_thread(
|
|
requests.get,
|
|
status_url,
|
|
headers=headers,
|
|
timeout=FAL_HTTP_TIMEOUT_SECONDS,
|
|
)
|
|
except requests.RequestException:
|
|
await asyncio.sleep(FAL_QUEUE_POLL_INTERVAL_SECONDS)
|
|
continue
|
|
|
|
if status_response.status_code in {408, 425, 429, 500, 502, 503, 504}:
|
|
await asyncio.sleep(FAL_QUEUE_POLL_INTERVAL_SECONDS)
|
|
continue
|
|
|
|
status_data = _response_json(status_response, "fal.ai queue status")
|
|
status = status_data.get("status")
|
|
if status == "COMPLETED":
|
|
error_message = _safe_error_detail(status_data.get("error"))
|
|
if error_message:
|
|
error_type = status_data.get("error_type")
|
|
error_suffix = f" ({error_type})" if error_type else ""
|
|
raise ValueError(
|
|
f"fal.ai queue run {run_label} failed{error_suffix}: {error_message}"
|
|
)
|
|
response_url = _queue_operation_url(
|
|
status_data.get("response_url"),
|
|
response_url,
|
|
)
|
|
break
|
|
if status in {"FAILED", "CANCELLED", "CANCELED"}:
|
|
error_message = _safe_error_detail(status_data.get("error")) or "Unknown queue error"
|
|
raise ValueError(f"fal.ai queue run {run_label} failed: {error_message}")
|
|
if status not in {"IN_QUEUE", "IN_PROGRESS"}:
|
|
raise ValueError(
|
|
f"fal.ai queue run {run_label} returned unknown status: {status!r}"
|
|
)
|
|
|
|
await asyncio.sleep(FAL_QUEUE_POLL_INTERVAL_SECONDS)
|
|
|
|
while True:
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(
|
|
f"fal.ai result retrieval timed out for run {run_label} "
|
|
f"(request_id: {request_id})."
|
|
)
|
|
|
|
try:
|
|
result_response = await asyncio.to_thread(
|
|
requests.get,
|
|
response_url,
|
|
headers=headers,
|
|
timeout=FAL_HTTP_TIMEOUT_SECONDS,
|
|
)
|
|
except requests.RequestException:
|
|
await asyncio.sleep(FAL_QUEUE_POLL_INTERVAL_SECONDS)
|
|
continue
|
|
|
|
if result_response.status_code in {202, 408, 425, 429, 500, 502, 503, 504}:
|
|
await asyncio.sleep(FAL_QUEUE_POLL_INTERVAL_SECONDS)
|
|
continue
|
|
|
|
result = _response_json(result_response, "fal.ai queue result")
|
|
if result.get("error") and not result.get("images"):
|
|
error_message = _safe_error_detail(result["error"])
|
|
raise ValueError(f"fal.ai queue run {run_label} failed: {error_message}")
|
|
print(f"NanoSeed: completed async run {run_label} ({request_id}).")
|
|
return result
|
|
except asyncio.CancelledError:
|
|
await asyncio.shield(_cancel_queue_request(cancel_url, headers))
|
|
raise
|
|
|
|
|
|
async def _run_fal_queue_requests(
|
|
run_url,
|
|
payload,
|
|
headers,
|
|
concurrent_runs,
|
|
queue_timeout,
|
|
allow_partial_results=False,
|
|
):
|
|
if not 1 <= concurrent_runs <= FAL_MAX_CONCURRENT_RUNS:
|
|
raise ValueError(
|
|
f"concurrent_runs must be between 1 and {FAL_MAX_CONCURRENT_RUNS}."
|
|
)
|
|
if queue_timeout < 1:
|
|
raise ValueError("queue_timeout must be at least 1 second.")
|
|
|
|
async def run_request(run_index):
|
|
run_payload = dict(payload)
|
|
run_payload["sync_mode"] = False
|
|
run_headers = dict(headers)
|
|
run_headers["X-Fal-Request-Timeout"] = str(queue_timeout)
|
|
if run_index and isinstance(run_payload.get("seed"), int):
|
|
run_payload["seed"] = (run_payload["seed"] + run_index) % (2**32)
|
|
return await _submit_fal_queue_request(
|
|
run_url,
|
|
run_payload,
|
|
run_headers,
|
|
queue_timeout,
|
|
run_index,
|
|
concurrent_runs,
|
|
)
|
|
|
|
ordered_results = await asyncio.gather(
|
|
*(run_request(run_index) for run_index in range(concurrent_runs)),
|
|
return_exceptions=True,
|
|
)
|
|
failures = [
|
|
(run_index, result)
|
|
for run_index, result in enumerate(ordered_results)
|
|
if isinstance(result, Exception)
|
|
]
|
|
|
|
if failures:
|
|
details = "; ".join(
|
|
f"run {run_index + 1}: {error}"
|
|
for run_index, error in sorted(failures)
|
|
)
|
|
if not allow_partial_results or len(failures) == concurrent_runs:
|
|
raise ValueError(
|
|
f"{len(failures)} of {concurrent_runs} fal.ai async runs failed: {details}"
|
|
)
|
|
print(
|
|
f"NanoSeed Warning: {len(failures)} of {concurrent_runs} fal.ai async "
|
|
f"runs failed; keeping successful outputs. {details}"
|
|
)
|
|
|
|
return [result for result in ordered_results if not isinstance(result, Exception)]
|
|
|
|
# Main node class
|
|
class NanoSeedEdit:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"prompt": ("STRING", {"default": "Edit the image according to this prompt.", "multiline": True}),
|
|
"model": (["nano_banana", "nano_banana_pro", "nano_banana_2", "gpt_image_2_edit", "gpt_image_2_5_flare_edit", "gpt_image_2_5_sunburst_edit", "grok_imagine_edit", "seedream_4.5", "seedream_5", "seedream_5_lite", "qwen_edit_plus", "flux_2_edit", "flux_2_pro", "flux_2_flex", "flux_2_klein_9b_edit"],),
|
|
"fal_key": ("STRING", {"default": "", "multiline": False}),
|
|
},
|
|
"optional": {
|
|
"image1": ("IMAGE",),
|
|
"image2": ("IMAGE",),
|
|
"image3": ("IMAGE",),
|
|
"image4": ("IMAGE",),
|
|
"image5": ("IMAGE",),
|
|
"image6": ("IMAGE",),
|
|
"image7": ("IMAGE",),
|
|
"image8": ("IMAGE",),
|
|
"image9": ("IMAGE",),
|
|
"image10": ("IMAGE",),
|
|
"mask": ("IMAGE",),
|
|
"width": ("INT", {"default": 0, "min": 0, "max": 4096, "display": "number"}),
|
|
"height": ("INT", {"default": 0, "min": 0, "max": 4096, "display": "number"}),
|
|
"num_images": ("INT", {"default": 1, "min": 1, "max": 10}),
|
|
"num_inference_steps": ("INT", {"default": 28, "min": 1, "max": 100}),
|
|
"seed": ("INT", {"default": 0, "min": 0, "max": 2**32 - 1}),
|
|
"aspect_ratio": (["auto", "21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16", "4:1", "1:4", "8:1", "1:8"], {"default": "auto"}),
|
|
"resolution": (["0.5K", "1K", "2K", "4K"], {"default": "1K"}),
|
|
"quality": (["auto", "low", "medium", "high", "xhigh", "max"], {"default": "high"}),
|
|
"enable_web_search": ("BOOLEAN", {"default": False}),
|
|
"thinking_level": (["off", "minimal", "high"], {"default": "off"}),
|
|
"concurrent_runs": ("INT", {"default": 1, "min": 1, "max": 8, "step": 1}),
|
|
"queue_timeout": ("INT", {"default": 900, "min": 60, "max": 3600, "step": 30}),
|
|
"gpt_background": (["auto", "transparent", "opaque"], {"default": "auto"}),
|
|
"gpt_image_size": (GPT_IMAGE_SIZE_OPTIONS, {"default": "auto"}),
|
|
"gpt_output_format": (["png", "jpeg", "webp"], {"default": "png"}),
|
|
"gpt_output_compression": ("INT", {"default": -1, "min": -1, "max": 100, "step": 1}),
|
|
"on_failure": (["continue", "raise"], {"default": "continue"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("edited_image",)
|
|
FUNCTION = "edit_image"
|
|
CATEGORY = "image/edit"
|
|
OUTPUT_NODE = True
|
|
|
|
async def edit_image(self, prompt, model, fal_key, image1=None, image2=None, image3=None, image4=None, image5=None,
|
|
image6=None, image7=None, image8=None, image9=None, image10=None, mask=None,
|
|
width=0, height=0, num_images=1, num_inference_steps=28, seed=0, aspect_ratio="auto", resolution="1K",
|
|
quality="high", enable_web_search=False, thinking_level="off", acceleration="none",
|
|
concurrent_runs=1, queue_timeout=900, gpt_background="auto", gpt_image_size="auto",
|
|
gpt_output_format="png", gpt_output_compression=-1,
|
|
on_failure="continue"): # Hardcoded acceleration kept for compatibility
|
|
env_fal_key = (os.environ.get("FAL_KEY") or "").strip()
|
|
ui_fal_key = (fal_key or "").strip()
|
|
if ui_fal_key == "your_fal_key_here":
|
|
ui_fal_key = ""
|
|
|
|
if env_fal_key:
|
|
fal_key = env_fal_key
|
|
if ui_fal_key and ui_fal_key != env_fal_key:
|
|
print("NanoSeed Info: Using FAL_KEY from environment (UI key ignored).")
|
|
elif ui_fal_key:
|
|
fal_key = ui_fal_key
|
|
else:
|
|
raise ValueError(
|
|
"Missing API key. Set FAL_KEY in your environment or provide a key in the node UI."
|
|
)
|
|
|
|
# Collect all non-None images
|
|
image_inputs = [image1, image2, image3, image4, image5, image6, image7, image8, image9, image10]
|
|
images = [img for img in image_inputs if img is not None]
|
|
if not images:
|
|
raise ValueError("At least one image input must be connected.")
|
|
|
|
resolved_gpt_image_size = None
|
|
if model in GPT_IMAGE_EDIT_MODELS:
|
|
resolved_gpt_image_size = _resolve_gpt_image_size(
|
|
images[0],
|
|
resolution,
|
|
width,
|
|
height,
|
|
gpt_image_size,
|
|
)
|
|
|
|
custom_size = (width > 0 and height > 0)
|
|
resize_to = None
|
|
if custom_size and model not in ["nano_banana", "nano_banana_pro", "nano_banana_2"]:
|
|
if resolved_gpt_image_size is not None:
|
|
resize_to = (
|
|
resolved_gpt_image_size["width"],
|
|
resolved_gpt_image_size["height"],
|
|
)
|
|
else:
|
|
resize_to = (width, height)
|
|
img_data_uris = await asyncio.to_thread(
|
|
_prepare_image_data_uris,
|
|
images,
|
|
resize_to,
|
|
)
|
|
|
|
# Enforce limits (Updated: Removed Flux 2 single image limit)
|
|
if model in ["seedream_4.5", "seedream_5", "seedream_5_pro", "seedream_5_lite"] and len(img_data_uris) + min(num_images, 6) > 15:
|
|
raise ValueError("Seedream: Total inputs + outputs must <=15.")
|
|
|
|
# Model-specific payloads
|
|
if model == "nano_banana":
|
|
url = "https://fal.run/fal-ai/nano-banana/edit"
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris,
|
|
"num_images": min(num_images, 4),
|
|
"aspect_ratio": aspect_ratio,
|
|
"output_format": "png",
|
|
"sync_mode": False,
|
|
}
|
|
elif model == "nano_banana_pro":
|
|
url = "https://fal.run/fal-ai/nano-banana-pro/edit"
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris,
|
|
"num_images": min(num_images, 4),
|
|
"aspect_ratio": aspect_ratio,
|
|
"resolution": resolution,
|
|
"output_format": "png",
|
|
"sync_mode": False,
|
|
}
|
|
elif model == "nano_banana_2":
|
|
url = "https://fal.run/fal-ai/nano-banana-2/edit"
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris,
|
|
"num_images": min(num_images, 4),
|
|
"seed": seed,
|
|
"aspect_ratio": aspect_ratio,
|
|
"resolution": resolution,
|
|
"output_format": "png",
|
|
"enable_web_search": enable_web_search,
|
|
"limit_generations": True,
|
|
"safety_tolerance": "6",
|
|
"sync_mode": False,
|
|
}
|
|
if thinking_level != "off":
|
|
payload["thinking_level"] = thinking_level
|
|
elif model == "gpt_image_2_edit":
|
|
if quality in {"xhigh", "max"}:
|
|
raise ValueError("GPT Image 2 quality supports auto, low, medium, or high.")
|
|
url = "https://fal.run/openai/gpt-image-2/edit"
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris,
|
|
"quality": quality,
|
|
"background": gpt_background,
|
|
"image_size": resolved_gpt_image_size,
|
|
"num_images": min(num_images, 4),
|
|
"output_format": gpt_output_format,
|
|
"sync_mode": False,
|
|
}
|
|
if mask is not None:
|
|
mask_data_uri = await asyncio.to_thread(tensor2data_uri, mask)
|
|
if mask_data_uri is not None:
|
|
payload["mask_url"] = mask_data_uri
|
|
elif model in GPT_IMAGE_25_EDIT_URLS:
|
|
url = GPT_IMAGE_25_EDIT_URLS[model]
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris,
|
|
"quality": quality,
|
|
"background": gpt_background,
|
|
"image_size": resolved_gpt_image_size,
|
|
"num_images": num_images,
|
|
"output_format": gpt_output_format,
|
|
"sync_mode": False,
|
|
}
|
|
if gpt_output_format in {"jpeg", "webp"} and gpt_output_compression >= 0:
|
|
payload["output_compression"] = gpt_output_compression
|
|
if mask is not None:
|
|
mask_data_uri = await asyncio.to_thread(tensor2data_uri, mask)
|
|
if mask_data_uri is not None:
|
|
payload["mask_url"] = mask_data_uri
|
|
elif model == "grok_imagine_edit":
|
|
url = "https://fal.run/xai/grok-imagine-image/edit"
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris[:3],
|
|
"num_images": min(num_images, 4),
|
|
"output_format": "png",
|
|
"sync_mode": False,
|
|
}
|
|
elif model == "seedream_4.5":
|
|
url = "https://fal.run/fal-ai/bytedance/seedream/v4.5/edit"
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris,
|
|
"num_images": min(num_images, 6),
|
|
"seed": seed,
|
|
"enable_safety_checker": False,
|
|
"sync_mode": False,
|
|
}
|
|
if custom_size:
|
|
if not (1920 <= width <= 4096 and 1920 <= height <= 4096):
|
|
raise ValueError("Seedream 4.5: Width/height must be 1920-4096px.")
|
|
area = width * height
|
|
if not (3686400 <= area <= 16777216):
|
|
raise ValueError(f"Seedream 4.5: Image area must be 3,686,400-16,777,216px. Got {area}.")
|
|
payload["image_size"] = {"width": width, "height": height}
|
|
elif model in ["seedream_5", "seedream_5_pro", "seedream_5_lite"]:
|
|
if model in ["seedream_5", "seedream_5_pro"]:
|
|
url = "https://fal.run/bytedance/seedream/v5/pro/edit"
|
|
else:
|
|
url = "https://fal.run/fal-ai/bytedance/seedream/v5/lite/edit"
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris[:10],
|
|
"image_size": seedream_5_image_size(aspect_ratio, resolution, model),
|
|
"num_images": min(num_images, 6),
|
|
"enable_safety_checker": False,
|
|
"sync_mode": False,
|
|
}
|
|
if model in ["seedream_5", "seedream_5_pro"]:
|
|
payload["output_format"] = "png"
|
|
else:
|
|
payload["max_images"] = 1
|
|
elif model == "qwen_edit_plus":
|
|
url = "https://fal.run/fal-ai/qwen-image-edit-plus"
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris,
|
|
"num_images": min(num_images, 4),
|
|
"seed": seed,
|
|
"guidance_scale": 4.0,
|
|
"num_inference_steps": num_inference_steps,
|
|
"enable_safety_checker": False,
|
|
"output_format": "png",
|
|
"sync_mode": False,
|
|
"acceleration": acceleration,
|
|
}
|
|
if custom_size:
|
|
payload["image_size"] = {"width": width, "height": height}
|
|
|
|
# Combined logic for Flux 2 Edit, Pro, Flex, and Klein 9B
|
|
elif model in ["flux_2_edit", "flux_2_pro", "flux_2_flex", "flux_2_klein_9b_edit"]:
|
|
if model == "flux_2_edit":
|
|
url = "https://fal.run/fal-ai/flux-2/edit"
|
|
elif model == "flux_2_pro":
|
|
url = "https://fal.run/fal-ai/flux-2-pro/edit"
|
|
elif model == "flux_2_flex":
|
|
url = "https://fal.run/fal-ai/flux-2-flex/edit"
|
|
elif model == "flux_2_klein_9b_edit":
|
|
url = "https://fal.run/fal-ai/flux-2/klein/9b/edit"
|
|
|
|
inference_steps = num_inference_steps
|
|
if model == "flux_2_klein_9b_edit":
|
|
inference_steps = min(max(num_inference_steps, 4), 8)
|
|
|
|
payload = {
|
|
"prompt": prompt,
|
|
"image_urls": img_data_uris[:4] if model == "flux_2_klein_9b_edit" else img_data_uris,
|
|
"num_images": min(num_images, 4),
|
|
"seed": seed,
|
|
"num_inference_steps": inference_steps,
|
|
"enable_safety_checker": False,
|
|
"output_format": "png",
|
|
"sync_mode": False,
|
|
}
|
|
if model != "flux_2_klein_9b_edit":
|
|
payload["guidance_scale"] = 2.5
|
|
payload["enable_prompt_expansion"] = False
|
|
payload["acceleration"] = acceleration
|
|
|
|
if custom_size:
|
|
# Standard validation for Flux Edit, relaxed for Pro/Flex as they might handle more
|
|
if model == "flux_2_edit":
|
|
if not (512 <= width <= 2048 and 512 <= height <= 2048):
|
|
raise ValueError("Flux 2 Edit: Size must be 512-2048px.")
|
|
|
|
payload["image_size"] = {"width": width, "height": height}
|
|
|
|
headers = {
|
|
"Authorization": f"Key {fal_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
try:
|
|
api_results = await _run_fal_queue_requests(
|
|
url,
|
|
payload,
|
|
headers,
|
|
concurrent_runs,
|
|
queue_timeout,
|
|
allow_partial_results=on_failure == "continue",
|
|
)
|
|
|
|
all_edited_tensors = []
|
|
for run_index, api_result in enumerate(api_results):
|
|
output_images = api_result.get("images") or []
|
|
if not output_images and api_result.get("image"):
|
|
output_images = [api_result["image"]]
|
|
if not output_images:
|
|
if on_failure == "raise":
|
|
raise ValueError(
|
|
f"No images returned from fal.ai async run {run_index + 1}."
|
|
)
|
|
print(
|
|
f"NanoSeed Warning: fal.ai async run {run_index + 1} returned "
|
|
"no images; keeping other successful outputs."
|
|
)
|
|
continue
|
|
|
|
for image_index, img_info in enumerate(output_images[:num_images]):
|
|
try:
|
|
img_data = img_info.get("data_uri") or img_info.get("url")
|
|
if not img_data:
|
|
raise ValueError("Generated image has no data URI or URL.")
|
|
|
|
if img_data.startswith("data:"):
|
|
_, encoded = img_data.split(",", 1)
|
|
pil_edited = Image.open(BytesIO(base64.b64decode(encoded)))
|
|
else:
|
|
img_resp = await asyncio.to_thread(
|
|
requests.get,
|
|
img_data,
|
|
timeout=FAL_HTTP_TIMEOUT_SECONDS,
|
|
)
|
|
if img_resp.status_code != 200:
|
|
raise ValueError(
|
|
"Failed to download generated image "
|
|
f"(HTTP {img_resp.status_code})."
|
|
)
|
|
pil_edited = Image.open(BytesIO(img_resp.content))
|
|
|
|
tensor_edited = pil2tensor(pil_edited)
|
|
if tensor_edited is None:
|
|
raise ValueError("Generated image could not be decoded.")
|
|
all_edited_tensors.append(tensor_edited)
|
|
except Exception as error:
|
|
if on_failure == "raise":
|
|
raise
|
|
print(
|
|
f"NanoSeed Warning: skipped output {image_index + 1} from "
|
|
f"async run {run_index + 1}: {_safe_error_detail(error)}"
|
|
)
|
|
|
|
if not all_edited_tensors:
|
|
raise ValueError("No usable images were returned from fal.ai.")
|
|
batched_output = torch.cat(all_edited_tensors, dim=0)
|
|
except Exception as error:
|
|
if on_failure == "raise":
|
|
raise
|
|
print(
|
|
"NanoSeed Warning: this node failed but the workflow will continue. "
|
|
f"Returning a red error image. {_safe_error_detail(error)}"
|
|
)
|
|
batched_output = _error_image_tensor()
|
|
|
|
return (batched_output,)
|