Comfy Apps: 12-workflow image-gen web GUI + payment frontend

- Flask frontend + serialized GPU worker + BTCPay invoice/webhook
- 12 example images rendered from the real ComfyUI workflows
- fix: ipadapter workflow uses modern IPAdapter node (was broken IPAdapterApply)
- helper scripts: generate/optimize examples
- README with deploy recipe
This commit is contained in:
drjones
2026-08-27 20:37:11 -07:00
commit 55bea5757c
23 changed files with 800 additions and 0 deletions

238
app.py Normal file
View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
comfy-web — web GUI + payment frontend for the ComfyUI app registry.
Enumerates comfy_apps.APPS, takes payment in sats via BTCPay, runs the app
against nightmare's ComfyUI (GPU), and delivers the image.
Run: python3 app.py (dev, port 5188)
"""
import os, json, time, sqlite3, threading, uuid, uuid as _uuid
import urllib.request, urllib.parse
from flask import Flask, request, render_template, redirect, url_for, jsonify, send_from_directory, abort
import sys
sys.path.insert(0, os.path.expanduser("~/auto-publisher/core"))
import comfy_apps as CA
import comfy_workflows_lib as CL
# ---------------------------------------------------------------------------
# config
# ---------------------------------------------------------------------------
BASE = os.path.expanduser("~/comfy-web")
DB = os.path.join(BASE, "jobs.db")
UPLOAD_DIR = os.path.join(BASE, "uploads")
RESULT_DIR = os.path.join(BASE, "results")
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(RESULT_DIR, exist_ok=True)
# BTCPay (CT133). API calls use the LAN IP (public domain has CF 1010 block for non-browser);
# checkout redirects use the public domain. Empty => MOCK mode.
BTCPAY_API = "https://10.30.20.140" # server-to-server API (self-signed, ssl unverified)
BTCPAY_PUBLIC = "https://btcpay.thetempleofdoom.com" # customer-facing checkout
BTCPAY_STORE_ID = "6AZoSSdADJ8NndwLDhhh3geg6BgS487ZCkW3msX2mT1"
BTCPAY_API_KEY = "18ebeb23d41e971e7a133479a7c0644f78a337fc"
WEBHOOK_URL = "https://nebula.thetempleofdoom.com"
MOCK_PAYMENTS = not (BTCPAY_API and BTCPAY_STORE_ID and BTCPAY_API_KEY)
import ssl
_CTX = ssl.create_default_context()
_CTX.check_hostname = False
_CTX.verify_mode = ssl.CERT_NONE
app = Flask(__name__)
# ---------------------------------------------------------------------------
# db
# ---------------------------------------------------------------------------
def db():
c = sqlite3.connect(DB)
c.row_factory = sqlite3.Row
return c
def init_db():
with db() as c:
c.execute("""CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
app TEXT, prompt TEXT, params TEXT,
status TEXT, -- queued | running | done | failed | awaiting_payment
btcpay_invoice TEXT, result_file TEXT, error TEXT,
created_at REAL, paid_at REAL, finished_at REAL
)""")
init_db()
# ---------------------------------------------------------------------------
# payment (BTCPay Greenfield API, or mock)
# ---------------------------------------------------------------------------
def create_invoice(job_id, app_name, sats):
price = CA.APPS[app_name]["price_sats"]
amount = f"{sats / 100_000_000:.8f}" # sats -> BTC
if MOCK_PAYMENTS:
return f"mock-{job_id}"
body = json.dumps({
"amount": amount, "currency": "BTC",
"metadata": {"job_id": job_id, "app": app_name},
"checkout": {"redirectURL": f"{WEBHOOK_URL}/job/{job_id}", "requiresRefundEmail": False},
}).encode()
req = urllib.request.Request(
f"{BTCPAY_API}/api/v1/stores/{BTCPAY_STORE_ID}/invoices",
data=body, headers={"Authorization": f"token {BTCPAY_API_KEY}", "Content-Type": "application/json"},
)
r = urllib.request.urlopen(req, timeout=30, context=_CTX)
inv = json.loads(r.read())
return inv["id"]
def settle_job(job_id):
"""Mark paid and queue for execution (idempotent — no double-credit)."""
with db() as c:
cur = c.execute("UPDATE jobs SET status='queued', paid_at=? WHERE id=? AND status='awaiting_payment'",
(time.time(), job_id))
if cur.rowcount == 0:
return # already settled (replay guard)
with db() as c:
row = c.execute("SELECT id, app, prompt, params FROM jobs WHERE id=?", (job_id,)).fetchone()
if row:
_queue.append(row)
# ---------------------------------------------------------------------------
# job worker (serializes the GPU)
# ---------------------------------------------------------------------------
_queue = []
def _worker():
while True:
if not _queue:
time.sleep(1)
continue
row = _queue.pop(0)
job_id, app_name, prompt, params = row["id"], row["app"], row["prompt"], json.loads(row["params"] or "{}")
try:
with db() as c:
c.execute("UPDATE jobs SET status='running' WHERE id=?", (job_id,))
# free the GPU (unload Ollama + switch to ComfyUI OC) before rendering
CL.free_gpu()
# run the app
fn = CA.APPS[app_name]["fn"]
params = dict(params)
if prompt:
params["prompt"] = prompt
wf = fn(**params)
pid, err = CL.submit(wf)
if err:
raise RuntimeError(str(err)[:400])
paths = CL.fetch_result(pid, timeout=900)
if not paths:
raise RuntimeError("job timed out")
# move result into results dir under job id
src = paths[0]
ext = os.path.splitext(src)[1] or ".png"
dest = os.path.join(RESULT_DIR, job_id + ext)
os.rename(src, dest)
with db() as c:
c.execute("UPDATE jobs SET status='done', result_file=?, finished_at=? WHERE id=?",
(os.path.basename(dest), time.time(), job_id))
CL.restore_gpu()
except Exception as e:
CL.restore_gpu()
with db() as c:
c.execute("UPDATE jobs SET status='failed', error=?, finished_at=? WHERE id=?",
(str(e)[:500], time.time(), job_id))
threading.Thread(target=_worker, daemon=True).start()
# ---------------------------------------------------------------------------
# routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
apps = CA.list_apps()
return render_template("index.html", apps=apps)
@app.route("/app/<name>")
def app_detail(name):
if name not in CA.APPS:
abort(404)
meta = CA.list_apps()[name]
return render_template("app.html", name=name, meta=meta)
@app.route("/run", methods=["POST"])
def run_job():
app_name = request.form.get("app")
if app_name not in CA.APPS:
abort(400)
prompt = request.form.get("prompt", "")
# gather params + handle uploaded source image
params = {}
img = request.files.get("image")
if img and img.filename:
local = os.path.join(UPLOAD_DIR, uuid.uuid4().hex + os.path.splitext(img.filename)[1])
img.save(local)
remote = os.path.basename(local)
CL.upload_image(local, name=remote)
params["image_name"] = remote
# for inpaint, a mask may come too
mask = request.files.get("mask")
if mask and mask.filename:
mlocal = os.path.join(UPLOAD_DIR, uuid.uuid4().hex + os.path.splitext(mask.filename)[1])
mask.save(mlocal)
CL.upload_image(mlocal, name=os.path.basename(mlocal))
params["mask_name"] = os.path.basename(mlocal)
# collect numeric/select params
for key in ("seed", "steps", "denoise", "upscaler", "control_type", "weight", "left", "right", "top", "bottom",
"w", "h", "cfg", "strength"):
if key in request.form and request.form[key] != "":
v = request.form[key]
params[key] = int(v) if v.lstrip("-").isdigit() else v
if request.form.get("negative", "").strip():
params["negative"] = request.form["negative"].strip()
job_id = uuid.uuid4().hex
with db() as c:
c.execute("INSERT INTO jobs (id, app, prompt, params, status, created_at) VALUES (?,?,?,?,?,?)",
(job_id, app_name, prompt, json.dumps(params), "awaiting_payment", time.time()))
# payment
sats = CA.APPS[app_name]["price_sats"]
if MOCK_PAYMENTS:
settle_job(job_id)
return redirect(url_for("job", job_id=job_id))
inv = create_invoice(job_id, app_name, sats)
with db() as c:
c.execute("UPDATE jobs SET btcpay_invoice=? WHERE id=?", (inv, job_id))
return redirect(f"{BTCPAY_PUBLIC}/i/{inv}")
@app.route("/webhook/btcpay", methods=["POST"])
def btcpay_webhook():
data = request.get_json(force=True)
# metadata is TOP-LEVEL in the BTCPay webhook payload, and job_id is nested inside it
if data.get("type") in ("InvoiceSettled", "InvoiceProcessing"):
metadata = (data.get("metadata") or {})
job_id = metadata.get("job_id") or data.get("job_id")
if job_id:
settle_job(job_id)
return "", 200
@app.route("/job/<job_id>")
def job(job_id):
with db() as c:
row = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
if not row:
abort(404)
return render_template("job.html", job=row)
@app.route("/jobs")
def jobs():
with db() as c:
rows = c.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT 100").fetchall()
return render_template("jobs.html", jobs=rows)
@app.route("/image/<filename>")
def image(filename):
return send_from_directory(RESULT_DIR, filename)
@app.route("/health")
def health():
return jsonify({"ok": True, "apps": len(CA.APPS), "mock_payments": MOCK_PAYMENTS})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5188, debug=False)