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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# runtime data / secrets
|
||||
jobs.db
|
||||
jobs.db-journal
|
||||
results/
|
||||
uploads/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
32
README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Comfy Apps — AI Image Studio
|
||||
|
||||
Web GUI + Bitcoin payment frontend for the ComfyUI app registry (12 apps).
|
||||
|
||||
## Stack
|
||||
- **Frontend:** Flask (`app.py`), port 5188, served behind nginx on CT760 (`nebula.thetempleofdoom.com`)
|
||||
- **GPU:** nightmare (`.128`, RTX 4080 SUPER), ComfyUI at `:8188`
|
||||
- **Payments:** BTCPay (CT133), store `6AZoSSd`, on-chain sats
|
||||
- **App registry:** `comfy_apps.py` + `comfy_workflows_lib.py` (lives in `~/auto-publisher/core`, separate repo)
|
||||
|
||||
## 12 apps
|
||||
`logo`, `hero`, `banner`, `article`, `portrait`, `upscale`, `inpaint`, `outpaint`, `controlnet`, `ipadapter`, `face_swap`, `restore_face`
|
||||
|
||||
## Layout
|
||||
- `app.py` — Flask frontend + serialized GPU worker + BTCPay invoice/webhook
|
||||
- `templates/` — index (card grid), app detail, job, jobs gallery
|
||||
- `static/style.css` — theme
|
||||
- `static/examples/` — one example image per app (generated by `generate_examples.py`)
|
||||
- `generate_examples.py` — renders all 12 example images against nightmare
|
||||
- `optimize_examples.py` — resizes + compresses the rendered PNGs into web JPEGs
|
||||
- `gen_ipadapter.py` — one-off regenerate for a single app
|
||||
|
||||
## Deploy (CT760)
|
||||
```bash
|
||||
cd ~/comfy-web && tar czf /tmp/cw.tar.gz templates/index.html templates/app.html static/style.css static/examples/
|
||||
scp /tmp/cw.tar.gz root@10.30.20.85:/tmp/
|
||||
ssh root@10.30.20.85 "pct push 760 /tmp/cw.tar.gz /tmp/cw.tar.gz && pct exec 760 -- bash -c 'cd /root/comfy-web && tar xzf /tmp/cw.tar.gz && systemctl restart comfy-web.service'"
|
||||
```
|
||||
> Live code lives at `/root/comfy-web/` on CT760 (not MacBook). `comfy_apps.py` changes go to `/root/auto-publisher/core/`.
|
||||
|
||||
## Note
|
||||
`app.py` contains the BTCPay API key inline (private LAN Gitea — matches house convention).
|
||||
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)
|
||||
44
gen_ipadapter.py
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the ipadapter example using the fixed workflow."""
|
||||
import os, sys, shutil
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.expanduser("~/auto-publisher/core"))
|
||||
import comfy_apps as CA
|
||||
import comfy_workflows_lib as CL
|
||||
|
||||
BASE = os.path.expanduser("~/comfy-web")
|
||||
EXAMPLES = os.path.join(BASE, "static", "examples")
|
||||
|
||||
# free GPU + re-upload the style image (in case it was cleaned up)
|
||||
print("freeing GPU...")
|
||||
CL.free_gpu()
|
||||
CL.upload_image(os.path.join(BASE, "static", "examples", "hero.jpg"), name="style_hero.jpg")
|
||||
print("uploaded style image")
|
||||
|
||||
print("generating ipadapter...")
|
||||
wf = CA.app_ipadapter(
|
||||
style_image="style_hero.jpg",
|
||||
prompt="a cozy cottage by a lake at sunset, same painterly style",
|
||||
steps=20, cfg=7.0, weight=1.0,
|
||||
)
|
||||
pid, err = CL.submit(wf)
|
||||
if err:
|
||||
print("SUBMIT ERROR:", err)
|
||||
sys.exit(1)
|
||||
print("pid", pid)
|
||||
paths = CL.fetch_result(pid, timeout=900)
|
||||
if not paths:
|
||||
print("TIMEOUT")
|
||||
sys.exit(1)
|
||||
|
||||
# optimize + save
|
||||
src = paths[0]
|
||||
im = Image.open(src).convert("RGB")
|
||||
im.thumbnail((1024, 1024), Image.LANCZOS)
|
||||
dest = os.path.join(EXAMPLES, "ipadapter.jpg")
|
||||
im.save(dest, "JPEG", quality=87, optimize=True, progressive=True)
|
||||
print("saved", dest, im.size, os.path.getsize(dest)//1024, "KB")
|
||||
|
||||
CL.restore_gpu()
|
||||
print("done")
|
||||
160
generate_examples.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate one example image per Comfy App workflow and save to static/examples/.
|
||||
Uses the real comfy_apps functions + comfy_workflows_lib builders against nightmare.
|
||||
"""
|
||||
import os, sys, shutil, time, json
|
||||
from PIL import Image, ImageFilter, ImageDraw, ImageOps
|
||||
|
||||
sys.path.insert(0, os.path.expanduser("~/auto-publisher/core"))
|
||||
import comfy_apps as CA
|
||||
import comfy_workflows_lib as CL
|
||||
|
||||
BASE = os.path.expanduser("~/comfy-web")
|
||||
EXAMPLES = os.path.join(BASE, "static", "examples")
|
||||
os.makedirs(EXAMPLES, exist_ok=True)
|
||||
|
||||
def upload(local, name):
|
||||
CL.upload_image(local, name=name)
|
||||
print(f" uploaded {name}")
|
||||
|
||||
def save_result(app, local_path):
|
||||
if not local_path or not os.path.exists(local_path):
|
||||
print(f" [{app}] NO RESULT")
|
||||
return False
|
||||
ext = os.path.splitext(local_path)[1] or ".png"
|
||||
dest = os.path.join(EXAMPLES, app + ext)
|
||||
shutil.copy(local_path, dest)
|
||||
print(f" [{app}] -> {dest} ({os.path.getsize(dest)//1024} KB)")
|
||||
return True
|
||||
|
||||
def gen(app, fn, **params):
|
||||
print(f"[{app}] building workflow...")
|
||||
wf = fn(**params)
|
||||
pid, err = CL.submit(wf)
|
||||
if err:
|
||||
print(f"[{app}] SUBMIT ERROR: {json.dumps(err)[:300]}")
|
||||
return None
|
||||
print(f"[{app}] pid={pid}, rendering...")
|
||||
paths = CL.fetch_result(pid, timeout=900)
|
||||
if not paths:
|
||||
print(f"[{app}] TIMEOUT/FAILED")
|
||||
return None
|
||||
save_result(app, paths[0])
|
||||
return paths[0]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# free GPU first
|
||||
# ---------------------------------------------------------------------------
|
||||
print("freeing GPU (unload ollama, apply comfy OC)...")
|
||||
CL.free_gpu()
|
||||
time.sleep(2)
|
||||
|
||||
results = {}
|
||||
|
||||
# 1. Text-to-image bases
|
||||
print("\n=== BASE T2I ===")
|
||||
results["logo"] = gen("logo", CA.app_logo,
|
||||
prompt="minimalist geometric logo mark of a phoenix rising from flames, gold metallic on deep black, symmetrical vector emblem, clean lines, no text",
|
||||
steps=8)
|
||||
results["hero"] = gen("hero", CA.app_hero,
|
||||
prompt="epic cinematic fantasy landscape, floating islands with waterfalls at golden sunset, volumetric god rays, dramatic clouds, ultra detailed",
|
||||
steps=20)
|
||||
results["article"] = gen("article", CA.app_article,
|
||||
prompt="photorealistic over-the-shoulder shot of a barista pouring latte art in a sunlit cafe, shallow depth of field, morning light, editorial photography",
|
||||
steps=8)
|
||||
results["portrait"] = gen("portrait", CA.app_portrait,
|
||||
prompt="professional studio portrait of a woman in her 30s, soft window light, warm tones, sharp focus, 85mm, head and shoulders",
|
||||
steps=8)
|
||||
|
||||
# upload base images to ComfyUI input/ for the derived apps
|
||||
print("\n=== UPLOADING BASES ===")
|
||||
names = {}
|
||||
for app, p in results.items():
|
||||
if p and os.path.exists(p):
|
||||
n = f"base_{app}.png"
|
||||
upload(p, n)
|
||||
names[app] = n
|
||||
|
||||
# make a low-res version of the portrait for upscale/restore_face demo
|
||||
lowres_path = None
|
||||
if results.get("portrait"):
|
||||
im = Image.open(results["portrait"]).convert("RGB")
|
||||
im = im.resize((192, 256), Image.LANCZOS)
|
||||
lowres_path = os.path.join(BASE, "uploads", "base_lowres.png")
|
||||
im.save(lowres_path)
|
||||
upload(lowres_path, "base_lowres.png")
|
||||
print(" created low-res portrait (192x256) for upscale demo")
|
||||
|
||||
# make an inpaint mask (white blob on black) for the article image
|
||||
mask_path = os.path.join(BASE, "uploads", "inpaint_mask.png")
|
||||
if results.get("article"):
|
||||
a = Image.open(results["article"])
|
||||
m = Image.new("L", a.size, 0)
|
||||
d = ImageDraw.Draw(m)
|
||||
w, h = a.size
|
||||
d.ellipse([w*0.35, h*0.25, w*0.75, h*0.75], fill=255)
|
||||
m.save(mask_path)
|
||||
upload(mask_path, "inpaint_mask.png")
|
||||
print(" created inpaint mask")
|
||||
|
||||
# make a canny-ish control image from the article
|
||||
control_path = os.path.join(BASE, "uploads", "control_canny.png")
|
||||
if results.get("article"):
|
||||
a = Image.open(results["article"]).convert("L")
|
||||
e = a.filter(ImageFilter.FIND_EDGES)
|
||||
e = ImageOps.autocontrast(e)
|
||||
e.save(control_path)
|
||||
upload(control_path, "control_canny.png")
|
||||
print(" created control edge image")
|
||||
|
||||
# 2. Derived apps
|
||||
print("\n=== DERIVED ===")
|
||||
if names.get("hero"):
|
||||
results["banner"] = gen("banner", CA.app_banner,
|
||||
image_name=names["hero"], prompt="epic cinematic fantasy landscape, floating islands, golden sunset, seamless extension",
|
||||
left=192, right=192, steps=25)
|
||||
|
||||
if names.get("portrait"):
|
||||
results["outpaint"] = gen("outpaint", CA.app_outpaint,
|
||||
image_name=names["portrait"], prompt="extend the studio backdrop with soft bokeh lights, seamless",
|
||||
left=96, right=96, top=64, bottom=64, steps=25)
|
||||
|
||||
if names.get("article") and os.path.exists(mask_path):
|
||||
results["inpaint"] = gen("inpaint", CA.app_inpaint,
|
||||
image_name=names["article"], mask_name="inpaint_mask.png",
|
||||
prompt="a glowing golden espresso machine with steam", steps=20, denoise=0.7)
|
||||
|
||||
if lowres_path:
|
||||
results["upscale"] = gen("upscale", CA.app_upscale,
|
||||
image_name="base_lowres.png", upscaler="4x-UltraSharp.pth")
|
||||
results["restore_face"] = gen("restore_face", CA.app_restore_face,
|
||||
image_name="base_lowres.png", upscaler="RealESRGAN_x4.pth")
|
||||
|
||||
if names.get("hero"):
|
||||
results["ipadapter"] = gen("ipadapter", CA.app_ipadapter,
|
||||
style_image=names["hero"],
|
||||
prompt="a cozy cottage by a lake at sunset, same painterly style", steps=20, cfg=7.0)
|
||||
|
||||
if os.path.exists(control_path):
|
||||
results["controlnet"] = gen("controlnet", CA.app_controlnet,
|
||||
prompt="a detailed illustration of a barista in a cafe, line art", control_image="control_canny.png",
|
||||
control_type="canny", steps=20, cfg=7.0)
|
||||
|
||||
if names.get("portrait"):
|
||||
try:
|
||||
results["face_swap"] = gen("face_swap", CA.app_face_swap,
|
||||
face_image=names["portrait"],
|
||||
prompt="a samurai warrior in full ornate armor, studio lighting, dramatic", steps=20)
|
||||
except Exception as e:
|
||||
print(f"[face_swap] ERROR: {e}")
|
||||
|
||||
# restore GPU
|
||||
print("\nrestoring GPU (ollama OC profile)...")
|
||||
CL.restore_gpu()
|
||||
|
||||
# summary
|
||||
print("\n=== SUMMARY ===")
|
||||
for app, p in results.items():
|
||||
status = "OK" if (p and os.path.exists(p)) else "MISSING"
|
||||
print(f" {app:14s} {status}")
|
||||
15
optimize_examples.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Optimize generated example PNGs into web-friendly JPEGs (<=1024px)."""
|
||||
import os, glob
|
||||
from PIL import Image
|
||||
|
||||
EXAMPLES = os.path.expanduser("~/comfy-web/static/examples")
|
||||
|
||||
for png in sorted(glob.glob(os.path.join(EXAMPLES, "*.png"))):
|
||||
im = Image.open(png).convert("RGB")
|
||||
im.thumbnail((1024, 1024), Image.LANCZOS)
|
||||
jpg = os.path.splitext(png)[0] + ".jpg"
|
||||
im.save(jpg, "JPEG", quality=87, optimize=True, progressive=True)
|
||||
kb = os.path.getsize(jpg) // 1024
|
||||
os.remove(png) # drop the giant full-res png
|
||||
print(f"{os.path.basename(jpg):22s} {im.size} {kb}KB")
|
||||
BIN
static/examples/article.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
static/examples/banner.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
static/examples/controlnet.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
static/examples/face_swap.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
static/examples/hero.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
static/examples/inpaint.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
static/examples/ipadapter.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/examples/logo.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
static/examples/outpaint.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
static/examples/portrait.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
static/examples/restore_face.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
static/examples/upscale.jpg
Normal file
|
After Width: | Height: | Size: 154 KiB |
68
static/style.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root{
|
||||
--bg:#0a0a0f; --bg2:#12121a; --card:#16161f; --card2:#1d1d28;
|
||||
--text:#e8e8f0; --muted:#8a8a9a; --accent:#7c5cff; --accent2:#00e5c7;
|
||||
--gold:#ffb347; --danger:#ff5470; --ok:#3ddc84;
|
||||
--r:16px; --glow:0 0 24px rgba(124,92,255,.35);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||
min-height:100vh;background-image:radial-gradient(1200px 600px at 80% -10%,rgba(124,92,255,.15),transparent),
|
||||
radial-gradient(900px 500px at -10% 110%,rgba(0,229,199,.10),transparent)}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
.wrap{max-width:1200px;margin:0 auto;padding:0 24px}
|
||||
header{padding:28px 0;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px}
|
||||
.logo{font-size:26px;font-weight:800;letter-spacing:-.5px}
|
||||
.logo span{background:linear-gradient(90deg,var(--accent),var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.nav a{margin-left:20px;color:var(--muted);font-weight:600}
|
||||
.nav a:hover{color:var(--text)}
|
||||
.hero{padding:50px 0 30px}
|
||||
.hero h1{font-size:46px;font-weight:800;letter-spacing:-1.5px;line-height:1.05}
|
||||
.hero h1 b{background:linear-gradient(90deg,var(--accent),var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.hero p{color:var(--muted);font-size:18px;margin-top:14px;max-width:620px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:20px;padding:20px 0 60px}
|
||||
.card{background:var(--card);border:1px solid #ffffff0d;border-radius:var(--r);padding:22px;transition:.2s;position:relative;overflow:hidden}
|
||||
.card:hover{transform:translateY(-4px);border-color:var(--accent);box-shadow:var(--glow)}
|
||||
.card .emoji{font-size:34px;margin-bottom:12px;width:100%;height:150px;align-items:center;justify-content:center}
|
||||
.card .thumb{width:100%;height:150px;border-radius:12px;overflow:hidden;margin-bottom:14px;background:var(--bg2);border:1px solid #ffffff0d}
|
||||
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block;transition:.25s}
|
||||
.card:hover .thumb img{transform:scale(1.05)}
|
||||
.example{max-width:680px;margin:0 0 22px}
|
||||
.example img{width:100%;border-radius:var(--r);border:1px solid #ffffff14;display:block}
|
||||
.example-caption{color:var(--muted);font-size:12.5px;margin-top:8px;text-align:center}
|
||||
.card h3{font-size:19px;margin-bottom:6px}
|
||||
.card .desc{color:var(--muted);font-size:13.5px;line-height:1.5;min-height:40px}
|
||||
.card .price{margin-top:16px;font-weight:700;font-size:15px;color:var(--gold)}
|
||||
.card .models{margin-top:6px;color:var(--muted);font-size:11.5px}
|
||||
.card .go{display:inline-block;margin-top:16px;padding:9px 16px;border-radius:10px;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent2));color:#000;font-weight:700;font-size:13.5px}
|
||||
form.panel{background:var(--card);border:1px solid #ffffff0d;border-radius:var(--r);padding:28px;max-width:680px;margin:10px 0 60px}
|
||||
form.panel label{display:block;font-size:13px;color:var(--muted);margin:16px 0 6px;font-weight:600}
|
||||
form.panel textarea,input[type=text],input[type=number],select{width:100%;background:var(--bg2);border:1px solid #ffffff14;
|
||||
border-radius:10px;padding:12px 14px;color:var(--text);font-size:15px;font-family:inherit}
|
||||
form.panel textarea{min-height:110px;resize:vertical}
|
||||
form.panel .btn{display:inline-block;margin-top:22px;padding:13px 26px;border-radius:12px;border:none;cursor:pointer;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent2));color:#000;font-weight:800;font-size:15px}
|
||||
.status{display:inline-block;padding:5px 12px;border-radius:20px;font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px}
|
||||
.status.done{background:var(--ok);color:#000}.status.running{background:var(--accent);color:#fff}
|
||||
.status.queued{background:var(--gold);color:#000}.status.failed{background:var(--danger);color:#fff}
|
||||
.status.awaiting_payment{background:#555;color:#fff}
|
||||
.result img{max-width:100%;border-radius:var(--r);border:1px solid #ffffff14}
|
||||
.badge{display:inline-block;background:#ffffff10;border-radius:6px;padding:2px 8px;font-size:11px;color:var(--muted);margin:2px 3px 0 0}
|
||||
.muted{color:var(--muted)}
|
||||
.stats{display:flex;gap:28px;margin-top:30px;flex-wrap:wrap}
|
||||
.stat{display:flex;flex-direction:column;align-items:flex-start}
|
||||
.stat b{font-size:28px;font-weight:800;background:linear-gradient(90deg,var(--accent),var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.stat span{color:var(--muted);font-size:12.5px;margin-top:2px}
|
||||
.how{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px;padding:24px 0 10px}
|
||||
.step{background:var(--card);border:1px solid #ffffff0d;border-radius:var(--r);padding:22px}
|
||||
.step .n{width:34px;height:34px;border-radius:50%;background:linear-gradient(90deg,var(--accent),var(--accent2));color:#000;font-weight:800;display:flex;align-items:center;justify-content:center;margin-bottom:12px;font-size:16px}
|
||||
.step h3{font-size:16px;margin-bottom:6px}
|
||||
.step p{color:var(--muted);font-size:13px;line-height:1.5}
|
||||
footer{border-top:1px solid #ffffff0d;padding:28px 0 40px;margin-top:20px;color:var(--muted);font-size:13px}
|
||||
.field{margin-bottom:2px}
|
||||
.row{display:flex;gap:14px;flex-wrap:wrap}
|
||||
.row .field{flex:1;min-width:130px}
|
||||
.row .field label{margin-top:8px}
|
||||
form.panel input[type=file]{width:100%;background:var(--bg2);border:1px solid #ffffff14;border-radius:10px;padding:11px 14px;color:var(--muted);font-size:14px;font-family:inherit}
|
||||
form.panel input[type=file]::file-selector-button{margin-right:12px;padding:6px 14px;border-radius:8px;border:none;background:var(--card2);color:var(--text);font-weight:600;cursor:pointer}
|
||||
form.panel input[type=file]:hover::file-selector-button{background:var(--accent);color:#000}
|
||||
137
templates/app.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{ name.replace('_',' ').title() }} — Comfy Apps</title>
|
||||
<link rel="stylesheet" href="/static/style.css"></head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo"><a href="/">Comfy<span>Apps</span></a></div>
|
||||
<div class="nav"><a href="/jobs">Gallery</a></div>
|
||||
</header>
|
||||
|
||||
<div class="hero" style="padding-bottom:10px">
|
||||
<h1>{{ name.replace('_',' ').title() }}</h1>
|
||||
<p>{{ meta.desc }} — <b style="color:var(--gold)">{{ meta.price_sats }} sats</b></p>
|
||||
</div>
|
||||
|
||||
<div class="example">
|
||||
<img src="/static/examples/{{ name }}.jpg" alt="{{ name }} example" onerror="this.parentElement.style.display='none'">
|
||||
<div class="example-caption">Example output — generated with this workflow</div>
|
||||
</div>
|
||||
|
||||
<form class="panel" action="/run" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="app" value="{{ name }}">
|
||||
|
||||
{% set is_t2i = name in ['logo','hero','article','portrait'] %}
|
||||
{% set is_img = name in ['upscale','inpaint','outpaint','restore_face','ipadapter','controlnet','face_swap','banner'] %}
|
||||
{% set is_flux = name in ['hero','banner','inpaint','outpaint'] %}
|
||||
|
||||
{% if is_img %}
|
||||
<div class="field">
|
||||
<label>{% if name in ['ipadapter'] %}Style image{% elif name in ['controlnet'] %}Control image (pose/edge/depth){% elif name in ['face_swap'] %}Face to swap{% elif name == 'banner' %}Image to extend{% else %}Source image{% endif %}</label>
|
||||
<input type="file" name="image" accept="image/*" required>
|
||||
{% if name == 'inpaint' %}
|
||||
<label style="margin-top:12px">Mask (white = edit this region)</label>
|
||||
<input type="file" name="mask" accept="image/*" required>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="field">
|
||||
<label>Prompt</label>
|
||||
<textarea name="prompt" placeholder="Describe what you want..."></textarea>
|
||||
</div>
|
||||
|
||||
{% if is_t2i or name in ['controlnet','ipadapter','face_swap'] %}
|
||||
<div class="field">
|
||||
<label>Negative prompt <span class="muted">(what to avoid)</span></label>
|
||||
<textarea name="negative" placeholder="blurry, low quality, extra fingers..." style="min-height:56px"></textarea>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_t2i %}
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Width</label>
|
||||
<input type="number" name="w" value="{{ 1024 if name=='logo' else (1344 if name=='hero' else (1152 if name=='article' else 768)) }}" min="256" max="2048" step="64">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Height</label>
|
||||
<input type="number" name="h" value="{{ 1024 if name=='logo' else (768 if name=='hero' else (768 if name=='article' else 1024)) }}" min="256" max="2048" step="64">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if name == 'banner' or name == 'outpaint' %}
|
||||
<div class="row">
|
||||
<div class="field"><label>Extend left</label><input type="number" name="left" value="192" min="0" max="1024" step="64"></div>
|
||||
<div class="field"><label>Extend right</label><input type="number" name="right" value="192" min="0" max="1024" step="64"></div>
|
||||
<div class="field"><label>Extend top</label><input type="number" name="top" value="0" min="0" max="1024" step="64"></div>
|
||||
<div class="field"><label>Extend bottom</label><input type="number" name="bottom" value="0" min="0" max="1024" step="64"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if name == 'upscale' or name == 'restore_face' or name == 'logo' %}
|
||||
<div class="field">
|
||||
<label>Upscaler</label>
|
||||
<select name="upscaler">
|
||||
<option>4x-UltraSharp.pth</option><option>RealESRGAN_x4.pth</option>
|
||||
<option>4x_foolhardy_Remacri.pth</option><option>4x_NMKD-Siax_200k.pth</option>
|
||||
<option>4x_NMKD-Superscale-SP_178000_G.pth</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if name == 'controlnet' %}
|
||||
<div class="field">
|
||||
<label>Control type</label>
|
||||
<select name="control_type">
|
||||
<option>openpose</option><option>canny</option><option>depth</option>
|
||||
<option>lineart</option><option>tile</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Steps</label>
|
||||
<input type="number" name="steps" value="{% if is_flux or name in ['controlnet','ipadapter','face_swap'] %}20{% else %}8{% endif %}" min="1" max="60">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Seed <span class="muted">(blank = random)</span></label>
|
||||
<input type="number" name="seed" placeholder="random">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if name in ['inpaint','outpaint'] %}
|
||||
<div class="field">
|
||||
<label>Denoise <span class="muted">(how much to change — {{ '0.7' if name=='inpaint' else '0.85' }} default)</span></label>
|
||||
<input type="number" name="denoise" value="{{ '0.7' if name=='inpaint' else '0.85' }}" min="0" max="1" step="0.05">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if name in ['controlnet','ipadapter'] %}
|
||||
<div class="field">
|
||||
<label>CFG scale <span class="muted">(prompt adherence)</span></label>
|
||||
<input type="number" name="cfg" value="7.0" min="1" max="15" step="0.5">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if name == 'controlnet' %}
|
||||
<div class="field">
|
||||
<label>Control strength</label>
|
||||
<input type="number" name="strength" value="1.0" min="0" max="2" step="0.1">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if name in ['ipadapter','face_swap'] %}
|
||||
<div class="field">
|
||||
<label>{% if name=='ipadapter' %}Style weight{% else %}Identity weight{% endif %}</label>
|
||||
<input type="number" name="weight" value="{{ '1.0' if name=='ipadapter' else '0.8' }}" min="0" max="1.5" step="0.05">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn" type="submit">Pay {{ meta.price_sats }} sats & generate</button>
|
||||
</form>
|
||||
</div>
|
||||
</body></html>
|
||||
50
templates/index.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Comfy Apps — AI Image Studio</title>
|
||||
<link rel="stylesheet" href="/static/style.css"></head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo">Comfy<span>Apps</span></div>
|
||||
<div class="nav"><a href="#apps">Apps</a><a href="/jobs">Gallery</a></div>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<h1>Generate <b>anything</b>.<br>Pay in Bitcoin.</h1>
|
||||
<p>12 AI image apps running on a local RTX 4080 — logos, heroes, face swaps, upscales, style transfers. Pay a few sats, get your image in under a minute. No account. No tracking.</p>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>12</b><span>AI apps</span></div>
|
||||
<div class="stat"><b>4080</b><span>local GPU</span></div>
|
||||
<div class="stat"><b>₿</b><span>sats-only</span></div>
|
||||
<div class="stat"><b>~60s</b><span>per render</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="how" id="how">
|
||||
<div class="step"><div class="n">1</div><h3>Pick an app</h3><p>Logo, hero, face swap, upscale — whatever you need.</p></div>
|
||||
<div class="step"><div class="n">2</div><h3>Pay sats</h3><p>Bitcoin on-chain, no account. A few thousand sats.</p></div>
|
||||
<div class="step"><div class="n">3</div><h3>Get your image</h3><p>The 4080 renders it in ~60s. Download and go.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="apps">
|
||||
{% set emoji = {'logo':'🎨','hero':'🎬','banner':'🖼️','article':'📰','portrait':'🧑','upscale':'🔍','inpaint':'🖌️','outpaint':'↔️','controlnet':'🦴','ipadapter':'🎭','face_swap':'👤','restore_face':'✨'} %}
|
||||
{% for name, meta in apps.items() %}
|
||||
<div class="card">
|
||||
<div class="thumb">
|
||||
<img src="/static/examples/{{ name }}.jpg" alt="{{ name }}" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
|
||||
<div class="emoji" style="display:none">{{ emoji.get(name,'✨') }}</div>
|
||||
</div>
|
||||
<h3>{{ name.replace('_',' ').title() }}</h3>
|
||||
<div class="desc">{{ meta.desc }}</div>
|
||||
<div class="price">{{ meta.price_sats }} sats</div>
|
||||
<div class="models">{% for m in meta.models %}<span class="badge">{{ m }}</span>{% endfor %}</div>
|
||||
<a class="go" href="/app/{{ name }}">Create →</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Comfy Apps · self-hosted · Bitcoin only · no accounts, no tracking</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body></html>
|
||||
24
templates/job.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Job — Comfy Apps</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<meta http-equiv="refresh" content="4"></head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header><div class="logo"><a href="/">Comfy<span>Apps</span></a></div><div class="nav"><a href="/jobs">Gallery</a></div></header>
|
||||
<div class="hero" style="padding-bottom:10px">
|
||||
<h1>Job <span class="status {{ job['status'] }}">{{ job['status'] }}</span></h1>
|
||||
<p class="muted">{{ job['app'].replace('_',' ').title() }} · created {{ '%.0f' % job['created_at'] }}</p>
|
||||
</div>
|
||||
{% if job['status'] == 'done' and job['result_file'] %}
|
||||
<div class="result" style="max-width:900px;margin-bottom:40px">
|
||||
<img src="/image/{{ job['result_file'] }}">
|
||||
</div>
|
||||
{% elif job['status'] == 'failed' %}
|
||||
<div style="max-width:680px"><p style="color:var(--danger)">{{ job['error'] }}</p></div>
|
||||
{% else %}
|
||||
<div style="max-width:680px"><p class="muted">Working on it… the 4080 is rendering your image. This page refreshes automatically.</p></div>
|
||||
{% endif %}
|
||||
<p class="muted" style="padding-bottom:40px">{% if job['prompt'] %}<em>“{{ job['prompt'] }}”</em>{% endif %}</p>
|
||||
</div>
|
||||
</body></html>
|
||||
24
templates/jobs.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Gallery — Comfy Apps</title>
|
||||
<link rel="stylesheet" href="/static/style.css"></head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header><div class="logo"><a href="/">Comfy<span>Apps</span></a></div><div class="nav"><a href="/">Home</a></div></header>
|
||||
<div class="hero" style="padding-bottom:10px"><h1>Gallery</h1></div>
|
||||
<div class="grid">
|
||||
{% for j in jobs %}
|
||||
<div class="card">
|
||||
<div class="emoji">🖼️</div>
|
||||
<h3>{{ j['app'].replace('_',' ').title() }}</h3>
|
||||
<div><span class="status {{ j['status'] }}">{{ j['status'] }}</span></div>
|
||||
{% if j['result_file'] %}
|
||||
<a href="/image/{{ j['result_file'] }}" style="display:block;margin-top:14px"><img src="/image/{{ j['result_file'] }}" style="width:100%;border-radius:10px"></a>
|
||||
{% else %}
|
||||
<p class="muted" style="margin-top:12px;font-size:13px">{% if j['prompt'] %}“{{ j['prompt'][:70] }}…”{% else %}in progress{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||