This commit refactors the `bulkgen.py` script to improve code organization and readability. Comments have been added to key classes and methods to clarify logic and reduce complexity, making the codebase easier to follow and maintain.
764 lines
30 KiB
Python
764 lines
30 KiB
Python
"""
|
|
File : bulkgen.py
|
|
Purpose : Script to create a batch of images based on a configuration file.
|
|
Author : Martin Rizzo | <martinrizzo@gmail.com>
|
|
Date : Mar 14, 2026
|
|
Repo : https://github.com/martin-rizzo/ComfyUI-ZImagePowerNodes
|
|
License : MIT
|
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
ComfyUI-ZImagePowerNodes
|
|
ComfyUI nodes designed specifically for the "Z-Image" model.
|
|
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
|
"""
|
|
import os
|
|
import sys
|
|
import argparse
|
|
from typing import overload
|
|
from copy import deepcopy
|
|
|
|
import json
|
|
import urllib.request
|
|
import urllib.parse
|
|
import uuid
|
|
|
|
# requires: pip install websocket-client
|
|
# or: sudo dnf install python3-websocket-client
|
|
import websocket
|
|
|
|
|
|
DEFAULT_HOST = os.getenv("BULKGEN_COMFY_HOST") or os.getenv("COMFY_HOST") or "127.0.0.1"
|
|
DEFAULT_PORT = os.getenv("BULKGEN_COMFY_PORT") or os.getenv("COMFY_PORT") or "8188"
|
|
DEFAULT_PROMPT =""
|
|
DEFAULT_CHECKPOINT =os.getenv("BULKGEN_CHECKPOINT" ) or "z_image_turbo-Q5_K_S.gguf"
|
|
DEFAULT_TEXT_ENCODER =os.getenv("BULKGEN_TEXT_ENCODER" ) or "Qwen3-4B.i1-Q5_K_S.gguf"
|
|
DEFAULT_STYLE =os.getenv("BULKGEN_STYLE" ) or "Casual Photo"
|
|
DEFAULT_STEPS =os.getenv("BULKGEN_STEPS" ) or "9"
|
|
DEFAULT_SEED =os.getenv("BULKGEN_SEED" ) or "1"
|
|
DEFAULT_INCALIBRATION=os.getenv("BULKGEN_INCALIBRATION") or "1.0"
|
|
DEFAULT_LOWRES_BIAS = False
|
|
DEFAULT_DENOISE = 1.0
|
|
README_NOTE ="This workflow was generated by bulkgen.py"
|
|
|
|
FILE_PREFIX = "bgen"
|
|
|
|
|
|
# get the directory where the script is located
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
# ANSI escape codes for colored terminal output
|
|
RED = '\033[91m'
|
|
DKRED = '\033[31m'
|
|
YELLOW = '\033[93m'
|
|
DKYELLOW = '\033[33m'
|
|
GREEN = '\033[92m'
|
|
CYAN = '\033[96m'
|
|
DKGRAY = '\033[90m'
|
|
RESET = '\033[0m'
|
|
|
|
#============================= ERROR MESSAGES ==============================#
|
|
|
|
def disable_colors():
|
|
global RED, DKRED, YELLOW, DKYELLOW, GREEN, CYAN, DKGRAY, RESET
|
|
RED, DKRED, YELLOW, DKYELLOW, GREEN, CYAN, DKGRAY, RESET = "", "", "", "", "", "", "", ""
|
|
|
|
|
|
def info(message: str, padding: int = 0, file=sys.stderr) -> None:
|
|
"""Displays an informational message to the error stream.
|
|
"""
|
|
print(f"{" "*padding}{CYAN}\u24d8 {message}{RESET}", file=file)
|
|
|
|
|
|
def warning(message: str, *info_messages: str, padding: int = 0, file=sys.stderr) -> None:
|
|
"""Displays a warning message to the standard error stream.
|
|
"""
|
|
print(f"{" "*padding}{CYAN}[{YELLOW}WARNING{CYAN}]{DKYELLOW} {message}{RESET}", file=file)
|
|
for info_message in info_messages:
|
|
info(info_message, padding=padding, file=file)
|
|
|
|
|
|
def error(message: str, *info_messages: str, padding: int = 0, file=sys.stderr) -> None:
|
|
"""Displays an error message to the standard error stream.
|
|
"""
|
|
print(f"{" "*padding}{DKRED}[{RED}ERROR!{DKRED}]{DKYELLOW} {message}{RESET}", file=file)
|
|
for info_message in info_messages:
|
|
info(info_message, padding=padding, file=file)
|
|
|
|
|
|
def fatal_error(message: str, *info_messages: str, padding: int = 0, file=sys.stderr) -> None:
|
|
"""Displays a fatal error message to the standard error stream and exits with status code 1.
|
|
"""
|
|
error(message, *info_messages, padding=padding, file=file)
|
|
sys.exit(1)
|
|
|
|
|
|
#================================= HELPERS =================================#
|
|
|
|
def _to_float(value: str) -> float:
|
|
"""Converts a string to float, handling optional percentage signs."""
|
|
value = value.strip()
|
|
if not value: raise ValueError("Empty string provided for float conversion.")
|
|
elif value.endswith('%') : return float(value[:-1]) / 100.0
|
|
elif value.startswith('%'): return float(value[1:] ) / 100.0
|
|
return float(value)
|
|
|
|
|
|
def _to_int(value: str) -> int:
|
|
"""Converts a string to int."""
|
|
value = value.strip()
|
|
if not value: raise ValueError("Empty string provided for int conversion.")
|
|
return int(float(value))
|
|
|
|
|
|
@overload
|
|
def to_float(values: str) -> float: ...
|
|
|
|
@overload
|
|
def to_float(values: list[str]) -> list[float]: ...
|
|
|
|
def to_float(values: str | list[str]) -> float | list[float]:
|
|
"""Converts a string (or list of strings) to float(s), supporting percentages.
|
|
Raises:
|
|
ValueError: If the input contains invalid characters for numbers/percentages.
|
|
"""
|
|
try:
|
|
if isinstance(values, str):
|
|
return _to_float(values)
|
|
return [_to_float(value) for value in values]
|
|
except ValueError as e:
|
|
raise ValueError(f"Invalid float/percent values: {values}") from e
|
|
|
|
|
|
@overload
|
|
def to_int(values: str) -> int: ...
|
|
|
|
@overload
|
|
def to_int(values: list[str]) -> list[int]: ...
|
|
|
|
def to_int(values: str | list[str]) -> int | list[int]:
|
|
"""Converts a string (or list of strings) to int(s).
|
|
Raises:
|
|
ValueError: If the input contains invalid characters for integer numbers.
|
|
"""
|
|
try:
|
|
if isinstance(values, str):
|
|
return _to_int(values)
|
|
return [_to_int(value) for value in values]
|
|
except (ValueError, TypeError) as e:
|
|
raise ValueError(f"Invalid int values: {values}") from e
|
|
|
|
|
|
def node_has_input(node: dict, input_name: str) -> bool:
|
|
"""Return `True` if the node has an input with the given name, (case insensitive)
|
|
"""
|
|
lower_input_name = input_name.lower()
|
|
for input in node.get("inputs", []):
|
|
if input.get("name","").lower() == lower_input_name:
|
|
return True
|
|
return False
|
|
|
|
|
|
#============================== FIXING VALUES ==============================#
|
|
|
|
def fix_percent(value: float | int, use_off: bool = False) -> str:
|
|
if use_off and value<=0:
|
|
return "off"
|
|
percent_str = f"{int(value*100)}%"
|
|
return percent_str
|
|
|
|
|
|
#================================ GenParams ================================#
|
|
|
|
class GenParams:
|
|
"""
|
|
Represents a generation parameter configuration (with multiple optional values)
|
|
|
|
This class allows storing multiple values for each parameter to support
|
|
combinatorial generation. For example, if you want to test different
|
|
checkpoints, distinct steps, and different seeds but with the same prompt.
|
|
|
|
Example Usage:
|
|
multi_params = GenParams(
|
|
prompt="A cyberpunk city",
|
|
style=["anime", "phone photo"]
|
|
checkpoint=["z_image_turbo-Q5_K_S.gguf", "z_image_turbo_bf16.safetensors"],
|
|
steps=[5,7,9],
|
|
seed=[1]
|
|
)
|
|
# Later logic could generate all images using `self.generate_combinations()`
|
|
|
|
Attributes:
|
|
prompt (str) : The single prompt text shared across all variations.
|
|
checkpoint (List[str]) : List of model checkpoint names.
|
|
text_encoder (List[str]) : List of text encoder names.
|
|
style (List[str]) : List of artistic styles to test.
|
|
steps (List[int]) : List of denoising step counts.
|
|
seed (List[int]) : List of random seeds.
|
|
incalibration (List[float]): List of initial noise calibration values.
|
|
filename (str) : Optional filename for the image generated by this parameter set.
|
|
"""
|
|
def __init__(self, *,
|
|
prompt : str | None = None,
|
|
checkpoint : list[str] | None = None,
|
|
text_encoder : list[str] | None = None,
|
|
style : list[str] | None = None,
|
|
steps : list[int] | None = None,
|
|
seed : list[int] | None = None,
|
|
incalibration: list[float] | None = None,
|
|
filename : str | None = None,
|
|
) -> None:
|
|
self.prompt = prompt if type(prompt) == str else DEFAULT_PROMPT
|
|
self.checkpoint = checkpoint if type(checkpoint) == list else [DEFAULT_CHECKPOINT]
|
|
self.text_encoder = text_encoder if type(text_encoder) == list else [DEFAULT_TEXT_ENCODER]
|
|
self.style = style if type(style) == list else [DEFAULT_STYLE]
|
|
self.steps = steps if type(steps) == list else [int(DEFAULT_STEPS)]
|
|
self.seed = seed if type(seed) == list else [int(DEFAULT_SEED)]
|
|
self.incalibration = incalibration if type(incalibration) == list else [float(DEFAULT_INCALIBRATION)]
|
|
self.filename = filename if type(filename) == str else ""
|
|
|
|
|
|
def set_param(self, param_and_values: str):
|
|
"""
|
|
Updates a generation parameter based on a string with format "param = value"
|
|
|
|
This method allows modifying parameters like checkpoint, style, steps, etc.,
|
|
using a key-value string format. Param names can be prefixed with "$".
|
|
|
|
Args:
|
|
param_and_values (str): The parameter name and values in the format "param=value"
|
|
or "param=value1,value2,...".
|
|
Example: "ckpt=zimage.safetensors" or "$steps=5,7,9".
|
|
Raises:
|
|
ValueError: If the parameter name is not recognized (e.g., unknown param).
|
|
ValueError: If value conversion fails (handled by to_int/to_float).
|
|
|
|
Supported Parameters:
|
|
- "ckpt" : Updates self.checkpoint.
|
|
- "style": Updates self.style.
|
|
- "steps": Updates self.steps.
|
|
- "seed" : Updates self.seed .
|
|
- "inc", "incalibration", "initial_noise_calibration": Updates self.incalibration.
|
|
"""
|
|
param, _, values = param_and_values.partition("=")
|
|
param = param.strip().lower()
|
|
if param.startswith('$'): param = param[1:]
|
|
|
|
# builds the list of values
|
|
values = [v.strip() for v in values.split(",")]
|
|
|
|
if param == "ckpt":
|
|
self.checkpoint = values
|
|
elif param == "style":
|
|
self.style = values
|
|
elif param == "steps":
|
|
self.steps = to_int(values)
|
|
elif param == "seed":
|
|
self.seed = to_int(values)
|
|
elif param == "inc" or param == "incalibration" or param == "initial_noise_calibration":
|
|
self.incalibration = to_float(values)
|
|
else:
|
|
raise ValueError(f"Unknown generation parameter: {param}")
|
|
|
|
|
|
def generate_combinations(self, name_prefix="bulk") -> list["GenParams"]:
|
|
"""
|
|
Generates a list of GenParams objects representing every possible
|
|
combination of the multi-valued parameters defined in this instance.
|
|
|
|
Returns:
|
|
List[GenParams]: A list containing unique GenParams configurations.
|
|
|
|
Example:
|
|
params_list = self.generate_combinations()
|
|
"""
|
|
from itertools import product
|
|
result_list: list[GenParams] = []
|
|
|
|
# define the options to iterate over
|
|
options = [ self.checkpoint,
|
|
self.style,
|
|
self.steps,
|
|
self.seed,
|
|
self.incalibration ]
|
|
|
|
# create a new GenParams instance for each combination
|
|
for combo in list(product(*options)):
|
|
filename = name_prefix
|
|
if len(self.steps)>1: filename += f"_steps{combo[2]}"
|
|
if len(self.seed )>1: filename += f"_seed{combo[3]}"
|
|
genparams = GenParams(
|
|
prompt = self.prompt,
|
|
filename = filename,
|
|
checkpoint = [ combo[0] ],
|
|
style = [ combo[1] ],
|
|
steps = [ combo[2] ],
|
|
seed = [ combo[3] ],
|
|
incalibration = [ combo[4] ],
|
|
)
|
|
result_list.append(genparams)
|
|
|
|
return result_list
|
|
|
|
|
|
def copy(self) -> 'GenParams':
|
|
"""Returns a deep copy with all attributes duplicated independently.
|
|
Example:
|
|
original = GenParams(prompt="A cyberpunk city", steps=[5, 7, 9])
|
|
copy_instance = original.copy()
|
|
# Modifying copy_instance.steps will not affect original
|
|
original.steps.append(10)
|
|
assert copy_instance.steps == [5, 7, 9] # True
|
|
"""
|
|
return deepcopy(self)
|
|
|
|
|
|
def __str__(self) -> str:
|
|
"""Returns a readable string representation of the multi-value configuration."""
|
|
return f"GenParams(checkpoint={self.checkpoint}, style={self.style}, " \
|
|
f"steps={self.steps}, seed={self.seed}, incalibration={self.incalibration}, "\
|
|
f"prompt=\"{self.prompt}\")"
|
|
|
|
|
|
#================================= CONFIG ==================================#
|
|
|
|
class Config:
|
|
"""
|
|
Class responsible for parsing and managing configuration files.
|
|
"""
|
|
def __init__(self):
|
|
self._action = ""
|
|
self._content = ""
|
|
self._is_first_line = True
|
|
self._is_shebang_line = False
|
|
self._is_comment_zone = True
|
|
self._global_params = GenParams()
|
|
self._custom_styles : dict[str, str] = {}
|
|
self._genparams_list : list[GenParams] = []
|
|
|
|
@classmethod
|
|
def from_file(cls, path: str) -> "Config":
|
|
"""Loads configuration from a file.
|
|
Args:
|
|
path (str): The file path to the configuration file.
|
|
Returns:
|
|
Config: The instantiated configuration object after loading.
|
|
"""
|
|
config = cls()
|
|
config.load_begin()
|
|
with open(path, "r") as file:
|
|
for line in file: config.load_text_line(line)
|
|
config.load_end()
|
|
return config
|
|
|
|
@property
|
|
def all_genparams(self) -> list[GenParams]:
|
|
"""Returns the list of all generation parameters stored in the configuration."""
|
|
return self._genparams_list
|
|
|
|
@property
|
|
def custom_styles(self) -> dict[str, str]:
|
|
"""Returns the dictionary of custom styles defined in the configuration."""
|
|
return self._custom_styles
|
|
|
|
|
|
def load_begin(self):
|
|
"""Initializes the loading process."""
|
|
pass
|
|
|
|
|
|
def load_text_line(self, line: str):
|
|
"""Loads a text line from the configuration file
|
|
|
|
The configuration loading process should be done line by line, wrapping
|
|
the whole process with load_begin() and load_end() methods.
|
|
"""
|
|
self._is_shebang_line = self._is_first_line and line.startswith("#!")
|
|
self._is_first_line = False
|
|
|
|
# note: trailing whitespaces are lost at the end of each line
|
|
line = line.rstrip()
|
|
|
|
# discard comments if it is in a zone that can have comments
|
|
if self._is_comment_zone and line.startswith(">>#"):
|
|
return
|
|
|
|
# verify if the line is a new action to be processed
|
|
if ( self._is_shebang_line or #< sheban "#!ZCONFIG" (compatibility with Amazing Z-Image Workflow)
|
|
line.startswith(">::") or #< action to modify workflow (compatibility with Amazing Z-Image Workflow)
|
|
line.startswith(">>>") or #< style selection / prompt start
|
|
line.startswith(">>$") #< parameter configuration
|
|
):
|
|
self._process_config_action(self._action, self._content)
|
|
self._action, self._content = line, ""
|
|
else:
|
|
self._content += line + "\n"
|
|
|
|
|
|
def load_end(self):
|
|
"""Finalizes the loading process."""
|
|
self._process_config_action(self._action, self._content)
|
|
self._action, self._content = "", ""
|
|
|
|
|
|
def add_new_genparams(self, genparams: GenParams):
|
|
self._genparams_list.append( genparams )
|
|
|
|
|
|
def _process_config_action(self, action: str, content: str, /):
|
|
"""
|
|
Process configuration commands based on the action syntax.
|
|
|
|
Parses the provided action to determine if it is a parameter assignment, a
|
|
prompt definition with parameters, or a simple prompt block, and executes
|
|
the corresponding logic.
|
|
|
|
Args:
|
|
action (str): The action to be processed (case inseensitive)
|
|
content (str): The accompanying content associated with the action.
|
|
"""
|
|
action = action.strip()
|
|
action_lower = action.lower()
|
|
content = content.strip()
|
|
|
|
# the action of ">>>Prompt" can have an optional ':' on the right side
|
|
if action_lower.startswith(">>>prompt"):
|
|
action = action.rstrip(":")
|
|
action_lower = action_lower.rstrip(":")
|
|
|
|
# processes global parameter assignment
|
|
# format: ">>>$param = valueA,valueB,...,valueN"
|
|
if action.startswith(">>>$"):
|
|
self._global_params.set_param( action[3:] )
|
|
|
|
# processes a prompt with parameters
|
|
# format: ">>>prompt{ param=valueA,valueB,...; param2=value2; ... }"
|
|
# and following lines are the full prompt
|
|
elif action_lower.startswith(">>>prompt{") and action.endswith("}"):
|
|
genparams = self._global_params.copy()
|
|
genparams.prompt = content.strip()
|
|
for param_and_values in action[10:-1].split(";"):
|
|
genparams.set_param( param_and_values )
|
|
self.add_new_genparams(genparams)
|
|
|
|
# processes prompt without parameters
|
|
# format: ">>>" or ">>>prompt:"
|
|
# and following lines are the full prompt
|
|
elif action_lower == ">>>prompt" or action == ">>>":
|
|
genparams = self._global_params.copy()
|
|
genparams.prompt = content.strip()
|
|
self.add_new_genparams(genparams)
|
|
|
|
# processes classical style selection
|
|
# format: ">>>Style Name"
|
|
elif action.startswith(">>>"):
|
|
style = action[3:].strip()
|
|
self._global_params.set_param(f"style={style}")
|
|
if content:
|
|
# TODO: verify that there are no commas
|
|
self._custom_styles[style] = content
|
|
|
|
|
|
|
|
|
|
#================================ ComfyAPI =================================#
|
|
|
|
class ComfyAPI:
|
|
|
|
def __init__(self, host="127.0.0.1", port="8188"):
|
|
self.server_address = f"{host}:{port}"
|
|
self.client_id = str(uuid.uuid4())
|
|
|
|
def _send_request(self, endpoint, data=None):
|
|
url = f"http://{self.server_address}{endpoint}"
|
|
headers = {'Content-Type': 'application/json'}
|
|
|
|
payload = json.dumps(data).encode('utf-8') if data else None
|
|
req = urllib.request.Request(url, data=payload, headers=headers)
|
|
|
|
with urllib.request.urlopen(req) as response:
|
|
return json.loads(response.read().decode('utf-8'))
|
|
|
|
|
|
def check_connection(self):
|
|
"""Returns True if a connection to the server can be established."""
|
|
try:
|
|
url = f"http://{self.server_address}/"
|
|
req = urllib.request.Request(url)
|
|
with urllib.request.urlopen(req, timeout=5) as response:
|
|
if 200 <= response.status < 300:
|
|
return True
|
|
else:
|
|
return False
|
|
except:
|
|
return False
|
|
|
|
|
|
def get_history(self, prompt_id):
|
|
return self._send_request(f"/history/{prompt_id}")
|
|
|
|
def download_image(self, filename, subfolder, folder_type):
|
|
params = urllib.parse.urlencode({
|
|
"filename": filename,
|
|
"subfolder": subfolder,
|
|
"type": folder_type
|
|
})
|
|
url = f"http://{self.server_address}/view?{params}"
|
|
with urllib.request.urlopen(url) as response:
|
|
return response.read()
|
|
|
|
def generate_and_wait(self, workflow, show_progress=True):
|
|
ws = websocket.WebSocket()
|
|
ws.connect(f"ws://{self.server_address}/ws?clientId={self.client_id}")
|
|
|
|
# Queue the prompt
|
|
queue_data = {"prompt": workflow, "client_id": self.client_id}
|
|
prompt_id = self._send_request("/prompt", queue_data)['prompt_id']
|
|
|
|
while True:
|
|
out = ws.recv()
|
|
if isinstance(out, str):
|
|
msg = json.loads(out)
|
|
|
|
# Update Progress
|
|
if show_progress and msg['type'] == 'progress':
|
|
val = msg['data']['value']
|
|
max_val = msg['data']['max']
|
|
percent = int(100 * val / max_val)
|
|
bar = "█" * (percent // 2) + "-" * (50 - percent // 2)
|
|
print(f"\rGenerating: |{bar}| {percent}%", end="", flush=True)
|
|
|
|
# Check if finished
|
|
if msg['type'] == 'executing':
|
|
if msg['data']['node'] is None and msg['data']['prompt_id'] == prompt_id:
|
|
print()
|
|
break
|
|
|
|
# Fetch output info
|
|
history = self.get_history(prompt_id)[prompt_id]
|
|
images_binary = []
|
|
|
|
for node_id in history['outputs']:
|
|
node_output = history['outputs'][node_id]
|
|
if 'images' in node_output:
|
|
for img in node_output['images']:
|
|
img_data = self.download_image(img['filename'], img['subfolder'], img['type'])
|
|
images_binary.append(img_data)
|
|
|
|
return images_binary
|
|
|
|
|
|
#============================== IMAGE BUILDER ==============================#
|
|
|
|
class ImageBuilder:
|
|
"""
|
|
Generates images through the ComfyUI API.
|
|
"""
|
|
def __init__(self, comfyapi: ComfyAPI) -> None:
|
|
self._confyapi = comfyapi
|
|
self._last_checkpoint = ""
|
|
|
|
|
|
def __call__(self, genparams: GenParams) -> bytes:
|
|
"""Convenience alias for `build_images()`"""
|
|
return self.build_image(genparams)
|
|
|
|
|
|
def build_image(self, genparams: GenParams) -> bytes:
|
|
"""
|
|
Generates images according to the provided generation parameters.
|
|
|
|
Args:
|
|
genparams: Object containing generation parameters (style, prompt, seed, etc.).
|
|
comfyapi : The API client instance for interacting with ComfyUI.
|
|
Returns:
|
|
List of generated images (raw bytes).
|
|
"""
|
|
# notify user when the checkpoint changes
|
|
if self._last_checkpoint != genparams.checkpoint[0]:
|
|
self._last_checkpoint = genparams.checkpoint[0]
|
|
info(f"Loading model \"{genparams.checkpoint[0]}\", this may take a while...")
|
|
|
|
workflow = self.build_workflow(genparams)
|
|
api_workflow = self.build_api_workflow(genparams)
|
|
return self._confyapi.generate_and_wait(api_workflow)[0]
|
|
|
|
|
|
def build_workflow(self, genparams: GenParams, custom_styles: str = "") -> dict:
|
|
"""
|
|
Builds a ComfyUI workflow by loading a template JSON and injecting parameters.
|
|
|
|
Loads the "bulkgen.json" workflow file, iterates through nodes, and
|
|
updates widget values based oon the provided generation parameters.
|
|
|
|
Args:
|
|
genparams : A `GenParams` object containing generation parameters (style, prompt, seed, etc.).
|
|
custom_styles: Optional custom style templates.
|
|
Returns:
|
|
The modified workflow dictionary with updated generation parameters.
|
|
"""
|
|
workflow_file = SCRIPT_DIR + "/bulkgen.json"
|
|
with open(workflow_file, 'r') as f:
|
|
workflow = json.load(f)
|
|
|
|
# get the workflow's list of nodes
|
|
nodes = workflow.get("nodes")
|
|
if not nodes: return {}
|
|
|
|
for node in nodes:
|
|
ntype : str = node.get("type","")
|
|
title : str = node.get("title","")
|
|
title_lower = title.lower()
|
|
|
|
# README
|
|
if ntype == "Note" and title_lower == "readme":
|
|
node["widgets_values"] = [ README_NOTE ]
|
|
|
|
# CUSTOM STYLES
|
|
if ntype == "PrimitiveStringMultiline" and "styles" in title_lower:
|
|
node["widgets_values"] = [ custom_styles ]
|
|
|
|
# Style + Prompt
|
|
if "StylePrompt" in ntype and node_has_input(node,"clip"):
|
|
number_of_widgets = len(node.get("widgets_values",[]))
|
|
if number_of_widgets != 4:
|
|
warning(f"Node \"{title}\" has {number_of_widgets} widgets, expected 4")
|
|
node["widgets_values"] = [
|
|
f'"{genparams.style[0]}"', None, None, genparams.prompt ]
|
|
|
|
# Seed + Steps + InCalibration
|
|
if "ZSamplerTurbo" in ntype and node_has_input(node,"model"):
|
|
number_of_widgets = len(node.get("widgets_values",[]))
|
|
if number_of_widgets != 7:
|
|
warning(f"Node \"{title}\" has {number_of_widgets} widgets, expected 7")
|
|
node["widgets_values"] = [
|
|
genparams.seed[0], "fixed", genparams.steps[0], DEFAULT_DENOISE, None,
|
|
fix_percent(genparams.incalibration[0], use_off=True),
|
|
DEFAULT_LOWRES_BIAS ]
|
|
|
|
# Checkpoint
|
|
if ntype == "UnetLoaderGGUF":
|
|
node["widgets_values"] = [
|
|
genparams.checkpoint[0] ]
|
|
|
|
# TextEncoder
|
|
if ntype == "CLIPLoaderGGUF":
|
|
node["widgets_values"] = [
|
|
genparams.text_encoder[0], "lumina2" ]
|
|
|
|
return workflow
|
|
|
|
|
|
def build_api_workflow(self, genparams: GenParams, custom_styles: str = "") -> dict:
|
|
"""
|
|
Builds a ComfyUI API workflow by loading a template JSON and updating inputs.
|
|
|
|
Loads the "bulkgen.api.json" workflow file, iterates through nodes, and
|
|
updates the input values based on the provided generation parameters.
|
|
|
|
Args:
|
|
genparams : A `GenParams` object containing generation parameters (style, prompt, seed, etc.).
|
|
custom_styles: Optional custom style templates.
|
|
Returns:
|
|
The modified API workflow dictionary, ready to be sent to "/prompt"
|
|
via the ComfyUI REST API.
|
|
"""
|
|
api_workflow_file = SCRIPT_DIR + "/bulkgen.api.json"
|
|
with open(api_workflow_file, 'r') as f:
|
|
api_workflow = json.load(f)
|
|
|
|
for _, node in api_workflow.items():
|
|
ntype : str = node.get("class_type","")
|
|
inputs : dict = node.get("inputs",{})
|
|
|
|
# Style + Prompt
|
|
if ntype.startswith("StylePrompt") and ntype.endswith("ZImagePowerNodes"):
|
|
inputs["style"] = genparams.style[0]
|
|
inputs["text" ] = genparams.prompt
|
|
|
|
# # Seed + Steps + InCalibration
|
|
if ntype.startswith("ZSamplerTurbo") and ntype.endswith("ZImagePowerNodes"):
|
|
inputs["seed" ] = genparams.seed[0]
|
|
inputs["steps" ] = genparams.steps[0]
|
|
inputs["initial_noise_calibration"] = fix_percent(genparams.incalibration[0], use_off=True)
|
|
inputs["denoise" ] = DEFAULT_DENOISE
|
|
inputs["lowres_bias"] = DEFAULT_LOWRES_BIAS
|
|
|
|
# Checkpoint
|
|
if ntype == "UnetLoaderGGUF":
|
|
inputs["unet_name"] = genparams.checkpoint[0]
|
|
|
|
# TextEncoder
|
|
if ntype == "CLIPLoaderGGUF":
|
|
inputs["clip_name"] = genparams.text_encoder[0]
|
|
|
|
return api_workflow
|
|
|
|
|
|
|
|
#===========================================================================#
|
|
#////////////////////////////////// MAIN ///////////////////////////////////#
|
|
#===========================================================================#
|
|
|
|
|
|
def main(args=None, parent_script=None):
|
|
"""
|
|
Main entry point for the script.
|
|
Args:
|
|
args (optional): List of arguments to parse. Default is None, which will use the command line arguments.
|
|
parent_script (optional): The name of the calling script if any. Used for customizing help output.
|
|
"""
|
|
prog = None
|
|
if parent_script:
|
|
prog = parent_script + " " + os.path.basename(__file__).split('.')[0]
|
|
|
|
# set up argument parser for the script
|
|
parser = argparse.ArgumentParser(
|
|
prog = prog,
|
|
description = "Create a batch of images based on a configuration file",
|
|
formatter_class = argparse.RawTextHelpFormatter,
|
|
epilog = """Environment Variables:
|
|
BULKGEN_COMFY_HOST / COMFY_HOST = Default comfyui host address.
|
|
BULKGEN_COMFY_PORT / COMFY_PORT = Default comfyui port number.
|
|
""")
|
|
parser.add_argument('--no-color' , action='store_true' , help="Disable colored output.")
|
|
parser.add_argument('--host' , type=str , help="ComfyUI host address.")
|
|
parser.add_argument('--port' , type=str , help="ComfyUI port.")
|
|
parser.add_argument('config_files', nargs='+', metavar='FILE', help="One or more configuration files to generate images from.")
|
|
args = parser.parse_args(args=args)
|
|
|
|
# if the user requested to disable colors, call disable_colors()
|
|
if args.no_color:
|
|
disable_colors()
|
|
|
|
# try to connect to ComfyUI,
|
|
# the host and port are determined by the --host and --port flags
|
|
# (if not specified, the values from environment variables are used)
|
|
comfy_host = args.host or DEFAULT_HOST
|
|
comfy_port = args.port or DEFAULT_PORT
|
|
comfyapi = ComfyAPI(comfy_host, comfy_port)
|
|
if not comfyapi.check_connection():
|
|
fatal_error(f"Could not connect to ComfyUI at \"{comfy_host}:{comfy_port}\"",
|
|
"Use the --host and --port flags to specify the host and port.",
|
|
"Alternatively, you can set the BULKGEN_COMFY_HOST and BULKGEN_COMFY_PORT environment variables.")
|
|
|
|
image_builder = ImageBuilder(comfyapi)
|
|
|
|
# loop through the configuration files and generate images
|
|
for config_file in args.config_files:
|
|
config = Config.from_file(config_file)
|
|
all_genparams = config.all_genparams
|
|
custom_styles = config.custom_styles
|
|
|
|
for prompt_index, genparams in enumerate(all_genparams):
|
|
filename_prefix = f"{FILE_PREFIX}_p{prompt_index:02}"
|
|
genparams_list = genparams.generate_combinations(name_prefix=filename_prefix)
|
|
for params in genparams_list:
|
|
image_bytes = image_builder(params)
|
|
with open(f"{params.filename}.png", "wb") as f:
|
|
f.write(image_bytes)
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |