import math import threading from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs import json import time from server import PromptServer from comfy.utils import ProgressBar, set_progress_bar_global_hook from typing_extensions import override from comfy_execution.progress import ProgressHandler, NodeProgressState, PreviewImageTuple, add_progress_handler, get_progress_state from .utils import preview_to_base64, print_list_or_dic import nodes # API POST endpoints that are handled by nodes class POSTPATHS: PATH_UPSCALE = '/sdapi/v1/extra-single-image/' PATH_INTERROGATE = '/sdapi/v1/interrogate' PATH_TXT2IMG = '/sdapi/v1/txt2img' PATH_IMG2IMG = '/sdapi/v1/img2img' PATH_OPTIONS = '/sdapi/v1/options/' VALID_POST_PATHS = [ POSTPATHS.PATH_UPSCALE, POSTPATHS.PATH_INTERROGATE, POSTPATHS.PATH_TXT2IMG, POSTPATHS.PATH_IMG2IMG, POSTPATHS.PATH_OPTIONS, ] class ProgressData: def __init__(self): self.preview_image = None self.start_time = None def store_preview_image(self, preview_image): if preview_image is not None: self.preview_image = preview_image def get_progress(self, skip_current_image): progress_value = 0 progress_max = 0 nodes = get_progress_state().nodes for node_id, state in nodes.items(): progress_value =+ state["value"] progress_max =+ state["max"] current_image = None progress = 0 eta = 0 if self.start_time is not None and progress_value > 0: if progress_value > 0 and self.preview_image is not None and not skip_current_image: current_image = preview_to_base64(self.preview_image[1]) progress = progress_value / progress_max eta = ((time.time() - self.start_time) / progress_value) * (progress_max - progress_value) return progress, current_image, eta def reset(self): self.preview_image = None self.start_time = None # Handler to get progress from ComfyUI class OpenOutpainterProgressHandler(ProgressHandler): def __init__(self, progress: ProgressData): super().__init__("openoutpainter") self.progress = progress self.progress.reset() @override def start_handler(self, node_id: str, state: NodeProgressState, prompt_id: str): if self.progress.start_time is None: self.progress.start_time = time.time() pass @override def update_handler( self, node_id: str, value: float, max_value: float, state: NodeProgressState, prompt_id: str, image: PreviewImageTuple | None = None, ): self.progress.store_preview_image(image) @override def finish_handler(self, node_id: str, state: NodeProgressState, prompt_id: str): pass @override def reset(self): self.progress.reset() class OpenOutpainterRequest: def __init__(self, request_id, request_data, path): self.id = request_id self.request_data = request_data self.extra_data = {} self.path = path self.output_ready = threading.Event() self.output = None def is_command(self, command): return self.path is not None and command == self.path def finalize(self, result): self.output = result self.output_ready.set() ###################### # API Server # ###################### class OpenOutpainterServingManager: def __init__(self): self.server_address = None self.port = None self.enable_cross_origin_requests = None self.node_type = None self.node_id = None self.requests = {} self.request_id = 0 # next request id self.http_running = False self.server_status = "" self.server = None self.thread = None self.comfy_progress_hook = None self.progress = ProgressData() self.oop_styles = {} self.oop_checkpoints = [] self.spammy_debug = False def start_server(self, server_address, port, enable_cross_origin_requests, node_type, node_id, spammy_debug = False): # server config from workflow self.server_address = server_address self.port = port self.enable_cross_origin_requests = enable_cross_origin_requests self.node_type = node_type self.node_id = node_id self.spammy_debug = spammy_debug if not self.http_running: try: self.thread = threading.Thread(target=self.http_handler, daemon=True) self.thread.start() self.http_running = True self.server_status = f"Server is running on {self.server_address}:{self.port}" print(f"OpenOutpaint API server running on port {self.port}") except Exception as e: self.http_running = False self.server_status = "ERROR: Could not start OpenOutpaint API server: {}".format(e) raise RuntimeError(self.server_status ) def stop_server(self): self.cancel_open_requests() if self.http_running: self.http_running = False if self.server: # server may have failed to start self.server.shutdown() self.server.server_close() self.thread.join() self.server_status = "Server not running" print(f"OpenOutpaint API server stopped on port {self.port}") def cancel_open_requests(self): # iterate over a copy of keys instead as requests dict can change during this process for request_id in self.requests.keys(): request = self.requests[request_id] print(f"OpenOutpaint API server, canceling request_id: {request_id} request: {request}") request.finalize({}) PromptServer.instance.prompt_queue.wipe_queue() nodes.interrupt_processing() def http_handler(self): class RequestHandler(BaseHTTPRequestHandler): def do_OPTIONS(self2): if (self.enable_cross_origin_requests): self2.send_response(200) self2.send_header('Access-Control-Allow-Origin', '*') self2.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') self2.send_header('Access-Control-Allow-Headers', '*') self2.end_headers() def log_message(self, format, *args): # Override method to suppress noisy logging return def do_POST(self2): print(f"OpenOutpaint Received POST request: {self2.path}") # unsupported command if not self2.path in VALID_POST_PATHS: self2.send_response(404) self2.send_header('Content-type', 'application/json') self2.cors_headers() self2.end_headers() self2.wfile.write(json.dumps({"error": "Command not found"}).encode('utf-8')) return content_length = int(self2.headers['Content-Length']) post_data = self2.rfile.read(content_length) data = json.loads(post_data.decode('utf-8')) # debug request if self.spammy_debug: print_list_or_dic(f"do_POST ({self2.path})", data) # /sdapi/v1/options/ POST just needs to be told everything is ok if self2.path == POSTPATHS.PATH_OPTIONS: self2.send_response(200) self2.send_header('Content-type', 'application/json') self2.cors_headers() self2.end_headers() self2.wfile.write(json.dumps({"status": "Whatever that was, it worked. Stop complaining. :O"}).encode('utf-8')) return request = OpenOutpainterRequest(self.request_id, data, self2.path) self.requests[self.request_id] = request self.request_id += 1 # start workflow from webui so user can interact with it self.queue_prompt(request) # for some reason this was needed to fix wait not working sometimes request.output_ready.clear() # waits here till workflow finished running # if workflow errors, just manually run workflow again with same req id to complete API request request.output_ready.wait() request.output_ready.clear() # reset progress self.progress.reset() response = request.output self2.send_response(200) self2.send_header('Content-type', 'application/json') self2.cors_headers() self2.end_headers() self2.wfile.write(json.dumps(response).encode('utf-8')) # clean up del self.requests[request.id] print("OpenOutpaint do_POST finished") def do_GET(self2): response = self.process_get_request(self2.path) # debug response if self.spammy_debug: print_list_or_dic(f"do_GET ({self2.path})", response, True) # unsupported command if not response: self2.send_response(404) self2.send_header('Content-type', 'application/json') self2.cors_headers() self2.end_headers() self2.wfile.write(json.dumps({"error": "Command not found"}).encode('utf-8')) return self2.send_response(200) self2.send_header('Content-type', 'application/json') self2.cors_headers() self2.end_headers() self2.wfile.write(json.dumps(response).encode('utf-8')) def cors_headers(self2): if (self.enable_cross_origin_requests): self2.send_header('Access-Control-Allow-Origin', '*') self2.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') self2.send_header('Access-Control-Allow-Headers', '*') self.server = ThreadingHTTPServer((self.server_address, self.port), RequestHandler) self.server.serve_forever() # trigger workflow to run from webui :) def queue_prompt(self, request): PromptServer.instance.send_sync("wo_QueuePrompt", { "node_type": self.node_type, "node_id": self.node_id, "port": self.port, "request_id": request.id, }) def get_data(self, request_id): print(f"get_data: start r:{request_id}") return self.requests.get(request_id, None) def add_progress_handler(self): add_progress_handler(OpenOutpainterProgressHandler(self.progress)) def process_get_request(self, url): url = urlparse(url) path = url.path print(f"process_get_request: {path}") match path: case '/startup-events': # output is not used for anything other than printing out server # startup errors when there is a connection error return {"status": "ok"} case '/sdapi/v1/interrupt': # cancel running gen # not yet implemented # ComfyUI doesn't respond super well to stopping anyway # but just forcing anything in the queue to finish clears stuck jobs due to workflow error self.cancel_open_requests() return {"status": "(LIE) Yup, we totally canceled that job."} case '/sdapi/v1/progress': # get progress of current running gen # request: skip_current_image: false = return latent preview # response: see notes below query = parse_qs(url.query) skip_current_image = query.get('skip_current_image', 'false') if isinstance(skip_current_image, list): skip_current_image = skip_current_image[0] skip_current_image = str(skip_current_image).lower() != 'false' progress, current_image, eta = self.progress.get_progress(skip_current_image) return { "progress": progress, # float "eta_relative": eta, # estimated time remaining in seconds "current_image": current_image, # latent preview as a base64 encoded png } case '/sdapi/v1/options': # Gets the current settings and config of the backend to fill in ui controls # and check for supported configuration # I don't use those controls in OOP, and not implemented # so mostly placeholder info can be returned that makes OOP not complain # data that is used: # `use_scale_latent_for_hires_fix` is False or undefined # `sd_model_checkpoint` for currently "loaded"/selected checkpoint # `sd_checkpoint_hash` only checked if the above is undefined # `img2img_color_correction` is not True # `inpainting_mask_weight` is set to 1.0 return { "status": "ok", "sd_model_checkpoint": "", # TODO: make openoutpaint not change settings based on what the api returns "sd_checkpoint_hash": "", "img2img_color_correction": False, "inpainting_mask_weight": 1.0, } case '/sdapi/v1/upscalers': # return list of upscalers, can just be dummy option and config this within workflow # only "name" is required return [ "[WIP] Use Workflow", ] case '/sdapi/v1/sd-models': # return list of checkpoints # Previously this returned an A1111 styled list of models, # but that isn't required anymore, just need a simple list now return self.oop_checkpoints case '/sdapi/v1/loras': # return list of loras, can be empty and config this within workflow # only "name" is used return [ {"name": "Configure LoRAs in workflow"}, ] case '/sdapi/v1/samplers': # return list of samplers, can just be dummy option and config this within workflow # only "name" is used return [ {"name": "Configure sampler in workflow"}, ] case '/sdapi/v1/schedulers': # return list of schedulers, can just be dummy option and config this within workflow # only "name" and "label" are used return [ {"name": "automatic", "label": "Automatic"}, ] case '/sdapi/v1/prompt-styles': # return list of A1111 prompt-styles # These are passed by as a multiple selected list by name for txt2img and img2img in "styles" # only "name" is used, but the prompts are displayed in the tooltip for each # {"name": "", "prompt": "", "negative_prompt":""}, return list(self.oop_styles.values()) ######################## # Extensions Functions # ######################## case '/sdapi/v1/scripts': # list of extensions # OOP only looks for controlnet and dynamic prompts to enable those features in its UI # extension support in OOP is not very complete # cn can be omitted as can be done better in the workflow manually # return almost the minimum to make OOP not complain return { "txt2img": [ "extra options", "openoutpaint", "refiner", "sampler", "seed" ], "img2img": [ "extra options", "openoutpaint", "refiner", "sampler", "seed" ] } case '/controlnet/version': # a1111 cn extension version, needs to be > 0 to enable ui # sending 0 to disable, use workflow instead return {"version": 0} case '/controlnet/settings': # number of ref layers needs to be not < 2 or triggers warning return {"control_net_unit_count": 2} case '/controlnet/model_list': # list of controlnet models # use workflow instead, send empty return {"model_list": []} case '/controlnet/module_list': # list of controlnet modules # not sure if can just be empty it not used return { "module_list": ["none", "inpaint"], "module_detail": { "none": { "model_free": False, "sliders": [] }, "inpaint": { "model_free": False, "sliders": [] } } } return None