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:
238
app.py
Normal file
238
app.py
Normal 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)
|
||||
Reference in New Issue
Block a user