diff --git a/README.md b/README.md
index 9c7668d..7879613 100644
--- a/README.md
+++ b/README.md
@@ -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
-
-
-
-
-
-
-
-
-
-
-**Workflow examples**
-
-
-
-
-
-
-
-
-
-
-
-
-
-Click any image to open full size.
+- 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/`) or an **image URL** (example: `https://civitai.com/images/`)
+**Tip:** you can use either:
+
+- a **post URL** like `https://civitai.com/posts/` or `https://civitai.red/posts/`
+- an **image URL** like `https://civitai.com/images/` or `https://civitai.red/images/`
---
### 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/`)
-- Some content may still be restricted by your CivitAI browsing/account settings
+
+- Prefer using a **post URL** (`/posts/`) 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.
-
diff --git a/civitai_gallery_backend.py b/civitai_gallery_backend.py
index 457a505..b70d49c 100644
--- a/civitai_gallery_backend.py
+++ b/civitai_gallery_backend.py
@@ -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": "", "url": "..."}
+ Body: {"node_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",
}
-
diff --git a/js/civitai_gallery.js b/js/civitai_gallery.js
index b1d89c6..18fd482 100644
--- a/js/civitai_gallery.js
+++ b/js/civitai_gallery.js
@@ -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);
diff --git a/pyproject.toml b/pyproject.toml
index 5f37b7f..8b1a3f1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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 = ""