From 2cf97d58c6d4d8e93916e65bd0d054c2c19b70b8 Mon Sep 17 00:00:00 2001 From: ShadowCoder-789 Date: Wed, 29 Jul 2026 19:13:30 -0500 Subject: [PATCH 01/10] refactor: complete maintainability refactor & modularization of sd-webui-ranbooruX - Reduced scripts/ranbooru.py by 27% (from 7,343 lines down to 5,356 lines) - Extracted booru scrapers into ranboorux/boorus/ package (__init__, gelbooru, simple) - Extracted AdetailerOrchestrator into ranboorux/integrations/adetailer_orchestration.py - Implemented AdetailerState enum with 5 explicit lifecycle states - Modularized ui() into 4 UI section builder methods - Eliminated 10 duplicated tag sets, dead code, and thin wrapper functions - Merged prompting.py into tag_pipeline.py and io_lists.py into user_store.py - Renamed host_state.py -> mutation_scope.py and requesting.py -> http_client.py - Added FilterContext dataclass and CatalogResolver Protocol - Expanded test suite to 179 passing tests (+6 test cases for tag pipeline edge cases) - Unified README.md with upstream origin/main changes & refreshed .gitignore --- .github/workflows/ci.yml | 46 + .gitignore | 24 +- README.md | 96 +- adetailer | 1 + pyproject.toml | 1 + ranboorux/boorus/__init__.py | 146 + ranboorux/boorus/gelbooru.py | 278 + ranboorux/boorus/simple.py | 280 + ranboorux/http_client.py | 612 ++ ranboorux/image_ops.py | 4 +- .../integrations/adetailer_orchestration.py | 769 ++ ranboorux/integrations/adetailer_runtime.py | 487 + ranboorux/integrations/controlnet.py | 31 +- ranboorux/integrations/img2img_lifecycle.py | 73 + ranboorux/io_lists.py | 74 - ranboorux/loranado.py | 89 + ranboorux/mutation_scope.py | 76 + ranboorux/prompting.py | 44 - ranboorux/run_options.py | 279 + ranboorux/tag_pipeline.py | 942 ++ ranboorux/user_store.py | 221 + scripts/ranbooru.py | 8269 ++++++----------- tests/conftest.py | 15 + tests/host_snapshot.py | 38 + tests/test_adetailer.py | 552 +- tests/test_adetailer_runtime.py | 350 + tests/test_controlnet.py | 73 +- tests/test_helpers.py | 127 + tests/test_host_state.py | 59 + tests/test_img2img_lifecycle.py | 56 + tests/test_lifecycle_contract.py | 216 + tests/test_loranado.py | 91 + tests/test_modules.py | 10 +- tests/test_prompt_and_parsing.py | 56 +- tests/test_release_hygiene.py | 89 + tests/test_repo_guard.py | 15 + tests/test_requesting.py | 756 ++ tests/test_run_options.py | 55 + tests/test_tag_catalog.py | 19 +- tests/test_tag_pipeline.py | 306 + tests/test_ui_contract.py | 28 + tests/test_user_store.py | 131 + tests/test_wrappers.py | 6 +- tools/build_release.py | 328 + tools/inspect_ui.py | 252 + tools/repo_guard.py | 98 + tools/verify.py | 66 + 47 files changed, 10973 insertions(+), 5661 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 160000 adetailer create mode 100644 ranboorux/boorus/__init__.py create mode 100644 ranboorux/boorus/gelbooru.py create mode 100644 ranboorux/boorus/simple.py create mode 100644 ranboorux/http_client.py create mode 100644 ranboorux/integrations/adetailer_orchestration.py create mode 100644 ranboorux/integrations/adetailer_runtime.py create mode 100644 ranboorux/integrations/img2img_lifecycle.py delete mode 100644 ranboorux/io_lists.py create mode 100644 ranboorux/loranado.py create mode 100644 ranboorux/mutation_scope.py delete mode 100644 ranboorux/prompting.py create mode 100644 ranboorux/run_options.py create mode 100644 ranboorux/tag_pipeline.py create mode 100644 ranboorux/user_store.py create mode 100644 tests/host_snapshot.py create mode 100644 tests/test_adetailer_runtime.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_host_state.py create mode 100644 tests/test_img2img_lifecycle.py create mode 100644 tests/test_lifecycle_contract.py create mode 100644 tests/test_loranado.py create mode 100644 tests/test_release_hygiene.py create mode 100644 tests/test_repo_guard.py create mode 100644 tests/test_requesting.py create mode 100644 tests/test_run_options.py create mode 100644 tests/test_tag_pipeline.py create mode 100644 tests/test_ui_contract.py create mode 100644 tests/test_user_store.py create mode 100644 tools/build_release.py create mode 100644 tools/inspect_ui.py create mode 100644 tools/repo_guard.py create mode 100644 tools/verify.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a59ef39 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test-and-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install pytest black ruff mypy + + - name: Block Gradio .update regression + run: python tools/check_no_gradio_update.py + + - name: Run tests (Gradio 3 stub) + run: python -m pytest tests/ -q + + - name: Run tests (Gradio 4 stub) + run: python -m pytest tests/ -q --gradio-version=4 + + - name: Ruff + run: python -m ruff check scripts/ranbooru.py ranboorux tests tools install.py + + - name: Black + run: python -m black --check scripts/ranbooru.py ranboorux tests tools install.py + + - name: Mypy + run: python -m mypy ranboorux --warn-return-any --warn-unused-ignores diff --git a/.gitignore b/.gitignore index 76e0125..ccbeaf0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,13 @@ .vscode/ .idea/ *.code-workspace +.project +.settings/ # OS clutter .DS_Store Thumbs.db +desktop.ini # Python cache/build/test artifacts __pycache__/ @@ -13,13 +16,13 @@ __pycache__/ *.pyo *.pyd .pytest_cache/ +.pytest_cache_local/ .mypy_cache/ .ruff_cache/ .hypothesis/ .pyre/ .tox/ .nox/ -.pytest_cache_local/ .cache/ .eggs/ *.egg-info/ @@ -31,6 +34,11 @@ dist/ pip-wheel-metadata/ __pypackages__/ +# SQLite databases & temp test artifacts +*.sqlite +*.db +temp_test_cache.sqlite + # Local env/secrets .env .env.* @@ -60,21 +68,25 @@ tmpclaude-* tmp_compile_err.txt temp_*.py -# Local tooling / personal notes +# Agent / Tooling runtime clutter +.omo/ .roomodes .rooroo/ -docs/ +.agent/ +.agents/ +.gemini/ +.claude/ cleancode.md ranbooru.before_revert.py block.txt line241.txt tools/debug_lines.py -adetailer/ + +docs/ # Extension user data user/ -# Bundled catalog assets are tracked +# Track bundled catalog assets !data/catalogs/ !data/catalogs/** -.github/workflows/ci.yml diff --git a/README.md b/README.md index 2bfbf76..5dc6fb6 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,46 @@ -# RanbooruX +
+ +# RanbooruX ![RanbooruX logo](pics/ranbooru.png) -RanbooruX is a fork of Ranbooru for Stable Diffusion WebUI environments focused on **Forge** and **Forge Neo**. +RanbooruX is a fork of Ranbooru for Stable Diffusion WebUI environments focused on **Forge Neo**. + +
It fetches booru tags and source images, builds prompts, and supports a two-stage generation flow with optional Img2Img, ControlNet handoff, and ADetailer postprocessing. ## Platform support -- Supported and tested: **Forge**, **Forge Neo** -- Not tested by this project owner: **Automatic1111 (A1111 / A111 WebUI)** - -As of **February 13, 2026**, this project owner has only tested RanbooruX on Forge/Forge Neo. If you run A1111, treat support as best-effort and validate manually. +> [!IMPORTANT] +> **Project Owner Testing Disclaimer**: This project is strictly developed and tested **only using Forge Neo**. Other WebUI distributions (including original SD WebUI / Automatic1111 and original SD WebUI Forge) are **not tested** by the repository owner. ## Why this fork? -- Fix brittle img2img/ControlNet interactions and make them **reliable on Forge and Forge Neo**. + +- Fix brittle Img2Img/ControlNet interactions and make them **reliable on Forge Neo**. - Split the old “remove bad tags” into **clear, no‑surprise filters**. - Make installs easy with `requirements.txt` and a bundled ControlNet helper. - Add **favorites**, **file‑driven prompts**, **logging**, and **sensible caching**. - ![UI screenshot](pics/image.png) -## What changed since the last published branch - -Runtime and workflow changes: - -- Hardened Forge/Forge Neo runtime compatibility for Img2Img + ADetailer + ControlNet interactions. -- Added preview guard behavior so intermediate first-pass frames are hidden until final images are ready. -- Kept original prompts in first pass (removed fallback `"abstract shapes, minimal"` replacement). -- Added robust manual ADetailer handling and script-runner guards for extension interoperability. -- Updated `Gelbooru: Fringe Benefits` visibility logic to appear only when `Booru = gelbooru`. -- Redesigned LoRAnado controls with PonyXL-aware scanning, selectable detected LoRAs, and blacklist. -- Added `timm>=0.9.0` to extension requirements for MiDaS depth preprocessor dependency paths. - -Filtering and catalog changes: - -- Added `Quick Strip` preset in Removal Filters. -- Removed deprecated weapon-tag filtering controls and code remnants. -- Added bundled Danbooru catalog support with import/validation for custom CSV catalogs. -- `Use Danbooru Tag Catalog` is now the default behavior (toggle remains available). +## Installation -Codebase and maintenance changes: +### Method 1: Install from URL in Forge Neo (Recommended) -- Added modular package extraction under `ranboorux/` (`prompting`, `image_ops`, `io_lists`, `catalog`, integrations). -- Added compatibility/integration test suite under `tests/` with Gradio 3/4 coverage. -- Added project tooling and guardrails: `.github/workflows/ci.yml`, `.pre-commit-config.yaml`, `pyproject.toml`, and `tools/check_no_gradio_update.py`. -- Removed bundled `scripts/controlnet.py`; runtime integration now resolves external/builtin ControlNet paths. -- Kept `scripts/ranbooru.py` as the WebUI entrypoint while moving reusable logic into modules. +1. Open **Forge Neo**. +2. Navigate to the **Extensions** tab -> **Install from URL** sub-tab. +3. Paste the URL of this repository into **URL for extension's git repository**: + `https://github.com/soficis/sd-webui-ranbooruX` +4. Click **Install**. +5. Restart **Forge Neo** or click **Apply and restart UI**. -## Installation +### Method 2: Manual Installation -1. Copy or clone this repo to your WebUI extensions directory: +1. Copy or clone this repository to your WebUI extensions directory: - `extensions/sd-webui-ranbooruX` 2. Start or restart WebUI. 3. `install.py` installs extension dependencies from `requirements.txt`. -4. Open the `RanbooruX` panel. +4. Open the **RanbooruX** panel. Optional environment overrides for ControlNet detection: @@ -71,12 +58,12 @@ Optional environment overrides for ControlNet detection: ## Key features - Booru sources: `aibooru`, `danbooru`, `e621`, `gelbooru`, `gelbooru-compatible`, `konachan`, `rule34`, `safebooru`, `xbooru`, `yande.re` -- Fine-grained removal filters (artist, character, series, clothing, text/commentary, furry, headwear, `*_girl`, subject constraints, and more) -- `Quick Strip` one-click removal preset +- Fine-grained removal filters (artist, character, series, clothing, text/commentary, furry, headwear, `*_girl`, subject constraints, preserve hair/eye colors, and more) +- `Quick Strip` one-click removal preset (instantly activates all major removal filters for aggressive prompt cleanup) - Danbooru tag catalog normalization/filtering (enabled by default, toggleable) - Img2Img and ControlNet handoff flow - Optional manual ADetailer pass after Img2Img -- LoRAnado random LoRA injection with PonyXL compatibility controls +- LoRAnado random LoRA injection with PonyXL & Anima compatibility controls (legacy feature) - Platform diagnostics panel for runtime visibility - Caching, file-driven tag sources, favorites, and prompt/source logging @@ -116,7 +103,7 @@ With catalog mode enabled (default), the catalog pipeline adds: - textual/meta tag cleanup backed by catalog categories - diagnostics panel for kept/dropped/unknown tag insight -Disable the toggle any time to fall back to legacy/non-catalog behavior. +When the toggle is disabled, RanbooruX still uses the bundled catalog path (catalog-only mode; no legacy filter engine). ### Custom catalog files @@ -142,24 +129,28 @@ Implementation details and format notes are documented in: `data/catalogs/README.txt` includes provenance/licensing context for the bundled `danbooru_tags.csv`, plus references used for the research notes. -## LoRAnado (PonyXL-aware redesign) +## LoRAnado (PonyXL & Anima detection) + +> [!NOTE] +> LoRAnado is a legacy feature inherited from original Ranbooru and is not extensively tested by the repository owner. -LoRAnado now includes detection and control surfaces to reduce incompatible LoRA picks in PonyXL workflows. +LoRAnado includes detection and control surfaces to reduce incompatible LoRA picks in PonyXL and Anima workflows. Controls: -- `Auto-detect PonyXL-compatible LoRAs` +- `Auto-detect PonyXL/Anima-compatible LoRAs` - `Scan LoRAs` - `Select All Compatible` - `Detected LoRAs (toggle enabled)` - `LoRAnado blacklist` -### PonyXL detection behavior +### Detection behavior -Detection now prefers strict compatibility signals: +Detection prefers strict compatibility signals: 1. Filename token matches (word-boundary aware): - - `pony`, `pony xl`, `pony-diffusion`, `ponydiffusion`, `pdxl`, `xlp` + - PonyXL: `pony`, `pony xl`, `pony-diffusion`, `ponydiffusion`, `pdxl`, `xlp` + - Anima: `anima` 2. Metadata matches from relevant base-model/architecture keys only - avoids scanning unrelated metadata fields that previously caused false positives @@ -169,6 +160,9 @@ If no compatible LoRAs are detected, RanbooruX falls back to all LoRAs in the se For Img2Img workflows, RanbooruX runs an initial pass, then a dedicated Img2Img pass, then optional manual ADetailer processing. +> [!NOTE] +> Img2Img is currently **not tested with Anima models/LoRAs**. + Important behavior: - first-pass previews are suppressed until final images are ready (preview guard) @@ -187,11 +181,6 @@ PYTHONPATH=/path/to/sd-webui-ranbooruX pytest -q --gradio-version=4 python3 -m py_compile scripts/ranbooru.py ``` -Additional project-level guidance is in: - -- `TESTING.md` -- `PROJECT_STATUS.md` - ## Forge/Forge Neo compatibility notes - Deepbooru support has been removed in RanbooruX. @@ -202,11 +191,12 @@ Additional project-level guidance is in: ## RanbooruX vs Original Ranbooru - Project scope: original Ranbooru is mostly a single-script extension; RanbooruX adds a modular package (`ranboorux/`), a full `tests/` suite, CI/pre-commit/tooling config, and contributor/testing docs. -- Core implementation: `scripts/ranbooru.py` is heavily expanded/refactored (about 1.1k lines in original vs about 7.9k lines here) with compatibility wrappers and integration boundaries. +- Core implementation: `scripts/ranbooru.py` is heavily expanded/refactored (about 1.1k lines in original vs about 5.8k lines here) with compatibility wrappers and integration boundaries for Forge Neo. - Feature set: RanbooruX adds Danbooru tag-catalog processing (bundled/custom CSV + validation/import), `Quick Strip`, richer removal filters, and a diagnostics panel. -- Integration flow: RanbooruX hardens Img2Img + ControlNet + ADetailer behavior with safer two-pass processing and guarded/manual ADetailer execution. -- LoRAnado: RanbooruX introduces PonyXL-aware LoRA detection/selection controls and blacklist support. -- Compatibility/dependencies: RanbooruX removes Deepbooru and bundled `scripts/controlnet.py`, and switches installer behavior to `requirements.txt`-driven installs with expanded deps (for example `requests`, `Pillow`, `timm`). +- Integration flow: RanbooruX hardens Img2Img + ControlNet + ADetailer behavior on Forge Neo with safer two-pass processing and guarded/manual ADetailer execution. +- LoRAnado: RanbooruX introduces PonyXL & Anima-aware LoRA detection/selection controls and blacklist support. +- Deepbooru Removal: Deepbooru support has been removed in RanbooruX. +- Compatibility/dependencies: RanbooruX switches installer behavior to `requirements.txt`-driven installs with expanded deps (for example `requests`, `Pillow`, `timm`). ## Credits diff --git a/adetailer b/adetailer new file mode 160000 index 0000000..3a599f5 --- /dev/null +++ b/adetailer @@ -0,0 +1 @@ +Subproject commit 3a599f5d4607d8f9d8b9fc5a15526197418dae1a diff --git a/pyproject.toml b/pyproject.toml index 6171e32..0dbd3fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ files = ["ranboorux"] warn_return_any = true warn_unused_ignores = true ignore_missing_imports = true +follow_imports = "skip" pretty = true show_error_codes = true diff --git a/ranboorux/boorus/__init__.py b/ranboorux/boorus/__init__.py new file mode 100644 index 0000000..4d7c409 --- /dev/null +++ b/ranboorux/boorus/__init__.py @@ -0,0 +1,146 @@ +"""Booru base class and factory function.""" + +import random +from typing import Dict, List, Optional + +from ranboorux import http_client as rb_http_client + + +class Booru: + def __init__(self, booru_name, base_api_url, http_client=None): + from scripts.ranbooru import Script + + self.booru_name = booru_name + self.base_api_url = base_api_url + self.http = http_client or rb_http_client.BooruSession() + self.headers = {"user-agent": f"Ranbooru Extension/{Script.version} for Forge"} + + def _fetch_data(self, query_url): + from scripts.ranbooru import BooruError, _log + + _log(f"Querying {self.booru_name}: {rb_http_client.redact_url(query_url)}") + try: + return self.http.get_json(query_url, headers=self.headers, timeout=30) + except Exception as e: + message = rb_http_client.safe_exception_message( + f"fetching data from {self.booru_name}", query_url, e + ) + _log(f"Error {message}") + raise BooruError(f"HTTP Error {message}") from e + + def _is_direct_image_url(self, url): + """Check if URL is a direct image URL (not from external sites like Pixiv/Twitter)""" + if not url or not isinstance(url, str): + return False + + # Skip external sites that don't provide direct image access + external_sites = [ + "pixiv.net", + "pximg.net", + "twitter.com", + "x.com", + "t.co", + "deviantart.com", + "artstation.com", + "instagram.com", + "facebook.com", + "patreon.com", + "fanbox.cc", + ] + + url_lower = url.lower() + for site in external_sites: + if site in url_lower: + return False + + # Check if URL ends with common image extensions + image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"] + if any(url_lower.endswith(ext) for ext in image_extensions): + return True + + # Check if URL contains image-serving patterns + if any(pattern in url_lower for pattern in ["/images/", "/img/", "/media/", "/files/"]): + return True + + return False + + def _standardize_post(self, post_data): + from scripts.ranbooru import _split_tag_string, _split_tag_string_override + + post = {} + # extract tags in a robust way; some APIs return categorized tags as dicts + raw_tags = post_data.get("tags", post_data.get("tag_string", "")) + # store categorized lists when possible + artist_tags = [] + character_tags = [] + copyright_tags = [] + if isinstance(post_data.get("tags"), dict): + tags_dict = post_data.get("tags") + # e621 style: tags dict with sublevels + if isinstance(tags_dict.get("artist"), list): + artist_tags = tags_dict.get("artist", []) + if isinstance(tags_dict.get("character"), list): + character_tags = tags_dict.get("character", []) + if isinstance(tags_dict.get("copyright"), list): + copyright_tags = tags_dict.get("copyright", []) + if "tag_string_artist" in post_data: + parsed = _split_tag_string_override(post_data.get("tag_string_artist")) + if parsed is not None: + artist_tags = parsed + if "tag_string_character" in post_data: + parsed = _split_tag_string_override(post_data.get("tag_string_character")) + if parsed is not None: + character_tags = parsed + if "tag_string_copyright" in post_data: + parsed = _split_tag_string_override(post_data.get("tag_string_copyright")) + if parsed is not None: + copyright_tags = parsed + + # For boorus that don't provide categorized tags, try to extract character tags from the main tag string + # This handles cases like Gelbooru/Danbooru where character tags are mixed with other tags + if not character_tags and isinstance(raw_tags, str): + all_tags = _split_tag_string(raw_tags) + for tag in all_tags: + # Common patterns for character tags: contains parentheses (series name) or ends with specific patterns + if ( + ("(" in tag and ")" in tag) + or tag.endswith(r"_\(series\)") + or tag.endswith(r"_\(character\)") + ): + character_tags.append(tag) + # Also catch some common character name patterns (this is heuristic but should catch most) + elif any( + series in tag.lower() + for series in [ + "genshin_impact", + "touhou", + "fate_", + "azur_lane", + "kantai_collection", + "pokemon", + ] + ): + character_tags.append(tag) + + post["tags"] = raw_tags + post["artist_tags"] = artist_tags + post["character_tags"] = character_tags + post["copyright_tags"] = copyright_tags + post["score"] = post_data.get("score", 0) + post["file_url"] = post_data.get("file_url") + if post["file_url"] is None: + post["file_url"] = post_data.get("large_file_url") + if post["file_url"] is None: + # Check if source is a direct image URL before using it + source_url = post_data.get("source") + if source_url and self._is_direct_image_url(source_url): + post["file_url"] = source_url + else: + post["file_url"] = None + post["id"] = post_data.get("id") + post["rating"] = post_data.get("rating") + post["booru_name"] = self.booru_name + return post + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + raise NotImplementedError \ No newline at end of file diff --git a/ranboorux/boorus/gelbooru.py b/ranboorux/boorus/gelbooru.py new file mode 100644 index 0000000..07c2303 --- /dev/null +++ b/ranboorux/boorus/gelbooru.py @@ -0,0 +1,278 @@ +"""Gelbooru and GelbooruCompatible booru classes.""" + +import random +import time +import xml.etree.ElementTree as ET +from typing import Dict, List, Optional, Tuple +from urllib.parse import quote_plus + +from ranboorux import http_client as rb_http_client +from ranboorux.boorus import Booru + + +class Gelbooru(Booru): + def __init__(self, fringe_benefits, credentials: Optional[Dict[str, str]] = None): + from scripts.ranbooru import POST_AMOUNT, _sanitize_gelbooru_credential + + super().__init__( + "Gelbooru", + f"https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&limit={POST_AMOUNT}", + ) + self.fringeBenefits = fringe_benefits + credentials = credentials or {} + self.api_key = ( + _sanitize_gelbooru_credential(credentials.get("api_key")) + if isinstance(credentials, dict) + else "" + ) + self.user_id = ( + _sanitize_gelbooru_credential(credentials.get("user_id")) + if isinstance(credentials, dict) + else "" + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + from scripts.ranbooru import BooruError + + _r.COUNT = 0 + all_fetched_posts = [] + if not self.api_key or not self.user_id: + raise BooruError( + "Gelbooru requires an API key and user ID. Set them under RanbooruX \u00bb Gelbooru settings." + ) + credentials_query = ( + f"&api_key={quote_plus(self.api_key)}&user_id={quote_plus(self.user_id)}" + ) + if post_id: + query_url = f"{self.base_api_url}{credentials_query}&id={post_id}{tags_query}" + fetched_data = self._fetch_data(query_url) + if fetched_data and "post" in fetched_data and isinstance(fetched_data["post"], list): + all_fetched_posts = fetched_data["post"] + _r.COUNT = len(all_fetched_posts) + print(f"[R] Found {_r.COUNT} post(s) for ID: {post_id}") + else: + page = random.randint(0, max_pages - 1) + query_url = f"{self.base_api_url}{credentials_query}&pid={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if fetched_data and "post" in fetched_data and isinstance(fetched_data["post"], list): + all_fetched_posts = fetched_data["post"] + if ( + fetched_data + and "@attributes" in fetched_data + and "count" in fetched_data["@attributes"] + ): + try: + _r.COUNT = int(fetched_data["@attributes"]["count"]) + except Exception: + _r.COUNT = len(all_fetched_posts) + else: + _r.COUNT = len(all_fetched_posts) + print( + f"[R] Fetched {len(all_fetched_posts)} posts from page {page}. Reported total (approx): {_r.COUNT}" + ) + return [self._standardize_post(post) for post in all_fetched_posts] + + +class GelbooruCompatible(Booru): + RETRIABLE_STATUS = {429, 500, 502, 503, 504} + + def __init__( + self, base_url: str, retries: int = 3, backoff: float = 1.5, log_diagnostics: bool = True + ): + from scripts.ranbooru import _sanitize_gelbooru_compat_base_url + + sanitized = _sanitize_gelbooru_compat_base_url(base_url) + if not sanitized: + raise ValueError("Invalid Gelbooru-compatible base URL.") + self.base_url = sanitized + self.retries = max(1, retries) + self.backoff = max(0.5, backoff) + self.log_diagnostics = log_diagnostics + self._post_endpoint = f"{self.base_url}/index.php?page=dapi&s=post&q=index" + self._tag_endpoint = f"{self.base_url}/index.php?page=dapi&s=tag&q=index" + self._alias_endpoint = f"{self.base_url}/index.php?page=dapi&s=tag_alias&q=index" + super().__init__("Gelbooru-Compatible", self._post_endpoint) + + def _perform_request(self, url: str): + from scripts.ranbooru import BooruError + + last_error: Optional[Exception] = None + for attempt in range(1, self.retries + 1): + try: + response = self.http.get(url, headers=self.headers, timeout=30, stream=True) + except Exception as exc: + last_error = exc + self._log_retry(url, attempt, f"Request error: {exc.__class__.__name__}") + else: + if response.status_code in self.RETRIABLE_STATUS: + last_error = BooruError(f"Status {response.status_code}") + self._log_retry(url, attempt, f"Status {response.status_code}") + close = getattr(response, "close", None) + if callable(close): + close() + else: + content = self.http._read_bounded_response( + response, + url, + rb_http_client.DEFAULT_API_MAX_BYTES, + ) + return rb_http_client.BoundedResponse( + url=str(getattr(response, "url", url) or url), + status_code=int(getattr(response, "status_code", 200) or 200), + headers=getattr(response, "headers", {}) or {}, + content=content, + encoding=getattr(response, "encoding", None), + ) + time.sleep(min(self.backoff * attempt, 5.0)) + if last_error is None: + error_summary = "unknown error" + elif isinstance(last_error, BooruError): + error_summary = str(last_error) + else: + error_summary = last_error.__class__.__name__ + raise BooruError( + f"HTTP Error fetching from {self.booru_name}: {error_summary} for {rb_http_client.redact_url(url)}" + ) + + def _log_retry(self, url: str, attempt: int, message: str) -> None: + from scripts.ranbooru import _log + + _log(f"{self.booru_name}: retry {attempt} for {rb_http_client.redact_url(url)} - {message}") + + def _log_snippet(self, response) -> None: + from scripts.ranbooru import _log + + if not self.log_diagnostics: + return + snippet = response.text.strip().replace("\n", " ")[:200] + _log( + f"{self.booru_name}: {rb_http_client.redact_url(getattr(response, 'url', ''))} -> {snippet}" + ) + + def _parse_json_entities(self, payload, entity_key: str) -> Tuple[List[dict], Optional[int]]: + entries: List[dict] = [] + approx = None + if isinstance(payload, dict): + possible = payload.get(entity_key) + if isinstance(possible, list): + entries = possible + elif isinstance(possible, dict): + entries = [possible] + attrs = payload.get("@attributes") + if isinstance(attrs, dict) and "count" in attrs: + try: + approx = int(attrs["count"]) + except (TypeError, ValueError): + approx = None + elif isinstance(payload, list): + entries = payload + return entries, approx + + def _parse_xml_entities( + self, text_payload: str, entity_key: str + ) -> Tuple[List[dict], Optional[int]]: + from scripts.ranbooru import BooruError + + probe = (text_payload or "").lower() + if (" Tuple[List[dict], int]: + from scripts.ranbooru import BooruError + + json_url = f"{url_base}&json=1" + try: + response = self._perform_request(json_url) + self._log_snippet(response) + ct = (response.headers.get("content-type") or "").lower() + text_head = (response.text or "").lstrip()[:64].lower() + if ( + "html" in ct + or text_head.startswith(" 0 else 0 + query_base = f"{self._post_endpoint}&limit={POST_AMOUNT}&pid={page}{tags_query}" + posts, approx = self._request_dapi(query_base, "post") + _r.COUNT = approx + print( + f"[R] Gelbooru-compatible: fetched {len(posts)} posts from page {page}. Reported count={approx}" + ) + standardized = [] + for post in posts: + normalized = self._standardize_post(post) + normalized["source_base_url"] = self.base_url + standardized.append(normalized) + return standardized + + def get_tags(self, name_pattern: Optional[str] = None, limit: int = 100) -> List[dict]: + query = f"{self._tag_endpoint}&limit={limit}" + if name_pattern: + query += f"&name_pattern={quote_plus(name_pattern)}" + tags, _ = self._request_dapi(query, "tag") + return tags + + def get_tag_aliases(self, name_pattern: Optional[str] = None, limit: int = 100) -> List[dict]: + query = f"{self._alias_endpoint}&limit={limit}" + if name_pattern: + query += f"&name_pattern={quote_plus(name_pattern)}" + aliases, _ = self._request_dapi(query, "tag_alias") + return aliases \ No newline at end of file diff --git a/ranboorux/boorus/simple.py b/ranboorux/boorus/simple.py new file mode 100644 index 0000000..198784c --- /dev/null +++ b/ranboorux/boorus/simple.py @@ -0,0 +1,280 @@ +"""Config-driven booru subclasses for 8 simple booru APIs. + +Each subclass has a unique base_url and slight variations in get_posts(). +""" + +import random +from typing import List, Optional + +from ranboorux.boorus import Booru + + +class Danbooru(Booru): + def __init__(self): + from scripts.ranbooru import POST_AMOUNT + + super().__init__( + "Danbooru", f"https://danbooru.donmai.us/posts.json?limit={POST_AMOUNT}" + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + _r.COUNT = 0 + all_fetched_posts = [] + if post_id: + query_url = f"https://danbooru.donmai.us/posts/{post_id}.json" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, dict) and "id" in fetched_data: + all_fetched_posts = [fetched_data] + _r.COUNT = len(all_fetched_posts) + print(f"[R] Found {_r.COUNT} post(s) for ID: {post_id}") + else: + page = random.randint(1, max_pages) + query_url = f"{self.base_api_url}&page={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, list): + all_fetched_posts = fetched_data + _r.COUNT = len(all_fetched_posts) + print(f"[R] Fetched {_r.COUNT} posts from page {page}.") + return [self._standardize_post(post) for post in all_fetched_posts if post] + + +class XBooru(Booru): + def __init__(self): + from scripts.ranbooru import POST_AMOUNT + + super().__init__( + "XBooru", + f"https://xbooru.com/index.php?page=dapi&s=post&q=index&json=1&limit={POST_AMOUNT}", + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + _r.COUNT = 0 + all_fetched_posts = [] + if post_id: + query_url = f"{self.base_api_url}&id={post_id}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, dict) and "id" in fetched_data: + all_fetched_posts = [fetched_data] + else: + page = random.randint(0, max_pages - 1) + query_url = f"{self.base_api_url}&pid={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, list): + all_fetched_posts = fetched_data + _r.COUNT = len(all_fetched_posts) + print(f"[R] Fetched {_r.COUNT} posts from XBooru.") + standardized_posts = [] + for post_data in all_fetched_posts: + post = self._standardize_post(post_data) + if "directory" in post_data and "image" in post_data: + post["file_url"] = ( + f"https://xbooru.com/images/{post_data['directory']}/{post_data['image']}" + ) + standardized_posts.append(post) + return standardized_posts + + +class Rule34(Booru): + def __init__(self): + from scripts.ranbooru import POST_AMOUNT + + super().__init__( + "Rule34", + f"https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&json=1&limit={POST_AMOUNT}", + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + _r.COUNT = 0 + all_fetched_posts = [] + if post_id: + query_url = f"{self.base_api_url}&id={post_id}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, dict) and "id" in fetched_data: + all_fetched_posts = [fetched_data] + else: + page = random.randint(0, max_pages - 1) + query_url = f"{self.base_api_url}&pid={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, list): + all_fetched_posts = fetched_data + _r.COUNT = len(all_fetched_posts) + print(f"[R] Fetched {_r.COUNT} posts from Rule34.") + return [self._standardize_post(post) for post in all_fetched_posts] + + +class Safebooru(Booru): + def __init__(self): + from scripts.ranbooru import POST_AMOUNT + + super().__init__( + "Safebooru", + f"https://safebooru.org/index.php?page=dapi&s=post&q=index&json=1&limit={POST_AMOUNT}", + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + _r.COUNT = 0 + all_fetched_posts = [] + if post_id: + query_url = f"{self.base_api_url}&id={post_id}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, dict) and "id" in fetched_data: + all_fetched_posts = [fetched_data] + else: + page = random.randint(0, max_pages - 1) + query_url = f"{self.base_api_url}&pid={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, list): + all_fetched_posts = fetched_data + _r.COUNT = len(all_fetched_posts) + print(f"[R] Fetched {_r.COUNT} posts from Safebooru.") + standardized_posts = [] + for post_data in all_fetched_posts: + post = self._standardize_post(post_data) + if "directory" in post_data and "image" in post_data: + post["file_url"] = ( + f"https://safebooru.org/images/{post_data['directory']}/{post_data['image']}" + ) + standardized_posts.append(post) + return standardized_posts + + +class Konachan(Booru): + def __init__(self): + from scripts.ranbooru import POST_AMOUNT + + super().__init__( + "Konachan", f"https://konachan.com/post.json?limit={POST_AMOUNT}" + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + _r.COUNT = 0 + all_fetched_posts = [] + if post_id: + print("[R] Warn: Konachan does not support post IDs.") + return [] + page = random.randint(1, max_pages) + query_url = f"{self.base_api_url}&page={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, list): + all_fetched_posts = fetched_data + _r.COUNT = len(all_fetched_posts) + print(f"[R] Fetched {_r.COUNT} posts from Konachan.") + return [self._standardize_post(post) for post in all_fetched_posts] + + +class Yandere(Booru): + def __init__(self): + from scripts.ranbooru import POST_AMOUNT + + super().__init__( + "Yandere", f"https://yande.re/post.json?limit={POST_AMOUNT}" + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + _r.COUNT = 0 + all_fetched_posts = [] + if post_id: + print("[R] Warn: Yandere does not support post IDs.") + return [] + page = random.randint(1, max_pages) + query_url = f"{self.base_api_url}&page={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, list): + all_fetched_posts = fetched_data + _r.COUNT = len(all_fetched_posts) + print(f"[R] Fetched {_r.COUNT} posts from Yandere.") + return [self._standardize_post(post) for post in all_fetched_posts] + + +class AIBooru(Booru): + def __init__(self): + from scripts.ranbooru import POST_AMOUNT + + super().__init__( + "AIBooru", f"https://aibooru.online/posts.json?limit={POST_AMOUNT}" + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + from scripts.ranbooru import POST_AMOUNT + + _r.COUNT = 0 + all_fetched_posts = [] + if post_id: + print("[R] Warn: AIBooru does not support post IDs.") + return [] + page = random.randint(1, max_pages) + query_url = f"{self.base_api_url}?limit={POST_AMOUNT}&page={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if isinstance(fetched_data, list): + all_fetched_posts = fetched_data + _r.COUNT = len(all_fetched_posts) + print(f"[R] Fetched {_r.COUNT} posts from AIBooru.") + standardized_posts = [] + for post_data in all_fetched_posts: + post = self._standardize_post(post_data) + post["tags"] = post_data.get("tag_string", "") + standardized_posts.append(post) + return standardized_posts + + +class e621(Booru): + def __init__(self): + from scripts.ranbooru import POST_AMOUNT + + super().__init__( + "e621", f"https://e621.net/posts.json?limit={POST_AMOUNT}" + ) + + def get_posts(self, tags_query="", max_pages=10, post_id=None): + import scripts.ranbooru as _r + + _r.COUNT = 0 + all_fetched_posts = [] + if post_id: + print("[R] Warn: e621 does not support post IDs.") + return [] + page = random.randint(1, max_pages) + query_url = f"{self.base_api_url}?page={page}{tags_query}" + fetched_data = self._fetch_data(query_url) + if ( + isinstance(fetched_data, dict) + and "posts" in fetched_data + and isinstance(fetched_data["posts"], list) + ): + all_fetched_posts = fetched_data["posts"] + _r.COUNT = len(all_fetched_posts) + print(f"[R] Fetched {_r.COUNT} posts from e621.") + standardized_posts = [] + for post_data in all_fetched_posts: + post = self._standardize_post(post_data) + temp_tags = [] + sublevels = ["general", "artist", "copyright", "character", "species"] + if "tags" in post_data: + for sublevel in sublevels: + if sublevel in post_data["tags"] and isinstance( + post_data["tags"][sublevel], list + ): + temp_tags.extend(post_data["tags"][sublevel]) + post["tags"] = " ".join(temp_tags) + if ( + "score" in post_data + and isinstance(post_data["score"], dict) + and "total" in post_data["score"] + ): + post["score"] = post_data["score"]["total"] + standardized_posts.append(post) + return standardized_posts \ No newline at end of file diff --git a/ranboorux/http_client.py b/ranboorux/http_client.py new file mode 100644 index 0000000..83dc1ff --- /dev/null +++ b/ranboorux/http_client.py @@ -0,0 +1,612 @@ +from __future__ import annotations + +import ipaddress +import json +import re +import socket +from dataclasses import dataclass +from typing import Any, Mapping, Optional +from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlparse + +import requests +from requests.adapters import HTTPAdapter +from urllib3 import PoolManager +from urllib3.connection import HTTPConnection, HTTPSConnection +from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool + +try: + import requests_cache +except Exception: # pragma: no cover - requests_cache is optional in host tests + requests_cache = None + + +SENSITIVE_QUERY_PARAMS = { + "x-amz-credential", + "x-amz-signature", + "x-amz-security-token", + "x-amz-date", + "x-goog-signature", + "x-goog-credential", + "signature", + "sig", + "token", + "access_token", + "authorization", + "key", + "api_key", + "user_id", + "password", +} +REDIRECT_STATUSES = {301, 302, 303, 307, 308} +MAX_REDIRECTS = 5 +STREAM_CHUNK_SIZE = 64 * 1024 +DEFAULT_API_MAX_BYTES = 5 * 1024 * 1024 + + +class ResponseTooLargeError(RuntimeError): + pass + + +class UnsafeUrlError(ValueError): + pass + + +class InvalidContentTypeError(RuntimeError): + pass + + +class BooruResponseError(ValueError): + pass + + +@dataclass +class BoundedResponse: + url: str + status_code: int + headers: Mapping[str, str] + content: bytes + encoding: Optional[str] = None + + @property + def text(self) -> str: + return self.content.decode(self.encoding or "utf-8", errors="replace") + + def json(self) -> Any: + return json.loads(self.content.decode(self.encoding or "utf-8")) + + def raise_for_status(self) -> None: + if 400 <= int(self.status_code) < 600: + raise RuntimeError(f"HTTP status {self.status_code} for {redact_url(self.url)}") + + +def redact_url(url: object) -> str: + text = str(url or "") + if not text: + return text + try: + parsed = urlparse(text) + if not parsed.query: + return text + qsl = parse_qsl(parsed.query, keep_blank_values=True) + new_qsl = [] + for name, value in qsl: + if name.lower() in SENSITIVE_QUERY_PARAMS: + new_qsl.append((name, "")) + else: + new_qsl.append((name, value)) + + query_parts = [] + for name, val in new_qsl: + if val == "": + query_parts.append(f"{quote(name)}=") + else: + query_parts.append(urlencode([(name, val)])) + + return parsed._replace(query="&".join(query_parts)).geturl() + except Exception: + return text + + +def redact_paths(text: str) -> str: + if not text: + return text + + idx = 0 + result = [] + n = len(text) + + def detect_prefix(pos): + # 1. file-URI or file:// + if text[pos:].lower().startswith("file:" + "///"): + return 8, "file" + if text[pos:].lower().startswith("file:" + "//"): + return 7, "file" + + # 2. UNC path starts with \\ + if text[pos:].startswith("\\\\"): + rest = text[pos + 2 :] + if rest and (rest[0].isalnum() or rest[0] in "._-"): + return 2, "unc" + + # 3. Windows drive path: [a-zA-Z]:\ or [a-zA-Z]:/ + is_word_boundary = pos == 0 or not text[pos - 1].isalnum() + if is_word_boundary and pos + 2 < n: + if text[pos].isalpha() and text[pos + 1] == ":" and text[pos + 2] in "\\/": + return 3, "win" + + # 4. POSIX absolute path: starts with / and not followed by / + is_posix_boundary = pos == 0 or (not text[pos - 1].isalnum() and text[pos - 1] != "/") + if is_posix_boundary and text[pos] == "/": + if pos + 1 < n and text[pos + 1] == "/": + return None + return 1, "posix" + + return None + + while idx < n: + prefix_info = detect_prefix(idx) + if prefix_info is None: + result.append(text[idx]) + idx += 1 + continue + + prefix_len, ptype = prefix_info + start_path_idx = idx + + scan_idx = idx + prefix_len + bracket_stack = [] + + while scan_idx < n: + char = text[scan_idx] + + # Stop on quotes, tabs, newlines + if char in "'\"`\t\r\n": + break + + # Stop on unmatched brackets + if char in "([{": + bracket_stack.append(char) + elif char in ")]}": + if not bracket_stack: + break + top = bracket_stack.pop() + if ( + (char == ")" and top != "(") + or (char == "]" and top != "[") + or (char == "}" and top != "{") + ): + break + + # Stop on trailing punctuation followed by space or end of string + is_last = scan_idx + 1 == n + next_char = text[scan_idx + 1] if not is_last else "" + if char in ".,!?;" and (is_last or next_char.isspace()): + break + + # Stop before another path prefix or a URL starts + rem = text[scan_idx:] + if rem.lower().startswith(("http://", "https://", "file:" + "//")): + break + + is_new_path_boundary = scan_idx > 0 and text[scan_idx - 1] not in "\\/:" + if is_new_path_boundary and rem.startswith("\\\\"): + break + + is_rem_word_boundary = scan_idx == 0 or not text[scan_idx - 1].isalnum() + if is_new_path_boundary and is_rem_word_boundary and len(rem) >= 3: + if rem[0].isalpha() and rem[1] == ":" and rem[2] in "\\/": + break + if ( + is_new_path_boundary + and is_rem_word_boundary + and rem.startswith("/") + and not rem.startswith("//") + ): + break + + scan_idx += 1 + + path_str = text[start_path_idx:scan_idx] + stripped_path = path_str.rstrip() + trailing_spaces = path_str[len(stripped_path) :] + + result.append("") + result.append(trailing_spaces) + idx = scan_idx + + return "".join(result) + + +def redact_urls_in_text(text: str) -> str: + # Find all http/https URLs in the text + url_pattern = re.compile(r"https?://[^\s'\")]+", re.IGNORECASE) + + def repl(match): + return redact_url(match.group(0)) + + return url_pattern.sub(repl, text) + + +def sanitize_exception_text(text: str) -> str: + if not text: + return text + # First, redact paths (so URL-like file-URI paths get redacted completely) + text = redact_paths(text) + # Next, redact any remaining HTTP/HTTPS URLs + text = redact_urls_in_text(text) + return text + + +def sanitize_exception(exc: Exception) -> Exception: + if isinstance( + exc, + ( + UnsafeUrlError, + ResponseTooLargeError, + InvalidContentTypeError, + BooruResponseError, + ), + ): + return exc + return RuntimeError(sanitize_exception_text(str(exc))) + + +def safe_exception_message(operation: str, url: object, exc: BaseException) -> str: + sanitized_msg = sanitize_exception_text(str(exc)) + return f"{operation} failed for {redact_url(url)} ({exc.__class__.__name__}: {sanitized_msg})" + + +def _has_sensitive_query(url: object) -> bool: + text = str(url or "") + if not text: + return False + try: + parsed = urlparse(text) + if not parsed.query: + return False + qsl = parse_qsl(parsed.query, keep_blank_values=True) + for name, _ in qsl: + if name.lower() in SENSITIVE_QUERY_PARAMS: + return True + except Exception: + pass + return False + + +def _is_public_ip(address: object) -> bool: + try: + parsed = ipaddress.ip_address(str(address)) + except ValueError: + return False + return bool( + parsed.is_global + and not parsed.is_loopback + and not parsed.is_link_local + and not parsed.is_multicast + and not parsed.is_unspecified + and not parsed.is_reserved + ) + + +def _close_socket(sock: object) -> None: + close = getattr(sock, "close", None) + if callable(close): + close() + + +def _validate_connected_socket(sock: object) -> None: + getpeername = getattr(sock, "getpeername", None) + if not callable(getpeername): + return + peer = getpeername() + address = peer[0] if isinstance(peer, tuple) and peer else None + if address is None: + raise UnsafeUrlError("Connected socket has no peer address") + if not _is_public_ip(address): + _close_socket(sock) + raise UnsafeUrlError(f"Connected peer resolves to a blocked address: {address}") + + +class _SafeHTTPConnection(HTTPConnection): + def _new_conn(self): + sock = super()._new_conn() + _validate_connected_socket(sock) + return sock + + +class _SafeHTTPSConnection(HTTPSConnection): + def _new_conn(self): + sock = super()._new_conn() + _validate_connected_socket(sock) + return sock + + +class _SafeHTTPConnectionPool(HTTPConnectionPool): + ConnectionCls = _SafeHTTPConnection + + +class _SafeHTTPSConnectionPool(HTTPSConnectionPool): + ConnectionCls = _SafeHTTPSConnection + + +class _SafeHTTPAdapter(HTTPAdapter): + def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): + self.poolmanager = PoolManager( + num_pools=connections, + maxsize=maxsize, + block=block, + **pool_kwargs, + ) + self.poolmanager.pool_classes_by_scheme = { + "http": _SafeHTTPConnectionPool, + "https": _SafeHTTPSConnectionPool, + } + + def proxy_manager_for(self, proxy, **proxy_kwargs): + manager = super().proxy_manager_for(proxy, **proxy_kwargs) + if hasattr(manager, "pool_classes_by_scheme"): + manager.pool_classes_by_scheme = { + "http": _SafeHTTPConnectionPool, + "https": _SafeHTTPSConnectionPool, + } + return manager + + +def _resolve_host(hostname: str, port: Optional[int]) -> list[str]: + try: + return [str(ipaddress.ip_address(hostname))] + except ValueError: + pass + try: + infos = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM) + except OSError as exc: + raise UnsafeUrlError(f"Could not resolve outbound host: {hostname}") from exc + addresses = [] + for info in infos: + sockaddr = info[4] + if sockaddr: + addresses.append(str(sockaddr[0])) + if not addresses: + raise UnsafeUrlError(f"Could not resolve outbound host: {hostname}") + return addresses + + +def validate_outbound_url(url: object) -> str: + text = str(url or "").strip() + parsed = urlparse(text) + if parsed.scheme not in {"http", "https"}: + raise UnsafeUrlError(f"Unsupported outbound URL scheme: {parsed.scheme or ''}") + if not parsed.hostname: + raise UnsafeUrlError("Outbound URL is missing a host") + if parsed.username or parsed.password: + raise UnsafeUrlError("Outbound URL must not contain userinfo credentials") + addresses = _resolve_host(parsed.hostname, parsed.port) + blocked = [address for address in addresses if not _is_public_ip(address)] + if blocked: + raise UnsafeUrlError(f"Outbound URL resolves to a blocked address: {parsed.hostname}") + return text + + +class BooruSession: + def __init__(self, *, use_cache: bool = False, expire_after: int = 3600): + session_factory = getattr(requests, "Session", None) + self._cache_enabled = bool(use_cache) + self._uncached_session = session_factory() if callable(session_factory) else requests + self._install_safe_adapter(self._uncached_session) + if use_cache: + if requests_cache is None: + raise RuntimeError("requests-cache is required when booru request cache is enabled") + cached_session = getattr(requests_cache, "CachedSession", None) + if not callable(cached_session): + raise RuntimeError("requests-cache CachedSession is unavailable") + self._session = cached_session( + "ranbooru_cache", + backend="sqlite", + expire_after=expire_after, + allowable_codes=(200,), + ) + self._install_safe_adapter(self._session) + return + self._session = self._uncached_session + + @staticmethod + def _install_safe_adapter(session: object) -> None: + mount = getattr(session, "mount", None) + if callable(mount): + adapter = _SafeHTTPAdapter() + mount("http://", adapter) + mount("https://", adapter) + + def _session_for_url(self, url: str): + if self._cache_enabled and _has_sensitive_query(url): + return self._uncached_session + return self._session + + def get( + self, + url: str, + *, + headers: Optional[Mapping[str, str]] = None, + timeout: int = 30, + stream: bool = False, + ): + try: + current_url = validate_outbound_url(url) + request_headers = dict(headers or {}) + history_urls = [] + chain_is_sensitive = _has_sensitive_query(current_url) + + for _ in range(MAX_REDIRECTS + 1): + if chain_is_sensitive: + session = self._uncached_session + else: + session = self._session_for_url(current_url) + + history_urls.append((session, current_url)) + + response = session.get( + current_url, + headers=request_headers, + timeout=timeout, + allow_redirects=False, + stream=stream, + ) + status_code = getattr(response, "status_code", None) + if status_code not in REDIRECT_STATUSES: + return response + location = (getattr(response, "headers", {}) or {}).get("location") + + # Remove from cache if the redirect target contains any sensitive queries + if status_code in REDIRECT_STATUSES and location: + redirect_target = urljoin(current_url, location) + if _has_sensitive_query(redirect_target): + chain_is_sensitive = True + + if chain_is_sensitive: + for hist_session, hist_url in history_urls: + delete_fn = getattr(hist_session, "delete", None) + if callable(delete_fn): + try: + delete_fn(hist_url) + except Exception: + pass + + close = getattr(response, "close", None) + if callable(close): + close() + if not location: + return response + current_url = validate_outbound_url(urljoin(current_url, location)) + raise UnsafeUrlError(f"Too many redirects while fetching {redact_url(url)}") + except Exception as exc: + raise sanitize_exception(exc) from None + + def _read_bounded_response(self, response: object, url: str, max_bytes: int) -> bytes: + response_headers = getattr(response, "headers", {}) or {} + content_length = ( + response_headers.get("content-length") if hasattr(response_headers, "get") else None + ) + if content_length: + try: + if int(content_length) > max_bytes: + raise ResponseTooLargeError( + f"Response from {redact_url(url)} exceeded {max_bytes} bytes" + ) + except ValueError: + pass + + chunks: list[bytes] = [] + total = 0 + try: + iter_content = getattr(response, "iter_content", None) + if callable(iter_content): + for chunk in iter_content(chunk_size=STREAM_CHUNK_SIZE): + if not chunk: + continue + total += len(chunk) + if total > max_bytes: + raise ResponseTooLargeError( + f"Response from {redact_url(url)} exceeded {max_bytes} bytes" + ) + chunks.append(chunk) + return b"".join(chunks) + + content = getattr(response, "content", b"") or b"" + if len(content) > max_bytes: + raise ResponseTooLargeError( + f"Response from {redact_url(url)} exceeded {max_bytes} bytes" + ) + return content + finally: + close = getattr(response, "close", None) + if callable(close): + close() + + def get_json( + self, + url: str, + *, + headers: Optional[Mapping[str, str]] = None, + timeout: int = 30, + max_bytes: int = DEFAULT_API_MAX_BYTES, + ) -> Any: + response = self.get(url, headers=headers, timeout=timeout, stream=True) + try: + response.raise_for_status() + response_headers = getattr(response, "headers", {}) or {} + content_type = ( + response_headers.get("content-type", "") if hasattr(response_headers, "get") else "" + ) + normalized_content_type = content_type.lower().split(";", 1)[0].strip() + if normalized_content_type and "json" not in normalized_content_type: + raise InvalidContentTypeError( + f"Response from {redact_url(url)} was not JSON ({content_type})" + ) + content = self._read_bounded_response(response, url, max_bytes) + encoding = getattr(response, "encoding", None) + try: + return json.loads(content.decode(encoding or "utf-8")) + except Exception as exc: + raise BooruResponseError(sanitize_exception_text(str(exc))) from exc + except Exception: + close = getattr(response, "close", None) + if callable(close): + close() + raise + + def get_text( + self, + url: str, + *, + headers: Optional[Mapping[str, str]] = None, + timeout: int = 30, + max_bytes: int = DEFAULT_API_MAX_BYTES, + ) -> BoundedResponse: + response = self.get(url, headers=headers, timeout=timeout, stream=True) + try: + response.raise_for_status() + content = self._read_bounded_response(response, url, max_bytes) + return BoundedResponse( + url=str(getattr(response, "url", url) or url), + status_code=int(getattr(response, "status_code", 200) or 200), + headers=getattr(response, "headers", {}) or {}, + content=content, + encoding=getattr(response, "encoding", None), + ) + except Exception: + close = getattr(response, "close", None) + if callable(close): + close() + raise + + def get_bytes( + self, + url: str, + *, + headers: Optional[Mapping[str, str]] = None, + timeout: int = 30, + max_bytes: int = 25 * 1024 * 1024, + ) -> bytes: + response = self.get(url, headers=headers, timeout=timeout, stream=True) + try: + response.raise_for_status() + response_headers = getattr(response, "headers", {}) or {} + content_type = ( + response_headers.get("content-type", "") if hasattr(response_headers, "get") else "" + ) + if content_type and not content_type.lower().split(";", 1)[0].startswith("image/"): + raise InvalidContentTypeError( + f"Response from {redact_url(url)} was not an image ({content_type})" + ) + return self._read_bounded_response(response, url, max_bytes) + except Exception: + close = getattr(response, "close", None) + if callable(close): + close() + raise + + def close(self) -> None: + for session in (self._session, self._uncached_session): + close = getattr(session, "close", None) + if callable(close): + close() diff --git a/ranboorux/image_ops.py b/ranboorux/image_ops.py index b84f04d..49d7cda 100644 --- a/ranboorux/image_ops.py +++ b/ranboorux/image_ops.py @@ -5,7 +5,9 @@ from PIL import Image -def resize_image(img: Optional[Image.Image], width: int, height: int, cropping: bool = True): +def resize_image( + img: Optional[Image.Image], width: int, height: int, cropping: bool = True +) -> Optional[Image.Image]: if img is None: return None if width <= 0 or height <= 0: diff --git a/ranboorux/integrations/adetailer_orchestration.py b/ranboorux/integrations/adetailer_orchestration.py new file mode 100644 index 0000000..82716a2 --- /dev/null +++ b/ranboorux/integrations/adetailer_orchestration.py @@ -0,0 +1,769 @@ +"""ADetailer orchestration — lifecycle management extracted from the Script class. + +Phase 3 of the maintainability refactor: encapsulates all ADetailer lifecycle +methods into a single ``AdetailerOrchestrator`` class that the Script instance +delegates to. + +The orchestrator holds a reference to the Script instance (``self._script``) +and accesses Script-owned state (``_adetailer_state``, ``_adetailer_patches``, +``_host_scope``, class-level ``_ranbooru_*`` flags, etc.) through it. +""" + +import logging +import types +from contextlib import contextmanager +from enum import Enum, auto +from typing import Any, Dict, Iterator, List, Optional, Tuple + + +class AdetailerState(Enum): + """Simplified state machine for the ADetailer lifecycle.""" + + IDLE = auto() + """No generation in progress or ADetailer is unblocked.""" + + INITIAL_PASS = auto() + """First pass is running — ADetailer is blocked / guarded.""" + + IMG2IMG_READY = auto() + """Initial pass done; ready for the img2img pass with ADetailer available.""" + + ADETAILER_ACTIVE = auto() + """Manual ADetailer execution is in progress.""" + + DONE = auto() + """Processing complete; guard flags cleared, ready for next generation.""" + +from ranboorux import http_client as rb_http_client +from ranboorux.integrations import adetailer_runtime as rb_adetailer_runtime + +_logger = logging.getLogger("ranboorux.adetailer_orch") + + +class AdetailerOrchestrator: + """Encapsulates ADetailer lifecycle management for RanbooruX. + + Receives a reference to the owning ``Script`` instance and delegates + Script-owned state access through ``self._script``. + """ + + def __init__(self, script_instance: object) -> None: + self._script = script_instance + self._state: AdetailerState = AdetailerState.IDLE + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _is_adetailer_script(script: object) -> bool: + """Check if a script is an ADetailer script.""" + try: + if script is None: + return False + script_name = ( + script.__class__.__name__.lower() + if hasattr(script, "__class__") + else str(script).lower() + ) + return ( + "adetailer" in script_name + or "afterdetailer" in script_name + or "after_detailer" in script_name + or "ad_script" in script_name + ) + except Exception as exc: + _logger.warning( + "Failed to inspect ADetailer script type: %s", + rb_http_client.sanitize_exception_text(str(exc)), + ) + return False + + # ------------------------------------------------------------------ + # Queries + # ------------------------------------------------------------------ + + def is_adetailer_enabled(self) -> bool: + """Return whether the Script-level ADetailer support toggle is on.""" + return bool(getattr(self._script, "_adetailer_support_enabled", False)) + + def _is_adetailer_enabled(self) -> bool: + """Alias kept for internal callers during extraction.""" + return self.is_adetailer_enabled() + + # ------------------------------------------------------------------ + # Initial-pass lifecycle + # ------------------------------------------------------------------ + + def _mark_initial_pass(self, p: object) -> None: + """Mark that we are in the initial pass so ADetailer can be intercepted later.""" + self._state = AdetailerState.INITIAL_PASS + try: + print("[R] Marking initial pass - ADetailer will run on img2img results instead") + + # Clear any previous hard-disable flag for ADetailer + try: + if hasattr(p, "_ad_disabled") and getattr(p, "_ad_disabled", False): + self._script._host_scope.set_attr(p, "_ad_disabled", False) + print("[R] Cleared p._ad_disabled from previous generation") + except Exception as _e: + print(f"[R] WARN: Could not clear p._ad_disabled: {_e}") + + # Clear our class-level guard + self._script._set_adetailer_block(False) + self._script._adetailer_state.initial_pass_suppressed = False + # Clear pipeline-level guard flag + setattr(self._script.__class__, "_ranbooru_block_all_adetailer", False) + + # Install runner guard (idempotent) + self._install_scriptrunner_guard(p) + + # CRITICAL: Re-enable any ADetailer scripts from previous generation + self._reenable_adetailer_from_previous_generation() + + # Just set a flag that we are in initial pass + self._script._ranbooru_initial_pass = True + + # Store reference to processing object for later use + self._script._initial_pass_p = p + + except Exception as e: + print(f"[R] Error marking initial pass: {e}") + + def _early_adetailer_protection(self, p: object) -> None: + """Complete ADetailer blocking during initial pass — remove scripts entirely.""" + if not self.is_adetailer_enabled(): + return + try: + print("[R Process] Early ADetailer protection activated") + + # Check if we are in the initial pass + if getattr(self._script, "_ranbooru_initial_pass", False): + print("[R Process] Detected initial pass - COMPLETELY BLOCKING ADetailer") + + # Set comprehensive block flags + self._script._host_scope.set_attr(p, "_ranbooru_skip_initial_adetailer", True) + self._script._host_scope.set_attr(p, "_ranbooru_suppress_all_processing", True) + self._script._host_scope.set_attr(p, "_ranbooru_initial_pass_only", True) + self._script._host_scope.set_attr(p, "_ad_disabled", True) + self._script._adetailer_state.initial_pass_suppressed = True + + # CRITICAL: Completely remove ADetailer scripts from the runner during initial pass + self._remove_adetailer_from_runner(p) + + # Set multiple block flags to ensure no ADetailer execution + self._script._set_adetailer_block(True) + setattr(self._script.__class__, "_ranbooru_block_all_adetailer", True) + setattr(self._script.__class__, "_adetailer_global_guard_active", True) + self._script._adetailer_state.global_guard_active = True + + print( + "[R Process] ADetailer completely blocked for initial pass " + "- will be restored for manual img2img processing" + ) + self._state = AdetailerState.INITIAL_PASS + + except Exception as e: + print(f"[R Process] Error in early ADetailer protection: {e}") + + def _remove_adetailer_from_runner(self, p: object) -> None: + """Temporarily remove ADetailer scripts from the script runner during initial pass.""" + try: + if not hasattr(p, "scripts") or p.scripts is None: + return + + # Store original scripts for restoration + if not hasattr(self._script, "_stored_adetailer_scripts"): + self._script._stored_adetailer_scripts = {"alwayson": [], "regular": []} + + # Remove ADetailer from alwayson_scripts + if hasattr(p.scripts, "alwayson_scripts") and p.scripts.alwayson_scripts: + original_alwayson = list(p.scripts.alwayson_scripts) + filtered_alwayson = [ + s for s in original_alwayson if not self._is_adetailer_script(s) + ] + removed_alwayson = [s for s in original_alwayson if self._is_adetailer_script(s)] + + p.scripts.alwayson_scripts = filtered_alwayson + self._script._stored_adetailer_scripts["alwayson"] = removed_alwayson + print( + f"[R Process] Removed {len(removed_alwayson)} ADetailer scripts " + "from alwayson_scripts" + ) + + # Remove ADetailer from regular scripts + if hasattr(p.scripts, "scripts") and p.scripts.scripts: + original_scripts = list(p.scripts.scripts) + filtered_scripts = [ + s for s in original_scripts if not self._is_adetailer_script(s) + ] + removed_scripts = [s for s in original_scripts if self._is_adetailer_script(s)] + + p.scripts.scripts = filtered_scripts + self._script._stored_adetailer_scripts["regular"] = removed_scripts + print(f"[R Process] Removed {len(removed_scripts)} ADetailer scripts from scripts") + + except Exception as e: + print(f"[R Process] Error removing ADetailer from runner: {e}") + + def _restore_early_adetailer_protection(self, processing_obj: object = None) -> None: + """Restore ADetailer scripts and flags after an interrupted or completed run.""" + try: + self._state = AdetailerState.IMG2IMG_READY + print("[R Process] Restoring ADetailer scripts for manual processing") + + # Clear initial pass/block flags so subsequent generations can run ADetailer + setattr(self._script.__class__, "_ranbooru_block_all_adetailer", False) + setattr(self._script.__class__, "_adetailer_global_guard_active", False) + self._script._set_adetailer_block(False) + + # Determine which processing object's script runner to restore into + candidate_p = ( + processing_obj + or getattr(self._script, "_initial_pass_p", None) + or getattr(self._script, "_current_processing_object", None) + ) + runner = getattr(candidate_p, "scripts", None) if candidate_p else None + + # Restore scripts we removed during the initial pass safeguard + stored = getattr(self._script, "_stored_adetailer_scripts", None) + if stored and runner: + try: + if hasattr(runner, "alwayson_scripts") and stored.get("alwayson"): + for script in stored["alwayson"]: + if script not in runner.alwayson_scripts: + runner.alwayson_scripts.append(script) + print( + f"[R Process] Reattached {len(stored['alwayson'])} " + "ADetailer always-on script(s)" + ) + if hasattr(runner, "scripts") and stored.get("regular"): + for script in stored["regular"]: + if script not in runner.scripts: + runner.scripts.append(script) + print( + f"[R Process] Reattached {len(stored['regular'])} " + "ADetailer on-demand script(s)" + ) + finally: + # Clear stored references so we don't duplicate reinsertion + delattr(self._script, "_stored_adetailer_scripts") + + # Ensure any scripts we hard-disabled are re-enabled for the next generation + if hasattr(self._script, "disabled_adetailer_scripts"): + self._reenable_adetailer_from_previous_generation() + + # Clear temporary protection flag if present + if hasattr(self._script, "_temp_disabled_adetailer"): + delattr(self._script, "_temp_disabled_adetailer") + + print("[R Process] Early protection restoration complete") + + except Exception as e: + print(f"[R Process] Error restoring early ADetailer protection: {e}") + + def _prepare_adetailer_for_img2img(self, p: object) -> None: + """Prepare ADetailer to run on img2img results.""" + if not self.is_adetailer_enabled(): + return + try: + print("[R] Preparing ADetailer to run on img2img results") + + # Clear the initial pass flag so ADetailer knows to run normally + self._script._ranbooru_initial_pass = False + + except Exception as e: + print(f"[R] Error preparing ADetailer: {e}") + + # ------------------------------------------------------------------ + # Full restore / native ADetailer re-enablement + # ------------------------------------------------------------------ + + def _restore_native_adetailer_scripts(self, p: object) -> None: + """Ensure native ADetailer scripts resume running when manual support is disabled.""" + try: + if not self._script._adetailer_patches.is_empty(): + self._script._unpatch_manual_adetailer_overrides() + except Exception as exc: + print(f"[R Before] Warn: Could not unpatch manual ADetailer overrides: {exc}") + try: + self._script._set_adetailer_block(False) + except Exception: + pass + setattr(self._script.__class__, "_ranbooru_block_all_adetailer", False) + setattr(self._script.__class__, "_adetailer_global_guard_active", False) + try: + self._restore_early_adetailer_protection(p) + except Exception as exc: + print(f"[R Before] Warn: Could not restore ADetailer runner state: {exc}") + try: + self._reenable_adetailer_from_previous_generation() + except Exception as exc: + print(f"[R Before] Warn: Could not re-enable ADetailer scripts: {exc}") + try: + restored = self._force_enable_adetailer_scripts(p) + except Exception as exc: + print(f"[R Before] Warn: Could not force-enable ADetailer scripts: {exc}") + restored = 0 + if restored: + print( + f"[R Before] Restored {restored} native ADetailer script(s) " + "after manual toggle was disabled" + ) + if hasattr(self._script, "disabled_adetailer_scripts"): + try: + delattr(self._script, "disabled_adetailer_scripts") + except Exception: + pass + guard_present = False + try: + import modules.scripts as scripts_module + + for runner_attr in ("scripts_txt2img", "scripts_img2img"): + runner = getattr(scripts_module, runner_attr, None) + if runner and getattr(runner, "_ranbooru_guard_installed", False): + guard_present = True + break + except Exception: + guard_present = False + if guard_present: + try: + self._script._reset_script_runner_guards() + except Exception as exc: + print(f"[R Before] Warn: Could not reset script runner guards: {exc}") + self._ensure_native_adetailer_enable_flags(p) + if not self._script._native_adetailer_detected(): + try: + import modules.scripts as scripts_module + + if hasattr(scripts_module, "reload_scripts"): + print("[R Before] Reloading scripts to restore native ADetailer") + scripts_module.reload_scripts() + except Exception as exc: + print(f"[R Before] Warn: Could not reload scripts for ADetailer: {exc}") + + def _force_enable_adetailer_scripts(self, processing_obj: object = None) -> int: + """Return the count of ADetailer scripts restored to their original behaviour.""" + try: + import modules.scripts as scripts_module + except Exception as exc: + print(f"[R Before] Warn: Could not access scripts module to restore ADetailer: {exc}") + return 0 + runners: List[object] = [] + for runner_attr in ("scripts_txt2img", "scripts_img2img"): + runner = getattr(scripts_module, runner_attr, None) + if runner: + runners.append(runner) + if ( + processing_obj is not None + and hasattr(processing_obj, "scripts") + and processing_obj.scripts not in runners + ): + runners.append(processing_obj.scripts) + seen_ids: set = set() + restored_count = 0 + for runner in runners: + if runner is None: + continue + for list_attr in ("alwayson_scripts", "scripts"): + script_list = getattr(runner, list_attr, None) + if not script_list: + continue + for script in script_list: + if not script: + continue + script_id = id(script) + if script_id in seen_ids: + continue + seen_ids.add(script_id) + if not self._is_adetailer_script(script): + continue + restored = False + if hasattr(script, "enabled") and script.enabled is False: + script.enabled = True + restored = True + for method_name in ( + "postprocess", + "process", + "process_batch", + "before_process", + "after_process", + ): + backup_name = f"_ranbooru_original_{method_name}" + if hasattr(script, backup_name): + try: + setattr(script, method_name, getattr(script, backup_name)) + except Exception: + pass + try: + delattr(script, backup_name) + except Exception: + pass + restored = True + for attr in ("_ranbooru_disabled_after_manual", "_ranbooru_disabled_source"): + if hasattr(script, attr): + try: + delattr(script, attr) + except Exception: + pass + restored = True + if restored: + restored_count += 1 + if restored_count == 0: + try: + debug_entries = [] + for runner in runners: + if not runner: + continue + for list_attr in ("alwayson_scripts", "scripts"): + script_list = getattr(runner, list_attr, None) + if not script_list: + continue + for script in script_list: + if self._is_adetailer_script(script): + debug_entries.append( + f"{script.__class__.__name__}" + f"(enabled={getattr(script, 'enabled', 'n/a')})" + ) + if debug_entries: + print( + "[R Before] Native ADetailer scripts detected: " + + ", ".join(debug_entries) + ) + except Exception: + pass + return restored_count + + def _ensure_native_adetailer_enable_flags(self, processing_obj: object) -> None: + """Ensure ADetailer enable/skip flags in script_args are set correctly.""" + if not getattr(self._script, "_adetailer_support_enabled", False): + return + try: + args = getattr(processing_obj, "script_args", None) + except Exception as exc: + print(f"[R Before] Native ADetailer: unable to read script_args: {exc}") + return + if not isinstance(args, (list, tuple)) or not args: + print( + "[R Before] Native ADetailer: script_args empty or not list/tuple; " + "skipping flag repair" + ) + return + args_list = list(args) + runners: List[object] = [] + runner = getattr(processing_obj, "scripts", None) + if runner is not None: + runners.append(runner) + try: + import modules.scripts as scripts_module + + for attr in ("scripts_txt2img", "scripts_img2img"): + global_runner = getattr(scripts_module, attr, None) + if global_runner is not None and global_runner not in runners: + runners.append(global_runner) + except Exception as exc: + print(f"[R Before] Native ADetailer: could not gather global runners: {exc}") + candidates: list = [] + for r in runners: + for list_attr in ("alwayson_scripts", "scripts"): + script_list = getattr(r, list_attr, None) + if script_list: + candidates.extend(script_list) + if not candidates: + print("[R Before] Native ADetailer: no script candidates found for flag repair") + return + changed = False + for script in candidates: + if not self._is_adetailer_script(script): + continue + extracted = self._script._extract_adetailer_script_args(script, processing_obj) + sanitized = list(extracted.get("args") or []) + meta = extracted.get("meta") or {} + start_idx = meta.get("slice_start") + end_idx = meta.get("slice_end") + if start_idx is None or end_idx is None: + continue + start_idx = max(0, min(len(args_list), start_idx)) + end_idx = max(start_idx, min(len(args_list), end_idx)) + if not sanitized or end_idx - start_idx != len(sanitized): + slice_view = args_list[start_idx:end_idx] + else: + slice_view = sanitized + print( + f"[R Before] Native ADetailer candidate {script.__class__.__name__} " + f"enabled={getattr(script, 'enabled', 'n/a')} " + f"slice [{start_idx}:{end_idx}] -> {slice_view}" + ) + if not sanitized: + continue + bool_index = 0 + local_changed = False + for offset, val in enumerate(sanitized): + if isinstance(val, bool): + if bool_index == 0 and val is False: + sanitized[offset] = True + local_changed = True + print( + f"[R Before] Set native ADetailer enable flag True at offset {offset}" + ) + elif bool_index == 1 and val is True: + sanitized[offset] = False + local_changed = True + print(f"[R Before] Cleared native ADetailer skip flag at offset {offset}") + bool_index += 1 + elif isinstance(val, dict): + if val.get("ad_tab_enable") is False and val.get("ad_model") not in ( + None, + "", + "None", + ): + val["ad_tab_enable"] = True + local_changed = True + print(f"[R Before] Enabled ad_tab_enable in dict at offset {offset}") + if local_changed: + if end_idx - start_idx == len(sanitized): + args_list[start_idx:end_idx] = sanitized + changed = True + continue + # fallback if lengths mismatch + for offset, val in enumerate(sanitized): + target_idx = start_idx + offset + if target_idx < len(args_list): + args_list[target_idx] = val + else: + args_list.append(val) + changed = True + if changed: + if isinstance(args, list): + processing_obj.script_args = args_list + else: + processing_obj.script_args = tuple(args_list) + print(f"[R Before] Native ADetailer flags updated: {args_list}") + else: + print("[R Before] Native ADetailer flags already enabled; no changes made") + + # ------------------------------------------------------------------ + # Re-enable from previous generation + # ------------------------------------------------------------------ + + def _reenable_adetailer_from_previous_generation(self) -> None: + """Re-enable ALL ADetailer scripts that were disabled in the previous generation.""" + try: + if ( + hasattr(self._script, "disabled_adetailer_scripts") + and self._script.disabled_adetailer_scripts + ): + print( + f"[R] COMPREHENSIVE RE-ENABLE: Restoring " + f"{len(self._script.disabled_adetailer_scripts)} ADetailer script(s) " + "from previous generation" + ) + + for script, original_enabled in self._script.disabled_adetailer_scripts: + source = getattr(script, "_ranbooru_disabled_source", "unknown") + print(f"[R] Re-enabling {script.__class__.__name__} from {source}") + + # Restore original enabled state + if hasattr(script, "enabled"): + script.enabled = original_enabled + + # Restore ALL original methods that were disabled + methods_to_restore = [ + "postprocess", + "process", + "process_batch", + "before_process", + "after_process", + ] + for method_name in methods_to_restore: + original_method_attr = f"_ranbooru_original_{method_name}" + if hasattr(script, original_method_attr): + original_method = getattr(script, original_method_attr) + setattr(script, method_name, original_method) + delattr(script, original_method_attr) + + # Remove our disable flags + if hasattr(script, "_ranbooru_disabled_after_manual"): + delattr(script, "_ranbooru_disabled_after_manual") + if hasattr(script, "_ranbooru_disabled_source"): + delattr(script, "_ranbooru_disabled_source") + + print( + f"[R] COMPREHENSIVE RE-ENABLE: Restored " + f"{len(self._script.disabled_adetailer_scripts)} ADetailer script(s) " + "for new generation" + ) + # Clear the list now that we've re-enabled everything + delattr(self._script, "disabled_adetailer_scripts") + + except Exception as e: + print(f"[R] Error in comprehensive ADetailer re-enable: {e}") + + # ------------------------------------------------------------------ + # Manual ADetailer execution + # ------------------------------------------------------------------ + + def _execute_manual_adetailer( + self, p: object, processed: object, img2img_results: List[Any] + ) -> bool: + """Run manual ADetailer on img2img results via the deterministic runtime executor.""" + if not self.is_adetailer_enabled() or not img2img_results: + return False + + self._script._clear_manual_adetailer_skip_flags(p) + adetailer_scripts = rb_adetailer_runtime.gather_adetailer_scripts(p) + if not adetailer_scripts: + print("[R Post] WARN: No ADetailer scripts discovered for manual execution") + return False + + setattr(self._script.__class__, "_ranbooru_manual_adetailer_active", True) + + def build_processed(single_image: object) -> object: + temp_processed = types.SimpleNamespace() + temp_processed.images = [single_image] + temp_processed.image = single_image + for attr in ( + "prompt", + "negative_prompt", + "seed", + "subseed", + "width", + "height", + "cfg_scale", + "steps", + ): + if hasattr(processed, attr): + setattr(temp_processed, attr, getattr(processed, attr)) + return temp_processed + + self._state = AdetailerState.ADETAILER_ACTIVE + try: + result = rb_adetailer_runtime.execute_manual_adetailer( + adetailer_scripts=adetailer_scripts, + images=list(img2img_results), + processing_obj=p, + run_state=self._script._adetailer_state, + patch_registry=self._script._adetailer_patches, + extract_script_args=self._script._extract_adetailer_script_args, + build_processed=build_processed, + isolation_factory=lambda script_obj: self._script._manual_adetailer_script_isolation( + p, + script_obj, + keep_controlnet=self._script._manual_adetailer_requires_controlnet( + self._script._extract_adetailer_script_args(script_obj, p).get("args") + or [] + ), + ), + ) + finally: + setattr(self._script.__class__, "_ranbooru_manual_adetailer_active", False) + self._state = AdetailerState.IMG2IMG_READY + + for error in result.errors: + print(f"[R Post] WARN: Manual ADetailer error: {error}") + processed.images.clear() + processed.images.extend(result.images) + img2img_results.clear() + img2img_results.extend(result.images) + if hasattr(p, "processed") and hasattr(p.processed, "images"): + p.processed.images.clear() + p.processed.images.extend(result.images) + return result.successful_processes > 0 + + # ------------------------------------------------------------------ + # ScriptRunner guard + # ------------------------------------------------------------------ + + def _install_scriptrunner_guard(self, p: object) -> None: + """Wrap p.scripts postprocess/postprocess_image to skip ADetailer when blocked.""" + try: + if not hasattr(p, "scripts") or p.scripts is None: + return + runner = p.scripts + if getattr(runner, "_ranbooru_guard_installed", False): + return + rb_adetailer_runtime.install_runner_guard( + runner=runner, + block_flag_fn=lambda: bool( + getattr(self._script.__class__, "_ranbooru_block_all_adetailer", False) + and not getattr( + self._script.__class__, "_ranbooru_manual_adetailer_active", False + ) + ), + patch_registry=self._script._adetailer_patches, + ) + runner._ranbooru_guard_installed = True + self._script._log_patch_event( + "info", "Installed ScriptRunner guard to skip ADetailer when blocked" + ) + except Exception as e: + self._script._log_patch_event( + "warning", f"Failed to install ScriptRunner guard: {e}" + ) + print(f"[R] Error installing ScriptRunner guard: {e}") + + # ------------------------------------------------------------------ + # Preview guard (shared.state) + # ------------------------------------------------------------------ + + def _install_preview_guard(self) -> None: + """Install a guard around shared.state.assign_current_image to block wrong previews.""" + try: + import modules.shared as shared + + if not hasattr(shared, "state"): + return + state = shared.state + installed_wrapper = getattr(state, "_ranbooru_preview_guard_wrapper", None) + if ( + getattr(state, "_ranbooru_preview_guard_installed", False) + and installed_wrapper is not None + and getattr(state, "assign_current_image", None) is installed_wrapper + ): + self._state = AdetailerState.IMG2IMG_READY + return + if not hasattr(state, "assign_current_image"): + self._state = AdetailerState.IMG2IMG_READY + return + self._state = AdetailerState.INITIAL_PASS + original_assign_current_image = state.assign_current_image + script_class = self._script.__class__ + + def guarded_assign_current_image(img: object) -> Any: + try: + if getattr(script_class, "_ranbooru_preview_guard_on", False): + if getattr(script_class, "_ranbooru_preview_block_all", False): + if not getattr( + script_class, "_ranbooru_preview_block_notice_emitted", False + ): + print( + "[R UI] Preview blocked: withholding intermediary frame " + "until final image is ready" + ) + script_class._ranbooru_preview_block_notice_emitted = True + return + # If we know final dims, only allow those; otherwise block 640x512 + final_dims = getattr(script_class, "_ranbooru_final_dims", None) + if img is not None and hasattr(img, "size"): + if final_dims and img.size != final_dims: + print("[R UI] Preview blocked: mismatched size") + return + if img.size == (640, 512): + print("[R UI] Preview blocked: 640x512 preview") + return + except Exception: + pass + return original_assign_current_image(img) + + self._script._host_scope.patch_attr( + state, "assign_current_image", guarded_assign_current_image + ) + self._script._host_scope.set_attr( + state, "_ranbooru_preview_guard_installed", True + ) + self._script._host_scope.set_attr( + state, "_ranbooru_preview_guard_wrapper", guarded_assign_current_image + ) + print("[R UI] Installed preview guard") + except Exception as e: + print(f"[R UI] Error installing preview guard: {e}") \ No newline at end of file diff --git a/ranboorux/integrations/adetailer_runtime.py b/ranboorux/integrations/adetailer_runtime.py new file mode 100644 index 0000000..2209214 --- /dev/null +++ b/ranboorux/integrations/adetailer_runtime.py @@ -0,0 +1,487 @@ +from __future__ import annotations + +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass, field +from typing import Any, Callable, ContextManager, Dict, Iterable, Iterator, List, Optional, cast + + +@dataclass +class AdetailerRunState: + block_all: bool = False + manual_active: bool = False + initial_pass_suppressed: bool = False + processing_complete: bool = False + preview_guard_on: bool = False + preview_block_all: bool = False + global_guard_active: bool = False + pipeline_blocked: bool = False + + def reset(self) -> None: + self.block_all = False + self.manual_active = False + self.initial_pass_suppressed = False + self.processing_complete = False + self.preview_guard_on = False + self.preview_block_all = False + self.global_guard_active = False + self.pipeline_blocked = False + + def is_blocked(self) -> bool: + return bool(self.block_all or self.pipeline_blocked) + + +@dataclass +class PatchRecord: + target: object + method_name: str + original_method: Callable[..., Any] + installed_method: Callable[..., Any] + description: str + + +@dataclass +class PatchRegistry: + _patches: List[PatchRecord] = field(default_factory=list) + restore_errors: List[str] = field(default_factory=list) + + def install( + self, + target: object, + method_name: str, + replacement: Callable[..., Any], + description: str, + ) -> None: + if target is None or not hasattr(target, method_name): + return + original = getattr(target, method_name) + if not callable(original) or not callable(replacement): + return + for existing in self._patches: + if existing.target is target and existing.method_name == method_name: + if getattr(target, method_name, None) is existing.installed_method: + setattr(target, method_name, replacement) + existing.installed_method = replacement + return + self._patches.append( + PatchRecord( + target=target, + method_name=method_name, + original_method=original, + installed_method=replacement, + description=description, + ) + ) + setattr(target, method_name, replacement) + + def uninstall_all(self) -> List[str]: + if not self._patches: + errors = list(self.restore_errors) + self.restore_errors.clear() + return errors + for patch in reversed(self._patches): + try: + if getattr(patch.target, patch.method_name, None) is patch.installed_method: + setattr(patch.target, patch.method_name, patch.original_method) + except Exception as exc: + self.restore_errors.append(f"{patch.description}: {exc}") + self._patches.clear() + errors = list(self.restore_errors) + self.restore_errors.clear() + return errors + + def is_empty(self) -> bool: + return not self._patches + + +@dataclass +class RunnerSnapshot: + alwayson_scripts: List[Any] + scripts: List[Any] + callback_map: Optional[Dict[Any, Any]] + + @classmethod + def capture(cls, runner: object) -> "RunnerSnapshot": + alwayson = list(getattr(runner, "alwayson_scripts", []) or []) + scripts = list(getattr(runner, "scripts", []) or []) + callback_map = getattr(runner, "callback_map", None) + callback_copy: Optional[Dict[Any, Any]] = None + if isinstance(callback_map, dict): + callback_copy = dict(callback_map) + return cls(alwayson_scripts=alwayson, scripts=scripts, callback_map=callback_copy) + + def restore( + self, + runner: object, + *, + expected_alwayson_scripts: Optional[List[Any]] = None, + expected_scripts: Optional[List[Any]] = None, + expected_callback_map: Optional[Dict[Any, Any]] = None, + ) -> None: + current_alwayson = list(getattr(runner, "alwayson_scripts", []) or []) + if expected_alwayson_scripts is None or current_alwayson == expected_alwayson_scripts: + setattr(runner, "alwayson_scripts", list(self.alwayson_scripts)) + + current_scripts = list(getattr(runner, "scripts", []) or []) + if expected_scripts is None or current_scripts == expected_scripts: + setattr(runner, "scripts", list(self.scripts)) + + if expected_callback_map is not None: + current_callback_map = getattr(runner, "callback_map", None) + if ( + not isinstance(current_callback_map, dict) + or current_callback_map != expected_callback_map + ): + return + if self.callback_map is None: + if hasattr(runner, "callback_map"): + try: + delattr(runner, "callback_map") + except Exception: + pass + return + setattr(runner, "callback_map", dict(self.callback_map)) + + +@dataclass +class ManualAdetailerResult: + images: List[Any] + successful_processes: int + errors: List[str] = field(default_factory=list) + + +def _is_adetailer_script(script_obj: object) -> bool: + try: + class_name = script_obj.__class__.__name__.lower() + except Exception: + class_name = "" + return "adetailer" in class_name or "afterdetailer" in class_name + + +def _is_controlnet_script(script_obj: object) -> bool: + try: + class_name = script_obj.__class__.__name__.lower() + if "controlnet" in class_name: + return True + title_attr = getattr(script_obj, "title", None) + if callable(title_attr): + title = str(title_attr()).strip().lower() + return "controlnet" in title + except Exception: + return False + return False + + +def _clear_runner_callback_map(runner: object) -> None: + callback_map = getattr(runner, "callback_map", None) + if isinstance(callback_map, dict): + callback_map.clear() + + +def _restore_runner_callback_map( + runner: object, + original_callback_map: Optional[Dict[Any, Any]], + expected_callback_map: Dict[Any, Any], +) -> None: + current_callback_map = getattr(runner, "callback_map", None) + if not isinstance(current_callback_map, dict) or current_callback_map != expected_callback_map: + return + if original_callback_map is None: + try: + delattr(runner, "callback_map") + except Exception: + pass + return + setattr(runner, "callback_map", dict(original_callback_map)) + + +def install_runner_guard( + runner: object, + block_flag_fn: Callable[[], bool], + patch_registry: PatchRegistry, +) -> None: + if runner is None: + return + + postprocess = getattr(runner, "postprocess", None) + if callable(postprocess): + + def guarded_postprocess(*args: Any, **kwargs: Any) -> Any: + if not block_flag_fn(): + return postprocess(*args, **kwargs) + saved_alwayson = list(getattr(runner, "alwayson_scripts", []) or []) + saved_scripts = list(getattr(runner, "scripts", []) or []) + callback_map = getattr(runner, "callback_map", None) + saved_callback_map = dict(callback_map) if isinstance(callback_map, dict) else None + expected_callback_map: Optional[Dict[Any, Any]] = None + try: + if hasattr(runner, "alwayson_scripts"): + setattr( + runner, + "alwayson_scripts", + [item for item in saved_alwayson if not _is_adetailer_script(item)], + ) + if hasattr(runner, "scripts"): + setattr( + runner, + "scripts", + [item for item in saved_scripts if not _is_adetailer_script(item)], + ) + _clear_runner_callback_map(runner) + if isinstance(getattr(runner, "callback_map", None), dict): + expected_callback_map = {} + return postprocess(*args, **kwargs) + finally: + if hasattr(runner, "alwayson_scripts"): + setattr(runner, "alwayson_scripts", saved_alwayson) + if hasattr(runner, "scripts"): + setattr(runner, "scripts", saved_scripts) + if expected_callback_map is not None: + _restore_runner_callback_map( + runner, + saved_callback_map, + expected_callback_map, + ) + + patch_registry.install( + runner, "postprocess", guarded_postprocess, "ScriptRunner postprocess guard" + ) + + postprocess_image = getattr(runner, "postprocess_image", None) + if callable(postprocess_image): + + def guarded_postprocess_image(*args: Any, **kwargs: Any) -> Any: + if not block_flag_fn(): + return postprocess_image(*args, **kwargs) + saved_alwayson = list(getattr(runner, "alwayson_scripts", []) or []) + saved_scripts = list(getattr(runner, "scripts", []) or []) + callback_map = getattr(runner, "callback_map", None) + saved_callback_map = dict(callback_map) if isinstance(callback_map, dict) else None + expected_callback_map: Optional[Dict[Any, Any]] = None + try: + if hasattr(runner, "alwayson_scripts"): + setattr( + runner, + "alwayson_scripts", + [item for item in saved_alwayson if not _is_adetailer_script(item)], + ) + if hasattr(runner, "scripts"): + setattr( + runner, + "scripts", + [item for item in saved_scripts if not _is_adetailer_script(item)], + ) + _clear_runner_callback_map(runner) + if isinstance(getattr(runner, "callback_map", None), dict): + expected_callback_map = {} + return postprocess_image(*args, **kwargs) + finally: + if hasattr(runner, "alwayson_scripts"): + setattr(runner, "alwayson_scripts", saved_alwayson) + if hasattr(runner, "scripts"): + setattr(runner, "scripts", saved_scripts) + if expected_callback_map is not None: + _restore_runner_callback_map( + runner, + saved_callback_map, + expected_callback_map, + ) + + patch_registry.install( + runner, + "postprocess_image", + guarded_postprocess_image, + "ScriptRunner postprocess_image guard", + ) + + +def _should_keep_script( + script_item: object, + adetailer_script: object, + keep_controlnet: bool, + keep_controlnet_fn: Optional[Callable[[object, str], bool]], + list_attr: str, +) -> bool: + if script_item is adetailer_script: + return True + if not keep_controlnet: + return False + if keep_controlnet_fn is not None: + return bool(keep_controlnet_fn(script_item, list_attr)) + return _is_controlnet_script(script_item) + + +@contextmanager +def runner_isolation( + runner: object, + adetailer_script: object, + keep_controlnet_fn: Optional[Callable[[object, str], bool]] = None, + *, + keep_controlnet: bool = False, +) -> Iterator[None]: + if runner is None or adetailer_script is None: + yield + return + + snapshot = RunnerSnapshot.capture(runner) + expected_alwayson: Optional[List[Any]] = None + expected_scripts: Optional[List[Any]] = None + expected_callback_map: Optional[Dict[Any, Any]] = None + try: + for list_attr in ("alwayson_scripts", "scripts"): + script_list = getattr(runner, list_attr, None) + if not isinstance(script_list, (list, tuple)): + continue + filtered: List[object] = [] + for script_item in list(script_list): + if _should_keep_script( + script_item, + adetailer_script, + keep_controlnet, + keep_controlnet_fn, + list_attr, + ): + filtered.append(script_item) + setattr(runner, list_attr, filtered) + if list_attr == "alwayson_scripts": + expected_alwayson = list(filtered) + else: + expected_scripts = list(filtered) + _clear_runner_callback_map(runner) + if isinstance(getattr(runner, "callback_map", None), dict): + expected_callback_map = {} + yield + finally: + snapshot.restore( + runner, + expected_alwayson_scripts=expected_alwayson, + expected_scripts=expected_scripts, + expected_callback_map=expected_callback_map, + ) + + +def _images_differ(original: object, updated: object) -> bool: + if updated is None: + return False + if original is None: + return True + original_size = getattr(original, "size", None) + updated_size = getattr(updated, "size", None) + if original_size is not None and updated_size is not None and original_size != updated_size: + return True + try: + original_bytes = original.tobytes() if hasattr(original, "tobytes") else None + updated_bytes = updated.tobytes() if hasattr(updated, "tobytes") else None + if original_bytes is not None and updated_bytes is not None: + return bool(original_bytes != updated_bytes) + except Exception: + return original is not updated + return original is not updated + + +def _candidate_scripts(adetailer_scripts: Iterable[object]) -> List[object]: + deduped: List[object] = [] + seen_ids: set[int] = set() + for script_obj in adetailer_scripts: + if script_obj is None: + continue + script_id = id(script_obj) + if script_id in seen_ids: + continue + seen_ids.add(script_id) + if _is_adetailer_script(script_obj): + deduped.append(script_obj) + return deduped + + +def _extract_processed_image(temp_processed: object, fallback: object) -> object: + images = getattr(temp_processed, "images", None) + if isinstance(images, list) and images: + return images[0] + image = getattr(temp_processed, "image", None) + if image is not None: + return image + return fallback + + +def execute_manual_adetailer( + adetailer_scripts: List[object], + images: List[Any], + processing_obj: object, + run_state: AdetailerRunState, + patch_registry: PatchRegistry, + *, + extract_script_args: Callable[[object, object], Dict[str, Any]], + build_processed: Callable[[object], object], + isolation_factory: Optional[Callable[[object], ContextManager[None]]] = None, +) -> ManualAdetailerResult: + del patch_registry + processed_images: List[Any] = list(images or []) + successful_processes = 0 + errors: List[str] = [] + run_state.manual_active = True + + try: + for adetailer_script in _candidate_scripts(adetailer_scripts): + extracted = extract_script_args(adetailer_script, processing_obj) + script_args = list(extracted.get("args") or []) + if not script_args: + continue + + for index, original_image in enumerate(list(processed_images)): + try: + temp_processed = build_processed(original_image) + except Exception as exc: + errors.append( + f"{adetailer_script.__class__.__name__} image {index + 1}: " + f"failed to build processed object: {exc}" + ) + continue + if temp_processed is None: + continue + + script_ctx = ( + isolation_factory(adetailer_script) if isolation_factory else nullcontext() + ) + try: + with script_ctx: + script_obj = cast(Any, adetailer_script) + if callable(getattr(script_obj, "postprocess_image", None)): + script_obj.postprocess_image( + processing_obj, + temp_processed, + *script_args, + ) + elif callable(getattr(script_obj, "postprocess", None)): + script_obj.postprocess( + processing_obj, + temp_processed, + *script_args, + ) + else: + continue + except Exception as exc: + errors.append(f"{adetailer_script.__class__.__name__} image {index + 1}: {exc}") + continue + + candidate_image = _extract_processed_image(temp_processed, original_image) + if _images_differ(original_image, candidate_image): + processed_images[index] = candidate_image + successful_processes += 1 + + return ManualAdetailerResult( + images=processed_images, + successful_processes=successful_processes, + errors=errors, + ) + finally: + run_state.manual_active = False + + +def gather_adetailer_scripts(processing_obj: object) -> List[object]: + runner = getattr(processing_obj, "scripts", None) + if runner is None: + return [] + scripts_list: List[object] = [] + scripts_list.extend(list(getattr(runner, "alwayson_scripts", []) or [])) + scripts_list.extend(list(getattr(runner, "scripts", []) or [])) + return _candidate_scripts(scripts_list) diff --git a/ranboorux/integrations/controlnet.py b/ranboorux/integrations/controlnet.py index 9c2d019..5e5c800 100644 --- a/ranboorux/integrations/controlnet.py +++ b/ranboorux/integrations/controlnet.py @@ -1,22 +1,25 @@ from __future__ import annotations -import importlib import importlib.util +import logging import os from types import ModuleType -from typing import Any + +from ranboorux.http_client import sanitize_exception_text + +logger = logging.getLogger("ranboorux") def _load_module_from_path(module_name: str, module_path: str) -> ModuleType: spec = importlib.util.spec_from_file_location(module_name, module_path) if spec is None or spec.loader is None: - raise ImportError(f"Unable to load module spec from {module_path}") + raise ImportError("Unable to load module spec") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -def load_external_code(extension_root: str) -> Any: +def load_external_code(extension_root: str) -> ModuleType: candidates = [ "sd_forge_controlnet.lib_controlnet.external_code", "extensions.sd_forge_controlnet.lib_controlnet.external_code", @@ -27,7 +30,8 @@ def load_external_code(extension_root: str) -> Any: try: return importlib.import_module(mod) except Exception as exc: - errors.append(f"{mod}: {exc}") + errors.append(f"{mod}: {exc.__class__.__name__}") + logger.debug(f"ControlNet candidate {mod} failed: {sanitize_exception_text(str(exc))}") try: env_root = os.environ.get("SD_FORGE_CONTROLNET_PATH") or os.environ.get("RANBOORUX_CN_PATH") @@ -38,9 +42,9 @@ def load_external_code(extension_root: str) -> Any: "sd_forge_controlnet.lib_controlnet.external_code", env_path, ) - errors.append(f"env:{env_path}: not found") + errors.append("env: configured ControlNet external_code.py not found") except Exception as exc: - errors.append(f"env_load: {exc}") + errors.append(f"env_load: {exc.__class__.__name__}") try: webui_root = None @@ -48,8 +52,11 @@ def load_external_code(extension_root: str) -> Any: from modules import paths as webui_paths webui_root = getattr(webui_paths, "script_path", None) + if not webui_root: + errors.append("modules.paths.script_path unavailable") except Exception as exc: - errors.append(f"modules.paths.script_path: {exc}") + errors.append("modules.paths.script_path unavailable") + logger.debug(f"modules.paths.script_path failed: {sanitize_exception_text(str(exc))}") if webui_root: builtin_path = os.path.join( webui_root, @@ -63,9 +70,9 @@ def load_external_code(extension_root: str) -> Any: "sd_forge_controlnet.lib_controlnet.external_code", builtin_path, ) - errors.append(f"builtin:{builtin_path}: not found") + errors.append("builtin: ControlNet external_code.py not found") except Exception as exc: - errors.append(f"builtin_load: {exc}") + errors.append(f"builtin_load: {exc.__class__.__name__}") try: ext_path = os.path.join( @@ -76,8 +83,8 @@ def load_external_code(extension_root: str) -> Any: "sd_forge_controlnet.lib_controlnet.external_code", ext_path, ) - errors.append(f"file://{ext_path}: not found") + errors.append("extension: bundled ControlNet external_code.py not found") except Exception as exc: - errors.append(f"file_fallback: {exc}") + errors.append(f"extension_load: {exc.__class__.__name__}") raise ImportError("Unable to import ControlNet external_code. Attempts: " + "; ".join(errors)) diff --git a/ranboorux/integrations/img2img_lifecycle.py b/ranboorux/integrations/img2img_lifecycle.py new file mode 100644 index 0000000..a6d459a --- /dev/null +++ b/ranboorux/integrations/img2img_lifecycle.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any, List, Sequence + + +def repeat_to_length(values: Any, length: int) -> List[Any]: + if length <= 0: + return [] + if not isinstance(values, list): + return [values] * length + if not values: + return [None] * length + if len(values) == length: + return list(values) + return values * (length // len(values)) + values[: length % len(values)] + + +def replace_processed_results( + processed: object, + *, + images: Sequence[Any], + prompts: Sequence[Any], + negative_prompts: Sequence[Any], + infotexts: Sequence[Any], + seed: int, + subseed: int, + width: int, + height: int, +) -> None: + image_list = list(images) + prompt_list = list(prompts) + negative_list = list(negative_prompts) + infotext_list = list(infotexts) + + _replace_list_attr(processed, "images", image_list) + prompt_value = prompt_list if len(prompt_list) > 1 else (prompt_list[0] if prompt_list else "") + negative_value = ( + negative_list if len(negative_list) > 1 else (negative_list[0] if negative_list else "") + ) + setattr(processed, "prompt", prompt_value) + setattr( + processed, + "negative_prompt", + negative_value, + ) + _replace_list_attr(processed, "infotexts", infotext_list) + setattr(processed, "seed", seed) + setattr(processed, "subseed", subseed) + setattr(processed, "width", width) + setattr(processed, "height", height) + + _replace_list_attr(processed, "all_prompts", prompt_list) + _replace_list_attr(processed, "all_negative_prompts", negative_list) + _replace_list_attr(processed, "all_seeds", [seed + i for i in range(len(image_list))]) + _replace_list_attr(processed, "all_subseeds", [subseed + i for i in range(len(image_list))]) + + for attr_name in ("cached_images", "images_list", "output_images", "_cached_images"): + if hasattr(processed, attr_name): + current = getattr(processed, attr_name) + if isinstance(current, list): + current.clear() + current.extend(image_list) + else: + setattr(processed, attr_name, list(image_list)) + + +def _replace_list_attr(target: object, attr_name: str, values: Sequence[Any]) -> None: + current = getattr(target, attr_name, None) + if isinstance(current, list): + current.clear() + current.extend(values) + else: + setattr(target, attr_name, list(values)) diff --git a/ranboorux/io_lists.py b/ranboorux/io_lists.py deleted file mode 100644 index 41fa80f..0000000 --- a/ranboorux/io_lists.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -import os -import re -import tempfile -from typing import Callable, Iterable, List, Optional - -NormalizeFn = Optional[Callable[[str], str]] - - -def ensure_user_file(path: str) -> None: - os.makedirs(os.path.dirname(path), exist_ok=True) - if not os.path.isfile(path): - with open(path, "w", encoding="utf-8") as handle: - handle.write("") - - -def read_list_file(path: str, normalize_tag: NormalizeFn = None) -> List[str]: - ensure_user_file(path) - with open(path, "r", encoding="utf-8") as handle: - contents = handle.read() - if not contents: - return [] - contents = contents.replace("\r\n", "\n").replace("\r", "\n") - parts = [segment.strip() for segment in re.split(r"[\n,]+", contents) if segment.strip()] - seen = set() - ordered: List[str] = [] - for part in parts: - key = normalize_tag(part) if callable(normalize_tag) else part.casefold() - key = key or part.casefold() - if key in seen: - continue - seen.add(key) - ordered.append(part) - return ordered - - -def write_list_file(path: str, tags: Iterable[str], normalize_tag: NormalizeFn = None) -> None: - ensure_user_file(path) - seen = set() - deduped: List[str] = [] - for tag in tags: - cleaned = (tag or "").strip() - if not cleaned: - continue - key = normalize_tag(cleaned) if callable(normalize_tag) else cleaned.casefold() - key = key or cleaned.casefold() - if key in seen: - continue - seen.add(key) - deduped.append(cleaned) - parent_dir = os.path.dirname(path) - payload = "\n".join(deduped) - temp_path = None - try: - os.makedirs(parent_dir, exist_ok=True) - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - newline="\n", - dir=parent_dir, - delete=False, - prefix=".ranboorux_", - suffix=".tmp", - ) as handle: - handle.write(payload) - temp_path = handle.name - os.replace(temp_path, path) - finally: - if temp_path and os.path.isfile(temp_path): - try: - os.remove(temp_path) - except OSError: - pass diff --git a/ranboorux/loranado.py b/ranboorux/loranado.py new file mode 100644 index 0000000..0e4e098 --- /dev/null +++ b/ranboorux/loranado.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import os +import random +from typing import Iterable, List, Optional, Tuple + + +def normalize_lora_name(value: object) -> str: + if value is None: + return "" + text = str(value).strip() + if not text: + return "" + return os.path.splitext(text)[0].strip().lower() + + +def parse_custom_weights(weights_str: Optional[str]) -> List[float]: + if not weights_str: + return [] + try: + return [float(weight.strip()) for weight in weights_str.split(",")] + except ValueError: + return [] + + +def filter_candidates( + candidates: Iterable[str], enabled_loras: Iterable[str], blacklist_loras: Iterable[str] +) -> List[str]: + """ + Filters candidates based on enabled selections and blacklists. + All inputs and filters are normalized prior to matching. + """ + enabled_selection = { + normalize_lora_name(name) for name in enabled_loras if normalize_lora_name(name) + } + blacklist_selection = { + normalize_lora_name(name) for name in blacklist_loras if normalize_lora_name(name) + } + + filtered = list(candidates) + if enabled_selection: + filtered = [c for c in filtered if normalize_lora_name(c) in enabled_selection] + + if blacklist_selection: + filtered = [c for c in filtered if normalize_lora_name(c) not in blacklist_selection] + + return filtered + + +def select_loras( + candidates: List[str], + amount: int, + lora_min: float, + lora_max: float, + custom_weights: Optional[List[float]] = None, + random_source=None, +) -> List[Tuple[str, float]]: + """ + Selects the requested amount of LoRAs and assigns weights. + Uses the provided random_source (e.g. random.Random(seed)) for deterministic results. + """ + if not candidates: + return [] + + rng = random_source if random_source is not None else random + + num_to_select = min(max(1, int(amount)), len(candidates)) + chosen_files = rng.sample(candidates, num_to_select) + + weights = custom_weights if custom_weights is not None else [] + + selected: List[Tuple[str, float]] = [] + for i in range(num_to_select): + chosen_file = chosen_files[i] + lora_name = os.path.splitext(chosen_file)[0] + + if i < len(weights): + weight = weights[i] + else: + weight = round(rng.uniform(lora_min, lora_max), 2) + + selected.append((lora_name, weight)) + + return selected + + +def format_lora_prompt(selected_loras: Iterable[Tuple[str, float]]) -> str: + fragments = [f"" for name, weight in selected_loras] + return " ".join(fragments) diff --git a/ranboorux/mutation_scope.py b/ranboorux/mutation_scope.py new file mode 100644 index 0000000..08b4e72 --- /dev/null +++ b/ranboorux/mutation_scope.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, List, Tuple + +_MISSING = object() +_ANY = object() + + +@dataclass +class RunContext: + temp_paths: List[Path] = field(default_factory=list) + cleanup_errors: List[str] = field(default_factory=list) + + def own_temp_path(self, path: str) -> None: + self.temp_paths.append(Path(path)) + + def cleanup(self) -> None: + for path in reversed(self.temp_paths): + try: + if path.is_dir(): + shutil.rmtree(path, ignore_errors=False) + elif path.exists(): + path.unlink() + except Exception as exc: + self.cleanup_errors.append(f"{path}: {exc}") + self.temp_paths.clear() + + +@dataclass +class HostMutationScope: + context: RunContext = field(default_factory=RunContext) + _snapshots: List[Tuple[object, str, Any, Any]] = field(default_factory=list) + _restored: bool = False + + def snapshot_attr(self, target: object, attr_name: str) -> None: + for existing_target, existing_attr, _value, _expected in self._snapshots: + if existing_target is target and existing_attr == attr_name: + return + value = getattr(target, attr_name, _MISSING) + self._snapshots.append((target, attr_name, value, _ANY)) + + def set_attr(self, target: object, attr_name: str, value: object) -> None: + self.snapshot_attr(target, attr_name) + setattr(target, attr_name, value) + + def patch_attr(self, target: object, attr_name: str, replacement: object) -> None: + for index, (existing_target, existing_attr, value, expected) in enumerate(self._snapshots): + if existing_target is target and existing_attr == attr_name: + if expected is _ANY or getattr(target, attr_name, _MISSING) is expected: + self._snapshots[index] = (target, attr_name, value, replacement) + setattr(target, attr_name, replacement) + return + value = getattr(target, attr_name, _MISSING) + self._snapshots.append((target, attr_name, value, replacement)) + setattr(target, attr_name, replacement) + + def restore(self) -> None: + if self._restored: + return + self._restored = True + for target, attr_name, value, expected in reversed(self._snapshots): + try: + if expected is not _ANY and getattr(target, attr_name, _MISSING) is not expected: + continue + if value is _MISSING: + if hasattr(target, attr_name): + delattr(target, attr_name) + else: + setattr(target, attr_name, value) + except Exception as exc: + self.context.cleanup_errors.append(f"{target!r}.{attr_name}: {exc}") + self._snapshots.clear() + self.context.cleanup() diff --git a/ranboorux/prompting.py b/ranboorux/prompting.py deleted file mode 100644 index 5032e90..0000000 --- a/ranboorux/prompting.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from typing import Iterable, List - - -def split_prompt_tags(prompt: str) -> List[str]: - if not isinstance(prompt, str): - return [] - return [tag.strip() for tag in prompt.split(",") if tag.strip()] - - -def dedupe_keep_order(tags: Iterable[str]) -> List[str]: - return list(dict.fromkeys(tags)) - - -def remove_repeated_tags(prompt: str) -> str: - tags = split_prompt_tags(prompt) - if not tags: - return "" - return ",".join(dedupe_keep_order(tags)) - - -def limit_prompt_tags(prompt: str, limit_val, mode: str) -> str: - tags = split_prompt_tags(prompt) - if not tags: - return "" - if mode == "Limit": - try: - pct = float(limit_val) - except Exception: - return prompt - if pct <= 0: - return "" - max_count = max(1, int(len(tags) * pct)) - return ",".join(tags[:max_count]) - if mode == "Max": - try: - max_count = int(limit_val) - except Exception: - return prompt - if max_count <= 0: - return prompt - return ",".join(tags[:max_count]) - return prompt diff --git a/ranboorux/run_options.py b/ranboorux/run_options.py new file mode 100644 index 0000000..cc6ac13 --- /dev/null +++ b/ranboorux/run_options.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +# SIZE_OK — cohesive dataclass/schema module; splitting by count scatters related definitions +from dataclasses import dataclass +from typing import Dict, List, Mapping, Sequence, Tuple + +UI_ARGUMENT_FIELDS: Tuple[str, ...] = ( + "enabled", + "tags", + "booru", + "gelbooru_api_key", + "gelbooru_user_id", + "gelbooru_compat_base_url", + "remove_bad_tags", + "max_pages", + "change_dash", + "same_prompt", + "fringe_benefits", + "remove_tags", + "use_img2img", + "denoising", + "use_last_img", + "change_background", + "change_color", + "shuffle_tags", + "post_id", + "mix_prompt", + "mix_amount", + "chaos_mode", + "chaos_amount", + "limit_tags", + "max_tags", + "sorting_order", + "mature_rating", + "lora_folder", + "lora_amount", + "lora_min", + "lora_max", + "lora_enabled", + "lora_custom_weights", + "lora_lock_prev", + "use_ip", + "use_search_txt", + "use_remove_txt", + "choose_search_txt", + "choose_remove_txt", + "search_refresh_btn", + "remove_refresh_btn", + "crop_center", + "enable_adetailer_support", + "use_same_seed", + "reuse_cached_posts", + "use_cache", + "log_prompt_sources", + "remove_artist_tags", + "remove_character_tags", + "remove_clothing_tags", + "remove_text_tags", + "restrict_subject_tags", + "remove_furry_tags", + "remove_headwear_tags", + "remove_girl_suffix_tags", + "preserve_hair_eye_colors", + "remove_series_tags", + "use_tag_catalog", + "catalog_path", + "lora_auto_detect_pony", + "lora_detected_loras", + "lora_blacklist", +) + + +@dataclass(frozen=True) +class GelbooruOptions: + api_key: object + user_id: object + compat_base_url: object + fringe_benefits: object + + +@dataclass(frozen=True) +class ImageWorkflowOptions: + use_img2img: object + denoising: object + use_last_img: object + use_ip: object + crop_center: object + enable_adetailer_support: object + use_same_seed: object + reuse_cached_posts: object + use_cache: object + + +@dataclass(frozen=True) +class TagFilterOptions: + remove_bad_tags: object + remove_tags: object + change_background: object + change_color: object + remove_artist_tags: object + remove_character_tags: object + remove_clothing_tags: object + remove_text_tags: object + restrict_subject_tags: object + remove_furry_tags: object + remove_headwear_tags: object + remove_girl_suffix_tags: object + preserve_hair_eye_colors: object + remove_series_tags: object + use_tag_catalog: object + catalog_path: object + + +@dataclass(frozen=True) +class LoranadoOptions: + folder: object + amount: object + minimum_weight: object + maximum_weight: object + enabled: object + custom_weights: object + lock_previous: object + auto_detect_pony: object + detected_loras: object + blacklist: object + + +@dataclass(frozen=True) +class RunOptions: + enabled: object + tags: object + booru: object + gelbooru_api_key: object + gelbooru_user_id: object + gelbooru_compat_base_url: object + remove_bad_tags: object + max_pages: object + change_dash: object + same_prompt: object + fringe_benefits: object + remove_tags: object + use_img2img: object + denoising: object + use_last_img: object + change_background: object + change_color: object + shuffle_tags: object + post_id: object + mix_prompt: object + mix_amount: object + chaos_mode: object + chaos_amount: object + limit_tags: object + max_tags: object + sorting_order: object + mature_rating: object + lora_folder: object + lora_amount: object + lora_min: object + lora_max: object + lora_enabled: object + lora_custom_weights: object + lora_lock_prev: object + use_ip: object + use_search_txt: object + use_remove_txt: object + choose_search_txt: object + choose_remove_txt: object + search_refresh_btn: object + remove_refresh_btn: object + crop_center: object + enable_adetailer_support: object + use_same_seed: object + reuse_cached_posts: object + use_cache: object + log_prompt_sources: object + remove_artist_tags: object + remove_character_tags: object + remove_clothing_tags: object + remove_text_tags: object + restrict_subject_tags: object + remove_furry_tags: object + remove_headwear_tags: object + remove_girl_suffix_tags: object + preserve_hair_eye_colors: object + remove_series_tags: object + use_tag_catalog: object + catalog_path: object + lora_auto_detect_pony: object + lora_detected_loras: object + lora_blacklist: object + + @classmethod + def from_script_args(cls, args: Sequence[object]) -> "RunOptions": + values = list(args) + expected = len(UI_ARGUMENT_FIELDS) + if len(values) != expected: + raise ValueError(f"Expected {expected} RanbooruX script args, got {len(values)}") + return cls(**dict(zip(UI_ARGUMENT_FIELDS, values))) + + def as_dict(self) -> Dict[str, object]: + return {field: getattr(self, field) for field in UI_ARGUMENT_FIELDS} + + @property + def gelbooru(self) -> GelbooruOptions: + return GelbooruOptions( + api_key=self.gelbooru_api_key, + user_id=self.gelbooru_user_id, + compat_base_url=self.gelbooru_compat_base_url, + fringe_benefits=self.fringe_benefits, + ) + + @property + def image_workflow(self) -> ImageWorkflowOptions: + return ImageWorkflowOptions( + use_img2img=self.use_img2img, + denoising=self.denoising, + use_last_img=self.use_last_img, + use_ip=self.use_ip, + crop_center=self.crop_center, + enable_adetailer_support=self.enable_adetailer_support, + use_same_seed=self.use_same_seed, + reuse_cached_posts=self.reuse_cached_posts, + use_cache=self.use_cache, + ) + + @property + def tag_filters(self) -> TagFilterOptions: + return TagFilterOptions( + remove_bad_tags=self.remove_bad_tags, + remove_tags=self.remove_tags, + change_background=self.change_background, + change_color=self.change_color, + remove_artist_tags=self.remove_artist_tags, + remove_character_tags=self.remove_character_tags, + remove_clothing_tags=self.remove_clothing_tags, + remove_text_tags=self.remove_text_tags, + restrict_subject_tags=self.restrict_subject_tags, + remove_furry_tags=self.remove_furry_tags, + remove_headwear_tags=self.remove_headwear_tags, + remove_girl_suffix_tags=self.remove_girl_suffix_tags, + preserve_hair_eye_colors=self.preserve_hair_eye_colors, + remove_series_tags=self.remove_series_tags, + use_tag_catalog=self.use_tag_catalog, + catalog_path=self.catalog_path, + ) + + @property + def loranado(self) -> LoranadoOptions: + return LoranadoOptions( + folder=self.lora_folder, + amount=self.lora_amount, + minimum_weight=self.lora_min, + maximum_weight=self.lora_max, + enabled=self.lora_enabled, + custom_weights=self.lora_custom_weights, + lock_previous=self.lora_lock_prev, + auto_detect_pony=self.lora_auto_detect_pony, + detected_loras=self.lora_detected_loras, + blacklist=self.lora_blacklist, + ) + + +@dataclass(frozen=True) +class RunComponents: + components: Mapping[str, object] + + @classmethod + def from_sequence(cls, values: Sequence[object]) -> "RunComponents": + expected = len(UI_ARGUMENT_FIELDS) + if len(values) != expected: + raise ValueError(f"Expected {expected} RanbooruX UI components, got {len(values)}") + return cls(dict(zip(UI_ARGUMENT_FIELDS, values))) + + def script_args(self) -> List[object]: + missing = [field for field in UI_ARGUMENT_FIELDS if field not in self.components] + if missing: + raise ValueError(f"Missing RanbooruX components: {', '.join(missing)}") + return [self.components[field] for field in UI_ARGUMENT_FIELDS] diff --git a/ranboorux/tag_pipeline.py b/ranboorux/tag_pipeline.py new file mode 100644 index 0000000..6ae3c0d --- /dev/null +++ b/ranboorux/tag_pipeline.py @@ -0,0 +1,942 @@ +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Protocol, Set, Tuple, Union + +# --- Regex Patterns --- +_DASH_UNDERSCORE_RE = re.compile(r"[_\-]+") +_WHITESPACE_RE = re.compile(r"\s+") +_TAG_SPLIT_RE = re.compile(r"[,\s]+") + +# --- Constants and Keyword Sets --- +FURRY_CORE_TAGS = { + "anthro", + "furry", + "feral", + "feral_focus", + "feral_only", + "scalie", + "avian", + "hooved_animal", + "digitigrade", + "taur", + "mythological_creature", + "kemono", + "beastman", + "beastgirl", + "beastboy", + "kemonomimi", + "fur", + "fur_focus", +} +_FURRY_CORE_NORMALIZED = {tag.replace("_", " ") for tag in FURRY_CORE_TAGS} + +POKEMON_PREFIXES = ( + "pokemon", + "pikachu", + "eevee", + "charizard", + "mewtwo", + "gardevoir", + "lucario", + "lopunny", +) +_POKEMON_PREFIXES_NORMALIZED = tuple(prefix.replace("_", " ") for prefix in POKEMON_PREFIXES) + +ANIMAL_EAR_KEYWORDS = ( + "_ear", + "animal_ears", + "beast_ears", + "cat_ears", + "dog_ears", + "fox_ears", + "bunny_ears", + "wolf_ears", + "horse_ears", + "bear_ears", +) +_ANIMAL_EAR_KEYWORDS_NORMALIZED = tuple(kw.replace("_", " ") for kw in ANIMAL_EAR_KEYWORDS) + +HORN_KEYWORDS = ( + "horn", + "horns", + "antlers", + "unicorn_horn", + "goat_horns", + "demon_horns", + "ram_horns", + "bull_horns", + "long_horns", +) +_HORN_KEYWORDS_NORMALIZED = tuple(kw.replace("_", " ") for kw in HORN_KEYWORDS) + +HEADWEAR_TAGS = { + "hat", + "cap", + "beret", + "helmet", + "hood", + "crown", + "tiara", + "headband", + "hairband", + "headdress", + "veil", + "witch_hat", + "wizard_hat", + "top_hat", + "beanie", + "goggles", + "glasses_on_head", + "sailor_hat", + "nurse_cap", + "maid_headdress", + "pirate_hat", + "sombrero", + "bunny_ears_headband", + "cat_ears_headband", + "animal_ears_headband", + "motorcycle_helmet", + "baseball_cap", + "bowler_hat", + "straw_hat", + "sun_hat", + "halo", + "circular_halo", + "floating_halo", +} +_HEADWEAR_TAGS_NORMALIZED = {tag.replace("_", " ") for tag in HEADWEAR_TAGS} + +HALO_TAGS = {"halo", "circular_halo", "ring_halo", "floating_halo", "angelic_halo"} +_HALO_TAGS_NORMALIZED = {tag.replace("_", " ") for tag in HALO_TAGS} + +HAIR_COLOR_TAGS = { + "blonde_hair", + "brown_hair", + "black_hair", + "grey_hair", + "gray_hair", + "white_hair", + "silver_hair", + "blue_hair", + "green_hair", + "red_hair", + "pink_hair", + "purple_hair", + "orange_hair", + "aqua_hair", + "magenta_hair", + "teal_hair", + "multicolored_hair", + "gradient_hair", + "rainbow_hair", +} +_HAIR_COLOR_TAGS_NORMALIZED = {tag.replace("_", " ") for tag in HAIR_COLOR_TAGS} + +EYE_COLOR_TAGS = { + "blue_eyes", + "green_eyes", + "red_eyes", + "brown_eyes", + "black_eyes", + "yellow_eyes", + "amber_eyes", + "orange_eyes", + "purple_eyes", + "pink_eyes", + "golden_eyes", + "silver_eyes", + "grey_eyes", + "gray_eyes", + "white_eyes", + "aqua_eyes", + "heterochromia", + "multicolored_eyes", + "gradient_eyes", +} +_EYE_COLOR_TAGS_NORMALIZED = {tag.replace("_", " ") for tag in EYE_COLOR_TAGS} + +SERIES_KEYWORDS = { + "franchise", + "series", + "canon", + "official_media", + "gacha_game", + "anime", + "manga_franchise", + "visual_novel", +} +_SERIES_KEYWORDS_NORMALIZED = {tag.replace("_", " ") for tag in SERIES_KEYWORDS} + +SERIES_SUFFIXES = ("_series", "_franchise", "_media", "_universe") +_SERIES_SUFFIXES_NORMALIZED = tuple(suffix.replace("_", " ") for suffix in SERIES_SUFFIXES) + +_CLOTHING_KEYWORDS = { + "dress", + "shirt", + "skirt", + "skorts", + "pants", + "jeans", + "shorts", + "jacket", + "coat", + "sweater", + "hoodie", + "kimono", + "robe", + "uniform", + "school uniform", + "sailor uniform", + "bikini", + "swimsuit", + "lingerie", + "underwear", + "panties", + "bra", + "corset", + "thighhighs", + "stockings", + "socks", + "gloves", + "mittens", + "scarf", + "cape", + "apron", + "armor", + "bustier", + "bodysuit", + "leotard", + "gown", + "tuxedo", + "suit", + "vest", + "necktie", + "bowtie", + "hat", + "cap", + "headband", + "hairband", + "headdress", + "veil", + "crown", + "helmet", + "sandals", + "boots", + "shoes", + "heels", + "sneakers", + "flip flops", + "garter", + "garter belt", + "pantyhose", + "stocking", + "cloak", + "cardigan", + "sleeves", + "armband", + "choker", + "ribbon", + "bow", + "shawl", + "loincloth", + "loin cloth", + "tabard", + "capelet", + "poncho", + "overalls", + "tank top", + "t-shirt", + "tee shirt", + "pajamas", + "nightgown", +} + +_TEXTUAL_TAGS = { + "text", + "english text", + "japanese text", + "chinese text", + "korean text", + "translated", + "translation", + "commentary", + "artist commentary", + "author commentary", + "publisher commentary", + "copyright text", + "speech bubble", + "speech bubbles", + "dialogue", + "dialog", + "sound effect", + "sound effects", + "comic text", + "comic panel", + "subtitle", + "subtitles", + "caption", + "captions", + "floating text", + "text focus", + "text overlay", + "text background", + "watermark", + "watermark text", + "signature", + "sign", + "tagme", + "written text", + "scribble", + "handwritten text", + "handwriting", + "text box", + "thought bubble", + "thought balloon", + "logo", + "logo text", + "notice", + "speech bubble text", +} + +_SUBJECT_TAGS = { + "solo", + "duo", + "trio", + "quartet", + "group", + "gang", + "crowd", + "couple", + "threesome", + "foursome", + "orgy", + "1girl", + "2girls", + "3girls", + "4girls", + "1boy", + "2boys", + "3boys", + "4boys", + "1other", + "2others", + "3others", + "4others", + "multiple girls", + "multiple boys", + "multiple people", + "multiple others", + "solo focus", + "female focus", + "male focus", + "mixed group", + "1female", + "1male", + "2females", + "2males", + "3females", + "3males", + "1person", + "2people", + "3people", + "4people", +} + +REMOVAL_SYNONYM_GROUPS_RAW = ( + {"grayscale", "greyscale", "monochrome"}, + {"1girl", "1female", "1woman"}, +) + + +@dataclass(frozen=True) +class FilterContext: + toggles: Tuple[bool, bool, bool, bool, bool, bool, bool, bool, bool, bool] + base_colors: Tuple[Set[str], Set[str]] + allowed_subjects: Set[str] + cache: Dict[str, str] + favorites_guard: Set[str] + + +class CatalogResolver(Protocol): + def resolve_alias(self, tag: str) -> Optional[str]: ... + def is_textual(self, tag: str) -> bool: ... + def is_hair(self, tag: str) -> bool: ... + def is_eye(self, tag: str) -> bool: ... + def category(self, tag: str) -> int: ... + + +# --- Core Tag Pipeline Functions --- + + +def split_prompt_tags(prompt: str) -> List[str]: + if not isinstance(prompt, str): + return [] + return [tag.strip() for tag in prompt.split(",") if tag.strip()] + + +def dedupe_keep_order(tags: Iterable[str]) -> List[str]: + return list(dict.fromkeys(tags)) + + +def remove_repeated_tags(prompt: str) -> str: + tags = split_prompt_tags(prompt) + if not tags: + return "" + return ",".join(dedupe_keep_order(tags)) + + +def limit_prompt_tags(prompt: str, limit_val: Union[int, float, str], mode: str) -> str: + tags = split_prompt_tags(prompt) + if not tags: + return "" + if mode == "Limit": + try: + pct = float(limit_val) + except (ValueError, TypeError): + return prompt + if pct <= 0: + return "" + max_count = max(1, int(len(tags) * pct)) + return ",".join(tags[:max_count]) + if mode == "Max": + try: + max_count = int(limit_val) + except (ValueError, TypeError): + return prompt + if max_count <= 0: + return prompt + return ",".join(tags[:max_count]) + return prompt + + +def canonicalize_raw_tag(tag: str) -> str: + if not isinstance(tag, str): + return "" + lowered = (tag or "").strip().lower().replace("_", " ") + return _WHITESPACE_RE.sub(" ", lowered) if lowered else "" + + +def normalize_tag(tag: str) -> str: + if not isinstance(tag, str): + return "" + normalized = unicodedata.normalize("NFKC", tag).casefold() + normalized = _DASH_UNDERSCORE_RE.sub(" ", normalized) + normalized = _WHITESPACE_RE.sub(" ", normalized).strip() + if not normalized: + return "" + wrapper_pairs = {("(", ")"), ("[", "]"), ("{", "}")} + while len(normalized) > 2 and (normalized[0], normalized[-1]) in wrapper_pairs: + normalized = normalized[1:-1].strip() + return normalized + + +def build_synonym_lookup(groups_raw: Iterable[Iterable[str]]) -> Dict[str, Set[str]]: + lookup: Dict[str, Set[str]] = {} + for group in groups_raw: + normalized_group = {normalize_tag(tag) for tag in group if normalize_tag(tag)} + if normalized_group: + for entry in normalized_group: + lookup[entry] = normalized_group + return lookup + + +def expand_with_synonyms( + normalized_tag: str, target_set: Set[str], synonym_lookup: Dict[str, Set[str]] +) -> None: + if not normalized_tag: + return + group = synonym_lookup.get(normalized_tag) + if group: + target_set.update(group) + + +def is_furry_tag(tag: str) -> bool: + normalized = (normalize_tag(tag) or "").strip().lower() + if not normalized: + normalized = canonicalize_raw_tag(tag) + if not normalized: + return False + raw_lower = (tag or "").strip().lower() + if normalized in _FURRY_CORE_NORMALIZED or raw_lower in FURRY_CORE_TAGS: + return True + if any( + normalized.startswith(prefix) or raw_lower.startswith(prefix) + for prefix in _POKEMON_PREFIXES_NORMALIZED + ): + return True + if any(keyword in normalized for keyword in _ANIMAL_EAR_KEYWORDS_NORMALIZED): + return True + if any(keyword in normalized for keyword in _HORN_KEYWORDS_NORMALIZED): + return True + return False + + +def is_headwear_tag(tag: str) -> bool: + normalized = (normalize_tag(tag) or "").strip().lower() + if not normalized: + normalized = canonicalize_raw_tag(tag) + if not normalized: + return False + if normalized in _HEADWEAR_TAGS_NORMALIZED or normalized in _HALO_TAGS_NORMALIZED: + return True + if " halo" in normalized or normalized.endswith(" halo"): + return True + return False + + +def is_girl_suffix_tag(tag: str) -> bool: + normalized = (normalize_tag(tag) or "").strip().lower() + if not normalized: + normalized = canonicalize_raw_tag(tag) + if not normalized: + return False + excluded = { + "girl", + "1girl", + "2girls", + "3girls", + "4girls", + "5girls", + "6+girls", + "multiple girls", + } + if normalized in excluded: + return False + if normalized.endswith(" girl") or normalized.endswith("_girl"): + return True + return False + + +def is_hair_color_tag(tag: str, catalog_is_hair_fn=None) -> bool: + normalized = normalize_tag(tag) + if catalog_is_hair_fn and normalized: + if catalog_is_hair_fn(normalized.replace(" ", "_")): + return True + return normalized in _HAIR_COLOR_TAGS_NORMALIZED + + +def is_eye_color_tag(tag: str, catalog_is_eye_fn=None) -> bool: + normalized = normalize_tag(tag) + if catalog_is_eye_fn and normalized: + if catalog_is_eye_fn(normalized.replace(" ", "_")): + return True + return normalized in _EYE_COLOR_TAGS_NORMALIZED + + +def is_series_tag(tag: str, catalog_category_fn=None) -> bool: + normalized = (normalize_tag(tag) or "").strip().lower() + if not normalized: + normalized = canonicalize_raw_tag(tag) + if not normalized: + return False + if catalog_category_fn and catalog_category_fn(normalized.replace(" ", "_")) == 3: + return True + if normalized in _SERIES_KEYWORDS_NORMALIZED: + return True + if any(normalized.endswith(suffix) for suffix in _SERIES_SUFFIXES_NORMALIZED): + return True + return False + + +def is_clothing_tag(tag: str) -> bool: + normalized = normalize_tag(tag) + if not normalized: + return False + if ( + normalized.startswith("no ") + or normalized.startswith("without ") + or " without " in normalized + or normalized.startswith("nude") + ): + return False + for keyword in _CLOTHING_KEYWORDS: + if keyword in normalized: + return True + if ( + normalized.endswith(" uniform") + or normalized.endswith(" outfit") + or normalized.endswith(" costume") + ): + return True + return False + + +def is_textual_tag(tag: str, catalog_is_textual_fn=None) -> bool: + normalized = normalize_tag(tag) + if not normalized: + return False + if catalog_is_textual_fn and catalog_is_textual_fn(normalized.replace(" ", "_")): + return True + if normalized in _TEXTUAL_TAGS: + return True + if " text" in normalized or normalized.endswith(" text") or normalized.startswith("text "): + return True + if ( + "commentary" in normalized + or "speech bubble" in normalized + or "dialog" in normalized + or "subtitle" in normalized + or "caption" in normalized + ): + return True + if normalized.startswith("translated ") or normalized.startswith("translation "): + return True + return False + + +def is_subject_tag(tag: str) -> bool: + normalized = normalize_tag(tag) + return normalized in _SUBJECT_TAGS + + +def extract_color_tags(text: str) -> Tuple[Set[str], Set[str]]: + if not text: + return set(), set() + hair_tags = set() + eye_tags = set() + segments = [seg.strip() for seg in _TAG_SPLIT_RE.split(text) if seg.strip()] + for seg in segments: + normalized = normalize_tag(seg) + if normalized in _HAIR_COLOR_TAGS_NORMALIZED: + hair_tags.add(normalized) + elif normalized in _EYE_COLOR_TAGS_NORMALIZED: + eye_tags.add(normalized) + return hair_tags, eye_tags + + +def extract_subject_tags(text: str) -> Set[str]: + if not text: + return set() + tags = [t.strip() for t in _TAG_SPLIT_RE.split(text) if t.strip()] + return {normalize_tag(t) for t in tags if is_subject_tag(t)} + + +# --- Filter Context and Matches --- + + +def build_removal_context( + removal_raw: Iterable[str], + favorites_raw: Iterable[str], + synonym_lookup: Dict[str, Set[str]], +) -> Dict[str, object]: + exact: Set[str] = set() + prefix: List[str] = [] + suffix: List[str] = [] + contains: List[str] = [] + regex_objects: List[re.Pattern[str]] = [] + + for raw in removal_raw: + if not isinstance(raw, str): + continue + candidate = raw.strip() + if not candidate: + continue + if "*" not in candidate: + normalized = normalize_tag(candidate) + if normalized: + exact.add(normalized) + expand_with_synonyms(normalized, exact, synonym_lookup) + continue + if candidate.startswith("*") and candidate.endswith("*") and candidate.count("*") == 2: + body = candidate[1:-1] + normalized = normalize_tag(body) + if normalized: + contains.append(normalized) + continue + if candidate.endswith("*") and candidate.count("*") == 1: + body = candidate[:-1] + normalized = normalize_tag(body) + if normalized: + prefix.append(normalized) + continue + if candidate.startswith("*") and candidate.count("*") == 1: + body = candidate[1:] + normalized = normalize_tag(body) + if normalized: + suffix.append(normalized) + continue + + segments = candidate.split("*") + pattern_fragments: List[str] = [] + for idx, segment in enumerate(segments): + if segment: + normalized_segment = normalize_tag(segment) + if normalized_segment: + pattern_fragments.append(re.escape(normalized_segment)) + if idx < len(segments) - 1: + pattern_fragments.append(".*") + pattern_body = "".join(pattern_fragments) + if pattern_body: + try: + regex_objects.append(re.compile(f"^{pattern_body}$")) + except re.error: + pass + + contains_set = set(filter(None, contains)) + contains_regex: Optional[re.Pattern[str]] = None + if len(contains_set) > 50: + pattern_union = "|".join(re.escape(term) for term in contains_set if term) + if pattern_union: + try: + contains_regex = re.compile(pattern_union) + except re.error: + contains_regex = None + + prefix_tuple = tuple(sorted(set(filter(None, prefix)))) + suffix_tuple = tuple(sorted(set(filter(None, suffix)))) + contains_tuple = tuple(sorted(contains_set)) + + favorites_exact: Set[str] = set() + for fav in favorites_raw: + if not isinstance(fav, str): + continue + normalized = normalize_tag(fav) + if not normalized: + continue + favorites_exact.add(normalized) + expand_with_synonyms(normalized, favorites_exact, synonym_lookup) + + return { + "exact": frozenset(exact), + "prefix": prefix_tuple, + "suffix": suffix_tuple, + "contains": contains_tuple, + "contains_regex": contains_regex, + "regex_objects": tuple(regex_objects), + "favorites": frozenset(favorites_exact), + } + + +def tag_matches_removal(normalized_tag: str, context: Optional[Dict[str, object]]) -> bool: + if not context or not normalized_tag: + return False + favorites: Set[str] = context.get("favorites", frozenset()) # type: ignore + if normalized_tag in favorites: + return False + exact: Set[str] = context.get("exact", frozenset()) # type: ignore + if normalized_tag in exact: + return True + prefix_terms: Tuple[str, ...] = context.get("prefix", tuple()) # type: ignore + if any(normalized_tag.startswith(term) for term in prefix_terms if term): + return True + suffix_terms: Tuple[str, ...] = context.get("suffix", tuple()) # type: ignore + if any(normalized_tag.endswith(term) for term in suffix_terms if term): + return True + contains_regex: Optional[re.Pattern[str]] = context.get("contains_regex") # type: ignore + if contains_regex and contains_regex.search(normalized_tag): + return True + contains_terms: Tuple[str, ...] = context.get("contains", tuple()) # type: ignore + if not contains_regex and any(term and term in normalized_tag for term in contains_terms): + return True + regex_patterns: Tuple[re.Pattern[str], ...] = context.get("regex_objects", tuple()) # type: ignore + for pattern in regex_patterns: + if pattern.fullmatch(normalized_tag): + return True + return False + + +def normalize_post_tags( + post: Optional[Dict[str, object]], + cache: Dict[str, str], + catalog_resolve_alias_fn=None, +) -> Tuple[Set[str], Dict[str, List[str]]]: + normalized_tags: Set[str] = set() + buckets: Dict[str, List[str]] = { + "tags": [], + "artist_tags": [], + "character_tags": [], + "copyright_tags": [], + } + if not isinstance(post, dict): + return normalized_tags, buckets + + raw_tags = post.get("tags") + tag_list: List[str] = [] + if isinstance(raw_tags, str): + tag_list = [ + segment.strip() for segment in _TAG_SPLIT_RE.split(raw_tags.strip()) if segment.strip() + ] + elif isinstance(raw_tags, dict): + for value in raw_tags.values(): + if isinstance(value, (list, tuple, set)): + tag_list.extend( + [str(item).strip() for item in value if isinstance(item, str) and item.strip()] + ) + elif isinstance(value, str) and value.strip(): + tag_list.append(value.strip()) + elif isinstance(raw_tags, (list, tuple, set)): + tag_list = [ + str(item).strip() for item in raw_tags if isinstance(item, str) and item.strip() + ] + buckets["tags"] = tag_list + + for key in ("artist_tags", "character_tags", "copyright_tags"): + values = post.get(key) + if isinstance(values, str) and values.strip(): + buckets[key] = [values.strip()] + elif isinstance(values, (list, tuple, set)): + buckets[key] = [ + str(item).strip() for item in values if isinstance(item, str) and item.strip() + ] + else: + buckets[key] = [] + + def get_normalized_cached(tag_val: str) -> str: + cached = cache.get(tag_val) + if cached is not None: + return cached + normalized = normalize_tag(tag_val) + if normalized: + if catalog_resolve_alias_fn: + catalog_token = normalized.replace(" ", "_") + canonical = catalog_resolve_alias_fn(catalog_token) + if canonical and canonical != catalog_token: + normalized = canonical.replace("_", " ") + cache[tag_val] = normalized + return normalized + + for key, values in buckets.items(): + cleaned: List[str] = [] + for tag in values: + if not isinstance(tag, str): + continue + cleaned_tag = tag.strip() + if not cleaned_tag: + continue + cleaned.append(cleaned_tag) + normalized = get_normalized_cached(cleaned_tag) + if normalized: + normalized_tags.add(normalized) + buckets[key] = cleaned + + return normalized_tags, buckets + + +def post_rejected_by_filter( + post: Optional[Dict[str, object]], + *, + filter_ctx: Optional[Dict[str, object]], + toggles: Tuple[bool, bool, bool, bool, bool, bool, bool, bool, bool, bool], + base_colors: Tuple[Set[str], Set[str]], + allowed_subjects: Set[str], + cache: Dict[str, str], + favorites_guard: Set[str], + catalog_resolve_alias_fn=None, + catalog_is_textual_fn=None, + catalog_is_hair_fn=None, + catalog_is_eye_fn=None, + catalog_category_fn=None, +) -> Tuple[bool, Optional[Dict[str, object]]]: + ( + remove_artist, + remove_character, + remove_clothing, + remove_text, + restrict_subject, + remove_furry, + remove_headwear, + remove_girl_suffix, + preserve_hair_eye, + remove_series, + ) = toggles + base_hair, base_eye = base_colors + _, buckets = normalize_post_tags(post, cache, catalog_resolve_alias_fn) + primary_subject: Optional[str] = None + + def get_normalized_cached(tag_val: str) -> str: + cached = cache.get(tag_val) + if cached is not None: + return cached + normalized = normalize_tag(tag_val) + if normalized: + if catalog_resolve_alias_fn: + catalog_token = normalized.replace(" ", "_") + canonical = catalog_resolve_alias_fn(catalog_token) + if canonical and canonical != catalog_token: + normalized = canonical.replace("_", " ") + cache[tag_val] = normalized + return normalized + + for bucket_name, tags in buckets.items(): + for raw_tag in tags: + normalized_tag = get_normalized_cached(raw_tag) + if normalized_tag and normalized_tag in favorites_guard: + continue + canonical_tag = normalized_tag or canonicalize_raw_tag(raw_tag) + canonical_tag = canonical_tag or "" + reason_base = { + "tag": raw_tag, + "norm": normalized_tag, + "bucket": bucket_name, + } + + if remove_artist and ( + bucket_name == "artist_tags" + or ( + normalized_tag + and (normalized_tag.endswith(" artist") or " drawn by" in normalized_tag) + ) + ): + return True, {**reason_base, "rule": "artist"} + + if remove_character and ( + bucket_name == "character_tags" + or ("(" in raw_tag and ")" in raw_tag and not raw_tag.strip().startswith("(")) + or ( + normalized_tag + and ( + normalized_tag.endswith(" character") + or normalized_tag.endswith(" characters") + or normalized_tag.endswith(" series") + or normalized_tag.endswith(" franchise") + ) + ) + ): + return True, {**reason_base, "rule": "character"} + + if remove_series and ( + bucket_name == "copyright_tags" or is_series_tag(raw_tag, catalog_category_fn) + ): + return True, {**reason_base, "rule": "series"} + + if remove_clothing and is_clothing_tag(raw_tag): + return True, {**reason_base, "rule": "clothing"} + + if remove_text and is_textual_tag(raw_tag, catalog_is_textual_fn): + return True, {**reason_base, "rule": "text"} + + if remove_furry and is_furry_tag(raw_tag): + return True, {**reason_base, "rule": "furry"} + + if remove_headwear and is_headwear_tag(raw_tag): + return True, {**reason_base, "rule": "headwear"} + + if remove_girl_suffix and is_girl_suffix_tag(raw_tag): + return True, {**reason_base, "rule": "girl-suffix"} + + if preserve_hair_eye: + if is_hair_color_tag(raw_tag, catalog_is_hair_fn) and base_hair: + if normalized_tag not in base_hair: + return True, {**reason_base, "rule": "hair-color-conflict"} + if is_eye_color_tag(raw_tag, catalog_is_eye_fn) and base_eye: + if normalized_tag not in base_eye: + return True, {**reason_base, "rule": "eye-color-conflict"} + + if restrict_subject and is_subject_tag(raw_tag): + subject_norm = normalized_tag or canonical_tag + if allowed_subjects: + if subject_norm not in allowed_subjects: + return True, {**reason_base, "rule": "subject-not-allowed"} + else: + if primary_subject is None: + primary_subject = subject_norm + elif subject_norm != primary_subject: + return True, {**reason_base, "rule": "multiple-subjects"} + + if tag_matches_removal(canonical_tag, filter_ctx): + return True, {**reason_base, "rule": "removal-list"} + + return False, None diff --git a/ranboorux/user_store.py b/ranboorux/user_store.py new file mode 100644 index 0000000..33521ac --- /dev/null +++ b/ranboorux/user_store.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import json +import os +import re +import tempfile +from pathlib import Path +from typing import Callable, Dict, Iterable, List, Mapping, Optional, Union + +PathLike = Union[str, Path] +NormalizeFn = Optional[Callable[[str], str]] + + +class UserStoreError(RuntimeError): + """Raised when RanbooruX user-data storage cannot complete an operation.""" + + +def sanitize_credential(value: object) -> str: + if value is None: + return "" + text = "".join(char for char in str(value) if char.isprintable()).strip().strip('"').strip("'") + if not text: + return "" + text = text.replace("%26", "&").replace("%3D", "=").replace("%3d", "=") + lower = text.lower() + if "api_key=" in lower or "user_id=" in lower or "&" in text: + for segment in text.split("&"): + segment_lower = segment.lower().strip() + if segment_lower.startswith("api_key=") or segment_lower.startswith("user_id="): + return segment.split("=", 1)[1].strip() + text = text.split("&", 1)[0].strip() + for prefix in ("api_key=", "user_id="): + if text.lower().startswith(prefix): + text = text[len(prefix) :].strip() + return text + + +def atomic_write_text(file_path: PathLike, content: str) -> None: + target = Path(file_path) + try: + target.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path_value = tempfile.mkstemp( + dir=target.parent, + prefix=".ranboorux_", + suffix=".tmp", + ) + except OSError as exc: + raise UserStoreError(f"Could not prepare atomic write for {target}: {exc}") from exc + + temp_path = Path(temp_path_value) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + temp_path.replace(target) + except Exception as exc: + try: + temp_path.unlink(missing_ok=True) + except OSError as cleanup_exc: + raise UserStoreError( + f"Could not write {target}; cleanup also failed: {cleanup_exc}" + ) from exc + raise UserStoreError(f"Could not write {target}: {exc}") from exc + + +def ensure_text_file(file_path: PathLike) -> None: + target = Path(file_path) + if target.is_file(): + return + atomic_write_text(target, "") + + +def load_gelbooru_credentials(file_path: PathLike) -> Optional[Dict[str, str]]: + target = Path(file_path) + if not target.is_file(): + return None + try: + with target.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except json.JSONDecodeError as exc: + raise UserStoreError(f"Invalid Gelbooru credentials JSON in {target}") from exc + except OSError as exc: + raise UserStoreError(f"Could not read Gelbooru credentials from {target}: {exc}") from exc + + if not isinstance(data, Mapping): + return None + api_key = sanitize_credential(data.get("api_key")) + user_id = sanitize_credential(data.get("user_id")) + if api_key and user_id: + return {"api_key": api_key, "user_id": user_id} + return None + + +def save_gelbooru_credentials(file_path: PathLike, api_key: object, user_id: object) -> None: + sanitized_api_key = sanitize_credential(api_key) + sanitized_user_id = sanitize_credential(user_id) + if not sanitized_api_key or not sanitized_user_id: + raise ValueError("Both Gelbooru API key and user ID are required") + content = json.dumps( + {"api_key": sanitized_api_key, "user_id": sanitized_user_id}, + ensure_ascii=False, + indent=2, + ) + atomic_write_text(file_path, content) + + +def clear_gelbooru_credentials(file_path: PathLike) -> None: + target = Path(file_path) + try: + if target.is_file(): + target.unlink() + except OSError as exc: + raise UserStoreError(f"Could not remove Gelbooru credentials at {target}: {exc}") from exc + + +def read_list_file(file_path: PathLike, normalize_fn: NormalizeFn = None) -> List[str]: + target = Path(file_path) + if not target.is_file(): + return [] + try: + contents = target.read_text(encoding="utf-8") + except OSError as exc: + raise UserStoreError(f"Could not read list file {target}: {exc}") from exc + + contents = contents.replace("\r\n", "\n").replace("\r", "\n") + parts = [segment.strip() for segment in re.split(r"[\n,]+", contents) if segment.strip()] + seen: set[str] = set() + ordered: List[str] = [] + for part in parts: + key = normalize_fn(part) if callable(normalize_fn) else part.casefold() + key = key or part.casefold() + if key in seen: + continue + seen.add(key) + ordered.append(part) + return ordered + + +def write_list_file( + file_path: PathLike, tags: Iterable[object], normalize_fn: NormalizeFn = None +) -> None: + seen: set[str] = set() + deduped: List[str] = [] + for tag in tags: + cleaned = (str(tag) if tag is not None else "").strip() + if not cleaned: + continue + key = normalize_fn(cleaned) if callable(normalize_fn) else cleaned.casefold() + key = key or cleaned.casefold() + if key in seen: + continue + seen.add(key) + deduped.append(cleaned) + atomic_write_text(file_path, "\n".join(deduped)) + + +def append_prompt_log(file_path: PathLike, payload: Mapping[str, object]) -> None: + target = Path(file_path) + try: + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("a", encoding="utf-8", newline="\n") as handle: + handle.write(json.dumps(dict(payload), ensure_ascii=False)) + handle.write("\n") + except OSError as exc: + raise UserStoreError(f"Could not append prompt log {target}: {exc}") from exc + + +def append_text_log(file_path: PathLike, lines: Iterable[str]) -> None: + target = Path(file_path) + try: + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("a", encoding="utf-8", newline="\n") as handle: + for line in lines: + text = str(line) + handle.write(text) + if not text.endswith("\n"): + handle.write("\n") + except OSError as exc: + raise UserStoreError(f"Could not append text log {target}: {exc}") from exc + + +def load_catalog_preferences(file_path: PathLike) -> Dict[str, object]: + defaults: Dict[str, object] = {"enabled": True, "source": "bundled", "custom_path": ""} + target = Path(file_path) + if not target.is_file(): + return dict(defaults) + try: + with target.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except json.JSONDecodeError as exc: + raise UserStoreError(f"Invalid catalog preference JSON in {target}") from exc + except OSError as exc: + raise UserStoreError(f"Could not read catalog preferences from {target}: {exc}") from exc + + if not isinstance(data, Mapping): + return dict(defaults) + source = data.get("source", "bundled") + source_text = source.strip().lower() if isinstance(source, str) else "bundled" + if source_text not in ("bundled", "custom"): + source_text = "bundled" + custom_path = data.get("custom_path", "") + return { + "enabled": bool(data.get("enabled", True)), + "source": source_text, + "custom_path": custom_path.strip() if isinstance(custom_path, str) else "", + } + + +def save_catalog_preferences( + file_path: PathLike, + *, + enabled: bool, + source: str, + custom_path: object, +) -> None: + source_text = source if source in ("bundled", "custom") else "bundled" + payload = { + "enabled": bool(enabled), + "source": source_text, + "custom_path": str(custom_path).strip() if custom_path is not None else "", + } + atomic_write_text(file_path, json.dumps(payload, ensure_ascii=False, indent=2)) diff --git a/scripts/ranbooru.py b/scripts/ranbooru.py index 6d95923..b4aa8b8 100644 --- a/scripts/ranbooru.py +++ b/scripts/ranbooru.py @@ -1,79 +1,93 @@ -from io import BytesIO -import re -import random -import requests -import modules.scripts as scripts -import gradio as gr -import os -import json import csv -import unicodedata -from typing import Dict, Iterable, List, Optional, Set, Tuple -from PIL import Image -import xml.etree.ElementTree as ET -import time -import numpy as np -import requests_cache -import sys -import traceback import difflib +import json import logging +import os +import random +import re import shutil +import sys +import time +import traceback +import types +import unicodedata +import xml.etree.ElementTree as ET +from contextlib import ExitStack, contextmanager from datetime import datetime +from io import BytesIO +from typing import Dict, Iterable, List, Optional, Set, Tuple from urllib.parse import quote_plus -from contextlib import contextmanager -from modules.processing import process_images, StableDiffusionProcessingImg2Img, StableDiffusionProcessing +import gradio as gr +import modules.scripts as scripts +import numpy as np +import requests from modules import shared +from modules.processing import ( + StableDiffusionProcessing, + StableDiffusionProcessingImg2Img, + process_images, +) +from PIL import Image + try: from modules.ui_components import InputAccordion except ImportError: InputAccordion = gr.Accordion from modules.scripts import basedir -from ranboorux import prompting as rb_prompting -from ranboorux import image_ops as rb_image_ops -from ranboorux import io_lists as rb_io_lists + from ranboorux import catalog as rb_catalog -from ranboorux.integrations import controlnet as rb_controlnet_integration +from ranboorux import mutation_scope as rb_mutation_scope +from ranboorux import image_ops as rb_image_ops +from ranboorux import loranado as rb_loranado +from ranboorux import http_client as rb_http_client +from ranboorux import run_options as rb_run_options +from ranboorux import tag_pipeline as rb_tag_pipeline +from ranboorux import user_store as rb_user_store from ranboorux.integrations import adetailer as rb_adetailer_integration +from ranboorux.integrations import adetailer_orchestration as rb_adetailer_orch +from ranboorux.integrations import adetailer_runtime as rb_adetailer_runtime +from ranboorux.integrations import controlnet as rb_controlnet_integration +from ranboorux.integrations import img2img_lifecycle as rb_img2img_lifecycle +from ranboorux.boorus import Booru # --- Constants and Paths --- EXTENSION_ROOT = basedir() # Ensure extension root is on sys.path for local package imports (e.g., sd_forge_controlnet) if EXTENSION_ROOT not in sys.path: sys.path.append(EXTENSION_ROOT) -USER_DATA_DIR = os.path.join(EXTENSION_ROOT, 'user') -USER_SEARCH_DIR = os.path.join(USER_DATA_DIR, 'search') -USER_REMOVE_DIR = os.path.join(USER_DATA_DIR, 'remove') -LOG_DIR = os.path.join(USER_DATA_DIR, 'logs') +USER_DATA_DIR = os.path.join(EXTENSION_ROOT, "user") +USER_SEARCH_DIR = os.path.join(USER_DATA_DIR, "search") +USER_REMOVE_DIR = os.path.join(USER_DATA_DIR, "remove") +LOG_DIR = os.path.join(USER_DATA_DIR, "logs") os.makedirs(USER_SEARCH_DIR, exist_ok=True) os.makedirs(USER_REMOVE_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True) -GELBOORU_CREDENTIALS_DIR = os.path.join(USER_DATA_DIR, 'gelbooru') -GELBOORU_CREDENTIALS_FILE = os.path.join(GELBOORU_CREDENTIALS_DIR, 'credentials.json') +GELBOORU_CREDENTIALS_DIR = os.path.join(USER_DATA_DIR, "gelbooru") +GELBOORU_CREDENTIALS_FILE = os.path.join(GELBOORU_CREDENTIALS_DIR, "credentials.json") -PERSONAL_REMOVE_FILE = os.path.join(USER_REMOVE_DIR, 'personal_remove.txt') -FAVORITES_FILE = os.path.join(USER_SEARCH_DIR, 'favorites.txt') -PROMPT_LOG_JSONL = os.path.join(LOG_DIR, 'prompt_sources.jsonl') -TAG_CATALOG_CONFIG_FILE = os.path.join(USER_DATA_DIR, 'tag_catalog.json') -BUNDLED_CATALOG_DIR = os.path.join(EXTENSION_ROOT, 'data', 'catalogs') -BUNDLED_CATALOG_PATH = os.path.join(BUNDLED_CATALOG_DIR, 'danbooru_tags.csv') -USER_CATALOGS_DIR = os.path.join(USER_DATA_DIR, 'catalogs') +PERSONAL_REMOVE_FILE = os.path.join(USER_REMOVE_DIR, "personal_remove.txt") +FAVORITES_FILE = os.path.join(USER_SEARCH_DIR, "favorites.txt") +PROMPT_LOG_JSONL = os.path.join(LOG_DIR, "prompt_sources.jsonl") +TAG_CATALOG_CONFIG_FILE = os.path.join(USER_DATA_DIR, "tag_catalog.json") +BUNDLED_CATALOG_DIR = os.path.join(EXTENSION_ROOT, "data", "catalogs") +BUNDLED_CATALOG_PATH = os.path.join(BUNDLED_CATALOG_DIR, "danbooru_tags.csv") +USER_CATALOGS_DIR = os.path.join(USER_DATA_DIR, "catalogs") os.makedirs(USER_CATALOGS_DIR, exist_ok=True) REMOVAL_SYNONYM_GROUPS_RAW: Tuple[Set[str], ...] = ( - {'grayscale', 'greyscale', 'monochrome'}, - {'1girl', '1female', '1woman'}, + {"grayscale", "greyscale", "monochrome"}, + {"1girl", "1female", "1woman"}, ) # Ensure default files exist -for filename in ['tags_search.txt', 'tags_remove.txt']: - dir_path = USER_SEARCH_DIR if 'search' in filename else USER_REMOVE_DIR +for filename in ["tags_search.txt", "tags_remove.txt"]: + dir_path = USER_SEARCH_DIR if "search" in filename else USER_REMOVE_DIR filepath = os.path.join(dir_path, filename) if not os.path.isfile(filepath): try: - with open(filepath, 'w', encoding='utf-8') as f: + with open(filepath, "w", encoding="utf-8") as f: pass except Exception as e: print(f"[Ranbooru] Error creating file {filepath}: {e}") @@ -83,97 +97,83 @@ try: os.makedirs(parent, exist_ok=True) if not os.path.isfile(ensured_path): - mode = 'w' - with open(ensured_path, mode, encoding='utf-8') as f: + mode = "w" + with open(ensured_path, mode, encoding="utf-8") as f: if ensured_path == PROMPT_LOG_JSONL: pass except Exception as exc: print(f"[Ranbooru] Error ensuring file {ensured_path}: {exc}") -COLORED_BG = ['black_background', 'aqua_background', 'white_background', 'colored_background', 'gray_background', 'blue_background', 'green_background', 'red_background', 'brown_background', 'purple_background', 'yellow_background', 'orange_background', 'pink_background', 'plain', 'transparent_background', 'simple_background', 'two-tone_background', 'grey_background'] -ADD_BG = ['outdoors', 'indoors'] -BW_BG = ['monochrome', 'greyscale', 'grayscale'] +COLORED_BG = [ + "black_background", + "aqua_background", + "white_background", + "colored_background", + "gray_background", + "blue_background", + "green_background", + "red_background", + "brown_background", + "purple_background", + "yellow_background", + "orange_background", + "pink_background", + "plain", + "transparent_background", + "simple_background", + "two-tone_background", + "grey_background", +] +ADD_BG = ["outdoors", "indoors"] +BW_BG = ["monochrome", "greyscale", "grayscale"] POST_AMOUNT = 100 COUNT = 100 DEBUG = False +MAX_SOURCE_IMAGE_BYTES = 25 * 1024 * 1024 +MAX_SOURCE_IMAGE_PIXELS = 50_000_000 +MAX_SOURCE_IMAGE_FRAMES = 1 _ranbooru_logger = logging.getLogger("ranboorux") -FURRY_CORE_TAGS = { - 'anthro', 'furry', 'feral', 'feral_focus', 'feral_only', 'scalie', 'avian', 'hooved_animal', 'digitigrade', - 'taur', 'mythological_creature', 'kemono', 'beastman', 'beastgirl', 'beastboy', 'kemonomimi', 'fur', 'fur_focus' -} - -POKEMON_PREFIXES = ('pokemon', 'pikachu', 'eevee', 'charizard', 'mewtwo', 'gardevoir', 'lucario', 'lopunny') -ANIMAL_EAR_KEYWORDS = ('_ear', 'animal_ears', 'beast_ears', 'cat_ears', 'dog_ears', 'fox_ears', 'bunny_ears', 'wolf_ears', 'horse_ears', 'bear_ears') -HORN_KEYWORDS = ('horn', 'horns', 'antlers', 'unicorn_horn', 'goat_horns', 'demon_horns', 'ram_horns', 'bull_horns', 'long_horns') - -HEADWEAR_TAGS = { - 'hat', 'cap', 'beret', 'helmet', 'hood', 'crown', 'tiara', 'headband', 'hairband', 'headdress', 'veil', - 'witch_hat', 'wizard_hat', 'top_hat', 'beanie', 'goggles', 'glasses_on_head', 'sailor_hat', 'nurse_cap', - 'maid_headdress', 'pirate_hat', 'sombrero', 'bunny_ears_headband', 'cat_ears_headband', 'animal_ears_headband', - 'motorcycle_helmet', 'baseball_cap', 'bowler_hat', 'straw_hat', 'sun_hat', 'halo', 'circular_halo', 'floating_halo' -} - -HALO_TAGS = {'halo', 'circular_halo', 'ring_halo', 'floating_halo', 'angelic_halo'} - -HAIR_COLOR_TAGS = { - 'blonde_hair', 'brown_hair', 'black_hair', 'grey_hair', 'gray_hair', 'white_hair', 'silver_hair', 'blue_hair', - 'green_hair', 'red_hair', 'pink_hair', 'purple_hair', 'orange_hair', 'aqua_hair', 'magenta_hair', 'teal_hair', - 'multicolored_hair', 'gradient_hair', 'rainbow_hair' -} - -EYE_COLOR_TAGS = { - 'blue_eyes', 'green_eyes', 'red_eyes', 'brown_eyes', 'black_eyes', 'yellow_eyes', 'amber_eyes', 'orange_eyes', - 'purple_eyes', 'pink_eyes', 'golden_eyes', 'silver_eyes', 'grey_eyes', 'gray_eyes', 'white_eyes', 'aqua_eyes', - 'heterochromia', 'multicolored_eyes', 'gradient_eyes' -} - - -SERIES_KEYWORDS = { - 'franchise', 'series', 'canon', 'official_media', 'gacha_game', 'anime', 'manga_franchise', 'visual_novel' -} -SERIES_SUFFIXES = ('_series', '_franchise', '_media', '_universe') - RATING_TYPES = { "none": {"All": "All"}, "full": {"All": "All", "Safe": "safe", "Questionable": "questionable", "Explicit": "explicit"}, - "single": {"All": "All", "Safe": "g", "Sensitive": "s", "Questionable": "q", "Explicit": "e"} + "single": {"All": "All", "Safe": "g", "Sensitive": "s", "Questionable": "q", "Explicit": "e"}, } RATINGS = { - "e621": RATING_TYPES['full'], - "danbooru": RATING_TYPES['single'], - "aibooru": RATING_TYPES['full'], - "yande.re": RATING_TYPES['full'], - "konachan": RATING_TYPES['full'], - "safebooru": RATING_TYPES['none'], - "rule34": RATING_TYPES['full'], - "xbooru": RATING_TYPES['full'], - "gelbooru": RATING_TYPES['single'], - "gelbooru-compatible": RATING_TYPES['single'] + "e621": RATING_TYPES["full"], + "danbooru": RATING_TYPES["single"], + "aibooru": RATING_TYPES["full"], + "yande.re": RATING_TYPES["full"], + "konachan": RATING_TYPES["full"], + "safebooru": RATING_TYPES["none"], + "rule34": RATING_TYPES["full"], + "xbooru": RATING_TYPES["full"], + "gelbooru": RATING_TYPES["single"], + "gelbooru-compatible": RATING_TYPES["single"], } STRICT_IMG2IMG_EXTRA_ROUNDS = 2 STRICT_IMG2IMG_LOG_SAMPLE = 5 _TAG_SPLIT_RE = re.compile(r"[,\s]+") _LORANADO_PONY_PATTERNS: Tuple[re.Pattern, ...] = ( - re.compile(r'(? None: def _gr_component_update(component_or_class, **kwargs): """Gradio 3/4 compatibility helper for component updates.""" - update_method = getattr(component_or_class, 'update', None) + update_method = getattr(component_or_class, "update", None) if callable(update_method): return update_method(**kwargs) return component_or_class(**kwargs) @@ -193,52 +193,23 @@ def _gr_component_update(component_or_class, **kwargs): def _gr_update(**kwargs): """Compatibility wrapper for gr.update() and fallback dict semantics.""" - update_fn = getattr(gr, 'update', None) + update_fn = getattr(gr, "update", None) if callable(update_fn): return update_fn(**kwargs) return kwargs def get_available_ratings(booru): - choices = list(RATINGS.get(booru, RATING_TYPES['none']).keys()) + choices = list(RATINGS.get(booru, RATING_TYPES["none"]).keys()) return _gr_component_update(gr.Radio, choices=choices, value="All", visible=True) def show_fringe_benefits(booru): - return _gr_component_update(gr.Checkbox, visible=(booru == 'gelbooru'), value=True) + return _gr_component_update(gr.Checkbox, visible=(booru == "gelbooru"), value=True) def _sanitize_gelbooru_credential(value: Optional[str]) -> str: - """Lenient cleanup for Gelbooru credential text. - Accepts raw tokens or mistakenly pasted query strings and extracts the core value. - - Strips quotes/whitespace - - Decodes minimal %26 and %3D encodings (ampersand/equal) - - Removes leading api_key= / user_id= if present - - Trims anything after the first '&' - """ - if not isinstance(value, str): - return "" - s = value.strip().strip('"').strip("'") - if not s: - return "" - # Minimal decode for common cases - s = s.replace('%26', '&').replace('%3D', '=').replace('%3d', '=') - # If it's a query-like string, prefer first matching segment - lower = s.lower() - if 'api_key=' in lower or 'user_id=' in lower or '&' in s: - parts = s.split('&') - for seg in parts: - seg_l = seg.lower().strip() - if seg_l.startswith('api_key=') or seg_l.startswith('user_id='): - return seg.split('=', 1)[1].strip() - # fallback: take the first segment before any '&' - s = parts[0].strip() - # Remove accidental prefixes if still present - for prefix in ('api_key=', 'user_id='): - if s.lower().startswith(prefix): - s = s[len(prefix):].strip() - return s - + return rb_user_store.sanitize_credential(value) def _sanitize_gelbooru_compat_base_url(value: Optional[str]) -> str: @@ -247,36 +218,22 @@ def _sanitize_gelbooru_compat_base_url(value: Optional[str]) -> str: sanitized = value.strip() if not sanitized: return "" - if not re.match(r'^https?://', sanitized, re.IGNORECASE): + if not re.match(r"^https?://", sanitized, re.IGNORECASE): sanitized = f"https://{sanitized}" - return sanitized.rstrip('/') + return sanitized.rstrip("/") def _load_gelbooru_credentials_from_disk() -> Optional[Dict[str, str]]: - if not os.path.isfile(GELBOORU_CREDENTIALS_FILE): - return None try: - with open(GELBOORU_CREDENTIALS_FILE, 'r', encoding='utf-8') as handle: - data = json.load(handle) - api_key = _sanitize_gelbooru_credential(data.get('api_key')) if isinstance(data, dict) else "" - user_id = _sanitize_gelbooru_credential(data.get('user_id')) if isinstance(data, dict) else "" - if api_key and user_id: - return {'api_key': api_key, 'user_id': user_id} + return rb_user_store.load_gelbooru_credentials(GELBOORU_CREDENTIALS_FILE) except Exception as exc: _log(f"Warn: Failed to read Gelbooru credentials: {exc}") return None def _save_gelbooru_credentials_to_disk(api_key: str, user_id: str) -> bool: - api_key = _sanitize_gelbooru_credential(api_key) - user_id = _sanitize_gelbooru_credential(user_id) if user_id else "" - if not api_key or not user_id: - return False try: - os.makedirs(GELBOORU_CREDENTIALS_DIR, exist_ok=True) - payload = {'api_key': api_key, 'user_id': user_id} - with open(GELBOORU_CREDENTIALS_FILE, 'w', encoding='utf-8') as handle: - json.dump(payload, handle) + rb_user_store.save_gelbooru_credentials(GELBOORU_CREDENTIALS_FILE, api_key, user_id) return True except Exception as exc: _log(f"Error: Unable to save Gelbooru credentials: {exc}") @@ -285,8 +242,7 @@ def _save_gelbooru_credentials_to_disk(api_key: str, user_id: str) -> bool: def _clear_gelbooru_credentials_from_disk() -> bool: try: - if os.path.isfile(GELBOORU_CREDENTIALS_FILE): - os.remove(GELBOORU_CREDENTIALS_FILE) + rb_user_store.clear_gelbooru_credentials(GELBOORU_CREDENTIALS_FILE) return True except Exception as exc: _log(f"Warn: Failed to clear Gelbooru credentials: {exc}") @@ -303,26 +259,10 @@ def _clear_gelbooru_credentials_from_disk() -> bool: def check_booru_exceptions(booru, post_id, tags): if post_id and booru in POST_ID_UNSUPPORTED_ERRORS: raise ValueError(POST_ID_UNSUPPORTED_ERRORS[booru]) - if booru == 'danbooru' and tags and len([t for t in tags.split(',') if t.strip()]) > 1: + if booru == "danbooru" and tags and len([t for t in tags.split(",") if t.strip()]) > 1: raise ValueError("Danbooru API only supports one tag.") -def resize_image(img, width, height, cropping=True): - try: - return rb_image_ops.resize_image(img, width, height, cropping=cropping) - except Exception as e: - _log(f"Error resize: {e}") - return img - - -def _split_prompt_tags(prompt: str) -> List[str]: - return [tag.strip() for tag in prompt.split(',') if tag.strip()] - - -def _dedupe_keep_order(tags: Iterable[str]) -> List[str]: - return list(dict.fromkeys(tags)) - - def _split_tag_string(value: Optional[str]) -> List[str]: if not isinstance(value, str): return [] @@ -354,25 +294,6 @@ def _split_tag_string_override(value: object) -> Optional[List[str]]: return _split_tag_string(value) -def remove_repeated_tags(prompt): - try: - return rb_prompting.remove_repeated_tags(prompt) - except Exception as e: - _log(f"Error remove_repeated: {e}. Input: '{prompt}'") - return "" - - -def limit_prompt_tags(prompt, limit_val, mode): - try: - return rb_prompting.limit_prompt_tags(prompt, limit_val, mode) - except ValueError: - _log(f"Error limiting tags: Invalid limit value '{limit_val}'") - return prompt - except Exception as e: - _log(f"Error limiting tags: {e}") - return prompt - - POST_URL_TEMPLATES = { "danbooru": "https://danbooru.donmai.us/posts/{pid}", "gelbooru": "https://gelbooru.com/index.php?page=post&s=view&id={pid}", @@ -388,12 +309,12 @@ def limit_prompt_tags(prompt, limit_val, mode): def get_original_post_url(post): try: - booru = (post.get('booru_name') or '').lower() - pid = post.get('id') + booru = (post.get("booru_name") or "").lower() + pid = post.get("id") if not pid: return None - if booru == 'gelbooru-compatible': - base = (post.get('source_base_url') or '').strip() + if booru == "gelbooru-compatible": + base = (post.get("source_base_url") or "").strip() if base: return f"{base.rstrip('/')}/index.php?page=post&s=view&id={pid}" return None @@ -406,8 +327,8 @@ def get_original_post_url(post): def generate_chaos(pos_tags, neg_tags, chaos_amount): - pos_tag_list = _split_prompt_tags(pos_tags) - neg_tag_list = _split_prompt_tags(neg_tags) + pos_tag_list = rb_tag_pipeline.split_prompt_tags(pos_tags) + neg_tag_list = rb_tag_pipeline.split_prompt_tags(neg_tags) chaos_list = list(set(pos_tag_list + neg_tag_list)) if not chaos_list: return pos_tags, neg_tags @@ -417,536 +338,13 @@ def generate_chaos(pos_tags, neg_tags, chaos_amount): pos_add = chaos_list[len_list:] final_pos = list(set(pos_tag_list) - set(neg_add)) + pos_add final_neg = list(set(neg_tag_list) - set(pos_add)) + neg_add - return ','.join(_dedupe_keep_order(final_pos)), ','.join(_dedupe_keep_order(final_neg)) + return ",".join(rb_tag_pipeline.dedupe_keep_order(final_pos)), ",".join(rb_tag_pipeline.dedupe_keep_order(final_neg)) class BooruError(Exception): pass -class Booru(): - def __init__(self, booru_name, base_api_url): - self.booru_name = booru_name - self.base_api_url = base_api_url - self.headers = {'user-agent': f'Ranbooru Extension/{Script.version} for Forge'} - - def _fetch_data(self, query_url): - _log(f"Querying {self.booru_name}: {query_url}") - try: - res = requests.get(query_url, headers=self.headers, timeout=30) - res.raise_for_status() - if 'application/json' not in res.headers.get('content-type', ''): - _log(f"Warn: Unexpected content type '{res.headers.get('content-type')}' from {self.booru_name}. Expected JSON.") - try: - return res.json() - except requests.exceptions.JSONDecodeError: - return None - return res.json() - except requests.exceptions.Timeout: - _log(f"Error: Timeout fetching data from {self.booru_name}.") - raise BooruError(f"Timeout connecting to {self.booru_name}") from None - except requests.exceptions.RequestException as e: - _log(f"Error fetching data from {self.booru_name}: {e}") - raise BooruError(f"HTTP Error fetching from {self.booru_name}: {e}") from e - except Exception as e: - _log(f"Error processing response from {self.booru_name}: {e}") - raise BooruError(f"Error processing response from {self.booru_name}: {e}") from e - - def _is_direct_image_url(self, url): - """Check if URL is a direct image URL (not from external sites like Pixiv/Twitter)""" - if not url or not isinstance(url, str): - return False - - # Skip external sites that don't provide direct image access - external_sites = [ - 'pixiv.net', 'pximg.net', 'twitter.com', 'x.com', 't.co', - 'deviantart.com', 'artstation.com', 'instagram.com', - 'facebook.com', 'patreon.com', 'fanbox.cc' - ] - - url_lower = url.lower() - for site in external_sites: - if site in url_lower: - return False - - # Check if URL ends with common image extensions - image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'] - if any(url_lower.endswith(ext) for ext in image_extensions): - return True - - # Check if URL contains image-serving patterns - if any(pattern in url_lower for pattern in ['/images/', '/img/', '/media/', '/files/']): - return True - - return False - - def _standardize_post(self, post_data): - post = {} - # extract tags in a robust way; some APIs return categorized tags as dicts - raw_tags = post_data.get('tags', post_data.get('tag_string', '')) - # store categorized lists when possible - artist_tags = [] - character_tags = [] - copyright_tags = [] - if isinstance(post_data.get('tags'), dict): - tags_dict = post_data.get('tags') - # e621 style: tags dict with sublevels - if isinstance(tags_dict.get('artist'), list): - artist_tags = tags_dict.get('artist', []) - if isinstance(tags_dict.get('character'), list): - character_tags = tags_dict.get('character', []) - if isinstance(tags_dict.get('copyright'), list): - copyright_tags = tags_dict.get('copyright', []) - if 'tag_string_artist' in post_data: - parsed = _split_tag_string_override(post_data.get('tag_string_artist')) - if parsed is not None: - artist_tags = parsed - if 'tag_string_character' in post_data: - parsed = _split_tag_string_override(post_data.get('tag_string_character')) - if parsed is not None: - character_tags = parsed - if 'tag_string_copyright' in post_data: - parsed = _split_tag_string_override(post_data.get('tag_string_copyright')) - if parsed is not None: - copyright_tags = parsed - - # For boorus that don't provide categorized tags, try to extract character tags from the main tag string - # This handles cases like Gelbooru/Danbooru where character tags are mixed with other tags - if not character_tags and isinstance(raw_tags, str): - all_tags = _split_tag_string(raw_tags) - for tag in all_tags: - # Common patterns for character tags: contains parentheses (series name) or ends with specific patterns - if ('(' in tag and ')' in tag) or tag.endswith(r'_\(series\)') or tag.endswith(r'_\(character\)'): - character_tags.append(tag) - # Also catch some common character name patterns (this is heuristic but should catch most) - elif any(series in tag.lower() for series in ['genshin_impact', 'touhou', 'fate_', 'azur_lane', 'kantai_collection', 'pokemon']): - character_tags.append(tag) - - post['tags'] = raw_tags - post['artist_tags'] = artist_tags - post['character_tags'] = character_tags - post['copyright_tags'] = copyright_tags - post['score'] = post_data.get('score', 0) - post['file_url'] = post_data.get('file_url') - if post['file_url'] is None: - post['file_url'] = post_data.get('large_file_url') - if post['file_url'] is None: - # Check if source is a direct image URL before using it - source_url = post_data.get('source') - if source_url and self._is_direct_image_url(source_url): - post['file_url'] = source_url - else: - post['file_url'] = None - post['id'] = post_data.get('id') - post['rating'] = post_data.get('rating') - post['booru_name'] = self.booru_name - return post - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - raise NotImplementedError - - -class Gelbooru(Booru): - def __init__(self, fringe_benefits, credentials: Optional[Dict[str, str]] = None): - super().__init__('Gelbooru', f'https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&limit={POST_AMOUNT}') - self.fringeBenefits = fringe_benefits - credentials = credentials or {} - self.api_key = _sanitize_gelbooru_credential(credentials.get('api_key')) if isinstance(credentials, dict) else "" - self.user_id = _sanitize_gelbooru_credential(credentials.get('user_id')) if isinstance(credentials, dict) else "" - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if not self.api_key or not self.user_id: - raise BooruError("Gelbooru requires an API key and user ID. Set them under RanbooruX ? Gelbooru settings.") - credentials_query = f"&api_key={quote_plus(self.api_key)}&user_id={quote_plus(self.user_id)}" - if post_id: - query_url = f"{self.base_api_url}{credentials_query}&id={post_id}{tags_query}" - fetched_data = self._fetch_data(query_url) - if fetched_data and 'post' in fetched_data and isinstance(fetched_data['post'], list): - all_fetched_posts = fetched_data['post'] - COUNT = len(all_fetched_posts) - print(f"[R] Found {COUNT} post(s) for ID: {post_id}") - else: - page = random.randint(0, max_pages - 1) - query_url = f"{self.base_api_url}{credentials_query}&pid={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if fetched_data and 'post' in fetched_data and isinstance(fetched_data['post'], list): - all_fetched_posts = fetched_data['post'] - if fetched_data and '@attributes' in fetched_data and 'count' in fetched_data['@attributes']: - try: - COUNT = int(fetched_data['@attributes']['count']) - except Exception: - COUNT = len(all_fetched_posts) - else: - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {len(all_fetched_posts)} posts from page {page}. Reported total (approx): {COUNT}") - return [self._standardize_post(post) for post in all_fetched_posts] - - -class GelbooruCompatible(Booru): - RETRIABLE_STATUS = {429, 500, 502, 503, 504} - - def __init__(self, base_url: str, retries: int = 3, backoff: float = 1.5, log_diagnostics: bool = True): - sanitized = _sanitize_gelbooru_compat_base_url(base_url) - if not sanitized: - raise ValueError("Invalid Gelbooru-compatible base URL.") - self.base_url = sanitized - self.retries = max(1, retries) - self.backoff = max(0.5, backoff) - self.log_diagnostics = log_diagnostics - self._post_endpoint = f"{self.base_url}/index.php?page=dapi&s=post&q=index" - self._tag_endpoint = f"{self.base_url}/index.php?page=dapi&s=tag&q=index" - self._alias_endpoint = f"{self.base_url}/index.php?page=dapi&s=tag_alias&q=index" - super().__init__('Gelbooru-Compatible', self._post_endpoint) - - def _perform_request(self, url: str) -> requests.Response: - last_error: Optional[Exception] = None - for attempt in range(1, self.retries + 1): - try: - response = requests.get(url, headers=self.headers, timeout=30) - except requests.exceptions.RequestException as exc: - last_error = exc - self._log_retry(url, attempt, f"Request error: {exc}") - else: - if response.status_code in self.RETRIABLE_STATUS: - last_error = BooruError(f"Status {response.status_code}") - self._log_retry(url, attempt, f"Status {response.status_code}") - else: - return response - time.sleep(min(self.backoff * attempt, 5.0)) - raise BooruError(f"HTTP Error fetching from {self.booru_name}: {last_error}") - - def _log_retry(self, url: str, attempt: int, message: str) -> None: - _log(f"{self.booru_name}: retry {attempt} for {url} - {message}") - - def _log_snippet(self, response: requests.Response) -> None: - if not self.log_diagnostics: - return - snippet = response.text.strip().replace('\n', ' ')[:200] - _log(f"{self.booru_name}: {response.url} -> {snippet}") - - def _parse_json_entities(self, payload, entity_key: str) -> Tuple[List[dict], Optional[int]]: - entries: List[dict] = [] - approx = None - if isinstance(payload, dict): - possible = payload.get(entity_key) - if isinstance(possible, list): - entries = possible - elif isinstance(possible, dict): - entries = [possible] - attrs = payload.get('@attributes') - if isinstance(attrs, dict) and 'count' in attrs: - try: - approx = int(attrs['count']) - except (TypeError, ValueError): - approx = None - elif isinstance(payload, list): - entries = payload - return entries, approx - - def _parse_xml_entities(self, text_payload: str, entity_key: str) -> Tuple[List[dict], Optional[int]]: - probe = (text_payload or '').lower() - if (' Tuple[List[dict], int]: - json_url = f"{url_base}&json=1" - try: - response = self._perform_request(json_url) - self._log_snippet(response) - ct = (response.headers.get('content-type') or '').lower() - text_head = (response.text or '').lstrip()[:64].lower() - if 'html' in ct or text_head.startswith(' 0 else 0 - query_base = f"{self._post_endpoint}&limit={POST_AMOUNT}&pid={page}{tags_query}" - posts, approx = self._request_dapi(query_base, 'post') - COUNT = approx - print(f"[R] Gelbooru-compatible: fetched {len(posts)} posts from page {page}. Reported count={approx}") - standardized = [] - for post in posts: - normalized = self._standardize_post(post) - normalized['source_base_url'] = self.base_url - standardized.append(normalized) - return standardized - - def get_tags(self, name_pattern: Optional[str] = None, limit: int = 100) -> List[dict]: - query = f"{self._tag_endpoint}&limit={limit}" - if name_pattern: - query += f"&name_pattern={quote_plus(name_pattern)}" - tags, _ = self._request_dapi(query, 'tag') - return tags - - def get_tag_aliases(self, name_pattern: Optional[str] = None, limit: int = 100) -> List[dict]: - query = f"{self._alias_endpoint}&limit={limit}" - if name_pattern: - query += f"&name_pattern={quote_plus(name_pattern)}" - aliases, _ = self._request_dapi(query, 'tag_alias') - return aliases - - -class Danbooru(Booru): - def __init__(self): - super().__init__('Danbooru', f'https://danbooru.donmai.us/posts.json?limit={POST_AMOUNT}') - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if post_id: - query_url = f"https://danbooru.donmai.us/posts/{post_id}.json" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, dict) and 'id' in fetched_data: - all_fetched_posts = [fetched_data] - COUNT = len(all_fetched_posts) - print(f"[R] Found {COUNT} post(s) for ID: {post_id}") - else: - page = random.randint(1, max_pages) - query_url = f"{self.base_api_url}&page={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, list): - all_fetched_posts = fetched_data - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {COUNT} posts from page {page}.") - return [self._standardize_post(post) for post in all_fetched_posts if post] - - -class XBooru(Booru): - def __init__(self): - super().__init__('XBooru', f'https://xbooru.com/index.php?page=dapi&s=post&q=index&json=1&limit={POST_AMOUNT}') - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if post_id: - query_url = f"{self.base_api_url}&id={post_id}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, dict) and 'id' in fetched_data: - all_fetched_posts = [fetched_data] - else: - page = random.randint(0, max_pages - 1) - query_url = f"{self.base_api_url}&pid={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, list): - all_fetched_posts = fetched_data - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {COUNT} posts from XBooru.") - standardized_posts = [] - for post_data in all_fetched_posts: - post = self._standardize_post(post_data) - if 'directory' in post_data and 'image' in post_data: - post['file_url'] = f"https://xbooru.com/images/{post_data['directory']}/{post_data['image']}" - standardized_posts.append(post) - return standardized_posts - - -class Rule34(Booru): - def __init__(self): - super().__init__('Rule34', f'https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&json=1&limit={POST_AMOUNT}') - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if post_id: - query_url = f"{self.base_api_url}&id={post_id}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, dict) and 'id' in fetched_data: - all_fetched_posts = [fetched_data] - else: - page = random.randint(0, max_pages - 1) - query_url = f"{self.base_api_url}&pid={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, list): - all_fetched_posts = fetched_data - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {COUNT} posts from Rule34.") - return [self._standardize_post(post) for post in all_fetched_posts] - - -class Safebooru(Booru): - def __init__(self): - super().__init__('Safebooru', f'https://safebooru.org/index.php?page=dapi&s=post&q=index&json=1&limit={POST_AMOUNT}') - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if post_id: - query_url = f"{self.base_api_url}&id={post_id}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, dict) and 'id' in fetched_data: - all_fetched_posts = [fetched_data] - else: - page = random.randint(0, max_pages - 1) - query_url = f"{self.base_api_url}&pid={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, list): - all_fetched_posts = fetched_data - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {COUNT} posts from Safebooru.") - standardized_posts = [] - for post_data in all_fetched_posts: - post = self._standardize_post(post_data) - if 'directory' in post_data and 'image' in post_data: - post['file_url'] = f"https://safebooru.org/images/{post_data['directory']}/{post_data['image']}" - standardized_posts.append(post) - return standardized_posts - - -class Konachan(Booru): - def __init__(self): - super().__init__('Konachan', f'https://konachan.com/post.json?limit={POST_AMOUNT}') - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if post_id: - print("[R] Warn: Konachan does not support post IDs.") - return [] - page = random.randint(1, max_pages) - query_url = f"{self.base_api_url}&page={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, list): - all_fetched_posts = fetched_data - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {COUNT} posts from Konachan.") - return [self._standardize_post(post) for post in all_fetched_posts] - - -class Yandere(Booru): - def __init__(self): - super().__init__('Yandere', f'https://yande.re/post.json?limit={POST_AMOUNT}') - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if post_id: - print("[R] Warn: Yandere does not support post IDs.") - return [] - page = random.randint(1, max_pages) - query_url = f"{self.base_api_url}&page={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, list): - all_fetched_posts = fetched_data - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {COUNT} posts from Yandere.") - return [self._standardize_post(post) for post in all_fetched_posts] - - -class AIBooru(Booru): - def __init__(self): - super().__init__('AIBooru', f'https://aibooru.online/posts.json?limit={POST_AMOUNT}') - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if post_id: - print("[R] Warn: AIBooru does not support post IDs.") - return [] - page = random.randint(1, max_pages) - query_url = f"{self.base_api_url}?limit={POST_AMOUNT}&page={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, list): - all_fetched_posts = fetched_data - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {COUNT} posts from AIBooru.") - standardized_posts = [] - for post_data in all_fetched_posts: - post = self._standardize_post(post_data) - post['tags'] = post_data.get('tag_string', '') - standardized_posts.append(post) - return standardized_posts - - -class e621(Booru): - def __init__(self): - super().__init__('e621', f'https://e621.net/posts.json?limit={POST_AMOUNT}') - - def get_posts(self, tags_query="", max_pages=10, post_id=None): - global COUNT - COUNT = 0 - all_fetched_posts = [] - if post_id: - print("[R] Warn: e621 does not support post IDs.") - return [] - page = random.randint(1, max_pages) - query_url = f"{self.base_api_url}?page={page}{tags_query}" - fetched_data = self._fetch_data(query_url) - if isinstance(fetched_data, dict) and 'posts' in fetched_data and isinstance(fetched_data['posts'], list): - all_fetched_posts = fetched_data['posts'] - COUNT = len(all_fetched_posts) - print(f"[R] Fetched {COUNT} posts from e621.") - standardized_posts = [] - for post_data in all_fetched_posts: - post = self._standardize_post(post_data) - temp_tags = [] - sublevels = ['general', 'artist', 'copyright', 'character', 'species'] - if 'tags' in post_data: - for sublevel in sublevels: - if sublevel in post_data['tags'] and isinstance(post_data['tags'][sublevel], list): - temp_tags.extend(post_data['tags'][sublevel]) - post['tags'] = ' '.join(temp_tags) - if 'score' in post_data and isinstance(post_data['score'], dict) and 'total' in post_data['score']: - post['score'] = post_data['score']['total'] - standardized_posts.append(post) - return standardized_posts @@ -957,7 +355,7 @@ def enabled(self) -> bool: return False def resolve_alias(self, tag: str) -> str: - return tag if isinstance(tag, str) else '' + return tag if isinstance(tag, str) else "" def category(self, tag: str) -> Optional[int]: return None @@ -987,23 +385,70 @@ class NoopCatalog(TagCatalogProvider): class CsvCatalog(TagCatalogProvider): _TEXTUAL_SEED: Set[str] = { - 'text', 'english_text', 'japanese_text', 'chinese_text', 'korean_text', 'translated', 'translation', - 'commentary', 'artist_commentary', 'author_commentary', 'publisher_commentary', 'speech_bubble', - 'speech_bubbles', 'dialogue', 'dialog', 'subtitle', 'subtitles', 'caption', 'captions', 'watermark', - 'logo', 'signature', 'url', 'filename', 'thought_bubble', 'thought_balloon', 'notice' + "text", + "english_text", + "japanese_text", + "chinese_text", + "korean_text", + "translated", + "translation", + "commentary", + "artist_commentary", + "author_commentary", + "publisher_commentary", + "speech_bubble", + "speech_bubbles", + "dialogue", + "dialog", + "subtitle", + "subtitles", + "caption", + "captions", + "watermark", + "logo", + "signature", + "url", + "filename", + "thought_bubble", + "thought_balloon", + "notice", } _TEXTUAL_KEYWORDS: Tuple[str, ...] = ( - 'text', 'commentary', 'speech_bubble', 'thought_bubble', 'watermark', 'logo', 'subtitle', 'caption', - 'dialog', 'dialogue', 'filename', 'url', 'signature', 'credit' + "text", + "commentary", + "speech_bubble", + "thought_bubble", + "watermark", + "logo", + "subtitle", + "caption", + "dialog", + "dialogue", + "filename", + "url", + "signature", + "credit", ) _TEXTUAL_PREFIXES: Tuple[str, ...] = ( - 'translated_', 'translation_', 'english_', 'japanese_', 'korean_', 'chinese_' + "translated_", + "translation_", + "english_", + "japanese_", + "korean_", + "chinese_", ) _TEXTUAL_SUFFIXES: Tuple[str, ...] = ( - '_text', '_commentary', '_logo', '_watermark', '_subtitle', '_caption', '_speech', '_bubble' + "_text", + "_commentary", + "_logo", + "_watermark", + "_subtitle", + "_caption", + "_speech", + "_bubble", ) - _HAIR_SUFFIXES: Tuple[str, ...] = ('_hair',) - _EYE_SUFFIXES: Tuple[str, ...] = ('_eyes', '_eye') + _HAIR_SUFFIXES: Tuple[str, ...] = ("_hair",) + _EYE_SUFFIXES: Tuple[str, ...] = ("_eyes", "_eye") def __init__(self, path: str): self._path = path @@ -1020,12 +465,12 @@ def __init__(self, path: str): @staticmethod def _normalize_name(value: str) -> str: if not isinstance(value, str): - return '' - cleaned = unicodedata.normalize('NFKC', value).strip().lower() + return "" + cleaned = unicodedata.normalize("NFKC", value).strip().lower() if not cleaned: - return '' - cleaned = cleaned.replace('-', '_').replace(' ', '_') - cleaned = re.sub(r'_+', '_', cleaned) + return "" + cleaned = cleaned.replace("-", "_").replace(" ", "_") + cleaned = re.sub(r"_+", "_", cleaned) return cleaned def _looks_textual(self, tag: str) -> bool: @@ -1044,35 +489,35 @@ def _looks_textual(self, tag: str) -> bool: def _process_row(self, row: List[str], columns: Optional[Dict[str, int]] = None) -> None: def _safe_get(idx: Optional[int]) -> str: if idx is None: - return '' + return "" if idx < 0 or idx >= len(row): - return '' + return "" return row[idx] if columns: - raw_name = _safe_get(columns.get('tag')) + raw_name = _safe_get(columns.get("tag")) if not raw_name: - raw_name = _safe_get(columns.get('name')) - cat_val = _safe_get(columns.get('category')) - count_val = _safe_get(columns.get('count')) - alias_field = _safe_get(columns.get('alias')) + raw_name = _safe_get(columns.get("name")) + cat_val = _safe_get(columns.get("category")) + count_val = _safe_get(columns.get("count")) + alias_field = _safe_get(columns.get("alias")) if not alias_field: - alias_field = _safe_get(columns.get('aliases')) + alias_field = _safe_get(columns.get("aliases")) else: - raw_name = row[0] if len(row) > 0 else '' - cat_val = row[1] if len(row) > 1 else '' - count_val = row[2] if len(row) > 2 else '' - alias_field = row[3] if len(row) > 3 else '' + raw_name = row[0] if len(row) > 0 else "" + cat_val = row[1] if len(row) > 1 else "" + count_val = row[2] if len(row) > 2 else "" + alias_field = row[3] if len(row) > 3 else "" name = self._normalize_name(raw_name) if not name: return try: - category = int(cat_val) if cat_val is not None and str(cat_val).strip() != '' else 0 + category = int(cat_val) if cat_val is not None and str(cat_val).strip() != "" else 0 except Exception: category = 0 try: - count = int(count_val) if count_val is not None and str(count_val).strip() != '' else 0 + count = int(count_val) if count_val is not None and str(count_val).strip() != "" else 0 except Exception: count = 0 self._cats[name] = category @@ -1086,7 +531,7 @@ def _safe_get(idx: Optional[int]) -> str: if self._looks_textual(name): self._textual.add(name) if alias_field: - for alias_candidate in re.split(r'[\s,]+', alias_field): + for alias_candidate in re.split(r"[\s,]+", alias_field): alias_name = self._normalize_name(alias_candidate) if not alias_name or alias_name == name: continue @@ -1103,14 +548,14 @@ def _load(self) -> None: self._hair.clear() self._eyes.clear() self._all_tags.clear() - with open(self._path, newline='', encoding='utf-8') as handle: + with open(self._path, newline="", encoding="utf-8") as handle: reader = csv.reader(handle) first_row = next(reader, None) if first_row is None: raise ValueError("CSV file is empty") header_map: Optional[Dict[str, int]] = None - lowered = [str(cell or '').strip().lower() for cell in first_row] - if len(lowered) >= 3 and lowered[0] in ('tag', 'name') and lowered[1] == 'category': + lowered = [str(cell or "").strip().lower() for cell in first_row] + if len(lowered) >= 3 and lowered[0] in ("tag", "name") and lowered[1] == "category": header_map = {name: idx for idx, name in enumerate(lowered)} else: self._process_row(first_row, None) @@ -1137,7 +582,7 @@ def enabled(self) -> bool: def resolve_alias(self, tag: str) -> str: normalized = self._normalize_name(tag) if not normalized: - return '' + return "" return self._aliases.get(normalized, normalized) def category(self, tag: str) -> Optional[int]: @@ -1176,11 +621,14 @@ def suggestions(self, tag: str, limit: int = 3) -> List[str]: matches.sort(key=lambda name: (-self._counts.get(name, 0), name)) return matches[:limit] + class Script(scripts.Script): def __init__(self): super().__init__() - self._gelbooru_saved_credentials: Optional[Dict[str, str]] = _load_gelbooru_credentials_from_disk() - self._gelbooru_compat_base_url: str = '' + self._gelbooru_saved_credentials: Optional[Dict[str, str]] = ( + _load_gelbooru_credentials_from_disk() + ) + self._gelbooru_compat_base_url: str = "" self._gelbooru_effective_credentials: Optional[Dict[str, str]] = None self._personal_remove_tags: Set[str] = set() self._favorite_tags: Set[str] = set() @@ -1204,8 +652,10 @@ def __init__(self): except Exception: self._synonym_groups = tuple() self._synonym_lookup = {} - self._legacy_bad_exact: Set[str] = set() - self._legacy_bad_wildcard: Dict[str, str] = {} + self._adetailer_state = rb_adetailer_runtime.AdetailerRunState() + self._adetailer_patches = rb_adetailer_runtime.PatchRegistry() + self._host_scope = rb_mutation_scope.HostMutationScope() + self._adetailer_orch = rb_adetailer_orch.AdetailerOrchestrator(self) self._strict_img2img_fetch: bool = True self._strict_img2img_active: bool = False self._strict_img2img_relaxed: bool = False @@ -1213,162 +663,97 @@ def __init__(self): self._strict_allowed_subjects: Set[str] = set() self._strict_initial_additions: str = "" self._use_tag_catalog: bool = True - self._catalog_source: str = 'bundled' - self._tag_catalog_path: str = '' - self._custom_catalog_path: str = '' + self._catalog_source: str = "bundled" + self._tag_catalog_path: str = "" + self._custom_catalog_path: str = "" self._catalog: TagCatalogProvider = NoopCatalog() self._tag_catalog_diag: Dict[str, object] = {} self._catalog_status_md = None self._tag_diag_md = None - self._tag_catalog_status_text: str = 'Catalog mode: OFF' + self._tag_catalog_status_text: str = "Catalog mode: ON - Bundled default" self._tag_catalog_linter_limit: int = 3 self._catalog_subject_anchors = None - self._use_legacy_filter_engine: bool = False self._loranado_scan_cache: Dict[str, Dict[str, object]] = {} + self._http_client = rb_http_client.BooruSession(use_cache=False) self._load_tag_catalog_preferences() sorting_priority = 1 # Highest priority to run before ALL other extensions - previous_loras = '' + previous_loras = "" last_img = [] real_steps = 0 version = "1.8-Refactored" - original_prompt = '' + original_prompt = "" run_img2img_pass = False img2img_denoising = 0.75 cache_installed_by_us = False _adetailer_support_enabled = False _post_adetailer_enabled = False _manual_adetailer_prev_enabled = False - _DASH_UNDERSCORE_RE = re.compile(r'[_\-]+') - _WHITESPACE_RE = re.compile(r'\s+') + _DASH_UNDERSCORE_RE = re.compile(r"[_\-]+") + _WHITESPACE_RE = re.compile(r"\s+") _LORANADO_MAX_HEADER_BYTES = 4 * 1024 * 1024 _USER_LIST_PATHS = { - 'personal': PERSONAL_REMOVE_FILE, - 'favorites': FAVORITES_FILE, - } - _CLOTHING_KEYWORDS = { - 'dress', 'shirt', 'skirt', 'skorts', 'pants', 'jeans', 'shorts', 'jacket', 'coat', 'sweater', 'hoodie', - 'kimono', 'robe', 'uniform', 'school uniform', 'sailor uniform', 'bikini', 'swimsuit', 'lingerie', 'underwear', - 'panties', 'bra', 'corset', 'thighhighs', 'stockings', 'socks', 'gloves', 'mittens', 'scarf', 'cape', 'apron', - 'armor', 'bustier', 'bodysuit', 'leotard', 'gown', 'tuxedo', 'suit', 'vest', 'necktie', 'bowtie', 'hat', 'cap', - 'headband', 'hairband', 'headdress', 'veil', 'crown', 'helmet', 'sandals', 'boots', 'shoes', 'heels', 'sneakers', - 'flip flops', 'garter', 'garter belt', 'pantyhose', 'stocking', 'cloak', 'cardigan', 'sleeves', 'armband', - 'choker', 'ribbon', 'bow', 'shawl', 'loincloth', 'loin cloth', 'tabard', 'capelet', 'poncho', 'overalls', 'tank top', - 't-shirt', 'tee shirt', 'pajamas', 'nightgown' + "personal": PERSONAL_REMOVE_FILE, + "favorites": FAVORITES_FILE, } - _TEXTUAL_TAGS = { - 'text', 'english text', 'japanese text', 'chinese text', 'korean text', 'translated', 'translation', 'commentary', - 'artist commentary', 'author commentary', 'publisher commentary', 'copyright text', 'speech bubble', 'speech bubbles', - 'dialogue', 'dialog', 'sound effect', 'sound effects', 'comic text', 'comic panel', 'subtitle', 'subtitles', 'caption', - 'captions', 'floating text', 'text focus', 'text overlay', 'text background', 'watermark', 'watermark text', 'signature', - 'sign', 'tagme', 'written text', 'scribble', 'handwritten text', 'handwriting', 'text box', 'thought bubble', 'thought balloon', - 'logo', 'logo text', 'notice', 'speech bubble text' - } - _SUBJECT_TAGS = { - 'solo', 'duo', 'trio', 'quartet', 'group', 'gang', 'crowd', 'couple', 'threesome', 'foursome', 'orgy', - '1girl', '2girls', '3girls', '4girls', '1boy', '2boys', '3boys', '4boys', '1other', '2others', '3others', '4others', - 'multiple girls', 'multiple boys', 'multiple people', 'multiple others', 'solo focus', 'female focus', 'male focus', - 'mixed group', '1female', '1male', '2females', '2males', '3females', '3males', '1person', '2people', '3people', '4people' - } - _FURRY_CORE_NORMALIZED = {tag.replace('_', ' ') for tag in FURRY_CORE_TAGS} - _POKEMON_PREFIXES_NORMALIZED = tuple(prefix.replace('_', ' ') for prefix in POKEMON_PREFIXES) - _ANIMAL_EAR_KEYWORDS_NORMALIZED = tuple(keyword.replace('_', ' ') for keyword in ANIMAL_EAR_KEYWORDS) - _HORN_KEYWORDS_NORMALIZED = tuple(keyword.replace('_', ' ') for keyword in HORN_KEYWORDS) - _HEADWEAR_TAGS_NORMALIZED = {tag.replace('_', ' ') for tag in HEADWEAR_TAGS} - _HALO_TAGS_NORMALIZED = {tag.replace('_', ' ') for tag in HALO_TAGS} - _HAIR_COLOR_TAGS_NORMALIZED = {tag.replace('_', ' ') for tag in HAIR_COLOR_TAGS} - _EYE_COLOR_TAGS_NORMALIZED = {tag.replace('_', ' ') for tag in EYE_COLOR_TAGS} - _SERIES_KEYWORDS_NORMALIZED = {tag.replace('_', ' ') for tag in SERIES_KEYWORDS} - _SERIES_SUFFIXES_NORMALIZED = tuple(suffix.replace('_', ' ') for suffix in SERIES_SUFFIXES) + @staticmethod def _canonicalize_raw_tag(tag: str) -> str: - if not isinstance(tag, str): - return "" - lowered = (tag or '').strip().lower().replace('_', ' ') - return re.sub(r'\s+', ' ', lowered) if lowered else "" + return rb_tag_pipeline.canonicalize_raw_tag(tag) @staticmethod def _normalize_tag(tag: str) -> str: - if not isinstance(tag, str): - return "" - normalized = unicodedata.normalize("NFKC", tag).casefold() - normalized = Script._DASH_UNDERSCORE_RE.sub(' ', normalized) - normalized = Script._WHITESPACE_RE.sub(' ', normalized).strip() - if not normalized: - return "" - wrapper_pairs = {('(', ')'), ('[', ']'), ('{', '}')} - while len(normalized) > 2 and (normalized[0], normalized[-1]) in wrapper_pairs: - normalized = normalized[1:-1].strip() - return normalized + return rb_tag_pipeline.normalize_tag(tag) def _ensure_user_file(self, path: str) -> None: try: - rb_io_lists.ensure_user_file(path) + rb_user_store.ensure_text_file(path) except Exception as exc: print(f"[R Files] Failed to ensure file {path}: {exc}") def _read_list_file(self, path: str) -> List[str]: try: - return rb_io_lists.read_list_file(path, normalize_tag=self._normalize_tag) + return rb_user_store.read_list_file(path, normalize_fn=self._normalize_tag) except Exception as exc: print(f"[R Files] Failed to read list file {path}: {exc}") return [] def _write_list_file(self, path: str, tags: Iterable[str]) -> None: try: - rb_io_lists.write_list_file(path, tags, normalize_tag=self._normalize_tag) + rb_user_store.write_list_file(path, tags, normalize_fn=self._normalize_tag) except Exception as exc: print(f"[R Files] Failed to write list file {path}: {exc}") def _load_tag_catalog_preferences(self) -> None: - """Load persisted catalog settings (supports v1 path migration).""" + """Load persisted catalog settings.""" self._use_tag_catalog = True - self._catalog_source = 'bundled' - self._custom_catalog_path = '' - self._tag_catalog_path = '' + self._catalog_source = "bundled" + self._custom_catalog_path = "" + self._tag_catalog_path = "" try: - if not os.path.isfile(TAG_CATALOG_CONFIG_FILE): - self._tag_catalog_status_text = self._format_catalog_status() - return - with open(TAG_CATALOG_CONFIG_FILE, 'r', encoding='utf-8') as handle: - data = json.load(handle) - if not isinstance(data, dict): - self._tag_catalog_status_text = self._format_catalog_status() - return - # v1 -> v2 migration - if 'source' not in data: - legacy_path = data.get('path', '') - legacy_clean = legacy_path.strip() if isinstance(legacy_path, str) else '' - if legacy_clean and os.path.isfile(legacy_clean): - data['source'] = 'custom' - data['custom_path'] = legacy_clean - else: - data['source'] = 'bundled' - data['custom_path'] = '' - self._use_tag_catalog = bool(data.get('enabled', True)) - source = str(data.get('source', 'bundled')).strip().lower() - self._catalog_source = source if source in ('bundled', 'custom') else 'bundled' - custom_path = data.get('custom_path', '') - self._custom_catalog_path = custom_path.strip() if isinstance(custom_path, str) else '' + data = rb_user_store.load_catalog_preferences(TAG_CATALOG_CONFIG_FILE) + self._use_tag_catalog = bool(data.get("enabled", True)) + source = str(data.get("source", "bundled")).strip().lower() + self._catalog_source = source if source in ("bundled", "custom") else "bundled" + custom_path = data.get("custom_path", "") + self._custom_catalog_path = custom_path.strip() if isinstance(custom_path, str) else "" self._tag_catalog_path = self._custom_catalog_path except Exception as exc: print(f"[Ranbooru] Warn: Failed to load tag catalog preferences: {exc}") self._use_tag_catalog = True - self._catalog_source = 'bundled' - self._custom_catalog_path = '' - self._tag_catalog_path = '' + self._catalog_source = "bundled" + self._custom_catalog_path = "" + self._tag_catalog_path = "" self._tag_catalog_status_text = self._format_catalog_status() def _save_tag_catalog_preferences(self) -> None: - data = { - 'enabled': bool(self._use_tag_catalog), - 'source': self._catalog_source, - 'custom_path': self._custom_catalog_path, - } try: - os.makedirs(os.path.dirname(TAG_CATALOG_CONFIG_FILE), exist_ok=True) - with open(TAG_CATALOG_CONFIG_FILE, 'w', encoding='utf-8') as handle: - json.dump(data, handle, ensure_ascii=False, indent=2) + rb_user_store.save_catalog_preferences( + TAG_CATALOG_CONFIG_FILE, + enabled=bool(self._use_tag_catalog), + source=self._catalog_source, + custom_path=self._custom_catalog_path, + ) except Exception as exc: print(f"[Ranbooru] Warn: Failed to save tag catalog preferences: {exc}") @@ -1384,22 +769,22 @@ def _update_catalog_status(self, message: Optional[str] = None) -> None: pass def _resolve_catalog_path(self) -> str: - if self._catalog_source == 'bundled': - legacy_override = (self._tag_catalog_path or '').strip() - if legacy_override and os.path.isfile(legacy_override): - return legacy_override + if self._catalog_source == "bundled": + bundled_override = (self._tag_catalog_path or "").strip() + if bundled_override and os.path.isfile(bundled_override): + return bundled_override return BUNDLED_CATALOG_PATH - if self._catalog_source == 'custom': - return (self._custom_catalog_path or '').strip() - return '' + if self._catalog_source == "custom": + return (self._custom_catalog_path or "").strip() + return "" def _set_catalog_source(self, source: str) -> str: - source_value = (source or '').strip().lower() - if source_value not in ('bundled', 'custom'): - source_value = 'bundled' + source_value = (source or "").strip().lower() + if source_value not in ("bundled", "custom"): + source_value = "bundled" self._catalog_source = source_value - if source_value == 'bundled': - self._tag_catalog_path = '' + if source_value == "bundled": + self._tag_catalog_path = "" else: self._tag_catalog_path = self._custom_catalog_path self._save_tag_catalog_preferences() @@ -1409,27 +794,27 @@ def _catalog_path_from_upload(self, uploaded: object) -> str: if isinstance(uploaded, str): return uploaded if isinstance(uploaded, dict): - for key in ('name', 'path', 'orig_name'): + for key in ("name", "path", "orig_name"): value = uploaded.get(key) if isinstance(value, str) and value.strip(): return value.strip() - return '' + return "" def _validate_csv_format(self, path: str) -> Tuple[bool, str]: return rb_catalog.validate_catalog_csv(path) - def _import_custom_catalog(self, uploaded: object, path_hint: str = '') -> Tuple[bool, str]: - source_path = (path_hint or '').strip() or self._catalog_path_from_upload(uploaded) + def _import_custom_catalog(self, uploaded: object, path_hint: str = "") -> Tuple[bool, str]: + source_path = (path_hint or "").strip() or self._catalog_path_from_upload(uploaded) ok, validation_message = self._validate_csv_format(source_path) if not ok: return False, f"Invalid CSV: {validation_message}" try: os.makedirs(USER_CATALOGS_DIR, exist_ok=True) source_name = os.path.basename(source_path) or "catalog.csv" - safe_name = re.sub(r'[^\w\-.]', '_', source_name) + safe_name = re.sub(r"[^\w\-.]", "_", source_name) destination = os.path.join(USER_CATALOGS_DIR, safe_name) shutil.copy2(source_path, destination) - self._catalog_source = 'custom' + self._catalog_source = "custom" self._custom_catalog_path = destination self._tag_catalog_path = destination self._use_tag_catalog = True @@ -1443,47 +828,55 @@ def _import_custom_catalog(self, uploaded: object, path_hint: str = '') -> Tuple def _format_catalog_status(self) -> str: if not self._use_tag_catalog: - return "Catalog mode: OFF" - catalog = getattr(self, '_catalog', None) + return "Catalog mode: ON - Bundled default" + catalog = getattr(self, "_catalog", None) if not isinstance(catalog, CsvCatalog): selected = self._resolve_catalog_path() if not selected: return "Catalog mode: ON - No catalog selected" - source_label = "Bundled" if self._catalog_source == 'bundled' else "Custom" + source_label = "Bundled" if self._catalog_source == "bundled" else "Custom" return f"Catalog mode: ON - {source_label}: {os.path.basename(selected)} (not loaded)" - source_label = "Bundled" if self._catalog_source == 'bundled' else "Custom" + source_label = "Bundled" if self._catalog_source == "bundled" else "Custom" filename = os.path.basename(catalog._path) tag_count = len(getattr(catalog, "_all_tags", set())) alias_count = len(getattr(catalog, "_aliases", {})) return f"Catalog mode: ON - {source_label}: {filename}\nTags: {tag_count:,} | Aliases: {alias_count:,}" def _active_catalog(self) -> Optional[TagCatalogProvider]: - if not getattr(self, '_use_tag_catalog', False): - return None + if not self._use_tag_catalog and self._catalog_source != "bundled": + self._set_catalog_source("bundled") if not self._resolve_catalog_path(): return None - catalog = getattr(self, '_catalog', None) + catalog = getattr(self, "_catalog", None) if not isinstance(catalog, TagCatalogProvider) or not catalog.enabled(): ok, msg = self._load_tag_catalog() self._update_catalog_status(msg) - catalog = getattr(self, '_catalog', None) + catalog = getattr(self, "_catalog", None) if not ok: - return None + if self._catalog_source == "custom": + self._set_catalog_source("bundled") + ok, msg = self._load_tag_catalog() + self._update_catalog_status(msg) + catalog = getattr(self, "_catalog", None) + if not ok: + return None + else: + return None try: - if hasattr(catalog, 'maybe_reload'): + if hasattr(catalog, "maybe_reload"): catalog.maybe_reload() # type: ignore[call-arg] except Exception: pass return catalog if isinstance(catalog, TagCatalogProvider) and catalog.enabled() else None def _load_tag_catalog(self) -> Tuple[bool, str]: - if not getattr(self, '_use_tag_catalog', False): - self._catalog = NoopCatalog() - return True, "Catalog mode: OFF" path_value = self._resolve_catalog_path() if not path_value: - self._catalog = NoopCatalog() - return False, "Catalog mode: ON - No path set" + self._set_catalog_source("bundled") + path_value = self._resolve_catalog_path() + if not path_value: + self._catalog = NoopCatalog() + return False, "Catalog load failed: bundled catalog path is not set" valid, validation_msg = self._validate_csv_format(path_value) if not valid: self._catalog = NoopCatalog() @@ -1494,30 +887,34 @@ def _load_tag_catalog(self) -> Tuple[bool, str]: return True, self._tag_catalog_status_text except Exception as exc: self._catalog = NoopCatalog() - return False, f"Catalog load failed: {exc} - Falling back to legacy" + return False, f"Catalog load failed: {exc}" def _render_tag_diag(self, diag: Dict[str, object]) -> str: if not diag: return "(run a search to populate)" - mode = diag.get('mode', 'legacy') - rules = diag.get('rules') or {} - kept = diag.get('kept') or [] - dropped = diag.get('dropped') or [] - normalized = diag.get('normalized') or [] - unknown = diag.get('unknown') or [] + mode = diag.get("mode", "catalog") + rules = diag.get("rules") or {} + kept = diag.get("kept") or [] + dropped = diag.get("dropped") or [] + normalized = diag.get("normalized") or [] + unknown = diag.get("unknown") or [] lines = [f"**Mode:** {mode}"] if isinstance(rules, dict) and rules: active = [k for k, v in rules.items() if v] lines.append(f"Active rules: {', '.join(active) if active else 'none'}") - lines.append(f"Kept: {len(kept)} | Dropped: {len(dropped)} | Normalized: {len(normalized)} | Unknown: {len(unknown)}") + lines.append( + f"Kept: {len(kept)} | Dropped: {len(dropped)} | Normalized: {len(normalized)} | Unknown: {len(unknown)}" + ) if unknown: sample = [] for entry in unknown[:3]: if isinstance(entry, dict): - tag = entry.get('tag') or entry.get('candidate') - suggestions = entry.get('suggestions') or [] + tag = entry.get("tag") or entry.get("candidate") + suggestions = entry.get("suggestions") or [] if tag: - sample.append(f"`{tag}` -> {', '.join(suggestions[:3]) if suggestions else 'no suggestions'}") + sample.append( + f"`{tag}` -> {', '.join(suggestions[:3]) if suggestions else 'no suggestions'}" + ) if sample: lines.append("Hints:\n- " + "\n- ".join(sample)) return "\n".join(lines) @@ -1536,8 +933,12 @@ def _log_patch_event(self, level: str, message: str) -> None: else: _ranbooru_logger.info(message) - def _verify_patch_target(self, target: object, method_name: str, *, require_callable: bool = True) -> bool: - ok, message = rb_adetailer_integration.verify_patch_target(target, method_name, require_callable=require_callable) + def _verify_patch_target( + self, target: object, method_name: str, *, require_callable: bool = True + ) -> bool: + ok, message = rb_adetailer_integration.verify_patch_target( + target, method_name, require_callable=require_callable + ) self._log_patch_event("info" if ok else "warning", message) return ok @@ -1548,31 +949,30 @@ def _apply_optional_catalog( keep_hair_eye: bool, drop_series: bool, drop_characters: bool, - drop_textual: bool + drop_textual: bool, ) -> Tuple[List[str], Dict[str, object]]: diag: Dict[str, object] = { - 'mode': 'legacy', - 'rules': { - 'drop_series': bool(drop_series), - 'drop_characters': bool(drop_characters), - 'drop_textual': bool(drop_textual), - 'keep_hair_eye': bool(keep_hair_eye), + "mode": "catalog", + "rules": { + "drop_series": bool(drop_series), + "drop_characters": bool(drop_characters), + "drop_textual": bool(drop_textual), + "keep_hair_eye": bool(keep_hair_eye), }, - 'kept': [], - 'dropped': [], - 'normalized': [], - 'unknown': [], + "kept": [], + "dropped": [], + "normalized": [], + "unknown": [], } catalog = self._active_catalog() if not catalog: self._tag_catalog_diag = diag self._update_tag_diag() return list(tags), diag - diag['mode'] = 'catalog' - subject_anchors = getattr(self, '_catalog_subject_anchors', None) + subject_anchors = getattr(self, "_catalog_subject_anchors", None) if subject_anchors is None: - subject_anchors = {s.replace(' ', '_') for s in getattr(self, '_SUBJECT_TAGS', set())} + subject_anchors = {s.replace(" ", "_") for s in rb_tag_pipeline._SUBJECT_TAGS} self._catalog_subject_anchors = subject_anchors kept: List[str] = [] @@ -1584,21 +984,21 @@ def _apply_optional_catalog( original_subjects: List[str] = [] for raw in tags: - tag = (raw or '').strip() + tag = (raw or "").strip() if not tag: continue - negated = tag.startswith('-') + negated = tag.startswith("-") base = tag[1:] if negated else tag - base_compact = re.sub(r'\s+', '_', base.strip().lower()) - base_compact = re.sub(r'_+', '_', base_compact) + base_compact = re.sub(r"\s+", "_", base.strip().lower()) + base_compact = re.sub(r"_+", "_", base_compact) if not base_compact: continue - if ':' in base_compact: + if ":" in base_compact: canonical = base_compact else: canonical = catalog.resolve_alias(base_compact) or base_compact if canonical != base_compact: - normalized_records.append({'from': base_compact, 'to': canonical}) + normalized_records.append({"from": base_compact, "to": canonical}) final_tag = f"-{canonical}" if negated else canonical if final_tag in seen: continue @@ -1612,34 +1012,34 @@ def _apply_optional_catalog( reason: Optional[str] = None if drop_series and category == 3: - reason = 'series' + reason = "series" elif drop_characters and category == 4: - reason = 'character' + reason = "character" elif drop_textual and catalog.is_textual(canonical): - reason = 'textual' + reason = "textual" if reason and not (keep_hair_eye and (is_hair or is_eye)): - dropped_records.append({'tag': final_tag, 'reason': reason}) + dropped_records.append({"tag": final_tag, "reason": reason}) continue if keep_hair_eye and (is_hair or is_eye): preserved_hair_eye.add(final_tag) - if not catalog.has(canonical) and ':' not in canonical: + if not catalog.has(canonical) and ":" not in canonical: suggestions = catalog.suggestions(canonical, self._tag_catalog_linter_limit) - unknown_records.append({'tag': canonical, 'suggestions': suggestions}) + unknown_records.append({"tag": canonical, "suggestions": suggestions}) kept.append(final_tag) - if original_subjects and not any(t.lstrip('-') in subject_anchors for t in kept): + if original_subjects and not any(t.lstrip("-") in subject_anchors for t in kept): kept.append(original_subjects[0]) - diag['kept'] = kept - diag['dropped'] = dropped_records - diag['normalized'] = normalized_records - diag['unknown'] = unknown_records + diag["kept"] = kept + diag["dropped"] = dropped_records + diag["normalized"] = normalized_records + diag["unknown"] = unknown_records if preserved_hair_eye: - diag['preserved'] = sorted(preserved_hair_eye) + diag["preserved"] = sorted(preserved_hair_eye) self._tag_catalog_diag = diag self._update_tag_diag() @@ -1662,135 +1062,34 @@ def _normalize_cached(self, tag: str, cache: Dict[str, str]) -> str: if normalized: catalog = self._active_catalog() if catalog: - catalog_token = normalized.replace(' ', '_') + catalog_token = normalized.replace(" ", "_") canonical = catalog.resolve_alias(catalog_token) if canonical and canonical != catalog_token: - normalized = canonical.replace('_', ' ') + normalized = canonical.replace("_", " ") cache[tag] = normalized return normalized def _expand_with_synonyms(self, normalized_tag: str, target_set: Set[str]) -> None: - if not normalized_tag: - return - group = self._synonym_lookup.get(normalized_tag) - if group: - target_set.update(group) - - def _build_removal_context(self, removal_raw: Iterable[str], favorites_raw: Iterable[str]) -> Dict[str, object]: - norm = self._normalize_tag - exact: Set[str] = set() - prefix: List[str] = [] - suffix: List[str] = [] - contains: List[str] = [] - regex_objects: List[re.Pattern[str]] = [] - for raw in removal_raw: - if not isinstance(raw, str): - continue - candidate = raw.strip() - if not candidate: - continue - if '*' not in candidate: - normalized = norm(candidate) - if normalized: - exact.add(normalized) - self._expand_with_synonyms(normalized, exact) - continue - if candidate.startswith('*') and candidate.endswith('*') and candidate.count('*') == 2: - body = candidate[1:-1] - normalized = norm(body) - if normalized: - contains.append(normalized) - continue - if candidate.endswith('*') and candidate.count('*') == 1: - body = candidate[:-1] - normalized = norm(body) - if normalized: - prefix.append(normalized) - continue - if candidate.startswith('*') and candidate.count('*') == 1: - body = candidate[1:] - normalized = norm(body) - if normalized: - suffix.append(normalized) - continue - segments = candidate.split('*') - pattern_fragments: List[str] = [] - for idx, segment in enumerate(segments): - if segment: - normalized_segment = norm(segment) - if normalized_segment: - pattern_fragments.append(re.escape(normalized_segment)) - if idx < len(segments) - 1: - pattern_fragments.append('.*') - pattern_body = ''.join(pattern_fragments) - if pattern_body: - try: - regex_objects.append(re.compile(f'^{pattern_body}$')) - except re.error as exc: - print(f"[R Filters] Invalid wildcard pattern '{candidate}': {exc}") - contains_set = set(filter(None, contains)) - contains_regex: Optional[re.Pattern[str]] = None - if len(contains_set) > 50: - pattern_union = '|'.join(re.escape(term) for term in contains_set if term) - if pattern_union: - try: - contains_regex = re.compile(pattern_union) - except re.error as exc: - print(f"[R Filters] Failed to compile contains regex: {exc}") - contains_regex = None - prefix_tuple = tuple(sorted(set(filter(None, prefix)))) - suffix_tuple = tuple(sorted(set(filter(None, suffix)))) - contains_tuple = tuple(sorted(contains_set)) - favorites_exact: Set[str] = set() - for fav in favorites_raw: - if not isinstance(fav, str): - continue - normalized = norm(fav) - if not normalized: - continue - favorites_exact.add(normalized) - self._expand_with_synonyms(normalized, favorites_exact) - return { - 'exact': frozenset(exact), - 'prefix': prefix_tuple, - 'suffix': suffix_tuple, - 'contains': contains_tuple, - 'contains_regex': contains_regex, - 'regex_objects': tuple(regex_objects), - 'favorites': frozenset(favorites_exact), - } + rb_tag_pipeline.expand_with_synonyms(normalized_tag, target_set, self._synonym_lookup) + + def _build_removal_context( + self, removal_raw: Iterable[str], favorites_raw: Iterable[str] + ) -> Dict[str, object]: + return rb_tag_pipeline.build_removal_context( + removal_raw, + favorites_raw, + self._synonym_lookup, + ) - def _tag_matches_removal(self, normalized_tag: str, context: Optional[Dict[str, object]]) -> bool: - if not context or not normalized_tag: - return False - favorites: Set[str] = context.get('favorites', frozenset()) # type: ignore[assignment] - if normalized_tag in favorites: - return False - exact: Set[str] = context.get('exact', frozenset()) # type: ignore[assignment] - if normalized_tag in exact: - return True - prefix_terms: Tuple[str, ...] = context.get('prefix', tuple()) # type: ignore[assignment] - if any(normalized_tag.startswith(term) for term in prefix_terms if term): - return True - suffix_terms: Tuple[str, ...] = context.get('suffix', tuple()) # type: ignore[assignment] - if any(normalized_tag.endswith(term) for term in suffix_terms if term): - return True - contains_regex: Optional[re.Pattern[str]] = context.get('contains_regex') # type: ignore[assignment] - if contains_regex and contains_regex.search(normalized_tag): - return True - contains_terms: Tuple[str, ...] = context.get('contains', tuple()) # type: ignore[assignment] - if not contains_regex and any(term and term in normalized_tag for term in contains_terms): - return True - regex_patterns: Tuple[re.Pattern[str], ...] = context.get('regex_objects', tuple()) # type: ignore[assignment] - for pattern in regex_patterns: - if pattern.fullmatch(normalized_tag): - return True - return False + def _tag_matches_removal( + self, normalized_tag: str, context: Optional[Dict[str, object]] + ) -> bool: + return rb_tag_pipeline.tag_matches_removal(normalized_tag, context) def _parse_user_tags(self, text: str) -> List[str]: if not text or not isinstance(text, str): return [] - segments = [seg.strip() for seg in re.split(r'[\n,]+', text) if seg and seg.strip()] + segments = [seg.strip() for seg in re.split(r"[\n,]+", text) if seg and seg.strip()] if not segments: return [] seen: Set[str] = set() @@ -1816,7 +1115,7 @@ def _merge_tag_lists(self, base: List[str], additions: List[str]) -> List[str]: merged: List[str] = [] seen: Set[str] = set() for tag in list(base) + list(additions): - cleaned = (tag or '').strip() + cleaned = (tag or "").strip() if not cleaned: continue key = self._normalize_tag(cleaned) or cleaned.casefold() @@ -1846,9 +1145,17 @@ def _apply_list_operation( if combined_additions: working = self._merge_tag_lists(working, combined_additions) if removals: - removal_keys = {self._normalize_tag(tag) or tag.casefold() for tag in removals if isinstance(tag, str)} + removal_keys = { + self._normalize_tag(tag) or tag.casefold() + for tag in removals + if isinstance(tag, str) + } if removal_keys: - working = [tag for tag in working if (self._normalize_tag(tag) or tag.casefold()) not in removal_keys] + working = [ + tag + for tag in working + if (self._normalize_tag(tag) or tag.casefold()) not in removal_keys + ] if dedupe: working = self._merge_tag_lists([], working) self._write_list_file(path, working) @@ -1857,76 +1164,92 @@ def _apply_list_operation( def _ui_add_personal_tags(self, tags_text: str, current_selection: Optional[object]): additions = self._parse_user_tags(tags_text) - new_list = self._apply_list_operation('personal', additions=additions) + new_list = self._apply_list_operation("personal", additions=additions) selection = additions or self._coerce_selection(current_selection) selection = [tag for tag in selection if tag in new_list] return ( - _gr_component_update(gr.Dropdown,choices=new_list, value=selection), - _gr_component_update(gr.Textbox,value="") + _gr_component_update(gr.Dropdown, choices=new_list, value=selection), + _gr_component_update(gr.Textbox, value=""), ) def _ui_remove_personal_tags(self, selected: Optional[object]): removals = self._coerce_selection(selected) - new_list = self._apply_list_operation('personal', removals=removals) if removals else self._read_list_file(PERSONAL_REMOVE_FILE) - return _gr_component_update(gr.Dropdown,choices=new_list, value=[]) + new_list = ( + self._apply_list_operation("personal", removals=removals) + if removals + else self._read_list_file(PERSONAL_REMOVE_FILE) + ) + return _gr_component_update(gr.Dropdown, choices=new_list, value=[]) def _ui_dedupe_personal_list(self): - new_list = self._apply_list_operation('personal', dedupe=True) - return _gr_component_update(gr.Dropdown,choices=new_list, value=new_list) + new_list = self._apply_list_operation("personal", dedupe=True) + return _gr_component_update(gr.Dropdown, choices=new_list, value=new_list) def _ui_import_personal_list(self, uploaded_file: Optional[dict]): if not uploaded_file: current = self._read_list_file(PERSONAL_REMOVE_FILE) - return _gr_component_update(gr.Dropdown,choices=current, value=current), _gr_component_update(gr.File,value=None) - data = uploaded_file.get('data') if isinstance(uploaded_file, dict) else None - text = '' + return _gr_component_update( + gr.Dropdown, choices=current, value=current + ), _gr_component_update(gr.File, value=None) + data = uploaded_file.get("data") if isinstance(uploaded_file, dict) else None + text = "" if isinstance(data, bytes): try: - text = data.decode('utf-8', errors='ignore') + text = data.decode("utf-8", errors="ignore") except Exception as exc: print(f"[R Lists] Failed to decode personal import: {exc}") additions = self._parse_user_tags(text) - new_list = self._apply_list_operation('personal', additions=additions) + new_list = self._apply_list_operation("personal", additions=additions) selection = [tag for tag in additions if tag in new_list] - return _gr_component_update(gr.Dropdown,choices=new_list, value=selection), _gr_component_update(gr.File,value=None) + return _gr_component_update( + gr.Dropdown, choices=new_list, value=selection + ), _gr_component_update(gr.File, value=None) def _ui_export_personal_list(self): return PERSONAL_REMOVE_FILE def _ui_add_favorite_tags(self, tags_text: str, current_selection: Optional[object]): additions = self._parse_user_tags(tags_text) - new_list = self._apply_list_operation('favorites', additions=additions) + new_list = self._apply_list_operation("favorites", additions=additions) selection = additions or self._coerce_selection(current_selection) selection = [tag for tag in selection if tag in new_list] return ( - _gr_component_update(gr.Dropdown,choices=new_list, value=selection), - _gr_component_update(gr.Textbox,value="") + _gr_component_update(gr.Dropdown, choices=new_list, value=selection), + _gr_component_update(gr.Textbox, value=""), ) def _ui_remove_favorite_tags(self, selected: Optional[object]): removals = self._coerce_selection(selected) - new_list = self._apply_list_operation('favorites', removals=removals) if removals else self._read_list_file(FAVORITES_FILE) - return _gr_component_update(gr.Dropdown,choices=new_list, value=[]) + new_list = ( + self._apply_list_operation("favorites", removals=removals) + if removals + else self._read_list_file(FAVORITES_FILE) + ) + return _gr_component_update(gr.Dropdown, choices=new_list, value=[]) def _ui_dedupe_favorite_list(self): - new_list = self._apply_list_operation('favorites', dedupe=True) - return _gr_component_update(gr.Dropdown,choices=new_list, value=new_list) + new_list = self._apply_list_operation("favorites", dedupe=True) + return _gr_component_update(gr.Dropdown, choices=new_list, value=new_list) def _ui_import_favorite_list(self, uploaded_file: Optional[dict]): if not uploaded_file: current = self._read_list_file(FAVORITES_FILE) - return _gr_component_update(gr.Dropdown,choices=current, value=current), _gr_component_update(gr.File,value=None) - data = uploaded_file.get('data') if isinstance(uploaded_file, dict) else None - text = '' + return _gr_component_update( + gr.Dropdown, choices=current, value=current + ), _gr_component_update(gr.File, value=None) + data = uploaded_file.get("data") if isinstance(uploaded_file, dict) else None + text = "" if isinstance(data, bytes): try: - text = data.decode('utf-8', errors='ignore') + text = data.decode("utf-8", errors="ignore") except Exception as exc: print(f"[R Lists] Failed to decode favorites import: {exc}") additions = self._parse_user_tags(text) - new_list = self._apply_list_operation('favorites', additions=additions) + new_list = self._apply_list_operation("favorites", additions=additions) selection = [tag for tag in additions if tag in new_list] - return _gr_component_update(gr.Dropdown,choices=new_list, value=selection), _gr_component_update(gr.File,value=None) + return _gr_component_update( + gr.Dropdown, choices=new_list, value=selection + ), _gr_component_update(gr.File, value=None) def _ui_export_favorite_list(self): return FAVORITES_FILE @@ -1934,43 +1257,47 @@ def _ui_export_favorite_list(self): def _get_saved_gelbooru_credentials(self) -> Optional[Dict[str, str]]: creds = self._gelbooru_saved_credentials if isinstance(creds, dict): - raw_api = creds.get('api_key') - raw_uid = creds.get('user_id') + raw_api = creds.get("api_key") + raw_uid = creds.get("user_id") api_key = _sanitize_gelbooru_credential(raw_api) user_id = _sanitize_gelbooru_credential(raw_uid) - if (not api_key and isinstance(raw_uid, str)) or (not user_id and isinstance(raw_api, str)): + if (not api_key and isinstance(raw_uid, str)) or ( + not user_id and isinstance(raw_api, str) + ): combined = f"{raw_api}&{raw_uid}" - for seg in str(combined).split('&'): + for seg in str(combined).split("&"): segl = seg.lower().strip() - if segl.startswith('api_key=') and not api_key: - api_key = seg.split('=', 1)[1].strip() - if segl.startswith('user_id=') and not user_id: - user_id = seg.split('=', 1)[1].strip() + if segl.startswith("api_key=") and not api_key: + api_key = seg.split("=", 1)[1].strip() + if segl.startswith("user_id=") and not user_id: + user_id = seg.split("=", 1)[1].strip() api_key = _sanitize_gelbooru_credential(api_key) user_id = _sanitize_gelbooru_credential(user_id) if api_key and user_id: - return {'api_key': api_key, 'user_id': user_id} + return {"api_key": api_key, "user_id": user_id} return None - def _resolve_gelbooru_credentials(self, runtime_api_key: Optional[str], runtime_user_id: Optional[str]) -> Optional[Dict[str, str]]: + def _resolve_gelbooru_credentials( + self, runtime_api_key: Optional[str], runtime_user_id: Optional[str] + ) -> Optional[Dict[str, str]]: runtime_api_key = _sanitize_gelbooru_credential(runtime_api_key) runtime_user_id = _sanitize_gelbooru_credential(runtime_user_id) - if (runtime_api_key and ('=' in runtime_api_key or '&' in runtime_api_key)) or ( - runtime_user_id and ('=' in runtime_user_id or '&' in runtime_user_id) + if (runtime_api_key and ("=" in runtime_api_key or "&" in runtime_api_key)) or ( + runtime_user_id and ("=" in runtime_user_id or "&" in runtime_user_id) ): combined = f"{runtime_api_key}&{runtime_user_id}" r_api = runtime_api_key r_uid = runtime_user_id - for seg in str(combined).split('&'): + for seg in str(combined).split("&"): segl = seg.lower().strip() - if segl.startswith('api_key='): - r_api = seg.split('=', 1)[1].strip() - elif segl.startswith('user_id='): - r_uid = seg.split('=', 1)[1].strip() + if segl.startswith("api_key="): + r_api = seg.split("=", 1)[1].strip() + elif segl.startswith("user_id="): + r_uid = seg.split("=", 1)[1].strip() runtime_api_key = _sanitize_gelbooru_credential(r_api) runtime_user_id = _sanitize_gelbooru_credential(r_uid) if runtime_api_key and runtime_user_id: - return {'api_key': runtime_api_key, 'user_id': runtime_user_id} + return {"api_key": runtime_api_key, "user_id": runtime_user_id} saved = self._get_saved_gelbooru_credentials() if saved: return saved @@ -1985,328 +1312,136 @@ def _ui_save_gelbooru_credentials(self, api_key: Optional[str], user_id: Optiona if not api_key or not user_id: warn = "Please enter both API Key and User ID before saving." return ( - _gr_component_update(gr.Markdown,value=warn, visible=True), + _gr_component_update(gr.Markdown, value=warn, visible=True), _gr_update(visible=True), - _gr_component_update(gr.Button,visible=False), - _gr_component_update(gr.Textbox,value=api_key), - _gr_component_update(gr.Textbox,value=user_id), + _gr_component_update(gr.Button, visible=False), + _gr_component_update(gr.Textbox, value=api_key), + _gr_component_update(gr.Textbox, value=user_id), ) if _save_gelbooru_credentials_to_disk(api_key, user_id): - self._gelbooru_saved_credentials = {'api_key': api_key, 'user_id': user_id} + self._gelbooru_saved_credentials = {"api_key": api_key, "user_id": user_id} message = self._gelbooru_saved_message() return ( - _gr_component_update(gr.Markdown,value=message, visible=True), + _gr_component_update(gr.Markdown, value=message, visible=True), _gr_update(visible=False), - _gr_component_update(gr.Button,visible=True), - _gr_component_update(gr.Textbox,value=""), - _gr_component_update(gr.Textbox,value=""), + _gr_component_update(gr.Button, visible=True), + _gr_component_update(gr.Textbox, value=""), + _gr_component_update(gr.Textbox, value=""), ) error = "Failed to save Gelbooru credentials. Check console for details." return ( - _gr_component_update(gr.Markdown,value=error, visible=True), + _gr_component_update(gr.Markdown, value=error, visible=True), _gr_update(visible=True), - _gr_component_update(gr.Button,visible=False), - _gr_component_update(gr.Textbox,value=api_key), - _gr_component_update(gr.Textbox,value=user_id), + _gr_component_update(gr.Button, visible=False), + _gr_component_update(gr.Textbox, value=api_key), + _gr_component_update(gr.Textbox, value=user_id), ) def _ui_clear_gelbooru_credentials(self): if _clear_gelbooru_credentials_from_disk(): self._gelbooru_saved_credentials = None return ( - _gr_component_update(gr.Markdown,value="Saved Gelbooru credentials cleared.", visible=True), + _gr_component_update( + gr.Markdown, value="Saved Gelbooru credentials cleared.", visible=True + ), _gr_update(visible=True), - _gr_component_update(gr.Button,visible=False), - _gr_component_update(gr.Textbox,value=""), - _gr_component_update(gr.Textbox,value=""), + _gr_component_update(gr.Button, visible=False), + _gr_component_update(gr.Textbox, value=""), + _gr_component_update(gr.Textbox, value=""), ) warn = "Gelbooru credentials file could not be removed." return ( - _gr_component_update(gr.Markdown,value=warn, visible=True), + _gr_component_update(gr.Markdown, value=warn, visible=True), _gr_update(visible=False), - _gr_component_update(gr.Button,visible=True), - _gr_component_update(gr.Textbox,value=""), - _gr_component_update(gr.Textbox,value=""), + _gr_component_update(gr.Button, visible=True), + _gr_component_update(gr.Textbox, value=""), + _gr_component_update(gr.Textbox, value=""), ) def _update_gelbooru_ui_visibility(self, booru_name: Optional[str]): booru_name = (booru_name or "").strip().lower() has_saved = self._get_saved_gelbooru_credentials() is not None - if booru_name == 'gelbooru': + if booru_name == "gelbooru": if has_saved: message = self._gelbooru_saved_message() - return (_gr_update(visible=False), - _gr_component_update(gr.Markdown,value=message, visible=True), - _gr_component_update(gr.Button,visible=True), - _gr_component_update(gr.Textbox,value=""), - _gr_component_update(gr.Textbox,value=""), + return ( + _gr_update(visible=False), + _gr_component_update(gr.Markdown, value=message, visible=True), + _gr_component_update(gr.Button, visible=True), + _gr_component_update(gr.Textbox, value=""), + _gr_component_update(gr.Textbox, value=""), ) - return (_gr_update(visible=True), - _gr_component_update(gr.Markdown,value="", visible=False), - _gr_component_update(gr.Button,visible=False), - _gr_component_update(gr.Textbox,value=""), - _gr_component_update(gr.Textbox,value=""), + return ( + _gr_update(visible=True), + _gr_component_update(gr.Markdown, value="", visible=False), + _gr_component_update(gr.Button, visible=False), + _gr_component_update(gr.Textbox, value=""), + _gr_component_update(gr.Textbox, value=""), ) # Hide for non-Gelbooru selections - return (_gr_update(visible=False), - _gr_component_update(gr.Markdown,value="", visible=False), - _gr_component_update(gr.Button,visible=False), - _gr_component_update(gr.Textbox,value=""), - _gr_component_update(gr.Textbox,value=""), + return ( + _gr_update(visible=False), + _gr_component_update(gr.Markdown, value="", visible=False), + _gr_component_update(gr.Button, visible=False), + _gr_component_update(gr.Textbox, value=""), + _gr_component_update(gr.Textbox, value=""), ) def _update_gelbooru_compat_visibility(self, booru_name: Optional[str]): booru_name = (booru_name or "").strip().lower() - visible = booru_name == 'gelbooru-compatible' - return (_gr_update(visible=visible), - _gr_component_update(gr.Textbox,value=self._gelbooru_compat_base_url if visible else self._gelbooru_compat_base_url) + visible = booru_name == "gelbooru-compatible" + return ( + _gr_update(visible=visible), + _gr_component_update( + gr.Textbox, + value=self._gelbooru_compat_base_url if visible else self._gelbooru_compat_base_url, + ), ) def _ui_set_gelbooru_compat_base_url(self, base_url: Optional[str]): sanitized = _sanitize_gelbooru_compat_base_url(base_url) self._gelbooru_compat_base_url = sanitized - return _gr_component_update(gr.Textbox,value=self._gelbooru_compat_base_url) + return _gr_component_update(gr.Textbox, value=self._gelbooru_compat_base_url) - def _build_legacy_bad_index(self, bad_tags: Iterable[str]) -> None: - exact: Set[str] = set() - wildcard: Dict[str, str] = {} - for tag in bad_tags: - if not isinstance(tag, str): - continue - cleaned = tag.strip() - if not cleaned: - continue - if '*' not in cleaned: - exact.add(cleaned) - continue - base = cleaned.replace('*', '') - if not base: - continue - if cleaned.endswith('*') and not cleaned.startswith('*'): - wildcard[base] = 's' - elif cleaned.startswith('*') and not cleaned.endswith('*'): - wildcard[base] = 'e' - else: - wildcard[base] = 'c' - self._legacy_bad_exact = exact - self._legacy_bad_wildcard = wildcard - - def _legacy_tag_matches(self, tag: str) -> bool: - if tag in self._legacy_bad_exact: - return True - for pattern, mode in self._legacy_bad_wildcard.items(): - if not pattern: - continue - if mode == 's' and tag.startswith(pattern): - return True - if mode == 'e' and tag.endswith(pattern): - return True - if mode == 'c' and pattern in tag: - return True - return False - - def _is_furry_tag(self, tag: str) -> bool: - normalized = (self._normalize_tag(tag) or '').strip().lower() - if not normalized: - normalized = self._canonicalize_raw_tag(tag) - if not normalized: - return False - raw_lower = (tag or '').strip().lower() - if normalized in self._FURRY_CORE_NORMALIZED or raw_lower in FURRY_CORE_TAGS: - return True - if any(normalized.startswith(prefix) or raw_lower.startswith(prefix) for prefix in self._POKEMON_PREFIXES_NORMALIZED): - return True - if any(keyword in normalized for keyword in self._ANIMAL_EAR_KEYWORDS_NORMALIZED): - return True - if any(keyword in normalized for keyword in self._HORN_KEYWORDS_NORMALIZED): - return True - return False - - def _is_headwear_tag(self, tag: str) -> bool: - normalized = (self._normalize_tag(tag) or '').strip().lower() - if not normalized: - normalized = self._canonicalize_raw_tag(tag) - if not normalized: - return False - if normalized in self._HEADWEAR_TAGS_NORMALIZED or normalized in self._HALO_TAGS_NORMALIZED: - return True - # Allow simple keyword containment like " halo" or " headpiece" - if ' halo' in normalized or normalized.endswith(' halo'): - return True - return False - - def _is_girl_suffix_tag(self, tag: str) -> bool: - """Check if tag ends with _girl pattern (e.g., demon_girl, cat_girl, angel_girl).""" - normalized = (self._normalize_tag(tag) or '').strip().lower() - if not normalized: - normalized = self._canonicalize_raw_tag(tag) - if not normalized: - return False - # Check for _girl or " girl" suffix pattern (but not just "girl" alone or common subject tags) - excluded = {'girl', '1girl', '2girls', '3girls', '4girls', '5girls', '6+girls', 'multiple girls'} - if normalized in excluded: - return False - if normalized.endswith(' girl') or normalized.endswith('_girl'): - return True - return False - - def _is_hair_color_tag(self, tag: str) -> bool: - - - normalized = (self._normalize_tag(tag) or '').strip().lower() - if not normalized: - normalized = self._canonicalize_raw_tag(tag) - if not normalized: - return False - catalog = self._active_catalog() - if catalog and catalog.is_hair(normalized.replace(' ', '_')): - return True - return normalized in self._HAIR_COLOR_TAGS_NORMALIZED - - def _is_eye_color_tag(self, tag: str) -> bool: - normalized = (self._normalize_tag(tag) or '').strip().lower() - if not normalized: - normalized = self._canonicalize_raw_tag(tag) - if not normalized: - return False - catalog = self._active_catalog() - if catalog and catalog.is_eye(normalized.replace(' ', '_')): - return True - return normalized in self._EYE_COLOR_TAGS_NORMALIZED - - def _is_series_tag(self, tag: str) -> bool: - normalized = (self._normalize_tag(tag) or '').strip().lower() - if not normalized: - normalized = self._canonicalize_raw_tag(tag) - if not normalized: - return False - catalog = self._active_catalog() - if catalog and catalog.category(normalized.replace(' ', '_')) == 3: - return True - raw_lower = (tag or '').strip().lower() - if any(keyword in normalized for keyword in self._SERIES_KEYWORDS_NORMALIZED): - return True - if any(raw_lower.endswith(suffix) or normalized.endswith(suffix) for suffix in SERIES_SUFFIXES): - return True - if any(normalized.endswith(suffix) for suffix in self._SERIES_SUFFIXES_NORMALIZED): - return True - return False - - def _extract_color_tags(self, text: str) -> tuple[set[str], set[str]]: - hair_tags: set[str] = set() - eye_tags: set[str] = set() - if not text or not isinstance(text, str): - return hair_tags, eye_tags - catalog = self._active_catalog() - tokens = [token.strip() for token in re.split(r'[\s,]+', text) if token.strip()] - for token in tokens: - normalized = (self._normalize_tag(token) or '').strip().lower() - if not normalized: - normalized = self._canonicalize_raw_tag(token) - if not normalized: + def _extract_color_tags(self, text: str) -> tuple[set[str], set[str]]: + hair_tags: set[str] = set() + eye_tags: set[str] = set() + if not text or not isinstance(text, str): + return hair_tags, eye_tags + catalog = self._active_catalog() + tokens = [token.strip() for token in re.split(r"[\s,]+", text) if token.strip()] + for token in tokens: + normalized = (self._normalize_tag(token) or "").strip().lower() + if not normalized: + normalized = self._canonicalize_raw_tag(token) + if not normalized: continue if catalog: - token_key = normalized.replace(' ', '_') + token_key = normalized.replace(" ", "_") if catalog.is_hair(token_key): canonical = catalog.resolve_alias(token_key) - hair_tags.add(canonical.replace('_', ' ') if canonical else normalized) + hair_tags.add(canonical.replace("_", " ") if canonical else normalized) if catalog.is_eye(token_key): canonical = catalog.resolve_alias(token_key) - eye_tags.add(canonical.replace('_', ' ') if canonical else normalized) - if normalized in self._HAIR_COLOR_TAGS_NORMALIZED: + eye_tags.add(canonical.replace("_", " ") if canonical else normalized) + if normalized in rb_tag_pipeline._HAIR_COLOR_TAGS_NORMALIZED: hair_tags.add(normalized) - if normalized in self._EYE_COLOR_TAGS_NORMALIZED: + if normalized in rb_tag_pipeline._EYE_COLOR_TAGS_NORMALIZED: eye_tags.add(normalized) return hair_tags, eye_tags - def _is_clothing_tag(self, tag: str) -> bool: - normalized = self._normalize_tag(tag) - if not normalized: - return False - if normalized.startswith('no ') or normalized.startswith('without ') or ' without ' in normalized or normalized.startswith('nude'): - return False - for keyword in self._CLOTHING_KEYWORDS: - if keyword in normalized: - return True - if normalized.endswith(' uniform') or normalized.endswith(' outfit') or normalized.endswith(' costume'): - return True - return False - - def _is_textual_tag(self, tag: str) -> bool: - normalized = self._normalize_tag(tag) - if not normalized: - return False - catalog = self._active_catalog() - if catalog and catalog.is_textual(normalized.replace(' ', '_')): - return True - if normalized in self._TEXTUAL_TAGS: - return True - if ' text' in normalized or normalized.endswith(' text') or normalized.startswith('text '): - return True - if 'commentary' in normalized or 'speech bubble' in normalized or 'dialog' in normalized or 'subtitle' in normalized or 'caption' in normalized: - return True - if normalized.startswith('translated ') or normalized.startswith('translation '): - return True - return False - - def _is_subject_tag(self, tag: str) -> bool: - normalized = self._normalize_tag(tag) - return normalized in self._SUBJECT_TAGS def _extract_subject_tags(self, text: str) -> set: - if not text or not isinstance(text, str): - return set() - tags = [t.strip() for t in re.split(r'[\s,]+', text) if t.strip()] - return {self._normalize_tag(t) for t in tags if self._is_subject_tag(t)} - - def _normalize_post_tags(self, post: Optional[Dict[str, object]], cache: Dict[str, str]) -> Tuple[Set[str], Dict[str, List[str]]]: - normalized_tags: Set[str] = set() - buckets: Dict[str, List[str]] = { - 'tags': [], - 'artist_tags': [], - 'character_tags': [], - 'copyright_tags': [], - } - if not isinstance(post, dict): - return normalized_tags, buckets - - raw_tags = post.get('tags') - tag_list: List[str] = [] - if isinstance(raw_tags, str): - tag_list = [segment.strip() for segment in re.split(r'[\s,]+', raw_tags) if segment.strip()] - elif isinstance(raw_tags, dict): - for value in raw_tags.values(): - if isinstance(value, (list, tuple, set)): - tag_list.extend([str(item).strip() for item in value if isinstance(item, str) and item.strip()]) - elif isinstance(value, str) and value.strip(): - tag_list.append(value.strip()) - elif isinstance(raw_tags, (list, tuple, set)): - tag_list = [str(item).strip() for item in raw_tags if isinstance(item, str) and item.strip()] - buckets['tags'] = tag_list - - for key in ('artist_tags', 'character_tags', 'copyright_tags'): - values = post.get(key) - if isinstance(values, str) and values.strip(): - buckets[key] = [values.strip()] - elif isinstance(values, (list, tuple, set)): - buckets[key] = [str(item).strip() for item in values if isinstance(item, str) and item.strip()] - else: - buckets[key] = [] - - for key, values in buckets.items(): - cleaned: List[str] = [] - for tag in values: - if not isinstance(tag, str): - continue - cleaned_tag = tag.strip() - if not cleaned_tag: - continue - cleaned.append(cleaned_tag) - normalized = self._normalize_cached(cleaned_tag, cache) - if normalized: - normalized_tags.add(normalized) - buckets[key] = cleaned + return rb_tag_pipeline.extract_subject_tags(text) - return normalized_tags, buckets + def _normalize_post_tags( + self, post: Optional[Dict[str, object]], cache: Dict[str, str] + ) -> Tuple[Set[str], Dict[str, List[str]]]: + catalog = self._active_catalog() + return rb_tag_pipeline.normalize_post_tags( + post, + cache, + catalog.resolve_alias if catalog else None, + ) def _post_rejected_by_filter( self, @@ -2319,95 +1454,21 @@ def _post_rejected_by_filter( cache: Dict[str, str], favorites_guard: Set[str], ) -> Tuple[bool, Optional[Dict[str, object]]]: - ( - remove_artist, - remove_character, - remove_clothing, - remove_text, - restrict_subject, - remove_furry, - remove_headwear, - remove_girl_suffix, - preserve_hair_eye, - remove_series, - ) = toggles - base_hair, base_eye = base_colors - _, buckets = self._normalize_post_tags(post, cache) - primary_subject: Optional[str] = None - - for bucket_name, tags in buckets.items(): - for raw_tag in tags: - normalized_tag = self._normalize_cached(raw_tag, cache) - if normalized_tag and normalized_tag in favorites_guard: - continue - canonical_tag = normalized_tag or self._canonicalize_raw_tag(raw_tag) - canonical_tag = canonical_tag or '' - reason_base = { - 'tag': raw_tag, - 'norm': normalized_tag, - 'bucket': bucket_name, - } - - if remove_artist and ( - bucket_name == 'artist_tags' - or (normalized_tag and (normalized_tag.endswith(' artist') or ' drawn by' in normalized_tag)) - ): - return True, {**reason_base, 'rule': 'artist'} - - if remove_character and ( - bucket_name == 'character_tags' - or ('(' in raw_tag and ')' in raw_tag and not raw_tag.strip().startswith('(')) - or (normalized_tag and ( - normalized_tag.endswith(' character') - or normalized_tag.endswith(' characters') - or normalized_tag.endswith(' series') - or normalized_tag.endswith(' franchise') - )) - ): - return True, {**reason_base, 'rule': 'character'} - - if remove_series and ( - bucket_name == 'copyright_tags' - or self._is_series_tag(raw_tag) - ): - return True, {**reason_base, 'rule': 'series'} - - if remove_clothing and self._is_clothing_tag(raw_tag): - return True, {**reason_base, 'rule': 'clothing'} - - if remove_text and self._is_textual_tag(raw_tag): - return True, {**reason_base, 'rule': 'text'} - - if remove_furry and self._is_furry_tag(raw_tag): - return True, {**reason_base, 'rule': 'furry'} - - if remove_headwear and self._is_headwear_tag(raw_tag): - return True, {**reason_base, 'rule': 'headwear'} - - if remove_girl_suffix and self._is_girl_suffix_tag(raw_tag): - return True, {**reason_base, 'rule': 'girl-suffix'} - - if preserve_hair_eye: - if base_hair and self._is_hair_color_tag(raw_tag) and canonical_tag not in base_hair: - return True, {**reason_base, 'rule': 'hair-color-conflict'} - if base_eye and self._is_eye_color_tag(raw_tag) and canonical_tag not in base_eye: - return True, {**reason_base, 'rule': 'eye-color-conflict'} - - if restrict_subject and self._is_subject_tag(raw_tag): - subject_norm = normalized_tag or canonical_tag - if allowed_subjects: - if subject_norm not in allowed_subjects: - return True, {**reason_base, 'rule': 'subject-not-allowed'} - else: - if primary_subject is None: - primary_subject = subject_norm - elif subject_norm != primary_subject: - return True, {**reason_base, 'rule': 'multiple-subjects'} - - if filter_ctx and normalized_tag and self._tag_matches_removal(normalized_tag, filter_ctx): - return True, {**reason_base, 'rule': 'removal-list'} - - return False, None + catalog = self._active_catalog() + return rb_tag_pipeline.post_rejected_by_filter( + post, + filter_ctx=filter_ctx, + toggles=toggles, + base_colors=base_colors, + allowed_subjects=allowed_subjects, + cache=cache, + favorites_guard=favorites_guard, + catalog_resolve_alias_fn=catalog.resolve_alias if catalog else None, + catalog_is_textual_fn=catalog.is_textual if catalog else None, + catalog_is_hair_fn=catalog.is_hair if catalog else None, + catalog_is_eye_fn=catalog.is_eye if catalog else None, + catalog_category_fn=catalog.category if catalog else None, + ) def _apply_strict_img2img_prefilter( self, @@ -2426,17 +1487,17 @@ def _apply_strict_img2img_prefilter( if not posts: return [], [], False, False - if not getattr(self, '_strict_img2img_fetch', True): + if not getattr(self, "_strict_img2img_fetch", True): return list(posts), [], False, False - cache = getattr(self, '_tag_normal_cache', {}) + cache = getattr(self, "_tag_normal_cache", {}) if not isinstance(cache, dict): cache = {} self._tag_normal_cache = cache favorites_guard: Set[str] = set() if filter_ctx: - favorites_guard = set(filter_ctx.get('favorites', frozenset())) # type: ignore[arg-type] + favorites_guard = set(filter_ctx.get("favorites", frozenset())) # type: ignore[arg-type] hair_colors, eye_colors = base_colors base_colors = (set(hair_colors or []), set(eye_colors or [])) @@ -2446,11 +1507,13 @@ def _apply_strict_img2img_prefilter( rejections: List[Dict[str, object]] = [] seen_keys: Set[Tuple[Optional[str], Optional[str], Optional[str]]] = set() - def _post_key(entry: Dict[str, object]) -> Tuple[Optional[str], Optional[str], Optional[str]]: + def _post_key( + entry: Dict[str, object], + ) -> Tuple[Optional[str], Optional[str], Optional[str]]: return ( - str(entry.get('booru_name')).lower() if entry.get('booru_name') else None, - entry.get('id'), - entry.get('file_url'), + str(entry.get("booru_name")).lower() if entry.get("booru_name") else None, + entry.get("id"), + entry.get("file_url"), ) original_posts = list(posts) @@ -2469,12 +1532,12 @@ def _post_key(entry: Dict[str, object]) -> Tuple[Optional[str], Optional[str], O ) if rejected: reason_entry = { - 'post_id': post.get('id'), - 'booru': post.get('booru_name'), - 'matched_tag': reason.get('tag') if reason else None, - 'normalized_tag': reason.get('norm') if reason else None, - 'rule_type': reason.get('rule') if reason else None, - 'bucket': reason.get('bucket') if reason else None, + "post_id": post.get("id"), + "booru": post.get("booru_name"), + "matched_tag": reason.get("tag") if reason else None, + "normalized_tag": reason.get("norm") if reason else None, + "rule_type": reason.get("rule") if reason else None, + "bucket": reason.get("bucket") if reason else None, } rejections.append(reason_entry) else: @@ -2487,7 +1550,9 @@ def _post_key(entry: Dict[str, object]) -> Tuple[Optional[str], Optional[str], O rounds = max(0, int(STRICT_IMG2IMG_EXTRA_ROUNDS)) for round_index in range(rounds): try: - extra_posts = api.get_posts(tags_query=tags_query, max_pages=max_pages, post_id=None) + extra_posts = api.get_posts( + tags_query=tags_query, max_pages=max_pages, post_id=None + ) except Exception as exc: print(f"[R Strict] Warn: extra fetch round {round_index + 1} failed: {exc}") break @@ -2509,13 +1574,13 @@ def _post_key(entry: Dict[str, object]) -> Tuple[Optional[str], Optional[str], O ) if rejected: reason_entry = { - 'post_id': post.get('id'), - 'booru': post.get('booru_name'), - 'matched_tag': reason.get('tag') if reason else None, - 'normalized_tag': reason.get('norm') if reason else None, - 'rule_type': reason.get('rule') if reason else None, - 'bucket': reason.get('bucket') if reason else None, - 'extra_round': round_index + 1, + "post_id": post.get("id"), + "booru": post.get("booru_name"), + "matched_tag": reason.get("tag") if reason else None, + "normalized_tag": reason.get("norm") if reason else None, + "rule_type": reason.get("rule") if reason else None, + "bucket": reason.get("bucket") if reason else None, + "extra_round": round_index + 1, } rejections.append(reason_entry) continue @@ -2526,19 +1591,21 @@ def _post_key(entry: Dict[str, object]) -> Tuple[Optional[str], Optional[str], O break if len(kept) < num_images_needed: - print("[R Strict] Img2Img strict pre-filter exhausted candidates; relaxing to prompt-level filtering") + print( + "[R Strict] Img2Img strict pre-filter exhausted candidates; relaxing to prompt-level filtering" + ) relaxed = True kept = list(original_posts) return kept, rejections, True, relaxed def _log_generation_reference(self, p): - if not getattr(self, '_log_prompt_sources', False): + if not getattr(self, "_log_prompt_sources", False): return try: - prompts = list(getattr(self, '_final_prompts_snapshot', [])) + prompts = list(getattr(self, "_final_prompts_snapshot", [])) if not prompts: - prompt_attr = getattr(p, 'prompt', '') + prompt_attr = getattr(p, "prompt", "") if isinstance(prompt_attr, list): prompts = list(prompt_attr) elif isinstance(prompt_attr, str): @@ -2546,51 +1613,64 @@ def _log_generation_reference(self, p): prompts = [pr for pr in prompts if isinstance(pr, str) and pr.strip()] if not prompts: return - seeds = list(getattr(p, 'all_seeds', []) or []) - posts = list(getattr(self, '_posts_used_for_generation', [])) - post_urls = list(getattr(self, '_last_post_urls', [])) if hasattr(self, '_last_post_urls') else [] - os.makedirs(LOG_DIR, exist_ok=True) - log_path = os.path.join(LOG_DIR, 'prompt_sources.txt') - booru = getattr(self, '_current_booru_name', 'unknown') - base_prompt = getattr(self, 'original_prompt', getattr(p, 'prompt', '')) - negative_prompt = getattr(p, 'negative_prompt', '') - with open(log_path, 'a', encoding='utf-8') as log_file: - log_file.write('---\n') - log_file.write(f"{datetime.now().isoformat()} | booru={booru} | reuse_cached={getattr(self, '_reuse_cached_posts', False)}\n") - log_file.write(f"base_prompt={base_prompt}\n") - if isinstance(negative_prompt, str) and negative_prompt: - log_file.write(f"negative_prompt={negative_prompt}\n") - for idx, prompt in enumerate(prompts): - seed = seeds[idx] if idx < len(seeds) else getattr(p, 'seed', None) - log_file.write(f"[{idx+1}] seed={seed}\n") - log_file.write(f"prompt={prompt}\n") - post = posts[idx] if idx < len(posts) else None - source_url = post_urls[idx] if idx < len(post_urls) else None - if not source_url and post: - source_url = get_original_post_url(post) - if not source_url and post and post.get('file_url'): - source_url = post.get('file_url') - if source_url: - log_file.write(f"source={source_url}\n") - if post and post.get('id') is not None: - log_file.write(f"post_id={post.get('id')}\n") - log_file.write('\n') + seeds = list(getattr(p, "all_seeds", []) or []) + posts = list(getattr(self, "_posts_used_for_generation", [])) + post_urls = ( + list(getattr(self, "_last_post_urls", [])) + if hasattr(self, "_last_post_urls") + else [] + ) + log_path = os.path.join(LOG_DIR, "prompt_sources.txt") + booru = getattr(self, "_current_booru_name", "unknown") + base_prompt = getattr(self, "original_prompt", getattr(p, "prompt", "")) + negative_prompt = getattr(p, "negative_prompt", "") + text_lines = [ + "---", + ( + f"{datetime.now().isoformat()} | booru={booru} | " + f"reuse_cached={getattr(self, '_reuse_cached_posts', False)}" + ), + f"base_prompt={base_prompt}", + ] + if isinstance(negative_prompt, str) and negative_prompt: + text_lines.append(f"negative_prompt={negative_prompt}") + for idx, prompt in enumerate(prompts): + seed = seeds[idx] if idx < len(seeds) else getattr(p, "seed", None) + text_lines.append(f"[{idx+1}] seed={seed}") + text_lines.append(f"prompt={prompt}") + post = posts[idx] if idx < len(posts) else None + source_url = post_urls[idx] if idx < len(post_urls) else None + if not source_url and post: + source_url = get_original_post_url(post) + if not source_url and post and post.get("file_url"): + source_url = post.get("file_url") + if source_url: + text_lines.append(f"source={source_url}") + if post and post.get("id") is not None: + text_lines.append(f"post_id={post.get('id')}") + text_lines.append("") + rb_user_store.append_text_log(log_path, text_lines) try: json_payload = { - 'timestamp': datetime.now().isoformat(), - 'booru': booru, - 'mode': 'img2img' if bool(getattr(self, '_post_use_img2img', False)) else 'txt2img', - 'strict_fetch_enabled': bool(getattr(self, '_strict_img2img_fetch', False)), - 'strict_prefilter_active': bool(getattr(self, '_strict_img2img_active', False)), - 'strict_prefilter_relaxed': bool(getattr(self, '_strict_img2img_relaxed', False)), - 'strict_rejections': list(getattr(self, '_strict_img2img_rejections', [])), - 'kept_post_ids': [post.get('id') for post in posts if isinstance(post, dict)], - 'prompts': prompts, - 'negative_prompt': negative_prompt if isinstance(negative_prompt, str) else None, - 'reuse_cached': bool(getattr(self, '_reuse_cached_posts', False)), + "timestamp": datetime.now().isoformat(), + "booru": booru, + "mode": ( + "img2img" if bool(getattr(self, "_post_use_img2img", False)) else "txt2img" + ), + "strict_fetch_enabled": bool(getattr(self, "_strict_img2img_fetch", False)), + "strict_prefilter_active": bool(getattr(self, "_strict_img2img_active", False)), + "strict_prefilter_relaxed": bool( + getattr(self, "_strict_img2img_relaxed", False) + ), + "strict_rejections": list(getattr(self, "_strict_img2img_rejections", [])), + "kept_post_ids": [post.get("id") for post in posts if isinstance(post, dict)], + "prompts": prompts, + "negative_prompt": ( + negative_prompt if isinstance(negative_prompt, str) else None + ), + "reuse_cached": bool(getattr(self, "_reuse_cached_posts", False)), } - with open(PROMPT_LOG_JSONL, 'a', encoding='utf-8') as jsonl_file: - jsonl_file.write(json.dumps(json_payload, ensure_ascii=False) + '\n') + rb_user_store.append_prompt_log(PROMPT_LOG_JSONL, json_payload) except Exception as exc: print(f"[R Log] Failed to append JSONL prompt record: {exc}") self._posts_used_for_generation = [] @@ -2601,20 +1681,20 @@ def _log_generation_reference(self, p): def _ensure_pil_images_in_processed(self, processed_obj): try: - if hasattr(processed_obj, 'images') and isinstance(processed_obj.images, list): + if hasattr(processed_obj, "images") and isinstance(processed_obj.images, list): for i, im in enumerate(list(processed_obj.images)): pil_im = self._ensure_pil_image(im) if pil_im is not None: processed_obj.images[i] = pil_im # Ensure single image too - if hasattr(processed_obj, 'image'): - processed_obj.image = self._ensure_pil_image(getattr(processed_obj, 'image')) + if hasattr(processed_obj, "image"): + processed_obj.image = self._ensure_pil_image(getattr(processed_obj, "image")) except Exception: pass def _ensure_pil_in_processing(self, p): try: - if hasattr(p, 'init_images') and isinstance(p.init_images, list) and p.init_images: + if hasattr(p, "init_images") and isinstance(p.init_images, list) and p.init_images: for i, im in enumerate(list(p.init_images)): pil_im = self._ensure_pil_image(im) if pil_im is not None: @@ -2667,7 +1747,7 @@ def get_files(self, path): files = [] try: for file in os.listdir(path): - if file.endswith('.txt'): + if file.endswith(".txt"): files.append(file) except FileNotFoundError: print(f"[R] Warn: Dir not found: {path}") @@ -2685,326 +1765,88 @@ def refresh_ser(self): def refresh_rem(self): return _gr_update(choices=self.get_files(USER_REMOVE_DIR)) - def ui(self, is_img2img): - with InputAccordion(False, label="RanbooruX", elem_id=self.elem_id("ra_enable")) as enabled: - booru_list = ["danbooru", "gelbooru", "gelbooru-compatible", "xbooru", "rule34", "safebooru", "konachan", 'yande.re', 'aibooru', 'e621'] - booru = gr.Dropdown(booru_list, label="Booru", value="danbooru") - with gr.Group(visible=False) as gelbooru_credentials_group: - gelbooru_api_key = gr.Textbox(label="Gelbooru API Key", type="password", placeholder="Enter your Gelbooru API key") - gelbooru_user_id = gr.Textbox(label="Gelbooru User ID", placeholder="Enter your Gelbooru user ID") - gelbooru_save_button = gr.Button("Save Credentials to Disk", variant="primary") - gelbooru_saved_message = gr.Markdown("", visible=False) - gelbooru_clear_button = gr.Button("Clear Saved Credentials", visible=False) - with gr.Group(visible=False) as gelbooru_compat_group: - gelbooru_compat_base_url = gr.Textbox(label="Gelbooru-compatible Base URL", placeholder="https://realbooru.com", value=self._gelbooru_compat_base_url) - max_pages = gr.Slider(label="Max Pages (tag search)", minimum=1, maximum=100, value=10, step=1) - gr.Markdown("""## Post"""); post_id = gr.Textbox(lines=1, label="Post ID (Overrides tags/pages)") - gr.Markdown("""## Tags"""); tags = gr.Textbox(lines=1, label="Tags to Search (Pre)"); remove_tags = gr.Textbox(lines=1, label="Tags to Remove (Post)") - mature_rating = gr.Radio(list(RATINGS.get('gelbooru', RATING_TYPES['none'])), label="Mature Rating", value="All") - with gr.Accordion("Removal Filters", open=False): - with gr.Group(): - gr.Markdown("**Danbooru Tag Catalog**") - - use_tag_catalog = gr.Checkbox( - label="Use Danbooru Tag Catalog", - value=bool(self._use_tag_catalog), - info="Enable category-aware filtering and alias resolution.", - ) + def _build_catalog_ui_section(self): + """Tag catalog toggle, file picker, validation/import, and diagnostics. - catalog_source = gr.Radio( - ["Bundled", "Custom file"], - label="Catalog Source", - value=("Custom file" if self._catalog_source == 'custom' else "Bundled"), - visible=bool(self._use_tag_catalog), - ) + Creates the Danbooru Tag Catalog group (toggle, source, custom path, import, + validation, reload, status) and the Platform Diagnostics toggle. Returns the + two components that must appear in the script-args component list. - with gr.Group(visible=bool(self._use_tag_catalog and self._catalog_source == 'custom')) as custom_catalog_group: - catalog_upload = gr.File(label="Upload CSV", file_types=['.csv'], file_count='single') - catalog_path = gr.Textbox( - label="Custom CSV Path", - value=self._custom_catalog_path, - placeholder="/path/to/custom_catalog.csv", - ) - with gr.Row(): - catalog_import_btn = gr.Button("Import Custom Catalog") - catalog_validate_btn = gr.Button("Validate CSV") + Must be called inside ``gr.Group()`` that lives inside the Removal Filters + accordion. + """ + gr.Markdown("**Danbooru Tag Catalog**") - reload_catalog = gr.Button("Reload Catalog", visible=bool(self._use_tag_catalog)) - catalog_status = gr.Markdown(self._tag_catalog_status_text or "Catalog mode: OFF") + use_tag_catalog = gr.Checkbox( + label="Use Danbooru Tag Catalog", + value=bool(self._use_tag_catalog), + info="Enable category-aware filtering and alias resolution.", + ) - self._catalog_status_md = catalog_status - self._tag_diag_md = None + catalog_source = gr.Radio( + ["Bundled", "Custom file"], + label="Catalog Source", + value=("Custom file" if self._catalog_source == "custom" else "Bundled"), + visible=bool(self._use_tag_catalog), + ) - gr.Markdown("**Quick Presets**: apply common filter combinations with one click.") + with gr.Group( + visible=bool(self._use_tag_catalog and self._catalog_source == "custom") + ) as custom_catalog_group: + catalog_upload = gr.File( + label="Upload CSV", file_types=[".csv"], file_count="single" + ) + catalog_path = gr.Textbox( + label="Custom CSV Path", + value=self._custom_catalog_path, + placeholder="/path/to/custom_catalog.csv", + ) + with gr.Row(): + catalog_import_btn = gr.Button("Import Custom Catalog") + catalog_validate_btn = gr.Button("Validate CSV") + reload_catalog = gr.Button( + "Reload Catalog", visible=bool(self._use_tag_catalog) + ) + catalog_status = gr.Markdown( + self._tag_catalog_status_text or "Catalog mode: OFF" + ) + self._catalog_status_md = catalog_status + self._tag_diag_md = None - with gr.Row(): - preset_strip_series = gr.Button("Strip Series/Character") - preset_remove_text = gr.Button("Remove Text-like Tags") - preset_preserve_colors = gr.Button("Preserve Base Colors") - preset_quick_strip = gr.Button("Quick Strip") - with gr.Group(): - gr.Markdown("**Text & Metadata**") - remove_bad_tags = gr.Checkbox( - label="Remove common 'bad' tags", - value=True, - info="Cull frequent watermark, commentary, and UI text tags from prompts." - ) - remove_text_tags = gr.Checkbox( - label="Remove tag/text/commentary metadata", - value=True, - info="Strip speech bubbles, watermark text, and similar metadata from fetched prompts." - ) - with gr.Group(): - gr.Markdown("**Characters & Series**") - remove_artist_tags = gr.Checkbox( - label="Remove artist tags", - value=False, - info="Drop artist credits drawn from the source post." - ) - remove_character_tags = gr.Checkbox( - label="Remove character tags", - value=False, - info="Filter character/franchise tags sourced from metadata." - ) - remove_series_tags = gr.Checkbox( - label="Remove series / franchise tags", - value=False, - info="Ignore franchise/game/anime tags to keep prompts generic." - ) - with gr.Group(): - gr.Markdown("**Clothing & Accessories**") - remove_clothing_tags = gr.Checkbox( - label="Remove clothing tags", - value=False, - info="Omit apparel/accessory tags introduced by the booru." - ) - with gr.Group(): - gr.Markdown("**Furry & Headwear**") - remove_furry_tags = gr.Checkbox( - label="Filter furry/pokemon tags", - value=False, - info="Remove furry, pokemon, and animal trait tags." - ) - remove_headwear_tags = gr.Checkbox( - label="Filter headwear / halo tags", - value=False, - info="Strip hats, halos, and similar head accessories." - ) - with gr.Group(): - gr.Markdown("**Girl Suffix**") - remove_girl_suffix_tags = gr.Checkbox( - label="Filter _girl suffix tags", - value=False, - info="Remove demon_girl, cat_girl, angel_girl and similar *_girl tags (keeps 1girl, 2girls, etc.)." - ) - with gr.Group(): + # --- inner event handlers --------------------------------------------------- - gr.Markdown("**Colors & Traits**") - preserve_hair_eye_colors = gr.Checkbox( - label="Preserve base hair & eye colors", - value=False, - info="Keep your prompt's hair/eye colors while removing conflicting imports." - ) - with gr.Group(): - gr.Markdown("**Subject Constraints**") - restrict_subject_tags = gr.Checkbox( - label="Keep only subject counts", - value=False, - info="Maintain your subject count (e.g., solo/1girl) by removing mismatched tags." - ) - with gr.Group(): - gr.Markdown("**Advanced**") - legacy_filter_toggle = gr.Checkbox( - label="Legacy Tag Filtering (fallback)", - value=False, - info="Use the older removal engine. Leave disabled to use the default normalized filter engine." - ) - personal_choices = self._read_list_file(PERSONAL_REMOVE_FILE) - favorite_choices = self._read_list_file(FAVORITES_FILE) - with gr.Accordion("Personal Lists", open=False): - with gr.Row(): - with gr.Column(): - gr.Markdown("**Personal Removal List**") - personal_remove_dropdown = gr.Dropdown( - choices=personal_choices, - value=personal_choices, - multiselect=True, - label="Removal Tags", - allow_custom_value=False - ) - personal_remove_input = gr.Textbox(label="Add tags", placeholder="comma or newline separated") - with gr.Row(): - personal_add_btn = gr.Button("Add", variant="primary") - personal_remove_btn = gr.Button("Remove Selected") - personal_dedupe_btn = gr.Button("De-duplicate") - with gr.Row(): - personal_import_file = gr.File(label="Import CSV/TXT", file_types=['.txt', '.csv'], visible=True) - personal_export_btn = gr.DownloadButton("Export") - with gr.Column(): - gr.Markdown("**Favorites List**") - favorites_dropdown = gr.Dropdown( - choices=favorite_choices, - value=favorite_choices, - multiselect=True, - label="Favorite Tags", - allow_custom_value=False - ) - favorites_input = gr.Textbox(label="Add favorites", placeholder="comma or newline separated") - with gr.Row(): - favorites_add_btn = gr.Button("Add", variant="primary") - favorites_remove_btn = gr.Button("Remove Selected") - favorites_dedupe_btn = gr.Button("De-duplicate") - with gr.Row(): - favorites_import_file = gr.File(label="Import CSV/TXT", file_types=['.txt', '.csv'], visible=True) - favorites_export_btn = gr.DownloadButton("Export") - shuffle_tags = gr.Checkbox(label="Shuffle tags", value=True) - change_dash = gr.Checkbox(label='Convert "_" to spaces', value=False) - same_prompt = gr.Checkbox(label="Use same prompt for batch", value=False) - fringe_benefits = gr.Checkbox(label="Gelbooru: Fringe Benefits", value=True, visible=False) - limit_tags = gr.Slider(value=1.0, label="Limit tags by %", minimum=0.05, maximum=1.0, step=0.05); max_tags = gr.Slider(value=0, label="Max tags (0=disabled)", minimum=0, maximum=300, step=1) - change_background = gr.Radio(["Don't Change", "Add Detail", "Force Simple", "Force Transparent/White"], label="Change Background", value="Don't Change") - change_color = gr.Radio(["Don't Change", "Force Color", "Force Monochrome"], label="Change Color", value="Don't Change") - sorting_order = gr.Radio(["Random", "Score Descending", "Score Ascending"], label="Sort Order (tag search)", value="Random") - booru.change(get_available_ratings, booru, mature_rating) - booru.change(show_fringe_benefits, booru, fringe_benefits) - booru.change( - self._update_gelbooru_ui_visibility, - inputs=[booru], - outputs=[gelbooru_credentials_group, gelbooru_saved_message, gelbooru_clear_button, gelbooru_api_key, gelbooru_user_id], - queue=False, - ) - booru.change( - self._update_gelbooru_compat_visibility, - inputs=[booru], - outputs=[gelbooru_compat_group, gelbooru_compat_base_url], - queue=False, - ) - gelbooru_compat_base_url.change( - fn=self._ui_set_gelbooru_compat_base_url, - inputs=[gelbooru_compat_base_url], - outputs=[gelbooru_compat_base_url], - queue=False, - ) - gelbooru_save_button.click( - fn=self._ui_save_gelbooru_credentials, - inputs=[gelbooru_api_key, gelbooru_user_id], - outputs=[gelbooru_saved_message, gelbooru_credentials_group, gelbooru_clear_button, gelbooru_api_key, gelbooru_user_id], - queue=False, - ) - gelbooru_clear_button.click( - fn=self._ui_clear_gelbooru_credentials, - inputs=[], - outputs=[gelbooru_saved_message, gelbooru_credentials_group, gelbooru_clear_button, gelbooru_api_key, gelbooru_user_id], - queue=False, - ) - - gr.Markdown("""\n---\n""") - with gr.Group(): - with gr.Accordion("Img2Img / ControlNet", open=False): - use_img2img = gr.Checkbox(label="Use Image for Img2Img", value=False) - use_ip = gr.Checkbox(label="Use Image for ControlNet (Unit 0)", value=False) - denoising = gr.Slider(value=0.75, label="Img2Img Denoising / CN Weight", minimum=0.0, maximum=1.0, step=0.05) - use_last_img = gr.Checkbox(label="Use same image for batch", value=False) - crop_center = gr.Checkbox(label="Crop image to fit target", value=False) - enable_adetailer_support = gr.Checkbox( - label="Enable RanbooruX ADetailer support", - value=False, - info="Run RanbooruX's manual ADetailer integration after img2img when enabled." - ) - reuse_cached_posts = gr.Checkbox( - label="Reuse cached booru posts", - value=False, - info="Leave disabled to fetch fresh images every generation. Enable when you want RanbooruX to reuse the previously cached posts." - ) - with gr.Group(): - with gr.Accordion("File Tags", open=False): - use_search_txt = gr.Checkbox(label="Add line from Search File", value=False); choose_search_txt = gr.Dropdown(self.get_files(USER_SEARCH_DIR), label="Choose Search File", value="", info=f"in '{USER_SEARCH_DIR}'") - search_refresh_btn = gr.Button("Refresh"); use_remove_txt = gr.Checkbox(label="Add tags from Remove File", value=False); choose_remove_txt = gr.Dropdown(self.get_files(USER_REMOVE_DIR), label="Choose Remove File", value="", info=f"in '{USER_REMOVE_DIR}'") - remove_refresh_btn = gr.Button("Refresh") - with gr.Group(): - with gr.Accordion("Extra Prompt Modes", open=False): - with gr.Box(): mix_prompt = gr.Checkbox(label="Mix tags from multiple posts", value=False); mix_amount = gr.Slider(value=2, label="Posts to mix", minimum=2, maximum=10, step=1) - with gr.Box(): chaos_mode = gr.Radio(["None", "Shuffle All", "Shuffle Negative"], label="Tag Shuffling (Chaos)", value="None"); chaos_amount = gr.Slider(value=0.5, label="Chaos Amount %", minimum=0.1, maximum=1.0, step=0.05) - with gr.Box(): use_same_seed = gr.Checkbox(label="Use same seed for batch", value=False); use_cache = gr.Checkbox(label="Cache Booru API requests", value=True); log_prompt_sources = gr.Checkbox(label="Log image sources/prompts to txt", value=False, info="When enabled, RanbooruX appends a log entry mapping seeds and prompts to the source posts.") - initial_lora_scan = self._scan_loranado_candidates('') - initial_lora_choices = initial_lora_scan.get('detected_names') or initial_lora_scan.get('all_names') or [] - initial_lora_status = initial_lora_scan.get('message', "No LoRAs found.") - if initial_lora_scan.get('all_names'): - if initial_lora_scan.get('detected_names'): - initial_lora_status = ( - f"Detected {len(initial_lora_scan['detected_names'])} PonyXL-compatible LoRAs." - ) - else: - initial_lora_status = ( - f"No PonyXL markers detected; using all {len(initial_lora_scan['all_names'])} LoRAs." - ) - - with InputAccordion(False, label="LoRAnado", elem_id=self.elem_id("lo_enable")) as lora_enabled: - with gr.Box(): lora_lock_prev = gr.Checkbox(label="Lock previous LoRAs", value=False); lora_folder = gr.Textbox(lines=1, label="LoRAs Subfolder", placeholder="e.g., 'Characters' or empty"); lora_amount = gr.Slider(value=1, label="LoRAs Amount", minimum=1, maximum=10, step=1) - with gr.Box(): lora_min = gr.Slider(value=0.6, label="Min LoRAs Weight", minimum=-1.0, maximum=1.5, step=0.1); lora_max = gr.Slider(value=1.0, label="Max LoRAs Weight", minimum=-1.0, maximum=1.5, step=0.1); lora_custom_weights = gr.Textbox(lines=1, label="Custom Weights (optional)", placeholder="e.g., 0.8, 0.5, 1.0") - with gr.Box(): - lora_auto_detect_pony = gr.Checkbox( - label="Auto-detect PonyXL-compatible LoRAs", - value=True, - info="Scans LoRA filenames and safetensors metadata for PonyXL markers." - ) - with gr.Row(): - lora_scan_btn = gr.Button("Scan LoRAs") - lora_select_all_btn = gr.Button("Select All Compatible") - lora_detected_loras = gr.Dropdown( - choices=initial_lora_choices, - value=initial_lora_choices, - multiselect=True, - label="Detected LoRAs (toggle enabled)", - info="Only selected entries are eligible for LoRAnado when auto-detect is enabled." - ) - lora_blacklist = gr.Dropdown( - choices=initial_lora_choices, - value=[], - multiselect=True, - label="LoRAnado blacklist", - info="Blacklisted LoRAs are excluded from random selection." - ) - lora_detect_status = gr.Markdown(initial_lora_status) - search_refresh_btn.click(fn=self.refresh_ser, inputs=[], outputs=[choose_search_txt]) - remove_refresh_btn.click(fn=self.refresh_rem, inputs=[], outputs=[choose_remove_txt]) - personal_add_btn.click(fn=self._ui_add_personal_tags, inputs=[personal_remove_input, personal_remove_dropdown], outputs=[personal_remove_dropdown, personal_remove_input], queue=False) - personal_remove_btn.click(fn=self._ui_remove_personal_tags, inputs=[personal_remove_dropdown], outputs=[personal_remove_dropdown], queue=False) - personal_dedupe_btn.click(fn=self._ui_dedupe_personal_list, inputs=[], outputs=[personal_remove_dropdown], queue=False) - personal_import_file.upload(fn=self._ui_import_personal_list, inputs=[personal_import_file], outputs=[personal_remove_dropdown, personal_import_file], queue=False) - personal_export_btn.click(fn=self._ui_export_personal_list, inputs=[], outputs=None, queue=False) - - favorites_add_btn.click(fn=self._ui_add_favorite_tags, inputs=[favorites_input, favorites_dropdown], outputs=[favorites_dropdown, favorites_input], queue=False) - favorites_remove_btn.click(fn=self._ui_remove_favorite_tags, inputs=[favorites_dropdown], outputs=[favorites_dropdown], queue=False) - favorites_dedupe_btn.click(fn=self._ui_dedupe_favorite_list, inputs=[], outputs=[favorites_dropdown], queue=False) - favorites_import_file.upload(fn=self._ui_import_favorite_list, inputs=[favorites_import_file], outputs=[favorites_dropdown, favorites_import_file], queue=False) - favorites_export_btn.click(fn=self._ui_export_favorite_list, inputs=[], outputs=None, queue=False) - - def _ui_toggle_catalog(enabled: bool): - self._use_tag_catalog = bool(enabled) - message = "Catalog mode: OFF" - if self._use_tag_catalog: - ok, message = self._load_tag_catalog() - if not ok: - self._catalog = NoopCatalog() - else: - self._catalog = NoopCatalog() - self._tag_catalog_diag = {} - self._update_tag_diag() - self._tag_catalog_status_text = message if self._use_tag_catalog else self._format_catalog_status() - self._save_tag_catalog_preferences() - return ( - _gr_component_update(gr.Radio, visible=self._use_tag_catalog, value=("Custom file" if self._catalog_source == 'custom' else "Bundled")), - _gr_component_update(gr.Group, visible=bool(self._use_tag_catalog and self._catalog_source == 'custom')), - _gr_component_update(gr.Textbox, visible=bool(self._use_tag_catalog and self._catalog_source == 'custom'), value=self._custom_catalog_path), - _gr_component_update(gr.Button,visible=self._use_tag_catalog), - _gr_component_update(gr.Markdown,value=self._tag_catalog_status_text), + def _ui_toggle_catalog(enabled: bool): + self._use_tag_catalog = bool(enabled) + if not self._use_tag_catalog: + self._set_catalog_source("bundled") + ok, message = self._load_tag_catalog() + if not ok: + self._catalog = NoopCatalog() + self._tag_catalog_status_text = message + self._save_tag_catalog_preferences() + return ( + _gr_component_update( + gr.Radio, + visible=self._use_tag_catalog, + value=("Custom file" if self._catalog_source == "custom" else "Bundled"), + ), + _gr_component_update( + gr.Group, + visible=bool(self._use_tag_catalog and self._catalog_source == "custom"), + ), + _gr_component_update( + gr.Textbox, + visible=bool(self._use_tag_catalog and self._catalog_source == "custom"), + value=self._custom_catalog_path, + ), + _gr_component_update(gr.Button, visible=self._use_tag_catalog), + _gr_component_update(gr.Markdown, value=self._tag_catalog_status_text), ) def _ui_set_catalog_source(source_label: str): - source = 'custom' if (source_label or '') == "Custom file" else 'bundled' + source = "custom" if (source_label or "") == "Custom file" else "bundled" self._set_catalog_source(source) if self._use_tag_catalog: ok, message = self._load_tag_catalog() @@ -3018,15 +1860,22 @@ def _ui_set_catalog_source(source_label: str): self._save_tag_catalog_preferences() self._update_tag_diag() return ( - _gr_component_update(gr.Group, visible=bool(self._use_tag_catalog and self._catalog_source == 'custom')), - _gr_component_update(gr.Textbox, visible=bool(self._use_tag_catalog and self._catalog_source == 'custom'), value=self._custom_catalog_path), + _gr_component_update( + gr.Group, + visible=bool(self._use_tag_catalog and self._catalog_source == "custom"), + ), + _gr_component_update( + gr.Textbox, + visible=bool(self._use_tag_catalog and self._catalog_source == "custom"), + value=self._custom_catalog_path, + ), _gr_component_update(gr.Markdown, value=self._tag_catalog_status_text), ) def _ui_set_catalog_path(path_value: str): - self._custom_catalog_path = (path_value or '').strip() + self._custom_catalog_path = (path_value or "").strip() self._tag_catalog_path = self._custom_catalog_path - if self._use_tag_catalog and self._catalog_source == 'custom': + if self._use_tag_catalog and self._catalog_source == "custom": if self._custom_catalog_path: ok, message = self._load_tag_catalog() if not ok: @@ -3042,8 +1891,12 @@ def _ui_set_catalog_path(path_value: str): self._save_tag_catalog_preferences() self._update_tag_diag() return ( - _gr_component_update(gr.Textbox,value=self._custom_catalog_path, visible=bool(self._use_tag_catalog and self._catalog_source == 'custom')), - _gr_component_update(gr.Markdown,value=self._tag_catalog_status_text), + _gr_component_update( + gr.Textbox, + value=self._custom_catalog_path, + visible=bool(self._use_tag_catalog and self._catalog_source == "custom"), + ), + _gr_component_update(gr.Markdown, value=self._tag_catalog_status_text), ) def _ui_reload_catalog(): @@ -3058,7 +1911,7 @@ def _ui_reload_catalog(): self._tag_catalog_status_text = self._format_catalog_status() self._save_tag_catalog_preferences() self._update_tag_diag() - return _gr_component_update(gr.Markdown,value=self._tag_catalog_status_text) + return _gr_component_update(gr.Markdown, value=self._tag_catalog_status_text) def _ui_catalog_upload(uploaded): guessed_path = self._catalog_path_from_upload(uploaded) @@ -3070,12 +1923,16 @@ def _ui_catalog_upload(uploaded): else: msg = self._tag_catalog_status_text return ( - _gr_component_update(gr.Textbox, value=self._custom_catalog_path, visible=bool(self._use_tag_catalog and self._catalog_source == 'custom')), + _gr_component_update( + gr.Textbox, + value=self._custom_catalog_path, + visible=bool(self._use_tag_catalog and self._catalog_source == "custom"), + ), _gr_component_update(gr.Markdown, value=msg), ) def _ui_validate_catalog(path_value, uploaded): - candidate = (path_value or '').strip() or self._catalog_path_from_upload(uploaded) + candidate = (path_value or "").strip() or self._catalog_path_from_upload(uploaded) ok, message = self._validate_csv_format(candidate) status = f"Validation passed: {message}" if ok else f"Validation failed: {message}" return _gr_component_update(gr.Markdown, value=status) @@ -3084,9 +1941,19 @@ def _ui_import_custom_catalog(uploaded, path_value): ok, message = self._import_custom_catalog(uploaded, path_hint=path_value) if not ok: return ( - _gr_component_update(gr.Radio, value=("Custom file" if self._catalog_source == 'custom' else "Bundled")), - _gr_component_update(gr.Group, visible=bool(self._use_tag_catalog and self._catalog_source == 'custom')), - _gr_component_update(gr.Textbox, value=self._custom_catalog_path, visible=bool(self._use_tag_catalog and self._catalog_source == 'custom')), + _gr_component_update( + gr.Radio, + value=("Custom file" if self._catalog_source == "custom" else "Bundled"), + ), + _gr_component_update( + gr.Group, + visible=bool(self._use_tag_catalog and self._catalog_source == "custom"), + ), + _gr_component_update( + gr.Textbox, + value=self._custom_catalog_path, + visible=bool(self._use_tag_catalog and self._catalog_source == "custom"), + ), _gr_component_update(gr.Markdown, value=message), ) self._tag_catalog_status_text = self._format_catalog_status() @@ -3098,10 +1965,18 @@ def _ui_import_custom_catalog(uploaded, path_value): _gr_component_update(gr.Markdown, value=self._tag_catalog_status_text), ) + # --- event wiring ----------------------------------------------------------- + use_tag_catalog.change( fn=_ui_toggle_catalog, inputs=[use_tag_catalog], - outputs=[catalog_source, custom_catalog_group, catalog_path, reload_catalog, catalog_status], + outputs=[ + catalog_source, + custom_catalog_group, + catalog_path, + reload_catalog, + catalog_status, + ], queue=False, ) catalog_source.change( @@ -3141,30 +2016,240 @@ def _ui_import_custom_catalog(uploaded, path_value): queue=False, ) + # --- Platform Diagnostics --------------------------------------------------- + + diagnostics_visible_state = gr.State(False) + diagnostics_toggle_btn = gr.Button("Show Platform Diagnostics") + diagnostics_md = gr.Markdown("", visible=False) + diagnostics_toggle_btn.click( + fn=self._toggle_platform_diagnostics, + inputs=[diagnostics_visible_state], + outputs=[diagnostics_visible_state, diagnostics_md, diagnostics_toggle_btn], + queue=False, + ) + + return use_tag_catalog, catalog_path + + def _build_lora_ui_section(self): + """LoRAnado controls, auto-detect, detected LoRAs, and blacklist. + + Performs the initial LoRA scan, creates all LoRAnado widgets inside + ``InputAccordion``, and wires up the change/click events. Returns the + components that must appear in the script-args component list. + """ + initial_lora_scan = self._scan_loranado_candidates("") + initial_lora_choices = ( + initial_lora_scan.get("detected_names") or initial_lora_scan.get("all_names") or [] + ) + initial_lora_status = initial_lora_scan.get("message", "No LoRAs found.") + if initial_lora_scan.get("all_names"): + if initial_lora_scan.get("detected_names"): + initial_lora_status = ( + f"Detected {len(initial_lora_scan['detected_names'])} PonyXL-compatible LoRAs." + ) + else: + initial_lora_status = f"No PonyXL markers detected; using all {len(initial_lora_scan['all_names'])} LoRAs." + + with InputAccordion( + False, label="LoRAnado", elem_id=self.elem_id("lo_enable") + ) as lora_enabled: + with gr.Box(): + lora_lock_prev = gr.Checkbox(label="Lock previous LoRAs", value=False) + lora_folder = gr.Textbox( + lines=1, label="LoRAs Subfolder", placeholder="e.g., 'Characters' or empty" + ) + lora_amount = gr.Slider( + value=1, label="LoRAs Amount", minimum=1, maximum=10, step=1 + ) + with gr.Box(): + lora_min = gr.Slider( + value=0.6, label="Min LoRAs Weight", minimum=-1.0, maximum=1.5, step=0.1 + ) + lora_max = gr.Slider( + value=1.0, label="Max LoRAs Weight", minimum=-1.0, maximum=1.5, step=0.1 + ) + lora_custom_weights = gr.Textbox( + lines=1, label="Custom Weights (optional)", placeholder="e.g., 0.8, 0.5, 1.0" + ) + with gr.Box(): + lora_auto_detect_pony = gr.Checkbox( + label="Auto-detect PonyXL-compatible LoRAs", + value=True, + info="Scans LoRA filenames and safetensors metadata for PonyXL markers.", + ) + with gr.Row(): + lora_scan_btn = gr.Button("Scan LoRAs") + lora_select_all_btn = gr.Button("Select All Compatible") + lora_detected_loras = gr.Dropdown( + choices=initial_lora_choices, + value=initial_lora_choices, + multiselect=True, + label="Detected LoRAs (toggle enabled)", + info="Only selected entries are eligible for LoRAnado when auto-detect is enabled.", + ) + lora_blacklist = gr.Dropdown( + choices=initial_lora_choices, + value=[], + multiselect=True, + label="LoRAnado blacklist", + info="Blacklisted LoRAs are excluded from random selection.", + ) + lora_detect_status = gr.Markdown(initial_lora_status) + + # --- LoRA event wiring ---------------------------------------------------- + + lora_folder.change( + fn=self._ui_refresh_loranado_controls, + inputs=[lora_folder, lora_auto_detect_pony, lora_detected_loras, lora_blacklist], + outputs=[lora_detected_loras, lora_blacklist, lora_detect_status], + queue=False, + ) + lora_auto_detect_pony.change( + fn=self._ui_refresh_loranado_controls, + inputs=[lora_folder, lora_auto_detect_pony, lora_detected_loras, lora_blacklist], + outputs=[lora_detected_loras, lora_blacklist, lora_detect_status], + queue=False, + ) + lora_scan_btn.click( + fn=self._ui_refresh_loranado_controls, + inputs=[lora_folder, lora_auto_detect_pony, lora_detected_loras, lora_blacklist], + outputs=[lora_detected_loras, lora_blacklist, lora_detect_status], + queue=False, + ) + lora_select_all_btn.click( + fn=self._ui_select_all_loranado, + inputs=[lora_folder, lora_auto_detect_pony, lora_blacklist], + outputs=[lora_detected_loras, lora_detect_status], + queue=False, + ) + + return ( + lora_enabled, + lora_folder, + lora_amount, + lora_min, + lora_max, + lora_custom_weights, + lora_lock_prev, + lora_auto_detect_pony, + lora_detected_loras, + lora_blacklist, + ) + + def _build_filter_ui_section(self): + """Removal toggle checkboxes, presets, and Quick Strip. + + Creates the Quick Presets buttons and all removal-filter checkboxes + (Text & Metadata, Characters & Series, Clothing, Furry & Headwear, + Girl Suffix, Colors & Traits, Subject Constraints). Wires up the + preset click events. Must be called inside ``gr.Accordion("Removal Filters")`` + after the catalog section. Returns the 11 filter components that + appear in the script-args component list. + """ + gr.Markdown("**Quick Presets**: apply common filter combinations with one click.") + + with gr.Row(): + preset_strip_series = gr.Button("Strip Series/Character") + preset_remove_text = gr.Button("Remove Text-like Tags") + preset_preserve_colors = gr.Button("Preserve Base Colors") + preset_quick_strip = gr.Button("Quick Strip") + with gr.Group(): + gr.Markdown("**Text & Metadata**") + remove_bad_tags = gr.Checkbox( + label="Remove common 'bad' tags", + value=True, + info="Cull frequent watermark, commentary, and UI text tags from prompts.", + ) + remove_text_tags = gr.Checkbox( + label="Remove tag/text/commentary metadata", + value=True, + info="Strip speech bubbles, watermark text, and similar metadata from fetched prompts.", + ) + with gr.Group(): + gr.Markdown("**Characters & Series**") + remove_artist_tags = gr.Checkbox( + label="Remove artist tags", + value=False, + info="Drop artist credits drawn from the source post.", + ) + remove_character_tags = gr.Checkbox( + label="Remove character tags", + value=False, + info="Filter character/franchise tags sourced from metadata.", + ) + remove_series_tags = gr.Checkbox( + label="Remove series / franchise tags", + value=False, + info="Ignore franchise/game/anime tags to keep prompts generic.", + ) + with gr.Group(): + gr.Markdown("**Clothing & Accessories**") + remove_clothing_tags = gr.Checkbox( + label="Remove clothing tags", + value=False, + info="Omit apparel/accessory tags introduced by the booru.", + ) + with gr.Group(): + gr.Markdown("**Furry & Headwear**") + remove_furry_tags = gr.Checkbox( + label="Filter furry/pokemon tags", + value=False, + info="Remove furry, pokemon, and animal trait tags.", + ) + remove_headwear_tags = gr.Checkbox( + label="Filter headwear / halo tags", + value=False, + info="Strip hats, halos, and similar head accessories.", + ) + with gr.Group(): + gr.Markdown("**Girl Suffix**") + remove_girl_suffix_tags = gr.Checkbox( + label="Filter _girl suffix tags", + value=False, + info="Remove demon_girl, cat_girl, angel_girl and similar *_girl tags (keeps 1girl, 2girls, etc.).", + ) + with gr.Group(): + + gr.Markdown("**Colors & Traits**") + preserve_hair_eye_colors = gr.Checkbox( + label="Preserve base hair & eye colors", + value=False, + info="Keep your prompt's hair/eye colors while removing conflicting imports.", + ) + with gr.Group(): + gr.Markdown("**Subject Constraints**") + restrict_subject_tags = gr.Checkbox( + label="Keep only subject counts", + value=False, + info="Maintain your subject count (e.g., solo/1girl) by removing mismatched tags.", + ) + + # --- preset wiring --------------------------------------------------------- + preset_strip_series.click( fn=lambda: ( - _gr_component_update(gr.Checkbox,value=True), - _gr_component_update(gr.Checkbox,value=True), - _gr_component_update(gr.Checkbox,value=True) + _gr_component_update(gr.Checkbox, value=True), + _gr_component_update(gr.Checkbox, value=True), + _gr_component_update(gr.Checkbox, value=True), ), inputs=[], outputs=[remove_series_tags, remove_character_tags, remove_artist_tags], - queue=False + queue=False, ) preset_remove_text.click( fn=lambda: ( - _gr_component_update(gr.Checkbox,value=True), - _gr_component_update(gr.Checkbox,value=True) + _gr_component_update(gr.Checkbox, value=True), + _gr_component_update(gr.Checkbox, value=True), ), inputs=[], outputs=[remove_text_tags, remove_bad_tags], - queue=False + queue=False, ) preset_preserve_colors.click( - fn=lambda: _gr_component_update(gr.Checkbox,value=True), + fn=lambda: _gr_component_update(gr.Checkbox, value=True), inputs=[], outputs=[preserve_hair_eye_colors], - queue=False + queue=False, ) preset_quick_strip.click( fn=lambda: tuple(_gr_component_update(gr.Checkbox, value=True) for _ in range(11)), @@ -3185,77 +2270,461 @@ def _ui_import_custom_catalog(uploaded, path_value): queue=False, ) - lora_folder.change( - fn=self._ui_refresh_loranado_controls, - inputs=[lora_folder, lora_auto_detect_pony, lora_detected_loras, lora_blacklist], - outputs=[lora_detected_loras, lora_blacklist, lora_detect_status], + return ( + remove_bad_tags, + remove_text_tags, + remove_artist_tags, + remove_character_tags, + remove_series_tags, + remove_clothing_tags, + remove_furry_tags, + remove_headwear_tags, + remove_girl_suffix_tags, + preserve_hair_eye_colors, + restrict_subject_tags, + ) + + def _build_personal_lists_ui_section(self): + """Search/remove file management with refresh buttons (File Tags accordion). + + Creates the File Tags accordion containing search-file and remove-file + dropdowns with Refresh buttons. Wires the refresh click events. Returns + the six components needed in the script-args list. + """ + with gr.Accordion("File Tags", open=False): + use_search_txt = gr.Checkbox(label="Add line from Search File", value=False) + choose_search_txt = gr.Dropdown( + self.get_files(USER_SEARCH_DIR), + label="Choose Search File", + value="", + info=f"in '{USER_SEARCH_DIR}'", + ) + search_refresh_btn = gr.Button("Refresh") + use_remove_txt = gr.Checkbox(label="Add tags from Remove File", value=False) + choose_remove_txt = gr.Dropdown( + self.get_files(USER_REMOVE_DIR), + label="Choose Remove File", + value="", + info=f"in '{USER_REMOVE_DIR}'", + ) + remove_refresh_btn = gr.Button("Refresh") + + search_refresh_btn.click(fn=self.refresh_ser, inputs=[], outputs=[choose_search_txt]) + remove_refresh_btn.click(fn=self.refresh_rem, inputs=[], outputs=[choose_remove_txt]) + + return ( + use_search_txt, + use_remove_txt, + choose_search_txt, + choose_remove_txt, + search_refresh_btn, + remove_refresh_btn, + ) + + def ui(self, is_img2img): + with InputAccordion(False, label="RanbooruX", elem_id=self.elem_id("ra_enable")) as enabled: + booru_list = [ + "danbooru", + "gelbooru", + "gelbooru-compatible", + "xbooru", + "rule34", + "safebooru", + "konachan", + "yande.re", + "aibooru", + "e621", + ] + booru = gr.Dropdown(booru_list, label="Booru", value="danbooru") + with gr.Group(visible=False) as gelbooru_credentials_group: + gelbooru_api_key = gr.Textbox( + label="Gelbooru API Key", + type="password", + placeholder="Enter your Gelbooru API key", + ) + gelbooru_user_id = gr.Textbox( + label="Gelbooru User ID", placeholder="Enter your Gelbooru user ID" + ) + gelbooru_save_button = gr.Button("Save Credentials to Disk", variant="primary") + gelbooru_saved_message = gr.Markdown("", visible=False) + gelbooru_clear_button = gr.Button("Clear Saved Credentials", visible=False) + with gr.Group(visible=False) as gelbooru_compat_group: + gelbooru_compat_base_url = gr.Textbox( + label="Gelbooru-compatible Base URL", + placeholder="https://realbooru.com", + value=self._gelbooru_compat_base_url, + ) + max_pages = gr.Slider( + label="Max Pages (tag search)", minimum=1, maximum=100, value=10, step=1 + ) + gr.Markdown("""## Post""") + post_id = gr.Textbox(lines=1, label="Post ID (Overrides tags/pages)") + gr.Markdown("""## Tags""") + tags = gr.Textbox(lines=1, label="Tags to Search (Pre)") + remove_tags = gr.Textbox(lines=1, label="Tags to Remove (Post)") + mature_rating = gr.Radio( + list(RATINGS.get("gelbooru", RATING_TYPES["none"])), + label="Mature Rating", + value="All", + ) + with gr.Accordion("Removal Filters", open=False): + with gr.Group(): + use_tag_catalog, catalog_path = self._build_catalog_ui_section() + + ( + remove_bad_tags, + remove_text_tags, + remove_artist_tags, + remove_character_tags, + remove_series_tags, + remove_clothing_tags, + remove_furry_tags, + remove_headwear_tags, + remove_girl_suffix_tags, + preserve_hair_eye_colors, + restrict_subject_tags, + ) = self._build_filter_ui_section() + personal_choices = self._read_list_file(PERSONAL_REMOVE_FILE) + favorite_choices = self._read_list_file(FAVORITES_FILE) + with gr.Accordion("Personal Lists", open=False): + with gr.Row(): + with gr.Column(): + gr.Markdown("**Personal Removal List**") + personal_remove_dropdown = gr.Dropdown( + choices=personal_choices, + value=personal_choices, + multiselect=True, + label="Removal Tags", + allow_custom_value=False, + ) + personal_remove_input = gr.Textbox( + label="Add tags", placeholder="comma or newline separated" + ) + with gr.Row(): + personal_add_btn = gr.Button("Add", variant="primary") + personal_remove_btn = gr.Button("Remove Selected") + personal_dedupe_btn = gr.Button("De-duplicate") + with gr.Row(): + personal_import_file = gr.File( + label="Import CSV/TXT", file_types=[".txt", ".csv"], visible=True + ) + personal_export_btn = gr.DownloadButton("Export") + with gr.Column(): + gr.Markdown("**Favorites List**") + favorites_dropdown = gr.Dropdown( + choices=favorite_choices, + value=favorite_choices, + multiselect=True, + label="Favorite Tags", + allow_custom_value=False, + ) + favorites_input = gr.Textbox( + label="Add favorites", placeholder="comma or newline separated" + ) + with gr.Row(): + favorites_add_btn = gr.Button("Add", variant="primary") + favorites_remove_btn = gr.Button("Remove Selected") + favorites_dedupe_btn = gr.Button("De-duplicate") + with gr.Row(): + favorites_import_file = gr.File( + label="Import CSV/TXT", file_types=[".txt", ".csv"], visible=True + ) + favorites_export_btn = gr.DownloadButton("Export") + shuffle_tags = gr.Checkbox(label="Shuffle tags", value=True) + change_dash = gr.Checkbox(label='Convert "_" to spaces', value=False) + same_prompt = gr.Checkbox(label="Use same prompt for batch", value=False) + fringe_benefits = gr.Checkbox( + label="Gelbooru: Fringe Benefits", value=True, visible=False + ) + limit_tags = gr.Slider( + value=1.0, label="Limit tags by %", minimum=0.05, maximum=1.0, step=0.05 + ) + max_tags = gr.Slider( + value=0, label="Max tags (0=disabled)", minimum=0, maximum=300, step=1 + ) + change_background = gr.Radio( + ["Don't Change", "Add Detail", "Force Simple", "Force Transparent/White"], + label="Change Background", + value="Don't Change", + ) + change_color = gr.Radio( + ["Don't Change", "Force Color", "Force Monochrome"], + label="Change Color", + value="Don't Change", + ) + sorting_order = gr.Radio( + ["Random", "Score Descending", "Score Ascending"], + label="Sort Order (tag search)", + value="Random", + ) + booru.change(get_available_ratings, booru, mature_rating) + booru.change(show_fringe_benefits, booru, fringe_benefits) + booru.change( + self._update_gelbooru_ui_visibility, + inputs=[booru], + outputs=[ + gelbooru_credentials_group, + gelbooru_saved_message, + gelbooru_clear_button, + gelbooru_api_key, + gelbooru_user_id, + ], + queue=False, + ) + booru.change( + self._update_gelbooru_compat_visibility, + inputs=[booru], + outputs=[gelbooru_compat_group, gelbooru_compat_base_url], + queue=False, + ) + gelbooru_compat_base_url.change( + fn=self._ui_set_gelbooru_compat_base_url, + inputs=[gelbooru_compat_base_url], + outputs=[gelbooru_compat_base_url], + queue=False, + ) + gelbooru_save_button.click( + fn=self._ui_save_gelbooru_credentials, + inputs=[gelbooru_api_key, gelbooru_user_id], + outputs=[ + gelbooru_saved_message, + gelbooru_credentials_group, + gelbooru_clear_button, + gelbooru_api_key, + gelbooru_user_id, + ], + queue=False, + ) + gelbooru_clear_button.click( + fn=self._ui_clear_gelbooru_credentials, + inputs=[], + outputs=[ + gelbooru_saved_message, + gelbooru_credentials_group, + gelbooru_clear_button, + gelbooru_api_key, + gelbooru_user_id, + ], + queue=False, + ) + + gr.Markdown("""\n---\n""") + with gr.Group(): + with gr.Accordion("Img2Img / ControlNet", open=False): + use_img2img = gr.Checkbox(label="Use Image for Img2Img", value=False) + use_ip = gr.Checkbox(label="Use Image for ControlNet (Unit 0)", value=False) + denoising = gr.Slider( + value=0.75, + label="Img2Img Denoising / CN Weight", + minimum=0.0, + maximum=1.0, + step=0.05, + ) + use_last_img = gr.Checkbox(label="Use same image for batch", value=False) + crop_center = gr.Checkbox(label="Crop image to fit target", value=False) + enable_adetailer_support = gr.Checkbox( + label="Enable RanbooruX ADetailer support", + value=False, + info="Run RanbooruX's manual ADetailer integration after img2img when enabled.", + ) + reuse_cached_posts = gr.Checkbox( + label="Reuse cached booru posts", + value=False, + info="Leave disabled to fetch fresh images every generation. Enable when you want RanbooruX to reuse the previously cached posts.", + ) + with gr.Group(): + ( + use_search_txt, + use_remove_txt, + choose_search_txt, + choose_remove_txt, + search_refresh_btn, + remove_refresh_btn, + ) = self._build_personal_lists_ui_section() + with gr.Group(): + with gr.Accordion("Extra Prompt Modes", open=False): + with gr.Box(): + mix_prompt = gr.Checkbox(label="Mix tags from multiple posts", value=False) + mix_amount = gr.Slider( + value=2, label="Posts to mix", minimum=2, maximum=10, step=1 + ) + with gr.Box(): + chaos_mode = gr.Radio( + ["None", "Shuffle All", "Shuffle Negative"], + label="Tag Shuffling (Chaos)", + value="None", + ) + chaos_amount = gr.Slider( + value=0.5, label="Chaos Amount %", minimum=0.1, maximum=1.0, step=0.05 + ) + with gr.Box(): + use_same_seed = gr.Checkbox(label="Use same seed for batch", value=False) + use_cache = gr.Checkbox(label="Cache Booru API requests", value=True) + log_prompt_sources = gr.Checkbox( + label="Log image sources/prompts to txt", + value=False, + info="When enabled, RanbooruX appends a log entry mapping seeds and prompts to the source posts.", + ) + ( + lora_enabled, + lora_folder, + lora_amount, + lora_min, + lora_max, + lora_custom_weights, + lora_lock_prev, + lora_auto_detect_pony, + lora_detected_loras, + lora_blacklist, + ) = self._build_lora_ui_section() + personal_add_btn.click( + fn=self._ui_add_personal_tags, + inputs=[personal_remove_input, personal_remove_dropdown], + outputs=[personal_remove_dropdown, personal_remove_input], queue=False, ) - lora_auto_detect_pony.change( - fn=self._ui_refresh_loranado_controls, - inputs=[lora_folder, lora_auto_detect_pony, lora_detected_loras, lora_blacklist], - outputs=[lora_detected_loras, lora_blacklist, lora_detect_status], + personal_remove_btn.click( + fn=self._ui_remove_personal_tags, + inputs=[personal_remove_dropdown], + outputs=[personal_remove_dropdown], queue=False, ) - lora_scan_btn.click( - fn=self._ui_refresh_loranado_controls, - inputs=[lora_folder, lora_auto_detect_pony, lora_detected_loras, lora_blacklist], - outputs=[lora_detected_loras, lora_blacklist, lora_detect_status], + personal_dedupe_btn.click( + fn=self._ui_dedupe_personal_list, + inputs=[], + outputs=[personal_remove_dropdown], queue=False, ) - lora_select_all_btn.click( - fn=self._ui_select_all_loranado, - inputs=[lora_folder, lora_auto_detect_pony, lora_blacklist], - outputs=[lora_detected_loras, lora_detect_status], + personal_import_file.upload( + fn=self._ui_import_personal_list, + inputs=[personal_import_file], + outputs=[personal_remove_dropdown, personal_import_file], queue=False, ) + personal_export_btn.click( + fn=self._ui_export_personal_list, inputs=[], outputs=None, queue=False + ) - diagnostics_visible_state = gr.State(False) - diagnostics_toggle_btn = gr.Button("Show Platform Diagnostics") - diagnostics_md = gr.Markdown("", visible=False) - diagnostics_toggle_btn.click( - fn=self._toggle_platform_diagnostics, - inputs=[diagnostics_visible_state], - outputs=[diagnostics_visible_state, diagnostics_md, diagnostics_toggle_btn], + favorites_add_btn.click( + fn=self._ui_add_favorite_tags, + inputs=[favorites_input, favorites_dropdown], + outputs=[favorites_dropdown, favorites_input], + queue=False, + ) + favorites_remove_btn.click( + fn=self._ui_remove_favorite_tags, + inputs=[favorites_dropdown], + outputs=[favorites_dropdown], queue=False, ) + favorites_dedupe_btn.click( + fn=self._ui_dedupe_favorite_list, inputs=[], outputs=[favorites_dropdown], queue=False + ) + favorites_import_file.upload( + fn=self._ui_import_favorite_list, + inputs=[favorites_import_file], + outputs=[favorites_dropdown, favorites_import_file], + queue=False, + ) + favorites_export_btn.click( + fn=self._ui_export_favorite_list, inputs=[], outputs=None, queue=False + ) - return [enabled, tags, booru, gelbooru_api_key, gelbooru_user_id, gelbooru_compat_base_url, remove_bad_tags, max_pages, change_dash, same_prompt, fringe_benefits, remove_tags, use_img2img, denoising, use_last_img, change_background, change_color, shuffle_tags, post_id, mix_prompt, mix_amount, chaos_mode, chaos_amount, limit_tags, max_tags, sorting_order, mature_rating, lora_folder, lora_amount, lora_min, lora_max, lora_enabled, lora_custom_weights, lora_lock_prev, use_ip, use_search_txt, use_remove_txt, choose_search_txt, choose_remove_txt, search_refresh_btn, remove_refresh_btn, crop_center, enable_adetailer_support, use_same_seed, reuse_cached_posts, use_cache, log_prompt_sources, remove_artist_tags, remove_character_tags, remove_clothing_tags, remove_text_tags, restrict_subject_tags, remove_furry_tags, remove_headwear_tags, remove_girl_suffix_tags, preserve_hair_eye_colors, remove_series_tags, legacy_filter_toggle, use_tag_catalog, catalog_path, lora_auto_detect_pony, lora_detected_loras, lora_blacklist] + components = [ + enabled, + tags, + booru, + gelbooru_api_key, + gelbooru_user_id, + gelbooru_compat_base_url, + remove_bad_tags, + max_pages, + change_dash, + same_prompt, + fringe_benefits, + remove_tags, + use_img2img, + denoising, + use_last_img, + change_background, + change_color, + shuffle_tags, + post_id, + mix_prompt, + mix_amount, + chaos_mode, + chaos_amount, + limit_tags, + max_tags, + sorting_order, + mature_rating, + lora_folder, + lora_amount, + lora_min, + lora_max, + lora_enabled, + lora_custom_weights, + lora_lock_prev, + use_ip, + use_search_txt, + use_remove_txt, + choose_search_txt, + choose_remove_txt, + search_refresh_btn, + remove_refresh_btn, + crop_center, + enable_adetailer_support, + use_same_seed, + reuse_cached_posts, + use_cache, + log_prompt_sources, + remove_artist_tags, + remove_character_tags, + remove_clothing_tags, + remove_text_tags, + restrict_subject_tags, + remove_furry_tags, + remove_headwear_tags, + remove_girl_suffix_tags, + preserve_hair_eye_colors, + remove_series_tags, + use_tag_catalog, + catalog_path, + lora_auto_detect_pony, + lora_detected_loras, + lora_blacklist, + ] + return rb_run_options.RunComponents.from_sequence(components).script_args() def _normalize_lora_name(self, value: object) -> str: - if value is None: - return '' - text = str(value).strip() - if not text: - return '' - return os.path.splitext(text)[0].strip().lower() + return rb_loranado.normalize_lora_name(value) def _get_lora_base_dir(self) -> str: - cmd_opts = getattr(shared, 'cmd_opts', None) - base_dir = getattr(cmd_opts, 'lora_dir', '') if cmd_opts is not None else '' + cmd_opts = getattr(shared, "cmd_opts", None) + base_dir = getattr(cmd_opts, "lora_dir", "") if cmd_opts is not None else "" if not isinstance(base_dir, str): - base_dir = str(base_dir) if base_dir is not None else '' + base_dir = str(base_dir) if base_dir is not None else "" return base_dir def _resolve_lora_target_folder(self, lora_folder: Optional[str]) -> str: lora_dir = self._get_lora_base_dir() - folder = (lora_folder or '').strip() + folder = (lora_folder or "").strip() return os.path.join(lora_dir, folder) if folder else lora_dir def _read_safetensors_metadata(self, file_path: str) -> Dict[str, object]: try: - with open(file_path, 'rb') as handle: + with open(file_path, "rb") as handle: header_len_raw = handle.read(8) if len(header_len_raw) != 8: return {} - header_len = int.from_bytes(header_len_raw, 'little', signed=False) + header_len = int.from_bytes(header_len_raw, "little", signed=False) if header_len <= 2 or header_len > self._LORANADO_MAX_HEADER_BYTES: return {} header_blob = handle.read(header_len) if len(header_blob) != header_len: return {} - header_data = json.loads(header_blob.decode('utf-8', errors='ignore')) - metadata = header_data.get('__metadata__', {}) if isinstance(header_data, dict) else {} + header_data = json.loads(header_blob.decode("utf-8", errors="ignore")) + metadata = header_data.get("__metadata__", {}) if isinstance(header_data, dict) else {} return metadata if isinstance(metadata, dict) else {} except Exception: return {} @@ -3294,7 +2763,7 @@ def _iter_metadata_values(self, value: object) -> Iterable[str]: pending.extend(current) def _is_ponyxl_lora(self, file_name: str, metadata: Dict[str, object]) -> bool: - stem = os.path.splitext(file_name or '')[0] + stem = os.path.splitext(file_name or "")[0] if self._matches_ponyxl_marker(stem): return True if not isinstance(metadata, dict): @@ -3312,46 +2781,46 @@ def _scan_loranado_candidates(self, lora_folder: Optional[str]) -> Dict[str, obj target_folder = self._resolve_lora_target_folder(lora_folder) if not target_folder: return { - 'target_folder': '', - 'all_files': [], - 'all_names': [], - 'detected_files': [], - 'detected_names': [], - 'message': "LoRA directory is not configured.", + "target_folder": "", + "all_files": [], + "all_names": [], + "detected_files": [], + "detected_names": [], + "message": "LoRA directory is not configured.", } if not os.path.isdir(target_folder): return { - 'target_folder': target_folder, - 'all_files': [], - 'all_names': [], - 'detected_files': [], - 'detected_names': [], - 'message': f"LoRA folder not found: {target_folder}", + "target_folder": target_folder, + "all_files": [], + "all_names": [], + "detected_files": [], + "detected_names": [], + "message": f"LoRA folder not found: {target_folder}", } try: all_files = sorted( file_name for file_name in os.listdir(target_folder) - if file_name.lower().endswith('.safetensors') + if file_name.lower().endswith(".safetensors") ) except Exception as exc: return { - 'target_folder': target_folder, - 'all_files': [], - 'all_names': [], - 'detected_files': [], - 'detected_names': [], - 'message': f"Could not scan LoRA folder: {exc}", + "target_folder": target_folder, + "all_files": [], + "all_names": [], + "detected_files": [], + "detected_names": [], + "message": f"Could not scan LoRA folder: {exc}", } if not all_files: return { - 'target_folder': target_folder, - 'all_files': [], - 'all_names': [], - 'detected_files': [], - 'detected_names': [], - 'message': f"No .safetensors files found in {target_folder}", + "target_folder": target_folder, + "all_files": [], + "all_names": [], + "detected_files": [], + "detected_names": [], + "message": f"No .safetensors files found in {target_folder}", } snapshot: List[Tuple[str, float, int]] = [] @@ -3365,8 +2834,8 @@ def _scan_loranado_candidates(self, lora_folder: Optional[str]) -> Dict[str, obj snapshot_key = tuple(snapshot) cached = self._loranado_scan_cache.get(target_folder) - if cached and cached.get('snapshot') == snapshot_key: - result = cached.get('result') + if cached and cached.get("snapshot") == snapshot_key: + result = cached.get("result") if isinstance(result, dict): return dict(result) @@ -3377,16 +2846,16 @@ def _scan_loranado_candidates(self, lora_folder: Optional[str]) -> Dict[str, obj detected_files.append(file_name) result = { - 'target_folder': target_folder, - 'all_files': all_files, - 'all_names': [os.path.splitext(file_name)[0] for file_name in all_files], - 'detected_files': detected_files, - 'detected_names': [os.path.splitext(file_name)[0] for file_name in detected_files], - 'message': f"Scanned {len(all_files)} LoRA(s) in {target_folder}", + "target_folder": target_folder, + "all_files": all_files, + "all_names": [os.path.splitext(file_name)[0] for file_name in all_files], + "detected_files": detected_files, + "detected_names": [os.path.splitext(file_name)[0] for file_name in detected_files], + "message": f"Scanned {len(all_files)} LoRA(s) in {target_folder}", } self._loranado_scan_cache[target_folder] = { - 'snapshot': snapshot_key, - 'result': dict(result), + "snapshot": snapshot_key, + "result": dict(result), } return result @@ -3398,30 +2867,32 @@ def _prepare_loranado_choice_state( blacklist_loras: object, ) -> Tuple[List[str], List[str], str]: scan = self._scan_loranado_candidates(lora_folder) - all_names = list(scan.get('all_names') or []) - detected_names = list(scan.get('detected_names') or []) + all_names = list(scan.get("all_names") or []) + detected_names = list(scan.get("detected_names") or []) if auto_detect_pony: choice_names = detected_names or all_names if detected_names: - status = ( - f"Detected {len(detected_names)} PonyXL-compatible LoRAs in `{scan.get('target_folder', '')}`." - ) + status = f"Detected {len(detected_names)} PonyXL-compatible LoRAs in `{scan.get('target_folder', '')}`." elif all_names: status = ( f"No PonyXL markers detected in `{scan.get('target_folder', '')}`. " f"Falling back to all {len(all_names)} LoRAs." ) else: - status = scan.get('message') or "No LoRAs found." + status = scan.get("message") or "No LoRAs found." else: choice_names = all_names if all_names: status = f"Auto-detect disabled. {len(all_names)} LoRAs available in `{scan.get('target_folder', '')}`." else: - status = scan.get('message') or "No LoRAs found." + status = scan.get("message") or "No LoRAs found." - chosen = [name for name in _coerce_multiselect_values(enabled_loras) if name in choice_names] - blacklisted = [name for name in _coerce_multiselect_values(blacklist_loras) if name in choice_names] + chosen = [ + name for name in _coerce_multiselect_values(enabled_loras) if name in choice_names + ] + blacklisted = [ + name for name in _coerce_multiselect_values(blacklist_loras) if name in choice_names + ] blacklisted_set = set(blacklisted) if not chosen and choice_names: @@ -3448,7 +2919,9 @@ def _ui_refresh_loranado_controls( enabled_loras=enabled_loras, blacklist_loras=blacklist_loras, ) - blacklisted = [name for name in _coerce_multiselect_values(blacklist_loras) if name in choices] + blacklisted = [ + name for name in _coerce_multiselect_values(blacklist_loras) if name in choices + ] return ( _gr_component_update(gr.Dropdown, choices=choices, value=enabled_values), _gr_component_update(gr.Dropdown, choices=choices, value=blacklisted), @@ -3472,16 +2945,15 @@ def _ui_select_all_loranado( _gr_component_update(gr.Markdown, value=status), ) - def check_orientation(self, img): if img is None: print("[R Orientation] No image provided, defaulting to 1024x1024") return [1024, 1024] x, y = img.size aspect_ratio = x / y - + print(f"[R Orientation] Original: {x}x{y}, aspect_ratio: {aspect_ratio:.3f}") - + # Calculate dimensions that maintain aspect ratio while staying within reasonable bounds # Target around 1024 pixels for the longer dimension, minimum 512 for shorter if aspect_ratio > 1.33: # Wide image @@ -3492,15 +2964,15 @@ def check_orientation(self, img): if target_height < 512: target_height = 512 target_width = int(target_height * aspect_ratio) - + # Round to multiples of 8 for better compatibility target_width = (target_width // 8) * 8 target_height = (target_height // 8) * 8 - + result = [target_width, target_height] print(f"[R Orientation] Wide image -> {result[0]}x{result[1]} (rounded to 8px)") return result - elif aspect_ratio < 0.75: # Tall image + elif aspect_ratio < 0.75: # Tall image # Portrait - height is longer target_height = 1152 target_width = int(target_height * aspect_ratio) @@ -3508,11 +2980,11 @@ def check_orientation(self, img): if target_width < 512: target_width = 512 target_height = int(target_width / aspect_ratio) - + # Round to multiples of 8 for better compatibility target_width = (target_width // 8) * 8 target_height = (target_height // 8) * 8 - + result = [target_width, target_height] print(f"[R Orientation] Tall image -> {result[0]}x{result[1]} (rounded to 8px)") return result @@ -3532,27 +3004,99 @@ def check_orientation(self, img): return result def _setup_cache(self, use_cache): - cache_was_installed = requests_cache.patcher.is_installed() - if use_cache and not cache_was_installed: - print("[R] Installing cache.") - requests_cache.install_cache('ranbooru_cache', backend='sqlite', expire_after=3600) - elif not use_cache and cache_was_installed: - print("[R] Uninstalling cache.") - requests_cache.uninstall_cache() - return use_cache and not cache_was_installed - - def _prepare_tags(self, ui_tags, ui_remove_tags, use_remove_file, remove_file, change_background, change_color, use_search_file, search_file, remove_default_bad): + old_client = getattr(self, "_http_client", None) + if old_client is not None: + try: + old_client.close() + except Exception as exc: + print(f"[R] Warn: Failed to close previous booru session: {exc}") + self._http_client = rb_http_client.BooruSession(use_cache=bool(use_cache)) + print(f"[R] Booru request cache {'enabled' if use_cache else 'disabled'} for this run.") + return False + + def _prepare_tags( + self, + ui_tags, + ui_remove_tags, + use_remove_file, + remove_file, + change_background, + change_color, + use_search_file, + search_file, + remove_default_bad, + ): bad_tags = set() if remove_default_bad: - bad_tags.update(['mixed-language_text', 'watermark', 'text', 'english_text', 'speech_bubble', 'signature', 'artist_name', 'censored', 'bar_censor', 'translation', 'twitter_username', "twitter_logo", 'patreon_username', 'commentary_request', 'tagme', 'commentary', 'character_name', 'mosaic_censoring', 'instagram_username', 'text_focus', 'english_commentary', 'comic', 'translation_request', 'fake_text', 'translated', 'paid_reward_available', 'thought_bubble', 'multiple_views', 'silent_comic', 'out-of-frame_censoring', 'symbol-only_commentary', '3koma', '2koma', 'character_watermark', 'spoken_question_mark', 'japanese_text', 'spanish_text', 'language_text', 'fanbox_username', 'commission', 'original', 'ai_generated', 'stable_diffusion', 'tagme_(artist)', 'text_bubble', 'qr_code', 'chinese_commentary', 'korean_text', 'partial_commentary', 'chinese_text', 'copyright_request', 'heart_censor', 'censored_nipples', 'page_number', 'scan', 'fake_magazine_cover', 'korean_commentary']) + bad_tags.update( + [ + "mixed-language_text", + "watermark", + "text", + "english_text", + "speech_bubble", + "signature", + "artist_name", + "censored", + "bar_censor", + "translation", + "twitter_username", + "twitter_logo", + "patreon_username", + "commentary_request", + "tagme", + "commentary", + "character_name", + "mosaic_censoring", + "instagram_username", + "text_focus", + "english_commentary", + "comic", + "translation_request", + "fake_text", + "translated", + "paid_reward_available", + "thought_bubble", + "multiple_views", + "silent_comic", + "out-of-frame_censoring", + "symbol-only_commentary", + "3koma", + "2koma", + "character_watermark", + "spoken_question_mark", + "japanese_text", + "spanish_text", + "language_text", + "fanbox_username", + "commission", + "original", + "ai_generated", + "stable_diffusion", + "tagme_(artist)", + "text_bubble", + "qr_code", + "chinese_commentary", + "korean_text", + "partial_commentary", + "chinese_text", + "copyright_request", + "heart_censor", + "censored_nipples", + "page_number", + "scan", + "fake_magazine_cover", + "korean_commentary", + ] + ) if ui_remove_tags: - bad_tags.update([t.strip() for t in ui_remove_tags.split(',') if t.strip()]) + bad_tags.update([t.strip() for t in ui_remove_tags.split(",") if t.strip()]) if use_remove_file and remove_file: try: filepath = os.path.join(USER_REMOVE_DIR, remove_file) print(f"[R] Reading remove tags: {filepath}") - with open(filepath, 'r', encoding='utf-8') as f: - read_tags = [t.strip() for t in f.read().split(',') if t.strip()] + with open(filepath, "r", encoding="utf-8") as f: + read_tags = [t.strip() for t in f.read().split(",") if t.strip()] print(f"[R] Tags read: {read_tags}") bad_tags.update(read_tags) except Exception as e: @@ -3560,33 +3104,43 @@ def _prepare_tags(self, ui_tags, ui_remove_tags, use_remove_file, remove_file, c initial_additions = [] bg_remove = set() color_remove = set() - if change_background == 'Add Detail': + if change_background == "Add Detail": initial_additions.append(random.choice(["outdoors", "indoors", "detailed_background"])) - bg_remove.update(COLORED_BG + ['simple_background', 'plain_background', 'transparent_background']) - elif change_background == 'Force Simple': - initial_additions.append(random.choice(['simple_background', 'plain_background'] + COLORED_BG)) - bg_remove.update(ADD_BG + ['detailed_background']) - elif change_background == 'Force Transparent/White': - initial_additions.append(random.choice(['transparent_background', 'white_background', 'plain_background'])) - bg_remove.update(ADD_BG + COLORED_BG + ['detailed_background', 'simple_background']) - if change_color == 'Force Color': - color_remove.update(BW_BG + ['limited_palette']) - elif change_color == 'Force Monochrome': + bg_remove.update( + COLORED_BG + ["simple_background", "plain_background", "transparent_background"] + ) + elif change_background == "Force Simple": + initial_additions.append( + random.choice(["simple_background", "plain_background"] + COLORED_BG) + ) + bg_remove.update(ADD_BG + ["detailed_background"]) + elif change_background == "Force Transparent/White": + initial_additions.append( + random.choice(["transparent_background", "white_background", "plain_background"]) + ) + bg_remove.update(ADD_BG + COLORED_BG + ["detailed_background", "simple_background"]) + if change_color == "Force Color": + color_remove.update(BW_BG + ["limited_palette"]) + elif change_color == "Force Monochrome": initial_additions.append(random.choice(BW_BG)) - color_remove.update(['colored_background', 'limited_palette']) + color_remove.update(["colored_background", "limited_palette"]) bad_tags.update(bg_remove) bad_tags.update(color_remove) - initial_additions_str = ','.join(initial_additions) + initial_additions_str = ",".join(initial_additions) search_tags = ui_tags if use_search_file and search_file: try: filepath = os.path.join(USER_SEARCH_DIR, search_file) print(f"[R] Reading search tags: {filepath}") - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, "r", encoding="utf-8") as f: search_lines = [line.strip() for line in f.readlines() if line.strip()] if search_lines: selected_file_tags = random.choice(search_lines) - search_tags = f'{search_tags},{selected_file_tags}' if search_tags else selected_file_tags + search_tags = ( + f"{search_tags},{selected_file_tags}" + if search_tags + else selected_file_tags + ) print(f"[R] Added file tags: {selected_file_tags}") else: print(f"[R] Warn: Search file empty: '{search_file}'") @@ -3594,48 +3148,66 @@ def _prepare_tags(self, ui_tags, ui_remove_tags, use_remove_file, remove_file, c print(f"[R] Warn: Read search file failed {search_file}: {e}") return search_tags, bad_tags, initial_additions_str - def _get_booru_api(self, booru_name, fringe_benefits, gelbooru_credentials: Optional[Dict[str, str]] = None): - booru_name = (booru_name or '').strip().lower() - if booru_name == 'gelbooru-compatible': - base_url = _sanitize_gelbooru_compat_base_url(getattr(self, '_gelbooru_compat_base_url', '')) + def _get_booru_api( + self, booru_name, fringe_benefits, gelbooru_credentials: Optional[Dict[str, str]] = None + ): + from ranboorux.boorus.gelbooru import GelbooruCompatible, Gelbooru + from ranboorux.boorus.simple import Danbooru, XBooru, Rule34, Safebooru, Konachan, Yandere, AIBooru, e621 + + booru_name = (booru_name or "").strip().lower() + if booru_name == "gelbooru-compatible": + base_url = _sanitize_gelbooru_compat_base_url( + getattr(self, "_gelbooru_compat_base_url", "") + ) if not base_url: - raise ValueError("Please enter a Gelbooru-compatible Base URL (e.g., https://realbooru.com).") + raise ValueError( + "Please enter a Gelbooru-compatible Base URL (e.g., https://realbooru.com)." + ) self._gelbooru_compat_base_url = base_url - return GelbooruCompatible(base_url) + api = GelbooruCompatible(base_url) + api.http = self._http_client + return api booru_apis = { - 'gelbooru': Gelbooru(fringe_benefits, gelbooru_credentials), - 'danbooru': Danbooru(), - 'xbooru': XBooru(), - 'rule34': Rule34(), - 'safebooru': Safebooru(), - 'konachan': Konachan(), - 'yande.re': Yandere(), - 'aibooru': AIBooru(), - 'e621': e621(), + "gelbooru": Gelbooru(fringe_benefits, gelbooru_credentials), + "danbooru": Danbooru(), + "xbooru": XBooru(), + "rule34": Rule34(), + "safebooru": Safebooru(), + "konachan": Konachan(), + "yande.re": Yandere(), + "aibooru": AIBooru(), + "e621": e621(), } if booru_name not in booru_apis: raise ValueError(f"Booru '{booru_name}' not implemented.") - return booru_apis.get(booru_name) + api = booru_apis.get(booru_name) + if api is not None: + api.http = self._http_client + return api def _fetch_booru_posts(self, api, search_tags, mature_rating, max_pages, post_id): add_tags_list = [] # Don't add search_tags to tags_query when using post_id - causes API confusion if search_tags and not post_id: - add_tags_list.extend([t.strip() for t in search_tags.split(',') if t.strip()]) + add_tags_list.extend([t.strip() for t in search_tags.split(",") if t.strip()]) booru_name = api.booru_name.lower() - if mature_rating != 'All' and booru_name in RATINGS and mature_rating in RATINGS[booru_name]: + if ( + mature_rating != "All" + and booru_name in RATINGS + and mature_rating in RATINGS[booru_name] + ): rating_tag = RATINGS[booru_name][mature_rating] if rating_tag != "All": add_tags_list.append(f"rating:{rating_tag}") - add_tags_list.append('-animated') + add_tags_list.append("-animated") if add_tags_list: add_tags_list, _ = self._apply_optional_catalog( add_tags_list, - keep_hair_eye=bool(getattr(self, '_preserve_hair_eye_colors', False)), - drop_series=bool(getattr(self, '_remove_series_tags', False)), - drop_characters=bool(getattr(self, '_remove_character_tags', False)), - drop_textual=bool(getattr(self, '_remove_text_tags', False)), + keep_hair_eye=bool(getattr(self, "_preserve_hair_eye_colors", False)), + drop_series=bool(getattr(self, "_remove_series_tags", False)), + drop_characters=bool(getattr(self, "_remove_character_tags", False)), + drop_textual=bool(getattr(self, "_remove_text_tags", False)), ) tags_query = f"&tags={'+'.join(add_tags_list)}" if add_tags_list else "" print(f"[R] Query Tags: '{tags_query}' (post_id={post_id})") @@ -3661,7 +3233,13 @@ def _select_posts(self, all_posts, sorting_order, num_images_needed, post_id, sa reverse = reverse_map.get(sorting_order, False) if sort_key: print(f"[R] Sorting {len(all_posts)} by {sort_key} {'Desc' if reverse else 'Asc'}") - all_posts = sorted(all_posts, key=lambda k: k.get(sort_key, 0) if isinstance(k.get(sort_key, 0), (int, float)) else 0, reverse=reverse) + all_posts = sorted( + all_posts, + key=lambda k: ( + k.get(sort_key, 0) if isinstance(k.get(sort_key, 0), (int, float)) else 0 + ), + reverse=reverse, + ) available_count = len(all_posts) selected_indices = [] if post_id: @@ -3674,14 +3252,30 @@ def _select_posts(self, all_posts, sorting_order, num_images_needed, post_id, sa selected_indices = random.choices(range(available_count), k=num_images_needed) else: indices_to_use = list(range(min(available_count, num_images_needed))) - selected_indices = indices_to_use + [indices_to_use[-1]] * (num_images_needed - len(indices_to_use)) + selected_indices = indices_to_use + [indices_to_use[-1]] * ( + num_images_needed - len(indices_to_use) + ) print(f"[R] Selected indices: {selected_indices}") return [all_posts[i] for i in selected_indices] + def _validate_source_image(self, image) -> None: + width, height = getattr(image, "size", (0, 0)) + if width <= 0 or height <= 0: + raise ValueError("downloaded image has invalid dimensions") + if width * height > MAX_SOURCE_IMAGE_PIXELS: + raise ValueError( + f"downloaded image exceeds {MAX_SOURCE_IMAGE_PIXELS} pixels ({width}x{height})" + ) + frame_count = int(getattr(image, "n_frames", 1) or 1) + if frame_count > MAX_SOURCE_IMAGE_FRAMES: + raise ValueError( + f"downloaded image has {frame_count} frames; maximum is {MAX_SOURCE_IMAGE_FRAMES}" + ) + def _fetch_images(self, posts_to_fetch, use_same_image, booru_name, fringe_benefits): print("[R] Fetching images...") fetched_images = [] - image_urls = [post.get('file_url') for post in posts_to_fetch] + image_urls = [post.get("file_url") for post in posts_to_fetch] if not any(url for url in image_urls if url): print("[R] Warn: No valid file_urls found.") return [] @@ -3693,7 +3287,9 @@ def _fetch_images(self, posts_to_fetch, use_same_image, booru_name, fringe_benef return [] image_urls = [first_valid_url] * len(posts_to_fetch) try: - api = self._get_booru_api(booru_name, fringe_benefits, getattr(self, '_gelbooru_effective_credentials', None)) + api = self._get_booru_api( + booru_name, fringe_benefits, getattr(self, "_gelbooru_effective_credentials", None) + ) except ValueError as e: print(f"[R] Error getting API for image fetch: {e}") return [] @@ -3701,84 +3297,145 @@ def _fetch_images(self, posts_to_fetch, use_same_image, booru_name, fringe_benef for i, img_url in enumerate(image_urls): img_to_append = None try: - if img_url and img_url.startswith(('http://', 'https://')): - print(f"[R] Fetching {i+1}/{len(image_urls)}: {img_url[:50]}...") - response = requests.get(img_url, headers=api.headers, timeout=30) - response.raise_for_status() - img_data = BytesIO(response.content) - pil_image = Image.open(img_data).convert("RGB") - img_to_append = pil_image + if img_url and img_url.startswith(("http://", "https://")): + safe_url = rb_http_client.redact_url(img_url) + print(f"[R] Fetching {i+1}/{len(image_urls)}: {safe_url[:80]}...") + content = self._http_client.get_bytes( + img_url, + headers=api.headers, + timeout=30, + max_bytes=MAX_SOURCE_IMAGE_BYTES, + ) + img_data = BytesIO(content) + source_image = Image.open(img_data) + try: + self._validate_source_image(source_image) + img_to_append = source_image.convert("RGB") + finally: + close = getattr(source_image, "close", None) + if callable(close) and img_to_append is not source_image: + close() fetched_count += 1 - print(f"[R] Successfully fetched image {i+1}: {pil_image.size}") + print(f"[R] Successfully fetched image {i+1}: {img_to_append.size}") elif img_url: - if any(site in img_url.lower() for site in ['pixiv.net', 'pximg.net', 'twitter.com', 'x.com']): - print(f"[R] Skipped external site URL {i+1}: {img_url[:50]} (not a direct image)") + if any( + site in img_url.lower() + for site in ["pixiv.net", "pximg.net", "twitter.com", "x.com"] + ): + print( + f"[R] Skipped external site URL {i+1}: {rb_http_client.redact_url(img_url)[:80]} (not a direct image)" + ) else: - print(f"[R] Invalid URL protocol {i+1}: {img_url[:50]}") + print( + f"[R] Invalid URL protocol {i+1}: {rb_http_client.redact_url(img_url)[:80]}" + ) else: print(f"[R] No URL available for image {i+1}") except Exception as e: - print(f"[R] Error fetching image {i+1}: {e}") + safe_msg = rb_http_client.safe_exception_message("Image fetch", img_url, e) + print(f"[R] Error fetching image {i+1}: {safe_msg}") fetched_images.append(img_to_append) print(f"[R] Fetched {fetched_count} images.") if None in fetched_images: print("[R] Warn: Some images failed.") return fetched_images - def _process_single_prompt(self, index, raw_prompt, base_positive, base_negative, initial_additions, settings): + def _process_single_prompt( + self, index, raw_prompt, base_positive, base_negative, initial_additions, settings + ): ( - shuffle_tags, chaos_mode, chaos_amount, limit_tags_pct, max_tags_count, change_dash, - remove_artist_tags, remove_character_tags, - remove_clothing_tags, remove_text_tags, restrict_subject_tags, - remove_furry_tags, remove_headwear_tags, preserve_hair_eye_colors, remove_series_tags + shuffle_tags, + chaos_mode, + chaos_amount, + limit_tags_pct, + max_tags_count, + change_dash, + remove_artist_tags, + remove_character_tags, + remove_clothing_tags, + remove_text_tags, + restrict_subject_tags, + remove_furry_tags, + remove_headwear_tags, + preserve_hair_eye_colors, + remove_series_tags, ) = settings current_prompt = f"{initial_additions},{raw_prompt}" if initial_additions else raw_prompt - prompt_tags = [tag.strip() for tag in re.split(r'[\,\t\s]+', current_prompt) if tag.strip()] - base_hair_colors = set(getattr(self, '_base_hair_color_tags', set()) or []) - base_eye_colors = set(getattr(self, '_base_eye_color_tags', set()) or []) + prompt_tags = [tag.strip() for tag in re.split(r"[\,\t\s]+", current_prompt) if tag.strip()] + base_hair_colors = set(getattr(self, "_base_hair_color_tags", set()) or []) + base_eye_colors = set(getattr(self, "_base_eye_color_tags", set()) or []) # If removal flags are set, remove tags coming from selected post's artist/character lists try: - post_meta = self._selected_posts[index] if hasattr(self, '_selected_posts') and index < len(self._selected_posts) else {} - artist_tags_meta = post_meta.get('artist_tags', []) if isinstance(post_meta, dict) else [] - character_tags_meta = post_meta.get('character_tags', []) if isinstance(post_meta, dict) else [] + post_meta = ( + self._selected_posts[index] + if hasattr(self, "_selected_posts") and index < len(self._selected_posts) + else {} + ) + artist_tags_meta = ( + post_meta.get("artist_tags", []) if isinstance(post_meta, dict) else [] + ) + character_tags_meta = ( + post_meta.get("character_tags", []) if isinstance(post_meta, dict) else [] + ) norm = self._normalize_tag artist_norm = {norm(t) for t in artist_tags_meta if isinstance(t, str)} char_norm = {norm(t) for t in character_tags_meta if isinstance(t, str)} if isinstance(post_meta, dict): - copyright_tags_meta = post_meta.get('copyright_tags') or [] + copyright_tags_meta = post_meta.get("copyright_tags") or [] if remove_character_tags and copyright_tags_meta: char_norm.update({norm(t) for t in copyright_tags_meta if isinstance(t, str)}) - char_norm.update({(t or '').strip().lower() for t in copyright_tags_meta if isinstance(t, str)}) - artist_norm.update({(t or '').strip().lower() for t in artist_tags_meta if isinstance(t, str)}) - char_norm.update({(t or '').strip().lower() for t in character_tags_meta if isinstance(t, str)}) + char_norm.update( + { + (t or "").strip().lower() + for t in copyright_tags_meta + if isinstance(t, str) + } + ) + artist_norm.update( + {(t or "").strip().lower() for t in artist_tags_meta if isinstance(t, str)} + ) + char_norm.update( + {(t or "").strip().lower() for t in character_tags_meta if isinstance(t, str)} + ) general_tags = [] if isinstance(post_meta, dict): - raw_all_tags = post_meta.get('tags') or '' + raw_all_tags = post_meta.get("tags") or "" if isinstance(raw_all_tags, str): - general_tags = [t.strip() for t in re.split(r'[\s,]+', raw_all_tags) if t.strip()] + general_tags = [ + t.strip() for t in re.split(r"[\s,]+", raw_all_tags) if t.strip() + ] if remove_character_tags and general_tags: for tag in general_tags: tag_norm = norm(tag) - if ('(' in tag and ')' in tag and not tag.strip().startswith('(')) or any(tag_norm.endswith(suffix) for suffix in (' series', ' franchise', ' character', ' characters')): + if ("(" in tag and ")" in tag and not tag.strip().startswith("(")) or any( + tag_norm.endswith(suffix) + for suffix in (" series", " franchise", " character", " characters") + ): char_norm.add(tag_norm) char_norm.add(tag.strip().lower()) if remove_artist_tags and general_tags: for tag in general_tags: tag_norm = norm(tag) - if tag_norm.startswith('artist:') or tag_norm.endswith(' artist') or ' drawn by' in tag_norm: + if ( + tag_norm.startswith("artist:") + or tag_norm.endswith(" artist") + or " drawn by" in tag_norm + ): artist_norm.add(tag_norm) artist_norm.add(tag.strip().lower()) allowed_subjects = set() if restrict_subject_tags: allowed_subjects.update(self._extract_subject_tags(base_positive)) allowed_subjects.update(self._extract_subject_tags(initial_additions)) - allowed_subjects.update(self._extract_subject_tags(getattr(self, 'original_prompt', ''))) - use_new_filter = not getattr(self, '_use_legacy_filter_engine', False) - filter_ctx = getattr(self, '_removal_context', None) if use_new_filter else None + allowed_subjects.update( + self._extract_subject_tags(getattr(self, "original_prompt", "")) + ) + filter_ctx = getattr(self, "_removal_context", None) favorites_guard: Set[str] = set() - if use_new_filter and filter_ctx: - favorites_guard = set(filter_ctx.get('favorites', frozenset())) # type: ignore[arg-type] - norm_cache = getattr(self, '_tag_normal_cache', {}) + if filter_ctx: + favorites_guard = set(filter_ctx.get("favorites", frozenset())) # type: ignore[arg-type] + catalog = self._active_catalog() + norm_cache = getattr(self, "_tag_normal_cache", {}) if not isinstance(norm_cache, dict): norm_cache = {} self._tag_normal_cache = norm_cache @@ -3787,36 +3444,53 @@ def _process_single_prompt(self, index, raw_prompt, base_positive, base_negative for t in prompt_tags: t_norm = self._normalize_cached(t, norm_cache) canonical_tag = t_norm or self._canonicalize_raw_tag(t) - t_orig = (t or '').strip().lower() - is_favorite = bool(use_new_filter and t_norm and t_norm in favorites_guard) + t_orig = (t or "").strip().lower() + is_favorite = bool(t_norm and t_norm in favorites_guard) if is_favorite: filtered_prompt_tags.append(t) continue should_remove = False - if remove_artist_tags and (t_norm in artist_norm or t_orig in artist_norm or (t_norm and t_norm.endswith(' artist'))): + if remove_artist_tags and ( + t_norm in artist_norm + or t_orig in artist_norm + or (t_norm and t_norm.endswith(" artist")) + ): should_remove = True - elif remove_character_tags and (t_norm in char_norm or t_orig in char_norm or ('(' in t and ')' in t and not t.strip().startswith('(')) or (t_norm and (t_norm.endswith(' series') or t_norm.endswith(' franchise')))): + elif remove_character_tags and ( + t_norm in char_norm + or t_orig in char_norm + or ("(" in t and ")" in t and not t.strip().startswith("(")) + or (t_norm and (t_norm.endswith(" series") or t_norm.endswith(" franchise"))) + ): should_remove = True - if not should_remove and remove_clothing_tags and self._is_clothing_tag(t): + if not should_remove and remove_clothing_tags and rb_tag_pipeline.is_clothing_tag(t): should_remove = True - if not should_remove and remove_text_tags and self._is_textual_tag(t): + if not should_remove and remove_text_tags and rb_tag_pipeline.is_textual_tag(t, catalog.is_textual if catalog else None): should_remove = True - if not should_remove and remove_furry_tags and self._is_furry_tag(t): + if not should_remove and remove_furry_tags and rb_tag_pipeline.is_furry_tag(t): should_remove = True - if not should_remove and remove_headwear_tags and self._is_headwear_tag(t): + if not should_remove and remove_headwear_tags and rb_tag_pipeline.is_headwear_tag(t): should_remove = True - if not should_remove and remove_series_tags and self._is_series_tag(t): + if not should_remove and remove_series_tags and rb_tag_pipeline.is_series_tag(t, catalog.category if catalog else None): should_remove = True if not should_remove and preserve_hair_eye_colors: if base_hair_colors and canonical_tag in base_hair_colors: pass elif base_eye_colors and canonical_tag in base_eye_colors: pass - elif base_hair_colors and self._is_hair_color_tag(t) and canonical_tag not in base_hair_colors: + elif ( + base_hair_colors + and rb_tag_pipeline.is_hair_color_tag(t, catalog.is_hair if catalog else None) + and canonical_tag not in base_hair_colors + ): should_remove = True - elif base_eye_colors and self._is_eye_color_tag(t) and canonical_tag not in base_eye_colors: + elif ( + base_eye_colors + and rb_tag_pipeline.is_eye_color_tag(t, catalog.is_eye if catalog else None) + and canonical_tag not in base_eye_colors + ): should_remove = True - if not should_remove and restrict_subject_tags and self._is_subject_tag(t): + if not should_remove and restrict_subject_tags and rb_tag_pipeline.is_subject_tag(t): subject_norm = t_norm if allowed_subjects: if subject_norm not in allowed_subjects: @@ -3826,38 +3500,39 @@ def _process_single_prompt(self, index, raw_prompt, base_positive, base_negative primary_subject = subject_norm elif subject_norm != primary_subject: should_remove = True - if not should_remove: - if use_new_filter and filter_ctx and t_norm: - should_remove = self._tag_matches_removal(t_norm, filter_ctx) - elif not use_new_filter: - should_remove = self._legacy_tag_matches(t) + if not should_remove and filter_ctx and t_norm: + should_remove = self._tag_matches_removal(t_norm, filter_ctx) if not should_remove: filtered_prompt_tags.append(t) prompt_tags = filtered_prompt_tags except Exception: # fallback: ignore removal if anything goes wrong pass - current_prompt = ','.join(prompt_tags) + current_prompt = ",".join(prompt_tags) if shuffle_tags: - tags_list = [t.strip() for t in current_prompt.split(',') if t.strip()] + tags_list = [t.strip() for t in current_prompt.split(",") if t.strip()] random.shuffle(tags_list) - current_prompt = ','.join(tags_list) + current_prompt = ",".join(tags_list) current_negative = base_negative - if chaos_mode == 'Shuffle All': - current_prompt, current_negative = generate_chaos(current_prompt, current_negative, chaos_amount) - elif chaos_mode == 'Shuffle Negative': + if chaos_mode == "Shuffle All": + current_prompt, current_negative = generate_chaos( + current_prompt, current_negative, chaos_amount + ) + elif chaos_mode == "Shuffle Negative": _, current_negative = generate_chaos("", current_negative, chaos_amount) if limit_tags_pct < 1.0: - current_prompt = limit_prompt_tags(current_prompt, limit_tags_pct, 'Limit') + current_prompt = rb_tag_pipeline.limit_prompt_tags(current_prompt, limit_tags_pct, "Limit") if max_tags_count > 0: - current_prompt = limit_prompt_tags(current_prompt, max_tags_count, 'Max') + current_prompt = rb_tag_pipeline.limit_prompt_tags(current_prompt, max_tags_count, "Max") if change_dash: current_prompt = current_prompt.replace("_", " ") current_negative = current_negative.replace("_", " ") if base_positive: - current_prompt = f"{base_positive}, {current_prompt}" if current_prompt else base_positive - current_prompt = remove_repeated_tags(current_prompt) - current_negative = remove_repeated_tags(current_negative) + current_prompt = ( + f"{base_positive}, {current_prompt}" if current_prompt else base_positive + ) + current_prompt = rb_tag_pipeline.remove_repeated_tags(current_prompt) + current_negative = rb_tag_pipeline.remove_repeated_tags(current_negative) return current_prompt, current_negative def _apply_loranado( @@ -3874,7 +3549,7 @@ def _apply_loranado( lora_detected_loras, lora_blacklist, ): - lora_prompt = '' + lora_prompt = "" if not lora_enabled: return p if lora_lock_prev and self.previous_loras: @@ -3882,128 +3557,139 @@ def _apply_loranado( print(f"[R] Using locked LoRAs: {lora_prompt}") else: scan = self._scan_loranado_candidates(lora_folder) - target_folder = str(scan.get('target_folder') or self._resolve_lora_target_folder(lora_folder)) - all_loras = list(scan.get('all_files') or []) + target_folder = str( + scan.get("target_folder") or self._resolve_lora_target_folder(lora_folder) + ) + all_loras = list(scan.get("all_files") or []) if not all_loras: - print(f"[R] {scan.get('message') or f'No .safetensors LoRAs found: {target_folder}'}") - self.previous_loras = '' + print( + f"[R] {scan.get('message') or f'No .safetensors LoRAs found: {target_folder}'}" + ) + self.previous_loras = "" return p if lora_auto_detect_pony: - detected_loras = list(scan.get('detected_files') or []) + detected_loras = list(scan.get("detected_files") or []) if detected_loras: candidate_loras = detected_loras print(f"[R] LoRAnado: using {len(candidate_loras)} PonyXL-detected LoRAs.") else: candidate_loras = all_loras - print(f"[R] LoRAnado: no PonyXL markers detected in {target_folder}; falling back to all LoRAs.") + print( + f"[R] LoRAnado: no PonyXL markers detected in {target_folder}; falling back to all LoRAs." + ) else: candidate_loras = all_loras - print(f"[R] LoRAnado: auto-detect disabled; using all {len(candidate_loras)} LoRAs.") - - enabled_selection = { - self._normalize_lora_name(name) - for name in _coerce_multiselect_values(lora_detected_loras) - if self._normalize_lora_name(name) - } - if enabled_selection: - candidate_loras = [ - lora_file - for lora_file in candidate_loras - if self._normalize_lora_name(lora_file) in enabled_selection - ] + print( + f"[R] LoRAnado: auto-detect disabled; using all {len(candidate_loras)} LoRAs." + ) - blacklist_selection = { - self._normalize_lora_name(name) - for name in _coerce_multiselect_values(lora_blacklist) - if self._normalize_lora_name(name) - } - if blacklist_selection: - before_count = len(candidate_loras) - candidate_loras = [ - lora_file - for lora_file in candidate_loras - if self._normalize_lora_name(lora_file) not in blacklist_selection - ] - print(f"[R] LoRAnado: blacklist removed {before_count - len(candidate_loras)} LoRAs.") + before_filter_count = len(candidate_loras) + before_blacklist_count = len(candidate_loras) + enabled_values = _coerce_multiselect_values(lora_detected_loras) + blacklist_values = _coerce_multiselect_values(lora_blacklist) + if blacklist_values: + enabled_filtered = rb_loranado.filter_candidates( + candidate_loras, + enabled_values, + [], + ) + before_blacklist_count = len(enabled_filtered) + candidate_loras = rb_loranado.filter_candidates( + candidate_loras, + enabled_values, + blacklist_values, + ) + if blacklist_values: + print( + f"[R] LoRAnado: blacklist removed {before_blacklist_count - len(candidate_loras)} LoRAs." + ) + if enabled_values and before_filter_count != before_blacklist_count: + print( + f"[R] LoRAnado: enabled list kept {before_blacklist_count}/{before_filter_count} LoRAs." + ) if not candidate_loras: print("[R] LoRAnado: no LoRAs remain after enabled/blacklist filtering.") - self.previous_loras = '' + self.previous_loras = "" return p - custom_weights = [] - if lora_custom_weights: - try: - custom_weights = [float(w.strip()) for w in lora_custom_weights.split(',')] - except ValueError: - print(f"[R] Warn: Invalid custom LoRA weights: '{lora_custom_weights}'") - selected_loras = [] - num_to_select = min(max(1, int(lora_amount)), len(candidate_loras)) - chosen_files = random.sample(candidate_loras, num_to_select) - for i in range(num_to_select): - lora_weight = custom_weights[i] if i < len(custom_weights) else round(random.uniform(lora_min, lora_max), 2) - chosen_lora_file = chosen_files[i] - lora_name = os.path.splitext(chosen_lora_file)[0] - selected_loras.append(f'') - lora_prompt = ' '.join(selected_loras) + custom_weights = rb_loranado.parse_custom_weights(lora_custom_weights) + if lora_custom_weights and not custom_weights: + print(f"[R] Warn: Invalid custom LoRA weights: '{lora_custom_weights}'") + selected_loras = rb_loranado.select_loras( + candidate_loras, + int(lora_amount), + float(lora_min), + float(lora_max), + custom_weights, + random, + ) + num_to_select = len(selected_loras) + lora_prompt = rb_loranado.format_lora_prompt(selected_loras) self.previous_loras = lora_prompt print(f"[R] LoRAnado pool size={len(candidate_loras)} | selected={num_to_select}") print(f"[R] Applying LoRAs: {lora_prompt}") if lora_prompt: if isinstance(p.prompt, list): - p.prompt = [f'{lora_prompt} {pr}' for pr in p.prompt] + p.prompt = [f"{lora_prompt} {pr}" for pr in p.prompt] else: - p.prompt = f'{lora_prompt} {p.prompt}' + p.prompt = f"{lora_prompt} {p.prompt}" return p def _prepare_img2img_pass(self, p, use_img2img, use_ip): self.run_img2img_pass = False if use_img2img: - # CRITICAL FIX: Use higher quality initial pass to prevent distortion initial_steps = max(5, min(10, p.steps // 3)) # Use 1/3 of total steps, min 5 - print(f"[R] Prep Img2Img pass (steps={initial_steps}) - ControlNet {'enabled' if use_ip else 'disabled'}.") + print( + f"[R] Prep Img2Img pass (steps={initial_steps}) - ControlNet {'enabled' if use_ip else 'disabled'}." + ) print("[R] Using higher quality initial pass to prevent distortion") self.real_steps = p.steps - + # Preserve the user's prompt for the initial pass. ADetailer is explicitly blocked # during this phase, so we no longer need an abstract placeholder prompt. self.original_full_prompt = p.prompt - print("[R] Keeping original prompt for initial pass; ADetailer remains blocked by guards") - - p.steps = initial_steps - + print( + "[R] Keeping original prompt for initial pass; ADetailer remains blocked by guards" + ) + + self._host_scope.set_attr(p, "steps", initial_steps) + # CRITICAL FIX: Don't reduce CFG too much - maintain image coherence self.original_cfg = p.cfg_scale - p.cfg_scale = max(4.0, min(p.cfg_scale, 8.0)) # Keep CFG between 4-8 - + self._host_scope.set_attr( + p, + "cfg_scale", + max(4.0, min(p.cfg_scale, 8.0)), + ) + # CRITICAL FIX: Reduce denoising strength to prevent over-processing self.original_denoising = self.img2img_denoising - self.img2img_denoising = min(0.6, self.img2img_denoising) # Cap at 0.6 to prevent distortion - + self.img2img_denoising = min( + 0.6, self.img2img_denoising + ) # Cap at 0.6 to prevent distortion + self.run_img2img_pass = True - - # CRITICAL: Store original save settings BEFORE any modifications - self.original_save_images = getattr(p, 'do_not_save_samples', False) - self.original_save_grid = getattr(p, 'do_not_save_grid', False) - self.original_outpath = getattr(p, 'outpath_samples', None) - - print(f"[R Save Prevention] Original save state: do_not_save_samples={self.original_save_images}, outpath='{self.original_outpath}'") - - # AGGRESSIVE: Prevent initial pass results from being saved by multiple methods - p.do_not_save_samples = True - p.do_not_save_grid = True - - # Create temp directory for initial pass saves (will be deleted) + + self._img2img_final_outpath_samples = getattr(p, "outpath_samples", None) + self._img2img_final_batch_size = getattr(p, "batch_size", 1) + + print( + "[R Save Prevention] Initial save state: " + f"do_not_save_samples={getattr(p, 'do_not_save_samples', False)}, " + f"outpath='{self._img2img_final_outpath_samples}'" + ) + import tempfile - temp_dir = tempfile.mkdtemp(prefix='ranbooru_temp_') - print(f"[R Save Prevention] Redirected initial pass saves to temp directory: {temp_dir}") - p.outpath_samples = temp_dir - + + temp_dir = tempfile.mkdtemp(prefix="ranbooru_temp_") + self._host_scope.context.own_temp_path(temp_dir) + self._prevent_all_image_saving(p, temp_dir) + # Set batch size to 1 and disable extensions for initial pass - self.original_batch_size = p.batch_size - p.batch_size = 1 # Minimize processing - + self._host_scope.set_attr(p, "batch_size", 1) + # LIGHTER APPROACH: Just mark that we're in initial pass - don't completely disable ADetailer self._mark_initial_pass(p) @@ -4013,133 +3699,86 @@ def _prepare_img2img_pass(self, p, use_img2img, use_ip): self._set_preview_guard(True, block_all=True) except Exception as guard_error: print(f"[R UI] Warn: Could not enable early preview guard: {guard_error}") - - # ADDITIONAL SAVE PREVENTION: More aggressive image saving prevention - self._prevent_all_image_saving(p) - + print("[R] AGGRESSIVE: Disabled all saving, minimized batch for initial pass") - print(f"[R] Optimized settings: steps={initial_steps}, cfg={p.cfg_scale}, denoising={self.img2img_denoising}") + print( + f"[R] Optimized settings: steps={initial_steps}, cfg={p.cfg_scale}, denoising={self.img2img_denoising}" + ) def _cleanup_after_run(self, use_cache): # Don't clear self.last_img or cached data - keep them for reuse self.real_steps = 0 self.run_img2img_pass = False - + try: + self._host_scope.restore() + if self._host_scope.context.cleanup_errors: + print( + "[R Cleanup] Host cleanup warnings: " + + "; ".join(self._host_scope.context.cleanup_errors) + ) + except Exception as exc: + print(f"[R Cleanup] Host mutation restore failed: {exc}") + self._host_scope = rb_mutation_scope.HostMutationScope() + # Clean up stored original values - if hasattr(self, 'original_full_prompt'): - delattr(self, 'original_full_prompt') - if hasattr(self, '_adetailer_script_args_snapshot'): - delattr(self, '_adetailer_script_args_snapshot') - if hasattr(self, '_current_processing_object'): - delattr(self, '_current_processing_object') - if hasattr(self, 'original_cfg'): - delattr(self, 'original_cfg') - if hasattr(self, 'original_denoising'): + if hasattr(self, "original_full_prompt"): + delattr(self, "original_full_prompt") + if hasattr(self, "_adetailer_script_args_snapshot"): + delattr(self, "_adetailer_script_args_snapshot") + if hasattr(self, "_current_processing_object"): + delattr(self, "_current_processing_object") + if hasattr(self, "original_cfg"): + delattr(self, "original_cfg") + if hasattr(self, "original_denoising"): # Restore original denoising value self.img2img_denoising = self.original_denoising - delattr(self, 'original_denoising') - if hasattr(self, 'original_save_images'): - # Restore original save settings before deleting - try: - import modules.shared - if hasattr(modules.shared, 'opts'): - modules.shared.opts.save_images = self.original_save_images - except Exception as e: - print(f"[R Cleanup] Warning: Could not restore original save_images: {e}") - delattr(self, 'original_save_images') - if hasattr(self, 'original_save_grid'): - # Restore original save grid settings before deleting - try: - import modules.shared - if hasattr(modules.shared, 'opts'): - modules.shared.opts.save_images = self.original_save_grid - except Exception as e: - print(f"[R Cleanup] Warning: Could not restore original save_grid: {e}") - delattr(self, 'original_save_grid') - if hasattr(self, 'original_outpath'): - # Restore original outpath before deleting - try: - import modules.shared - if hasattr(modules.shared, 'opts'): - modules.shared.opts.outdir_txt2img_samples = self.original_outpath - except Exception as e: - print(f"[R Cleanup] Warning: Could not restore original outpath: {e}") - delattr(self, 'original_outpath') - if hasattr(self, 'original_batch_size'): - delattr(self, 'original_batch_size') - - # Clean up additional save-related parameters - if hasattr(self, 'original_save_to_dirs'): - delattr(self, 'original_save_to_dirs') - - # Clean up temporary directory - if hasattr(self, 'temp_initial_dir'): - try: - import shutil - shutil.rmtree(self.temp_initial_dir, ignore_errors=True) - print(f"[R Cleanup] Cleaned up temporary directory: {self.temp_initial_dir}") - delattr(self, 'temp_initial_dir') - except Exception as e: - print(f"[R Cleanup] Warning: Could not clean temp directory: {e}") - if hasattr(self, 'original_filename_format'): - delattr(self, 'original_filename_format') - if hasattr(self, 'original_save_images_history'): - delattr(self, 'original_save_images_history') - if hasattr(self, 'original_save_samples_dir'): - delattr(self, 'original_save_samples_dir') - + delattr(self, "original_denoising") + for attr in ("_img2img_final_outpath_samples", "_img2img_final_batch_size"): + if hasattr(self, attr): + delattr(self, attr) + # Clean up processing state flags - if hasattr(self, '_ranbooru_processing_complete'): - delattr(self, '_ranbooru_processing_complete') - if hasattr(self, '_ranbooru_intermediate_results'): - delattr(self, '_ranbooru_intermediate_results') + if hasattr(self, "_ranbooru_processing_complete"): + delattr(self, "_ranbooru_processing_complete") + if hasattr(self, "_ranbooru_intermediate_results"): + delattr(self, "_ranbooru_intermediate_results") - if hasattr(self, '_native_adetailer_fallback_used'): - delattr(self, '_native_adetailer_fallback_used') + if hasattr(self, "_native_adetailer_fallback_used"): + delattr(self, "_native_adetailer_fallback_used") # Clean up ADetailer state - if hasattr(self, '_ranbooru_initial_pass'): + if hasattr(self, "_ranbooru_initial_pass"): self._ranbooru_initial_pass = False print("[R Cleanup] Cleared initial pass flag") - if hasattr(self, '_initial_pass_p'): - delattr(self, '_initial_pass_p') - - # CRITICAL FIX: Don't re-enable ADetailer in cleanup - let it stay disabled for this generation - if hasattr(self, 'disabled_adetailer_scripts'): - print(f"[R Cleanup] Keeping {len(self.disabled_adetailer_scripts)} ADetailer script(s) disabled to prevent wrong image processing") - # We'll re-enable them on the NEXT generation start instead of now - # This prevents ADetailer from running on wrong images after our manual processing - + if hasattr(self, "_initial_pass_p"): + delattr(self, "_initial_pass_p") + # Clean up early protection state - if hasattr(self, '_temp_disabled_adetailer'): + if hasattr(self, "_temp_disabled_adetailer"): # Force restore if cleanup is called early - self._restore_early_adetailer_protection(getattr(self, '_initial_pass_p', None)) - - # Clean up blocking state - if hasattr(self.__class__, '_block_640x512_images'): - print("[R Cleanup] Clearing 640x512 image blocking for next generation") - delattr(self.__class__, '_block_640x512_images') - - # Restore ADetailer scripts if they were removed from the pipeline - if hasattr(self, '_removed_adetailer_scripts'): - print("[R Cleanup] Restoring ADetailer scripts to pipeline for next generation") - # Note: We don't actually restore here since it's too aggressive - # ADetailer will be available for the next generation automatically - delattr(self, '_removed_adetailer_scripts') - - # Ensure any manual patches are removed once we're finished + self._restore_early_adetailer_protection(getattr(self, "_initial_pass_p", None)) + + # Ensure any manual patches are removed once we're finished. self._unpatch_manual_adetailer_overrides() + patch_errors = self._adetailer_patches.uninstall_all() + if patch_errors: + print("[R Cleanup] ADetailer patch restore warnings: " + "; ".join(patch_errors)) # Ensure preview suppression never leaks into the next generation. try: self._set_preview_guard(False) except Exception: pass + self._adetailer_state.reset() - if not use_cache and hasattr(self, 'cache_installed_by_us') and self.cache_installed_by_us and requests_cache.patcher.is_installed(): - requests_cache.uninstall_cache() - print("[R Post] Uninstalled cache.") - if hasattr(self, 'cache_installed_by_us'): + http_client = getattr(self, "_http_client", None) + if http_client is not None: + try: + http_client.close() + except Exception as exc: + print(f"[R Post] Warn: Failed to close booru session: {exc}") + self._http_client = rb_http_client.BooruSession(use_cache=False) + if hasattr(self, "cache_installed_by_us"): try: del self.cache_installed_by_us except AttributeError: @@ -4155,33 +3794,42 @@ def _force_release_processing_guards(self, reason, attempt_cleanup=True, process released = False if attempt_cleanup: try: - use_cache = getattr(self, '_post_use_cache', True) + use_cache = getattr(self, "_post_use_cache", True) self._cleanup_after_run(use_cache) except Exception as exc: print(f"[R Guard] Cleanup while releasing lock failed: {exc}") try: - if hasattr(self, '_current_processing_key'): + if hasattr(self, "_current_processing_key"): processing_key = self._current_processing_key if hasattr(self, processing_key): delattr(self, processing_key) - delattr(self, '_current_processing_key') + delattr(self, "_current_processing_key") released = True except Exception as exc: print(f"[R Guard] Failed clearing instance processing key: {exc}") try: - setattr(self.__class__, '_ranbooru_global_processing', False) + setattr(self.__class__, "_ranbooru_global_processing", False) except Exception as exc: print(f"[R Guard] Failed clearing global processing flag: {exc}") released = False - if hasattr(self, '_current_processing_object'): + if hasattr(self, "_current_processing_object"): try: - delattr(self, '_current_processing_object') + delattr(self, "_current_processing_object") except Exception as exc: print(f"[R Guard] Failed clearing processing object reference: {exc}") released = False + if processing_obj is not None and hasattr(processing_obj, "_ranbooru_already_processing"): + try: + delattr(processing_obj, "_ranbooru_already_processing") + except Exception: + try: + setattr(processing_obj, "_ranbooru_already_processing", False) + except Exception as exc: + print(f"[R Guard] Failed clearing processing object guard: {exc}") + released = False # Ensure ADetailer hooks are restored so future generations run normally try: @@ -4192,62 +3840,105 @@ def _force_release_processing_guards(self, reason, attempt_cleanup=True, process return released + def _clear_processing_guards(self, p=None, prefix="[R Post]") -> None: + if hasattr(self, "_current_processing_key"): + processing_key = self._current_processing_key + if hasattr(self, processing_key): + delattr(self, processing_key) + print(f"{prefix} Cleared processing guard for request {processing_key}") + delattr(self, "_current_processing_key") + setattr(self.__class__, "_ranbooru_global_processing", False) + if hasattr(self, "_current_processing_object"): + try: + delattr(self, "_current_processing_object") + except Exception: + pass + if p is not None: + try: + setattr(p, "_ranbooru_finalized", True) + except Exception: + pass + if hasattr(p, "_ranbooru_already_processing"): + try: + delattr(p, "_ranbooru_already_processing") + except Exception: + try: + setattr(p, "_ranbooru_already_processing", False) + except Exception: + pass + + def _abort_before_process_run(self, reason: str, p=None) -> None: + """Abort a before_process run after guards were acquired.""" + try: + print(f"[R Before] Aborting RanbooruX run: {reason}") + except Exception: + pass + processing_obj = p or getattr(self, "_current_processing_object", None) + self._force_release_processing_guards( + reason, attempt_cleanup=True, processing_obj=processing_obj + ) + def _maybe_release_stale_guards(self, new_processing_obj): """Detect and release stale locks left behind by interrupted runs.""" - guard_active = getattr(self.__class__, '_ranbooru_global_processing', False) + guard_active = getattr(self.__class__, "_ranbooru_global_processing", False) if not guard_active: return - previous_obj = getattr(self, '_current_processing_object', None) + previous_obj = getattr(self, "_current_processing_object", None) if previous_obj is new_processing_obj: return state_interrupted = False state_processing = False try: - state = getattr(shared, 'state', None) + state = getattr(shared, "state", None) if state is not None: - state_interrupted = getattr(state, 'interrupted', False) or getattr(state, 'stopping_job', False) - state_processing = getattr(state, 'processing', False) + state_interrupted = getattr(state, "interrupted", False) or getattr( + state, "stopping_job", False + ) + state_processing = getattr(state, "processing", False) except Exception: state_processing = False previous_finalized = False if previous_obj is not None: - previous_finalized = getattr(previous_obj, '_ranbooru_finalized', False) + previous_finalized = getattr(previous_obj, "_ranbooru_finalized", False) # Release when the WebUI is idle, interrupted, or the previous object was already finalized. if not state_processing or state_interrupted or previous_finalized or previous_obj is None: reason_bits = [] if not state_processing: - reason_bits.append('WebUI idle') + reason_bits.append("WebUI idle") if state_interrupted: - reason_bits.append('interrupted flag set') + reason_bits.append("interrupted flag set") if previous_finalized: - reason_bits.append('previous job finalized') + reason_bits.append("previous job finalized") if previous_obj is None: - reason_bits.append('no tracked processing object') - reason = '; '.join(reason_bits) if reason_bits else 'stale lock detected' + reason_bits.append("no tracked processing object") + reason = "; ".join(reason_bits) if reason_bits else "stale lock detected" self._force_release_processing_guards(reason, processing_obj=new_processing_obj) else: # New processing object arrived while guard is still active - treat as stale lock. - self._force_release_processing_guards('new processing request detected while guard active', processing_obj=new_processing_obj) + self._force_release_processing_guards( + "new processing request detected while guard active", + processing_obj=new_processing_obj, + ) def before_process(self, p: StableDiffusionProcessing, *args): try: # Fast-path for our own internal img2img calls: initialize seeds and exit - if getattr(p, '_ranbooru_internal_img2img', False): + if getattr(p, "_ranbooru_internal_img2img", False): try: # Minimal seeds init to satisfy WebUI expectations - base_seed = getattr(p, 'seed', -1) + base_seed = getattr(p, "seed", -1) if base_seed == -1: base_seed = random.randint(0, 2**32 - 1) p.seed = base_seed - batch_count = max(1, getattr(p, 'n_iter', 1)) - batch_size = max(1, getattr(p, 'batch_size', 1)) + batch_count = max(1, getattr(p, "n_iter", 1)) + batch_size = max(1, getattr(p, "batch_size", 1)) total_images = batch_count * batch_size p.all_seeds = [base_seed + i for i in range(total_images)] - base_subseed = getattr(p, 'subseed', -1) + base_subseed = getattr(p, "subseed", -1) if base_subseed == -1: base_subseed = random.randint(0, 2**32 - 1) p.subseed = base_subseed @@ -4255,7 +3946,9 @@ def before_process(self, p: StableDiffusionProcessing, *args): # Mirror common aliases expected by some codepaths p.seeds = list(p.all_seeds) p.subseeds = list(p.all_subseeds) - print(f"[R Before] Internal img2img fast-path: seeds={len(p.all_seeds)} from {base_seed}, subseeds from {base_subseed}") + print( + f"[R Before] Internal img2img fast-path: seeds={len(p.all_seeds)} from {base_seed}, subseeds from {base_subseed}" + ) except Exception as _e: print(f"[R Before] WARN: Internal img2img seed init failed: {_e}") return @@ -4264,29 +3957,31 @@ def before_process(self, p: StableDiffusionProcessing, *args): self._maybe_release_stale_guards(p) # CRITICAL: Ultra-strict processing guard to prevent any duplicate runs - processing_key = f'_ranbooru_processing_{id(p)}' - + processing_key = f"_ranbooru_processing_{id(p)}" + # Check multiple levels of guards - if (hasattr(self, processing_key) or - getattr(self.__class__, '_ranbooru_global_processing', False) or - hasattr(p, '_ranbooru_already_processing')): - print(f"[R Before] RanbooruX already processing - BLOCKING duplicate run") + if ( + hasattr(self, processing_key) + or getattr(self.__class__, "_ranbooru_global_processing", False) + or hasattr(p, "_ranbooru_already_processing") + ): + print("[R Before] RanbooruX already processing - BLOCKING duplicate run") # Ensure seeds exist to prevent IndexError in core pipeline try: - base_seed = getattr(p, 'seed', -1) + base_seed = getattr(p, "seed", -1) if base_seed == -1: base_seed = random.randint(0, 2**32 - 1) p.seed = base_seed - batch_count = max(1, getattr(p, 'n_iter', 1)) - batch_size = max(1, getattr(p, 'batch_size', 1)) + batch_count = max(1, getattr(p, "n_iter", 1)) + batch_size = max(1, getattr(p, "batch_size", 1)) total_images = batch_count * batch_size - if not getattr(p, 'all_seeds', None): + if not getattr(p, "all_seeds", None): p.all_seeds = [base_seed + i for i in range(total_images)] - base_subseed = getattr(p, 'subseed', -1) + base_subseed = getattr(p, "subseed", -1) if base_subseed == -1: base_subseed = random.randint(0, 2**32 - 1) p.subseed = base_subseed - if not getattr(p, 'all_subseeds', None): + if not getattr(p, "all_subseeds", None): p.all_subseeds = [base_subseed + i for i in range(total_images)] except Exception as _e: print(f"[R Before] WARN: Seed safety init failed on duplicate: {_e}") @@ -4294,34 +3989,89 @@ def before_process(self, p: StableDiffusionProcessing, *args): # Set triple-level guards: instance, class, and processing object setattr(self, processing_key, True) - setattr(self.__class__, '_ranbooru_global_processing', True) - setattr(p, '_ranbooru_already_processing', True) + setattr(self.__class__, "_ranbooru_global_processing", True) + setattr(p, "_ranbooru_already_processing", True) print(f"[R Before] Started RanbooruX processing for request {id(p)}") self._current_processing_object = p - script_args_source = getattr(p, 'script_args', None) + try: + self._host_scope.restore() + except Exception as exc: + print(f"[R Before] Warn: stale host-scope cleanup failed: {exc}") + self._host_scope = rb_mutation_scope.HostMutationScope() + script_args_source = getattr(p, "script_args", None) if isinstance(script_args_source, (list, tuple)): self._adetailer_script_args_snapshot = list(script_args_source) else: self._adetailer_script_args_snapshot = None - + # Store the processing key for cleanup self._current_processing_key = processing_key - # Keep existing ordering stable for most outputs; new toggles are appended toward the end. - (enabled, tags, booru, gelbooru_api_key_ui, gelbooru_user_id_ui, gelbooru_compat_base_url_ui, remove_bad_tags_ui, max_pages, change_dash, same_prompt, - fringe_benefits, remove_tags_ui, use_img2img, denoising, use_last_img, - change_background, change_color, shuffle_tags, post_id, mix_prompt, mix_amount, - chaos_mode, chaos_amount, limit_tags_pct, max_tags_count, sorting_order, mature_rating, - lora_folder, lora_amount, lora_min, lora_max, lora_enabled, - lora_custom_weights, lora_lock_prev, use_ip, use_search_txt, use_remove_txt, - choose_search_txt, choose_remove_txt, search_refresh_btn, remove_refresh_btn, - crop_center, enable_adetailer_support, use_same_seed, reuse_cached_posts, use_cache, log_prompt_sources_ui, - remove_artist_tags_ui, remove_character_tags_ui, remove_clothing_tags_ui, remove_text_tags_ui, restrict_subject_tags_ui, - remove_furry_tags_ui, remove_headwear_tags_ui, remove_girl_suffix_tags_ui, preserve_hair_eye_colors_ui, remove_series_tags_ui, legacy_filter_toggle_ui, use_tag_catalog_ui, tag_catalog_path_ui, - lora_auto_detect_pony_ui, lora_detected_loras_ui, lora_blacklist_ui) = args + options = rb_run_options.RunOptions.from_script_args(args) + enabled = options.enabled + tags = options.tags + booru = options.booru + gelbooru_api_key_ui = options.gelbooru_api_key + gelbooru_user_id_ui = options.gelbooru_user_id + gelbooru_compat_base_url_ui = options.gelbooru_compat_base_url + remove_bad_tags_ui = options.remove_bad_tags + max_pages = options.max_pages + change_dash = options.change_dash + same_prompt = options.same_prompt + fringe_benefits = options.fringe_benefits + remove_tags_ui = options.remove_tags + use_img2img = options.use_img2img + denoising = options.denoising + use_last_img = options.use_last_img + change_background = options.change_background + change_color = options.change_color + shuffle_tags = options.shuffle_tags + post_id = options.post_id + mix_prompt = options.mix_prompt + mix_amount = options.mix_amount + chaos_mode = options.chaos_mode + chaos_amount = options.chaos_amount + limit_tags_pct = options.limit_tags + max_tags_count = options.max_tags + sorting_order = options.sorting_order + mature_rating = options.mature_rating + lora_folder = options.lora_folder + lora_amount = options.lora_amount + lora_min = options.lora_min + lora_max = options.lora_max + lora_enabled = options.lora_enabled + lora_custom_weights = options.lora_custom_weights + lora_lock_prev = options.lora_lock_prev + use_ip = options.use_ip + use_search_txt = options.use_search_txt + use_remove_txt = options.use_remove_txt + choose_search_txt = options.choose_search_txt + choose_remove_txt = options.choose_remove_txt + crop_center = options.crop_center + enable_adetailer_support = options.enable_adetailer_support + use_same_seed = options.use_same_seed + reuse_cached_posts = options.reuse_cached_posts + use_cache = options.use_cache + log_prompt_sources_ui = options.log_prompt_sources + remove_artist_tags_ui = options.remove_artist_tags + remove_character_tags_ui = options.remove_character_tags + remove_clothing_tags_ui = options.remove_clothing_tags + remove_text_tags_ui = options.remove_text_tags + restrict_subject_tags_ui = options.restrict_subject_tags + remove_furry_tags_ui = options.remove_furry_tags + remove_headwear_tags_ui = options.remove_headwear_tags + remove_girl_suffix_tags_ui = options.remove_girl_suffix_tags + preserve_hair_eye_colors_ui = options.preserve_hair_eye_colors + remove_series_tags_ui = options.remove_series_tags + use_tag_catalog_ui = options.use_tag_catalog + tag_catalog_path_ui = options.catalog_path + lora_auto_detect_pony_ui = options.lora_auto_detect_pony + lora_detected_loras_ui = options.lora_detected_loras + lora_blacklist_ui = options.lora_blacklist except Exception as e: print(f"[R Before] CRITICAL Error unpack args: {e}. Aborting.") traceback.print_exc() + self._abort_before_process_run("script argument parsing failed", p) return # denoising may come through as an empty string from the UI in some contexts; parse defensively @@ -4329,8 +4079,10 @@ def before_process(self, p: StableDiffusionProcessing, *args): self.img2img_denoising = float(denoising) except Exception: # fall back to previous default and warn - self.img2img_denoising = float(getattr(self, 'img2img_denoising', 0.75)) - print(f"[R Before] Warn: invalid denoising value '{denoising}', falling back to {self.img2img_denoising}") + self.img2img_denoising = float(getattr(self, "img2img_denoising", 0.75)) + print( + f"[R Before] Warn: invalid denoising value '{denoising}', falling back to {self.img2img_denoising}" + ) # Persist values needed for postprocess to avoid fragile unpacking there self._post_enabled = bool(enabled) @@ -4342,23 +4094,26 @@ def before_process(self, p: StableDiffusionProcessing, *args): self._reuse_cached_posts = bool(reuse_cached_posts) self._adetailer_support_enabled = bool(enable_adetailer_support) self._post_adetailer_enabled = self._adetailer_support_enabled - prev_manual_state = getattr(self, '_manual_adetailer_prev_enabled', False) + prev_manual_state = getattr(self, "_manual_adetailer_prev_enabled", False) self._handle_adetailer_toggle_change(prev_manual_state, self._adetailer_support_enabled, p) self._manual_adetailer_prev_enabled = self._adetailer_support_enabled self._log_prompt_sources = bool(log_prompt_sources_ui) - self._use_legacy_filter_engine = bool(legacy_filter_toggle_ui) self._current_booru_name = booru - if booru == 'gelbooru': - self._gelbooru_effective_credentials = self._resolve_gelbooru_credentials(gelbooru_api_key_ui, gelbooru_user_id_ui) + if booru == "gelbooru": + self._gelbooru_effective_credentials = self._resolve_gelbooru_credentials( + gelbooru_api_key_ui, gelbooru_user_id_ui + ) else: self._gelbooru_effective_credentials = None - if booru == 'gelbooru-compatible': + if booru == "gelbooru-compatible": sanitized_base = _sanitize_gelbooru_compat_base_url(gelbooru_compat_base_url_ui) if sanitized_base: self._gelbooru_compat_base_url = sanitized_base elif not self._gelbooru_compat_base_url: - print("[R Before] Warn: Gelbooru-compatible base URL is empty. Set a base URL in the UI before running.") + print( + "[R Before] Warn: Gelbooru-compatible base URL is empty. Set a base URL in the UI before running." + ) if not self._reuse_cached_posts: self._last_post_urls = [] self._posts_used_for_generation = [] @@ -4379,90 +4134,88 @@ def before_process(self, p: StableDiffusionProcessing, *args): lora_detected_loras_ui, lora_blacklist_ui, ) - + # CRITICAL: Ensure seeds are properly initialized to prevent IndexError # This must happen EVERY time, not just when they're empty - if hasattr(p, 'seed'): + if hasattr(p, "seed"): base_seed = p.seed if p.seed != -1 else random.randint(0, 2**32 - 1) else: base_seed = random.randint(0, 2**32 - 1) p.seed = base_seed - + # Calculate batch size - be more defensive about this - batch_count = max(1, getattr(p, 'n_iter', 1)) - batch_size = max(1, getattr(p, 'batch_size', 1)) + batch_count = max(1, getattr(p, "n_iter", 1)) + batch_size = max(1, getattr(p, "batch_size", 1)) total_images = batch_count * batch_size - + # ALWAYS reinitialize seeds to prevent index errors p.all_seeds = [base_seed + i for i in range(total_images)] - print(f"[R Before] Initialized p.all_seeds with {len(p.all_seeds)} seeds starting from {base_seed}") - + print( + f"[R Before] Initialized p.all_seeds with {len(p.all_seeds)} seeds starting from {base_seed}" + ) + # Also reinitialize all_subseeds - base_subseed = getattr(p, 'subseed', -1) + base_subseed = getattr(p, "subseed", -1) if base_subseed == -1: base_subseed = random.randint(0, 2**32 - 1) p.all_subseeds = [base_subseed + i for i in range(total_images)] - print(f"[R Before] Initialized p.all_subseeds with {len(p.all_subseeds)} subseeds starting from {base_subseed}") - + print( + f"[R Before] Initialized p.all_subseeds with {len(p.all_subseeds)} subseeds starting from {base_subseed}" + ) + # ADDITIONAL: Ensure other seed-related attributes exist - if not hasattr(p, 'seeds'): + if not hasattr(p, "seeds"): p.seeds = p.all_seeds.copy() - if not hasattr(p, 'subseeds'): + if not hasattr(p, "subseeds"): p.subseeds = p.all_subseeds.copy() - + self._reset_adetailer_state_for_run(p) if not enabled: print("[R] RanbooruX is DISABLED - skipping image fetch") self._adetailer_support_enabled = False self._post_adetailer_enabled = False - # Clear processing guards even when disabled - if hasattr(self, '_current_processing_key'): - processing_key = self._current_processing_key - if hasattr(self, processing_key): - delattr(self, processing_key) - delattr(self, '_current_processing_key') - setattr(self.__class__, '_ranbooru_global_processing', False) + self._abort_before_process_run("extension disabled for this run", p) return self._reset_script_runner_guards() if self._is_adetailer_enabled(): print("[R Before] Resetting ADetailer blocking flags for new generation") else: - print("[R Before] Manual ADetailer support disabled - ensuring native ADetailer remains available") + print( + "[R Before] Manual ADetailer support disabled - ensuring native ADetailer remains available" + ) # Clear notification that extension is active print("[R Before] RanbooruX IS ENABLED AND RUNNING") - print(f"[R Before] Search tags: '{tags}' | Booru: {booru} | Img2Img: {use_img2img} | ControlNet: {use_ip}") - + print( + f"[R Before] Search tags: '{tags}' | Booru: {booru} | Img2Img: {use_img2img} | ControlNet: {use_ip}" + ) + # Check if we should reuse existing images or fetch new ones - reuse_cached_posts = bool(getattr(self, '_reuse_cached_posts', False)) + reuse_cached_posts = bool(getattr(self, "_reuse_cached_posts", False)) # Special handling: if tags contain "!refresh", force fetch new images force_refresh = "!refresh" in (tags or "") if force_refresh: original_tags = tags tags = tags.replace("!refresh", "").replace(",,", ",").strip(",") - print(f"[R Before] Detected !refresh command - forcing new image fetch") + print("[R Before] Detected !refresh command - forcing new image fetch") print(f"[R Before] Original tags: '{original_tags}' -> Cleaned: '{tags}'") self._use_tag_catalog = bool(use_tag_catalog_ui) - if self._catalog_source == 'custom': - incoming_custom_path = (tag_catalog_path_ui or '').strip() + if self._catalog_source == "custom": + incoming_custom_path = (tag_catalog_path_ui or "").strip() if incoming_custom_path: self._custom_catalog_path = incoming_custom_path self._tag_catalog_path = self._custom_catalog_path else: - self._tag_catalog_path = '' - if self._use_tag_catalog: - ok, message = self._load_tag_catalog() - if not ok: - self._catalog = NoopCatalog() - else: + self._tag_catalog_path = "" + if not self._use_tag_catalog: + self._set_catalog_source("bundled") + ok, message = self._load_tag_catalog() + if not ok: self._catalog = NoopCatalog() - self._tag_catalog_diag = {} - self._update_tag_diag() - message = 'Catalog mode: OFF' self._tag_catalog_status_text = message self._save_tag_catalog_preferences() self._update_catalog_status(message) @@ -4474,20 +4227,20 @@ def before_process(self, p: StableDiffusionProcessing, *args): should_fetch_new = True else: should_fetch_new = ( - force_refresh or - not hasattr(self, '_last_search_key') or - self._last_search_key != current_search_key or - not hasattr(self, '_cached_posts') or - not self._cached_posts or - not hasattr(self, 'last_img') or - not self.last_img + force_refresh + or not hasattr(self, "_last_search_key") + or self._last_search_key != current_search_key + or not hasattr(self, "_cached_posts") + or not self._cached_posts + or not hasattr(self, "last_img") + or not self.last_img ) - + if not reuse_cached_posts: self._cached_posts = [] - self._cached_search_tags = '' + self._cached_search_tags = "" self._cached_bad_tags = set() - self._cached_initial_additions = '' + self._cached_initial_additions = "" self._cached_strict_rejections = [] self._cached_strict_active = False self._cached_strict_relaxed = False @@ -4496,13 +4249,21 @@ def before_process(self, p: StableDiffusionProcessing, *args): if force_refresh: print("[R Before] Fetching new images (!refresh command used)") else: - print("[R Before] Fetching new images (search parameters changed or caching disabled)") + print( + "[R Before] Fetching new images (search parameters changed or caching disabled)" + ) else: if reuse_cached_posts: - print(f"[R Before] Reusing cached images ({len(self.last_img)} images) from previous search") + print( + f"[R Before] Reusing cached images ({len(self.last_img)} images) from previous search" + ) print("[R Before] TIP: Add '!refresh' to your tags to force fetch new images") - - self.original_prompt = p.prompt if isinstance(p.prompt, str) else (p.prompt[0] if isinstance(p.prompt, list) and p.prompt else "") + + self.original_prompt = ( + p.prompt + if isinstance(p.prompt, str) + else (p.prompt[0] if isinstance(p.prompt, list) and p.prompt else "") + ) base_hair_colors, base_eye_colors = self._extract_color_tags(self.original_prompt) self._base_hair_color_tags = base_hair_colors self._base_eye_color_tags = base_eye_colors @@ -4512,7 +4273,7 @@ def before_process(self, p: StableDiffusionProcessing, *args): self._strict_initial_additions = "" self._strict_allowed_subjects = set(self._extract_subject_tags(self.original_prompt)) base_subjects = set(self._strict_allowed_subjects) - + if not should_fetch_new: # Skip the fetching process but continue with cached images selected_posts = self._cached_posts @@ -4522,10 +4283,9 @@ def before_process(self, p: StableDiffusionProcessing, *args): try: self.cache_installed_by_us = self._setup_cache(use_cache) - + # Always calculate num_images_needed - needed for both new and cached images - original_batch = getattr(self, 'original_batch_size', p.batch_size) - num_images_needed = original_batch * p.n_iter + num_images_needed = p.batch_size * p.n_iter filter_ctx: Optional[Dict[str, object]] = None if should_fetch_new: search_tags, bad_tags, initial_additions = self._prepare_tags( @@ -4572,8 +4332,12 @@ def before_process(self, p: StableDiffusionProcessing, *args): self._preserve_hair_eye_colors = bool(preserve_hair_eye_colors_ui) base_colors_tuple = (set(base_hair_colors), set(base_eye_colors)) - api = self._get_booru_api(booru, fringe_benefits, getattr(self, '_gelbooru_effective_credentials', None)) - all_posts, tags_query = self._fetch_booru_posts(api, search_tags, mature_rating, max_pages, post_id) + api = self._get_booru_api( + booru, fringe_benefits, getattr(self, "_gelbooru_effective_credentials", None) + ) + all_posts, tags_query = self._fetch_booru_posts( + api, search_tags, mature_rating, max_pages, post_id + ) filtered_posts = list(all_posts) strict_rejections: List[Dict[str, object]] = [] @@ -4583,17 +4347,19 @@ def before_process(self, p: StableDiffusionProcessing, *args): strict_enabled_for_run = bool(use_img2img and not post_id) if strict_enabled_for_run: - filtered_posts, strict_rejections, strict_active, strict_relaxed = self._apply_strict_img2img_prefilter( - list(all_posts), - api=api, - tags_query=tags_query, - post_id=post_id, - num_images_needed=num_images_needed, - max_pages=max_pages, - filter_ctx=filter_ctx, - toggles=toggles_tuple, - base_colors=base_colors_tuple, - allowed_subjects=allowed_subjects, + filtered_posts, strict_rejections, strict_active, strict_relaxed = ( + self._apply_strict_img2img_prefilter( + list(all_posts), + api=api, + tags_query=tags_query, + post_id=post_id, + num_images_needed=num_images_needed, + max_pages=max_pages, + filter_ctx=filter_ctx, + toggles=toggles_tuple, + base_colors=base_colors_tuple, + allowed_subjects=allowed_subjects, + ) ) self._strict_img2img_active = strict_active self._strict_img2img_relaxed = strict_relaxed @@ -4601,14 +4367,18 @@ def before_process(self, p: StableDiffusionProcessing, *args): self._last_rejections = list(self._strict_img2img_rejections) if strict_active: if strict_rejections: - print(f"[R Strict] Img2Img strict pre-filter rejected {len(strict_rejections)} candidate(s) before download") + print( + f"[R Strict] Img2Img strict pre-filter rejected {len(strict_rejections)} candidate(s) before download" + ) preview = strict_rejections[:STRICT_IMG2IMG_LOG_SAMPLE] for entry in preview: print( f"[R Strict] - {entry.get('booru')} post {entry.get('post_id')} rejected by {entry.get('rule_type')} (tag: {entry.get('matched_tag')})" ) if len(strict_rejections) > STRICT_IMG2IMG_LOG_SAMPLE: - print(f"[R Strict] - ... {len(strict_rejections) - STRICT_IMG2IMG_LOG_SAMPLE} more") + print( + f"[R Strict] - ... {len(strict_rejections) - STRICT_IMG2IMG_LOG_SAMPLE} more" + ) print( f"[R Strict] {len(filtered_posts)} candidate(s) available after strict filtering (need {num_images_needed})" ) @@ -4619,7 +4389,9 @@ def before_process(self, p: StableDiffusionProcessing, *args): self._last_rejections = [] all_posts = filtered_posts - selected_posts = self._select_posts(filtered_posts, sorting_order, num_images_needed, post_id, same_prompt) + selected_posts = self._select_posts( + filtered_posts, sorting_order, num_images_needed, post_id, same_prompt + ) # Cache the results for future use self._cached_posts = selected_posts @@ -4643,16 +4415,18 @@ def before_process(self, p: StableDiffusionProcessing, *args): self._last_post_urls = post_urls if use_img2img or use_ip: - self.last_img = self._fetch_images(selected_posts, use_last_img, booru, fringe_benefits) + self.last_img = self._fetch_images( + selected_posts, use_last_img, booru, fringe_benefits + ) else: # Use cached values - search_tags = getattr(self, '_cached_search_tags', '') - bad_tags = set(getattr(self, '_cached_bad_tags', set())) + search_tags = getattr(self, "_cached_search_tags", "") + bad_tags = set(getattr(self, "_cached_bad_tags", set())) bad_tags.update(personal_remove_tags) - initial_additions = getattr(self, '_cached_initial_additions', '') + initial_additions = getattr(self, "_cached_initial_additions", "") self._cached_bad_tags = set(bad_tags) self._strict_initial_additions = initial_additions - all_posts = list(getattr(self, '_cached_posts', [])) + all_posts = list(getattr(self, "_cached_posts", [])) if bool(restrict_subject_tags_ui): allowed_subjects = set(base_subjects) @@ -4661,21 +4435,22 @@ def before_process(self, p: StableDiffusionProcessing, *args): else: self._strict_allowed_subjects = set() - filter_ctx = getattr(self, '_removal_context', None) + filter_ctx = getattr(self, "_removal_context", None) if filter_ctx is None: filter_ctx = self._build_removal_context(bad_tags, favorites_tags) - self._strict_img2img_active = bool(getattr(self, '_cached_strict_active', False)) - self._strict_img2img_relaxed = bool(getattr(self, '_cached_strict_relaxed', False)) - cached_rejections = getattr(self, '_cached_strict_rejections', []) - self._strict_img2img_rejections = list(cached_rejections) if cached_rejections else [] + self._strict_img2img_active = bool(getattr(self, "_cached_strict_active", False)) + self._strict_img2img_relaxed = bool(getattr(self, "_cached_strict_relaxed", False)) + cached_rejections = getattr(self, "_cached_strict_rejections", []) + self._strict_img2img_rejections = ( + list(cached_rejections) if cached_rejections else [] + ) self._last_rejections = list(self._strict_img2img_rejections) - self._build_legacy_bad_index(bad_tags) if filter_ctx is None: filter_ctx = self._build_removal_context(bad_tags, favorites_tags) self._removal_context = filter_ctx self._tag_normal_cache = {} - + # persist selected posts and removal flags so prompt processing can access them self._selected_posts = selected_posts self._remove_artist_tags = bool(remove_artist_tags_ui) @@ -4690,26 +4465,40 @@ def before_process(self, p: StableDiffusionProcessing, *args): # Preview UI removed by request - base_negative = getattr(p, 'negative_prompt', '') or "" + base_negative = getattr(p, "negative_prompt", "") or "" final_prompts = [] final_negative_prompts = [base_negative] * num_images_needed prompt_processing_settings = ( - shuffle_tags, chaos_mode, chaos_amount, limit_tags_pct, max_tags_count, change_dash, - self._remove_artist_tags, self._remove_character_tags, - self._remove_clothing_tags, self._remove_text_tags, self._restrict_subject_tags, - self._remove_furry_tags, self._remove_headwear_tags, self._preserve_hair_eye_colors, - self._remove_series_tags + shuffle_tags, + chaos_mode, + chaos_amount, + limit_tags_pct, + max_tags_count, + change_dash, + self._remove_artist_tags, + self._remove_character_tags, + self._remove_clothing_tags, + self._remove_text_tags, + self._restrict_subject_tags, + self._remove_furry_tags, + self._remove_headwear_tags, + self._preserve_hair_eye_colors, + self._remove_series_tags, ) - + # Ensure we only use the number of posts that match the current generation request - posts_to_use = selected_posts[:num_images_needed] if len(selected_posts) > num_images_needed else selected_posts + posts_to_use = ( + selected_posts[:num_images_needed] + if len(selected_posts) > num_images_needed + else selected_posts + ) # If we need more images than available posts, repeat the last post while len(posts_to_use) < num_images_needed: posts_to_use.append(posts_to_use[-1] if posts_to_use else selected_posts[0]) self._posts_used_for_generation = list(posts_to_use) - + # Also align cached images with current generation request - if not should_fetch_new and hasattr(self, 'last_img') and self.last_img: + if not should_fetch_new and hasattr(self, "last_img") and self.last_img: # Adjust cached images to match current request if len(self.last_img) > num_images_needed: self.last_img = self.last_img[:num_images_needed] @@ -4717,29 +4506,49 @@ def before_process(self, p: StableDiffusionProcessing, *args): # Repeat images to fill the requirement while len(self.last_img) < num_images_needed: self.last_img.append(self.last_img[-1] if self.last_img else None) - print(f"[R] Aligned cached images: {len(self.last_img)} images for {num_images_needed} requested") - - raw_prompts = [post.get('tags', '') for post in posts_to_use] - print(f"[R] Using {len(posts_to_use)} posts for {num_images_needed} images (from {len(selected_posts)} cached)") + print( + f"[R] Aligned cached images: {len(self.last_img)} images for {num_images_needed} requested" + ) + + raw_prompts = [post.get("tags", "") for post in posts_to_use] + print( + f"[R] Using {len(posts_to_use)} posts for {num_images_needed} images (from {len(selected_posts)} cached)" + ) if mix_prompt and not post_id and not same_prompt: print(f"[R] Mixing tags from {mix_amount} posts...") mixed_prompts = [] original_indices_map = {i: post for i in range(len(all_posts))} for _ in range(num_images_needed): - mix_indices = random.sample(list(original_indices_map.keys()), min(mix_amount, len(original_indices_map))) + mix_indices = random.sample( + list(original_indices_map.keys()), + min(mix_amount, len(original_indices_map)), + ) combined_tags = set() for mix_idx in mix_indices: - combined_tags.update([t.strip() for t in all_posts[mix_idx].get('tags', '').split(' ') if t.strip()]) + combined_tags.update( + [ + t.strip() + for t in all_posts[mix_idx].get("tags", "").split(" ") + if t.strip() + ] + ) final_mix_tags = list(combined_tags) random.shuffle(final_mix_tags) if max_tags_count > 0: final_mix_tags = final_mix_tags[:max_tags_count] - mixed_prompts.append(','.join(final_mix_tags)) + mixed_prompts.append(",".join(final_mix_tags)) raw_prompts = mixed_prompts for i, rp in enumerate(raw_prompts): - processed_prompt, processed_negative = self._process_single_prompt(i, rp, self.original_prompt, base_negative, initial_additions, prompt_processing_settings) + processed_prompt, processed_negative = self._process_single_prompt( + i, + rp, + self.original_prompt, + base_negative, + initial_additions, + prompt_processing_settings, + ) final_prompts.append(processed_prompt) final_negative_prompts[i] = processed_negative @@ -4767,15 +4576,23 @@ def before_process(self, p: StableDiffusionProcessing, *args): # Preferred: external_code API from ControlNet try: cn_module = self._load_cn_external_code() - if hasattr(cn_module, 'get_all_units_in_processing') and hasattr(cn_module, 'update_cn_script_in_processing'): + if hasattr(cn_module, "get_all_units_in_processing") and hasattr( + cn_module, "update_cn_script_in_processing" + ): cn_units = cn_module.get_all_units_in_processing(p) if cn_units and len(cn_units) > 0: copied_unit = cn_units[0].__dict__.copy() - copied_unit['enabled'] = True - copied_unit['weight'] = float(self.img2img_denoising) - img_for_cn = self.last_img[0].convert('RGB') if self.last_img[0].mode != 'RGB' else self.last_img[0] - copied_unit['image']['image'] = np.array(img_for_cn) - cn_module.update_cn_script_in_processing(p, [copied_unit] + cn_units[1:]) + copied_unit["enabled"] = True + copied_unit["weight"] = float(self.img2img_denoising) + img_for_cn = ( + self.last_img[0].convert("RGB") + if self.last_img[0].mode != "RGB" + else self.last_img[0] + ) + copied_unit["image"]["image"] = np.array(img_for_cn) + cn_module.update_cn_script_in_processing( + p, [copied_unit] + cn_units[1:] + ) cn_configured = True print("[R Before] ControlNet configured via external_code.") # else: module loaded but does not expose update helpers; silently skip to fallback @@ -4798,17 +4615,25 @@ def before_process(self, p: StableDiffusionProcessing, *args): max_idx = max(enabled_idx, weight_idx, image_idx) if max_idx < len(args_target_list): try: - img_for_cn = self.last_img[0].convert('RGB') if self.last_img[0].mode != 'RGB' else self.last_img[0] - cn_image_input = {'image': np.array(img_for_cn), 'mask': None} + img_for_cn = ( + self.last_img[0].convert("RGB") + if self.last_img[0].mode != "RGB" + else self.last_img[0] + ) + cn_image_input = {"image": np.array(img_for_cn), "mask": None} args_target_list[enabled_idx] = True args_target_list[weight_idx] = float(self.img2img_denoising) args_target_list[image_idx] = cn_image_input p.script_args = tuple(args_target_list) - print("[R Before] ControlNet using fallback p.script_args hack.") + print( + "[R Before] ControlNet using fallback p.script_args hack." + ) except Exception as e: print(f"[R Before] Error setting CN via p.script_args: {e}") else: - print(f"[R Before] Error: CN arg index ({max_idx}) OOB ({len(args_target_list)}).") + print( + f"[R Before] Error: CN arg index ({max_idx}) OOB ({len(args_target_list)})." + ) else: print("[R Before] Error: p.script_args is not a tuple.") @@ -4817,25 +4642,28 @@ def before_process(self, p: StableDiffusionProcessing, *args): except Exception as e: print(f"[Ranbooru BeforeProcess] UNEXPECTED ERROR: {e}") traceback.print_exc() - if hasattr(self, 'cache_installed_by_us') and self.cache_installed_by_us and requests_cache.patcher.is_installed(): - requests_cache.uninstall_cache() - print("[R] Uninstalled cache due to error.") + self._abort_before_process_run("before_process failed", p) + return print("[Ranbooru BeforeProcess] Finished.") def _reset_adetailer_state_for_run(self, p): """Clear RanbooruX-managed ADetailer flags before a generation begins.""" + self._adetailer_state.reset() + patch_errors = self._adetailer_patches.uninstall_all() + if patch_errors: + print("[R Before] ADetailer stale patch restore warnings: " + "; ".join(patch_errors)) self._unpatch_manual_adetailer_overrides() - setattr(self.__class__, '_ranbooru_block_all_adetailer', False) - setattr(self.__class__, '_adetailer_global_guard_active', False) - setattr(self.__class__, '_adetailer_pipeline_blocked', False) - setattr(self.__class__, '_ranbooru_manual_adetailer_active', False) + setattr(self.__class__, "_ranbooru_block_all_adetailer", False) + setattr(self.__class__, "_adetailer_global_guard_active", False) + setattr(self.__class__, "_adetailer_pipeline_blocked", False) + setattr(self.__class__, "_ranbooru_manual_adetailer_active", False) cleanup_attrs = ( - '_ranbooru_manual_adetailer_complete', - '_ad_disabled', - '_ranbooru_skip_initial_adetailer', - '_ranbooru_suppress_all_processing', - '_ranbooru_adetailer_already_processed', + "_ranbooru_manual_adetailer_complete", + "_ad_disabled", + "_ranbooru_skip_initial_adetailer", + "_ranbooru_suppress_all_processing", + "_ranbooru_adetailer_already_processed", ) for attr in cleanup_attrs: if hasattr(p, attr): @@ -4850,293 +4678,74 @@ def _reset_adetailer_state_for_run(self, p): except Exception as exc: print(f"[R Before] Warn: Could not clear ADetailer global guard: {exc}") - # If a previous manual run removed or disabled ADetailer scripts, restore them now so - # disabling the manual toggle returns control back to Forge's native behaviour. - restore_needed = ( - hasattr(self, '_stored_adetailer_scripts') or - hasattr(self, 'disabled_adetailer_scripts') or - getattr(self.__class__, '_block_640x512_images', False) - ) - if restore_needed: - try: - self._restore_early_adetailer_protection(p) - except Exception as exc: - print(f"[R Before] Warn: Failed to restore ADetailer pipeline state: {exc}") - if not getattr(self, '_adetailer_support_enabled', False): - try: - self._restore_native_adetailer_scripts(p) - except Exception as exc: - print(f"[R Before] Warn: Failed to restore native ADetailer state: {exc}") - - def _restore_native_adetailer_scripts(self, p): - """Ensure native ADetailer scripts resume running when manual support is disabled.""" - try: - needs_unpatch = any( - hasattr(self, attr) - for attr in ('_patched_processed_objects', '_patched_adetailer_modules', '_patched_conversion_modules') - ) - if needs_unpatch: - self._unpatch_manual_adetailer_overrides() - except Exception as exc: - print(f"[R Before] Warn: Could not unpatch manual ADetailer overrides: {exc}") - try: - self._set_adetailer_block(False) - except Exception: - pass - setattr(self.__class__, '_ranbooru_block_all_adetailer', False) - setattr(self.__class__, '_adetailer_global_guard_active', False) - try: - self._restore_early_adetailer_protection(p) - except Exception as exc: - print(f"[R Before] Warn: Could not restore ADetailer runner state: {exc}") - try: - self._reenable_adetailer_from_previous_generation() - except Exception as exc: - print(f"[R Before] Warn: Could not re-enable ADetailer scripts: {exc}") - try: - restored = self._force_enable_adetailer_scripts(p) - except Exception as exc: - print(f"[R Before] Warn: Could not force-enable ADetailer scripts: {exc}") - restored = 0 - if restored: - print(f"[R Before] Restored {restored} native ADetailer script(s) after manual toggle was disabled") - if hasattr(self, 'disabled_adetailer_scripts'): - try: - delattr(self, 'disabled_adetailer_scripts') - except Exception: - pass - guard_present = False - try: - import modules.scripts as scripts_module - for runner_attr in ('scripts_txt2img', 'scripts_img2img'): - runner = getattr(scripts_module, runner_attr, None) - if runner and getattr(runner, '_ranbooru_guard_installed', False): - guard_present = True - break - except Exception: - guard_present = False - if guard_present: + # If a previous manual run removed or disabled ADetailer scripts, restore them now so + # disabling the manual toggle returns control back to Forge's native behaviour. + restore_needed = hasattr(self, "_stored_adetailer_scripts") or hasattr( + self, "disabled_adetailer_scripts" + ) + if restore_needed: try: - self._reset_script_runner_guards() + self._restore_early_adetailer_protection(p) except Exception as exc: - print(f"[R Before] Warn: Could not reset script runner guards: {exc}") - self._ensure_native_adetailer_enable_flags(p) - if not self._native_adetailer_detected(): + print(f"[R Before] Warn: Failed to restore ADetailer pipeline state: {exc}") + if not getattr(self, "_adetailer_support_enabled", False): try: - import modules.scripts as scripts_module - if hasattr(scripts_module, 'reload_scripts'): - print('[R Before] Reloading scripts to restore native ADetailer') - scripts_module.reload_scripts() + self._restore_native_adetailer_scripts(p) except Exception as exc: - print(f"[R Before] Warn: Could not reload scripts for ADetailer: {exc}") + print(f"[R Before] Warn: Failed to restore native ADetailer state: {exc}") + + def _restore_native_adetailer_scripts(self, p): + """Ensure native ADetailer scripts resume running when manual support is disabled.""" + self._adetailer_orch._restore_native_adetailer_scripts(p) def _force_enable_adetailer_scripts(self, processing_obj=None): """Return the count of ADetailer scripts restored to their original behaviour.""" - try: - import modules.scripts as scripts_module - except Exception as exc: - print(f"[R Before] Warn: Could not access scripts module to restore ADetailer: {exc}") - return 0 - runners = [] - for runner_attr in ('scripts_txt2img', 'scripts_img2img'): - runner = getattr(scripts_module, runner_attr, None) - if runner: - runners.append(runner) - if processing_obj is not None and hasattr(processing_obj, 'scripts') and processing_obj.scripts not in runners: - runners.append(processing_obj.scripts) - seen_ids = set() - restored_count = 0 - for runner in runners: - if runner is None: - continue - for list_attr in ('alwayson_scripts', 'scripts'): - script_list = getattr(runner, list_attr, None) - if not script_list: - continue - for script in script_list: - if not script: - continue - script_id = id(script) - if script_id in seen_ids: - continue - seen_ids.add(script_id) - if not self._is_adetailer_script(script): - continue - restored = False - if hasattr(script, 'enabled') and script.enabled is False: - script.enabled = True - restored = True - for method_name in ('postprocess', 'process', 'process_batch', 'before_process', 'after_process'): - backup_name = f'_ranbooru_original_{method_name}' - if hasattr(script, backup_name): - try: - setattr(script, method_name, getattr(script, backup_name)) - except Exception: - pass - try: - delattr(script, backup_name) - except Exception: - pass - restored = True - for attr in ('_ranbooru_disabled_after_manual', '_ranbooru_disabled_source'): - if hasattr(script, attr): - try: - delattr(script, attr) - except Exception: - pass - restored = True - if restored: - restored_count += 1 - if restored_count == 0: - try: - debug_entries = [] - for runner in runners: - if not runner: - continue - for list_attr in ('alwayson_scripts', 'scripts'): - script_list = getattr(runner, list_attr, None) - if not script_list: - continue - for script in script_list: - if self._is_adetailer_script(script): - debug_entries.append(f"{script.__class__.__name__}(enabled={getattr(script, 'enabled', 'n/a')})") - if debug_entries: - print(f"[R Before] Native ADetailer scripts detected: {', '.join(debug_entries)}") - except Exception: - pass - return restored_count - + return self._adetailer_orch._force_enable_adetailer_scripts(processing_obj) def _ensure_native_adetailer_enable_flags(self, processing_obj): - if not getattr(self, '_adetailer_support_enabled', False): - return - try: - args = getattr(processing_obj, 'script_args', None) - except Exception as exc: - print(f"[R Before] Native ADetailer: unable to read script_args: {exc}") - return - if not isinstance(args, (list, tuple)) or not args: - print("[R Before] Native ADetailer: script_args empty or not list/tuple; skipping flag repair") - return - args_list = list(args) - runners = [] - runner = getattr(processing_obj, 'scripts', None) - if runner is not None: - runners.append(runner) - try: - import modules.scripts as scripts_module - for attr in ('scripts_txt2img', 'scripts_img2img'): - global_runner = getattr(scripts_module, attr, None) - if global_runner is not None and global_runner not in runners: - runners.append(global_runner) - except Exception as exc: - print(f"[R Before] Native ADetailer: could not gather global runners: {exc}") - candidates = [] - for r in runners: - for list_attr in ('alwayson_scripts', 'scripts'): - script_list = getattr(r, list_attr, None) - if script_list: - candidates.extend(script_list) - if not candidates: - print("[R Before] Native ADetailer: no script candidates found for flag repair") - return - changed = False - for script in candidates: - if not self._is_adetailer_script(script): - continue - extracted = self._extract_adetailer_script_args(script, processing_obj) - sanitized = list(extracted.get('args') or []) - meta = extracted.get('meta') or {} - start_idx = meta.get('slice_start') - end_idx = meta.get('slice_end') - if start_idx is None or end_idx is None: - continue - start_idx = max(0, min(len(args_list), start_idx)) - end_idx = max(start_idx, min(len(args_list), end_idx)) - if not sanitized or end_idx - start_idx != len(sanitized): - slice_view = args_list[start_idx:end_idx] - else: - slice_view = sanitized - print(f"[R Before] Native ADetailer candidate {script.__class__.__name__} enabled={getattr(script, 'enabled', 'n/a')} slice [{start_idx}:{end_idx}] -> {slice_view}") - if not sanitized: - continue - bool_index = 0 - local_changed = False - for offset, val in enumerate(sanitized): - if isinstance(val, bool): - if bool_index == 0 and val is False: - sanitized[offset] = True - local_changed = True - print(f"[R Before] Set native ADetailer enable flag True at offset {offset}") - elif bool_index == 1 and val is True: - sanitized[offset] = False - local_changed = True - print(f"[R Before] Cleared native ADetailer skip flag at offset {offset}") - bool_index += 1 - elif isinstance(val, dict): - if val.get('ad_tab_enable') is False and val.get('ad_model') not in (None, '', 'None'): - val['ad_tab_enable'] = True - local_changed = True - print(f"[R Before] Enabled ad_tab_enable in dict at offset {offset}") - if local_changed: - if end_idx - start_idx == len(sanitized): - args_list[start_idx:end_idx] = sanitized - changed = True - continue - # fallback if lengths mismatch - for offset, val in enumerate(sanitized): - target_idx = start_idx + offset - if target_idx < len(args_list): - args_list[target_idx] = val - else: - args_list.append(val) - changed = True - if changed: - if isinstance(args, list): - processing_obj.script_args = args_list - else: - processing_obj.script_args = tuple(args_list) - print(f"[R Before] Native ADetailer flags updated: {args_list}") - else: - print("[R Before] Native ADetailer flags already enabled; no changes made") + self._adetailer_orch._ensure_native_adetailer_enable_flags(processing_obj) def _force_native_adetailer_execution(self, p, processed): - if getattr(self, '_adetailer_support_enabled', False): + if getattr(self, "_adetailer_support_enabled", False): return False if not self._native_adetailer_requested(p): return False try: - if getattr(self, '_native_adetailer_fallback_used', False): + if getattr(self, "_native_adetailer_fallback_used", False): return False - if not processed or not getattr(processed, 'images', None): - print('[R Before] Native fallback: processed has no images; skipping ADetailer run') + if not processed or not getattr(processed, "images", None): + print("[R Before] Native fallback: processed has no images; skipping ADetailer run") return False image_list = [img for img in processed.images if img is not None] if not image_list: - print('[R Before] Native fallback: no valid images available for ADetailer') + print("[R Before] Native fallback: no valid images available for ADetailer") return False if not self._native_adetailer_detected(): - print('[R Before] Native fallback: no native ADetailer scripts detected; skipping fallback') + print( + "[R Before] Native fallback: no native ADetailer scripts detected; skipping fallback" + ) return False - print(f"[R Before] Native fallback: running manual ADetailer on {len(image_list)} txt2img result(s)") + print( + f"[R Before] Native fallback: running manual ADetailer on {len(image_list)} txt2img result(s)" + ) self._prepare_processing_for_manual_adetailer(p, processed, image_list) self._native_adetailer_fallback_used = True - original_support = getattr(self, '_adetailer_support_enabled', False) - original_post_support = getattr(self, '_post_adetailer_enabled', False) - original_prev_manual = getattr(self, '_manual_adetailer_prev_enabled', False) + original_support = getattr(self, "_adetailer_support_enabled", False) + original_post_support = getattr(self, "_post_adetailer_enabled", False) + original_prev_manual = getattr(self, "_manual_adetailer_prev_enabled", False) try: self._adetailer_support_enabled = True self._post_adetailer_enabled = True self._manual_adetailer_prev_enabled = True - ran = self._run_adetailer_on_img2img(p, processed, image_list) + ran = self._execute_manual_adetailer(p, processed, image_list) finally: self._adetailer_support_enabled = original_support self._post_adetailer_enabled = original_post_support self._manual_adetailer_prev_enabled = original_prev_manual if ran: - print('[R Before] Native fallback: manual ADetailer execution complete') + print("[R Before] Native fallback: manual ADetailer execution complete") else: - print('[R Before] Native fallback: manual ADetailer execution reported failure') + print("[R Before] Native fallback: manual ADetailer execution reported failure") return bool(ran) except Exception as exc: print(f"[R Before] Native fallback: error running manual ADetailer: {exc}") @@ -5144,19 +4753,20 @@ def _force_native_adetailer_execution(self, p, processed): def _native_adetailer_requested(self, processing_obj): try: - args = getattr(processing_obj, 'script_args', None) + args = getattr(processing_obj, "script_args", None) except Exception: return False if not isinstance(args, (list, tuple)) or not args: return False args_list = list(args) runners = [] - runner = getattr(processing_obj, 'scripts', None) + runner = getattr(processing_obj, "scripts", None) if runner is not None: runners.append(runner) try: import modules.scripts as scripts_module - for attr in ('scripts_txt2img', 'scripts_img2img'): + + for attr in ("scripts_txt2img", "scripts_img2img"): global_runner = getattr(scripts_module, attr, None) if global_runner is not None and global_runner not in runners: runners.append(global_runner) @@ -5164,7 +4774,7 @@ def _native_adetailer_requested(self, processing_obj): pass candidates = [] for r in runners: - for list_attr in ('alwayson_scripts', 'scripts'): + for list_attr in ("alwayson_scripts", "scripts"): script_list = getattr(r, list_attr, None) if script_list: candidates.extend(script_list) @@ -5172,7 +4782,7 @@ def _native_adetailer_requested(self, processing_obj): if not self._is_adetailer_script(script): continue extracted = self._extract_adetailer_script_args(script, processing_obj) - sanitized = list(extracted.get('args') or []) + sanitized = list(extracted.get("args") or []) if sanitized and isinstance(sanitized[0], bool): return sanitized[0] # Fallback: try first bool in original args @@ -5186,11 +4796,11 @@ def _native_adetailer_detected(self): import modules.scripts as scripts_module except Exception: return False - for runner_attr in ('scripts_txt2img', 'scripts_img2img'): + for runner_attr in ("scripts_txt2img", "scripts_img2img"): runner = getattr(scripts_module, runner_attr, None) if not runner: continue - for list_attr in ('alwayson_scripts', 'scripts'): + for list_attr in ("alwayson_scripts", "scripts"): script_list = getattr(runner, list_attr, None) if not script_list: continue @@ -5209,56 +4819,70 @@ def _handle_adetailer_toggle_change(self, previous_enabled, current_enabled, p): def postprocess(self, p: StableDiffusionProcessing, processed, *args): try: # If this generation already finalized, avoid looping - if getattr(p, '_ranbooru_finalized', False): + if getattr(p, "_ranbooru_finalized", False): print("[R Post] Already finalized this generation; skipping repeat postprocess") return # If this call is re-entered during our manual ADetailer run, skip to avoid loops - if getattr(self.__class__, '_ranbooru_manual_adetailer_active', False): + if getattr(self.__class__, "_ranbooru_manual_adetailer_active", False): print("[R Post] Skipping RanbooruX postprocess during manual ADetailer run") return # Prevent duplicate img2img runs within the same generation - if getattr(p, '_ranbooru_img2img_started', False): - print("[R Post] Img2Img already started for this generation; skipping duplicate postprocess entry") + if getattr(p, "_ranbooru_img2img_started", False): + print( + "[R Post] Img2Img already started for this generation; skipping duplicate postprocess entry" + ) return - enabled = getattr(self, '_post_enabled', False) - use_img2img = getattr(self, '_post_use_img2img', False) - use_last_img = getattr(self, '_post_use_last_img', False) - crop_center = getattr(self, '_post_crop_center', False) - use_cache = getattr(self, '_post_use_cache', True) - use_adetailer = getattr(self, '_post_adetailer_enabled', False) and self._is_adetailer_enabled() - + enabled = getattr(self, "_post_enabled", False) + use_img2img = getattr(self, "_post_use_img2img", False) + getattr(self, "_post_use_last_img", False) + crop_center = getattr(self, "_post_crop_center", False) + use_cache = getattr(self, "_post_use_cache", True) + use_adetailer = ( + getattr(self, "_post_adetailer_enabled", False) and self._is_adetailer_enabled() + ) + # Validate essential objects - if not processed or not hasattr(processed, 'images'): + if not processed or not hasattr(processed, "images"): print("[R Post] Error: Invalid processed object, skipping img2img") self._cleanup_after_run(use_cache) + self._clear_processing_guards(p) return - + if not enabled: print("[R Post] RanbooruX disabled, skipping img2img") self._cleanup_after_run(use_cache) + self._clear_processing_guards(p) return - - if not (getattr(self, 'run_img2img_pass', False) and hasattr(self, 'last_img') and self.last_img and use_img2img): + + if not ( + getattr(self, "run_img2img_pass", False) + and hasattr(self, "last_img") + and self.last_img + and use_img2img + ): fallback_ran = False - if not use_adetailer and not getattr(self, '_adetailer_support_enabled', False): + if not use_adetailer and not getattr(self, "_adetailer_support_enabled", False): fallback_ran = self._force_native_adetailer_execution(p, processed) if fallback_ran: self._cleanup_after_run(use_cache) + self._clear_processing_guards(p) return print("[R Post] Img2Img conditions not met, skipping") self._cleanup_after_run(use_cache) + self._clear_processing_guards(p) return - + except Exception as e: print(f"[R Post] Error in postprocess validation: {e}") - self._cleanup_after_run(getattr(self, '_post_use_cache', True)) + self._cleanup_after_run(getattr(self, "_post_use_cache", True)) + self._clear_processing_guards(p) return - + # Main img2img processing block try: # Mark as started to avoid re-entrant img2img runs try: - setattr(p, '_ranbooru_img2img_started', True) + setattr(p, "_ranbooru_img2img_started", True) except Exception: pass @@ -5268,221 +4892,247 @@ def postprocess(self, p: StableDiffusionProcessing, processed, *args): # CRITICAL: Prepare ADetailer for img2img so it can process the final results self._prepare_adetailer_for_img2img(p) else: - print('[R Post] Manual ADetailer support disabled; skipping ADetailer preparation steps') + print( + "[R Post] Manual ADetailer support disabled; skipping ADetailer preparation steps" + ) - print('[R Post] Starting separate Img2Img run...') + print("[R Post] Starting separate Img2Img run...") valid_images = [img for img in self.last_img if img is not None] if not valid_images: print("[R Post] No valid images for Img2Img.") self._cleanup_after_run(use_cache) + self._clear_processing_guards(p) return if len(valid_images) < len(self.last_img): - print(f"[R Post] Warn: Only {len(valid_images)}/{len(self.last_img)} valid. Filling gaps.") + print( + f"[R Post] Warn: Only {len(valid_images)}/{len(self.last_img)} valid. Filling gaps." + ) if valid_images: - self.last_img = [(img if img is not None else valid_images[0]) for img in self.last_img] + self.last_img = [ + (img if img is not None else valid_images[0]) for img in self.last_img + ] else: print("[R Post] No valid images left.") self._cleanup_after_run(use_cache) + self._clear_processing_guards(p) return - target_w, target_h = (p.width, p.height) if crop_center else self.check_orientation(self.last_img[0]) - print(f"[R Post] Preparing {len(self.last_img)} images ({'Crop' if crop_center else 'Resize'}) to {target_w}x{target_h} for Img2Img.") - prepared_images = [resize_image(img, target_w, target_h, cropping=crop_center) for img in self.last_img if img is not None] + target_w, target_h = ( + (p.width, p.height) if crop_center else self.check_orientation(self.last_img[0]) + ) + print( + f"[R Post] Preparing {len(self.last_img)} images ({'Crop' if crop_center else 'Resize'}) to {target_w}x{target_h} for Img2Img." + ) + prepared_images = [ + rb_image_ops.resize_image(img, target_w, target_h, cropping=crop_center) + for img in self.last_img + if img is not None + ] if not prepared_images: print("[R Post] No images left after resize.") self._cleanup_after_run(use_cache) + self._clear_processing_guards(p) return # Use the original RanbooruX-generated prompts, not the simplified initial prompts - if hasattr(self, 'original_full_prompt') and self.original_full_prompt: - print("[R Post] Using original RanbooruX prompts for img2img (not simplified initial prompts)") + if hasattr(self, "original_full_prompt") and self.original_full_prompt: + print( + "[R Post] Using original RanbooruX prompts for img2img (not simplified initial prompts)" + ) final_prompts = self.original_full_prompt else: final_prompts = processed.prompt final_negative_prompts = processed.negative_prompt num_imgs = len(prepared_images) - if not isinstance(final_prompts, list) or len(final_prompts) != num_imgs: - final_prompts = ([final_prompts] * num_imgs) if not isinstance(final_prompts, list) else (final_prompts * (num_imgs // len(final_prompts)) + final_prompts[:num_imgs % len(final_prompts)]) - if not isinstance(final_negative_prompts, list) or len(final_negative_prompts) != num_imgs: - final_negative_prompts = ([final_negative_prompts] * num_imgs) if not isinstance(final_negative_prompts, list) else (final_negative_prompts * (num_imgs // len(final_negative_prompts)) + final_negative_prompts[:num_imgs % len(final_negative_prompts)]) + final_prompts = rb_img2img_lifecycle.repeat_to_length(final_prompts, num_imgs) + final_negative_prompts = rb_img2img_lifecycle.repeat_to_length( + final_negative_prompts, + num_imgs, + ) img2img_width, img2img_height = prepared_images[0].size # Process images in batches that match WebUI expectations # Use batch_size=1 to ensure compatibility with all configurations - print(f"[R] Processing {len(prepared_images)} images individually to ensure compatibility") - + print( + f"[R] Processing {len(prepared_images)} images individually to ensure compatibility" + ) + # Process all prepared images (do not limit by original txt2img batch size) - - print(f"[R] Running Img2Img ({len(prepared_images)} images) steps={self.real_steps}, Denoise={self.img2img_denoising}") - + + print( + f"[R] Running Img2Img ({len(prepared_images)} images) steps={self.real_steps}, Denoise={self.img2img_denoising}" + ) + # Process images individually to avoid batch size issues all_img2img_results = [] all_infotexts = [] last_seed = processed.seed last_subseed = processed.subseed - + for i, img in enumerate(prepared_images): current_prompt = final_prompts[i] if i < len(final_prompts) else final_prompts[0] - current_negative = final_negative_prompts[i] if i < len(final_negative_prompts) else final_negative_prompts[0] - + current_negative = ( + final_negative_prompts[i] + if i < len(final_negative_prompts) + else final_negative_prompts[0] + ) + p_img2img = StableDiffusionProcessingImg2Img( - sd_model=shared.sd_model, outpath_samples=shared.opts.outdir_samples or shared.opts.outdir_img2img_samples, - outpath_grids=shared.opts.outdir_grids or shared.opts.outdir_img2img_grids, - prompt=current_prompt, negative_prompt=current_negative, - seed=processed.seed + i, subseed=processed.subseed + i, - sampler_name=p.sampler_name, scheduler=getattr(p, 'scheduler', None), - batch_size=1, n_iter=1, steps=self.real_steps, cfg_scale=p.cfg_scale, - width=img2img_width, height=img2img_height, init_images=[img], denoising_strength=self.img2img_denoising, + sd_model=shared.sd_model, + outpath_samples=shared.opts.outdir_samples + or shared.opts.outdir_img2img_samples, + outpath_grids=shared.opts.outdir_grids or shared.opts.outdir_img2img_grids, + prompt=current_prompt, + negative_prompt=current_negative, + seed=processed.seed + i, + subseed=processed.subseed + i, + sampler_name=p.sampler_name, + scheduler=getattr(p, "scheduler", None), + batch_size=1, + n_iter=1, + steps=self.real_steps, + cfg_scale=p.cfg_scale, + width=img2img_width, + height=img2img_height, + init_images=[img], + denoising_strength=self.img2img_denoising, ) # Mark as internal so our before_process performs a minimal seed init instead of blocking try: - setattr(p_img2img, '_ranbooru_internal_img2img', True) + setattr(p_img2img, "_ranbooru_internal_img2img", True) except Exception: pass - + # CRITICAL: Explicitly enable saving for img2img pass (was disabled for initial pass) p_img2img.do_not_save_samples = False # Always enable saving for final results - p_img2img.do_not_save_grid = False # Always enable grid saving for final results - + p_img2img.do_not_save_grid = False # Always enable grid saving for final results + # Ensure correct output path for img2img results - if hasattr(self, 'original_outpath') and self.original_outpath: - p_img2img.outpath_samples = self.original_outpath - print(f"[R Save] Saving img2img result {i+1} to: {self.original_outpath}") + final_outpath = getattr(self, "_img2img_final_outpath_samples", None) + if final_outpath: + p_img2img.outpath_samples = final_outpath + print(f"[R Save] Saving img2img result {i+1} to: {final_outpath}") else: # Fallback to default img2img output directory - p_img2img.outpath_samples = shared.opts.outdir_img2img_samples or shared.opts.outdir_samples - print(f"[R Save] Saving img2img result {i+1} to default: {p_img2img.outpath_samples}") - - # Restore original batch size - if hasattr(self, 'original_batch_size'): - p_img2img.batch_size = self.original_batch_size - + p_img2img.outpath_samples = ( + shared.opts.outdir_img2img_samples or shared.opts.outdir_samples + ) + print( + f"[R Save] Saving img2img result {i+1} to default: {p_img2img.outpath_samples}" + ) + + # Restore original batch size + final_batch_size = getattr(self, "_img2img_final_batch_size", None) + if final_batch_size: + p_img2img.batch_size = final_batch_size + print(f"[R] Processing image {i+1}/{len(prepared_images)} individually") single_result = process_images(p_img2img) all_img2img_results.extend(single_result.images) all_infotexts.extend(single_result.infotexts) last_seed = single_result.seed last_subseed = single_result.subseed - + # CRITICAL: Complete replacement of processed object to force all extensions to see new results - print("[R Post] Performing COMPLETE processed object replacement for extension compatibility") - - # Store original processed object reference - original_processed = processed - - # Update ALL possible references that might be cached by other extensions - processed.images.clear() - processed.images.extend(all_img2img_results) - - # Force immediate update of all fields - processed.prompt = final_prompts if len(final_prompts) > 1 else (final_prompts[0] if final_prompts else processed.prompt) - processed.negative_prompt = final_negative_prompts if len(final_negative_prompts) > 1 else (final_negative_prompts[0] if final_negative_prompts else processed.negative_prompt) - processed.infotexts.clear() - processed.infotexts.extend(all_infotexts) - processed.seed = last_seed - processed.subseed = last_subseed - processed.width = img2img_width - processed.height = img2img_height - - # Force update all array fields by clearing and extending (not replacing references) - if hasattr(processed, 'all_prompts'): - processed.all_prompts.clear() - processed.all_prompts.extend(final_prompts if isinstance(final_prompts, list) else [final_prompts] * len(all_img2img_results)) - else: - processed.all_prompts = final_prompts if isinstance(final_prompts, list) else [final_prompts] * len(all_img2img_results) - - if hasattr(processed, 'all_negative_prompts'): - processed.all_negative_prompts.clear() - processed.all_negative_prompts.extend(final_negative_prompts if isinstance(final_negative_prompts, list) else [final_negative_prompts] * len(all_img2img_results)) - else: - processed.all_negative_prompts = final_negative_prompts if isinstance(final_negative_prompts, list) else [final_negative_prompts] * len(all_img2img_results) - - if hasattr(processed, 'all_seeds'): - processed.all_seeds.clear() - processed.all_seeds.extend([last_seed + i for i in range(len(all_img2img_results))]) - else: - processed.all_seeds = [last_seed + i for i in range(len(all_img2img_results))] - - if hasattr(processed, 'all_subseeds'): - processed.all_subseeds.clear() - processed.all_subseeds.extend([last_subseed + i for i in range(len(all_img2img_results))]) - else: - processed.all_subseeds = [last_subseed + i for i in range(len(all_img2img_results))] - - # Clear any cached references and force immediate updates - for attr_name in ['cached_images', 'images_list', 'output_images', '_cached_images']: - if hasattr(processed, attr_name): - attr_val = getattr(processed, attr_name) - if isinstance(attr_val, list): - attr_val.clear() - attr_val.extend(all_img2img_results) - else: - setattr(processed, attr_name, all_img2img_results) - + print( + "[R Post] Performing COMPLETE processed object replacement for extension compatibility" + ) + + rb_img2img_lifecycle.replace_processed_results( + processed, + images=all_img2img_results, + prompts=final_prompts, + negative_prompts=final_negative_prompts, + infotexts=all_infotexts, + seed=last_seed, + subseed=last_subseed, + width=img2img_width, + height=img2img_height, + ) + # Force update the main processing result references - if hasattr(p, 'processed_result'): + if hasattr(p, "processed_result"): p.processed_result = processed - if hasattr(p, '_processed'): + if hasattr(p, "_processed"): p._processed = processed - - # CRITICAL: Force global state update to ensure other extensions see the changes - self._force_global_processed_update(p, processed, all_img2img_results) - + adetailer_ran_successfully = False if use_adetailer: - # FINAL AGGRESSIVE FIX: Directly patch ADetailer to force it to use our results - self._patch_adetailer_directly(processed, all_img2img_results) print("[R Post] Attempting manual ADetailer run on img2img results...") # Ensure processing object is aligned to our img2img result for ADetailer self._prepare_processing_for_manual_adetailer(p, processed, all_img2img_results) try: self._set_adetailer_block(False) - setattr(self.__class__, '_ranbooru_block_all_adetailer', False) - setattr(p, '_ranbooru_skip_initial_adetailer', False) + setattr(self.__class__, "_ranbooru_block_all_adetailer", False) + setattr(p, "_ranbooru_skip_initial_adetailer", False) print("[R Post] Unblocked ADetailer guard for manual run") except Exception: pass try: - final_dims = all_img2img_results[0].size if all_img2img_results and hasattr(all_img2img_results[0], 'size') else None + final_dims = ( + all_img2img_results[0].size + if all_img2img_results and hasattr(all_img2img_results[0], "size") + else None + ) self._install_preview_guard() self._set_preview_guard(True, final_dims, block_all=True) except Exception: pass - adetailer_ran_successfully = self._run_adetailer_on_img2img(p, processed, all_img2img_results) + adetailer_ran_successfully = self._execute_manual_adetailer( + p, processed, all_img2img_results + ) if adetailer_ran_successfully: print("[R Post] SUCCESS: ADetailer processed img2img results") all_img2img_results = processed.images.copy() try: - setattr(p, '_ranbooru_manual_adetailer_complete', True) + setattr(p, "_ranbooru_manual_adetailer_complete", True) except Exception: pass else: - print("[R Post] WARN: ADetailer manual run failed - img2img results will be unprocessed by ADetailer") + print( + "[R Post] WARN: ADetailer manual run failed - img2img results will be unprocessed by ADetailer" + ) else: adetailer_ran_successfully = False - print("[R Post] Manual ADetailer support disabled; skipping manual ADetailer execution") + print( + "[R Post] Manual ADetailer support disabled; skipping manual ADetailer execution" + ) # Mark processing as complete for other extensions and UI - setattr(self, '_ranbooru_processing_complete', True) - if hasattr(self, '_ranbooru_intermediate_results'): - delattr(self, '_ranbooru_intermediate_results') - + setattr(self, "_ranbooru_processing_complete", True) + if hasattr(self, "_ranbooru_intermediate_results"): + delattr(self, "_ranbooru_intermediate_results") + print("[R Post] Img2Img finished.") - print(f"[R Post] Updated processed object with {len(all_img2img_results)} img2img results") + print( + f"[R Post] Updated processed object with {len(all_img2img_results)} img2img results" + ) # DEBUG: Add comprehensive logging to trace what ADetailer will see # CRITICAL: Force UI to display our final results self._force_ui_update(p, processed, all_img2img_results) - - print("[R Post] RanbooruX processing complete - final results ready for UI and other extensions") - print(f"[R Post DEBUG] Final processed.images count: {len(processed.images) if hasattr(processed, 'images') else 'NO IMAGES ATTR'}") - if hasattr(processed, 'images') and processed.images: + + print( + "[R Post] RanbooruX processing complete - final results ready for UI and other extensions" + ) + print( + f"[R Post DEBUG] Final processed.images count: {len(processed.images) if hasattr(processed, 'images') else 'NO IMAGES ATTR'}" + ) + if hasattr(processed, "images") and processed.images: for i, img in enumerate(processed.images[:3]): # Show first 3 images if img: - print(f"[R Post DEBUG] Image {i}: {type(img)} size={getattr(img, 'size', 'unknown')}") + print( + f"[R Post DEBUG] Image {i}: {type(img)} size={getattr(img, 'size', 'unknown')}" + ) else: print(f"[R Post DEBUG] Image {i}: None") else: print("[R Post DEBUG] WARNING: No images in processed.images!") - + # DEBUG: Check all image attributes - debug_attrs = ['images', 'images_list', 'output_images', '_cached_images', 'cached_images'] + debug_attrs = [ + "images", + "images_list", + "output_images", + "_cached_images", + "cached_images", + ] for attr in debug_attrs: if hasattr(processed, attr): val = getattr(processed, attr) @@ -5492,343 +5142,72 @@ def postprocess(self, p: StableDiffusionProcessing, processed, *args): print(f"[R Post DEBUG] {attr}: {type(val)}") else: print(f"[R Post DEBUG] {attr}: not present") - + except Exception as e: print(f"[R Post] Critical error during img2img processing: {e}") import traceback + traceback.print_exc() try: # Attempt to preserve original images if img2img fails - if hasattr(self, 'last_img') and self.last_img: + if hasattr(self, "last_img") and self.last_img: print("[R Post] Attempting to fallback to original txt2img results") else: print("[R Post] No fallback images available") - except: + except Exception as fallback_error: + _ranbooru_logger.warning( + "Fallback handling failed in postprocess: %s", fallback_error + ) print("[R Post] Fallback failed") - + finally: - if getattr(self, '_log_prompt_sources', False): + if getattr(self, "_log_prompt_sources", False): self._log_generation_reference(p) # Always cleanup regardless of success or failure self._cleanup_after_run(use_cache) - - # Clear all processing guards only when truly complete - if hasattr(self, '_current_processing_key'): - processing_key = self._current_processing_key - if hasattr(self, processing_key): - delattr(self, processing_key) - print(f"[R Post] Cleared processing guard for request {processing_key}") - delattr(self, '_current_processing_key') - - # Clear global processing guard with delay to ensure no race conditions - import time - time.sleep(0.1) # Small delay to ensure all processing is complete - setattr(self.__class__, '_ranbooru_global_processing', False) - print(f"[R Post] Cleared global processing guard") - - # Also clear the processing object guard - # Note: p might not be available in postprocess, so we'll clear it in a different way - # The guard will be cleared when the processing object is destroyed - try: - setattr(p, '_ranbooru_finalized', True) - except Exception: - pass + self._clear_processing_guards(p) - def _force_global_processed_update(self, p, processed, img2img_results): - """Force global state updates to ensure ALL extensions see the img2img results""" - try: - print(f"[R Post] Forcing global processed update with {len(img2img_results)} img2img results") - - # Update WebUI's global state references if accessible - try: - import modules.shared as shared_modules - - # Force update any global processing state - if hasattr(shared_modules, 'state'): - # Mark that processing is complete with final results - if hasattr(shared_modules.state, 'textinfo'): - shared_modules.state.textinfo = "RanbooruX img2img complete" - - # Update global opts if they cache processing results - if hasattr(shared_modules, 'opts') and hasattr(shared_modules.opts, 'current_processed'): - shared_modules.opts.current_processed = processed - - except Exception as e: - print(f"[R Post] Could not update global state: {e}") - - # Force update ALL possible image references that extensions might cache - image_attrs = [ - 'images', 'image', 'images_list', 'output_images', '_cached_images', - 'cached_images', 'result_images', 'final_images', '_images' - ] - - for attr in image_attrs: - if hasattr(processed, attr): - current_val = getattr(processed, attr) - if isinstance(current_val, list): - current_val.clear() - current_val.extend(img2img_results) - print(f"[R Post] Updated list attribute: {attr}") - else: - setattr(processed, attr, img2img_results[0] if img2img_results else None) - print(f"[R Post] Updated single attribute: {attr}") - - # Force refresh processed state with timestamp - import time - processed._images_updated = True - processed._ranbooru_update_time = time.time() - processed._ranbooru_image_count = len(img2img_results) - - # Try to update the processing pipeline's cached references - if hasattr(p, '__dict__'): - for key, value in p.__dict__.items(): - if 'processed' in key.lower() and hasattr(value, 'images'): - print(f"[R Post] Found cached processed reference: {key}") - if isinstance(value.images, list): - value.images.clear() - value.images.extend(img2img_results) - - # Force immediate memory sync - import gc - gc.collect() - - print(f"[R Post] Global processed update complete - {len(img2img_results)} results should now be visible to all extensions") - - # NUCLEAR OPTION: Try to override WebUI's main processing result completely - try: - self._nuclear_processed_override(p, processed, img2img_results) - except Exception as e: - print(f"[R Post] Nuclear override failed: {e}") - - except Exception as e: - print(f"[R Post] Warning: Could not complete global processed update: {e}") - - def _nuclear_processed_override(self, p, processed, img2img_results): - """Last resort: completely override all possible processing references""" - try: - print("[R Post] NUCLEAR OPTION: Overriding ALL processing references") - - # Store the img2img results in a global location that we control - setattr(self.__class__, '_global_ranbooru_results', img2img_results) - setattr(self.__class__, '_global_ranbooru_processed', processed) - - # Try to patch the processing result at the module level - try: - import modules.processing - if hasattr(modules.processing, '_current_processed'): - modules.processing._current_processed = processed - print("[R Post] Patched modules.processing._current_processed") - except: - pass - - # Try to override Gradio/WebUI state - try: - import modules.shared as shared - if hasattr(shared, 'state'): - # Store our results in shared state for other extensions to find - shared.state.ranbooru_images = img2img_results - shared.state.ranbooru_processed = processed - print("[R Post] Stored results in shared.state") - except: - pass - - # Force all script results to point to our img2img results (converted to PIL) - if hasattr(p, 'scripts') and hasattr(p.scripts, 'scripts'): - # Convert img2img_results to PIL Images first - converted_script_images = [] - for img in img2img_results: - if hasattr(img, 'mode') and img.mode != 'RGB': - img = img.convert('RGB') - elif hasattr(img, 'shape'): # Handle numpy arrays - import numpy as np - from PIL import Image - if len(img.shape) == 3 and img.shape[2] == 3: - img = Image.fromarray(img.astype(np.uint8), 'RGB') - else: - img = Image.fromarray(img.astype(np.uint8)) - converted_script_images.append(img) - - for script in p.scripts.scripts: - if hasattr(script, 'postprocessed_images'): - script.postprocessed_images = converted_script_images - print(f"[R Post] Override {script.__class__.__name__}.postprocessed_images with {len(converted_script_images)} PIL images") - - print("[R Post] Nuclear override complete - all processing references should now point to img2img results") - - except Exception as e: - print(f"[R Post] Nuclear override error: {e}") - - def _patch_adetailer_directly(self, processed, img2img_results): - """FINAL AGGRESSIVE FIX: Directly patch ADetailer to force correct image access""" - try: - print("[R Post] FINAL FIX: Patching ADetailer directly") - - # Method 1: Monkey patch common image access patterns - if hasattr(processed, '_ranbooru_original_getattribute'): - original_getattr = getattr(processed, '_ranbooru_original_getattribute') - else: - original_getattr = processed.__getattribute__ - try: - setattr(processed, '_ranbooru_original_getattribute', original_getattr) - if not hasattr(self, '_patched_processed_objects'): - self._patched_processed_objects = [] - self._patched_processed_objects.append(processed) - except Exception: - pass - - def patched_getattr(name): - if name in ['images', 'image', 'imgs']: - print(f"[R Post] Intercepted ADetailer access to '{name}' - returning img2img results") - return img2img_results if name == 'images' else (img2img_results[0] if img2img_results else None) - return original_getattr(name) - - # Apply the monkey patch - processed.__getattribute__ = patched_getattr - - # Method 2: Try to find and patch ADetailer extension directly - try: - import sys - adetailer_modules = [name for name in sys.modules if 'adetailer' in name.lower()] - for module_name in adetailer_modules: - module = sys.modules[module_name] - # Patch any image access methods we can find - if self._verify_patch_target(module, 'get_images'): - if not hasattr(module, '_ranbooru_original_get_images'): - try: - setattr(module, '_ranbooru_original_get_images', module.get_images) - if not hasattr(self, '_patched_adetailer_modules'): - self._patched_adetailer_modules = [] - self._patched_adetailer_modules.append((module, 'get_images')) - self._log_patch_event("info", f"Stored original patch target for {module_name}.get_images") - except Exception: - pass - - def patched_get_images(*args, **kwargs): - print("[R Post] Intercepted ADetailer.get_images() - returning img2img results") - return img2img_results - - module.get_images = patched_get_images - self._log_patch_event("info", f"Patched {module_name}.get_images") - print(f"[R Post] Patched {module_name}.get_images()") - - print(f"[R Post] Found and attempted to patch {len(adetailer_modules)} ADetailer modules") - - # Method 2b: Install global guard wrappers on AfterDetailerScript methods - self._install_adetailer_global_guard() - - except Exception as e: - print(f"[R Post] ADetailer module patching failed: {e}") - - # Method 3: Force update all possible cached references in the processing pipeline - if hasattr(processed, '__dict__'): - for attr_name in processed.__dict__: - if 'image' in attr_name.lower(): - attr_value = getattr(processed, attr_name) - if isinstance(attr_value, list): - # Replace list contents - attr_value.clear() - attr_value.extend(img2img_results) - print(f"[R Post] Force-updated list attribute: {attr_name}") - elif attr_value is not None: - # CRITICAL FIX: Handle special attributes that must remain as lists - if attr_name in ['extra_images', 'images_list', 'output_images', '_cached_images']: - setattr(processed, attr_name, img2img_results.copy()) # Set as list - print(f"[R Post] Force-updated list attribute: {attr_name} (converted to list)") - elif attr_name in ['index_of_first_image', '_ranbooru_image_count']: - # These are numeric indices, don't change them - print(f"[R Post] Skipped numeric attribute: {attr_name}") - else: - setattr(processed, attr_name, img2img_results[0] if img2img_results else None) - print(f"[R Post] Force-updated single attribute: {attr_name}") - - # Method 4: Create a global intercept for image access - self.__class__._force_adetailer_images = img2img_results - - print("[R Post] ADetailer direct patching complete") - - except Exception as e: - print(f"[R Post] ADetailer direct patching error: {e}") - - def _install_adetailer_global_guard(self): - """Install wrappers on AfterDetailerScript to early-exit when our block flag is set""" - try: - import sys - if not hasattr(self, '_adetailer_classes'): - self._adetailer_classes = [] - installed = 0 - for module_name in list(sys.modules.keys()): - if 'adetailer' not in module_name.lower(): - continue - module = sys.modules[module_name] - Cls = getattr(module, 'AfterDetailerScript', None) - if Cls is None or Cls in self._adetailer_classes: - continue - # Wrap methods once - if not getattr(Cls, '_ranbooru_guard_installed', False): - def wrap_method(method_name): - orig = getattr(Cls, method_name, None) - if not callable(orig): - return - def wrapped(inst, *args, **kwargs): - try: - if getattr(inst.__class__, '_ranbooru_should_block', False): - print(f"[R Guard] Blocked ADetailer.{method_name}") - # Return strict boolean to avoid TypeError with |= aggregation - return False - except Exception: - pass - return orig(inst, *args, **kwargs) - setattr(Cls, method_name, wrapped) - for m in [name for name in dir(Cls) if 'process' in name.lower()]: - wrap_method(m) - setattr(Cls, '_ranbooru_guard_installed', True) - setattr(Cls, '_ranbooru_should_block', False) - self._adetailer_classes.append(Cls) - installed += 1 - if installed: - print(f"[R Post] Installed global ADetailer guard on {installed} class(es)") - except Exception as e: - print(f"[R Post] Error installing ADetailer global guard: {e}") - def _is_adetailer_enabled(self): - return getattr(self, '_adetailer_support_enabled', False) - + return self._adetailer_orch.is_adetailer_enabled() def _set_adetailer_block(self, should_block: bool): """Toggle the global guard on patched ADetailer classes""" + self._adetailer_state.block_all = bool(should_block) + setattr(self.__class__, "_ranbooru_block_all_adetailer", bool(should_block)) try: - if hasattr(self, '_adetailer_classes'): + if hasattr(self, "_adetailer_classes"): for Cls in self._adetailer_classes: try: - setattr(Cls, '_ranbooru_should_block', bool(should_block)) + setattr(Cls, "_ranbooru_should_block", bool(should_block)) except Exception: pass print(f"[R Post] ADetailer global guard set to {should_block}") except Exception as e: print(f"[R Post] Error toggling ADetailer global guard: {e}") - + def _reset_script_runner_guards(self): """Reset ScriptRunner guards to ensure ADetailer is available for each generation""" try: print("[R Before] Resetting ScriptRunner guards for new generation") - + # Reset the guard installation flag so guards can be reinstalled if needed import modules.scripts + for runner in [modules.scripts.scripts_txt2img, modules.scripts.scripts_img2img]: - if hasattr(runner, '_ranbooru_guard_installed'): - delattr(runner, '_ranbooru_guard_installed') - + if hasattr(runner, "_ranbooru_guard_installed"): + delattr(runner, "_ranbooru_guard_installed") + # Clear any cached ADetailer classes - if hasattr(self, '_adetailer_classes'): - delattr(self, '_adetailer_classes') - + if hasattr(self, "_adetailer_classes"): + delattr(self, "_adetailer_classes") + print("[R Before] ScriptRunner guards reset complete") except Exception as e: print(f"[R Before] Error resetting ScriptRunner guards: {e}") - def _extract_adetailer_script_args(self, script, processing_obj): """Return sanitized ADetailer arguments derived from the processing object.""" + def _normalize(args): if args is None: return [] @@ -5837,33 +5216,39 @@ def _normalize(args): if isinstance(args, list): return list(args) return [args] - + def _contains_ad_dict(seq): for item in seq: - if isinstance(item, dict) and any(str(key).startswith('ad_') for key in item.keys()): + if isinstance(item, dict) and any( + str(key).startswith("ad_") for key in item.keys() + ): return True return False - + def _extract_ad_dicts(seq): - return [item for item in seq if isinstance(item, dict) and any(str(key).startswith('ad_') for key in item.keys())] - - all_args = _normalize(getattr(processing_obj, 'script_args', None)) - snapshot = getattr(self, '_adetailer_script_args_snapshot', None) - + return [ + item + for item in seq + if isinstance(item, dict) and any(str(key).startswith("ad_") for key in item.keys()) + ] + + all_args = _normalize(getattr(processing_obj, "script_args", None)) + snapshot = getattr(self, "_adetailer_script_args_snapshot", None) + used_snapshot = False if not _contains_ad_dict(all_args) and isinstance(snapshot, (list, tuple)): snapshot_norm = _normalize(snapshot) if _contains_ad_dict(snapshot_norm): all_args = snapshot_norm used_snapshot = True - - start_idx = getattr(script, 'args_from', None) - end_idx = getattr(script, 'args_to', None) + + start_idx = getattr(script, "args_from", None) + end_idx = getattr(script, "args_to", None) if isinstance(start_idx, int) and start_idx < 0: start_idx = 0 if isinstance(end_idx, int) and end_idx < 0: end_idx = 0 - + if isinstance(start_idx, int) and start_idx < len(all_args): slice_start = max(start_idx, 0) slice_end = max(end_idx, slice_start) if isinstance(end_idx, int) else len(all_args) @@ -5871,1214 +5256,160 @@ def _extract_ad_dicts(seq): else: slice_start = 0 slice_end = len(all_args) - + subset = all_args[slice_start:slice_end] - + bool_candidates = [item for item in subset if isinstance(item, bool)] enable_flag = bool_candidates[0] if bool_candidates else None skip_flag = bool_candidates[1] if len(bool_candidates) > 1 else None - + dicts = _extract_ad_dicts(subset) fallback_reason = None - + if not dicts and not used_snapshot and isinstance(snapshot, (list, tuple)): snapshot_norm = _normalize(snapshot) snap_subset = snapshot_norm[slice_start:slice_end] dicts = _extract_ad_dicts(snap_subset) or _extract_ad_dicts(snapshot_norm) if dicts: - fallback_reason = 'snapshot' + fallback_reason = "snapshot" used_snapshot = True - + if not dicts and _contains_ad_dict(all_args): dicts = _extract_ad_dicts(all_args) if dicts and fallback_reason is None: - fallback_reason = 'all_args' - + fallback_reason = "all_args" + meta = { - 'slice_start': slice_start, - 'slice_end': slice_end, - 'total_args': len(all_args), - 'dict_count': len(dicts), - 'fallback_reason': fallback_reason, - 'used_snapshot': used_snapshot, + "slice_start": slice_start, + "slice_end": slice_end, + "total_args": len(all_args), + "dict_count": len(dicts), + "fallback_reason": fallback_reason, + "used_snapshot": used_snapshot, } - - if not dicts: - return {'args': [], 'meta': meta} - - # Manual img2img ADetailer execution must run regardless of persisted UI flags - # to avoid silently skipping detailing when the extracted enable flag is False. - enable_flag = True - # Manual runs should never honour the "skip img2img" style bool. - skip_flag = False - - # Ensure each tab only runs when it has a valid model - for idx_dict, ad_dict in enumerate(dicts): - model_name = str(ad_dict.get('ad_model', '') or '').strip().lower() - has_model = model_name not in ('', 'none') - if idx_dict == 0 and has_model: - ad_dict['ad_tab_enable'] = True - else: - ad_dict['ad_tab_enable'] = bool(ad_dict.get('ad_tab_enable', False) and has_model) - - sanitized = [enable_flag, skip_flag] + dicts - return {'args': sanitized, 'meta': meta} - - def _manual_adetailer_requires_controlnet(self, script_args): - """Return True when extracted ADetailer args request ControlNet integration.""" - try: - for arg in script_args or []: - if not isinstance(arg, dict): - continue - model_name = str(arg.get('ad_controlnet_model', '') or '').strip().lower() - if model_name and model_name not in ('none', 'passthrough'): - return True - except Exception: - pass - return False - - def _images_visibly_different(self, original_image, processed_image): - """Return True only when pixel content or dimensions actually changed.""" - try: - if original_image is None or processed_image is None: - return False - - original_size = getattr(original_image, 'size', None) - processed_size = getattr(processed_image, 'size', None) - if original_size and processed_size and original_size != processed_size: - return True - - original_compare = original_image - processed_compare = processed_image - - if hasattr(original_compare, 'mode') and original_compare.mode != 'RGB': - original_compare = original_compare.convert('RGB') - if hasattr(processed_compare, 'mode') and processed_compare.mode != 'RGB': - processed_compare = processed_compare.convert('RGB') - if hasattr(original_compare, 'tobytes') and hasattr(processed_compare, 'tobytes'): - return original_compare.tobytes() != processed_compare.tobytes() - except Exception as compare_exc: - print(f"[R Post] WARN: Could not compare image pixels: {compare_exc}") + if not dicts: + return {"args": [], "meta": meta} - return False - - def _run_adetailer_on_img2img(self, p, processed, img2img_results): - """Manually run ADetailer on our img2img results - EACH IMAGE in batch""" - if not self._is_adetailer_enabled(): - print("[R Manual ADetailer] Support disabled - skipping manual run request") - return False - try: - # Remove generation-based limiting - ADetailer should process ALL images in batch - print(f"[R Post] Starting manual ADetailer execution on {len(img2img_results)} img2img results") - - if not img2img_results: - print("[R Post] No img2img results to process with ADetailer") - return False - - # Debug: Check the images we're about to process - for i, img in enumerate(img2img_results): - if img: - print(f"[R Post] DEBUG - Image {i}: {type(img)} size={getattr(img, 'size', 'unknown')}") - else: - print(f"[R Post] DEBUG - Image {i}: None") - - # Try to find and run ADetailer scripts manually - if not hasattr(p, 'scripts'): - print("[R Post] No scripts container on processing object") - return False - - # Collect both always-on and regular scripts - candidate_scripts = [] - try: - if hasattr(p.scripts, 'alwayson_scripts') and p.scripts.alwayson_scripts: - candidate_scripts.extend(p.scripts.alwayson_scripts) - if hasattr(p.scripts, 'scripts') and p.scripts.scripts: - candidate_scripts.extend(p.scripts.scripts) - except Exception: - pass - - # Fallback: also check global ScriptRunner registries - try: - import modules.scripts as _ms - for runner_name in ('scripts_img2img', 'scripts_txt2img'): - runner = getattr(_ms, runner_name, None) - if runner is None: - continue - if hasattr(runner, 'alwayson_scripts') and runner.alwayson_scripts: - candidate_scripts.extend([s for s in runner.alwayson_scripts]) - if hasattr(runner, 'scripts') and runner.scripts: - candidate_scripts.extend([s for s in runner.scripts]) - except Exception as _e: - print(f"[R Post] WARN: Could not read global ScriptRunner registries: {_e}") - - # De-duplicate while preserving order - try: - seen_ids = set() - deduped = [] - for s in candidate_scripts: - sid = id(s) - if sid in seen_ids: - continue - seen_ids.add(sid) - deduped.append(s) - candidate_scripts = deduped - except Exception: - pass - - print(f"[R Post] DEBUG - Candidate scripts total: {len(candidate_scripts)}") - - if not candidate_scripts: - print("[R Post] No candidate scripts available on processing object or global runners") - return False - - adetailer_scripts_found = 0 - for script in candidate_scripts: - try: - script_name_lower = getattr(script.__class__, '__name__', '').lower() - except Exception: - script_name_lower = '' - if 'adetailer' in script_name_lower or 'afterdetailer' in script_name_lower: - adetailer_scripts_found += 1 - print(f"[R Post] Found ADetailer script #{adetailer_scripts_found}: {script.__class__.__name__}") - - # Check if script is enabled - if hasattr(script, 'enabled') and not script.enabled: - print(f"[R Post] Script {script.__class__.__name__} is disabled - skipping") - continue - - # Process EACH IMAGE INDIVIDUALLY through ADetailer (not as batch) - final_processed_images = [] - successful_processes = 0 - - # Convert all images to PIL format first - converted_images = [] - for img in img2img_results: - if hasattr(img, 'mode') and img.mode != 'RGB': - img = img.convert('RGB') - elif hasattr(img, 'shape'): # Handle numpy arrays - import numpy as np - from PIL import Image - if len(img.shape) == 3 and img.shape[2] == 3: - img = Image.fromarray(img.astype(np.uint8), 'RGB') - else: - img = Image.fromarray(img.astype(np.uint8)) - converted_images.append(img) - - print(f"[R Post] Processing {len(converted_images)} images individually through ADetailer") - - # Process each image individually with error handling - for img_idx, single_img in enumerate(converted_images): - try: - print(f"[R Post] Processing image {img_idx + 1}/{len(converted_images)} individually") - - # Create temp_processed for this single image - single_temp_processed = None - construction_methods = [ - lambda: type(processed)(p, [single_img]), # Method 1: Standard constructor with single PIL image - lambda: self._construct_processed_fallback(processed, [single_img], p) # Method 2: Fallback - ] - - for method_num, construct_method in enumerate(construction_methods, 1): - try: - print(f"[R Post] Image {img_idx + 1}: Trying construction method {method_num}") - single_temp_processed = construct_method() - - # Copy essential attributes - essential_attrs = ['prompt', 'negative_prompt', 'seed', 'subseed', 'width', 'height', 'cfg_scale', 'steps'] - for attr in essential_attrs: - if hasattr(processed, attr): - setattr(single_temp_processed, attr, getattr(processed, attr)) - - # CRITICAL: ADetailer expects 'image' attribute (singular) for postprocess_image - # Ensure the image is definitely PIL format before assignment - if not hasattr(single_img, 'mode'): - import numpy as np - from PIL import Image - if hasattr(single_img, 'shape') and len(single_img.shape) == 3: - single_img = Image.fromarray(single_img.astype(np.uint8), 'RGB') - else: - single_img = Image.fromarray(single_img.astype(np.uint8)) - - single_temp_processed.image = single_img - print(f"[R Post] Image {img_idx + 1}: Set temp_processed.image = {single_img.size} mode={single_img.mode} type={type(single_img)}") - print(f"[R Post] Image {img_idx + 1}: Successfully created temp_processed with method {method_num}") - break - - except Exception as construct_e: - print(f"[R Post] Image {img_idx + 1}: Construction method {method_num} failed: {construct_e}") - continue - - if single_temp_processed is None: - print(f"[R Post] Image {img_idx + 1}: Could not construct temp_processed - using original image") - final_processed_images.append(single_img) - continue - - # Now run ADetailer on this single image - print(f"[R Post] Running {script.__class__.__name__} on image {img_idx + 1}") - - # Setup ADetailer processing parameters for this single image - p.init_images = [single_img] - p.width = single_img.width - p.height = single_img.height - - # Clear blocking flags for this run - setattr(p, '_ad_disabled', False) - setattr(p, '_ranbooru_skip_initial_adetailer', False) - setattr(p, '_ranbooru_suppress_all_processing', False) - setattr(p, '_ranbooru_adetailer_already_processed', False) - setattr(p, '_adetailer_can_save', True) - - # CRITICAL: Enable saving for ADetailer processed results - p.do_not_save_samples = False - p.do_not_save_grid = False - - # Ensure processed object has save configuration - if hasattr(single_temp_processed, 'do_not_save_samples'): - single_temp_processed.do_not_save_samples = False - single_temp_processed.do_not_save_grid = False - - # Set proper save path for ADetailer results - import os - save_path = getattr(p, 'outpath_samples', 'outputs/txt2img-images') - setattr(single_temp_processed, 'outpath_samples', save_path) - setattr(single_temp_processed, 'save_samples', True) - print(f"[R Post] Configured ADetailer save path: {save_path}") - - # Enable ADetailer globally for this run - setattr(self.__class__, '_ranbooru_block_all_adetailer', False) - setattr(self.__class__, '_adetailer_global_guard_active', False) - setattr(self.__class__, '_ranbooru_manual_adetailer_active', True) - - # CRITICAL: Comprehensive PIL enforcement for this image - self._enforce_pil_everywhere(p, single_temp_processed, [single_img]) - - # NUCLEAR: Hook into WebUI's image conversion functions to intercept numpy arrays - self._patch_image_conversion_functions() - - # HOOK: Intercept ADetailer's image modifications - original_images_backup = [] - if hasattr(single_temp_processed, 'images') and single_temp_processed.images: - original_images_backup = [img.copy() if hasattr(img, 'copy') else img for img in single_temp_processed.images] - - # CRITICAL: Convert ALL images in temp_processed to PIL format BEFORE calling ADetailer - try: - import numpy as np - from PIL import Image - - # Ensure temp_processed.images contains only PIL images - if hasattr(single_temp_processed, 'images') and single_temp_processed.images: - converted_images = [] - for img_idx_inner, img in enumerate(single_temp_processed.images): - if hasattr(img, 'shape'): # It's a numpy array - pil_img = Image.fromarray(img.astype(np.uint8), 'RGB') - converted_images.append(pil_img) - print(f"[R Post] CONVERTED numpy to PIL: {pil_img.size}") - else: - converted_images.append(img) # Already PIL - single_temp_processed.images = converted_images - - # Ensure temp_processed.image (singular) is also PIL - if hasattr(single_temp_processed, 'image') and hasattr(single_temp_processed.image, 'shape'): - pil_img = Image.fromarray(single_temp_processed.image.astype(np.uint8), 'RGB') - single_temp_processed.image = pil_img - print(f"[R Post] CONVERTED temp_processed.image to PIL: {pil_img.size}") - - print(f"[R Post] All images converted to PIL before ADetailer call") - except Exception as conversion_error: - print(f"[R Post] PIL conversion failed: {conversion_error}") - - # Get script arguments - extracted_args = self._extract_adetailer_script_args(script, p) - script_args = extracted_args['args'] - meta = extracted_args['meta'] - if not script_args: - reason = meta.get('fallback_reason') - reason_text = f' (fallback={reason})' if reason else '' - print(f"[R Post] Image {img_idx + 1}: No ADetailer config found in script args; skipping manual run{reason_text}") - continue - args_preview = [] - for arg_index, arg_value in enumerate(script_args[:6]): - if isinstance(arg_value, dict): - keys = {k: arg_value.get(k) for k in ('ad_model', 'ad_tab_enable', 'ad_prompt', 'ad_negative_prompt')} - args_preview.append((arg_index, 'dict', keys)) - else: - args_preview.append((arg_index, type(arg_value).__name__, arg_value)) - print(f"[R Post] DEBUG - Image {img_idx + 1}: Using {len(script_args)} script args (slice={meta.get('slice_start')}->{meta.get('slice_end')}); preview={args_preview}") - requires_controlnet = self._manual_adetailer_requires_controlnet(script_args) - if requires_controlnet: - print(f"[R Post] Image {img_idx + 1}: Keeping ControlNet script available for ADetailer ControlNet integration") - - - # Store the original image before ADetailer processing - original_image = single_temp_processed.images[0] if single_temp_processed.images else single_img - original_image_size = getattr(original_image, 'size', 'unknown') - print(f"[R Post] Image {img_idx + 1}: Original before ADetailer: {original_image_size}") - - # DETAILED DEBUG: Show temp_processed state before ADetailer - print(f"[R Post] PRE-ADETAILER STATE:") - print(f"[R Post] temp_processed.images count: {len(getattr(single_temp_processed, 'images', []))}") - if hasattr(single_temp_processed, 'images') and single_temp_processed.images: - for idx, img in enumerate(single_temp_processed.images): - print(f"[R Post] Image {idx}: {type(img)} {getattr(img, 'size', 'no-size')}") - - # Try postprocess_image first (ADetailer's main method) - adetailer_success = False - if hasattr(script, 'postprocess_image'): - try: - print(f"[R Post] Image {img_idx + 1}: Calling postprocess_image") - with self._manual_adetailer_script_isolation(p, script, keep_controlnet=requires_controlnet): - result = script.postprocess_image(p, single_temp_processed, *script_args) - print(f"[R Post] Image {img_idx + 1}: postprocess_image returned: {result}") - - processed_attr = getattr(single_temp_processed, 'image', None) - if processed_attr is not None: - if hasattr(single_temp_processed, 'images') and single_temp_processed.images: - single_temp_processed.images[0] = processed_attr - else: - single_temp_processed.images = [processed_attr] - print(f"[R Post] DEBUG - Image {img_idx + 1}: Synced processed image from temp_processed.image ({getattr(processed_attr, 'size', 'unknown')})") - - - # COMPREHENSIVE DEBUG: Check ALL possible result locations - print(f"[R Post] POST-ADETAILER STATE:") - print(f"[R Post] temp_processed.images count: {len(getattr(single_temp_processed, 'images', []))}") - - # Check temp_processed.images - if hasattr(single_temp_processed, 'images') and single_temp_processed.images: - for idx, img in enumerate(single_temp_processed.images): - print(f"[R Post] Image {idx}: {type(img)} {getattr(img, 'size', 'no-size')}") - - # Check other possible result attributes - for attr in ['extra_images', 'all_images', 'output_images', 'processed_images']: - if hasattr(single_temp_processed, attr): - attr_value = getattr(single_temp_processed, attr) - if attr_value: - print(f"[R Post] {attr}: {len(attr_value) if isinstance(attr_value, (list, tuple)) else type(attr_value)}") - - # Check p object for results - if hasattr(p, 'processed') and hasattr(p.processed, 'images'): - print(f"[R Post] p.processed.images count: {len(p.processed.images)}") - - # Now check if temp_processed.images was modified by ADetailer - if hasattr(single_temp_processed, 'images') and single_temp_processed.images: - # ADetailer may add processed images - check for the best quality result - if len(single_temp_processed.images) > 1: - # Multiple images - use the last (most processed) one - processed_image = single_temp_processed.images[-1] - print(f"[R Post] Found {len(single_temp_processed.images)} images - using last processed image") - else: - processed_image = single_temp_processed.images[0] - - processed_size = getattr(processed_image, 'size', 'unknown') - print(f"[R Post] Image {img_idx + 1}: After ADetailer: {processed_size}") - - # Check for upscaling (ADetailer often upscales faces) - original_size = getattr(original_image, 'size', (0, 0)) - if processed_size != original_size and processed_size != 'unknown': - print(f"[R Post] UPSCALING DETECTED: {original_size} -> {processed_size}") - - # Only treat manual ADetailer as successful when pixels or dimensions changed. - print(f"[R Post] COMPARISON - Original: {type(original_image)} {original_image_size}, Processed: {type(processed_image)} {processed_size}") - image_changed = self._images_visibly_different(original_image, processed_image) - if image_changed: - print(f"[R Post] Image {img_idx + 1}: Pixel or size change detected after ADetailer") - else: - print(f"[R Post] Image {img_idx + 1}: No visible pixel changes detected after ADetailer") - - if image_changed: - adetailer_success = True - print(f"[R Post] Image {img_idx + 1}: ADetailer processing detected as successful") - if isinstance(getattr(p, 'extra_generation_params', None), dict): - single_temp_processed.extra_generation_params = dict(p.extra_generation_params) - else: - print(f"[R Post] Image {img_idx + 1}: No changes detected; keeping original image") - else: - print(f"[R Post] Image {img_idx + 1}: No images in temp_processed after ADetailer") - except Exception as e: - print(f"[R Post] Image {img_idx + 1}: postprocess_image failed: {e}") - - # Try postprocess as fallback - if not adetailer_success and hasattr(script, 'postprocess'): - try: - print(f"[R Post] Image {img_idx + 1}: FALLBACK calling postprocess") - with self._manual_adetailer_script_isolation(p, script, keep_controlnet=requires_controlnet): - script.postprocess(p, single_temp_processed, *script_args) - - # Check results after postprocess - if hasattr(single_temp_processed, 'images') and single_temp_processed.images: - processed_image = single_temp_processed.images[0] - processed_size = getattr(processed_image, 'size', 'unknown') - print(f"[R Post] Image {img_idx + 1}: After postprocess: {processed_size}") - if self._images_visibly_different(original_image, processed_image): - adetailer_success = True - print(f"[R Post] Image {img_idx + 1}: postprocess produced visible changes") - else: - print(f"[R Post] Image {img_idx + 1}: postprocess returned without visible changes") - else: - print(f"[R Post] Image {img_idx + 1}: No images after postprocess") - except Exception as e: - print(f"[R Post] Image {img_idx + 1}: postprocess failed: {e}") - - # Collect the processed result safely. - processed_img = None - if adetailer_success and hasattr(single_temp_processed, 'images') and single_temp_processed.images: - # ENHANCED: Look for the best result from multiple sources - if hasattr(single_temp_processed, 'extra_images') and single_temp_processed.extra_images: - processed_img = single_temp_processed.extra_images[-1] - print(f"[R Post] Using enhanced image from extra_images: {getattr(processed_img, 'size', 'unknown')}") - elif len(single_temp_processed.images) > 1: - processed_img = single_temp_processed.images[-1] # Last = most processed - print(f"[R Post] Using last processed image from {len(single_temp_processed.images)} available") - elif original_images_backup: - current_img = single_temp_processed.images[0] - if len(original_images_backup) > 0: - original_backup = original_images_backup[0] - if (hasattr(current_img, 'size') and hasattr(original_backup, 'size') and - current_img.size != original_backup.size): - processed_img = current_img - print(f"[R Post] Detected size change: {original_backup.size} -> {current_img.size}") - elif current_img is not original_backup: - processed_img = current_img - print(f"[R Post] Detected object change (same size)") - - if processed_img is None: - processed_img = single_temp_processed.images[0] - print(f"[R Post] Using fallback image: {getattr(processed_img, 'size', 'unknown')}") - - if processed_img is not None: - final_processed_images.append(processed_img) - successful_processes += 1 - print(f"[R Post] Image {img_idx + 1}: Using ADetailer result - size {getattr(processed_img, 'size', 'unknown')}") - - # Debug: Compare original vs processed - if hasattr(processed_img, 'size') and hasattr(single_img, 'size'): - orig_size = getattr(single_img, 'size', 'unknown') - proc_size = getattr(processed_img, 'size', 'unknown') - print(f"[R Post] SIZE COMPARISON: Original {orig_size} -> Processed {proc_size}") - - # CRITICAL: Manually save ADetailer result since auto-save may not work - try: - import os - from modules import images as images_module - save_dir = getattr(p, 'outpath_samples', 'outputs/txt2img-images') - os.makedirs(save_dir, exist_ok=True) - - # Generate filename with ADetailer suffix - base_filename = f"{getattr(p, 'seed', 'unknown')}_{img_idx+1}_adetailer" - - # Save both original and processed for comparison - info_text = None - if hasattr(processed, 'infotexts') and processed.infotexts: - try: - info_text = processed.infotexts[img_idx] - except Exception: - info_text = processed.infotexts[0] - if info_text is None: - info_text = getattr(single_temp_processed, 'info', '') - if original_images_backup: - orig_filepath = images_module.save_image( - original_images_backup[0], - save_dir, - f"{base_filename}_ORIGINAL", - extension='png', - info=info_text, - p=p - ) - print(f"[R Post] SAVED original for comparison: {orig_filepath}") - - filepath = images_module.save_image( - processed_img, - save_dir, - f"{base_filename}_PROCESSED", - extension='png', - info=info_text, - p=p - ) - print(f"[R Post] SAVED ADetailer result: {filepath}") - except Exception as save_error: - print(f"[R Post] Manual save failed: {save_error}") - else: - # Use original if ADetailer failed or produced no visible output - final_processed_images.append(single_img) - print(f"[R Post] Image {img_idx + 1}: ADetailer failed/no-change - using original image") - - except Exception as img_error: - # Comprehensive error handling for individual image processing - print(f"[R Post] Critical error processing image {img_idx + 1}: {img_error}") - # Always add the original image to prevent complete failure - final_processed_images.append(single_img) - import traceback - traceback.print_exc() - - # Report results - print(f"[R Post] Individual processing complete: {successful_processes}/{len(converted_images)} images processed by ADetailer") - - # Update processed object with final results - if final_processed_images: - processed.images.clear() - processed.images.extend(final_processed_images) - img2img_results.clear() - img2img_results.extend(final_processed_images) - if hasattr(p, 'processed'): - p.processed.images.clear() - p.processed.images.extend(final_processed_images) - print(f"[R Post] Updated processed.images with {len(final_processed_images)} final results") - - # Clear the manual ADetailer flag - setattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - - return successful_processes > 0 - - # Try to run ADetailer's postprocess method - if not hasattr(script, 'postprocess'): - print(f"[R Post] Script {script.__class__.__name__} has no postprocess method") - continue - - print(f"[R Post] Running {script.__class__.__name__}.postprocess() on img2img results") - try: - # CRITICAL: Clear all our blocking flags so ADetailer can actually run - setattr(p, '_ad_disabled', False) - setattr(p, '_ranbooru_skip_initial_adetailer', False) - setattr(p, '_ranbooru_suppress_all_processing', False) - setattr(p, '_ranbooru_adetailer_already_processed', False) - - # Clear class-level blocks - setattr(self.__class__, '_ranbooru_block_all_adetailer', False) - setattr(self.__class__, '_adetailer_global_guard_active', False) - setattr(self.__class__, '_adetailer_pipeline_blocked', False) - - # CRITICAL: Set flag to prevent recursive guard calls - setattr(self.__class__, '_ranbooru_manual_adetailer_active', True) - - print("[R Post] CLEARED all blocking flags for manual ADetailer run") - - # CRITICAL: Set up processing object for ADetailer - # ADetailer needs these parameters to actually run - p.init_images = [img2img_results[0]] # Set the input image - p.width = img2img_results[0].width - p.height = img2img_results[0].height - - # Enable saving so ADetailer can work AND set proper output path - p.do_not_save_samples = False - p.do_not_save_grid = False - - # CRITICAL: Set ADetailer-specific save paths and flags - if hasattr(p, 'outpath_samples'): - # Store original path to restore later - original_outpath = p.outpath_samples - else: - original_outpath = 'outputs/txt2img-images' - - # Ensure ADetailer has a valid save path - try: - import modules.shared as shared - adetailer_outpath = getattr(shared.opts, 'outdir_txt2img_samples', None) or original_outpath - p.outpath_samples = adetailer_outpath - print(f"[R Post] Set ADetailer save path: {adetailer_outpath}") - except Exception: - p.outpath_samples = original_outpath - print(f"[R Post] Fallback ADetailer save path: {original_outpath}") - - # Set ADetailer-friendly flags - setattr(p, 'save_images', True) - setattr(p, '_adetailer_can_save', True) - - # CRITICAL: Comprehensive PIL enforcement - patch ALL possible image sources - self._enforce_pil_everywhere(p, temp_processed, img2img_results) - - # NUCLEAR: Hook into WebUI's image conversion functions to intercept numpy arrays - self._patch_image_conversion_functions() - - # ULTIMATE: Hook ADetailer's validation function directly to intercept numpy at source - try: - # Inline validation hook since method might not exist yet - import numpy as np - from PIL import Image - if hasattr(script, 'postprocess_image') and not hasattr(script.__class__, '_ranbooru_numpy_hooked'): - original_method = script.postprocess_image - def numpy_safe_postprocess_image(*args, **kwargs): - new_args = [] - for arg in args: - if isinstance(arg, dict) and 'image' in arg and hasattr(arg['image'], 'shape'): - img_data = arg['image'] - if len(img_data.shape) == 3: - pil_img = Image.fromarray(img_data.astype(np.uint8), 'RGB') - new_arg = arg.copy() - new_arg['image'] = pil_img - new_args.append(new_arg) - print(f"[R Post] NUMPY HOOK: Intercepted and converted numpy array to PIL {pil_img.size}") - continue - new_args.append(arg) - return original_method(*new_args, **kwargs) - script.postprocess_image = numpy_safe_postprocess_image - setattr(script.__class__, '_ranbooru_numpy_hooked', True) - print(f"[R Post] INSTALLED: Numpy->PIL conversion hook on ADetailer.postprocess_image") - except Exception as hook_error: - print(f"[R Post] Numpy validation hook failed: {hook_error}") - - # Restore original parameters - if hasattr(self, 'original_full_prompt'): - p.prompt = self.original_full_prompt - if hasattr(self, 'original_outpath'): - p.outpath_samples = self.original_outpath - - print(f"[R Post] SETUP: p.init_images[0]={p.init_images[0].size}, p.width={p.width}, p.height={p.height}") - print(f"[R Post] SETUP: p.do_not_save_samples={p.do_not_save_samples}, p.outpath_samples='{p.outpath_samples}'") - - # Get script args - this is critical for ADetailer - script_args = getattr(p, 'script_args', []) - print(f"[R Post] DEBUG - Using {len(script_args)} script args") - - # Debug ADetailer's internal state - print(f"[R Post] DEBUG - Script enabled: {getattr(script, 'enabled', 'unknown')}") - print(f"[R Post] DEBUG - Script methods: {[m for m in dir(script) if 'process' in m.lower()]}") - - # Debug processing object state - print(f"[R Post] DEBUG - p.do_not_save_samples: {getattr(p, 'do_not_save_samples', 'unknown')}") - print(f"[R Post] DEBUG - p._ad_disabled: {getattr(p, '_ad_disabled', 'unknown')}") - print(f"[R Post] DEBUG - temp_processed type: {type(temp_processed)}") - print(f"[R Post] DEBUG - temp_processed.images count: {len(getattr(temp_processed, 'images', []))}") - - # Check if ADetailer has any internal flags that might block it - adetailer_flags = [attr for attr in dir(p) if 'adetailer' in attr.lower() or 'ad_' in attr.lower()] - if adetailer_flags: - print(f"[R Post] DEBUG - ADetailer-related flags on p: {adetailer_flags}") - for flag in adetailer_flags[:5]: # Show first 5 to avoid spam - print(f"[R Post] DEBUG - p.{flag}: {getattr(p, flag, 'unknown')}") - - # Try to check ADetailer's configuration - try: - if hasattr(script, 'args_info'): - print(f"[R Post] DEBUG - Script args_info: {len(getattr(script, 'args_info', []))} items") - if hasattr(script, 'enabled') and script.enabled: - print(f"[R Post] DEBUG - Script is enabled and ready") - else: - print(f"[R Post] DEBUG - Script enabled status: {getattr(script, 'enabled', 'no enabled attr')}") - except Exception as debug_e: - print(f"[R Post] DEBUG - Could not check script config: {debug_e}") - - # Try both ADetailer methods - postprocess_image is the main one - adetailer_processed = False - - # FINAL VALIDATION: Check that ADetailer will receive only PIL Images - def validate_and_convert_image_data(data_dict): - """Final validation to ensure ADetailer receives only PIL Images""" - import numpy as np - from PIL import Image - - for key, value in data_dict.items(): - if key == 'image' and hasattr(value, 'shape'): # numpy array - if len(value.shape) == 3 and value.shape[2] == 3: - data_dict[key] = Image.fromarray(value.astype(np.uint8), 'RGB') - else: - data_dict[key] = Image.fromarray(value.astype(np.uint8)) - print(f"[R Post] FINAL CONVERSION: {key} converted from numpy to PIL Image {data_dict[key].size}") - return data_dict - - # Hook ADetailer's validation to ensure PIL Images - original_adetailer_validate = None - def pil_ensuring_wrapper(original_func): - def wrapper(*args, **kwargs): - # Convert first argument if it's a dict with 'image' key containing numpy array - if args and isinstance(args[0], dict) and 'image' in args[0]: - args = (validate_and_convert_image_data(args[0]),) + args[1:] - return original_func(*args, **kwargs) - return wrapper - - # Method 1: Try postprocess_image (ADetailer's main method) - if hasattr(script, 'postprocess_image'): - print(f"[R Post] TRYING: {script.__class__.__name__}.postprocess_image(p, temp_processed, *{len(script_args)} args)") - try: - # CRITICAL: ADetailer extracts image data from different sources - # We need to patch ALL possible image sources, not just temp_processed - - # 1. Patch temp_processed images (our standard approach) - final_validation_images = [] - for img in getattr(temp_processed, 'images', []): - if hasattr(img, 'shape'): # numpy array - import numpy as np - from PIL import Image - if len(img.shape) == 3 and img.shape[2] == 3: - final_img = Image.fromarray(img.astype(np.uint8), 'RGB') - else: - final_img = Image.fromarray(img.astype(np.uint8)) - print(f"[R Post] CONVERTED temp_processed.images: numpy -> PIL Image {final_img.size}") - final_validation_images.append(final_img) - else: - final_validation_images.append(img) - - if final_validation_images: - temp_processed.images = final_validation_images - if hasattr(temp_processed, 'image'): - temp_processed.image = final_validation_images[0] - - # 2. CRITICAL: Patch p.init_images (ADetailer might read from here) - if hasattr(p, 'init_images') and p.init_images: - patched_init_images = [] - for img in p.init_images: - if hasattr(img, 'shape'): # numpy array - import numpy as np - from PIL import Image - if len(img.shape) == 3 and img.shape[2] == 3: - patched_img = Image.fromarray(img.astype(np.uint8), 'RGB') - else: - patched_img = Image.fromarray(img.astype(np.uint8)) - print(f"[R Post] CONVERTED p.init_images: numpy -> PIL Image {patched_img.size}") - patched_init_images.append(patched_img) - else: - patched_init_images.append(img) - p.init_images = patched_init_images - - # 3. Hook ADetailer's validation function directly - original_validate_inputs = None - try: - # Try to find ADetailer's input validation - import sys - adetailer_modules = [mod for name, mod in sys.modules.items() if 'adetailer' in name.lower()] - for mod in adetailer_modules: - if hasattr(mod, 'validate_inputs') or hasattr(mod, 'process_image'): - # Found a potential validation function - this is where ADetailer processes the image - print(f"[R Post] Found ADetailer module with validation: {mod.__name__}") - break - except Exception: - pass - - print(f"[R Post] COMPREHENSIVE PIL VALIDATION: All image sources patched") - - with self._manual_adetailer_script_isolation(p, script): - result = script.postprocess_image(p, temp_processed, *script_args) - print(f"[R Post] postprocess_image returned: {result}") - if result is not None: - adetailer_processed = True - print(f"[R Post] postprocess_image succeeded!") - except Exception as e: - print(f"[R Post] postprocess_image failed: {e}") - # Show ValidationError details if it's that type - error_str = str(e) - if 'ValidationError' in error_str and 'array(' in error_str: - print(f"[R Post] ADetailer ValidationError - ADetailer is reading numpy arrays from an unknown source") - print(f"[R Post] Debug temp_processed.images types: {[type(img).__name__ for img in getattr(temp_processed, 'images', [])]}") - if hasattr(temp_processed, 'image'): - print(f"[R Post] Debug temp_processed.image type: {type(temp_processed.image).__name__}") - if hasattr(p, 'init_images'): - print(f"[R Post] Debug p.init_images types: {[type(img).__name__ for img in p.init_images]}") - # Try to skip ADetailer if it keeps failing - print(f"[R Post] ValidationError persists - ADetailer may be reading from internal cache or other source") - - # Method 2: Try postprocess (fallback) - if not adetailer_processed and hasattr(script, 'postprocess'): - print(f"[R Post] FALLBACK: {script.__class__.__name__}.postprocess(p, temp_processed, *{len(script_args)} args)") - try: - with self._manual_adetailer_script_isolation(p, script): - script.postprocess(p, temp_processed, *script_args) - adetailer_processed = True - print(f"[R Post] postprocess succeeded!") - except Exception as e: - print(f"[R Post] postprocess failed: {e}") - - # Method 3: Try direct ADetailer processing (bypass all checks) - if not adetailer_processed: - print(f"[R Post] DIRECT: Attempting direct ADetailer processing bypass") - try: - # Force enable the script - original_enabled = getattr(script, 'enabled', True) - script.enabled = True - - # Try to call ADetailer's internal processing directly - if hasattr(script, '_process_image'): - print(f"[R Post] DIRECT: Trying _process_image") - result = script._process_image(temp_processed.images[0], p) - if result: - temp_processed.images[0] = result - adetailer_processed = True - print(f"[R Post] _process_image succeeded!") - - # Restore original state - script.enabled = original_enabled - - except Exception as e: - print(f"[R Post] Direct processing failed: {e}") - try: - script.enabled = original_enabled - except: - pass - - # Method 4: Try using modules.scripts to run ADetailer normally - if not adetailer_processed: - print(f"[R Post] PIPELINE: Attempting to run ADetailer through normal pipeline") - try: - import modules.scripts - # Try to run postprocess_image through the script system - if hasattr(modules.scripts, 'postprocess_image'): - print(f"[R Post] PIPELINE: Calling modules.scripts.postprocess_image") - modules.scripts.postprocess_image(p, temp_processed) - adetailer_processed = True - print(f"[R Post] Pipeline postprocess_image succeeded!") - except Exception as e: - print(f"[R Post] Pipeline processing failed: {e}") - - print(f"[R Post] AFTER ALL CALLS: temp_processed.images has {len(getattr(temp_processed, 'images', []))} images") - print(f"[R Post] ADetailer processing result: {adetailer_processed}") - - # Check if ADetailer actually processed the images - if hasattr(temp_processed, 'images') and temp_processed.images: - if len(temp_processed.images) > 0: - # CRITICAL: Update ALL image references immediately - processed.images.clear() - processed.images.extend(temp_processed.images) - - # Also update the img2img_results array that other code might reference - img2img_results.clear() - img2img_results.extend(temp_processed.images) - - # Force update the original processed object references - if hasattr(p, 'processed'): - p.processed.images.clear() - p.processed.images.extend(temp_processed.images) - - print(f"[R Post] SUCCESS! {script.__class__.__name__} processed {len(temp_processed.images)} images") - print(f"[R Post] Updated processed.images, img2img_results, and p.processed with ADetailer results") - - # Mark completion to prevent any subsequent manual re-runs - try: - setattr(p, '_ranbooru_manual_adetailer_complete', True) - except Exception: - pass - return True - else: - print(f"[R Post] {script.__class__.__name__} returned empty images list") - else: - print(f"[R Post] {script.__class__.__name__} didn't return processed images") - except Exception as e: - print(f"[R Post] {script.__class__.__name__} postprocess failed: {e}") - import traceback - traceback.print_exc() - continue - finally: - # CRITICAL: Clear the manual ADetailer flag - setattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - - if adetailer_scripts_found == 0: - print("[R Post] No ADetailer scripts found") + # Manual img2img ADetailer execution must run regardless of persisted UI flags + # to avoid silently skipping detailing when the extracted enable flag is False. + enable_flag = True + # Manual runs should never honour the "skip img2img" style bool. + skip_flag = False + + # Ensure each tab only runs when it has a valid model + for idx_dict, ad_dict in enumerate(dicts): + model_name = str(ad_dict.get("ad_model", "") or "").strip().lower() + has_model = model_name not in ("", "none") + if idx_dict == 0 and has_model: + ad_dict["ad_tab_enable"] = True else: - print(f"[R Post] Found {adetailer_scripts_found} ADetailer script(s) but none processed successfully") - - return False - except Exception as e: - print(f"[R Post] Critical error in manual ADetailer execution: {e}") - import traceback - traceback.print_exc() - return False - finally: - # Clear the manual ADetailer active flag - setattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - print("[R Post] Cleared ADetailer active flag") - - def _enforce_pil_everywhere(self, p, temp_processed, img2img_results): - """Comprehensive PIL enforcement - patch ALL possible image sources that ADetailer might read from""" + ad_dict["ad_tab_enable"] = bool(ad_dict.get("ad_tab_enable", False) and has_model) + + sanitized = [enable_flag, skip_flag] + dicts + return {"args": sanitized, "meta": meta} + + def _manual_adetailer_requires_controlnet(self, script_args): + """Return True when extracted ADetailer args request ControlNet integration.""" try: - import numpy as np - from PIL import Image - - def convert_to_pil(img): - """Convert any image format to PIL RGB""" - if img is None: - return None - if hasattr(img, 'shape'): # numpy array - if len(img.shape) == 3 and img.shape[2] == 3: - return Image.fromarray(img.astype(np.uint8), 'RGB') - else: - return Image.fromarray(img.astype(np.uint8)) - elif hasattr(img, 'mode'): # PIL Image - return img.convert('RGB') if img.mode != 'RGB' else img - return img - - # 1. Convert temp_processed images - if hasattr(temp_processed, 'images') and temp_processed.images: - for idx, img in enumerate(temp_processed.images): - converted = convert_to_pil(img) - if converted != img: - temp_processed.images[idx] = converted - print(f"[R Post] ENFORCED PIL: temp_processed.images[{idx}] -> {converted.size}") - - if hasattr(temp_processed, 'image'): - converted = convert_to_pil(temp_processed.image) - if converted != temp_processed.image: - temp_processed.image = converted - print(f"[R Post] ENFORCED PIL: temp_processed.image -> {converted.size}") - - # 2. Convert p.init_images (ADetailer reads from here too) - if hasattr(p, 'init_images') and p.init_images: - for idx, img in enumerate(p.init_images): - converted = convert_to_pil(img) - if converted != img: - p.init_images[idx] = converted - print(f"[R Post] ENFORCED PIL: p.init_images[{idx}] -> {converted.size}") - - # 3. Convert the main processed object images - if hasattr(p, 'processed') and hasattr(p.processed, 'images'): - for idx, img in enumerate(p.processed.images): - converted = convert_to_pil(img) - if converted != img: - p.processed.images[idx] = converted - print(f"[R Post] ENFORCED PIL: p.processed.images[{idx}] -> {converted.size}") - - print("[R Post] COMPREHENSIVE PIL ENFORCEMENT: All image sources converted to PIL RGB") - - except Exception as e: - print(f"[R Post] Error in PIL enforcement: {e}") - - def _patch_image_conversion_functions(self): - """Patch WebUI's image conversion functions to prevent numpy arrays from reaching ADetailer""" + for arg in script_args or []: + if not isinstance(arg, dict): + continue + model_name = str(arg.get("ad_controlnet_model", "") or "").strip().lower() + if model_name and model_name not in ("none", "passthrough"): + return True + except Exception: + pass + return False + + def _images_visibly_different(self, original_image, processed_image): + """Return True only when pixel content or dimensions actually changed.""" try: - import sys - from PIL import Image - import numpy as np - - # Find and patch modules that might convert PIL to numpy - modules_to_patch = [] - for module_name in sys.modules: - if any(name in module_name.lower() for name in ['processing', 'shared', 'scripts']): - module = sys.modules[module_name] - if hasattr(module, 'pil2numpy') or hasattr(module, 'numpy_to_pil') or hasattr(module, 'image_to_numpy'): - modules_to_patch.append(module) - - # Install interceptors - for module in modules_to_patch: - if self._verify_patch_target(module, 'pil2numpy') and not hasattr(module, '_ranbooru_original_pil2numpy'): - original_func = module.pil2numpy - module._ranbooru_original_pil2numpy = original_func - - def patched_pil2numpy(*args, **kwargs): - result = original_func(*args, **kwargs) - # If ADetailer is active, return PIL instead of numpy - if getattr(self.__class__, '_ranbooru_manual_adetailer_active', False): - if isinstance(result, np.ndarray) and len(result.shape) == 3: - pil_img = Image.fromarray(result.astype(np.uint8), 'RGB') - print("[R Post] INTERCEPTED: Blocked numpy conversion during ADetailer, returning PIL") - return pil_img - return result - - module.pil2numpy = patched_pil2numpy - self._log_patch_event("info", f"Patched conversion target: {module.__name__}.pil2numpy") - try: - if not hasattr(self, '_patched_conversion_modules'): - self._patched_conversion_modules = [] - self._patched_conversion_modules.append((module, 'pil2numpy')) - except Exception: - pass - - print(f"[R Post] PATCHED: {len(modules_to_patch)} modules to prevent numpy leaks to ADetailer") - - except Exception as e: - print(f"[R Post] Error patching image conversion functions: {e}") + if original_image is None or processed_image is None: + return False + + original_size = getattr(original_image, "size", None) + processed_size = getattr(processed_image, "size", None) + if original_size and processed_size and original_size != processed_size: + return True + + original_compare = original_image + processed_compare = processed_image + + if hasattr(original_compare, "mode") and original_compare.mode != "RGB": + original_compare = original_compare.convert("RGB") + if hasattr(processed_compare, "mode") and processed_compare.mode != "RGB": + processed_compare = processed_compare.convert("RGB") + + if hasattr(original_compare, "tobytes") and hasattr(processed_compare, "tobytes"): + return original_compare.tobytes() != processed_compare.tobytes() + except Exception as compare_exc: + print(f"[R Post] WARN: Could not compare image pixels: {compare_exc}") + + return False + + def _execute_manual_adetailer(self, p, processed, img2img_results): + """Run manual ADetailer on img2img results via the deterministic runtime executor.""" + return self._adetailer_orch._execute_manual_adetailer(p, processed, img2img_results) def _unpatch_manual_adetailer_overrides(self): """Restore any monkey patches applied for manual ADetailer runs.""" try: self._log_patch_event("info", "Starting unpatch of manual ADetailer overrides") - if hasattr(self, '_patched_processed_objects'): - for proc in list(self._patched_processed_objects): - original = getattr(proc, '_ranbooru_original_getattribute', None) - if original is not None: - try: - proc.__getattribute__ = original - except Exception: - pass - try: - delattr(proc, '_ranbooru_original_getattribute') - except Exception: - pass - delattr(self, '_patched_processed_objects') - - if hasattr(self, '_patched_adetailer_modules'): - for module, attr_name in list(self._patched_adetailer_modules): - attr_key = f'_ranbooru_original_{attr_name}' - original = getattr(module, attr_key, None) - if original is not None: - try: - setattr(module, attr_name, original) - except Exception: - pass - if hasattr(module, attr_key): - try: - delattr(module, attr_key) - except Exception: - pass - delattr(self, '_patched_adetailer_modules') - - if hasattr(self, '_patched_conversion_modules'): - for module, attr_name in list(self._patched_conversion_modules): - attr_key = f'_ranbooru_original_{attr_name}' - original = getattr(module, attr_key, None) - if original is not None: - try: - setattr(module, attr_name, original) - except Exception: - pass - if hasattr(module, attr_key): - try: - delattr(module, attr_key) - except Exception: - pass - delattr(self, '_patched_conversion_modules') - - if hasattr(self.__class__, '_force_adetailer_images'): - try: - delattr(self.__class__, '_force_adetailer_images') - except Exception: - pass - - # Restore ScriptRunner guards back to their original implementations + patch_errors = self._adetailer_patches.uninstall_all() + if patch_errors: + print("[R Patch] ADetailer restore warnings: " + "; ".join(patch_errors)) + for attr in ( + "_patched_processed_objects", + "_patched_adetailer_modules", + "_patched_conversion_modules", + "_force_adetailer_images", + ): + if hasattr(self, attr): + try: + delattr(self, attr) + except Exception: + pass + if hasattr(self.__class__, attr): + try: + delattr(self.__class__, attr) + except Exception: + pass try: - import modules.scripts as _ranbooru_scripts_module # type: ignore - for runner_attr in ('scripts_txt2img', 'scripts_img2img'): + import modules.scripts as _ranbooru_scripts_module + + for runner_attr in ("scripts_txt2img", "scripts_img2img"): runner = getattr(_ranbooru_scripts_module, runner_attr, None) - if not runner: - continue - original_post = getattr(runner, '_ranbooru_original_postprocess', None) - if original_post is not None: - try: - runner.postprocess = original_post - except Exception: - pass - try: - delattr(runner, '_ranbooru_original_postprocess') - except Exception: - pass - original_post_image = getattr(runner, '_ranbooru_original_postprocess_image', None) - if original_post_image is not None: - try: - runner.postprocess_image = original_post_image - except Exception: - pass - try: - delattr(runner, '_ranbooru_original_postprocess_image') - except Exception: - pass - if hasattr(runner, '_ranbooru_guard_installed'): - try: - delattr(runner, '_ranbooru_guard_installed') - except Exception: - pass + if runner and hasattr(runner, "_ranbooru_guard_installed"): + delattr(runner, "_ranbooru_guard_installed") except Exception: pass self._log_patch_event("info", "Completed unpatch of manual ADetailer overrides") except Exception as exc: self._log_patch_event("warning", f"Failed to unpatch manual ADetailer overrides: {exc}") print(f"[R Cleanup] Warn: Failed to unpatch manual ADetailer overrides: {exc}") - - def _construct_processed_fallback(self, processed, img2img_results, p): - """Fallback method to construct Processed object""" - try: - # Try creating a minimal Processed-like object - temp_processed = type('TempProcessed', (), {})() - - # Convert all images to PIL format before assignment - converted_images = [] - for img in img2img_results: - if hasattr(img, 'mode') and img.mode != 'RGB': - img = img.convert('RGB') - elif hasattr(img, 'shape'): # Handle numpy arrays - import numpy as np - from PIL import Image - if len(img.shape) == 3 and img.shape[2] == 3: - img = Image.fromarray(img.astype(np.uint8), 'RGB') - else: - img = Image.fromarray(img.astype(np.uint8)) - converted_images.append(img) - - temp_processed.images = converted_images - temp_processed.infotexts = [''] * len(converted_images) - - # CRITICAL: ADetailer expects 'image' attribute (singular) - if converted_images: - temp_processed.image = converted_images[0] - print(f"[R Post] FALLBACK: Set temp_processed.image = {converted_images[0].size} mode={converted_images[0].mode}") - print(f"[R Post] FALLBACK: Converted {len(converted_images)} images to PIL format") - - return temp_processed - except Exception as e: - raise Exception(f"Fallback construction failed: {e}") - - def _disable_original_adetailer(self, p): - """Comprehensively disable ALL ADetailer scripts from ALL possible sources""" - try: - print("[R Post] COMPREHENSIVE: Finding and disabling ALL ADetailer scripts everywhere") - self.disabled_adetailer_scripts = [] - - # Method 1: Check alwayson_scripts (primary location) - if hasattr(p, 'scripts') and hasattr(p.scripts, 'alwayson_scripts'): - for script in p.scripts.alwayson_scripts: - if self._is_adetailer_script(script): - self._disable_single_adetailer(script, "alwayson_scripts") - - # Method 2: Check regular scripts list - if hasattr(p, 'scripts') and hasattr(p.scripts, 'scripts'): - for script in p.scripts.scripts: - if self._is_adetailer_script(script): - self._disable_single_adetailer(script, "scripts") - - # Method 3: Check global scripts registry - try: - import modules.scripts as scripts_module - if hasattr(scripts_module, 'scripts_data'): - for script_data in scripts_module.scripts_data: - if hasattr(script_data, 'script_class'): - script = script_data.script_class - if self._is_adetailer_script(script): - self._disable_single_adetailer(script, "global_registry") - except: - pass # Global registry might not be accessible - - # Method 4: Find ADetailer through module inspection - try: - import sys - for module_name in sys.modules: - if 'adetailer' in module_name.lower(): - module = sys.modules[module_name] - for attr_name in dir(module): - attr = getattr(module, attr_name) - if hasattr(attr, 'postprocess') and self._is_adetailer_script(attr): - self._disable_single_adetailer(attr, f"module_{module_name}") - except: - pass # Module inspection might fail - - print(f"[R Post] COMPREHENSIVE DISABLE: Found and disabled {len(self.disabled_adetailer_scripts)} ADetailer script(s) from all sources") - - # NUCLEAR OPTION: Block the wrong image size entirely - self._block_wrong_image_size() - - except Exception as e: - print(f"[R Post] Error in comprehensive ADetailer disable: {e}") - + def _is_adetailer_script(self, script): """Check if a script is an ADetailer script""" - try: - if script is None: - return False - script_name = script.__class__.__name__.lower() if hasattr(script, '__class__') else str(script).lower() - return ('adetailer' in script_name or - 'afterdetailer' in script_name or - 'after_detailer' in script_name or - 'ad_script' in script_name) - except: - return False + return self._adetailer_orch._is_adetailer_script(script) def _is_controlnet_script(self, script): """Check if a script appears to be a ControlNet script.""" try: if script is None: return False - script_name = script.__class__.__name__.lower() if hasattr(script, '__class__') else str(script).lower() - if 'controlnet' in script_name: + script_name = ( + script.__class__.__name__.lower() + if hasattr(script, "__class__") + else str(script).lower() + ) + if "controlnet" in script_name: return True - title_attr = getattr(script, 'title', None) + title_attr = getattr(script, "title", None) if callable(title_attr): try: title_value = str(title_attr()).strip().lower() - if 'controlnet' in title_value: + if "controlnet" in title_value: return True except Exception: pass @@ -7091,17 +5422,17 @@ def _is_forge_controlnet_script(self, script): try: if script is None: return False - cls = getattr(script, '__class__', None) - class_name = getattr(cls, '__name__', '') - module_name = getattr(cls, '__module__', '') - filename = str(getattr(script, 'filename', '') or '') + cls = getattr(script, "__class__", None) + class_name = getattr(cls, "__name__", "") + module_name = getattr(cls, "__module__", "") + filename = str(getattr(script, "filename", "") or "") class_name_l = str(class_name).lower() module_name_l = str(module_name).lower() - filename_l = filename.replace('\\', '/').lower() + filename_l = filename.replace("\\", "/").lower() return ( - class_name_l == 'controlnetforforgeofficial' - or 'sd_forge_controlnet' in module_name_l - or 'sd_forge_controlnet' in filename_l + class_name_l == "controlnetforforgeofficial" + or "sd_forge_controlnet" in module_name_l + or "sd_forge_controlnet" in filename_l ) except Exception: return False @@ -7110,303 +5441,139 @@ def _is_forge_controlnet_script(self, script): def _clear_runner_callback_cache(runner): """Invalidate ScriptRunner callback cache after script list mutations.""" try: - callback_map = getattr(runner, 'callback_map', None) + callback_map = getattr(runner, "callback_map", None) if isinstance(callback_map, dict): callback_map.clear() except Exception: pass @contextmanager - def _manual_adetailer_script_isolation(self, processing_obj, adetailer_script, keep_controlnet: bool = False): + def _manual_adetailer_script_isolation( + self, processing_obj, adetailer_script, keep_controlnet: bool = False + ): """Run manual ADetailer with only the selected ADetailer script present in runners.""" - snapshots = [] - try: - if adetailer_script is None: - yield + if adetailer_script is None: + yield + return + + runners = [] + seen_runner_ids = set() + + def add_runner(runner): + if runner is None: return + runner_id = id(runner) + if runner_id in seen_runner_ids: + return + seen_runner_ids.add(runner_id) + runners.append(runner) - runners = [] - seen_runner_ids = set() + add_runner(getattr(processing_obj, "scripts", None)) + try: + import modules.scripts as scripts_module - def add_runner(runner): - if runner is None: - return - runner_id = id(runner) - if runner_id in seen_runner_ids: - return - seen_runner_ids.add(runner_id) - runners.append(runner) + add_runner(getattr(scripts_module, "scripts_txt2img", None)) + add_runner(getattr(scripts_module, "scripts_img2img", None)) + except Exception: + pass - add_runner(getattr(processing_obj, 'scripts', None)) - try: - import modules.scripts as scripts_module - add_runner(getattr(scripts_module, 'scripts_txt2img', None)) - add_runner(getattr(scripts_module, 'scripts_img2img', None)) - except Exception: - pass + def keep_controlnet_fn(script_item, list_attr): + if not keep_controlnet or not self._is_controlnet_script(script_item): + return False + if list_attr == "scripts": + return True + return not self._is_forge_controlnet_script(script_item) - removed_total = 0 - kept_controlnet = 0 - forge_controlnet_discovery_only = 0 + with ExitStack() as stack: for runner in runners: - for list_attr in ('alwayson_scripts', 'scripts'): - script_list = getattr(runner, list_attr, None) - if not isinstance(script_list, (list, tuple)): - continue - original_items = list(script_list) - filtered_items = [] - for script_item in original_items: - if script_item is adetailer_script: - filtered_items.append(script_item) - continue - if keep_controlnet and self._is_controlnet_script(script_item): - # Forge ControlNet only needs to stay discoverable in runner.scripts. - # Keeping it always-on during manual ADetailer can trigger stale - # callbacks where process_before_every_sampling runs without process(). - if list_attr == 'scripts': - filtered_items.append(script_item) - kept_controlnet += 1 - elif not self._is_forge_controlnet_script(script_item): - filtered_items.append(script_item) - kept_controlnet += 1 - else: - forge_controlnet_discovery_only += 1 - if filtered_items == original_items: - continue - snapshots.append((runner, list_attr, script_list)) - setattr(runner, list_attr, filtered_items) - self._clear_runner_callback_cache(runner) - removed_total += max(0, len(original_items) - len(filtered_items)) - - if removed_total: - if keep_controlnet: - print(f"[R Post] Manual ADetailer isolation active: removed {removed_total} non-ADetailer/ControlNet script entry(s), kept {kept_controlnet} ControlNet entry(s)") - if forge_controlnet_discovery_only: - print(f"[R Post] Manual ADetailer isolation: Forge ControlNet kept for discovery only ({forge_controlnet_discovery_only} always-on callback slot(s) suppressed)") - else: - print(f"[R Post] Manual ADetailer isolation active: removed {removed_total} non-ADetailer script entry(s)") + stack.enter_context( + rb_adetailer_runtime.runner_isolation( + runner=runner, + adetailer_script=adetailer_script, + keep_controlnet_fn=keep_controlnet_fn, + keep_controlnet=keep_controlnet, + ) + ) yield - finally: - for runner, list_attr, original_value in reversed(snapshots): - try: - setattr(runner, list_attr, original_value) - self._clear_runner_callback_cache(runner) - except Exception: - pass - - def _disable_single_adetailer(self, script, source): - """Disable a single ADetailer script""" - try: - print(f"[R Post] Disabling {script.__class__.__name__} from {source}") - - # Store original state for cleanup - original_enabled = getattr(script, 'enabled', True) - self.disabled_adetailer_scripts.append((script, original_enabled)) - - # Disable the script completely - if hasattr(script, 'enabled'): - script.enabled = False - - # Replace ALL processing methods with no-ops - methods_to_disable = ['postprocess', 'process', 'process_batch', 'before_process', 'after_process'] - for method_name in methods_to_disable: - if hasattr(script, method_name): - original_method = getattr(script, method_name) - setattr(script, f'_ranbooru_original_{method_name}', original_method) - setattr(script, method_name, lambda *args, **kwargs: None) # No-op - - # Mark as disabled by RanbooruX - script._ranbooru_disabled_after_manual = True - script._ranbooru_disabled_source = source - - except Exception as e: - print(f"[R Post] Error disabling single ADetailer from {source}: {e}") - - def _block_wrong_image_size(self): - """Block processing of 640x512 images entirely""" - try: - print("[R Post] NUCLEAR: Blocking 640x512 image processing entirely") - - # Store global flag to block wrong image sizes - self.__class__._block_640x512_images = True - - # Try to patch common image processing functions - import modules.processing - if hasattr(modules.processing, '_current_processed'): - original_processed = modules.processing._current_processed - if hasattr(original_processed, 'images'): - # Filter out 640x512 images from any processing - filtered_images = [] - for img in original_processed.images: - if hasattr(img, 'size') and img.size != (640, 512): - filtered_images.append(img) - else: - print(f"[R Post] BLOCKED 640x512 image from processing") - original_processed.images = filtered_images - - except Exception as e: - print(f"[R Post] Error in nuclear image blocking: {e}") - + def _mark_initial_pass(self, p): """Mark that we're in initial pass so ADetailer can be intercepted later""" - try: - print("[R] Marking initial pass - ADetailer will run on img2img results instead") - - # Clear any previous hard-disable flag for ADetailer - try: - if hasattr(p, "_ad_disabled") and getattr(p, "_ad_disabled", False): - setattr(p, "_ad_disabled", False) - print("[R] Cleared p._ad_disabled from previous generation") - except Exception as _e: - print(f"[R] WARN: Could not clear p._ad_disabled: {_e}") - - # Clear our class-level guard - self._set_adetailer_block(False) - # Clear pipeline-level guard flag - setattr(self.__class__, '_ranbooru_block_all_adetailer', False) - - # Install runner guard (idempotent) - self._install_scriptrunner_guard(p) - - # CRITICAL: Re-enable any ADetailer scripts from previous generation - self._reenable_adetailer_from_previous_generation() - - # Just set a flag that we're in initial pass - self._ranbooru_initial_pass = True - - # Store reference to processing object for later use - self._initial_pass_p = p - - except Exception as e: - print(f"[R] Error marking initial pass: {e}") - + self._adetailer_orch._mark_initial_pass(p) + def _reenable_adetailer_from_previous_generation(self): """Re-enable ALL ADetailer scripts that were disabled in the previous generation""" - try: - if hasattr(self, 'disabled_adetailer_scripts') and self.disabled_adetailer_scripts: - print(f"[R] COMPREHENSIVE RE-ENABLE: Restoring {len(self.disabled_adetailer_scripts)} ADetailer script(s) from previous generation") - - for script, original_enabled in self.disabled_adetailer_scripts: - source = getattr(script, '_ranbooru_disabled_source', 'unknown') - print(f"[R] Re-enabling {script.__class__.__name__} from {source}") - - # Restore original enabled state - if hasattr(script, 'enabled'): - script.enabled = original_enabled - - # Restore ALL original methods that were disabled - methods_to_restore = ['postprocess', 'process', 'process_batch', 'before_process', 'after_process'] - for method_name in methods_to_restore: - original_method_attr = f'_ranbooru_original_{method_name}' - if hasattr(script, original_method_attr): - original_method = getattr(script, original_method_attr) - setattr(script, method_name, original_method) - delattr(script, original_method_attr) - - # Remove our disable flags - if hasattr(script, '_ranbooru_disabled_after_manual'): - delattr(script, '_ranbooru_disabled_after_manual') - if hasattr(script, '_ranbooru_disabled_source'): - delattr(script, '_ranbooru_disabled_source') - - print(f"[R] COMPREHENSIVE RE-ENABLE: Restored {len(self.disabled_adetailer_scripts)} ADetailer script(s) for new generation") - # Clear the list now that we've re-enabled everything - delattr(self, 'disabled_adetailer_scripts') - - # Unblock 640x512 images for normal processing - self._unblock_wrong_image_size() - - except Exception as e: - print(f"[R] Error in comprehensive ADetailer re-enable: {e}") - - def _unblock_wrong_image_size(self): - """Unblock 640x512 image processing for normal ADetailer operation""" - try: - if hasattr(self.__class__, '_block_640x512_images'): - print("[R] UNBLOCKING: Re-enabling 640x512 image processing for normal operation") - delattr(self.__class__, '_block_640x512_images') - except Exception as e: - print(f"[R] Error unblocking wrong image size: {e}") - - def _prevent_all_image_saving(self, p): + self._adetailer_orch._reenable_adetailer_from_previous_generation() + + def _prevent_all_image_saving(self, p, temp_dir): """Prevent all possible image saving during initial pass""" try: print("[R] Implementing comprehensive save prevention for initial pass") - - # Store additional original values for restoration - self.original_save_to_dirs = getattr(p, 'save_to_dirs', True) - self.original_filename_format = getattr(p, 'filename_format', None) - - # Disable additional save mechanisms - p.save_to_dirs = False - - # AGGRESSIVE: Set outpath to a temporary location that we can clean up - import tempfile - temp_dir = tempfile.mkdtemp(prefix='ranbooru_temp_') - self.temp_initial_dir = temp_dir - p.outpath_samples = temp_dir - - # ULTIMATE: Set a flag to completely suppress this generation from being processed by anything else - setattr(p, '_ranbooru_suppress_all_processing', True) - setattr(p, '_ranbooru_initial_pass_only', True) - print(f"[R Save Prevention] Redirected initial pass saves to temp directory: {temp_dir}") - print("[R Save Prevention] ULTIMATE: Marked initial pass for complete processing suppression") - + scope = self._host_scope + + scope.set_attr(p, "do_not_save_samples", True) + scope.set_attr(p, "do_not_save_grid", True) + scope.set_attr(p, "save_to_dirs", False) + scope.set_attr(p, "outpath_samples", temp_dir) + scope.set_attr(p, "_ranbooru_suppress_all_processing", True) + scope.set_attr(p, "_ranbooru_initial_pass_only", True) + print( + f"[R Save Prevention] Redirected initial pass saves to temp directory: {temp_dir}" + ) + print( + "[R Save Prevention] ULTIMATE: Marked initial pass for complete processing suppression" + ) + # Try to disable any gallery/history saving - if hasattr(p, 'save_images_history'): - self.original_save_images_history = p.save_images_history - p.save_images_history = False - + if hasattr(p, "save_images_history"): + scope.set_attr(p, "save_images_history", False) + # Disable any extra network saving - if hasattr(p, 'save_samples_dir'): - self.original_save_samples_dir = p.save_samples_dir - p.save_samples_dir = None - + if hasattr(p, "save_samples_dir"): + scope.set_attr(p, "save_samples_dir", None) + # Make filename format minimal to prevent accidental saves - if hasattr(p, 'filename_format'): - p.filename_format = "" - + if hasattr(p, "filename_format"): + scope.set_attr(p, "filename_format", "") + print("[R] Comprehensive save prevention applied") - + except Exception as e: print(f"[R] Error applying save prevention: {e}") - + def _prepare_adetailer_for_img2img(self, p): """Prepare ADetailer to run on img2img results""" - if not self._is_adetailer_enabled(): - return - try: - print("[R] Preparing ADetailer to run on img2img results") - - # Clear the initial pass flag so ADetailer knows to run normally - self._ranbooru_initial_pass = False - - except Exception as e: - print(f"[R] Error preparing ADetailer: {e}") - + self._adetailer_orch._prepare_adetailer_for_img2img(p) + def _force_ui_update(self, p, processed, final_results): """Force ForgeUI to display our final ADetailer-processed results""" try: print(f"[R UI] Forcing UI to display {len(final_results)} final results") - + # SAFETY CHECK: Filter out any 640x512 images from final results filtered_results = [] for img in final_results: - if hasattr(img, 'size') and img.size == (640, 512): - print(f"[R UI] BLOCKED 640x512 image from UI display") + if hasattr(img, "size") and img.size == (640, 512): + print("[R UI] BLOCKED 640x512 image from UI display") else: filtered_results.append(img) - + if len(filtered_results) != len(final_results): - print(f"[R UI] Filtered out {len(final_results) - len(filtered_results)} wrong-sized images") + print( + f"[R UI] Filtered out {len(final_results) - len(filtered_results)} wrong-sized images" + ) final_results = filtered_results - + # Method 1: Update all possible UI-related attributes ui_attrs = [ - 'images', 'output_images', 'result_images', 'final_images', - 'display_images', 'ui_images', 'gallery_images' + "images", + "output_images", + "result_images", + "final_images", + "display_images", + "ui_images", + "gallery_images", ] - + for attr in ui_attrs: if hasattr(processed, attr): if isinstance(getattr(processed, attr), list): @@ -7416,299 +5583,132 @@ def _force_ui_update(self, p, processed, final_results): else: setattr(processed, attr, final_results) print(f"[R UI] Set {attr} for UI") - + # Method 2: Try to update WebUI/Gradio state directly try: import modules.shared as shared_modules - if hasattr(shared_modules, 'state'): + + if hasattr(shared_modules, "state"): # Force UI refresh - if hasattr(shared_modules.state, 'current_image'): - shared_modules.state.current_image = final_results[0] if final_results else None + if hasattr(shared_modules.state, "current_image"): + shared_modules.state.current_image = ( + final_results[0] if final_results else None + ) print("[R UI] Updated shared.state.current_image") - + # Update any gallery state - if hasattr(shared_modules.state, 'gallery_images'): + if hasattr(shared_modules.state, "gallery_images"): shared_modules.state.gallery_images = final_results print("[R UI] Updated shared.state.gallery_images") - + # Force UI state update shared_modules.state.need_restart = False # Prevent restart - + except Exception as e: print(f"[R UI] Could not update WebUI state: {e}") - + # Method 3: Try to update processing pipeline UI references - if hasattr(p, 'cached_images'): + if hasattr(p, "cached_images"): p.cached_images = final_results print("[R UI] Updated p.cached_images") - + # Method 4: Force update any Gradio components we can find try: # This is a bit hacky but should force UI refresh processed._ui_force_update = True - processed._ui_timestamp = __import__('time').time() + processed._ui_timestamp = __import__("time").time() print("[R UI] Added UI force update flags") - except: - pass - + except Exception as ui_update_error: + _ranbooru_logger.warning( + "Unable to add UI force-update flags: %s", + rb_http_client.sanitize_exception_text(str(ui_update_error)), + ) + # Method 5: Update the main result that ForgeUI looks for - if hasattr(processed, '__dict__'): + if hasattr(processed, "__dict__"): for key, value in processed.__dict__.items(): - if 'result' in key.lower() and isinstance(value, list): + if "result" in key.lower() and isinstance(value, list): value.clear() value.extend(final_results) print(f"[R UI] Updated result attribute: {key}") - - print(f"[R UI] UI force update complete - ForgeUI should now display final results") - + + print("[R UI] UI force update complete - ForgeUI should now display final results") + # Disable preview guard now that correct image is presented try: self._set_preview_guard(False) except Exception: pass - + except Exception as e: print(f"[R UI] Error forcing UI update: {e}") def postprocess_batch(self, p, *args, **kwargs): """Ensure the final batch results show img2img instead of txt2img""" try: - if not getattr(self, '_post_enabled', False): + if not getattr(self, "_post_enabled", False): return - - if not getattr(self, 'run_img2img_pass', False): + if not getattr(self, "run_img2img_pass", False): return - - # This method runs after all individual postprocess methods - # Use it to ensure the UI gets the final img2img results - print("[R PostBatch] Ensuring UI displays img2img results") - - # FINAL INTERCEPT: If we have global results, force them into all possible locations - if hasattr(self.__class__, '_global_ranbooru_results'): - img2img_results = self.__class__._global_ranbooru_results - print(f"[R PostBatch] FINAL INTERCEPT: Forcing {len(img2img_results)} img2img results into all extensions") - - # Try to find the processed object in the arguments and force update it - for arg in args: - if hasattr(arg, 'images') and hasattr(arg, 'prompt'): - print("[R PostBatch] Found processed object in args - force updating") - arg.images.clear() - arg.images.extend(img2img_results) - # Apply UI force update here too - self._force_ui_update(p, arg, img2img_results) - # Apply the same monkey patch here - self._patch_adetailer_directly(arg, img2img_results) - - # COMPREHENSIVE: Try to patch any scripts that might be running - all_script_collections = [] - if hasattr(p, 'scripts'): - if hasattr(p.scripts, 'alwayson_scripts'): - all_script_collections.extend([(script, 'alwayson') for script in p.scripts.alwayson_scripts]) - if hasattr(p.scripts, 'scripts'): - all_script_collections.extend([(script, 'regular') for script in p.scripts.scripts]) - - for script, script_type in all_script_collections: - script_name = script.__class__.__name__.lower() - if self._is_adetailer_script(script): - # Skip if we've already disabled this script - if hasattr(script, '_ranbooru_disabled_after_manual'): - print(f"[R PostBatch] Skipping {script.__class__.__name__} ({script_type}) - disabled by RanbooruX after manual processing") - continue - - print(f"[R PostBatch] Found potential ADetailer script: {script.__class__.__name__} ({script_type})") - # Force update any image attributes this script might have - for attr_name in dir(script): - if 'image' in attr_name.lower() and not attr_name.startswith('_'): - try: - attr_value = getattr(script, attr_name) - if isinstance(attr_value, list): - attr_value.clear() - attr_value.extend(img2img_results) - print(f"[R PostBatch] Updated {script_name}.{attr_name}") - except: - pass - - # CRITICAL: Final UI force update at batch level - print("[R PostBatch] Performing final UI force update") - if args and hasattr(args[0], 'images'): - self._force_ui_update(p, args[0], img2img_results) - - # Mark processing as complete for UI - if hasattr(self, '_ranbooru_processing_complete'): - print("[R PostBatch] RanbooruX img2img processing marked as complete") - + if args and hasattr(args[0], "images"): + self._force_ui_update(p, args[0], args[0].images) except Exception as e: print(f"[R PostBatch] Error: {e}") - + def process_batch(self, p, *args, **kwargs): """Process batch - used to mark initial results as intermediate""" try: - if getattr(self, 'run_img2img_pass', False): + if getattr(self, "run_img2img_pass", False): # Mark that we're in a two-pass process - setattr(self, '_ranbooru_intermediate_results', True) + setattr(self, "_ranbooru_intermediate_results", True) print("[R ProcessBatch] Marked results as intermediate - img2img will follow") - + except Exception as e: print(f"[R ProcessBatch] Error: {e}") - + def process(self, p, *args): """Process method - runs during main processing, can intercept results early""" try: # This method runs during the main processing phase # We can use it to prepare for result interception - if getattr(self, 'run_img2img_pass', False): + if getattr(self, "run_img2img_pass", False): print("[R Process] Preparing for img2img result interception") # Mark that we need to intercept results - setattr(self, '_intercept_results', True) - + setattr(self, "_intercept_results", True) + if self._is_adetailer_enabled(): # EARLY PROTECTION: Disable ADetailer during initial pass self._early_adetailer_protection(p) - + # Set early block flag if we're about to process with img2img - if hasattr(self, '_ranbooru_manual_adetailer_complete'): - setattr(self.__class__, '_ranbooru_block_all_adetailer', True) + if hasattr(self, "_ranbooru_manual_adetailer_complete"): + setattr(self.__class__, "_ranbooru_block_all_adetailer", True) print("[R Process] Early block flag set - preventing ADetailer execution") - + except Exception as e: print(f"[R Process] Error: {e}") - + def _early_adetailer_protection(self, p): """Complete ADetailer blocking during initial pass - remove scripts entirely""" - if not self._is_adetailer_enabled(): - return - try: - print("[R Process] Early ADetailer protection activated") - - # Check if we're in the initial pass - if getattr(self, '_ranbooru_initial_pass', False): - print("[R Process] Detected initial pass - COMPLETELY BLOCKING ADetailer") - - # Set comprehensive block flags - setattr(p, '_ranbooru_skip_initial_adetailer', True) - setattr(p, '_ranbooru_suppress_all_processing', True) - setattr(p, '_ranbooru_initial_pass_only', True) - setattr(p, '_ad_disabled', True) # ADetailer's own disable flag - - # CRITICAL: Completely remove ADetailer scripts from the runner during initial pass - self._remove_adetailer_from_runner(p) - - # Set multiple block flags to ensure no ADetailer execution - self._set_adetailer_block(True) - setattr(self.__class__, '_ranbooru_block_all_adetailer', True) - setattr(self.__class__, '_adetailer_global_guard_active', True) - - print("[R Process] ADetailer completely blocked for initial pass - will be restored for manual img2img processing") - - except Exception as e: - print(f"[R Process] Error in early ADetailer protection: {e}") - + self._adetailer_orch._early_adetailer_protection(p) + def _remove_adetailer_from_runner(self, p): """Temporarily remove ADetailer scripts from the script runner during initial pass""" - try: - if not hasattr(p, 'scripts') or p.scripts is None: - return - - # Store original scripts for restoration - if not hasattr(self, '_stored_adetailer_scripts'): - self._stored_adetailer_scripts = {'alwayson': [], 'regular': []} - - # Remove ADetailer from alwayson_scripts - if hasattr(p.scripts, 'alwayson_scripts') and p.scripts.alwayson_scripts: - original_alwayson = list(p.scripts.alwayson_scripts) - filtered_alwayson = [s for s in original_alwayson if not self._is_adetailer_script(s)] - removed_alwayson = [s for s in original_alwayson if self._is_adetailer_script(s)] - - p.scripts.alwayson_scripts = filtered_alwayson - self._stored_adetailer_scripts['alwayson'] = removed_alwayson - print(f"[R Process] Removed {len(removed_alwayson)} ADetailer scripts from alwayson_scripts") - - # Remove ADetailer from regular scripts - if hasattr(p.scripts, 'scripts') and p.scripts.scripts: - original_scripts = list(p.scripts.scripts) - filtered_scripts = [s for s in original_scripts if not self._is_adetailer_script(s)] - removed_scripts = [s for s in original_scripts if self._is_adetailer_script(s)] - - p.scripts.scripts = filtered_scripts - self._stored_adetailer_scripts['regular'] = removed_scripts - print(f"[R Process] Removed {len(removed_scripts)} ADetailer scripts from scripts") - - except Exception as e: - print(f"[R Process] Error removing ADetailer from runner: {e}") - + self._adetailer_orch._remove_adetailer_from_runner(p) + def _restore_early_adetailer_protection(self, processing_obj=None): """Restore ADetailer scripts and flags after an interrupted or completed run.""" - try: - print("[R Process] Restoring ADetailer scripts for manual processing") + self._adetailer_orch._restore_early_adetailer_protection(processing_obj) - # Clear initial pass/block flags so subsequent generations can run ADetailer - setattr(self.__class__, '_ranbooru_block_all_adetailer', False) - setattr(self.__class__, '_adetailer_global_guard_active', False) - self._set_adetailer_block(False) - - # Determine which processing object's script runner to restore into - candidate_p = processing_obj or getattr(self, '_initial_pass_p', None) or getattr(self, '_current_processing_object', None) - runner = getattr(candidate_p, 'scripts', None) if candidate_p else None - - # Restore scripts we removed during the initial pass safeguard - stored = getattr(self, '_stored_adetailer_scripts', None) - if stored and runner: - try: - if hasattr(runner, 'alwayson_scripts') and stored.get('alwayson'): - for script in stored['alwayson']: - if script not in runner.alwayson_scripts: - runner.alwayson_scripts.append(script) - print(f"[R Process] Reattached {len(stored['alwayson'])} ADetailer always-on script(s)") - if hasattr(runner, 'scripts') and stored.get('regular'): - for script in stored['regular']: - if script not in runner.scripts: - runner.scripts.append(script) - print(f"[R Process] Reattached {len(stored['regular'])} ADetailer on-demand script(s)") - finally: - # Clear stored references so we don't duplicate reinsertion - delattr(self, '_stored_adetailer_scripts') - - # Ensure any scripts we hard-disabled are re-enabled for the next generation - if hasattr(self, 'disabled_adetailer_scripts'): - self._reenable_adetailer_from_previous_generation() - - # Clear temporary protection flag if present - if hasattr(self, '_temp_disabled_adetailer'): - delattr(self, '_temp_disabled_adetailer') - - print("[R Process] Early protection restoration complete") - - except Exception as e: - print(f"[R Process] Error restoring early ADetailer protection: {e}") - def process_batch_pre(self, p, *args, **kwargs): """Pre-batch processing to set up result interception""" try: - if getattr(self, 'run_img2img_pass', False): + if getattr(self, "run_img2img_pass", False): print("[R ProcessBatchPre] Setting up early result interception") except Exception as e: print(f"[R ProcessBatchPre] Error: {e}") - - @classmethod - def get_ranbooru_results(cls): - """Public method for other extensions to get RanbooruX img2img results""" - try: - if hasattr(cls, '_global_ranbooru_results'): - return cls._global_ranbooru_results - return None - except: - return None - - @classmethod - def get_ranbooru_processed(cls): - """Public method for other extensions to get RanbooruX processed object""" - try: - if hasattr(cls, '_global_ranbooru_processed'): - return cls._global_ranbooru_processed - return None - except: - return None + @classmethod def random_number(self, sorting_order, size): global COUNT effective_count = COUNT @@ -7718,17 +5718,19 @@ def random_number(self, sorting_order, size): if size <= 0: return [] max_index = effective_count - if sorting_order in ('Score Descending', 'Score Ascending'): + if sorting_order in ("Score Descending", "Score Ascending"): weights = np.arange(1, max_index + 1) weights = weights.astype(float) - if sorting_order == 'Score Ascending': + if sorting_order == "Score Ascending": weights = weights[::-1] if weights.sum() == 0: weights = np.ones(max_index) weights /= weights.sum() replace = size > max_index try: - random_indices = np.random.choice(np.arange(max_index), size=size, p=weights, replace=replace) + random_indices = np.random.choice( + np.arange(max_index), size=size, p=weights, replace=replace + ) except ValueError as e: print(f"[R] Err weighted choice: {e}. Fallback.") random_indices = random.choices(range(max_index), k=size) @@ -7741,108 +5743,7 @@ def use_autotagger(self, model): def _install_scriptrunner_guard(self, p): """Wrap p.scripts postprocess and postprocess_image to skip ADetailer when our block flag is active""" - try: - if not hasattr(p, 'scripts') or p.scripts is None: - return - runner = p.scripts - if not hasattr(runner, '_ranbooru_guard_installed'): - runner._ranbooru_guard_installed = False - if runner._ranbooru_guard_installed: - return - - def is_adetailer(s): - try: - name = s.__class__.__name__.lower() - return 'adetailer' in name or 'afterdetailer' in name - except Exception: - return False - - # Guard postprocess - if self._verify_patch_target(runner, 'postprocess') and not hasattr(runner, '_ranbooru_original_postprocess'): - original_postprocess = runner.postprocess - runner._ranbooru_original_postprocess = original_postprocess - def guarded_postprocess(p_arg, processed_arg, *args, **kwargs): - # Sanitize images to PIL to avoid numpy leaking into downstream extensions - try: - self._ensure_pil_in_processing(p_arg) - if processed_arg is not None: - self._ensure_pil_images_in_processed(processed_arg) - except Exception: - pass - block = getattr(self.__class__, '_ranbooru_block_all_adetailer', False) - manual_active = getattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - print(f"[R Guard] postprocess called - block={block}, manual={manual_active}, prompt='{getattr(p_arg, 'prompt', 'unknown')[:50]}...'") - - # Don't block if manual ADetailer is active - if block and not manual_active: - try: - saved_alwayson = list(getattr(runner, 'alwayson_scripts', []) or []) - saved_scripts = list(getattr(runner, 'scripts', []) or []) - adetailer_count = sum(1 for s in saved_alwayson if is_adetailer(s)) + sum(1 for s in saved_scripts if is_adetailer(s)) - print(f"[R Guard] BLOCKING {adetailer_count} ADetailer script(s) from postprocess") - if hasattr(runner, 'alwayson_scripts'): - runner.alwayson_scripts = [s for s in saved_alwayson if not is_adetailer(s)] - if hasattr(runner, 'scripts'): - runner.scripts = [s for s in saved_scripts if not is_adetailer(s)] - try: - return original_postprocess(p_arg, processed_arg, *args, **kwargs) - finally: - if hasattr(runner, 'alwayson_scripts'): - runner.alwayson_scripts = saved_alwayson - if hasattr(runner, 'scripts'): - runner.scripts = saved_scripts - except Exception as e: - print(f"[R Guard] Error during postprocess blocking: {e}") - return original_postprocess(p_arg, processed_arg, *args, **kwargs) - runner.postprocess = guarded_postprocess - self._log_patch_event("info", "Installed guarded runner.postprocess patch") - - # Guard postprocess_image - if self._verify_patch_target(runner, 'postprocess_image') and not hasattr(runner, '_ranbooru_original_postprocess_image'): - original_postprocess_image = runner.postprocess_image - runner._ranbooru_original_postprocess_image = original_postprocess_image - def guarded_postprocess_image(p_arg, pp_arg, *args, **kwargs): - # Sanitize images to PIL before ADetailer or others consume them - try: - self._ensure_pil_in_processing(p_arg) - if pp_arg is not None: - self._ensure_pil_images_in_processed(pp_arg) - except Exception: - pass - block = getattr(self.__class__, '_ranbooru_block_all_adetailer', False) - manual_active = getattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - print(f"[R Guard] postprocess_image called - block={block}, manual={manual_active}, prompt='{getattr(p_arg, 'prompt', 'unknown')[:50]}...'") - - # Don't block if manual ADetailer is active - if block and not manual_active: - try: - saved_alwayson = list(getattr(runner, 'alwayson_scripts', []) or []) - saved_scripts = list(getattr(runner, 'scripts', []) or []) - adetailer_count = sum(1 for s in saved_alwayson if is_adetailer(s)) + sum(1 for s in saved_scripts if is_adetailer(s)) - print(f"[R Guard] BLOCKING {adetailer_count} ADetailer script(s) from postprocess_image") - if hasattr(runner, 'alwayson_scripts'): - runner.alwayson_scripts = [s for s in saved_alwayson if not is_adetailer(s)] - if hasattr(runner, 'scripts'): - runner.scripts = [s for s in saved_scripts if not is_adetailer(s)] - try: - return original_postprocess_image(p_arg, pp_arg, *args, **kwargs) - finally: - if hasattr(runner, 'alwayson_scripts'): - runner.alwayson_scripts = saved_alwayson - if hasattr(runner, 'scripts'): - runner.scripts = saved_scripts - except Exception as e: - print(f"[R Guard] Error during postprocess_image blocking: {e}") - return original_postprocess_image(p_arg, pp_arg, *args, **kwargs) - runner.postprocess_image = guarded_postprocess_image - self._log_patch_event("info", "Installed guarded runner.postprocess_image patch") - - runner._ranbooru_guard_installed = True - self._log_patch_event("info", "Installed ScriptRunner guard to skip ADetailer when blocked") - print("[R] Installed ScriptRunner guard to skip ADetailer when blocked (postprocess & postprocess_image)") - except Exception as e: - self._log_patch_event("warning", f"Failed to install ScriptRunner guard: {e}") - print(f"[R] Error installing ScriptRunner guard: {e}") + self._adetailer_orch._install_scriptrunner_guard(p) def _prepare_processing_for_manual_adetailer(self, p, processed, img2img_results): """Ensure p has correct images, sizes, prompts, and save paths before running ADetailer manually""" @@ -7851,6 +5752,8 @@ def _prepare_processing_for_manual_adetailer(self, p, processed, img2img_results try: if not img2img_results: return + # ADetailer 26.x exits early while this initial-pass suppression flag remains set. + self._clear_manual_adetailer_skip_flags(p) # Set init image to the first img2img result first_img = img2img_results[0] try: @@ -7859,549 +5762,87 @@ def _prepare_processing_for_manual_adetailer(self, p, processed, img2img_results pass # Align width/height to the image try: - if hasattr(first_img, 'size'): + if hasattr(first_img, "size"): p.width, p.height = first_img.size except Exception: pass # Restore a meaningful prompt (avoid minimal initial-pass prompt) try: - if hasattr(self, 'original_full_prompt'): + if hasattr(self, "original_full_prompt"): p.prompt = self.original_full_prompt - elif hasattr(processed, 'all_prompts') and processed.all_prompts: + elif hasattr(processed, "all_prompts") and processed.all_prompts: p.prompt = processed.all_prompts[0] except Exception: pass # Ensure saving paths are valid for ADetailer internals try: import modules.shared as shared - outdir = getattr(shared.opts, 'outdir_img2img_samples', None) or getattr(shared.opts, 'outdir_samples', None) or 'outputs/img2img-images' + + outdir = ( + getattr(shared.opts, "outdir_img2img_samples", None) + or getattr(shared.opts, "outdir_samples", None) + or "outputs/img2img-images" + ) if not outdir: - outdir = 'outputs/img2img-images' + outdir = "outputs/img2img-images" p.outpath_samples = outdir # Allow saving final artifacts if extension attempts - if hasattr(p, 'do_not_save_samples'): + if hasattr(p, "do_not_save_samples"): p.do_not_save_samples = False - if hasattr(p, 'do_not_save_grid'): + if hasattr(p, "do_not_save_grid"): p.do_not_save_grid = True - if hasattr(p, 'save_to_dirs'): + if hasattr(p, "save_to_dirs"): p.save_to_dirs = True except Exception: # As a last resort, set a default path try: - p.outpath_samples = 'outputs/img2img-images' + p.outpath_samples = "outputs/img2img-images" except Exception: pass except Exception as e: print(f"[R Post] WARN: Could not fully prepare p for manual ADetailer: {e}") + def _clear_manual_adetailer_skip_flags(self, processing_obj): + """Clear per-request suppression flags before manually invoking ADetailer.""" + if processing_obj is None: + return + for attr in ( + "_ad_disabled", + "_ranbooru_skip_initial_adetailer", + "_ranbooru_suppress_all_processing", + "_ranbooru_initial_pass_only", + ): + try: + setattr(processing_obj, attr, False) + except Exception: + pass + self._adetailer_state.initial_pass_suppressed = False + def _install_preview_guard(self): """Install a guard around shared.state.assign_current_image to block wrong previews""" - try: - import modules.shared as shared - if not hasattr(shared, 'state'): - return - state = shared.state - if getattr(state, '_ranbooru_preview_guard_installed', False): - return - if not hasattr(state, 'assign_current_image'): - return - state._ranbooru_original_assign_current_image = state.assign_current_image - - def guarded_assign_current_image(img): - try: - if getattr(self.__class__, '_ranbooru_preview_guard_on', False): - if getattr(self.__class__, '_ranbooru_preview_block_all', False): - if not getattr(self.__class__, '_ranbooru_preview_block_notice_emitted', False): - print("[R UI] Preview blocked: withholding intermediary frame until final image is ready") - self.__class__._ranbooru_preview_block_notice_emitted = True - return - # If we know final dims, only allow those; otherwise block 640x512 - final_dims = getattr(self.__class__, '_ranbooru_final_dims', None) - if img is not None and hasattr(img, 'size'): - if final_dims and img.size != final_dims: - print("[R UI] Preview blocked: mismatched size") - return - if img.size == (640, 512): - print("[R UI] Preview blocked: 640x512 preview") - return - except Exception: - pass - return state._ranbooru_original_assign_current_image(img) - - state.assign_current_image = guarded_assign_current_image - state._ranbooru_preview_guard_installed = True - print("[R UI] Installed preview guard") - except Exception as e: - print(f"[R UI] Error installing preview guard: {e}") - + self._adetailer_orch._install_preview_guard() + def _set_preview_guard(self, enabled: bool, final_dims=None, block_all: bool = False): try: self.__class__._ranbooru_preview_guard_on = bool(enabled) + self._adetailer_state.preview_guard_on = bool(enabled) if enabled: self.__class__._ranbooru_preview_block_all = bool(block_all) + self._adetailer_state.preview_block_all = bool(block_all) self.__class__._ranbooru_preview_block_notice_emitted = False if final_dims is not None: self.__class__._ranbooru_final_dims = final_dims - elif hasattr(self.__class__, '_ranbooru_final_dims'): - delattr(self.__class__, '_ranbooru_final_dims') + elif hasattr(self.__class__, "_ranbooru_final_dims"): + delattr(self.__class__, "_ranbooru_final_dims") else: self.__class__._ranbooru_preview_block_all = False - if hasattr(self.__class__, '_ranbooru_final_dims'): - delattr(self.__class__, '_ranbooru_final_dims') - if hasattr(self.__class__, '_ranbooru_preview_block_notice_emitted'): - delattr(self.__class__, '_ranbooru_preview_block_notice_emitted') - print(f"[R UI] Preview guard set to {enabled} with dims={final_dims}, block_all={block_all}") + self._adetailer_state.preview_block_all = False + if hasattr(self.__class__, "_ranbooru_final_dims"): + delattr(self.__class__, "_ranbooru_final_dims") + if hasattr(self.__class__, "_ranbooru_preview_block_notice_emitted"): + delattr(self.__class__, "_ranbooru_preview_block_notice_emitted") + print( + f"[R UI] Preview guard set to {enabled} with dims={final_dims}, block_all={block_all}" + ) except Exception as e: print(f"[R UI] Error setting preview guard: {e}") - - def _patch_adetailer_methods_directly(self): - """Directly patch ADetailer class methods to check our block flag""" - try: - # Find all ADetailer script instances and patch their core methods - patched_count = 0 - - # Check sys.modules for ADetailer - import sys - for module_name, module in sys.modules.items(): - if 'adetailer' in module_name.lower(): - try: - # Look for AfterDetailerScript classes - for attr_name in dir(module): - attr = getattr(module, attr_name) - if hasattr(attr, '__name__') and 'adetailer' in attr.__name__.lower(): - # Patch the class methods - if hasattr(attr, 'postprocess_image') and not hasattr(attr, '_ranbooru_original_postprocess_image'): - original_method = attr.postprocess_image - attr._ranbooru_original_postprocess_image = original_method - - def blocked_postprocess_image(self, *args, **kwargs): - from scripts.ranbooru import Script as RanbooruScript - block = getattr(RanbooruScript, '_ranbooru_block_all_adetailer', False) - if block: - print(f"[R Direct Patch] Blocked ADetailer.postprocess_image") - return False - return original_method(self, *args, **kwargs) - - attr.postprocess_image = blocked_postprocess_image - patched_count += 1 - print(f"[R Direct Patch] Patched {attr.__name__}.postprocess_image") - - if hasattr(attr, 'postprocess') and not hasattr(attr, '_ranbooru_original_postprocess'): - original_method = attr.postprocess - attr._ranbooru_original_postprocess = original_method - - def blocked_postprocess(self, *args, **kwargs): - from scripts.ranbooru import Script as RanbooruScript - block = getattr(RanbooruScript, '_ranbooru_block_all_adetailer', False) - if block: - print(f"[R Direct Patch] Blocked ADetailer.postprocess") - return False - return original_method(self, *args, **kwargs) - - attr.postprocess = blocked_postprocess - patched_count += 1 - print(f"[R Direct Patch] Patched {attr.__name__}.postprocess") - except Exception as e: - continue - - print(f"[R Direct Patch] Patched {patched_count} ADetailer methods directly") - - except Exception as e: - print(f"[R Direct Patch] Error patching ADetailer methods: {e}") - - def _install_nuclear_adetailer_hook(self): - """Install a nuclear hook that intercepts ANY script execution to catch ADetailer""" - try: - import modules.scripts - - # Hook into the main script execution method - if not hasattr(modules.scripts, '_ranbooru_nuclear_hook_installed'): - original_run_script = getattr(modules.scripts, 'run_script', None) - if original_run_script: - def nuclear_script_hook(*args, **kwargs): - # Check if this is an ADetailer script - try: - if len(args) > 0: - script = args[0] - if hasattr(script, '__class__') and hasattr(script.__class__, '__name__'): - class_name = script.__class__.__name__ - if 'adetailer' in class_name.lower(): - block = getattr(self.__class__, '_ranbooru_block_all_adetailer', False) - if block: - print(f"[R Nuclear] BLOCKED script execution: {class_name}") - return None - except Exception: - pass - return original_run_script(*args, **kwargs) - - modules.scripts.run_script = nuclear_script_hook - modules.scripts._ranbooru_nuclear_hook_installed = True - print("[R Nuclear] Installed nuclear ADetailer execution hook") - - # Also hook into postprocess_image directly at the modules level - if hasattr(modules.scripts, 'postprocess_image') and not hasattr(modules.scripts, '_ranbooru_nuclear_postprocess_image_hook'): - original_postprocess_image = modules.scripts.postprocess_image - def nuclear_postprocess_image_hook(*args, **kwargs): - block = getattr(self.__class__, '_ranbooru_block_all_adetailer', False) - if block: - print("[R Nuclear] BLOCKED modules.scripts.postprocess_image") - return False - return original_postprocess_image(*args, **kwargs) - - modules.scripts.postprocess_image = nuclear_postprocess_image_hook - modules.scripts._ranbooru_nuclear_postprocess_image_hook = True - print("[R Nuclear] Installed nuclear postprocess_image hook") - - except Exception as e: - print(f"[R Nuclear] Error installing nuclear hook: {e}") - - def _override_all_image_access(self, img2img_results): - """Final solution: Override ALL possible image access methods to force correct images""" - try: - print(f"[R Final] Overriding ALL image access with {len(img2img_results)} img2img results") - - # Store our correct images globally - self.__class__._force_images = img2img_results.copy() - - # Skip PIL.Image.open override as it interferes with normal operations - # print("[R Final] Skipped PIL.Image.open override to prevent interference") - - # Also override any existing processed.images access - import modules.processing - if hasattr(modules.processing, '_current_processed') and modules.processing._current_processed: - current_processed = modules.processing._current_processed - if hasattr(current_processed, 'images') and current_processed.images: - print(f"[R Final] Replacing _current_processed.images ({len(current_processed.images)} -> {len(img2img_results)})") - current_processed.images.clear() - current_processed.images.extend(img2img_results) - - # Override shared.state images - import modules.shared - if hasattr(modules.shared.state, 'current_image'): - print("[R Final] Replacing shared.state.current_image") - modules.shared.state.current_image = img2img_results[0] if img2img_results else None - - print("[R Final] Image access override complete") - - except Exception as e: - print(f"[R Final] Error overriding image access: {e}") - - def _remove_adetailer_from_pipeline_completely(self, p): - """Nuclear option: Remove ADetailer from all processing pipelines""" - try: - print("[R Nuclear] REMOVING ADetailer from processing pipeline completely") - - # Remove from the current processing object - if hasattr(p, 'scripts'): - if hasattr(p.scripts, 'alwayson_scripts'): - original_count = len(p.scripts.alwayson_scripts) - p.scripts.alwayson_scripts = [s for s in p.scripts.alwayson_scripts if not self._is_adetailer_script(s)] - new_count = len(p.scripts.alwayson_scripts) - print(f"[R Nuclear] Removed {original_count - new_count} ADetailer from p.scripts.alwayson_scripts") - - if hasattr(p.scripts, 'scripts'): - original_count = len(p.scripts.scripts) - p.scripts.scripts = [s for s in p.scripts.scripts if not self._is_adetailer_script(s)] - new_count = len(p.scripts.scripts) - print(f"[R Nuclear] Removed {original_count - new_count} ADetailer from p.scripts.scripts") - - # Remove from global script runners - import modules.scripts - for runner_attr in ['scripts_txt2img', 'scripts_img2img']: - if hasattr(modules.scripts, runner_attr): - runner = getattr(modules.scripts, runner_attr) - if hasattr(runner, 'alwayson_scripts'): - original_count = len(runner.alwayson_scripts) - runner.alwayson_scripts = [s for s in runner.alwayson_scripts if not self._is_adetailer_script(s)] - new_count = len(runner.alwayson_scripts) - print(f"[R Nuclear] Removed {original_count - new_count} ADetailer from {runner_attr}.alwayson_scripts") - - if hasattr(runner, 'scripts'): - original_count = len(runner.scripts) - runner.scripts = [s for s in runner.scripts if not self._is_adetailer_script(s)] - new_count = len(runner.scripts) - print(f"[R Nuclear] Removed {original_count - new_count} ADetailer from {runner_attr}.scripts") - - # Remove from script data - if hasattr(modules.scripts, 'scripts_data'): - original_count = len(modules.scripts.scripts_data) - modules.scripts.scripts_data = [s for s in modules.scripts.scripts_data if 'adetailer' not in s.path.lower()] - new_count = len(modules.scripts.scripts_data) - print(f"[R Nuclear] Removed {original_count - new_count} ADetailer from scripts_data") - - print("[R Nuclear] ADetailer completely removed from processing pipeline") - - except Exception as e: - print(f"[R Nuclear] Error removing ADetailer from pipeline: {e}") - import traceback - traceback.print_exc() - - def _install_adetailer_skip_hook(self, p): - """Install a hook that makes ADetailer skip processing if our flag is set""" - try: - print("[R Hook] Installing ADetailer skip hook") - - # First, let's see what ADetailer modules are available - import sys - adetailer_modules = [] - for module_name, module in sys.modules.items(): - if 'adetailer' in module_name.lower(): - adetailer_modules.append(module_name) - - print(f"[R Hook] Found {len(adetailer_modules)} ADetailer modules: {adetailer_modules}") - - # Try to find and patch ADetailer's main processing method - hooked_count = 0 - for module_name, module in sys.modules.items(): - if 'adetailer' in module_name.lower(): - print(f"[R Hook] Examining module: {module_name}") - print(f"[R Hook] Module attributes: {[attr for attr in dir(module) if 'process' in attr.lower()]}") - - # Hook postprocess_image if it exists - if hasattr(module, 'postprocess_image') and not hasattr(module, '_ranbooru_original_postprocess_image'): - original_method = module.postprocess_image - module._ranbooru_original_postprocess_image = original_method - - def hooked_postprocess_image(p_arg, pp_arg, *args, **kwargs): - # Allow manual ADetailer execution - manual_active = getattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - if manual_active: - print("[R Hook] Allowing manual ADetailer postprocess_image execution") - return original_method(p_arg, pp_arg, *args, **kwargs) - - # Check if we've already processed this image - if hasattr(p_arg, '_ranbooru_adetailer_already_processed'): - print("[R Hook] ADetailer postprocess_image skipped - RanbooruX already processed") - return False - print(f"[R Hook] ADetailer postprocess_image running on {getattr(p_arg, 'prompt', 'unknown')[:50]}...") - return original_method(p_arg, pp_arg, *args, **kwargs) - - module.postprocess_image = hooked_postprocess_image - print(f"[R Hook] Installed postprocess_image hook on {module_name}") - hooked_count += 1 - - # Also hook any postprocess method - if hasattr(module, 'postprocess') and not hasattr(module, '_ranbooru_original_postprocess'): - original_method = module.postprocess - module._ranbooru_original_postprocess = original_method - - def hooked_postprocess(p_arg, processed_arg, *args, **kwargs): - # Allow manual ADetailer execution - manual_active = getattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - if manual_active: - print("[R Hook] Allowing manual ADetailer postprocess execution") - return original_method(p_arg, processed_arg, *args, **kwargs) - - # Check if we've already processed this image - if hasattr(p_arg, '_ranbooru_adetailer_already_processed'): - print("[R Hook] ADetailer postprocess skipped - RanbooruX already processed") - return False - print(f"[R Hook] ADetailer postprocess running on {getattr(p_arg, 'prompt', 'unknown')[:50]}...") - return original_method(p_arg, processed_arg, *args, **kwargs) - - module.postprocess = hooked_postprocess - print(f"[R Hook] Installed postprocess hook on {module_name}") - hooked_count += 1 - - # Try to hook ADetailer scripts directly from the script runners - if hasattr(p, 'scripts'): - print("[R Hook] Attempting to hook ADetailer scripts directly...") - if hasattr(p.scripts, 'alwayson_scripts'): - for script in p.scripts.alwayson_scripts: - if self._is_adetailer_script(script): - script_name = script.__class__.__name__ - print(f"[R Hook] Found ADetailer script: {script_name}") - print(f"[R Hook] Script methods: {[attr for attr in dir(script) if 'process' in attr.lower()]}") - - # Wrap all instance methods containing 'process' - try: - def make_inst_wrap(method_name, orig): - def wrapped(p_arg, *args, **kwargs): - # Allow manual ADetailer execution - manual_active = getattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - if manual_active: - print(f"[R Hook] Allowing manual ADetailer {script_name}.{method_name} execution") - return orig(p_arg, *args, **kwargs) - - # Skip if RanbooruX already processed - if hasattr(p_arg, '_ranbooru_adetailer_already_processed'): - print(f"[R Hook] {script_name}.{method_name} skipped - RanbooruX already processed") - return False - return orig(p_arg, *args, **kwargs) - return wrapped - for m in [name for name in dir(script) if 'process' in name.lower()]: - try: - orig = getattr(script, m, None) - if callable(orig) and not hasattr(orig, '_ranbooru_wrapped'): - wrapped = make_inst_wrap(m, orig) - setattr(script, m, wrapped) - setattr(getattr(script, m), '_ranbooru_wrapped', True) - hooked_count += 1 - print(f"[R Hook] Hooked instance method {script_name}.{m}") - except Exception: - pass - except Exception as _e: - print(f"[R Hook] WARN: Could not wrap instance methods: {_e}") - - # Hook the script's postprocess_image method - if hasattr(script, 'postprocess_image') and not hasattr(script, '_ranbooru_original_postprocess_image'): - original_method = script.postprocess_image - script._ranbooru_original_postprocess_image = original_method - - def hooked_script_postprocess_image(p_arg, pp_arg, *args, **kwargs): - # Allow manual ADetailer execution - manual_active = getattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - if manual_active: - print(f"[R Hook] Allowing manual ADetailer {script_name}.postprocess_image execution") - return original_method(p_arg, pp_arg, *args, **kwargs) - - if hasattr(p_arg, '_ranbooru_adetailer_already_processed'): - print(f"[R Hook] {script_name}.postprocess_image skipped - RanbooruX already processed") - return False - print(f"[R Hook] {script_name}.postprocess_image running...") - return original_method(p_arg, pp_arg, *args, **kwargs) - - script.postprocess_image = hooked_script_postprocess_image - print(f"[R Hook] Hooked {script_name}.postprocess_image") - hooked_count += 1 - else: - print(f"[R Hook] Could not hook {script_name}.postprocess_image - method {'exists' if hasattr(script, 'postprocess_image') else 'missing'}, already hooked: {hasattr(script, '_ranbooru_original_postprocess_image')}") - - # Also try to hook postprocess method - if hasattr(script, 'postprocess') and not hasattr(script, '_ranbooru_original_postprocess'): - original_method = script.postprocess - script._ranbooru_original_postprocess = original_method - - def hooked_script_postprocess(p_arg, processed_arg, *args, **kwargs): - # Allow manual ADetailer execution - manual_active = getattr(self.__class__, '_ranbooru_manual_adetailer_active', False) - if manual_active: - print(f"[R Hook] Allowing manual ADetailer {script_name}.postprocess execution") - return original_method(p_arg, processed_arg, *args, **kwargs) - - if hasattr(p_arg, '_ranbooru_adetailer_already_processed'): - print(f"[R Hook] {script_name}.postprocess skipped - RanbooruX already processed") - return False - print(f"[R Hook] {script_name}.postprocess running...") - return original_method(p_arg, processed_arg, *args, **kwargs) - - script.postprocess = hooked_script_postprocess - print(f"[R Hook] Hooked {script_name}.postprocess") - hooked_count += 1 - else: - print(f"[R Hook] Could not hook {script_name}.postprocess - method {'exists' if hasattr(script, 'postprocess') else 'missing'}, already hooked: {hasattr(script, '_ranbooru_original_postprocess')}") - - print(f"[R Hook] ADetailer skip hook installation complete - hooked {hooked_count} methods") - - except Exception as e: - print(f"[R Hook] Error installing ADetailer skip hook: {e}") - import traceback - traceback.print_exc() - - def _suppress_initial_pass_completely(self, p): - """Ultimate method to completely suppress the initial pass from any processing""" - try: - print("[R Suppress] ULTIMATE: Completely suppressing initial pass from all processing") - - # Clear any saved initial images from the processing object - if hasattr(p, '_ranbooru_initial_images'): - p._ranbooru_initial_images.clear() - print("[R Suppress] Cleared initial images from p._ranbooru_initial_images") - - # Try to find and clear any cached initial pass results - import modules.processing - if hasattr(modules.processing, '_current_processed'): - current_processed = modules.processing._current_processed - if current_processed and hasattr(current_processed, 'images'): - # Filter out any 640x512 or other wrong-sized images - original_count = len(current_processed.images) - current_processed.images = [img for img in current_processed.images if img.size != (640, 512) and img.size != (512, 640)] - filtered_count = len(current_processed.images) - if filtered_count != original_count: - print(f"[R Suppress] Filtered out {original_count - filtered_count} wrong-sized images from _current_processed") - - # Set flags to prevent any ADetailer from running on the initial pass - setattr(p, '_ranbooru_suppress_all_adetailer', True) - setattr(p, '_ranbooru_initial_pass_suppressed', True) - - # Try to hook into the postprocess_image function at the global level - try: - import modules.scripts - if hasattr(modules.scripts, 'postprocess_image') and not hasattr(modules.scripts, '_ranbooru_suppress_hook'): - original_postprocess_image = modules.scripts.postprocess_image - modules.scripts._ranbooru_original_postprocess_image = original_postprocess_image - - def suppressed_postprocess_image(p_arg, pp_arg, *args, **kwargs): - # Check if this is the initial pass we want to suppress - if hasattr(p_arg, '_ranbooru_suppress_all_adetailer'): - print("[R Suppress] BLOCKED global postprocess_image - initial pass suppressed") - return False # Don't call the original function and return strict boolean - return original_postprocess_image(p_arg, pp_arg, *args, **kwargs) - - modules.scripts.postprocess_image = suppressed_postprocess_image - modules.scripts._ranbooru_suppress_hook = True - print("[R Suppress] Installed global postprocess_image suppression hook") - except Exception as e: - print(f"[R Suppress] Could not install global hook: {e}") - - print("[R Suppress] Initial pass suppression complete") - - except Exception as e: - print(f"[R Suppress] Error suppressing initial pass: {e}") - import traceback - traceback.print_exc() - - def _completely_remove_adetailer_from_pipeline(self, p): - """Nuclear option: Completely remove ADetailer from all processing pipelines for this generation""" - try: - print("[R Nuclear Pipeline] NUCLEAR: Completely removing ADetailer from processing pipeline") - - # Remove from the current processing object - removed_count = 0 - if hasattr(p, 'scripts'): - if hasattr(p.scripts, 'alwayson_scripts'): - original_count = len(p.scripts.alwayson_scripts) - p.scripts.alwayson_scripts = [s for s in p.scripts.alwayson_scripts if not self._is_adetailer_script(s)] - new_count = len(p.scripts.alwayson_scripts) - removed_count += original_count - new_count - print(f"[R Nuclear Pipeline] Removed {original_count - new_count} ADetailer from p.scripts.alwayson_scripts") - - if hasattr(p.scripts, 'scripts'): - original_count = len(p.scripts.scripts) - p.scripts.scripts = [s for s in p.scripts.scripts if not self._is_adetailer_script(s)] - new_count = len(p.scripts.scripts) - removed_count += original_count - new_count - print(f"[R Nuclear Pipeline] Removed {original_count - new_count} ADetailer from p.scripts.scripts") - - # Remove from global script runners - import modules.scripts - for runner_attr in ['scripts_txt2img', 'scripts_img2img']: - if hasattr(modules.scripts, runner_attr): - runner = getattr(modules.scripts, runner_attr) - if hasattr(runner, 'alwayson_scripts'): - original_count = len(runner.alwayson_scripts) - runner.alwayson_scripts = [s for s in runner.alwayson_scripts if not self._is_adetailer_script(s)] - new_count = len(runner.alwayson_scripts) - removed_count += original_count - new_count - print(f"[R Nuclear Pipeline] Removed {original_count - new_count} ADetailer from {runner_attr}.alwayson_scripts") - - if hasattr(runner, 'scripts'): - original_count = len(runner.scripts) - runner.scripts = [s for s in runner.scripts if not self._is_adetailer_script(s)] - new_count = len(runner.scripts) - removed_count += original_count - new_count - print(f"[R Nuclear Pipeline] Removed {original_count - new_count} ADetailer from {runner_attr}.scripts") - - # Store the removed scripts so we can restore them later - self._removed_adetailer_scripts = [] - - # Mark that we've removed ADetailer for this generation - setattr(p, '_ranbooru_adetailer_removed_from_pipeline', True) - - print(f"[R Nuclear Pipeline] NUCLEAR COMPLETE: Removed {removed_count} ADetailer script instances from processing pipeline") - - except Exception as e: - print(f"[R Nuclear Pipeline] Error removing ADetailer from pipeline: {e}") - import traceback - traceback.print_exc() - diff --git a/tests/conftest.py b/tests/conftest.py index 2b80acb..cdf2a6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -164,6 +164,17 @@ def uninstall_cache(*args, **kwargs): sys.modules["requests_cache"] = requests_cache_mod requests_mod = types.ModuleType("requests") + requests_adapters_mod = types.ModuleType("requests.adapters") + + class _DummyHTTPAdapter: + def __init__(self, *args, **kwargs): + pass + + def init_poolmanager(self, *args, **kwargs): + return None + + def proxy_manager_for(self, *args, **kwargs): + return types.SimpleNamespace() class _DummyResponse: def __init__(self, payload=None): @@ -188,7 +199,10 @@ def _dummy_get(*args, **kwargs): requests_mod.Response = _DummyResponse requests_mod.RequestException = Exception requests_mod.Session = lambda: types.SimpleNamespace(get=_dummy_get, post=_dummy_get) + requests_adapters_mod.HTTPAdapter = _DummyHTTPAdapter + requests_mod.adapters = requests_adapters_mod sys.modules["requests"] = requests_mod + sys.modules["requests.adapters"] = requests_adapters_mod numpy_mod = types.ModuleType("numpy") sys.modules["numpy"] = numpy_mod @@ -225,6 +239,7 @@ def crop(self, *args, **kwargs): "gradio", "requests_cache", "requests", + "requests.adapters", "numpy", "PIL.Image", "PIL", diff --git a/tests/host_snapshot.py b/tests/host_snapshot.py new file mode 100644 index 0000000..323dd27 --- /dev/null +++ b/tests/host_snapshot.py @@ -0,0 +1,38 @@ +def snapshot_host_state(p, script_runner=None, preview_method=None, request_cache=None): + return { + "prompt": getattr(p, "prompt", None), + "negative_prompt": getattr(p, "negative_prompt", None), + "seed": getattr(p, "seed", None), + "batch_size": getattr(p, "batch_size", None), + "steps": getattr(p, "steps", None), + "cfg_scale": getattr(p, "cfg_scale", None), + "do_not_save_grid": getattr(p, "do_not_save_grid", None), + "do_not_save_samples": getattr(p, "do_not_save_samples", None), + "outpath_grids": getattr(p, "outpath_grids", None), + "outpath_samples": getattr(p, "outpath_samples", None), + "script_args": list(getattr(p, "script_args", [])), + "script_runner_scripts": ( + [script.__class__.__name__ for script in getattr(script_runner, "scripts", [])] + if script_runner + else [] + ), + "callback_map": ( + dict(getattr(script_runner, "callback_map", {}) or {}) + if script_runner and isinstance(getattr(script_runner, "callback_map", None), dict) + else None + ), + "preview_method_id": id(preview_method) if preview_method is not None else None, + "request_cache_installed": ( + request_cache.patcher.is_installed() + if ( + request_cache + and hasattr(request_cache, "patcher") + and hasattr(request_cache.patcher, "is_installed") + ) + else False + ), + } + + +def assert_snapshots_equal(left, right): + assert left == right diff --git a/tests/test_adetailer.py b/tests/test_adetailer.py index 51e9e5b..77a6d04 100644 --- a/tests/test_adetailer.py +++ b/tests/test_adetailer.py @@ -9,6 +9,27 @@ def _make_script(): return ranbooru.Script() +class DummyImage: + def __init__(self, token: str, size=(64, 64)): + self.token = token + self.size = size + self.mode = "RGB" + + @property + def width(self): + return self.size[0] + + @property + def height(self): + return self.size[1] + + def tobytes(self): + return self.token.encode("utf-8") + + def convert(self, mode): + return DummyImage(self.token, self.size) + + def test_patch_health_check(): script = _make_script() target = types.SimpleNamespace(process=lambda *args, **kwargs: None) @@ -133,7 +154,7 @@ class AfterDetailerScript: processing = types.SimpleNamespace( script_args=[ False, # global enable from UI (disabled) - True, # skip flag from UI + True, # skip flag from UI { "ad_model": "face_yolov8n.pt", "ad_tab_enable": True, @@ -155,20 +176,26 @@ class AfterDetailerScript: def test_manual_adetailer_requires_controlnet_detection(): script = _make_script() assert script._manual_adetailer_requires_controlnet([]) is False - assert script._manual_adetailer_requires_controlnet( - [ - True, - False, - {"ad_model": "face_yolov8n.pt", "ad_controlnet_model": "None"}, - ] - ) is False - assert script._manual_adetailer_requires_controlnet( - [ - True, - False, - {"ad_model": "face_yolov8n.pt", "ad_controlnet_model": "sargezt_xl_depth"}, - ] - ) is True + assert ( + script._manual_adetailer_requires_controlnet( + [ + True, + False, + {"ad_model": "face_yolov8n.pt", "ad_controlnet_model": "None"}, + ] + ) + is False + ) + assert ( + script._manual_adetailer_requires_controlnet( + [ + True, + False, + {"ad_model": "face_yolov8n.pt", "ad_controlnet_model": "sargezt_xl_depth"}, + ] + ) + is True + ) def test_manual_adetailer_script_isolation_can_keep_controlnet(): @@ -198,7 +225,9 @@ class OtherScript: scripts_mod.scripts_txt2img = runner scripts_mod.scripts_img2img = runner - with script._manual_adetailer_script_isolation(processing, adetailer_script, keep_controlnet=True): + with script._manual_adetailer_script_isolation( + processing, adetailer_script, keep_controlnet=True + ): assert runner.alwayson_scripts == [adetailer_script] assert runner.scripts == [controlnet_script, adetailer_script] @@ -233,7 +262,9 @@ class OtherScript: scripts_mod.scripts_txt2img = runner scripts_mod.scripts_img2img = runner - with script._manual_adetailer_script_isolation(processing, adetailer_script, keep_controlnet=True): + with script._manual_adetailer_script_isolation( + processing, adetailer_script, keep_controlnet=True + ): assert runner.alwayson_scripts == [controlnet_script, adetailer_script] assert runner.scripts == [controlnet_script, adetailer_script] @@ -241,7 +272,7 @@ class OtherScript: assert runner.scripts == [other_script, controlnet_script, adetailer_script] -def test_manual_adetailer_script_isolation_clears_runner_callback_cache(): +def test_manual_adetailer_script_isolation_restores_runner_callback_cache(): script = _make_script() class AfterDetailerScript: @@ -267,7 +298,37 @@ class OtherScript: with script._manual_adetailer_script_isolation(processing, adetailer_script): assert runner.callback_map == {} - assert runner.callback_map == {} + assert runner.callback_map == {"script_process_before_every_sampling": (1, [other_script])} + + +def test_manual_adetailer_script_isolation_preserves_newer_callback_cache(): + script = _make_script() + + class AfterDetailerScript: + pass + + class OtherScript: + pass + + adetailer_script = AfterDetailerScript() + other_script = OtherScript() + third_party_script = OtherScript() + + runner = types.SimpleNamespace( + alwayson_scripts=[adetailer_script, other_script], + scripts=[other_script, adetailer_script], + callback_map={"original": (1, [other_script])}, + ) + processing = types.SimpleNamespace(scripts=runner, prompt="prompt") + + scripts_mod = sys.modules["modules.scripts"] + scripts_mod.scripts_txt2img = runner + scripts_mod.scripts_img2img = runner + + with script._manual_adetailer_script_isolation(processing, adetailer_script): + runner.callback_map = {"third-party": (2, [third_party_script])} + + assert runner.callback_map == {"third-party": (2, [third_party_script])} def test_preview_guard_block_all_hides_intermediate_frames(): @@ -300,3 +361,456 @@ def test_cleanup_after_run_turns_preview_guard_off(): assert getattr(script.__class__, "_ranbooru_preview_guard_on", False) is False assert getattr(script.__class__, "_ranbooru_preview_block_all", False) is False + + +def test_preview_guard_cleanup_preserves_later_callback_replacement(): + script = _make_script() + shared_mod = sys.modules["modules.shared"] + assigned_images = [] + + def original(img): + assigned_images.append(("original", img)) + + def third_party(img): + assigned_images.append(("third-party", img)) + + shared_mod.state.assign_current_image = original + + script._install_preview_guard() + installed_wrapper = shared_mod.state.assign_current_image + assert installed_wrapper is not original + + shared_mod.state.assign_current_image = third_party + script._cleanup_after_run(use_cache=True) + + assert shared_mod.state.assign_current_image is third_party + assert not hasattr(shared_mod.state, "_ranbooru_preview_guard_installed") + assert not hasattr(shared_mod.state, "_ranbooru_preview_guard_wrapper") + + +def test_initial_pass_suppression_sets_flags_and_blocks_runner_guard(): + script = _make_script() + script._adetailer_support_enabled = True + + class AfterDetailerScript: + def __init__(self): + self.postprocess_calls = 0 + self.postprocess_image_calls = 0 + + def postprocess(self, *args, **kwargs): + self.postprocess_calls += 1 + return True + + def postprocess_image(self, *args, **kwargs): + self.postprocess_image_calls += 1 + return True + + class Runner: + def __init__(self, adetailer_script): + self.alwayson_scripts = [adetailer_script] + self.scripts = [adetailer_script] + + def postprocess(self, p, processed, *args, **kwargs): + for script_obj in self.alwayson_scripts: + if hasattr(script_obj, "postprocess"): + script_obj.postprocess(p, processed, *args, **kwargs) + return "ok" + + def postprocess_image(self, p, processed, *args, **kwargs): + for script_obj in self.scripts: + if hasattr(script_obj, "postprocess_image"): + script_obj.postprocess_image(p, processed, *args, **kwargs) + return "ok" + + adetailer_script = AfterDetailerScript() + runner = Runner(adetailer_script) + processing = types.SimpleNamespace( + scripts=runner, + steps=20, + prompt="test prompt", + cfg_scale=7.0, + batch_size=1, + do_not_save_samples=False, + do_not_save_grid=False, + outpath_samples="outputs", + ) + processed = types.SimpleNamespace(images=[DummyImage("base")]) + + scripts_mod = sys.modules["modules.scripts"] + scripts_mod.scripts_txt2img = runner + scripts_mod.scripts_img2img = runner + + script._prepare_img2img_pass(processing, use_img2img=True, use_ip=False) + script._early_adetailer_protection(processing) + + assert getattr(processing, "_ad_disabled", False) is True + assert getattr(processing, "_ranbooru_skip_initial_adetailer", False) is True + assert getattr(processing, "_ranbooru_suppress_all_processing", False) is True + assert getattr(script.__class__, "_ranbooru_block_all_adetailer", False) is True + + runner.postprocess(processing, processed) + runner.postprocess_image(processing, processed) + + assert adetailer_script.postprocess_calls == 0 + assert adetailer_script.postprocess_image_calls == 0 + + +def test_img2img_initial_pass_mutations_restore_processing_object(monkeypatch): + import scripts.ranbooru as ranbooru + + script = _make_script() + processing = types.SimpleNamespace( + steps=30, + prompt="test prompt", + cfg_scale=7.0, + batch_size=4, + do_not_save_samples=False, + do_not_save_grid=False, + outpath_samples="outputs/original", + save_to_dirs=True, + filename_format="[seed]-[prompt]", + save_images_history=True, + save_samples_dir="history", + scripts=types.SimpleNamespace(alwayson_scripts=[], scripts=[]), + ) + shared_opts = types.SimpleNamespace( + save_images="shared-save-images", + outdir_txt2img_samples="shared-txt2img", + ) + ranbooru.shared.opts = shared_opts + + monkeypatch.setattr(script, "_install_preview_guard", lambda: None) + monkeypatch.setattr(script, "_set_preview_guard", lambda *_args, **_kwargs: None) + + script._prepare_img2img_pass(processing, use_img2img=True, use_ip=False) + + assert processing.do_not_save_samples is True + assert processing.do_not_save_grid is True + assert processing.save_to_dirs is False + assert processing.outpath_samples != "outputs/original" + assert processing.filename_format == "" + assert processing.save_images_history is False + assert processing.save_samples_dir is None + assert getattr(processing, "_ranbooru_suppress_all_processing", False) is True + + script._cleanup_after_run(use_cache=True) + + assert processing.steps == 30 + assert processing.cfg_scale == 7.0 + assert processing.batch_size == 4 + assert processing.do_not_save_samples is False + assert processing.do_not_save_grid is False + assert processing.outpath_samples == "outputs/original" + assert processing.save_to_dirs is True + assert processing.filename_format == "[seed]-[prompt]" + assert processing.save_images_history is True + assert processing.save_samples_dir == "history" + assert not hasattr(processing, "_ranbooru_suppress_all_processing") + assert not hasattr(processing, "_ranbooru_initial_pass_only") + assert shared_opts.save_images == "shared-save-images" + assert shared_opts.outdir_txt2img_samples == "shared-txt2img" + assert not hasattr(script, "original_save_images") + assert not hasattr(script, "original_save_grid") + assert not hasattr(script, "original_outpath") + + +def test_guard_blocks_during_suppression(): + script = _make_script() + + class AfterDetailerScript: + def __init__(self): + self.calls = 0 + + def postprocess(self, *args, **kwargs): + self.calls += 1 + + class Runner: + def __init__(self, adetailer_script): + self.alwayson_scripts = [adetailer_script] + self.scripts = [adetailer_script] + + def postprocess(self, p, processed, *args, **kwargs): + for script_obj in self.alwayson_scripts: + if hasattr(script_obj, "postprocess"): + script_obj.postprocess(p, processed, *args, **kwargs) + return "ok" + + def postprocess_image(self, *args, **kwargs): + return "ok" + + adetailer_script = AfterDetailerScript() + runner = Runner(adetailer_script) + processing = types.SimpleNamespace(scripts=runner, prompt="prompt") + + scripts_mod = sys.modules["modules.scripts"] + scripts_mod.scripts_txt2img = runner + scripts_mod.scripts_img2img = runner + + script._install_scriptrunner_guard(processing) + setattr(script.__class__, "_ranbooru_block_all_adetailer", True) + runner.postprocess(processing, types.SimpleNamespace(images=[])) + assert adetailer_script.calls == 0 + + +def test_manual_per_image_execution(): + script = _make_script() + script._adetailer_support_enabled = True + + class AfterDetailerScript: + args_from = 0 + args_to = 3 + + def __init__(self): + self.calls = [] + + def postprocess_image(self, p, temp_processed, *args): + self.calls.append(temp_processed.image.token) + temp_processed.images = [ + DummyImage(f"{temp_processed.image.token}-ad", temp_processed.image.size) + ] + return True + + adetailer_script = AfterDetailerScript() + runner = types.SimpleNamespace( + alwayson_scripts=[adetailer_script], + scripts=[adetailer_script], + ) + processing = types.SimpleNamespace( + scripts=runner, + script_args=[True, False, {"ad_model": "face_yolov8n.pt", "ad_tab_enable": True}], + processed=types.SimpleNamespace(images=[]), + ) + images = [DummyImage("img1"), DummyImage("img2"), DummyImage("img3")] + processed = types.SimpleNamespace( + images=list(images), + prompt="prompt", + negative_prompt="", + seed=1, + subseed=2, + width=64, + height=64, + cfg_scale=7.0, + steps=20, + ) + + ran = script._execute_manual_adetailer(processing, processed, images) + + assert ran is True + assert adetailer_script.calls == ["img1", "img2", "img3"] + assert [img.token for img in processed.images] == ["img1-ad", "img2-ad", "img3-ad"] + + +def test_manual_execution_clears_adetailer_disable_flag(): + script = _make_script() + script._adetailer_support_enabled = True + + class AfterDetailerScript: + args_from = 0 + args_to = 3 + + def postprocess_image(self, p, temp_processed, *args): + if getattr(p, "_ad_disabled", False): + return True + temp_processed.images = [DummyImage(f"{temp_processed.image.token}-ad")] + return True + + adetailer_script = AfterDetailerScript() + runner = types.SimpleNamespace( + alwayson_scripts=[adetailer_script], + scripts=[adetailer_script], + ) + processing = types.SimpleNamespace( + scripts=runner, + script_args=[True, False, {"ad_model": "face_yolov8n.pt", "ad_tab_enable": True}], + processed=types.SimpleNamespace(images=[]), + _ad_disabled=True, + _ranbooru_skip_initial_adetailer=True, + _ranbooru_suppress_all_processing=True, + ) + original = [DummyImage("same-1")] + processed = types.SimpleNamespace( + images=list(original), + prompt="prompt", + negative_prompt="", + seed=1, + subseed=2, + width=64, + height=64, + cfg_scale=7.0, + steps=20, + ) + + ran = script._execute_manual_adetailer(processing, processed, original) + + assert ran is True + assert getattr(processing, "_ad_disabled", False) is False + assert getattr(processing, "_ranbooru_skip_initial_adetailer", False) is False + assert [img.token for img in processed.images] == ["same-1-ad"] + + +def test_unchanged_image_noop(): + script = _make_script() + script._adetailer_support_enabled = True + + class AfterDetailerScript: + args_from = 0 + args_to = 3 + + def postprocess_image(self, p, temp_processed, *args): + temp_processed.images = [temp_processed.image] + return True + + adetailer_script = AfterDetailerScript() + runner = types.SimpleNamespace( + alwayson_scripts=[adetailer_script], + scripts=[adetailer_script], + ) + processing = types.SimpleNamespace( + scripts=runner, + script_args=[True, False, {"ad_model": "face_yolov8n.pt", "ad_tab_enable": True}], + processed=types.SimpleNamespace(images=[]), + ) + original = [DummyImage("same-1"), DummyImage("same-2")] + processed = types.SimpleNamespace( + images=list(original), + prompt="prompt", + negative_prompt="", + seed=1, + subseed=2, + width=64, + height=64, + cfg_scale=7.0, + steps=20, + ) + + ran = script._execute_manual_adetailer(processing, processed, original) + + assert ran is False + assert [img.token for img in processed.images] == ["same-1", "same-2"] + + +def test_failure_path_cleanup(monkeypatch): + import scripts.ranbooru as ranbooru + + script = ranbooru.Script() + script._post_enabled = True + script._post_use_img2img = True + script._post_use_last_img = False + script._post_crop_center = False + script._post_use_cache = True + script._post_adetailer_enabled = True + script._adetailer_support_enabled = True + script.run_img2img_pass = True + script.real_steps = 10 + script.last_img = [DummyImage("base")] + script._img2img_final_outpath_samples = "outputs" + script._img2img_final_batch_size = 1 + + class AfterDetailerScript: + pass + + class Runner: + def __init__(self): + self.alwayson_scripts = [AfterDetailerScript()] + self.scripts = [AfterDetailerScript()] + + def postprocess(self, *args, **kwargs): + return "postprocess" + + def postprocess_image(self, *args, **kwargs): + return "postprocess_image" + + runner = Runner() + processing = types.SimpleNamespace( + scripts=runner, + width=64, + height=64, + sampler_name="Euler", + cfg_scale=7.0, + prompt="prompt", + ) + processed = types.SimpleNamespace( + images=[DummyImage("base")], + prompt="prompt", + negative_prompt="", + seed=11, + subseed=22, + infotexts=["info"], + all_prompts=[], + all_negative_prompts=[], + all_seeds=[], + all_subseeds=[], + ) + processing.processed = types.SimpleNamespace(images=[DummyImage("base")]) + + scripts_mod = sys.modules["modules.scripts"] + scripts_mod.scripts_txt2img = runner + scripts_mod.scripts_img2img = runner + + pre_guard_postprocess = runner.postprocess + pre_guard_postprocess_image = runner.postprocess_image + script._install_scriptrunner_guard(processing) + + class DummyImg2Img: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + monkeypatch.setattr(ranbooru, "StableDiffusionProcessingImg2Img", DummyImg2Img) + monkeypatch.setattr( + ranbooru, + "process_images", + lambda proc: types.SimpleNamespace( + images=[proc.init_images[0]], + infotexts=["info"], + seed=getattr(proc, "seed", 0), + subseed=getattr(proc, "subseed", 0), + ), + ) + monkeypatch.setattr(ranbooru.rb_image_ops, "resize_image", lambda img, *_args, **_kwargs: img) + monkeypatch.setattr(script, "_force_ui_update", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + script, "_prepare_processing_for_manual_adetailer", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr( + script, + "_execute_manual_adetailer", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + ranbooru.shared.sd_model = object() + ranbooru.shared.opts = types.SimpleNamespace( + outdir_samples="outputs", + outdir_img2img_samples="outputs", + outdir_grids="outputs", + outdir_img2img_grids="outputs", + ) + + script.postprocess(processing, processed) + + assert runner.postprocess == pre_guard_postprocess + assert runner.postprocess_image == pre_guard_postprocess_image + + +def test_no_state_leakage(): + script = _make_script() + p = types.SimpleNamespace() + + setattr(script, "_ranbooru_processing_complete", True) + setattr(script, "_ranbooru_intermediate_results", True) + setattr(script, "_native_adetailer_fallback_used", True) + setattr(script.__class__, "_ranbooru_block_all_adetailer", True) + setattr(script.__class__, "_adetailer_global_guard_active", True) + setattr(script.__class__, "_ranbooru_manual_adetailer_active", True) + script._set_preview_guard(True, final_dims=(64, 64), block_all=True) + + script._reset_adetailer_state_for_run(p) + script._cleanup_after_run(use_cache=True) + + assert not hasattr(script, "_ranbooru_processing_complete") + assert not hasattr(script, "_ranbooru_intermediate_results") + assert not hasattr(script, "_native_adetailer_fallback_used") + assert getattr(script.__class__, "_ranbooru_block_all_adetailer", False) is False + assert getattr(script.__class__, "_adetailer_global_guard_active", False) is False + assert getattr(script.__class__, "_ranbooru_manual_adetailer_active", False) is False + assert getattr(script.__class__, "_ranbooru_preview_guard_on", False) is False + assert getattr(script.__class__, "_ranbooru_preview_block_all", False) is False diff --git a/tests/test_adetailer_runtime.py b/tests/test_adetailer_runtime.py new file mode 100644 index 0000000..a0c0b15 --- /dev/null +++ b/tests/test_adetailer_runtime.py @@ -0,0 +1,350 @@ +import types +from contextlib import nullcontext + +from ranboorux.integrations import adetailer_runtime + + +class DummyImage: + def __init__(self, token: str, size=(64, 64)): + self.token = token + self.size = size + + def tobytes(self): + return self.token.encode("utf-8") + + +def _extract_args(*_args, **_kwargs): + return {"args": [True, False, {"ad_model": "face_yolov8n.pt", "ad_tab_enable": True}]} + + +def _build_processed(image): + return types.SimpleNamespace(images=[image], image=image) + + +def test_state_reset(): + state = adetailer_runtime.AdetailerRunState( + block_all=True, + manual_active=True, + initial_pass_suppressed=True, + processing_complete=True, + preview_guard_on=True, + preview_block_all=True, + global_guard_active=True, + pipeline_blocked=True, + ) + + assert state.is_blocked() is True + state.reset() + + assert state.block_all is False + assert state.manual_active is False + assert state.initial_pass_suppressed is False + assert state.processing_complete is False + assert state.preview_guard_on is False + assert state.preview_block_all is False + assert state.global_guard_active is False + assert state.pipeline_blocked is False + assert state.is_blocked() is False + + +def test_patch_registry_install_and_uninstall(): + class Target: + def method(self): + return "original" + + target = Target() + registry = adetailer_runtime.PatchRegistry() + + def replacement(): + return "patched" + + registry.install(target, "method", replacement, "unit-test patch") + assert target.method() == "patched" + assert registry.is_empty() is False + + registry.uninstall_all() + assert target.method() == "original" + assert registry.is_empty() is True + + +def test_patch_registry_uninstall_is_idempotent(): + target = types.SimpleNamespace(method=lambda: "ok") + registry = adetailer_runtime.PatchRegistry() + registry.install(target, "method", lambda: "patched", "idempotent-test") + + registry.uninstall_all() + registry.uninstall_all() + + assert target.method() == "ok" + assert registry.is_empty() is True + + +def test_patch_registry_reports_restore_errors_once(): + class Target: + block_restore = False + + def method(self): + return "original" + + def __setattr__(self, name, value): + if name == "method" and self.block_restore: + raise RuntimeError("restore blocked") + super().__setattr__(name, value) + + target = Target() + registry = adetailer_runtime.PatchRegistry() + registry.install(target, "method", lambda: "patched", "restore-error-test") + target.block_restore = True + + errors = registry.uninstall_all() + + assert errors == ["restore-error-test: restore blocked"] + assert registry.uninstall_all() == [] + + +def test_patch_registry_preserves_later_third_party_patch(): + target = types.SimpleNamespace(method=lambda: "original") + registry = adetailer_runtime.PatchRegistry() + + def ranbooru_patch(): + return "ranbooru" + + def third_party(): + return "third-party" + + registry.install(target, "method", ranbooru_patch, "ownership-test") + target.method = third_party + + assert registry.uninstall_all() == [] + assert target.method is third_party + assert target.method() == "third-party" + + +def test_runner_snapshot_roundtrip(): + runner = types.SimpleNamespace( + alwayson_scripts=["a", "b"], + scripts=["x", "y"], + callback_map={"k": "v"}, + ) + + snapshot = adetailer_runtime.RunnerSnapshot.capture(runner) + + runner.alwayson_scripts = ["changed"] + runner.scripts = [] + runner.callback_map = {"changed": True} + + snapshot.restore(runner) + + assert runner.alwayson_scripts == ["a", "b"] + assert runner.scripts == ["x", "y"] + assert runner.callback_map == {"k": "v"} + + +def test_runner_snapshot_preserves_later_runner_changes(): + runner = types.SimpleNamespace( + alwayson_scripts=["ranbooru-filtered"], + scripts=["ranbooru-filtered"], + callback_map={}, + ) + snapshot = adetailer_runtime.RunnerSnapshot( + alwayson_scripts=["original"], + scripts=["original"], + callback_map={"original": True}, + ) + + runner.alwayson_scripts = ["third-party"] + runner.scripts = ["third-party"] + runner.callback_map = {"third-party": True} + + snapshot.restore( + runner, + expected_alwayson_scripts=["ranbooru-filtered"], + expected_scripts=["ranbooru-filtered"], + expected_callback_map={}, + ) + + assert runner.alwayson_scripts == ["third-party"] + assert runner.scripts == ["third-party"] + assert runner.callback_map == {"third-party": True} + + +def test_runner_snapshot_restores_owned_callback_map(): + runner = types.SimpleNamespace( + alwayson_scripts=["isolated"], + scripts=["isolated"], + callback_map={}, + ) + snapshot = adetailer_runtime.RunnerSnapshot( + alwayson_scripts=["original"], + scripts=["original"], + callback_map={"original": True}, + ) + + snapshot.restore( + runner, + expected_alwayson_scripts=["isolated"], + expected_scripts=["isolated"], + expected_callback_map={}, + ) + + assert runner.alwayson_scripts == ["original"] + assert runner.scripts == ["original"] + assert runner.callback_map == {"original": True} + + +def test_runner_isolation_restores_owned_callback_map(): + class AfterDetailerScript: + pass + + class OtherScript: + pass + + adetailer_script = AfterDetailerScript() + other_script = OtherScript() + runner = types.SimpleNamespace( + alwayson_scripts=[adetailer_script, other_script], + scripts=[other_script, adetailer_script], + callback_map={"original": (1, [other_script])}, + ) + + with adetailer_runtime.runner_isolation(runner, adetailer_script): + assert runner.callback_map == {} + + assert runner.callback_map == {"original": (1, [other_script])} + + +def test_runner_isolation_preserves_later_callback_map(): + class AfterDetailerScript: + pass + + class OtherScript: + pass + + adetailer_script = AfterDetailerScript() + other_script = OtherScript() + third_party_script = OtherScript() + runner = types.SimpleNamespace( + alwayson_scripts=[adetailer_script, other_script], + scripts=[other_script, adetailer_script], + callback_map={"original": (1, [other_script])}, + ) + + with adetailer_runtime.runner_isolation(runner, adetailer_script): + runner.callback_map = {"third-party": (2, [third_party_script])} + + assert runner.callback_map == {"third-party": (2, [third_party_script])} + + +def test_runner_guard_restores_owned_callback_map(): + class AfterDetailerScript: + def __init__(self): + self.calls = 0 + + def postprocess(self, *_args, **_kwargs): + self.calls += 1 + + class Runner: + def __init__(self, adetailer_script): + self.alwayson_scripts = [adetailer_script] + self.scripts = [adetailer_script] + self.callback_map = {"original": (1, [adetailer_script])} + + def postprocess(self, *_args, **_kwargs): + return "ok" + + adetailer_script = AfterDetailerScript() + runner = Runner(adetailer_script) + registry = adetailer_runtime.PatchRegistry() + adetailer_runtime.install_runner_guard(runner, lambda: True, registry) + + runner.postprocess() + + assert runner.callback_map == {"original": (1, [adetailer_script])} + + +def test_execute_manual_adetailer_counts_changed_image(): + class AfterDetailerScript: + def postprocess_image(self, _p, temp_processed, *_args): + temp_processed.images = [DummyImage(f"{temp_processed.image.token}-ad")] + + state = adetailer_runtime.AdetailerRunState() + result = adetailer_runtime.execute_manual_adetailer( + adetailer_scripts=[AfterDetailerScript()], + images=[DummyImage("img-1")], + processing_obj=types.SimpleNamespace(), + run_state=state, + patch_registry=adetailer_runtime.PatchRegistry(), + extract_script_args=_extract_args, + build_processed=_build_processed, + isolation_factory=lambda _script: nullcontext(), + ) + + assert result.successful_processes == 1 + assert result.images[0].token == "img-1-ad" + assert state.manual_active is False + + +def test_execute_manual_adetailer_treats_unchanged_as_noop(): + class AfterDetailerScript: + def postprocess_image(self, _p, temp_processed, *_args): + temp_processed.images = [temp_processed.image] + + result = adetailer_runtime.execute_manual_adetailer( + adetailer_scripts=[AfterDetailerScript()], + images=[DummyImage("img-1")], + processing_obj=types.SimpleNamespace(), + run_state=adetailer_runtime.AdetailerRunState(), + patch_registry=adetailer_runtime.PatchRegistry(), + extract_script_args=_extract_args, + build_processed=_build_processed, + ) + + assert result.successful_processes == 0 + assert result.images[0].token == "img-1" + + +def test_execute_manual_adetailer_batch_counts_only_changed_images(): + class AfterDetailerScript: + def postprocess_image(self, _p, temp_processed, *_args): + token = temp_processed.image.token + if token == "img-2": + temp_processed.images = [temp_processed.image] + else: + temp_processed.images = [DummyImage(f"{token}-ad")] + + result = adetailer_runtime.execute_manual_adetailer( + adetailer_scripts=[AfterDetailerScript()], + images=[DummyImage("img-1"), DummyImage("img-2"), DummyImage("img-3")], + processing_obj=types.SimpleNamespace(), + run_state=adetailer_runtime.AdetailerRunState(), + patch_registry=adetailer_runtime.PatchRegistry(), + extract_script_args=_extract_args, + build_processed=_build_processed, + ) + + assert result.successful_processes == 2 + assert [img.token for img in result.images] == ["img-1-ad", "img-2", "img-3-ad"] + + +def test_execute_manual_adetailer_continues_after_single_image_exception(): + class AfterDetailerScript: + def postprocess_image(self, _p, temp_processed, *_args): + if temp_processed.image.token == "img-2": + raise RuntimeError("simulated failure") + temp_processed.images = [DummyImage(f"{temp_processed.image.token}-ad")] + + state = adetailer_runtime.AdetailerRunState() + result = adetailer_runtime.execute_manual_adetailer( + adetailer_scripts=[AfterDetailerScript()], + images=[DummyImage("img-1"), DummyImage("img-2"), DummyImage("img-3")], + processing_obj=types.SimpleNamespace(), + run_state=state, + patch_registry=adetailer_runtime.PatchRegistry(), + extract_script_args=_extract_args, + build_processed=_build_processed, + ) + + assert result.successful_processes == 2 + assert [img.token for img in result.images] == ["img-1-ad", "img-2", "img-3-ad"] + assert result.errors == ["AfterDetailerScript image 2: simulated failure"] + assert state.manual_active is False diff --git a/tests/test_controlnet.py b/tests/test_controlnet.py index a100558..8aa056d 100644 --- a/tests/test_controlnet.py +++ b/tests/test_controlnet.py @@ -28,14 +28,85 @@ def fake_import(name): def test_load_external_code_failure(monkeypatch): script = _make_script() import importlib + import os monkeypatch.setattr( importlib, "import_module", lambda name: (_ for _ in ()).throw(ImportError(name)) ) - monkeypatch.setattr("os.path.isfile", lambda path: False) + monkeypatch.setattr(os.path, "isfile", lambda path: False) raised = False try: script._load_cn_external_code() except ImportError: raised = True assert raised + + +def test_load_external_code_failure_does_not_leak_local_paths(monkeypatch): + script = _make_script() + import importlib + import os + + private_path = "E:" + "\\private\\sd-webui\\extensions\\sd_forge_controlnet" + monkeypatch.setenv("SD_FORGE_CONTROLNET_PATH", private_path) + monkeypatch.setattr( + importlib, "import_module", lambda name: (_ for _ in ()).throw(ImportError(name)) + ) + monkeypatch.setattr(os.path, "isfile", lambda path: False) + + try: + script._load_cn_external_code() + except ImportError as exc: + message = str(exc) + else: + raise AssertionError("expected ControlNet import failure") + + assert private_path not in message + assert "file://" not in message + assert "configured ControlNet external_code.py not found" in message + + +def test_load_external_code_redacts_all_path_types_and_secrets(monkeypatch): + script = _make_script() + import importlib + import os + + windows_path = "E:" + "\\private\\forge\\extensions\\sd_forge_controlnet" + posix_path = "/home/user/forge/extensions/sd-webui-controlnet" + unc_path = "\\\\server\\share\\path\\to\\extensions" + file_path = "file:" + "///C:/Users/fanph/secret_extension" + signed_url = "https://cdn.test/foo?sig=secret123&x-amz-signature=amzsecret" + + err_msg = ( + f"Failed loading from {windows_path} and {posix_path} and {unc_path} " + f"and {file_path} with signed URL {signed_url}" + ) + + monkeypatch.setattr( + importlib, + "import_module", + lambda name: (_ for _ in ()).throw(ImportError(err_msg)), + ) + monkeypatch.setattr(os.path, "isfile", lambda path: False) + + try: + script._load_cn_external_code() + except ImportError as exc: + message = str(exc) + else: + raise AssertionError("expected ControlNet import failure") + + assert windows_path not in message + assert posix_path not in message + assert unc_path not in message + assert file_path not in message + assert "secret123" not in message + assert "amzsecret" not in message + + diagnostics = script._render_platform_diagnostics() + assert windows_path not in diagnostics + assert posix_path not in diagnostics + assert unc_path not in diagnostics + assert file_path not in diagnostics + assert "secret123" not in diagnostics + assert "amzsecret" not in diagnostics diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..e45360c --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,127 @@ +from host_snapshot import assert_snapshots_equal, snapshot_host_state + + +class MockScriptRunner: + def __init__(self, scripts): + self.scripts = scripts + self.callback_map = {"on_after": object()} + + +class MockP: + def __init__(self): + self.prompt = "masterpiece, 1girl" + self.negative_prompt = "low quality" + self.seed = 42 + self.batch_size = 1 + self.steps = 20 + self.cfg_scale = 7.0 + self.do_not_save_grid = False + self.do_not_save_samples = False + self.outpath_grids = "/tmp/grids" + self.outpath_samples = "/tmp/samples" + self.script_args = [1, 2, 3] + + +class MockRequestCache: + def __init__(self, installed=False): + class DummyPatcher: + def __init__(self, is_installed): + self._installed = is_installed + + def is_installed(self): + return self._installed + + self.patcher = DummyPatcher(installed) + + +def test_snapshot_identical(): + p = MockP() + runner = MockScriptRunner([]) + + def dummy_preview(): + return None + + cache = MockRequestCache() + + snap1 = snapshot_host_state(p, runner, dummy_preview, cache) + snap2 = snapshot_host_state(p, runner, dummy_preview, cache) + + assert_snapshots_equal(snap1, snap2) + + +def test_snapshot_detects_changed_attribute(): + p = MockP() + runner = MockScriptRunner([]) + + def dummy_preview(): + return None + + cache = MockRequestCache() + + snap1 = snapshot_host_state(p, runner, dummy_preview, cache) + p.prompt = "new prompt" + p.steps = 50 + p.do_not_save_grid = True + snap2 = snapshot_host_state(p, runner, dummy_preview, cache) + + assert snap1 != snap2 + assert snap1["prompt"] == "masterpiece, 1girl" + assert snap2["prompt"] == "new prompt" + assert snap1["steps"] == 20 + assert snap2["steps"] == 50 + assert snap1["do_not_save_grid"] is False + assert snap2["do_not_save_grid"] is True + + +def test_snapshot_detects_changed_preview_identity(): + p = MockP() + runner = MockScriptRunner([]) + + def preview1(): + return None + + def preview2(): + return None + + cache = MockRequestCache() + + snap1 = snapshot_host_state(p, runner, preview1, cache) + snap2 = snapshot_host_state(p, runner, preview2, cache) + + assert snap1 != snap2 + assert snap1["preview_method_id"] != snap2["preview_method_id"] + + +def test_snapshot_detects_changed_runner_scripts_and_callbacks(): + p = MockP() + + class ScriptA: + pass + + class ScriptB: + pass + + runner = MockScriptRunner([ScriptA()]) + snap1 = snapshot_host_state(p, runner) + + runner.scripts.append(ScriptB()) + runner.callback_map = {"changed": True} + snap2 = snapshot_host_state(p, runner) + + assert snap1 != snap2 + assert snap1["script_runner_scripts"] == ["ScriptA"] + assert snap2["script_runner_scripts"] == ["ScriptA", "ScriptB"] + assert snap1["callback_map"] != snap2["callback_map"] + + +def test_snapshot_detects_changed_request_cache_state(): + p = MockP() + cache1 = MockRequestCache(installed=False) + cache2 = MockRequestCache(installed=True) + + snap1 = snapshot_host_state(p, request_cache=cache1) + snap2 = snapshot_host_state(p, request_cache=cache2) + + assert snap1 != snap2 + assert snap1["request_cache_installed"] is False + assert snap2["request_cache_installed"] is True diff --git a/tests/test_host_state.py b/tests/test_host_state.py new file mode 100644 index 0000000..b0ec2b2 --- /dev/null +++ b/tests/test_host_state.py @@ -0,0 +1,59 @@ +import types + +from ranboorux.mutation_scope import HostMutationScope, RunContext + + +def test_host_mutation_scope_restores_changed_and_missing_attrs(): + target = types.SimpleNamespace(existing="before") + scope = HostMutationScope() + + scope.set_attr(target, "existing", "after") + scope.set_attr(target, "new_attr", 123) + + assert target.existing == "after" + assert target.new_attr == 123 + + scope.restore() + scope.restore() + + assert target.existing == "before" + assert not hasattr(target, "new_attr") + + +def test_host_mutation_scope_patch_restore_is_ownership_safe(): + target = types.SimpleNamespace(callback=lambda: "original") + original = target.callback + scope = HostMutationScope() + + def replacement(): + return "ranbooru" + + def third_party(): + return "third-party" + + scope.patch_attr(target, "callback", replacement) + target.callback = third_party + + scope.restore() + + assert target.callback is third_party + assert target.callback() == "third-party" + assert original() == "original" + + +def test_run_context_removes_owned_temp_paths(tmp_path): + owned_dir = tmp_path / "owned" + owned_dir.mkdir() + (owned_dir / "file.txt").write_text("data", encoding="utf-8") + owned_file = tmp_path / "owned.txt" + owned_file.write_text("data", encoding="utf-8") + + context = RunContext() + context.own_temp_path(str(owned_dir)) + context.own_temp_path(str(owned_file)) + + context.cleanup() + + assert not owned_dir.exists() + assert not owned_file.exists() + assert context.cleanup_errors == [] diff --git a/tests/test_img2img_lifecycle.py b/tests/test_img2img_lifecycle.py new file mode 100644 index 0000000..c741eac --- /dev/null +++ b/tests/test_img2img_lifecycle.py @@ -0,0 +1,56 @@ +import types + +from ranboorux.integrations.img2img_lifecycle import repeat_to_length, replace_processed_results + + +def test_repeat_to_length_repeats_lists_and_scalars(): + assert repeat_to_length("prompt", 3) == ["prompt", "prompt", "prompt"] + assert repeat_to_length(["a", "b"], 5) == ["a", "b", "a", "b", "a"] + assert repeat_to_length([], 2) == [None, None] + assert repeat_to_length(["only"], 0) == [] + + +def test_replace_processed_results_preserves_existing_list_objects(): + images = ["old"] + infotexts = ["old info"] + all_prompts = ["old prompt"] + cached_images = ["old cached"] + processed = types.SimpleNamespace( + images=images, + infotexts=infotexts, + all_prompts=all_prompts, + all_negative_prompts=[], + all_seeds=[], + all_subseeds=[], + cached_images=cached_images, + ) + + replace_processed_results( + processed, + images=["img1", "img2"], + prompts=["prompt1", "prompt2"], + negative_prompts=["neg1", "neg2"], + infotexts=["info1", "info2"], + seed=10, + subseed=20, + width=64, + height=96, + ) + + assert processed.images is images + assert processed.images == ["img1", "img2"] + assert processed.infotexts is infotexts + assert processed.infotexts == ["info1", "info2"] + assert processed.prompt == ["prompt1", "prompt2"] + assert processed.negative_prompt == ["neg1", "neg2"] + assert processed.seed == 10 + assert processed.subseed == 20 + assert processed.width == 64 + assert processed.height == 96 + assert processed.all_prompts is all_prompts + assert processed.all_prompts == ["prompt1", "prompt2"] + assert processed.all_negative_prompts == ["neg1", "neg2"] + assert processed.all_seeds == [10, 11] + assert processed.all_subseeds == [20, 21] + assert processed.cached_images is cached_images + assert processed.cached_images == ["img1", "img2"] diff --git a/tests/test_lifecycle_contract.py b/tests/test_lifecycle_contract.py new file mode 100644 index 0000000..8578a1c --- /dev/null +++ b/tests/test_lifecycle_contract.py @@ -0,0 +1,216 @@ +import types + +from ranboorux.run_options import UI_ARGUMENT_FIELDS + + +def _args(**overrides): + defaults = { + "enabled": False, + "tags": "1girl", + "booru": "danbooru", + "gelbooru_api_key": "", + "gelbooru_user_id": "", + "gelbooru_compat_base_url": "", + "remove_bad_tags": True, + "max_pages": 1, + "change_dash": False, + "same_prompt": False, + "fringe_benefits": True, + "remove_tags": "", + "use_img2img": False, + "denoising": 0.75, + "use_last_img": False, + "change_background": "Don't Change", + "change_color": "Don't Change", + "shuffle_tags": False, + "post_id": "", + "mix_prompt": False, + "mix_amount": 2, + "chaos_mode": "None", + "chaos_amount": 0.5, + "limit_tags": 1.0, + "max_tags": 0, + "sorting_order": "Random", + "mature_rating": "All", + "lora_folder": "", + "lora_amount": 1, + "lora_min": 0.6, + "lora_max": 1.0, + "lora_enabled": False, + "lora_custom_weights": "", + "lora_lock_prev": False, + "use_ip": False, + "use_search_txt": False, + "use_remove_txt": False, + "choose_search_txt": "", + "choose_remove_txt": "", + "search_refresh_btn": None, + "remove_refresh_btn": None, + "crop_center": False, + "enable_adetailer_support": False, + "use_same_seed": False, + "reuse_cached_posts": False, + "use_cache": False, + "log_prompt_sources": False, + "remove_artist_tags": False, + "remove_character_tags": False, + "remove_clothing_tags": False, + "remove_text_tags": False, + "restrict_subject_tags": False, + "remove_furry_tags": False, + "remove_headwear_tags": False, + "remove_girl_suffix_tags": False, + "preserve_hair_eye_colors": False, + "remove_series_tags": False, + "use_tag_catalog": True, + "catalog_path": "", + "lora_auto_detect_pony": True, + "lora_detected_loras": [], + "lora_blacklist": [], + } + defaults.update(overrides) + return [defaults[field] for field in UI_ARGUMENT_FIELDS] + + +def _processing(): + return types.SimpleNamespace( + prompt="base_prompt", + negative_prompt="", + seed=10, + subseed=20, + n_iter=1, + batch_size=1, + steps=30, + cfg_scale=7.0, + width=64, + height=64, + script_args=[], + scripts=types.SimpleNamespace(alwayson_scripts=[], scripts=[]), + ) + + +def test_disabled_run_releases_processing_guards(stub_modules): + import scripts.ranbooru as ranbooru + + script = ranbooru.Script() + p = _processing() + + script.before_process(p, *_args(enabled=False)) + + assert getattr(script.__class__, "_ranbooru_global_processing", False) is False + assert not hasattr(script, "_current_processing_key") + + +def test_tags_only_run_updates_prompt_without_img2img(monkeypatch, stub_modules): + import scripts.ranbooru as ranbooru + + script = ranbooru.Script() + p = _processing() + + class FakeApi: + booru_name = "Danbooru" + headers = {} + + def get_posts(self, **_kwargs): + return [{"id": 1, "tags": "1girl blonde_hair", "file_url": "https://img.test/a.png"}] + + monkeypatch.setattr(script, "_get_booru_api", lambda *_args, **_kwargs: FakeApi()) + + script.before_process(p, *_args(enabled=True, use_img2img=False, use_ip=False)) + processed = types.SimpleNamespace(images=["txt2img"], seed=10, subseed=20) + script.postprocess(p, processed) + + assert "base_prompt" in p.prompt + assert "1girl" in p.prompt + assert getattr(script.__class__, "_ranbooru_global_processing", False) is False + + +def test_failed_fetch_releases_processing_guards(monkeypatch, stub_modules): + import scripts.ranbooru as ranbooru + + script = ranbooru.Script() + p = _processing() + + class FailingApi: + booru_name = "Danbooru" + headers = {} + + def get_posts(self, **_kwargs): + raise ranbooru.BooruError("boom") + + monkeypatch.setattr(script, "_get_booru_api", lambda *_args, **_kwargs: FailingApi()) + + script.before_process(p, *_args(enabled=True)) + + assert getattr(script.__class__, "_ranbooru_global_processing", False) is False + assert not hasattr(script, "_current_processing_key") + assert not hasattr(p, "_ranbooru_already_processing") + + +def test_argument_parse_failure_releases_processing_guards(stub_modules): + import scripts.ranbooru as ranbooru + + script = ranbooru.Script() + p = _processing() + + script.before_process(p, *([None] * (len(UI_ARGUMENT_FIELDS) - 1))) + + assert getattr(script.__class__, "_ranbooru_global_processing", False) is False + assert not hasattr(script, "_current_processing_key") + assert not hasattr(p, "_ranbooru_already_processing") + + +def test_booru_error_redacts_credential_url(stub_modules): + import scripts.ranbooru as ranbooru + from ranboorux.boorus import Booru + + secret_url = "https://site.test/api?api_key=secret&user_id=123&tags=1girl" + + class FakeHttp: + def get_json(self, *_args, **_kwargs): + raise RuntimeError(f"boom while fetching {secret_url}") + + booru = Booru("Gelbooru", "https://site.test") + booru.http = FakeHttp() + + try: + booru._fetch_data(secret_url) + except ranbooru.BooruError as exc: + message = str(exc) + else: + raise AssertionError("expected BooruError") + + assert "secret" not in message + assert "123" not in message + assert "api_key=" in message + + +def test_sequential_jobs_do_not_reuse_previous_prompt(monkeypatch, stub_modules): + import scripts.ranbooru as ranbooru + + script = ranbooru.Script() + + class FakeApi: + booru_name = "Danbooru" + headers = {} + + def __init__(self, tag): + self.tag = tag + + def get_posts(self, **_kwargs): + return [{"id": 1, "tags": self.tag, "file_url": "https://img.test/a.png"}] + + tags = iter(["first_tag", "second_tag"]) + monkeypatch.setattr(script, "_get_booru_api", lambda *_args, **_kwargs: FakeApi(next(tags))) + + first = _processing() + script.before_process(first, *_args(enabled=True)) + script.postprocess(first, types.SimpleNamespace(images=["a"], seed=10, subseed=20)) + + second = _processing() + script.before_process(second, *_args(enabled=True)) + script.postprocess(second, types.SimpleNamespace(images=["b"], seed=10, subseed=20)) + + assert "first_tag" in first.prompt + assert "second_tag" in second.prompt + assert "first_tag" not in second.prompt diff --git a/tests/test_loranado.py b/tests/test_loranado.py new file mode 100644 index 0000000..1953bb8 --- /dev/null +++ b/tests/test_loranado.py @@ -0,0 +1,91 @@ +import random + +from ranboorux.loranado import ( + filter_candidates, + format_lora_prompt, + normalize_lora_name, + parse_custom_weights, + select_loras, +) + + +def test_normalize_lora_name(): + assert normalize_lora_name("my_lora.safetensors") == "my_lora" + assert normalize_lora_name("Folder/Another_Lora.pt") == "folder/another_lora" + assert normalize_lora_name("") == "" + assert normalize_lora_name(None) == "" + + +def test_parse_custom_weights(): + assert parse_custom_weights("0.5, 0.85, 1.0") == [0.5, 0.85, 1.0] + assert parse_custom_weights("0.5, invalid, 1.0") == [] + assert parse_custom_weights("") == [] + assert parse_custom_weights(None) == [] + + +def test_filter_candidates(): + candidates = [ + "lora_a.safetensors", + "lora_b.safetensors", + "lora_c.safetensors", + "pony_lora.safetensors", + ] + + # 1. Enabled candidate filtering + enabled = ["lora_a", "pony_lora"] + filtered_enabled = filter_candidates(candidates, enabled_loras=enabled, blacklist_loras=[]) + assert filtered_enabled == ["lora_a.safetensors", "pony_lora.safetensors"] + + # 2. Blacklist filtering + blacklist = ["pony_lora"] + filtered_blacklisted = filter_candidates( + candidates, enabled_loras=[], blacklist_loras=blacklist + ) + assert filtered_blacklisted == [ + "lora_a.safetensors", + "lora_b.safetensors", + "lora_c.safetensors", + ] + + # 3. Both enabled and blacklist + filtered_both = filter_candidates(candidates, enabled_loras=enabled, blacklist_loras=blacklist) + assert filtered_both == ["lora_a.safetensors"] + + +def test_select_loras_deterministic(): + candidates = ["lora1.safetensors", "lora2.safetensors", "lora3.safetensors"] + + # Seeded random source to ensure determinism + rng1 = random.Random(42) + selection1 = select_loras(candidates, amount=2, lora_min=0.5, lora_max=0.9, random_source=rng1) + + rng2 = random.Random(42) + selection2 = select_loras(candidates, amount=2, lora_min=0.5, lora_max=0.9, random_source=rng2) + + assert selection1 == selection2 + assert len(selection1) == 2 + # Verify name stripping in selections + assert selection1[0][0] in ("lora1", "lora2", "lora3") + assert 0.5 <= selection1[0][1] <= 0.9 + + # Custom weights priority test + rng3 = random.Random(100) + selection_custom = select_loras( + candidates, + amount=3, + lora_min=0.1, + lora_max=0.2, + custom_weights=[0.88, 0.99], + random_source=rng3, + ) + assert len(selection_custom) == 3 + # The first two should use custom weights, the third uses rng.uniform + assert selection_custom[0][1] == 0.88 + assert selection_custom[1][1] == 0.99 + assert 0.1 <= selection_custom[2][1] <= 0.2 + + +def test_format_lora_prompt(): + selected = [("lora_a", 0.75), ("lora_b", 1.0)] + assert format_lora_prompt(selected) == " " + assert format_lora_prompt([]) == "" diff --git a/tests/test_modules.py b/tests/test_modules.py index 686fc65..439d72d 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,7 +1,7 @@ def test_prompting_import(): - import ranboorux.prompting as prompting + import ranboorux.tag_pipeline as tag_pipeline - assert prompting.remove_repeated_tags("a, b, a") == "a,b" + assert tag_pipeline.remove_repeated_tags("a, b, a") == "a,b" def test_image_ops_import(): @@ -10,10 +10,10 @@ def test_image_ops_import(): assert hasattr(image_ops, "resize_image") -def test_io_lists_import(): - import ranboorux.io_lists as io_lists +def test_user_store_import(): + import ranboorux.user_store as user_store - assert hasattr(io_lists, "read_list_file") + assert hasattr(user_store, "read_list_file") def test_catalog_import(): diff --git a/tests/test_prompt_and_parsing.py b/tests/test_prompt_and_parsing.py index e58a2cc..b2075b1 100644 --- a/tests/test_prompt_and_parsing.py +++ b/tests/test_prompt_and_parsing.py @@ -1,18 +1,18 @@ def test_remove_repeated_tags(): - import scripts.ranbooru as ranbooru + from ranboorux import tag_pipeline - assert ranbooru.remove_repeated_tags("a, b, a, c") == "a,b,c" - assert ranbooru.remove_repeated_tags("") == "" - assert ranbooru.remove_repeated_tags(None) == "" + assert tag_pipeline.remove_repeated_tags("a, b, a, c") == "a,b,c" + assert tag_pipeline.remove_repeated_tags("") == "" + assert tag_pipeline.remove_repeated_tags(None) == "" def test_limit_prompt_tags(): - import scripts.ranbooru as ranbooru + from ranboorux import tag_pipeline - assert ranbooru.limit_prompt_tags("a, b, c, d", 0.5, "Limit") == "a,b" - assert ranbooru.limit_prompt_tags("a, b, c, d", 2, "Max") == "a,b" - assert ranbooru.limit_prompt_tags("a, b", "bad", "Max") == "a, b" - assert ranbooru.limit_prompt_tags("a, b", 1, "Unknown") == "a, b" + assert tag_pipeline.limit_prompt_tags("a, b, c, d", 0.5, "Limit") == "a,b" + assert tag_pipeline.limit_prompt_tags("a, b, c, d", 2, "Max") == "a,b" + assert tag_pipeline.limit_prompt_tags("a, b", "bad", "Max") == "a, b" + assert tag_pipeline.limit_prompt_tags("a, b", 1, "Unknown") == "a, b" def test_sanitize_gelbooru_credential_variants(): @@ -35,9 +35,9 @@ def test_sanitize_gelbooru_compat_base_url(): def test_gelbooru_compat_parse_json_entities(): - import scripts.ranbooru as ranbooru + from ranboorux.boorus.gelbooru import GelbooruCompatible - client = ranbooru.GelbooruCompatible("https://example.com") + client = GelbooruCompatible("https://example.com") payload = {"post": [{"id": "1"}, {"id": "2"}], "@attributes": {"count": "42"}} entries, approx = client._parse_json_entities(payload, "post") assert [entry["id"] for entry in entries] == ["1", "2"] @@ -45,9 +45,9 @@ def test_gelbooru_compat_parse_json_entities(): def test_gelbooru_compat_parse_xml_entities(): - import scripts.ranbooru as ranbooru + from ranboorux.boorus.gelbooru import GelbooruCompatible - client = ranbooru.GelbooruCompatible("https://example.com") + client = GelbooruCompatible("https://example.com") xml_payload = ( "" "" @@ -60,9 +60,9 @@ def test_gelbooru_compat_parse_xml_entities(): def test_standardize_post_uses_tag_dict(): - import scripts.ranbooru as ranbooru + from ranboorux.boorus import Booru - booru = ranbooru.Booru("Test", "https://example.com") + booru = Booru("Test", "https://example.com") post = booru._standardize_post( { "tags": {"artist": ["alice"], "character": ["bob"], "copyright": ["copy"]}, @@ -78,9 +78,9 @@ def test_standardize_post_uses_tag_dict(): def test_standardize_post_tag_string_override_and_heuristic(): - import scripts.ranbooru as ranbooru + from ranboorux.boorus import Booru - booru = ranbooru.Booru("Test", "https://example.com") + booru = Booru("Test", "https://example.com") post = booru._standardize_post( { "tags": "foo_(series) bar", @@ -116,6 +116,7 @@ def test_show_fringe_benefits_only_visible_for_gelbooru(active_gradio_version): def test_loranado_scan_detects_ponyxl_markers(tmp_path): import types + import scripts.ranbooru as ranbooru ranbooru.shared.cmd_opts = types.SimpleNamespace(lora_dir=str(tmp_path)) @@ -166,6 +167,7 @@ def test_loranado_detection_ignores_unrelated_metadata_keys(): def test_apply_loranado_respects_enabled_and_blacklist(tmp_path): import types + import scripts.ranbooru as ranbooru ranbooru.shared.cmd_opts = types.SimpleNamespace(lora_dir=str(tmp_path)) @@ -212,16 +214,16 @@ def test_post_rejected_by_filter_does_not_reject_unrelated_tags(): post, filter_ctx=None, toggles=( - True, # remove_artist - True, # remove_character - True, # remove_clothing - True, # remove_text - True, # restrict_subject - True, # remove_furry - True, # remove_headwear - True, # remove_girl_suffix - True, # preserve_hair_eye - True, # remove_series + True, # remove_artist + True, # remove_character + True, # remove_clothing + True, # remove_text + True, # restrict_subject + True, # remove_furry + True, # remove_headwear + True, # remove_girl_suffix + True, # preserve_hair_eye + True, # remove_series ), base_colors=(set(), set()), allowed_subjects=set(), diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py new file mode 100644 index 0000000..5023994 --- /dev/null +++ b/tests/test_release_hygiene.py @@ -0,0 +1,89 @@ +import os + +from tools import build_release + + +def test_release_allowlist_excludes_internal_docs(tmp_path): + src = tmp_path / "src" + stage = tmp_path / "stage" + allowed = [ + "README.md", + "docs/usage.md", + "docs/CHANGELOG.md", + "docs/CONFIG.md", + "docs/handoff/GEMINI_HANDOFF.md", + "docs/joblog.txt", + "docs/ranbooru backup.py", + "docs/ranbooru_fix_bundle/ranbooru.py", + ] + for rel_path in allowed: + path = src / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("content", encoding="utf-8") + + build_release.copy_by_allowlist(str(src), str(stage)) + + assert (stage / "README.md").exists() + assert (stage / "docs/usage.md").exists() + assert (stage / "docs/CHANGELOG.md").exists() + assert (stage / "docs/CONFIG.md").exists() + assert not (stage / "docs/handoff/GEMINI_HANDOFF.md").exists() + assert not (stage / "docs/joblog.txt").exists() + assert not (stage / "docs/ranbooru backup.py").exists() + assert not (stage / "docs/ranbooru_fix_bundle/ranbooru.py").exists() + + +def test_release_hygiene_rejects_internal_paths_and_private_content(tmp_path): + stage = tmp_path / "stage" + forbidden_files = [ + "docs/joblog.txt", + "docs/handoff/GEMINI_HANDOFF.md", + "docs/ranbooru backup.py", + "docs/ranbooru_fix_bundle/ranbooru.py", + ] + for rel_path in forbidden_files: + path = stage / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("content", encoding="utf-8") + + zip_path = tmp_path / "dirty_paths.zip" + build_release.build_zip(str(stage), str(zip_path)) + + try: + build_release.check_archive_hygiene(str(zip_path)) + except ValueError: + pass + else: + raise AssertionError("expected release hygiene failure") + + +def test_release_hygiene_rejects_file_uri_and_windows_absolute_paths(tmp_path): + stage = tmp_path / "stage" + docs_path = stage / "docs" / "usage.md" + docs_path.parent.mkdir(parents=True, exist_ok=True) + docs_path.write_text("file:" + "///v:/private/handoff.md", encoding="utf-8") + script_path = stage / "scripts" / "ranbooru.py" + script_path.parent.mkdir(parents=True, exist_ok=True) + script_path.write_text("MODEL_PATH = r'E:\\private\\models'", encoding="utf-8") + + zip_path = tmp_path / "dirty_content.zip" + build_release.build_zip(str(stage), str(zip_path)) + + try: + build_release.check_archive_hygiene(str(zip_path)) + except ValueError: + pass + else: + raise AssertionError("expected release hygiene failure") + + +def test_release_builder_self_test_keeps_staging_paths_relative(tmp_path): + src = tmp_path / "src" + stage = tmp_path / "stage" + path = src / "scripts" / "ranbooru.py" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("print('ok')", encoding="utf-8") + + build_release.copy_by_allowlist(str(src), str(stage)) + + assert os.path.exists(stage / "scripts" / "ranbooru.py") diff --git a/tests/test_repo_guard.py b/tests/test_repo_guard.py new file mode 100644 index 0000000..f9f6b49 --- /dev/null +++ b/tests/test_repo_guard.py @@ -0,0 +1,15 @@ +from tools import repo_guard + + +def test_repo_guard_is_not_applicable_without_git_metadata(tmp_path, capsys): + files = repo_guard.get_files_to_check(tmp_path, []) + + captured = capsys.readouterr() + assert files == [] + assert "not applicable in source release" in captured.out + + +def test_repo_guard_explicit_paths_still_checked_without_git_metadata(tmp_path): + files = repo_guard.get_files_to_check(tmp_path, ["scripts/ranbooru.py"]) + + assert files == ["scripts/ranbooru.py"] diff --git a/tests/test_requesting.py b/tests/test_requesting.py new file mode 100644 index 0000000..93227b5 --- /dev/null +++ b/tests/test_requesting.py @@ -0,0 +1,756 @@ +import json +import types + +from ranboorux import http_client + + +def _public_dns(monkeypatch): + monkeypatch.setattr( + http_client.socket, + "getaddrinfo", + lambda *_args, **_kwargs: [ + (None, None, None, None, ("93.184.216.34", 443)), + ], + ) + + +def test_redact_url_hides_credential_query_values(): + url = "https://site.test/api?api_key=secret&user_id=123&tags=1girl" + + assert http_client.redact_url(url) == ( + "https://site.test/api?api_key=&user_id=&tags=1girl" + ) + + +def test_booru_session_uses_cached_session_without_global_patch(monkeypatch): + _public_dns(monkeypatch) + calls = [] + + class FakeCachedSession: + def __init__(self, *args, **kwargs): + calls.append((args, kwargs)) + + def get(self, *_args, **_kwargs): + return types.SimpleNamespace( + status_code=200, + headers={"content-type": "image/png"}, + content=b"ok", + raise_for_status=lambda: None, + close=lambda: None, + ) + + fake_cache = types.SimpleNamespace( + CachedSession=FakeCachedSession, + install_cache=lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("global install_cache should not be called") + ), + ) + monkeypatch.setattr(http_client, "requests_cache", fake_cache) + + session = http_client.BooruSession(use_cache=True) + + assert isinstance(session._session, FakeCachedSession) + assert calls + + +def test_get_bytes_rejects_large_response(monkeypatch): + _public_dns(monkeypatch) + + class FakeSession: + def get(self, *_args, **_kwargs): + return types.SimpleNamespace( + status_code=200, + headers={"content-type": "image/png"}, + content=b"12345", + raise_for_status=lambda: None, + close=lambda: None, + ) + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + try: + session.get_bytes("https://site.test/image.png", max_bytes=4) + except http_client.ResponseTooLargeError as exc: + assert "exceeded 4 bytes" in str(exc) + else: + raise AssertionError("expected ResponseTooLargeError") + + +def test_get_rejects_private_ip_before_request(monkeypatch): + calls = [] + + class FakeSession: + def get(self, *_args, **_kwargs): + calls.append(True) + return types.SimpleNamespace(status_code=200, headers={}) + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + try: + session.get("http://127.0.0.1/private") + except http_client.UnsafeUrlError: + pass + else: + raise AssertionError("expected UnsafeUrlError") + + assert calls == [] + + +def test_validate_outbound_url_rejects_carrier_grade_nat(): + try: + http_client.validate_outbound_url("http://100.64.0.1/api") + except http_client.UnsafeUrlError: + pass + else: + raise AssertionError("expected UnsafeUrlError") + + +def test_validate_outbound_url_rejects_ipv6_loopback(): + try: + http_client.validate_outbound_url("http://[::1]/api") + except http_client.UnsafeUrlError: + pass + else: + raise AssertionError("expected UnsafeUrlError") + + +def test_validate_outbound_url_rejects_hostname_alias_with_private_result(monkeypatch): + monkeypatch.setattr( + http_client.socket, + "getaddrinfo", + lambda *_args, **_kwargs: [ + (None, None, None, None, ("93.184.216.34", 443)), + (None, None, None, None, ("10.0.0.7", 443)), + ], + ) + + try: + http_client.validate_outbound_url("https://site.test/api") + except http_client.UnsafeUrlError: + pass + else: + raise AssertionError("expected UnsafeUrlError") + + +def test_connected_socket_rejects_rebound_private_peer_before_request(): + class FakeSocket: + def __init__(self): + self.closed = False + + def getpeername(self): + return ("10.0.0.5", 443) + + def close(self): + self.closed = True + + sock = FakeSocket() + + try: + http_client._validate_connected_socket(sock) + except http_client.UnsafeUrlError: + pass + else: + raise AssertionError("expected UnsafeUrlError") + + assert sock.closed is True + + +def test_get_rejects_redirect_to_private_ip(monkeypatch): + _public_dns(monkeypatch) + + class FakeSession: + def __init__(self): + self.calls = [] + + def get(self, url, **_kwargs): + self.calls.append(url) + return types.SimpleNamespace( + status_code=302, + headers={"location": "http://127.0.0.1/private"}, + close=lambda: None, + ) + + fake = FakeSession() + monkeypatch.setattr(http_client.requests, "Session", lambda: fake) + session = http_client.BooruSession(use_cache=False) + + try: + session.get("https://site.test/start") + except http_client.UnsafeUrlError: + pass + else: + raise AssertionError("expected UnsafeUrlError") + + assert fake.calls == ["https://site.test/start"] + + +def test_cached_session_bypasses_cache_for_sensitive_urls(monkeypatch): + _public_dns(monkeypatch) + cached_calls = [] + uncached_calls = [] + + class FakeResponse: + status_code = 200 + headers = {} + + class FakeCachedSession: + def __init__(self, *_args, **_kwargs): + pass + + def get(self, url, **_kwargs): + cached_calls.append(url) + return FakeResponse() + + class FakeUncachedSession: + def get(self, url, **_kwargs): + uncached_calls.append(url) + return FakeResponse() + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeUncachedSession()) + monkeypatch.setattr( + http_client, + "requests_cache", + types.SimpleNamespace(CachedSession=FakeCachedSession), + ) + + session = http_client.BooruSession(use_cache=True) + session.get("https://site.test/api?api_key=secret&user_id=123") + + assert cached_calls == [] + assert uncached_calls == ["https://site.test/api?api_key=secret&user_id=123"] + + +def test_cached_session_bypasses_cache_for_sensitive_redirect(monkeypatch): + _public_dns(monkeypatch) + cached_calls = [] + uncached_calls = [] + + class FakeCachedSession: + def __init__(self, *_args, **_kwargs): + pass + + def get(self, url, **_kwargs): + cached_calls.append(url) + return types.SimpleNamespace( + status_code=302, + headers={"location": "https://site.test/api?api_key=secret"}, + close=lambda: None, + ) + + class FakeUncachedSession: + def get(self, url, **_kwargs): + uncached_calls.append(url) + return types.SimpleNamespace(status_code=200, headers={}) + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeUncachedSession()) + monkeypatch.setattr( + http_client, + "requests_cache", + types.SimpleNamespace(CachedSession=FakeCachedSession), + ) + + session = http_client.BooruSession(use_cache=True) + session.get("https://site.test/start") + + assert cached_calls == ["https://site.test/start"] + assert uncached_calls == ["https://site.test/api?api_key=secret"] + + +def test_get_bytes_streams_until_limit_without_materializing_full_response(monkeypatch): + _public_dns(monkeypatch) + + class FakeResponse: + status_code = 200 + headers = {"content-type": "image/png"} + + def __init__(self): + self.iterated_chunks = 0 + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size): + del chunk_size + for chunk in (b"1234", b"5678", b"9012"): + self.iterated_chunks += 1 + yield chunk + + def close(self): + return None + + response = FakeResponse() + + class FakeSession: + def get(self, *_args, **_kwargs): + return response + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + try: + session.get_bytes("https://site.test/image.png", max_bytes=5) + except http_client.ResponseTooLargeError: + pass + else: + raise AssertionError("expected ResponseTooLargeError") + + assert response.iterated_chunks == 2 + + +def test_get_bytes_rejects_non_image_content_type(monkeypatch): + _public_dns(monkeypatch) + + class FakeSession: + def get(self, *_args, **_kwargs): + return types.SimpleNamespace( + status_code=200, + headers={"content-type": "text/html"}, + raise_for_status=lambda: None, + close=lambda: None, + ) + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + try: + session.get_bytes("https://site.test/not-image") + except http_client.InvalidContentTypeError as exc: + assert "text/html" in str(exc) + else: + raise AssertionError("expected InvalidContentTypeError") + + +def test_get_json_parses_bounded_response_with_missing_content_type(monkeypatch): + _public_dns(monkeypatch) + + class FakeResponse: + status_code = 200 + headers = {} + encoding = "utf-8" + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size): + del chunk_size + yield b'{"ok": true}' + + def close(self): + return None + + class FakeSession: + def get(self, *_args, **_kwargs): + return FakeResponse() + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + assert session.get_json("https://site.test/api") == {"ok": True} + + +def test_get_json_rejects_declared_oversized_response_before_parsing(monkeypatch): + _public_dns(monkeypatch) + + class FakeResponse: + status_code = 200 + headers = {"content-type": "application/json", "content-length": "99"} + + def raise_for_status(self): + return None + + def json(self): + raise AssertionError("raw response json parser should not be called") + + def close(self): + return None + + class FakeSession: + def get(self, *_args, **_kwargs): + return FakeResponse() + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + try: + session.get_json("https://site.test/api", max_bytes=10) + except http_client.ResponseTooLargeError: + pass + else: + raise AssertionError("expected ResponseTooLargeError") + + +def test_get_json_rejects_chunked_oversized_response(monkeypatch): + _public_dns(monkeypatch) + + class FakeResponse: + status_code = 200 + headers = {"content-type": "application/json"} + iterated_chunks = 0 + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size): + del chunk_size + for chunk in (b"12345", b"67890"): + self.iterated_chunks += 1 + yield chunk + + def close(self): + return None + + response = FakeResponse() + + class FakeSession: + def get(self, *_args, **_kwargs): + return response + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + try: + session.get_json("https://site.test/api", max_bytes=6) + except http_client.ResponseTooLargeError: + pass + else: + raise AssertionError("expected ResponseTooLargeError") + + assert response.iterated_chunks == 2 + + +def test_get_json_rejects_non_json_content_type(monkeypatch): + _public_dns(monkeypatch) + + class FakeResponse: + status_code = 200 + headers = {"content-type": "text/html"} + + def raise_for_status(self): + return None + + def close(self): + return None + + class FakeSession: + def get(self, *_args, **_kwargs): + return FakeResponse() + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + try: + session.get_json("https://site.test/api") + except http_client.InvalidContentTypeError: + pass + else: + raise AssertionError("expected InvalidContentTypeError") + + +def test_get_json_surfaces_invalid_json(monkeypatch): + _public_dns(monkeypatch) + + class FakeResponse: + status_code = 200 + headers = {"content-type": "application/json"} + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size): + del chunk_size + yield b"{not-json" + + def close(self): + return None + + class FakeSession: + def get(self, *_args, **_kwargs): + return FakeResponse() + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + try: + session.get_json("https://site.test/api") + except ValueError: + pass + else: + raise AssertionError("expected JSON parse error") + + +def test_redact_url_mixed_case_and_percent_encoding(): + url1 = "https://site.test/api?X-Amz-Signature=secret123&X-goog-Credential=secret456&sig=secret789&normal=hello" + redacted1 = http_client.redact_url(url1) + assert "secret123" not in redacted1 + assert "secret456" not in redacted1 + assert "secret789" not in redacted1 + assert "X-Amz-Signature=" in redacted1 + assert "X-goog-Credential=" in redacted1 + assert "sig=" in redacted1 + assert "normal=hello" in redacted1 + + url2 = "https://site.test/api?api_key=secret%20key&password=hello%26world" + redacted2 = http_client.redact_url(url2) + assert "secret%20key" not in redacted2 + assert "hello%26world" not in redacted2 + assert "api_key=" in redacted2 + assert "password=" in redacted2 + + +def test_exception_sanitization_mixed_content(): + exc_text = ( + "Error accessing file E:\\private\\forge\\extensions\\sd_forge_controlnet " + "when calling https://cdn.test/foo?X-Amz-Signature=supersecret&normal=param" + ) + sanitized = http_client.sanitize_exception_text(exc_text) + assert "E:\\private" not in sanitized + assert "supersecret" not in sanitized + assert "" in sanitized + assert "X-Amz-Signature=" in sanitized + assert "normal=param" in sanitized + + +def test_cache_redirect_history_purged(monkeypatch): + _public_dns(monkeypatch) + + class FakeResponse: + def __init__(self, status_code, headers): + self.status_code = status_code + self.headers = headers + + def close(self): + pass + + db = {} + + class MockBaseCache: + def __init__(self): + pass + + def get_response(self, key): + return None + + def save_response(self, key, response, *args, **kwargs): + pass + + def delete(self, key): + if key in db: + del db[key] + + def has_url(self, url): + return url in db + + def contains(self, url): + return url in db + + def urls(self): + return list(db.keys()) + + class FakeCachedSession: + def __init__(self, *args, **kwargs): + self.cache = MockBaseCache() + + def get(self, url, **kwargs): + db[url] = True + if url == "https://site.test/start": + return FakeResponse(302, {"location": "https://site.test/redirect"}) + elif url == "https://site.test/redirect": + return FakeResponse( + 302, {"location": "https://site.test/api?X-Amz-Signature=secret"} + ) + return FakeResponse(200, {}) + + def delete(self, url): + self.cache.delete(url) + + class FakeUncachedSession: + def get(self, url, **kwargs): + return FakeResponse(200, {}) + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeUncachedSession()) + monkeypatch.setattr( + http_client, + "requests_cache", + types.SimpleNamespace(CachedSession=FakeCachedSession), + ) + + session = http_client.BooruSession(use_cache=True) + session.get("https://site.test/start") + + assert not session._session.cache.contains("https://site.test/start") + assert not session._session.cache.contains("https://site.test/redirect") + assert not session._session.cache.contains("https://site.test/api?X-Amz-Signature=secret") + + +def test_sanitize_exception_invalid_url_with_secrets_and_paths(): + from requests.exceptions import InvalidURL + + windows_path = "E:" + "\\private\\forge\\extensions\\sd_forge_controlnet" + posix_path = "/home/user/forge/extensions/sd-webui-controlnet" + unc_path = "\\\\server\\share\\path\\to\\extensions" + file_path = "file:" + "///C:/Users/fanph/secret_extension" + signed_url = "https://cdn.test/foo?sig=secret123&x-amz-signature=amzsecret" + + err_msg = ( + f"Invalid URL: {windows_path} and {posix_path} and {unc_path} " + f"and {file_path} with signed URL {signed_url}" + ) + + exc = InvalidURL(err_msg) + + sanitized_exc = http_client.sanitize_exception(exc) + + assert isinstance(sanitized_exc, RuntimeError) + message = str(sanitized_exc) + + assert windows_path not in message + assert posix_path not in message + assert unc_path not in message + assert file_path not in message + assert "secret123" not in message + assert "amzsecret" not in message + + +def test_get_json_parser_failure_contains_sanitized_error(monkeypatch): + _public_dns(monkeypatch) + + windows_path = "E:" + "\\private\\forge\\extensions\\sd_forge_controlnet" + file_path = "file:" + "///C:/Users/fanph/secret_extension" + signed_url = "https://cdn.test/foo?sig=secret123" + + class FakeResponse: + status_code = 200 + headers = {"content-type": "application/json"} + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size): + del chunk_size + yield b"{}" + + def close(self): + return None + + class FakeSession: + def get(self, *_args, **_kwargs): + return FakeResponse() + + monkeypatch.setattr(http_client.requests, "Session", lambda: FakeSession()) + session = http_client.BooruSession(use_cache=False) + + def mock_loads(*_args, **_kwargs): + raise json.JSONDecodeError( + f"Expecting value in document containing {windows_path} and {file_path} and {signed_url}", + "{}", + 0, + ) + + monkeypatch.setattr(http_client.json, "loads", mock_loads) + + try: + session.get_json("https://site.test/api") + except http_client.BooruResponseError as exc: + message = str(exc) + assert exc.__cause__ is not None + assert isinstance(exc.__cause__, json.JSONDecodeError) + else: + raise AssertionError("expected BooruResponseError") + + assert windows_path not in message + assert file_path not in message + assert "secret123" not in message + assert "" in message + + +def test_path_redaction_with_spaces(): + windows_path = "C:" + "\\Users\\user profile\\Private Folder\\file.py" + unc_path = "\\\\server\\share name\\folder\\file.py" + posix_path = "/home/user/Private Folder/file.py" + file_path = "file:" + "///C:/Users/user/Private Folder/file.py" + + assert "user profile" not in http_client.sanitize_exception_text( + "Error C:\\Users\\user profile\\Private Folder\\file.py." + ) + assert "share name" not in http_client.sanitize_exception_text( + "Error \\\\server\\share name\\folder\\file.py." + ) + assert "Private Folder" not in http_client.sanitize_exception_text( + "Error /home/user/Private Folder/file.py." + ) + assert "Private Folder" not in http_client.sanitize_exception_text( + "Error file:" + "///C:/Users/user/Private Folder/file.py." + ) + + signed_url = "https://cdn.test/foo?sig=secret123&x-amz-signature=amzsecret" + mixed_msg = ( + f"Error accessing {windows_path} and {posix_path} and {unc_path} " + f"and {file_path} with signed URL {signed_url}" + ) + + sanitized = http_client.sanitize_exception_text(mixed_msg) + + assert "user profile" not in sanitized + assert "share name" not in sanitized + assert "Private Folder" not in sanitized + assert "secret123" not in sanitized + assert "amzsecret" not in sanitized + assert "" in sanitized + assert "x-amz-signature=" in sanitized + + +def test_logger_handler_includes_no_secrets(monkeypatch): + import logging + import sys + import types + + # Setup dummy modules to satisfy script base class lookup in ranbooru.py under test environment + mock_modules = ["modules", "modules.scripts", "modules.shared", "modules.paths"] + for m in mock_modules: + if m not in sys.modules: + sys.modules[m] = types.SimpleNamespace() + sys.modules["modules.scripts"].Script = object + + import scripts.ranbooru as ranbooru + + records = [] + + class MockHandler(logging.Handler): + def emit(self, record): + records.append(record) + + logger = logging.getLogger("ranboorux") + handler = MockHandler() + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + + try: + windows_path = "E:" + "\\private\\forge\\extensions\\sd_forge_controlnet" + signed_url = "https://cdn.test/foo?sig=secret123" + + class MismatchedScript: + @property + def __class__(self): + raise ValueError(f"Secret path: {windows_path} and URL {signed_url}") + + script = ranbooru.Script() + script._is_adetailer_script(MismatchedScript()) + + assert len(records) > 0 + for rec in records: + msg = rec.getMessage() + assert windows_path not in msg + assert "secret123" not in msg + + assert not rec.exc_info + + for arg in rec.args or []: + if isinstance(arg, Exception): + raise AssertionError("Raw exception object passed to logger") + arg_str = str(arg) + assert windows_path not in arg_str + assert "secret123" not in arg_str + finally: + logger.removeHandler(handler) diff --git a/tests/test_run_options.py b/tests/test_run_options.py new file mode 100644 index 0000000..5be8c5b --- /dev/null +++ b/tests/test_run_options.py @@ -0,0 +1,55 @@ +import pytest + +from ranboorux.run_options import UI_ARGUMENT_FIELDS, RunComponents, RunOptions + + +def test_ui_argument_field_order_is_frozen(): + assert len(UI_ARGUMENT_FIELDS) == 62 + assert UI_ARGUMENT_FIELDS[:6] == ( + "enabled", + "tags", + "booru", + "gelbooru_api_key", + "gelbooru_user_id", + "gelbooru_compat_base_url", + ) + assert UI_ARGUMENT_FIELDS[-5:] == ( + "use_tag_catalog", + "catalog_path", + "lora_auto_detect_pony", + "lora_detected_loras", + "lora_blacklist", + ) + + +def test_run_options_from_script_args_maps_names_once(): + values = list(range(len(UI_ARGUMENT_FIELDS))) + + options = RunOptions.from_script_args(values) + + assert options.enabled == 0 + assert options.tags == 1 + assert options.gelbooru.compat_base_url == 5 + assert options.image_workflow.use_img2img == 12 + assert options.tag_filters.remove_text_tags == 50 + assert options.loranado.blacklist == 61 + assert options.as_dict() == dict(zip(UI_ARGUMENT_FIELDS, values)) + + +def test_run_options_rejects_wrong_count(): + with pytest.raises(ValueError, match="Expected 62"): + RunOptions.from_script_args([object()]) + + +def test_run_components_round_trips_script_args_in_contract_order(): + values = [object() for _ in UI_ARGUMENT_FIELDS] + components = RunComponents.from_sequence(values) + + assert components.script_args() == values + + +def test_run_components_rejects_missing_fields(): + components = RunComponents({"enabled": object()}) + + with pytest.raises(ValueError, match="Missing RanbooruX components"): + components.script_args() diff --git a/tests/test_tag_catalog.py b/tests/test_tag_catalog.py index 7a6dc22..53ac196 100644 --- a/tests/test_tag_catalog.py +++ b/tests/test_tag_catalog.py @@ -54,7 +54,7 @@ def test_catalog_passthrough_when_disabled(tmp_path): drop_textual=False, ) assert filtered == tags - assert diag["mode"] == "legacy" + assert diag["mode"] == "catalog" def test_bundled_catalog_exists(): @@ -183,7 +183,7 @@ def test_import_custom_catalog(tmp_path): assert os.path.isfile(script._custom_catalog_path) -def test_config_migration_v1_to_v2(tmp_path): +def test_legacy_catalog_config_is_not_migrated(tmp_path): import scripts.ranbooru as ranbooru catalog_path = _write_catalog(tmp_path) @@ -194,5 +194,20 @@ def test_config_migration_v1_to_v2(tmp_path): encoding="utf-8", ) script = ranbooru.Script() + assert script._catalog_source == "bundled" + assert script._custom_catalog_path == "" + + +def test_current_catalog_config_loads_custom_path(tmp_path): + import scripts.ranbooru as ranbooru + + catalog_path = _write_catalog(tmp_path) + cfg = Path(ranbooru.TAG_CATALOG_CONFIG_FILE) + cfg.parent.mkdir(parents=True, exist_ok=True) + cfg.write_text( + json.dumps({"enabled": True, "source": "custom", "custom_path": str(catalog_path)}), + encoding="utf-8", + ) + script = ranbooru.Script() assert script._catalog_source == "custom" assert script._custom_catalog_path == str(catalog_path) diff --git a/tests/test_tag_pipeline.py b/tests/test_tag_pipeline.py new file mode 100644 index 0000000..b412b81 --- /dev/null +++ b/tests/test_tag_pipeline.py @@ -0,0 +1,306 @@ +from ranboorux.tag_pipeline import ( + FilterContext, + build_removal_context, + build_synonym_lookup, + canonicalize_raw_tag, + dedupe_keep_order, + expand_with_synonyms, + is_clothing_tag, + is_eye_color_tag, + is_furry_tag, + is_girl_suffix_tag, + is_hair_color_tag, + is_headwear_tag, + is_series_tag, + is_subject_tag, + is_textual_tag, + normalize_tag, + post_rejected_by_filter, + remove_repeated_tags, + split_prompt_tags, + tag_matches_removal, +) + + +def test_split_prompt_tags(): + # Happy path + assert split_prompt_tags("1girl, blonde hair, blue eyes") == [ + "1girl", + "blonde hair", + "blue eyes", + ] + # Malformed/Empty inputs + assert split_prompt_tags("") == [] + assert split_prompt_tags(None) == [] + assert split_prompt_tags(" , ,, , ") == [] + + +def test_dedupe_keep_order(): + # Duplicates & ordering + assert dedupe_keep_order(["1girl", "blonde hair", "1girl", "blue eyes", "blonde hair"]) == [ + "1girl", + "blonde hair", + "blue eyes", + ] + assert dedupe_keep_order([]) == [] + + +def test_remove_repeated_tags(): + assert ( + remove_repeated_tags("1girl, blonde hair, 1girl, blue eyes") + == "1girl,blonde hair,blue eyes" + ) + assert remove_repeated_tags("") == "" + + +def test_canonicalize_raw_tag(): + assert canonicalize_raw_tag(" 1GIRL_with_Sword ") == "1girl with sword" + assert canonicalize_raw_tag("") == "" + assert canonicalize_raw_tag(None) == "" + + +def test_normalize_tag(): + # Malformed, wrappers, casing + assert normalize_tag("(1girl)") == "1girl" + assert normalize_tag("[blonde_hair]") == "blonde hair" + assert normalize_tag(" {blue-eyes} ") == "blue eyes" + assert normalize_tag("") == "" + assert normalize_tag(None) == "" + + +def test_synonyms_and_lookup(): + syn_groups = [ + {"grayscale", "greyscale", "monochrome"}, + {"1girl", "1female", "1woman"}, + ] + lookup = build_synonym_lookup(syn_groups) + assert "grayscale" in lookup + assert "greyscale" in lookup + assert lookup["grayscale"] == {"grayscale", "greyscale", "monochrome"} + + target = {"grayscale"} + expand_with_synonyms("grayscale", target, lookup) + assert target == {"grayscale", "greyscale", "monochrome"} + + +def test_tag_classification(): + # Furry + assert is_furry_tag("kemono") is True + assert is_furry_tag("pokemon_pikachu") is True + assert is_furry_tag("cat_ears") is True + assert is_furry_tag("1girl") is False + + # Headwear + assert is_headwear_tag("witch_hat") is True + assert is_headwear_tag("floating halo") is True + assert is_headwear_tag("gloves") is False + + # Girl suffix + assert is_girl_suffix_tag("cat_girl") is True + assert is_girl_suffix_tag("girl") is False + assert is_girl_suffix_tag("1girl") is False + + # Hair & Eye color + assert is_hair_color_tag("blonde_hair") is True + assert is_hair_color_tag("blue_eyes") is False + assert is_eye_color_tag("blue_eyes") is True + + # Series + assert is_series_tag("gacha_game") is True + assert is_series_tag("fate_series") is True + assert is_series_tag("hat") is False + + # Clothing + assert is_clothing_tag("dress") is True + assert is_clothing_tag("no_clothing") is False + assert is_clothing_tag("nude") is False + + # Textual + assert is_textual_tag("speech bubble") is True + assert is_textual_tag("watermark") is True + assert is_textual_tag("1girl") is False + + # Subject + assert is_subject_tag("solo") is True + assert is_subject_tag("2girls") is True + assert is_subject_tag("blonde_hair") is False + + +def test_removal_context_and_matching(): + synonym_lookup = build_synonym_lookup([{"1girl", "1female"}]) + removal_raw = ["bad_tag", "remove_*", "*_bad", "*commentary*", "c*a"] + favorites_raw = ["remove_fav", "1girl"] + + context = build_removal_context(removal_raw, favorites_raw, synonym_lookup) + + # Exact removal matching + assert tag_matches_removal("bad tag", context) is True + # Prefix matching + assert tag_matches_removal("remove tag", context) is True + # Suffix matching + assert tag_matches_removal("really bad", context) is True + # Contains matching + assert tag_matches_removal("some commentary here", context) is True + # Regex wildcard matching + assert tag_matches_removal("cta", context) is True + assert tag_matches_removal("cbba", context) is True + + # Favorites bypass check + assert tag_matches_removal("1girl", context) is False + + +def test_post_rejected_by_filter(): + post = { + "id": "123", + "booru_name": "danbooru", + "tags": "1girl, blonde_hair, blue_eyes, speech_bubble", + "artist_tags": "drawn_by_unknown", + "character_tags": "heroine", + "copyright_tags": "cool_franchise", + } + + # Toggles order: + # 0: remove_artist, 1: remove_character, 2: remove_clothing, 3: remove_text, + # 4: restrict_subject, 5: remove_furry, 6: remove_headwear, 7: remove_girl_suffix, + # 8: preserve_hair_eye, 9: remove_series + + cache = {} + + # Test remove artist + rejected, reason = post_rejected_by_filter( + post, + filter_ctx=None, + toggles=(True, False, False, False, False, False, False, False, False, False), + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), + ) + assert rejected is True + assert reason["rule"] == "artist" + + # Test remove text/commentary + rejected, reason = post_rejected_by_filter( + post, + filter_ctx=None, + toggles=(False, False, False, True, False, False, False, False, False, False), + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), + ) + assert rejected is True + assert reason["rule"] == "text" + + # Test preserve hair/eye colors (mismatch) + rejected, reason = post_rejected_by_filter( + post, + filter_ctx=None, + toggles=(False, False, False, False, False, False, False, False, True, False), + base_colors=({"brown hair"}, {"blue eyes"}), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), + ) + assert rejected is True + assert reason["rule"] == "hair-color-conflict" + + # Test successful matching (no rejection) + rejected, reason = post_rejected_by_filter( + post, + filter_ctx=None, + toggles=(False, False, False, False, False, False, False, False, False, False), + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), + ) + assert rejected is False + + +def test_post_rejected_by_filter_remove_furry(): + """Test that remove_furry flag rejects furry tags.""" + post = {"id": "1", "booru_name": "danbooru", "tags": "kemonomimi, 1girl, blonde_hair"} + cache = {} + rejected, reason = post_rejected_by_filter( + post, filter_ctx=None, + toggles=(False, False, False, False, False, True, False, False, False, False), + base_colors=(set(), set()), allowed_subjects=set(), + cache=cache, favorites_guard=set(), + ) + assert rejected is True + assert reason["rule"] == "furry" + + +def test_post_rejected_by_filter_remove_clothing(): + """Test that remove_clothing rejects clothing tags but not 'no_clothing'.""" + post = {"id": "2", "booru_name": "danbooru", "tags": "dress, 1girl, no_clothing"} + cache = {} + rejected, reason = post_rejected_by_filter( + post, filter_ctx=None, + toggles=(False, False, True, False, False, False, False, False, False, False), + base_colors=(set(), set()), allowed_subjects=set(), + cache=cache, favorites_guard=set(), + ) + assert rejected is True + assert reason["rule"] == "clothing" + + +def test_post_rejected_by_filter_remove_headwear(): + """Test remove_headwear with halo edge case.""" + post = {"id": "3", "booru_name": "danbooru", "tags": "halo, 1girl, blonde_hair"} + cache = {} + rejected, reason = post_rejected_by_filter( + post, filter_ctx=None, + toggles=(False, False, False, False, False, False, True, False, False, False), + base_colors=(set(), set()), allowed_subjects=set(), + cache=cache, favorites_guard=set(), + ) + assert rejected is True + assert reason["rule"] == "headwear" + + +def test_post_rejected_by_filter_remove_girl_suffix(): + """Test remove_girl_suffix rejects _girl tags but not 1girl/girl.""" + post = {"id": "4", "booru_name": "danbooru", "tags": "cat_girl, 1girl, girl, blonde_hair"} + cache = {} + rejected, reason = post_rejected_by_filter( + post, filter_ctx=None, + toggles=(False, False, False, False, False, False, False, True, False, False), + base_colors=(set(), set()), allowed_subjects=set(), + cache=cache, favorites_guard=set(), + ) + assert rejected is True + assert reason["rule"] == "girl-suffix" + assert reason["tag"] == "cat_girl" + + +def test_post_rejected_by_filter_remove_character(): + """Test remove_character rejects character tags.""" + post = {"id": "5", "booru_name": "danbooru", + "tags": "1girl", "character_tags": "heroine"} + cache = {} + rejected, reason = post_rejected_by_filter( + post, filter_ctx=None, + toggles=(False, True, False, False, False, False, False, False, False, False), + base_colors=(set(), set()), allowed_subjects=set(), + cache=cache, favorites_guard=set(), + ) + assert rejected is True + assert reason["rule"] == "character" + + +def test_post_rejected_by_filter_favorites_guard(): + """Test that favorites_guard bypasses removal matching.""" + post = {"id": "6", "booru_name": "danbooru", "tags": "bad_tag, 1girl"} + removal_raw = ["bad_tag"] + ctx = build_removal_context(removal_raw, favorites_raw=[], synonym_lookup={}) + cache = {} + # With favorites_guard containing "bad_tag" - should NOT be rejected + rejected, reason = post_rejected_by_filter( + post, filter_ctx=ctx, + toggles=(False, False, False, False, False, False, False, False, False, False), + base_colors=(set(), set()), allowed_subjects=set(), + cache=cache, favorites_guard={"bad tag"}, + ) + assert rejected is False diff --git a/tests/test_ui_contract.py b/tests/test_ui_contract.py new file mode 100644 index 0000000..bd143d3 --- /dev/null +++ b/tests/test_ui_contract.py @@ -0,0 +1,28 @@ +import importlib +import sys + +from ranboorux.run_options import UI_ARGUMENT_FIELDS, RunComponents + + +def _reload_ranbooru(): + sys.modules.pop("scripts.ranbooru", None) + return importlib.import_module("scripts.ranbooru") + + +def test_ui_argument_contract_length(stub_modules): + ranbooru = _reload_ranbooru() + script = ranbooru.Script() + + # Check with is_img2img = False + components_txt = script.ui(is_img2img=False) + assert len(components_txt) == len(UI_ARGUMENT_FIELDS) + assert RunComponents.from_sequence(components_txt).script_args() == components_txt + + # Check with is_img2img = True + components_img = script.ui(is_img2img=True) + assert len(components_img) == len(UI_ARGUMENT_FIELDS) + assert RunComponents.from_sequence(components_img).script_args() == components_img + + # Verify sequence contains mocked Gradio components + for i, comp in enumerate(components_txt): + assert comp is not None, f"Component at index {i} is None" diff --git a/tests/test_user_store.py b/tests/test_user_store.py new file mode 100644 index 0000000..b1abf7d --- /dev/null +++ b/tests/test_user_store.py @@ -0,0 +1,131 @@ +import json + +import pytest + +from ranboorux.user_store import ( + UserStoreError, + append_prompt_log, + append_text_log, + atomic_write_text, + clear_gelbooru_credentials, + load_catalog_preferences, + load_gelbooru_credentials, + read_list_file, + save_catalog_preferences, + save_gelbooru_credentials, + write_list_file, +) + + +def test_credentials_operations(tmp_path): + cred_file = tmp_path / "credentials.json" + + # 1. Missing file + assert load_gelbooru_credentials(cred_file) is None + + # 2. Corrupt JSON + cred_file.write_text("{invalid json", encoding="utf-8") + with pytest.raises(UserStoreError): + load_gelbooru_credentials(cred_file) + + # 3. Successful save & load + save_gelbooru_credentials(cred_file, "my_api_key", "my_user_id") + loaded = load_gelbooru_credentials(cred_file) + assert loaded == {"api_key": "my_api_key", "user_id": "my_user_id"} + + # 4. Empty/invalid inputs + with pytest.raises(ValueError): + save_gelbooru_credentials(cred_file, "", "user") + with pytest.raises(ValueError): + save_gelbooru_credentials(cred_file, "key", "") + + # 5. Clear credentials + clear_gelbooru_credentials(cred_file) + assert cred_file.exists() is False + + +def test_list_file_operations(tmp_path): + list_file = tmp_path / "list.txt" + + # 1. Missing file + assert read_list_file(list_file) == [] + + # 2. Duplicate entries & empty data & normalization + tags = [" 1girl ", "blonde_hair", "1girl", " ", "blue_eyes"] + write_list_file(list_file, tags) + + read_tags = read_list_file(list_file) + # Check that duplicates were removed and whitespace stripped + assert read_tags == ["1girl", "blonde_hair", "blue_eyes"] + + # 3. Write with custom normalization + def dummy_norm(val): + return val.replace("_", " ").strip().lower() + + write_list_file(list_file, tags, normalize_fn=dummy_norm) + # Normalized: 1girl (exact match), blonde hair (exact match), blue eyes (exact match) + # The saved tags keep original characters but deduped on normalized key + read_tags_norm = read_list_file(list_file, normalize_fn=dummy_norm) + assert read_tags_norm == ["1girl", "blonde_hair", "blue_eyes"] + + +def test_prompt_log_append(tmp_path): + log_file = tmp_path / "prompt_sources.jsonl" + + # 1. Append to missing file (creates it) + payload1 = {"prompt": "1girl", "seed": 123} + append_prompt_log(log_file, payload1) + + # 2. Append multiple entries + payload2 = {"prompt": "2girls", "seed": 456} + append_prompt_log(log_file, payload2) + + # Verify contents + lines = log_file.read_text(encoding="utf-8").splitlines() + assert len(lines) == 2 + assert json.loads(lines[0]) == payload1 + assert json.loads(lines[1]) == payload2 + + append_text_log(tmp_path / "prompt_sources.txt", ["---", "prompt=1girl"]) + assert (tmp_path / "prompt_sources.txt").read_text(encoding="utf-8").splitlines() == [ + "---", + "prompt=1girl", + ] + + +def test_catalog_preferences(tmp_path): + pref_file = tmp_path / "tag_catalog.json" + + # 1. Missing file (returns defaults) + defaults = load_catalog_preferences(pref_file) + assert defaults == {"enabled": True, "source": "bundled", "custom_path": ""} + + # 2. Corrupt JSON + pref_file.write_text("not json", encoding="utf-8") + with pytest.raises(UserStoreError): + load_catalog_preferences(pref_file) + + # 3. Save and load current preferences + save_catalog_preferences( + pref_file, + enabled=False, + source="custom", + custom_path="/path/to/custom", + ) + loaded = load_catalog_preferences(pref_file) + assert loaded == {"enabled": False, "source": "custom", "custom_path": "/path/to/custom"} + + +def test_atomic_write_cleanup(tmp_path): + target = tmp_path / "sub" / "target.txt" + + # Trigger write error on directory permission issues or mock failures + # By making the directory a file, we cause directory creation to fail + tmp_path.joinpath("sub").write_text("blocking file") + + with pytest.raises(UserStoreError): + atomic_write_text(target, "some content") + + # Check that no temporary files were left behind in the parent directory + temp_files = list(tmp_path.glob(".ranboorux_tmp_*")) + assert len(temp_files) == 0 diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index c0c294e..8373c93 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -1,10 +1,10 @@ def test_prompt_wrappers_match_module(): import scripts.ranbooru as ranbooru - from ranboorux import prompting + from ranboorux import tag_pipeline prompt = "a, b, a, c" - assert ranbooru.remove_repeated_tags(prompt) == prompting.remove_repeated_tags(prompt) - assert ranbooru.limit_prompt_tags("a, b, c, d", 2, "Max") == prompting.limit_prompt_tags( + assert ranbooru.rb_tag_pipeline.remove_repeated_tags(prompt) == tag_pipeline.remove_repeated_tags(prompt) + assert ranbooru.rb_tag_pipeline.limit_prompt_tags("a, b, c, d", 2, "Max") == tag_pipeline.limit_prompt_tags( "a, b, c, d", 2, "Max" ) diff --git a/tools/build_release.py b/tools/build_release.py new file mode 100644 index 0000000..c00b981 --- /dev/null +++ b/tools/build_release.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +import fnmatch +import os +import re +import shutil +import sys +import tempfile +import time +import zipfile + +ALLOWLIST_PATTERNS = [ + ".gitignore", + ".pre-commit-config.yaml", + "install.py", + "pyproject.toml", + "README.md", + "requirements.txt", + "adetailer/**/*", + "data/**/*", + "docs/CHANGELOG.md", + "docs/CONFIG.md", + "docs/usage.md", + "pics/**/*", + "ranboorux/**/*", + "scripts/**/*", + "tests/**/*", + "tools/**/*", +] + +# Files/dirs that must NEVER end up in the zip archive +FORBIDDEN_PATTERNS = [ + "*/.git/*", + "*/.venv/*", + "*/__pycache__/*", + "*/.mypy_cache/*", + "*/.pytest_cache/*", + "*/.pytest_cache_local/*", + "*/.ruff_cache/*", + "*.log", + "*/logs/*", + "*credentials.json", + "*/credentials.json", + "*.zip", + "*.tar.gz", + "*.bak", + "*~", + "*/tmpclaude-*", + "*/.ranboorux_*", + "*/docs/handoff/*", + "*/docs/joblog.txt", + "*/docs/ranbooru backup*.py", + "*/docs/ranbooru_fix_bundle/*", + "*/docs/ranboorux_planning_docs/*", + "*/docs/ranboorux_planning_docs_v2/*", +] + +TEXT_CONTENT_EXTENSIONS = { + ".cfg", + ".css", + ".csv", + ".html", + ".ini", + ".js", + ".json", + ".md", + ".py", + ".toml", + ".txt", + ".yml", +} +LOCAL_FILE_URI_RE = re.compile(rb"file:" + rb"///", re.IGNORECASE) +WINDOWS_ABSOLUTE_PATH_RE = re.compile(rb"\b[A-Za-z]:\\[^\\\r\n\t ]+\\[^\\\r\n\t ]+") + + +def matches_any(path, patterns): + path_norm = path.replace("\\", "/") + for pattern in patterns: + if fnmatch.fnmatch(path_norm, pattern) or fnmatch.fnmatch( + os.path.basename(path_norm), pattern + ): + return True + # Handle recursive glob patterns manually for simplicity + if "**" in pattern: + parts = pattern.split("/**/") + if len(parts) == 2: + prefix, suffix = parts[0], parts[1] + if path_norm.startswith(prefix) and fnmatch.fnmatch(path_norm, f"*/{suffix}"): + return True + return False + + +def check_archive_hygiene(zip_path): + print(f"Verifying hygiene of archive: {zip_path}") + violations = [] + with zipfile.ZipFile(zip_path, "r") as zf: + for name in zf.namelist(): + if matches_any(name, FORBIDDEN_PATTERNS): + violations.append(name) + continue + suffix = os.path.splitext(name)[1].lower() + if suffix not in TEXT_CONTENT_EXTENSIONS: + continue + payload = zf.read(name) + if LOCAL_FILE_URI_RE.search(payload): + violations.append(f"{name}: contains local file URI") + if WINDOWS_ABSOLUTE_PATH_RE.search(payload): + violations.append(f"{name}: contains Windows absolute path") + if violations: + print("HYGIENE ERROR: Forbidden files detected in the archive:") + for v in violations: + print(f" - {v}") + raise ValueError("Archive hygiene check failed due to forbidden files.") + print("Hygiene check passed successfully.") + + +def copy_by_allowlist(src_dir, dest_dir): + os.makedirs(dest_dir, exist_ok=True) + + # We walk the source directory + for root, dirs, files in os.walk(src_dir): + # Calculate relative path + rel_root = os.path.relpath(root, src_dir) + if rel_root == ".": + rel_root = "" + + for file in files: + rel_file = os.path.join(rel_root, file).replace("\\", "/") + + # Check if it matches ALLOWLIST_PATTERNS + matched = False + for pat in ALLOWLIST_PATTERNS: + if "**" in pat: + prefix = pat.split("/**")[0] + if rel_file.startswith(prefix): + matched = True + break + else: + if fnmatch.fnmatch(rel_file, pat): + matched = True + break + + if matched: + # Still check if it matches forbidden patterns just in case + if matches_any(rel_file, FORBIDDEN_PATTERNS): + continue + src_path = os.path.join(root, file) + dest_path = os.path.join(dest_dir, rel_file) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + try: + with open(src_path, "rb") as sf, open(dest_path, "wb") as df: + df.write(sf.read()) + except Exception as exc: + print(f"Failed to copy {src_path} -> {dest_path}: {exc}") + raise exc + + +def build_zip(staging_dir, zip_path, folder_name="sd-webui-ranbooruX"): + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(staging_dir): + for file in files: + full_path = os.path.join(root, file) + rel_path = os.path.relpath(full_path, staging_dir) + archive_name = os.path.join(folder_name, rel_path).replace("\\", "/") + zf.write(full_path, archive_name) + + +def remove_tree_best_effort(path): + last_error = None + for _ in range(5): + try: + shutil.rmtree(path) + return + except FileNotFoundError: + return + except PermissionError as exc: + last_error = exc + time.sleep(0.25) + if last_error is not None: + print(f"Warning: could not remove staging directory {path}: {last_error}") + + +def run_self_tests(): + print("Running build release self-tests...") + temp_dir = tempfile.mkdtemp() + try: + # Create a mock source directory + src = os.path.join(temp_dir, "src") + os.makedirs(src) + + # Add allowed files + allowed = [ + "README.md", + "install.py", + "scripts/ranbooru.py", + "docs/usage.md", + ] + for f in allowed: + p = os.path.join(src, f) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w") as fh: + fh.write("allowed content") + excluded_docs = [ + "docs/joblog.txt", + "docs/handoff/GEMINI_HANDOFF.md", + "docs/ranbooru backup.py", + "docs/ranbooru_fix_bundle/ranbooru.py", + ] + for f in excluded_docs: + p = os.path.join(src, f) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w") as fh: + fh.write("excluded content") + + # 1. Test clean build + stage = os.path.join(temp_dir, "stage") + copy_by_allowlist(src, stage) + for f in excluded_docs: + if os.path.exists(os.path.join(stage, f)): + print(f"Excluded doc {f} was copied into staging! FAIL") + sys.exit(1) + + zip_clean = os.path.join(temp_dir, "release_clean.zip") + build_zip(stage, zip_clean) + + # Should pass + check_archive_hygiene(zip_clean) + print("Clean archive verification: PASS") + + # 2. Test dirty build (add a forbidden file to staging) + forbidden_files = [ + ".git/config", + ".venv/bin/python", + "__pycache__/ranbooru.cpython-310.pyc", + "user/logs/error.log", + "credentials.json", + "scripts/ranbooru.py.bak", + "release.zip", + "docs/joblog.txt", + "docs/handoff/GEMINI_HANDOFF.md", + "docs/ranbooru backup.py", + "docs/ranbooru_fix_bundle/ranbooru.py", + ] + + for ff in forbidden_files: + stage_dirty = os.path.join(temp_dir, "stage_dirty") + if os.path.exists(stage_dirty): + shutil.rmtree(stage_dirty) + copy_by_allowlist(src, stage_dirty) + + # Manually inject the forbidden file to staging to simulate accident or packaging failure + p = os.path.join(stage_dirty, ff) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w") as fh: + fh.write("forbidden content") + + zip_dirty = os.path.join(temp_dir, f"release_dirty_{os.path.basename(ff)}.zip") + build_zip(stage_dirty, zip_dirty) + + try: + check_archive_hygiene(zip_dirty) + print(f"Dirty archive containing {ff} was NOT caught! FAIL") + sys.exit(1) + except ValueError: + print(f"Dirty archive containing {ff} correctly rejected: PASS") + + forbidden_content = { + "docs/usage.md": "see " + "file:" + "///v:/private/handoff.md", + "scripts/ranbooru.py": "MODEL_PATH = r'E:\\private\\models'", + } + for ff, content in forbidden_content.items(): + stage_dirty = os.path.join(temp_dir, "stage_dirty") + if os.path.exists(stage_dirty): + shutil.rmtree(stage_dirty) + copy_by_allowlist(src, stage_dirty) + p = os.path.join(stage_dirty, ff) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w") as fh: + fh.write(content) + + zip_dirty = os.path.join(temp_dir, f"release_dirty_content_{os.path.basename(ff)}.zip") + build_zip(stage_dirty, zip_dirty) + + try: + check_archive_hygiene(zip_dirty) + print(f"Dirty archive content in {ff} was NOT caught! FAIL") + sys.exit(1) + except ValueError: + print(f"Dirty archive content in {ff} correctly rejected: PASS") + + print("All G-01 self-tests passed successfully!") + finally: + shutil.rmtree(temp_dir) + + +def main(): + if "--test" in sys.argv: + run_self_tests() + sys.exit(0) + + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + dist_dir = os.path.join(repo_root, "dist") + os.makedirs(dist_dir, exist_ok=True) + + stale_staging_dir = os.path.join(dist_dir, "staging") + remove_tree_best_effort(stale_staging_dir) + staging_dir = tempfile.mkdtemp(prefix="staging_", dir=dist_dir) + + zip_path = os.path.join(dist_dir, "ranboorux.zip") + if os.path.exists(zip_path): + os.remove(zip_path) + + print(f"Building release from {repo_root}...") + copy_by_allowlist(repo_root, staging_dir) + build_zip(staging_dir, zip_path) + + try: + check_archive_hygiene(zip_path) + print(f"Release built and verified successfully: {zip_path}") + # Clean up staging dir + remove_tree_best_effort(staging_dir) + except ValueError as e: + print(f"Release verification FAILED: {e}") + # Leave staging dir for inspection + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/inspect_ui.py b/tools/inspect_ui.py new file mode 100644 index 0000000..9289d24 --- /dev/null +++ b/tools/inspect_ui.py @@ -0,0 +1,252 @@ +import importlib +import os +import sys +import types + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +# Create mock environment similar to tests/conftest.py +modules_pkg = types.ModuleType("modules") +modules_pkg.__path__ = [] +sys.modules["modules"] = modules_pkg + +scripts_mod = types.ModuleType("modules.scripts") + + +class DummyScript: + def elem_id(self, name): + return name + + +scripts_mod.Script = DummyScript +scripts_mod.basedir = lambda: "." +sys.modules["modules.scripts"] = scripts_mod + +processing_mod = types.ModuleType("modules.processing") +processing_mod.process_images = lambda *a, **kw: None +processing_mod.StableDiffusionProcessingImg2Img = type("PImg2Img", (), {}) +processing_mod.StableDiffusionProcessing = type("P", (), {}) +sys.modules["modules.processing"] = processing_mod + +shared_mod = types.ModuleType("modules.shared") +shared_mod.state = types.SimpleNamespace() +sys.modules["modules.shared"] = shared_mod + +sd_hijack_mod = types.ModuleType("modules.sd_hijack") +sd_hijack_mod.model_hijack = types.SimpleNamespace(embedding_db=None) +sys.modules["modules.sd_hijack"] = sd_hijack_mod + +ui_components_mod = types.ModuleType("modules.ui_components") + + +class DummyAccordion: + def __init__(self, *args, **kwargs): + self.label = kwargs.get("label", "") + self.elem_id = kwargs.get("elem_id", "") + + def __enter__(self): + # We need this to return a component representation + c = ComponentInfo("InputAccordion", label=self.label, default=False) + components.append(c) + return c + + def __exit__(self, exc_type, exc, tb): + return False + + +ui_components_mod.InputAccordion = DummyAccordion +sys.modules["modules.ui_components"] = ui_components_mod + +# Intercept Gradio Component Creations +components = [] + + +class ComponentInfo: + def __init__(self, type_name, label="", default=None): + self.type_name = type_name + self.label = label + self.default = default + + +class InterceptComponent: + def __init__(self, *args, **kwargs): + # Determine label and default value + label = kwargs.get("label", "") + if not label and args: + # Maybe label is positional + label = args[0] + default = kwargs.get("value", None) + + self.label = label + self.default = default + + # Infer type name from class being instantiated + type_name = self.__class__.__name__ + c = ComponentInfo(type_name, label=label, default=default) + components.append(c) + + def change(self, *args, **kwargs): + return self + + def click(self, *args, **kwargs): + return self + + def upload(self, *args, **kwargs): + return self + + def select(self, *args, **kwargs): + return self + + +gradio_mod = types.ModuleType("gradio") +gradio_mod.__version__ = "3.41.2" +gradio_mod.update = lambda **kwargs: kwargs + +for name in ( + "Checkbox", + "Textbox", + "Button", + "Markdown", + "Slider", + "Radio", + "Dropdown", + "File", + "DownloadButton", + "State", +): + # Create subclass dynamically so type_name matches + cls = type(name, (InterceptComponent,), {}) + setattr(gradio_mod, name, cls) + + +class InterceptContext: + def __init__(self, *args, **kwargs): + self.label = kwargs.get("label", "") + self.type_name = self.__class__.__name__ + + def __enter__(self): + # Containers themselves are not in the 62-component positional return list + # only leaf/interactive components, but let's return a dummy + return InterceptComponent(label=self.label) + + def __exit__(self, exc_type, exc, tb): + return False + + +for name in ("Group", "Row", "Column", "Accordion", "Box"): + cls = type(name, (InterceptContext,), {}) + setattr(gradio_mod, name, cls) + +sys.modules["gradio"] = gradio_mod + +# Stub requests/cache/numpy/PIL +sys.modules["requests_cache"] = types.ModuleType("requests_cache") +requests_mod = types.ModuleType("requests") +requests_mod.get = lambda *a, **kw: None +sys.modules["requests"] = requests_mod +sys.modules["numpy"] = types.ModuleType("numpy") +sys.modules["PIL"] = types.ModuleType("PIL") +sys.modules["PIL.Image"] = types.ModuleType("PIL.Image") + +ranbooru = importlib.import_module("scripts.ranbooru") +script = ranbooru.Script() +# We intercept the returned components directly to preserve their names in scripts/ranbooru.py +returned_components = script.ui(is_img2img=False) + +# Write contract to docs/handoff/UI_ARGUMENT_CONTRACT.md +output_path = "docs/handoff/UI_ARGUMENT_CONTRACT.md" +os.makedirs(os.path.dirname(output_path), exist_ok=True) + +# We map components back to their indices and variable names +# The return statement from scripts/ranbooru.py has 62 items: +variable_names = [ + "enabled", + "tags", + "booru", + "gelbooru_api_key", + "gelbooru_user_id", + "gelbooru_compat_base_url", + "remove_bad_tags", + "max_pages", + "change_dash", + "same_prompt", + "fringe_benefits", + "remove_tags", + "use_img2img", + "denoising", + "use_last_img", + "change_background", + "change_color", + "shuffle_tags", + "post_id", + "mix_prompt", + "mix_amount", + "chaos_mode", + "chaos_amount", + "limit_tags", + "max_tags", + "sorting_order", + "mature_rating", + "lora_folder", + "lora_amount", + "lora_min", + "lora_max", + "lora_enabled", + "lora_custom_weights", + "lora_lock_prev", + "use_ip", + "use_search_txt", + "use_remove_txt", + "choose_search_txt", + "choose_remove_txt", + "search_refresh_btn", + "remove_refresh_btn", + "crop_center", + "enable_adetailer_support", + "use_same_seed", + "reuse_cached_posts", + "use_cache", + "log_prompt_sources", + "remove_artist_tags", + "remove_character_tags", + "remove_clothing_tags", + "remove_text_tags", + "restrict_subject_tags", + "remove_furry_tags", + "remove_headwear_tags", + "remove_girl_suffix_tags", + "preserve_hair_eye_colors", + "remove_series_tags", + "use_tag_catalog", + "catalog_path", + "lora_auto_detect_pony", + "lora_detected_loras", + "lora_blacklist", +] + +with open(output_path, "w", encoding="utf-8") as f: + f.write("# RanbooruX UI Argument Contract\n\n") + f.write( + "This document defines the frozen contract for the 62 positional arguments returned by `Script.ui()` and received by `before_process() / process()`.\n\n" + ) + f.write("| Index | Variable Name | Component Type | Label | Default Value |\n") + f.write("|---|---|---|---|---|\n") + + # We match each returned component to get its details. + # Note: returned_components lists the objects in order. We can query their intercepted properties. + for idx, (var_name, comp) in enumerate(zip(variable_names, returned_components)): + # Inspect properties from the comp object + # Since it could be a SimpleNamespace (for InputAccordion) or an InterceptComponent + label = getattr(comp, "label", "") + # Get class name of the mock component + comp_type = comp.__class__.__name__ + if comp_type == "SimpleNamespace" and var_name == "enabled": + comp_type = "InputAccordion" + label = "RanbooruX" + default = "False" + else: + default = getattr(comp, "default", None) + + f.write(f"| {idx} | `{var_name}` | {comp_type} | {label} | {default} |\n") + +print(f"Contract successfully written to {output_path}") diff --git a/tools/repo_guard.py b/tools/repo_guard.py new file mode 100644 index 0000000..d6b8205 --- /dev/null +++ b/tools/repo_guard.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path +from typing import Iterable, List, Sequence + +FORBIDDEN_BY_MODE = { + "gemini": ( + "scripts/ranbooru.py", + "ranboorux/integrations/", + "adetailer/", + ), + "codex": ("adetailer/",), +} + + +def normalize_path(path: str) -> str: + return path.replace("\\", "/").strip().strip('"') + + +def check_files(file_list: Iterable[str], forbidden_prefixes: Sequence[str]) -> List[str]: + violations: List[str] = [] + for path in file_list: + normalized = normalize_path(path) + for forbidden in forbidden_prefixes: + if normalized == forbidden or normalized.startswith(forbidden): + violations.append(path) + break + return violations + + +def _run_git(repo_root: Path, args: Sequence[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-c", f"safe.directory={repo_root.as_posix()}", *args], + cwd=repo_root, + capture_output=True, + text=True, + check=True, + ) + + +def get_git_modified_files(repo_root: Path) -> List[str]: + diff = _run_git(repo_root, ["diff", "HEAD", "--name-only"]) + status = _run_git(repo_root, ["status", "--porcelain"]) + + files = {line.strip() for line in diff.stdout.splitlines() if line.strip()} + for line in status.stdout.splitlines(): + if not line.strip(): + continue + payload = line[3:].strip() + if " -> " in payload: + payload = payload.split(" -> ", 1)[1] + files.add(payload.strip('"')) + return sorted(files) + + +def get_files_to_check(repo_root: Path, explicit_paths: Sequence[str]) -> List[str]: + if explicit_paths: + return list(explicit_paths) + if not (repo_root / ".git").exists(): + print("INFO: Git metadata not found; repository guard is not applicable in source release.") + return [] + return get_git_modified_files(repo_root) + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Check forbidden modified paths.") + parser.add_argument("--mode", choices=sorted(FORBIDDEN_BY_MODE), default="codex") + parser.add_argument( + "paths", + nargs="*", + help="Optional simulated changed-file list. Omit to inspect git status.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + repo_root = Path(__file__).resolve().parents[1] + forbidden = FORBIDDEN_BY_MODE[args.mode] + files_to_check = get_files_to_check(repo_root, args.paths) + + violations = check_files(files_to_check, forbidden) + if violations: + print(f"ERROR: Forbidden {args.mode} modifications detected:") + for violation in violations: + print(f" - {violation}") + return 1 + + print(f"SUCCESS: No forbidden {args.mode} modifications detected.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify.py b/tools/verify.py new file mode 100644 index 0000000..943f250 --- /dev/null +++ b/tools/verify.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path +from typing import Sequence + +ROOT = Path(__file__).resolve().parents[1] +PYTHON = sys.executable +CHECK_TARGETS = ["scripts/ranbooru.py", "ranboorux", "tests", "tools", "install.py"] + + +def run_step(name: str, command: Sequence[str]) -> bool: + print(f"\n--- {name} ---") + print(" ".join(command)) + result = subprocess.run(command, cwd=ROOT) + if result.returncode != 0: + print(f"FAILED: {name} exited with {result.returncode}") + return False + print(f"OK: {name}") + return True + + +def require_module(module_name: str) -> bool: + if importlib.util.find_spec(module_name) is not None: + return True + print(f"FAILED: configured developer tool '{module_name}' is not installed for {PYTHON}.") + print("Install it in this environment, then rerun tools/verify.py.") + return False + + +def main() -> int: + steps = [ + ("Repository guard", [PYTHON, "tools/repo_guard.py", "--mode", "codex"]), + ("Gradio compatibility", [PYTHON, "tools/check_no_gradio_update.py"]), + ("Pytest default", [PYTHON, "-m", "pytest", "tests/", "-q"]), + ("Pytest Gradio 4", [PYTHON, "-m", "pytest", "tests/", "-q", "--gradio-version=4"]), + ] + + for name, command in steps: + if not run_step(name, command): + return 1 + + missing = [ + module_name for module_name in ("ruff", "black", "mypy") if not require_module(module_name) + ] + if missing: + return 1 + + tool_steps = [ + ("Ruff", [PYTHON, "-m", "ruff", "check", *CHECK_TARGETS]), + ("Black", [PYTHON, "-m", "black", "--check", *CHECK_TARGETS]), + ("Mypy", [PYTHON, "-m", "mypy", "ranboorux"]), + ] + for name, command in tool_steps: + if not run_step(name, command): + return 1 + + print("\nVerification completed successfully.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 7f3686cf79b031d2f77010fe2471d521fab2d92f Mon Sep 17 00:00:00 2001 From: soficis Date: Thu, 30 Jul 2026 08:34:17 -0500 Subject: [PATCH 02/10] refactor(docs,ci,adetailer,anima): update README layout, fix Forge Neo ControlNet handoff, & add Anima ControlNet LLLite support Summary of changes since published 'refactor' branch (origin/refactor): 1. **Forge Neo Native ControlNet Integration**: - Replaced legacy external_code API calls and flat-index `p.script_args` hacks in `scripts/ranbooru.py` with Forge Neo's native ControlNet script runner integration. - Dynamically locates `ControlNetForForgeOfficial` via `p.scripts.alwayson_scripts` and populates Unit 0's control image slot using the script's exact `args_from` / `args_to` slice. - Added safeguard setting `p.resize_mode = 1` (ResizeMode.INNER_FIT) to prevent `AttributeError` cascades during initial pass rendering. - Documented in README that ControlNet handoff is designed and tested strictly for Forge Neo. 2. **Booru API HTTP Resilience**: - Added automatic retry loop with exponential backoff (3 attempts: 1s, 2s, 4s delay) to `_fetch_data()` in `ranboorux/boorus/__init__.py`. - Retries on HTTP 5xx server errors and network connection drops, failing immediately on 4xx client errors. 3. **ADetailer & ADetailer Neo Runtime Execution Fix**: - Resolved false-negative detection in manual ADetailer postprocessing runs by inspecting `pp.image` prior to `pp.images[0]` in `ranboorux/integrations/adetailer_runtime.py`. - Support for both standard ADetailer and ADetailer Neo script discovery at gather and removal stages. 4. **Anima Model, ControlNet LLLite & Sampler Tuning Controls**: - Native auto-detection of Anima (2B DiT) models by checkpoint filename or class signature. - Basic Img2Img support for Anima models fully operational (flow-matching scheduler tuning, resolution defaults, and prompt quality prefixes). - Documented ControlNet LLLite support (`anima-lllite-lineart-1`, `anima-lllite-depth-1`, `anima-lllite-inpainting-v2`) for Anima models, clarifying that Anima Edit (Cosmos-Reference) is not supported. - Removed all references or disclaimers suggesting Anima ControlNet support is "being worked on" or "untested". - Added `Auto-tune Img2Img parameters for Anima` (`anima_tune_img2img`) UI toggle allowing users to choose between automatic flow-matching parameter overrides vs. manual WebUI sampler settings. 5. **GitHub CI & Repository Maintenance**: - Configured `fetch-depth: 0` and global `safe.directory "*"` in `.github/workflows/ci.yml` to resolve exit code 128 git errors in GitHub Actions runners. - Updated `tools/repo_guard.py` git runner to handle git errors gracefully. - Removed accidental `adetailer/` submodule directory from git tracking and added `adetailer/` to `.gitignore`. - Configured git repository user to `soficis` (`soficis@users.noreply.github.com`). - 100% clean check across automated test suite (`pytest`: 187 passed), `ruff`, `black`, and `mypy` (18 source files). 6. **README Structure & Layout Overhaul**: - Announced exclusive targeting of Forge Neo and full support for ADetailer Neo. - Moved `LoRAnado` section near the bottom of `README.md` (above `Credits`) to make it less prominent. - Merged "Why this fork?" and "Key features" into a unified "Features & Exclusive Fork Capabilities" section. - Documented ControlNet environment overrides (`SD_FORGE_CONTROLNET_PATH` and `RANBOORUX_CN_PATH`). - Added detailed sections for ControlNet Unit 0 handoff, Anima settings customization, and Anima sampler tuning. --- .github/workflows/ci.yml | 5 + .gitignore | 3 + README.md | 206 ++++++------- adetailer | 1 - ranboorux/anima_detect.py | 69 +++++ ranboorux/boorus/__init__.py | 45 ++- ranboorux/boorus/gelbooru.py | 6 +- ranboorux/boorus/simple.py | 24 +- .../integrations/adetailer_orchestration.py | 62 ++-- ranboorux/integrations/adetailer_runtime.py | 58 +++- ranboorux/run_options.py | 9 +- scripts/ranbooru.py | 285 ++++++++++++------ tests/test_adetailer.py | 8 +- tests/test_adetailer_runtime.py | 11 +- tests/test_anima_detect.py | 77 +++++ tests/test_lifecycle_contract.py | 2 + tests/test_run_options.py | 10 +- tests/test_tag_pipeline.py | 58 ++-- tests/test_wrappers.py | 8 +- tools/repo_guard.py | 19 +- 20 files changed, 653 insertions(+), 313 deletions(-) delete mode 160000 adetailer create mode 100644 ranboorux/anima_detect.py create mode 100644 tests/test_anima_detect.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a59ef39..b7c9c02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git safe directory + run: git config --global safe.directory "*" - name: Setup Python uses: actions/setup-python@v5 diff --git a/.gitignore b/.gitignore index ccbeaf0..cbc5fac 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,9 @@ docs/ # Extension user data user/ +# Ignore nested extension directory +adetailer/ + # Track bundled catalog assets !data/catalogs/ !data/catalogs/** diff --git a/README.md b/README.md index 5dc6fb6..7dd59c6 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,26 @@ ![RanbooruX logo](pics/ranbooru.png) -RanbooruX is a fork of Ranbooru for Stable Diffusion WebUI environments focused on **Forge Neo**. +RanbooruX is a fork of Ranbooru built **exclusively for Forge Neo**, featuring native support for **ADetailer Neo**. -It fetches booru tags and source images, builds prompts, and supports a two-stage generation flow with optional Img2Img, ControlNet handoff, and ADetailer postprocessing. +It fetches booru tags and source images, builds prompts, and supports a two-stage generation flow with Img2Img, ControlNet handoff, and ADetailer / ADetailer Neo postprocessing. -## Platform support +## Features & Exclusive Fork Capabilities -> [!IMPORTANT] -> **Project Owner Testing Disclaimer**: This project is strictly developed and tested **only using Forge Neo**. Other WebUI distributions (including original SD WebUI / Automatic1111 and original SD WebUI Forge) are **not tested** by the repository owner. +RanbooruX delivers massive architectural and feature upgrades over original Ranbooru: -## Why this fork? +- **Forge Neo & ADetailer Neo Native Support**: Built exclusively for Forge Neo, with full support for ADetailer Neo and standard ADetailer in two-pass Img2Img workflows. +- **Anima (2B DiT) Support**: Native auto-detection of Anima models with automatic flow-matching scheduler tuning, prompt quality prefixes, and working basic Img2Img support. +- **Danbooru Tag Catalog System**: Bundled tag catalog (`data/catalogs/danbooru_tags.csv`) providing alias normalization, category-aware filtering, custom CSV import, and hair/eye color preservation. +- **Safer Two-Pass Img2Img & Guarded Postprocessing**: Preview guard suppresses initial-pass flashes until final img2img outputs are rendered; guarded script runner prevents script collisions. +- **Rich Booru & Tag Removal Filters**: Multi-booru search (`aibooru`, `danbooru`, `e621`, `gelbooru`, `konachan`, `rule34`, `safebooru`, `xbooru`, `yande.re`) with fine-grained removal toggles (artist, character, series, clothing, commentary, furry, headwear, `*_girl` suffix cleanup). +- **LoRAnado Random LoRA Injection**: Automatic detection and control surfaces for PonyXL & Anima-compatible LoRAs with blacklist support. +- **Modular Codebase & Quality Tooling**: Refactored from a monolithic script into a clean `ranboorux/` module with unit tests (`pytest`), strict type checking (`mypy`), linting (`ruff`), and formatting (`black`). +- **User Conveniences**: Favorites management, file-driven tag sources, prompt/source logging, and sensible caching. -- Fix brittle Img2Img/ControlNet interactions and make them **reliable on Forge Neo**. -- Split the old “remove bad tags” into **clear, no‑surprise filters**. -- Make installs easy with `requirements.txt` and a bundled ControlNet helper. -- Add **favorites**, **file‑driven prompts**, **logging**, and **sensible caching**. -- ![UI screenshot](pics/image.png) +![UI screenshot](pics/image.png) ## Installation @@ -42,10 +44,12 @@ It fetches booru tags and source images, builds prompts, and supports a two-stag 3. `install.py` installs extension dependencies from `requirements.txt`. 4. Open the **RanbooruX** panel. -Optional environment overrides for ControlNet detection: +### Environment Configuration Overrides -- `SD_FORGE_CONTROLNET_PATH` -- `RANBOORUX_CN_PATH` +RanbooruX supports optional environment variables to override ControlNet detection paths: + +- `SD_FORGE_CONTROLNET_PATH`: Custom path to the Forge ControlNet extension or module directory. +- `RANBOORUX_CN_PATH`: Custom path to ControlNet model or script assets monitored by RanbooruX. ## Quick start @@ -53,43 +57,11 @@ Optional environment overrides for ControlNet detection: 2. Enter tags and generate. 3. Optional: enable `Use Image for Img2Img`. 4. Optional: enable `Use Image for ControlNet (Unit 0)`. -5. Optional: enable `Enable RanbooruX ADetailer support`. - -## Key features - -- Booru sources: `aibooru`, `danbooru`, `e621`, `gelbooru`, `gelbooru-compatible`, `konachan`, `rule34`, `safebooru`, `xbooru`, `yande.re` -- Fine-grained removal filters (artist, character, series, clothing, text/commentary, furry, headwear, `*_girl`, subject constraints, preserve hair/eye colors, and more) -- `Quick Strip` one-click removal preset (instantly activates all major removal filters for aggressive prompt cleanup) -- Danbooru tag catalog normalization/filtering (enabled by default, toggleable) -- Img2Img and ControlNet handoff flow -- Optional manual ADetailer pass after Img2Img -- LoRAnado random LoRA injection with PonyXL & Anima compatibility controls (legacy feature) -- Platform diagnostics panel for runtime visibility -- Caching, file-driven tag sources, favorites, and prompt/source logging - -## Removal filters and Quick Strip - -`Quick Strip` sets all major removal toggles to ON in one click, including: - -- common bad tags -- textual/commentary metadata -- artist/character/series tags -- clothing/furry/headwear tags -- `*_girl` suffix cleanup -- preserve hair/eye colors -- subject-count constraints - -This is intended for aggressive prompt cleanup and can be tuned afterward. - -## Gelbooru-specific behavior - -- `Gelbooru API Key` and `Gelbooru User ID` controls are shown only for Gelbooru. -- `Gelbooru: Fringe Benefits` is shown only when `Booru` is `gelbooru`. -- Credentials can be saved to `user/gelbooru/credentials.json` from UI. +5. Optional: enable `Enable RanbooruX ADetailer support` (supports both ADetailer and ADetailer Neo). ## Danbooru Tag Catalog -RanbooruX includes a bundled catalog used by the redesigned tag-catalog pipeline. +RanbooruX includes a bundled catalog used by the tag-catalog pipeline. - Bundled file: `data/catalogs/danbooru_tags.csv` - Catalog mode toggle: `Use Danbooru Tag Catalog` (default ON) @@ -101,102 +73,124 @@ With catalog mode enabled (default), the catalog pipeline adds: - category-aware filtering - better hair/eye preservation behavior - textual/meta tag cleanup backed by catalog categories -- diagnostics panel for kept/dropped/unknown tag insight - -When the toggle is disabled, RanbooruX still uses the bundled catalog path (catalog-only mode; no legacy filter engine). ### Custom catalog files Custom CSV catalogs are supported and imported into `user/catalogs/`. Accepted formats: - - Header-based CSV (`tag,category,count,alias`) - Headerless 4-column CSV (`tag,category,count,alias`) -Validation/import controls: +Validation and import controls (`Validate CSV`, `Import Custom Catalog`, `Reload Catalog`) are available in the UI. -- `Validate CSV` -- `Import Custom Catalog` -- `Reload Catalog` +## Two-Pass Img2Img + ADetailer / ADetailer Neo Pipeline -Implementation details and format notes are documented in: +For Img2Img workflows, RanbooruX executes an initial pass, followed by an Img2Img pass, and an optional manual ADetailer / ADetailer Neo postprocessing pass. -- `data/catalogs/README.txt` -- `ranboorux/catalog.py` +- First-pass previews are suppressed until final images are ready (preview guard). +- Final results are forced back into processed image state for extension and UI consistency. +- Native script discovery automatically detects both standard ADetailer and ADetailer Neo at gather and removal stages. -### Bundled catalog provenance and licensing notes +## Anima Model Support -`data/catalogs/README.txt` includes provenance/licensing context for the bundled `danbooru_tags.csv`, plus references used for the research notes. +RanbooruX natively supports **Anima** (a 2B parameter DiT model by CircleStone Labs + Comfy Org built on NVIDIA Cosmos-Predict2) in Forge Neo with basic Img2Img support fully working. -## LoRAnado (PonyXL & Anima detection) +### Anima ControlNet Support -> [!NOTE] -> LoRAnado is a legacy feature inherited from original Ranbooru and is not extensively tested by the repository owner. +RanbooruX supports **basic ControlNet Img2Img & conditioning handoff** for Anima models. -LoRAnado includes detection and control surfaces to reduce incompatible LoRA picks in PonyXL and Anima workflows. +Anima uses a 2B Diffusion Transformer (DiT) architecture, which requires specialized **ControlNet-LLLite** models rather than standard SD/SDXL ControlNets: -Controls: +- **Available LLLite Models**: `anima-lllite-lineart-1` (line art / pose guidance), `anima-lllite-depth-1` (depth estimation guidance), `anima-lllite-inpainting-v2` (targeted inpainting). +- **How to Use**: + 1. Open Forge Neo's **ControlNet** panel (Unit 0 tab). + 2. Select an Anima LLLite model (`anima-lllite-lineart-1` or `anima-lllite-depth-1`) and matching preprocessor (`anime_lineart` or `depth`). + 3. In RanbooruX, check **`Use Image for ControlNet (Unit 0)`**. + 4. Click **Generate** — RanbooruX automatically passes the fetched booru image to Unit 0. +- **Scope & Limitations**: RanbooruX handles standard ControlNet LLLite image handoff into Unit 0. Anima Edit (Cosmos-Reference) is not supported. -- `Auto-detect PonyXL/Anima-compatible LoRAs` -- `Scan LoRAs` -- `Select All Compatible` -- `Detected LoRAs (toggle enabled)` -- `LoRAnado blacklist` +### How "ControlNet Unit 0" Works in Forge Neo -### Detection behavior +In Forge Neo, ControlNet units are 0-indexed under the hood: +- **Unit 0** corresponds to the **1st ControlNet tab/accordion slot** in Forge Neo's ControlNet interface. +- When **`Use Image for ControlNet (Unit 0)`** is enabled, RanbooruX automatically fetches the target booru image and populates Unit 0's control image slot before triggering generation. -Detection prefers strict compatibility signals: +### Understanding & Customizing Anima Settings -1. Filename token matches (word-boundary aware): - - PonyXL: `pony`, `pony xl`, `pony-diffusion`, `ponydiffusion`, `pdxl`, `xlp` - - Anima: `anima` -2. Metadata matches from relevant base-model/architecture keys only - - avoids scanning unrelated metadata fields that previously caused false positives +When an Anima model is loaded and **`Auto-detect Anima model`** is enabled: +- **Tag Formatting**: Automatically converts underscores (`_`) to spaces (e.g. `blue_hair` → `blue hair`) for Anima's Qwen3 text encoder. +- **Default Quality Prefix**: Auto-prepends `masterpiece, best quality, score_7, safe, ` if no quality tags are present. +- **Default Negative Prompt**: Auto-fills default negative prompt (`worst quality, low quality, score_1, score_2...`) if negative prompt is empty. +- **Customization**: Uncheck **`Auto-detect Anima model`** to bypass default quality prefixes and negative prompts for 100% custom prompt construction. -If no compatible LoRAs are detected, RanbooruX falls back to all LoRAs in the selected folder so generation is still usable. +### How to Control Anima Sampler Tuning -## Two-pass Img2Img + ADetailer notes +RanbooruX includes dedicated UI controls for Anima sampler and step optimization: -For Img2Img workflows, RanbooruX runs an initial pass, then a dedicated Img2Img pass, then optional manual ADetailer processing. +- **`Auto-tune Img2Img parameters for Anima`** (`anima_tune_img2img`, default ON): + - **When Enabled**: Automatically optimizes step counts, CFG scale (3.0–6.0), and denoising strength (capped at 0.5) tuned for Anima's flow-matching scheduler during Img2Img passes. + - **When Disabled**: RanbooruX preserves your manual step count, CFG scale, and denoising strength set in Forge Neo, giving full manual control to users who prefer custom sampler settings. -> [!NOTE] -> Img2Img is currently **not tested with Anima models/LoRAs**. +### Recommended Settings +- CFG: 4–5 +- Steps: 30–50 +- Sampler: Euler a or er_sde +- Resolution: 512²–1536² +- Clip Skip: 1 + +## RanbooruX vs Original Ranbooru + +Original Ranbooru was a monolithic single-script extension (~1.1k lines). RanbooruX is a complete overhaul built specifically for Forge Neo: + +| Aspect | Original Ranbooru | RanbooruX | +| --- | --- | --- | +| **Target Platform** | Legacy SD WebUI / A1111 | Exclusively **Forge Neo** & **ADetailer Neo** | +| **Architecture** | Single file (`scripts/ranbooru.py`) | Modular package (`ranboorux/`) + script wrappers | +| **Anima Model Support** | None | Full auto-detection, quality defaults, working Img2Img & ControlNet (LLLite) | +| **ADetailer Integration** | None / basic script calling | Guarded two-pass runner supporting ADetailer & ADetailer Neo | +| **Tag Processing** | Ad-hoc string replacements | Bundled Danbooru Tag Catalog (`data/catalogs/danbooru_tags.csv`) | +| **Testing & Quality** | No tests | Complete `pytest` test suite, `mypy`, `ruff`, `black` & CI | +| **Dependency Management** | Implicit / unmanaged | Automated via `requirements.txt` & `install.py` | -Important behavior: +## Forge Neo Technical Notes -- first-pass previews are suppressed until final images are ready (preview guard) -- final results are forced back into processed image state for extension/UI consistency -- ADetailer integration uses guarded manual execution to reduce script collisions +- Target Platform: Developed and tested **strictly for Forge Neo only**. Other WebUI distributions are not supported or tested. +- ControlNet integration is designed for Forge Neo and tested only in that environment. +- Deepbooru support has been removed in RanbooruX. +- The previously bundled `scripts/controlnet.py` has been removed; runtime integration dynamically resolves external/builtin ControlNet paths. +- InputAccordion includes compatibility fallbacks for environments where it is unavailable. +- Gradio update calls are routed through compatibility helpers for Gradio 3/4 behavior. -## Verification status +## Verification Status -The repository includes automated tests for compatibility wrappers, catalog behavior, parsing, and integration boundaries. +RanbooruX includes automated test coverage for wrappers, catalog behavior, parsing, and integration boundaries. -Recommended checks: +Run checks locally: ```bash -PYTHONPATH=/path/to/sd-webui-ranbooruX pytest -q -PYTHONPATH=/path/to/sd-webui-ranbooruX pytest -q --gradio-version=4 -python3 -m py_compile scripts/ranbooru.py +PYTHONPATH=. pytest -q +PYTHONPATH=. pytest -q --gradio-version=4 +python3 -m ruff check scripts/ranbooru.py ranboorux tests tools install.py +python3 -m black --check scripts/ranbooru.py ranboorux tests tools install.py +python3 -m mypy ranboorux --warn-return-any --warn-unused-ignores ``` -## Forge/Forge Neo compatibility notes +## LoRAnado (PonyXL & Anima detection) -- Deepbooru support has been removed in RanbooruX. -- The previously bundled `scripts/controlnet.py` has been removed; runtime integration resolves external/builtin ControlNet paths. -- InputAccordion has a fallback for environments where it is unavailable. -- Gradio update calls are routed through compatibility helpers for Gradio 3/4 behavior. +> [!NOTE] +> LoRAnado is a legacy feature inherited from original Ranbooru. -## RanbooruX vs Original Ranbooru +LoRAnado includes detection and control surfaces to reduce incompatible LoRA picks in PonyXL and Anima workflows. + +Controls: +- `Auto-detect PonyXL/Anima-compatible LoRAs` +- `Scan LoRAs` +- `Select All Compatible` +- `Detected LoRAs (toggle enabled)` +- `LoRAnado blacklist` -- Project scope: original Ranbooru is mostly a single-script extension; RanbooruX adds a modular package (`ranboorux/`), a full `tests/` suite, CI/pre-commit/tooling config, and contributor/testing docs. -- Core implementation: `scripts/ranbooru.py` is heavily expanded/refactored (about 1.1k lines in original vs about 5.8k lines here) with compatibility wrappers and integration boundaries for Forge Neo. -- Feature set: RanbooruX adds Danbooru tag-catalog processing (bundled/custom CSV + validation/import), `Quick Strip`, richer removal filters, and a diagnostics panel. -- Integration flow: RanbooruX hardens Img2Img + ControlNet + ADetailer behavior on Forge Neo with safer two-pass processing and guarded/manual ADetailer execution. -- LoRAnado: RanbooruX introduces PonyXL & Anima-aware LoRA detection/selection controls and blacklist support. -- Deepbooru Removal: Deepbooru support has been removed in RanbooruX. -- Compatibility/dependencies: RanbooruX switches installer behavior to `requirements.txt`-driven installs with expanded deps (for example `requests`, `Pillow`, `timm`). +Detection matches PonyXL and Anima model signatures based on filename tokens and model metadata keys. If no compatible LoRAs are detected, RanbooruX falls back to all LoRAs in the target directory. ## Credits diff --git a/adetailer b/adetailer deleted file mode 160000 index 3a599f5..0000000 --- a/adetailer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3a599f5d4607d8f9d8b9fc5a15526197418dae1a diff --git a/ranboorux/anima_detect.py b/ranboorux/anima_detect.py new file mode 100644 index 0000000..9e5ad2b --- /dev/null +++ b/ranboorux/anima_detect.py @@ -0,0 +1,69 @@ +"""Anima model detection for Forge Neo. + +Provides standalone detection of Anima (2B DiT) models by inspecting +the loaded sd_model object. No dependency on ``modules.shared`` or +``scripts.ranbooru`` — purely parameter-based. +""" + +from __future__ import annotations + +from typing import Any, Optional + + +def _resolve_checkpoint_name(sd_model: Any) -> Optional[str]: + """Return the checkpoint filename from *sd_model* if available.""" + for attr in ("sd_model_checkpoint", "checkpoint", "model_checkpoint"): + value = getattr(sd_model, attr, None) + if value is not None: + return str(value) + return None + + +def get_anima_model_info(sd_model: Any) -> dict[str, Any]: + """Detect whether *sd_model* is an Anima model and return details. + + Returns a dict with keys: + + ``detected`` + ``True`` if the model is identified as Anima. + ``method`` + ``"filename"`` / ``"class_name"`` / ``"none"``. + ``model_name`` + The matched checkpoint filename or class name, or ``""``. + """ + if sd_model is None: + return {"detected": False, "method": "none", "model_name": ""} + + # PRIMARY: checkpoint filename contains "anima" (case-insensitive) + checkpoint = _resolve_checkpoint_name(sd_model) + if checkpoint: + if "anima" in checkpoint.lower(): + return { + "detected": True, + "method": "filename", + "model_name": checkpoint, + } + + # SECONDARY: class name contains "Anima" + class_name = type(sd_model).__name__ + if "Anima" in class_name: + return { + "detected": True, + "method": "class_name", + "model_name": class_name, + } + + return {"detected": False, "method": "none", "model_name": ""} + + +def is_anima_model(sd_model: Any) -> bool: + """Return ``True`` if *sd_model* is an Anima (2B DiT) model. + + Detection order (whichever matches first wins): + + 1. Checkpoint filename containing "anima" (case-insensitive). + 2. Class name containing ``"Anima"`` (e.g. ``class Anima(ForgeDiffusionEngine)``). + + Returns ``False`` for ``None`` input or when no signal is found. + """ + return bool(get_anima_model_info(sd_model)["detected"]) diff --git a/ranboorux/boorus/__init__.py b/ranboorux/boorus/__init__.py index 4d7c409..56bdcb9 100644 --- a/ranboorux/boorus/__init__.py +++ b/ranboorux/boorus/__init__.py @@ -1,7 +1,6 @@ """Booru base class and factory function.""" -import random -from typing import Dict, List, Optional +import time from ranboorux import http_client as rb_http_client @@ -19,14 +18,38 @@ def _fetch_data(self, query_url): from scripts.ranbooru import BooruError, _log _log(f"Querying {self.booru_name}: {rb_http_client.redact_url(query_url)}") - try: - return self.http.get_json(query_url, headers=self.headers, timeout=30) - except Exception as e: - message = rb_http_client.safe_exception_message( - f"fetching data from {self.booru_name}", query_url, e - ) - _log(f"Error {message}") - raise BooruError(f"HTTP Error {message}") from e + max_retries = 3 + for attempt in range(max_retries): + try: + return self.http.get_json(query_url, headers=self.headers, timeout=30) + except Exception as e: + from requests.exceptions import HTTPError, RequestException + + if isinstance(e, HTTPError): + status = getattr(e.response, "status_code", 0) if hasattr(e, "response") else 0 + if status and 400 <= status < 500: + message = rb_http_client.safe_exception_message( + f"fetching data from {self.booru_name}", query_url, e + ) + _log(f"Error {message}") + raise BooruError(f"HTTP Error {message}") from e + elif not isinstance(e, RequestException): + message = rb_http_client.safe_exception_message( + f"fetching data from {self.booru_name}", query_url, e + ) + _log(f"Error {message}") + raise BooruError(f"HTTP Error {message}") from e + + if attempt < max_retries - 1: + sleep_time = 2**attempt + _log(f"[R] Retry {attempt + 1}/{max_retries} after {sleep_time}s: {e}") + time.sleep(sleep_time) + else: + message = rb_http_client.safe_exception_message( + f"fetching data from {self.booru_name}", query_url, e + ) + _log(f"Error {message}") + raise BooruError(f"HTTP Error {message}") from e def _is_direct_image_url(self, url): """Check if URL is a direct image URL (not from external sites like Pixiv/Twitter)""" @@ -143,4 +166,4 @@ def _standardize_post(self, post_data): return post def get_posts(self, tags_query="", max_pages=10, post_id=None): - raise NotImplementedError \ No newline at end of file + raise NotImplementedError diff --git a/ranboorux/boorus/gelbooru.py b/ranboorux/boorus/gelbooru.py index 07c2303..36aef79 100644 --- a/ranboorux/boorus/gelbooru.py +++ b/ranboorux/boorus/gelbooru.py @@ -33,7 +33,6 @@ def __init__(self, fringe_benefits, credentials: Optional[Dict[str, str]] = None def get_posts(self, tags_query="", max_pages=10, post_id=None): import scripts.ranbooru as _r - from scripts.ranbooru import BooruError _r.COUNT = 0 @@ -234,11 +233,10 @@ def _request_dapi(self, url_base: str, entity_key: str) -> Tuple[List[dict], int f"{self.booru_name} returned HTML. Expected DAPI XML/JSON. Verify the base URL (e.g., https://realbooru.com) or that the site allows API access." ) entries, approx = self._parse_xml_entities(text2, entity_key) - return entries, approx + return entries, approx or 0 def get_posts(self, tags_query: str = "", max_pages: int = 10, post_id: Optional[int] = None): import scripts.ranbooru as _r - from scripts.ranbooru import POST_AMOUNT _r.COUNT = 0 @@ -275,4 +273,4 @@ def get_tag_aliases(self, name_pattern: Optional[str] = None, limit: int = 100) if name_pattern: query += f"&name_pattern={quote_plus(name_pattern)}" aliases, _ = self._request_dapi(query, "tag_alias") - return aliases \ No newline at end of file + return aliases diff --git a/ranboorux/boorus/simple.py b/ranboorux/boorus/simple.py index 198784c..215879b 100644 --- a/ranboorux/boorus/simple.py +++ b/ranboorux/boorus/simple.py @@ -4,7 +4,6 @@ """ import random -from typing import List, Optional from ranboorux.boorus import Booru @@ -13,9 +12,7 @@ class Danbooru(Booru): def __init__(self): from scripts.ranbooru import POST_AMOUNT - super().__init__( - "Danbooru", f"https://danbooru.donmai.us/posts.json?limit={POST_AMOUNT}" - ) + super().__init__("Danbooru", f"https://danbooru.donmai.us/posts.json?limit={POST_AMOUNT}") def get_posts(self, tags_query="", max_pages=10, post_id=None): import scripts.ranbooru as _r @@ -150,9 +147,7 @@ class Konachan(Booru): def __init__(self): from scripts.ranbooru import POST_AMOUNT - super().__init__( - "Konachan", f"https://konachan.com/post.json?limit={POST_AMOUNT}" - ) + super().__init__("Konachan", f"https://konachan.com/post.json?limit={POST_AMOUNT}") def get_posts(self, tags_query="", max_pages=10, post_id=None): import scripts.ranbooru as _r @@ -176,9 +171,7 @@ class Yandere(Booru): def __init__(self): from scripts.ranbooru import POST_AMOUNT - super().__init__( - "Yandere", f"https://yande.re/post.json?limit={POST_AMOUNT}" - ) + super().__init__("Yandere", f"https://yande.re/post.json?limit={POST_AMOUNT}") def get_posts(self, tags_query="", max_pages=10, post_id=None): import scripts.ranbooru as _r @@ -202,13 +195,10 @@ class AIBooru(Booru): def __init__(self): from scripts.ranbooru import POST_AMOUNT - super().__init__( - "AIBooru", f"https://aibooru.online/posts.json?limit={POST_AMOUNT}" - ) + super().__init__("AIBooru", f"https://aibooru.online/posts.json?limit={POST_AMOUNT}") def get_posts(self, tags_query="", max_pages=10, post_id=None): import scripts.ranbooru as _r - from scripts.ranbooru import POST_AMOUNT _r.COUNT = 0 @@ -235,9 +225,7 @@ class e621(Booru): def __init__(self): from scripts.ranbooru import POST_AMOUNT - super().__init__( - "e621", f"https://e621.net/posts.json?limit={POST_AMOUNT}" - ) + super().__init__("e621", f"https://e621.net/posts.json?limit={POST_AMOUNT}") def get_posts(self, tags_query="", max_pages=10, post_id=None): import scripts.ranbooru as _r @@ -277,4 +265,4 @@ def get_posts(self, tags_query="", max_pages=10, post_id=None): ): post["score"] = post_data["score"]["total"] standardized_posts.append(post) - return standardized_posts \ No newline at end of file + return standardized_posts diff --git a/ranboorux/integrations/adetailer_orchestration.py b/ranboorux/integrations/adetailer_orchestration.py index 82716a2..87746a3 100644 --- a/ranboorux/integrations/adetailer_orchestration.py +++ b/ranboorux/integrations/adetailer_orchestration.py @@ -11,9 +11,11 @@ import logging import types -from contextlib import contextmanager from enum import Enum, auto -from typing import Any, Dict, Iterator, List, Optional, Tuple +from typing import Any, List + +from ranboorux import http_client as rb_http_client +from ranboorux.integrations import adetailer_runtime as rb_adetailer_runtime class AdetailerState(Enum): @@ -34,8 +36,6 @@ class AdetailerState(Enum): DONE = auto() """Processing complete; guard flags cleared, ready for next generation.""" -from ranboorux import http_client as rb_http_client -from ranboorux.integrations import adetailer_runtime as rb_adetailer_runtime _logger = logging.getLogger("ranboorux.adetailer_orch") @@ -47,8 +47,8 @@ class AdetailerOrchestrator: Script-owned state access through ``self._script``. """ - def __init__(self, script_instance: object) -> None: - self._script = script_instance + def __init__(self, script_instance: Any) -> None: + self._script: Any = script_instance self._state: AdetailerState = AdetailerState.IDLE # ------------------------------------------------------------------ @@ -194,15 +194,37 @@ def _remove_adetailer_from_runner(self, p: object) -> None: # Remove ADetailer from regular scripts if hasattr(p.scripts, "scripts") and p.scripts.scripts: original_scripts = list(p.scripts.scripts) - filtered_scripts = [ - s for s in original_scripts if not self._is_adetailer_script(s) - ] + filtered_scripts = [s for s in original_scripts if not self._is_adetailer_script(s)] removed_scripts = [s for s in original_scripts if self._is_adetailer_script(s)] p.scripts.scripts = filtered_scripts self._script._stored_adetailer_scripts["regular"] = removed_scripts print(f"[R Process] Removed {len(removed_scripts)} ADetailer scripts from scripts") + # Also check global script lists (ADetailer-Neo on Forge Neo) + try: + import modules.scripts as scripts_module + + for attr in ("scripts_txt2img", "scripts_img2img"): + global_runner = getattr(scripts_module, attr, None) + if global_runner is None or global_runner is p.scripts: + continue + for list_attr in ("alwayson_scripts", "scripts"): + script_list = getattr(global_runner, list_attr, None) + if not script_list: + continue + adetailer_global = [s for s in script_list if self._is_adetailer_script(s)] + if adetailer_global: + self._script._stored_adetailer_scripts[list_attr] = adetailer_global + print( + f"[R Process] Found {len(adetailer_global)} ADetailer " + f"scripts in global {attr}.{list_attr} " + "(blocking via flags)" + ) + except Exception: + pass + + # Also check global script lists (ADetailer-Neo on Forge Neo) except Exception as e: print(f"[R Process] Error removing ADetailer from runner: {e}") @@ -427,14 +449,13 @@ def _force_enable_adetailer_scripts(self, processing_obj: object = None) -> int: ) if debug_entries: print( - "[R Before] Native ADetailer scripts detected: " - + ", ".join(debug_entries) + "[R Before] Native ADetailer scripts detected: " + ", ".join(debug_entries) ) except Exception: pass return restored_count - def _ensure_native_adetailer_enable_flags(self, processing_obj: object) -> None: + def _ensure_native_adetailer_enable_flags(self, processing_obj: Any) -> None: """Ensure ADetailer enable/skip flags in script_args are set correctly.""" if not getattr(self._script, "_adetailer_support_enabled", False): return @@ -603,9 +624,7 @@ def _reenable_adetailer_from_previous_generation(self) -> None: # Manual ADetailer execution # ------------------------------------------------------------------ - def _execute_manual_adetailer( - self, p: object, processed: object, img2img_results: List[Any] - ) -> bool: + def _execute_manual_adetailer(self, p: Any, processed: Any, img2img_results: List[Any]) -> bool: """Run manual ADetailer on img2img results via the deterministic runtime executor.""" if not self.is_adetailer_enabled() or not img2img_results: return False @@ -650,8 +669,7 @@ def build_processed(single_image: object) -> object: p, script_obj, keep_controlnet=self._script._manual_adetailer_requires_controlnet( - self._script._extract_adetailer_script_args(script_obj, p).get("args") - or [] + self._script._extract_adetailer_script_args(script_obj, p).get("args") or [] ), ), ) @@ -697,9 +715,7 @@ def _install_scriptrunner_guard(self, p: object) -> None: "info", "Installed ScriptRunner guard to skip ADetailer when blocked" ) except Exception as e: - self._script._log_patch_event( - "warning", f"Failed to install ScriptRunner guard: {e}" - ) + self._script._log_patch_event("warning", f"Failed to install ScriptRunner guard: {e}") print(f"[R] Error installing ScriptRunner guard: {e}") # ------------------------------------------------------------------ @@ -758,12 +774,10 @@ def guarded_assign_current_image(img: object) -> Any: self._script._host_scope.patch_attr( state, "assign_current_image", guarded_assign_current_image ) - self._script._host_scope.set_attr( - state, "_ranbooru_preview_guard_installed", True - ) + self._script._host_scope.set_attr(state, "_ranbooru_preview_guard_installed", True) self._script._host_scope.set_attr( state, "_ranbooru_preview_guard_wrapper", guarded_assign_current_image ) print("[R UI] Installed preview guard") except Exception as e: - print(f"[R UI] Error installing preview guard: {e}") \ No newline at end of file + print(f"[R UI] Error installing preview guard: {e}") diff --git a/ranboorux/integrations/adetailer_runtime.py b/ranboorux/integrations/adetailer_runtime.py index 2209214..b6cf640 100644 --- a/ranboorux/integrations/adetailer_runtime.py +++ b/ranboorux/integrations/adetailer_runtime.py @@ -359,23 +359,44 @@ def runner_isolation( ) -def _images_differ(original: object, updated: object) -> bool: +def _images_differ(original: object, updated: object, _debug: bool = False) -> bool: if updated is None: + if _debug: + print("[R] _images_differ: updated is None → False") return False if original is None: + if _debug: + print("[R] _images_differ: original is None → True") return True + if original is updated: + if _debug: + print("[R] _images_differ: same object → False") + return False original_size = getattr(original, "size", None) updated_size = getattr(updated, "size", None) if original_size is not None and updated_size is not None and original_size != updated_size: + if _debug: + print(f"[R] _images_differ: size {original_size} vs {updated_size} → True") return True try: original_bytes = original.tobytes() if hasattr(original, "tobytes") else None updated_bytes = updated.tobytes() if hasattr(updated, "tobytes") else None if original_bytes is not None and updated_bytes is not None: - return bool(original_bytes != updated_bytes) + differ = bool(original_bytes != updated_bytes) + if _debug: + o_token = getattr(original, "token", "?") + u_token = getattr(updated, "token", "?") + print( + f"[R] _images_differ: tobytes {o_token} vs {u_token} len={len(original_bytes)}/{len(updated_bytes)} → {differ}" + ) + return differ except Exception: - return original is not updated - return original is not updated + if _debug: + print("[R] _images_differ: tobytes exception → identity check") + return True + if _debug: + print("[R] _images_differ: no tobytes → identity check → True") + return True def _candidate_scripts(adetailer_scripts: Iterable[object]) -> List[object]: @@ -394,12 +415,13 @@ def _candidate_scripts(adetailer_scripts: Iterable[object]) -> List[object]: def _extract_processed_image(temp_processed: object, fallback: object) -> object: - images = getattr(temp_processed, "images", None) - if isinstance(images, list) and images: - return images[0] + # ADetailer modifies pp.image in place; check it BEFORE pp.images image = getattr(temp_processed, "image", None) if image is not None: return image + images = getattr(temp_processed, "images", None) + if isinstance(images, list) and images: + return images[0] return fallback @@ -478,10 +500,22 @@ def execute_manual_adetailer( def gather_adetailer_scripts(processing_obj: object) -> List[object]: - runner = getattr(processing_obj, "scripts", None) - if runner is None: - return [] scripts_list: List[object] = [] - scripts_list.extend(list(getattr(runner, "alwayson_scripts", []) or [])) - scripts_list.extend(list(getattr(runner, "scripts", []) or [])) + + runner = getattr(processing_obj, "scripts", None) + if runner is not None: + scripts_list.extend(list(getattr(runner, "alwayson_scripts", []) or [])) + scripts_list.extend(list(getattr(runner, "scripts", []) or [])) + + try: + import modules.scripts as scripts_module + + for attr in ("scripts_txt2img", "scripts_img2img"): + global_runner = getattr(scripts_module, attr, None) + if global_runner is not None and global_runner is not runner: + scripts_list.extend(list(getattr(global_runner, "alwayson_scripts", []) or [])) + scripts_list.extend(list(getattr(global_runner, "scripts", []) or [])) + except Exception: + pass + return _candidate_scripts(scripts_list) diff --git a/ranboorux/run_options.py b/ranboorux/run_options.py index cc6ac13..3c58ccf 100644 --- a/ranboorux/run_options.py +++ b/ranboorux/run_options.py @@ -2,7 +2,7 @@ # SIZE_OK — cohesive dataclass/schema module; splitting by count scatters related definitions from dataclasses import dataclass -from typing import Dict, List, Mapping, Sequence, Tuple +from typing import Any, Dict, List, Mapping, Sequence, Tuple UI_ARGUMENT_FIELDS: Tuple[str, ...] = ( "enabled", @@ -67,6 +67,8 @@ "lora_auto_detect_pony", "lora_detected_loras", "lora_blacklist", + "anima_auto_detect", + "anima_tune_img2img", ) @@ -189,6 +191,8 @@ class RunOptions: lora_auto_detect_pony: object lora_detected_loras: object lora_blacklist: object + anima_auto_detect: bool = True + anima_tune_img2img: bool = True @classmethod def from_script_args(cls, args: Sequence[object]) -> "RunOptions": @@ -196,7 +200,8 @@ def from_script_args(cls, args: Sequence[object]) -> "RunOptions": expected = len(UI_ARGUMENT_FIELDS) if len(values) != expected: raise ValueError(f"Expected {expected} RanbooruX script args, got {len(values)}") - return cls(**dict(zip(UI_ARGUMENT_FIELDS, values))) + kw: Dict[str, Any] = dict(zip(UI_ARGUMENT_FIELDS, values)) + return cls(**kw) def as_dict(self) -> Dict[str, object]: return {field: getattr(self, field) for field in UI_ARGUMENT_FIELDS} diff --git a/scripts/ranbooru.py b/scripts/ranbooru.py index b4aa8b8..b13154d 100644 --- a/scripts/ranbooru.py +++ b/scripts/ranbooru.py @@ -7,21 +7,16 @@ import re import shutil import sys -import time import traceback -import types import unicodedata -import xml.etree.ElementTree as ET from contextlib import ExitStack, contextmanager from datetime import datetime from io import BytesIO from typing import Dict, Iterable, List, Optional, Set, Tuple -from urllib.parse import quote_plus import gradio as gr import modules.scripts as scripts import numpy as np -import requests from modules import shared from modules.processing import ( StableDiffusionProcessing, @@ -37,19 +32,20 @@ from modules.scripts import basedir from ranboorux import catalog as rb_catalog -from ranboorux import mutation_scope as rb_mutation_scope +from ranboorux import http_client as rb_http_client from ranboorux import image_ops as rb_image_ops from ranboorux import loranado as rb_loranado -from ranboorux import http_client as rb_http_client +from ranboorux import mutation_scope as rb_mutation_scope from ranboorux import run_options as rb_run_options from ranboorux import tag_pipeline as rb_tag_pipeline from ranboorux import user_store as rb_user_store +from ranboorux.anima_detect import get_anima_model_info +from ranboorux.boorus import Booru from ranboorux.integrations import adetailer as rb_adetailer_integration from ranboorux.integrations import adetailer_orchestration as rb_adetailer_orch from ranboorux.integrations import adetailer_runtime as rb_adetailer_runtime from ranboorux.integrations import controlnet as rb_controlnet_integration from ranboorux.integrations import img2img_lifecycle as rb_img2img_lifecycle -from ranboorux.boorus import Booru # --- Constants and Paths --- EXTENSION_ROOT = basedir() @@ -338,16 +334,15 @@ def generate_chaos(pos_tags, neg_tags, chaos_amount): pos_add = chaos_list[len_list:] final_pos = list(set(pos_tag_list) - set(neg_add)) + pos_add final_neg = list(set(neg_tag_list) - set(pos_add)) + neg_add - return ",".join(rb_tag_pipeline.dedupe_keep_order(final_pos)), ",".join(rb_tag_pipeline.dedupe_keep_order(final_neg)) + return ",".join(rb_tag_pipeline.dedupe_keep_order(final_pos)), ",".join( + rb_tag_pipeline.dedupe_keep_order(final_neg) + ) class BooruError(Exception): pass - - - class TagCatalogProvider: """Interface for optional tag catalog backends.""" @@ -632,6 +627,7 @@ def __init__(self): self._gelbooru_effective_credentials: Optional[Dict[str, str]] = None self._personal_remove_tags: Set[str] = set() self._favorite_tags: Set[str] = set() + self._is_anima_model: bool = False self._removal_context: Dict[str, object] = {} self._tag_normal_cache: Dict[str, str] = {} self._synonym_groups: Tuple[Set[str], ...] = tuple() @@ -1793,9 +1789,7 @@ def _build_catalog_ui_section(self): with gr.Group( visible=bool(self._use_tag_catalog and self._catalog_source == "custom") ) as custom_catalog_group: - catalog_upload = gr.File( - label="Upload CSV", file_types=[".csv"], file_count="single" - ) + catalog_upload = gr.File(label="Upload CSV", file_types=[".csv"], file_count="single") catalog_path = gr.Textbox( label="Custom CSV Path", value=self._custom_catalog_path, @@ -1805,12 +1799,8 @@ def _build_catalog_ui_section(self): catalog_import_btn = gr.Button("Import Custom Catalog") catalog_validate_btn = gr.Button("Validate CSV") - reload_catalog = gr.Button( - "Reload Catalog", visible=bool(self._use_tag_catalog) - ) - catalog_status = gr.Markdown( - self._tag_catalog_status_text or "Catalog mode: OFF" - ) + reload_catalog = gr.Button("Reload Catalog", visible=bool(self._use_tag_catalog)) + catalog_status = gr.Markdown(self._tag_catalog_status_text or "Catalog mode: OFF") self._catalog_status_md = catalog_status self._tag_diag_md = None @@ -2432,6 +2422,16 @@ def ui(self, is_img2img): favorites_export_btn = gr.DownloadButton("Export") shuffle_tags = gr.Checkbox(label="Shuffle tags", value=True) change_dash = gr.Checkbox(label='Convert "_" to spaces', value=False) + anima_auto_detect = gr.Checkbox( + label="Auto-detect Anima model", + value=True, + info="Automatically enable space-separated tags when an Anima model is loaded", + ) + anima_tune_img2img = gr.Checkbox( + label="Auto-tune Img2Img parameters for Anima", + value=True, + info="Automatically optimize steps, CFG scale, and denoising for Anima flow-matching", + ) same_prompt = gr.Checkbox(label="Use same prompt for batch", value=False) fringe_benefits = gr.Checkbox( label="Gelbooru: Fringe Benefits", value=True, visible=False @@ -2693,6 +2693,8 @@ def ui(self, is_img2img): lora_auto_detect_pony, lora_detected_loras, lora_blacklist, + anima_auto_detect, + anima_tune_img2img, ] return rb_run_options.RunComponents.from_sequence(components).script_args() @@ -3151,8 +3153,17 @@ def _prepare_tags( def _get_booru_api( self, booru_name, fringe_benefits, gelbooru_credentials: Optional[Dict[str, str]] = None ): - from ranboorux.boorus.gelbooru import GelbooruCompatible, Gelbooru - from ranboorux.boorus.simple import Danbooru, XBooru, Rule34, Safebooru, Konachan, Yandere, AIBooru, e621 + from ranboorux.boorus.gelbooru import Gelbooru, GelbooruCompatible + from ranboorux.boorus.simple import ( + AIBooru, + Danbooru, + Konachan, + Rule34, + Safebooru, + XBooru, + Yandere, + e621, + ) booru_name = (booru_name or "").strip().lower() if booru_name == "gelbooru-compatible": @@ -3302,7 +3313,7 @@ def _fetch_images(self, posts_to_fetch, use_same_image, booru_name, fringe_benef print(f"[R] Fetching {i+1}/{len(image_urls)}: {safe_url[:80]}...") content = self._http_client.get_bytes( img_url, - headers=api.headers, + headers=self._get_image_fetch_headers(api, img_url), timeout=30, max_bytes=MAX_SOURCE_IMAGE_BYTES, ) @@ -3340,6 +3351,19 @@ def _fetch_images(self, posts_to_fetch, use_same_image, booru_name, fringe_benef print("[R] Warn: Some images failed.") return fetched_images + def _get_image_fetch_headers(self, api, img_url: str) -> dict: + base = dict(api.headers) + if "gelbooru" in img_url.lower() or "img4.gelbooru.com" in img_url.lower(): + base.update( + { + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "referer": "https://gelbooru.com/", + "accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + } + ) + return base + def _process_single_prompt( self, index, raw_prompt, base_positive, base_negative, initial_additions, settings ): @@ -3463,15 +3487,31 @@ def _process_single_prompt( or (t_norm and (t_norm.endswith(" series") or t_norm.endswith(" franchise"))) ): should_remove = True - if not should_remove and remove_clothing_tags and rb_tag_pipeline.is_clothing_tag(t): + if ( + not should_remove + and remove_clothing_tags + and rb_tag_pipeline.is_clothing_tag(t) + ): should_remove = True - if not should_remove and remove_text_tags and rb_tag_pipeline.is_textual_tag(t, catalog.is_textual if catalog else None): + if ( + not should_remove + and remove_text_tags + and rb_tag_pipeline.is_textual_tag(t, catalog.is_textual if catalog else None) + ): should_remove = True if not should_remove and remove_furry_tags and rb_tag_pipeline.is_furry_tag(t): should_remove = True - if not should_remove and remove_headwear_tags and rb_tag_pipeline.is_headwear_tag(t): + if ( + not should_remove + and remove_headwear_tags + and rb_tag_pipeline.is_headwear_tag(t) + ): should_remove = True - if not should_remove and remove_series_tags and rb_tag_pipeline.is_series_tag(t, catalog.category if catalog else None): + if ( + not should_remove + and remove_series_tags + and rb_tag_pipeline.is_series_tag(t, catalog.category if catalog else None) + ): should_remove = True if not should_remove and preserve_hair_eye_colors: if base_hair_colors and canonical_tag in base_hair_colors: @@ -3480,7 +3520,9 @@ def _process_single_prompt( pass elif ( base_hair_colors - and rb_tag_pipeline.is_hair_color_tag(t, catalog.is_hair if catalog else None) + and rb_tag_pipeline.is_hair_color_tag( + t, catalog.is_hair if catalog else None + ) and canonical_tag not in base_hair_colors ): should_remove = True @@ -3490,7 +3532,11 @@ def _process_single_prompt( and canonical_tag not in base_eye_colors ): should_remove = True - if not should_remove and restrict_subject_tags and rb_tag_pipeline.is_subject_tag(t): + if ( + not should_remove + and restrict_subject_tags + and rb_tag_pipeline.is_subject_tag(t) + ): subject_norm = t_norm if allowed_subjects: if subject_norm not in allowed_subjects: @@ -3521,9 +3567,13 @@ def _process_single_prompt( elif chaos_mode == "Shuffle Negative": _, current_negative = generate_chaos("", current_negative, chaos_amount) if limit_tags_pct < 1.0: - current_prompt = rb_tag_pipeline.limit_prompt_tags(current_prompt, limit_tags_pct, "Limit") + current_prompt = rb_tag_pipeline.limit_prompt_tags( + current_prompt, limit_tags_pct, "Limit" + ) if max_tags_count > 0: - current_prompt = rb_tag_pipeline.limit_prompt_tags(current_prompt, max_tags_count, "Max") + current_prompt = rb_tag_pipeline.limit_prompt_tags( + current_prompt, max_tags_count, "Max" + ) if change_dash: current_prompt = current_prompt.replace("_", " ") current_negative = current_negative.replace("_", " ") @@ -3670,6 +3720,20 @@ def _prepare_img2img_pass(self, p, use_img2img, use_ip): 0.6, self.img2img_denoising ) # Cap at 0.6 to prevent distortion + # Anima-specific img2img overrides + options = getattr(self, "options", None) + if getattr(self, "_is_anima_model", False) and getattr( + options, "anima_tune_img2img", getattr(options, "anima_auto_detect", True) + ): + self.img2img_denoising = min(0.5, self.img2img_denoising) + initial_steps = max(8, min(15, p.steps // 3)) + self._host_scope.set_attr(p, "steps", initial_steps) + p.cfg_scale = max(3.0, min(p.cfg_scale, 6.0)) + print( + f"[R] Anima: using flow-matching optimized parameters " + f"(denoise={self.img2img_denoising}, steps={initial_steps}, cfg={p.cfg_scale})" + ) + self.run_img2img_pass = True self._img2img_final_outpath_samples = getattr(p, "outpath_samples", None) @@ -3924,6 +3988,33 @@ def _maybe_release_stale_guards(self, new_processing_obj): processing_obj=new_processing_obj, ) + @staticmethod + def _anima_quality_prefix() -> str: + """Return Anima's recommended positive quality prefix.""" + return "masterpiece, best quality, score_7, safe, " + + @staticmethod + def _anima_negative_default() -> str: + """Return Anima's recommended negative prompt.""" + return "worst quality, low quality, score_1, score_2, score_3, artist name, blurry, jpeg artifacts, chromatic aberration" + + @staticmethod + def _has_quality_prefix(prompt: str) -> bool: + """Check if prompt already has quality tokens (case-insensitive).""" + if not prompt: + return False + quality_tokens = { + "masterpiece", + "best quality", + "high quality", + "score_7", + "score_8", + "score_9", + "safe", + } + first_10 = [t.strip().lower() for t in prompt.split(",")[:10]] + return any(token in tag for token in quality_tokens for tag in first_10) + def before_process(self, p: StableDiffusionProcessing, *args): try: # Fast-path for our own internal img2img calls: initialize seeds and exit @@ -4099,6 +4190,20 @@ def before_process(self, p: StableDiffusionProcessing, *args): self._manual_adetailer_prev_enabled = self._adetailer_support_enabled self._log_prompt_sources = bool(log_prompt_sources_ui) + # Anima model detection + try: + info = get_anima_model_info(shared.sd_model) + self._is_anima_model = info["detected"] + if self._is_anima_model: + anima_auto_detect = getattr(options, "anima_auto_detect", True) + if anima_auto_detect: + change_dash = True + print( + f"[R] Anima model detected ({info['model_name']}) - auto-enabling space-separated tags" + ) + except Exception: + self._is_anima_model = False + self._current_booru_name = booru if booru == "gelbooru": self._gelbooru_effective_credentials = self._resolve_gelbooru_credentials( @@ -4264,6 +4369,15 @@ def before_process(self, p: StableDiffusionProcessing, *args): if isinstance(p.prompt, str) else (p.prompt[0] if isinstance(p.prompt, list) and p.prompt else "") ) + + # Anima: apply default prompts + if self._is_anima_model and getattr(options, "anima_auto_detect", True): + if not self._has_quality_prefix(self.original_prompt): + self.original_prompt = f"{self._anima_quality_prefix()}{self.original_prompt}" + print("[R] Anima: applied default quality tags") + if not isinstance(p.negative_prompt, str) or not p.negative_prompt.strip(): + p.negative_prompt = self._anima_negative_default() + base_hair_colors, base_eye_colors = self._extract_color_tags(self.original_prompt) self._base_hair_color_tags = base_hair_colors self._base_eye_color_tags = base_eye_colors @@ -4573,69 +4687,58 @@ def before_process(self, p: StableDiffusionProcessing, *args): if use_ip and self.last_img and self.last_img[0] is not None: cn_configured = False - # Preferred: external_code API from ControlNet + # Forge Neo direct: find ControlNet script in alwayson_scripts try: - cn_module = self._load_cn_external_code() - if hasattr(cn_module, "get_all_units_in_processing") and hasattr( - cn_module, "update_cn_script_in_processing" - ): - cn_units = cn_module.get_all_units_in_processing(p) - if cn_units and len(cn_units) > 0: - copied_unit = cn_units[0].__dict__.copy() - copied_unit["enabled"] = True - copied_unit["weight"] = float(self.img2img_denoising) - img_for_cn = ( - self.last_img[0].convert("RGB") - if self.last_img[0].mode != "RGB" - else self.last_img[0] + scripts_runner = getattr(p, "scripts", None) + cn_script = None + if scripts_runner is not None: + for s in getattr(scripts_runner, "alwayson_scripts", []): + filename = getattr(s, "filename", "") or "" + title = getattr(s, "title", lambda: "")() + if "controlnet" in filename.lower() or "controlnet" in title.lower(): + cn_script = s + break + + if cn_script is not None: + start = getattr(cn_script, "args_from", None) + end = getattr(cn_script, "args_to", None) + if isinstance(start, int) and isinstance(end, int) and 0 <= start < end: + full_args = ( + list(p.script_args) + if isinstance(p.script_args, tuple) + else list(p.script_args or []) ) - copied_unit["image"]["image"] = np.array(img_for_cn) - cn_module.update_cn_script_in_processing( - p, [copied_unit] + cn_units[1:] - ) - cn_configured = True - print("[R Before] ControlNet configured via external_code.") - # else: module loaded but does not expose update helpers; silently skip to fallback - except Exception: - # Silently fallback if external_code path not supported in this build - pass - - # Fallback: p.script_args hack (fragile but effective) - if not cn_configured: - cn_arg_start_guess = 0 - num_controls_per_unit = 20 - if num_controls_per_unit > 0: - target_unit_arg_start = cn_arg_start_guess - enabled_idx = target_unit_arg_start + 0 - weight_idx = target_unit_arg_start + 3 - image_idx = target_unit_arg_start + 4 - args_source = p.script_args - if isinstance(args_source, tuple): - args_target_list = list(args_source) - max_idx = max(enabled_idx, weight_idx, image_idx) - if max_idx < len(args_target_list): - try: - img_for_cn = ( - self.last_img[0].convert("RGB") - if self.last_img[0].mode != "RGB" - else self.last_img[0] - ) - cn_image_input = {"image": np.array(img_for_cn), "mask": None} - args_target_list[enabled_idx] = True - args_target_list[weight_idx] = float(self.img2img_denoising) - args_target_list[image_idx] = cn_image_input - p.script_args = tuple(args_target_list) - print( - "[R Before] ControlNet using fallback p.script_args hack." - ) - except Exception as e: - print(f"[R Before] Error setting CN via p.script_args: {e}") - else: + if end <= len(full_args): + unit = full_args[start] + img_for_cn = ( + self.last_img[0].convert("RGB") + if self.last_img[0].mode != "RGB" + else self.last_img[0] + ) + cn_image = {"image": np.array(img_for_cn), "mask": None} + + if isinstance(unit, dict): + unit["enabled"] = True + unit["weight"] = float(self.img2img_denoising) + unit["image"] = cn_image + elif hasattr(unit, "enabled"): + unit.enabled = True + unit.weight = float(self.img2img_denoising) + unit.image = cn_image + + setattr(p, "resize_mode", 1) + p.script_args = tuple(full_args) + cn_configured = True print( - f"[R Before] Error: CN arg index ({max_idx}) OOB ({len(args_target_list)})." + "[R Before] ControlNet configured via Forge Neo direct (p.script_args slice)." ) - else: - print("[R Before] Error: p.script_args is not a tuple.") + except Exception as e: + print(f"[R Before] ControlNet config error: {e}") + + if not cn_configured and use_ip: + if not hasattr(p, "resize_mode"): + setattr(p, "resize_mode", 1) + print("[R Before] ControlNet script not found; p.resize_mode safeguard set.") self._prepare_img2img_pass(p, use_img2img, use_ip) diff --git a/tests/test_adetailer.py b/tests/test_adetailer.py index 77a6d04..deecbd7 100644 --- a/tests/test_adetailer.py +++ b/tests/test_adetailer.py @@ -565,9 +565,9 @@ def __init__(self): def postprocess_image(self, p, temp_processed, *args): self.calls.append(temp_processed.image.token) - temp_processed.images = [ - DummyImage(f"{temp_processed.image.token}-ad", temp_processed.image.size) - ] + temp_processed.image = DummyImage( + f"{temp_processed.image.token}-ad", temp_processed.image.size + ) return True adetailer_script = AfterDetailerScript() @@ -611,7 +611,7 @@ class AfterDetailerScript: def postprocess_image(self, p, temp_processed, *args): if getattr(p, "_ad_disabled", False): return True - temp_processed.images = [DummyImage(f"{temp_processed.image.token}-ad")] + temp_processed.image = DummyImage(f"{temp_processed.image.token}-ad") return True adetailer_script = AfterDetailerScript() diff --git a/tests/test_adetailer_runtime.py b/tests/test_adetailer_runtime.py index a0c0b15..021a656 100644 --- a/tests/test_adetailer_runtime.py +++ b/tests/test_adetailer_runtime.py @@ -265,7 +265,8 @@ def postprocess(self, *_args, **_kwargs): def test_execute_manual_adetailer_counts_changed_image(): class AfterDetailerScript: def postprocess_image(self, _p, temp_processed, *_args): - temp_processed.images = [DummyImage(f"{temp_processed.image.token}-ad")] + # Real ADetailer updates pp.image, not pp.images + temp_processed.image = DummyImage(f"{temp_processed.image.token}-ad") state = adetailer_runtime.AdetailerRunState() result = adetailer_runtime.execute_manual_adetailer( @@ -287,7 +288,7 @@ def postprocess_image(self, _p, temp_processed, *_args): def test_execute_manual_adetailer_treats_unchanged_as_noop(): class AfterDetailerScript: def postprocess_image(self, _p, temp_processed, *_args): - temp_processed.images = [temp_processed.image] + temp_processed.image = temp_processed.image result = adetailer_runtime.execute_manual_adetailer( adetailer_scripts=[AfterDetailerScript()], @@ -308,9 +309,9 @@ class AfterDetailerScript: def postprocess_image(self, _p, temp_processed, *_args): token = temp_processed.image.token if token == "img-2": - temp_processed.images = [temp_processed.image] + temp_processed.image = temp_processed.image else: - temp_processed.images = [DummyImage(f"{token}-ad")] + temp_processed.image = DummyImage(f"{token}-ad") result = adetailer_runtime.execute_manual_adetailer( adetailer_scripts=[AfterDetailerScript()], @@ -331,7 +332,7 @@ class AfterDetailerScript: def postprocess_image(self, _p, temp_processed, *_args): if temp_processed.image.token == "img-2": raise RuntimeError("simulated failure") - temp_processed.images = [DummyImage(f"{temp_processed.image.token}-ad")] + temp_processed.image = DummyImage(f"{temp_processed.image.token}-ad") state = adetailer_runtime.AdetailerRunState() result = adetailer_runtime.execute_manual_adetailer( diff --git a/tests/test_anima_detect.py b/tests/test_anima_detect.py new file mode 100644 index 0000000..548a28e --- /dev/null +++ b/tests/test_anima_detect.py @@ -0,0 +1,77 @@ +from ranboorux.anima_detect import get_anima_model_info, is_anima_model + + +class _Obj: + """Minimal attribute holder for test mocks.""" + + pass + + +def test_is_anima_model_none(): + assert is_anima_model(None) is False + + +def test_is_anima_model_non_anima(): + obj = _Obj() + obj.sd_model_checkpoint = "sd_xl_base_1.0.safetensors" + assert is_anima_model(obj) is False + + +def test_is_anima_model_filename_detection(): + obj = _Obj() + obj.sd_model_checkpoint = "anima-base-v1.0.safetensors" + assert is_anima_model(obj) is True + + +def test_is_anima_model_class_detection(): + # Class name containing "Anima" -> True (no checkpoint at all) + obj = type("Anima", (), {})() + assert is_anima_model(obj) is True + + +def test_get_anima_model_info_returns_dict(): + info = get_anima_model_info(None) + assert isinstance(info, dict) + assert "detected" in info + assert "method" in info + assert "model_name" in info + + +def test_is_anima_model_case_insensitive(): + obj = _Obj() + obj.sd_model_checkpoint = "Anima-Base-v1.0.safetensors" + assert is_anima_model(obj) is True + + +def test_is_anima_model_multiple_attr_paths(): + # Fallback to 'checkpoint' attr + obj = _Obj() + obj.checkpoint = "anima-preview3-base.safetensors" + assert is_anima_model(obj) is True + + # Fallback to 'model_checkpoint' attr + obj2 = _Obj() + obj2.model_checkpoint = "anima-aesthetic-v1.0.safetensors" + assert is_anima_model(obj2) is True + + +def test_anima_tune_img2img_can_be_disabled(monkeypatch): + import types + + import scripts.ranbooru as ranbooru + from ranboorux.run_options import RunOptions + + script = ranbooru.Script() + script._is_anima_model = True + script.img2img_denoising = 0.8 + + p = types.SimpleNamespace( + prompt="test", steps=30, cfg_scale=7.5, outpath_samples=None, batch_size=1 + ) + + # When anima_tune_img2img is False, script.img2img_denoising and p.steps should not be overridden by Anima bounds + opts = RunOptions.from_script_args([object()] * 63 + [False]) + script.options = opts + script._prepare_img2img_pass(p, use_img2img=True, use_ip=False) + + assert script.img2img_denoising == 0.6 # Default non-anima max cap, not Anima's 0.5 cap diff --git a/tests/test_lifecycle_contract.py b/tests/test_lifecycle_contract.py index 8578a1c..48fa37f 100644 --- a/tests/test_lifecycle_contract.py +++ b/tests/test_lifecycle_contract.py @@ -66,6 +66,8 @@ def _args(**overrides): "catalog_path": "", "lora_auto_detect_pony": True, "lora_detected_loras": [], + "anima_auto_detect": False, + "anima_tune_img2img": True, "lora_blacklist": [], } defaults.update(overrides) diff --git a/tests/test_run_options.py b/tests/test_run_options.py index 5be8c5b..4a8dde1 100644 --- a/tests/test_run_options.py +++ b/tests/test_run_options.py @@ -4,7 +4,7 @@ def test_ui_argument_field_order_is_frozen(): - assert len(UI_ARGUMENT_FIELDS) == 62 + assert len(UI_ARGUMENT_FIELDS) == 64 assert UI_ARGUMENT_FIELDS[:6] == ( "enabled", "tags", @@ -13,12 +13,13 @@ def test_ui_argument_field_order_is_frozen(): "gelbooru_user_id", "gelbooru_compat_base_url", ) - assert UI_ARGUMENT_FIELDS[-5:] == ( - "use_tag_catalog", + assert UI_ARGUMENT_FIELDS[-6:] == ( "catalog_path", "lora_auto_detect_pony", "lora_detected_loras", "lora_blacklist", + "anima_auto_detect", + "anima_tune_img2img", ) @@ -33,11 +34,12 @@ def test_run_options_from_script_args_maps_names_once(): assert options.image_workflow.use_img2img == 12 assert options.tag_filters.remove_text_tags == 50 assert options.loranado.blacklist == 61 + assert options.anima_tune_img2img == 63 assert options.as_dict() == dict(zip(UI_ARGUMENT_FIELDS, values)) def test_run_options_rejects_wrong_count(): - with pytest.raises(ValueError, match="Expected 62"): + with pytest.raises(ValueError, match="Expected 64"): RunOptions.from_script_args([object()]) diff --git a/tests/test_tag_pipeline.py b/tests/test_tag_pipeline.py index b412b81..78f3603 100644 --- a/tests/test_tag_pipeline.py +++ b/tests/test_tag_pipeline.py @@ -1,5 +1,4 @@ from ranboorux.tag_pipeline import ( - FilterContext, build_removal_context, build_synonym_lookup, canonicalize_raw_tag, @@ -223,10 +222,13 @@ def test_post_rejected_by_filter_remove_furry(): post = {"id": "1", "booru_name": "danbooru", "tags": "kemonomimi, 1girl, blonde_hair"} cache = {} rejected, reason = post_rejected_by_filter( - post, filter_ctx=None, + post, + filter_ctx=None, toggles=(False, False, False, False, False, True, False, False, False, False), - base_colors=(set(), set()), allowed_subjects=set(), - cache=cache, favorites_guard=set(), + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), ) assert rejected is True assert reason["rule"] == "furry" @@ -237,10 +239,13 @@ def test_post_rejected_by_filter_remove_clothing(): post = {"id": "2", "booru_name": "danbooru", "tags": "dress, 1girl, no_clothing"} cache = {} rejected, reason = post_rejected_by_filter( - post, filter_ctx=None, + post, + filter_ctx=None, toggles=(False, False, True, False, False, False, False, False, False, False), - base_colors=(set(), set()), allowed_subjects=set(), - cache=cache, favorites_guard=set(), + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), ) assert rejected is True assert reason["rule"] == "clothing" @@ -251,10 +256,13 @@ def test_post_rejected_by_filter_remove_headwear(): post = {"id": "3", "booru_name": "danbooru", "tags": "halo, 1girl, blonde_hair"} cache = {} rejected, reason = post_rejected_by_filter( - post, filter_ctx=None, + post, + filter_ctx=None, toggles=(False, False, False, False, False, False, True, False, False, False), - base_colors=(set(), set()), allowed_subjects=set(), - cache=cache, favorites_guard=set(), + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), ) assert rejected is True assert reason["rule"] == "headwear" @@ -265,10 +273,13 @@ def test_post_rejected_by_filter_remove_girl_suffix(): post = {"id": "4", "booru_name": "danbooru", "tags": "cat_girl, 1girl, girl, blonde_hair"} cache = {} rejected, reason = post_rejected_by_filter( - post, filter_ctx=None, + post, + filter_ctx=None, toggles=(False, False, False, False, False, False, False, True, False, False), - base_colors=(set(), set()), allowed_subjects=set(), - cache=cache, favorites_guard=set(), + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), ) assert rejected is True assert reason["rule"] == "girl-suffix" @@ -277,14 +288,16 @@ def test_post_rejected_by_filter_remove_girl_suffix(): def test_post_rejected_by_filter_remove_character(): """Test remove_character rejects character tags.""" - post = {"id": "5", "booru_name": "danbooru", - "tags": "1girl", "character_tags": "heroine"} + post = {"id": "5", "booru_name": "danbooru", "tags": "1girl", "character_tags": "heroine"} cache = {} rejected, reason = post_rejected_by_filter( - post, filter_ctx=None, + post, + filter_ctx=None, toggles=(False, True, False, False, False, False, False, False, False, False), - base_colors=(set(), set()), allowed_subjects=set(), - cache=cache, favorites_guard=set(), + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard=set(), ) assert rejected is True assert reason["rule"] == "character" @@ -298,9 +311,12 @@ def test_post_rejected_by_filter_favorites_guard(): cache = {} # With favorites_guard containing "bad_tag" - should NOT be rejected rejected, reason = post_rejected_by_filter( - post, filter_ctx=ctx, + post, + filter_ctx=ctx, toggles=(False, False, False, False, False, False, False, False, False, False), - base_colors=(set(), set()), allowed_subjects=set(), - cache=cache, favorites_guard={"bad tag"}, + base_colors=(set(), set()), + allowed_subjects=set(), + cache=cache, + favorites_guard={"bad tag"}, ) assert rejected is False diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index 8373c93..849402b 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -3,10 +3,12 @@ def test_prompt_wrappers_match_module(): from ranboorux import tag_pipeline prompt = "a, b, a, c" - assert ranbooru.rb_tag_pipeline.remove_repeated_tags(prompt) == tag_pipeline.remove_repeated_tags(prompt) - assert ranbooru.rb_tag_pipeline.limit_prompt_tags("a, b, c, d", 2, "Max") == tag_pipeline.limit_prompt_tags( + assert ranbooru.rb_tag_pipeline.remove_repeated_tags( + prompt + ) == tag_pipeline.remove_repeated_tags(prompt) + assert ranbooru.rb_tag_pipeline.limit_prompt_tags( "a, b, c, d", 2, "Max" - ) + ) == tag_pipeline.limit_prompt_tags("a, b, c, d", 2, "Max") def test_controlnet_wrapper_uses_integration(monkeypatch): diff --git a/tools/repo_guard.py b/tools/repo_guard.py index d6b8205..75632d6 100644 --- a/tools/repo_guard.py +++ b/tools/repo_guard.py @@ -33,13 +33,18 @@ def check_files(file_list: Iterable[str], forbidden_prefixes: Sequence[str]) -> def _run_git(repo_root: Path, args: Sequence[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", "-c", f"safe.directory={repo_root.as_posix()}", *args], - cwd=repo_root, - capture_output=True, - text=True, - check=True, - ) + try: + return subprocess.run( + ["git", "-c", "safe.directory=*", *args], + cwd=repo_root, + capture_output=True, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return subprocess.CompletedProcess( + args=["git", *args], returncode=128, stdout="", stderr="" + ) def get_git_modified_files(repo_root: Path) -> List[str]: From 0ca1b63049b9962f3cc74a6101e10246ff6574b5 Mon Sep 17 00:00:00 2001 From: soficis Date: Thu, 30 Jul 2026 08:57:30 -0500 Subject: [PATCH 03/10] docs(readme): overhaul README layout, update image assets, & collapse env overrides Summary of changes: - **Image Assets**: Replaced `pics/image.png` with `pics/image.jpg` and embedded `pics/filters.jpg` under the Tag Filters section. - **Workflow Guide**: Merged and updated Quick Start, Tag Catalog, and Two-Pass Img2Img + ADetailer / ADetailer Neo pipeline into a unified workflow guide. - **Collapsible Overrides**: Converted Environment Configuration Overrides into an expandable HTML dropdown (`
`). - **Developer Documentation**: Added a cross-platform Developer & Verification guide with execution instructions for Windows (PowerShell/CMD) and Linux/macOS. --- README.md | 138 +++++++++++++++++++++++++++++++++++------------ pics/filters.jpg | Bin 103419 -> 124660 bytes pics/image.jpg | Bin 0 -> 151646 bytes pics/image.png | Bin 98271 -> 0 bytes 4 files changed, 103 insertions(+), 35 deletions(-) create mode 100644 pics/image.jpg delete mode 100644 pics/image.png diff --git a/README.md b/README.md index 7dd59c6..ddb0afd 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,14 @@ RanbooruX is a fork of Ranbooru built **exclusively for Forge Neo**, featuring n It fetches booru tags and source images, builds prompts, and supports a two-stage generation flow with Img2Img, ControlNet handoff, and ADetailer / ADetailer Neo postprocessing. +![UI screenshot](pics/image.jpg) + ## Features & Exclusive Fork Capabilities RanbooruX delivers massive architectural and feature upgrades over original Ranbooru: - **Forge Neo & ADetailer Neo Native Support**: Built exclusively for Forge Neo, with full support for ADetailer Neo and standard ADetailer in two-pass Img2Img workflows. -- **Anima (2B DiT) Support**: Native auto-detection of Anima models with automatic flow-matching scheduler tuning, prompt quality prefixes, and working basic Img2Img support. +- **Anima (2B DiT) Support**: Native auto-detection of Anima models with automatic flow-matching scheduler tuning, prompt quality prefixes, and working basic Img2Img & ControlNet LLLite support. - **Danbooru Tag Catalog System**: Bundled tag catalog (`data/catalogs/danbooru_tags.csv`) providing alias normalization, category-aware filtering, custom CSV import, and hair/eye color preservation. - **Safer Two-Pass Img2Img & Guarded Postprocessing**: Preview guard suppresses initial-pass flashes until final img2img outputs are rendered; guarded script runner prevents script collisions. - **Rich Booru & Tag Removal Filters**: Multi-booru search (`aibooru`, `danbooru`, `e621`, `gelbooru`, `konachan`, `rule34`, `safebooru`, `xbooru`, `yande.re`) with fine-grained removal toggles (artist, character, series, clothing, commentary, furry, headwear, `*_girl` suffix cleanup). @@ -23,8 +25,6 @@ RanbooruX delivers massive architectural and feature upgrades over original Ranb - **Modular Codebase & Quality Tooling**: Refactored from a monolithic script into a clean `ranboorux/` module with unit tests (`pytest`), strict type checking (`mypy`), linting (`ruff`), and formatting (`black`). - **User Conveniences**: Favorites management, file-driven tag sources, prompt/source logging, and sensible caching. -![UI screenshot](pics/image.png) - ## Installation ### Method 1: Install from URL in Forge Neo (Recommended) @@ -44,22 +44,64 @@ RanbooruX delivers massive architectural and feature upgrades over original Ranb 3. `install.py` installs extension dependencies from `requirements.txt`. 4. Open the **RanbooruX** panel. -### Environment Configuration Overrides +
+Environment Configuration Overrides + +If your Forge Neo ControlNet extension is located in a custom or non-standard directory outside of `extensions/sd_forge_controlnet` or `extensions-builtin/sd_forge_controlnet`, RanbooruX supports optional environment variables to override the ControlNet detection path: + +* `SD_FORGE_CONTROLNET_PATH`: Primary override pointing to the root directory of `sd_forge_controlnet` (containing `lib_controlnet/external_code.py`). +* `RANBOORUX_CN_PATH`: Secondary fallback override path for RanbooruX ControlNet asset resolution. + +#### How to Configure + +##### Option 1: In WebUI Startup Scripts (Recommended) +Add the environment variable directly to your WebUI launcher so it persists across restarts: + +* **Windows (`webui-user.bat`)**: + ```cmd + set SD_FORGE_CONTROLNET_PATH=C:\path\to\sd_forge_controlnet + ``` +* **Linux / macOS (`webui-user.sh`)**: + ```bash + export SD_FORGE_CONTROLNET_PATH="/path/to/sd_forge_controlnet" + ``` + +##### Option 2: Terminal / Shell + +* **Windows PowerShell**: + ```powershell + $env:SD_FORGE_CONTROLNET_PATH="C:\path\to\sd_forge_controlnet" + ``` +* **Windows Command Prompt (`cmd.exe`)**: + ```cmd + set SD_FORGE_CONTROLNET_PATH=C:\path\to\sd_forge_controlnet + ``` +* **Linux / macOS (`bash` / `zsh`)**: + ```bash + export SD_FORGE_CONTROLNET_PATH="/path/to/sd_forge_controlnet" + ``` + +
-RanbooruX supports optional environment variables to override ControlNet detection paths: +## Quick Start & Usage Workflow -- `SD_FORGE_CONTROLNET_PATH`: Custom path to the Forge ControlNet extension or module directory. -- `RANBOORUX_CN_PATH`: Custom path to ControlNet model or script assets monitored by RanbooruX. +RanbooruX integrates prompt fetching, tag catalog processing, two-pass generation, ControlNet handoff, and postprocessing into a streamlined workflow: -## Quick start +1. **Select Source & Query Tags**: Choose a booru source (`danbooru`, `gelbooru`, `e621`, etc.), enter your desired search tags, and specify post limits. +2. **Apply Tag Catalog & Removal Filters**: Keep `Use Danbooru Tag Catalog` enabled (default ON) for alias normalization and category-aware filtering. Configure tag removal toggles to strip unwanted artist/character/clothing metadata. +3. **Configure Image Handoff (Optional)**: + - Check `Use Image for Img2Img` to run an initial pass followed by an Img2Img pass with automatic denoising caps. + - Check `Use Image for ControlNet (Unit 0)` to automatically pass the fetched booru image into Forge Neo's ControlNet Unit 0 slot. +4. **Enable Postprocessing (Optional)**: Check `Enable RanbooruX ADetailer support` to automatically run a guarded manual ADetailer or ADetailer Neo pass on the final outputs. +5. **Generate**: Click **Generate** — RanbooruX fetches posts, processes prompts, and executes the multi-pass pipeline automatically. -1. Select a booru source. -2. Enter tags and generate. -3. Optional: enable `Use Image for Img2Img`. -4. Optional: enable `Use Image for ControlNet (Unit 0)`. -5. Optional: enable `Enable RanbooruX ADetailer support` (supports both ADetailer and ADetailer Neo). +## Tag Filters & Catalog Processing -## Danbooru Tag Catalog +RanbooruX provides powerful tag filtering and catalog normalization to keep prompts clean and coherent. + +![Tag Removal Filters](pics/filters.jpg) + +### Danbooru Tag Catalog RanbooruX includes a bundled catalog used by the tag-catalog pipeline. @@ -67,30 +109,30 @@ RanbooruX includes a bundled catalog used by the tag-catalog pipeline. - Catalog mode toggle: `Use Danbooru Tag Catalog` (default ON) - Source selection: `Bundled` or `Custom file` -With catalog mode enabled (default), the catalog pipeline adds: - -- alias normalization -- category-aware filtering -- better hair/eye preservation behavior -- textual/meta tag cleanup backed by catalog categories +With catalog mode enabled (default), the pipeline provides: +- Alias normalization (maps variant tags to canonical Danbooru tags) +- Category-aware tag filtering (artist, character, series, meta, general) +- Smart hair & eye color preservation +- Textual & commentary tag cleanup -### Custom catalog files +### Custom Catalog Files -Custom CSV catalogs are supported and imported into `user/catalogs/`. +Custom CSV catalogs can be imported into `user/catalogs/` via the UI. Accepted formats: - Header-based CSV (`tag,category,count,alias`) - Headerless 4-column CSV (`tag,category,count,alias`) -Validation and import controls (`Validate CSV`, `Import Custom Catalog`, `Reload Catalog`) are available in the UI. +Validation and management buttons (`Validate CSV`, `Import Custom Catalog`, `Reload Catalog`) are provided in the UI. ## Two-Pass Img2Img + ADetailer / ADetailer Neo Pipeline -For Img2Img workflows, RanbooruX executes an initial pass, followed by an Img2Img pass, and an optional manual ADetailer / ADetailer Neo postprocessing pass. +For Img2Img workflows, RanbooruX executes a coordinated multi-stage process: -- First-pass previews are suppressed until final images are ready (preview guard). -- Final results are forced back into processed image state for extension and UI consistency. -- Native script discovery automatically detects both standard ADetailer and ADetailer Neo at gather and removal stages. +1. **Initial Pass & Preview Guard**: Generates the base image while suppressing intermediate preview flashes until final images are rendered. +2. **Img2Img Pass**: Automatically applies tuned denoising caps to refine details without breaking composition. +3. **ControlNet Handoff**: When `Use Image for ControlNet (Unit 0)` is enabled, the fetched booru reference image is automatically assigned to Unit 0 in Forge Neo's ControlNet runner. +4. **ADetailer / ADetailer Neo Pass**: Runs a guarded postprocessing pass on the final images, auto-detecting both standard ADetailer and ADetailer Neo scripts. ## Anima Model Support @@ -162,18 +204,44 @@ Original Ranbooru was a monolithic single-script extension (~1.1k lines). Ranboo - InputAccordion includes compatibility fallbacks for environments where it is unavailable. - Gradio update calls are routed through compatibility helpers for Gradio 3/4 behavior. -## Verification Status +## Developer & Verification Guide + +RanbooruX uses a modular architecture with comprehensive automated tests, linting, and type safety checks. + +### Repository Architecture +- `scripts/ranbooru.py`: WebUI Extension entry point and Gradio UI definition. +- `ranboorux/`: Core modular package (catalog pipeline, booru API clients, ADetailer runtime/orchestration, ControlNet integration, Anima model detection). +- `tests/`: Automated test suite covering wrappers, catalog processing, ADetailer runtime, and lifecycle contracts. +- `tools/`: CI helper scripts (`check_no_gradio_update.py`, `repo_guard.py`). -RanbooruX includes automated test coverage for wrappers, catalog behavior, parsing, and integration boundaries. +### Cross-Platform Development Commands -Run checks locally: +Run tests, linters, and type checkers locally in your operating system environment: + +#### Windows (PowerShell) +```powershell +$env:PYTHONPATH="." +python -m pytest tests/ -q +python -m ruff check scripts/ranbooru.py ranboorux tests tools install.py +python -m black --check scripts/ranbooru.py ranboorux tests tools install.py +python -m mypy ranboorux --warn-return-any --warn-unused-ignores +``` + +#### Windows (Command Prompt `cmd.exe`) +```cmd +set PYTHONPATH=. +python -m pytest tests/ -q +python -m ruff check scripts/ranbooru.py ranboorux tests tools install.py +python -m black --check scripts/ranbooru.py ranboorux tests tools install.py +python -m mypy ranboorux --warn-return-any --warn-unused-ignores +``` +#### Linux / macOS (`bash` / `zsh`) ```bash -PYTHONPATH=. pytest -q -PYTHONPATH=. pytest -q --gradio-version=4 -python3 -m ruff check scripts/ranbooru.py ranboorux tests tools install.py -python3 -m black --check scripts/ranbooru.py ranboorux tests tools install.py -python3 -m mypy ranboorux --warn-return-any --warn-unused-ignores +PYTHONPATH=. python3 -m pytest tests/ -q +PYTHONPATH=. python3 -m ruff check scripts/ranbooru.py ranboorux tests tools install.py +PYTHONPATH=. python3 -m black --check scripts/ranbooru.py ranboorux tests tools install.py +PYTHONPATH=. python3 -m mypy ranboorux --warn-return-any --warn-unused-ignores ``` ## LoRAnado (PonyXL & Anima detection) diff --git a/pics/filters.jpg b/pics/filters.jpg index 52e14f6b0b210b4b441bb1aa9aaf76aac6af506c..035c6ce685a4c28e5306de0faabc00444d53f6cc 100644 GIT binary patch literal 124660 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 literal 103419 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 CBe1Ff diff --git a/pics/image.jpg b/pics/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..93ebaff284beeaa5c24104976d47bc13ea3d8597 GIT binary patch literal 151646 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 literal 0 HcmV?d00001 diff --git a/pics/image.png b/pics/image.png deleted file mode 100644 index e3bdc7ef3a1c0686439bb4666976d10905b565bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98271 zcmd43XH=70v@WdfEpAYBy9E&tuxyczbO8Yyy+a^EXo_?aYCuZB3Q85}U1}g9^biPy zrhw9G0tul??}R@nsAKZ{ECFWv;bmdFC_oK~G1OnTdnx#EBEk z>S~V-PMr9&g7z2x7X$6ie%#j2e!Ym*GZ}Eo-B9Y#GW1+v0Ggw*VEb1~*Jv zjX&G~6Jrm1UT>OOmzC01jADHE}GFWh_C4&EK#%1~^`>(#2 zoafm$tXltpnG)Ip((l$^c+9Pk+1>qDwQljxNzh((ZUWQUrKiu3V6TPgcXpU1xOxiE zHh(+Qd#M+__&>Zl{UeO;%E~fa0JQ8LQkfC@dVPe?hSAfhYg{Wuhs!iS3wefbYt=VR-w0oFoGRMOndkuWr72F~b8k9*JT);1 zb+rYhH=?FtD)}CiQ)v%>Ku|;M`AV2D!uBIuhj6&`rF*$Pj3t_&zKWrog*n_Y?z$ky3< zFIeB7PEp18VmDsJ_cx=&pPG%T&ga>0%E46g=mOvG*0k&dW>=@@3M=hib-WeZ1k;L5 z4S2zezhs*D2wWL!T0ey+S*LW!wxlf}ze#cE7rlZ=fGG2bqiG@K%g&#t706p{BmTI} z9!V}&3lI3A6u1L*0Gh+E3R45>uYoJ1=+X?Bj`m85xhuPLHB)lC4YC~2{M?Pl(2mf* z9%KS>opL^T@;jz+w{))7R2Fcz&Z9=5bEp>-Daf2$clUEvr?uL7-KvQL^5)dyJ7S3k za%*J>!R4Wv@AODFxyQTJSC|{G!!?qer@bteoVy9QiBWj4Wk8*^+m@40^{Dmb-xbSA zMyy1hy;M}AJt4vEVi&6xj~V3`uT@%`JT( zmN;Zmu`O4)ROFV<1|#2q@G@^CZB4>jhso-xn_*bFEtaZ`+lPYfm_a%HToYC;u)(;@C@O?h zHvsAsI|vxgF~cZjrSV9+f;jEFR|Dqt#7sTT-0j+Eyw;&$p~J-xkoREZZ`4yum~?Tf zz~e4p9b1YuLp!0zeJN#w%KPW@X$z))f8)r@WW*6}z)&GLa2~+G|F7LgJ8X{NzjxzN zXWiO5tMdZ>s2!NTDmCB$5hQ1`a`df$FwDQ^U9X!X>$tG5>cl>G1|*&3{zYKro3OXU z&2lRAFGI@0oAT{$_Jcc0%?h0<(I>}Q2gV5w)DGt|Hb*L_6l?h!h`GY>A|`JX2lZ@w zL*87jWTYOJ)lwlgF%LuH`ck)^6PDMoa0@%X_V;Yfjt<#?0#7bX&rSEvGuWl?4>-oJ zZY-benyrTRN)06CS2-;1S91BFH=|oNXaAn@X@=#g$_F5F;F`!?!>+*%OMgUSYl=n2 z5jv3QA^Io{pT$%3QA4J$FLmj> zepyf9*Mwhcj>@Wn&Fk%jzqDKgad$Wc<+&<$Z9d*eknBegmWpSt31)qd6Py)@<>$)m zNQW99_>v`?P|{fy5Liue?Y>)*vCESpZcMPc57WP4gXkQ>%l3FP1-Mtrm3AU*4ekI+QTqE+y`Iz^5?ICRQ^bn za$Gn?+arpnLCu-rCDLC7Qbds|xPPJ?^PuCkMx#YQKCGt%bZH_;Zo9%LRHJve0ouQF zq}U%Lo5<=Iii8npBVNKXqk=9i{-7Z6i2!7rhJ5;E$4&5cLGksW_9%R(SN zil^#y^Y}O--t0Cv^0YXuBMcFP4w+ugWW*>{raNj^u=g(iogChxQ2oR`CHFKxWUdlN#!3rHubgC-Yj{Bcpl(W~Tnl$w zkc1}(=_uu$qR8_G2;QV0Ev2%o3URygxQibpGOcQ=1LGcc#0!q>rkk8oyml?rBKnj9$zDI(f0QkX8J6G!DtMhK^+^>;KaAAK8E^o+n_ z=)A~(`p-Z4Ko!nO8>D-Ba}}>2y&fnqxx^_BwI9{-+g|D`81ir?37T7!jvE(Y{E*tDt=4}& zDw|_#>=#TWU2@@R{#t^-LVPNPa}Md!jBtqd+x$8iN%VO0`VC-IdJRa-#>eH|IwaN@B9ivH?XPf^??~rpSFIw z6?*>a-7#){E_pAlSFc`S=Mkr*$3j9vN;6^!-)EzlBLC7%Fumd}*O?z$u}5E_mqp?= z&G_cqq2NvhYS z0AY*L{@;QZ7x~g)9K3Y_`S6`Pu>u@?S=h9I?6;L?N=XJ(x!;n1xRB@v6ndFXe(S1pE%L9Bt?K~Rp4ye%B8hQy*F{Q$hcX8f3?YK_ z4#$4$s0WVtg2Li%J^7~YKP}W*g$TSmX!~kTeDzV2jM>D=RP8hV*uS)-J z{cnvkeHwIpN2!6++2%#~ao{s%aR2|ox&Key@>>HGUd_m8O~t%?_~iymg7A}*Cn3kj ze=KX~1;2>2N=s(M{A$J@a&C(Wq>1EXl~8*I^e!|O@Z>Q>IX%qSQ>%Pp;u-lk`nzU* z>W?!1fA2oNKJ>xnI6PgE-Nos=E6G~&6+Okm^663F0aULgLiM5}{gjTqPVoQ! zLyOl%yej&8y0`$@sx7SF;QnQ0Wo7JfL~(z*q{{IAW!>|!Cmk3-w%WoH2Oss$(nZNe z`;yjqn{__R9Cqi#cM}~+rpGdFvUTT+R^1iouxj()%P`^Nrr)m|{Ex>^+n%+}INnkF zH~;hYpOx~z*Xi0#A3##W@$6jlZw%7)*h|a*=@s_v8=@$<|e%vuaF@Q%Jyi`N

#x7V=6`;7vfr!pk8|u&w{D-UPG9Le3A9(?#$D1-^LF1G9nkkZto>rYgK70w zhh$lml?uUWS$-*#v-5RXw|yg0_fnf{-IGaUd42j{>byypb^6~1fQ|5A@lOg}iz|}U z0ePcdu%GML6>ZKzeVj`2*2-oNtal+@*JFMe8tGcIuHL9X-vMSiA*{lg$g@9mJtSJp z91mYvA)|ev1qF4@idl#O(!v1yo~Pp|ivqa-S&P_uJp!w$eLYi`l`BIqLW&+5qgyO` zE-fe8@#~rGUdGK_T`-%O;fr-9s~@3kIZPL_2qeUC6u&Rp$Xsek41`5zjT}UB`d4*b zs&&sZO`Kt`dUOj^n~6!^d~4s(@KnHT$%0(j<`DS9hew|B#HMY!Up5(Z z>~oqe?rrEEz-N$Dn{`Y+B)8+LM}E~#gK_9Hfq!arK}y647B9#3=0^r=OxQktBPRpx zTNgU?S1ju6lMP4iJd41m3*I1Jg}buUi0*88`Uu!kPrEE(IogX0^?v=*ThX1 zsB6w$sDQC<-Jtf_cp(p`e-h>TY$K}r&GxMps;5i?vlAfxZCVF{xm!|2MuQ_<)Fuak zWI-+Lycg_wE-w+41vwXDpcOXL@7bh2?z0lL9we~y?K+q=j+eijdiT~8mZ&3B-sSN; zUBx%4K&>wgV>Qv=Xeeg-W0}5R%^dz|L^XXFj~l83JQAMdNeHP_3za@Z?Mul!EG|Y4 z93r69or4kS$8z?KIZ89Lb3|r<)KjM{c-=-yV#S?zf2v2F;nlpI+R1ztsEBFeW1ro3 z|M79_?u9KM_qPk&q>0vfXVt@AVA?;wc6L2$R}d&wyAnMNu{|xsQsi*dh%O|RJs z+4MA+9>mp8wO@ij(WIQKj_gPGiz&&M!2)^HmVR(fIZ}TVb#{)F{eetLciBe%h)#bo zKNC<8Y2i1R%3W!xfBT^k%BJXTl(86ArO4kQB01kXIcRQMGzeMEPDNYiS#WJ7zh0;* zWSj=E)h%apM($^NGx#saicM{7R;!NJ1?F$>32m3dpZ$nor7j>yn}t;)GuxV>c!5?h zeQQ*vB;H>zxPT6OnR4;;B>A8WkY`}Nx4i<@oXJ-g?eJBEt0*c0Ky9qRN<&tQ8GW3E zPdYYgW1edZM?OP% zsj|Q0(sWDxGi|0P!g7=3ixCiRLe6%&%d*DKd(c(!wK8)U^g7DCwp%!-^SSYKLMV?j zPTIf;d62=D48@#nPF|f$3xH8Zoe25X9$lu#*6d|el-0t7uJTn0<+{A25t`c8Vg0?}bHkDaW00#pB-#7jd;w+E^YQ8^Y%ifg;Bm_JsJw|ImmA zNe#yPvcS<}A5z>>Wj#;w4XV;mjOkqabR50;m!T~`^mrrJjglJhCDy8}|6HB;Z$24+ zbcW?qs?~4fk^VhHi+c)5YwRb8IY0aK-Me?y|6z3`cB6lPE^OV^i89z{Z#*8U8H==c zh|=Wu4DoAZD@~^EE$szQ0qWcJskWl-l&3;`7k&-u<=JQJx1uEyYrg-`;SCg*1*rT< zLn>z{i#gPuY+27V0esA zhW!b$SRQA;cRYNxj~}gV{D1VH{_}Vx)HEZy^`<&O_cNYeWNL3Nz{D)RM-5)#3YBxL zJ1uteY%}NYC)pm1KfA?xISO=JHwEC?uHMwtj1^WBxC4@Y_op3j>*~-F=KAtbRR>r- zU1o6o>DsZC+{rvP;%6@G=UcbjWMyMpNOWlN!rM}tH>2hR`|NU8#6zqExvD&7dWB_1 zf5a7DRSsprLkY2Nn!iMHO~E!h?o=t?`s_vB7uGpy9)kwXX=ilPZPqrQGGuc)51>FK z2tUndH{Txa>t?u?m|>8^*kMC*MZr5h&+@rquS`C@O z`Sta)^9NqKWc{KD&`nOS&Ba6PNC2`L0YFXQEfV5{mksL_aVZP6wiq$Xi0*#=Nu%m5 z@bbl@2$0i2_opUJ^s^=b1`NFg6=@u<7E?OpK=9KY zAkV!e%(3@0X4a13NqRS-ec$?K(pSrmyzt0-M?XK{O~92`F9A zW|tWSC|PJNOn@pYa>+OHJ!8lE_8u z!aAIH&1}04wL#J@Mz4_Tr2G$~f!k<&p+zq9g0cO6r1hm-h9tlF0c3TYXsS!PH_i1n zEpdL;93W4upd7ilfb2Sx#pi zn+4;kuK1M@GwX9d7x87-U+tF{P54Hx^|yY0`@C%{V@}gDhsW85xx0{D&6*?uhoFr5 zfrh5%9p#5;18@5!oTN2dgqSDP(G(z`+_OJ>dZ!D%D1rj=&uUASKYc zqgwsZqdOxC5>Vb%en8&m^ZPz}?^0Vjl4X#}$TOSkg681Cb-m;>i*DP}P6+vhfq^qT z=gYcqz7!7mMugEq@aDRvC}L{~<1jY1?FoF-Eay4@YYLtF7WGBWf9E}~R#JhbU3>3n@FD)Q_b5U2k&^L?7nP+9377FK~9bwsHHmEXElITz(7w|;h=`7xSw}f*d&t{SLXVx%*;F8SZSX32T_-V=4 zfw-7xD(#(%@8r(%Cb4>eL~K65X67_UXuY(2xlgT!t#VMNu5f%BvL)IPotZMDz!j(h!axY^iDgT9rSNp02TDick8;{#@P|rAo?JI+aTXNbSsLbQ^(|ordc7xWdm~2!k+->55`^qcFc93SG6sZk2ZvV-Fo#pX zab*N1=eqitxo^3^vP@%3&TT!L;li@ZoAO-kWw)YP`Hk?8 zw(}#Uw>CCaFh_=oPM7K)teS13J%`-4|B8%C56qDDn4Vi};TibxL1{KtBo%xo4*yf8 zL5%%6DypP|;$l~2`IT5ka_=Q?Y8dl*k00Nb@ppMX3Yj>s(hZQm>)IJ7lTZ~L9Ox%R zXfTAl16H>Ru-7Pi_#W7X*431#k=Ek~slv`{rIUumHtmV>!0XqCj541y3Y*t9q9}QQ zIR=H@ACtsNw`h)GGT>m8J$v%DZY8SM+eJ=IRSK^@Ce?zB$;^8vi{8#aHvnH6|^ITV~JE}NyFoiG+S1VoT*cKo)^KRlrcDX z_dxsrSbYWO?KGH|cv;j0L4!jKL*p*Y_q53Ii_a&d1s<~R$$gT8HA|Q-n6-O0&ksQG z$qK%65!K5*>r=?#IFC`FhOJ~=qQy|Qx`+0WO^_+Zu~2?~Kz;_-k|GytYYF!hn2vc} zdV0tqntdwO(Ln`DcLbI6B#Rm|&pSkdVz%bSqV!m+I)yE4{m)ml=OGXFwijl5vZ!iZ3;;cI#r6lF zs+2(To8O=CkhYR&DKxLPFe$Uue`2rWmqJKW@QbOtuZa1<_@R{H-v}!#w%bU~N8FTl zO3Sh^A#8$<3*jDQpaz(vXr*eENU&8YO^hX1*)FHbNwonBi?fQ`In1V^$$QR( zR||=o?v?&Y(S2V!l>lFLk+^XNfPL@lx*KTo|v}+B?R)%c2$tU&=huscs0DY~D?F z%IV|g`TO$`^TD;J#1YXx_}~m|<={fOcb6h=Z&|+LFu6!&ZVsN_%`5}x2h&a)+s$B- zZ`s&h<2y}B?~zm62yRXpu)dn!dm2vJ5~r;ebIZw?nR`DXAZb6V0!+#%UcCd`>##pq zJ&<8Kf8N+R%GqV(!`#+bNdH2SH+qS-mOZ0^Ja}D0c6YjAUyXgWtlY&O3V#V3PTJZKwF|#2sGnJxsjK4Dm#dw0*C8ZM6jZn) zu_ovKBhT;cj9Fitc#RwoN1(xv;5?%&6)8@4cH7pm{(Pm|DgQB>gear@(>gQQmar}K zoJ?O}?ORio1!G|rmKg22&Xg(qFXQ`Cb0m+In7fWG+XGj;u-k4G@Uc$~sQU2;wGo}) zfUkSn`-5M=tny>7Zt`>ZnIspTbLW_}pcz{mVgZf+Y9~vZi!Ec$QRknH5}IuBs~lnz zXHie0Dz7R2YFsUq-C?s!>?YdQap{u|B2H7lCw(YAZyc;eiuc{!B-a9`xxOHl; zk`${L^Kp2KK9mtAj^oYOCupc}@{Joe%H5WjFe1>A{P*$N$GGdN3%^+bjg9hdcCdu6 z>MZzr*2E+$2S@IpVGps)BN>#44J~b^Lv{7BvouJQ>3bSkc-%ixQ4%&McuZhsoyGpFdUieG~~A^s{=ku6Z=>C?9$_Dd^s67ZGY3erWh|_VfQ5TQyD6kAt|$e+~66xBIU1e{IopD-n~s z4DBz>kByoOW*80mi{5SBSIiB1cv)Zcf9BMbyx6y)1zRePy_Fe-b%kM#jSs=*fPOKW zcT>izlZ05i1Rs>6HyYT8xna?)^_kMz*2S+HakVvFDZSLDEmz3o-kXii6xpo7!6#hz zcH(tX?q+y1oWDLsA3;D94fq%;tMf=e@z}sydT3lPN=r{&j;zy4m>BA0BnQ8$#CZijVhU~i%1fz`4EGdfsPZ~3>gIYYaeV40r3o?f@fc&zv6 zJzlcz5TsLzVAW6GBCBkFg5qEChI1llPMY02V6C%yq!i%}fi8g6Gbd$lGBsE4*?re9 zNNjX@p>FcpgXCW0@%;WvGI}DF>C*cbJ%=mhk$$_C8f z%F4T3cU|=!T$crN{*p+&(%eejg@sgahPi5<=4z>5`Ufv!nXvU9m7Nh6C4N(#J*j(n zx$-c7E9C)vDRVV9Wiusa=63A*-Lgcd6j=}AaXM+oZt8n9sHkTB6G(%{@S^CY5m$k^ zxU>yfx?&qMh@xdVt9(Cpe!c3YSqb1$vQaTjb<>>bGP|i6R{%NYA*4^y*aS#P!r<$` z9nZl%$H6h3cR@kCpmQT^w&B7na-fz7uD*VUy4jN3>E0l^*hLf+Q6;C3?zr^=s%Q7! ziT@XA{gfqb+s%IcaETTM>RfqcQ?dE40-m;FPYdZ;u(1n2*KFn7)l8+s!C0Yg4Z&dz zpOpW7&3N0@ko<;lCsM_dbly4xF#jD$oz!mIDK;Z+N{3T+q6;2ef2u%+?NphR%^<8A zkg>PDMW)&l4QZJ(h^G69)xwYO(L^jXnO8e8Hw{M}q925pn&z)wD|=dvLSCcV!Wl8= zukL6PEP*Qe`YkLhEYIusdz@9w%u>uX+q4w+H@cJtijJx|w!Z&GqI-#B7it=e?{~g` zZP#V=#U|tU>@ipUJb9^%)d8cGLjil&4KMtX(@P};&9>0iR_%Af=xBnu;>o?^+(y8M z-^kYg<%<8nMY-lci~`oTxCjwzSTi>j56qa>Nh<1qu+mSNYl%-CtJs0n!kRv@zbvC(mUKmMcC9iK$?`-w;nH5*2QdYpTZg8vQ#Pu=wGWWrDe;{X)A|~V3d<(lECrj zuSTu`riBoobk2e~1x>$JHuc1m2Tb4D7o9t^K=gg}QV`Bgfpjnm z5)YCmwKR(bMs~v>XjSv8-j{1y$@D{bLd11#r$fmLKa7H0cTqI*^bQY+JdfYZ5kv=e z8Fy89;Ea#%J>e3lI`gmobVhAb?K9X!NoR$_mMf(aQ|`HHB7Iwy0HX5|`;;+V$Bb!2XIKeNP#dVT z3f`Qu`bhY_+z_u@+gHGc&;sU)BqcMT&EhQLiZIuRi72dWWXQ5eOaX+L*?g1xK;Vfg zSkBL@s5P+A0J>1or)ijaAiN=EGyEr)?2?1QusWA(>u53POchK3 zFk)Jmj0%ut{pR3kz1gDsW^>kQV@V7X_uYku6=S5y;Jmh2-7G|0WJl*%nH$#TlR2pi z;=1Nm4rulY3nbh>yKpZP>%eVa{8Wpx@Ujrqv?gn$#au8!FeV67IP%=~YB}U?&*c^9%dKLnIa4YV{1FrPLcekvQqg_F z1ZjPYE{*8v7ogUYi{EEutGI`hW8B;xt*C0+-l`di`6YJ?ne=0Do)c#M@3X= zp?UtRmKom_6sm3Hm8GSOykHjCo&Ru<+m?Vf7e{L$!CsiwH> z4h5xdAad9tVp-~uMvOJH$gj3v3Icwrz_sN=Wxbz#o+XpVhqISqN-!Ks+bBoQ(W1HR z6matuCFzs1KJ~E;#N~6yX)V3WS$_z^9U$|;$EI7{C_@8EOJA%$J97+(-=AJ*0I@w4 zxIv$R>)QX>`}v=KY97~sr=i=~!&M$tx6Bw@V{vKQSATZ#Qcw&3`D1a~asXl;k3wn0 zLb@pgOE?XC5upB1S5{G}LM75L-SAj}`IWjOV^2@d(iu@2?B}&OILDoc^;>qxgA{r7 z93BKZ_T_d|6xv5MIubXz(au9=G%u)syzYW!KVi(M%oYl}^SroF*xa;irXA{ZNy-WT zI6ZRbv8?-#?#w35VWOtxmmu%CM>Eg^=hl3 z_NCxT&ya0xqIugSg@Lj?r!BX6-Aj_2S=*xap6!D!stX;2>Z+U{rWKXCMRkXW);Qcq zT(siR$883O@0p|hC?_^jToM-Q=tSD$`Wco8W9&z?EF*Uxnp^nlqY=P9WRYiyjp32* zU+0+&tn{QYo^G(saX+u>K}g;c3!3vdKnqc-rH%^2Ic1#2Gn=qzV`ddPYtQxcnuEZ^ zr(2pvRgTQ$CplO}uEQz>u-asaSm6ee5K)a-WMVq+t~s}%cmzhTbhn#$cpe>~HWwvy zt56>VfQ8xXQ>~6utwxMfI;m;>88lDn@C@k}Zt-PFetzYPDvjX7wWO9F>TtOK&XERx zV7*tS@MdN-NNv%pB=Mprr?j)HW_*xx=w(6O<$Kt@<)CApPvtO;!+}x`WfPnq6r1Ww zAB~8ZI~pA^t@6*1hCIo!%+>9AbD`WTGA7N3sxIHyTHcW^Z}`U{3D_5q^!d{*jXaaG zX47)3h&11IvF+JS8lCDfY!8J!p!Tc#=4j45FLL>UWla*FJ3}KVMd|Zg z*wgz7%NwK2oGn#bu}3xJM~3h)-7BfnxHJb4jY=iw_2TiXS0>=(r>`q?mDmwd$T;Fw zk!oPn-?cf5ZJnZuE(^GJzRI7tlgy}pw6PG#D!uH{UNB{&|HGE4EvJ}&f=0ZEO{7dl zdR47YAsQJ!6bC~ey4hkHM}W0wn5dHmLik_4@Aw%GXArHH2?d!?}F z)Ol2z%b@he)m)c&(bS?*LQ#L?ZrRU_M@7e`a*+iYoLnX2k>(SyAs4n4Szfkn)d{L` zjes2l&0X*qovx~ZJx{Z1E-Le!_@Qp@4R-MRbs$ev?%UiQ6fi=(efatfbam_jZofAa z0h;k*-rF%Go76>ltq{Sg24v_Am)FVd%ficLHwy{{LF(!Lau3^pLWK7hEoWCA1uhWS z%Qn+T4pO#;mSbVojl!1#83X`M{b>&QneI-eI0XXIR)z z(3n`}7s(y)Lrgnuzt04!87E>Gks|w#3OsRv3?-y;NI8aajF15fG6g^@tIuEwi@Ehr ztug#L@fK7iG;BxzUC>PbWTZlpqa}o#eC+mI%G&09OF^7k@_ecXpGI;YClJsXW4Plpv^{I-pLrV{YNBmmA9Oj%H> zAA!furT4+R?sk>o<2sxQ0r{DImV#iOgL|+b4jC*6OuYAbb=B1dYGFwzbU_@NZ`-$Q zrTUJ&GL|;0#+cCLUR0>KQ9Pv$NsLTvLQk?e(DU|-l{||JG8xa3 z&#VA5Zk(p{_jQpJX%sK@My%8HK~=83elR6Jr%s&$EZ{jpWT6^%ohkZJH{Kbch{7CV>u1AveG3i;aUl34 zjWuU!%O$j;h6sBf8tSqAN#f)upMXmN?7aIL9f>6o@;2t$j$h;7FmPPHaej4vCZ#&38+X!h)+KPdmxo~K+aPHD{!UF0^ ztpSbN=M-sK?D`=y%i4L5i!wjhqsDtJS#c65U+UZxG9Uoj!UFc(8^3uAaVMo#v zZLS*GS-Z5fu{}q!#2xk#f}G&bteruHW@%D+H0UuG=eIpHBi6+R6Hs9ljKP56&vfSy zNT%6ogb_Gzb1L4FC*@M zZRcV|3{Cfr_WMROU!B@Ds@ahWk>`Ss%0S9zmiDbN%-Y(n#zfZhF$yxVi8#NBefmXFf8?Hz`SwC$_DCz#(|d}j~K?K|7>cE-tH_fhIyxAmI6Qlqv# z=PR=F8{>{cnN<%{2uo-$vTv-A(L)+V6@F&f#fAVUmivT){Z?=n7RzfJ-t0N`zIAgX z{@@0=;PC>cmG9$FPaN9v*Mfphug{c?s>rUU2AEbnPaB!t3KZ}_*>Pk)Rq06F{!B|^ zNOwzQ#4-}&L_iQYf715TMHx$Cz@5>0d+JPoz9Gl%E?bg>T^e`bzP@(a&YM*6BtXSj zplwFLFTZAWWrKf^Sa+n0BT}?+6faA8rk1+_`){9FPsZGKfqQ?j&;J;_SyuCKn7C*2|_A7t!-2j81Y)|ZyUR@_vZCt3pHTpjV<0xp@x*`C^kK=lx;|NBa_6zO2I+&7BCoc!EZ_jHKk#jKp61TKlmM zZrozyL0i1vWr~b3$|4K4w~bWZ@ROQui<0x1$R6Pl@2^OGujl|bs=(HPA1VQmZqd(_!&P9eC-mcX=oRZ?yf zEEwsY^gwPsH9fUPK*LJy(F0P&N;hs>x+E+9+=hKC$$Pm!hF@t2J38G7ypI<`05KI_ z%YzPIgnwnXdOr@Ubj2R14}iTdiiYG)Z4E0(4~2kkMnB$qm= zr#R6KvuW9`f%s_-^+c<7dDgCiBAbYzmCX-~E3Gjiu?1Z#1%bg8E~G*bViZE?ohPEI z6*|qzHbT3GW+bh7n6pPJFYB@^Fs&liwrrD0{c$*$O*we)CG3KB zy-jXZlK$(AP8>VX5*iD`{o1=@t*#>tacd)cJoYPTFyC#m_acT!HagM?PrqX_8gXE8 zOOpi{?Z@HJo6WQ6SxSN+bMpIgNlooC`-jyWl9hb{H!U!bqDr7V#uV*n-?xIK48b4K zkQ<0Rb#}p}8H@T*6WSclr{AcU^m#h<fy8sQ2U zbwjzln;96EUDQ<*yo%cEcVr<5EHGA{NfHDKm4<#T8eOgCcJ!!IXz>T-Y*(EtyL-q? z<6Xd>5oxwWqN&6Pd3lR!8Fm%2(D7{LESpFP%ul-{lM$8zC>*ZZ{9b%v3B6of{j||; z3?sY2a+y2VrH&oNQ+uS+(9jSPE^E_eQss!A_~X=OT?WtKj9BunvGxY|eQx?O(Rr|I zpxqLluNp~kb~UZ)4fj2u6mIPt9_-Ej#0=s2ea;E&mpL3CN&Z?5SDU}SvFqP)TORQd z@9Y1Kvqwp)C)PKFRjLo!&TDr+y{yosWf>qg96_90*O0+9e;Kx3n`l~mJ;|?nHK!%T zPrK&^O)qZv2z#X)kg@2c{znoMd{Pb+6Gw`Aq92ZmIOX*=fw9(L!OkQ6>7!u#mQJP3 z$(;{0{J73t_3`Y7+j7K@D;>)&sg$JTQG!;aBDI*h3A;T6i@c(!b-J*%2%hGvGet9U zqxTcbBiq$b{qxmzJ=#Vth-XE1_{r8!Qb^*xZqtmXvQXX0>8&ds%gnI?L_*Z2tRXV+ zunG=s;et}!;|uc(-RSs?2oEa)Kp;+?mAE~KjiF_SUJ@&+ERW_kMm-o*fpV9;aAah# zZF_=4ootVNf8Hx>Em3*0MYp&9!O9Ren^uCLZBrb78&0l2H88U*+v|yTc=_OLK&W)30T&j~y%JEO{3u)^{Vk zJ?)=)WO;509nZ*~E=@ho8oX>H*~qhA{Pl$sz@T&CgRK`i5W_xa`6PuNz8ZSX`uEr| z`g1}tBr<;DnYE|TeBOB;xz98%U6pX{_f^2x#wHB`p&J;Tw7dDU3DKR=E!boJ-2LW1 zZQF7`jxDyP-K@@x;Krw=l7{0pZTJQS+`zK@*FtYZa=USpHR!#MJ#5k(zgU&hB0;1j zNI;3wwDKnj13|ujgKyl7tdwGyqvP%;L;%l(Sh>?Q;hDNSlJ0GAtG0rCsP^{-&(*PX zNcipU(z@ePuoGkcO}E3*_xyG@EOV49z*^oN!s)MtB}@hHfZj)NMS5zSR6iwb@SE(% zr;D9pgip|zK4xwuJ+#VC+A5n2yCdk#Dn>MYlpl9b)zFw#(T@`EwE}5G^`A0go1uZX z8OOsDH-A`a<8e$%7p!M4yGP*dr^~&*>0^SD&pO{|HoNn!Ykj|OdUEeZyZq)sx_-F+ z|CxUNKc%Vv|L3{-y5;U#uI03q-g6ZbljLJmcI<|1xl45rGpFsg#3xl6)t^RKi7E6OYBstjB;tdynOOnpcv#Q1tgL8^ z%V55L{oZrYl6HE>r6L(C4RKL7B>`0%m-XzQyggNRTJT!^F)n(-|2IYSMOm(~N-ht_ z4C`^7{D~uJm*UuEj$`zzKIi@aFdy*WnaFhpio_IzHs!4yUzG+ z_c{hE&Z^Iz4bv6yiLnVykFR(Ws&3z5um*|Tk)=6Taa48;lh!LW!!Lr^jq}mi^T~Mw zoE45o+}@-r3uNFjQabIPP$C`tUx?jGIB=+K!f>ToBW2^Fcr9y+=UAW~y$RlaLU8C`>w{M_EI`R-f z*%=}YQr8jHhPKfsQJMLiwtXVT%$Ad2Hp)va3K==b2@$ep+QmA^V5e zYi8HehJr#q$c_!UojVv{ZaJli>?o#~Doovk;_6b4$?f&A{DyB(AzjRb3GGW#*ad6s z#&j>1H`36jRf4sL$B6=uTdA-U~`eKvc_7Ye4Lz_ zmk~#Ya$)Fr?J-XXM9I%0|=J-VdOhB*zh(V%|VX zLaWeRe!Q=4cM?o&3p;)~bt^NiSjd+kMDZ&v%N?Ybj+`j`_c%SCm}S5vC3;Cj`6x#! zx)a@L>;#j-STGikLZx~@X+d9|aR2wKb2C26IXUR?=L^_BLEuEkx zGTW0wUMQ{u`V|yF$V^a7Yrb}u{yd;bV&IPZ5a4&wLiYS9dr1xhhooOIG3LBio5AMV zm1>cA;12|Cm+9-`yXeEXFFtY+Wb)==qc0YE^cj^T?Vh{EFl97i*1*)xedCft>fQ23 z$;Ryiz!hlEK~cCu3)SrSKz?H7d-C|q*3uzH<+q-nndyCEeQa><>i#D6uWJ6^u8v9< z3I(&=ZHl}#6X7q`o_W!CO{LlKFQ}yFK*&U<3m3dKKXj%jqO_A`oFIq)?`O^cbX#p0r+R%?eVZ;(^ zxG~CfDNfvWwf->V8*8DeJne^7=uWoKHiI9Op`>mZK6E@^AAPZp&Eq(ZbMjyA=xzUgpB^sK&t_1s^Um z@_B!E;}@EFNGLEzJ_5th0!z~_m>e58X2m;{JX->f@rVh{Vxh_z%@jmodON;B0A9pW+w7GZ=%3jLStbA zXt@c64Kq8g{RPo*9;NSb$bm}pBhIaSKfv_a|0L+6*36@M!##g|yQt7#_-46aRIN*K z)I`FrqxN6b_yH6wnT8nSX*_o_$54Ru0hqcwXJ-lM>`rk!as^=qy!!$Um4j0Q?u@hl z5?}q)Yz<+MR*(9j_GsA&WscM0#G|@*cv=v&@`#F#djSg6vnUgEe?`%gih77k?TGe& z!>>=9w2AOpEIBU#sQjpSRs4x{9T#|kq|_@iyjbwS(J-{(=1)%jcEMu_+janrJ0!Ij$dgBdO>Up9t09;aTSYnc zBeG~*ZoSaU+e7-=4!E?>W7lZ3X5Z6iZ2D**X;zeFm~kYxOCGU9W{&Kw^uM@!&w!@3 zu3gk^-4^uSA}T1bZ6F|^6sZA40qF>YUR0zvA#@0_7Z4EXHBv)>5Ge^I1W-hzCDKa> zL0W)Nga83T!dVgA@B7{F+j9&cSLR5Rp+?KDS1n9A>-TtD4Y>^1Xck=uWfzQb3?q5?}E z4B`UdoF3A&bQpLUc^X_~IE+mX2jvzPi+)HgG{xL(<8ab}cF%+9y9A7W6TAa>)MO!( z?mYz-T^Wp?KFZ)q-u@$@W@liOB0vY@L)3maUl z_oi(Cih$Y6$RPTu%j{(raCPQ`6m-*7DwN&mjQcwj`To+Z9Irkz=%vQn&CkPKA3iMz z>jubl9n@o`U}!S1C-k!%PI~1>4|+?o^sCZ+`)Ascr9C4xZ{L1yOIr;W(mRc~maQQw ze-RkG%wdoCqw;7P{+!@InACkBON=g|N82dQSyT2t*q(F}Q|U*so^_~%+B1y!uuZlV zXfaR|AShLfcMkf|z@;V!d+M??Y}!CTv+o~#m(x|^5*VVqlye*h2LsIH{QUff6sbU| zjhxRHKKr(|%T>v~`5Fz+H)T}x0H|f>q`1|}w}xX#JwUtR$AO|tD4{M0jTB@QAk0_v zn=t`G@LGwVs_axwmZLswey@Ju=-H1Gp zR_Uvi<{sX*u`#{0#TLHP`R+Gw{^njSc@KZ;JuInQ*q>xhCe>h4$Ruq*&*N{E!$Dd{ z5CIDr{^i}X3XtW2*F+Ce8f6lx-g>}e>IAQ!E>U&sZF)jt!nU>_G~f+TpG&d}057l5>Ka+g|X(=L@rA>#rjsA@gFx z;+Lc{;k=nEgEaEopBMsE#mjgmP>!UCyr1qoAL?N9UE!zcZj^S}+L59m#m`_BcRF?{i2(wDd_&jZo6*i(8@ zH`5B^gMqNFk|(VDu4dO4*Rd5*ws=AUzRl7|RgZnwYbB5@G4E+@l~>M%^=FrmYyp&I zsCr>I58FNz&AS*sOw(!}V$Op?OmGB`0@JyZR!+SOOY_Xndwn{tU2B0;%C2s%%lFY1 zMFUrQ{0~uIa@GBo3MNL=4`JIj^dnA5g7LwCp4Gn}pBieN?2zBg^iZ z%6-<=@QcA_EH+aka&z-ANNe47z4h%pAoFa{7|j zX)-HQizzIj#&TN&ZK0eqOP>sZn%Q+t_|_bT17OLSEEipvER0OOjx<3Y_Y@feCfS-l zteW39&M8{CL~d+nEkDgT*;ochkf7<9)74|f^D#TR=EoaeSsC}Kf6eu;zY066+7Wf9 zh5xTi;f$$#-a0~ODw$GBwJ|P#ecfX??c+$Ww&NG@`k@I7l)3ucUY87DajuMc{B>&` z9S%5HZCYrCD#`x%e#y3J2-4|?GYSDsU(5dTS?!jzZ&acMqmTtWMFA9c3WzCJaeWN< z@5$QXz&S8fVe(8QZ*1t3?HFXD;@1?r_h~%{Own-SfM$~M|k*~LBr}{=;+fSzdh1*;7cvr>t ztiJ2#-O!p7r_N;C-_`V6`~C+2QnYz2>b(S3!vA@ot+_(7j}FKXE?}Oj`COJ-dTnL@ z?l%ykcIm6pj~`VW$*aMIicnzir&#vI)#w9l>I3-QrBKfgQ|?U>4eG-ddKZx&_{_ zCw=wp8(I?Sm&(TME(5sMi+2zw-d+5DzGr)|)xTKEvC4QPYMZIhz;o(M`h31P0B1Cc zBU*kLIAr|xd`On8XTr&H3USTZq45AC74WX`_MWd0JicQo@pspyi&p<2LA!^pwn999 zXY1BS-mJ$8M=$9Cn&RD3zX3j7YK-do$=}&^;^`em_pY@||M$Myq#d0j{g_{uXul_> zGECkXVG{3eF|ls_AMg9C{<6L>wRV47y3Y}^1LgpPs+X4Nm07pnFec5s`!0|kR$kg3 zzJJp%zO-by5Bwv={w25u;l9M(-ag=AR$OL+g?;Nk2fVRw;y8tT{(K?r_&+%A&_HId zf8^w>UaOV9>W{JHJ&4&UJT?T}c@@-d#VdLJ@#3ZW<*Qa7ckliU zO4VyFE|yZloPP;;9Ug9EdfWbnVt;zk_ghUDm+ZLkScm^6_>Uz1PrZ`3|Br@RqBcwY z5DoB3+KKXG9jhI-9I!H@Pyp4UB5tZ}x(mow%C373&GuR=ih`?eHb$I+=yZO1c7S6V zR55PHNnwG;rMXwldIE=(@75Z3H=)SFgVY#kgJx57s@Eq|yD_BH!BU4ptL@_QkqdI0 zZ=B@+QFfKoA!<{QLq2x!;9Ym?G<_u!(zt{}p_V?CaRX#08&Jqf2b~B)H3^28APkyB zR8)p*i?aciz<1mNoI%qbAUc-!K4!CIT$WC7F#{_@w*6D;a{(zquEPIo0mjODVX0wg z_|9kYTQSF+N$+b?h%Cc>9s?+AeMi?0%6f&NNi%{eGM+hw=vvFx1hlVP&RA1)VUrb} z+qUmVwq|!`LxX?I==vSt3ib}~=&A>$eht9-+}Z!}>Z?DBPkQio?bU#|(ezw4gfVYi zZ2cE@2q;KXR=f6w5XBND%lodiS(SkaU1$(aCMsvN!Wq*t6fY71!o6xcAY zhtvekv$y6v(D&!36Q_-rNA{rR6v1O(qdEZE-r(2~2w@o+yR4l^XeLParEvtmZE(Es7bes={(hR{fKneq-n7= zN{J@d$*Ir&h_`qq3KAL;G0rOo7+u*ZvV}?vdKw_YLIX|W-jRS;{eE8tznfX7!aD%fet+L-K=$hhAj7oU{~(El7~lftddrB)xutz}Xh84` z=vsXa-lXV(3opxud`ytEyH6~HGCla@*FYi1ULEDvUhz92W~&R38hd{)tRe zFgXaI2d)h-DJ$BEiSCbkXmi7^U08hS-iLP+B_s!{uOAymtEnHF0Xl|eq*sDHS(kK{ z=PDNnGfwuzVd$Y|fvl$l=FT%IqO@?Yb?-T}KeS7qe)!Knzn9oI#mN~6kW6dLuPJG} zHim;!04k9>Jp69sp$QKXU@68i{;IBW1xNBTp4DzHxmf{X&>mNHg+9f9tBk<_rzd3C z%nnv)gJU??*9Tx08SMZOQ=CyWuKUeV<6QFH2Me{#D7NyW8}P`p6KCyi0m2B5d^#kZ zSa%wgpMRMn9rqF~SNVKTEEsM=O*g6JoKhAVnD2;ji=_3S&;|6Lq; z_S}Hq#xifk_+Q757a=mHoHY|zGC?&h@{DVqv&RaI)-~eALZF}VsbO0iB=1$*1;AO2 zLrY?rT(%>;^c4D@suaMsV7Vu$Pq&Bg@$tE~ZZ9Z_1^>M+YJNf%Irkoh309UR^j;2P&PSu`-XMTK8~ zB-M2Upa^hiO_$OFoLIpJi4=g-gu4v0c25h*g(yKc%CVl5Vy-%`?}!G?f}n^4GD{JX zJbWhsRaWU#{gXZOF{L-N4=(DymS3Vvrqw1Pngn4cvBKpt zd2-u{=C}VrTB`Uu{q6BCB^FT^B9qxa115HS#Q4CE3f|WY<-JC5lR5txM0?HqM1L!3?3e<&@2pdv(RG(j>n>Pw zW-ls*RuaWObRkyS&EJ9RNcrKPJJ4PWL9Acu4`9-*@*NrZ<4j5trzDO4?!U zrzFK7+Wr)kjwkLlxu2;(mImqWikJP{yZbv3taRF|X$fleOWu9{td%~Ol^H*5sD!`h z__Ccz$%7z(ocJLDZJl|=qO72U%r%EUEffe8oGrSEGR7yo^3BiPf3o^Da>3Nl@Hz^h z3Mar)mF)f?xD{>_gpdPeFdg}YNvi^(G^gHrsjfeb2g{tImri}7CUiP(_*6hbF*9-l zwX$gHj@#aud<;OvvBcC6Yt0dRjTWCG{D+KMMjXGeo zt&LLpeEU3kR*h=w7_)pvl>7lKc8av>pffq0dM&%V0DPG2ZzT@B=2MA+0TfSwpYUdq zU^S94{e$bn{$pWHVcb8?Q>%W{QM|ZA0o%O|79-L>+$Ok|8CJ78$24YCcp}BEursY4 zc1Zm>l8oCVan*9q{^cIP{IFV+Ige+kejf%gXZdU=6$5(ejK_p^dn@egD(0la(sKb} zap9eq^Olv)B`E3l8BQA`J8I@fR@@##fM8Vwv1J9$Z(#{lN(4>+4B;R%nSZa2u*gu#HPlv|=? z&BuFUvO?xTIJ0BzN!N^_8>Phgwm+`&M9?$TwpgxOx5TZ_GhWm}P26(G?UaXC-{c>%)wm~NOplD0 zp+94?va`t-Vlti`*eazwuUuqAhQEF5<0dH}?a_H9c#F0#>cLJ2B54OucVYvgMIfv7 z5R~+--u406HR9Kix1(R(bt+b_UlHx4zSH?*d+_p=#Q%SF$^p=03aC*y4S(S%eu!0Q z><{GY9RtI3WpAU7Yr^Tm(SAEE^T%%wwk7DD`!o;_()8y@c|l4QRpQgFh9AMBgFYNv zLIKdIa&`RnG+F;5Jn{1dABAYAa%fj@g_MWN0V~HGQ^Pw))ingX-Hvqrt}pYyn2Rba zCj!#mpDyDG2A>ivjA<$F`=&=esgn5&J~+TG?JxrB*TW6?+=5n@sCC*fcT&=PiWP_9 zTyA;~`!9Yaf$6y2@Aq2f>{uLD8fN&ma_cvW$v9PxKFIFk$4qcxODiW#lD$-A4mWcD zE{Ey+0=LSTLcggI95#Ujfwg+;cTN1T8Zfo_U1^y zt~1x$=AAA^YfJZsM?~Jm*1PZEvAe!m4;#`j8BmYkgb~1ttKi+cfBnN-$@pSe2p3O1 z+XBQG_;bb3smE%iPKinHx24;Ub>pPErQF<;|2Y8g;sp3orsjdqjn9*R<0eW<-Gjw{ zUC`+NeV7#ByeZTOziWQ_#|{u9(N?8~s@OQPG;YZIFTeu$gMkZp#{;ZGIGcx`8&z3p zUa?cqeoLyacRzIS=v}~%N6AR+LZ<1q1DP7-nfL`w=X@Wk_=oQw{{##)O>aCVZHHpl z+GQ2nRnoC`wM9Ta{Z?0123SS5FK`#zWB8sJSr)r}J%UjrA<3FuPJ`3u=YgD|uCp(+#Rsueg>EyxD2Bn8sa@qJ{<0l_1t zsRt}M@{|qcLWAed1I*Ujcj#ot6DY1ru%u^V;#4#^AoC>fUD0`Lf7|G`uX@XdtJH*A+0~FexdV{5}p7+nerNTBZf{QJ0$(bUH z@7hK6w#$J#$N|FpO_2wWwB?;ufb?skg_S@EPA3BJfgtm32i;n<6PO(KKI{+#uuPwL z?St7Fx`w`Mcb1Sr|J~}#ZMGuYuT@-bdNS3kGu+|uGRJtUJ2u+otEB3sw^7xNF4Eb# zx~caC+JoSsn=$wVUTDuwf$Cvip$3$w-BuvV$EG|g127T>vUMQo)dV2cPN4L3UGH4q zfU>m3(^ReI1U=mrsO^1r;3u2wE2hzXbNHkXsSEfv=;`6VYyc>|@4b4R%h*dW;I1Yg zqSx@dr)lD5CRv8=VFqsp?%jIlF>L96=e~LJq4s3sirxa3J%B!3q`IG)P||gi0;d9;0v$_Vkc5`U1qSxrg zjmKCoN+w7D!ga*t|Dk{Rr5@K$D*|dIDDZKs8>toUCE0C0k(*E zC>1DE3B4{V>)HJctNQPD=F|{PY=ilo!p9z<2IB(kNZT%RkJ~1?D$WZigqk!(2?9RS z6;pswio0Q4*dv~v@hfPNLAPr*6mh)YQs!C}`d>3|w> zy7SVB)2H)+q191L4spjH%xtlRrHAo? z^+aA#o4oSXt#t&RGA67)^)n{h|6yMSo+&d&wc|8+8Ln^Ix?7u#^IVt4kttWbOW7N= zcf}IE@g8EfMM< zwFc&U9FX0o}2d4S} z6SN2O9)iL3y*4lp-Z||OM>8Wwy+TP%m_uJ{iY(oUiFysx3z&!jgag1>@z=Xjb~2F{ zwL(e@I&}D0R|Vy5?=cE2dt~HZV|5pAqPSgg{|68N&=;QhzRx)F zNM^lmz$VbB!%1xSZgxr8?ZB=33N6a2xgy%0E{Y^ihnv}fZ$ubBtM)_ zdZy)Vw<6CO8eKsoImQy;sP_R7l*iBbIL3zKcrrR-=*(oz|DN*h3P=~19T^ov7q&;# zqs$$I+R^i)U#`@XVE{^X{`xj~m2@srBa$zlR%jywECgIXy_D8>%hrYp=wpRtIm;W$Cn}L4Ul+|C`r+=)o z?>E-{|7&43`VCYA-)4T^rix=-q5AM&P=A*(){{@v5z74y4#T(pg|sR$sm=~@#(j=b z*C%chR9sta`1u&j%t!U%-hUtcV=jPm-KPkf%J%HA^m&snYiny_{yL%I%jC3!vXNJ>V3~uMQ-)<`dakHL6$F&TcBzusCbT$me9ZQyT>>IrADda>z?${AF#+d!~lSRm7-4 zQ-Awx>Mz6UA%In*8MHLnHQluccu2lmT_AhNm3htRrYcMdn$=eG^+}AKln_sJhkqL7 z++W~>ch%R;WuZ!Vj3#dFdu~IWRXTO>+obi>Tl$o8`oL`1b&?~nOGci5WO)T(QtSln zLEBylVx`*6Ff103&#dOJ`WA$3{pcBQTC2PiZ;6w%VsGv{F;X-63^w1lI2IEg5#eWB z4!P+h&CDZ|C)>A`AZ;$RqXKy_f%BK}bhy*<_?&~0s!%+tw#%Tf_VkBg)&bZ4TS!5O zij@%(!z{I#RS~nmm)Oqs+AR16nODT?u#(ya1imx5g{Ei{U?UfLE{M(Gqe$J4v=Vt zi=gI;GIkO1dqRyQ6be(Sp`*f>qr709+_})}oIoWnWWDV_g#4~^h*0>rXdqqCr_H$1 z>M@-=df<4Ag>sX8(;!4D>+6LX(1%wqYgS(u;J}F~%0cu&mzTg=hE<)t2?+5oi2*;;t%RG}RR0h9)669X(I+;%BO==rf)!S;Doxg_3`q z!V%|ZR4i3P`@zS0s!Ze0VEcr#^QK=FW5~U2r_P?u`QYxExw5N0N$SS2D)kE%IKfIF z98ESh>BPnSKY}q9e({A}YGevy1p;MPM<-8*9vylbBYx)`A>>)N%`EZLt@IG*8)(lN zR`BTR%ro#%ukHwqdFI6E*;BJ8jn>mR0a)uxelDViWEDazt#0(Mw4~tP3xAYS?Fnos zdicb&vwBxZ5vWN)4&#=G1$&v^TsK4Y^nT{)E)DLyPJQna=G|)hX0FytE4MeJH$nXJ zollWV^v{p?8sSyTWf;3?ROlW&KqRW5PKGK?K--9aCXu~8OPqu8Gi)OPD`GGm3s&$hM)d&u56Jmk|t)Y6dqHJ&8N_r$WF7Q0Mr?i;>x za(45^7+3M`(tPHvAC~N!VYpgy`0}y(Hpm3>@)){o$i8X%*=i%`z}7P$urEZu?G4td zcBg$i$G2H%W$2BWjqi7A6L`G{C1;MmF|%f3)%< zoS|r@yCrR$+*m_cY?P#yxe;J&p%)X?T9OjJ8?3^_*oulX`9XA*-ojxjqiZ0IM7s8M#6VoG@L zo)y~Xm>Ey2$crrJp1aihW94m7_@n}Kj(-kZ02QQK6Pe{MgwxQUx=taUIta?#!mAf! z%ieC(8}zWMI=P}8R^6x*OXdT5%u-L^RgxlWi>4{b>`fG*P?qLaJ_Rl?m0GK! zs3566-HniyA|gjo_84aKRnt9P>#lx@1DE82wd@zBaTNWY8tN(rm3S+2po}w}S3mrb z0NhMDJdL#a($7SJA|x(|yKUb$2aeH>1d~ag3yRIq5bb6nV;+IMJlHWhMr8tkEFRXluT>(qEFtJZX z;jmXPRT&>zOhRwpO81Fv3NEPbQdxzKm7muh1mP-d&&X)d3+sxG+2VCRK|%`ezu-$qp7=sZ_dC zHoUw%ApFX+GXf;ISnhP#=+Qc^TU(X3$Q0;qHC`<+$vzp}Q;rGNH|SHyJt)~{n0$y3 z1PO4S3x()W)6(Yuj9%R8aw}tK-*sD=fTe|PQJ#;_*%kH97x9>kg)M-<;0V!1zn_dZ zDJQo+sLT)8@~lRiN>f=bYm-L%z8Gq}*yET0Ku zEl4fzx}yf+V!80NCW6&*%+4558nV)Si@@}@^Ai8oVQvH1;YGO>n+c+_li)ao1vFh@ zE3_8txn>LJ(q3Zzm^-)OUIO8#hf&+|{tkgwY#lTB-l~_zURW(OVIGA3vd*Cz;M^~$9{sA7|o2UbR7Gp;kEtifvQCu_1_^0ZgQh^~BK;4%DDKhLI4 zQoC@uban_SRvX1iOYO=K0Y;(6Fg1YiXZ3~<*A%&{4A1_+k`9~Ip}1HC@`F4!YHbZb zzE(9S-wJ%-(YG>uR2e>GEnFfAQw*@nSYaQ9s$pd9(By?sM*+S}m%0#@XOk(P6xxE{ z>BPeH*<)e)v+G(|-wh7k{>G#Z3^2QdoiZ2@h-=-<8J`VQ6Q>7yMgEI)Vq`PU{(6IP zV&Gd)Q(CH#D3P*U9NtnF6j?hYA-)!d^0za-DIUx9PkYM#D+dxISfUTHzFzz-D;pN7*;7ZVkU^U zi~0$WmT6} zmSd#6_0tL7(<&(O{Zpvyve6d0+<)nBKT$7BPa~bO`Tn<0g69zY`a!WT^|x#g+9oS+ zk4r_#!>gQLxqJdVbAC3vdbFpQsW14?WIs5OE(_N-^O#viLu|M1Zwx>*viuIqoLh?X zRkS=HtjJPvizSW78g-fS8&`>ngIi9x$=r>1TXkJ$=;S)h6li?6gbsM;^eX9D!Dcci zLJlKqcwhQj;8nr#sueiegooU-8^(}cmMU^TxXh)okLWU`h^U-%!q>RN!%gxRPGrsQ zYT6C#H5yOPKyUfJ-p?w%bvR6aiFtqY3YCR9wRE*ljs+2g^wU|C9i!k* zWS%Px8b%)ol!zz3?5Z76m7M;;<}R=Oq9+>fhd!#YPi#|^=+ZkJ6dd?&9ZyYTPTm%e z_Mwm-hOUL?O%}16KbR1R-Nc)Gj8kd2q?SZ{Z(021oK39%+YKnz`THroMD$b z2h(cB#ubsZHy}|=<4C#hQpZ7DB4f<;_Ga)M-wTmOFm7 z|0tlV*>RO$IMI^t0Ve|HO;@0U82n(uqQv9b3>rCrZ6;w>>FehKeXs#3DH9J?13x{_ z!#(C=KwFPZB)10Vc4pML>%4KGZ8EQrX{0Tz+vfLT*I-Q#*$~Lv-dCxQ3lRlMtaw#~ z$#~Qs1ezRl#GPnr@_o@b+#fv#JsyJha8xzFzn;btY3L64vd}`wO8qLCks}x)Tary} zqlW|%wC)iMa-nN+vWn(;S%W8|%~phXOva;S`Ed0vt%=5;6hx;wo->>hQfzs$lZ^FK z8DX<4v+?iaJVY!)q>$ypk|d)C1Jy|j!~n&>{-AFu+981-m35%0e2l(}bGC~u*+0*I zDj50%sQzKHqWMYOh_tV8SzzNKR6AJ>szL1BMB|VSZ{nySEt9Z=vUyw4o4F`L$h-#} zrmWH8htUplt$7u!t)+T~u%!*S?$jnJ+qh{mx^+CfjMzoP&tjF)0|CT77ZGK<*o$-e z9>H+pMJkC|;b5h0r0))_RsIG82?SS${1)+Ma9;&8DxYhtdt~^4GAVn(`6;P(qKqL6 zG4KXUKn%y2(fZ1i_&|^ogeg@*?s8-&8tPB5^yDcMBDj5w(_I;xfu#32?mEqboTBYv z?Xsr3f)V@ASo77{OKz%uUyzS9?^lVBol;aa$$Mu`6>tJ9C97oxGVotYv+vpfrd2qh znhs4go~%hu^Z5SsmZ%X@Yst^8=%JI)9r{L8H&J`Mbsb@E&vQCy?yP2xP`!Wy@Cm^= zVg3&kw3yWgXBgkng9U1$Hhm&nlruBlO(M|sM;<-yKR;NCN62Veh!9ns!|qU2b)qRo zniF0~)FFZ?J7F?-GxcJh!TE`G5fZn<>}K0^8N`m#YGu3C@Gs1U1J0!KIp5Sf-oRY4 zw5`8g-Sh#3Dql@b$NZwLlO1tg`Dm!ApI4CKoQiq&YYnu^_pKny&V(%1D+|kjHA}Dy zSWQptMCUv>w!9vBRQ^W|mfLOFqLhKarmQTc!B$y;xx*Qk?WtSb3&f|WS0`+jgn~>- zI_|LQLCm4yGA-`ou90QL#3+$T-?yf3A5m1jU}V*LtY`XCt$?SA6;}pd9?yNQ?g|QB zgkTT^Lbz|JUK!%QHtr(n@ELjGRXIsqINtnv2-osbtB1)lBcQuT!(O%$R^WeW(>rNN z_k!-Zn?~d8akL3_Wqo-zH}oW>6$u!+!=HVTHmXw#p38+p)ww?RIX8w;8FPpAy5v=r zwd{lxX=^6KcF}Q~dE+jswBY%%s_i>o4BYYbH+{SeyR@+}VvbdmQfcpcv+y8@G=}PE zk_mP!kJ4-+{Yt6QAo_wfsq-tTNiMIIzm0}`9SynBw%0Vmp2HXYL;T&LUFZmdc&&=4WPKMaY1Y!*@A+H`KiudZ!t&$-54SzQHW_^11{RH z6ea9}wkt(6)^{*SV*!kG(sW@RhoFeE zHPq~bl+y_tbYX1^{pLQpWDc>d%9a{hOUZNH#MZd>LE^9y@NBW#vz z4F8M}R$Q3tjHxhYtqq@R&1C6+~8w<-#cFS#vhRu}| z@78G-#ui0jY*^JtyUIT(@-1gWYS)!vi+1>-nyl{pBvHzGjZhLhVB=?l(o$K$e`3E4an8PbD~qZP5Y7K6YFQhHEk;8*QjGK^c+`w892?iF#4YClvK#Iued zjfda$#;>N+gCJqYV7_*hz0s%his^c-8vMT1D3fdhhuWWup{np?o9i%@bO!ZrsZJ{O zFnYZPTWptuqA8OSw4f{fv}i@BDp)Xu`1#ZVtzqJm>vv?LGHg)EXXqZYb2DfybGnuI z5@{t0O^#!*BC953iA-E*Ts6q>Li}I#S}MX3PR>P4g~gQ>&Ooe`=eX`GQw@YTPAOPa zYV2mCa{c;Wjn55jk%_mwW>HuEa)`fVRf*EE)@fxI zVPiU8tlQ(m`D>{%9_*tC6KmNsNiMowGHxDUi_Nl3cscuytv2{01Vf==UJ77$u^(gw>dK*og=VUI5`=ql+=_<_x=W;IHQo4)vNfl2^ z9*Yngm+T6d+)K$&b*Z|I?9|$D*QstoT%n4+qfD38(`Tja78r^>-nLGG-A5zgRg1H$ zhh%%oAAUSqoSeH(f>veRRg>kjMrED z{*}a-4mxXswRm}D=#k#6iCXIcUlS6Mz*bH`lMHj)WWGa|1CS{zCMtI2bmoKFwv>_K z)lONO;>tBAR#-{Xfd#EG7JV5OXz~>Xi<2Vq*2t|8Q^|7h=FbG`Xy3(i>0P#?Lb63k zdbl~aN71TiA|l3Ig-N{Lz@XE4E z+kR4eF>4Z64%@Z7Ig;2ooed3PRQhRrg)dMf5J&V0)Zl%Gc~k(E?cBzj;+j$zUiDJX zOnPl_;mw27-)!fi6!OFuusvO|EdKVaX;vH;?|FFF93 z7^_I3C5Qd2BBrt1r&iG8K1S3KJZzpdP~c_fi7Gu-n_rwA)`O-*b;^-qu};guarlWG z0wb7r>+HkX4PJ50rrV;EmGmvSWM6#5U5?YmyY)~@ds-;J-C{~sg5TYtOown)KXXT; zm*h04@hMDA1ZdL7DUY&Q1w(5!#fhCyEUvD8M{cV{GU-IT;+j+Vi-6)sd8)c~X=T7g z$*{s}XpC=<{zw`D;@~+-@KyS+O*7uDOQ}5ArZ?}Z+?%8Npp|`i zIetAUPO^zU<)0vi;apr3fPzvNTLN`~{9ZtkZ~-_Y(AoKSr$$iIJ$I(Ty5fRo@P-tnst3R){^FFbZvn|-)5DpPAkO0#t1~d}5fC*qz@pG(q zH{a?COjvp^o>6wil34ac=l~Xc%^j|t#F+~(kePMlB@Yzo5X`~ad zc3(5y(ao&o?iesx2Q2}6#HkHDea7>z`na9lw;#S;pZwXMbQ++in`p9C^8f9?q}CT_ z=o~1)ud;>!ER{NtNwJ|&1>i)$KdH7O35O05ihsVgZb{z~<&RL#!o%DHthy32k*^zd z>=ripKD5(JXGY*5JlxQyztvaN#&K;SJ1K?O=2YGJabU|{pxu0iTMJY=lZ~Y~hLNmWmP5M(C=1k8JUc8E&sYx)tJ|!6b8m;#q%Xql zCFL#54U_WD&-!_$Ukc)y{Nq)fr+BE@)$K3&Azyd1`Aep*a>2*V{n)nKi;nu@!N0d2 z_LW)`=6)r$nE!bR_*;0GvrzbswTiynKff2{Iq^`H^{?%ZlPBw*ZTXUYsMQPDRe1S) z!@suMr9U;VdSB*_7QIsUtZ9M+_mnU@g?ogl%ul%*sEWA;2`(A5wMoELL z6!ulBXu%y>*R#`}@Y(HcO?HJNaZIoJN zOQQt?T`WXu$P4neCs+H!|VbV6h=tCHkr5~m)?}s z7!hCYT2gC0>v;Bpt=(pgQS5?$>~>(B_ITYWG$Y)>c1?71sq5-=uDNCT#apDvnc;*d z#Za#0)s46br(9oDSS!GV@`2Rk$zM97!DWIwx<^*D(U@+2{m3@s?UDc5o;{Yf2#+Wf zmlwh{y6mXLn3m2;4v616{3 z!NcoK*Oz@d%1zSLa_6THNVpaKUg<8310Hf#E;&M+mUa0>0oc8?EWK!e7#PmCa;A7d zD}#QmPdem|6C$Iv+)Bo1&jVrS>0rbr?jYZXJ1b|e+Z4)Gd(@_)o1>ZK+hasNo&EfA zLZ8cFcXh7(%oc*8eVs7q-sg*#e2?^Gomw!$9rfm`!{lwKNXv)hKQO%W&ei4=;t9rd zYU0(HtZUEBp6YWNa9x;gB9!rRzlywCgp^xB1IT-38vBOt>P^8$jl!kggW(~M-i-=v z=KNf}wlPI!hj*S9XKEGr1^N0z?H{!Y9n!JM_NLsP`AW(HFC+#L7Wcc1aNCy))#S3- zH-?Z05AE9r=;*I*g;~Vf8MTp`wT-J2BBQ}=_9c9Z1I=|IMJmBUmOmn1Ay3^uv);TNS$#>rq@ufLHF~tW*iW#rrVf0lwR?ZXw%=X7 z%cAi9@)~`cdtyZ~8^OYcb{}t-33?&x{REvWdBaa9f5_;2F6v?gTM6-~eQyf=fKaSM zUqrNHYGWU>D)4Ye}?T6e&X(}cK3+{zV5=@ z&sd?EOu|7v)u5EGO#x=JZ&8PE)fM|~#POTW71|NbAC6Y^?~jtlxc+@f{48x&N&OZM z+c8nmsL+&}Co*Jp_*7 z>jkzc>J9r~%ZPf^*_<+NO;jpW^IKeDdDw_uG^91Hnk{5<%dlCur?dW4zYMlg-MXPA z5|Mr#+Rd`lSNZky)VsufkSzWVA1;~fX7nE_D$4(M&*QY*ow?#SUAnP-vf8ro$QHAx zpX+-+Ij18G!NvAg4z0E4=7&s@lbbUl?PbhRe~Ng}eTdhrrOiCSDd$aSKEjJ%eZ}=q zIT2ouUS0TFVRGS;+kn;Z8}#{}nR$7=w3PfF$|bj-J()%?UY}p?Z{d8wqi#0so3(M zQC~34*#?X+E-={|F*y7veWQ8IimRi|?pDv#%t`;tS>Wq;K52KYF1(MA8@DlaCUm=O zj9XRc$LtfCs-&ZpfDITo_Ox4LZvB<$UwTX6fSs+KCC=RYxJjan z-T&7cOVFnyjoa6{cHv9VZtKN}_{qu5yZQ}htpqRs!1J`cp3Uml92&kQRv`+$-!i1A zwny-6oEz^(Eke5kXf^X2^;gOI?DNiYjfX^)-?B0b?u`8k%@q{8JzZLSt@?_TF>rPS zWMR-sPB!5rmsfVw7GGxaVFUT|e&LmUkif#1)GEzAufj%p6*5lf@v0ckrSzE9A+314 z+uLWT3ET7N=DFdi>|ofV$Fy=?fw~`!_fy^<&v6W>jBPMT&o}7RY7ge>xd-$rz9sNI z3W$!KvXIdmQ_;Dr@vN9{o^_&r^%~zWrdF!Ay|WUDeP90bmN>~;t9`$EVdi_xl)4<{ zkjYxr%ABc5pM1MBH= z=D(Ca@$;rbpwadv!irR{*85rRw(5M)cu}`2n#ZzW;ZN>Li=xVqQCSBwDA(zje2+7+ z7>`eTLBX15FiSG>=OP4f2GM%t;+Fx(`cA33cVkN!$R8QEStNzt^A%AFt2tg3qUWWI zv@;Nyp2c}#MFyt)CfR=gQ}CDubTPEtU(z;)_EM7c@mBckkGS;EdRh`R_iaxaIJuz5 zF9*b)xt311HYmkzzRPdknnlqpgHy(ZMK2isko)3>MJ~)bSu=gKgPDGyiG*#k)TT(y zAH^C_nU;3Np51Dn3mvOSP0s!0RlQg2M)UMzzIt?vi|U44Pcqo;OV>AjsN+|elNFa@ zJ$~**uQVV1du;LA}LbLz*s8Pk-?pj z#}!KzYi{2@?`6+J+|f(D>tlPYwu4#ked5wbzm%81^$mzq|EFYOCrxgAPpLOGHBA7V z&4;%2u)7{@B+19U(d0>>)c0n#>F(Pf?{BgMdDtE+Kv87OH(Xsw*`7a8y5I&A4M!?~ijoz=zj)!!1OOubWJWnivg~{s1zv+rRA=)f3|5 z9aLH0bx(Lrb`RFck(ZbUOhv())J*Cdd5`0 zZlS-p9E9_`N`DO6UgmavxUB1htpCt?=&&SWDPGjgYtWOmH0D@Qeos9`xz>OD1)*=3 zhdItj3A>fm_LjAG$42y#nyz*8mqgvP<}azbL(SYU(UjeLtnv`OfRcNqH9GZA$L)M# zBu;8kh&$j{3M+aDNIazx3A|xzwBFAF`?d$$uJD#G@PFs4ze1FS_?G( zCP|8GumX{cPx=02EM%%_hgnF8a%D2Vw+0zJA+-8I+9$ zeIIO>U(wgPSLlhpVC##^kS^1>6 zZBY(5VrAliTtjp%x2Y@X>E4}Z$M9{?9!tFb)*Y;|+=*-AT4U%py za{Gy+V0dME&hT(xB^@^?V;WC*uCOLhZ}2CKA7goUpAch ze7;9)b^mnNeZDpHUnxr=HT$`oks}TVXNId|gYh-i$>(miTwUQo-M4>L;FWzuJlOjk zkm2e^toE2u5;dI7L##fL5wVHi)l*_ex_L9zlVy8;WK_;Jk7N(7e29lc5$qbW!(RDDosTFR#WQwB(~`{Dqrz)2=c0XY3;T z-b|TPCfOL^%gnV7H|0|z=<$35Cgf5y$OLyvrNCb(e}y0;?s_`2<&yNu*x2t0x zMe>|G-ntAzzaV`cMnjGgg4)cBxc>Qu28q8iPJu#szbB}VLNiJa^Ivkk>7t;cJ2TKt z&Lfoh&dZ4Wo=dVk{_^H4^DCh~SvCUB1y{kQsM)`bs>7_4r#zK*7V0u4Hzw@UUM`v$ zMf}m~DT}+G1`AehL4`hQ^X9G8X@3Hr2jS~$j^f;!_QO5U`GrikpQ*7cjivE}Qk9Vq z{mg>U7FB|GiV!TN=vm@0Lh}7_+4c3^FL^eq{C_Q9k5%o`ie33pg#~@!_!+@Xmp_V) zPxTLC%(sfo>@e*~C5ud1j5}iV9B1*)bg^rt`T1$Gx#$0)?!BX$TEDJQj|C4ZSSTvh za_AyTZvheMy@XywI-!Tqi()~kD!uoTK4TBrN7;L>ALYx0lRd=Md8nFt#BU*A@{*VT1r*zp4OLp zj%!0}3+eA4Sx47^wZv~Tai|{)XdFD)yywj#ey`*pxG3b0H^-;@UZaEZjgKZT>)bRUACT6N#maph&*`pSD_?parR^U zLGN_~DhA}6c=`%UztT#Sa$i%AN*SALFBih%T_D{S=6iKV<>5^dMi-xJ9`y(v<-Ju^ zkYyb(<}W`~96ncWHlB$;c;{g9_iCMP7|?u57Uw%pi4Lo69N?W4J6w^?PDmTUk+)NL zqVd8;_f6OIZ;&mBRy{DxiqgpWL>4Mvjw!WV^|SlJrIGngwX8Lhl}E=;oeDH-v{V^3 zNGzQ^y=8W?-QFR5GQoPDiW%W;D8rZyk{dvF8&VAvnjB_*Do$g0f$p3$TAkf2UoM7Q%F zIHSXQXz?#P+#{``C(5x1gR=0%QxuWp{ht>A8DNf@K-(b%S~NA~6gUZIE7x^=Oa2W8 zgEN|%V#<6R6n1*_tvL|QEC}uDguuYAYqKY}(nS8)g@wwL*M;@Bbf>ltw^_@1`SBOx z?f#Y42hkk#2v-_Txc!K8^rcJpzMLY%dtIHKrN8w#A*kKTWa8>9twhgIu`Lv--;l-r zikqj(`}%1o(K92ZGkP)vVc5SQW2$$C!k*?u_#b7iTl)XKGWXvimXp!`KTCN3;|n!U zrl6Q3b}1XYxtJ)3p^B1;*iGh)wJ9Hv4)xD`6| zC9J06{=x~!QXfyADcrCOVrBx(k{iS$1iwU^zU)F(!ID~S&BtgphtMEuF^eyG<}ZSA`GrPJ^6Iv`a-V8@+(qF>rcE zefY6}$7FGOz8$i)yOZEqo9OmWw{p*_O7tT>StO^th7b#+%e!nlD>f8tYS^V){$dg( z(u0p*!%xL{#ve!mxXg^(d|AR;a%Q<`$&ZdSa>KzTNW9SWkr4>H8@#++?$Rmfy|pNE zRZR?9_a>gpfxdU`hU@7WlJ?~7a>-mj(y|z#yLm;8A2OLXEa1}U!>Ru^%$~)jvyL&i z#`23!F9STNMz&^0f^$fhOGT>@k}2Sf*O7s(WHwjck#D{`N4-4g#mW8;d#w;UYnrP3 zfy%CnYQYO8z7VzDkrD$7$lR?*)05tL(>h_xM!Mj_Fm%R8rr?0=Y(xBGTh9WCNzQC! z+;ftMTWNW^>Ny!-A=&yO*eL8)IuR_kbhpa5DnKHqfhLBFb(CXC{3(R@s$KtOB6^iP z*I7PvcUXBq3kho9vssh%Ge2GUX^$>l|M3~1!<$uHucP~_3Ropxykm$NP`4f{2$=AC z=3ovDG{|w(2(2lrB5M#f9lJlu?*1&J@&R3bkT2WENg`6qI*v9w%cc|3RgrGNl8ixf zZ``-tqO@d);Uy^#Ms--NDf9vu$`KTC3+pM97E_Ow<0*3e%rVISYYB;fDX^)PaM^D0 zuSoo~XZT@862S~HN~kk3Gt4BJ`We$lXAEh>jo#q2Zt{d6vdU3i0xYcx1$61eT0kv~ zc8n-{eKDI?0H!|{6YLsJ8Y;8;{?EzEzm%{3ihE`W7G!@d-@Elj!mr+6zON0~SY@={ zUDd9&BBCE!g$}<-JcDtra8-gn{_EZau7b}h$xoxHnE>hNoa?*Gj*A9bxA%P81w}EBy3yg2Y;ojB%Q~PvMrR-#qYYHFQ$CmmZK56Z;WJ0O*@8T zkGHgy<4)>g|H5c2G!65JMdbA=*$T#4CIKMdQGvjgHXJq4 zu6Ro*1$kEOZ<>A1$f4wxQxMv{kN-wwqsEq!xIdRhVxkq}CK;w+d|7>$)bs2oi1aIgFrHf2pzwKej{$K0JgO`)c^18}h10TYW!*#vj=| zNg^8)Y5go<`||~I@@7FXBITqW*cs~XfagmKX0@HbQ3Yu^UzOtClu z7J+8Gk5=LE#=kO4xJ`}x1jVMm*OKMj8fJCGGDOC(D#cD%FFj!7Ryh=xxxNCz70;oz zI=2OI3~fC7I=5rny$VrLMW%~8n=Rj2?O-j@Zpj=;ceArWp4K?QKBB zj89&kr%@e3_uEm&6lR&TzTtTTpd{pcvysEf;XU38vvprJo`U^ZhuB{KEP09)5q*r` zQQodfbeYfwbpYP@<`M1;a_2oO2L65?c^T3@DPQ8KzM)X)jg$5 ze7^aX(dnVcehCQL5!9bi#qs{dIk?xk+4gy*V$RwHA6Ij}BJ?qx5G)f6}!#g+m)x8aBfQ01yf(MO{A6+sB)CO7QzI!u(T9nlxlMQZ`B1are9h+rwqB(`O=A*PSneHUpUtBHG*T$rg_pseB z(#vj0C=Y#+>X}dbwEGi?cy3T{yWDd)xreFa2<;Wg@qp(=Y|fT*A1r{b#yUA+wubDV zlA|9%v#lbk)r5>SOob*srFHO82^8cT`pdoth~8lIRE}$i`q0}?!3ey)4syvJ1GZFq z#rzf$`G`-yEY*eTQ5eU>n?Q`6+cDb^x25YSmO!rQx?kJ zdYn*alU;dG2ZVR^;5@RExw4L*QsN7_`KUJg0C+TaQ7h#bc9?}EP{bD!Lu3)TDqiB7 zt2dBGkJ=pNh>Cs7G#x1i{z9fRWS;GkHT7j=p=i6ZUME_+#Wtp3&8!-v+HeeuIU0jp ze5_K7(tZ(?Jcq+7LUrhsD@`v{EM|UC`iveF$<9kmGA~Rm`cRd)Z`0Z!X1av(62aB` zH_L;Deh7ZQKe`pTFA&+_H-#KvlQ&*6`1l#BdVNvtkNKLz6L4c`gypcpTrvu3Szm5@5i#Fxt`4@=PGFjbVKDUTR7Fgy&GLI2H5c`? zKza`iH5Dzwf8ZR|{!)F8QR7^===e`KCpLHKA;sWsQILXeJ7j#&m|C1iM;Ba_?|$C{ zflf>J{yxlCUxvq5ihU*azfK-{cnEHdFiq!oK;t4>Ip>cd(bC*|TfYxAjnnpkBb;c^ z?!|j~su88T+go-5pTeQt?LZ8!b>JoW(Xcdy2R`{?CODgT2c=Hm8qSo$DBmggPuo_Y zPiM9DF`kkO5yI0)I<4xyuB>?oD-cb_8IeL1I6b68-z8QzFid~C-WkXDEykEQsTv;nZp zJ-nt}WPbJ;&ftaH3{CpzFWUmBNe@asJ|~Xt2GJZ`-n=P%lg82X8WR+G#r_D8)WV%=PC__#gNLNJVIgXj~@S!@;_l=1wY ziZ8V?onIj{t~`FAh^2tL-%1H5alF5terX~aK_S6dL%Y%y#W|!75=st10zg7BEguPSKlvH0F4v8s2*a>BJGDXXo3>nhYfSWwulm_|B9Yp1^|TpBe+;uRsZp~X96 zBun{5R)h!8_Aj}Muy&hM<#v!XjR{tTPEdVT^=Yb7X`AE^1!B)qR$RmYZ0A7D z)~F$scVs|SHMP<&vka^65<${MR;VGcGAI~0D1vD2p2m}EZpbPLfXtlOZI9gkA_#YfoA%GtriDmU&*H*p0`esAp9N9um!lFUFb6})hT0%5$bdAUe zrC}(3^htDC`-htLRbjze3^>#OAlV_3M__3d#3nZrV`o|A!!Fnmk-QMl@)bA&1i_cc zyHfI)en0w@#X-j_Zd=VGf4bebZm6ln(aOjAhVzuVlbp2`-OBtapVMCPuyQWEo8J8z z(+VFcv<(h;5js}$TpGv1Rp8=h=_>|eyR~)rq&0jD;T@q&x{^93tU{G~B$uKlw)q&= z5OZLlg=H0nH744#Pe$DnH`>l>Wn{XEW!Oa?M*1nv1i=ey{xa(h=h z<&G&;MNM(Gcm3yu>dJ3>L%(v>1RmCEs$e%$A_-A`YunkQ60r9_?Vm48tLVq&Se?rm zDOF>F=YbkW$tX!bf(&iqL@Ko;#44)_A@R7R?zNmy?Pa7&Rv`>s;Fn~Th?}2{fG`(` z5W)`5&9j|I5CJsDAh&FM#hl;9wHm0TE5@+Ob&k`Z_60mq{}vJEK$5?Z z%?g?SS_c)8L4ezLFDZ2uEn94hS(&6j{~i@pfuf!*27JuR&qTA^kD}md2SK(TNfV|7SUZ$|+84IV4e&Q^&^#yVGd1WHDH5 z{T?H1c#pKgt@%7(SzdpP#$Kt@Bc-k}Nh0CPKdMO1FAAo$4Fg&z!homHPEaSMJ&|Kv zUG-#g^L-R5p7UV;xn=6FZDW&1p-sbDXbC`C0PE~w1f=Q{aq;Td+I@W~?)w{Qyjvn6 ziRXWRkiBPr>j$r5EV;&gH}M_R6ymW=3zO*i@>(c*yA#8VuZB>I-_v?=77 zB-={@)TeyGW zlVkkR*o!(1oU11P7e8UmgB@O=h)Rhm1d35|a& zx>wwd#>L8NfQC0(#bd1B6L86y_uxTJI2S1DY}Z->#x2)du5%mCiw!0x{D(X0BD zrs!z4&#>;F`tgD}4$b$_2y=<%V^JDut|U{fus@O^u)JY`V&c;VTD_pp2$8=1vk;Q1 zYqySdTrXcJ>QJC3wr)6{w9iS3J~l%p8Kg`Hr8I%v|L)rP3~|}0H_TCy+HmnI@0TWT zp_%Mb@g@cjet+{JjS$3H-R;dPWhZ?LY`26Z)DzS&#|G6;{7J|wjFa3;Kr*Q8|LX>#`*kM z&MPF@j$14FZiyLz96rp%FOKp;zr2`)MeMcQP+?Fv1qG>Kyc12}BfZaRqZxtyz^h-e z_pzX8`!DXU`K0#UMY^5s^W4S+CTPF>TvBPSxLK1PQm}pd!9+($CGL5IZLdofQzuj9 zh#XaHH|CpllFyXMWK8dkq2`njT#Ee~wy`>=_Z`G}O8_}E@c3ZGx%W+chj+PU-dBS* zo3338+&)M}JZU4?0saUl;s)&$udlhcQm+RDW>-r)6c-Yp1W;5u_q;!qO^C;)lY#c*m#cs>33f>Fz`zSAM8@OEZ22k#4oOy#`M!-CNkM2=#t6zi8 z1>AUjQG_K`sp(0$d1_gGfypo+VQhv!&p0P%+)x*c@EG{+#bMy!O=-+nQ98em}Pd*RalN(FdOK+>ii2 z>6PxsOoxiipc(%4xI2mfBf>A$K-}$yQ1fvu<)Y#S5e=THYF@@zceyVT-o=rl2ha>M zdF`a~b0>8LUs#ElUjpe zP4DA7m7@8F#zO$$GNcJ|A}pVH!Csb0O`kQCS$qlO6ocHhjqhaF*C!^B)bFz_&25_{ zELv36WQS$A|HJvvDnU?qF$-g53$w~Hn2V1Rzb}2MU{o7p%m}B@qS_qFqgHGF^$Nc| zb&FdOUIfY1j7s2)4#^{M@VWctE09#xKZ{ts_&g9;Wv78Vuc83pc1y!(+8c}blL&Nwnn4@E;mrFQ3Y6YPfispq2$(FDqYZE(*#;(Bmma>i5*3aZl9VCCuo5)jZ= z$QNB|<9b>#J*daFk_7GKZx#QP#|)_i_l{vZ!zs#C4ybWo?r)-K7pFR}qMeLN%Z|(p zj`E=@G`>X~3Xx3w7>iYmR>G$nS>CZE-GSxfFm{J{v9uPe+Mp5Cf8?`($KGGo$#AUd zNcY?8crTSK%7@7dBH`7NLXAjEyA1lh5i%yRd5)g(MsQEX7k!cN0A^Z{du&a6Kw8%K zm|)IoFx*z^Qjr}Vg^2>i4@{p^g!PzrE?e|Mo*%uSX!MHPU_jNAyc)tO2E{n${h&Ou z3z39D8V%$t!xPc0hs@eP0ts)SmgH2p4x-{8yTZaS+(MZO(N0g&7BHYf74z4A%hT_3 zw6yR}@B-1?EQ;SVwEOku8PfETPRR;3u+5Kuy)1gP)FtT#*+gO9H6cmKAS}%P-cuTe zfoB1|5)l@#`~F%HGU=_W^VqBW%m?E@< z%9g;)HUmGYE#JIBl!`9Z{mntdRHT@>{??DJX3i@c6=dB|ahu^vc2MKZ>_>B#RY5On zds>nh?(Z$7=R-IhP`Y%4j*b~=Zi7U)tV57b&!kZopAfs9?&tXiGcIalbrG2_k-c&h zx6+pEav;Q~YLD!CC2-4^(3!Rg^9AGS@3|a%Q@%H}YzXD+$5Z?90a=kh)>fs@p$nqm zL#U3e5jmJcT4zJ9Mc{j>Y?@Wp6-b&LJ+9$PBvHLfXs%tG zvj1}X?evgDZ5UIjB&fr(6OF)D-Ka7XD4mC;M3w03SpZaNVR-!tc}m%F2)ei^d1fYBo* zXQK;!4R#qnJzj(-tOq}qbTzekQ($R|v2TPt!%GYvoOiEZu$k}G|4cgpM z?AN+_Ww1NVrgz6ld1y+h3ewq^dr4^Xp{W3D&?g1D|8q^L!ViC~wOXoFK`Am@iBM*# zB)6Ap;iq}jOxGx&Su!ZTN9UpRWjK^B=N2D4AU%tt&e)$c{V&-#@L!jgS=m7ke&v;V zCeSaPgU$Gaw=z9P51Uw7g(is^y%rS$^Hqudo>RiL$G8#X_ygfSE5kbtKiJz!u!&|X zyQ8nqNu@a~+g+LEi|gPD{bMPeO|Bb%ubm%Gr1=#8f{mgV`P(v@He`V^@=Wr|S#Ee8 zRm0n_%A10k#fax+-3|fOx2Og;LiulOw)P6y`0TR;8{S09N*P9I5%e^4#fU8fKu>~H zwV_ISO`>*YZq#g&b>Fb>DpZ3IR9`gku47{({!YX5t(x`ftLQ77X(Z>wFZto9Ne57PMUMi>6xGx)ug>i$cX#qo?Qt55K1)weA1!x8QZ7&8Vw!}C z(Ff0hZlr`RH|eBCF07p(;$OI91(#BW{}l&S8gKNydx$*6XgmL#TJ1wDdC{rLmdj#W zTJ%Sc;x8u8i2X}LRskAvvZbEs;eU(&(^4l1{@=MFKKyt7%>RE~claddKDenW2$6)tnTDGub?kcaH(?7T3!zg6@Lys&p~N!KAbM0)8y>6@1>5{s zO3D1IQ5dFdJl1A=nOb2+5I-D_p*SIT42_^l&Ppchb6`=|KKSja*|=6_YhC>w&3o!W z?uC6l7Y@ev17(b{(n1wZ=>`|IHv5lcZB{v}no@C86)W$wLFl{)S&@#v2!%sacBu%( zC1JIl+Z>um(P7~Nad#!* z=lqyx&(HD_hs!JmA3)`kQIG9YQ*t;6K(M zR#s_oG}HxCQ+I@1c8PxDB2}?3_eN#FydrmN>=yPC_AE0DXO_#SGj|cjJl3|PH-;bE^TZPK zCx~kS@=n}E<*hBHy-Nt|MsPL#E>~f`p{Kz!_6PEvczAt(U-}! zQ&b&)-_jIMPr&o8_45edbNUnFW!m|An&f^h{6qSMUz#=_tjEwiwFvY!p+aQAfh?Ua z6x9A!^hYj6G?!GF?mv*RUiyla+T&cP0qzdD4|K=TUo~u)k}GN7jybabL-oh2a6A zy&o%if87_@!+OGs;ZMDRa7K41<|Vn(vPieTEFPeYi8!-x5#yFs=KSn=@ouk&w^oxv zxJt{QluG5TMlDx6MoTBk;tt|cTuKN%m|bRZ%lu?N+WB$80xTT6TG_rvGVg%_eI_a| zrr?-(`e)?~YO>n{!pZr}hY*2U{*lmzO6!>F$yKjJpBrPEW;6zP4Jc^KBJ0aIx0}47s(D+Yt?J&)6U)hpt`sudUm}H0{ zgYxv73n%QxqrT`BatX*vl8i14>Hl#(rAasc?tT6&rMwsrM&X!v{oxW7C9!ud1Py8D z*CyC4^{Lu<;FJ<9Haa^zqMic@V4WAdcnfQDvgwSmPjyGM?*hqL+ks4Y`oj`3_l2yu zs@dg9k#}XLRe@#3rdV$UtzEe##9odqWTgkc)K*_}u<@9PEH--F4+Cd!{Eh4o&y(Tt z^nM+m;&N4B9PueRxfEhv!JZZL)3k$JbWXG8a}A785cE^duC3TcrXV@nMGGWzxMki` zp))pywnts7fUZvW(KOq^AWks8xBe+J|6%($w$E;fCbt@G&BjvmS?5PpKAmBPb_NC9 z!7}Aat?&xWncx_bWVwo5>$wLP^^8$a&{&AKOzOAQ(2KFiP`9Q0FG60OAM=R3?1le3CU=^kQgd0KV~N# z0a^71VEO*gIDpK25>yV;8ZQ9+8L+EFuoAJ3Rr6x3s&=DGhw7X~z3;}DK@Mw8zxCnX zP;YAKoI<`b#MrT=LBw@~MlS7}rXwv1-xk`q&Q>fVCA>$J)l+#@&NaFh z9(-2K8F~B_FAM2w+H)I36T$g)ZpF%P=tj<%-rXvj)2i6RTvX&GJ$kHQ8=bUAu-Nkn zS19vfpDB*2wob_D*0b*_Lnah)`0!@8T#OPO;YeK>-z!(Pj4CMbM)?Tuxrb9pD)V9S zCwoUx=CxPc$1S-7YI*g@{krUa-naU0#z6tAMD5~|<%R?W+43y$w4?8|v*Rl~zNIcE zn|sf7E1_1ihw`3^!0pOeyn_>KE0rc76r-oB^ztts=K%Ig=zv;GZbsGk2}=O5cvyVEJ223oah7Qacw zaKj9f`anrTuVu?SuO!kYHLc6e0^bg17XF}k$z#>J>@SJt@#20m(?t z4trQACeIiCY^nby0jd~W$qXfcdDZ8oY1MW`b`wOE*D^m-_YYp7FwPih@lKlWSos~h zYKqVM?>sazTe7A8!VGX7Qmyn)Wdg$>SNmq4%O6HFap$A)XdY?I@j~O*W#>vzqwk#+ z$%TKqI;hUBzvoTvbdTV{1r_^a=OMw&^VMbMvaVfd!aVf35w; z#=`93$*z?I27E8OmbmLgFAfx0S!g7soj#D(=yU8D$#6Nq*Lr=z>okkyM)6y2xn>XB z=l|G)b{ZP5V0+}$ztmKNnncB!k?*89cH5MnNxGDLro>=iMQ&T0s~?t&-nf5KG%S0P z$*%&{Sylf=Q|03=CL$^xP?iwwGTQSiE22VUZ15>KKi)e0A)o#}jCE^9G`(X+O>@#j zC~uuqwDdCspT8x8LO+~8=b<-wYO(R$s@DKQ6LZ+D%zo|`^v1e+w*OIdr0CiK^^V9c zuT)3cKqW2~rW8sxhEWQptzRn{Uj7)^a>?aHo}4PsTxg0h3)HVSRn}VGuFt)bH}@DG z-~YiSM4q7BB%Sf4%+8iasXt8U&Z&3IYk8Suc9maHU=*y znwtxLhv$^&9~#SZsd@uJ<(UOPrV7dZ$7U+z!=)OtFAYByqI$!$%8Uo8kKR~sPiGyz z7Vw8;y~q`@>~V3eKw)mF=-ULk-gh_{@lb%qc5aL| z0P#tI?rUuhoonIeV>h^utFb)w>2!^MEp*>9lc?^~x4LROZS><2vBo1oOSAl7_9sDv z_S<*-_tAH48d$@+J)WKzi=1gMpFl?DOoOVqPS-=e$mWZH zJBPKebZiHTw-l);3mo6{HJ>Yht*9EX6EsH~(xT%6;jHHIHT)u{0&F6JhZzjOsj9=ao z>m7#Ez?=ElmTCyh;6`|l--k!_6Lfi#9}h338tCdCbZK>^I6bpUFCuuV`bzFdwg=An z<)q+lee$ZqoM^kkh647ZMvxeuHRd0PAKpo4xl9lR1@|RhaZ8Rj={$Kt&N#kOeq*L$;aVrqiD8*p%lIYUTUGln!jlxbnx!)0%n`^hiPTC<^9*zbbfnTkIGE zqPi9au&w^Q$}c;U!_WY6BdZjm%Hv{R z!T+0R$tgEDiC9fhvujlrbf(g$m!?m1>T}8i7Vy6c3LY%}N>^S{narFFP(mNp`(QtR zwLJ+XJv|MWOlc$hCw4AT@D!cfyUKol<0U1t=xj`rZii+zHG{T6c2|_nZptKa<7(pg z=RmJ>hF4C#=)yAiq5PtzDc;dfHO4-q?8RTx(w`TRt*+fKR>P|0=1t;#tX7RR{=|HQ zF973xAt@8cdIL4nbE)wMvGo4b_gWnhN%7G;e}SxWw9)|?!<|?QXE3gD8^}#nMo$t} z2=mkv9p#|pZrDSkM~1!PpdToa$49;v9}k7*{3yIuZKHWidr;A%#y(+W`v=`MRwqNh z4g@Q#w#^I@g!1Od$A1*{+>8+q@d+xeN;*Q-^O5?-yS{QhDn9PGj$f_at${PJC?#b! zrQJz}s`iWd%{f5M8V%;C4$s93lr7dt>`yU*)&g7{@!eYydxwSY7~{+iW)*M?84-5m zcW(T~Vr2@5nwEAx=l72{jePx`I=B9v+@{)8Z~dX;n=bm0Y-1|m`tW!*ahHtG*I#msla>i~SqMW~N^F&>tttSC{W^YKxCm6@X$TuT?dR4 z-Dckb5U*^kfi;5ZwFL8(aN&Yy=EmGdL)A8S#&<@EV-G2OcF;zkCc&&MpwC&ALp*$A z87eK{ba)nwG>{3Q<<{Aw7hTFWD?(^wlDSW|fk>N!DOg*)v-!>X?Ez<;xIa+BtS;*) zlqpk;@C7Rpp<*{tTJcWeDC2XZQl-N>!UUOM;!lKOhnFa8i)!~sT6TEKgI_HXK*#X) zWv{Y_5O+A>#V_|J8YBw(k5wqamWE5lD(r^cMhO}biB6gwH&}=*5rzV5D<$50C?Pi@ zSa6qW{5wZ;u7LI8VzXLh6C?T*kdQXSlPon47ve z#7Azhwc^Uc!otN3GA1+}rLSa{o6X2xh^5qd_Kc;fvK;}=k8bcN3b$ZHw}mANI8B_b zdIq%P!|wKv@8x55Ey5YlLn;X_!> zoh1z0wuevh+w`3qsqg;1Le8if#RYa{K?Zqzux^iGXV;pv=zgrjwttyJog|vWg&u=pHgDK3 zs5d_gR^bz3t7Ur|Y{EZ6KY=n*0}f?4IOr=ZIxsa~ zG^C9OvP!nHR!W)+>y({J^vfj5#<&fP%+BevZd!Vdpjg*X{_PANz?tO!ts=>+?xO?6UZ42x&?@U4^vf}yl zJ%QS~Xai$Eq=~1?7nG3nMO2b$4qGg_Y`ogT2O2*a-o{Xr%r$gIU#=qZe7U2DEUX*o zDI#J?OZFpm7fgjcxSwDuZIOfqL+jVZ?a(e1w!(=Zp%VA2P|#^2Lo@p<3|L|a&Fa!* zD8y;kl;`uwm^vey=a_%~N%Ea!`We$xaR zM!)139>aop6p!w@$7H^4ui-Kq(}X+Zs#t6+W<8noa%{I-1_Tbw0=9$V*@Sc`ybXUJVhmnDKYzPr+n>tyBPL(ru3YiRo=9$ z^;kEd!nc((F_xL?31ndNh9B>SR-(z~+>3)RL^cYh{Q&QU^Z=W;&=ja-s#Dj=h^xDu z`covqC^|Gc&FtK9+ho;isk&V;C>m<&C{VTUM7dd@LNAD_XaqWMJBI*Yx_ntHc@zi7 zIe_@rl1ZC*$bE;t0v!~cL8F;;L;D&p5uqJkyx|_7V%G4ke%lFMduPCZ*L;GPXK*ZE zF`85G+I+sl*l=fe_YlzPbDZJ#ov4o=1GUD(UFcTs{X-5WA1%n}Vb0AlP=OG(;3tcc zzP?qt2@I*fD(U!QwF(Yx>n@o~j>UU6vr_%NPVr(+g&t@vtm*0b#eHQL72~(R=9ZnE zz0}t%J-RdY(6D{;%!YqWhGdS(Piv^_g~IOKTU(3J#AKnR5S|8cNA<2pd%g5Syxm^c zhfYLcVJ~at2&7hXcZk4jb?!=E2jiF)n726UK>J%nu8Qv0zMEJBHpc-mCxlrG*{<)Z zFNoL;1;QD>pJl2jQz~+Pv~ooaUt;NPcd#NZG(Echfo{Tgz2u{0Om4eQG$*>kU1r+b zJ{b&Tkx@<6?3Y?8PS(awXqFPEH^JStY`6h-u@2O7#m z&Q)9Y)R&Qp|Ni$Y?j>T_mY%oyg9~n@m)=e1)Gv)P46N&}e@yy5&IRr5uIECOt!tgO z`8ia!?2G~LK<{e{@*A-zUEp|mRk53eOG(t1eNgqdq5@~pNt|4>eSZ!9zDZ$?!Uhb({2r*Lg#q|$QJ8(vWuEt%Ha7Z+u_+C@lSN}` zi+om#GmCr9ymw&HW;C;k*yd#AcFHGqhtbkYXiNv z2f`V}DguuD>wgVEWGI~haLaP`we$>D{ESQ`#+W{R^>-r4%f`ZOHglNn1St z6Jy`{L)Mqkla(Hi9h42rA~Np$N%)MknjZo{h_^f8!DOot!E-JLIU=WV?KDKvr0^Jo z1d5mejknv+7uAhft<)x0jh74Tu`3q+n@B3mkt|xN`|Me^d7$oFecz=7zc@b{oj)0` zmOV=u#UbWys_I^eJ+5}5?cZbOEaF~!YpCv+=KFq%JWNdFUA(Kh>nH?{+ltast-d)I zrM1e+0Q^;C&gA$%5}nok=VJplzx&Ii=q|>Q&rnQOHYke`b{G0H-9>!2iv{iXrF4Nk zf?F)*k44JIeuu0gv<~-)^o`v^$S5jxWp4Fd{=y0eNv67XIwQ%|G^&>VVdsDQPf?n= zyRYPyvwm5PRrya;Hmncxg^b9J@Jcy4Hs1>VJE>TwhZk$zv55SwmNc1$2?@{DQqMjc z-3F?}6aJI{c_P&o>JMJY zEYcb&wS=mWnclJr=O&0B7fnGId=tS35Y9Q;9$pe?Kdqvn{t=j~90IVQL{S$jW-HUT z|5kltn*(O>@m#dDu&^LC@hV9HZ=&*lxn|{UtjS=6q$7%^Z~f+Yr(y5$tGx6lBUsor zCl}qVUsho|Xhom5PFA>n*>NW(zP0?ZL&$((7yI>F40Hs?6Xbk`&HgY@24PSU%o)X# z3v?KbexMI1{wVq%qRvw%T7VJBiz|7;mkLyw!0Lqo|mzXI8_ za|vJ|x%VLJS=p_itIU;9+xYaLu_`A_AI@}mL8xu4^eZr@k1OUdjWHirK?ME?D;SHy z)N{jE96oaKmlvS;nhOaShvHO!y`l6VlhX|AKR=e{x%MAx;=eDA_xN;ln>ciG?DeB1 zg`QeUw}4BvKLVw0&g$vw4@^tPj{;k4<53I(*f48Y-nPuHo!m>V6mg?R*T&O}@khXu z+wm9Ow)^f@oo`T#;mn>&8#r!II6&GjObz%k8$%u1F>_*US1l1G;<+Au33bnM#7y|v z9(T5~@P?Eyl*Rd&uYRi5ollAP(bW!EY&s^8T084=<3pypE2FrPQ+-Z&P)q4A9J@oW zquQ^wPAZgH2as%%;^Q8UW1^LM^$J<(U^vE71aRSF?v`&AhIdq5d(m?cpPQx_F)=aO%K}h z*WupgER~Bp{qF~-PL9_C(p{95HpnW(U(xTOcfNkMb|I*5#cVWId(1N>_~`2al$^vvrzAB_j+p{Pxw1a*@Nb8)^5GnmA-`Xn za-u)yp$pPzt>Uu|_uw(r%W*WeKkQ(?t)~b)AHbspxYqcl;;5%=LI zD#?IJCb2G91#5*{^=Wdx_B)J1=@r49oW>)~sX&aY*dU z4%$tohtTqv{G3mUzag2JIq5;rSsK=@J}G#a#@?OFobchN%FDKQHm(g#JUAw=!>X}= zZ>Wb{SWeRMKtUxzu!nNfXt)6GKY37#-NO6eR(9=&-HI3gd2@8Fr)v^M2RKsgMrUvMZ4hr(u5{l%5_3F*facOiZ!hv2_Ks5xUPg5pT7{Q* z_KO2eLDAf6KZ@_l5Hm7Pt4py5|2Nbe?EF0c*~`kq&B)GoZ+<+9ApZvRgV#zFbaaP! zY9kF=$=-|tgocO02YlRRJhq2Xz;WMDQDG!2vGzzs*T|(BwRgA&OIUA=z{U)e-%efF zsNSepsxqeATF<8w!8aZw+z+2wXz2Iej2Z`IR@xs*RZV!8QN+Mj2J|t;gE~bKw;Up> zykVakj2kqCNDJ91RoDzW0R*0Kj#-^Bwzs(C)X*QpE%`-mUAWQRfzj1*XmplfU!yqp z_sV3IX}%7!axpbv2Tj(1AA&oOMrH?S+aL6b$373@qGWKc63ntM#|fK#72{k?D${+g zo(3@DlKQ@I50k^fLH4l@AykIH@Ejt3KyeUfCRiK=r$s`{q@9yLU-{-&A%zpI9^&nRt$icJ} zGJi9`Nd=R@@?qD#L)?!z{JN%xn?u5NH5q3@s|pm-GA9vy>?2sI;%X4+Xu;mipX+~E zJH;%6coe)lR5_Xpw>T|e_Vb@(_P?BYHUFCk3qTY+92#QD*|ULiUibCoDqLo)P>EGf zF4Ov^zaoM1dgr^r7TQ^r4wH@~#_Jji6%fn-Kn{rqHL%g$!QS2J_EhaEM@E6#ayTF> zW(+hp+xE$e*DO$D-v7b=PbE~!UNtSxp@9HoSeW$;8sAAPm}j+GK{6T`9EA11z4@b* zH(Lp^pXdZ>4F>PdM(U65&gfKSw_jD={obFs#qfI*f3oJ%s&&rIu!88lHf&?;z4Xih zfd!KeH^i&TZ;8C}d=7Yv_4V})N_?Xe_^b6$4;?Z9u9@52tz0$KviY4?8p~QIpo4Y7 z=R8k6^MM>#V?-gr@9T4)S>7f6 z{yy!U+jcX>AdBKyy+(w4;kLGuqswQ*HoLaY`kIA-!jzw7M!?gyeU%BJW-u~<^%P}V zcIkepW(uP>E$|`K8!inabvG%JiTjsZ$_Y*WRoXR&H4xa&9BLDw<3k9U4fFZB&fa8? z@xpmsqZ~FeNhGAGQKKu?ht*q12dO$xG*-C?8~{KHbfSCPzOE^~WtXy3cF@u7eAVO{ z+pVN(ybH8yd8fWEoC);eHOyqJGMAH`{gKbMDbWs`EGk#wwL#k`eO=`$8&s{Ecyrbh zH%AcNpwi3<1@yrpMtHSPT>P1s+nqb#C1=7#)S&gvQc~_eX4bS3dKz++%sK`J38%G5 zdVF>ByhS)xsze+Oygl}u2C6IEauX#IX+(=Hy^S5lDph>8miu#4dt9NQW8vcFC-Q6Y z(=d%sFo!d3k3b>bTXiyG9)~&8Ev9G@7Nv#N!}gx*mS_1`&h~ULUDC}quw)eem>xzi zG-9Gs(VK6(5e6x;&3833^PWvDso5;$f+_V%?CvgQR~C2>3>^rMspNS07MspV;31;{X`xVTZW=!8u#MtRH8TZnttwS-$Th0w5Pg41Cyf{a#{ERH#@ zOlS30jCq}`5RHs)BAmFSL^IZ~#&UfsvBr@Brs5b4$qla=qFnLbn5^mB%?MH7DLl0lXB zZhgx@#g0k8A`_5PiS(56OEzv0HYQ$;A~ZO7NRC;hV&j=gqTnE<_3|K2&~SB*qWcQ- zVW7l;1wznaG^4Lr-S5|-*zRN$36LnVq~k9-V59SOxVY{-Il4{s-YOQ``0(Z`X#1*w zt?!3J!qE$diH+W?{e{MjON06!DwAV92DX8^(Lab;3WUWCuggPKPRyuOQv9*D;`ggr zuHDGm#OlSq+Z`*htzq;dcTj8djS1UZ zdSfl!SFjtr(w87Zc{%9(&-T5Due>YWY+MN@+J|SQ+r^4zjQ_df4h-};_sec$_!>!|1CoO9pzx%b}JzV@{-*r^E+?|o_@-heCbupn+z zTe36d_Vrr_-bjLs)C|PI0~wiCf(i|XWX8r<-(0SbhW`b+i6)ZC>i9|>ibc(ro;+F+ z%{RYJQEhI$H4^@&JU#Ppett=ndo56=Eh7a*tRgu!>p;ltMStsRyZmEfNtY!2;k)&B zd{+`~09bKDXYb~cAQEU8$J9-Ze@Dnvk&3JDBA1R1W08+Is*`Q#f1coJu)L!ySLFKl zg}t;TkIqD4ce0Cxq2R#}n!2B?|MA8{hjiixqL%WN#$zbQpg(#n4R1!oLzsz>z_Lm{f;J8NV?$`iP_~+ic_YY z-B2rc>xY8``16l%^eS>n4hdge5{xdHetdTGp9|Jt1~+Ui92M0lshdof%16~ezREuE zV&cdy_zOT;O)KJs>x(m5k{^&Ppu;3@$)hIqyA)^9g(`Fc-e$u_2$vq)HubBsE5PUk z9S2~Bz%NYo<8ApO9m?tNI12I4)mUV5uxw(7Lv~y0tnaQ}-(@_nWi=tWzGYmI&ojSI z4r#BV&*lYii~rA4T)MWQ`~D9^M<@8lE583?E)I_dSXpsL{0Z9b_npQbIsuVgukK0T zS^5*$RZdP@&@6rD&a9qqVPMgf{}a$TicV4cPr%eetaQu%w5pHyO=|nvm3xlgbANv| zocnK~oNsHaA@&qHZM4i~6Y$;oD*OIY_Tj^eZ+noylcph$xhL@1wO-AkRVkvFv6H%u>({RzExY>uG2kJ_Rb8b7gGU}fj1W8j=}`SRq^S32AlPe}w**EoE@@=No#u<@D; zAnsM|(j!Q_TM=vbwdzQEq)(&L_uAn#ldeB~Py74NeQvArk#zWL+&AuEb-Z|Cjx%7D zSZ7jazJBVqv7dO>HTetZ8=-Q|G~lmslRw?|6MsRf<4i=5?^T9`9`~rIhs8VSG+r+& z5vT=>v}k++-)E8h_rK+A8E(%`CCPZ`qY+h?fdSxdbr9)oFnM(R+qnGYys|Y6Kr*P$ zZfW){OcgeRH5i-gSv>>*-V-nO)V8hllHUL&vGM_2<=fMARk~ij+_&6RU;=3YEL7M!H+q*F z;*~DrSR*idx}E=OtX6$nTCJIo{~>DfecFpQe+N%CD+5Y2Q94F(7`=|)ZRX}+J9qC6 zw$l1|`0c0eRJONQ;FnK8>vyANQJ#_b!D7q5P>EZ|_1aajvz`pC!++&Ds18;7!&Ig> zZ`FnH?CM#PIF$vA`myEj%?2>G-Q@$#fuhITw|rNITZ7yMESauI^I!+e3vLvz%tutKCpD6OTrJ$CMDo=RjGw!o+gcTkh) zUELN4Wxsj9(on*Ge_!jn^w!fAog?+br5F6+U+L?&+9#d^l1!bYAXhxXu71}M-^j~rc6a~)zQ&s6Ntv)RCZ~5bTQc7suR5gH zUh?)n+=Y?Cs|B(81p~%?LqNRNUK#>rb}$BK2K0vQC*-ra-&WJpmjL<~N-J;Pd%Oml zgeHwFw4rGo3?!pgfj{cNqJ-Cnl*G*ZXlBj@($+xY;BefRU&46QuGMclPW(0R+;?;yKOLlzQ+)LbYM|{Pe<7{D^Ofqg8%YsaxN|i_bLdGT&nh zUkH=Xo;~!Z?+)ONXuzw5^dvJ_VyRW7S^Sp|pSmM#{)H28N{vrGB|idO(fmCvD`Hl6 zbLh-PSj0WFLmysGfBTAr`{maH>8C$eLoJI0sNQTxzfmFdc;9w~a!{U1m?RJ}0?R#lU`jt-zfs+P`mRS9O~@#|OsfMxhD^E6+uS{?DNfRu zdDffs*-EeGs(%{908QG*UI7d1+=F=l`?~91V-o{i8mT`Cf1oNU=Mb#UT`#kld#e&z zFB-0P8@{tuC6+wUI{yjzw(8=WwE1DO!L5d??=J$|SgDR4q1}&Ib^gtbg1Hbdn_loZ=(`o97XHXCq%%i z4kC_Qs~AVT2=LW-=-SE=CA0a?t0{3l3kJv@9>UPL9kw?&%3N>=T3N#seb9h(@X(P% zPC%aa*^RZfa~v3#To_64#{vpYD8#G@e*unJ1%@)smmUsm`T1wp^ZCbsAwFoEY#HVb{OTH1X@heB1_mBRXI$^T>Q_`3xv1(E<3Is|a-4PVLcS z-RpfV^G$5gDaUNd<;ds7W=NNu(425?DUh6})<>5BXiOu3TL6ak;BbZCIv<$5ipq*; zI?Iv0=w5Sa=5Te~$Fs^WVFR@eY-)QoS2dLW`B;vUl1G%&L%@Uv_Sqd_&joD{>-sYJ z%q}4CYL$0ej9@okzuoB`dSH0Pn9;&K61Gy^mR#vWE`Q+VlT=1e^e@_Vc;)r%uqD@A zw$z6e6!o_S?CWo0F689q26D+ja?w>NH5KgMvg`0*YL+61)**a#m9}Mp3Zb^^N+r>( z%rw}-PcUDG9=LL{>o1iWNL;`cAbUv+xP8J%F+ogJ;NrYY0)K!`ZXmS*^^MIN-xZ8~k1J`W^MT0GpHoF1M! z-JSg?zcQTGiYxnO)PmFrzv^ty{^`euUQNNA*&PEnmE@;Wk135ki}7iv*Dw+koZKB@ zaf4neyOaHwf1DVqt+p&U=9Z<=xr%Q9R(rlUf!>_;8kScGTaE##q36b$4#Z(rbMdDI zoK9hcW@P8{$S*Za@a}T>CIKyZcK5l&OD}zx0XM9HNvFJs^caQmh+JTU(pITS=CmaO z+NzQpx3{dKc`b*rtM74F;e$wFXEMLz4QHwa1Um$T@-0VS9fIEP zPTdx=pi;76^0%e=| zst<^#AtaPV#5X6gYwVBT|Lh3O#j!mAazkA7)Vbro`>)l%9grO1R=LlQIbiC`E$gsT zDrceqtz{ElQ_QfoS!taEn>WO3Q7c#>!jk#Tk6KWoWh*i{gkSY1O=scE6Zn&7w|TR= zQP3EJnP&lgyAtKI=5~|eeM(lL^F3tt-;%%=Vi>2b&!JB7ca~$DQRWPHcld;_M~|5m z(oVkx03L$b1Zt{8fF649+)Nb(|#K|W=E?1n>4$_v?2h2wrxLs z`b2GJ8LNB{txV0?*xHUfyP4WV4w7p@^#t1$4*P@s1)ubTul8<%%9VX$pAl;>IpY$z z4dMMJ7|yt#=6yyd4GBW_9AU=qKdIZ@uN8PF>$mSrhoF*E!cp;oe7fUl6pXsAl!S~xFW+`7E_L_|NB}0J!dUpQ$H;(F>edyL6 z%AP%=OUHhw>(hsnFL%PivE`pXU%jLBJ+LBN`Io`DW=VVL-^6EE{;mJJBv$njps0|u zOI`)E)OgK$`s5giI4P3xzb;(gM;ER}NkqL_p(?dMd2>1bqWATTdLSU z2Yeqvn_3oFlRLQRs8>j@kXx82^C z7A&v)qBJAlDO|cyG{;nU=*>=_n7r)DqIzV!8 zL7Bnhvzd<_MejN2CGsVd2YtyqB%!#t+&rN@F^8GcI!<|)1N^06X?*Up55SSS)~Nn( z#QpS=_P0E&`hn-6Tgx!)M-ok^!XU-kd3&Z0&COCoZ}yqV;{IPbbTBnM>|IHaXY)+! z$1M50I<_`Cg-fkldN2I{eAt_{ZR?iC)yZjt;CeL~xR&aarOyBN!y;krrzAm0c~?2< z7SBxMe{{`%`=kHu3I4o)=lTgFp`XA{qxkp9TBo9&IqEa=|M~|wS|144x;(1^CF*?4 zYdKR|^084v=5axyZ3iC=axlf-n9s6=4JOU#YTApj*+A^vHI`}8@@?;SI=u5lk2@7L zG3$ul2FxV|`7G{IV%vgAOQPxyEO5w4%1uNdi<^aTNFu5;AIu(2uJKgtq~N)$L1K}Q zLs&-b_9%zZvztS|mi9I!ItJ9C<_@DxX$>kNX zLywcPKlQ)mNm#u}g5d?IF^SQ!Em5%gM8x$Q1fx`c1(mxwY8MjfKCPt`x*OX??cC|q zUI6`*srLwLuJRne`7NTZL4dRF<2Y2q;Rd?}vS?pxjb(ds$&&rk@*y;xdm7VR)gbQ$N z!Mz0rZ=t&=2>y=Ls9(8GQXGU#BPw1V!bsQm{yQy+FhFMe&=rTveNx;<$?&_erLVgB z@VWCFZVg2}E-QL7ZK9a``GjT=?TF`fqW-yGN9Mm`(L0hAd{CVCiVLrFo~ z{7*iFgy-dTf)Vs$_Oeh}tz~aI~$Vq+Z!uIo| zR+Fa+;w67_n-KYk$#>U3b&wsM5zf89oJO5bA}Cu8LEzdtBBU*!ccBBZw9%N@!Sn#> zQhmxb0KQ`b&Q+`Vg4L5k)>nqyHP5<|?_&{%6jl#Cm7Zj;a6FP;pKP6Po;w{I^nfq^ z(g!YHzQ?g5ERlJ#KL|mQJ6bwDoY68lTw;E^u1HtOk(v^R2hZ{rs;NQXCjvE1A7AE0 zA4b))c}dY}!Dp%W*reQhQF}=~(d@CP8;KdQgn1fojC8*Tt^V?^9L&dML3#&BpEZUv zLZ=*Y7JQZ=lrxvKIR0lpS{N3+`BFJhK}~rGkcieyxCtkS8jM6ub;6;PgPC zS#K~?%i{`@zORd3!r@KHJb86yWx^Gfc9lxfb)waXJwtk9mY8y~GsA-*XYaLu7SY9o z!dUptjq|bGbTMPxV_9fx*9OhshM_dD(_T?M{N|ZHh0koPi@?`}3(<(Jy&`zG-*1+C zh~)T;$1LFvCx*rNV(-MD|59VStb0sxnZ#mWV2bn<-cSV@ z%+6>;cd#@y;B|^OySzy?ZrYb#UB6BYc@itwqw0cv8s~dEoQbpS1Za9PSu_(aEtOct z=bbFx9Gb9~(i}PbrEQAOfMGqdHii$eK6)$@@<~;Z;V!7MuBU%($XyCk4n^Mfg+-vV zdb5x*td7kzVnR5|-x=MR)VU)j{xi)zIqWFlPUgC0h1FPv*0r45ajPUO8iDrsby zb9I=^g?&h?Ajc~J$a1X_eecN5cRYw4QQyFlK)HmU38dFNp3!seG704D)Fi3&_X3Qh zfi3lbWIVSBq%o4GS!`wup9}UG@2K5tG*@YDX;x7#Qzs)OiM^Xcxr7w#vCM@bQu_E+ zC#~D5>$MLt1hqz2-&ur4A8}SnNO!N+GiLSnafZHT;s=9t#bb z4Wpf{W@d&ZAdUHD*n=Fj8$=T$S4x4(Q6-&2cV=RITDKMbRHG>YI@6G z9cX_Glg@$0g)a;7?1Qb3(So|hl5vsRObU84(SaHKvH95zJm~pcr2MI?o9hF!i5n_= zwT^v#8tZ#{Vy0!@wf1?FY}12gEhWbbL|+RKcV10`_nN)1X+yhJP^JY!&W7%A{l^yp zzq(#~m<0AN*5`o#>YVF95n#E$ob6s%jm3I2x#7Yp}1vXe*92q0nZsvPI$o|B0M1rDTDdQ_h1nEVVJF>Dgb1E*2CYK5lzVMX-gZZ1G zC~)Xl&&}b97AXF2{MH=ro!#fOYU0{T~!pK(wsIA~a3@gx@gJHtm9f730RY*;Bcyxw9g-1Dd&WWA7Y5 zsIBC3AYY4?C6Dx&$gM#0kC*uc_3uP)mT%;WaglAEN<0gp-zT%Qx+_YLd=PT`hJyAF zZOGZS6WB-M>ur6FBPj1IKena<>%Lgcl5X}JWSG*>4tqJur+t{&fUj(xV8%7YjGGg-BLxAx1>vZS)|3NI3ZIxB@6#U;8cFa{EGi1si5c%i zuPYX^f5S+5%4}gDuQ*6?owvZ2jE^fc&8RY}P{`y+7)b)`M2PQc`qYdkj+s?Go}xes z{rDbT%JRIxbdu-Q38o1fiAQu^^5FA zz7!Vm2(zdZyi&gSC{Ys7fx@v44j6G&Fe%MK=D+}vgo!k*tP8CsKNfk(=F;ccYG5Mo zORV3Mi@6Ta!RqE{n5Ua5!(53eZ#m)UH+jDD@iEPIxF`+6R`w9a|nqa9j@|wl&WS>@7B@DZ8kQ6s(*}w6t$y`+IxlAc+UOQ4Qa%G(CJI5N z^Um|!QZIAMGdCj-RqyQPxOSdP2%T1H9yXI7-C8vjqP}BC`CgAW4>H%+#RHSFGXGce z3dlGvrg!af9p_~gM$G_MeB3Kjcjc@Nxa1yb+ed#zK1#B}^l<&cu@@&-6~^y_Ap+t) z&I~T6!shQF;pOO1A1qz5lpSV{MeRS{Q38`wF7hMbE-E@;?Df+HNt*%I-_Y+~#w-I& z2SyDgN}rE^d4b6Nfl}f)Ub&0lNYNeen!oc>B%XVxFaG+B)6Tp}TV|}74M)-bWKxS> zXy$5tDMx-O+l7kPj7LIuw3r{NXgX$~bOMChN78>ao6c1;4`F~Gu6995OaZB69!%3eR$`tK*1tLiZul($L~@MXH&Z@DrHVjI;A>?f>}MuTzb7!DvJ=LdSr78HaNc z`lMggkW(;LQ^Ogf;nvRenF%Dq@QR;$NI?5Cc`cLATVU-~JlMhMh}d>G(Z1FD3Tom? zfG^$U<~VO#yl-JS^Y6v!PI-ren@F86T{<=+XAp>z{iOw7%b}%c-!>N?#Zj-UfyR_s zjbWMJW6lwhX+%3-eDpp1?Z?ethG&dVkjhvueL@$7J%(M-iZHU(6U=m1VDfVDg=E-Q z`zr1%?8%f#y5#)Y1<8vCb&kl9Bm|198qSemH&R6Ii?eW3V|zZ%ltiY)lbY=}z>P{V z^Kls?%dvox^5+@6Ds<61;pr~(PeXBau>Qz_^qsS&$_CMnXKb=v|yKwc^zrFst-siHz&jq=rsH*Dcc ztR|_gxvlKcV{i;nMp1+YI2puoqdRqnr-T&=d@etdxsN3azgw<5v7c5svTy2jZfGO9 z169OJk-=_*6R^LHb2rKH9;0Y~-S+DDnM6_pAXK(AO;7bRme)5hB_uF{l1c(@UGVrK*l1W^c3MTwnMv!n)lwQ+n zK2vYZy*M#c)v`@babDQjNYnyj9?3M!2xkn3_!e-gVx_J9Z%6W87r97igS}+WhYN!8 zjMDkh)RS}P@5I~L4mBkOB28vwRUWaK`B{0@osH*V&C=T4TR|P3S_VB3QRc<6sz8U| zFe$D@?LGE`pY5I7+lhf?ob1=y=aPE+GZL_rGt9i!H-*O`RHO&Q{~pwBYGWfKq6%d3 zL4L8}ZDrvxXz|6&A$Rg|;vyGPy$cB2ew4k1)Vj3z&0f}3ylHD;SunGNQfV1{#)G5p z^W0TS)iUK>%Yf+3N@5J+S z9L7mfqr>o?U=7aD^#d0J3wYxE0eI)eAVwidnlD6!0n)mCHF-mgT|z8oB17Nz#SDZ}!%NC7Vax3EMOAk) zXbbNdCSXSkR4@!&3-?D+_SMwR!k3CjpOuHpfOsV8!O&!+5@f!YWXM-g=Tbl%6O?ez z=pI;+a3=L=R`JMy>4PH-vm1zy9-Y2%zCnfj#a?i>FF-A#%4m1QbLkSJ*x5j)QC7vM zR&|bVHOiD4!zB*1FxwOQ;TNLI>)JSD44p;?mjs6c!WBzek1b$iJDx6ACJX2?)k47# zNwNT&NfOMe-T#^-pvTjL2$Up&$BMb+4Y85&kU7>PNfMYarw*fhnA(j=))Yv4QVX$x z5+OdfxUMKjM2Isbh>`|Lxa?VT*4`F;c-@1035SSUBrgNsMhZ6$KQh8w&>@RVF18ow zh0Js?i$`94$kQ0(Ep?#qBdO7|{OmHL$c|Fa!gX$~bKxx$c^lx^(Y8yD`6JIw2<8k4 z6t9#t-zPnstcgaSCB_}(yo>5p3e6Om}L#HCEV9wS7!+fN>Jaa(u;_~vQzAC4Nm zywI&dq7+1?YRT&g-A4MGxmqT<#azwvel2s>UP}|c!=1}oaYwty#Vt>`}+3{ zXN_F$YZ(argbv|NLiQzopP8dX2#y)p;J;#lI$$=jf0Vc>D}OF!v5h&4D1vT6i&U*YQk9#4c07#J^yRm6;X_|z67FSuG`pRm2;_hR zM>N=y!*F3Fueyn%!U39H42c5Fj|}koy~p??h|(So-aaqB6MULkUdgG=9P>56OAInD z6mnH9mvWgimFiVC6XAoY%yx*kJH)#`{a4l zGuCDFnL{G=KQhr|7c|?=79*=U%bkC)i8i|6&&^~8oe36&#;baM5N>8lU%)(OMD)mA z46dwgM&ud0FFMcNrPrfmBD*PM0E@C(8&^@g*ZMf*ja>+JX}|{SYuy=X4mOHcR83E% zkc@-}x@0Dx--TWr?MW|bC;t9g^s1^qaWhaxk)jZLJ~_Lo7`(2dvwj@uj_8;kMS3!) z+xT|C4v&!Z<94_4ysBYSp}pfxUfW;9YwMogtdjt*Gu2p=(;ogJh$cG_;m{_-HN5}& zzRxpEma^1{r$1MH_F#uhn9{rO*(77^>ul0v6x$k|c;;iidX+U^LK4 z`okZq)G_#mM7&t}GGtXndi7DC_}vZZ>(Sxl|A9J)C^)|HT%`!^Ao62M`|h+^1evFE zp`;@gQk&GutjIP{F5|J#C`!iITlE${;3Y?8=}n@T5wOmUZs>pTo{y# zi4eS)Q+cq*(6bMzbn(GP6i|Asrk_Clb0AMjZey9+20vVzQ;xPY48io*2cTH;5Kgxx zbN=v%QVbp=YSRDvu?Hefs*4;BLNiQedU2n8-w0~Un;jXI8YAOXL<59#dF4Z}Pb4)k zf7#UjZl{mE9^NOKJUG0}v0iRx!wXv{%Hk7!soTg9>>-9u(HpMt4@aL!5RI!3WcBCutC;jBH<_Zi)Kvj z=#upMvMlYbdVtjv6%VLXxVfe#U}NOb1vr&%7w*Ea8Shw={((NdK-%=os_b2Qm0!>s zhh*E5Lv{52XiE zs?kYz3fpA%(|xGrgwf}cR-=VT)b!%n>cF!hJuPuwkjGmk+AoT94`o{#A9tZjal@Zu znCjBnNEbyO6Smp%QhD=16)n?0yy7fOLO;Cl6bRH09+vc?or;O)4!iY0;>o*7R!+#6qn|&}!PJ zUxzd63pZ3?3f1AKe^Mn5zs=_cm%d+uk&rG0yuM(kyJiUmdQK10@~Mkc$>FgVbvXn02m>qUEnI>6 z7c6ffy~5eZjVc7>8G_pX9Cl`=6Lr102dpO$N-wPrP*|s#k~eSRMA8mn_u(D8%hNb~ z3OfNqb!AjHr)!#3?nhux#ed=F4(2SAHySAC z9`$(-BE>BchaIi-;(2L@xTGqoZx{@aeyMoz+8}=@? z9yyq)Qah?Y0Ux!PULe6V9QJFHa2<*Vl&VJ?^%4X|27fq`uG!lpE}Ot=GBP}r?U|_HZ9OhmINu@%)&7RjDYk}Q>x71CHL``oEO_;gFircJ7U=`HipcFZ1|A{--+=*gRE}*qyR2rm$|`!j@pT4 zE0ihFq@a*t3D4uHL&`Viv-rd38B+UXvyjFc`wO*Z`!tQFy9bCCdqdHE)T6~anqo^jraU~U@jcmvl5nlG)R84n&ju23G9_0Uk#naEJP6fj@V z(%5G#o-EPNi59IZIO2cLCFtI7;NX&{q7tJoXI(gIM3{MGFO6Yb&x5CBF8y7ld0?m*ojCmuW)ma)+CHz?Eu#=lwWPgjjS#wmK4R?(w%Cu5 zg}I$^ujec6ne)AN1sH6GG4gzIU|K|M-7({Q-%Sy|eovq^2q(z)yfH$rvV-yisq}~2 z_JEez0N-^)y3?wuR!vUmYvMCTs)V3&2i4}gbr_pX#t>=;U!&yR{Lq7;y-a1IF)P)=8AC+gXH@aryx#7$YTMiB8mR~&lqxR)>^tzvLHub+y-0FWwH}pwMdw0s| z&!^YYet6{jeC-d}&u{Es|M#sc^rHN>_x3L@MErhzJsvH*lTQvL_7{x{IgEU0W0uSa zJ5iG}zma``L@FRsLIflz_x^sEVo555$Q>Xy80l71(Mdv>bIPfBWEaQcsH5}jaso=z z!ie41}U03-07K znH0*zGS5exSSv{d>P=~F5)_>(cxdCd0 zQ%XdJEWOid5`S{o8(R>0^QxPU`F@M~xb~^1F~d~?q!CH9bYeRS z78Io*=Aa64zuwZ;Yj>zkZSxHJL5Tlxj~vy)5Tsuz+&Z-0xZ3H9G!4Hu>e9^!xR+=d z=|J4WCH#|&1)E>5O+owjb+4~-P_xN)D0qJ>vyf{=S;F8hDIiNV$6QjMy88}%kffL9 zmJmL8pgS>byXtQC+(En@ z6lA*XhV4e3KMn25Y;rF){6Md|Cx^b zhZR9cM2%{H?b?-AC;d6>(?TzIa%0*yj>W_4t6YyRQQ!8_C6>ppwa820yAXZEaF9& z56<*K7@4>|o6bi05IYk#3c0VnVDgBqP_EJ?AsroGMg; zi}%d+pAU*}A$!gUN`(F!W^S$pY?k?o-SH}}7Ra)0J9Zr_F} zUHvLwtrX}Iz2W`s{?}Z6FDl91(HZNvJzbkY`6Ni>-S%uKK~SUH0gU;MB1PK zR`?%!{GYn`^Z)7jz5jo!T>p_@!;FHX z9t7ge+z$AoUC8Al)d^UbA)kF=ewY=}dhU~3w&a^!(6yqO-J|U*pI;<@Zn$0*8*

zG|h-*g@?z13gZh-%;~~q{LohSPp<#DU>vw0boj;X`Qh}T$8T(#zmVU=(<2G0LxtNl zSU7GAHKYWn@DfWK&x(&n7w)FA_Hs2$z1`7w%2y^`+GL)?ssz0Q;O%ty6dR!{0wm zEl3AcJXjNRie_Gih%DU3=DJFIqT?A9EU40$zPomQxQZI!IpaDa@dngh{JwV)ShLwZ z<9coAaMk?Z_p-cjh?2r7P-pw20>e373&H5+4Y`M&nQ=S1iCC|)7cCJJReBQ~%0t50 z%2T4H`LXV#t@VkEzg1moxKXFD^?ze7xpD4qd_`}LPNfg6C0=w|i4RmRy$^*2S$8fk z1R$5|?tEONdd^R}wFo$%z-%7F!sV-neR$fl+=nGp zSz{$^|0npL)s}C84xEmN8)(bcN8T$i3crH3j$J0lf9pWCX0rqE7Mpi!A39QhtFV`j zNza>SD;!$U_o;X|POsMWJyG3^V{fsO-;^OoxB|uFrZLlZ^|U;QdIo^XN?qox{s(Jy zU8nJm%w>RiJwK?O6FULkTYaT0jarah>Vp#ovs(VlIpplkXAKYQ8n-r~iQ+FMne3@r z9~|B5_?N2cx+vlNyISr*WN_6WKrp|lG&?GJV#-&AFP^=z_jo(S5^s#0FE1+#0Yu^o zMDnnM2wrXR>b4GKuy17fUxp~;GE;=~R+@5OgO`S;qr4K(|AtA*RU$uZYNUTG8Z+QK z5jL%A#h5Xac?FZ`5M-5W)P-7Ua!!jT+v(;;4;og(7dZjmv150sF0BRVy>(ah>iif_ zO~OCZ*o5Q7t6GRtahlc}p-- zPEb2BIeTL1;`f%TLDs-zZrHLbba)<+U;3=UN0wrXgHTTkzRq5<;0s^Ho0^SyLz{bZ zb*teL;TU#%!TZVXQ{Cf(;}hdmw=E?p`Fp`5-UE^CXBCK$reejghfcGwM?H>gKS0(j z$n2Q!bFDz}4?-ym$p^z}b+>NqjE84-41FtX>Kc2*GP~2z zpbtEaQF82MWcVdTbGaL4KJDnx)DaQMj}7-=2XY>25J{ZHlPt2UYmGd|E3hg zv4)<(K@yLR_7XRpyb$Tj5%y0^Fw(y$Ow}Ap2L-;X`^$wl?bV<>Z`L>Rd;+f)bLhg!(lmE zSebbFCyB?e%(nwB3-9vAztuvndHKuE`L5heSox~yTeIf(>iz$rcD`6z-x4Ep`z}}d z^vM4&>+=6-hBkj2Nz1dq`;F^*R~)bPe0Ac4QZ(54$&N<2eHjrOw5ew;BWF%QaS-d`$mMm4_n{l^w! zUJ*1~`W2VJHbq8rTMn`Z(Bjhcb5sywA=7Zq;KFLUwk!W~#y6p`H9Ic+S7+*d>b3$! zQ$&MeI}A0VeA~CO%PX_uOd>L;V$AxW9=Gyy4sGuFL!ITIUPM_LM>v+jZv&^^$r;A0 zCoua?{P^=`p#R^K*S)@B`hQU3D=(J0JwFp&Y%an$piXcaRasIndP0)?I>?MtV*w#0 zu66;u)^UUpGLUd@E&Pg3h41|v*o@(`%HRV#^=q55Dg{?s^xIdws>5!%1-%5RFssx1 za$MSMdui7*7zl}*6?-CFKTtCL-mrS%H{7eyIM<_3^>F06>=iVhzOGwZiFBwjiH^iI&BqRYs$WeKUF+|G9 zq428(vtjQlJ4@A@8)hA`=#}8j{R$$ZmONVz4K+K`c*M9EBQ)y zR`ImE*t z8gNd)o`{Yay}unild(WElj@R^!4}eRWU|ocl9u%VeBcmiXy|ikEs}NIS;E3DwPPqT zqBmlLIcwFgm`)vVFB|XL*fXTRr|q$spelE?jyrI+MU?gCQ3{V*}Ug}QlXt}Lj8|Jtf1V0j0;HKm-Myio2kUrZNY9NtDa)vOmyo{-l% zG8G_;#%lE>>{}ALrd9FrY%Po8CP@VkDlFk{nck4zT5{v1W`}T(^%T0alG~~G>!njC zPaZ6uOdV(&5y;|OJ9t04j5KNex;wE~o>CY2(&K>d?%De{muLQndtd8wy++>!_bjV9 zbl!Gv6wK#r%ffSJj~zw&sWq^Y2?XjUFzy< z_XN8;oCtB8)SldLK{jxO&?_$M1FJk~5!H)WXG0yT&p%!evr_hG9Gn=Z;WS}Y^C zWLEkyo+UT7y5xc+MSYov_|y8-#(w*aIZl`6{@Nf4W1Kp0&b+j|=d)2MRK;lTr+Yh?9vGpUzO=!P3LDFyVD5^QeXFf3j5}l3=Tb}VtP=T0 z^k%oZlkPk-U9h~L2a?>-ODIA}psPfCfZeiR*p(~$>l(9iO=BV;`s0w)@S&aJd$TrD zf0mW5S+VoEuW{bR{Kv{;>0`%}@^66LRmhaF93jGjb$<$WLS(j6xa`!a2;b8jEUEB8 z<;J(QTS{_s;j*%pu=5}8oSowLv-2>mToF~`C8eHD78fVtq_}3@vf$de2$8;N>?l5&{h=20%a5b{3#$glKU8bFw{`uLMz6|HGn;#PG1K?@`eHf6+daqQgXDK>b`$t2UE zdr|TkLj*2Z+Czb>{Zgx_t?yMPEM#D6nCQ4-XVksSrGBg>WBp$F}YjH=twx+R%^>9NjRu~D*Gtx^KCY_?Zl#Hu_ z(|w^5zuo~E>hTO>BuWVBJut*DO?4A6Z@I}o+3p?sTkpksok6jABZ(y5x3Y3uFvm7W zL-qT;uQV587JUX?tJ94lb`>@aXYS+~^3-eH`s%oCh@*m}FhiyvTo0dwRF#;TNHRx? zDsuP8dY4TMNb;iS_?g@wuR;iOO6;Zh2V4nyjn!=FNJ7H-TJ<%ugKtGwSnKOdjkdO= z_g^Ik9xdv)ZyK*s9UdVO`ruv2gb6LT2Tov};brsz!-b4vUS{)%;YuXoEdqSH^U9r2 zCwmOs$*H1hV$==jXXsO0v-DAA?eH?Tc$lue<%3x}_pRFA(0CPN-_uq!9aVa12r2W! z2StBhiqvanXG|0hUocg!ixFV&*R?wo7?-AURwSp`_?qTq*@V`G_Kpngh~m0bIn^$X zgRs4j;|1xu`_{ql^3xmhEzZWeO}((-chb(PiI()|MHemj%d${0?eN6#%Y4pV->L~s z;k--}H~%bO_+{XU#EpyDLuvC2aKv78%CqLjxL_vcd>B8=bCq?RU*|iGKmJwv&f(lI ze*+1q#wqTi`aUyP8P@b^{znT}%<21|hwX0qW;mwEg| z;zEOINc9m_el1Hz7`E}^?y9$Q7+}LK>RICqzIV)o`S0RHx$|U@{4<^;%pzJ z7j8zaTw#(4uI83iSA>wnR9V%q(7FebOCACi;CqLNnV@7aX<Yx~E)80TQ2Brm!f_?|vU_EKVByCt-yZlZXwh|~IL0{A8=&f8fA zpPjL!yO{C)5@=nBhtbL_aS;wy?>lC2#{W6>Q0xQIIHcTRdrP&z=k!jq`a9$C5?IZKNS?F*qP=(mqjn8fvyPhoava*B;k`i zN%>Szdh8hpU9HCXC$pw-`HouA$tow&aZHMLQ~NoR(`&(x6Ryxyfo%Wn+{`F|yQCEH zp~<=jG-I|yIJ@!SPTZMc&JhW_hU&Ji8OBPiR#4hV3I~aU7q|oD)uTRYW53Qg#0#xO z4a*5Hwz7i=yqFZ~oFhd(lPv(eeN0m~OD-M=$%;c-lFLz{6*3RorjI_m2=p;+*!fzO z9Opqz{X_BcZDs$9v-b{bDs8`o9mg{2sN(?Rh=Ys;5D-xTk#0kzgdSRes7MV(N+<~l zHbjx4(xf+ODMU)>5E~%91QJLHQ920_DFKs^a5ggYzQ6DK&h>rQIsR+*=Gjlb@3ro= zeBy0ao)*xYh^ckKA+)sQXUly1Nv7v=p>f9fituOzX^~h}U{4iHQdRxyRgzaD_~m(B zXk6I^x4vpkn~Ge14eB00uvQ_Nh|B~<2xsGqP`=~TlG=^uDf3I9^rmJ1h79uN{UtLv zLxke8LksT=w;=l2P2JC`mb!+{)?4ip@zTbs(*5fEJKf2hSNf4^* za`0MR&vVL`ja2qs1}P%ewgDO2g^CT}u!qj9sWs}kWmB?>@WUlE5XYvg5;-8Esp2z9 z)U)SZYnjj@H_LTY^abINs{X#JzshuXv+SnR>B8>t1HrYZ0~BKcvCf^STMoYn%}FanJVrH*M6K6jbcefEd?gZl~UI~xk&ZEXR3E+=VLH*zw$+sVJv z8QJkUkezzbhK>^~t1~LCbGRJ;0!*AjV)E*I>g{Q*$*80|Ql)nW&o zIFXoDIei$LnSRd|pFM0UV1DU0y=(%n_ZYzG(<%{(caF^!(h34RNFUe5-k8_V?si!0 zIyC63>L>*>B1(H;5XX^3pJ#(j;YW5RVTyDWQ@j$0-c`v5k}Q*0&I=70*~+3Sy|DF~ z=)-H;xe0XV*lqR6o-Q=R-Rg+=ZS1rbC#ofPq+l>#+OBp|+=2CU)rXmC5fK)xcCoz} z@i@Jz#mK1KJ>rOq?6N3x-K)CcF4S0n&~<=5Af@|H-D5=%P;N~Hce=%NMl&k)g#J6e zYoM*-OUtQE@hQhiWMB^pq(>n5Uld|j>j+W?c42Pb((C$$tIUTk%@*triFh2%*P+$2 zA?rxnL_tIGp398w5a$I#NBC5tf=fYQ zJu|iEcw?jTg8TKSg2g)(rBTygOFuuyo7FH^y8y0h`1Yr=)j(V!2Sp3yK&$ zsk?QkJq`_e?%L%(oc6`=&FkB{gFyJ-u)(g=>u9@{j#Iua?+a`FHfx}YGK@x>8CD#w z@L9T01~{KLt){}yUzjGEQ(>6XaKSDRNTA> zsj*SJ^j?6pd!=h1xuudz?6Eo=_^eaUemVa1w=NkDIauD38qzpRYk*)F-_UApi5&Z-erdf{f~~#t;my2)Lw7f-Yw9mxRg1B- z&rh5sM%zn72=wb2K~urHrS*;9k-80v$>4~J)eaEd zzLvq6>wb?<@g#NY>deNtT$)9Zii~8g4UU57aFbi2YT@j3COm>dY9jAk(b!BsYzs?* z-BKi2k|Ll#tyaq{M>a1P$aEdJs}{Oqi}Zi!aKzOO(4?koV}bHH6;a87$&k|vczK%} z4avTIu!JOh@IJyc>EikM!M76!@b)urmM7xJEpNRLs#4{r;;ADb%STya;)9gT--?`V zV|iall3ZK4)W(Y#XJ^_5IfUl@ihInwMyQz{fnW~XwHXUaX5o~>?SHHla}|v*n$_sce6jh2Xh}qj@)qgGBA3|4B0cx(u6sm5;*&7}e ztwf*Yy*}x>*BKpX?6Zv+n*ljfUT5nDz69yz^=+l6GHlXaa+zu_V|t8Gyh-#xejb7% zdbB+`lZ^zTJy-2kERnz~OU(+tX-qF2Dsp}sKe)!^c%*~f>Bx+`A{tasR;<*r$Eu3eRchwS-u4SZO_6Xv_MjGzSK_P!FP4#Mmbql6pIN2pHJppFR6pAnK;WS+ zlnmSXu%LMZ^HY0%Rqv55j0eYi2Cgf})Y{jwi_4x%+s;%rMKdf6h+Q%VJPVF*e19G+ zWdFfM&<9FN3Vm3`AIhYnA?3|?C+xduUul(Y%|~J%wSzH3V%O#5(hZBt(=Fscxw)?J zmuffABka^ft%w0%iV^(U0YL<+;;2e6`o-$B>Mln(glIAU4vIRaLKl6P+|9d)R#D`y z4IZKAMpd8IJ)V;OwSBQp5C#>#oL-z#N)e!L2)aLoXALGU0k zf1sBS%ji%Hg?j!0A2{nNvhfX9vC<1~uIw=#O~ptYdwFb|MuNY2?j3!=i^(BkeK87v zSc1kLt7tT&nrojvZ3oD&3`iS9P=Sitxp46_3yOQi{>_bk+flWIic|8Fy#){L+L--B zfF%acsi&q^&Nb66h84!-P-b7}t{pa1GKk0}=!Ti?OF2VnxNSQV)kc_tm@Dd~zH0$g zIZhV{OUiG%f@k{`fQ%|AOaA0{i zcTA95q*5jr&B+dOd!y2kwka=npPYoerO1wOl0&_J)5uehb5bRzem@PA(0dpDlUM&& z7C-tQJbG1L!KSnjy2c5ys_AvK%zd~jtV#F3m-V$N@7!XH!RFw9-nX1d2~YWD6HTCp zTw$qskNJMRN(2CtmiJ*#1<@Zq{Xc&9`TSd%T78p80bdQX8N=S^4s;(Ku@#q1Z8^F5 zvhO`T|EgG+*8l*5$+1lu1km*V8%+Iwi+Y@RI~nyETRI?Nd%dP)pFyl-3Vn+iaryRy z?JcbS^P_5WvmW{e8=y_REgf>N^ZYIy-fL9nMZ6z8P)IFvLE>+IH;LD~_^wL>br|Ys zbN-_!7rDTingaZCOe6{Mx*79@7mxswj%cad0z0=wS^!I9=unSmCz~)c1#tkNVy%@` zh}b56@9Q}lE47SiyxTg5WJSo8K&aiT=M5Uyl>sU7fxy*I15$vFFK~K#E;Pi?1^Ei~ zvd=t)7PqH3V^#tEr!8glZIJ+csI>fs0_sZl?8`G7%yWe??u~aLW+(d`8WDKNNsiI3 z-Sp?m2f6Hg03{z-=SgGjmu-MQ)b#xofQ0879AiyWH0vEkLLaRzFtC6MA$>N3AJOy- z`u!PNE4266zh43Za7v&x7`w$WP4rkz`bkjwYU$WWQA#Hap)6wW#6v2sGEo)I*=N!S z$b|*p6sNV6mgsa9;~pI?lMa$2M&|k9neq6knL4&4TiIrqHrT;k(YrvJqExnV0qv&4 zpp2C&;H{bbI10|b=tk_(V*3i&y5(*<@s`L@M=+Ob8brq?LVrs4;tVzHZYcO{{V zcNUJ_)tguI|8QxOEO0g=gdai9-lk#(ZPY-=xJ7&`Wr$uz323|VO!ox1NpZFMmw|VDR@3BLU&BgavP8z0|<@D}VW2+oe}xqvY5B zU0sevq0_w9N*RctFDJcFY$XNfyqc?k%34R45|>#*UYW=tv71wF0Af9j6kT zg5EtZj>uku&lV)jE&0%2jW|Z_IRiLmm0XS7tKi)5xIKk*9q_~IdpdMjLP}W4h^OHF z1_w%1f&@}hiQhqaU_uSufluGq4>*qb0CqU^HPiu1vv_r3IR8Lq9g8>ZLi8uQ3I4n>hyhoQ3pj(d zYzEDP!2v$cINNyM7TZ%`oK*QJ`bW=ii!>|^^aj`w2L*dD#%F7sd=*g$limbt?cU^)*^u{Sf(Fby+y98N6P@h$q4*vjr*8GC^5*Gu5ap z?)K|#@nP+4J8*xX>W0t-_vUH)>sT1Yxd0bH)dIogmIX(cAmej#{uZS&`LtyQ(vR3jM!2yD^XZw?)H7;ko)vS0#=5T!hrF;8! z+~f)2_i;0TNiw%C+C5n6BuH#)?}GWuzN-qWSdPxB7PFD=6muLEeoIbE^VUp((+jL2pwLR|dphI<9$Gz$MgRh9cUInj zITby6)Cu_`%TZzBqUIDmEf-T@@^$8Bx?K5#vbD>vB9*%VmSFDj{P9lixHyjNZ&Kd z?5A#H@Z~psZ;SH5)xK|Z;u8r)n9c;}O?@rF?d^98V^c3Pv?pD|xr0XY!bJl{kk#^v zA>$;W?gDD*d&c_uvaXb|Mq!6Cc46x70%?zUDs=(D+%ToM8y_x#QO~Xt&Nb<}WkA^0Q*AQKD?>x-&V8@U4$nx!Hrk>N z(p_a=tniPHTQ}JGgHXtFp%F7?)~^JX(j6RTYSQb?R2IzFmTNZ%Hh`ni3*c$g@uqB6 z(LXpkjafqu6>j*_rYSFh?Jpo6i$EGcsuZ$-Z6FYK(-!IkRUZv~Boa_g)ps*OLdek) z6k;4S(TAi5tuiTTjhkg|#0|>r)9ZIlv8{&w+!a@=D~(<$15CjDm~w%*15yi>KPT0e zfU!?>dTc27w`S10+CR)SaRJ>Nn!U-k2^Ne+U)D<9Gf;8C>@1)Ns(+N?*Qv@w1e*wr z6~UE18w~F>0feG+EenkJjbb;s}mw7GisOTpg|3n&X^q3S;^6LYOprbQyD3pzP;*i&-DU!0IbfldUdGq zqUZ8mnwcJ_Lv>GtsrH)x+Cn3mc)nFQ-CLyU(}G-u75{TcT=|#$vmJ8y!ZJHvnWSo& zjjp>+Xw;>2b!G0^tFtzDuATuLrSlC{KJ?S9k`3}mtu$-WfbQ@`UJ_6uzLWqD0;WV*sI%|Q&822SM z=EpXDA2V(gwb&I*d}H0ZZBZNGssz0~C8W(wZ8b6jDWPaf23RD^pD4q(oL{PjH@0kT z%J&<-{>vZ|H_Uox6G!?-IeeSA&A;m8=BNJU99@3-A3Hex|H=&;{(t|_ax*seTv(O= zaq-VRH&p4ib8UzFPM7_><`|jH27dON(!%nZUczp%i?UbiCzhTiF1xpD`0 zoD|yf!!&$C-Ru_D?$*7VG3g=E(Medqz4)$rWT5=1Q`Q3XNSDr)2QL5f$IF?6;mrR* z#L+GTU;ie)zx$6C$)$Z;eSP}0_CHkO`zMrutTbRd!HeQ*IJ=MKXQ;8mA;?9nE{rma z+l+m3auz=Ul>`T*sp zu7n(DF$3O<6TaR4ad+|cuHE3Vr7Q*Kg3Rbh#}YlOHN`H67xlew%V$Z}A%$zF&YixQ zo142=5z_5&*5;Z+Y#`JeXcLngNdrh>pX?ObaYS<%E-9duX4pat>rqMG zY}((X%p|D(uNZa5_qMI9yyt|Z=oA}Tn>7+p#Og^*)x3l}d)C%;@~k`|L5{pa_am{2 z8tdnqxA^Yc+@s4j!@zB)J!*B!;4`Y(L#Ky5aNCW*DchqV#^)8~x*VW)m3~^NN^&o} zwdV|WOae0deKg)gg>=2j124<s z6Mq(T*0%jUoR7|i+^ZV&enU|ou)6yoK7M`eLxwV!kZi!6iaI(t;Wy6pwYKtXDRN*Z zXYY?8k}V9>iLf^v#-}76Hq{^+3&B>%*(HDJ87oFUbvm{e1tyUN&;_`nxOR`-=IGsD zx5)t_WGd2TZ{78unvH<3?K(cvyn{IRE?Exs(yA2$t6sF~x$<$PzoHNERod-YA6+`U z1g!F;m$D=5sC+l@xVJyi(PsM5Ds8~Uk61({Q{2J`T zK&Vg|6A3{iGwx;IXgGJqCA_n&rM2@k96y_USVCsI#9!hwve9t>I2iWppLy!I^J^{4 zg+P_P*?+^%>`cBVwmC}ovd$eU1pF`xuo=nsUnqj-#$XxulXz3CJ#MahJ;J7cFmP-j zP(f$ipHa4PwnAB@`GW2jk($=kna9FEhX~2gxod5-C5zzTtz5Oyj$IB=FJsLIyV3(0 z^DvHY*z}RYQULGZ)7+;fb#+1w_Rm8vS;zXabJP8NOSGIbi+Y+ISosU~c608L!CqPJ z;Umk17r|ZxcV|hQY!mBnUh`LP<%q9nHsV0vn=|me2p}t=T{QXdC~RTrZh|I_gieTa z>NAP|ep*lNxiI8b8Chk3Q$=B|e86mDz5n-1A~rAy5aM2iW`1 zS4?`$@nQK78#fh?oLT|s*sNoWQ^~%Sb5$Yrx)c{N1yT|Plx$K!HmMZ!pO4Z9-<(A( zkJ}@b7muec*Mi;J4>uNG0Wt0jnp54G{Vbx5h*rQ~npYH`NWPS@q)ZB?`8uu?W|cMF z)fzz=7!+}k-hdRn z&%#x452xY*YjsaU1REafJSU4|Q$*5!tPY6ohk?S9YI`6{xV>Vat#@zkvXmsA|g|tr2bFK+a;tg$1tG5wrEdrijOX z6s^IZjDCyBu3KZr*6zy;$99dC9c(+MxvCSqj9$s@KnGWVbl1uBl;L2l7S_$#mZ|f5qjI zS+~{cHN$K){_X0b39|Yf30=A>>O438HPeGz&9Cc|s+J~w8P5J1<5}dLtLy7D+NIw4 zz3tC~S<9#?sh-ePQAXkkL6djH6PkcelQNriIkY*A_r^Eqm2usZgOUsEJe**J_gQ}B zl=dCOs{`yQO9iW?6PtUNvemr~FBjTWRYd*Bg8dC%baFd&heD{1`^#;hJ3SUR*evr1APSa`aM;MFE;YBN|J z3_tm@aAR(G&C5oDcZzp6Go7%s(rkcSRyFZrN!u99J^ivnB14ey-VnL_F96TDNEU^! za@3kpi&#KIu27kIB-g-~q-|AO>w9tmlk2mAGO4CkEVOOdc+%@eO5M}8Ke`IWZ-W0{ z)!+@t7dJS^^3=1u@Hwu^Gpl^ip#RcOYN)#&Q*w9bL>lfdvfTd&?EE+hz#gV`J+E_F z5rfX4nPKZg5vVI2#zd=ueUwICMMV~iY+14|AFpfQ8me{0kQwentTph9L*7(M%*hRb z_#jLz)~lKF?y5mXZ3v=tM&V$=_b;4`AUcI8GfWG{jSo%Gq5H@ z`=h4j9XPE`LeF-bU-`d`9`Z%3cbs$p;~H&c(9*|&=ol!C&vgnM9r&zKcsJ?E!O%|H zXdVp&yNNt7bFbtA#69*wD zQQ7H9(v!_biM`LnTIcWH$T_ZcZOH9lWRaW0j*c}+Y9qCgY{iTe^JV;OYU^>S#DfC{|{_QZb_2P z+oZH)B470VTq9}N2;$;Cfi=!te+Noi{Q@znEKis^NKR`6TT@{l)S2TTFR$+Dc(CMn z6(~at+eP`a%`jNtWzPOxN_0DaEZA-j)|>90393u*JPPCvo$S#9Nv(ZOYhvbRb zXZAHzHaR$C>l;dwN?%$9JJdQyi22>+rN8BqA{IL&y--hvgpMotIN+YLd%n^BfbT$M zYYU8b0NeF=zmY=lGYsAAc-ncV8-~kynN?ag_Nd|8u~UDozqONc8N)4C#8pgev1w#U zND%lS--7N;ECr{*GyKpmairbZfqzb2{88VNbAM9wDVZnT6M?GUplbOPT}YxgkUHv^ zjyiw_3W~1SLk4IEbJm5z^vahFUw__@T7Wlmh=2z-0{Td#VQUy1wx1}yUYC5z4yu}s zyooAREV^8uOCJ3?oK1Xv%b?@}#qIGH`@%OREN=RoCv>vSNJAD}+}~_iL{)qK?A%aE z4BwvYmMNc!ss--CrnC@b6qUG|PL{0}YRyVTA3cmeP+e)>TI7EE{J;*WI|J8`P?Z9a zQ0nrx66&&D^zS1}pUZRm3zSsf?@*neJg)YuiH#QqxRFMX-L<~H$q=SkybbH(JRUw< z)N6Ug>)U-|e9}S_&Blh+H*PZ@+PJ>-t503c^BX2m%;UAZ!9ndotES3_2Sj;qq_Tvy z0(D*it0cY%pa5xF8xgxd%7=uqAnCK?T{_tS#UXDvzG&o5K}HbbiJXtzoR8K#k?m9$7&RbLTg>rt}i4b^sLVxk)cEnT%8U6IMINH01P-_*<}E zyW-S9*aS}#*UGgR;t^lknFXfr;2D=LtA$!Z*5YlYECH)>ZEoYdT5UhgjQrlT5qT09 ze8}rUuRI{WE7L6PgDdvucz{Mu@2_jFx zqrSB$N|d`t{gLJbC?cU=!wXkRCO6Ms^WK1nI~}jMFs~c5;dW~@K*;&JNyzDRx{Z3& zdE#Ub-W?!r_{M!4Zoi2%-K(WGfoNzsW*?Xr>aqQeFv@krluEcKSB`2;|54L zt52t4E!@P7mepJC6E1B=V!Z_)53D7EO=ug-XVm9E({u%u72w`Uxp-;Y#W9)kl6m9% zP92-fzpBqS1*0xw8_rhMX2CbXFib2JzI=}*sLM+Jt8`k##P8Bm0+ZMd#ihi7`qBd8 z0uIfom2f;F2(0QP5rHoJY>6cDtP*td5)(=sw3;i`1^#OxRO4Xb{4&&vQ>7FnIO*pF zx_PuYF5FQQ;kb8CQY!H@Sp*XG#ik{WO1no$8PrT_+*Eu2*fN(jbBgRrBOSC41R;2b zk0!m5Wa%ub_@G6*3OXa~`!@W&_!!PNr!25DEqm53FhDz;(_9~3p+>2zcfk|U76FB2 zQ=S{xbdGjl;^?RpOn1$XNd+ySy8iT=YC=duBm8i6QR-gaoxkiy`999WPYQjmM0F7n{o=>R)BN%Tbz5rNvH zHm~7`2i5sXfWHfEWn_G-+m&qvippivsb#;f>Y8ZvR53d;HgDbkFrpFG5ZVjosU#M^AtUVh#MhtaG)`UbP84f#AprUiV(})aTIqJ$lmqk|7*pxhweFB^Hek_81;lYt zxL>$)3}@nK#nSUZ+oQ`t&ynlcw2S3cnYv*u*AW{dffr^=*deEhmM(Bq&XdeicglY`hL4l&WJtB)5452tm=_{Wem-=1lJ=L|N(41t~J~N`(2I zySM^7pFo`~HOlX}vOfmfVt%qZmTNFlR@Jhs?iH_AgjRy#xGRz{;mJ%P@fchi7@GVN(_H0w+vNJ94cesX~u~wt?~AE>C1?kUEaeBGxz`&m0+l527!$OhL7A1efWPJq=rhDGbe?Weus???Vv5cgNEQ-Z1`dTz^(FK8~Jv z^#gBy82rS{Tcd$o!tAN+cdO3wv%HZe_1Wv&3}JO_2k}CC=N(}Cx#8Q|;T?6OvFx&8 z63ye=GX72KzRHz&zT~&})3kz}ZB{aRl4_=WnjO<|4CjP_ zhK)}oHMkjQf2wa1VcpS1B#HUdHZGC*#pd4RB;v@_5evBz$%NKB~}#Y8F)!U zV{IID8-rN-F$JLb3U$|phx{Mq{|BR5jH@3 zHuOD*RvIzF8S8qH8~YeYa@4@SXefY1wXW`h%F8c0fsjjHQqddLh_!Uzo-T=7v8hAF zi_r(YOZoycUF1Y-P|1@r&ecw1J_PGx>{p@@Z~eu7gLC zOlc}+ram2$nl2sGQ4y`qqwpH_Scl+#9w8^($DMnNl`RK@KGz3Jk3Dh5Pc+(ZZ%TF~ zkccLrp`3vy@mf@7Mo+sLalJ8@WkV*dYP1<6Rh&(cIQ#K|&x@hi?lDB4DTLp>7MgVk zu5sf#Nv%r~)`%~2TgYCsOLuFb{W{H$QFHNW57oDK>6%__Siqu4_=_qGL7FpZiaD^- zZY_gWYwo(|9Wi}WCaE;Put(jJ;U=X<3(tBj5L}j;cRpvFfYStkD?I}(*IA*eFl0m4 zWJAn7zW=lCl$@l>-Fbpb(k_y=t)YaL*Bk=NNb_eodlPzkbhD?~GM9}LzxhbORfJV7 z6*^hENL#V&N?qvuQfGki)}{PM-6GvDn=5&-Z}kn{8EU;9N63}^W%8?*(-iG+ki`(V zuK017^E(AlwsA*A@WE6!iYI?Lv|OaU5{rhenbo6w1wA@WRv`L-MQ>K5L%BR>o=x=1 z_bP#iXTK%8hh1)TW0@NpMMFwKP3;V8bRmoq*ITHjIEO6%SSNR2gPuaLvuKW%uni~= zW2odm@$&=>;IIjDW?maj*{Fq{mujd;hoIuzw;#16swcD*GYc9<_fz1;6{V1eo$Kt` z$*`s@+u{OvMh>*f>&k~IKV)d45 zx(uu1%LJ?R+onDdYqlLQTxK^jB9bf#lUwO%66&r^Yx%TX2C;ST1~$&0m$=6gVyV_r z^JU3Av{raQroidz>my{ydNJpu&zj6lf%;kShqyD7!`xP1F>j(hZn8TFmF&IBDa@!O zGjntn9qQV$`MUj?8wC>A+CAfx^v~^@}*nD|MIqKQgT>D>5bT zK@!L2m(p105S2HewHA#Fp_-^trHEC7A6}c6uPpAUT2FK7$Wm^0)(R~Co}k;%kZpR5 ze?v3O#8WO_6L7i6P+YwuB(znun;u)g0cfgd73j=%dioXBlNyo1?1^V$3ecukMkxo% z2VhRtRh0Y!nfKgdLT9To^D zd>OA6rN_)%oX(uFe6O}(xmeCEcvo&yI5~6$AT?UZGf71WBpE(2dmqY4vB8vYrAn{1 zU;ES(+(fxj@K8eXcefsxHSV$4y>q0SK$*EgQbzdA5_x&_d=&gEgz5vY|3JJJcsOF( z76oNH9CCJ<|8+z3=pnxBkF+j3E$>)jKE-a?MGx2WzK#MNC`2ET8m+m$XX?u32znS^ zCmXUkF*qjtf1rhlLW6Y4+k zhtC4BA8iG41;<|q2q)5@H5WF1%HnD`Apy`J4UWb)@~pDe*mIQ4FEZ~9y|cwN+NS?1 z+sxb5Nwo5Ey3u?=i&-(zI0Csn2c3!xVDhpG?xg_FI}ZuW!oJG?Xz;`2BA_fez6Nq6%sq)IkjotAWo;d|zipyyRU7t=&L3qL{@4o~*EaXe?%AB9ynuH8 zlL2>Ysz~D;)!0=vvF?WC@14HM&p+t)2>EdPC%bFtHq|YZA%Z6{WHjWQKG-6< zy-)`@5t>x_srrqMTUNg~ihmfF{ZX z652yHdaK*E?M-`jEj2We1iUlpm%Zf?!hdW*PkYE7a2>M{wV!|MiblMo{dEt8p#O;$^A*BZ&o8F>Xp(MP7awx|7e7;CWLk<+3MrzYf2d43#dioVxT z;1ykS!tqcNW5RyWN!?N2ho{gDLc;QgconLOh4rOpLrnBG-kQm$T(lbaQ;~DN+Nfy; z?|C#~rTMw<1roRzk0m^{p5ASM#ivi6qcZ9?Bu4I*-dSnRf~Uca?r*ucW*pZ337@rd z!`$ppm_*qlQU96idND_HB=UOd1crV+qWS6(3@K!*mtKm)TX^Ys;8$+cDcQ%|ADt{ zn@+0uvv=wA>vxpE<;uWeJDqPsr|=_pGGD+gWf^+fjygBG$jKah14oP)khdmgYe}DW z^Zcl8D$IWMJ2CKi#Tmp|(~nw}7WFR|eL&As#ye zg(h{=Y>&! z5k95|G+59TC@w_grMr8R=k{2sF+oST(nqn%lsc4=L#}emL2FcXr@-+^V})v?-z=5m z9GJNq8>S(j_ngd3^5a%PTDq&NTGzmdbl(mEYJUBx>3RM=a0`6+oZOAfz@s6#QI%fr zbq6`;EiE(#VrRrN{ZV)nA-qH9u*%u0pi2HT=XTu5aAx0c%wUj9a>*nr=ZZReLXSu@0}?JEE-39Sl!n+^89BOX(UB+E=QPB z6|-;_^*i<_vsO=7$xWSems<9}RU;Q0_IVwxyzxdKr{-R_SmoH;t+1Xpbe@|Qgns9pmG0B40Z5^4(1&^%DhVxlgI))oZj^)2L6f2;+ zsPQI8F)6QdUQ!l!H6-R-d)bH&jPF`X`&x1!E{?3AW*$E$n@o~#p@l%5P`5Cf8olMFDAG(;@)q7tjiC;2E~iAbvaww6{pv`Z(r`n|KhBFrpdU1Ufp+}I<6`s zYb5QP@G!zD@2vX4e#MG0)pB|(4YRkd;iZ-EK{`uu<=qX`N_a+6cCkDv1qSnYBWdty zmdrKH{MAPlgZg z&nmF)Mj-oP{liC9o=X7faAKk)t?V*l>1AB>rCK1t5OE)+>_*bPj2d-Rj0_pLKO)Oy zUNm~-c1mHTYPVXuz);|Lz$!e;eps&}uUhn;=h{@y@)%e(=cB+%U4%lOc}#TZ)pK z&8SSNQa)fa{mm&&xSr#kmA4e|!RhEiu#wS?UbzaP=1P57dr4|~@&}{cMkML>D(0d2 z6X3=A9s5JbMSHF9@eO(X*HgfWuR?HbD_&#k@cKktblse$`p0j&DRZXpQ>3fC?jnS4 zm_SeSWNkK|R0*!fDLS9m9v`WwP@CG{wQ=4tQ#!tTMWJLkM3{Ema*@XJLks(yb~znY zNWxC-I{0HrUhX@LKMJYriv`2j{TP)V`}^2llVhYguBSsX?Q(qhMX(E03VYCiXwmE= z4GTzqhV3MrZ0fJ53kJmx-rwo#)^sGlEU>ENPQ3;H-nvIDO3@irs!{05F>RmP6^Y&8 z0;Y_!DD}+d$ESq2zny|zcdLI)`Fp3UR(2iOriIa|J}Kci!dN+eskos&DIs+K>q_g^ z(puxPR5YV^IHW!Il+VcP*Ja^H2*b;|9TzYC5U<%5UG5?GP9h=auKKjMyy^oba7m$2 zbG7GiN^6k)^{1<-Ke4Myc`7>1cc=0YlVvvx!nKW1qO9{T!_jpO0Wwk$z^~)+6ZQoq z>X2L=hFvpvES^=!AFP^Xt5+yy7rLewL{kBFAdZm!B&yy_%ZE}bw49L{yr3f#_qVr? z^dbf0167tl8BI)g{8dFvRKnL9Y@V=kq^Ev7Bgj|wlh+dKvKj{rit0DOS8%?JmGFBz z8Iz5_X1*4X;Xh99oSC#<2)sMsJ{G8Cps9hcmwlwE@~#kJbO`EBp&Q~u>kgtW-(5xi zcHW&%?W~9EGta`O(qd{_bOpM& zj#pt-vR7fzs3caTuF;3*hv>6Eu8niwL)^BnUIIDSfaKv?o-2AT-@NGd-U}pu^ersLfGN*tZDtxKj zHAs={8n`sjRX!vr9ko0^&Okv}Of~a^p;O}ynTq3maeUv*5*n1MFG%!R3EQo z-ThgIXa7nizsbJIY~V>`8SBs}YglE~0FCHx}BgA#viH7N(*~+qBOJh!TIL1(n zu3bmBU$U3YK*@vG#ZQPtKcPTF?^)&F4ydfu68vo+)j6qY>h?ckSy)YhyVu0B%Dh6y zlLG_28hTh~QXhW|$$p)%3p1JwW!cz629E{nXCV5aGKZW+jLOu*gyh- z0RHB4-r;WCnmtZ4O2)IDc`R|ds|O5OqyI!ucJBO|vR^lzQloE=PVOHK$$RLhE-)Q1 z#VS8{o+~;Vd-T#IU3NBsTT+K3+tqu2{FLEL-numDC(FG9lR^*;$4NUA4`4H$yo!Z zRfvsM4VB90$pp!%$iNtDpG@LFyaxUR+?aYVQi@hM{QP%@cKkgxzr!UtPDO%_NK-*H zU;ij-J@2lNu6g>z=a3^m2C?QIPb_|EK>pMN{6bpQMH~3<2zK3rJ-%6!g?$b@)7?^O z=8$ThFnZ)jsBad!P7zEPn8m#GOJ}y>N2He@5#VOQw@d9K!bQqj$`T!0y` zjAV<0fm%U(?MX|4uacFr3Xh0& zFJZl?LAC=+ zaN-9~45-q){O|whTLL8$m6fVHckHOyveVs`^W{HReahm1H4xkcS#2$V%kR(op9Z?m z7I+2-lH^r!xb%PCxHs-U;e?d-Tj*ii;!o_STTh)fmlfR|GnYL!N%-q$h(Y{ceJ>{- zVH_I^)3-Wr+k~E2#D#xftmMKuNSFBY&tip?!Lhcj?$|b=DCT#;UvBef#SNd;-nz~^ zYgMCM@MLj|H3x99wf>d&1-}GrUC_Q%zOH%oOgZ&Z!ZY>Mngp?kc1VMKPLQ3eUc|NY zpn~?t#oo1h6|7`UWF9{J#dqtOzkB-o`{$r~s$$LQ8bp8$<~_-mcE%NR^rdnSAN5nMzzX9ieh0)~A0=<(Ite!yiw^q}; zBN|-WL-92-qPFFUMetc{$%R~3E?tzJ6h4y^S0K6_@=wnKB}{LvZvp2rAjZ`!p)h-M z>B_wDb`&J7UK?}_dUD-}&D`e0XfPE-jA928^LG%pnt9K`#IfyDib+P>j{x$S-Xy5> zM-@&PK#y39n%_~e^}82@9}(z}7;@>vCYRc(qXGSW{OpGG&VR;dseLp2Z2y$Mp5$_? z!$jO~YGBOppKljG^*_gMnH}Yp7rOQ0oSf8uBSxENK#2f#>$C<)Rc`;83{ypi!}kAR z;s8=SkNz`*wgzQeq{P2HEdKw)^Xwnq;~i;$LD$sd-B@~eD(?u?1Ib&>xa7xWV?_&Y z=#G?kHq(c!xXokVt&;6eYT6Yvs?A3%SI$HLQHPV3WpyPd=d8Lut)$`e!lmvD;Ihn- ze~K|KRsV0p#k^pdowc)_$?T5D*L_UvN?mx#0sd2PX=c%oX_5b-59uW&V)lOK zB>`_%0C!Nu)Sno+lRqC=aiKi{hxYML*6*>8{K`=C`nnl$mAMXE@7KyiZ}pSo>Fz6+ zJH~N4sva8cmLGB`Cr7Y;ZLZqaSDtcgz{e+F&D9pJ^KIm9gV~usMnO8huWTBn@_#b> zwg0pJZtzCw`{tpxiK2 z$;Hy_lCM&um%ePc&H(mEwg}IGy*>3ZQIoB@nmrbrHJV z?PddsO&vrvRm<3uV=J==`Mf(jXWyMgEji~^p?$gIS)-B-?>zrWw#3_^{2RMNX(cL2 z@qym>!Ngn($vOM1>P2kAD)Nm%Q^kB=U^Z7@A@Jpb-5=P4IIYTyM8h zH|%K=W{vo~>e!{zJCizJ-xC$y+By67fN<`Vd*|~Xk#|_fUL{FA^USb)TeJRMlm}xd zFV+|507pSXJ+rSS)xlzP zl#|ykEa_CoWj_%TV-*&%O0*ycdq1}s6Z^W%Yzxv9B$$P+&|_rRL&s@rw{f)UrA?noHt7Z#&R17FXW@5x;L0EKY(~XYcn#h)Pe+PK^gRJ#4ZK*`&8K#`q#}9VgUkLbt zvEyD^-dT*OOlCZo{`NX3Oo`&NmATnQ3fu0;MRsKFRABzUE=cWWFY5|m@Pg9k)N&u+ zWPgw`JnomlTzoe{JS+K|n-|h2tbjjc5dTb3^~9ZLyR?rHZm};_eM71(mF?p5fLcob z;ir&cKNpT$PBJ3#rwrOX4G zs<~CZ&;6ITy5^qggv?v`!t{_P%#GCnih|qMmpQCZfS6~{p{1w4{h5??u+TbIws>ox z%-=X(exT&($SGy<*O4>{@lrnbq{N{*GihOc^@8`&1YXLat-acO#_4|*cI9zNZEL$u zCq3CQPo&;O9s=oKlwpf{5Oi(yoQuu#ih`&NEKKAh8+n9D)25rY54xr4|R-ZfRc40G)8pQ6D zIDcxkfCOtZ%GTg}Vt}lJ?8UV0&~gM2S1HZS2DUY1af_|!56_NByQ{O&Y}6QtlE?*C z&6ETZPe`a`63-PW2nneeKj5zs|68~ng6=MAQCL}$NJJ>u${P8+x3$bZ~h5^9pbIarUVJdMWCAtA`?!rv=+IZzKZ9Ot+tn_UFGFg=Q&UqiZip!CN3=*Lbqr z)6qAQTU!3#=eM!j4j%*>>qWB3KC`NDY;&aBTT7b~BmS*CYmqCLLJe_*0_QoC27mC# zBj*KCN?eh;!<+2@cl@2AU*b&_04%3MvAlF4Rm5*bLRRJi6?NCGll{( zXxxejk|+5y<7SuVkg05=_t0h80q-Gm^Huq4z$RI?91S?Unb4r`FZXEkac+{Egoglp02hl}@V)2xM|X~0_69)XgOa^% z+tUb;@%x&hH+l6gQT6}m-TDo!NInSFlGvU>{6RTI-s<7}|9(39Kg;WY<&qT7XyHe8 z+{0)LWAHQm+R~w3wyr0!(Lpli-5I2jVAbnBP0O^d6*aRiH>t8`YI{)cH3EaJ81g3N z<65@~zOK^B3$kg333B>>Fh+fZJ6n70yCXU0ohm~#*tnW_2RrqL zt6Nhnc0rmv5e%&2G1ab5zD;b2&7E07G!zZxRy8mHZTqa0B8?BU4<${qv0__JvC!tj zAK8xI)E=!yiK(HV5c>N~Ad$+hcV5@r>f5g}9VmL!^`0IEQA$FJe4La~{MwVd$6}w5 z*a1VK>s2A?HyqwFa)=gkkN^&)l4)Z(7uyoJv46Mgi;~X0-O4Gy16VO2EE8V2p z&uy0BP`AtyVb~i4E73@gp=3ntu+phI;WwUmDYBV(P)FZN5fEs*bXt3! zP?je?t+d?7gB1n(;PljyvTX^rQZg0N^I{k<$)Yg{{5Im1fO^G`L5RgeHta;?HI%A|DOY)a{vq_!}4_#=sa_LuZ8 zCp}96kFK1iC1D>sPEbz^$XACYu({E8;nI;79TxXHS6yR?F*8jSK4IY#X%4~>+UCw? z#_ZS^Y7@JUhMH&5BuZ3HW+5jZ7$)XE$WLc|*6{3hH$K5vlZgv0ay_W06l40xsCf&S z?8W`B*<@EN=#=q8NB z?U9Rm0b$OAYEG!`+Oticls95g^U|cGK!xs{>Qh@v_U-JnStW_$TJXRJo4i>w)FgHQ zB&FeCg?~vyO-q0p19CDk|0s@^D^rjYLHr0~4^Sw}W&HwYf4zmW9H;cmIaCnZ@(59Ef*~y`$KraZE5WV}>HI;!V&Zo*Y1_NKfO+-;(o{`9rKUc(VKoz;YnfXiQ2jm&V z)W}vG95)uz*oGK>-?fkBKzXSB=|xd&ZMjvxCPwqMiYcTdPuqFekMXZYwig^s$`di~ zCZ_$(UTrm~Gv%#JTN=$8HF&{&N$s2tKi>-=y_Xt9d$U`Cxuv-xDILX}!y3m4Bmz~W zgHg8PH2RQeV{Y14=a}cP_PM%M+oX6mDC<0v-}^`H;4akfTJ1HdF|lQP_JQ1Jl`32% zZWI#dT)6KU2p@DgX+^|lcu#Pml;n)~$d@-|pzMl%1 zRR^S3uAbTp2LXcZPwKc3CSC5beTgX7A z$U5%>8G>qi!knl2RareE$84V@m%Dr}BnezL4>NmiC3|OIe|(%d$6jXtJHLqEh_>f15jS&wNh_P|^Y9{<0$NlkPbB#uT82yawBw5@iL!}|Ok57>f&M_e#Y zxjL4)NQz&sxR-91+=&{qJxjG0pJ&v%vWow>PQ-+hk?QLfri0l$Yrsakj~_3;#Jv?tSYl|nw@@5hq#j(hC+b_ z^r#>qkL9i-z8}7lt3vfDWU4VO7+<1TCGE;y74K@@rXsI^XMYw@*|ke62Ku#3+xM^L z3}8kLowV(s`;IpBi_70yu${yRhxyN{3qR7mNWFn0C)5>m$zcX-KhTZE9-q=5JF;KN z^#z)tTpxeC4UsYX{>G9|yE^8(E0G-zD)pGA3RWh8N>z&KZ78e)v#X>I2X`RzdQTYB zi1{NQ#{H`CY2JcZBK)u35A)cPKGK3&T_*B5wb_C0f(glw3zSmtS<84C#Qg`@piNP_ zM~-)oMxr}pm!u~`88PWAig%d5cUR<)KKUeXsM~&FR1;p;yjcRU!%d~mBa_*bXQ2cF zTrnlR`~39gMo#q?+^b#o>w7EDNtEzNPc(X>mc|XZG1FEWB2J{YEpjZzmGAfxi)0ga zEE5O6BneS8T!{mHeQR-;3+vPsE=T%|wvUEE;mW4Z*%)o>aA_Bc?NT)Akm#D_{VOsF zHaxZ2<%yLO&}o^{c$pOIyvx8VL+BRrTkqG63RyF@QWmR66ASt|o`S@)gH(4g+}1kv zdV9+C`Etncx@>L5($STy6BZg)4eOdFq{J$#$<-4ammv+Jt78CuH9Zm<KL%c^`-I5u@m42%0Z zpCy7H23Biw(GrkRyrCv5{k4KwM!6pUkNW(aZaX%hKQ)gr-}o!L*5~A;M@wN^fHV8% z{bsvq3(rmZ<5Dtr2w-2^R_779Uz~m4rjH9En&)9#bf;Z3wyfNHxS$#~PS_-R&4}Yq z`G|VQVMB$0o(klw2>te35Nz{jcPg~QrKLi@D@+hJuiT9D+LqleBuiQY`6>`Tj9Zc#nY!P0Xg$k8lSMV z+HX*Vjb>=TXZ@bla6NG4wNtTt!8DC%S(+FZXLfL8>oL`WPacM!36|Z^d*#;Ct0G`O zJ>1djw>`0@8T)o*?i}36sRELlash$~;C~>s1rq8fr5Dosj;r5p1Jqgya4J8h8mlZ) z6)Ek^C!Z6Y!w%?}AolNDHIkZZhJllp`{WIuDbV6p(p~55#f-aNfVNwgqqwGDHDej*kfm4r^X^N==0=or?s5ME DtrmlN From 07620ea80bf2e1b44bdec811285be008836f9a0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:22:57 +0000 Subject: [PATCH 04/10] Fix booru review feedback items --- ranboorux/boorus/__init__.py | 9 ++++++--- ranboorux/boorus/simple.py | 5 ++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ranboorux/boorus/__init__.py b/ranboorux/boorus/__init__.py index 56bdcb9..23566dd 100644 --- a/ranboorux/boorus/__init__.py +++ b/ranboorux/boorus/__init__.py @@ -42,7 +42,10 @@ def _fetch_data(self, query_url): if attempt < max_retries - 1: sleep_time = 2**attempt - _log(f"[R] Retry {attempt + 1}/{max_retries} after {sleep_time}s: {e}") + message = rb_http_client.safe_exception_message( + f"fetching data from {self.booru_name}", query_url, e + ) + _log(f"[R] Retry {attempt + 1}/{max_retries} after {sleep_time}s: {message}") time.sleep(sleep_time) else: message = rb_http_client.safe_exception_message( @@ -127,8 +130,8 @@ def _standardize_post(self, post_data): # Common patterns for character tags: contains parentheses (series name) or ends with specific patterns if ( ("(" in tag and ")" in tag) - or tag.endswith(r"_\(series\)") - or tag.endswith(r"_\(character\)") + or tag.endswith("_(series)") + or tag.endswith("_(character)") ): character_tags.append(tag) # Also catch some common character name patterns (this is heuristic but should catch most) diff --git a/ranboorux/boorus/simple.py b/ranboorux/boorus/simple.py index 215879b..dba20da 100644 --- a/ranboorux/boorus/simple.py +++ b/ranboorux/boorus/simple.py @@ -199,7 +199,6 @@ def __init__(self): def get_posts(self, tags_query="", max_pages=10, post_id=None): import scripts.ranbooru as _r - from scripts.ranbooru import POST_AMOUNT _r.COUNT = 0 all_fetched_posts = [] @@ -207,7 +206,7 @@ def get_posts(self, tags_query="", max_pages=10, post_id=None): print("[R] Warn: AIBooru does not support post IDs.") return [] page = random.randint(1, max_pages) - query_url = f"{self.base_api_url}?limit={POST_AMOUNT}&page={page}{tags_query}" + query_url = f"{self.base_api_url}&page={page}{tags_query}" fetched_data = self._fetch_data(query_url) if isinstance(fetched_data, list): all_fetched_posts = fetched_data @@ -236,7 +235,7 @@ def get_posts(self, tags_query="", max_pages=10, post_id=None): print("[R] Warn: e621 does not support post IDs.") return [] page = random.randint(1, max_pages) - query_url = f"{self.base_api_url}?page={page}{tags_query}" + query_url = f"{self.base_api_url}&page={page}{tags_query}" fetched_data = self._fetch_data(query_url) if ( isinstance(fetched_data, dict) From abba00ca8e155d912310d1d13deed501265bffe4 Mon Sep 17 00:00:00 2001 From: soficis <107279009+soficis@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:25:57 -0500 Subject: [PATCH 05/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tools/inspect_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/inspect_ui.py b/tools/inspect_ui.py index 9289d24..cd59b96 100644 --- a/tools/inspect_ui.py +++ b/tools/inspect_ui.py @@ -158,7 +158,7 @@ def __exit__(self, exc_type, exc, tb): os.makedirs(os.path.dirname(output_path), exist_ok=True) # We map components back to their indices and variable names -# The return statement from scripts/ranbooru.py has 62 items: +# The return statement from scripts/ranbooru.py has 64 items: variable_names = [ "enabled", "tags", From d27bb48dca9363135b53d6ace49b3089eff957c2 Mon Sep 17 00:00:00 2001 From: soficis <107279009+soficis@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:26:07 -0500 Subject: [PATCH 06/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tools/inspect_ui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/inspect_ui.py b/tools/inspect_ui.py index cd59b96..2c82de4 100644 --- a/tools/inspect_ui.py +++ b/tools/inspect_ui.py @@ -216,12 +216,13 @@ def __exit__(self, exc_type, exc, tb): "remove_headwear_tags", "remove_girl_suffix_tags", "preserve_hair_eye_colors", - "remove_series_tags", "use_tag_catalog", "catalog_path", "lora_auto_detect_pony", "lora_detected_loras", "lora_blacklist", + "anima_auto_detect", + "anima_tune_img2img", ] with open(output_path, "w", encoding="utf-8") as f: From 95710ff981fcfef728d32d3df90254567d044647 Mon Sep 17 00:00:00 2001 From: soficis <107279009+soficis@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:26:21 -0500 Subject: [PATCH 07/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tools/build_release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/build_release.py b/tools/build_release.py index c00b981..5845c82 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -15,7 +15,7 @@ "pyproject.toml", "README.md", "requirements.txt", - "adetailer/**/*", + # "adetailer/**/*", # local nested extension dir (ignored by .gitignore); do not package "data/**/*", "docs/CHANGELOG.md", "docs/CONFIG.md", From 30318eb3a1e9c4f1c3ceb3c4c881297df83460fd Mon Sep 17 00:00:00 2001 From: soficis <107279009+soficis@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:26:33 -0500 Subject: [PATCH 08/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tools/build_release.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/build_release.py b/tools/build_release.py index 5845c82..b4f3661 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -68,9 +68,7 @@ ".txt", ".yml", } -LOCAL_FILE_URI_RE = re.compile(rb"file:" + rb"///", re.IGNORECASE) -WINDOWS_ABSOLUTE_PATH_RE = re.compile(rb"\b[A-Za-z]:\\[^\\\r\n\t ]+\\[^\\\r\n\t ]+") - +WINDOWS_ABSOLUTE_PATH_RE = re.compile(rb"\b[A-Za-z]:(?:\\[^\\\r\n\t]+)+") def matches_any(path, patterns): path_norm = path.replace("\\", "/") From cf84f3c7f99535580ca6175324ff6a2fe5cd7e94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:30:01 +0000 Subject: [PATCH 09/10] Apply remaining changes --- tools/build_release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/build_release.py b/tools/build_release.py index b4f3661..5f475f4 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -68,6 +68,7 @@ ".txt", ".yml", } +LOCAL_FILE_URI_RE = re.compile(rb"\bfile:///") WINDOWS_ABSOLUTE_PATH_RE = re.compile(rb"\b[A-Za-z]:(?:\\[^\\\r\n\t]+)+") def matches_any(path, patterns): From 40318717e12978541a5353b762334a67d1d396ce Mon Sep 17 00:00:00 2001 From: soficis Date: Thu, 30 Jul 2026 09:34:37 -0500 Subject: [PATCH 10/10] style(ci): format tools/build_release.py with black to pass GitHub Actions CI --- tools/build_release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/build_release.py b/tools/build_release.py index 5f475f4..6f8e7b9 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -71,6 +71,7 @@ LOCAL_FILE_URI_RE = re.compile(rb"\bfile:///") WINDOWS_ABSOLUTE_PATH_RE = re.compile(rb"\b[A-Za-z]:(?:\\[^\\\r\n\t]+)+") + def matches_any(path, patterns): path_norm = path.replace("\\", "/") for pattern in patterns: