Initial commit

This commit is contained in:
Justin Oros
2026-04-13 18:03:33 -07:00
commit 73d16c9b69
26 changed files with 1660 additions and 0 deletions
BIN
View File
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
import requests
import base64
def generate_image(base_url, positive, negative, steps=20, width=768, height=1024, cfg=4.5):
r = requests.post(f"{base_url}/sdapi/v1/txt2img", json={
"prompt": positive,
"negative_prompt": negative,
"steps": steps,
"width": width,
"height": height,
"cfg_scale": cfg,
})
r.raise_for_status()
return base64.b64decode(r.json()["images"][0])
def is_ready(base_url, timeout=3):
try:
requests.get(f"{base_url}/sdapi/v1/options", timeout=timeout)
return True
except Exception:
return False
+70
View File
@@ -0,0 +1,70 @@
import os
import sys
from image.sd_launcher import read_config, write_config
def get_backend_type(sd_path):
if not sd_path:
return None
if os.path.exists(os.path.join(sd_path, "webui.sh")):
return "a1111"
if os.path.exists(os.path.join(sd_path, "comfy_extras")) or "ComfyUI" in sd_path:
return "comfyui"
if "InvokeAI" in sd_path:
return "invokeai"
return "a1111"
def get_base_url(backend_type):
if backend_type == "comfyui":
return "http://127.0.0.1:8188"
return "http://127.0.0.1:7860"
def is_ready(backend_type, base_url):
if backend_type == "comfyui":
from image.comfy_api import is_ready as comfy_ready
return comfy_ready()
from image.a1111_api import is_ready as a1111_ready
return a1111_ready(base_url)
def ensure_model(backend_type, sd_path, style="manga"):
if backend_type == "comfyui":
from image import comfy_api
model = comfy_api.ensure_model_for_style(sd_path, style=style)
write_config("comfy-model", model)
return model
return None
def setup(sd_path):
backend_type = get_backend_type(sd_path)
write_config("sd-backend", backend_type)
if backend_type == "comfyui":
from image import comfy_api
comfy_api.install_dependencies(sd_path)
return backend_type
def _force_bw(img_bytes):
from PIL import Image
import io
img = Image.open(io.BytesIO(img_bytes)).convert("L").convert("RGB")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def generate_image(positive, negative, steps=20, width=768, height=1024, cfg=4.5, force_bw=True, style="manga"):
config = read_config()
sd_path = config.get("sd-path", "")
backend_type = config.get("sd-backend") or get_backend_type(sd_path)
base_url = get_base_url(backend_type)
if backend_type == "comfyui":
from image import comfy_api
model = comfy_api.ensure_model_for_style(sd_path, style=style)
write_config("comfy-model", model)
if not model:
print("\nError: no model found. Re-run to trigger model download.")
sys.exit(1)
result = comfy_api.generate_image(base_url, positive, negative, model, steps, width, height, cfg)
return _force_bw(result) if force_bw else result
from image import a1111_api
result = a1111_api.generate_image(base_url, positive, negative, steps, width, height, cfg)
return _force_bw(result) if force_bw else result
+188
View File
@@ -0,0 +1,188 @@
import requests
import json
import uuid
import time
import os
import subprocess
import sys
COMFY_PORT = 8188
POLL_TIMEOUT = 300
MODELS = {
"lineart": {
"name": "Deliberate_v2.safetensors",
"url": "https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v2.safetensors",
},
"manga": {
"name": "Deliberate_v2.safetensors",
"url": "https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v2.safetensors",
},
}
DEFAULT_MODEL_URL = MODELS["manga"]["url"]
DEFAULT_MODEL_NAME = MODELS["manga"]["name"]
def base_url(host="127.0.0.1"):
return f"http://{host}:{COMFY_PORT}"
def is_ready(host="127.0.0.1", timeout=3):
try:
requests.get(f"{base_url(host)}/system_stats", timeout=timeout)
return True
except Exception:
return False
def find_model(comfy_path, name=None):
model_dir = os.path.join(comfy_path, "models", "checkpoints")
if not os.path.isdir(model_dir):
return None
if name:
return name if os.path.exists(os.path.join(model_dir, name)) else None
for f in os.listdir(model_dir):
if f.endswith(".safetensors") or f.endswith(".ckpt"):
return f
return None
def download_model(comfy_path, style="manga"):
model_info = MODELS.get(style, MODELS["manga"])
model_name = model_info["name"]
model_url = model_info["url"]
style_label = "Anything V5 (anime/manga lineart)" if style == "lineart" else "Deliberate v2 (general purpose)"
model_dir = os.path.join(comfy_path, "models", "checkpoints")
os.makedirs(model_dir, exist_ok=True)
dest = os.path.join(model_dir, model_name)
if os.path.exists(dest):
return model_name
print(f"\n Model for --style {style} not found: {model_name}")
print(f" Recommended model: {style_label}")
print(f" Source: {model_url}")
confirm = input("\n Download it now? (~2GB) [Y/n] ").strip().lower()
if confirm in ("n", "no"):
print(" Aborted.")
sys.exit(0)
print(f"\n Downloading {model_name}...")
bar_width = 35
with requests.get(model_url, stream=True) as r:
r.raise_for_status()
total = int(r.headers.get("content-length", 0))
downloaded = 0
with open(dest, "wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 1024):
f.write(chunk)
downloaded += len(chunk)
if total:
pct = downloaded / total
filled = int(bar_width * pct)
bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
mb_done = downloaded / 1024 / 1024
mb_total = total / 1024 / 1024
print(f"\r [{bar}] {mb_done:.0f}/{mb_total:.0f} MB ", end="", flush=True)
print(f"\r Download complete: {dest} ")
return model_name
def ensure_model_for_style(comfy_path, style="manga"):
model_info = MODELS.get(style, MODELS["manga"])
model_name = model_info["name"]
existing = find_model(comfy_path, name=model_name)
if existing:
return existing
return download_model(comfy_path, style=style)
def install_dependencies(comfy_path):
req_file = os.path.join(comfy_path, "requirements.txt")
stamp = os.path.join(comfy_path, ".deps_installed")
if not os.path.exists(req_file):
return
if os.path.exists(stamp):
req_mtime = os.path.getmtime(req_file)
stamp_mtime = os.path.getmtime(stamp)
if stamp_mtime >= req_mtime:
return
print(" Installing ComfyUI dependencies...")
subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", req_file, "--quiet"],
check=True,
)
open(stamp, "w").close()
def build_workflow(positive, negative, model_name, steps=20, width=768, height=1024, cfg=4.5):
seed = int(time.time()) % 2**32
return {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": "euler_ancestral",
"scheduler": "karras",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": model_name},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "manga", "images": ["8", 0]},
},
}
def generate_image(base_url_str, positive, negative, model_name, steps=20, width=768, height=1024, cfg=4.5):
client_id = str(uuid.uuid4())
workflow = build_workflow(positive, negative, model_name, steps, width, height, cfg)
r = requests.post(f"{base_url_str}/prompt", json={"prompt": workflow, "client_id": client_id})
r.raise_for_status()
prompt_id = r.json()["prompt_id"]
deadline = time.time() + POLL_TIMEOUT
while time.time() < deadline:
time.sleep(1)
hist = requests.get(f"{base_url_str}/history/{prompt_id}").json()
if prompt_id in hist:
outputs = hist[prompt_id]["outputs"]
for node_id, node_output in outputs.items():
if "images" in node_output:
img_info = node_output["images"][0]
img_r = requests.get(
f"{base_url_str}/view",
params={
"filename": img_info["filename"],
"subfolder": img_info.get("subfolder", ""),
"type": img_info["type"],
},
)
img_r.raise_for_status()
return img_r.content
break
raise RuntimeError(f"ComfyUI: no images returned for prompt_id {prompt_id} within {POLL_TIMEOUT}s")
+14
View File
@@ -0,0 +1,14 @@
import torch
import platform
def detect_device():
if torch.cuda.is_available():
return "cuda"
if platform.system() == "Darwin":
try:
if torch.backends.mps.is_available():
return "mps"
except:
pass
return "cpu"
+337
View File
@@ -0,0 +1,337 @@
import os
import subprocess
import time
import threading
import sys
import requests
_sd_process = None
CONFIG_FILE = os.path.expanduser("~/.epub-to-manga")
BACKENDS = [
{
"name": "AUTOMATIC1111 Stable Diffusion WebUI",
"repo": "https://github.com/AUTOMATIC1111/stable-diffusion-webui",
"dir": "stable-diffusion-webui",
"type": "a1111",
"launch": ["bash", "webui.sh", "--api", "--nowebui"],
"ready_url": "http://127.0.0.1:7860/sdapi/v1/options",
},
{
"name": "ComfyUI (lighter, faster on Apple Silicon)",
"repo": "https://github.com/comfyanonymous/ComfyUI",
"dir": "ComfyUI",
"type": "comfyui",
"launch": [sys.executable, "main.py", "--listen"],
"ready_url": "http://127.0.0.1:8188/system_stats",
},
{
"name": "InvokeAI",
"repo": "https://github.com/invoke-ai/InvokeAI",
"dir": "InvokeAI",
"type": "invokeai",
"launch": ["invokeai-web"],
"ready_url": "http://127.0.0.1:9090/api/v1/app/version",
},
]
KNOWN_ERRORS = [
{
"markers": ["No module named 'pkg_resources'", "Couldn't install clip"],
"message": "AUTOMATIC1111 is not compatible with Python {python_version}. A1111 requires Python 3.10.",
"solutions": [
{
"label": "Install Python 3.10 via pyenv and relaunch A1111",
"action": "fix_a1111_python",
},
{
"label": "Switch to ComfyUI (better Apple Silicon support)",
"action": "switch_backend",
"backend_index": 1,
},
],
},
{
"markers": ["CUDA out of memory", "out of memory"],
"message": "GPU ran out of memory.",
"solutions": [
{
"label": "Re-run with a smaller image size (--size 256x384)",
"action": "suggest_flag",
"flag": "--size 256x384",
},
],
},
]
def read_config():
config = {}
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE) as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
key, _, val = line.partition("=")
config[key.strip()] = val.strip()
return config
def write_config(key, value):
config = read_config()
config[key] = value
with open(CONFIG_FILE, "w") as f:
for k, v in config.items():
f.write(f"{k}={v}\n")
def get_backend_for_path(sd_path):
if not sd_path:
return BACKENDS[0]
for b in BACKENDS:
if b["dir"] in sd_path or os.path.exists(os.path.join(sd_path, b["dir"])):
return b
if b["type"] == "a1111" and os.path.exists(os.path.join(sd_path, "webui.sh")):
return b
if b["type"] == "comfyui" and os.path.exists(os.path.join(sd_path, "comfy_extras")):
return b
return BACKENDS[0]
def is_running(ready_url, timeout=3):
try:
requests.get(ready_url, timeout=timeout)
return True
except Exception:
return False
def find_installed():
home = os.path.expanduser("~")
search_dirs = [home, os.path.join(home, "Downloads"), os.path.join(home, "Documents"), os.getcwd()]
for backend in BACKENDS:
for base in search_dirs:
candidate = os.path.join(base, backend["dir"])
if os.path.isdir(candidate):
return candidate, backend
return None, None
def get_python_version():
return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
def handle_known_error(error_def, sd_path, backend):
python_version = get_python_version()
msg = error_def["message"].format(python_version=python_version)
solutions = error_def["solutions"]
print(f"\n\n ⚠️ {msg}\n")
print(" Solutions:\n")
for i, s in enumerate(solutions, 1):
print(f" {i}) {s['label']}")
print()
while True:
choice = input(" Choose a solution (or q to quit): ").strip().lower()
if choice == "q":
sys.exit(0)
if choice.isdigit() and 1 <= int(choice) <= len(solutions):
solution = solutions[int(choice) - 1]
break
print(f" Enter a number between 1 and {len(solutions)}")
action = solution["action"]
if action == "fix_a1111_python":
print("\n Installing pyenv and Python 3.10.14...")
subprocess.run(["brew", "install", "pyenv"], check=False)
subprocess.run(["pyenv", "install", "3.10.14"], check=False)
pyenv_python = os.path.expanduser("~/.pyenv/versions/3.10.14/bin/python3")
if not os.path.exists(pyenv_python):
print("\n Error: pyenv install failed. See https://github.com/pyenv/pyenv")
sys.exit(1)
env = os.environ.copy()
env["PYTHON"] = pyenv_python
print(f"\n Relaunching A1111 with Python 3.10.14...")
global _sd_process
_sd_process = subprocess.Popen(
["bash", "webui.sh", "--api", "--nowebui"],
cwd=sd_path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
)
stream_and_wait(backend, sd_path)
elif action == "switch_backend":
new_backend = BACKENDS[solution["backend_index"]]
dest = os.path.join(os.path.expanduser("~"), new_backend["dir"])
if os.path.isdir(dest):
print(f"\n Found existing {new_backend['name']} at {dest} — using it.")
else:
confirm = input(f"\n Install {new_backend['name']} to {dest}? [Y/n] ").strip().lower()
if confirm in ("n", "no"):
print(" Aborted.")
sys.exit(0)
print(f"\n Cloning {new_backend['repo']}...")
result = subprocess.run(["git", "clone", "--recursive", new_backend["repo"], dest], check=False)
if result.returncode != 0:
print("\n Error: git clone failed.")
sys.exit(1)
print(f"\n Installed to {dest}")
write_config("sd-path", dest)
write_config("sd-backend", new_backend["type"])
print(f" Re-run your original command — switching will take effect automatically.")
sys.exit(0)
elif action == "suggest_flag":
print(f"\n Re-run with: {solution['flag']}")
sys.exit(0)
def stream_logs(proc, ready_event, error_detected, sd_path, backend):
interesting = [
"Loading", "Downloading", "Installing", "Running", "Creating",
"Model loaded", "Starting", "Applying", "torch", "CUDA", "MPS",
"checkpoint", "venv", "pip", "Startup", "listen",
]
for raw in proc.stdout:
if ready_event.is_set():
break
line = raw.decode("utf-8", errors="replace").rstrip()
for err_def in KNOWN_ERRORS:
if any(m in line for m in err_def["markers"]):
ready_event.set()
error_detected["def"] = err_def
return
if any(kw in line for kw in interesting):
print(f"\r > {line[:100]:<100}")
print(" ", end="", flush=True)
def stream_and_wait(backend, sd_path):
global _sd_process
ready_event = threading.Event()
error_detected = {}
log_thread = threading.Thread(
target=stream_logs,
args=(_sd_process, ready_event, error_detected, sd_path, backend),
daemon=True,
)
log_thread.start()
start = time.time()
while not ready_event.is_set():
elapsed = int(time.time() - start)
mins, secs = divmod(elapsed, 60)
elapsed_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
print(f"\r Waiting for API... {elapsed_str} elapsed ", end="", flush=True)
if is_running(backend["ready_url"]):
ready_event.set()
elapsed = int(time.time() - start)
mins, secs = divmod(elapsed, 60)
elapsed_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
print(f"\r Ready in {elapsed_str}! \n")
return
if _sd_process.poll() is not None and not ready_event.is_set():
ready_event.set()
break
time.sleep(2)
log_thread.join(timeout=2)
if "def" in error_detected:
handle_known_error(error_detected["def"], sd_path, backend)
elif not is_running(backend["ready_url"]):
print("\n\n Error: SD process exited unexpectedly.")
sys.exit(1)
def launch(sd_path, backend):
global _sd_process
print(f"\nStarting {backend['name']} from {sd_path}...")
_sd_process = subprocess.Popen(
backend["launch"],
cwd=sd_path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
stream_and_wait(backend, sd_path)
def prompt_install():
print("\nNo Stable Diffusion backend found on your system.")
print("\nAvailable backends to install:\n")
for i, b in enumerate(BACKENDS, 1):
print(f" {i}) {b['name']}")
print(f" {b['repo']}")
print()
while True:
choice = input("Enter number to install (or q to quit): ").strip().lower()
if choice == "q":
sys.exit(0)
if choice.isdigit() and 1 <= int(choice) <= len(BACKENDS):
backend = BACKENDS[int(choice) - 1]
break
print(f" Please enter a number between 1 and {len(BACKENDS)}")
dest = os.path.join(os.path.expanduser("~"), backend["dir"])
confirm = input(f"\nInstall {backend['name']} to {dest}? [Y/n] ").strip().lower()
if confirm in ("n", "no"):
print("Aborted.")
sys.exit(0)
if os.path.isdir(dest):
print(f"\nFound existing {backend['name']} at {dest} — using it.")
else:
print(f"\nCloning {backend['repo']}...")
result = subprocess.run(["git", "clone", "--recursive", backend["repo"], dest], check=False)
if result.returncode != 0:
print("\nError: git clone failed. Is git installed?")
sys.exit(1)
print(f"\nInstalled to {dest}")
write_config("sd-path", dest)
write_config("sd-backend", backend["type"])
print(f"Saved to {CONFIG_FILE}")
print(f"\nRe-run your original command — no --sd-path needed.")
sys.exit(0)
def ensure_running(sd_url=None, sd_path=None, style="manga"):
config = read_config()
if not sd_path:
sd_path = config.get("sd-path")
backend = get_backend_for_path(sd_path)
if is_running(backend["ready_url"]):
return
if sd_path and os.path.isdir(sd_path):
from image import backend as backend_mod
backend_mod.setup(sd_path)
backend_mod.ensure_model(backend["type"], sd_path, style=style)
launch(sd_path, backend)
return
installed_path, found_backend = find_installed()
if installed_path:
print(f"\nFound {found_backend['name']} at {installed_path}")
write_config("sd-path", installed_path)
write_config("sd-backend", found_backend["type"])
print(f"Saved to {CONFIG_FILE}")
from image import backend as backend_mod
backend_mod.setup(installed_path)
backend_mod.ensure_model(found_backend["type"], installed_path, style=style)
launch(installed_path, found_backend)
return
prompt_install()
def shutdown():
global _sd_process
if _sd_process:
_sd_process.terminate()
_sd_process = None