commit 55bea5757c12dc3b73cbe083584d73c899861b14 Author: drjones Date: Thu Aug 27 20:37:11 2026 -0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73a81ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# runtime data / secrets +jobs.db +jobs.db-journal +results/ +uploads/ +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..b615864 --- /dev/null +++ b/README.md @@ -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). diff --git a/app.py b/app.py new file mode 100644 index 0000000..01cbdea --- /dev/null +++ b/app.py @@ -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/") +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/") +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/") +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) diff --git a/gen_ipadapter.py b/gen_ipadapter.py new file mode 100644 index 0000000..e8dbc34 --- /dev/null +++ b/gen_ipadapter.py @@ -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") diff --git a/generate_examples.py b/generate_examples.py new file mode 100644 index 0000000..e577d4f --- /dev/null +++ b/generate_examples.py @@ -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}") diff --git a/optimize_examples.py b/optimize_examples.py new file mode 100644 index 0000000..66ddd1a --- /dev/null +++ b/optimize_examples.py @@ -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") diff --git a/static/examples/article.jpg b/static/examples/article.jpg new file mode 100644 index 0000000..08b96c9 Binary files /dev/null and b/static/examples/article.jpg differ diff --git a/static/examples/banner.jpg b/static/examples/banner.jpg new file mode 100644 index 0000000..0ae9d5b Binary files /dev/null and b/static/examples/banner.jpg differ diff --git a/static/examples/controlnet.jpg b/static/examples/controlnet.jpg new file mode 100644 index 0000000..149c3d2 Binary files /dev/null and b/static/examples/controlnet.jpg differ diff --git a/static/examples/face_swap.jpg b/static/examples/face_swap.jpg new file mode 100644 index 0000000..454939a Binary files /dev/null and b/static/examples/face_swap.jpg differ diff --git a/static/examples/hero.jpg b/static/examples/hero.jpg new file mode 100644 index 0000000..16a2f0b Binary files /dev/null and b/static/examples/hero.jpg differ diff --git a/static/examples/inpaint.jpg b/static/examples/inpaint.jpg new file mode 100644 index 0000000..40c4e58 Binary files /dev/null and b/static/examples/inpaint.jpg differ diff --git a/static/examples/ipadapter.jpg b/static/examples/ipadapter.jpg new file mode 100644 index 0000000..a263860 Binary files /dev/null and b/static/examples/ipadapter.jpg differ diff --git a/static/examples/logo.jpg b/static/examples/logo.jpg new file mode 100644 index 0000000..9822498 Binary files /dev/null and b/static/examples/logo.jpg differ diff --git a/static/examples/outpaint.jpg b/static/examples/outpaint.jpg new file mode 100644 index 0000000..7e46b27 Binary files /dev/null and b/static/examples/outpaint.jpg differ diff --git a/static/examples/portrait.jpg b/static/examples/portrait.jpg new file mode 100644 index 0000000..2c6f995 Binary files /dev/null and b/static/examples/portrait.jpg differ diff --git a/static/examples/restore_face.jpg b/static/examples/restore_face.jpg new file mode 100644 index 0000000..2ec961a Binary files /dev/null and b/static/examples/restore_face.jpg differ diff --git a/static/examples/upscale.jpg b/static/examples/upscale.jpg new file mode 100644 index 0000000..80ed12a Binary files /dev/null and b/static/examples/upscale.jpg differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d6e7305 --- /dev/null +++ b/static/style.css @@ -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} diff --git a/templates/app.html b/templates/app.html new file mode 100644 index 0000000..7cfc2e7 --- /dev/null +++ b/templates/app.html @@ -0,0 +1,137 @@ + + +{{ name.replace('_',' ').title() }} — Comfy Apps + + +
+
+ + +
+ +
+

{{ name.replace('_',' ').title() }}

+

{{ meta.desc }} — {{ meta.price_sats }} sats

+
+ +
+ {{ name }} example +
Example output — generated with this workflow
+
+ +
+ + + {% 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 %} +
+ + + {% if name == 'inpaint' %} + + + {% endif %} +
+ {% endif %} + +
+ + +
+ + {% if is_t2i or name in ['controlnet','ipadapter','face_swap'] %} +
+ + +
+ {% endif %} + + {% if is_t2i %} +
+
+ + +
+
+ + +
+
+ {% endif %} + + {% if name == 'banner' or name == 'outpaint' %} +
+
+
+
+
+
+ {% endif %} + + {% if name == 'upscale' or name == 'restore_face' or name == 'logo' %} +
+ + +
+ {% endif %} + + {% if name == 'controlnet' %} +
+ + +
+ {% endif %} + +
+
+ + +
+
+ + +
+
+ + {% if name in ['inpaint','outpaint'] %} +
+ + +
+ {% endif %} + + {% if name in ['controlnet','ipadapter'] %} +
+ + +
+ {% endif %} + + {% if name == 'controlnet' %} +
+ + +
+ {% endif %} + + {% if name in ['ipadapter','face_swap'] %} +
+ + +
+ {% endif %} + + +
+
+ diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..bf730da --- /dev/null +++ b/templates/index.html @@ -0,0 +1,50 @@ + + +Comfy Apps — AI Image Studio + + +
+
+ + +
+ +
+

Generate anything.
Pay in Bitcoin.

+

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.

+
+
12AI apps
+
4080local GPU
+
₿sats-only
+
~60sper render
+
+
+ +
+
1

Pick an app

Logo, hero, face swap, upscale — whatever you need.

+
2

Pay sats

Bitcoin on-chain, no account. A few thousand sats.

+
3

Get your image

The 4080 renders it in ~60s. Download and go.

+
+ +
+ {% set emoji = {'logo':'🎨','hero':'🎬','banner':'🖼️','article':'📰','portrait':'🧑','upscale':'🔍','inpaint':'🖌️','outpaint':'↔️','controlnet':'🦴','ipadapter':'🎭','face_swap':'👤','restore_face':'✨'} %} + {% for name, meta in apps.items() %} +
+
+ {{ name }} + +
+

{{ name.replace('_',' ').title() }}

+
{{ meta.desc }}
+
{{ meta.price_sats }} sats
+
{% for m in meta.models %}{{ m }}{% endfor %}
+ Create → +
+ {% endfor %} +
+ +
+

Comfy Apps · self-hosted · Bitcoin only · no accounts, no tracking

+
+
+ diff --git a/templates/job.html b/templates/job.html new file mode 100644 index 0000000..7abd597 --- /dev/null +++ b/templates/job.html @@ -0,0 +1,24 @@ + + +Job — Comfy Apps + + + +
+
+
+

Job {{ job['status'] }}

+

{{ job['app'].replace('_',' ').title() }} · created {{ '%.0f' % job['created_at'] }}

+
+ {% if job['status'] == 'done' and job['result_file'] %} +
+ +
+ {% elif job['status'] == 'failed' %} +

{{ job['error'] }}

+ {% else %} +

Working on it… the 4080 is rendering your image. This page refreshes automatically.

+ {% endif %} +

{% if job['prompt'] %}“{{ job['prompt'] }}”{% endif %}

+
+ diff --git a/templates/jobs.html b/templates/jobs.html new file mode 100644 index 0000000..5997eea --- /dev/null +++ b/templates/jobs.html @@ -0,0 +1,24 @@ + + +Gallery — Comfy Apps + + +
+
+

Gallery

+
+ {% for j in jobs %} +
+
🖼️
+

{{ j['app'].replace('_',' ').title() }}

+
{{ j['status'] }}
+ {% if j['result_file'] %} + + {% else %} +

{% if j['prompt'] %}“{{ j['prompt'][:70] }}…”{% else %}in progress{% endif %}

+ {% endif %} +
+ {% endfor %} +
+
+