From 2decebee7722273f78bdf7dfa4795a4af76a30fc Mon Sep 17 00:00:00 2001 From: pandawhore Date: Thu, 30 Apr 2026 16:17:05 -0600 Subject: [PATCH] Release 0.2.0: CivitAI domain compatibility --- README.md | 212 ++++++++++------- civitai_gallery_backend.py | 453 ++++++++++++++++++++++++++++++------- js/civitai_gallery.js | 154 +++++++++++-- pyproject.toml | 42 ++-- 4 files changed, 653 insertions(+), 208 deletions(-) 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 - -

- - Image 2 Image example - - - Nodes - -

- -**Workflow examples** - -

- - Text 2 Image example - - - Image 2 Image example - - - Alternate nodes can be used (show text, show any), they will not populate until Run is initiated. - -

- -

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 = ""