Release 0.2.0: CivitAI domain compatibility

This commit is contained in:
pandawhore
2026-04-30 16:17:05 -06:00
parent 43f0659ab3
commit 2decebee77
4 changed files with 653 additions and 208 deletions
+129 -83
View File
@@ -1,22 +1,56 @@
# ComfyUI CivitAI ImagePlus
**Browse CivitAI images in ComfyUI, preview protected images, and edit prompts before you generate and pass to image 2 image.**
Compatible with ComfyUI 0.6+
**Browse CivitAI images in ComfyUI, preview protected images, edit prompts before generating, and pass selected images into img2img workflows.**
ComfyUI_CivitAI_ImagePlus adds a CivitAI gallery browser to ComfyUI, plus helper nodes for **image preview with I2I connector**, **prompt editing**, and **quick info**.
Nodes are populated instantly without a need to run the workflow.
**Tested on current stable ComfyUI releases (verified on 0.19.x at time of writing).**
ComfyUI_CivitAI_ImagePlus adds a CivitAI gallery browser to ComfyUI, plus helper nodes for:
- **image preview with img2img connector**
- **prompt editing**
- **quick info display**
Nodes are populated instantly without needing to run the workflow first.
---
## ✅ What you can do
- **Open a full-screen CivitAI gallery, display Most Reactions, Most Comments and Newest** from inside ComfyUI
- **Open a full-screen CivitAI gallery** from inside ComfyUI
- Browse images using:
- **Most Reactions**
- **Most Comments**
- **Newest**
- **Pick an image** and instantly:
- preview it (including **protected** images if you have an API key)
- preview it
- preview **protected** images if you have a valid CivitAI API key
- load its prompts into an editor
- see a handy info panel
- **Fetch by URL** (paste a CivitAI link)
- Use the preview image for **img2img**
- **Fetch by URL** using a CivitAI page link
- Use the selected preview image as input for **img2img** workflows
---
## 🌐 CivitAI domain compatibility
CivitAI now uses separate public-facing domains:
- `https://civitai.com`
- `https://civitai.red`
This plugin supports both for:
- **Fetch URL** input
- copied page links
- protected preview routing
The gallery includes a **Page Links** selector so you can choose:
- **Auto**
- **Civitai.com**
- **Civitai.red**
If you paste a CivitAI URL directly, the plugin will try to preserve that domain automatically.
---
@@ -28,75 +62,49 @@ Example workflows:
- [workflows/Gallery_image_to_image.json](https://github.com/CovertBannana/ComfyUI_CivitAI_ImagePlus/blob/main/workflows/Gallery_image_to_image.json)
- [workflows/Gallery_Z-image_Turbo.json](https://github.com/CovertBannana/ComfyUI_CivitAI_ImagePlus/blob/main/workflows/Gallery_Z-image_Turbo.json)
**Note:**
Gallery text to image and Gallery image to image workflows use ComfyUI core nodes plus this node pack.
Gallery Z-image Turbo workflow uses additional custome nodes.
Click the Fetch button to grab a random test image, the test image is available without an API key.
**Note:**
---
## 📸 Screenshots
Image2Image and Text2Image workflow examples.
Alternative 'Show Text' or 'Show Any' nodes can be used but they will not populate automatically, Run will need to initiated first.
They must also be compatible with Node2 requirements.
**Nodes**
To search for the nodes type Civit
<p align="center">
<a href="examples/search.jpg">
<img src="examples/search.jpg" width="300" alt="Image 2 Image example" />
</a>
<a href="examples/nodes.jpg">
<img src="examples/nodes.jpg" width="300" alt="Nodes" />
</a>
</p>
**Workflow examples**
<p align="center">
<a href="examples/gallery_T2I.jpg">
<img src="examples/gallery_T2I.jpg" width="300" alt="Text 2 Image example" />
</a>
<a href="examples/gallery_I2I.jpg">
<img src="examples/gallery_I2I.jpg" width="300" alt="Image 2 Image example" />
</a>
<a href="examples/Alt_nodes.jpg">
<img src="examples/Alt_nodes.jpg" width="300" alt="Alternate nodes can be used (show text, show any), they will not populate until Run is initiated." />
</a>
</p>
<p align="center"><em>Click any image to open full size.</em></p>
- The text-to-image and image-to-image workflows use ComfyUI core nodes plus this node pack.
- The Z-image Turbo workflow uses additional custom nodes.
---
## 🧩 Installation
### Option A — ComfyUI Manager
### Option A — ComfyUI Manager / Registry
1. Open **ComfyUI → Manager**
2. Install **ComfyUI-CivitAI-ImagePlus**
3. Restart ComfyUI
2. Search for **CivitAI ImagePlus**
3. Install the node pack
4. Restart ComfyUI
### Option B — Manual
1. Go to your ComfyUI `custom_nodes` folder:
- `ComfyUI/custom_nodes/`
2. Clone this repo:
```bash
git clone https://github.com/CovertBannana/ComfyUI_CivitAI_ImagePlus.git
```
```bash
git clone https://github.com/CovertBannana/ComfyUI_CivitAI_ImagePlus.git
```
3. Restart ComfyUI
### Option C — (Zip)
1. If your having trouble with Git, click the Code button, download as Zip and extract the folder to your ComfyUI custom_nodes folder.
2. Restart ComfyUI
### Option C — ZIP
1. If you are having trouble with Git, click the **Code** button on GitHub and download the repository as a ZIP
2. Extract the folder into your ComfyUI `custom_nodes` directory
3. Restart ComfyUI
---
## 🔑 CivitAI API Key (required for protected images)
To preview and fetch **protected** content, you need a CivitAI API key.
1. Create the file if it doesn't exist:
1. Create the file if it does not exist:
```
```txt
custom_nodes/ComfyUI_CivitAI_ImagePlus/api_key.txt
```
@@ -106,45 +114,63 @@ custom_nodes/ComfyUI_CivitAI_ImagePlus/api_key.txt
CIVITAI_API_KEY=YOUR_CIVITAI_API_KEY_HERE
```
✅ Restart ComfyUI after adding/changing the key.
✅ Restart ComfyUI after adding or changing the key.
### Security note
Protected image previews are handled **server-side**.
- Your API key stays on your machine in `api_key.txt`
- The browser UI does **not** need direct access to your key
---
## 🧱 Nodes (what each one does)
## 🧱 Nodes
### 1) **CivitAI Gallery**
Open the gallery and select images.
- **Open Gallery**: full-screen browsing
- **Fetch URL** + **Fetch**: paste a CivitAI URL and load the image
- **Open Gallery** → full-screen browsing
- **Fetch URL** + **Fetch** → paste a CivitAI URL and load the image
**Tip:** you can use a **post URL** (example: `https://civitai.com/posts/<id>`) or an **image URL** (example: `https://civitai.com/images/<id>`)
**Tip:** you can use either:
- a **post URL** like `https://civitai.com/posts/<id>` or `https://civitai.red/posts/<id>`
- an **image URL** like `https://civitai.com/images/<id>` or `https://civitai.red/images/<id>`
---
### 2) **CivitAI Image Preview**
- **Copy CivitAI Page URL** button: copies the CivitAI page link for the selected image (the page that shows generation info)
- Output: `IMAGE` (great for **img2img**)
- Shows the selected preview image directly in the node
- Includes **Copy CivitAI Page URL** button
- Output: `IMAGE`
This makes it ideal for **img2img** workflows.
---
### 3) **CivitAI Prompt Editor**
Lets you edit prompts before generating.
- Shows **Positive** + **Negative** previews right on the node
- Buttons at the bottom:
- **Edit Positive** / **Edit Negative** (opens a big editor window)
- **Restore** (revert to the original prompts from the last selection)
- **Clear** (wipe prompts)
- Shows **Positive** and **Negative** previews directly on the node
- Bottom buttons:
- **Edit Positive**
- **Edit Negative**
- **Restore** → revert to prompts from the last selection
- **Clear** → wipe both prompts
Output:
- Positive (STRING)
- Negative (STRING)
Outputs:
- `Positive` (`STRING`)
- `Negative` (`STRING`)
---
### 4) **CivitAI Info Display**
A small info preview panel that updates instantly when you select an image.
---
@@ -152,12 +178,14 @@ A small info preview panel that updates instantly when you select an image.
## 🔁 Recommended workflow setups
### A) txt2img (browse → edit → generate)
1. Select an image in **CivitAI Gallery**
2. Edit prompts in **CivitAI Prompt Editor**
3. Connect Prompt Editor outputs to your text/CLIP encode nodes
3. Connect Prompt Editor outputs to your text / CLIP encode nodes
4. Generate
### B) img2img (preview → use as init image)
1. Select an image in **CivitAI Gallery**
2. Confirm it in **CivitAI Image Preview**
3. Use Preview output as your **img2img init image**
@@ -168,24 +196,42 @@ A small info preview panel that updates instantly when you select an image.
## 🛠 Troubleshooting
### Nothing updates instantly
- Restart ComfyUI
- Hard refresh your browser (Ctrl+F5)
### Protected images won’t preview
- Restart ComfyUI
- Hard refresh your browser (`Ctrl+F5`)
### Protected images will not preview
- Confirm `api_key.txt` exists and is formatted correctly:
`CIVITAI_API_KEY=...`
```txt
CIVITAI_API_KEY=...
```
- Restart ComfyUI
### Fetch says “No items returned”
- Prefer using a **post URL** (`/posts/<id>`)
- Some content may still be restricted by your CivitAI browsing/account settings
- Prefer using a **post URL** (`/posts/<id>`) when possible
- Some content may still be restricted by CivitAI account, domain, or region visibility rules
### Page links open on the wrong domain
Use the gallery’s **Page Links** selector:
- **Auto**
- **Civitai.com**
- **Civitai.red**
If you fetch by URL, the plugin will try to preserve that domain automatically.
---
## 🔐 Security notes
## 🧪 Compatibility notes
- Your API key stays on your machine in `api_key.txt`.
- The browser does **not** need your key.
- Tested on current stable ComfyUI builds
- Uses standard ComfyUI frontend extension registration
- If you are testing against nightly/master builds, upstream frontend changes may land before a stable release
---
@@ -193,8 +239,8 @@ A small info preview panel that updates instantly when you select an image.
MIT License (see `LICENSE`).
---
## 🙏 Acknowledgements
Inspired by earlier community work exploring similar workflows and UI ideas.
+367 -86
View File
@@ -1,4 +1,3 @@
import os
import json
import aiohttp
@@ -9,7 +8,8 @@ import numpy as np
from PIL import Image
import io
import urllib.request
from urllib.parse import urlparse, quote
from urllib.parse import urlparse, quote, parse_qs
from typing import Optional
# ------------------------------------------------------------
# API key handling
@@ -17,6 +17,19 @@ from urllib.parse import urlparse, quote
NODE_DIR = os.path.dirname(os.path.abspath(__file__))
API_KEY_FILE = os.path.join(NODE_DIR, "api_key.txt")
# ------------------------------------------------------------
# Config / future-proofing
# ------------------------------------------------------------
DEFAULT_SITE_API_BASES = (
"https://civitai.com/api/v1",
"https://civitai.red/api/v1",
)
DEFAULT_PAGE_BASES = {
"com": "https://civitai.com",
"red": "https://civitai.red",
}
def load_api_key():
if not os.path.exists(API_KEY_FILE):
@@ -29,11 +42,37 @@ def load_api_key():
return None
def _get_site_api_bases():
"""
Future-proof:
- allows override via env var
- tries both .com and .red
"""
env_base = (os.getenv("CIVITAI_SITE_API_BASE") or "").strip().rstrip("/")
bases = []
if env_base:
bases.append(env_base)
for b in DEFAULT_SITE_API_BASES:
if b not in bases:
bases.append(b)
return tuple(bases)
def _get_page_domain_mode():
"""
auto | com | red
"""
mode = (os.getenv("CIVITAI_PAGE_DOMAIN_MODE") or "auto").strip().lower()
if mode not in ("auto", "com", "red"):
return "auto"
return mode
# ------------------------------------------------------------
# Simple in-memory stores (keyed by node unique_id)
# ------------------------------------------------------------
PROMPT_STORE = {} # unique_id -> {"positive": str, "negative": str, "rev": int}
PREVIEW_STORE = {} # unique_id -> {"url": str, "rev": int}
PROMPT_STORE = {} # unique_id -> {"positive": str, "negative": str, "rev": int}
PREVIEW_STORE = {} # unique_id -> {"url": str, "page_url": str, "rev": int}
def _bump(store: dict, unique_id: str):
@@ -60,9 +99,29 @@ def _redact_params(d: dict):
return safe
def _is_allowed_page_host(host: str) -> bool:
host = (host or "").lower()
return host in {
"civitai.com",
"www.civitai.com",
"civitai.red",
"www.civitai.red",
}
def _is_allowed_page_url(raw_url: str) -> bool:
try:
u = urlparse(raw_url)
if u.scheme not in ("http", "https"):
return False
return _is_allowed_page_host(u.netloc)
except Exception:
return False
def _is_allowed_image_url(raw_url: str) -> bool:
"""
Prevent SSRF: only allow civitai image hosts.
Prevent SSRF: only allow known civitai hosts.
"""
try:
u = urlparse(raw_url)
@@ -75,17 +134,86 @@ def _is_allowed_image_url(raw_url: str) -> bool:
return True
if host == "civitai.com":
return True
if host.endswith(".civitai.red"):
return True
if host == "civitai.red":
return True
return False
except Exception:
return False
async def fetch_bytes_authed(url: str, api_key: str, timeout_s: int = 60) -> tuple[bytes, str]:
headers = {
def _page_base_from_url(raw_url: str) -> Optional[str]:
if not raw_url:
return None
try:
u = urlparse(raw_url)
if u.scheme not in ("http", "https"):
return None
if not _is_allowed_page_host(u.netloc):
return None
return f"{u.scheme}://{u.netloc}"
except Exception:
return None
def _preferred_page_base(
explicit_url: str = "",
source_url: str = "",
page_domain: str = "",
) -> str:
"""
Resolution order:
1) explicit URL (e.g. fetched post/image URL)
2) page_domain forced by frontend ("com" or "red")
3) source URL if it is a civitai page URL
4) env override mode
5) safe default: civitai.com
"""
forced = (page_domain or "").strip().lower()
if forced in DEFAULT_PAGE_BASES:
return DEFAULT_PAGE_BASES[forced]
base = _page_base_from_url(explicit_url)
if base:
return base
base = _page_base_from_url(source_url)
if base:
return base
mode = _get_page_domain_mode()
if mode in DEFAULT_PAGE_BASES:
return DEFAULT_PAGE_BASES[mode]
return DEFAULT_PAGE_BASES["com"]
def _build_civitai_page_url(image_id=None, post_id=None, page_base: str = ""):
base = (page_base or DEFAULT_PAGE_BASES["com"]).rstrip("/")
if image_id:
return f"{base}/images/{image_id}"
if post_id:
return f"{base}/posts/{post_id}"
return ""
def _auth_headers(api_key: str, referer_url: str = "") -> dict:
referer_base = _preferred_page_base(explicit_url=referer_url)
return {
"User-Agent": "ComfyUI-CivitAI-Gallery",
"Authorization": f"Bearer {api_key}",
"Referer": "https://civitai.com/",
"Referer": referer_base.rstrip("/") + "/",
}
async def fetch_bytes_authed(
url: str,
api_key: str,
timeout_s: int = 60,
referer_url: str = "",
) -> tuple[bytes, str]:
headers = _auth_headers(api_key, referer_url=referer_url)
timeout = aiohttp.ClientTimeout(total=timeout_s)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as resp:
@@ -97,6 +225,137 @@ async def fetch_bytes_authed(url: str, api_key: str, timeout_s: int = 60) -> tup
return data, content_type
def fetch_bytes_authed_sync(
url: str,
api_key: str,
timeout_s: int = 60,
referer_url: str = "",
) -> tuple[bytes, str]:
headers = _auth_headers(api_key, referer_url=referer_url)
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout_s) as r:
data = r.read()
content_type = r.headers.get("Content-Type", "application/octet-stream")
return data, content_type
async def _call_site_api_json(
path: str,
params: dict,
api_key: str,
timeout_s: int = 60,
):
"""
Try configured API bases in order. This future-proofs against
any host-level routing changes while keeping auth in headers.
"""
clean_params = dict(params or {})
headers = _auth_headers(api_key)
timeout = aiohttp.ClientTimeout(total=timeout_s)
last_error = None
for base in _get_site_api_bases():
url = base.rstrip("/") + "/" + path.lstrip("/")
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, params=clean_params, headers=headers) as resp:
text = await resp.text()
if resp.status != 200:
last_error = {
"error": "CivitAI request failed",
"status": resp.status,
"details": text[:800],
"params": _redact_params(clean_params),
"url": url,
}
continue
try:
data = json.loads(text)
except Exception:
last_error = {
"error": "CivitAI returned non-JSON response",
"status": resp.status,
"details": text[:800],
"params": _redact_params(clean_params),
"url": url,
}
continue
return data, None
except Exception as e:
last_error = {
"error": "CivitAI request exception",
"details": str(e),
"params": _redact_params(clean_params),
"url": url,
}
return None, last_error or {"error": "Unknown CivitAI API failure"}
def _safe_int(value, default, min_value=None, max_value=None):
try:
n = int(value)
except Exception:
n = default
if min_value is not None:
n = max(min_value, n)
if max_value is not None:
n = min(max_value, n)
return n
def _iter_lookup_param_variants(id_key: str, id_value: str):
"""
Future-proof against possible API changes around content filtering.
Try a few variants instead of assuming one forever.
"""
base = {id_key: id_value, "limit": 1}
yield dict(base)
yield dict(base, nsfw="X")
yield dict(base, nsfw="true")
yield dict(base, nsfw=True)
def _maybe_fetch_tensor_from_image_url(image_url: str, page_url: str = "") -> torch.Tensor:
"""
Best-effort:
- try authed fetch first when API key exists
- otherwise try plain public fetch
"""
if not image_url:
return torch.zeros((1, 1, 1, 3), dtype=torch.float32)
api_key = load_api_key()
# First: try authenticated fetch when we can
if api_key and _is_allowed_image_url(image_url):
try:
img_bytes, _ = fetch_bytes_authed_sync(
image_url,
api_key,
timeout_s=30,
referer_url=page_url,
)
img = Image.open(io.BytesIO(img_bytes))
return _tensor_from_pil(img)
except Exception:
pass
# Fallback: direct public fetch
try:
req = urllib.request.Request(
image_url,
headers={"User-Agent": "ComfyUI-CivitAI-Gallery"},
)
with urllib.request.urlopen(req, timeout=30) as r:
img_bytes = r.read()
img = Image.open(io.BytesIO(img_bytes))
return _tensor_from_pil(img)
except Exception as e:
print(f"[CivitAI Gallery] Image download failed: {e}")
return torch.zeros((1, 1, 1, 3), dtype=torch.float32)
# ------------------------------------------------------------
# Nodes
# ------------------------------------------------------------
@@ -126,13 +385,6 @@ class CivitaiGalleryNode:
)
return positive or "", negative or ""
def _build_civitai_page_url(self, image_id=None, post_id=None):
if image_id:
return f"https://civitai.com/images/{image_id}"
if post_id:
return f"https://civitai.com/posts/{post_id}"
return ""
def run(self, selection_data="{}"):
try:
data = json.loads(selection_data) if selection_data else {}
@@ -157,20 +409,31 @@ class CivitaiGalleryNode:
positive, negative = self._extract_prompts(meta)
# Best-effort image tensor (protected-safe preview uses proxy route)
tensor = torch.zeros((1, 1, 1, 3), dtype=torch.float32)
image_url = item.get("url")
if image_url:
try:
req = urllib.request.Request(
image_url, headers={"User-Agent": "ComfyUI-CivitAI-Gallery"}
)
with urllib.request.urlopen(req, timeout=30) as r:
img_bytes = r.read()
img = Image.open(io.BytesIO(img_bytes))
tensor = _tensor_from_pil(img)
except Exception as e:
print(f"[CivitAI Gallery] Image download failed: {e}")
page_url = (
item.get("page_url")
or item.get("pageUrl")
or ""
)
source_page_url = (
item.get("source_page_url")
or item.get("sourcePageUrl")
or ""
)
page_domain = (
item.get("page_domain")
or item.get("pageDomain")
or ""
)
page_base = _preferred_page_base(
explicit_url=page_url,
source_url=source_page_url,
page_domain=page_domain,
)
if not page_url:
page_url = _build_civitai_page_url(image_id, post_id, page_base=page_base)
tensor = _maybe_fetch_tensor_from_image_url(item.get("url"), page_url=page_url)
model_name = ""
try:
@@ -180,7 +443,6 @@ class CivitaiGalleryNode:
except Exception:
pass
page_url = self._build_civitai_page_url(image_id, post_id)
info = f"CivitAI Page: {page_url}" + (f"\nModel: {model_name}" if model_name else "")
if not positive and not negative:
info = "No prompts found.\n" + info
@@ -224,7 +486,6 @@ class CivitaiInfoDisplayNode:
Display-only node:
- No inputs
- No outputs
- JS adds a textarea widget and updates it instantly.
"""
@classmethod
def INPUT_TYPES(cls):
@@ -275,19 +536,17 @@ class CivitaiImagePreviewNode:
return (image,)
uid = str(unique_id) if unique_id is not None else ""
url = (PREVIEW_STORE.get(uid) or {}).get("url", "") or ""
entry = PREVIEW_STORE.get(uid) or {}
url = (entry.get("url", "") or "").strip()
page_url = (entry.get("page_url", "") or "").strip()
if not url:
tensor = torch.zeros((1, 1, 1, 3), dtype=torch.float32)
return (tensor,)
# Fetch via local proxy route to keep auth handling consistent
try:
local = f"http://127.0.0.1:8188/civitai_gallery/proxy_image?url={quote(url, safe='')}"
req = urllib.request.Request(local, headers={"User-Agent": "ComfyUI-CivitAI-Gallery"})
with urllib.request.urlopen(req, timeout=60) as r:
img_bytes = r.read()
img = Image.open(io.BytesIO(img_bytes))
return (_tensor_from_pil(img),)
tensor = _maybe_fetch_tensor_from_image_url(url, page_url=page_url)
return (tensor,)
except Exception as e:
print(f"[CivitAI Preview] Failed to fetch image: {e}")
tensor = torch.zeros((1, 1, 1, 3), dtype=torch.float32)
@@ -329,7 +588,7 @@ async def civitai_set_prompt(request):
@prompt_server.routes.post("/civitai_gallery/set_preview")
async def civitai_set_preview(request):
"""
Body: {"node_id": "<id>", "url": "..."}
Body: {"node_id": "<id>", "url": "...", "page_url": "..."}
"""
try:
body = await request.json()
@@ -341,8 +600,11 @@ async def civitai_set_preview(request):
return web.json_response({"error": "Missing node_id"}, status=400)
url = (body.get("url", "") or "").strip()
page_url = (body.get("page_url", "") or body.get("pageUrl", "") or "").strip()
entry = PREVIEW_STORE.get(node_id) or {"rev": 0}
entry["url"] = url
entry["page_url"] = page_url
PREVIEW_STORE[node_id] = entry
_bump(PREVIEW_STORE, node_id)
@@ -356,31 +618,39 @@ async def civitai_images(request):
return web.json_response({"error": "CivitAI API key missing (api_key.txt)"}, status=401)
p = dict(request.query)
limit = int(p.get("limit", "36"))
limit = _safe_int(p.get("limit", "36"), 36, min_value=1, max_value=200)
sort = p.get("sort", "Most Reactions")
period = p.get("period", "AllTime")
nsfw = p.get("nsfw", "None")
nsfw = (p.get("nsfw", "None") or "").strip()
cursor = p.get("cursor", None)
if cursor is not None:
cursor = str(cursor).strip()
if cursor == "":
cursor = None
api_url = "https://civitai.com/api/v1/images"
headers = {"Authorization": f"Bearer {api_key}"}
params = {"limit": min(200, limit), "sort": sort, "period": period, "nsfw": nsfw}
params = {
"limit": limit,
"sort": sort,
"period": period,
}
if nsfw:
params["nsfw"] = nsfw
if cursor is not None:
params["cursor"] = cursor
try:
async with aiohttp.ClientSession() as session:
async with session.get(api_url, params=params, headers=headers) as resp:
data = await resp.json()
return web.json_response(
{"items": data.get("items", []), "metadata": data.get("metadata", {}) or {}}
)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
data, err = await _call_site_api_json("/images", params, api_key)
if err and "nsfw" in params:
# Fallback in case the API changes NSFW filter behavior
params2 = dict(params)
params2.pop("nsfw", None)
data, err = await _call_site_api_json("/images", params2, api_key)
if err:
return web.json_response(err, status=err.get("status", 500))
return web.json_response(
{"items": data.get("items", []), "metadata": data.get("metadata", {}) or {}}
)
@prompt_server.routes.get("/civitai_gallery/image_by_url")
@@ -393,15 +663,13 @@ async def civitai_image_by_url(request):
if not raw_url:
return web.json_response({"error": "Missing url parameter"}, status=400)
from urllib.parse import urlparse, parse_qs
if not _is_allowed_page_url(raw_url):
return web.json_response({"error": "Only civitai.com and civitai.red page URLs are supported"}, status=400)
parsed = urlparse(raw_url)
qs = parse_qs(parsed.query)
path_parts = [p for p in parsed.path.strip("/").split("/") if p]
images_api = "https://civitai.com/api/v1/images"
headers = {"Authorization": f"Bearer {api_key}"}
image_id = None
post_id = None
@@ -421,52 +689,57 @@ async def civitai_image_by_url(request):
if not post_id:
post_id = (qs.get("postId") or [None])[0]
async def _call(params):
params = dict(params)
params["token"] = api_key
params.setdefault("nsfw", "X") # Option A
page_base = _preferred_page_base(explicit_url=raw_url)
async with aiohttp.ClientSession() as session:
async with session.get(images_api, params=params, headers=headers) as resp:
if resp.status != 200:
text = await resp.text()
return None, {
"error": "CivitAI request failed",
"status": resp.status,
"details": text,
"params": _redact_params(params),
}
data = await resp.json()
return data, None
async def _lookup(id_key: str, id_value: str):
last_err = None
for params in _iter_lookup_param_variants(id_key, id_value):
data, err = await _call_site_api_json("/images", params, api_key)
if err:
last_err = err
continue
items = data.get("items", []) if isinstance(data, dict) else []
if items:
item = dict(items[0])
item["pageUrl"] = _build_civitai_page_url(
image_id=item.get("id") if id_key == "imageId" else None,
post_id=id_value if id_key == "postId" else item.get("postId"),
page_base=page_base,
)
item["sourcePageUrl"] = raw_url
if page_base.endswith(".red"):
item["pageDomain"] = "red"
elif page_base.endswith(".com"):
item["pageDomain"] = "com"
return item, None
return None, last_err
if post_id:
data, err = await _call({"postId": post_id, "limit": 1})
item, err = await _lookup("postId", post_id)
if item:
return web.json_response({"item": item})
if err:
return web.json_response(err, status=err.get("status", 500))
items = data.get("items", []) if isinstance(data, dict) else []
if items:
return web.json_response({"item": items[0]})
return web.json_response(
{
"error": "No items returned for postId",
"postId": post_id,
"hint": "If this is a video-only post, /api/v1/images may return empty. Otherwise it may be restricted by browsing settings.",
"hint": "The post may not currently expose an image through the images API, or visibility may be limited by account/domain/region settings.",
},
status=404,
)
if image_id:
data, err = await _call({"imageId": image_id, "limit": 1})
item, err = await _lookup("imageId", image_id)
if item:
return web.json_response({"item": item})
if err:
return web.json_response(err, status=err.get("status", 500))
items = data.get("items", []) if isinstance(data, dict) else []
if items:
return web.json_response({"item": items[0]})
return web.json_response(
{
"error": "No items returned for imageId",
"imageId": image_id,
"hint": "This can happen for login-gated images or due to imageId lookup behavior returning empty.",
"hint": "This can happen for protected items or when image lookup behavior changes upstream.",
},
status=404,
)
@@ -490,8 +763,17 @@ async def civitai_proxy_image(request):
if not _is_allowed_image_url(raw_url):
return web.json_response({"error": "URL host not allowed"}, status=400)
page_url = (request.query.get("page_url", "") or "").strip()
if page_url and not _is_allowed_page_url(page_url):
return web.json_response({"error": "page_url host not allowed"}, status=400)
try:
img_bytes, content_type = await fetch_bytes_authed(raw_url, api_key, timeout_s=60)
img_bytes, content_type = await fetch_bytes_authed(
raw_url,
api_key,
timeout_s=60,
referer_url=page_url,
)
return web.Response(
body=img_bytes,
status=200,
@@ -514,4 +796,3 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"CivitaiInfoDisplayNode": "CivitAI Info Display",
"CivitaiImagePreviewNode": "CivitAI Image Preview",
}
+136 -18
View File
@@ -1,10 +1,15 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
console.log("[CivitAI Gallery] frontend script loaded");
const STORAGE_KEY = "civitai_gallery_simple_v1";
const STORAGE_KEY = "civitai_gallery_simple_v2";
const PAGE_BASES = {
com: "https://civitai.com",
red: "https://civitai.red",
};
let currentAbort = null;
// cursor paging state
@@ -12,6 +17,42 @@ let nextCursor = null;
let nextPageUrl = null;
let seenIds = new Set();
function normalizePageDomain(value) {
const v = String(value ?? "").trim().toLowerCase();
if (v === "com" || v === "red" || v === "auto") return v;
return "auto";
}
function pageBaseFromSetting(value) {
const v = normalizePageDomain(value);
if (v === "com" || v === "red") return PAGE_BASES[v];
return "";
}
function pageBaseFromUrl(raw) {
try {
if (!raw) return "";
const u = new URL(String(raw));
const host = (u.hostname || "").toLowerCase();
if (
host === "civitai.com" ||
host === "www.civitai.com" ||
host === "civitai.red" ||
host === "www.civitai.red"
) {
return `${u.protocol}//${u.host}`;
}
} catch {}
return "";
}
function buildProxyImageUrl(srcUrl, pageUrl = "") {
const proxy = new URL("/civitai_gallery/proxy_image", window.location.origin);
proxy.searchParams.set("url", srcUrl ?? "");
if (pageUrl) proxy.searchParams.set("page_url", pageUrl);
return proxy.toString();
}
// ---------------- Clipboard helper ----------------
async function copyText(text) {
const t = (text || "").toString().trim();
@@ -233,7 +274,7 @@ function isPreviewNode(node) {
// ---------------- Data helpers ----------------
function normalizeItemForSelection(item) {
const url = item?.url || item?.imageUrl || item?.src || "";
const url = item?.url ?? item?.imageUrl ?? item?.src ?? "";
let meta = item?.meta ?? {};
let id = item?.id ?? null;
let postId = item?.postId ?? null;
@@ -246,7 +287,15 @@ function normalizeItemForSelection(item) {
if (id != null) id = String(id);
if (postId != null) postId = String(postId);
return { url, meta: meta || {}, id, postId };
return {
url,
meta: meta ?? {},
id,
postId,
pageUrl: item?.pageUrl ?? item?.page_url ?? "",
sourcePageUrl: item?.sourcePageUrl ?? item?.source_page_url ?? "",
pageDomain: item?.pageDomain ?? item?.page_domain ?? "",
};
}
function extractPrompts(meta) {
@@ -265,9 +314,18 @@ function extractPrompts(meta) {
return { positive: positive || "", negative: negative || "" };
}
function buildPageUrl(imageId, postId) {
if (imageId) return `https://civitai.com/images/${imageId}`;
if (postId) return `https://civitai.com/posts/${postId}`;
function buildPageUrl(imageId, postId, options = {}) {
const settings = loadSettings();
const pageDomain = normalizePageDomain(options.pageDomain ?? settings.pageDomain ?? "auto");
const forcedBase = pageBaseFromSetting(pageDomain);
const explicitBase = pageBaseFromUrl(options.explicitPageUrl);
const sourceBase = pageBaseFromUrl(options.sourcePageUrl);
const base = forcedBase || explicitBase || sourceBase || PAGE_BASES.com;
if (imageId) return `${base}/images/${imageId}`;
if (postId) return `${base}/posts/${postId}`;
return "";
}
@@ -369,24 +427,24 @@ function setInfoNode(node, text, pageUrl) {
}
async function setPreviewNode(node, srcUrl, pageUrl) {
node.__civitaiPageUrl = pageUrl || "";
node.__civitaiSourceUrl = srcUrl || "";
node.__civitaiPageUrl = pageUrl ?? "";
node.__civitaiSourceUrl = srcUrl ?? "";
await postJSON("/civitai_gallery/set_preview", {
node_id: String(node.id),
url: node.__civitaiSourceUrl,
page_url: node.__civitaiPageUrl,
});
const proxyUrl = `/civitai_gallery/proxy_image?url=${encodeURIComponent(node.__civitaiSourceUrl)}`;
node.__civitaiThumbUrl = proxyUrl;
const proxyUrl = buildProxyImageUrl(node.__civitaiSourceUrl, node.__civitaiPageUrl);
node.__civitaiThumbUrl = proxyUrl;
if (!node.__civitaiThumbImg) {
node.__civitaiThumbImg = new Image();
node.__civitaiThumbImg.onload = () => node.graph?.setDirtyCanvas(true, true);
node.__civitaiThumbImg.onerror = () => node.graph?.setDirtyCanvas(true, true);
}
node.__civitaiThumbImg.src = proxyUrl;
node.graph?.setDirtyCanvas(true, true);
}
@@ -397,7 +455,11 @@ async function updateSupportNodes(normalized) {
const prompts = extractPrompts(normalized.meta);
const model = modelNameFromMeta(normalized.meta);
const page = buildPageUrl(normalized.id, normalized.postId);
const page = buildPageUrl(normalized.id, normalized.postId, {
explicitPageUrl: normalized.pageUrl,
sourcePageUrl: normalized.sourcePageUrl,
pageDomain: normalized.pageDomain,
});
const infoText =
`CivitAI Page: ${page}` +
@@ -418,6 +480,7 @@ async function updateSupportNodes(normalized) {
function applySelection(galleryNode, item) {
const normalized = normalizeItemForSelection(item);
const selectedPageDomain = normalized.pageDomain || loadSettings().pageDomain || "auto";
const payload = JSON.stringify({
item: {
@@ -425,6 +488,9 @@ function applySelection(galleryNode, item) {
meta: normalized.meta,
id: normalized.id,
postId: normalized.postId,
pageUrl: normalized.pageUrl,
sourcePageUrl: normalized.sourcePageUrl,
pageDomain: selectedPageDomain,
},
});
@@ -440,7 +506,10 @@ function applySelection(galleryNode, item) {
galleryNode.flags = galleryNode.flags || {};
galleryNode.flags.dirty = true;
updateSupportNodes(normalized);
updateSupportNodes({
...normalized,
pageDomain: selectedPageDomain,
});
}
// ---------------- Gallery overlay ----------------
@@ -458,9 +527,24 @@ function saveSettings(s) {
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
if (raw) {
const parsed = JSON.parse(raw);
return {
sort: parsed.sort ?? "Most Reactions",
period: parsed.period ?? "AllTime",
limit: parsed.limit ?? 36,
nsfw: parsed.nsfw ?? "None",
pageDomain: normalizePageDomain(parsed.pageDomain ?? "auto"),
};
}
} catch {}
return { sort: "Most Reactions", period: "AllTime", limit: 36, nsfw: "None" };
return {
sort: "Most Reactions",
period: "AllTime",
limit: 36,
nsfw: "None",
pageDomain: "auto",
};
}
function cursorFromNextPage(url) {
@@ -519,13 +603,24 @@ async function fetchPage(node, grid, settings, cursor, append = false) {
if (itemId) seenIds.add(itemId);
if (!item.url) continue;
const selectedPageDomain = settings.pageDomain || "auto";
const normalized = normalizeItemForSelection({
...item,
pageDomain: item?.pageDomain ?? selectedPageDomain,
});
const pageUrl = buildPageUrl(normalized.id, normalized.postId, {
explicitPageUrl: normalized.pageUrl,
sourcePageUrl: normalized.sourcePageUrl,
pageDomain: normalized.pageDomain,
});
const card = document.createElement("div");
card.style.display = "flex";
card.style.flexDirection = "column";
card.style.gap = "6px";
const img = document.createElement("img");
img.src = item.url;
img.src = buildProxyImageUrl(item.url, pageUrl);
img.loading = "lazy";
img.style.width = "100%";
img.style.borderRadius = "6px";
@@ -533,7 +628,11 @@ async function fetchPage(node, grid, settings, cursor, append = false) {
img.onclick = (e) => {
e.stopPropagation();
applySelection(node, item);
applySelection(node, {
...item,
pageDomain: normalized.pageDomain,
pageUrl,
});
closeFullGallery();
};
@@ -617,6 +716,16 @@ async function openFullGallery(node) {
settings.nsfw
);
const pageDomainSel = makeSelect(
"Page Links",
[
["Auto", "auto"],
["Civitai.com", "com"],
["Civitai.red", "red"],
],
settings.pageDomain ?? "auto"
);
const searchBtn = document.createElement("button");
searchBtn.textContent = "Search";
stylizeButton(searchBtn);
@@ -631,6 +740,7 @@ async function openFullGallery(node) {
periodSel.container,
limitInput.container,
nsfwSel.container,
pageDomainSel.container,
searchBtn,
closeBtn
);
@@ -675,6 +785,7 @@ async function openFullGallery(node) {
period: periodSel.select.value,
limit: parseInt(limitInput.input.value || "36", 10),
nsfw: nsfwSel.select.value,
pageDomain: pageDomainSel.select.value,
};
saveSettings(s0);
@@ -687,6 +798,7 @@ async function openFullGallery(node) {
period: periodSel.select.value,
limit: parseInt(limitInput.input.value || "36", 10),
nsfw: nsfwSel.select.value,
pageDomain: pageDomainSel.select.value,
};
saveSettings(s2);
const r = await fetchPage(node, grid, s2, 0, false);
@@ -699,6 +811,7 @@ async function openFullGallery(node) {
period: periodSel.select.value,
limit: parseInt(limitInput.input.value || "36", 10),
nsfw: nsfwSel.select.value,
pageDomain: pageDomainSel.select.value,
};
saveSettings(s);
@@ -751,6 +864,11 @@ async function fetchAndApplyUrl(node, url) {
return;
}
try {
item.sourcePageUrl = trimmed;
if (!item.pageUrl) item.pageUrl = trimmed;
} catch {}
applySelection(node, item);
} catch (e) {
console.warn("[CivitAI Gallery] node fetch URL failed", e);
+21 -21
View File
@@ -1,21 +1,21 @@
[project]
name = "comfyui-civitai-imageplus"
version = "0.1.2"
description = "Browse CivitAI images in ComfyUI, extract and edit prompts, preview selected image and pass to I2I."
license = { file = "LICENSE" }
requires-python = ">=3.9"
dependencies = [
"aiohttp",
"numpy",
"Pillow",
]
[project.urls]
Repository = "https://github.com/CovertBannana/ComfyUI_CivitAI_ImagePlus"
[tool.comfy]
PublisherId = "covertbannana"
DisplayName = "CivitAI ImagePlus"
Icon = ""
[project]
name = "comfyui-civitai-imageplus"
version = "0.2.0"
description = "Browse CivitAI images in ComfyUI, preview protected images, edit prompts, and pass selected images into img2img workflows."
license = { file = "LICENSE" }
requires-python = ">=3.9"
dependencies = [
"aiohttp",
"numpy",
"Pillow",
]
[project.urls]
Repository = "https://github.com/CovertBannana/ComfyUI_CivitAI_ImagePlus"
Documentation = "https://github.com/CovertBannana/ComfyUI_CivitAI_ImagePlus#readme"
"Bug Tracker" = "https://github.com/CovertBannana/ComfyUI_CivitAI_ImagePlus/issues"
[tool.comfy]
PublisherId = "covertbannana"
DisplayName = "CivitAI ImagePlus"
Icon = ""