Add booru tag prompter node
This commit is contained in:
@@ -14,6 +14,9 @@ tests/
|
||||
|
||||
# Local test outputs, logs, extracted frames and temporary videos
|
||||
/test/
|
||||
/.wildcard_settings.json
|
||||
/favorites/
|
||||
/.favorite_settings.json
|
||||
|
||||
# Local agent instructions
|
||||
/AGENTS.md
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"wiki_db_path": ""}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
This is a custom node that collects the tools I use frequently.
|
||||
|
||||
|
||||
https://github.com/toyxyz/ComfyUI_toyxyz_test_nodes/assets/8006000/8536e96a-514a-48b2-b1aa-8eccbd3fa853
|
||||
|
||||
(This video is at 4x speed)
|
||||
@@ -233,7 +232,8 @@ frames of `video_1` with the opening frames of `video_2`. During the overlap, vi
|
||||
<img width="1995" height="1461" alt="image" src="https://github.com/user-attachments/assets/48f93f03-23c9-4371-9d16-aefce5c35c08" />
|
||||
|
||||
|
||||
Turns requests into English scene or image-editing prompts using local Qwen. Optionally connect reference images and an `image prompter preset` node.
|
||||
Turns requests into Anima tag-and-caption prompts, English scene prompts, or image-editing instructions using local Qwen. Optionally connect reference images and an `image prompter preset` node.
|
||||
|
||||
|
||||
1. Enter your request in `prompt`.
|
||||
2. Optionally connect `image_1` and/or `preset`. Connecting an image reveals the next input, up to `image_10`.
|
||||
@@ -243,10 +243,22 @@ Turns requests into English scene or image-editing prompts using local Qwen. Opt
|
||||
|
||||
- `llm_model`: shows the supported Qwen model and installation status.
|
||||
- `seed`: controls prompt variation, not the image generator's seed. Use `fixed` for repeatable tests.
|
||||
- `prompt_type`: `default` uses medium-first descriptions, explicit spatial relationships, and user-information preservation (formerly `normal_2`). Older `normal` and `normal_2` selections migrate to `default`. Enhance strength is a separate setting.
|
||||
- `prompt_type`: `anima` writes a tag-forward hybrid prompt: validated Danbooru tags followed by concise English sentences covering every user-specified placement, pose, limb position, gaze, expression, action, and object location. Complex inputs use as many brief sentences as their explicit spatial facts require. It accepts tags alone, natural language alone, or both. `default` uses medium-first prose descriptions, explicit spatial relationships, and user-information preservation (formerly `normal_2`). Older `normal` and `normal_2` selections migrate to `default`. Enhance strength is a separate setting.
|
||||
- `qwen_image_2.1`: writes editing instructions, identifying what to change and preserve. Uses the existing Qwen writer, not a separate official Prompt Enhancer model. Without images, it rewrites the editing request from text only.
|
||||
- `enhance`: `none` translates and organizes; `normal` adds detail; `strong` develops open details more richly. Every level preserves explicit user information rather than summarizing it. No target word count is imposed; check generated text for model omissions.
|
||||
|
||||
In `anima`, recognized input tags are mapped to the bundled 2026-09-24 Danbooru vocabulary. Generated candidates are looked up by exact name, bundled alias, a few explicit semantic equivalents, then conservative spelling similarity and component lookup. Ambiguous or unsupported candidates are dropped rather than mapped to unrelated tags. `rapidfuzz`, when available, enables the spelling step; exact, alias, and component lookup work without it. Unknown user-authored tag tokens are retained. General tags use lowercase and spaces, score tags keep underscores, and recognized user-authored artist tags receive one leading `@`. No quality, safety, score, artist, or style tag is added by default. The short scene line describes only spatial relations and actions; appearance, clothing, light effects, style, and quality belong in tags. The dictionary snapshot and attribution are in `nodes/data/README.md`.
|
||||
|
||||
For Anima gaze and perspective, `looking at camera` maps to `looking at viewer`, while `facing camera` maps to `facing viewer` without asserting eye contact. Viewing position uses tags such as `from behind` and `from below`; scene prose uses `viewer` or `viewpoint` for these relations. A physical camera explicitly held or placed in the scene remains a camera object and can produce a camera tag.
|
||||
|
||||
In initial image prompter generation, explicit numeric ComfyUI weights such as `(from front:4.92)` retain their exact text and number. This applies to Anima and the prose prompt types even when Qwen omits or changes a weighted term. Prompt edits can intentionally change or remove weights, so the original weights are not restored after an Edit Prompt action.
|
||||
|
||||
For `anima`, `enhance` primarily controls tag expansion. `none` maps supplied facts; with tags alone, it returns only those recognized/custom tags and does not invent a scene line. `normal` asks for about 6–10 compatible optional tag candidates, and `strong` asks for about 16–24 plus a second pass for about 8–12 more when the user establishes a setting. Without an authored setting, the strong second pass adds only compatible lighting effects; clothing or sunlight does not establish a beach, sky, or other location. These are candidate targets, not forced output counts: dictionary validation and source fidelity can reduce the final count. For person appearance, clothing, and gaze extracted from natural-language input, Qwen must quote the exact source phrase; unsupported details are removed. The second `strong` pass cannot add subject appearance or clothing details and also checks the scene sentences against the user's pose and spatial instructions. Explicit user facts and limits remain the priority. Appearance leakage in the scene is repaired or removed locally while valid pose sentences are retained.
|
||||
|
||||
If the Anima writer returns empty or unusable output, or reaches its output token limit twice, generation logs a warning and emits the user's recognized/custom tags plus any authored natural-language text. This fallback preserves the source but may leave its language untranslated and cannot provide the requested enhancement. The other prompt types retain their existing error handling.
|
||||
|
||||
Anima's main writer, bounded retry, tag enrichment, and scene repair each allow up to 8,192 output tokens. The local Qwen server still has a 16,384-token context shared by input and output; a response can end sooner when that context is exhausted.
|
||||
|
||||
Qwen analyzes connected references together before writing the prompt. Specify source roles with `<image1>` through `<image10>`, for example: “Use <image1> as the canvas; replace only its bag with the bag from <image2>.” Only the first batch frame per socket is used. Existing `image` connections migrate to `image_1`.
|
||||
|
||||
Use the numbered `image_1`–`image_10` outputs to pass the original images to the downstream editor in matching reference order. These outputs preserve the original tensors, resolution and full batches, not the resized first-frame analysis copies; an unconnected input returns no image. The `prompt` output remains first. Disconnecting an interior input does not renumber later references: avoid gaps when using a downstream editor that numbers references consecutively. “Only change…” and “keep… unchanged” take priority over presets and enhancement.
|
||||
@@ -256,7 +268,7 @@ Use the numbered `image_1`–`image_10` outputs to pass the original images to t
|
||||
The generated prompt appears at the bottom of the node.
|
||||
|
||||
- **Edit Prompt**: enter an instruction and click **OK** to revise the current output with Qwen.
|
||||
- Editing first interprets the requested change, then revises the complete prompt while preserving unrelated details. An unchanged response is reported in the dialog instead of being applied as a successful edit.
|
||||
- For `default` and `qwen_image_2.1`, editing first interprets the requested change, then revises the complete prompt. For `anima`, editing revises the complete tag list and short scene line while preserving unrelated custom tags. An unchanged response is reported in the dialog instead of being applied as a successful edit.
|
||||
- **Undo**: restore the prompt before the last edit.
|
||||
- **Regenerate from inputs**: clear the edited output, then run the workflow to generate from the inputs again.
|
||||
|
||||
@@ -264,6 +276,35 @@ Changing `prompt_type` clears the edited output, displayed prompt and Undo state
|
||||
|
||||
**Setup:** uses the shared H3 Qwen/llama.cpp runtime. Existing weights are reused; missing model weights download on first use (about 16.8 GB for the language model, plus vision weights when needed).
|
||||
|
||||
### Booru tag prompter
|
||||
|
||||
Enter tags in `tags` and connect the `tags` output to a text encoder. Autocomplete uses the bundled Danbooru tag list: type a fragment, then use ↑/↓ and Enter or Tab, or click a result. Selecting `shiroko_(blue_archive)`, for example, inserts `shiroko \(blue archive\)`. Manually typed text is not rewritten.
|
||||
|
||||
| Menu / input | Function |
|
||||
| --- | --- |
|
||||
| **W** (Wildcards) | Lists files in `wildcards/`. Click a file to insert `__name__`; each execution replaces it with one random non-empty line from that file. **Folder** opens the folder and **↻** refreshes the list. |
|
||||
| **F** (Favorites) | **Save** stores the entire current prompt. Click an entry to insert it at the cursor; right-click to edit or delete it. **↻** refreshes the list. |
|
||||
| **Wiki** | Opens a movable offline browser. Browse categories, search, follow wiki links, and use **Insert** on a tag entry to add it at the cursor. Drag the title bar to move the window or its bottom-right corner to resize it. |
|
||||
| `camera` | Optionally connect `booru tag camera`; its selected camera guidance is appended after your text. |
|
||||
|
||||
The node does not use Qwen or invent additional tags. Wildcard file contents and manually entered prompt text are preserved as written.
|
||||
|
||||
### Booru tag camera
|
||||
|
||||
Connect its `camera` output to `booru tag prompter.camera`.
|
||||
|
||||
| Menu | Function |
|
||||
| --- | --- |
|
||||
| **3D preview** | Shows an illustrative camera position. Drag a colored ring to change the horizontal or vertical view; release to snap to a preset. Scroll to change framing. |
|
||||
| **Vertical view** | Selects a view from above or below. |
|
||||
| **Horizontal view** | Selects front, back, side, left/right side, or a front/rear 45° view. |
|
||||
| **Framing** | Sets subject coverage, from close-up to very wide shot. |
|
||||
| **Angle** | Rotates the image view (Dutch, sideways, or upside-down). |
|
||||
| **Perspective / Depth** | Adds one perspective or projection tag, such as fisheye or isometric. |
|
||||
| **Focus / Blur** | Combines toggleable effects such as depth of field, bokeh, lens flare, and motion blur. **Random** samples a combination; **Strength** applies to the whole group. |
|
||||
|
||||
Each main list also has **Random**, which selects a non-None option on each run. Weights range from 0–10 (default 2.0): drag the number to adjust it or click to type; the reset icon restores 2.0. The preview is a guide, not a guarantee of the generated angle. Camera options do not choose a pose, outfit, background, or number of people.
|
||||
|
||||
### image prompter preset
|
||||
|
||||
Supplies optional shot, angle, and style guidance to `image prompter`. Connect its `preset` output to the prompter's `preset` input. Use `preset_prompt` to inspect the preset text.
|
||||
@@ -342,6 +383,25 @@ Create a mask for regional prompting. Use Ctrl + click to select an area, and Al
|
||||
|
||||
|
||||
|
||||
## Booru tag prompter wildcards
|
||||
|
||||
Add UTF-8 text files to this custom node's `wildcards/` folder. Each non-empty
|
||||
line is one random option; it can contain multiple words, sentences, tags or weights.
|
||||
Use `__cloth__` for `cloth.txt` or `__outfits/cloth__` for a subfolder file.
|
||||
`(__cloth__:1.5)` preserves the weight around the chosen line. Nested calls are
|
||||
supported with cycle/depth protection. Missing or unreadable calls remain literal.
|
||||
Wildcards are expanded before camera composition without reformatting their text;
|
||||
authored tag order is retained. Every occurrence samples independently on each execution
|
||||
(consecutive runs may coincidentally select the same line).
|
||||
|
||||
Type `__` in the prompt field to search the wildcard files inline. Use Up/Down,
|
||||
Enter, Tab, Escape, or click just like tag suggestions. The right-side Wildcards
|
||||
tab also lists files in a scrollable panel. Click a file to insert its call at
|
||||
the cursor or replace selected text. Refresh updates the list
|
||||
after filesystem changes. Calls are highlighted in the editor; expanded output
|
||||
does not overwrite the original editable input. The screenshot's brace/multi-select
|
||||
syntax is not part of this file-based wildcard implementation.
|
||||
|
||||
## Load Random Text From File
|
||||
|
||||
Retrieves the entire text or random lines from a txt file at the entered path.
|
||||
|
||||
@@ -8,6 +8,8 @@ from .nodes.toyxyz_test_nodes import CaptureWebcam, LoadWebcamImage, LoadImageFr
|
||||
from .nodes.visual_area_mask import VisualAreaMask
|
||||
from .nodes.minimax_h3_prompter import MinimaxH3Prompter
|
||||
from .nodes.image_prompter import ImagePrompter
|
||||
from .nodes.booru_tag_prompter import BooruTagPrompter
|
||||
from .nodes.booru_tag_presets import BooruTagPresets
|
||||
from .nodes.image_camera_presets import ImageCameraPresets
|
||||
from .nodes.minimax_h3_frames import MiniMaxH3AddGuideFrames
|
||||
from .nodes.minimax_h3_camera import MinimaxH3Camera
|
||||
@@ -31,6 +33,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
"VisualAreaMask": VisualAreaMask,
|
||||
"MinimaxH3Prompter": MinimaxH3Prompter,
|
||||
"ToyxyzImagePrompter": ImagePrompter,
|
||||
"ToyxyzBooruTagPrompter": BooruTagPrompter,
|
||||
"ToyxyzBooruTagPresets": BooruTagPresets,
|
||||
"ToyxyzImageCameraPresets": ImageCameraPresets,
|
||||
"MinimaxH3Camera": MinimaxH3Camera,
|
||||
"MiniMaxH3AddGuideFrames": MiniMaxH3AddGuideFrames,
|
||||
@@ -70,6 +74,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"VisualAreaMask": "Visual Area Mask",
|
||||
"MinimaxH3Prompter": "Minimax-H3-prompter",
|
||||
"ToyxyzImagePrompter": "image prompter",
|
||||
"ToyxyzBooruTagPrompter": "booru tag prompter",
|
||||
"ToyxyzBooruTagPresets": "booru tag camera",
|
||||
"ToyxyzImageCameraPresets": "image prompter preset",
|
||||
"MinimaxH3Camera": "minimax h3 camera",
|
||||
"MiniMaxH3AddGuideFrames": "Add Guide for MiniMax H3 frames",
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""Pinned Danbooru vocabulary and tag-forward Anima prompt formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from rapidfuzz import fuzz, process
|
||||
except ImportError: # Exact names, aliases, and component lookup still work.
|
||||
fuzz = process = None
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
DATA_PATH = Path(__file__).with_name("data") / "danbooru-2026-09-24.csv"
|
||||
DATA_REVISION = "79e7d75fcef571b7c9049db4659d1cc9e3970ce9"
|
||||
DATA_SHA256 = "1f64a73ac7e11b12d78d89eb5b9fc73525ee4347a182733f1db01b9cc5c85dcd"
|
||||
|
||||
SEMANTIC_ALIASES = {
|
||||
"1woman": "1girl",
|
||||
"1man": "1boy",
|
||||
"adult_woman": "1girl",
|
||||
"young_woman": "1girl",
|
||||
"woman": "1girl",
|
||||
"adult_man": "1boy",
|
||||
"young_man": "1boy",
|
||||
"man": "1boy",
|
||||
"slim": "skinny",
|
||||
"glossy_skin": "shiny_skin",
|
||||
"oiled_skin": "shiny_skin",
|
||||
"train_platform": "train_station_platform",
|
||||
"railway_station": "train_station",
|
||||
"backlight": "backlighting",
|
||||
"rim_light": "backlighting",
|
||||
"rim_lighting": "backlighting",
|
||||
"shoreline": "shore",
|
||||
"water_droplets": "water_drop",
|
||||
"signage": "sign",
|
||||
"bob": "bob_cut",
|
||||
"half_open_eyes": "half-closed_eyes",
|
||||
"looking_at_camera": "looking_at_viewer",
|
||||
"facing_camera": "facing_viewer",
|
||||
}
|
||||
QUALITY = {"masterpiece", "best quality", "good quality", "normal quality",
|
||||
"low quality", "worst quality"}
|
||||
RATING = {"safe", "sensitive", "nsfw", "explicit"}
|
||||
DEFAULT_STYLE = {"depth of field", "bokeh", "photorealistic", "realistic",
|
||||
"cinematic", "high contrast", "soft focus",
|
||||
"shallow depth of field", "blurry background"}
|
||||
SCORE = re.compile(r"score_[1-9]$")
|
||||
YEAR = re.compile(r"year[ _]\d{4}$")
|
||||
SCENE_FORBIDDEN = re.compile(
|
||||
r"\b(?:beautiful|gorgeous|stunning|photorealistic|realistic|cinematic|"
|
||||
r"masterpiece|quality|aesthetic|vibrant|highly detailed|illustration|"
|
||||
r"anime style|blonde|brunette|hair|skin|wearing|dressed|"
|
||||
r"shirt|skirt|dress|jacket|coat|bikini|jeans|boots|"
|
||||
r"illuminating|glistening|glowing|gleaming|"
|
||||
r"rainy|sunny|overcast|sandy)\b", re.I)
|
||||
SCENE_EYE_COLOR = re.compile(
|
||||
r"\b(?:blue|green|brown|black|red|purple|golden|grey|gray)"
|
||||
r"(?:\s+eyes|-eyed)\b", re.I)
|
||||
CAMERA_PROP_TAG = re.compile(r"(?:^|_)camera(?:_|$)")
|
||||
PHYSICAL_CAMERA_SOURCE = re.compile(
|
||||
r"\b(?:holding|holds?|carrying|carries|using|uses|placing|places|"
|
||||
r"wearing|wears|visible|physical|film|digital|video|security|"
|
||||
r"on (?:a|the) (?:table|desk|shelf|tripod))\s+(?:a |the )?camera\b|"
|
||||
r"\b(?:a|the) camera\s+(?:lies|rests|sits|stands|hangs|is on|is held)\b|"
|
||||
r"\uce74\uba54\ub77c\ub97c\s*[^.!?]{0,24}?(?:\ub4e4|\uc7a1|\uc0ac\uc6a9|\ub193|\uc124\uce58)|"
|
||||
r"\uce74\uba54\ub77c\uac00\s*(?:\ub193|\uc788|\ubcf4|\uc7a5\ucc29)", re.I)
|
||||
SOURCE_BOUND_TAGS = {
|
||||
"day": re.compile(r"\b(?:day|daytime|morning|afternoon|noon|sunny)\b|\ub0ae|\uc544\uce68|\uc624\ud6c4|\uc815\uc624", re.I),
|
||||
"sunlight": re.compile(r"\b(?:sun|sunlight|sunshine|sunny|sunbeam|sunrise)\b|\ud587\ube5b|\ud587\uc0b4|\ud0dc\uc591", re.I),
|
||||
"sun": re.compile(r"\b(?:sun|sunlight|sunshine|sunny|sunbeam|sunrise)\b|\ud587\ube5b|\ud587\uc0b4|\ud0dc\uc591", re.I),
|
||||
"sunbeam": re.compile(r"\b(?:sun|sunlight|sunshine|sunny|sunbeam|sunrise)\b|\ud587\ube5b|\ud587\uc0b4|\ud0dc\uc591", re.I),
|
||||
"sunset": re.compile(r"\b(?:sunset|dusk)\b|\uc77c\ubab0|\ub099\uc591|\uc800\ub141", re.I),
|
||||
"night": re.compile(r"\b(?:night|midnight)\b|\ubc24|\uc57c\uac04", re.I),
|
||||
"steam": re.compile(r"\b(?:steam|steaming)\b|\uc218\uc99d\uae30|\uc99d\uae30", re.I),
|
||||
}
|
||||
SOLAR_TAG = re.compile(r"\b(?:sun|sunlight|sunshine|sunbeam|sunlit|sunrise|sunset|sunny)\b", re.I)
|
||||
OPEN_AIR_SOURCE = re.compile(
|
||||
r"\b(?:outdoors?|outside|open air|beach|shore|coast|ocean|sea|"
|
||||
r"park|field|forest|street|sidewalk|road|rooftop)\b|"
|
||||
r"\uc57c\uc678|\uc2e4\uc678|\ud574\ubcc0|\ubc14\ub2f7\uac00|\uacf5\uc6d0|\uc232|\uac70\ub9ac", re.I)
|
||||
SKY_SOURCE = re.compile(r"\bsky\b|\ud558\ub298", re.I)
|
||||
BLUE_SKY_SOURCE = re.compile(r"\bblue sky\b|\ud478\ub978 \ud558\ub298|\ud30c\ub780 \ud558\ub298", re.I)
|
||||
CLEAR_SKY_SOURCE = re.compile(r"\bclear sky\b|\ub9d1\uc740 \ud558\ub298|\uad6c\ub984 \uc5c6\ub294 \ud558\ub298", re.I)
|
||||
GLARE_SOURCE = re.compile(r"\b(?:glaring|glared|glares)\s+at\b|\ub178\ub824\ubcf4|\uc9f8\ub824\ubcf4", re.I)
|
||||
COASTAL_SOURCE = re.compile(
|
||||
r"\b(?:beach|shore|coast|ocean|sea|sand|waves?|seaside|seashore)\b|"
|
||||
r"\ud574\ubcc0|\ubc14\ub2f7\uac00|\ubc14\ub2e4|\ubaa8\ub798|\ud30c\ub3c4|\ud574\uc548", re.I)
|
||||
COASTAL_TAGS = {"beach", "ocean", "sea", "shore", "sand", "waves", "wave",
|
||||
"sea spray", "seaweed", "seashore", "coast"}
|
||||
HORIZON_SOURCE = re.compile(r"\bhorizon\b|\uc218\ud3c9\uc120|\uc9c0\ud3c9\uc120", re.I)
|
||||
FOAM_SOURCE = re.compile(r"\bfoam\b|\uac70\ud488", re.I)
|
||||
CLOUD_SOURCE = re.compile(r"\bclouds?\b|\uad6c\ub984", re.I)
|
||||
SUN_OBJECT_SOURCE = re.compile(r"\b(?:the\s+sun|sun)\b(?!light)|\ud0dc\uc591", re.I)
|
||||
SETTING_SOURCE = re.compile(
|
||||
r"\b(?:indoors?|outdoors?|outside|beach|shore|coast|ocean|sea|"
|
||||
r"park|field|forest|street|sidewalk|road|rooftop|room|bedroom|"
|
||||
r"kitchen|library|station|platform|cafe|café|restaurant|garden|"
|
||||
r"office|classroom|school|hotel|train|bus|ship|airplane|city|"
|
||||
r"village|mountain|desert|studio)\b|"
|
||||
r"\ud574\ubcc0|\ubc14\ub2e4|\uacf5\uc6d0|\uc232|\uac70\ub9ac|\ub3c4\uc11c\uad00|"
|
||||
r"\uae30\ucc28\uc5ed|\uc2b9\uac15\uc7a5|\ubc29\uc548|\uce74\ud398|\uc2e4\ub0b4|\uc57c\uc678", re.I)
|
||||
|
||||
|
||||
def has_explicit_setting(source: str) -> bool:
|
||||
"""Only an authored place opens the strong environment-expansion pass."""
|
||||
return bool(SETTING_SOURCE.search(source))
|
||||
WATER_SOURCE = re.compile(r"\b(?:water|rain|river|lake|pool|ocean|sea|beach)\b|"
|
||||
r"\ubb3c|\ube44\uac00|\ube44 \uc624|\ube57\ubb3c|\ube57\ubc29\uc6b8|"
|
||||
r"\uac15\ubb3c|\uac15\uac00|\uac15\ubcc0|\ud638\uc218|\uc218\uc601\uc7a5|\ubc14\ub2e4|\ud574\ubcc0", re.I)
|
||||
WIND_SOURCE = re.compile(r"\b(?:wind|breeze|gust|storm)\b|\ubc14\ub78c|\ub3cc\ud48d", re.I)
|
||||
LIGHT_SMILE_SOURCE = re.compile(
|
||||
r"\b(?:slight|subtle|faint|small|gentle)\s+smile\b|"
|
||||
r"\uc0b4\uc9dd\s*\uc6c3|\uc870\uae08\s*\uc6c3|\uc5f7\uc740\s*\ubbf8\uc18c|"
|
||||
r"\ud76c\ubbf8\ud55c\s*\ubbf8\uc18c", re.I)
|
||||
EXTRA_SUBJECT_DETAIL = re.compile(
|
||||
r"\b(?:skin|hair|eyes?|face|bob|breasts?|chest|shirt|skirt|dress|pants|"
|
||||
r"jeans|shoes|boots|sleeves?|collar|sweater|pantyhose|coat|jacket|"
|
||||
r"bikini|swimsuit|socks?|gloves?|hat|eyewear)\b", re.I)
|
||||
GAZE_DETAIL = {"looking at viewer", "facing viewer", "looking away",
|
||||
"looking back", "looking up", "looking down"}
|
||||
COLOR_OR_NEGATION = {"black", "white", "red", "blue", "green", "yellow",
|
||||
"brown", "blonde", "pink", "purple", "orange", "grey",
|
||||
"gray", "no", "without", "not"}
|
||||
SUBJECT_COMPONENTS = {"hair", "skin", "eye", "eyes", "face", "breast",
|
||||
"breasts", "shirt", "skirt", "dress", "pants", "jeans",
|
||||
"shoe", "shoes", "boot", "boots"}
|
||||
|
||||
|
||||
def filter_enrichment_tags(tags: list[str]) -> list[str]:
|
||||
"""Keep the second creative pass from changing established subject traits."""
|
||||
return [tag for tag in tags if not EXTRA_SUBJECT_DETAIL.search(tag.replace("_", " "))]
|
||||
|
||||
|
||||
UNLOCATED_LIGHT_TAGS = {"shadow", "shadows", "light rays", "lens flare",
|
||||
"rim lighting", "backlighting", "silhouette", "sunbeam"}
|
||||
|
||||
|
||||
def filter_unlocated_lighting_tags(tags: list[str]) -> list[str]:
|
||||
"""Without an authored place, permit only compatible lighting additions."""
|
||||
return [tag for tag in tags if tag.strip().lower().replace("_", " ")
|
||||
in UNLOCATED_LIGHT_TAGS]
|
||||
|
||||
|
||||
def normalize(value: str) -> str:
|
||||
return re.sub(r"\s+", "_", value.strip().lower().replace("\\(", "(").replace("\\)", ")"))
|
||||
|
||||
|
||||
class TagDB:
|
||||
def __init__(self, path: Path = DATA_PATH):
|
||||
self.canonical: dict[str, int] = {}
|
||||
self.counts: dict[str, int] = {}
|
||||
self.aliases: dict[str, str] = {}
|
||||
pending = []
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
for row in csv.reader(handle):
|
||||
if len(row) != 4 or not row[0] or not row[1].isdigit():
|
||||
raise ValueError(f"Invalid Danbooru dictionary row: {row[:2]}")
|
||||
key = normalize(row[0])
|
||||
self.canonical[key] = int(row[1])
|
||||
self.counts[key] = int(row[2])
|
||||
pending.extend((normalize(alias), key) for alias in row[3].split(",") if alias.strip())
|
||||
for alias, key in pending:
|
||||
if alias not in self.canonical:
|
||||
self.aliases.setdefault(alias, key)
|
||||
punctuation_candidates: dict[str, str | None] = {}
|
||||
for key in self.canonical:
|
||||
flattened = key.replace("-", "_")
|
||||
if flattened != key:
|
||||
if flattened in punctuation_candidates and punctuation_candidates[flattened] != key:
|
||||
punctuation_candidates[flattened] = None
|
||||
else:
|
||||
punctuation_candidates[flattened] = key
|
||||
self.punctuation_aliases = {flat: key for flat, key in punctuation_candidates.items()
|
||||
if key and flat not in self.canonical and flat not in self.aliases}
|
||||
self.fuzzy_names = tuple(key.replace("_", " ") for key, category in self.canonical.items()
|
||||
if category == 0 and self.counts[key] >= 20)
|
||||
|
||||
def resolve(self, value: str) -> tuple[str, int] | None:
|
||||
key = normalize(value)
|
||||
key = SEMANTIC_ALIASES.get(key, key)
|
||||
canonical = (key if key in self.canonical else
|
||||
self.aliases.get(key) or self.punctuation_aliases.get(key))
|
||||
return (canonical, self.canonical[canonical]) if canonical else None
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def search_generated(self, value: str) -> tuple[str, int] | None:
|
||||
"""Resolve an LLM candidate without guessing across unrelated concepts."""
|
||||
exact = self.resolve(value)
|
||||
if exact:
|
||||
return exact
|
||||
key = normalize(value)
|
||||
if key.startswith("hold_"):
|
||||
held = self.resolve("holding_" + key[5:])
|
||||
if held:
|
||||
return held
|
||||
if process is not None and len(key) >= 5:
|
||||
matches = process.extract(key.replace("_", " "), self.fuzzy_names,
|
||||
scorer=fuzz.ratio, limit=2, score_cutoff=90)
|
||||
if matches:
|
||||
best_name, best_score, _ = matches[0]
|
||||
second_score = matches[1][1] if len(matches) > 1 else 0
|
||||
candidate = normalize(best_name)
|
||||
shared = set(key.split("_")) & set(candidate.split("_"))
|
||||
if (best_score >= 92 and best_score - second_score >= 4
|
||||
and (shared or ("_" not in key and "_" not in candidate
|
||||
and key[0] == candidate[0]))):
|
||||
return candidate, self.canonical[candidate]
|
||||
parts = key.split("_")
|
||||
if (2 <= len(parts) <= 3 and parts[0] not in COLOR_OR_NEGATION
|
||||
and not {"to", "on", "in", "at", "of", "with", "from",
|
||||
"under", "above", "behind", "between"}.intersection(parts)
|
||||
and not SUBJECT_COMPONENTS.intersection(parts)):
|
||||
head = parts[-1]
|
||||
if self.canonical.get(head) == 0 and self.counts[head] >= 1000:
|
||||
return head, 0
|
||||
return None
|
||||
|
||||
|
||||
_DB: TagDB | None = None
|
||||
|
||||
|
||||
def get_db() -> TagDB:
|
||||
global _DB
|
||||
if _DB is None:
|
||||
_DB = TagDB()
|
||||
return _DB
|
||||
|
||||
|
||||
def read_response_data(response: str) -> dict:
|
||||
text = response.strip()
|
||||
fence = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", text, flags=re.I | re.S)
|
||||
if fence:
|
||||
text = fence[1]
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("Image prompter: Anima writer returned invalid JSON.") from exc
|
||||
if not isinstance(data, dict) or not isinstance(data.get("tags"), list) or not isinstance(data.get("scene"), str):
|
||||
raise RuntimeError("Image prompter: Anima writer must return tags and scene.")
|
||||
if any(not isinstance(tag, str) for tag in data["tags"]):
|
||||
raise RuntimeError("Image prompter: Anima writer returned invalid tag candidates.")
|
||||
evidence = data.get("source_tag_evidence", [])
|
||||
data["source_tag_evidence"] = [item for item in evidence
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("tag"), str)
|
||||
and isinstance(item.get("evidence"), str)] if isinstance(evidence, list) else []
|
||||
data["scene"] = " ".join(data["scene"].split())
|
||||
return data
|
||||
|
||||
|
||||
def read_response(response: str) -> tuple[list[str], str]:
|
||||
data = read_response_data(response)
|
||||
return data["tags"], data["scene"]
|
||||
|
||||
|
||||
def _supported_subject_detail(tag: str, evidence: dict[str, str], source: str) -> bool:
|
||||
key = normalize(tag)
|
||||
if key in {"bob", "bob_cut"}:
|
||||
return bool(re.search(r"\bbob\s+(?:cut|haircut)\b|\ub2e8\ubc1c", source, re.I))
|
||||
if key == "short_hair" and re.search(
|
||||
r"\bshort\s+hair\b|\uc9e7\uc740.{0,12}(?:\uba38\ub9ac|\ubaa8\ubc1c)|\ub2e8\ubc1c", source, re.I):
|
||||
return True
|
||||
proof = evidence.get(normalize(tag), "").strip()
|
||||
if (not proof or (len(proof) < 2 and proof != "\uae34")
|
||||
or proof.casefold() not in source.casefold()):
|
||||
return False
|
||||
start = source.casefold().find(proof.casefold())
|
||||
local = source[max(0, start - 10):start + len(proof) + 10]
|
||||
# The cited words must name a relevant trait, garment, or gaze, not just
|
||||
# a generic person noun from the same sentence.
|
||||
if re.search(r"\bhair\b", tag, re.I):
|
||||
if not re.search(r"hair|\uba38\ub9ac|\ubaa8\ubc1c|\ud5e4\uc5b4|\uae08\ubc1c|\ud751\ubc1c", local, re.I):
|
||||
return False
|
||||
key = normalize(tag)
|
||||
if key.startswith("long_") and not re.search(r"\blong\b|\uae34|\uae38", local, re.I):
|
||||
return False
|
||||
if key.startswith("short_") and not re.search(r"\bshort\b|\uc9e7", local, re.I):
|
||||
return False
|
||||
if key.startswith("blonde_") and not re.search(r"blond|\uae08\ubc1c|\uae08\uc0c9", local, re.I):
|
||||
return False
|
||||
if key.startswith("black_") and not re.search(r"\bblack\b|\ud751\ubc1c|\uac80\uc740|\uac80\uc815", local, re.I):
|
||||
return False
|
||||
return True
|
||||
if normalize(tag).replace("_", " ") in GAZE_DETAIL:
|
||||
return bool(re.search(r"look|gaze|view|fac|\ubc14\ub77c|\uc751\uc2dc|\uc2dc\uc120|\uc815\uba74|\uce74\uba54\ub77c", proof, re.I))
|
||||
if re.search(r"\beyes?\b", tag, re.I):
|
||||
if not re.search(r"eye|\ub208", local, re.I):
|
||||
return False
|
||||
key = normalize(tag)
|
||||
if "closed" in key and not re.search(r"clos|shut|\uac10|\ubc18\ucbe4", local, re.I):
|
||||
return False
|
||||
if "open" in key and not re.search(r"open|\ub728|\ub728\uace0|\ubc18\ucbe4", local, re.I):
|
||||
return False
|
||||
return True
|
||||
return bool(re.search(
|
||||
r"skin|eye|face|breast|chest|shirt|skirt|dress|pants|jeans|shoe|boot|"
|
||||
r"sleeve|collar|sweater|pantyhose|coat|jacket|bikini|swimsuit|sock|glove|hat|"
|
||||
r"\ud53c\ubd80|\ub208|\uc5bc\uad74|\uac00\uc2b4|\uc154\uce20|\ud2f0\uc154\uce20|\ube14\ub77c\uc6b0\uc2a4|\uce58\ub9c8|"
|
||||
r"\uc2a4\ucee4\ud2b8|\ub4dc\ub808\uc2a4|\uc6d0\ud53c\uc2a4|\ubc14\uc9c0|\uccad\ubc14\uc9c0|\uc2e0\ubc1c|"
|
||||
r"\ubd80\uce20|\uc7a5\ud654|\uc2a4\uc6e8\ud130|\ub2c8\ud2b8|\uc2a4\ud0c0\ud0b9|\ucf54\ud2b8|\uc790\ucf13|"
|
||||
r"\uc7ac\ud0b7|\ube44\ud0a4\ub2c8|\uc218\uc601\ubcf5|\uc591\ub9d0|\uc7a5\uac11|\ubaa8\uc790", proof, re.I))
|
||||
|
||||
|
||||
def _tag_entry(tag: str, db: TagDB, explicit: bool) -> str | None:
|
||||
tag = tag.strip()
|
||||
if not tag:
|
||||
return None
|
||||
# Escaped literal parentheses are already valid ComfyUI prompt syntax.
|
||||
# Dictionary lookup may normalize them for identity, but must not remove
|
||||
# the user's escaping from the emitted tag.
|
||||
if explicit and (r"\(" in tag or r"\)" in tag):
|
||||
return tag
|
||||
if tag.startswith("@"):
|
||||
resolved = db.resolve(tag[1:])
|
||||
if resolved and resolved[1] == 1:
|
||||
return "@" + resolved[0].replace("_", " ")
|
||||
return tag if explicit else None
|
||||
resolved = db.resolve(tag) if explicit else db.search_generated(tag)
|
||||
if resolved:
|
||||
if not explicit and (resolved[1] != 0 or
|
||||
resolved[0].replace("_", " ") in QUALITY | RATING | DEFAULT_STYLE):
|
||||
return None
|
||||
if resolved[1] == 1:
|
||||
return "@" + resolved[0].replace("_", " ")
|
||||
return resolved[0].replace("_", " ")
|
||||
lowered = tag.lower().strip()
|
||||
if lowered in QUALITY | RATING | DEFAULT_STYLE or SCORE.fullmatch(lowered) or YEAR.fullmatch(lowered):
|
||||
if not explicit:
|
||||
return None
|
||||
return lowered.replace("_", " ") if YEAR.fullmatch(lowered) else lowered
|
||||
return tag if explicit else None
|
||||
|
||||
|
||||
def _recover_explicit_detail(tag: str, evidence: dict[str, str], source: str) -> str | None:
|
||||
"""Retain a short, directly cited source detail when vocabulary lookup fails."""
|
||||
proof = evidence.get(normalize(tag), "").strip()
|
||||
if not proof or len(proof) < 2 or proof.casefold() not in source.casefold():
|
||||
return None
|
||||
phrase = tag.replace("_", " ").strip()
|
||||
if (not 1 <= len(phrase.split()) <= 5 or len(phrase) > 72
|
||||
or not re.fullmatch(r"[\w\s'-]+", phrase, re.UNICODE)
|
||||
or normalize(phrase).replace("_", " ") in QUALITY | RATING | DEFAULT_STYLE
|
||||
or SCORE.fullmatch(normalize(phrase)) or YEAR.fullmatch(normalize(phrase))):
|
||||
return None
|
||||
# For English evidence, require meaningful lexical agreement as well as
|
||||
# an exact source citation. A cited generic noun must not license a new prop.
|
||||
if re.search(r"[a-z]", proof, re.I):
|
||||
words = set(re.findall(r"[a-z]{3,}", phrase.casefold()))
|
||||
cited = set(re.findall(r"[a-z]{3,}", proof.casefold()))
|
||||
if not words or not words.intersection(cited):
|
||||
return None
|
||||
return phrase
|
||||
|
||||
|
||||
def scene_needs_repair(scene: str) -> bool:
|
||||
"""Flag appearance/style drift or runaway prose without dropping source facts."""
|
||||
if not scene:
|
||||
return False
|
||||
return bool(SCENE_FORBIDDEN.search(scene) or SCENE_EYE_COLOR.search(scene)
|
||||
or len(scene.split()) > 180
|
||||
or len(re.findall(r"[.!?](?:\s|$)", scene)) > 12)
|
||||
|
||||
|
||||
def sanitize_scene(scene: str) -> str:
|
||||
"""Remove simple appearance leakage while preserving pose sentences."""
|
||||
scene = re.sub(
|
||||
r"\b(?:blue|green|brown|black|red|purple|golden|grey|gray)\s+(?=eyes\b)",
|
||||
"", scene, flags=re.I)
|
||||
scene = re.sub(
|
||||
r"\b(?:blue|green|brown|black|red|purple|golden|grey|gray)-eyed\b",
|
||||
"", scene, flags=re.I)
|
||||
scene = re.sub(
|
||||
r",?\s*(?:illuminating|highlighting|lighting)\s+(?:her|his|their)\s+"
|
||||
r"(?:\w+\s+)?(?:shirt|skirt|dress|jacket|coat|bikini|jeans|boots)\b",
|
||||
"", scene, flags=re.I)
|
||||
return " ".join(scene.split())
|
||||
|
||||
|
||||
def normalize_viewpoint_scene(scene: str, source_text: str) -> str:
|
||||
"""Keep physical cameras, but name the observer in gaze/viewpoint prose."""
|
||||
if PHYSICAL_CAMERA_SOURCE.search(source_text):
|
||||
return scene
|
||||
scene = re.sub(r"\b(look(?:s|ing|ed)?(?:\s+directly)?\s+(?:at|into|toward|towards|away from)\s+)"
|
||||
r"(?:the\s+)?camera\b", r"\1the viewer", scene, flags=re.I)
|
||||
scene = re.sub(r"\b(back\s+to\s+)(?:the\s+)?camera\b",
|
||||
r"\1the viewer", scene, flags=re.I)
|
||||
scene = re.sub(r"\b((?:facing|faces|face)\s+)(?:the\s+)?camera\b",
|
||||
r"\1the viewer", scene, flags=re.I)
|
||||
scene = re.sub(r"\b(?:the\s+)?camera\s+(?:is\s+)?(?:positioned\s+|placed\s+)?"
|
||||
r"(?=(?:behind|above|below|in front of|to the (?:left|right) of)\b)",
|
||||
"the viewpoint is ", scene, flags=re.I)
|
||||
return scene
|
||||
|
||||
|
||||
def format_response(input_tags: str, response: str, *, edited: bool = False,
|
||||
source_text: str = "", enhance: str = "normal",
|
||||
natural_language: str = "", trace: list[dict] | None = None) -> str:
|
||||
"""Keep explicit tag meanings, validate generated tags, and retain scene facts."""
|
||||
db = get_db()
|
||||
data = read_response_data(response)
|
||||
candidates = data["tags"]
|
||||
scene = normalize_viewpoint_scene(sanitize_scene(data["scene"]), source_text)
|
||||
source_evidence = {}
|
||||
for item in data.get("source_tag_evidence", []):
|
||||
if isinstance(item, dict) and isinstance(item.get("tag"), str) and isinstance(item.get("evidence"), str):
|
||||
source_evidence[normalize(item["tag"])] = item["evidence"]
|
||||
explicit = [] if edited else [tag.strip() for tag in input_tags.split(",") if tag.strip()]
|
||||
old_custom = ({normalize(tag) for tag in input_tags.split(",")
|
||||
if tag.strip() and not db.resolve(tag)} if edited else set())
|
||||
ordered = []
|
||||
seen = set()
|
||||
dropped = []
|
||||
recovered = []
|
||||
tags_only_none = not edited and enhance == "none" and not natural_language
|
||||
generated = [] if tags_only_none else candidates
|
||||
|
||||
# An LLM may describe a spatial relation as one candidate although the
|
||||
# vocabulary stores the action and object separately. Split only patterns
|
||||
# with unambiguous atomic meanings; leave the relation in scene prose.
|
||||
expanded_generated = []
|
||||
for candidate in generated:
|
||||
key = normalize(candidate)
|
||||
if key.startswith("sitting_on_"):
|
||||
expanded_generated.extend(["sitting", key[len("sitting_on_"):]])
|
||||
elif key.endswith("_on_ground"):
|
||||
expanded_generated.append(key[:-len("_on_ground")])
|
||||
elif re.fullmatch(r"(?:sitting|standing|kneeling|lying|walking|running)_"
|
||||
r"(?:girl|boy|woman|man|person)", key):
|
||||
expanded_generated.append(key.split("_", 1)[0])
|
||||
else:
|
||||
expanded_generated.append(candidate)
|
||||
generated = expanded_generated
|
||||
specific_smile = any((db.resolve(tag) or (None,))[0] == "light_smile"
|
||||
for tag in generated)
|
||||
for tag, authored in ([(tag, True) for tag in explicit]
|
||||
+ [(tag, edited and normalize(tag) in old_custom) for tag in generated]):
|
||||
original_tag = tag
|
||||
def record(status: str, reason: str, output: str = "") -> None:
|
||||
if trace is not None:
|
||||
trace.append({"source": "input" if authored else "writer",
|
||||
"candidate": original_tag, "output": output,
|
||||
"status": status, "reason": reason})
|
||||
if not authored and normalize(tag) == "smile" and LIGHT_SMILE_SOURCE.search(natural_language):
|
||||
tag = "light smile"
|
||||
if not authored and normalize(tag) == "smile" and specific_smile:
|
||||
record("rejected", "more specific smile candidate")
|
||||
continue
|
||||
if (not authored and not edited
|
||||
and (EXTRA_SUBJECT_DETAIL.search(tag.replace("_", " "))
|
||||
or normalize(tag).replace("_", " ") in GAZE_DETAIL)
|
||||
and not _supported_subject_detail(tag, source_evidence, natural_language)):
|
||||
record("rejected", "unsupported person detail")
|
||||
continue
|
||||
if not authored and normalize(tag) == "platform" and re.search(r"\btrain\b", scene, re.I):
|
||||
tag = "train station platform"
|
||||
if not authored and normalize(tag) in {"tracks", "train_tracks"} and re.search(
|
||||
r"\btrain\b|\uAE30\uCC28", source_text + " " + scene, re.I):
|
||||
tag = "railroad tracks"
|
||||
entry = _tag_entry(tag, db, authored)
|
||||
if entry is None:
|
||||
detail = (None if authored or edited else
|
||||
_recover_explicit_detail(tag, source_evidence, natural_language))
|
||||
if detail and detail.casefold() not in scene.casefold() and normalize(detail) not in seen:
|
||||
recovered.append(detail)
|
||||
record("recovered", "dictionary miss with exact source evidence", detail)
|
||||
else:
|
||||
if tag.strip():
|
||||
dropped.append(tag.strip())
|
||||
record("rejected", "dictionary miss or unsupported generated tag")
|
||||
continue
|
||||
identity = entry.casefold()
|
||||
dedupe_key = normalize(entry)
|
||||
if not authored:
|
||||
if (CAMERA_PROP_TAG.search(normalize(entry))
|
||||
and not PHYSICAL_CAMERA_SOURCE.search(source_text)):
|
||||
record("rejected", "camera object not in source")
|
||||
continue
|
||||
if identity == "glaring" and not GLARE_SOURCE.search(source_text):
|
||||
record("rejected", "expression not in source")
|
||||
continue
|
||||
if identity == "sky" and not (
|
||||
SKY_SOURCE.search(source_text) or OPEN_AIR_SOURCE.search(source_text)):
|
||||
record("rejected", "sky not supported by source")
|
||||
continue
|
||||
if identity == "outdoors" and not OPEN_AIR_SOURCE.search(source_text):
|
||||
record("rejected", "outdoor setting not in source")
|
||||
continue
|
||||
if identity == "blue sky" and not BLUE_SKY_SOURCE.search(source_text):
|
||||
record("rejected", "sky color not in source")
|
||||
continue
|
||||
if identity == "clear sky" and not CLEAR_SKY_SOURCE.search(source_text):
|
||||
record("rejected", "clear sky not in source")
|
||||
continue
|
||||
if identity in COASTAL_TAGS and not COASTAL_SOURCE.search(source_text):
|
||||
record("rejected", "coastal setting not in source")
|
||||
continue
|
||||
if identity in {"cloud", "clouds", "cloudy sky"} and not CLOUD_SOURCE.search(source_text):
|
||||
record("rejected", "cloud not in source")
|
||||
continue
|
||||
if identity == "sun" and not SUN_OBJECT_SOURCE.search(source_text):
|
||||
record("rejected", "visible sun not in source")
|
||||
continue
|
||||
if identity == "horizon" and not (
|
||||
HORIZON_SOURCE.search(source_text) or COASTAL_SOURCE.search(source_text)
|
||||
or OPEN_AIR_SOURCE.search(source_text)):
|
||||
record("rejected", "horizon not supported by source")
|
||||
continue
|
||||
if identity == "foam" and not (
|
||||
FOAM_SOURCE.search(source_text) or COASTAL_SOURCE.search(source_text)):
|
||||
record("rejected", "foam not supported by source")
|
||||
continue
|
||||
if identity == "water" and not WATER_SOURCE.search(source_text):
|
||||
record("rejected", "water not in source")
|
||||
continue
|
||||
if identity == "wind" and not (
|
||||
WIND_SOURCE.search(source_text) or OPEN_AIR_SOURCE.search(source_text)):
|
||||
record("rejected", "wind not supported by source")
|
||||
continue
|
||||
cue = SOURCE_BOUND_TAGS.get(identity)
|
||||
if not authored and cue and not cue.search(source_text):
|
||||
record("rejected", "source-bound detail not in source")
|
||||
continue
|
||||
if not authored and cue is None and SOLAR_TAG.search(identity) and not SOURCE_BOUND_TAGS["sun"].search(source_text):
|
||||
record("rejected", "solar detail not in source")
|
||||
continue
|
||||
if dedupe_key not in seen:
|
||||
ordered.append(entry)
|
||||
seen.add(dedupe_key)
|
||||
record("accepted", "authored" if authored else "dictionary match", entry)
|
||||
else:
|
||||
record("duplicate", "already emitted", entry)
|
||||
if dropped:
|
||||
LOG.warning("Image prompter Anima: %d candidates absent from dictionary or not auto-added: %s",
|
||||
len(dropped), ", ".join(dropped[:8]))
|
||||
if tags_only_none:
|
||||
scene = ""
|
||||
elif scene_needs_repair(scene):
|
||||
clean_sentences = []
|
||||
word_count = 0
|
||||
for part in re.split(r"(?<=[.!?])\s+", scene):
|
||||
part = part.strip()
|
||||
if (not part or scene_needs_repair(part)
|
||||
or word_count + len(part.split()) > 180
|
||||
or len(clean_sentences) >= 12):
|
||||
continue
|
||||
clean_sentences.append(part)
|
||||
word_count += len(part.split())
|
||||
scene = " ".join(clean_sentences)
|
||||
LOG.warning("Image prompter Anima: invalid scene clauses omitted; remaining pose text retained.")
|
||||
if recovered:
|
||||
unique_details = list(dict.fromkeys(recovered))[:5]
|
||||
scene = " ".join(part for part in
|
||||
(scene, "Specified details: " + ", ".join(unique_details) + ".") if part)
|
||||
if not ordered and not scene:
|
||||
raise RuntimeError("Image prompter: Anima writer produced no usable tags or scene.")
|
||||
return "\n".join(part for part in (", ".join(ordered), scene) if part)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Separate a user's Danbooru tag prefix from natural-language input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .image_prompt_weights import WEIGHTED_TAG, is_weighted_tag
|
||||
|
||||
|
||||
_KOREAN = re.compile(r"[\uac00-\ud7a3]")
|
||||
_SENTENCE_START = re.compile(
|
||||
r"^(?:a|an|the|she|he|they|we|i|this|that|there|her|his|their|"
|
||||
r"in|on|at|with|under|behind|before|after)\b", re.I)
|
||||
_SENTENCE_VERB = re.compile(
|
||||
r"\b(?:is|are|was|were|stands?|sits?|walks?|wears?|holds?|looks?|"
|
||||
r"has|have|shows?|appears?|rests?|runs?|lies?|fills?|falls?|glows?)\b", re.I)
|
||||
_TAG_THEN_SENTENCE = re.compile(r"\.\s+(?=(?:A|An|The|She|He|They|In|On|At|With)\b)")
|
||||
|
||||
|
||||
def _prose_start(part: str) -> int | None:
|
||||
"""Find a sentence start in one comma-delimited input component."""
|
||||
leading = len(part) - len(part.lstrip())
|
||||
content = part[leading:]
|
||||
if not content:
|
||||
return None
|
||||
if is_weighted_tag(content):
|
||||
return None
|
||||
line_break = re.search(
|
||||
r"\r?\n(?=\s*(?:(?:A|An|The|She|He|They|In|On|At|With)\b|[\uac00-\ud7a3]))",
|
||||
content)
|
||||
if line_break:
|
||||
return leading + line_break.end()
|
||||
if _KOREAN.search(content):
|
||||
return leading
|
||||
boundary = _TAG_THEN_SENTENCE.search(content)
|
||||
if boundary:
|
||||
return leading + boundary.end()
|
||||
if _SENTENCE_START.search(content) and _SENTENCE_VERB.search(content):
|
||||
return leading
|
||||
if len(content.split()) >= 6 and re.search(r"[.!?]", content):
|
||||
return leading
|
||||
return None
|
||||
|
||||
|
||||
def _separate_weighted_tail(tags: str, prose: str) -> tuple[str, str]:
|
||||
"""Keep standalone authored weights after prose in the source tag order.
|
||||
|
||||
They are explicit tags, not generated camera guidance. Leaving them in
|
||||
prose lets the writer reorder them and the weight fallback prepend them.
|
||||
Do not extract a weight embedded in a sentence or quoted visible text.
|
||||
"""
|
||||
def weights_only(remainder: str) -> bool:
|
||||
remainder = remainder.lstrip(" ,\t\r\n")
|
||||
while remainder:
|
||||
next_weight = WEIGHTED_TAG.match(remainder)
|
||||
if not next_weight:
|
||||
return False
|
||||
remainder = remainder[next_weight.end():].lstrip(" ,\t\r\n")
|
||||
return True
|
||||
|
||||
matches = []
|
||||
for match in WEIGHTED_TAG.finditer(prose):
|
||||
before, after = prose[:match.start()], prose[match.end():]
|
||||
preceding_weight = bool(matches and
|
||||
not prose[matches[-1].end():match.start()].strip(" ,\t\r\n"))
|
||||
standalone_start = (not before.strip() or
|
||||
before.rstrip().endswith((",", ".", "!", "?")) or
|
||||
before.endswith(("\n", "\r")) or preceding_weight)
|
||||
standalone_end = weights_only(after)
|
||||
if standalone_start and standalone_end:
|
||||
matches.append(match)
|
||||
if not matches:
|
||||
return tags, prose
|
||||
ordered = [tags.rstrip(" ,\r\n")] if tags.strip(" ,\r\n") else []
|
||||
ordered.extend(match.group() for match in matches)
|
||||
for match in reversed(matches):
|
||||
end = match.end()
|
||||
delimiter = re.match(r"[ \t]*,", prose[end:])
|
||||
if delimiter:
|
||||
end += delimiter.end()
|
||||
prose = prose[:match.start()] + prose[end:]
|
||||
return ", ".join(ordered), prose.strip(" ,\r\n")
|
||||
|
||||
|
||||
def split_anima_input(source: str) -> tuple[str, str]:
|
||||
"""Return authored tags in source order and the natural-language remainder."""
|
||||
if not isinstance(source, str) or not source.strip():
|
||||
raise ValueError("Image prompter: enter an Anima prompt before running the queue.")
|
||||
segment_start = 0
|
||||
for comma in re.finditer(",", source):
|
||||
part = source[segment_start:comma.start()]
|
||||
offset = _prose_start(part)
|
||||
if offset is not None:
|
||||
return _separate_weighted_tail(
|
||||
source[:segment_start + offset], source[segment_start + offset:].strip())
|
||||
segment_start = comma.end()
|
||||
part = source[segment_start:]
|
||||
offset = _prose_start(part)
|
||||
if offset is not None:
|
||||
return _separate_weighted_tail(
|
||||
source[:segment_start + offset], source[segment_start + offset:].strip())
|
||||
return source, ""
|
||||
@@ -0,0 +1,172 @@
|
||||
"""JSON-backed saved prompts for the Booru tag prompter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path(__file__).resolve().parents[1] / "favorites"
|
||||
CONFIG_PATH = Path(__file__).resolve().parents[1] / ".favorite_settings.json"
|
||||
FAVORITES_PATH = DEFAULT_ROOT / "prompts.json"
|
||||
MAX_PROMPT_LENGTH = 100_000
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class FavoriteConflictError(ValueError):
|
||||
"""The selected favorite no longer matches the current file contents."""
|
||||
|
||||
|
||||
def _validate_prompt(prompt: str) -> None:
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
raise ValueError("Enter a prompt before saving it as a favorite.")
|
||||
if len(prompt) > MAX_PROMPT_LENGTH:
|
||||
raise ValueError("The prompt is too long to save as a favorite.")
|
||||
|
||||
|
||||
def _favorite_from_line(line: str) -> str | None:
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return value if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def _empty_store() -> dict:
|
||||
return {"version": 1, "favorites": [], "unparsed_legacy_lines": []}
|
||||
|
||||
|
||||
def _write_store_unlocked(path: Path, store: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", newline="\n", dir=path.parent,
|
||||
prefix=".prompts-", suffix=".tmp", delete=False,
|
||||
) as output:
|
||||
temporary = Path(output.name)
|
||||
json.dump(store, output, ensure_ascii=False, indent=2)
|
||||
output.write("\n")
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
temporary.replace(path)
|
||||
finally:
|
||||
if temporary is not None and temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
|
||||
def _read_store_unlocked(path: Path) -> dict:
|
||||
if path.exists():
|
||||
try:
|
||||
store = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
raise OSError("The favorites JSON file could not be read. It was not changed.") from exc
|
||||
if (not isinstance(store, dict) or store.get("version") != 1
|
||||
or not isinstance(store.get("favorites"), list)
|
||||
or not isinstance(store.get("unparsed_legacy_lines", []), list)
|
||||
or any(not isinstance(value, str) or not value.strip()
|
||||
for value in store["favorites"])
|
||||
or any(not isinstance(value, str)
|
||||
for value in store.get("unparsed_legacy_lines", []))):
|
||||
raise OSError("The favorites JSON file has an invalid structure. It was not changed.")
|
||||
return store
|
||||
|
||||
legacy = path.with_suffix(".txt")
|
||||
if not legacy.exists():
|
||||
return _empty_store()
|
||||
try:
|
||||
lines = legacy.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise OSError("The legacy favorites file could not be read. It was not changed.") from exc
|
||||
store = _empty_store()
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
value = _favorite_from_line(line)
|
||||
if value is None:
|
||||
store["unparsed_legacy_lines"].append(line)
|
||||
else:
|
||||
store["favorites"].append(value)
|
||||
_write_store_unlocked(path, store)
|
||||
return store
|
||||
|
||||
|
||||
def active_favorite_root() -> Path:
|
||||
try:
|
||||
settings = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return DEFAULT_ROOT.resolve()
|
||||
except (OSError, UnicodeError, ValueError, TypeError) as exc:
|
||||
raise OSError("Favorite folder settings could not be read.") from exc
|
||||
if not isinstance(settings, dict):
|
||||
raise OSError("Favorite folder settings are invalid.")
|
||||
configured = settings.get("favorite_path", "")
|
||||
if configured:
|
||||
if not isinstance(configured, str):
|
||||
raise OSError("Favorite folder settings are invalid.")
|
||||
path = Path(configured)
|
||||
if not path.is_absolute() or not path.is_dir():
|
||||
raise OSError("Configured favorite folder is unavailable.")
|
||||
return path.resolve()
|
||||
return DEFAULT_ROOT.resolve()
|
||||
|
||||
|
||||
def set_favorite_path(value: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Favorite path must be text.")
|
||||
value = value.strip()
|
||||
if value:
|
||||
path = Path(value)
|
||||
if not path.is_absolute() or not path.is_dir():
|
||||
raise ValueError("Enter an existing absolute folder path, or leave it blank for the built-in folder.")
|
||||
value = str(path.resolve())
|
||||
temporary = CONFIG_PATH.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps({"favorite_path": value}, ensure_ascii=False), encoding="utf-8")
|
||||
temporary.replace(CONFIG_PATH)
|
||||
return value
|
||||
|
||||
|
||||
def browse_favorite_folder() -> str:
|
||||
from .booru_wildcards import browse_wildcard_folder
|
||||
return browse_wildcard_folder("Select favorite folder")
|
||||
|
||||
|
||||
def list_favorites(path: Path | None = None) -> list[str]:
|
||||
"""Return saved prompts in file order, migrating a legacy text file once."""
|
||||
path = path if path is not None else active_favorite_root() / "prompts.json"
|
||||
with _LOCK:
|
||||
return list(_read_store_unlocked(path)["favorites"])
|
||||
|
||||
|
||||
def save_favorite(prompt: str, path: Path | None = None) -> list[str]:
|
||||
_validate_prompt(prompt)
|
||||
path = path if path is not None else active_favorite_root() / "prompts.json"
|
||||
with _LOCK:
|
||||
store = _read_store_unlocked(path)
|
||||
store["favorites"].append(prompt)
|
||||
_write_store_unlocked(path, store)
|
||||
return list(store["favorites"])
|
||||
|
||||
|
||||
def change_favorite(index: int, original: str, *, replacement: str | None = None,
|
||||
delete: bool = False, path: Path | None = None) -> list[str]:
|
||||
"""Replace or remove one verified entry, writing the JSON file atomically."""
|
||||
if not isinstance(index, int) or isinstance(index, bool) or index < 0 or not isinstance(original, str):
|
||||
raise ValueError("Invalid favorite selection.")
|
||||
if not delete:
|
||||
_validate_prompt(replacement)
|
||||
path = path if path is not None else active_favorite_root() / "prompts.json"
|
||||
with _LOCK:
|
||||
store = _read_store_unlocked(path)
|
||||
favorites = store["favorites"]
|
||||
if index >= len(favorites) or favorites[index] != original:
|
||||
raise FavoriteConflictError("This favorite changed. Refresh the list and try again.")
|
||||
if delete:
|
||||
del favorites[index]
|
||||
else:
|
||||
favorites[index] = replacement
|
||||
_write_store_unlocked(path, store)
|
||||
return list(favorites)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Camera tags and pose-neutral view descriptions; preserve authored text."""
|
||||
import math
|
||||
import re
|
||||
import random
|
||||
|
||||
|
||||
def strength_value(value):
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 1.0
|
||||
return max(0.0, min(10.0, value)) if math.isfinite(value) else 1.0
|
||||
|
||||
CAMERA_ANGLES = {
|
||||
"None": (),
|
||||
"Dutch angle": ("dutch_angle",),
|
||||
"Sideways": ("sideways",),
|
||||
"Upside-down": ("upside-down",),
|
||||
}
|
||||
VERTICAL_VIEWS = {
|
||||
"None": (),
|
||||
"Above": ("from_above",),
|
||||
"Below": ("from_below",),
|
||||
}
|
||||
HORIZONTAL_VIEWS = {
|
||||
"None": (),
|
||||
"Front": ("straight-on",),
|
||||
"Behind": ("from_behind",),
|
||||
"Side": ("from_side",),
|
||||
"Left side": ("from_side",),
|
||||
"Right side": ("from_side",),
|
||||
"Front-left 45°": (),
|
||||
"Front-right 45°": (),
|
||||
"Rear-left 45°": ("from_behind",),
|
||||
"Rear-right 45°": ("from_behind",),
|
||||
}
|
||||
LEGACY_VERTICAL_VIEWS = {"Above 45°": "Above", "Below 45°": "Below",
|
||||
"Bird's-eye view": "Above", "Worm's-eye view": "Below"}
|
||||
PERSPECTIVE_DEPTH = {
|
||||
"None": (), "Perspective": ("perspective",), "Fisheye": ("fisheye",),
|
||||
"Atmospheric perspective": ("atmospheric_perspective",),
|
||||
"Vanishing point": ("vanishing_point",), "Panorama": ("panorama",),
|
||||
"Foreshortening": ("foreshortening",), "Isometric": ("isometric",),
|
||||
}
|
||||
FOCUS_BLUR = {key: {"None": (), "Enabled": (key,)} for key in
|
||||
("depth_of_field", "blurry_background", "blurry_foreground", "bokeh", "soft_focus",
|
||||
"chromatic_aberration", "lens_flare", "motion_blur")}
|
||||
SUBJECT_FACING = {
|
||||
"None": "",
|
||||
"Screen left": "The person faces left. Their face and torso are oriented toward the left edge of the image.",
|
||||
"Screen right": "The person faces right. Their face and torso are oriented toward the right edge of the image.",
|
||||
}
|
||||
|
||||
# Describe only the viewing relationship, never a subject action, pose, gaze,
|
||||
# wardrobe, environment or anatomical target inferred from camera position.
|
||||
VIEW_DESCRIPTIONS = {
|
||||
"vertical_view": {
|
||||
"Above": "High-angle view, looking downward at the subject.",
|
||||
"Below": "Low-angle view, looking upward at the subject.",
|
||||
},
|
||||
"horizontal_view": {
|
||||
"Front": "Frontal view of the subject.",
|
||||
"Behind": "Rear view of the subject.",
|
||||
"Side": "Side-on view of the subject.",
|
||||
"Left side": "The subject is seen from its own left side, with its left-side surfaces nearer.",
|
||||
"Right side": "The subject is seen from its own right side, with its right-side surfaces nearer.",
|
||||
# One orientation clause, not repeated face/body/detail views. Long
|
||||
# weighted multi-part descriptions produced duplicate portraits/insets.
|
||||
# Avoid the noun "screen": under strong weighting the model can draw
|
||||
# a physical screen/panel beside the subject, even without any prop.
|
||||
**{f"Front-{side} 45°": f"Front three-quarter view of the subject facing diagonally toward the {side}." for side in ("left", "right")},
|
||||
**{f"Rear-{side} 45°": f"Rear three-quarter view of the subject facing diagonally away toward the {side}." for side in ("left", "right")},
|
||||
},
|
||||
"zoom": {
|
||||
# Framing is tag-only: high-weight space/frame prose generated white
|
||||
# margins, insets or multiple panels. Never add subject-count rules.
|
||||
"Very wide shot": "",
|
||||
"Wide shot": "",
|
||||
"Full body": "",
|
||||
"Cowboy shot": "",
|
||||
"Upper body": "",
|
||||
"Portrait": "",
|
||||
"Close-up": "",
|
||||
"Lower body": "",
|
||||
},
|
||||
"camera_angle": {
|
||||
"Dutch angle": "The image plane is tilted diagonally, with the whole scene rotated together.",
|
||||
"Sideways": "The image plane is rotated by a quarter turn, with the whole scene rotated together.",
|
||||
"Upside-down": "The image plane is rotated by a half turn, with the whole scene rotated together.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def effective_settings(settings):
|
||||
"""Translate old continuous panel values into discrete descriptive selections."""
|
||||
settings = dict(settings)
|
||||
vertical = settings.get("vertical_view")
|
||||
if isinstance(vertical, str):
|
||||
settings["vertical_view"] = LEGACY_VERTICAL_VIEWS.get(vertical, vertical)
|
||||
if not settings.get("panel_enabled"):
|
||||
return settings
|
||||
resolved = dict(settings)
|
||||
x, y, z, roll = (axis_value(settings.get(k, 0)) for k in ("pos_x", "pos_y", "pos_z", "roll"))
|
||||
resolved.update(panel_enabled=False,
|
||||
horizontal_view="Behind" if abs(x) > .75 else "Left side" if x > .25 else "Right side" if x < -.25 else "Front",
|
||||
vertical_view="Above" if y > .2 else "Below" if y < -.2 else "None",
|
||||
zoom="Close-up" if z < -.6 else "Portrait" if z < -.2 else "Upper body" if z <= .2 else "Cowboy shot" if z <= .4 else "Full body" if z <= .7 else "Wide shot",
|
||||
camera_angle="Dutch angle" if abs(roll) >= .15 else "None")
|
||||
return resolved
|
||||
ZOOMS = {
|
||||
"None": (),
|
||||
"Very wide shot": ("very_wide_shot",),
|
||||
"Wide shot": ("wide_shot",),
|
||||
"Full body": ("full_body",),
|
||||
"Cowboy shot": ("cowboy_shot",),
|
||||
"Upper body": ("upper_body",),
|
||||
"Portrait": ("portrait",),
|
||||
"Close-up": ("close-up",),
|
||||
"Lower body": ("lower_body",),
|
||||
}
|
||||
|
||||
|
||||
def axis_value(value):
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 0.0
|
||||
return max(-1.0, min(1.0, value)) if math.isfinite(value) else 0.0
|
||||
|
||||
|
||||
def preset_tags(preset):
|
||||
"""Rebuild from allowlisted options; supplied payload prose is not trusted."""
|
||||
if not isinstance(preset, dict) or preset.get("kind") != "booru_tag_preset":
|
||||
return ""
|
||||
settings = preset.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
return ""
|
||||
if not settings.get("camera_enabled", True):
|
||||
return ""
|
||||
settings = effective_settings(settings)
|
||||
tags = []
|
||||
for key, choices in (("camera_angle", CAMERA_ANGLES),
|
||||
("vertical_view", VERTICAL_VIEWS),
|
||||
("horizontal_view", HORIZONTAL_VIEWS), ("zoom", ZOOMS),
|
||||
("perspective_depth", PERSPECTIVE_DEPTH), *FOCUS_BLUR.items()):
|
||||
value = settings.get(key)
|
||||
if isinstance(value, str):
|
||||
strength = strength_value(settings.get(key + "_strength", 1.0))
|
||||
selected = choices.get(value, ())
|
||||
tags.extend(selected if strength == 1.0 else
|
||||
(f"({tag}:{strength:.2f})" for tag in selected))
|
||||
return ", ".join(tags)
|
||||
|
||||
|
||||
def preset_prompt(preset):
|
||||
"""Allowlisted tags plus concise view prose, independent of scene content."""
|
||||
tags = preset_tags(preset)
|
||||
if not isinstance(preset, dict) or preset.get("kind") != "booru_tag_preset" or not isinstance(preset.get("settings"), dict):
|
||||
return ""
|
||||
if not preset["settings"].get("camera_enabled", True):
|
||||
return ""
|
||||
settings = effective_settings(preset["settings"])
|
||||
clauses = []
|
||||
for key in ("horizontal_view", "vertical_view", "zoom", "camera_angle"):
|
||||
strength = strength_value(settings.get(key + "_strength", 1))
|
||||
if strength <= 0:
|
||||
continue # Zero weight must not reintroduce the instruction in prose.
|
||||
value = settings.get(key)
|
||||
# Anatomical left/right captions previously defeated screen-facing cues.
|
||||
# Keep the side camera class, but let explicitly selected screen facing
|
||||
# specify the visible orientation, not a competing anatomical cue.
|
||||
direction = ""
|
||||
if key == "horizontal_view" and value in ("Left side", "Right side"):
|
||||
facing = "Screen left" if value == "Left side" else "Screen right"
|
||||
direction = SUBJECT_FACING[facing] if not settings.get("suppress_subject_facing") else ""
|
||||
value = "Side"
|
||||
clause = VIEW_DESCRIPTIONS[key].get(value) if isinstance(value, str) else None
|
||||
if key == "horizontal_view" and isinstance(value,str) and "45°" in value and settings.get("suppress_subject_facing"):
|
||||
clause = ("Front" if value.startswith("Front") else "Rear") + " three-quarter view of the subject."
|
||||
if clause:
|
||||
clause += (" " + direction) if direction else ""
|
||||
clauses.append(clause if strength == 1 else f"({clause}:{strength:.2f})")
|
||||
if not clauses:
|
||||
return tags
|
||||
return (tags + "\n" if tags else "") + " ".join(clauses)
|
||||
|
||||
|
||||
def authored_camera_fields(text):
|
||||
"""Conservative explicit camera cues, never infer a view from a subject pose."""
|
||||
groups = (("camera_angle", CAMERA_ANGLES), ("vertical_view", VERTICAL_VIEWS),
|
||||
("horizontal_view", HORIZONTAL_VIEWS), ("zoom", ZOOMS),
|
||||
("perspective_depth", PERSPECTIVE_DEPTH), *FOCUS_BLUR.items())
|
||||
tokens = set()
|
||||
for token in re.split(r"[,;\n]", text):
|
||||
token = token.strip().lower()
|
||||
weighted = re.fullmatch(r"\((.*):[+-]?(?:\d+(?:\.\d*)?|\.\d+)\)", token)
|
||||
tokens.add((weighted.group(1) if weighted else token).replace("_", " "))
|
||||
fields = {key for key, choices in groups if any(
|
||||
tag.replace("_", " ") in tokens for tags in choices.values() for tag in tags)}
|
||||
if tokens & {"from left", "from right"}: fields.add("horizontal_view")
|
||||
if tokens & {"bird's eye view", "bird's-eye view", "worm's eye view", "worm's-eye view"}:
|
||||
fields.add("vertical_view")
|
||||
patterns = {
|
||||
"vertical_view": r"\b(?:high-angle (?:view|shot)|low-angle (?:view|shot)|overhead view|view from (?:above|below)|(?:camera|viewpoint|lens) [^.!?\n]{0,60}looking (?:straight |steeply )?(?:downward|upward))\b",
|
||||
"horizontal_view": r"\b(?:frontal view|rear view|side-on view|(?:front|rear) three-quarter view|view from (?:behind|the front)|seen from (?:its|her|his|their) own (?:left|right) side)\b",
|
||||
"zoom": r"\b(?:full-body shot|head-and-shoulders framing|tight close-up|wide framing|upper-body framing|lower-body framing)\b",
|
||||
"camera_angle": r"\b(?:image plane|image roll|camera roll)\b",
|
||||
}
|
||||
for key, pattern in patterns.items():
|
||||
if re.search(pattern, text, re.IGNORECASE): fields.add(key)
|
||||
return fields
|
||||
|
||||
|
||||
def append_preset_tags(tags, preset):
|
||||
# Do not append conflicting defaults over recognized explicit authored camera
|
||||
# instructions. No source edits, scene inference or blocking validation.
|
||||
if isinstance(preset, dict) and isinstance(preset.get("settings"), dict):
|
||||
settings = dict(effective_settings(preset["settings"]))
|
||||
for field in authored_camera_fields(tags):
|
||||
settings[field] = "None"
|
||||
if re.search(r"\b(?:faces|facing)\s+(?:diagonally\s+)?(?:away\s+)?(?:(?:toward(?:s)?|to)\s+)?(?:the\s+|screen[ -]|image[ -])?(?:left|right)\b", tags.replace("_", " "), re.IGNORECASE):
|
||||
settings["suppress_subject_facing"] = True
|
||||
preset = {**preset, "settings": settings}
|
||||
suffix = preset_prompt(preset)
|
||||
if not suffix:
|
||||
return tags
|
||||
if not tags.strip():
|
||||
return tags + suffix
|
||||
# Preserve every source character, including weights and trailing whitespace.
|
||||
separator = "\n" if tags.endswith("\n") or tags.rstrip().endswith((".", "!", "?")) or not preset_tags(preset) else " " if tags.rstrip().endswith((",", ";")) else ", "
|
||||
return tags + separator + suffix
|
||||
|
||||
|
||||
class BooruTagPresets:
|
||||
RANDOM_CHOICES = {"camera_angle": CAMERA_ANGLES, "vertical_view": VERTICAL_VIEWS,
|
||||
"horizontal_view": HORIZONTAL_VIEWS, "zoom": ZOOMS,
|
||||
"perspective_depth": PERSPECTIVE_DEPTH, **FOCUS_BLUR}
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {
|
||||
"camera_angle": ([*CAMERA_ANGLES, "Random"], {"default": "None",
|
||||
"tooltip": "Image rotation, not subject leaning. None adds nothing."}),
|
||||
"vertical_view": ([*VERTICAL_VIEWS, "Random"], {"default": "None",
|
||||
"tooltip": "View from above/below. These are category tags, not physical lens-height or tilt controls."}),
|
||||
"horizontal_view": ([*HORIZONTAL_VIEWS, "Random"], {"default": "None",
|
||||
"tooltip": "Left/Right side combine a side camera view with the person's face and torso facing screen left/right. Side leaves orientation unspecified. This does not mean image placement. The preview is a camera proxy; generation accuracy depends on the model."}),
|
||||
"zoom": ([*ZOOMS, "Random"], {"default": "None",
|
||||
"tooltip": "Visible subject coverage, not a numeric lens zoom. Close-up does not specify a target body part."}),
|
||||
**{key + "_strength": ("FLOAT", {
|
||||
"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.01,
|
||||
"tooltip": "Weight from 0 to 10 for all tags and the complete natural-language instruction generated by this selection. This is emphasis, not a physical angle.",
|
||||
}) for key in ("camera_angle", "vertical_view", "horizontal_view", "zoom")},
|
||||
"panel_enabled": ("BOOLEAN", {"default": False}),
|
||||
"camera_enabled": ("BOOLEAN", {"default": True}),
|
||||
**{key: ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01})
|
||||
for key in ("pos_x", "pos_y", "pos_z", "roll")},
|
||||
}, "optional": {
|
||||
"subject_facing": (list(SUBJECT_FACING), {"default": "None",
|
||||
"tooltip": "Legacy compatibility input, hidden and ignored. Use Horizontal view Left side or Right side instead."}),
|
||||
# Append optional widgets to preserve older serialized widget order.
|
||||
"perspective_depth": ([*PERSPECTIVE_DEPTH, "Random"], {"default": "None",
|
||||
"tooltip": "Tag-only depth or projection effect. Foreshortening depicts perspective shortening. Isometric uses non-converging parallel lines, not a calibrated 45-degree camera angle. Does not set subject count, pose, scenery or output dimensions. Not simulated in the preview."}),
|
||||
"perspective_depth_strength": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.01,
|
||||
"tooltip": "Weight for the selected perspective/depth tag only. High weights may distort the image. The preview geometry is unchanged."}),
|
||||
**{name: definition for key, choices in list(FOCUS_BLUR.items())[:5] for name, definition in (
|
||||
(key, ([*choices, "Random"], {"default": "None", "tooltip": "Independent tag-only focus/blur effect. Combine effects as needed. Does not set subject count, pose or scenery. Not simulated in the 3D preview."})),
|
||||
(key + "_strength", ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.01,
|
||||
"tooltip": "Weight for this focus/blur tag only. High weights can reduce image detail."})))},
|
||||
"random_camera": ("BOOLEAN", {"default": False,
|
||||
"tooltip": "Legacy compatibility input, hidden and ignored. Use Random at the end of each selection list."}),
|
||||
"focus_blur_random": ("BOOLEAN", {"default": False,
|
||||
"tooltip": "Randomize the Focus / Blur combination on every execution. Overrides manual effect selections, preserves weights, and may select no effects."}),
|
||||
"focus_blur_strength": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.01,
|
||||
"tooltip": "Shared weight for every selected Focus / Blur tag, including random combinations."}),
|
||||
# New effects follow existing optional widgets to preserve saved values.
|
||||
**{key: ([*choices, "Random"], {"default": "None",
|
||||
"tooltip": "Tag-only optical or motion effect. Uses the shared Focus / Blur strength. Not simulated in the 3D preview."})
|
||||
for key, choices in list(FOCUS_BLUR.items())[5:]},
|
||||
}}
|
||||
|
||||
RETURN_TYPES = ("TOYXYZ_BOORU_PRESET",)
|
||||
RETURN_NAMES = ("camera",)
|
||||
FUNCTION = "build_camera"
|
||||
CATEGORY = "ToyxyzTestNodes/Prompt"
|
||||
DESCRIPTION = "Append camera tags and view prose without sorting source text. Horizontal Left/Right include face/torso screen direction. Each selection weight applies to all its tags and complete prose. No wardrobe or background presets."
|
||||
|
||||
def build_camera(self, **kwargs):
|
||||
camera = self.compose(**kwargs)[0]
|
||||
if kwargs.get("focus_blur_random") or any(kwargs.get(key) == "Random" for key in self.RANDOM_CHOICES):
|
||||
return {"ui": {"camera_settings": [camera["settings"]]}, "result": (camera,)}
|
||||
return (camera,)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, random_camera=False, camera_enabled=True, **kwargs):
|
||||
return float("nan") if camera_enabled and (kwargs.get("focus_blur_random") or any(kwargs.get(key) == "Random" for key in cls.RANDOM_CHOICES)) else False
|
||||
|
||||
def compose(self, camera_angle="None", vertical_view="None",
|
||||
horizontal_view="None", zoom="None", camera_angle_strength=2.0,
|
||||
vertical_view_strength=2.0, horizontal_view_strength=2.0, zoom_strength=2.0,
|
||||
panel_enabled=False, camera_enabled=True, pos_x=0.0, pos_y=0.0, pos_z=0.0, roll=0.0,
|
||||
subject_facing="None", perspective_depth="None", perspective_depth_strength=2.0,
|
||||
random_camera=False, focus_blur_random=False, focus_blur_strength=None, **focus_options):
|
||||
if isinstance(vertical_view, str):
|
||||
vertical_view = LEGACY_VERTICAL_VIEWS.get(vertical_view, vertical_view)
|
||||
settings = {}
|
||||
for key, value, choices in (
|
||||
("camera_angle", camera_angle, CAMERA_ANGLES),
|
||||
("vertical_view", vertical_view, VERTICAL_VIEWS),
|
||||
("horizontal_view", horizontal_view, HORIZONTAL_VIEWS),
|
||||
("zoom", zoom, ZOOMS), ("perspective_depth", perspective_depth, PERSPECTIVE_DEPTH)):
|
||||
settings[key] = value if isinstance(value, str) and (value in choices or value == "Random") else "None"
|
||||
for key, value in (("camera_angle", camera_angle_strength),
|
||||
("vertical_view", vertical_view_strength),
|
||||
("horizontal_view", horizontal_view_strength), ("zoom", zoom_strength),
|
||||
("perspective_depth", perspective_depth_strength)):
|
||||
settings[key + "_strength"] = strength_value(value)
|
||||
settings.update(panel_enabled=bool(panel_enabled), camera_enabled=bool(camera_enabled),
|
||||
**{key: axis_value(value) for key, value in
|
||||
(("pos_x", pos_x), ("pos_y", pos_y), ("pos_z", pos_z), ("roll", roll))})
|
||||
settings["subject_facing"] = subject_facing if isinstance(subject_facing, str) and subject_facing in SUBJECT_FACING else "None"
|
||||
for key, choices in FOCUS_BLUR.items():
|
||||
value = focus_options.get(key, "None")
|
||||
settings[key] = value if isinstance(value, str) and (value in choices or value == "Random") else "None"
|
||||
settings[key + "_strength"] = strength_value(focus_blur_strength if focus_blur_strength is not None else focus_options.get(key + "_strength", 2.0))
|
||||
settings["focus_blur_strength"] = strength_value(focus_blur_strength if focus_blur_strength is not None else 2.0)
|
||||
if camera_enabled:
|
||||
chooser = random.SystemRandom()
|
||||
previous = getattr(self, "_last_camera_choices", {})
|
||||
for key, choices in self.RANDOM_CHOICES.items():
|
||||
if key in FOCUS_BLUR:
|
||||
continue
|
||||
if settings[key] == "Random":
|
||||
candidates = [value for value in choices if value not in ("None", "Random")]
|
||||
different = [value for value in candidates if value != previous.get(key)]
|
||||
settings[key] = chooser.choice(different or candidates)
|
||||
settings["panel_enabled"] = False
|
||||
if settings[key] != "None":
|
||||
previous[key] = settings[key]
|
||||
self._last_camera_choices = previous
|
||||
if focus_blur_random or any(settings[key] == "Random" for key in FOCUS_BLUR):
|
||||
last = getattr(self, "_last_focus_blur_mask", None)
|
||||
mask = chooser.choice([value for value in range(1 << len(FOCUS_BLUR)) if value != last])
|
||||
for index, key in enumerate(FOCUS_BLUR):
|
||||
settings[key] = "Enabled" if mask & (1 << index) else "None"
|
||||
settings["panel_enabled"] = False
|
||||
self._last_focus_blur_mask = mask
|
||||
result = {"kind": "booru_tag_preset", "version": 10, "settings": settings}
|
||||
return result, preset_tags(result), preset_prompt(result)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Danbooru tag entry with source-preserving text output and local suggestions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
|
||||
from .anima_tags import get_db, normalize
|
||||
from .booru_tag_presets import append_preset_tags
|
||||
from .booru_wildcards import TOKEN, expand_wildcards, wildcard_files, wildcard_preview, open_wildcard_folder, browse_wildcard_folder, set_wildcard_path, active_wildcard_root
|
||||
from .booru_favorites import (FavoriteConflictError, active_favorite_root,
|
||||
browse_favorite_folder, change_favorite,
|
||||
list_favorites, save_favorite, set_favorite_path)
|
||||
from .booru_wiki import search_wiki, wiki_categories, wiki_page
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CATEGORIES = {0: "general", 1: "artist", 3: "copyright", 4: "character", 5: "meta"}
|
||||
PAGE_SIZE = 50
|
||||
|
||||
|
||||
class BooruTagPrompter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {
|
||||
"tags": ("STRING", {
|
||||
"default": "", "multiline": True, "dynamicPrompts": False,
|
||||
"pysssss.autocomplete": False,
|
||||
"tooltip": "Type Danbooru tags or __wildcard__ calls from this node's wildcards folder. Each call samples one non-empty text line on every run. Choose tag suggestions with click, Tab, or Enter.",
|
||||
}),
|
||||
}, "optional": {
|
||||
"camera": ("TOYXYZ_BOORU_PRESET", {
|
||||
"tooltip": "Connect booru tag camera. Camera tags and pose-neutral view prose are appended without reordering your text. Framing uses tags only. Pose, subject count, clothing and background remain user-controlled.",
|
||||
}),
|
||||
}}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("tags",)
|
||||
FUNCTION = "emit"
|
||||
CATEGORY = "ToyxyzTestNodes/Prompt"
|
||||
DESCRIPTION = "Compose Danbooru tags with local suggestions while preserving authored text."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, tags="", **kwargs):
|
||||
return float("nan") if TOKEN.search(tags) else False
|
||||
|
||||
def emit(self, tags, camera=None, preset=None, **_legacy_inputs):
|
||||
# Accept stale saved API inputs without exposing or applying model settings.
|
||||
tags = append_preset_tags(expand_wildcards(tags), camera if camera is not None else preset)
|
||||
return (tags,)
|
||||
|
||||
|
||||
def _match_rank(name: str, query: str, alias: bool) -> int | None:
|
||||
position = name.find(query)
|
||||
if position < 0:
|
||||
return None
|
||||
if name == query:
|
||||
return 0 if not alias else 1
|
||||
if position == 0:
|
||||
return 2 if not alias else 3
|
||||
if name[position - 1] == "_":
|
||||
return 4 if not alias else 5
|
||||
return 6 if not alias else 7
|
||||
|
||||
|
||||
def search_tags(query: str, limit: int = PAGE_SIZE, db=None, offset: int = 0) -> list[dict]:
|
||||
"""Rank exact, prefix, word-boundary, and substring hits by popularity."""
|
||||
if not isinstance(query, str):
|
||||
return []
|
||||
needle = normalize(query)
|
||||
if not needle or limit <= 0 or offset < 0:
|
||||
return []
|
||||
db = db or get_db()
|
||||
candidates: dict[str, tuple[int, str | None]] = {}
|
||||
|
||||
for name in db.canonical:
|
||||
rank = _match_rank(name, needle, False)
|
||||
if rank is not None:
|
||||
candidates[name] = rank, None
|
||||
for alias, canonical in db.aliases.items():
|
||||
rank = _match_rank(alias, needle, True)
|
||||
if rank is not None and (canonical not in candidates or rank < candidates[canonical][0]):
|
||||
candidates[canonical] = rank, alias
|
||||
for alias, canonical in db.punctuation_aliases.items():
|
||||
rank = _match_rank(alias, needle, True)
|
||||
if rank is not None and (canonical not in candidates or rank < candidates[canonical][0]):
|
||||
candidates[canonical] = rank, alias
|
||||
|
||||
best = sorted(candidates.items(),
|
||||
key=lambda item: (item[1][0], -db.counts[item[0]], len(item[0]), item[0]))[
|
||||
offset:offset + limit]
|
||||
return [{"tag": tag, "count": db.counts[tag],
|
||||
"category": CATEGORIES.get(db.canonical[tag], "other"),
|
||||
**({"alias": match} if match else {})}
|
||||
for tag, (_, match) in best]
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def cached_suggestions(query: str, offset: int = 0, limit: int = PAGE_SIZE) -> list[dict]:
|
||||
return search_tags(query, limit, offset=offset)
|
||||
|
||||
|
||||
def register_routes():
|
||||
try:
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
except ImportError:
|
||||
return
|
||||
if not getattr(PromptServer, "instance", None):
|
||||
return
|
||||
|
||||
def local_request(request):
|
||||
from urllib.parse import urlsplit
|
||||
origin = request.headers.get("Origin")
|
||||
return request.remote in ("127.0.0.1", "::1") and (not origin or urlsplit(origin).netloc == request.host)
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/wiki/categories")
|
||||
async def wiki_categories_route(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Wiki categories require local access."}, status=403)
|
||||
try:
|
||||
data = await asyncio.to_thread(wiki_categories)
|
||||
except OSError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=503)
|
||||
return web.json_response(data)
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/wiki/search")
|
||||
async def search_wiki_route(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Wiki search requires local access."}, status=403)
|
||||
try:
|
||||
results = await asyncio.to_thread(search_wiki, request.query.get("q", ""),
|
||||
request.query.get("category", ""),
|
||||
int(request.query.get("page", "1")))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except OSError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=503)
|
||||
return web.json_response(results)
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/wiki/page")
|
||||
async def wiki_page_route(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Wiki pages require local access."}, status=403)
|
||||
try:
|
||||
page_id = request.query.get("id")
|
||||
page = await asyncio.to_thread(wiki_page, int(page_id) if page_id else None,
|
||||
request.query.get("title", ""))
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Invalid wiki page ID."}, status=400)
|
||||
except OSError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=503)
|
||||
return web.json_response(page if page is not None else {"error": "Wiki page not found."},
|
||||
status=200 if page is not None else 404)
|
||||
|
||||
@PromptServer.instance.routes.post("/toyxyz/booru-tags/wildcards/settings")
|
||||
async def configure_wildcards(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Wildcard settings require local access."}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
path = await asyncio.to_thread(set_wildcard_path, data.get("path"))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return web.json_response({"error": "Enter an existing absolute folder path, or leave it blank for the built-in folder."}, status=400)
|
||||
return web.json_response({"path": path})
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/wildcards/settings")
|
||||
async def current_wildcard_settings(request):
|
||||
return web.json_response({"path": str(active_wildcard_root())})
|
||||
|
||||
@PromptServer.instance.routes.post("/toyxyz/booru-tags/wildcards/browse")
|
||||
async def browse_wildcards(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Folder browsing requires local access."}, status=403)
|
||||
try:
|
||||
selected = await asyncio.to_thread(browse_wildcard_folder)
|
||||
except (OSError, AttributeError, TimeoutError):
|
||||
LOG.exception("Wildcard folder picker failed")
|
||||
return web.json_response({"error": "Unable to open the folder picker on this computer."}, status=500)
|
||||
return web.json_response({"path": selected})
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/wildcards")
|
||||
async def list_wildcards(request):
|
||||
names = await asyncio.to_thread(lambda: sorted(wildcard_files()))
|
||||
return web.json_response({"wildcards": names})
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/wildcards/preview")
|
||||
async def preview_wildcard(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Wildcard previews require local access."}, status=403)
|
||||
try:
|
||||
preview = await asyncio.to_thread(wildcard_preview, request.query.get("name"))
|
||||
except KeyError:
|
||||
return web.json_response({"error": "Wildcard file not found."}, status=404)
|
||||
except OSError:
|
||||
LOG.exception("Could not read wildcard preview")
|
||||
return web.json_response({"error": "Unable to read wildcard file."}, status=500)
|
||||
return web.json_response(preview)
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/favorites/list")
|
||||
async def get_favorites(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Favorites require local access."}, status=403)
|
||||
try:
|
||||
prompts = await asyncio.to_thread(list_favorites)
|
||||
except OSError as exc:
|
||||
LOG.exception("Could not read favorite prompts")
|
||||
return web.json_response({"error": str(exc) or "Unable to load favorites."}, status=500)
|
||||
return web.json_response({"favorites": prompts})
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/favorites/settings/current")
|
||||
async def current_favorite_settings(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Favorite settings require local access."}, status=403)
|
||||
try:
|
||||
path = await asyncio.to_thread(active_favorite_root)
|
||||
except OSError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
return web.json_response({"path": str(path)})
|
||||
|
||||
@PromptServer.instance.routes.post("/toyxyz/booru-tags/favorites/settings")
|
||||
async def configure_favorites(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Favorite settings require local access."}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
path = await asyncio.to_thread(set_favorite_path, data.get("path"))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return web.json_response({"error": "Enter an existing absolute folder path, or leave it blank for the built-in folder."}, status=400)
|
||||
except OSError:
|
||||
LOG.exception("Could not save favorite folder setting")
|
||||
return web.json_response({"error": "Unable to save favorite folder setting."}, status=500)
|
||||
return web.json_response({"path": path})
|
||||
|
||||
@PromptServer.instance.routes.post("/toyxyz/booru-tags/favorites/browse")
|
||||
async def browse_favorites(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Folder browsing requires local access."}, status=403)
|
||||
try:
|
||||
selected = await asyncio.to_thread(browse_favorite_folder)
|
||||
except (OSError, AttributeError, TimeoutError):
|
||||
LOG.exception("Favorite folder picker failed")
|
||||
return web.json_response({"error": "Unable to open the folder picker on this computer."}, status=500)
|
||||
return web.json_response({"path": selected})
|
||||
|
||||
@PromptServer.instance.routes.post("/toyxyz/booru-tags/favorites")
|
||||
async def add_favorite(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Favorites require local access."}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
prompts = await asyncio.to_thread(save_favorite, data.get("prompt"))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
return web.json_response({"error": str(exc) or "Invalid prompt."}, status=400)
|
||||
except OSError:
|
||||
LOG.exception("Could not save favorite prompt")
|
||||
return web.json_response({"error": "Unable to save the favorite."}, status=500)
|
||||
return web.json_response({"favorites": prompts})
|
||||
|
||||
@PromptServer.instance.routes.post("/toyxyz/booru-tags/favorites/change")
|
||||
async def update_favorite(request):
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Favorites require local access."}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
action = data.get("action")
|
||||
if action not in ("edit", "delete"):
|
||||
raise ValueError("Invalid favorite action.")
|
||||
prompts = await asyncio.to_thread(
|
||||
change_favorite, data.get("index"), data.get("original"),
|
||||
replacement=data.get("prompt"), delete=action == "delete")
|
||||
except FavoriteConflictError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=409)
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
return web.json_response({"error": str(exc) or "Invalid favorite change."}, status=400)
|
||||
except OSError:
|
||||
LOG.exception("Could not change favorite prompt")
|
||||
return web.json_response({"error": "Unable to change the favorite."}, status=500)
|
||||
return web.json_response({"favorites": prompts})
|
||||
|
||||
@PromptServer.instance.routes.post("/toyxyz/booru-tags/wildcards/open-folder")
|
||||
async def open_folder(request):
|
||||
# Folder launch is a local desktop action. Reject remote clients and
|
||||
# cross-origin requests; no path or shell command is accepted.
|
||||
if not local_request(request):
|
||||
return web.json_response({"error": "Open Folder is available only on the ComfyUI computer."}, status=403)
|
||||
try:
|
||||
await asyncio.to_thread(open_wildcard_folder)
|
||||
except (OSError, AttributeError):
|
||||
LOG.exception("Could not open wildcard folder")
|
||||
return web.json_response({"error": "Unable to open the wildcard folder on this computer."}, status=500)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
@PromptServer.instance.routes.get("/toyxyz/booru-tags/suggest")
|
||||
async def suggest(request):
|
||||
query = request.query.get("q", "")
|
||||
try:
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Invalid offset."}, status=400)
|
||||
if offset < 0:
|
||||
return web.json_response({"error": "Invalid offset."}, status=400)
|
||||
try:
|
||||
results = await asyncio.to_thread(cached_suggestions, query, offset, PAGE_SIZE + 1)
|
||||
except Exception:
|
||||
LOG.exception("Booru tag suggestion lookup failed")
|
||||
return web.json_response({"error": "Tag suggestions unavailable."}, status=500)
|
||||
return web.json_response({"suggestions": results[:PAGE_SIZE],
|
||||
"has_more": len(results) > PAGE_SIZE})
|
||||
|
||||
|
||||
register_routes()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Read-only access to a local Danbooru wiki SQLite snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import re
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
DEFAULT_DB = Path(__file__).resolve().parent / "data" / "danbooru_wiki.sqlite3"
|
||||
PAGE_SIZE = 30
|
||||
WIKI_LINK = re.compile(r"\[\[([^\]]+)\]\]")
|
||||
|
||||
|
||||
def active_wiki_db() -> Path:
|
||||
if not DEFAULT_DB.is_file():
|
||||
raise OSError("Bundled wiki database is missing from nodes/data.")
|
||||
return DEFAULT_DB
|
||||
|
||||
|
||||
def _connect(path: Path | None = None) -> sqlite3.Connection:
|
||||
path = path or active_wiki_db()
|
||||
uri = "file:" + quote(path.resolve().as_posix(), safe="/:") + "?mode=ro"
|
||||
con = None
|
||||
try:
|
||||
con = sqlite3.connect(uri, uri=True, timeout=2)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA query_only = ON")
|
||||
return con
|
||||
except sqlite3.Error as exc:
|
||||
if con is not None:
|
||||
con.close()
|
||||
raise OSError("Wiki database could not be opened.") from exc
|
||||
|
||||
|
||||
def _escaped_like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _other_names(value: str | None) -> list[str]:
|
||||
try:
|
||||
names = json.loads(value or "[]")
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
return [name for name in names if isinstance(name, str)] if isinstance(names, list) else []
|
||||
|
||||
|
||||
def _snippet(body: str | None, query: str) -> str:
|
||||
plain = WIKI_LINK.sub(lambda match: match.group(1).split("|", 1)[-1], body or "")
|
||||
plain = re.sub(r"\[[^\]]+\]|\bh[4-6]\.\s*", " ", plain)
|
||||
plain = " ".join(plain.split())
|
||||
position = plain.casefold().find(query.casefold()) if query else -1
|
||||
start = max(0, position - 45) if position >= 0 else 0
|
||||
return ("…" if start else "") + plain[start:start + 180] + (
|
||||
"…" if start + 180 < len(plain) else "")
|
||||
|
||||
|
||||
def wiki_categories(*, path: Path | None = None) -> dict:
|
||||
try:
|
||||
with closing(_connect(path)) as con:
|
||||
rows = con.execute(
|
||||
"SELECT category_name, COUNT(*) AS count FROM wiki_pages "
|
||||
"WHERE is_deleted = 0 GROUP BY category_name ORDER BY count DESC"
|
||||
).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
raise OSError("Wiki categories could not be read.") from exc
|
||||
return {"total": sum(row["count"] for row in rows),
|
||||
"categories": [{"name": row["category_name"], "count": row["count"]}
|
||||
for row in rows]}
|
||||
|
||||
|
||||
def search_wiki(query: str, category: str = "", page: int = 1,
|
||||
*, path: Path | None = None) -> dict:
|
||||
if not isinstance(query, str):
|
||||
raise ValueError("Search text must be a string.")
|
||||
query = query.strip()
|
||||
if len(query) > 150:
|
||||
raise ValueError("Search text is too long.")
|
||||
if not isinstance(category, str) or len(category) > 50:
|
||||
raise ValueError("Invalid wiki category.")
|
||||
if not isinstance(page, int) or isinstance(page, bool) or page < 1 or page > 10000:
|
||||
raise ValueError("Invalid wiki page number.")
|
||||
normalized = query.replace(" ", "_")
|
||||
title_pattern = f"%{_escaped_like(normalized)}%"
|
||||
alias_pattern = f"%{_escaped_like(query)}%"
|
||||
clauses = ["w.is_deleted = 0"]
|
||||
args: list[object] = []
|
||||
if category:
|
||||
clauses.append("w.category_name = ?")
|
||||
args.append(category)
|
||||
if query:
|
||||
tokens = re.findall(r"\w+", query, flags=re.UNICODE)
|
||||
if tokens:
|
||||
expression = " AND ".join('"' + token + '"' for token in tokens)
|
||||
clauses.append("(w.title LIKE ? ESCAPE '\\' OR w.other_names_json LIKE ? ESCAPE '\\' "
|
||||
"OR w.id IN (SELECT rowid FROM wiki_fts WHERE wiki_fts MATCH ?))")
|
||||
args.extend((title_pattern, alias_pattern, expression))
|
||||
else:
|
||||
clauses.append("(w.title LIKE ? ESCAPE '\\' OR w.other_names_json LIKE ? ESCAPE '\\')")
|
||||
args.extend((title_pattern, alias_pattern))
|
||||
order = ("CASE WHEN lower(w.title) = lower(?) THEN 0 "
|
||||
"WHEN lower(w.title) LIKE lower(?) ESCAPE '\\' THEN 1 "
|
||||
"WHEN w.other_names_json LIKE ? ESCAPE '\\' THEN 2 ELSE 3 END, "
|
||||
"COALESCE(w.post_count, 0) DESC, w.title")
|
||||
args.extend((normalized, _escaped_like(normalized) + "%", alias_pattern))
|
||||
else:
|
||||
order = "COALESCE(w.post_count, 0) DESC, w.title"
|
||||
sql = ("SELECT w.id, w.title, w.body, w.category_name, w.post_count, "
|
||||
"w.other_names_json FROM wiki_pages w WHERE " + " AND ".join(clauses) +
|
||||
" ORDER BY " + order + " LIMIT ? OFFSET ?")
|
||||
args.extend((PAGE_SIZE + 1, (page - 1) * PAGE_SIZE))
|
||||
try:
|
||||
with closing(_connect(path)) as con:
|
||||
rows = con.execute(sql, args).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
raise OSError("Wiki search failed. Check the database format.") from exc
|
||||
return {"items": [{"id": row["id"], "title": row["title"],
|
||||
"category": row["category_name"], "post_count": row["post_count"],
|
||||
"other_names": _other_names(row["other_names_json"])[:4],
|
||||
"snippet": _snippet(row["body"], query)} for row in rows[:PAGE_SIZE]],
|
||||
"page": page, "has_more": len(rows) > PAGE_SIZE}
|
||||
|
||||
|
||||
def wiki_page(page_id: int | None = None, title: str = "", *, path: Path | None = None) -> dict | None:
|
||||
if page_id is not None and (not isinstance(page_id, int) or isinstance(page_id, bool) or page_id < 0):
|
||||
raise ValueError("Invalid wiki page ID.")
|
||||
if page_id is None and (not isinstance(title, str) or not title or len(title) > 200):
|
||||
raise ValueError("Invalid wiki title.")
|
||||
try:
|
||||
with closing(_connect(path)) as con:
|
||||
where = "id = ?" if page_id is not None else "title = ? COLLATE NOCASE"
|
||||
value = page_id if page_id is not None else re.sub(r"\s+", "_", title.strip())
|
||||
row = con.execute("SELECT id, title, body, category_name, post_count, source_url, "
|
||||
"other_names_json, updated_at FROM wiki_pages WHERE " + where +
|
||||
" AND is_deleted = 0", (value,)).fetchone()
|
||||
except sqlite3.Error as exc:
|
||||
raise OSError("Wiki page could not be read.") from exc
|
||||
if row is None:
|
||||
return None
|
||||
result = dict(row)
|
||||
result["category"] = result.pop("category_name")
|
||||
result["other_names"] = _other_names(result.pop("other_names_json"))
|
||||
return result
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Local line-based wildcards; never read outside this node's wildcard folder."""
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / "wildcards"
|
||||
CONFIG_PATH = Path(__file__).resolve().parents[1] / ".wildcard_settings.json"
|
||||
TOKEN = re.compile(r"__([^\r\n]+?)__")
|
||||
MAX_PREVIEW_CHARS = 20_000
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def active_wildcard_root():
|
||||
try:
|
||||
configured = json.loads(CONFIG_PATH.read_text(encoding="utf-8")).get("wildcard_path", "")
|
||||
if isinstance(configured, str) and configured:
|
||||
return Path(configured).resolve()
|
||||
except (OSError, UnicodeError, ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
return ROOT.resolve()
|
||||
|
||||
|
||||
def set_wildcard_path(value):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Wildcard path must be text.")
|
||||
value = value.strip()
|
||||
if value:
|
||||
path = Path(value)
|
||||
if not path.is_absolute() or not path.is_dir():
|
||||
raise ValueError("Enter an existing absolute folder path, or leave it blank for the built-in folder.")
|
||||
value = str(path.resolve())
|
||||
temporary = CONFIG_PATH.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps({"wildcard_path": value}, ensure_ascii=False), encoding="utf-8")
|
||||
temporary.replace(CONFIG_PATH)
|
||||
return value
|
||||
|
||||
|
||||
def open_wildcard_folder():
|
||||
"""Open the configured wildcard directory, never a client-supplied URL path."""
|
||||
root = active_wildcard_root()
|
||||
if root == ROOT.resolve():
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
os.startfile(str(root))
|
||||
|
||||
|
||||
def browse_wildcard_folder(description="Select wildcard folder"):
|
||||
"""Ask the local Windows user to choose a folder; do not alter settings."""
|
||||
if sys.platform != "win32":
|
||||
raise OSError("Native folder browsing is available only on Windows.")
|
||||
executable = shutil.which("powershell.exe")
|
||||
if not executable:
|
||||
raise OSError("Windows PowerShell is unavailable.")
|
||||
script = (
|
||||
"Add-Type -AssemblyName System.Windows.Forms; "
|
||||
"$dialog = New-Object System.Windows.Forms.FolderBrowserDialog; "
|
||||
f"$dialog.Description = '{description}'; "
|
||||
"$dialog.ShowNewFolderButton = $false; "
|
||||
"if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { "
|
||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); "
|
||||
"[Console]::Write($dialog.SelectedPath) }"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[executable, "-NoProfile", "-STA", "-WindowStyle", "Hidden", "-Command", script],
|
||||
capture_output=True, encoding="utf-8", errors="replace", timeout=300,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
if result.returncode:
|
||||
raise OSError("The folder picker could not be opened.")
|
||||
selected = result.stdout.strip()
|
||||
if selected and (not Path(selected).is_absolute() or not Path(selected).is_dir()):
|
||||
raise OSError("The selected folder is unavailable.")
|
||||
return selected
|
||||
|
||||
|
||||
def wildcard_files(root=None):
|
||||
root = Path(root).resolve() if root is not None else active_wildcard_root()
|
||||
if not root.is_dir():
|
||||
return {}
|
||||
return {path.relative_to(root).with_suffix("").as_posix(): path
|
||||
for path in sorted(root.rglob("*.txt"))
|
||||
if path.is_file() and path.resolve().is_relative_to(root)}
|
||||
|
||||
|
||||
def wildcard_preview(name, root=None):
|
||||
"""Read only a configured wildcard file selected by its listed name."""
|
||||
if not isinstance(name, str) or not name or len(name) > 512:
|
||||
raise KeyError(name)
|
||||
path = wildcard_files(root).get(name)
|
||||
if path is None:
|
||||
raise KeyError(name)
|
||||
with path.open(encoding="utf-8-sig", errors="replace") as source:
|
||||
content = source.read(MAX_PREVIEW_CHARS + 1)
|
||||
return {"content": content[:MAX_PREVIEW_CHARS],
|
||||
"truncated": len(content) > MAX_PREVIEW_CHARS}
|
||||
|
||||
|
||||
def expand_wildcards(text, root=None, chooser=None):
|
||||
files = wildcard_files(root)
|
||||
chooser = chooser or random.SystemRandom()
|
||||
lines = {}
|
||||
replacements = 0
|
||||
|
||||
def expand(value, stack=()):
|
||||
def replace(match):
|
||||
nonlocal replacements
|
||||
name = match.group(1)
|
||||
if name not in files or name in stack or len(stack) >= 16 or replacements >= 256:
|
||||
LOG.warning("Wildcard unavailable or recursive: %s", name)
|
||||
return match.group(0)
|
||||
if name not in lines:
|
||||
try:
|
||||
lines[name] = [line.strip() for line in files[name].read_text(encoding="utf-8-sig").splitlines() if line.strip()]
|
||||
except (OSError, UnicodeError):
|
||||
LOG.warning("Wildcard could not be read: %s", name)
|
||||
lines[name] = []
|
||||
if not lines[name]:
|
||||
return match.group(0)
|
||||
replacements += 1
|
||||
return expand(chooser.choice(lines[name]), (*stack, name))
|
||||
return TOKEN.sub(replace, value)
|
||||
return expand(text)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Danbooru tag snapshot for Anima
|
||||
|
||||
`danbooru-2026-09-24.csv` comes from
|
||||
[HDiffusion/historical-danbooru-tag-counts](https://huggingface.co/datasets/HDiffusion/historical-danbooru-tag-counts),
|
||||
revision `79e7d75fcef571b7c9049db4659d1cc9e3970ce9`, licensed Apache-2.0.
|
||||
|
||||
SHA-256: `1f64a73ac7e11b12d78d89eb5b9fc73525ee4347a182733f1db01b9cc5c85dcd`
|
||||
|
||||
This pinned snapshot contains 125,312 rows. It validates generated tag names
|
||||
offline; it does not establish that every tag is understood by a given Anima
|
||||
checkpoint. Refresh the snapshot and revision together when updating it.
|
||||
|
||||
`danbooru_wiki.sqlite3` is the bundled offline Danbooru wiki snapshot used by
|
||||
the `booru tag prompter` Wiki panel. It is opened read-only and is not modified
|
||||
by node execution. SHA-256:
|
||||
`c17b31e0f6468d2a0d5094452085925643d8442a2433f798f74f26923758bac6`.
|
||||
The snapshot is separate from the tag autocomplete CSV and does not update
|
||||
automatically.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Mapping
|
||||
from types import MappingProxyType
|
||||
|
||||
from . import default, qwen_image_2_1
|
||||
from . import anima, default, qwen_image_2_1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -25,6 +25,14 @@ class ImagePromptProfile:
|
||||
|
||||
# Scene and edit writers are independent. Legacy names resolve at the node boundary.
|
||||
PROMPT_PROFILES = MappingProxyType({
|
||||
"anima": ImagePromptProfile(
|
||||
system_prompt=anima.SYSTEM_PROMPT,
|
||||
image_analysis_prompt=anima.IMAGE_ANALYSIS_PROMPT,
|
||||
reference_policy="",
|
||||
camera_resolution_prompt=anima.CAMERA_RESOLUTION_PROMPT,
|
||||
camera_intent_prompt=anima.CAMERA_INTENT_PROMPT,
|
||||
output_mode="anima",
|
||||
),
|
||||
"default": ImagePromptProfile(
|
||||
system_prompt=default.SYSTEM_PROMPT,
|
||||
image_analysis_prompt=default.IMAGE_ANALYSIS_PROMPT,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tag-forward hybrid prompts for CircleStone Labs Anima."""
|
||||
|
||||
import re
|
||||
|
||||
from .default import (CAMERA_INTENT_PROMPT, CAMERA_RESOLUTION_PROMPT,
|
||||
IMAGE_ANALYSIS_PROMPT)
|
||||
|
||||
|
||||
def candidate_budget(input_tags: str, natural_language: str, enhance: str,
|
||||
has_setting: bool) -> int:
|
||||
"""Bound optional candidates by concrete source information, not a quota."""
|
||||
if enhance == "none":
|
||||
return 0
|
||||
tag_count = len([part for part in input_tags.split(",") if part.strip()])
|
||||
prose_units = len([part for part in re.split(r"[,;.!?\n]+", natural_language)
|
||||
if part.strip()])
|
||||
density = min(12, tag_count + 2 * prose_units)
|
||||
if not has_setting:
|
||||
return min(3 if enhance == "strong" else 1, density)
|
||||
if enhance == "strong":
|
||||
return min(16, 3 + density)
|
||||
return min(8, 1 + density // 2)
|
||||
|
||||
|
||||
def budget_instruction(budget: int) -> str:
|
||||
return (f"Optional new tag candidate ceiling: {budget}. This is not a target; "
|
||||
"use fewer or zero when no grounded visible detail remains. "
|
||||
"Explicit source facts do not count against this ceiling.")
|
||||
|
||||
SYSTEM_PROMPT = """Write one positive hybrid prompt for CircleStone Labs Anima.
|
||||
Return ONLY JSON with "tags" (an array of individual Danbooru tag candidates)
|
||||
and "scene" (concise English scene sentences, or ""), plus
|
||||
"source_tag_evidence" (an array of {"tag": "...", "evidence": "..."}). The app
|
||||
validates tags against a local Danbooru vocabulary, then prints the tags first
|
||||
and the scene on the next line. Never include a tag list inside scene.
|
||||
|
||||
Read input_tags and natural_language as one user request. Preserve every explicit
|
||||
visual fact, including count, subject ownership, degree, color, clothing, body
|
||||
attributes, pose, gaze, camera crop, placement, object relationships, exclusions,
|
||||
and exact visible lettering. Explicit user text outranks reference evidence and
|
||||
presets. A comparison such as 'as if coated in oil' describes appearance, not
|
||||
a confirmed material. Do not change a girl into a woman or vice versa. Do not
|
||||
invent new subject traits, garments, gaze, crop, props, or people.
|
||||
Keep each user-authored numeric prompt weight, including its exact number and
|
||||
parenthesized syntax such as `(from front:4.92)`. Never round or reinterpret it.
|
||||
For every tag derived from natural_language, provide the exact substring of
|
||||
natural_language that explicitly supports it in source_tag_evidence. Quote the shortest relevant
|
||||
phrase in its original language, not a translation or a generic person noun.
|
||||
Do not list optional invented details in source_tag_evidence. The app drops
|
||||
unsupported person-detail tags and may retain an explicit detail as brief
|
||||
prose when no dictionary tag exists. Input_tags are preserved separately.
|
||||
TAG ORDER: Keep input_tags in their original relative order, including weighted
|
||||
tags at the end. Never move camera, quality, artist, or subject tags to the
|
||||
front by category. Put newly translated or expanded tags AFTER the authored
|
||||
tags. Expansion does not authorize rearranging the user's tags.
|
||||
|
||||
Tags carry the visual inventory. Include the user's valid tags and translate
|
||||
natural-language visual facts into concrete tag candidates. Add compatible,
|
||||
useful tags for distinct visible subject traits, clothing, action, framing,
|
||||
objects, setting, and lighting according to enhancement strength. Prefer
|
||||
established, specific Danbooru names; use lowercase and spaces for ordinary
|
||||
tags, score_* for scores, and @ for artist tags. Avoid duplicates, contradictory
|
||||
strength tiers, made-up compound phrases, and tags for off-frame details.
|
||||
Represent actions and objects as separate established tags when their combined
|
||||
relation is not a tag: `sitting`, `bench`, `bag`, not `sitting on bench` or
|
||||
`bag on ground`. Put the exact relation in the scene.
|
||||
Before writing the scene, check that each explicit subject count, pose or action,
|
||||
held object, and setting in natural_language has a corresponding tag. For example,
|
||||
"stands holding an umbrella" needs both `standing` and `holding umbrella`.
|
||||
Treat camera words that describe gaze or viewing direction as the viewer's
|
||||
viewpoint, not a camera device in the picture. Use `looking at viewer` only for
|
||||
eye contact; use `facing viewer` for body orientation when eye contact is not
|
||||
established. Use established perspective tags such as `from behind`, `from
|
||||
above`, or `from below` for the view. In scene prose say "viewer" or "viewpoint"
|
||||
instead of "camera" for these relations. Do not add `camera`, `holding camera`,
|
||||
or other camera-prop tags from a viewing angle. If the user explicitly describes
|
||||
a physical camera as an object, preserve that object and its relationships.
|
||||
Use the established `1girl` tag for one female person even when described as an
|
||||
adult woman, and `1boy` for one male person. For a railway station use
|
||||
`train station`; for its platform use `train station platform`. Use
|
||||
`backlighting` for light behind a subject. Do not infer daytime or sunlight
|
||||
from a beach or from an unspecified bright light.
|
||||
Sunlight on a person does not establish an outdoor location or visible sky.
|
||||
Clothing such as a bikini does not establish a beach, water, sand, or horizon.
|
||||
Do not add `sky`, `outdoors`, `blue sky`, or `clear sky` from sunlight or a
|
||||
portrait crop alone. `glaring` describes a person's expression, not bright
|
||||
sunlight. Enhance normal and strong add detail only in parts of the setting
|
||||
that the user actually establishes; an unspecified setting stays unspecified.
|
||||
Do not add default quality, score, safety, artist, or rendering-style tags.
|
||||
Preserve any such tags only when the user explicitly supplies them.
|
||||
|
||||
The scene is subordinate to the tag list, but it must NOT omit the user's
|
||||
spatial and bodily instructions for brevity. In concise connected English
|
||||
sentences, state the subject's placement, orientation, posture,
|
||||
limb and hand positions, gaze, eye openness, expression, action, and the
|
||||
positions of other subjects, objects, and explicitly located light sources.
|
||||
Translate every such fact from natural_language AND input_tags; do not merely
|
||||
repeat "standing on a beach" when the user also described arms, gaze, or smile.
|
||||
Use only as many words as the facts need. A complex multi-person scene may need
|
||||
six or more sentences; preserve every explicit relation without padding.
|
||||
Use fewer words when the input contains only one fact. Include no invented
|
||||
pose or position. Do not narrate beauty, hair or eye color, clothing, body
|
||||
attributes, visual style, quality, mood, weather, or lighting effects; those
|
||||
belong in tags. You may locate a light behind someone, but do not describe
|
||||
its glow on their clothes. If no spatial, pose, or object relation exists,
|
||||
return an empty scene.
|
||||
For cowboy shot, framing is approximately mid-thigh upward.
|
||||
Return no Markdown, notes, negatives, or explanation."""
|
||||
|
||||
ENHANCEMENT = {
|
||||
"none": "Enhance none: tag only the explicit input facts and their direct Danbooru equivalents. Target zero optional detail tags. Keep scene minimal.",
|
||||
"normal": "Enhance normal: preserve all input facts, then propose additional candidate tags for compatible setting, object relationships, and lighting only when the input leaves room. Follow the source-dependent candidate budget below; it is a ceiling, not a target. Optional additions must NOT include new hairstyle, hair color, gaze, skin, body traits, garments, or footwear. Cover more than one aspect of the scene, but stop if additions would invent unrelated objects or alter the camera crop. Enrich tags, not scene prose.",
|
||||
"strong": (
|
||||
"Enhance strong: preserve all input facts, then build a richer tag "
|
||||
"inventory from the SAME established scene. Cover explicit subject, "
|
||||
"action, clothing, framing, setting, and light facts first. When the "
|
||||
"user names a setting, fill genuinely open visible parts with compatible "
|
||||
"surfaces, nearby environment, background layers, and light effects. "
|
||||
"Follow the source-dependent candidate budget below as a ceiling, "
|
||||
"not a target; dictionary lookup may reject some. Without a stated "
|
||||
"setting, do not invent one or pad the count. Never add a new hairstyle, "
|
||||
"hair color, gaze, skin or body trait, garment, footwear, person, prop, "
|
||||
"camera view, or time of day. Use distinct visible details instead of "
|
||||
"synonyms. Keep the scene prose spatial and concise."
|
||||
),
|
||||
}
|
||||
|
||||
TAG_ENRICHMENT_PROMPT = """Expand the tag inventory and complete the scene
|
||||
sentence of an Anima prompt draft.
|
||||
Return ONLY JSON with "tags" (additional Danbooru tag candidates, excluding
|
||||
all tags already in draft_tags), "scene" (a revised English scene), and
|
||||
"source_tag_evidence" (exact source substrings for any newly translated
|
||||
explicit candidate; omit optional additions from this list).
|
||||
Find concrete, compatible details that the first pass missed. Cover the same
|
||||
subject and the visible foreground, surroundings, depth, and light where open.
|
||||
Follow the source-dependent candidate budget as a ceiling, not a target;
|
||||
local dictionary lookup may reject some. Never pad
|
||||
with synonyms or details that contradict the user. User_input is authoritative.
|
||||
Use only the location and objects established in user_input. Inspect related
|
||||
dictionary-valid environment, object-relation, and lighting tags rather than
|
||||
generic adjectives. Do not transfer scenery from a different setting.
|
||||
Preserve its specified people, clothing, pose, gaze, framing, and objects.
|
||||
If the user gave no setting, do not invent sky, outdoors, architecture, or
|
||||
new background objects to meet the candidate target. Lighting may be expanded
|
||||
with compatible light-effect tags without declaring a location.
|
||||
This pass may add setting, object-relation, and lighting tags only; do not
|
||||
propose new hair, skin, face, body, or clothing details. Do not invent another
|
||||
person, garment, body trait, prop, camera view, time of
|
||||
day, or light source. Do not add quality, safety, score, artist, or style tags.
|
||||
Viewing direction refers to the viewer; never add a physical `camera` tag
|
||||
unless user_input explicitly describes a camera device in the scene.
|
||||
Use established lowercase Danbooru tags with spaces, not descriptive phrases.
|
||||
Prefer atomic established tags when a descriptive compound may be absent from
|
||||
the vocabulary: `railing` rather than `metal railing`, and `pavement` rather
|
||||
than `concrete floor`. Avoid generic `background` and rendering tags such as
|
||||
`depth of field`.
|
||||
Rewrite draft_scene using user_input as the authority. Include EVERY explicit
|
||||
person placement, orientation, pose, arm or hand position, gaze, eye openness,
|
||||
smile or other expression, action, and object/light-source location that the
|
||||
draft may have omitted. Use concise connected sentences, however many are
|
||||
needed to preserve the explicit facts. Do not invent new pose or location details.
|
||||
Do not use the scene for colors, clothing, beauty, quality, style, mood, or
|
||||
lighting effects. All optional visual expansion belongs in tags."""
|
||||
|
||||
LIGHT_ENRICHMENT_PROMPT = """Enrich an Anima draft whose user input has NO
|
||||
specified setting. Return ONLY JSON with "tags" (additional Danbooru candidates)
|
||||
and "scene" (copy draft_scene exactly). Add only compatible visible lighting
|
||||
effects from the stated light and silhouette, such as shadow, light rays,
|
||||
rim lighting, or lens flare when appropriate. Aim for a few distinct useful
|
||||
tags, never pad. Do not infer beach, sea, sky, cloud, sun disk, weather,
|
||||
architecture, objects, clothing, facial features, style, or quality. Do not
|
||||
alter the user's tags, pose, framing, or scene text."""
|
||||
|
||||
EDIT_PROMPT = """Revise the existing Anima prompt according to the user's edit.
|
||||
Return ONLY JSON with "tags" (the complete revised array) and "scene" (a brief
|
||||
spatial description, or ""). The edit overrides conflicting old tags and scene
|
||||
facts. Preserve unrelated tags, including custom user tokens, and keep the
|
||||
scene consistent with the revised tags. Do not add default quality, score,
|
||||
safety, artist, or style tags. The scene must cover only placement, pose, action,
|
||||
and object relationships, not style, beauty, appearance, or quality prose.
|
||||
For gaze and viewing direction say viewer or viewpoint, not camera; preserve
|
||||
an explicitly requested physical camera object."""
|
||||
|
||||
SCENE_REPAIR_PROMPT = """Rewrite ONLY the scene field of the supplied draft.
|
||||
Preserve its tags array exactly. Compare with the original user input and
|
||||
retain every explicitly given placement, orientation, posture, limb/hand
|
||||
position, gaze, eye openness, expression, action, and object/light-source
|
||||
location. Use concise English sentences; retain all explicit facts even when
|
||||
the source needs a longer scene.
|
||||
Remove appearance colors, clothing, beauty, quality, style, mood, weather,
|
||||
lighting effects, atmosphere, and rendering. Do not invent new scene facts.
|
||||
For gaze and viewing direction say viewer or viewpoint rather than camera.
|
||||
Preserve an explicitly requested physical camera object.
|
||||
Use an empty scene only if the source has no spatial or pose facts.
|
||||
Return ONLY JSON with "tags" and "scene"."""
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Preserve user-authored ComfyUI numeric prompt weights."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
WEIGHTED_TAG = re.compile(
|
||||
r"(?<!\\)\((?P<term>(?:\\.|[^()\\\r\n])+?):\s*"
|
||||
r"(?P<weight>[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*\)"
|
||||
)
|
||||
|
||||
|
||||
def is_weighted_tag(value: str) -> bool:
|
||||
"""A complete numeric weight is a tag even if its text is a sentence."""
|
||||
return bool(WEIGHTED_TAG.fullmatch(value.strip()))
|
||||
|
||||
|
||||
def preserve_weighted_tags(source: str, output: str) -> str:
|
||||
"""Restore exact authored weights without rewriting unrelated output text."""
|
||||
authored = list(WEIGHTED_TAG.finditer(source))
|
||||
if not authored:
|
||||
return output
|
||||
existing = list(WEIGHTED_TAG.finditer(output))
|
||||
replacements: list[tuple[int, int, str]] = []
|
||||
missing: list[str] = []
|
||||
used = set()
|
||||
for item in authored:
|
||||
exact = item.group()
|
||||
if exact in output or exact in missing:
|
||||
continue
|
||||
term = " ".join(item.group("term").split()).casefold()
|
||||
match = next((candidate for candidate in existing
|
||||
if candidate.start() not in used
|
||||
and " ".join(candidate.group("term").split()).casefold() == term), None)
|
||||
if match:
|
||||
used.add(match.start())
|
||||
replacements.append((match.start(), match.end(), exact))
|
||||
else:
|
||||
missing.append(exact)
|
||||
for start, end, exact in sorted(replacements, reverse=True):
|
||||
output = output[:start] + exact + output[end:]
|
||||
return ", ".join(missing) + (", " if missing and output else "") + output
|
||||
+160
-8
@@ -10,6 +10,16 @@ import threading
|
||||
|
||||
from . import minimax_h3_prompter as runtime
|
||||
from .image_prompt_profiles import PROMPT_PROFILES
|
||||
from .image_prompt_profiles import anima as anima_profile
|
||||
from .anima_text import split_anima_input
|
||||
from .image_prompt_weights import preserve_weighted_tags
|
||||
from .anima_tags import (filter_enrichment_tags,
|
||||
filter_unlocated_lighting_tags,
|
||||
has_explicit_setting,
|
||||
format_response as format_anima_response,
|
||||
read_response as read_anima_response,
|
||||
read_response_data as read_anima_data,
|
||||
scene_needs_repair)
|
||||
from .image_prompt_profiles.multi_reference import ANALYSIS as MULTI_ANALYSIS, WRITER as MULTI_WRITER
|
||||
from .image_camera import camera_guidance, camera_components, SHOT_FRAMING, shot_framing
|
||||
from .image_camera_presets import style_guidance
|
||||
@@ -38,6 +48,7 @@ into 'above', or a held object into a floating one. Keep each relation's two end
|
||||
Do not replace specific requirements with 'as requested', 'all details', 'clear composition' or a recap.
|
||||
Explicit negative constraints must remain explicit; omission of invisible detail concerns only invented or
|
||||
reference-derived detail, never a user's stated constraint. Do not force an off-frame object into view.
|
||||
Preserve every explicit numeric prompt weight such as (from front:4.92) exactly, including its number.
|
||||
User text overrides presets and reference assumptions; explicit user corrections replace superseded facts.
|
||||
The output must contain at least the user's information, not necessarily the same character or word count
|
||||
across languages. Rephrasing must be lossless. No target word count, paragraph count or token-saving summary.
|
||||
@@ -137,6 +148,22 @@ def build_messages(prompt: str, prompt_type: str, analysis: str | None = None,
|
||||
if not isinstance(prompt, str) or (not prompt.strip() and analysis is None):
|
||||
raise ValueError("Image prompter: enter a prompt before running the queue.")
|
||||
profile = PROMPT_PROFILES[prompt_type]
|
||||
if profile.output_mode == "anima":
|
||||
if enhance not in ENHANCE_LEVELS:
|
||||
raise ValueError(f"Unsupported image enhancement level: {enhance}")
|
||||
input_tags, natural_language = split_anima_input(prompt)
|
||||
budget = anima_profile.candidate_budget(
|
||||
input_tags, natural_language, enhance, has_explicit_setting(prompt))
|
||||
payload = {"input_tags": input_tags, "natural_language": natural_language,
|
||||
"reference_evidence": analysis,
|
||||
"camera_guidance": camera_plan or camera_guidance(camera),
|
||||
"style_preset": style_guidance(camera)}
|
||||
return [{"role": "system", "content": profile.system_prompt + "\n\n" + anima_profile.ENHANCEMENT[enhance]
|
||||
+ "\n" + anima_profile.budget_instruction(budget)
|
||||
+ "\nExplicit camera instructions in the input override camera_guidance. "
|
||||
"For multiple people, keep each person's tags and spatial role consistent."},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||||
{"role": "user", "content": "Return JSON with a rich, grounded tag list and a complete but concise scene. Cover every explicit placement, pose, limb position, gaze, expression, and object relationship from the user input. Do not write style, beauty, or quality prose."}]
|
||||
if profile.output_mode == "edit":
|
||||
if not prompt.strip():
|
||||
raise ValueError("Image prompter: enter an editing instruction for qwen_image_2.1.")
|
||||
@@ -477,13 +504,26 @@ def clean_response(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def fallback_anima_from_input(prompt: str, input_tags: str, natural_language: str) -> str:
|
||||
"""Keep the user's request available when the Anima JSON cannot be used."""
|
||||
if not input_tags.strip():
|
||||
return natural_language or prompt
|
||||
try:
|
||||
tags = format_anima_response(
|
||||
input_tags, '{"tags": [], "scene": ""}',
|
||||
source_text=prompt, enhance="none")
|
||||
except RuntimeError:
|
||||
return prompt
|
||||
return tags + ("\n" + natural_language if natural_language else "")
|
||||
|
||||
|
||||
class ImagePrompter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
choices = list(model_choices())
|
||||
return {"required": {
|
||||
"prompt": ("STRING", {"default": "", "multiline": True,
|
||||
"tooltip": "Without an image, describe what you want to create; your text is expanded into a detailed image-generation prompt. With a reference image, describe what to change and what to keep, for example: 'Change the background to a beach; keep the person unchanged.' Your instructions always take priority."}),
|
||||
"tooltip": "Without an image, describe what you want to create. For anima, enter tags, natural language, or both; recognized tags lead the output and English scene sentences retain your placement, pose, gaze, and expression. With a reference image, describe what to change and keep."}),
|
||||
"llm_model": (choices, {"default": choices[0],
|
||||
"tooltip": "Qwen3.8 Uncensored, shared with H3 prompter. Missing weights download automatically on queue execution (about 16.8 GB)."}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFE, "control_after_generate": "fixed",
|
||||
@@ -491,7 +531,7 @@ class ImagePrompter:
|
||||
}, "optional": {
|
||||
# Optional with a default also keeps old API graphs (target_model)
|
||||
# executable; positional UI widget order remains unchanged.
|
||||
"prompt_type": (list(PROMPT_PROFILES), {"default": "default", "tooltip": "default: image-generation scene descriptions. qwen_image_2.1: editing instructions based on references and your request, with explicit change/preservation boundaries. Both use the selected Qwen writer."}),
|
||||
"prompt_type": (list(PROMPT_PROFILES), {"default": "default", "tooltip": "anima: dictionary-validated Danbooru tags followed by concise pose and spatial scene sentences; no default quality tags. default: image-generation prose. qwen_image_2.1: reference editing instructions."}),
|
||||
"preset": ("TOYXYZ_IMAGE_CAMERA", {"tooltip": "Optional guidance from image prompter preset. Explicit user instructions override the preset; compatible settings override reference framing."}),
|
||||
**{f"image_{i}": ("IMAGE", {"tooltip": f"Reference <image{i}>. One image per socket (first batch frame). Connect to reveal the next input, up to 10. Keep source numbering consistent with the downstream editor."}) for i in range(1,11)},
|
||||
"image": ("IMAGE", {"tooltip": "Legacy image input; migrated to image_1 in the UI."}),
|
||||
@@ -513,7 +553,7 @@ class ImagePrompter:
|
||||
RETURN_NAMES = ("prompt",) + tuple(f"image_{i}" for i in range(1, 11))
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "ToyxyzTestNodes/Prompt"
|
||||
DESCRIPTION = "Write English scene prompts (default) or editing instructions (qwen_image_2.1) from user text and up to 10 reference images. Explicit user choices and preservation boundaries take priority. Numbered image outputs pass through the unchanged original inputs for the downstream editor."
|
||||
DESCRIPTION = "Write tag-forward Anima prompts with source-faithful pose and spatial scene sentences, English scene prompts, or reference-edit instructions. Explicit user choices take priority. Numbered image outputs pass through unchanged."
|
||||
|
||||
def generate(self, prompt, llm_model, seed, prompt_type=None, image=None, enhance="none", target_model=None, camera=None, edited_prompt="", _edit_instruction=None, preset=None, **images):
|
||||
# `camera` is a legacy Python-call alias, not an exposed input socket.
|
||||
@@ -549,9 +589,10 @@ class ImagePrompter:
|
||||
try:
|
||||
# Resolve framing once; a preliminary rewrite could invent crops and leak off-frame attributes.
|
||||
profile = PROMPT_PROFILES[prompt_type]
|
||||
use_camera_plan = _edit_instruction is None and bool(profile.camera_resolution_prompt and
|
||||
use_camera_plan = (_edit_instruction is None
|
||||
and bool(profile.camera_resolution_prompt and
|
||||
(profile.camera_intent_prompt and (prompt.strip() or camera_guidance(camera))
|
||||
or camera_guidance(camera) and not profile.camera_system_prompt))
|
||||
or camera_guidance(camera) and not profile.camera_system_prompt)))
|
||||
progress = comfy.utils.ProgressBar((4 if reference else 3) + int(use_camera_plan))
|
||||
mm.throw_exception_if_processing_interrupted()
|
||||
last_download_step = None
|
||||
@@ -621,7 +662,7 @@ class ImagePrompter:
|
||||
mm.throw_exception_if_processing_interrupted()
|
||||
messages = build_messages(prompt, prompt_type, analysis_text, enhance, camera, plan)
|
||||
progress.update(1)
|
||||
if _edit_instruction is not None:
|
||||
if _edit_instruction is not None and prompt_type != "anima":
|
||||
# Resolve edits separately from full-text reproduction, including
|
||||
# source-dependent contradictions. No scene-specific rules/retries.
|
||||
delta = session.chat(build_edit_intent_messages(_edit_instruction, prompt),
|
||||
@@ -640,9 +681,116 @@ class ImagePrompter:
|
||||
progress.update(1)
|
||||
metrics = getattr(session, "last_metrics", {})
|
||||
LOG.info("Image prompter: %s", metrics)
|
||||
if metrics.get("finish_reason") == "length":
|
||||
if prompt_type == "anima" and metrics.get("finish_reason") == "length":
|
||||
# An occasional runaway JSON generation should get one bounded
|
||||
# chance to restate the same source facts compactly.
|
||||
output = session.chat(messages + [{"role": "user", "content":
|
||||
"Return exactly one complete JSON object with keys "
|
||||
"\"tags\" (array of strings), \"scene\" (English string), and "
|
||||
"\"source_tag_evidence\" (array). Use concise tag "
|
||||
"candidates and as many brief scene sentences as needed to "
|
||||
"retain every explicit pose, placement, and object relation. "
|
||||
"No explanations, analysis, or repeated text."}],
|
||||
max_tokens=MAX_OUTPUT_TOKENS, temperature=.1, top_p=.8, top_k=30,
|
||||
repeat_penalty=1.1, seed=seed)
|
||||
mm.throw_exception_if_processing_interrupted()
|
||||
metrics = getattr(session, "last_metrics", {})
|
||||
LOG.info("Image prompter Anima bounded retry: %s", metrics)
|
||||
if metrics.get("finish_reason") == "length" and prompt_type != "anima":
|
||||
raise RuntimeError("Image prompter: output hit the model/context token limit; incomplete text was not sent downstream.")
|
||||
result = clean_response(output)
|
||||
anima_clean_error = None
|
||||
try:
|
||||
result = clean_response(output)
|
||||
except RuntimeError as exc:
|
||||
if prompt_type != "anima":
|
||||
raise
|
||||
anima_clean_error = exc
|
||||
result = ""
|
||||
if prompt_type == "anima":
|
||||
input_tags, natural_language = split_anima_input(prompt)
|
||||
invalid_writer = bool(anima_clean_error or metrics.get("finish_reason") == "length")
|
||||
if not invalid_writer:
|
||||
try:
|
||||
read_anima_data(result)
|
||||
except RuntimeError as exc:
|
||||
invalid_writer = True
|
||||
LOG.warning("Image prompter Anima: writer format invalid; using source-based fallback: %s", exc)
|
||||
elif metrics.get("finish_reason") == "length":
|
||||
LOG.warning("Image prompter Anima: writer reached token limit twice; using source-based fallback.")
|
||||
else:
|
||||
LOG.warning("Image prompter Anima: unusable writer response; using source-based fallback: %s", anima_clean_error)
|
||||
if enhance == "strong" and _edit_instruction is None and not invalid_writer:
|
||||
try:
|
||||
draft_data = read_anima_data(result)
|
||||
draft_tags, draft_scene = draft_data["tags"], draft_data["scene"]
|
||||
has_setting = has_explicit_setting(prompt)
|
||||
extra_budget = anima_profile.candidate_budget(
|
||||
input_tags, natural_language, "strong", has_setting) // 2
|
||||
enrichment = session.chat([
|
||||
{"role": "system", "content": (
|
||||
anima_profile.TAG_ENRICHMENT_PROMPT if has_setting
|
||||
else anima_profile.LIGHT_ENRICHMENT_PROMPT)
|
||||
+ "\n" + anima_profile.budget_instruction(extra_budget)},
|
||||
{"role": "user", "content": json.dumps({
|
||||
"user_input": prompt, "draft_tags": draft_tags,
|
||||
"draft_scene": draft_scene,
|
||||
}, ensure_ascii=False)},
|
||||
], max_tokens=MAX_OUTPUT_TOKENS, temperature=.2, seed=seed)
|
||||
mm.throw_exception_if_processing_interrupted()
|
||||
if getattr(session, "last_metrics", {}).get("finish_reason") == "length":
|
||||
raise RuntimeError("tag expansion reached its token limit")
|
||||
enriched_data = read_anima_data(clean_response(enrichment))
|
||||
extra_tags, enhanced_scene = enriched_data["tags"], enriched_data["scene"]
|
||||
extra_tags = filter_enrichment_tags(extra_tags)
|
||||
if not has_setting:
|
||||
extra_tags = filter_unlocated_lighting_tags(extra_tags)
|
||||
result = json.dumps({**draft_data, "tags": draft_tags + extra_tags,
|
||||
"source_tag_evidence": (
|
||||
draft_data.get("source_tag_evidence", [])
|
||||
+ enriched_data.get("source_tag_evidence", [])),
|
||||
"scene": (enhanced_scene or draft_scene)
|
||||
if has_setting else draft_scene}, ensure_ascii=False)
|
||||
except RuntimeError as exc:
|
||||
LOG.warning("Image prompter Anima: extra tag pass skipped: %s", exc)
|
||||
if not invalid_writer:
|
||||
try:
|
||||
before_repair = read_anima_data(result)
|
||||
tags_before_repair, scene = before_repair["tags"], before_repair["scene"]
|
||||
if not (enhance == "none" and not natural_language and _edit_instruction is None) and scene_needs_repair(scene):
|
||||
repaired = session.chat([
|
||||
{"role": "system", "content": anima_profile.SCENE_REPAIR_PROMPT},
|
||||
{"role": "user", "content": json.dumps({"original_input": prompt,
|
||||
"draft": before_repair}, ensure_ascii=False)},
|
||||
], max_tokens=MAX_OUTPUT_TOKENS, temperature=.1, seed=seed)
|
||||
mm.throw_exception_if_processing_interrupted()
|
||||
_, repaired_scene = read_anima_response(clean_response(repaired))
|
||||
result = json.dumps({**before_repair, "tags": tags_before_repair,
|
||||
"scene": repaired_scene}, ensure_ascii=False)
|
||||
except RuntimeError as exc:
|
||||
LOG.warning("Image prompter Anima: scene repair skipped: %s", exc)
|
||||
if invalid_writer:
|
||||
result = (prompt if _edit_instruction is not None else
|
||||
fallback_anima_from_input(prompt, input_tags, natural_language))
|
||||
else:
|
||||
try:
|
||||
tag_trace = []
|
||||
result = format_anima_response(input_tags, result,
|
||||
edited=_edit_instruction is not None,
|
||||
source_text=prompt if _edit_instruction is None else prompt + " " + _edit_instruction,
|
||||
enhance=enhance,
|
||||
natural_language=natural_language,
|
||||
trace=tag_trace)
|
||||
if tag_trace:
|
||||
counts = {status: sum(item["status"] == status for item in tag_trace)
|
||||
for status in ("accepted", "duplicate", "recovered", "rejected")}
|
||||
LOG.info("Image prompter Anima tag decisions: %s", counts)
|
||||
LOG.debug("Image prompter Anima tag trace: %s", tag_trace)
|
||||
except RuntimeError as exc:
|
||||
LOG.warning("Image prompter Anima: unusable writer output; using source-based fallback: %s", exc)
|
||||
result = (prompt if _edit_instruction is not None else
|
||||
fallback_anima_from_input(prompt, input_tags, natural_language))
|
||||
if _edit_instruction is None:
|
||||
result = preserve_weighted_tags(prompt, result)
|
||||
if _edit_instruction is not None and ' '.join(result.split()) == ' '.join(prompt.split()):
|
||||
LOG.warning('Image prompter: edit returned unchanged text; no requested change was detected.')
|
||||
progress.update(1)
|
||||
@@ -681,6 +829,10 @@ def build_edit_messages(current, instruction, prompt_type='default'):
|
||||
raise ValueError("Current prompt and edit request are required.")
|
||||
if len(current) > 18000 or len(instruction) > 6000:
|
||||
LOG.warning("Image prompter: long edit input; continuing unchanged (actual model context limit may apply).")
|
||||
if resolve_prompt_type(prompt_type) == "anima":
|
||||
return [{"role": "system", "content": anima_profile.EDIT_PROMPT},
|
||||
{"role": "user", "content": json.dumps({"current_prompt": current,
|
||||
"edit_request": instruction}, ensure_ascii=False)}]
|
||||
return [{"role": "system", "content":
|
||||
"Revise the supplied English image-generation prompt. Perform the requested change in the returned text; do not merely copy the source. "
|
||||
"The edit request has highest priority over the existing prompt. Preserve all unrelated details, "
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { installPresetList } from "./util/booru_preset_list.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "toyxyz.BooruPresetStrength",
|
||||
beforeRegisterNodeDef(nodeType, data) {
|
||||
if (data.name !== "ToyxyzBooruTagPresets") return;
|
||||
const created = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = created?.apply(this, arguments);
|
||||
installPresetList(this,data);
|
||||
return result;
|
||||
};
|
||||
const configured = nodeType.prototype.onConfigure;
|
||||
const executed = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
const result=executed?.apply(this,arguments);
|
||||
this._booruRandomResult?.(message?.camera_settings?.[0]);
|
||||
return result;
|
||||
};
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = configured?.apply(this, arguments);
|
||||
// Camera remains slot zero, retaining its existing connections.
|
||||
for (let i=(this.outputs?.length??0)-1;i>0;i--) this.removeOutput?.(i);
|
||||
if (this.outputs?.[0]) this.outputs[0].name = "camera";
|
||||
if (this.title === "booru tag preset") this.title = "booru tag camera";
|
||||
const panel = this.widgets?.find(w => w.name === "panel_enabled");
|
||||
const vertical = this.widgets?.find(w => w.name === "vertical_view");
|
||||
if (["Above 45°", "Bird's-eye view"].includes(vertical?.value)) vertical.value = "Above";
|
||||
if (["Below 45°", "Worm's-eye view"].includes(vertical?.value)) vertical.value = "Below";
|
||||
if (panel) panel.value = false;
|
||||
// Compact older camera-panel layouts once; later resized layouts stay intact.
|
||||
this.properties ??= {};
|
||||
if (this.properties.booruPanelLayoutVersion !== 5) {
|
||||
this.setSize?.([Math.max(this.size[0],480),800]);
|
||||
this.properties.booruPanelLayoutVersion = 5;
|
||||
}
|
||||
this._booruMigrateRandom?.();
|
||||
this._booruStrengthSync?.forEach(sync => sync());
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
.toyxyz-booru-suggestions {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
overflow-y: auto;
|
||||
max-height: min(300px, 45vh);
|
||||
border: 1px solid var(--border-color, #666);
|
||||
border-radius: 0.45em;
|
||||
background: var(--comfy-menu-bg, #252525);
|
||||
color: var(--input-text, #eee);
|
||||
box-shadow: 0 8px 24px #0009;
|
||||
font: 13px sans-serif;
|
||||
}
|
||||
|
||||
.toyxyz-booru-suggestion {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6em;
|
||||
width: 100%;
|
||||
padding: 0.45em 0.7em;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toyxyz-booru-suggestion[aria-selected="true"] {
|
||||
background: #205b91;
|
||||
color: #fff;
|
||||
box-shadow: inset 3px 0 #79c4ff, inset 0 0 0 1px #438bca;
|
||||
}
|
||||
|
||||
.toyxyz-booru-suggestion[aria-selected="true"] .toyxyz-booru-suggestion-detail {
|
||||
color: #d9edff;
|
||||
}
|
||||
|
||||
.toyxyz-booru-suggestion[aria-selected="true"] .toyxyz-booru-match {
|
||||
text-decoration-color: #bce3ff;
|
||||
}
|
||||
|
||||
.toyxyz-booru-suggestion-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toyxyz-booru-suggestion-detail {
|
||||
color: var(--descrip-text, #aaa);
|
||||
font-size: 0.82em;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toyxyz-booru-match {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: #67adff;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
.toyxyz-wildcard-editor { display:flex; gap:8px; width:100%; height:100%; min-width:0; box-sizing:border-box; font:13px sans-serif; }
|
||||
.toyxyz-wildcard-text { position:relative; flex:1; min-width:0; height:100%; background:#202020; }
|
||||
.toyxyz-wildcard-backdrop { position:absolute; inset:0; overflow:hidden; pointer-events:none; border:1px solid transparent; box-sizing:border-box; }
|
||||
.toyxyz-wildcard-backdrop pre { margin:0; box-sizing:border-box; white-space:pre-wrap; overflow-wrap:break-word; color:#ddd; }
|
||||
.toyxyz-wildcard-backdrop mark { color:#fff; background:#505050; border-radius:3px; }
|
||||
.toyxyz-wildcard-input { background:transparent !important; color:transparent !important; -webkit-text-fill-color:transparent; caret-color:#fff; overflow-y:scroll; }
|
||||
.toyxyz-wildcard-input::selection { background:rgba(85,155,235,.45); }
|
||||
.toyxyz-wildcard-sidebar { display:flex; flex-direction:column; flex:0 0 180px; min-width:0; overflow:hidden; background:var(--toyxyz-node-bg,#353535); border-radius:5px; color:#ddd; }
|
||||
.toyxyz-wildcard-tabs { display:flex; gap:4px; padding:5px 5px 0; flex-shrink:0; }
|
||||
.toyxyz-wildcard-tabs button { flex:1; font-weight:700; }
|
||||
.toyxyz-wildcard-tabs button.toyxyz-tab-active { background:#315b84; border-color:#71b8ff; color:#fff; box-shadow:inset 0 0 0 1px #71b8ff; }
|
||||
.toyxyz-wiki-panel { position:fixed; z-index:10001; display:flex; flex-direction:column; box-sizing:border-box; height:min(760px,calc(100vh - 16px)); overflow:hidden; border:1px solid #647991; border-radius:9px; background:#222a34; color:#e9f0f7; box-shadow:0 16px 38px #000b; font:13px/1.5 sans-serif; }
|
||||
.toyxyz-wiki-panel[hidden] { display:none; }
|
||||
.toyxyz-wiki-panel button { border:1px solid #536a80; border-radius:5px; padding:5px 8px; background:#304155; color:#edf4fa; font:inherit; cursor:pointer; }
|
||||
.toyxyz-wiki-panel button:hover { background:#415c77; }
|
||||
.toyxyz-wiki-panel button:disabled { opacity:.45; cursor:default; }
|
||||
.toyxyz-wiki-header { display:flex; align-items:center; gap:12px; flex-shrink:0; padding:10px 13px; border-bottom:1px solid #405267; cursor:grab; user-select:none; touch-action:none; }
|
||||
.toyxyz-wiki-header:active { cursor:grabbing; }
|
||||
.toyxyz-wiki-header strong { font-size:15px; }
|
||||
.toyxyz-wiki-header-note { flex:1; color:#a8bbca; font-size:11px; }
|
||||
.toyxyz-wiki-header button { font-size:18px; line-height:1; cursor:pointer; }
|
||||
.toyxyz-wiki-resize-grip { position:absolute; right:0; bottom:0; z-index:1; width:22px; height:22px; cursor:nwse-resize; touch-action:none; background:linear-gradient(135deg,transparent 48%,#8eabc6 49%,#8eabc6 54%,transparent 55%) right 5px bottom 5px/10px 10px no-repeat; }
|
||||
.toyxyz-wiki-resize-grip:focus-visible { outline:2px solid #80b7ef; outline-offset:-2px; }
|
||||
.toyxyz-wiki-layout { display:grid; grid-template-columns:minmax(145px,175px) minmax(225px,285px) minmax(260px,1fr); flex:1; min-height:0; overflow-x:auto; }
|
||||
.toyxyz-wiki-sidebar,.toyxyz-wiki-browse,.toyxyz-wiki-detail { min-height:0; overflow-y:auto; padding:15px 13px; }
|
||||
.toyxyz-wiki-sidebar,.toyxyz-wiki-browse { border-right:1px solid #3d4d5e; }
|
||||
.toyxyz-wiki-sidebar { background:#26303b; }
|
||||
.toyxyz-wiki-eyebrow { color:#80b7ef; font-size:10px; font-weight:700; letter-spacing:.13em; }
|
||||
.toyxyz-wiki-panel h2 { margin:3px 0 5px; font-size:18px; line-height:1.3; }
|
||||
.toyxyz-wiki-total,.toyxyz-wiki-help { margin:0 0 14px; color:#aec0cf; font-size:11px; }
|
||||
.toyxyz-wiki-section-title { margin:18px 0 7px; color:#9eb4c7; font-size:11px; font-weight:600; }
|
||||
.toyxyz-wiki-categories,.toyxyz-wiki-quicklinks { display:flex; flex-direction:column; gap:3px; }
|
||||
.toyxyz-wiki-category { display:flex; align-items:center; gap:6px; width:100%; text-align:left; background:transparent!important; border:0!important; }
|
||||
.toyxyz-wiki-category.toyxyz-wiki-active { background:#345b84!important; color:#fff; }
|
||||
.toyxyz-wiki-category-icon { display:grid; place-items:center; flex:0 0 17px; height:17px; border-radius:4px; background:#435c77; font-size:10px; font-weight:700; }
|
||||
.toyxyz-wiki-category-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.toyxyz-wiki-category-count { color:#a8b9c9; font-size:10px; }
|
||||
.toyxyz-wiki-quicklinks button { border:0; background:transparent; text-align:left; color:#b8d5f4; }
|
||||
.toyxyz-wiki-sidebar-help { margin-top:18px; color:#a8b9c9; font-size:10px; }
|
||||
.toyxyz-wiki-search { box-sizing:border-box; width:100%; margin:2px 0 9px; padding:8px 10px; border:1px solid #657c93; border-radius:6px; background:#151e28; color:#fff; font:inherit; }
|
||||
.toyxyz-wiki-status { padding:4px 0 9px; color:#b9c9d8; font-size:11px; font-weight:600; }
|
||||
.toyxyz-wiki-results { display:flex; flex-direction:column; min-height:0; }
|
||||
.toyxyz-wiki-result { display:flex; flex-direction:column; gap:4px; width:100%; padding:9px 3px!important; border:0!important; border-top:1px solid #3c4c5e!important; border-radius:0!important; background:transparent!important; text-align:left; }
|
||||
.toyxyz-wiki-result:hover { background:#30465c!important; }
|
||||
.toyxyz-wiki-result.toyxyz-wiki-selected { background:#345b84!important; }
|
||||
.toyxyz-wiki-result-top { display:flex; align-items:center; gap:5px; }
|
||||
.toyxyz-wiki-result-top strong { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
|
||||
.toyxyz-wiki-result-top small { padding:2px 4px; border-radius:3px; background:#3c5066; color:#bbcee0; font-size:9px; }
|
||||
.toyxyz-wiki-result-snippet,.toyxyz-wiki-result-meta { color:#aebfce; font-size:10px; line-height:1.4; }
|
||||
.toyxyz-wiki-result-snippet { display:-webkit-box; overflow:hidden; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
|
||||
.toyxyz-wiki-result-meta { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.toyxyz-wiki-pager { display:flex; align-items:center; justify-content:space-between; gap:5px; margin-top:10px; font-size:11px; }
|
||||
.toyxyz-wiki-placeholder { color:#b2c4d2; white-space:pre-wrap; }
|
||||
.toyxyz-wiki-detail { background:#202833; }
|
||||
.toyxyz-wiki-detail-top { display:flex; align-items:center; justify-content:space-between; gap:8px; color:#9cb0c5; font-size:10px; letter-spacing:.08em; }
|
||||
.toyxyz-wiki-detail-title { margin:14px 0 5px!important; overflow-wrap:anywhere; font-size:23px!important; }
|
||||
.toyxyz-wiki-aliases { color:#a9bdd0; font-size:11px; overflow-wrap:anywhere; }
|
||||
.toyxyz-wiki-facts,.toyxyz-wiki-actions { display:flex; align-items:center; flex-wrap:wrap; gap:6px; margin:12px 0; }
|
||||
.toyxyz-wiki-facts span { padding:4px 6px; border:1px solid #4e6072; border-radius:4px; color:#c9d6e2; font-size:10px; }
|
||||
.toyxyz-wiki-actions { padding-bottom:10px; border-bottom:1px solid #435265; }
|
||||
.toyxyz-wiki-actions .toyxyz-wiki-insert { background:#32618f; border-color:#77b6f0; }
|
||||
.toyxyz-wiki-actions a { margin-left:auto; color:#9bcbff; font-size:11px; }
|
||||
.toyxyz-wiki-article { overflow-wrap:anywhere; color:#e5edf5; font-size:12px; line-height:1.6; }
|
||||
.toyxyz-wiki-article p { margin:8px 0; }
|
||||
.toyxyz-wiki-article h2,.toyxyz-wiki-article h3,.toyxyz-wiki-article h4 { margin:20px 0 8px; font-size:15px; }
|
||||
.toyxyz-wiki-gap { height:8px; }
|
||||
.toyxyz-wiki-list-line { margin:4px 0; }
|
||||
.toyxyz-wiki-bullet { margin-right:7px; color:#91bdec; }
|
||||
.toyxyz-wiki-article blockquote { margin:10px 0; padding:5px 11px; border-left:3px solid #6585a5; color:#bfd1df; }
|
||||
.toyxyz-wiki-panel .toyxyz-wiki-inline-link { padding:0; border:0; background:transparent; color:#9bcbff; text-decoration:underline; }
|
||||
@media(max-width:760px) { .toyxyz-wiki-layout { grid-template-columns:125px 190px minmax(250px,1fr); } }
|
||||
.toyxyz-wildcard-header { display:flex; align-items:center; gap:4px; padding:5px; flex-shrink:0; }
|
||||
.toyxyz-wildcard-sidebar button { border:1px solid #666; background:var(--toyxyz-node-bg,#353535); color:#eee; border-radius:4px; padding:5px; cursor:pointer; }
|
||||
.toyxyz-wildcard-header span { flex:1; min-width:0; }
|
||||
.toyxyz-wildcard-status { padding:5px; font-size:11px; overflow-wrap:anywhere; color:#f0c6aa; }
|
||||
.toyxyz-wildcard-files,.toyxyz-favorite-files { overflow-y:auto; min-height:0; padding:5px; font:11px/1.4 sans-serif; }
|
||||
.toyxyz-wildcard-files button,.toyxyz-favorite-files button { display:block; width:100%; text-align:left; margin-bottom:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font:inherit; padding:4px 6px; }
|
||||
.toyxyz-wildcard-files[hidden],.toyxyz-favorite-files[hidden] { display:none; }
|
||||
.toyxyz-wildcard-directory { margin-bottom:3px; }
|
||||
.toyxyz-wildcard-directory summary { cursor:pointer; padding:4px 2px; overflow-wrap:anywhere; }
|
||||
.toyxyz-wildcard-directory summary:hover { background:#505050; border-radius:4px; }
|
||||
.toyxyz-wildcard-directory-contents { margin-left:10px; padding-left:5px; border-left:1px solid #555; }
|
||||
.toyxyz-wildcard-folder-icon { display:inline-block; position:relative; width:12px; height:8px; margin:0 5px 0 2px; border:1px solid #aaa; border-radius:1px; background:#666; vertical-align:middle; }
|
||||
.toyxyz-wildcard-folder-icon::before { content:''; position:absolute; top:-4px; left:-1px; width:6px; height:3px; border:1px solid #aaa; border-bottom:0; background:#666; border-radius:2px 2px 0 0; }
|
||||
.toyxyz-wildcard-sidebar button:hover { background:#505050; }
|
||||
.toyxyz-wildcard-tabs button.toyxyz-tab-active:hover { background:#386a9b; }
|
||||
.toyxyz-wildcard-preview { position:fixed; z-index:100000; box-sizing:border-box; width:min(480px,calc(100vw - 16px)); max-height:min(420px,calc(100vh - 16px)); overflow:auto; padding:10px 12px; border:1px solid #6b8196; border-radius:6px; background:#20262d; color:#eee; box-shadow:0 8px 24px #0009; white-space:pre-wrap; overflow-wrap:anywhere; font:12px/1.45 monospace; pointer-events:auto; }
|
||||
.toyxyz-wildcard-preview[hidden] { display:none; }
|
||||
.toyxyz-favorite-menu { position:fixed; z-index:100001; min-width:140px; padding:4px; border:1px solid #637b91; border-radius:6px; background:#242c35; box-shadow:0 8px 24px #0009; }
|
||||
.toyxyz-favorite-menu[hidden],.toyxyz-favorite-overlay[hidden] { display:none; }
|
||||
.toyxyz-favorite-menu button { display:block; width:100%; padding:7px 10px; border:0; border-radius:4px; background:transparent; color:#eee; text-align:left; cursor:pointer; }
|
||||
.toyxyz-favorite-menu button:hover { background:#3a5268; }
|
||||
.toyxyz-favorite-overlay { position:fixed; inset:0; z-index:100002; display:flex; align-items:center; justify-content:center; background:#0009; }
|
||||
.toyxyz-favorite-dialog { box-sizing:border-box; display:flex; flex-direction:column; gap:10px; width:min(720px,calc(100vw - 32px)); max-height:calc(100vh - 32px); padding:16px; border:1px solid #637b91; border-radius:8px; background:#242c35; color:#eee; box-shadow:0 12px 32px #000a; }
|
||||
.toyxyz-favorite-dialog h3 { margin:0; font:600 16px sans-serif; }
|
||||
.toyxyz-favorite-dialog textarea { box-sizing:border-box; width:100%; min-height:240px; max-height:65vh; resize:vertical; padding:10px; border:1px solid #637b91; border-radius:5px; background:#171d24; color:#eee; font:13px/1.5 monospace; }
|
||||
.toyxyz-favorite-dialog-actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.toyxyz-favorite-dialog-actions button { padding:7px 12px; border:1px solid #637b91; border-radius:5px; background:#35485a; color:#eee; cursor:pointer; }
|
||||
.toyxyz-favorite-dialog-actions button:hover { background:#496783; }
|
||||
@@ -0,0 +1,427 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { currentTagRange, formatSelectedTag, insertTag, matchRanges } from "./util/booru_tag_input.js";
|
||||
import { textareaCaretRect } from "./util/textarea_caret.js";
|
||||
import { currentWildcardRange, insertWildcard, installWildcards, matchingWildcards } from "./util/booru_wildcards.js";
|
||||
import { installWiki } from "./util/booru_wiki.js";
|
||||
|
||||
if (!document.querySelector('link[data-toyxyz-booru-style]')) {
|
||||
const style = document.createElement("link");
|
||||
style.rel = "stylesheet";
|
||||
style.href = new URL("./booru_tag_prompter.css", import.meta.url).href;
|
||||
style.dataset.toyxyzBooruStyle = "true";
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
const TYPE = "ToyxyzBooruTagPrompter";
|
||||
const WILDCARD_PATH_SETTING='toyxyz_test_nodes.WildcardPath';
|
||||
const FAVORITE_PATH_SETTING='toyxyz_test_nodes.FavoritePath';
|
||||
let wildcardPathTimer,favoritePathTimer;
|
||||
async function syncWildcardPath(value) {
|
||||
const response=await api.fetchApi('/toyxyz/booru-tags/wildcards/settings',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:value}),
|
||||
});
|
||||
if(!response.ok){
|
||||
const data=await response.json().catch(()=>({}));
|
||||
throw new Error(data.error||`HTTP ${response.status}`);
|
||||
}
|
||||
window.dispatchEvent(new Event('toyxyz-wildcards-changed'));
|
||||
}
|
||||
async function browseWildcardPath(apiClient=api) {
|
||||
const response=await apiClient.fetchApi('/toyxyz/booru-tags/wildcards/browse',{method:'POST'});
|
||||
const data=await response.json().catch(()=>({}));
|
||||
if(!response.ok)throw new Error(data.error||`Folder picker failed (HTTP ${response.status}).`);
|
||||
return typeof data.path==='string'?data.path:'';
|
||||
}
|
||||
async function syncFavoritePath(value) {
|
||||
const response=await api.fetchApi('/toyxyz/booru-tags/favorites/settings',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:value}),
|
||||
});
|
||||
if(!response.ok){
|
||||
const data=await response.json().catch(()=>({}));
|
||||
throw new Error(data.error||`HTTP ${response.status}`);
|
||||
}
|
||||
window.dispatchEvent(new Event('toyxyz-favorites-changed'));
|
||||
}
|
||||
async function browseFavoritePath(apiClient=api) {
|
||||
const response=await apiClient.fetchApi('/toyxyz/booru-tags/favorites/browse',{method:'POST'});
|
||||
const data=await response.json().catch(()=>({}));
|
||||
if(!response.ok)throw new Error(data.error||`Folder picker failed (HTTP ${response.status}).`);
|
||||
return typeof data.path==='string'?data.path:'';
|
||||
}
|
||||
function folderPathControl(name,setter,value,placeholder,browse) {
|
||||
const row=document.createElement('div');row.style.cssText='display:flex;align-items:center;gap:8px;width:100%;min-width:0;';
|
||||
const input=document.createElement('input');input.type='text';input.value=typeof value==='string'?value:'';
|
||||
input.placeholder=placeholder;input.setAttribute('aria-label',name);
|
||||
input.style.cssText='flex:1;min-width:0;padding:6px 8px;border:1px solid #666;border-radius:5px;background:#222;color:#eee;';
|
||||
const button=document.createElement('button');button.type='button';button.textContent='Browse';
|
||||
button.title='Choose a folder on the ComfyUI computer';
|
||||
button.style.cssText='flex-shrink:0;padding:6px 10px;border:1px solid #666;border-radius:5px;background:#353535;color:#eee;cursor:pointer;';
|
||||
const status=document.createElement('span');status.setAttribute('role','status');status.style.cssText='font-size:11px;color:#e7b7a2;';
|
||||
input.addEventListener('change',()=>setter(input.value));
|
||||
button.addEventListener('click',async()=>{
|
||||
button.disabled=true;status.textContent='';
|
||||
try {
|
||||
const path=await browse();
|
||||
if(path){input.value=path;setter(path);}
|
||||
}catch(error){status.textContent=error.message;}
|
||||
finally{button.disabled=false;}
|
||||
});
|
||||
row.append(input,button,status);
|
||||
return row;
|
||||
}
|
||||
function wildcardPathControl(name,setter,value) {
|
||||
return folderPathControl(name,setter,value,'Default: this node’s wildcards folder',browseWildcardPath);
|
||||
}
|
||||
function favoritePathControl(name,setter,value) {
|
||||
return folderPathControl(name,setter,value,'Default: this node’s favorites folder',browseFavoritePath);
|
||||
}
|
||||
|
||||
function attachSuggestions(node) {
|
||||
const widget = node.widgets?.find(item => item.name === "tags");
|
||||
const input = widget?.inputEl;
|
||||
if (!(input instanceof HTMLTextAreaElement) || input.dataset.toyxyzBooruAttached) return;
|
||||
input.dataset.toyxyzBooruAttached = "true";
|
||||
input.setAttribute("autocomplete", "off");
|
||||
installWildcards(node,widget,input,api);
|
||||
installWiki(node,widget,input,api);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "toyxyz-booru-suggestions";
|
||||
list.setAttribute("role", "listbox");
|
||||
list.setAttribute("aria-label", "Tag and wildcard suggestions");
|
||||
let suggestions = [];
|
||||
let selected = -1;
|
||||
let activeRange = null;
|
||||
let activeKind = "tag";
|
||||
let wildcardNames = null;
|
||||
let nextOffset = 0;
|
||||
let hasMore = false;
|
||||
let loading = false;
|
||||
let pointerInsideList = false;
|
||||
let requestId = 0;
|
||||
let timer;
|
||||
let composing = false;
|
||||
let removed = false;
|
||||
let positionFrame = 0;
|
||||
let lastInputRect = "";
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(timer);
|
||||
cancelAnimationFrame(positionFrame);
|
||||
positionFrame = 0;
|
||||
lastInputRect = "";
|
||||
requestId++;
|
||||
list.remove();
|
||||
suggestions = [];
|
||||
activeRange = null;
|
||||
nextOffset = 0;
|
||||
hasMore = false;
|
||||
loading = false;
|
||||
pointerInsideList = false;
|
||||
};
|
||||
|
||||
const position = () => {
|
||||
if (!list.isConnected) return;
|
||||
const rect = input.getBoundingClientRect();
|
||||
const caret = textareaCaretRect(input);
|
||||
if (caret.bottom < rect.top || caret.top > rect.bottom) { hide(); return; }
|
||||
const style = getComputedStyle(input);
|
||||
const wordStart = activeRange ? textareaCaretRect(input, activeRange.start) : caret;
|
||||
const anchorLeft = Math.abs(wordStart.top - caret.top) < 1
|
||||
? Math.max(rect.left, Math.min(wordStart.left, rect.right)) : caret.left;
|
||||
const scale = caret.scale;
|
||||
const fontSize = Number.parseFloat(style.fontSize) || 13;
|
||||
list.style.fontSize = `${fontSize * scale}px`;
|
||||
list.style.width = "max-content";
|
||||
list.style.maxWidth = `${Math.min(420, rect.width * 0.5, window.innerWidth - 16)}px`;
|
||||
const width = list.getBoundingClientRect().width;
|
||||
list.style.left = `${Math.max(8, Math.min(anchorLeft, window.innerWidth - width - 8))}px`;
|
||||
const top = caret.bottom + 2;
|
||||
list.style.top = `${top}px`;
|
||||
list.style.maxHeight = `${Math.max(32, Math.min(300 * scale, window.innerHeight - top - 8))}px`;
|
||||
};
|
||||
|
||||
const watchPosition = () => {
|
||||
if (!list.isConnected || removed) { positionFrame = 0; return; }
|
||||
const rect = input.getBoundingClientRect();
|
||||
const key = `${rect.left},${rect.top},${rect.width},${rect.height}`;
|
||||
if (key !== lastInputRect) {
|
||||
lastInputRect = key;
|
||||
position();
|
||||
}
|
||||
positionFrame = list.isConnected ? requestAnimationFrame(watchPosition) : 0;
|
||||
};
|
||||
|
||||
const select = index => {
|
||||
selected = (index + suggestions.length) % suggestions.length;
|
||||
[...list.children].forEach((item, i) =>
|
||||
item.setAttribute("aria-selected", String(i === selected)));
|
||||
list.children[selected]?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
const insert = index => {
|
||||
const choice = suggestions[index];
|
||||
const range = activeKind === "wildcard"
|
||||
? currentWildcardRange(input.value, input.selectionStart, input.selectionEnd)
|
||||
: currentTagRange(input.value, input.selectionStart, input.selectionEnd);
|
||||
if (!choice || !range || range.query !== activeRange?.query) return;
|
||||
const next = activeKind === "wildcard"
|
||||
? insertWildcard(input.value, range.start, range.end, choice.tag)
|
||||
: insertTag(input.value, range, formatSelectedTag(choice.tag));
|
||||
input.value = next.value;
|
||||
input.setSelectionRange(next.caret, next.caret);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
if (widget.value !== input.value) {
|
||||
widget.value = input.value;
|
||||
widget.callback?.(widget.value);
|
||||
}
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
input.focus();
|
||||
hide();
|
||||
};
|
||||
|
||||
const appendHighlighted = (element, label, query) => {
|
||||
let cursor = 0;
|
||||
for (const [start, end] of matchRanges(label, query)) {
|
||||
element.append(document.createTextNode(label.slice(cursor, start)));
|
||||
const match = document.createElement("span");
|
||||
match.className = "toyxyz-booru-match";
|
||||
match.textContent = label.slice(start, end);
|
||||
element.append(match);
|
||||
cursor = end;
|
||||
}
|
||||
element.append(document.createTextNode(label.slice(cursor)));
|
||||
};
|
||||
|
||||
const render = (results, append = false) => {
|
||||
if (!append) {
|
||||
suggestions = [];
|
||||
selected = -1;
|
||||
list.replaceChildren();
|
||||
list.scrollTop = 0;
|
||||
}
|
||||
if (!results.length && !suggestions.length) { hide(); return; }
|
||||
for (const result of results) {
|
||||
const index = suggestions.length;
|
||||
suggestions.push(result);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "toyxyz-booru-suggestion";
|
||||
button.setAttribute("role", "option");
|
||||
button.setAttribute("aria-selected", "false");
|
||||
const name = document.createElement("span");
|
||||
name.className = "toyxyz-booru-suggestion-name";
|
||||
appendHighlighted(name, activeKind === "wildcard" ? `__${result.tag}__` : result.tag, activeRange.query);
|
||||
const detail = document.createElement("span");
|
||||
detail.className = "toyxyz-booru-suggestion-detail";
|
||||
detail.append(document.createTextNode(activeKind === "wildcard"
|
||||
? "wildcard" : `${result.category} · ${result.count.toLocaleString()}`));
|
||||
if (result.alias) {
|
||||
detail.append(document.createTextNode(" · "));
|
||||
appendHighlighted(detail, result.alias, activeRange.query);
|
||||
}
|
||||
button.append(name, detail);
|
||||
button.addEventListener("pointerdown", event => event.preventDefault());
|
||||
button.addEventListener("click", () => insert(index));
|
||||
list.append(button);
|
||||
}
|
||||
if (!list.isConnected) document.body.append(list);
|
||||
position();
|
||||
if (list.isConnected && !positionFrame) positionFrame = requestAnimationFrame(watchPosition);
|
||||
};
|
||||
|
||||
const loadPage = async (range, currentRequest, offset, append) => {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
let data;
|
||||
if (activeKind === "wildcard") {
|
||||
if (!wildcardNames) {
|
||||
const response = await api.fetchApi('/toyxyz/booru-tags/wildcards');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result = await response.json();
|
||||
wildcardNames = Array.isArray(result.wildcards) ? result.wildcards : [];
|
||||
}
|
||||
data = matchingWildcards(wildcardNames, range.query, offset);
|
||||
} else {
|
||||
const response = await api.fetchApi(
|
||||
`/toyxyz/booru-tags/suggest?q=${encodeURIComponent(range.query)}&offset=${offset}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
data = await response.json();
|
||||
}
|
||||
if (currentRequest !== requestId ||
|
||||
(document.activeElement !== input && !pointerInsideList) || removed) return;
|
||||
const page = Array.isArray(data.suggestions) ? data.suggestions : [];
|
||||
nextOffset = offset + page.length;
|
||||
hasMore = Boolean(data.has_more);
|
||||
render(page, append);
|
||||
} catch (error) {
|
||||
if (currentRequest === requestId) {
|
||||
hasMore = false;
|
||||
if (!append) hide();
|
||||
}
|
||||
console.warn("booru tag prompter suggestions unavailable", error);
|
||||
} finally {
|
||||
if (currentRequest === requestId) loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
clearTimeout(timer);
|
||||
hide();
|
||||
const wildcardRange = currentWildcardRange(input.value, input.selectionStart, input.selectionEnd);
|
||||
activeKind = wildcardRange ? "wildcard" : "tag";
|
||||
const range = wildcardRange || currentTagRange(input.value, input.selectionStart, input.selectionEnd);
|
||||
activeRange = range;
|
||||
const currentRequest = ++requestId;
|
||||
if (!range || (activeKind === "tag" && range.query.includes('__')) || composing || removed) return;
|
||||
timer = setTimeout(() => loadPage(range, currentRequest, 0, false), 100);
|
||||
};
|
||||
|
||||
const onWildcardListChanged = () => {
|
||||
wildcardNames = null;
|
||||
if (activeKind === "wildcard" && document.activeElement === input) update();
|
||||
};
|
||||
|
||||
list.addEventListener("scroll", () => {
|
||||
if (hasMore && !loading && activeRange &&
|
||||
list.scrollTop + list.clientHeight >= list.scrollHeight - 60) {
|
||||
loadPage(activeRange, requestId, nextOffset, true);
|
||||
}
|
||||
});
|
||||
list.addEventListener("pointerenter", () => { pointerInsideList = true; });
|
||||
list.addEventListener("pointerleave", () => {
|
||||
pointerInsideList = false;
|
||||
if (document.activeElement !== input) hide();
|
||||
});
|
||||
|
||||
const onKeyDown = event => {
|
||||
// Leave ComfyUI's Ctrl+arrow weights and Ctrl+Enter action untouched.
|
||||
if (event.ctrlKey && (event.key.startsWith("Arrow") || event.key === "Enter")) return;
|
||||
if (!list.isConnected || !suggestions.length) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.key === "ArrowUp" && selected === 0) return;
|
||||
if (event.key === "ArrowDown" && selected === suggestions.length - 1 && hasMore) {
|
||||
const nextIndex = suggestions.length;
|
||||
const currentRequest = requestId;
|
||||
if (!loading) {
|
||||
loadPage(activeRange, currentRequest, nextOffset, true).then(() => {
|
||||
if (currentRequest === requestId && list.isConnected &&
|
||||
suggestions.length > nextIndex) select(nextIndex);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
select(selected < 0 ? 0 : selected + (event.key === "ArrowDown" ? 1 : -1));
|
||||
} else if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
insert(selected < 0 ? 0 : selected);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
hide();
|
||||
}
|
||||
};
|
||||
const onOutsidePointer = event => {
|
||||
if (event.target !== input && !list.contains(event.target)) hide();
|
||||
};
|
||||
const onBlur = () => setTimeout(() => {
|
||||
if (!pointerInsideList && !list.contains(document.activeElement)) hide();
|
||||
}, 120);
|
||||
|
||||
input.addEventListener("input", update);
|
||||
input.addEventListener("click", update);
|
||||
input.addEventListener("keyup", event => {
|
||||
if (event.ctrlKey && event.key.startsWith("Arrow")) return;
|
||||
if (["ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key)) {
|
||||
update();
|
||||
}
|
||||
});
|
||||
input.addEventListener("keydown", onKeyDown);
|
||||
input.addEventListener("compositionstart", () => { composing = true; hide(); });
|
||||
input.addEventListener("compositionend", () => { composing = false; update(); });
|
||||
input.addEventListener("blur", onBlur);
|
||||
input.addEventListener("scroll", position);
|
||||
document.addEventListener("pointerdown", onOutsidePointer);
|
||||
window.addEventListener("resize", position);
|
||||
window.addEventListener("scroll", position, true);
|
||||
window.addEventListener("toyxyz-wildcard-list-updated", onWildcardListChanged);
|
||||
window.addEventListener("toyxyz-wildcards-changed", onWildcardListChanged);
|
||||
|
||||
const originalRemoved = node.onRemoved;
|
||||
node.onRemoved = function (...args) {
|
||||
removed = true;
|
||||
clearTimeout(timer);
|
||||
hide();
|
||||
document.removeEventListener("pointerdown", onOutsidePointer);
|
||||
window.removeEventListener("resize", position);
|
||||
window.removeEventListener("scroll", position, true);
|
||||
window.removeEventListener("toyxyz-wildcard-list-updated", onWildcardListChanged);
|
||||
window.removeEventListener("toyxyz-wildcards-changed", onWildcardListChanged);
|
||||
input.removeEventListener("scroll", position);
|
||||
return originalRemoved?.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "toyxyz.booru_tag_prompter",
|
||||
settings: [{
|
||||
id: WILDCARD_PATH_SETTING,
|
||||
name: 'Wildcard folder path',
|
||||
category: ['toyxyz_test_nodes', 'Wildcards', 'Wildcard folder path'],
|
||||
type: wildcardPathControl,
|
||||
defaultValue: '',
|
||||
tooltip: 'Enter an existing absolute folder path or choose Browse on the ComfyUI computer. Leave blank to use this custom node’s wildcards folder.',
|
||||
onChange(value) {
|
||||
clearTimeout(wildcardPathTimer);
|
||||
wildcardPathTimer=setTimeout(async()=>{
|
||||
try {
|
||||
await syncWildcardPath(value);
|
||||
}catch(error){console.warn('Wildcard folder path was not applied:',error);}
|
||||
},400);
|
||||
},
|
||||
},{
|
||||
id: FAVORITE_PATH_SETTING,
|
||||
name: 'Favorite folder path',
|
||||
category: ['toyxyz_test_nodes', 'Favorites', 'Favorite folder path'],
|
||||
type: favoritePathControl,
|
||||
defaultValue: '',
|
||||
tooltip: 'Enter an existing absolute folder path or choose Browse on the ComfyUI computer. Leave blank to use this custom node’s favorites folder.',
|
||||
onChange(value) {
|
||||
clearTimeout(favoritePathTimer);
|
||||
favoritePathTimer=setTimeout(async()=>{
|
||||
try {await syncFavoritePath(value);}
|
||||
catch(error){console.warn('Favorite folder path was not applied:',error);}
|
||||
},400);
|
||||
},
|
||||
}],
|
||||
async setup(app) {
|
||||
const saved=app.ui.settings.getSettingValue(WILDCARD_PATH_SETTING);
|
||||
if(typeof saved==='string'&&saved.trim()) {
|
||||
try {await syncWildcardPath(saved);}catch(error){console.warn('Saved wildcard folder path was not applied:',error);}
|
||||
}
|
||||
const favoritePath=app.ui.settings.getSettingValue(FAVORITE_PATH_SETTING);
|
||||
if(typeof favoritePath==='string'&&favoritePath.trim()) {
|
||||
try {await syncFavoritePath(favoritePath);}catch(error){console.warn('Saved favorite folder path was not applied:',error);}
|
||||
}
|
||||
},
|
||||
beforeRegisterNodeDef(nodeType, data) {
|
||||
if (data.name !== TYPE) return;
|
||||
const configured = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = configured?.apply(this, arguments);
|
||||
const legacy = this.inputs?.find(input => input.name === "preset");
|
||||
if (legacy) legacy.name = "camera";
|
||||
const obsoleteWidget = this.widgets?.findIndex(widget => widget.name === "model_type");
|
||||
if (obsoleteWidget >= 0) this.widgets.splice(obsoleteWidget, 1);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
nodeCreated(node) {
|
||||
if (node.comfyClass === TYPE || node.type === TYPE) attachSuggestions(node);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// Camera proxy: the orbit is illustrative, not a scene-aware framing calculation.
|
||||
import { drawCameraScene } from './booru_camera_scene.js';
|
||||
export function installCameraPanel(node) {
|
||||
const widgets = Object.fromEntries(node.widgets.map(w => [w.name, w]));
|
||||
if (!widgets.pos_x || !node.addDOMWidget) return;
|
||||
for (const widget of node.widgets) {
|
||||
widget.type = 'hidden'; widget.computeSize = () => [0, -4]; widget.draw = () => {};
|
||||
}
|
||||
widgets.panel_enabled.value = true;
|
||||
const root = document.createElement('div');
|
||||
root.style.cssText = 'width:100%;height:100%;min-width:0;min-height:0;overflow:auto;background:#222a33;color:#e5edf5;border-radius:10px;padding:12px;box-sizing:border-box;font:13px sans-serif;display:flex;flex-direction:column;gap:9px;';
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex;align-items:center;gap:10px;flex-shrink:0;';
|
||||
const title = document.createElement('strong'); title.textContent = 'Camera composition'; title.style.flex = '1';
|
||||
const enabled = document.createElement('input'); enabled.type = 'checkbox'; enabled.setAttribute('aria-label', 'Enable camera');
|
||||
enabled.title = 'Enable or disable camera tags without changing your source tags.';
|
||||
const reset = document.createElement('button'); reset.textContent = 'Reset';
|
||||
reset.title = 'Reset position, elevation, distance and roll. Keep tag strengths.';
|
||||
header.append(title, enabled, reset); root.append(header);
|
||||
const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 300;
|
||||
canvas.style.cssText = 'display:block;width:100%;height:190px;min-height:160px;flex:1 0 190px;background:#171e26;border-radius:8px;touch-action:none;';
|
||||
canvas.title = 'Left drag: camera position. Right drag or Alt+drag: inspect the 3D scene. Scroll: distance. Shift+scroll: roll. Proxy geometry only.';
|
||||
root.append(canvas);
|
||||
const help = document.createElement('div');
|
||||
help.textContent = 'Left drag: camera / Right drag or Alt+drag: inspect / Scroll: distance / Shift+scroll: roll'; help.style.flexShrink = '0'; root.append(help);
|
||||
const view = {yaw:0,pitch:Math.PI/4};
|
||||
const controls = [];
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const value = name => Number(widgets[name]?.value) || 0;
|
||||
const set = (name, next) => {
|
||||
const widget = widgets[name]; widget.value = next; widget.callback?.(next);
|
||||
sync(); node.setDirtyCanvas?.(true, true);
|
||||
};
|
||||
const makeRow = (name, label, min, max) => {
|
||||
const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:10px;flex-shrink:0;min-width:0;';
|
||||
const text = document.createElement('span'); text.textContent = label; text.style.cssText = 'width:126px;flex-shrink:0;white-space:nowrap;';
|
||||
const slider = document.createElement('input'); slider.type = 'range'; slider.min = min; slider.max = max; slider.step = '.01';
|
||||
slider.style.cssText = 'flex:1;min-width:30px;accent-color:#72b8ff;'; slider.setAttribute('aria-label', label);
|
||||
const number = document.createElement('input'); number.type = 'number'; number.min = min; number.max = max; number.step = '.01';
|
||||
number.style.cssText = 'width:70px;background:#151c24;color:#e5edf5;border:1px solid #536779;border-radius:5px;padding:5px;';
|
||||
number.setAttribute('aria-label', label + ' numeric input');
|
||||
const tooltip = `${label}: ${min} to ${max}. Arrow keys: 0.01; Shift+arrow: 0.10. Enter applies; Escape cancels.`;
|
||||
slider.title = tooltip; number.title = tooltip;
|
||||
if (name === 'pos_z') {
|
||||
slider.title = number.title = tooltip + ' Lower values move closer; higher values move farther away.';
|
||||
}
|
||||
const apply = v => set(name, Math.round(clamp(v, min, max) * 100) / 100);
|
||||
slider.addEventListener('input', () => apply(slider.value)); number.addEventListener('change', () => apply(number.value));
|
||||
number.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { sync(); number.blur(); }
|
||||
if (event.key === 'Enter') { apply(number.value); number.blur(); }
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault(); apply(Number(number.value) + (event.key === 'ArrowUp' ? 1 : -1) * (event.shiftKey ? .1 : .01));
|
||||
}
|
||||
});
|
||||
row.append(text, slider, number); root.append(row); controls.push({name, slider, number});
|
||||
};
|
||||
for (const [name, label] of [['pos_x','Horizontal (X)'],['pos_y','Elevation (Y)'],['pos_z','Distance (Z)'],['roll','Roll (R)']]) makeRow(name,label,-1,1);
|
||||
const buttons = document.createElement('div'); buttons.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;flex-shrink:0;';
|
||||
for (const [label, x] of [['Front',0],['Left',.5],['Right',-.5],['Rear',1]]) {
|
||||
const button = document.createElement('button'); button.textContent = label;
|
||||
button.title = `Set ${label.toLowerCase()} view relative to the subject. Reset elevation only.`;
|
||||
button.addEventListener('click', () => { set('pos_x', x); set('pos_y',0); }); buttons.append(button);
|
||||
}
|
||||
root.append(buttons);
|
||||
const note = document.createElement('div'); note.textContent = 'Conceptual proxy. Tag blending does not guarantee exact angles. Left/right are subject-relative.';
|
||||
note.style.cssText = 'font-size:11px;color:#a7bacd;flex-shrink:0;overflow-wrap:anywhere;'; root.append(note);
|
||||
const caption = document.createElement('div'); caption.textContent = 'Tag strengths (0–10)'; caption.style.flexShrink = '0'; root.append(caption);
|
||||
for (const [name,label] of [['horizontal_view_strength','Horizontal weight'],['vertical_view_strength','Elevation weight'],['zoom_strength','Zoom weight'],['camera_angle_strength','Roll weight']]) makeRow(name,label,0,10);
|
||||
function draw() {
|
||||
const ctx = canvas.getContext('2d'); if (!ctx) return;
|
||||
const width = canvas.clientWidth || 640, height = canvas.clientHeight || 300;
|
||||
const pixelRatio = globalThis.devicePixelRatio || 1;
|
||||
const backingWidth = Math.round(width * pixelRatio), backingHeight = Math.round(height * pixelRatio);
|
||||
if (canvas.width !== backingWidth || canvas.height !== backingHeight) {
|
||||
canvas.width = backingWidth; canvas.height = backingHeight;
|
||||
}
|
||||
ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
const scale = Math.min(width / 640, height / 300);
|
||||
ctx.setTransform(scale * pixelRatio,0,0,scale * pixelRatio,
|
||||
(width - 640 * scale) / 2 * pixelRatio, (height - 300 * scale) / 2 * pixelRatio);
|
||||
drawCameraScene(ctx,{x:value('pos_x'),y:value('pos_y'),z:value('pos_z'),roll:value('roll')},view);
|
||||
}
|
||||
function sync() {
|
||||
enabled.checked = widgets.camera_enabled.value !== false;
|
||||
for (const {name,slider,number} of controls) { slider.value=value(name); number.value=value(name).toFixed(2); }
|
||||
draw();
|
||||
}
|
||||
enabled.addEventListener('change', () => set('camera_enabled', enabled.checked));
|
||||
reset.addEventListener('click', () => { for (const key of ['pos_x','pos_y','pos_z','roll']) set(key,0); });
|
||||
let drag = null;
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
if (event.button !== 0 && event.button !== 2) return;
|
||||
event.preventDefault();
|
||||
drag = {x:event.clientX,y:event.clientY,az:value('pos_x'),el:value('pos_y'),inspect:event.button===2||event.altKey,yaw:view.yaw,pitch:view.pitch}; canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
canvas.addEventListener('pointermove', event => {
|
||||
if (!drag) return;
|
||||
const rect=canvas.getBoundingClientRect();
|
||||
if (drag.inspect) {
|
||||
view.yaw=drag.yaw+(event.clientX-drag.x)/rect.width*6;
|
||||
view.pitch=clamp(drag.pitch+(event.clientY-drag.y)/rect.height*2,-1.2,1.2);
|
||||
draw();return;
|
||||
}
|
||||
let az = drag.az + (event.clientX-drag.x)/rect.width*2;
|
||||
az = ((az+1)%2+2)%2-1;
|
||||
set('pos_x',Math.round(az*100)/100); set('pos_y',Math.round(clamp(drag.el-(event.clientY-drag.y)/rect.height*2,-1,1)*100)/100);
|
||||
});
|
||||
for (const event of ['pointerup','pointercancel','lostpointercapture']) canvas.addEventListener(event, () => {drag=null;});
|
||||
canvas.addEventListener('contextmenu',event=>event.preventDefault());
|
||||
canvas.addEventListener('wheel', event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const key=event.shiftKey?'roll':'pos_z';
|
||||
const direction=event.shiftKey?-1:1;
|
||||
set(key,Math.round(clamp(value(key)+direction*Math.sign(event.deltaY)*.05,-1,1)*100)/100);
|
||||
},{passive:false});
|
||||
const dom = node.addDOMWidget('booru_camera_panel','booru-camera-panel',root,{serialize:false,hideOnZoom:false});
|
||||
dom.serialize = false;
|
||||
const panelHeight = current => Math.max(220, (current?.size?.[1] || 740) - 110);
|
||||
dom.computeSize = width => [width || node.size[0], panelHeight(node)];
|
||||
dom.computeLayoutSize = current => {
|
||||
const height = panelHeight(current || node);
|
||||
return {minHeight: height, maxHeight: height};
|
||||
};
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function () {
|
||||
onResize?.apply(this, arguments);
|
||||
draw(); this.setDirtyCanvas?.(true,true);
|
||||
};
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(draw);
|
||||
observer?.observe(canvas);
|
||||
const onRemoved = node.onRemoved;
|
||||
node.onRemoved = function () { observer?.disconnect(); onRemoved?.apply(this, arguments); };
|
||||
node._booruStrengthSync = [sync]; sync();
|
||||
node.setSize?.([Math.max(node.size[0],560),Math.max(node.size[1],1000)]);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Software perspective projection of a 3D proxy scene. No actual scene geometry is known.
|
||||
const add=(a,b)=>a.map((v,i)=>v+b[i]);
|
||||
const mul=(a,s)=>a.map(v=>v*s);
|
||||
const dot=(a,b)=>a.reduce((sum,v,i)=>sum+v*b[i],0);
|
||||
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
||||
const unit=a=>mul(a,1/(Math.hypot(...a)||1));
|
||||
// Fit the maximum (Very wide) orbit plus the camera body, not an empty
|
||||
// 640x300 letterbox. Keep this transform identical for rendering and picking.
|
||||
export function previewViewport(width,height) {
|
||||
const scale=Math.min(width,height)/344;
|
||||
return {scale,offsetX:width/2-320*scale,offsetY:height/2-150*scale,
|
||||
left:320-width/(2*scale),right:320+width/(2*scale),
|
||||
top:150-height/(2*scale),bottom:150+height/(2*scale)};
|
||||
}
|
||||
export function cameraPosition(x,y,z) {
|
||||
const az=x*Math.PI, el=y*Math.PI/2, radius=2.4+z*.9;
|
||||
return [Math.sin(az)*Math.cos(el)*radius,.9+Math.sin(el)*radius,Math.cos(az)*Math.cos(el)*radius];
|
||||
}
|
||||
export function cameraOrbitPaths(state,steps=96) {
|
||||
const radius=2.4+state.z*.9,az=state.x*Math.PI,el=state.y*Math.PI/2;
|
||||
const horizontal=[],vertical=[];
|
||||
for(let i=0;i<=steps;i++) {
|
||||
const t=i/steps*Math.PI*2;
|
||||
horizontal.push([Math.sin(t)*Math.cos(el)*radius,.9+Math.sin(el)*radius,Math.cos(t)*Math.cos(el)*radius]);
|
||||
vertical.push([Math.sin(az)*Math.cos(t)*radius,.9+Math.sin(t)*radius,Math.cos(az)*Math.cos(t)*radius]);
|
||||
}
|
||||
return {horizontal,vertical};
|
||||
}
|
||||
export function projectPoint(point,yaw=0,pitch=Math.PI/4) {
|
||||
const right=[Math.cos(yaw),0,-Math.sin(yaw)];
|
||||
const up=[-Math.sin(yaw)*Math.sin(pitch),Math.cos(pitch),-Math.cos(yaw)*Math.sin(pitch)];
|
||||
const depth=[Math.sin(yaw)*Math.cos(pitch),Math.sin(pitch),Math.cos(yaw)*Math.cos(pitch)];
|
||||
const p=add(point,[0,-.9,0]);
|
||||
const scale=540/Math.max(1,12.5-dot(p,depth));
|
||||
return [320+dot(p,right)*scale,150-dot(p,up)*scale];
|
||||
}
|
||||
export function drawCameraScene(ctx,state,view) {
|
||||
const project=p=>projectPoint(p,view.yaw,view.pitch);
|
||||
const line=(a,b,color='#35536a',dashed=false)=>{
|
||||
const p=project(a),q=project(b);ctx.strokeStyle=color;ctx.setLineDash(dashed?[4,5]:[]);
|
||||
ctx.beginPath();ctx.moveTo(...p);ctx.lineTo(...q);ctx.stroke();ctx.setLineDash([]);
|
||||
};
|
||||
// Paths follow the actual orbit controls at the current distance.
|
||||
for(let i=-2;i<=2;i++) {line([i,0,-2],[i,0,2],'#253947');line([-2,0,i],[2,0,i],'#253947');}
|
||||
const camera=cameraPosition(state.x,state.y,state.z);
|
||||
const targetHeight=Number.isFinite(state.targetHeight)?state.targetHeight:.9;
|
||||
camera[1]+=targetHeight-.9;
|
||||
const paths=cameraOrbitPaths(state);
|
||||
const horizontalColor=state.activeAxis==='x'?'#b6e5ff':state.hoverAxis==='x'?'#80caff':'#4d98cc';
|
||||
const verticalColor=state.activeAxis==='y'?'#e6ccff':state.hoverAxis==='y'?'#cc9cff':'#a176cb';
|
||||
for(const [points,color] of [
|
||||
[paths.horizontal,horizontalColor],
|
||||
[paths.vertical,verticalColor]]) {
|
||||
ctx.strokeStyle=color;ctx.setLineDash([4,6]);ctx.beginPath();
|
||||
points.forEach((p,i)=>{const q=project(add(p,[0,targetHeight-.9,0]));if(i===0)ctx.moveTo(...q);else ctx.lineTo(...q);});
|
||||
ctx.stroke();ctx.setLineDash([]);
|
||||
}
|
||||
if(state.activeAxis) {
|
||||
const stops=state.activeAxis==='x'?[0,.25,-.25,.5,-.5,.75,-.75,1]:[0,.45,-.45];
|
||||
const color=state.activeAxis==='x'?horizontalColor:verticalColor;
|
||||
for(const stop of stops) {
|
||||
const position=cameraPosition(state.activeAxis==='x'?stop:state.x,state.activeAxis==='y'?stop:state.y,state.z);
|
||||
position[1]+=targetHeight-.9;
|
||||
const point=project(position);
|
||||
ctx.beginPath();ctx.arc(point[0],point[1],4,0,Math.PI*2);
|
||||
ctx.fillStyle=color;ctx.fill();
|
||||
ctx.strokeStyle='#14202b';ctx.lineWidth=1.5;ctx.stroke();
|
||||
}
|
||||
ctx.lineWidth=1;
|
||||
}
|
||||
const faces=[];
|
||||
const depth=[Math.sin(view.yaw)*Math.cos(view.pitch),Math.sin(view.pitch),Math.cos(view.yaw)*Math.cos(view.pitch)];
|
||||
const box=(center,size,color,basis=[[1,0,0],[0,1,0],[0,0,1]])=>{
|
||||
const vertices=[];
|
||||
for(let i=0;i<8;i++) {
|
||||
let p=center;
|
||||
for(let axis=0;axis<3;axis++)p=add(p,mul(basis[axis],((i>>axis)&1?1:-1)*size[axis]/2));
|
||||
vertices.push(p);
|
||||
}
|
||||
for(const indices of [[0,2,6,4],[1,5,7,3],[0,4,5,1],[2,3,7,6],[0,1,3,2],[4,6,7,5]]) {
|
||||
const points=indices.map(i=>vertices[i]);
|
||||
const faceCenter=points.reduce((sum,p)=>add(sum,mul(p,.25)),[0,0,0]);
|
||||
let normal=unit(cross(add(points[1],mul(points[0],-1)),add(points[2],mul(points[0],-1))));
|
||||
if(dot(normal,add(faceCenter,mul(center,-1)))<0)normal=mul(normal,-1);
|
||||
// Ambient + fixed upper-front key light; normals are world-space.
|
||||
const brightness=.45+.55*Math.max(0,dot(normal,unit([-.6,1,.8])));
|
||||
const rgb=[1,3,5].map(start=>Math.round(parseInt(color.slice(start,start+2),16)*brightness));
|
||||
faces.push({points,color:`rgb(${rgb.join(',')})`,depth:dot(faceCenter,depth)});
|
||||
}
|
||||
};
|
||||
box([0,1.65,0],[.3,.3,.3],'#b4b4b4');box([0,1.1,0],[.48,.7,.25],'#b4b4b4');
|
||||
box([-.14,.38,0],[.17,.75,.2],'#b4b4b4');box([.14,.38,0],[.17,.75,.2],'#b4b4b4');
|
||||
box([-.35,1.08,0],[.14,.65,.2],'#b4b4b4');box([.35,1.08,0],[.14,.65,.2],'#b4b4b4');
|
||||
const target=[0,targetHeight,0],forward=unit(add(target,mul(camera,-1)));
|
||||
let right=unit(cross(forward,Math.abs(forward[1])>.99?[0,0,1]:[0,1,0]));
|
||||
let up=unit(cross(right,forward));
|
||||
const roll=state.roll*Math.PI/4,r=right;
|
||||
right=add(mul(r,Math.cos(roll)),mul(up,Math.sin(roll)));
|
||||
up=add(mul(up,Math.cos(roll)),mul(r,-Math.sin(roll)));
|
||||
box(camera,[.38,.25,.3],'#ffb86b',[right,up,forward]);
|
||||
const lens=add(camera,mul(forward,.22));line(camera,target,'#ffc978');
|
||||
const corners=[[-1,-1],[-1,1],[1,1],[1,-1]].map(([x,y])=>
|
||||
add(add(add(camera,mul(forward,.8)),mul(right,x*.35)),mul(up,y*.22)));
|
||||
corners.forEach((p,i)=>{line(lens,p,'#cb995c');line(p,corners[(i+1)%4],'#cb995c');});
|
||||
const ground=[camera[0],0,camera[2]];line(camera,ground,'#ffc978',true);
|
||||
// Painter-sort all mannequin/camera faces together for proper proxy occlusion.
|
||||
faces.sort((a,b)=>a.depth-b.depth);
|
||||
for(const face of faces) {
|
||||
ctx.fillStyle=face.color;ctx.beginPath();
|
||||
face.points.forEach((p,i)=>{const q=project(p);if(i===0)ctx.moveTo(...q);else ctx.lineTo(...q);});
|
||||
ctx.closePath();ctx.fill();
|
||||
}
|
||||
// Orientation gizmo is independent of camera output; only X/Y/Z text remains.
|
||||
const origin=project([0,.9,0]),anchor=[(view.right??640)-52,(view.top??0)+52];
|
||||
for(const [axis,point,color] of [['X',[1,.9,0],'#f07878'],['Y',[0,1.9,0],'#85d798'],['Z',[0,.9,1],'#79b8ff']]) {
|
||||
const end=project(point),dx=(end[0]-origin[0])*.45,dy=(end[1]-origin[1])*.45;
|
||||
ctx.strokeStyle=color;ctx.fillStyle=color;ctx.lineWidth=2;
|
||||
ctx.beginPath();ctx.moveTo(...anchor);ctx.lineTo(anchor[0]+dx,anchor[1]+dy);ctx.stroke();
|
||||
const angle=Math.atan2(dy,dx),x=anchor[0]+dx,y=anchor[1]+dy;
|
||||
ctx.beginPath();ctx.moveTo(x,y);
|
||||
ctx.lineTo(x-6*Math.cos(angle-.45),y-6*Math.sin(angle-.45));
|
||||
ctx.lineTo(x-6*Math.cos(angle+.45),y-6*Math.sin(angle+.45));ctx.closePath();ctx.fill();
|
||||
ctx.font='12px sans-serif';ctx.fillText(axis,x+5,y-4);
|
||||
}
|
||||
ctx.lineWidth=1;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { drawCameraScene, cameraPosition, projectPoint, previewViewport } from './booru_camera_scene.js';
|
||||
export const presetFields = ['vertical_view','horizontal_view','zoom','camera_angle','perspective_depth','depth_of_field','blurry_background','blurry_foreground','bokeh','soft_focus','chromatic_aberration','lens_flare','motion_blur'];
|
||||
const labels = ['Vertical view','Horizontal view','Framing','Angle','Perspective / Depth','Depth of field','Blurry background','Blurry foreground','Bokeh','Soft focus','Chromatic aberration','Lens flare','Motion blur'];
|
||||
const focusFields=presetFields.slice(5);
|
||||
export function selectionPreview(settings) {
|
||||
return {
|
||||
x: ({Front:0,Behind:1,Side:.5,'Left side':.5,'Right side':-.5,'Front-left 45°':.25,'Front-right 45°':-.25,'Rear-left 45°':.75,'Rear-right 45°':-.75})[settings.horizontal_view] ?? 0,
|
||||
y: ({Above:.45,Below:-.45})[settings.vertical_view] ?? 0,
|
||||
z: ({'Very wide shot':1,'Wide shot':.8,'Full body':.55,'Cowboy shot':.3,'Upper body':0,Portrait:-.4,'Close-up':-.9,'Lower body':0})[settings.zoom] ?? 0,
|
||||
roll: ({'Dutch angle':20/45,Sideways:2,'Upside-down':4})[settings.camera_angle] ?? 0,
|
||||
targetHeight: ({Portrait:1.6,'Close-up':1.65,'Upper body':1.25,'Lower body':.45,'Cowboy shot':1.0})[settings.zoom] ?? .9,
|
||||
};
|
||||
}
|
||||
export function snapPresetAt(point,settings,view) {
|
||||
let best=null;
|
||||
const horizontal=['Front','Left side','Right side','Behind','Front-left 45°','Front-right 45°','Rear-left 45°','Rear-right 45°'];
|
||||
if(settings.horizontal_view==='Side')horizontal[1]='Side';
|
||||
for(const horizontal_view of horizontal)
|
||||
for(const vertical_view of ['None','Above','Below']) {
|
||||
const candidate={...settings,horizontal_view,vertical_view};
|
||||
const state=selectionPreview(candidate),position=cameraPosition(state.x,state.y,state.z);
|
||||
position[1]+=state.targetHeight-.9;
|
||||
const projected=projectPoint(position,view.yaw,view.pitch);
|
||||
const score=Math.hypot(projected[0]-point[0],projected[1]-point[1])+
|
||||
(horizontal_view===settings.horizontal_view?0:.001);
|
||||
if(!best||score<best.score)best={horizontal_view,vertical_view,score};
|
||||
}
|
||||
return best;
|
||||
}
|
||||
export const clampWeight = value => Number.isFinite(Number(value)) ? Math.max(0,Math.min(10,Number(value))) : 1;
|
||||
const wrap=value=>((value+1)%2+2)%2-1;
|
||||
export function magneticSnap(axis,value,force=false) {
|
||||
const stops=axis==='x'?[[0,'Front'],[.25,'Front-left 45°'],[-.25,'Front-right 45°'],[.5,'Left side'],[-.5,'Right side'],[.75,'Rear-left 45°'],[-.75,'Rear-right 45°'],[1,'Behind']]:
|
||||
[[0,'None'],[.45,'Above'],[-.45,'Below']];
|
||||
let best=null;
|
||||
for(const [position,label] of stops) {
|
||||
const delta=axis==='x'?wrap(position-value):position-value;
|
||||
if(!best||Math.abs(delta)<Math.abs(best.delta))best={position,label,delta};
|
||||
}
|
||||
return force||Math.abs(best.delta)<=.055?{value:axis==='x'?wrap(best.position):best.position,label:best.label}:{value,label:null};
|
||||
}
|
||||
export function pickOrbit(point,state,view,axis,near=null) {
|
||||
let best=null;
|
||||
for(let i=0;i<=400;i++) {
|
||||
const parameter=-1+i/200;
|
||||
if(near!==null && Math.abs(axis==='x'?wrap(parameter-near):parameter-near)>.2)continue;
|
||||
const candidate={...state,[axis]:parameter},p=cameraPosition(candidate.x,candidate.y,candidate.z);
|
||||
p[1]+=state.targetHeight-.9;
|
||||
const q=projectPoint(p,view.yaw,view.pitch),distance=Math.hypot(q[0]-point[0],q[1]-point[1]);
|
||||
if(!best||distance<best.distance)best={parameter,distance};
|
||||
}
|
||||
return best;
|
||||
}
|
||||
export function dragWeight(start,delta,shift=false) {
|
||||
return Math.round(clampWeight(start+delta*(shift ? .01 : .05))*100)/100;
|
||||
}
|
||||
|
||||
export function installPresetList(node,data) {
|
||||
const originals=Object.fromEntries(node.widgets.map(w=>[w.name,w]));
|
||||
if(!originals.camera_angle || !node.addDOMWidget)return;
|
||||
for(const widget of node.widgets) {
|
||||
widget.type='hidden';widget.computeSize=()=>[0,-4];widget.draw=()=>{};
|
||||
}
|
||||
if(originals.panel_enabled)originals.panel_enabled.value=false;
|
||||
const root=document.createElement('div');
|
||||
root.style.cssText='width:100%;height:100%;box-sizing:border-box;padding:10px;display:flex;flex-direction:column;gap:8px;overflow:auto;background:#252b33;border-radius:8px;color:#e4eaf0;font:13px sans-serif;';
|
||||
const resolvedRandom={};
|
||||
const settings=()=>Object.fromEntries(presetFields.map(field=>[field,
|
||||
originals[field].value==='Random' ? resolvedRandom[field]??'None' : originals[field].value]));
|
||||
node._booruMigrateRandom=()=>{
|
||||
if(originals.random_camera?.value===true) {
|
||||
for(const field of presetFields)originals[field].value='Random';
|
||||
originals.random_camera.value=false;
|
||||
}
|
||||
if(focusFields.some(field=>originals[field].value==='Random')) {
|
||||
if(originals.focus_blur_random)originals.focus_blur_random.value=true;
|
||||
for(const field of focusFields)if(originals[field].value==='Random')originals[field].value='None';
|
||||
}
|
||||
};
|
||||
node._booruMigrateRandom();
|
||||
const canvas=document.createElement('canvas');
|
||||
canvas.width=640;canvas.height=300;
|
||||
const viewport=document.createElement('div');
|
||||
viewport.style.cssText='position:relative;width:100%;height:220px;min-height:180px;flex:1 0 220px;min-width:0;';
|
||||
canvas.style.cssText='display:block;width:100%;height:100%;background:#171e26;border-radius:8px;touch-action:none;cursor:grab;';
|
||||
canvas.title='Conceptual camera proxy, not a generated-image guarantee. Connected user camera text and face/torso screen direction are not geometrically resolved. Horizontal Left/Right output includes subject facing. Fixed viewpoint. Drag a circular gizmo to move one axis. Scroll changes framing.';
|
||||
const usage=document.createElement('div');
|
||||
usage.textContent='Click + drag a ring: orbit\nRelease: snap to nearest preset\nScroll: framing (down = farther)';
|
||||
usage.style.cssText='position:absolute;left:8px;bottom:8px;max-width:calc(100% - 16px);box-sizing:border-box;padding:5px 7px;border-radius:5px;background:rgba(15,21,28,.78);color:#acbfd2;font:11px/1.4 sans-serif;white-space:pre-line;pointer-events:none;user-select:none;';
|
||||
viewport.append(canvas,usage);root.append(viewport);
|
||||
const view={yaw:0,pitch:Math.PI/4};
|
||||
let previewState=null;
|
||||
let hoverAxis=null;
|
||||
const draw=()=>{
|
||||
const ctx=canvas.getContext?.('2d');if(!ctx)return;
|
||||
const width=canvas.clientWidth||640,height=canvas.clientHeight||300,dpr=globalThis.devicePixelRatio||1;
|
||||
const w=Math.round(width*dpr),h=Math.round(height*dpr);
|
||||
if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}
|
||||
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,w,h);
|
||||
const fit=previewViewport(width,height),scale=fit.scale;
|
||||
ctx.setTransform(scale*dpr,0,0,scale*dpr,fit.offsetX*dpr,fit.offsetY*dpr);
|
||||
drawCameraScene(ctx,{...(previewState||selectionPreview(settings())),hoverAxis},{...view,...fit});
|
||||
};
|
||||
const pointerPoint=event=>{
|
||||
const rect=canvas.getBoundingClientRect(),fit=previewViewport(rect.width,rect.height);
|
||||
return [(event.clientX-rect.left-fit.offsetX)/fit.scale,
|
||||
(event.clientY-rect.top-fit.offsetY)/fit.scale];
|
||||
};
|
||||
const changeSelections=values=>{
|
||||
for(const [field,value] of Object.entries(values)) {
|
||||
if(originals[field].value===value)continue;
|
||||
originals[field].value=value;originals[field].callback?.(value);
|
||||
}
|
||||
syncs.forEach(sync=>sync());node.setDirtyCanvas?.(true,true);
|
||||
};
|
||||
let inspect=null;
|
||||
canvas.addEventListener('pointerdown',event=>{
|
||||
if(event.button!==0)return;event.preventDefault();event.stopPropagation();
|
||||
const state=previewState||selectionPreview(settings()),pointer=pointerPoint(event);
|
||||
const horizontal=pickOrbit(pointer,state,view,'x'),vertical=pickOrbit(pointer,state,view,'y');
|
||||
const axis=horizontal.distance<=vertical.distance+.1?'x':'y',hit=axis==='x'?horizontal:vertical;
|
||||
if(hit.distance>12)return;
|
||||
previewState={...state,activeAxis:axis};
|
||||
inspect={axis,pointerParameter:hit.parameter,raw:state[axis]};
|
||||
canvas.style.cursor='grabbing';
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
canvas.addEventListener('pointermove',event=>{
|
||||
if(!inspect) {
|
||||
const state=previewState||selectionPreview(settings()),point=pointerPoint(event);
|
||||
const horizontal=pickOrbit(point,state,view,'x'),vertical=pickOrbit(point,state,view,'y');
|
||||
const axis=horizontal.distance<=vertical.distance+.1?'x':'y';
|
||||
const next=Math.min(horizontal.distance,vertical.distance)<=12?axis:null;
|
||||
if(next!==hoverAxis){hoverAxis=next;canvas.style.cursor=next?'grab':'default';draw();}
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
const axis=inspect.axis,hit=pickOrbit(pointerPoint(event),previewState,view,axis,inspect.pointerParameter);
|
||||
const delta=axis==='x'?wrap(hit.parameter-inspect.pointerParameter):hit.parameter-inspect.pointerParameter;
|
||||
inspect.raw=axis==='x'?wrap(inspect.raw+delta):Math.max(-1,Math.min(1,inspect.raw+delta));
|
||||
inspect.pointerParameter=hit.parameter;
|
||||
const snap=magneticSnap(axis,inspect.raw);previewState[axis]=snap.value;
|
||||
if(snap.label) {
|
||||
changeSelections({[axis==='x'?'horizontal_view':'vertical_view']:snap.label});
|
||||
note.textContent='Snapped · Output follows the selected preset';
|
||||
note.title='Preset snapped. Output follows the lists. Drag a circular gizmo to move one axis.';
|
||||
} else {
|
||||
note.textContent='Between presets · Preview only';
|
||||
note.title='Output remains at the last selected preset. Move near a preset to snap.';
|
||||
}
|
||||
draw();
|
||||
});
|
||||
canvas.addEventListener('pointerup',()=>{
|
||||
if(inspect&&previewState) {
|
||||
const axis=inspect.axis,snap=magneticSnap(axis,inspect.raw,true);
|
||||
previewState[axis]=snap.value;
|
||||
changeSelections({[axis==='x'?'horizontal_view':'vertical_view']:snap.label});
|
||||
note.textContent='Snapped · Output follows the selected preset';
|
||||
note.title='On release, the moved axis snaps to its nearest preset.';
|
||||
}
|
||||
inspect=null;if(previewState)delete previewState.activeAxis;canvas.style.cursor='grab';draw();
|
||||
});
|
||||
for(const event of ['pointercancel','lostpointercapture'])canvas.addEventListener(event,()=>{inspect=null;if(previewState)delete previewState.activeAxis;canvas.style.cursor='grab';draw();});
|
||||
canvas.addEventListener('pointerleave',()=>{if(hoverAxis!==null){hoverAxis=null;draw();}if(!inspect)canvas.style.cursor='default';});
|
||||
canvas.addEventListener('wheel',event=>{
|
||||
event.preventDefault();event.stopPropagation();if(!event.deltaY)return;
|
||||
const choices=['Close-up','Portrait','Upper body','Cowboy shot','Full body','Wide shot','Very wide shot'];
|
||||
let index=choices.indexOf(originals.zoom.value);if(index<0)index=2;
|
||||
previewState=null;
|
||||
changeSelections({zoom:choices[Math.max(0,Math.min(choices.length-1,index+Math.sign(event.deltaY)))]});
|
||||
},{passive:false});
|
||||
const note=document.createElement('div');
|
||||
note.textContent='Horizontal: blue · Vertical: purple · Scroll: framing';
|
||||
note.title='Fixed view. Drag blue for horizontal or purple for vertical movement. Dots show snap positions while dragging. Release to snap to the nearest preset.';
|
||||
note.style.cssText='font-size:11px;color:#acbfd2;flex:0 0 18px;height:18px;min-height:18px;max-height:18px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';root.append(note);
|
||||
const syncs=[];
|
||||
let focusHeader=null;
|
||||
presetFields.forEach((field,index)=>{
|
||||
if(focusFields.includes(field)&&field!=='depth_of_field')return;
|
||||
if(field==='depth_of_field') {
|
||||
const heading=document.createElement('div');heading.textContent='Focus / Blur';
|
||||
heading.title='Independent tag-only effects; combine foreground blur, background blur and bokeh. Preview geometry is unchanged.';
|
||||
focusHeader=document.createElement('div');
|
||||
focusHeader.style.cssText='display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 14px;flex-shrink:0;min-width:0;';
|
||||
heading.style.cssText='font-size:12px;font-weight:bold;color:#acbfd2;flex-shrink:0;';focusHeader.append(heading);root.append(focusHeader);
|
||||
const buttons=document.createElement('div');buttons.style.cssText='display:flex;flex-wrap:wrap;gap:6px;flex-shrink:0;';root.append(buttons);
|
||||
for(const effect of focusFields) {
|
||||
const button=document.createElement('button');button.type='button';button.textContent=labels[presetFields.indexOf(effect)];
|
||||
button.title='Toggle this tag-only effect. Random overrides manual selections without changing them.';
|
||||
const sync=()=>{const active=originals[effect].value==='Enabled';button.disabled=!!originals.focus_blur_random?.value;button.setAttribute('aria-pressed',String(active));button.style.cssText='padding:7px 10px;border:1px solid '+(active?'#75baff':'#526477')+';border-radius:5px;color:'+(active?'#fff':'#acbfd2')+';background:'+(active?'#255b8c':'#171d24')+';cursor:pointer;opacity:'+(button.disabled?'.55':'1')+';';};
|
||||
button.addEventListener('click',event=>{event.stopPropagation();if(button.disabled)return;originals[effect].value=originals[effect].value==='Enabled'?'None':'Enabled';originals[effect].callback?.(originals[effect].value);sync();node.setDirtyCanvas?.(true,true);});
|
||||
buttons.append(button);syncs.push(sync);sync();
|
||||
}
|
||||
}
|
||||
const isFocus=field==='depth_of_field';
|
||||
const selectionWidget=isFocus?originals.focus_blur_random:originals[field],weightWidget=isFocus?originals.focus_blur_strength:originals[field+'_strength'];
|
||||
const row=document.createElement('div');
|
||||
row.style.cssText='display:flex;align-items:center;gap:8px;flex-shrink:0;min-width:0;';
|
||||
const label=document.createElement('span');label.textContent=isFocus?'Random':labels[index];
|
||||
label.style.cssText='width:126px;flex-shrink:0;white-space:nowrap;';
|
||||
const selection=document.createElement(isFocus?'input':'select');
|
||||
if(isFocus)selection.type='checkbox';
|
||||
selection.style.cssText='flex:1;min-width:70px;background:#171d24;color:#e4eaf0;border:1px solid #526477;border-radius:5px;padding:6px;';
|
||||
if(isFocus) {
|
||||
selection.style.cssText='width:18px;height:18px;flex:0 0 18px;margin:0 auto 0 0;accent-color:#75baff;cursor:pointer;';
|
||||
label.style.cursor='pointer';
|
||||
label.addEventListener('click',()=>{if(!selection.disabled){selection.checked=!selection.checked;selection.dispatchEvent(new Event('change'));}});
|
||||
}
|
||||
selection.setAttribute('aria-label',labels[index]);
|
||||
const definition=data.input.required[field]??data.input.optional?.[field];
|
||||
selection.title=definition[1]?.tooltip || labels[index];
|
||||
if(field==='horizontal_view')selection.title+=' 45-degree options are approximate targets, not calibrated generated-image angles. The camera preview is a proxy; model output can reverse direction or rotate the head independently.';
|
||||
selection.title+=' Random samples a non-None preset on each run, excluding the previous result when multiple choices exist. Weights stay unchanged.';
|
||||
const selectionTitle=selection.title;
|
||||
for(const value of isFocus?[]:definition[0]) {
|
||||
const option=document.createElement('option');option.value=value;option.textContent=value;selection.append(option);
|
||||
}
|
||||
const number=document.createElement('input');number.type='number';number.min='0';number.max='10';number.step='.01';number.readOnly=true;
|
||||
number.style.cssText='width:76px;flex-shrink:0;box-sizing:border-box;padding:6px;background:#171d24;color:#e4eaf0;border:1px solid #526477;border-radius:5px;cursor:ew-resize;';
|
||||
number.title=(!['vertical_view','horizontal_view','camera_angle'].includes(field)
|
||||
? 'Weight 0–10 applies to the selected tag only. '
|
||||
: 'Weight 0–10 applies to all tags and the entire natural-language instruction for this selection. ')
|
||||
+'Drag to adjust; Shift+drag for finer control. Click to type. Enter applies; Escape cancels.';
|
||||
number.setAttribute('aria-label',labels[index]+' weight');
|
||||
const sync=()=>{selection.value=selectionWidget.value;if(isFocus)selection.checked=!!selectionWidget.value;selection.title=isFocus?'Randomize the entire Focus / Blur combination on every run. Manual toggles are preserved.':selectionTitle+(selection.value==='Random'&&resolvedRandom[field] ? ' Last random result: '+resolvedRandom[field]+'.' : '');number.value=clampWeight(weightWidget.value).toFixed(2);draw();};
|
||||
const apply=value=>{weightWidget.value=Math.round(clampWeight(value)*100)/100;weightWidget.callback?.(weightWidget.value);sync();node.setDirtyCanvas?.(true,true);};
|
||||
const reset=document.createElement('button');
|
||||
reset.type='button';reset.textContent='↺';
|
||||
reset.title='Reset this weight to 2.00';
|
||||
reset.setAttribute('aria-label',(isFocus?'Focus / Blur':labels[index])+' reset weight');
|
||||
reset.style.cssText='width:26px;height:28px;flex-shrink:0;padding:0;background:#171d24;color:#acbfd2;border:1px solid #526477;border-radius:5px;font-size:18px;cursor:pointer;';
|
||||
reset.addEventListener('click',event=>{event.stopPropagation();apply(2);number.readOnly=true;number.style.cursor='ew-resize';});
|
||||
selection.addEventListener('change',()=>{previewState=null;selectionWidget.value=isFocus?selection.checked:selection.value;selectionWidget.callback?.(selectionWidget.value);syncs.forEach(sync=>sync());sync();node.setDirtyCanvas?.(true,true);});
|
||||
let drag=null;
|
||||
number.addEventListener('pointerdown',event=>{
|
||||
if(event.button!==0 || !number.readOnly)return;
|
||||
event.preventDefault();event.stopPropagation();
|
||||
drag={x:event.clientX,start:clampWeight(weightWidget.value),moved:false};number.setPointerCapture(event.pointerId);
|
||||
});
|
||||
number.addEventListener('pointermove',event=>{
|
||||
if(!drag)return;event.stopPropagation();
|
||||
if(Math.abs(event.clientX-drag.x)>2)drag.moved=true;
|
||||
if(drag.moved)apply(dragWeight(drag.start,event.clientX-drag.x,event.shiftKey));
|
||||
});
|
||||
number.addEventListener('pointerup',event=>{
|
||||
if(!drag)return;event.stopPropagation();const moved=drag.moved;drag=null;
|
||||
number.releasePointerCapture?.(event.pointerId);
|
||||
if(!moved){number.readOnly=false;number.style.cursor='text';number.focus();number.select();}
|
||||
});
|
||||
for(const event of ['pointercancel','lostpointercapture'])number.addEventListener(event,()=>{drag=null;});
|
||||
number.addEventListener('change',()=>apply(number.value));
|
||||
number.addEventListener('blur',()=>{number.readOnly=true;number.style.cursor='ew-resize';sync();});
|
||||
number.addEventListener('keydown',event=>{
|
||||
event.stopPropagation();
|
||||
if(event.key==='Enter'){apply(number.value);number.blur();}
|
||||
else if(event.key==='Escape'){sync();number.blur();}
|
||||
else if(event.key==='ArrowUp'||event.key==='ArrowDown') {
|
||||
event.preventDefault();apply(Number(number.value)+(event.key==='ArrowUp'?1:-1)*(event.shiftKey ? .1 : .01));
|
||||
}
|
||||
});
|
||||
if(isFocus){
|
||||
const strengthLabel=document.createElement('span');strengthLabel.textContent='Strength';strengthLabel.title='Shared strength for all Focus / Blur effects.';
|
||||
strengthLabel.style.cssText='margin-left:8px;white-space:nowrap;';
|
||||
label.style.cssText='width:auto;flex-shrink:0;white-space:nowrap;cursor:pointer;';
|
||||
selection.style.cssText='width:16px;height:16px;flex:0 0 16px;margin:0;accent-color:#75baff;cursor:pointer;';
|
||||
row.style.cssText='display:flex;align-items:center;gap:6px;flex-shrink:0;min-width:0;margin-left:auto;';
|
||||
number.title='Shared Focus / Blur weight (0–10). Drag to adjust; Shift+drag for finer control. Click to type.';
|
||||
number.setAttribute('aria-label','Focus / Blur strength');selection.setAttribute('aria-label','Random Focus / Blur');
|
||||
row.append(label,selection,strengthLabel,number,reset);focusHeader.append(row);
|
||||
}else {row.append(label,selection,number,reset);root.append(row);}
|
||||
syncs.push(sync);sync();
|
||||
});
|
||||
const hint=document.createElement('div');hint.textContent='Weight: drag to adjust · click to type · Shift+drag for fine control';
|
||||
hint.style.cssText='font-size:11px;color:#acbfd2;flex-shrink:0;';root.append(hint);
|
||||
const dom=node.addDOMWidget('booru_preset_list','booru-preset-list',root,{serialize:false,hideOnZoom:false});dom.serialize=false;
|
||||
// Only one output remains. Reserve its header/slot space, not the old
|
||||
// three-output panel's 130px allowance. Flex gives surplus height to the preview.
|
||||
const height=current=>Math.max(180,(current?.size?.[1]||800)-50);
|
||||
dom.computeSize=width=>[width||node.size[0],height(node)];
|
||||
dom.computeLayoutSize=current=>({minHeight:height(current||node),maxHeight:height(current||node)});
|
||||
node._booruStrengthSync=syncs;
|
||||
node._booruRandomResult=values=>{
|
||||
if(!values || typeof values!=='object')return;
|
||||
previewState=null;
|
||||
for(const field of presetFields) {
|
||||
const definition=data.input.required[field]??data.input.optional?.[field];
|
||||
if(originals[field].value==='Random' && values[field]!=='None' && values[field]!=='Random' && definition[0].includes(values[field]))resolvedRandom[field]=values[field];
|
||||
}
|
||||
if(originals.focus_blur_random?.value) {
|
||||
const enabled=focusFields.filter(field=>values[field]==='Enabled').map(field=>labels[presetFields.indexOf(field)]);
|
||||
note.textContent='Random Focus / Blur: '+(enabled.join(', ')||'None');
|
||||
note.title=note.textContent;
|
||||
}
|
||||
syncs.forEach(sync=>sync());node.setDirtyCanvas?.(true,true);
|
||||
};
|
||||
syncs.forEach(sync=>sync());
|
||||
const observer=typeof ResizeObserver==='undefined'?null:new ResizeObserver(draw);observer?.observe(canvas);
|
||||
const removed=node.onRemoved;node.onRemoved=function(){observer?.disconnect();removed?.apply(this,arguments);};
|
||||
const resized=node.onResize;node.onResize=function(){resized?.apply(this,arguments);draw();};
|
||||
node.setSize?.([Math.max(node.size[0],480),800]);draw();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
const SEPARATORS = /[,.;\n]/;
|
||||
|
||||
export function currentTagRange(value, selectionStart, selectionEnd = selectionStart) {
|
||||
if (selectionStart !== selectionEnd) return null;
|
||||
const cursor = Math.max(0, Math.min(selectionStart, value.length));
|
||||
let start = cursor;
|
||||
while (start > 0 && !SEPARATORS.test(value[start - 1])) start--;
|
||||
while (start < cursor && /\s/.test(value[start])) start++;
|
||||
const query = value.slice(start, cursor).trim();
|
||||
return query ? { start, end: cursor, query } : null;
|
||||
}
|
||||
|
||||
export function insertTag(value, range, tag) {
|
||||
const before = value.slice(0, range.start);
|
||||
const after = value.slice(range.end);
|
||||
// Keep every character after the caret. Separate a remaining fragment
|
||||
// from the completed tag instead of treating it as text to replace.
|
||||
const nextIsDelimiter = /^\s*[,.;\n]/.test(after);
|
||||
const separator = nextIsDelimiter ? "" : /^\s/.test(after) ? "," : ", ";
|
||||
const insertion = tag + separator;
|
||||
return {
|
||||
value: before + insertion + after,
|
||||
caret: before.length + insertion.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSelectedTag(tag) {
|
||||
return tag.replaceAll("_", " ").replaceAll("(", "\\(").replaceAll(")", "\\)");
|
||||
}
|
||||
|
||||
export function matchRanges(label, query) {
|
||||
const needle = query.trim().toLowerCase()
|
||||
.replace(/\\([()])/g, "$1").replace(/\s+/g, "_");
|
||||
if (!needle) return [];
|
||||
let haystack = "";
|
||||
const starts = [];
|
||||
const ends = [];
|
||||
for (let index = 0; index < label.length; index++) {
|
||||
if (label[index] === "\\" && /[()]/.test(label[index + 1] || "")) continue;
|
||||
haystack += label[index] === " " ? "_" : label[index].toLowerCase();
|
||||
starts.push(index);
|
||||
ends.push(index + 1);
|
||||
}
|
||||
const ranges = [];
|
||||
for (let start = haystack.indexOf(needle); start >= 0;
|
||||
start = haystack.indexOf(needle, start + needle.length)) {
|
||||
ranges.push([starts[start], ends[start + needle.length - 1]]);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import {formatSelectedTag} from './booru_tag_input.js';
|
||||
|
||||
export function insertWikiTag(text,start,end,tag) {
|
||||
start=Math.max(0,Math.min(start,text.length));
|
||||
end=Math.max(start,Math.min(end,text.length));
|
||||
const before=text.slice(0,start),after=text.slice(end),value=formatSelectedTag(tag);
|
||||
const prefix=before&&/[,.]$/.test(before)?' '
|
||||
:before&&!/[\s,(.;\n]$/.test(before)?', ':'';
|
||||
const suffix=after&&!/^[\s,.;)\n]/.test(after)?', '
|
||||
:after?'':', ';
|
||||
return {value:before+prefix+value+suffix+after,caret:start+prefix.length+value.length};
|
||||
}
|
||||
|
||||
const label=title=>String(title||'').replaceAll('_',' ');
|
||||
const number=value=>Number(value||0).toLocaleString();
|
||||
const node=(tag,className='',value='')=>{
|
||||
const element=document.createElement(tag);
|
||||
if(className)element.className=className;
|
||||
if(value)element.textContent=value;
|
||||
return element;
|
||||
};
|
||||
|
||||
// Render the small DText subset used for wiki navigation without injecting HTML.
|
||||
export function renderWikiDText(body,openTitle) {
|
||||
const article=node('article','toyxyz-wiki-article');
|
||||
let parent=article;
|
||||
const inline=(container,text)=>{
|
||||
const tokens=/\[\[([^\]]+)\]\]|\[(\/?)((?:b|i|u|s))\]/gi;
|
||||
const tags={b:'strong',i:'em',u:'u',s:'s'};
|
||||
const stack=[{name:'',element:container}];
|
||||
let last=0;
|
||||
for(const match of text.matchAll(tokens)){
|
||||
if(match.index>last)stack.at(-1).element.append(document.createTextNode(text.slice(last,match.index)));
|
||||
if(match[1]!==undefined){
|
||||
const [target,display]=match[1].split('|',2);
|
||||
const title=target.trim().split('#',1)[0].replace(/\s+/g,'_').toLowerCase();
|
||||
const link=node('button','toyxyz-wiki-inline-link',(display||target).trim());
|
||||
link.type='button';link.title=`Open ${label(title)}`;
|
||||
link.addEventListener('click',()=>openTitle(title));
|
||||
stack.at(-1).element.append(link);
|
||||
}else if(match[2]){
|
||||
if(stack.at(-1).name===match[3].toLowerCase())stack.pop();
|
||||
else stack.at(-1).element.append(document.createTextNode(match[0]));
|
||||
}else{
|
||||
const name=match[3].toLowerCase(),element=node(tags[name]);
|
||||
stack.at(-1).element.append(element);stack.push({name,element});
|
||||
}
|
||||
last=match.index+match[0].length;
|
||||
}
|
||||
if(last<text.length)stack.at(-1).element.append(document.createTextNode(text.slice(last)));
|
||||
};
|
||||
for(const raw of String(body||'').split(/\r?\n/)){
|
||||
const line=raw.trim();
|
||||
if(!line){parent.append(node('div','toyxyz-wiki-gap'));continue;}
|
||||
if(line==='[quote]'){const quote=node('blockquote');parent.append(quote);parent=quote;continue;}
|
||||
if(line==='[/quote]'){parent=article;continue;}
|
||||
if(/^\[\/?expand(?:=.*)?\]$/.test(line))continue;
|
||||
const heading=/^h([4-6])(?:#[\w-]+)?\.\s*(.*)$/.exec(line);
|
||||
if(heading){const element=node(`h${Number(heading[1])-2}`);inline(element,heading[2]);parent.append(element);continue;}
|
||||
const list=/^(\*+)\s*(.*)$/.exec(line);
|
||||
if(list){
|
||||
const element=node('div','toyxyz-wiki-list-line');
|
||||
element.style.marginLeft=`${Math.min(list[1].length-1,5)*14}px`;
|
||||
element.append(node('span','toyxyz-wiki-bullet','•'));
|
||||
inline(element,list[2]);parent.append(element);continue;
|
||||
}
|
||||
const paragraph=node('p');inline(paragraph,line);parent.append(paragraph);
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
export function installWiki(nodeRef,widget,input,api) {
|
||||
const root=widget.element;
|
||||
const tabs=root?.querySelector?.('.toyxyz-wildcard-tabs');
|
||||
if(!tabs)return;
|
||||
const toggle=node('button','','Wiki');toggle.type='button';
|
||||
toggle.title='Browse the offline tag wiki';toggle.setAttribute('aria-expanded','false');tabs.append(toggle);
|
||||
|
||||
const panel=node('section','toyxyz-wiki-panel');panel.hidden=true;
|
||||
panel.setAttribute('aria-label','Tag Wiki browser');
|
||||
const header=node('header','toyxyz-wiki-header');
|
||||
header.title='Drag to move the wiki window';
|
||||
header.append(node('strong','','Tag Wiki Browser'),node('span','toyxyz-wiki-header-note','Offline · Read only'));
|
||||
const close=node('button','','×');close.type='button';close.title='Close wiki';
|
||||
close.setAttribute('aria-label','Close wiki');header.append(close);
|
||||
const layout=node('div','toyxyz-wiki-layout');
|
||||
const sidebar=node('aside','toyxyz-wiki-sidebar');sidebar.setAttribute('aria-label','Wiki categories');
|
||||
sidebar.append(node('div','toyxyz-wiki-eyebrow','LIBRARY'),node('h2','','Explore the Wiki'));
|
||||
const total=node('p','toyxyz-wiki-total','Loading categories…');sidebar.append(total);
|
||||
sidebar.append(node('h3','toyxyz-wiki-section-title','Categories'));
|
||||
const categories=node('nav','toyxyz-wiki-categories');sidebar.append(categories);
|
||||
sidebar.append(node('h3','toyxyz-wiki-section-title','Quick links'));
|
||||
const quickLinks=node('div','toyxyz-wiki-quicklinks');sidebar.append(quickLinks);
|
||||
sidebar.append(node('p','toyxyz-wiki-sidebar-help',
|
||||
'Categories are tag types. Browse topic lists in Tag Groups. Wiki links open here; Insert adds the current title to your prompt.'));
|
||||
|
||||
const browse=node('main','toyxyz-wiki-browse');
|
||||
browse.append(node('div','toyxyz-wiki-eyebrow','EXPLORE THE WIKI'));
|
||||
browse.append(node('h2','','Find a tag or topic'));
|
||||
browse.append(node('p','toyxyz-wiki-help','Search titles, aliases, and wiki text.'));
|
||||
const search=node('input','toyxyz-wiki-search');search.type='search';
|
||||
search.placeholder='Search wiki entries';search.autocomplete='off';
|
||||
search.setAttribute('aria-label','Search wiki entries');browse.append(search);
|
||||
const status=node('div','toyxyz-wiki-status');status.setAttribute('role','status');browse.append(status);
|
||||
const results=node('div','toyxyz-wiki-results');browse.append(results);
|
||||
const pager=node('div','toyxyz-wiki-pager');
|
||||
const previous=node('button','','← Previous'),pageLabel=node('span','','Page 1'),next=node('button','','Next →');
|
||||
previous.type=next.type='button';pager.append(previous,pageLabel,next);browse.append(pager);
|
||||
|
||||
const detail=node('section','toyxyz-wiki-detail');detail.setAttribute('aria-label','Wiki entry');
|
||||
detail.append(node('p','toyxyz-wiki-placeholder','Select an entry or follow a wiki link.'));
|
||||
const resizeGrip=node('div','toyxyz-wiki-resize-grip');
|
||||
resizeGrip.title='Drag to resize the wiki window';
|
||||
resizeGrip.setAttribute('role','separator');
|
||||
resizeGrip.setAttribute('aria-label','Resize wiki window');
|
||||
resizeGrip.tabIndex=0;
|
||||
layout.append(sidebar,browse,detail);panel.append(header,layout,resizeGrip);document.body.append(panel);
|
||||
|
||||
const state={open:false,disposed:false,category:'',query:'',page:1,
|
||||
searchToken:0,detailToken:0,history:[],frame:0,lastRect:'',timer:0,
|
||||
manualPosition:false,dragging:null,resizing:null};
|
||||
const readJSON=async url=>{
|
||||
const response=await api.fetchApi(url);
|
||||
const data=await response.json().catch(()=>({}));
|
||||
if(!response.ok)throw new Error(response.status===404&&url.includes('/categories')
|
||||
?'Restart ComfyUI to activate the Tag Wiki Browser.'
|
||||
:data.error||`Wiki request failed (HTTP ${response.status}).`);
|
||||
return data;
|
||||
};
|
||||
const place=()=>{
|
||||
if(!state.open||state.disposed)return;
|
||||
const rect=root.getBoundingClientRect(),right=window.innerWidth-rect.right-8,left=rect.left-8;
|
||||
const available=Math.max(right,left),width=Math.min(1050,Math.max(600,available-8));
|
||||
panel.style.width=`${Math.min(width,window.innerWidth-16)}px`;
|
||||
panel.style.left=`${right>=left&&right>=width?rect.right+8:
|
||||
left>=width?rect.left-width-8:Math.max(8,(window.innerWidth-width)/2)}px`;
|
||||
panel.style.top=`${Math.max(8,Math.min(rect.top,window.innerHeight-panel.offsetHeight-8))}px`;
|
||||
};
|
||||
const clampPosition=(left,top)=>{
|
||||
const width=panel.offsetWidth||Number.parseFloat(panel.style.width)||0;
|
||||
const height=panel.offsetHeight||0;
|
||||
panel.style.left=`${Math.max(8,Math.min(left,Math.max(8,window.innerWidth-width-8)))}px`;
|
||||
panel.style.top=`${Math.max(8,Math.min(top,Math.max(8,window.innerHeight-height-8)))}px`;
|
||||
};
|
||||
const fitManualPanel=()=>{
|
||||
const maxWidth=Math.max(100,window.innerWidth-16);
|
||||
const maxHeight=Math.max(100,window.innerHeight-16);
|
||||
if(panel.offsetWidth>maxWidth)panel.style.width=`${maxWidth}px`;
|
||||
if(panel.offsetHeight>maxHeight)panel.style.height=`${maxHeight}px`;
|
||||
clampPosition(Number.parseFloat(panel.style.left)||8,Number.parseFloat(panel.style.top)||8);
|
||||
};
|
||||
const resizeTo=(width,height)=>{
|
||||
const left=Number.parseFloat(panel.style.left)||8,top=Number.parseFloat(panel.style.top)||8;
|
||||
const maxWidth=Math.max(100,window.innerWidth-left-8);
|
||||
const maxHeight=Math.max(100,window.innerHeight-top-8);
|
||||
const minWidth=Math.min(560,maxWidth),minHeight=Math.min(320,maxHeight);
|
||||
panel.style.width=`${Math.max(minWidth,Math.min(width,maxWidth))}px`;
|
||||
panel.style.height=`${Math.max(minHeight,Math.min(height,maxHeight))}px`;
|
||||
state.manualPosition=true;
|
||||
};
|
||||
const watch=()=>{
|
||||
if(!state.open||state.disposed){state.frame=0;return;}
|
||||
const rect=root.getBoundingClientRect();
|
||||
const key=`${rect.left},${rect.top},${rect.width},${rect.height},${window.innerWidth},${window.innerHeight}`;
|
||||
if(key!==state.lastRect){
|
||||
state.lastRect=key;
|
||||
if(state.manualPosition)fitManualPanel();
|
||||
else place();
|
||||
}
|
||||
state.frame=requestAnimationFrame(watch);
|
||||
};
|
||||
header.addEventListener('pointerdown',event=>{
|
||||
if(event.button!==0||event.target===close||close.contains?.(event.target))return;
|
||||
const rect=panel.getBoundingClientRect();
|
||||
state.dragging={id:event.pointerId,offsetX:event.clientX-rect.left,offsetY:event.clientY-rect.top};
|
||||
header.setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();event.stopPropagation();
|
||||
});
|
||||
header.addEventListener('pointermove',event=>{
|
||||
if(!state.dragging||event.pointerId!==state.dragging.id)return;
|
||||
state.manualPosition=true;
|
||||
clampPosition(event.clientX-state.dragging.offsetX,event.clientY-state.dragging.offsetY);
|
||||
event.preventDefault();event.stopPropagation();
|
||||
});
|
||||
const endDrag=event=>{
|
||||
if(!state.dragging||event.pointerId!==state.dragging.id)return;
|
||||
state.dragging=null;
|
||||
if(header.hasPointerCapture?.(event.pointerId))header.releasePointerCapture(event.pointerId);
|
||||
event.stopPropagation();
|
||||
};
|
||||
header.addEventListener('pointerup',endDrag);
|
||||
header.addEventListener('pointercancel',endDrag);
|
||||
resizeGrip.addEventListener('pointerdown',event=>{
|
||||
if(event.button!==0)return;
|
||||
state.resizing={id:event.pointerId,x:event.clientX,y:event.clientY,
|
||||
width:panel.offsetWidth,height:panel.offsetHeight};
|
||||
resizeGrip.setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();event.stopPropagation();
|
||||
});
|
||||
resizeGrip.addEventListener('pointermove',event=>{
|
||||
if(!state.resizing||event.pointerId!==state.resizing.id)return;
|
||||
resizeTo(state.resizing.width+event.clientX-state.resizing.x,
|
||||
state.resizing.height+event.clientY-state.resizing.y);
|
||||
event.preventDefault();event.stopPropagation();
|
||||
});
|
||||
const endResize=event=>{
|
||||
if(!state.resizing||event.pointerId!==state.resizing.id)return;
|
||||
state.resizing=null;
|
||||
if(resizeGrip.hasPointerCapture?.(event.pointerId))resizeGrip.releasePointerCapture(event.pointerId);
|
||||
event.stopPropagation();
|
||||
};
|
||||
resizeGrip.addEventListener('pointerup',endResize);
|
||||
resizeGrip.addEventListener('pointercancel',endResize);
|
||||
resizeGrip.addEventListener('keydown',event=>{
|
||||
const step=event.shiftKey?5:20;
|
||||
if(!['ArrowRight','ArrowLeft','ArrowDown','ArrowUp'].includes(event.key))return;
|
||||
resizeTo(panel.offsetWidth+(event.key==='ArrowRight'?step:event.key==='ArrowLeft'?-step:0),
|
||||
panel.offsetHeight+(event.key==='ArrowDown'?step:event.key==='ArrowUp'?-step:0));
|
||||
event.preventDefault();event.stopPropagation();
|
||||
});
|
||||
const insert=title=>{
|
||||
const result=insertWikiTag(input.value,input.selectionStart,input.selectionEnd,title);
|
||||
input.value=result.value;input.focus();input.setSelectionRange(result.caret,result.caret);
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
widget.value=input.value;widget.callback?.(widget.value);nodeRef.setDirtyCanvas?.(true,true);
|
||||
};
|
||||
let openPage;
|
||||
const addCategory=(name,count)=>{
|
||||
const button=node('button','toyxyz-wiki-category');button.type='button';
|
||||
button.append(node('span','toyxyz-wiki-category-icon',name?name[0]:'∞'),
|
||||
node('span','toyxyz-wiki-category-name',name||'All entries'),
|
||||
node('span','toyxyz-wiki-category-count',number(count)));
|
||||
button.title=name||'All entries';
|
||||
button.addEventListener('click',()=>{
|
||||
state.category=name;state.page=1;
|
||||
for(const item of categories.children){
|
||||
const active=item===button;item.classList.toggle('toyxyz-wiki-active',active);
|
||||
item.setAttribute('aria-pressed',String(active));
|
||||
}
|
||||
loadResults();
|
||||
});
|
||||
button.classList.toggle('toyxyz-wiki-active',name===state.category);
|
||||
button.setAttribute('aria-pressed',String(name===state.category));categories.append(button);
|
||||
};
|
||||
const loadCategories=async()=>{
|
||||
try{
|
||||
const data=await readJSON('/toyxyz/booru-tags/wiki/categories');
|
||||
if(state.disposed)return;
|
||||
total.textContent=`${number(data.total)} entries`;
|
||||
categories.replaceChildren();addCategory('',data.total);
|
||||
for(const item of data.categories||[])addCategory(item.name,item.count);
|
||||
}catch(error){if(!state.disposed)total.textContent=error.message;}
|
||||
};
|
||||
const loadResults=async()=>{
|
||||
const token=++state.searchToken;
|
||||
status.textContent=state.query?`Results for “${state.query}”`:
|
||||
state.category?`${state.category} entries`:'Popular entries';
|
||||
results.replaceChildren(node('p','toyxyz-wiki-placeholder','Loading entries…'));
|
||||
const params=new URLSearchParams({q:state.query,category:state.category,page:String(state.page)});
|
||||
try{
|
||||
const data=await readJSON(`/toyxyz/booru-tags/wiki/search?${params}`);
|
||||
if(token!==state.searchToken||state.disposed)return;
|
||||
results.replaceChildren();
|
||||
if(!data.items?.length)results.append(node('p','toyxyz-wiki-placeholder','No matching entries. Try another search or category.'));
|
||||
for(const item of data.items||[]){
|
||||
const card=node('button','toyxyz-wiki-result');card.type='button';
|
||||
card.wikiPageId=item.id;
|
||||
const row=node('span','toyxyz-wiki-result-top');
|
||||
row.append(node('strong','',label(item.title)),node('small','',item.category||'Wiki-only'));
|
||||
card.append(row,node('span','toyxyz-wiki-result-snippet',item.snippet||'No description available.'),
|
||||
node('span','toyxyz-wiki-result-meta',item.other_names?.length
|
||||
?`Aliases: ${item.other_names.join(', ')}`:`${number(item.post_count)} posts`));
|
||||
card.title=`Read ${label(item.title)}`;
|
||||
card.addEventListener('click',()=>openPage({id:item.id}));results.append(card);
|
||||
}
|
||||
pageLabel.textContent=`Page ${data.page}`;previous.disabled=data.page<=1;next.disabled=!data.has_more;
|
||||
}catch(error){if(token===state.searchToken&&!state.disposed)results.replaceChildren(
|
||||
node('p','toyxyz-wiki-placeholder',error.message));}
|
||||
};
|
||||
const renderDetail=data=>{
|
||||
for(const card of results.children){
|
||||
card.classList?.toggle('toyxyz-wiki-selected',card.wikiPageId===data.id);
|
||||
}
|
||||
detail.replaceChildren();
|
||||
const top=node('div','toyxyz-wiki-detail-top');
|
||||
const back=node('button','','← Back');back.type='button';back.disabled=!state.history.length;
|
||||
back.addEventListener('click',()=>{
|
||||
const previousPage=state.history.pop();if(previousPage)openPage({...previousPage,remember:false});
|
||||
});
|
||||
top.append(back,node('span','',`WIKI ENTRY #${data.id}`));detail.append(top);
|
||||
detail.append(node('h2','toyxyz-wiki-detail-title',label(data.title)));
|
||||
detail.append(node('p','toyxyz-wiki-aliases',data.other_names?.length
|
||||
?`Aliases: ${data.other_names.join(' · ')}`:'No aliases listed.'));
|
||||
const facts=node('div','toyxyz-wiki-facts');
|
||||
facts.append(node('span','',`Type: ${data.category||'Wiki-only'}`),
|
||||
node('span','',`Posts: ${number(data.post_count)}`));
|
||||
if(data.updated_at)facts.append(node('span','',`Updated: ${data.updated_at.slice(0,10)}`));
|
||||
detail.append(facts);
|
||||
const actions=node('div','toyxyz-wiki-actions');
|
||||
if(data.category&&data.category.toLowerCase()!=='wiki-only'){
|
||||
const add=node('button','toyxyz-wiki-insert','Insert');add.type='button';
|
||||
add.title='Insert this tag at the prompt cursor';add.addEventListener('click',()=>insert(data.title));
|
||||
actions.append(add);
|
||||
}
|
||||
if(typeof data.source_url==='string'&&data.source_url.startsWith('https://danbooru.donmai.us/wiki_pages/')){
|
||||
const source=node('a','','Source ↗');source.href=data.source_url;source.target='_blank';
|
||||
source.rel='noopener noreferrer';actions.append(source);
|
||||
}
|
||||
if(actions.children.length)detail.append(actions);
|
||||
detail.append(renderWikiDText(data.body,title=>openPage({title})));
|
||||
detail.scrollTop=0;
|
||||
};
|
||||
openPage=async({id,title,remember=true})=>{
|
||||
const token=++state.detailToken;
|
||||
const params=new URLSearchParams(id?{id:String(id)}:{title});
|
||||
detail.replaceChildren(node('p','toyxyz-wiki-placeholder','Loading wiki entry…'));
|
||||
try{
|
||||
const data=await readJSON(`/toyxyz/booru-tags/wiki/page?${params}`);
|
||||
if(token!==state.detailToken||state.disposed)return;
|
||||
if(remember&&state.currentPage)state.history.push(state.currentPage);
|
||||
state.currentPage={id:data.id};renderDetail(data);
|
||||
}catch(error){if(token===state.detailToken&&!state.disposed)detail.replaceChildren(
|
||||
node('p','toyxyz-wiki-placeholder',error.message));}
|
||||
};
|
||||
for(const [title,name] of [['help:home','Wiki Help'],['tag_groups','Tag Group Index'],
|
||||
['help:glossary','Glossary']]){
|
||||
const button=node('button','',name+' ↗');button.type='button';
|
||||
button.addEventListener('click',()=>openPage({title}));quickLinks.append(button);
|
||||
}
|
||||
const setOpen=value=>{
|
||||
state.open=value;panel.hidden=!value;
|
||||
toggle.classList.toggle('toyxyz-tab-active',value);
|
||||
toggle.setAttribute('aria-expanded',String(value));
|
||||
if(value){
|
||||
state.lastRect='';
|
||||
if(state.manualPosition)fitManualPanel();
|
||||
else place();
|
||||
if(!state.frame)state.frame=requestAnimationFrame(watch);
|
||||
loadCategories();loadResults();
|
||||
openPage(state.currentPage?{id:state.currentPage.id,remember:false}:
|
||||
{title:'help:home',remember:false});
|
||||
search.focus();
|
||||
}else{
|
||||
if(state.dragging&&header.hasPointerCapture?.(state.dragging.id)){
|
||||
header.releasePointerCapture(state.dragging.id);
|
||||
}
|
||||
state.dragging=null;
|
||||
if(state.resizing&&resizeGrip.hasPointerCapture?.(state.resizing.id)){
|
||||
resizeGrip.releasePointerCapture(state.resizing.id);
|
||||
}
|
||||
state.resizing=null;
|
||||
clearTimeout(state.timer);cancelAnimationFrame(state.frame);state.frame=0;
|
||||
++state.searchToken;++state.detailToken;
|
||||
}
|
||||
};
|
||||
search.addEventListener('input',()=>{
|
||||
clearTimeout(state.timer);state.timer=setTimeout(()=>{
|
||||
state.query=search.value.trim();state.page=1;loadResults();
|
||||
},220);
|
||||
});
|
||||
search.addEventListener('keydown',event=>{
|
||||
if(event.key==='Enter'){
|
||||
event.preventDefault();clearTimeout(state.timer);
|
||||
state.query=search.value.trim();state.page=1;loadResults();
|
||||
}
|
||||
if(event.key==='Escape'){event.preventDefault();setOpen(false);}
|
||||
});
|
||||
previous.addEventListener('click',()=>{if(state.page>1){--state.page;loadResults();}});
|
||||
next.addEventListener('click',()=>{++state.page;loadResults();});
|
||||
toggle.addEventListener('click',()=>setOpen(!state.open));close.addEventListener('click',()=>setOpen(false));
|
||||
const removed=nodeRef.onRemoved;
|
||||
nodeRef.onRemoved=function(){state.disposed=true;setOpen(false);panel.remove();return removed?.apply(this,arguments);};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
export function wildcardSegments(text) {
|
||||
const result=[];let cursor=0;
|
||||
for(const match of text.matchAll(/__([^\r\n]+?)__/g)) {
|
||||
result.push({text:text.slice(cursor,match.index),wildcard:false},{text:match[0],wildcard:true});
|
||||
cursor=match.index+match[0].length;
|
||||
}
|
||||
result.push({text:text.slice(cursor),wildcard:false});return result;
|
||||
}
|
||||
export function insertWildcard(text,start,end,name) {
|
||||
const token=`__${name}__`;
|
||||
const prefix=start>0&&!/[\s,(]$/.test(text.slice(0,start))?', ':'';
|
||||
const suffix=end<text.length&&!/^[\s,):]/.test(text.slice(end))?', ':'';
|
||||
const inserted=prefix+token+suffix;
|
||||
return {value:text.slice(0,start)+inserted+text.slice(end),caret:start+prefix.length+token.length};
|
||||
}
|
||||
export function insertFavorite(text,caret,prompt) {
|
||||
const position=Math.max(0,Math.min(caret,text.length));
|
||||
return {value:text.slice(0,position)+prompt+text.slice(position),caret:position+prompt.length};
|
||||
}
|
||||
export function currentWildcardRange(text,selectionStart,selectionEnd=selectionStart) {
|
||||
if(selectionStart!==selectionEnd)return null;
|
||||
const cursor=Math.max(0,Math.min(selectionStart,text.length));
|
||||
const segmentStart=Math.max(text.lastIndexOf(',',cursor-1),text.lastIndexOf(';',cursor-1),text.lastIndexOf('\n',cursor-1))+1;
|
||||
const markers=[...text.slice(segmentStart,cursor).matchAll(/__/g)];
|
||||
if(markers.length%2===0)return null;
|
||||
const start=segmentStart+markers.at(-1).index;
|
||||
const query=text.slice(start+2,cursor);
|
||||
if(/[():]/.test(query))return null;
|
||||
const close=text.indexOf('__',cursor);
|
||||
const nextDelimiter=text.slice(cursor).search(/[,;\n]/);
|
||||
const end=close>=0&&(nextDelimiter<0||close-cursor<nextDelimiter)?close+2:cursor;
|
||||
return {start,end,query};
|
||||
}
|
||||
export function matchingWildcards(names,query,offset=0,limit=50) {
|
||||
const needle=query.toLocaleLowerCase();
|
||||
const matches=names.filter(name=>name.toLocaleLowerCase().includes(needle));
|
||||
matches.sort((a,b)=>Number(b.toLocaleLowerCase().startsWith(needle))-Number(a.toLocaleLowerCase().startsWith(needle))||a.localeCompare(b));
|
||||
return {suggestions:matches.slice(offset,offset+limit).map(tag=>({tag,category:'wildcard'})),has_more:offset+limit<matches.length};
|
||||
}
|
||||
export async function openWildcardFolder(api) {
|
||||
const response=await api.fetchApi('/toyxyz/booru-tags/wildcards/open-folder',{method:'POST'});
|
||||
if(response.ok)return;
|
||||
if(response.status===404||response.status===405)throw new Error('Restart ComfyUI to enable Open Folder, then refresh the browser.');
|
||||
let message=`Unable to open the wildcard folder (HTTP ${response.status}).`;
|
||||
try {
|
||||
const result=await response.json();
|
||||
if(typeof result.error==='string')message=result.error;
|
||||
}catch { /* Plain-text server errors are not JSON. Keep the useful HTTP error. */ }
|
||||
throw new Error(message);
|
||||
}
|
||||
export function wildcardTree(names) {
|
||||
const root={folders:new Map(),files:[]};
|
||||
for(const path of [...new Set(names)].sort()) {
|
||||
const parts=path.split('/');let branch=root;
|
||||
for(const name of parts.slice(0,-1)) {
|
||||
if(!branch.folders.has(name))branch.folders.set(name,{folders:new Map(),files:[]});
|
||||
branch=branch.folders.get(name);
|
||||
}
|
||||
branch.files.push({name:parts.at(-1),path});
|
||||
}
|
||||
return root;
|
||||
}
|
||||
export function installWildcards(node,widget,input,api) {
|
||||
if(!node.addDOMWidget)return;
|
||||
const root=document.createElement('div');root.className='toyxyz-wildcard-editor';
|
||||
const editor=document.createElement('div');editor.className='toyxyz-wildcard-text';
|
||||
const backdrop=document.createElement('div');backdrop.className='toyxyz-wildcard-backdrop';
|
||||
const highlight=document.createElement('pre');backdrop.append(highlight);
|
||||
input.classList.add('toyxyz-wildcard-input');
|
||||
input.style.cssText='position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;resize:none;margin:0;';
|
||||
editor.append(backdrop,input);
|
||||
const sidebar=document.createElement('aside');sidebar.className='toyxyz-wildcard-sidebar';
|
||||
const tabs=document.createElement('div');tabs.className='toyxyz-wildcard-tabs';tabs.setAttribute('role','tablist');
|
||||
const wildcardTab=document.createElement('button');wildcardTab.type='button';wildcardTab.textContent='W';wildcardTab.title='Wildcards';wildcardTab.setAttribute('aria-label','Wildcards');
|
||||
const favoriteTab=document.createElement('button');favoriteTab.type='button';favoriteTab.textContent='F';favoriteTab.title='Favorites';favoriteTab.setAttribute('aria-label','Favorites');
|
||||
tabs.append(wildcardTab,favoriteTab);
|
||||
const header=document.createElement('div');header.className='toyxyz-wildcard-header';
|
||||
const title=document.createElement('span');title.textContent='Wildcards';
|
||||
const folder=document.createElement('button');folder.type='button';folder.textContent='Folder';folder.title='Open wildcard folder';folder.setAttribute('aria-label','Open wildcard folder');
|
||||
const save=document.createElement('button');save.type='button';save.textContent='Save';save.title='Save the entire current prompt as a favorite';save.hidden=true;
|
||||
const refresh=document.createElement('button');refresh.type='button';refresh.textContent='↻';refresh.title='Refresh wildcard files';
|
||||
const list=document.createElement('div');list.className='toyxyz-wildcard-files';
|
||||
const favoritesList=document.createElement('div');favoritesList.className='toyxyz-favorite-files';favoritesList.hidden=true;
|
||||
const status=document.createElement('div');status.className='toyxyz-wildcard-status';status.setAttribute('role','status');status.hidden=true;
|
||||
header.append(title,folder,save,refresh);sidebar.append(tabs,header,status,list,favoritesList);root.append(editor,sidebar);
|
||||
root.style.setProperty('--toyxyz-node-bg',node.bgcolor||'#353535');
|
||||
let removed=false,request=0,favoriteRequest=0,activeTab='wildcards';
|
||||
let favoriteWritePending=false,reloadFavoritesAfterWrite=false;
|
||||
const openFolders=new Set();
|
||||
const previewCache=new Map();
|
||||
const preview=document.createElement('div');preview.className='toyxyz-wildcard-preview';preview.hidden=true;
|
||||
document.body?.append(preview);
|
||||
const favoriteMenu=document.createElement('div');favoriteMenu.className='toyxyz-favorite-menu';favoriteMenu.hidden=true;
|
||||
const editAction=document.createElement('button');editAction.type='button';editAction.textContent='Edit';
|
||||
const deleteAction=document.createElement('button');deleteAction.type='button';deleteAction.textContent='Delete';
|
||||
favoriteMenu.append(editAction,deleteAction);document.body?.append(favoriteMenu);
|
||||
const editOverlay=document.createElement('div');editOverlay.className='toyxyz-favorite-overlay';editOverlay.hidden=true;
|
||||
editOverlay.setAttribute('role','dialog');editOverlay.setAttribute('aria-modal','true');
|
||||
const editPanel=document.createElement('div');editPanel.className='toyxyz-favorite-dialog';
|
||||
const editTitle=document.createElement('h3');editTitle.textContent='Edit favorite';
|
||||
const editText=document.createElement('textarea');editText.setAttribute('aria-label','Favorite prompt');editText.maxLength=100000;
|
||||
const editStatus=document.createElement('div');editStatus.className='toyxyz-wildcard-status';editStatus.setAttribute('role','status');
|
||||
const editButtons=document.createElement('div');editButtons.className='toyxyz-favorite-dialog-actions';
|
||||
const cancelEdit=document.createElement('button');cancelEdit.type='button';cancelEdit.textContent='Cancel';
|
||||
const saveEdit=document.createElement('button');saveEdit.type='button';saveEdit.textContent='Save changes';
|
||||
editButtons.append(cancelEdit,saveEdit);editPanel.append(editTitle,editText,editStatus,editButtons);
|
||||
editOverlay.append(editPanel);document.body?.append(editOverlay);
|
||||
let selectedFavorite=null;
|
||||
const hideFavoriteMenu=()=>{favoriteMenu.hidden=true;};
|
||||
const closeFavoriteEditor=()=>{editOverlay.hidden=true;editStatus.textContent='';selectedFavorite=null;};
|
||||
favoriteMenu.addEventListener('pointerdown',event=>event.stopPropagation());
|
||||
editOverlay.addEventListener('pointerdown',event=>event.stopPropagation());
|
||||
const outsidePointer=()=>hideFavoriteMenu();
|
||||
const escapeFavorite=event=>{if(event.key==='Escape'){hideFavoriteMenu();closeFavoriteEditor();}};
|
||||
window.addEventListener('pointerdown',outsidePointer);
|
||||
window.addEventListener('keydown',escapeFavorite);
|
||||
let hovered=null,previewHideTimer;
|
||||
const hidePreview=()=>{clearTimeout(previewHideTimer);hovered=null;preview.hidden=true;};
|
||||
const schedulePreviewHide=()=>{
|
||||
clearTimeout(previewHideTimer);
|
||||
previewHideTimer=setTimeout(hidePreview,180);
|
||||
};
|
||||
preview.addEventListener('mouseenter',()=>clearTimeout(previewHideTimer));
|
||||
preview.addEventListener('mouseleave',hidePreview);
|
||||
const showPreview=async(button,path)=>{
|
||||
hovered=button;
|
||||
const rect=button.getBoundingClientRect();
|
||||
const width=Math.min(480,window.innerWidth-16);
|
||||
preview.style.left=`${Math.max(8,Math.min(rect.right+8,window.innerWidth-width-8))}px`;
|
||||
preview.style.top=`${Math.max(8,Math.min(rect.top,window.innerHeight-428))}px`;
|
||||
preview.textContent=`${path}.txt\n\nLoading…`;preview.hidden=false;
|
||||
try {
|
||||
if(!previewCache.has(path)){
|
||||
const response=await api.fetchApi(`/toyxyz/booru-tags/wildcards/preview?name=${encodeURIComponent(path)}`);
|
||||
if(!response.ok)throw new Error('Unable to load wildcard contents.');
|
||||
previewCache.set(path,await response.json());
|
||||
}
|
||||
if(removed||hovered!==button||activeTab!=='wildcards')return;
|
||||
const data=previewCache.get(path);
|
||||
preview.textContent=`${path}.txt\n\n${data.content||'(empty file)'}${data.truncated?'\n\n[Preview truncated]':''}`;
|
||||
}catch(error){if(!removed&&hovered===button)preview.textContent=`${path}.txt\n\n${error.message}`;}
|
||||
};
|
||||
const paint=()=>{
|
||||
const style=getComputedStyle(input);
|
||||
for(const key of ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','padding','borderWidth'])highlight.style[key]=style[key];
|
||||
highlight.style.width=`${input.clientWidth}px`;
|
||||
highlight.replaceChildren();
|
||||
for(const segment of wildcardSegments(input.value)) {
|
||||
const span=document.createElement(segment.wildcard?'mark':'span');span.textContent=segment.text;highlight.append(span);
|
||||
}
|
||||
highlight.append(document.createTextNode('\n'));
|
||||
highlight.style.transform=`translate(${-input.scrollLeft}px,${-input.scrollTop}px)`;
|
||||
};
|
||||
const load=async()=>{
|
||||
const id=++request;refresh.disabled=true;hidePreview();previewCache.clear();
|
||||
try {
|
||||
const response=await api.fetchApi('/toyxyz/booru-tags/wildcards');
|
||||
if(!response.ok)throw new Error('Wildcard files unavailable');
|
||||
const data=await response.json();if(removed||id!==request)return;
|
||||
window.dispatchEvent(new Event('toyxyz-wildcard-list-updated'));
|
||||
list.replaceChildren();
|
||||
const renderTree=(branch,parent,prefix='')=>{
|
||||
for(const [name,child] of branch.folders) {
|
||||
const path=prefix+name,folder=document.createElement('details');folder.className='toyxyz-wildcard-directory';folder.open=openFolders.has(path);
|
||||
const summary=document.createElement('summary'),icon=document.createElement('span'),label=document.createElement('span');
|
||||
icon.className='toyxyz-wildcard-folder-icon';icon.setAttribute('aria-hidden','true');label.textContent=name;
|
||||
summary.title='Expand or collapse '+name;summary.append(icon,label);
|
||||
const contents=document.createElement('div');contents.className='toyxyz-wildcard-directory-contents';
|
||||
folder.append(summary,contents);parent.append(folder);
|
||||
folder.addEventListener('toggle',()=>{if(folder.open)openFolders.add(path);else openFolders.delete(path);});
|
||||
renderTree(child,contents,path+'/');
|
||||
}
|
||||
for(const {name,path} of branch.files) {
|
||||
const button=document.createElement('button');button.type='button';button.textContent=name;button.title=`Insert __${path}__`;
|
||||
button.addEventListener('mouseenter',()=>{button.title='';showPreview(button,path);});
|
||||
button.addEventListener('mouseleave',()=>{button.title=`Insert __${path}__`;schedulePreviewHide();});
|
||||
button.addEventListener('pointerdown',event=>event.preventDefault());
|
||||
button.addEventListener('click',()=>{
|
||||
const next=insertWildcard(input.value,input.selectionStart,input.selectionEnd,path);
|
||||
input.value=next.value;input.focus();input.setSelectionRange(next.caret,next.caret);
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
widget.value=input.value;widget.callback?.(widget.value);node.setDirtyCanvas?.(true,true);
|
||||
});parent.append(button);
|
||||
}
|
||||
};
|
||||
renderTree(wildcardTree(data.wildcards||[]),list);
|
||||
if(!list.children.length)list.textContent='Add .txt files to wildcards/';
|
||||
}catch(error){if(!removed&&id===request)list.textContent='Unable to load wildcard files. Use Refresh to retry.';}
|
||||
finally{if(!removed&&id===request)refresh.disabled=false;}
|
||||
};
|
||||
const renderFavorites=prompts=>{
|
||||
hideFavoriteMenu();
|
||||
favoritesList.replaceChildren();
|
||||
for(const [index,prompt] of prompts.entries()) {
|
||||
const button=document.createElement('button');button.type='button';
|
||||
button.textContent=`${index+1}. ${prompt.replace(/\s+/g,' ').slice(0,70)}`;
|
||||
button.title=prompt;button.setAttribute('aria-label',`Insert favorite ${index+1}: ${prompt}`);
|
||||
button.addEventListener('contextmenu',event=>{
|
||||
event.preventDefault();event.stopPropagation();
|
||||
selectedFavorite={index,prompt};
|
||||
favoriteMenu.style.left=`${Math.max(8,Math.min(event.clientX,window.innerWidth-150))}px`;
|
||||
favoriteMenu.style.top=`${Math.max(8,Math.min(event.clientY,window.innerHeight-90))}px`;
|
||||
favoriteMenu.hidden=false;
|
||||
});
|
||||
button.addEventListener('pointerdown',event=>event.preventDefault());
|
||||
button.addEventListener('click',()=>{
|
||||
const next=insertFavorite(input.value,input.selectionStart,prompt);
|
||||
input.value=next.value;input.focus();input.setSelectionRange(next.caret,next.caret);
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
widget.value=input.value;widget.callback?.(widget.value);node.setDirtyCanvas?.(true,true);
|
||||
});
|
||||
favoritesList.append(button);
|
||||
}
|
||||
if(!prompts.length)favoritesList.textContent='No favorites saved yet.';
|
||||
};
|
||||
const beginFavoriteWrite=()=>{
|
||||
if(favoriteWritePending)throw new Error('Wait for the current favorite change to finish.');
|
||||
favoriteWritePending=true;
|
||||
++favoriteRequest; // Invalidate any GET started before this write.
|
||||
refresh.disabled=true;
|
||||
};
|
||||
const finishFavoriteWrite=()=>{
|
||||
favoriteWritePending=false;
|
||||
if(reloadFavoritesAfterWrite&&!removed){
|
||||
reloadFavoritesAfterWrite=false;
|
||||
loadFavorites();
|
||||
}else if(!removed)refresh.disabled=false;
|
||||
};
|
||||
const changeFavorite=async(action,selection,replacement)=>{
|
||||
beginFavoriteWrite();
|
||||
try {
|
||||
const response=await api.fetchApi('/toyxyz/booru-tags/favorites/change',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({action,index:selection.index,original:selection.prompt,prompt:replacement})});
|
||||
const data=await response.json().catch(()=>({}));
|
||||
if(!response.ok)throw new Error(data.error||`Unable to ${action} favorite.`);
|
||||
if(!Array.isArray(data.favorites))throw new Error('Invalid favorites response. Refresh the list.');
|
||||
if(!removed&&!reloadFavoritesAfterWrite)renderFavorites(data.favorites);
|
||||
}finally{finishFavoriteWrite();}
|
||||
};
|
||||
editAction.addEventListener('click',()=>{
|
||||
if(!selectedFavorite)return;
|
||||
hideFavoriteMenu();editText.value=selectedFavorite.prompt;
|
||||
editStatus.textContent='';editOverlay.hidden=false;editText.focus();
|
||||
});
|
||||
deleteAction.addEventListener('click',async()=>{
|
||||
const selection=selectedFavorite;hideFavoriteMenu();
|
||||
if(!selection||!window.confirm(`Delete this favorite?\n\n${selection.prompt.slice(0,200)}`))return;
|
||||
deleteAction.disabled=true;status.hidden=true;
|
||||
try {await changeFavorite('delete',selection);selectedFavorite=null;}
|
||||
catch(error){if(!removed){status.textContent=error.message;status.hidden=false;}}
|
||||
finally{if(!removed)deleteAction.disabled=false;}
|
||||
});
|
||||
cancelEdit.addEventListener('click',closeFavoriteEditor);
|
||||
saveEdit.addEventListener('click',async()=>{
|
||||
if(!selectedFavorite)return;
|
||||
saveEdit.disabled=true;editStatus.textContent='';
|
||||
try {await changeFavorite('edit',selectedFavorite,editText.value);closeFavoriteEditor();}
|
||||
catch(error){if(!removed)editStatus.textContent=error.message;}
|
||||
finally{if(!removed)saveEdit.disabled=false;}
|
||||
});
|
||||
const loadFavorites=async()=>{
|
||||
if(favoriteWritePending){reloadFavoritesAfterWrite=true;return;}
|
||||
const id=++favoriteRequest;refresh.disabled=true;
|
||||
try {
|
||||
const response=await api.fetchApi('/toyxyz/booru-tags/favorites/list');
|
||||
const data=await response.json().catch(()=>({}));
|
||||
if(response.status===404)throw new Error('Restart ComfyUI to activate the updated Favorites route.');
|
||||
if(!response.ok)throw new Error(data.error||`Unable to load favorites (HTTP ${response.status}).`);
|
||||
if(!Array.isArray(data.favorites))throw new Error('Invalid favorites response. Try Refresh.');
|
||||
if(removed||id!==favoriteRequest)return;
|
||||
renderFavorites(data.favorites);status.textContent='';status.hidden=true;
|
||||
}catch(error){if(!removed&&id===favoriteRequest){status.textContent=error.message;status.hidden=false;}}
|
||||
finally{if(!removed&&id===favoriteRequest)refresh.disabled=false;}
|
||||
};
|
||||
const selectTab=kind=>{
|
||||
hidePreview();hideFavoriteMenu();closeFavoriteEditor();activeTab=kind;const favorites=kind==='favorites';
|
||||
wildcardTab.setAttribute('aria-selected',String(!favorites));favoriteTab.setAttribute('aria-selected',String(favorites));
|
||||
wildcardTab.classList.toggle('toyxyz-tab-active',!favorites);favoriteTab.classList.toggle('toyxyz-tab-active',favorites);
|
||||
title.textContent=favorites?'Favorites':'Wildcards';folder.hidden=favorites;save.hidden=!favorites;
|
||||
list.hidden=favorites;favoritesList.hidden=!favorites;refresh.title=favorites?'Refresh favorites':'Refresh wildcard files';
|
||||
status.hidden=true;status.textContent='';
|
||||
if(favorites)loadFavorites();
|
||||
};
|
||||
wildcardTab.addEventListener('click',()=>selectTab('wildcards'));
|
||||
favoriteTab.addEventListener('click',()=>selectTab('favorites'));
|
||||
refresh.addEventListener('click',()=>activeTab==='favorites'?loadFavorites():load());
|
||||
const favoritePathChanged=()=>{if(activeTab==='favorites')loadFavorites();};
|
||||
window.addEventListener('toyxyz-favorites-changed',favoritePathChanged);
|
||||
save.addEventListener('click',async()=>{
|
||||
save.disabled=true;status.hidden=true;status.textContent='';
|
||||
try {
|
||||
beginFavoriteWrite();
|
||||
try {
|
||||
const response=await api.fetchApi('/toyxyz/booru-tags/favorites',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt:input.value})});
|
||||
const data=await response.json().catch(()=>({}));
|
||||
if(!response.ok)throw new Error(data.error||'Unable to save favorite.');
|
||||
if(!Array.isArray(data.favorites))throw new Error('Invalid favorites response. Refresh the list.');
|
||||
if(!removed&&!reloadFavoritesAfterWrite)renderFavorites(data.favorites);
|
||||
}finally{finishFavoriteWrite();}
|
||||
}catch(error){if(!removed){status.textContent=error.message;status.hidden=false;}}
|
||||
finally{if(!removed)save.disabled=false;}
|
||||
});
|
||||
window.addEventListener('toyxyz-wildcards-changed',load);
|
||||
folder.addEventListener('click',async()=>{
|
||||
folder.disabled=true;status.hidden=true;status.textContent='';
|
||||
try {
|
||||
await openWildcardFolder(api);
|
||||
}catch(error){if(!removed){status.textContent=error.message;status.hidden=false;}}
|
||||
finally{if(!removed)folder.disabled=false;}
|
||||
});
|
||||
input.addEventListener('input',paint);input.addEventListener('scroll',paint);
|
||||
// Keep the original DOM widget as the sole owner. Hiding it while moving
|
||||
// its textarea into another DOM widget lets ComfyUI hide/reparent the input.
|
||||
// Its existing getValue/setValue and textarea bindings remain untouched.
|
||||
widget.element=root;
|
||||
const dom=widget;
|
||||
const height=()=>Math.max(180,(node.size?.[1]||340)-85);
|
||||
dom.computeSize=width=>[width||node.size[0],height()];
|
||||
dom.computeLayoutSize=()=>({minHeight:height(),maxHeight:height()});
|
||||
const observer=typeof ResizeObserver==='undefined'?null:new ResizeObserver(paint);observer?.observe(input);
|
||||
const configure=node.onConfigure;node.onConfigure=function(){const result=configure?.apply(this,arguments);paint();return result;};
|
||||
const remove=node.onRemoved;node.onRemoved=function(){removed=true;request++;favoriteRequest++;hidePreview();preview.remove?.();favoriteMenu.remove?.();editOverlay.remove?.();observer?.disconnect();input.removeEventListener('input',paint);input.removeEventListener('scroll',paint);window.removeEventListener('pointerdown',outsidePointer);window.removeEventListener('keydown',escapeFavorite);window.removeEventListener('toyxyz-wildcards-changed',load);window.removeEventListener('toyxyz-favorites-changed',favoritePathChanged);return remove?.apply(this,arguments);};
|
||||
node.setSize?.([Math.max(node.size[0],520),Math.max(node.size[1],340)]);
|
||||
selectTab('wildcards');paint();load();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const MIRROR_PROPERTIES = [
|
||||
"direction", "boxSizing", "width", "height", "overflowX", "overflowY",
|
||||
"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
|
||||
"borderStyle", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
|
||||
"fontStyle", "fontVariant", "fontWeight", "fontStretch", "fontSize", "fontSizeAdjust",
|
||||
"lineHeight", "fontFamily", "textAlign", "textTransform", "textIndent",
|
||||
"letterSpacing", "wordSpacing", "tabSize",
|
||||
];
|
||||
|
||||
export function scaleCaretPosition(rect, offsetWidth, offsetHeight, caretLeft, caretTop,
|
||||
scrollLeft, scrollTop, caretHeight) {
|
||||
const scaleX = offsetWidth ? rect.width / offsetWidth : 1;
|
||||
const scaleY = offsetHeight ? rect.height / offsetHeight : scaleX;
|
||||
const localTop = caretTop - scrollTop;
|
||||
return {
|
||||
left: rect.left + (caretLeft - scrollLeft) * scaleX,
|
||||
top: rect.top + localTop * scaleY,
|
||||
bottom: rect.top + (localTop + caretHeight) * scaleY,
|
||||
scale: scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
// Measure the caret in a hidden textarea-shaped mirror, then account for scrolling.
|
||||
export function textareaCaretRect(input, index = input.selectionStart) {
|
||||
index = Math.max(0, Math.min(index, input.value.length));
|
||||
const style = getComputedStyle(input);
|
||||
const mirror = document.createElement("div");
|
||||
mirror.style.position = "absolute";
|
||||
mirror.style.left = "-10000px";
|
||||
mirror.style.top = "0";
|
||||
mirror.style.visibility = "hidden";
|
||||
mirror.style.whiteSpace = "pre-wrap";
|
||||
mirror.style.overflowWrap = "break-word";
|
||||
for (const property of MIRROR_PROPERTIES) mirror.style[property] = style[property];
|
||||
mirror.style.overflowY = style.overflowY === "scroll" || input.scrollHeight > input.clientHeight
|
||||
? "scroll" : "hidden";
|
||||
mirror.textContent = input.value.slice(0, index);
|
||||
const marker = document.createElement("span");
|
||||
// The remaining text belongs inside the marker so wrapping matches the
|
||||
// textarea at the measured position (as in comfyui-custom-scripts).
|
||||
marker.textContent = input.value.slice(index) || ".";
|
||||
mirror.append(marker);
|
||||
document.body.append(mirror);
|
||||
const rect = input.getBoundingClientRect();
|
||||
const caretHeight = Number.parseFloat(style.lineHeight) || marker.offsetHeight ||
|
||||
Number.parseFloat(style.fontSize) || 13;
|
||||
const result = scaleCaretPosition(rect, input.offsetWidth, input.offsetHeight,
|
||||
marker.offsetLeft + (Number.parseFloat(style.borderLeftWidth) || 0),
|
||||
marker.offsetTop + (Number.parseFloat(style.borderTopWidth) || 0),
|
||||
input.scrollLeft, input.scrollTop, caretHeight);
|
||||
mirror.remove();
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Wildcards
|
||||
|
||||
Place UTF-8 `.txt` files here. Each non-empty line is one complete random option.
|
||||
Lines may contain multiple tags, sentences, weights, or nested wildcards.
|
||||
|
||||
`cloth.txt` is called with `__cloth__`; `outfits/cloth.txt` with `__outfits/cloth__`.
|
||||
`(__cloth__:1.5)` keeps the weight around the selected text.
|
||||
Blank lines are skipped. Missing, empty, unreadable, or cyclic wildcards stay literal
|
||||
instead of stopping generation. Each occurrence samples independently on each run.
|
||||
Use Refresh in the node's Wildcards tab after adding or removing files.
|
||||
In the prompt field, type `__` to search wildcard filenames inline; select a
|
||||
suggestion to insert its `__name__` call.
|
||||
|
||||
To use another folder, set **Settings → toyxyz_test_nodes → Wildcards →
|
||||
Wildcard folder path** to an existing absolute directory on the ComfyUI
|
||||
computer, or click Browse to select one in a native Windows folder picker.
|
||||
Leave the path blank to use this bundled folder. The sidebar and Folder
|
||||
button follow the active directory. The setting is server-wide for this node
|
||||
installation and is saved across restarts; it is not stored in workflows.
|
||||
Reference in New Issue
Block a user