Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc4a7742e | ||
|
|
08d37f680e | ||
|
|
2ee9b90d76 | ||
|
|
edecbf7bfc | ||
|
|
53fe2c0f69 | ||
|
|
0e9201a4d6 | ||
|
|
90cd14b430 | ||
|
|
50c4a7012b | ||
|
|
4574c910b7 | ||
|
|
aae467f1c7 | ||
|
|
557d135c1a | ||
|
|
d8788642af | ||
|
|
092569c608 | ||
|
|
4a0e20352e | ||
|
|
95b3b0cad6 | ||
|
|
f9c1ed5a23 | ||
|
|
95539b2b8c | ||
|
|
965f8646b2 |
@@ -12,6 +12,8 @@ TMDB_API_KEY=
|
||||
ENABLE_CIVITAI_SCRAPING=false
|
||||
ENABLE_TMDB_SCRAPING=false
|
||||
ENABLE_LORA_USAGE=true
|
||||
# Track and display Character Preset Picker usage counts and sorting badges
|
||||
ENABLE_CHARACTER_USAGE=true
|
||||
ENABLE_PERSISTENT_QUEUE=true
|
||||
ENABLE_TRAY_ICON=false
|
||||
ENABLE_ASSETS_RESTORE=true
|
||||
|
||||
@@ -2,6 +2,10 @@ name: Publish to ComfyUI Registry
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- pyproject.toml
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -22,3 +26,67 @@ jobs:
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
- name: Attach CHANGELOG section to the published version
|
||||
env:
|
||||
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
API = "https://api.comfy.org"
|
||||
PUBLISHER = "kofiblto"
|
||||
NODE = "saturnnodes"
|
||||
TOKEN = os.environ["REGISTRY_ACCESS_TOKEN"]
|
||||
|
||||
with open("pyproject.toml", "rb") as handle:
|
||||
version = tomllib.load(handle)["project"]["version"]
|
||||
|
||||
with open("CHANGELOG.md", encoding="utf-8") as handle:
|
||||
changelog = handle.read()
|
||||
|
||||
# Grab the "## [<version>] ... " block up to the next "## [" heading.
|
||||
pattern = re.compile(
|
||||
rf"^##\s*\[{re.escape(version)}\][^\n]*\n(.*?)(?=^##\s*\[|\Z)",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
match = pattern.search(changelog)
|
||||
if not match or not match.group(1).strip():
|
||||
print(f"::warning::No CHANGELOG.md section found for {version}; the registry version stays without a changelog.")
|
||||
sys.exit(0)
|
||||
|
||||
body = match.group(1).strip()
|
||||
|
||||
def call(method, path, payload=None):
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{API}{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
return json.loads(response.read().decode("utf-8") or "{}")
|
||||
|
||||
versions = call("GET", f"/nodes/{NODE}/versions")
|
||||
target = next((item for item in versions if item.get("version") == version), None)
|
||||
if not target:
|
||||
print(f"::warning::Version {version} is not listed on the registry yet; skipping the changelog update.")
|
||||
sys.exit(0)
|
||||
|
||||
updated = call(
|
||||
"PUT",
|
||||
f"/publishers/{PUBLISHER}/nodes/{NODE}/versions/{target['id']}",
|
||||
{"changelog": body, "deprecated": False},
|
||||
)
|
||||
print(f"Registry changelog for {version} updated ({len(body)} chars).")
|
||||
PY
|
||||
|
||||
@@ -50,6 +50,7 @@ ComfyUI-SaturnNodes/
|
||||
│ ├── aspect_ratio.py # Text & preview aspect ratio calculation nodes
|
||||
│ ├── assets_restore.py # Startup output-folder history restoration
|
||||
│ ├── auto_watcher.py # Automated folder watch image loaders
|
||||
│ ├── character_presets.py # Character preset cards, visual picker & LoRA applier
|
||||
│ ├── decision_node.py # Inline pause/gate decision node & notifications
|
||||
│ ├── generation_times.py # Prompt duration recording for restored asset history
|
||||
│ ├── image_loader.py # Visual image browser & EXIF/PNG metadata parser
|
||||
@@ -78,6 +79,7 @@ ComfyUI-SaturnNodes/
|
||||
│ ├── logo.png # Registry icon (400x400)
|
||||
│ └── js/ # Node-specific UI widgets (visual pickers, buttons, aspect preview)
|
||||
│ ├── auth_helper.js # CSRF token helper shared by all frontend extensions
|
||||
│ ├── character_presets.js # Character preset card grid, editor modal & detect preview
|
||||
│ ├── copy_prompt.js
|
||||
│ ├── decision_node.js
|
||||
│ ├── folder_lora_loader.js
|
||||
@@ -289,7 +291,7 @@ Use atomic, descriptive English commit headers:
|
||||
- `Release: ...` (Consolidated version release merge)
|
||||
|
||||
### 6.4 Registry Publishing (ComfyUI Registry)
|
||||
- Registry node name: `saturnnodes` (Publisher `KOFiblto`, `DisplayName = ComfyUI-SaturnNodes`). **Published versions are immutable** — always bump the version before publishing a change.
|
||||
- `.github/workflows/publish_action.yml` publishes automatically when `pyproject.toml` changes on `main` (and can also be triggered manually via workflow dispatch). It requires the `REGISTRY_ACCESS_TOKEN` repository secret.
|
||||
- Registry node name: `saturnnodes` (Publisher `kofiblto`, `DisplayName = ComfyUI-SaturnNodes`). **Published versions are immutable** — always bump the version before publishing a change.
|
||||
- `.github/workflows/publish_action.yml` publishes automatically when `pyproject.toml` changes on `main` (and can also be triggered manually via workflow dispatch). It requires the `REGISTRY_ACCESS_TOKEN` repository secret, and after publishing it copies the matching `CHANGELOG.md` section into the registry version's changelog field via `PUT /publishers/{publisherId}/nodes/{nodeId}/versions/{versionId}`.
|
||||
- `.comfyignore` excludes development files (tests, CI, contributor docs) from the published package archive.
|
||||
- Icon (`web/logo.png`) must be square, 400x400 or smaller.
|
||||
|
||||
@@ -5,6 +5,49 @@ All notable changes to `ComfyUI-SaturnNodes` will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.7.0] - In Development
|
||||
|
||||
### Added
|
||||
- **Character Preset Picker**: New visual card browser that selects character/style presets from `ComfyUI/user/default/SaturnNodes/character_presets/` and emits their LoRA stack, prompt snippet and negative prompt. Connecting a prompt text makes it pick cards by any of their trigger words instead.
|
||||
- **Apply Character LoRAs**: Companion node that applies the picker's stack to `MODEL`/`CLIP`, using each card entry's own model and clip strength and skipping missing files.
|
||||
- **Character Card Editor**: Wide modal editor with a 30%-wide image column; the card id, an editable **Display name** (shown on the tile and node), the trigger list and the side-by-side prompt/negative fields sit beside it, followed by multi-LoRA rows with per-entry strengths. The preview uses the same `3:4` ratio as picker tiles, the prompt fields grow to fill the available height, icon buttons upload/remove the thumbnail, and **Fetch CivitAI Image** reports `LoRA not found` / `Image not found` / `Image applied!` inline (with no reserved empty status gap). Cards can be duplicated into a prefilled editor, renamed across folders, and deleted from the picker.
|
||||
- **Picker Selection Modes**: `Sequential`, `Random`, `Random (No Replace)`, `All` and `None` over the cards the user clicked, with persisted cycle state and post-run flashing of the cards actually used.
|
||||
- **Character Preset Usage Tracking**: New `ENABLE_CHARACTER_USAGE` setting (ComfyUI Settings → `Enable Character Preset Usage Tracking`) gates usage counting, tile badges and the `Usage` sort options independently from LoRA usage. Disabling it keeps the existing `character_usage.json` history. The picker now also prunes selections whose card was deleted or moved, using the full card-id list returned by `get_presets`.
|
||||
- **Character-Driven LoRA Usage**: Applying a character card's LoRA stack through **Apply Character LoRAs** now increments the same `lora_usage.json` counters used by the Visual LoRA Loader, so character renders update LoRA badges and usage sorting too.
|
||||
- **Character Picker Alignment & Maintenance**: The picker now has **Left / Right** content alignment (`content_alignment`), plus confirmed **↺ Counter**, **🧹 Usage** and **🗑 Cache** reset buttons that clear the per-node cycle state, `character_usage.json`, or the shared thumbnail cache without touching cards or LoRA files.
|
||||
- **Character Picker Rank Borders**: Gold/silver/bronze borders mark the three most-used cards and an animated RGB border marks cards with 1,000+ uses, at 150% of the normal tile border thickness. Controlled by the new `SaturnNodes.🖼️ Visual Loaders.05b_CharacterRankBorders` setting (default on); usage count badges stay visible in every mode.
|
||||
- **Character Picker Corner Usage Badges**: Usage counts now render as a flap tucked into the top-right corner of each tile (flush with the top and right edges, rounded on the inner edge) whose size grows logarithmically with the usage count.
|
||||
- **Picker Control-Bar Card Actions**: **Edit**, **Duplicate** and **Delete** moved from per-tile overlays into the top control bar; the icon buttons are enabled only when exactly one card is selected and greyed out otherwise.
|
||||
- **Floating Dock Idle Modes**: New `Dock Mode when Idle` setting with three choices — `Auto-Hide`, `Keep Dock Visible`, and `View Image`. `View Image` keeps the last latent on screen, swaps to the decoded final output image from the `executed` event once it has loaded, and holds it until the next run's first latent. `Auto-Hide` and `View Image` keep the dock hidden at startup until the first latent arrives.
|
||||
- **Character Picker Selection Count**: A line under the card grid now reports how many cards are selected, e.g. `3 cards selected`, updating on every click, range, folder toggle or prune.
|
||||
- **Character Preview Scraping**: The hidden `_scrape_on_new` flag starts the shared CivitAI/TMDB preview scraper after saving or importing cards (and on picker refresh), with no visible button.
|
||||
- **Selection-Order Badges**: Both the Character Preset Picker and the Visual LoRA Loader now number selected cards in the order they were clicked, matching the order the LoRAs are actually applied.
|
||||
- **Character Preset Tests**: 54 Python tests for card normalization, path containment, LoRA resolution, read-route authorization, import scanning and naming, matching order, selection modes, prompt assembly and the applier, plus a fake-DOM regression test for the picker grid, selection numbering, text-driven highlighting, the LoRA import modal and post-run feedback.
|
||||
|
||||
### Changed
|
||||
- **Automatic Text Detection**: The picker's `mode` widget is gone. A non-empty connected `text` input switches to trigger detection; an empty or unconnected text falls back to the clicked cards, so the node never errors for a missing prompt.
|
||||
- **Detect Feedback**: Trigger matches use a green overlay wash plus a `MATCH` pill, visually distinct from the green ring used by manual selection, and the grid becomes read-only while text detection is active.
|
||||
- **Picker Layout Matches the Visual LoRA Loader**: The card grid is now grouped into collapsible folder sections (name, card count, collapse arrow, select-all checkbox, remembered per folder) instead of one flat list. Tiles use a fixed `80px` / `3:4` size and wrap, so resizing the node changes how many cards fit per row but never their height.
|
||||
- **Picker Control Bar**: `selection_mode`, `sort_presets_by`, `sort_folders_by`, `content_alignment` and `display_mode` moved out of the native widget stack into a loader-style control bar (**Mode**, **Sort**, **Folders**, **Align**, **View**, **Reset**) that drives the hidden widgets, next to icon-only **New**, **Refresh** and **Open folder** buttons. The bar buttons are 125% of the Visual LoRA Loader's size. **Scroll** keeps a fixed-height scrolling grid; **Show All** grows the node to fit every card and returns to your previous height afterwards.
|
||||
- **Picker Selection & Pruning**: A plain click now selects only that card, **Ctrl/Cmd+click** toggles individual cards and **Shift+click** selects the visual range from the last clicked card (matching the Visual LoRA Loader); clicking the only selected card clears it. Deleted or moved cards are pruned from the selection on refresh instead of staying stuck as an invisible selection.
|
||||
- **New Card Id Suggestions**: New cards start from the current folder with a normalized single-slash id and a unique suffix, so repeated **New** clicks no longer suggest `Krea2\/New Character` or overwrite the previous card.
|
||||
- **Native Picker Widgets**: The folder filter is a real ComfyUI `folder` widget again (empty = all presets) instead of a DOM dropdown, and a native `tile_size` slider (`50`–`180`, saved per node) controls the card width.
|
||||
- **Picker Tile Actions**: **Edit / Duplicate / Delete** live in the top control bar (rather than per-tile overlays) and require exactly one selected card. **Duplicate** opens a prefilled editor with `(1)` appended to the id instead of writing a copy immediately.
|
||||
- **Multi-Trigger Cards & Prompt Snippets**: Cards support a canonical `triggers` list (the legacy single `trigger` is still read and written as the first entry). Prompt detection checks every trigger, and the new **Prompt snippet** field becomes the `positive` output; cards without a snippet fall back to their display name, so existing cards keep working unchanged.
|
||||
|
||||
### Removed
|
||||
- **Character Picker Import & Search Controls**: The **Import** button and search field were removed from the picker control bar. Folder filtering uses the native `folder` widget, and cards are created with the **+** button or by dropping `<Name>.json` files into the presets folder.
|
||||
|
||||
### Fixed
|
||||
- **Queue Overlay Height Per Screen**: The saved queue height is now correctly restored per monitor/window profile on startup, no longer overwritten by the global setting during initialization. Set it to `0` (Native) and the stale per-screen value is cleared. On startup and on every window resize the height is clamped to the space actually available in the viewport, so a size saved on a 4K monitor cannot overflow a laptop screen.
|
||||
- **Character Picker `display_mode` Invalid Input**: `sort_folders_by` is appended after the existing widgets instead of being inserted before `folder_position`, so existing workflows no longer shift their saved widget values. The picker also re-validates every combo in `onConfigure` (after workflow values are applied), repairing already-saved shifted values such as `display_mode = "Folders First"` back to `Scrollable` instead of failing validation.
|
||||
- **Character Picker Blank Grid**: The picker's read routes (`get_presets`, `get_preview`, `get_loras`) no longer require the CSRF header, which plain `fetch` and `<img>` requests cannot send. The card grid, thumbnails and folder filter now populate on localhost, while every mutating route keeps the strict token check. The picker also renders immediately instead of looking empty while loading.
|
||||
- **Floating Latent Dock Stale Preview**: With *Auto-hide when idle* enabled, the dock no longer pops up at `execution_start` showing the previous run's latent. It stays hidden until the first fresh preview of the current run arrives.
|
||||
- **Inherited LoRA Preview Extension**: **Fetch CivitAI Image** (formerly *Use LoRA preview*) now copies the image with its real extension instead of always writing a `.png`, so JPEG/WebP previews are stored with a matching extension and any stale sidecar of another type is removed. The endpoint now distinguishes a missing LoRA (`LoRA not found`) from a LoRA without a preview (`Image not found`), and the editor shows the result inline.
|
||||
- **Editor LoRA Names With `/`**: The card editor and `get_loras` now normalize backslashes to forward slashes and compare names case-insensitively, so LoRAs stored as `Krea2/Celebrity/...` are no longer flagged as `(missing)` on Windows.
|
||||
|
||||
---
|
||||
|
||||
## [2.6.3] - 2026-10-30 [Stable]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,6 +12,7 @@ A unified workflow control and productivity suite for **ComfyUI**. Both **Fronte
|
||||
- **Stereoscopic 3D & Cross-Eye Generator**: Convert 2D artwork and renders into 3D stereograms, parallel views, and red-cyan anaglyphs with automatic Depth Anything V2 fallback.
|
||||
- **Batch Prompting & Iteration**: Queue multiple prompts automatically with delimiter splits, regex blocks, and persistent queue state tracking.
|
||||
- **Visual Thumbnail Browsing**: Browse LoRAs and image folders with auto-scraped Civitai & TMDB previews, ranking badges, and multi-selection.
|
||||
- **Character Preset Cards**: Save characters as visual cards (thumbnail, multiple trigger words, prompt snippet, LoRA stack) and either click them or let a connected prompt text pick them by trigger word.
|
||||
- **Real-Time Queue & Pause Controls**: Pause, resume, or cleanly cancel execution mid-generation, with automatic crash recovery for interrupted queues.
|
||||
- **Aspect Ratio & Resolution Utilities**: Find exact matching aspect ratios, crop dimensions, and visual sizing helpers.
|
||||
- **Safe Local Script Execution**: Run local scripts and executables strictly confined to `ComfyUI/scripts/` with interactive operator authorization.
|
||||
@@ -68,7 +69,7 @@ Loads a LoRA using formatted pretty names (e.g. `Cyberpunk Character V1` instead
|
||||
<summary><b>🪐 🖼️ Visual LoRA Loader</b> (<code>VisualLoraLoader</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scraping, popularity rank badges (🔥), multi-selection support, and independent per-node thumbnail zoom sizing.
|
||||
Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scraping, popularity rank badges (🔥), multi-selection support with click-order numbering, and independent per-node thumbnail zoom sizing.
|
||||
|
||||
#### Maintenance Reset Buttons
|
||||
The node's control bar exposes three maintenance buttons — each asks for confirmation before acting:
|
||||
@@ -366,6 +367,74 @@ Inspired by Stereonix, this node transforms any 2D image into stereoscopic 3D pa
|
||||
- **`depth_map`** (`IMAGE`): The normalized depth map used for disparity calculation.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🪐 🧑🎤 Character Preset Picker</b> (<code>CharacterPresetPicker</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
A visual browser for **character preset cards**. Each card stores a thumbnail, one or more trigger words, a prompt snippet, a negative prompt, and a stack of LoRAs (each with its own model/clip strength). Pick cards by clicking them, or connect a prompt text to the `text` input and let the node find the character by any of its triggers instead.
|
||||
|
||||
Cards are plain JSON files with an optional sidecar image, stored in `ComfyUI/user/default/SaturnNodes/character_presets/` (subfolders act as categories). The node only outputs data — use the companion **Apply Character LoRAs** node to patch `MODEL`/`CLIP`.
|
||||
|
||||
> [!TIP]
|
||||
> The card's **prompt snippet** is emitted as the `positive` output. Trigger words are only used to find the card when text is connected; they are not injected into the prompt. A card with an empty snippet falls back to its display name.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`folder`** (`STRING`): Native folder filter widget, matching the **Visual LoRA Loader**. Leave it empty for all presets or enter a subfolder such as `Celebrity`.
|
||||
- **`tile_size`** (slider, `50`–`180`, default `80`): Native slider that sets the card tile width. Saved per node and remembered across sessions.
|
||||
- **`selection_mode`** (`COMBO`): How the clicked cards are used — `Sequential` (one per run, in order), `Random` (one random card), `Random (No Replace)` (shuffles and uses every clicked card before repeating), `All` (every clicked card in one run), `None` (emits nothing). Hidden in the UI and driven by the **Mode** dropdown.
|
||||
- **`separator`** (`STRING`, *Advanced*, default `", "`): Joins positive/negative prompts when several cards are used.
|
||||
- **`match_mode`** (`COMBO`, *Advanced*): `Whole Word` (default), `Contains`, or `Exact`. No regular expressions.
|
||||
- **`match_all`** (`BOOLEAN`, *Advanced*, default `False`): Off = the earliest matching card wins; on = every card with any trigger found in the text is used.
|
||||
- **`case_sensitive`** (`BOOLEAN`, *Advanced*, default `False`).
|
||||
- **`include_disabled`** (`BOOLEAN`, *Advanced*, default `False`): Also show cards marked as disabled.
|
||||
- **`sort_presets_by`** (`COMBO`, *Advanced*): Name, usage count, or date modified (both directions). Hidden in the UI and driven by the **Sort** dropdown.
|
||||
- **`sort_folders_by`** (`COMBO`, *Advanced*): `Name (A-Z)`, `Name (Z-A)`, `Total Usage (High to Low)`, `Average Usage (High to Low)` or `Total LoRAs (Most First)`. Hidden in the UI and driven by the **Folders** dropdown.
|
||||
- **`content_alignment`** (`COMBO`, *Advanced*): `Left Aligned` (default) or `Right Aligned`. Hidden in the UI and driven by the **Align** buttons.
|
||||
- **`_scrape_on_new`** (`STRING`, hidden, default `"true"`): Starts the shared CivitAI/TMDB preview scraper when cards are saved or imported, and when the picker refreshes. There is no visible toggle.
|
||||
- **`folder_position`** (`COMBO`, *Advanced*): `Folders First` or `Root Presets First`.
|
||||
- **`display_mode`** (`COMBO`, *Advanced*): `Scrollable` (fixed height grid) or `Show All` (taller node). Hidden in the UI and driven by the **Scroll / Show All** buttons.
|
||||
- **`text`** (`STRING`, optional input socket): The prompt text scanned for trigger words. While it carries text, detection is active and the clicked selection is ignored; when it is empty or unconnected, the clicked cards are used instead.
|
||||
|
||||
#### Outputs
|
||||
- **`loras`** (`SATURN_LORA_STACK`): The resolved LoRA stack, with per-entry model/clip strength.
|
||||
- **`loras_json`** (`STRING`): The same stack serialized as JSON (connect to **Apply Character LoRAs**).
|
||||
- **`positive`** (`STRING`): Joined prompt snippets of the used cards (the display name is used when a card has no snippet).
|
||||
- **`negative`** (`STRING`): Joined negative prompts of the used cards.
|
||||
- **`names`** (`STRING`): Names of the used cards.
|
||||
|
||||
#### Picker UI
|
||||
The card grid mirrors the **Visual LoRA Loader**: a compact control bar, collapsible folder groups, and fixed-width `3:4` tiles that wrap instead of stretching with the node width.
|
||||
- **Folder groups**: cards are grouped under their folder name with a card count, a collapse arrow, and a select-all checkbox. Collapse state is remembered per folder.
|
||||
- **Control bar**: **Mode** (run behavior), **Sort** (per-folder ordering), **Folders** (folder-group ordering: name, total usage, average usage or LoRA count), **Align** (`Left` / `Right` content alignment), **View** (`Scroll` / `Show All`), **Reset** (`↺ Counter`, `🧹 Usage`, `🗑 Cache`, each with a confirmation prompt), and icon-only **New** (`+`), **Refresh** (`↻`), **Open folder** (`📂`), **Edit** (`✎`), **Duplicate** (`⧉`) and **Delete** (`✕`) actions. The card actions enable only when exactly one card is selected. The folder filter and tile-size slider are native node widgets below the picker, like the **Visual LoRA Loader**.
|
||||
- **Usage badges & borders**: usage counts always render as a flap tucked into the top-right corner, flush with the top/right edges and rounded on the inner edge, growing with the usage count. When Character Preset usage tracking and rank borders are enabled, the three most-used cards additionally get gold/silver/bronze borders and cards with **1,000+ uses** get an animated RGB border, both 150% of the normal border thickness.
|
||||
- **Selection count**: a line under the grid reports how many cards are currently selected (for example `3 cards selected`).
|
||||
- **Click** a card to select only it (green ring + selection-order number), **Ctrl/Cmd+click** to add or remove individual cards, and **Shift+click** to select every card visually between the last clicked card and the one clicked. Clicking the only selected card again clears the selection; **double-click** edits a card, or use the **✎ Edit**, **⧉ Duplicate** and **✕ Delete** icons in the top control bar, which enable only when exactly one card is selected.
|
||||
- **Duplicate** opens the editor prefilled with a copy whose id gets a `(1)` suffix; adjust it before saving.
|
||||
- Manual selection is shown with a green ring and a numbered badge in click order; connected-text matches are shown with a green wash and a `MATCH` pill, and the grid stops accepting clicks.
|
||||
- After every run the cards that were actually used flash on the canvas, so you can see what detection picked.
|
||||
|
||||
**Card editor:** set the card id, a display name shown on the node, a comma-separated **Triggers** list (for example `Madison Beer, madison_beer`), the **Prompt snippet** that becomes the `positive` output, a negative prompt, and any number of LoRAs with per-entry model/clip strengths. The editor is a wide modal with a 30%-wide image column using the same `3:4` ratio as the picker tiles; the id, display name, triggers and the side-by-side prompt/negative fields sit beside it, and the prompt fields grow to fill the remaining height. Icon buttons upload or remove the image, and **Fetch CivitAI Image** reports its result inline (`LoRA not found`, `Image not found`, `Image applied!`). LoRA names may use either `/` or `\` and are matched against the installed list.
|
||||
|
||||
Overwrite mode merges instead of clobbering: a card that is imported again keeps its existing prompts and triggers and only gets the LoRA added if it is missing.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🪐 🧬 Apply Character LoRAs</b> (<code>ApplyCharacterLoRAs</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Applies the LoRA stack produced by **Character Preset Picker** to a `MODEL`/`CLIP` pair, using each card entry's own model and clip strength. Missing LoRA files are skipped with a console warning instead of failing the run. Successfully applied LoRAs also increment the same usage counters shown by the **Visual LoRA Loader**.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
- **`clip`** (`CLIP`): Input CLIP.
|
||||
- **`loras`** (`SATURN_LORA_STACK`, *Optional*): Stack output of the picker.
|
||||
- **`loras_json`** (`STRING`, *Optional*): JSON stack output of the picker (used when `loras` is not connected).
|
||||
|
||||
#### Outputs
|
||||
- **`MODEL`**: Model with every card LoRA applied in order.
|
||||
- **`CLIP`**: CLIP with every card LoRA applied in order.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="comfyui-settings-menu-reference"></a>
|
||||
@@ -384,6 +453,8 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for actor / media preview search.
|
||||
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download media preview images from TMDB.
|
||||
- **`Enable LoRA Usage Tracking`** (`boolean`, *Default: true*): Toggle LoRA usage counting and visual rank badges (🔥, Gold, Silver, Bronze) in the picker.
|
||||
- **`Enable Character Preset Usage Tracking`** (`boolean`, *Default: true*): Track and display Character Preset Picker usage counts, tile badges and the `Usage` sort options. Existing history is preserved when disabled.
|
||||
- **`Enable Character Preset Rank Borders`** (`boolean`, *Default: true*): Add gold/silver/bronze borders for the 3 most-used Character Preset Picker cards and an animated RGB border at 1,000+ uses. These rings are 150% of the normal tile border thickness and sit alongside the normal usage badges. Disable to remove the rings; the usage badges always stay visible.
|
||||
- **`Reset Failed Scrapes Cache`** (*Button: `🗑️ Clear Scrapes Cache`*): Clears failed scrape history so Civitai/TMDB can retry downloading missing preview images.
|
||||
- **`Disable Thumbnail Downscaling (Serve Raw)`** (`boolean`, *Default: false*): Serves full-resolution original preview images instead of generating downscaled WebP thumbnails (downscaled WebP thumbnails render faster in the picker and are recommended).
|
||||
- **`Image Loader Metadata Scan Limit`** (`number`, *Default: 128*, 0–2000): Number of newest images in a folder scanned for embedded prompts by the Visual Image Loader (`0` disables extraction).
|
||||
@@ -395,7 +466,7 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
- **`Enable Floating Latent Dock`** (`boolean`, *Default: true*): Adds the floating picture-in-picture Live Latent preview dock (bottom-right corner) plus a canvas toolbar toggle button.
|
||||
- **`Floating Dock Max Width`** (`slider`, *Default: 280*, 1px steps): Maximum dock width box in pixels (100–2000). Drag the dock's top-left corner to set a free-form width×height box; the dragged dimensions are saved back into this setting, and the dock then snaps to the exact latent aspect ratio without distortion.
|
||||
- **`Floating Dock Max Height`** (`slider`, *Default: 280*, 1px steps): Maximum dock height box in pixels (100–2000). Drag the dock's top-left corner to set a free-form width×height box; the dragged dimensions are saved back into this setting, and the dock then snaps to the exact latent aspect ratio without distortion.
|
||||
- **`Auto-Hide Dock When Idle`** (`boolean`, *Default: false*): Hides the dock while idle and reveals it automatically when sampling begins.
|
||||
- **`Dock Mode when Idle`** (`combo`, *Default: `Auto-Hide`*): Chooses what the floating dock does between runs. `Auto-Hide` hides it while idle and reveals it on the first fresh latent. `Keep Dock Visible` leaves it on screen at all times. `View Image` keeps the last latent visible, then swaps to the decoded final output image once it is written and keeps showing it until the next run's first latent. At startup both `Auto-Hide` and `View Image` keep the dock hidden until the first latent arrives.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -87,6 +87,21 @@ Tired of guessing what a LoRA does based on its filename?
|
||||
4. **Independent Per-Node Zoom**: Use the `tile_size` slider to adjust preview card sizes to your preference. Zoom levels are saved per node directly inside your workflow JSON, so different visual loaders maintain independent tile sizes across browser reloads.
|
||||
5. **Maintenance Reset Buttons**: The control bar offers `↺ Counter` (reset this node's Sequential/Random LoRA cycle + prompt→LoRA assignments), `🧹 Usage` (clear usage badges), and `🗑 Cache` (delete cached thumbnails). Each asks for confirmation. ⚠️ Only use `↺ Counter` when the queue is empty — resetting mid-batch restarts the LoRA cycle from the first selected LoRA and can mismatch prompts against LoRAs.
|
||||
|
||||
### 🪐 🧑🎤 Character Preset Picker
|
||||
Keep characters as **cards** instead of remembering LoRA filenames and trigger words.
|
||||
|
||||
1. Add **`🪐 🧑🎤 Character Preset Picker`** and click the **+** button in its toolbar. Give the card an id (`Folder/Name` for categories), a **Display name** shown on the tile, a comma-separated list of **Triggers** (e.g. `Madison Beer, madison_beer`), a **Prompt snippet**, a negative prompt, and one or more LoRAs with their own model/clip strengths.
|
||||
2. Drop a thumbnail in with the **⬆ upload** icon, or click **Fetch CivitAI Image** to copy the preview of a card that has exactly one LoRA. The fetch button reports its result inline (`LoRA not found`, `Image not found`, `Image applied!`), and the **✕ remove** icon clears the image.
|
||||
3. Connect the picker's `loras` (or `loras_json`) output to **`🪐 🧬 Apply Character LoRAs`**, and route `positive`/`negative` into your prompt encoder. The picker itself never touches `MODEL`/`CLIP`, so it can sit anywhere in the graph.
|
||||
4. **Clicking cards**: a normal click selects only that card; hold **Ctrl/Cmd** to add or remove individual cards, or **Shift** to select every card visually between the last clicked card and the one you click (same as the Visual LoRA Loader). Clicking the only selected card again clears the selection. Each selected card shows a numbered badge with its selection order. Cards are grouped by folder; click a folder header to collapse it, or its checkbox to select the whole folder at once. Choose how cards are consumed with the **Mode** dropdown in the control bar — `Sequential` cycles through the clicked cards, `Random` picks one, `Random (No Replace)` uses all of them before repeating, `All` uses every clicked card at once (in click order).
|
||||
5. **Finding cards**: use the native **folder** widget below the picker (empty = every preset) and **Scroll / Show All** to switch between a fixed-height scrolling grid and a node that grows to fit every card. The native **tile_size** slider changes the card size, and the tiles keep a fixed `3:4` ratio, so resizing the node only changes how many cards fit per row. Use **Align → Left/Right** to anchor folder headers and tiles to either edge; a line under the grid shows how many cards are currently selected.
|
||||
6. **Maintenance, badges & actions**: **↺ Counter** resets this picker's Sequential/Random cycle, **🧹 Usage** clears all character usage counters, and **🗑 Cache** deletes cached thumbnails — each asks for confirmation first. Usage badges always show, tucked into the top-right corner as a rounded flap that grows with the usage count. With rank borders enabled, the three most-used cards also glow gold/silver/bronze and cards with 1,000+ uses get an animated RGB border (both 150% of the normal border thickness). Use the **✎ / ⧉ / ✕** icons in the top bar to edit, duplicate or delete the selected card; they are enabled only when exactly one card is selected.
|
||||
7. **Prompt-driven picking**: wire your prompt text into the `text` input and the picker highlights the card whose trigger appears in it (the earliest trigger in the text wins; enable `match_all` for several). While text is connected the grid is read-only; disconnect it or clear the text and your clicked cards are used again. After each run the card that was actually used flashes on the canvas.
|
||||
8. The card's **prompt snippet** is what the `positive` output emits. Triggers are only used for detection; they are not injected into the prompt. A card without a snippet falls back to its display name.
|
||||
|
||||
> [!NOTE]
|
||||
> Cards live in `ComfyUI/user/default/SaturnNodes/character_presets/` as `<Name>.json` plus an optional sidecar image. Use the **📂 folder** icon to open that directory and back it up or share cards.
|
||||
|
||||
### 🪐 ⏱️ Recent Outputs
|
||||
Want to quickly reference your recent generations without digging through Windows Explorer? The **`🪐 ⏱️ Recent Outputs`** node cycles chronologically through your `output/` directory, pulling your freshest creations right back into the workflow canvas.
|
||||
|
||||
@@ -120,6 +135,9 @@ Several nodes across this suite (e.g. *Load Image From Folder*, *Recent Outputs*
|
||||
|
||||
Click the standard ComfyUI Settings gear ⚙️ and search for `SaturnNodes` to access the suite's configurations. Here you can:
|
||||
- Safely enter and mask API Keys for Civitai and TMDB scraping.
|
||||
- Toggle LoRA and Character Preset usage tracking (counts, badges and `Usage` sorting); disabling either keeps the existing history.
|
||||
- Toggle **Enable Character Preset Rank Borders** to add gold/silver/bronze + 1,000-use RGB rings on top of the always-visible (and usage-sized) corner badges.
|
||||
- Choose the **Dock Mode when Idle** for the floating Live Latent dock: `Auto-Hide`, `Keep Dock Visible`, or `View Image` (shows the decoded final output until the next run's first latent).
|
||||
- Change the colors of the Pause Queue buttons via built-in color pickers.
|
||||
- Adjust the default boot behavior for the Persistent Queue (e.g., forcing ComfyUI to always boot in a Paused state), retry limits for failed queue restores, and whether the last manual pause state survives a restart.
|
||||
- Tune the Visual Image Loader metadata scan limit, restore nested output folders, show recorded generation times on restored assets, and trigger **Restore Now** without restarting.
|
||||
|
||||
+17
-1
@@ -4,7 +4,7 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
__version__ = "2.6.3"
|
||||
__version__ = "2.7.0"
|
||||
|
||||
from .nodes.queue_control import (
|
||||
setup_queue_control_routes,
|
||||
@@ -36,6 +36,7 @@ from .nodes.text_replacer import MultiTextReplacer
|
||||
from .nodes.text_split import SaturnTextSplit
|
||||
from .nodes.local_runner import RunLocalFileNode, setup_local_runner_routes
|
||||
from .nodes.stereogram import SaturnStereo3D
|
||||
from .nodes.character_presets import CharacterPresetPicker, ApplyCharacterLoRAs
|
||||
from .nodes.utils import (
|
||||
get_saturnnodes_user_dir,
|
||||
get_env_setting,
|
||||
@@ -63,6 +64,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
"SaturnTextSplit": SaturnTextSplit,
|
||||
"RunLocalFileNode": RunLocalFileNode,
|
||||
"SaturnStereo3D": SaturnStereo3D,
|
||||
"CharacterPresetPicker": CharacterPresetPicker,
|
||||
"ApplyCharacterLoRAs": ApplyCharacterLoRAs,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -83,6 +86,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SaturnTextSplit": "🪐 ✂️ Text Split",
|
||||
"RunLocalFileNode": "🪐 ⚡ Run Local File",
|
||||
"SaturnStereo3D": "🪐 👓 Stereoscopic 3D Generator",
|
||||
"CharacterPresetPicker": "🪐 🧑🎤 Character Preset Picker",
|
||||
"ApplyCharacterLoRAs": "🪐 🧬 Apply Character LoRAs",
|
||||
}
|
||||
|
||||
ALL_SATURN_NODE_CLASSES = dict(NODE_CLASS_MAPPINGS)
|
||||
@@ -312,6 +317,7 @@ async def get_settings(request):
|
||||
"enable_civitai_scraping": _env_bool("ENABLE_CIVITAI_SCRAPING", False),
|
||||
"enable_tmdb_scraping": _env_bool("ENABLE_TMDB_SCRAPING", False),
|
||||
"enable_lora_usage": _env_bool("ENABLE_LORA_USAGE", True),
|
||||
"enable_character_usage": _env_bool("ENABLE_CHARACTER_USAGE", True),
|
||||
"enable_persistent_queue": _env_bool("ENABLE_PERSISTENT_QUEUE", True),
|
||||
"persistent_queue_restored_state": get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default"),
|
||||
"persistent_queue_max_retries": _env_int("PERSISTENT_QUEUE_MAX_RETRIES", 0),
|
||||
@@ -348,6 +354,7 @@ async def save_settings(request):
|
||||
enable_civitai = _clean_env_val(data.get("enable_civitai_scraping"))
|
||||
enable_tmdb = _clean_env_val(data.get("enable_tmdb_scraping"))
|
||||
enable_lora_usage = _clean_env_val(data.get("enable_lora_usage"))
|
||||
enable_character_usage = _clean_env_val(data.get("enable_character_usage"))
|
||||
enable_tray_icon = _clean_env_val(data.get("enable_tray_icon"))
|
||||
enable_assets_restore = _clean_env_val(data.get("enable_assets_restore"))
|
||||
restore_assets_count = _clean_env_val(data.get("restore_assets_count"))
|
||||
@@ -378,6 +385,7 @@ async def save_settings(request):
|
||||
has_civ_enable = False
|
||||
has_tmdb_enable = False
|
||||
has_lora_usage = False
|
||||
has_character_usage = False
|
||||
has_tray_enable = False
|
||||
has_assets_restore = False
|
||||
has_restore_count = False
|
||||
@@ -416,6 +424,9 @@ async def save_settings(request):
|
||||
elif line.strip().startswith("ENABLE_LORA_USAGE=") and enable_lora_usage is not None:
|
||||
new_lines.append(f"ENABLE_LORA_USAGE={enable_lora_usage}\n")
|
||||
has_lora_usage = True
|
||||
elif line.strip().startswith("ENABLE_CHARACTER_USAGE=") and enable_character_usage is not None:
|
||||
new_lines.append(f"ENABLE_CHARACTER_USAGE={enable_character_usage}\n")
|
||||
has_character_usage = True
|
||||
elif line.strip().startswith("ENABLE_TRAY_ICON=") and enable_tray_icon is not None:
|
||||
new_lines.append(f"ENABLE_TRAY_ICON={enable_tray_icon}\n")
|
||||
has_tray_enable = True
|
||||
@@ -474,6 +485,8 @@ async def save_settings(request):
|
||||
new_lines.append(f"ENABLE_TMDB_SCRAPING={enable_tmdb}\n")
|
||||
if not has_lora_usage and enable_lora_usage is not None:
|
||||
new_lines.append(f"ENABLE_LORA_USAGE={enable_lora_usage}\n")
|
||||
if not has_character_usage and enable_character_usage is not None:
|
||||
new_lines.append(f"ENABLE_CHARACTER_USAGE={enable_character_usage}\n")
|
||||
if not has_tray_enable and enable_tray_icon is not None:
|
||||
new_lines.append(f"ENABLE_TRAY_ICON={enable_tray_icon}\n")
|
||||
if not has_assets_restore and enable_assets_restore is not None:
|
||||
@@ -512,6 +525,8 @@ async def save_settings(request):
|
||||
os.environ["TMDB_API_KEY"] = tmdb_key
|
||||
if enable_lora_usage is not None:
|
||||
os.environ["ENABLE_LORA_USAGE"] = enable_lora_usage
|
||||
if enable_character_usage is not None:
|
||||
os.environ["ENABLE_CHARACTER_USAGE"] = enable_character_usage
|
||||
if enable_tray_icon is not None:
|
||||
os.environ["ENABLE_TRAY_ICON"] = str(enable_tray_icon)
|
||||
tray_enabled_bool = str(enable_tray_icon).lower() in ["true", "1", "yes"]
|
||||
@@ -659,6 +674,7 @@ async def export_debug_profile(request):
|
||||
"enable_civitai_scraping": get_env_setting("ENABLE_CIVITAI_SCRAPING", "false"),
|
||||
"enable_tmdb_scraping": get_env_setting("ENABLE_TMDB_SCRAPING", "false"),
|
||||
"enable_lora_usage": get_env_setting("ENABLE_LORA_USAGE", "true"),
|
||||
"enable_character_usage": get_env_setting("ENABLE_CHARACTER_USAGE", "true"),
|
||||
"enable_tray_icon": get_env_setting("ENABLE_TRAY_ICON", "false"),
|
||||
"enable_assets_restore": get_env_setting("ENABLE_ASSETS_RESTORE", "true"),
|
||||
"restore_assets_count": get_env_setting("RESTORE_ASSETS_COUNT", "64"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "saturnnodes"
|
||||
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
|
||||
version = "2.6.3"
|
||||
description = "Workflow control and productivity suite for ComfyUI: mid-generation pause/continue, crash-safe persistent queue, batch queue visualizer, folder watchers, visual LoRA and image browsers, character preset cards, live latent previews, and stereogram tools."
|
||||
version = "2.7.0"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
+2
-1
@@ -348,7 +348,8 @@ export class FakeElement {
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const walk = node => {
|
||||
for (const child of node.children) {
|
||||
for (const child of node.children || []) {
|
||||
if (child.nodeType !== 1) continue;
|
||||
if (matchesSelector(child, selector)) found.push(child);
|
||||
walk(child);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
import folder_paths
|
||||
import nodes.character_presets as cp
|
||||
from nodes.character_presets import (
|
||||
ApplyCharacterLoRAs,
|
||||
CharacterPresetPicker,
|
||||
build_outputs,
|
||||
card_json_path,
|
||||
card_preview_path,
|
||||
delete_card,
|
||||
duplicate_card,
|
||||
humanize_lora_name,
|
||||
import_lora_cards,
|
||||
load_card,
|
||||
match_cards,
|
||||
normalize_card,
|
||||
normalize_card_id,
|
||||
normalize_lora_entry,
|
||||
parse_selected,
|
||||
resolve_lora,
|
||||
save_card,
|
||||
scan_lora_import,
|
||||
select_cards,
|
||||
)
|
||||
|
||||
|
||||
FAKE_LORAS = [
|
||||
"krea2/Chey.safetensors",
|
||||
"krea2/Dilaraa.safetensors",
|
||||
"styles/film_grain.safetensors",
|
||||
]
|
||||
|
||||
|
||||
def card(card_id, trigger, positive="", negative="", loras=None, **extra):
|
||||
payload = {
|
||||
"name": card_id.rsplit("/", 1)[-1],
|
||||
"trigger": trigger,
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
"loras": loras if loras is not None else [],
|
||||
}
|
||||
payload.update(extra)
|
||||
return normalize_card(payload, card_id)
|
||||
|
||||
|
||||
class CharacterPresetTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp(prefix="saturn_chars_")
|
||||
self.presets_dir = os.path.join(self.temp_dir, "character_presets")
|
||||
os.makedirs(self.presets_dir, exist_ok=True)
|
||||
self.patches = [
|
||||
patch.object(cp, "PRESETS_DIR", self.presets_dir),
|
||||
patch.object(cp, "USER_DIR", self.temp_dir),
|
||||
patch.object(cp, "USAGE_FILE", os.path.join(self.temp_dir, "character_usage.json")),
|
||||
patch.object(cp, "CYCLE_STATE_FILE", os.path.join(self.temp_dir, "character_cycle_state.json")),
|
||||
patch.object(cp, "available_loras", lambda: list(FAKE_LORAS)),
|
||||
patch("folder_paths.get_full_path", return_value=""),
|
||||
]
|
||||
for patcher in self.patches:
|
||||
patcher.start()
|
||||
cp.invalidate_card_cache()
|
||||
|
||||
def tearDown(self):
|
||||
for patcher in reversed(self.patches):
|
||||
patcher.stop()
|
||||
cp.invalidate_card_cache()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def write_raw(self, card_id, payload, image=None):
|
||||
path = card_json_path(card_id)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
if image:
|
||||
with open(os.path.splitext(path)[0] + ".png", "wb") as handle:
|
||||
handle.write(image)
|
||||
cp.invalidate_card_cache()
|
||||
return path
|
||||
|
||||
|
||||
class TestCardModel(CharacterPresetTestCase):
|
||||
def test_normalize_card_id(self):
|
||||
self.assertEqual(normalize_card_id(" Chey.json "), "Chey")
|
||||
self.assertEqual(normalize_card_id("folder\\Chey.json"), "folder/Chey")
|
||||
self.assertEqual(normalize_card_id("/folder//Chey/"), "folder/Chey")
|
||||
self.assertEqual(normalize_card_id(None), "")
|
||||
|
||||
def test_normalize_lora_entry_variants(self):
|
||||
self.assertEqual(
|
||||
normalize_lora_entry("krea2\\Chey.safetensors"),
|
||||
{
|
||||
"name": "krea2/Chey.safetensors",
|
||||
"strength_model": 1.0,
|
||||
"strength_clip": 1.0,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
self.assertIsNone(normalize_lora_entry(" "))
|
||||
self.assertIsNone(normalize_lora_entry(42))
|
||||
entry = normalize_lora_entry({"name": "a.safetensors", "strength_model": "0.5", "enabled": False})
|
||||
self.assertEqual(entry["strength_model"], 0.5)
|
||||
self.assertEqual(entry["strength_clip"], 1.0)
|
||||
self.assertFalse(entry["enabled"])
|
||||
|
||||
def test_normalize_card_defaults(self):
|
||||
normalized = normalize_card({"positive": "a photo", "tags": "one, two"}, "folder/Chey")
|
||||
self.assertEqual(normalized["id"], "folder/Chey")
|
||||
self.assertEqual(normalized["name"], "Chey")
|
||||
self.assertEqual(normalized["folder"], "folder")
|
||||
self.assertEqual(normalized["positive"], "a photo")
|
||||
self.assertEqual(normalized["tags"], ["one", "two"])
|
||||
self.assertTrue(normalized["enabled"])
|
||||
self.assertEqual(normalized["loras"], [])
|
||||
|
||||
def test_normalize_card_legacy_prompt_keys(self):
|
||||
normalized = normalize_card({"prompt": "legacy", "negative_prompt": "bad"}, "Chey")
|
||||
self.assertEqual(normalized["positive"], "legacy")
|
||||
self.assertEqual(normalized["negative"], "bad")
|
||||
|
||||
|
||||
class TestPaths(CharacterPresetTestCase):
|
||||
def test_card_json_path_containment(self):
|
||||
self.assertIsNotNone(card_json_path("folder/Chey"))
|
||||
self.assertIsNone(card_json_path("../evil"))
|
||||
self.assertIsNone(card_json_path("folder/../../evil"))
|
||||
self.assertIsNone(card_json_path("C:/windows/evil"))
|
||||
self.assertIsNone(card_json_path(""))
|
||||
# A leading slash is stripped, so the card stays inside the presets dir.
|
||||
inside = card_json_path("/absolute")
|
||||
self.assertTrue(inside.startswith(os.path.abspath(self.presets_dir)))
|
||||
|
||||
def test_save_load_delete_round_trip(self):
|
||||
save_card("folder/Chey", {"trigger": "Chey", "positive": "smiling"})
|
||||
loaded = load_card("folder/Chey")
|
||||
self.assertEqual(loaded["trigger"], "Chey")
|
||||
self.assertTrue(os.path.exists(card_json_path("folder/Chey")))
|
||||
|
||||
with open(os.path.splitext(card_json_path("folder/Chey"))[0] + ".png", "wb") as handle:
|
||||
handle.write(b"png")
|
||||
self.assertTrue(delete_card("folder/Chey"))
|
||||
self.assertFalse(os.path.exists(card_json_path("folder/Chey")))
|
||||
self.assertFalse(os.path.exists(os.path.splitext(card_json_path("folder/Chey"))[0] + ".png"))
|
||||
|
||||
def test_sidecar_detection_is_case_insensitive(self):
|
||||
save_card("Chey", {"trigger": "Chey"})
|
||||
upper = os.path.splitext(card_json_path("Chey"))[0] + ".PNG"
|
||||
with open(upper, "wb") as handle:
|
||||
handle.write(b"png-bytes")
|
||||
self.assertEqual(cp.card_preview_path("Chey"), upper)
|
||||
self.assertTrue(delete_card("Chey"))
|
||||
self.assertFalse(os.path.exists(upper))
|
||||
|
||||
def test_move_thumbnail_on_rename(self):
|
||||
save_card("Chey", {"trigger": "Chey"})
|
||||
source = os.path.splitext(card_json_path("Chey"))[0] + ".png"
|
||||
with open(source, "wb") as handle:
|
||||
handle.write(b"png-bytes")
|
||||
self.assertTrue(cp._move_thumbnail("Chey", "folder/Chey Renamed"))
|
||||
target = os.path.splitext(card_json_path("folder/Chey Renamed"))[0] + ".png"
|
||||
self.assertTrue(os.path.exists(target))
|
||||
self.assertFalse(os.path.exists(source))
|
||||
|
||||
def test_duplicate_copies_thumbnail(self):
|
||||
save_card("Chey", {"trigger": "Chey"})
|
||||
source = os.path.splitext(card_json_path("Chey"))[0] + ".png"
|
||||
with open(source, "wb") as handle:
|
||||
handle.write(b"png-bytes")
|
||||
duplicate = duplicate_card("Chey", "folder/Chey Copy")
|
||||
self.assertIsNotNone(duplicate)
|
||||
self.assertEqual(duplicate["trigger"], "Chey")
|
||||
copied = os.path.splitext(card_json_path("folder/Chey Copy"))[0] + ".png"
|
||||
with open(copied, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"png-bytes")
|
||||
|
||||
def test_resolve_lora_variants(self):
|
||||
self.assertEqual(resolve_lora("krea2/Chey.safetensors"), "krea2/Chey.safetensors")
|
||||
self.assertEqual(resolve_lora("KREA2\\chey.safetensors"), "krea2/Chey.safetensors")
|
||||
self.assertEqual(resolve_lora("Chey.safetensors"), "krea2/Chey.safetensors")
|
||||
self.assertEqual(resolve_lora("Chey"), "krea2/Chey.safetensors")
|
||||
self.assertIsNone(resolve_lora("missing.safetensors"))
|
||||
self.assertIsNone(resolve_lora(""))
|
||||
|
||||
def test_resolve_lora_ambiguous_basename_is_rejected(self):
|
||||
with patch.object(cp, "available_loras", lambda: ["a/Chey.safetensors", "b/Chey.safetensors"]):
|
||||
self.assertIsNone(resolve_lora("Chey.safetensors"))
|
||||
|
||||
|
||||
class TestMatching(CharacterPresetTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.char1 = card("char1", "Char1", "red hair")
|
||||
self.char2 = card("char2", "Character2", "blue hair")
|
||||
self.cards = [self.char1, self.char2]
|
||||
|
||||
def test_first_trigger_in_text_wins(self):
|
||||
matched = match_cards("I am Char1 and Character2", self.cards, "Contains")
|
||||
self.assertEqual([entry["id"] for entry in matched], ["char1"])
|
||||
|
||||
def test_text_order_not_card_order(self):
|
||||
matched = match_cards("Character2 meets Char1", self.cards, "Contains")
|
||||
self.assertEqual([entry["id"] for entry in matched], ["char2"])
|
||||
|
||||
def test_match_all_returns_text_order(self):
|
||||
matched = match_cards("Character2 meets Char1", self.cards, "Contains", match_all=True)
|
||||
self.assertEqual([entry["id"] for entry in matched], ["char2", "char1"])
|
||||
|
||||
def test_whole_word_avoids_partial_hits(self):
|
||||
cards = [card("char", "Char", ""), card("char2", "Char2", "")]
|
||||
matched = match_cards("Char2 posing", cards, "Whole Word")
|
||||
self.assertEqual([entry["id"] for entry in matched], ["char2"])
|
||||
self.assertEqual(match_cards("Character posing", cards, "Whole Word"), [])
|
||||
|
||||
def test_contains_allows_partial_hits(self):
|
||||
cards = [card("char", "Char", "")]
|
||||
self.assertEqual(len(match_cards("Character posing", cards, "Contains")), 1)
|
||||
self.assertEqual(match_cards("Character posing", cards, "Whole Word"), [])
|
||||
|
||||
def test_exact_mode(self):
|
||||
cards = [card("char", "Char", "")]
|
||||
self.assertEqual(match_cards("char", cards, "Exact", case_sensitive=True), [])
|
||||
self.assertEqual(len(match_cards(" Char ", cards, "Exact")), 1)
|
||||
self.assertEqual(match_cards("Char posing", cards, "Exact"), [])
|
||||
|
||||
def test_case_sensitivity(self):
|
||||
self.assertEqual(len(match_cards("char1 here", self.cards, "Contains")), 1)
|
||||
self.assertEqual(match_cards("char1 here", self.cards, "Contains", case_sensitive=True), [])
|
||||
|
||||
def test_cards_without_trigger_are_ignored(self):
|
||||
cards = [card("notrigger", "", ""), card("char1", "Char1", "")]
|
||||
self.assertEqual([entry["id"] for entry in match_cards("Char1", cards, "Contains")], ["char1"])
|
||||
|
||||
|
||||
class TestOutputs(CharacterPresetTestCase):
|
||||
def test_build_outputs_assembles_and_dedupes(self):
|
||||
first = card(
|
||||
"char1",
|
||||
"Char1",
|
||||
"red hair",
|
||||
"ugly",
|
||||
loras=[
|
||||
{"name": "krea2/Chey.safetensors", "strength_model": 0.8, "strength_clip": 0.6},
|
||||
{"name": "styles/film_grain.safetensors", "strength_model": 0.2},
|
||||
],
|
||||
)
|
||||
second = card(
|
||||
"char2",
|
||||
"Char2",
|
||||
"blue hair",
|
||||
"blurry",
|
||||
loras=[{"name": "Chey.safetensors", "strength_model": 1.0}],
|
||||
)
|
||||
positive, negative, stack, names = build_outputs([first, second])
|
||||
self.assertEqual(positive, "red hair, blue hair")
|
||||
self.assertEqual(negative, "ugly, blurry")
|
||||
self.assertEqual(names, "char1, char2")
|
||||
self.assertEqual(len(stack), 2)
|
||||
self.assertEqual(stack[0]["name"], "krea2/Chey.safetensors")
|
||||
self.assertEqual(stack[0]["strength_model"], 0.8)
|
||||
self.assertEqual(stack[0]["strength_clip"], 0.6)
|
||||
self.assertTrue(stack[0]["resolved"])
|
||||
|
||||
def test_build_outputs_custom_separator_and_missing_lora(self):
|
||||
entry = card("char1", "Char1", "a", "b", loras=[{"name": "missing.safetensors"}])
|
||||
positive, negative, stack, _ = build_outputs([entry], separator="\n")
|
||||
self.assertEqual(positive, "a")
|
||||
self.assertEqual(negative, "b")
|
||||
self.assertFalse(stack[0]["resolved"])
|
||||
self.assertEqual(stack[0]["name"], "missing.safetensors")
|
||||
|
||||
def test_build_outputs_skips_disabled_loras(self):
|
||||
entry = card(
|
||||
"char1",
|
||||
"Char1",
|
||||
loras=[
|
||||
{"name": "krea2/Chey.safetensors", "enabled": False},
|
||||
{"name": "krea2/Dilaraa.safetensors"},
|
||||
],
|
||||
)
|
||||
_, _, stack, _ = build_outputs([entry])
|
||||
self.assertEqual([item["name"] for item in stack], ["krea2/Dilaraa.safetensors"])
|
||||
|
||||
|
||||
class TestSelection(CharacterPresetTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.cards = [card(f"c{index}", f"T{index}") for index in range(4)]
|
||||
self.ids = [entry["id"] for entry in self.cards]
|
||||
|
||||
def test_parse_selected(self):
|
||||
self.assertEqual(parse_selected('["a", "b"]'), ["a", "b"])
|
||||
self.assertEqual(parse_selected("a, b ,, c"), ["a", "b", "c"])
|
||||
self.assertEqual(parse_selected(""), [])
|
||||
self.assertEqual(parse_selected("[]"), [])
|
||||
self.assertEqual(parse_selected('{"bad": 1}'), [])
|
||||
|
||||
def test_none_and_all_modes(self):
|
||||
self.assertEqual(select_cards(self.cards, self.ids[:2], "None", "node"), [])
|
||||
picked = select_cards(self.cards, self.ids[:2], "All", "node")
|
||||
self.assertEqual([entry["id"] for entry in picked], self.ids[:2])
|
||||
|
||||
def test_all_mode_ignores_unselected_cards(self):
|
||||
picked = select_cards(self.cards, [self.ids[2]], "All", "node")
|
||||
self.assertEqual([entry["id"] for entry in picked], [self.ids[2]])
|
||||
|
||||
def test_sequential_cycles_through_selection(self):
|
||||
picks = [select_cards(self.cards, self.ids[:2], "Sequential", "seq-node")[0]["id"] for _ in range(4)]
|
||||
self.assertEqual(picks, [self.ids[0], self.ids[1], self.ids[0], self.ids[1]])
|
||||
|
||||
def test_random_no_replace_covers_selection_before_repeat(self):
|
||||
picks = [select_cards(self.cards, self.ids, "Random (No Replace)", "rand-node")[0]["id"] for _ in range(4)]
|
||||
self.assertEqual(sorted(picks), sorted(self.ids))
|
||||
|
||||
def test_selection_is_ignored_for_unknown_ids(self):
|
||||
self.assertEqual(select_cards(self.cards, ["ghost"], "All", "node"), [])
|
||||
|
||||
|
||||
class TestPickerNode(CharacterPresetTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
save_card("char1", {"trigger": "Char1", "positive": "red hair"})
|
||||
save_card("char2", {"trigger": "Char2", "positive": "blue hair"})
|
||||
cp.invalidate_card_cache()
|
||||
self.node = CharacterPresetPicker()
|
||||
|
||||
def test_detect_uses_selection_when_text_is_empty(self):
|
||||
stack, _, positive, _, names = self.node.pick(
|
||||
text="", selection_mode="All", _selected_presets='["char1"]'
|
||||
)
|
||||
self.assertEqual(positive, "red hair")
|
||||
self.assertEqual(names, "char1")
|
||||
self.assertEqual(stack, [])
|
||||
|
||||
def test_detect_mode_matches_first_trigger(self):
|
||||
stack, stack_json, positive, negative, names = self.node.pick(
|
||||
text="Char2 and Char1", match_mode="Whole Word"
|
||||
)
|
||||
self.assertEqual(positive, "blue hair")
|
||||
self.assertEqual(names, "char2")
|
||||
self.assertEqual(json.loads(stack_json), stack)
|
||||
|
||||
def test_select_mode_without_selection_is_empty(self):
|
||||
stack, _, positive, _, names = self.node.pick(_selected_presets="[]")
|
||||
self.assertEqual(stack, [])
|
||||
self.assertEqual(positive, "")
|
||||
self.assertEqual(names, "")
|
||||
|
||||
def test_select_mode_uses_hidden_widget(self):
|
||||
_, _, positive, _, names = self.node.pick(
|
||||
selection_mode="All", _selected_presets='["char1", "char2"]'
|
||||
)
|
||||
self.assertEqual(positive, "red hair, blue hair")
|
||||
self.assertEqual(names, "char1, char2")
|
||||
|
||||
def test_folder_filter_applies_to_detect(self):
|
||||
save_card("folder/char3", {"trigger": "Char3", "positive": "green hair"})
|
||||
cp.invalidate_card_cache()
|
||||
_, _, positive, _, _ = self.node.pick(text="Char3", folder="folder")
|
||||
self.assertEqual(positive, "green hair")
|
||||
_, _, positive, _, _ = self.node.pick(text="Char3", folder="")
|
||||
self.assertEqual(positive, "green hair")
|
||||
|
||||
def test_disabled_cards_are_skipped_by_default(self):
|
||||
save_card("off", {"trigger": "Off", "positive": "hidden", "enabled": False})
|
||||
cp.invalidate_card_cache()
|
||||
_, _, positive, _, _ = self.node.pick(text="Off")
|
||||
self.assertEqual(positive, "")
|
||||
_, _, positive, _, _ = self.node.pick(text="Off", include_disabled=True)
|
||||
self.assertEqual(positive, "hidden")
|
||||
|
||||
def test_input_types_expose_alignment_and_hidden_scrape_flag(self):
|
||||
inputs = CharacterPresetPicker.INPUT_TYPES()
|
||||
self.assertEqual(
|
||||
inputs["required"]["content_alignment"][0],
|
||||
["Left Aligned", "Right Aligned"],
|
||||
)
|
||||
self.assertIn("_scrape_on_new", inputs["hidden"])
|
||||
# Both are appended after the legacy widgets so saved positional
|
||||
# widget_values keep matching their original combo boxes.
|
||||
self.assertEqual(list(inputs["required"])[-1], "content_alignment")
|
||||
|
||||
def test_pick_accepts_new_alignment_and_scrape_arguments(self):
|
||||
_, _, positive, _, _ = self.node.pick(
|
||||
selection_mode="All",
|
||||
content_alignment="Right Aligned",
|
||||
_scrape_on_new="false",
|
||||
_selected_presets='["char1"]',
|
||||
)
|
||||
self.assertEqual(positive, "red hair")
|
||||
|
||||
def test_maybe_start_scraping_respects_the_hidden_flag(self):
|
||||
with patch("nodes.lora_loader.start_async_scraping") as start:
|
||||
self.assertTrue(cp._maybe_start_scraping({"scrape_on_new": "true"}))
|
||||
start.assert_called_once()
|
||||
self.assertFalse(cp._maybe_start_scraping({"scrape_on_new": "false"}))
|
||||
self.assertEqual(start.call_count, 1)
|
||||
|
||||
|
||||
class TestApplierNode(CharacterPresetTestCase):
|
||||
def test_applier_is_a_noop_without_stack(self):
|
||||
model, clip = object(), object()
|
||||
applied_model, applied_clip = ApplyCharacterLoRAs().apply(model, clip)
|
||||
self.assertIs(applied_model, model)
|
||||
self.assertIs(applied_clip, clip)
|
||||
|
||||
def test_applier_reads_json_fallback_and_skips_missing(self):
|
||||
model, clip = "MODEL", "CLIP"
|
||||
lora_file = os.path.join(self.temp_dir, "Chey.safetensors")
|
||||
with open(lora_file, "wb") as handle:
|
||||
handle.write(b"stub")
|
||||
stack = [
|
||||
{"name": "krea2/Chey.safetensors", "strength_model": 0.7, "strength_clip": 0.4},
|
||||
{"name": "missing.safetensors", "strength_model": 1.0, "strength_clip": 1.0},
|
||||
]
|
||||
loader = MagicMock(return_value=("MODEL_LORA", "CLIP_LORA"))
|
||||
with patch("folder_paths.get_full_path", return_value=lora_file), patch.object(
|
||||
cp.comfy.sd, "load_lora_for_models", loader
|
||||
), patch.object(cp, "increment_lora_usage") as usage:
|
||||
applied_model, applied_clip = ApplyCharacterLoRAs().apply(
|
||||
model, clip, loras_json=json.dumps(stack)
|
||||
)
|
||||
self.assertEqual((applied_model, applied_clip), ("MODEL_LORA", "CLIP_LORA"))
|
||||
loader.assert_called_once()
|
||||
args = loader.call_args[0]
|
||||
self.assertEqual(args[3], 0.7)
|
||||
self.assertEqual(args[4], 0.4)
|
||||
usage.assert_called_once_with("krea2/Chey.safetensors")
|
||||
|
||||
def test_applier_skips_zero_strength_entries(self):
|
||||
loader = MagicMock()
|
||||
with patch.object(cp.comfy.sd, "load_lora_for_models", loader), patch.object(
|
||||
cp, "increment_lora_usage"
|
||||
) as usage:
|
||||
ApplyCharacterLoRAs().apply(
|
||||
"MODEL",
|
||||
"CLIP",
|
||||
loras=[{"name": "krea2/Chey.safetensors", "strength_model": 0, "strength_clip": 0}],
|
||||
)
|
||||
loader.assert_not_called()
|
||||
usage.assert_called_once_with("krea2/Chey.safetensors")
|
||||
|
||||
|
||||
class TestReadAuthorization(CharacterPresetTestCase):
|
||||
"""Read-only card routes are loaded by plain fetch/<img>, which cannot send
|
||||
the CSRF header. They must stay loopback + same-origin instead."""
|
||||
|
||||
def request(self, remote="127.0.0.1", headers=None, transport=None):
|
||||
return SimpleNamespace(remote=remote, headers=headers or {}, transport=transport)
|
||||
|
||||
def test_local_same_origin_reads_need_no_token(self):
|
||||
self.assertTrue(cp._read_allowed(self.request()))
|
||||
|
||||
def test_remote_reads_are_rejected(self):
|
||||
self.assertFalse(cp._read_allowed(self.request(remote="10.1.2.3")))
|
||||
|
||||
def test_cross_site_reads_are_rejected(self):
|
||||
self.assertFalse(cp._read_allowed(self.request(headers={"Sec-Fetch-Site": "cross-site"})))
|
||||
self.assertFalse(cp._read_allowed(self.request(headers={"Origin": "https://evil.example"})))
|
||||
self.assertFalse(cp._read_allowed(self.request(headers={"Referer": "https://evil.example/page"})))
|
||||
|
||||
def test_remote_peer_through_transport_is_rejected(self):
|
||||
transport = SimpleNamespace(get_extra_info=lambda name: ("192.168.1.5", 1234))
|
||||
self.assertFalse(cp._read_allowed(self.request(remote=None, transport=transport)))
|
||||
|
||||
|
||||
class TestLoraImport(CharacterPresetTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.loras_dir = os.path.join(self.temp_dir, "loras")
|
||||
os.makedirs(os.path.join(self.loras_dir, "krea2", "Celebrity", "Actresses"), exist_ok=True)
|
||||
self.write_lora("krea2/Celebrity/Actresses/krea2_Ana-De-Armas_v1.safetensors", {"1_sks woman": {"sks woman": 1}})
|
||||
self.write_lora("krea2/Celebrity/Actresses/krea2_Emma-Watson-V4.safetensors")
|
||||
self.write_lora("krea2/Celebrity/Actresses/krea2_Emma-Watson-V2_large_onetrainer.safetensors")
|
||||
self.write_lora("krea2/Celebrity/Men/krea2_Hero.safetensors", {"1_hero": {"hero": 3}})
|
||||
self.write_lora("krea2/Other/Unrelated.safetensors")
|
||||
self.import_patches = [
|
||||
patch.object(cp, "available_loras", self.list_loras),
|
||||
patch.object(
|
||||
folder_paths,
|
||||
"get_folder_paths",
|
||||
create=True,
|
||||
new=lambda kind: [self.loras_dir] if kind == "loras" else [],
|
||||
),
|
||||
patch("folder_paths.get_full_path", self.full_path),
|
||||
]
|
||||
for patcher in self.import_patches:
|
||||
patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
for patcher in reversed(self.import_patches):
|
||||
patcher.stop()
|
||||
super().tearDown()
|
||||
|
||||
def write_lora(self, relative, tag_frequency=None):
|
||||
path = os.path.join(self.loras_dir, *relative.split("/"))
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
metadata = {}
|
||||
if tag_frequency is not None:
|
||||
metadata["ss_tag_frequency"] = json.dumps(tag_frequency)
|
||||
header = json.dumps({"__metadata__": metadata, "weight": {"dtype": "F16", "shape": [1], "data_offsets": [0, 2]}}).encode("utf-8")
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(struct.pack("<Q", len(header)))
|
||||
handle.write(header)
|
||||
handle.write(b"\x00\x00")
|
||||
return path
|
||||
|
||||
def list_loras(self):
|
||||
found = []
|
||||
for root, _dirs, files in os.walk(self.loras_dir):
|
||||
for name in files:
|
||||
if name.lower().endswith(".safetensors"):
|
||||
found.append(os.path.relpath(os.path.join(root, name), self.loras_dir).replace("\\", "/"))
|
||||
return sorted(found)
|
||||
|
||||
def full_path(self, _kind, name):
|
||||
candidate = os.path.join(self.loras_dir, *str(name).replace("\\", "/").split("/"))
|
||||
return candidate if os.path.exists(candidate) else ""
|
||||
|
||||
def test_humanize_lora_name(self):
|
||||
self.assertEqual(humanize_lora_name("krea2_Ana-De-Armas_v1.safetensors"), "Ana De Armas")
|
||||
self.assertEqual(humanize_lora_name("krea2_Emma-Watson-V2_large_onetrainer.safetensors"), "Emma Watson")
|
||||
self.assertEqual(humanize_lora_name("krea2_Judy-Hopps.safetensors"), "Judy Hopps")
|
||||
self.assertEqual(humanize_lora_name("Celebrity.safetensors"), "Celebrity")
|
||||
|
||||
def test_scan_only_reads_the_requested_folder(self):
|
||||
_, entries = scan_lora_import("krea2/Celebrity", target_folder="Celebrity")
|
||||
loras = {entry["lora"] for entry in entries}
|
||||
self.assertIn("krea2/Celebrity/Actresses/krea2_Ana-De-Armas_v1.safetensors", loras)
|
||||
self.assertNotIn("krea2/Other/Unrelated.safetensors", loras)
|
||||
self.assertEqual(len(entries), 4)
|
||||
|
||||
def test_scan_accepts_a_loader_style_wildcard(self):
|
||||
_, entries = scan_lora_import("krea2/*", target_folder="")
|
||||
loras = {entry["lora"] for entry in entries}
|
||||
self.assertIn("krea2/Celebrity/Actresses/krea2_Ana-De-Armas_v1.safetensors", loras)
|
||||
self.assertIn("krea2/Other/Unrelated.safetensors", loras)
|
||||
|
||||
def test_import_overrides_name_triggers_and_snippet(self):
|
||||
lora = "krea2/Celebrity/Men/krea2_Hero.safetensors"
|
||||
result = import_lora_cards(
|
||||
"krea2/Celebrity/Men",
|
||||
target_folder="Celebrity",
|
||||
overrides=[{
|
||||
"lora": lora,
|
||||
"name": "Hero Prime",
|
||||
"triggers": ["Hero Prime", "hero_prime"],
|
||||
"snippet": "A long appearance prompt",
|
||||
}],
|
||||
)
|
||||
self.assertEqual(len(result["created"]), 1)
|
||||
saved = load_card("Celebrity/Hero Prime")
|
||||
self.assertIsNotNone(saved)
|
||||
self.assertEqual(saved["triggers"], ["Hero Prime", "hero_prime"])
|
||||
self.assertEqual(saved["trigger"], "Hero Prime")
|
||||
self.assertEqual(saved["positive"], "A long appearance prompt")
|
||||
self.assertEqual(saved["loras"][0]["name"], lora)
|
||||
|
||||
def test_scan_builds_cards_under_the_target_folder(self):
|
||||
_, entries = scan_lora_import("krea2/Celebrity", target_folder="Celebrity")
|
||||
ids = {entry["id"] for entry in entries}
|
||||
self.assertIn("Celebrity/Actresses/Ana De Armas", ids)
|
||||
self.assertIn("Celebrity/Men/Hero", ids)
|
||||
|
||||
def test_scan_disambiguates_version_collisions(self):
|
||||
_, entries = scan_lora_import("krea2/Celebrity/Actresses", target_folder="")
|
||||
ids = sorted(entry["id"] for entry in entries)
|
||||
self.assertIn("Emma Watson", ids)
|
||||
self.assertIn("Emma Watson V4", ids)
|
||||
|
||||
def test_metadata_trigger_is_used_when_meaningful(self):
|
||||
_, entries = scan_lora_import("krea2/Celebrity/Men", trigger_mode="metadata")
|
||||
hero = next(entry for entry in entries if entry["lora"].endswith("krea2_Hero.safetensors"))
|
||||
self.assertEqual(hero["trigger"], "hero")
|
||||
|
||||
def test_generic_metadata_trigger_falls_back_to_the_name(self):
|
||||
_, entries = scan_lora_import("krea2/Celebrity/Actresses", trigger_mode="metadata")
|
||||
ana = next(entry for entry in entries if entry["lora"].endswith("krea2_Ana-De-Armas_v1.safetensors"))
|
||||
self.assertEqual(ana["trigger"], "Ana De Armas")
|
||||
|
||||
def test_trigger_mode_none_emits_no_trigger(self):
|
||||
_, entries = scan_lora_import("krea2/Celebrity/Men", trigger_mode="none")
|
||||
self.assertEqual(entries[0]["trigger"], "")
|
||||
|
||||
def test_scan_rejects_folders_outside_the_loras_root(self):
|
||||
with self.assertRaises(ValueError):
|
||||
scan_lora_import("../")
|
||||
with self.assertRaises(ValueError):
|
||||
scan_lora_import("krea2/../..")
|
||||
|
||||
def test_import_creates_cards_with_lora_and_preview(self):
|
||||
result = import_lora_cards("krea2/Celebrity/Men", target_folder="Celebrity")
|
||||
self.assertEqual(len(result["created"]), 1)
|
||||
saved = load_card("Celebrity/Hero")
|
||||
self.assertIsNotNone(saved)
|
||||
self.assertEqual(saved["trigger"], "Hero")
|
||||
self.assertEqual(saved["loras"][0]["name"], "krea2/Celebrity/Men/krea2_Hero.safetensors")
|
||||
self.assertTrue(saved["notes"].startswith("Imported from"))
|
||||
|
||||
def test_import_marks_existing_cards_and_skips_them(self):
|
||||
import_lora_cards("krea2/Celebrity/Men")
|
||||
_, entries = scan_lora_import("krea2/Celebrity/Men")
|
||||
self.assertTrue(entries[0]["exists"])
|
||||
result = import_lora_cards("krea2/Celebrity/Men")
|
||||
self.assertEqual(result["created"], [])
|
||||
self.assertEqual(result["skipped"][0]["reason"], "exists")
|
||||
|
||||
def test_import_overwrite_keeps_existing_prompts(self):
|
||||
import_lora_cards("krea2/Celebrity/Men")
|
||||
saved = load_card("Hero")
|
||||
saved["positive"] = "heroic portrait"
|
||||
saved["trigger"] = "cap"
|
||||
save_card("Hero", saved)
|
||||
result = import_lora_cards("krea2/Celebrity/Men", overwrite=True)
|
||||
self.assertEqual(len(result["created"]), 1)
|
||||
merged = load_card("Hero")
|
||||
self.assertEqual(merged["positive"], "heroic portrait")
|
||||
self.assertEqual(merged["trigger"], "cap")
|
||||
|
||||
def test_import_only_selected_loras(self):
|
||||
result = import_lora_cards(
|
||||
"krea2/Celebrity/Actresses",
|
||||
["krea2/Celebrity/Actresses/krea2_Emma-Watson-V4.safetensors"],
|
||||
)
|
||||
self.assertEqual([entry["id"] for entry in result["created"]], ["Emma Watson"])
|
||||
|
||||
def test_import_preview_comes_from_the_lora_sidecar(self):
|
||||
image = os.path.join(self.loras_dir, "krea2", "Celebrity", "Men", "krea2_Hero.jpg")
|
||||
with open(image, "wb") as handle:
|
||||
handle.write(b"\xff\xd8\xff\xd9")
|
||||
import_lora_cards("krea2/Celebrity/Men")
|
||||
preview = card_preview_path("Hero")
|
||||
self.assertEqual(os.path.normcase(preview), os.path.normcase(image))
|
||||
|
||||
|
||||
class TestCharacterUsage(CharacterPresetTestCase):
|
||||
def test_increment_usage_respects_setting(self):
|
||||
with patch.object(cp, "get_env_setting", return_value="false"):
|
||||
cp.increment_usage(["char1"])
|
||||
self.assertEqual(cp.load_usage(), {}, "disabled tracking records nothing")
|
||||
|
||||
with patch.object(cp, "get_env_setting", return_value="true"):
|
||||
cp.increment_usage(["char1"])
|
||||
self.assertEqual(cp.load_usage(), {"char1": 1}, "enabled tracking increments the counter")
|
||||
|
||||
def test_usage_is_hidden_when_tracking_is_disabled(self):
|
||||
save_card("Hero", card("Hero", "hero"))
|
||||
with open(cp.USAGE_FILE, "w", encoding="utf-8") as handle:
|
||||
json.dump({"Hero": 7}, handle)
|
||||
with patch.object(cp, "get_env_setting", return_value="false"):
|
||||
cards = cp.list_cards(folder="", use_cache=False)
|
||||
self.assertEqual(cards[0]["usage"], 0, "disabled tracking hides stored counts")
|
||||
|
||||
with patch.object(cp, "get_env_setting", return_value="true"):
|
||||
cards = cp.list_cards(folder="", use_cache=False)
|
||||
self.assertEqual(cards[0]["usage"], 7, "enabled tracking shows stored counts")
|
||||
|
||||
|
||||
class TestMaintenanceHelpers(CharacterPresetTestCase):
|
||||
def test_reset_cycle_only_clears_the_requested_picker(self):
|
||||
state = {
|
||||
"card_seq_3_abc": {"pool": [], "assigned": {}},
|
||||
"card_rand_3_abc": {"pool": [], "assigned": {}},
|
||||
"card_seq_9_xyz": {"pool": [], "assigned": {}},
|
||||
"other": {"pool": [], "assigned": {}},
|
||||
}
|
||||
cp._save_cycle_state(state)
|
||||
self.assertEqual(cp.reset_cycle_state("3"), 2)
|
||||
remaining = cp._load_cycle_state()
|
||||
self.assertIn("card_seq_9_xyz", remaining)
|
||||
self.assertIn("other", remaining)
|
||||
|
||||
def test_reset_usage_empties_character_usage_only(self):
|
||||
with open(cp.USAGE_FILE, "w", encoding="utf-8") as handle:
|
||||
json.dump({"char1": 12, "char2": 3}, handle)
|
||||
cp.reset_usage_file()
|
||||
self.assertEqual(cp.load_usage(), {})
|
||||
|
||||
def test_clear_cache_removes_shared_thumbnails(self):
|
||||
cache_dir = os.path.join(self.temp_dir, "thumbnails")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
cached = os.path.join(cache_dir, "thumb_test.webp")
|
||||
with open(cached, "wb") as handle:
|
||||
handle.write(b"webp")
|
||||
with patch("nodes.lora_loader.THUMBNAILS_DIR", cache_dir):
|
||||
self.assertEqual(cp.clear_thumbnail_cache(), 1)
|
||||
self.assertFalse(os.path.exists(cached))
|
||||
|
||||
def test_clear_cache_rejects_paths_outside_the_user_dir(self):
|
||||
outside = os.path.join(tempfile.mkdtemp(prefix="saturn_outside_"), "thumbs")
|
||||
os.makedirs(outside, exist_ok=True)
|
||||
self.addCleanup(shutil.rmtree, os.path.dirname(outside), True)
|
||||
with patch("nodes.lora_loader.THUMBNAILS_DIR", outside):
|
||||
with self.assertRaises(PermissionError):
|
||||
cp.clear_thumbnail_cache()
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,834 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { FakeElement, createDocument, sleep, stripImports } from "./fake_dom.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "..");
|
||||
|
||||
const document = createDocument();
|
||||
// The character picker renders through a document fragment and uses variadic
|
||||
// Element.append(); both are trivial to model on top of the shared fake DOM.
|
||||
document.createDocumentFragment = () => new FakeElement("fragment", document);
|
||||
FakeElement.prototype.append = function append(...nodes) {
|
||||
for (const node of nodes) this.appendChild(node);
|
||||
};
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
id: "char1",
|
||||
name: "Char1",
|
||||
folder: "",
|
||||
trigger: "Char1",
|
||||
positive: "red hair",
|
||||
negative: "",
|
||||
loras: [{ name: "chey.safetensors", strength_model: 1, strength_clip: 1, enabled: true }],
|
||||
tags: ["red"],
|
||||
enabled: true,
|
||||
usage: 0,
|
||||
has_preview: false,
|
||||
missing_loras: [],
|
||||
mtime: 1,
|
||||
},
|
||||
{
|
||||
id: "char2",
|
||||
name: "Char2",
|
||||
folder: "",
|
||||
trigger: "Char2",
|
||||
positive: "blue hair",
|
||||
negative: "",
|
||||
loras: [],
|
||||
tags: [],
|
||||
enabled: true,
|
||||
usage: 3,
|
||||
has_preview: false,
|
||||
missing_loras: ["gone.safetensors"],
|
||||
mtime: 2,
|
||||
},
|
||||
{
|
||||
id: "folder/char3",
|
||||
name: "Char3",
|
||||
folder: "folder",
|
||||
trigger: "Char3",
|
||||
positive: "green hair",
|
||||
negative: "",
|
||||
loras: [],
|
||||
tags: ["green"],
|
||||
enabled: true,
|
||||
usage: 0,
|
||||
has_preview: false,
|
||||
missing_loras: [],
|
||||
mtime: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const handlers = {};
|
||||
const api = {
|
||||
async fetchApi(url) {
|
||||
if (url.includes("/character_presets/get_presets")) {
|
||||
return { ok: true, status: 200, json: async () => ({ cards: CARDS, folders: ["folder"], root: "" }) };
|
||||
}
|
||||
if (url.includes("/character_presets/get_loras")) {
|
||||
return { ok: true, status: 200, json: async () => ({ loras: ["chey.safetensors"] }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
},
|
||||
addEventListener(name, callback) {
|
||||
handlers[name] = callback;
|
||||
},
|
||||
};
|
||||
|
||||
const storage = new Map();
|
||||
const window = {
|
||||
prompt: () => null,
|
||||
confirm: () => true,
|
||||
localStorage: {
|
||||
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
||||
setItem: (key, value) => storage.set(key, String(value)),
|
||||
removeItem: (key) => storage.delete(key),
|
||||
},
|
||||
};
|
||||
|
||||
const extension = { name: "", nodeCreated: null };
|
||||
const app = {
|
||||
registerExtension(candidate) {
|
||||
extension.name = candidate.name;
|
||||
extension.nodeCreated = candidate.nodeCreated;
|
||||
},
|
||||
graph: { _nodes: [], links: {}, getNodeById: () => null, setDirtyCanvas() {} },
|
||||
ui: {
|
||||
settings: {
|
||||
values: {},
|
||||
getSettingValue(id) {
|
||||
return this.values[id];
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const importCalls = [];
|
||||
const intervals = [];
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
document,
|
||||
app,
|
||||
api,
|
||||
authenticatedFetch: async (url, options = {}) => {
|
||||
const body = options.body ? JSON.parse(options.body) : null;
|
||||
importCalls.push({ url: String(url), body });
|
||||
if (String(url).includes("import_scan")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: "ok",
|
||||
root: "V:/models/loras/krea2/Celebrity",
|
||||
candidates: [
|
||||
{
|
||||
lora: "krea2/Celebrity/Men/krea2_Hero.safetensors",
|
||||
id: "Celebrity/Hero",
|
||||
name: "Hero",
|
||||
folder: "Celebrity",
|
||||
trigger: "Hero",
|
||||
has_preview: true,
|
||||
exists: false,
|
||||
},
|
||||
{
|
||||
lora: "krea2/Celebrity/Men/krea2_Villain.safetensors",
|
||||
id: "Celebrity/Villain",
|
||||
name: "Villain",
|
||||
folder: "Celebrity",
|
||||
trigger: "Villain",
|
||||
has_preview: false,
|
||||
exists: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (String(url).includes("import_loras")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: "ok",
|
||||
created: [{ id: "Celebrity/Hero", name: "Hero" }],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
total: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
},
|
||||
window,
|
||||
CSS: { escape: (value) => String(value).replace(/["\\]/g, "\\$&") },
|
||||
setInterval: (callback) => {
|
||||
intervals.push(callback);
|
||||
return intervals.length;
|
||||
},
|
||||
clearInterval: () => {},
|
||||
setTimeout: () => 0,
|
||||
clearTimeout: () => {},
|
||||
FileReader: class {
|
||||
readAsDataURL() {}
|
||||
},
|
||||
});
|
||||
|
||||
const source = stripImports(
|
||||
fs.readFileSync(path.join(repoRoot, "web", "js", "character_presets.js"), "utf8")
|
||||
);
|
||||
vm.runInContext(source, context, { filename: "character_presets.js" });
|
||||
|
||||
assert.equal(extension.name, "ComfyUI.SaturnNodes.CharacterPresets");
|
||||
assert.equal(typeof extension.nodeCreated, "function");
|
||||
assert.equal(typeof handlers["saturnnodes_character_presets_selected"], "function");
|
||||
|
||||
function makeNode() {
|
||||
const widgets = [];
|
||||
const node = {
|
||||
id: 3,
|
||||
type: "CharacterPresetPicker",
|
||||
comfyClass: "CharacterPresetPicker",
|
||||
size: [420, 540],
|
||||
widgets,
|
||||
inputs: [],
|
||||
graph: app.graph,
|
||||
setSize(size) {
|
||||
node.size = size;
|
||||
},
|
||||
triggerSlotEvent() {},
|
||||
onResize: null,
|
||||
addWidget(type, name, value, callback, options) {
|
||||
const widget = { type, name, value, callback, options: options || {} };
|
||||
widgets.push(widget);
|
||||
return widget;
|
||||
},
|
||||
addDOMWidget(name, type, element, options) {
|
||||
const widget = { name, type, element, ...options };
|
||||
widgets.push(widget);
|
||||
return widget;
|
||||
},
|
||||
};
|
||||
const specs = [
|
||||
["folder", ""],
|
||||
["selection_mode", "Sequential"],
|
||||
["separator", ", "],
|
||||
["match_mode", "Whole Word"],
|
||||
["match_all", false],
|
||||
["case_sensitive", false],
|
||||
["include_disabled", false],
|
||||
["sort_presets_by", "Name (A-Z)"],
|
||||
["sort_folders_by", "Name (A-Z)"],
|
||||
["folder_position", "Folders First"],
|
||||
["display_mode", "Scrollable"],
|
||||
["content_alignment", "Left Aligned"],
|
||||
];
|
||||
specs.forEach(([name, value]) => node.addWidget(typeof value === "boolean" ? "toggle" : "text", name, value));
|
||||
return node;
|
||||
}
|
||||
|
||||
const node = makeNode();
|
||||
app.graph._nodes = [node];
|
||||
|
||||
await extension.nodeCreated(node);
|
||||
await sleep(40);
|
||||
|
||||
const ui = node._saturnCharacterPicker;
|
||||
assert.ok(ui, "the picker UI must attach to the node");
|
||||
|
||||
const hidden = node.widgets.find((widget) => widget.name === "_selected_presets");
|
||||
assert.ok(hidden, "the hidden selection widget must exist");
|
||||
assert.equal(hidden.value, "[]");
|
||||
assert.ok(hidden.hidden, "the selection widget must stay hidden");
|
||||
|
||||
// Shifted workflow widget values (e.g. a positional mismatch) must be repaired
|
||||
// before ComfyUI validates the prompt, otherwise display_mode errors out.
|
||||
node.widgets.find((widget) => widget.name === "display_mode").value = "[]";
|
||||
ui.normalizeWidgetValues();
|
||||
assert.equal(
|
||||
node.widgets.find((widget) => widget.name === "display_mode").value,
|
||||
"Scrollable",
|
||||
"invalid display_mode values self-heal to the default"
|
||||
);
|
||||
|
||||
// Workflow values are applied after nodeCreated, so onConfigure must repair
|
||||
// the shifted positional values as well.
|
||||
node.widgets.find((widget) => widget.name === "display_mode").value = "Folders First";
|
||||
node.widgets.find((widget) => widget.name === "folder_position").value = "Name (A-Z)";
|
||||
node.onConfigure?.({});
|
||||
assert.equal(
|
||||
node.widgets.find((widget) => widget.name === "display_mode").value,
|
||||
"Scrollable",
|
||||
"onConfigure repairs an invalid display_mode"
|
||||
);
|
||||
assert.equal(
|
||||
node.widgets.find((widget) => widget.name === "folder_position").value,
|
||||
"Folders First",
|
||||
"onConfigure repairs an invalid folder_position"
|
||||
);
|
||||
|
||||
// The folder filter is a native widget, matching the visual LoRA loader.
|
||||
const folderWidget = node.widgets.find((widget) => widget.name === "folder");
|
||||
assert.ok(folderWidget && !folderWidget.hidden, "the folder widget stays visible");
|
||||
assert.equal(ui.folderWidget, folderWidget, "the picker drives the native folder widget");
|
||||
|
||||
// The tile size slider is a native widget too.
|
||||
assert.ok(node.widgets.find((widget) => widget.name === "tile_size"), "the picker adds a tile_size slider");
|
||||
|
||||
const tiles = ui.grid.querySelectorAll(".saturn-char-tile");
|
||||
assert.equal(tiles.length, 3, "every card renders a tile");
|
||||
assert.equal(ui.grid.querySelectorAll(".saturn-char-trigger").length, 0, "tiles no longer show a trigger line");
|
||||
assert.equal(tiles[0].querySelector(".saturn-char-actions"), null, "tile actions moved out of the tile overlays");
|
||||
assert.ok(ui.grid.classList.contains("scrollable"), "default display mode scrolls");
|
||||
assert.equal(ui.grid.querySelectorAll(".saturn-char-folder-grid").length, 2, "tiles live in folder groups");
|
||||
|
||||
// The picker mirrors the visual LoRA loader: one compact control bar and
|
||||
// fixed-size tiles instead of a width-stretched CSS grid.
|
||||
const controlLabels = ui.container
|
||||
.querySelectorAll(".saturn-char-control-label")
|
||||
.map((node) => node.textContent);
|
||||
assert.deepEqual(
|
||||
controlLabels,
|
||||
["Mode:", "Sort:", "Folders:", "Align:", "View:", "Reset:"],
|
||||
"the control bar exposes the loader-style controls"
|
||||
);
|
||||
const toggleLabels = ui.container
|
||||
.querySelectorAll(".saturn-char-toggle-btn")
|
||||
.map((node) => node.textContent);
|
||||
assert.deepEqual(
|
||||
toggleLabels,
|
||||
[
|
||||
"Left",
|
||||
"Right",
|
||||
"Scroll",
|
||||
"Show All",
|
||||
"\u21BA Counter",
|
||||
"\uD83E\uDDF9 Usage",
|
||||
"\uD83D\uDDD1 Cache",
|
||||
"\uFF0B",
|
||||
"\u21BB",
|
||||
"\uD83D\uDCC2",
|
||||
"\u270e",
|
||||
"\u29c9",
|
||||
"\u2715",
|
||||
],
|
||||
"the extra picker actions use icon buttons"
|
||||
);
|
||||
["selection_mode", "sort_presets_by", "sort_folders_by", "content_alignment", "display_mode", "_scrape_on_new"].forEach((name) => {
|
||||
const widget = node.widgets.find((item) => item.name === name);
|
||||
assert.ok(widget.hidden, `${name} is replaced by the in-node control bar`);
|
||||
});
|
||||
|
||||
// Content alignment mirrors the Visual LoRA Loader: Left keeps the normal
|
||||
// flow, Right anchors folder headers and tiles to the right edge.
|
||||
const alignWidget = node.widgets.find((widget) => widget.name === "content_alignment");
|
||||
ui.rightAlignButton.click();
|
||||
assert.equal(alignWidget.value, "Right Aligned", "Right alignment updates content_alignment");
|
||||
assert.ok(ui.container.classList.contains("saturn-char-right-aligned"), "Right alignment toggles the root class");
|
||||
assert.ok(ui.rightAlignButton.classList.contains("active"), "Right button is active");
|
||||
assert.ok(!ui.leftAlignButton.classList.contains("active"), "Left button is inactive");
|
||||
ui.leftAlignButton.click();
|
||||
assert.equal(alignWidget.value, "Left Aligned", "Left alignment restores content_alignment");
|
||||
assert.ok(!ui.container.classList.contains("saturn-char-right-aligned"), "Left alignment removes the root class");
|
||||
|
||||
// Selection count is shown under the rendered grid.
|
||||
hidden.value = JSON.stringify(["char1", "char2"]);
|
||||
ui.updateSelectionStyles();
|
||||
assert.equal(ui.selectionCountEl.textContent, "2 cards selected", "selection count mirrors the hidden selection");
|
||||
hidden.value = JSON.stringify(["char1"]);
|
||||
ui.updateSelectionStyles();
|
||||
assert.equal(ui.selectionCountEl.textContent, "1 card selected", "single selection uses the singular label");
|
||||
hidden.value = "[]";
|
||||
ui.updateSelectionStyles();
|
||||
assert.equal(ui.selectionCountEl.textContent, "No cards selected", "empty selection is explicit");
|
||||
|
||||
// Edit/Duplicate/Delete live in the top control bar and stay disabled unless
|
||||
// exactly one card is selected.
|
||||
hidden.value = "[]";
|
||||
ui.updateSelectionStyles();
|
||||
assert.ok(ui.editCardButton.disabled && ui.duplicateCardButton.disabled && ui.deleteCardButton.disabled,
|
||||
"card actions are disabled with no selection");
|
||||
hidden.value = JSON.stringify(["char1", "char2"]);
|
||||
ui.updateSelectionStyles();
|
||||
assert.ok(ui.editCardButton.disabled && ui.duplicateCardButton.disabled && ui.deleteCardButton.disabled,
|
||||
"card actions are disabled with a multi-selection");
|
||||
hidden.value = JSON.stringify(["char1"]);
|
||||
ui.updateSelectionStyles();
|
||||
assert.ok(!ui.editCardButton.disabled && !ui.duplicateCardButton.disabled && !ui.deleteCardButton.disabled,
|
||||
"card actions enable for exactly one selected card");
|
||||
ui.editCardButton.click();
|
||||
await sleep(0);
|
||||
assert.ok(ui.modal, "the edit action opens the selected card's editor");
|
||||
ui.modal.remove();
|
||||
ui.modal = null;
|
||||
hidden.value = "[]";
|
||||
ui.updateSelectionStyles();
|
||||
|
||||
// Rank borders: the most-used card gets the gold ring while normal badges
|
||||
// (including the missing-LoRA warning) stay visible.
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="char2"]').classList.contains("rank-1"),
|
||||
true,
|
||||
"the most-used card gets the rank-1 border"
|
||||
);
|
||||
assert.ok(
|
||||
ui.grid.querySelector('[data-card-id="char2"] .saturn-char-badge.warn'),
|
||||
"warning badges remain visible with rank borders enabled"
|
||||
);
|
||||
|
||||
// Usage badges are kept and scale with the usage count.
|
||||
const originalUsage = CARDS[0].usage;
|
||||
CARDS[0].usage = 5;
|
||||
ui.render();
|
||||
const rankedUsage = ui.grid.querySelector('[data-card-id="char1"] .saturn-char-badge.usage');
|
||||
assert.ok(rankedUsage, "usage badges are kept when rank borders are enabled");
|
||||
assert.equal(rankedUsage.textContent, "5", "the usage badge shows the count");
|
||||
assert.ok(
|
||||
parseFloat(rankedUsage.style.getPropertyValue("--usage-scale")) > 1,
|
||||
"the usage badge grows with the usage count"
|
||||
);
|
||||
|
||||
// A 1000+ usage card gets the animated RGB border and keeps its badge.
|
||||
CARDS[0].usage = 1200;
|
||||
ui.render();
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="char1"]').classList.contains("usage-1k"),
|
||||
true,
|
||||
"1000+ usage adds the RGB border class"
|
||||
);
|
||||
const bigBadge = ui.grid.querySelector('[data-card-id="char1"] .saturn-char-badge.usage');
|
||||
assert.ok(bigBadge, "the 1k border keeps the usage badge");
|
||||
assert.ok(
|
||||
parseFloat(bigBadge.style.getPropertyValue("--usage-scale")) > 1.5,
|
||||
"large usage counts make the corner badge noticeably bigger"
|
||||
);
|
||||
|
||||
// Disabling the setting keeps the badges and removes the rank rings.
|
||||
app.ui.settings.values["SaturnNodes.🖼️ Visual Loaders.05b_CharacterRankBorders"] = false;
|
||||
CARDS[0].usage = 5;
|
||||
ui.render();
|
||||
assert.ok(
|
||||
ui.grid.querySelector('[data-card-id="char1"] .saturn-char-badge.usage'),
|
||||
"usage badges stay visible with rank borders disabled"
|
||||
);
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="char1"]').classList.contains("rank-1"),
|
||||
false,
|
||||
"disabled rank borders remove rank classes"
|
||||
);
|
||||
app.ui.settings.values["SaturnNodes.🖼️ Visual Loaders.05b_CharacterRankBorders"] = true;
|
||||
CARDS[0].usage = originalUsage;
|
||||
ui.render();
|
||||
|
||||
// Maintenance buttons ask first and call the picker reset endpoints.
|
||||
const beforeResets = importCalls.length;
|
||||
ui.cycleResetButton.click();
|
||||
await sleep(0);
|
||||
const cycleReset = importCalls.slice(beforeResets).find((call) => call.url.includes("character_presets/reset_cycle"));
|
||||
assert.ok(cycleReset, "the counter button calls reset_cycle");
|
||||
assert.equal(cycleReset.body.node_id, "3", "the cycle reset targets this picker");
|
||||
const beforeUsage = importCalls.length;
|
||||
ui.usageResetButton.click();
|
||||
await sleep(40);
|
||||
assert.ok(
|
||||
importCalls.slice(beforeUsage).some((call) => call.url.includes("character_presets/reset_usage")),
|
||||
"the usage button calls reset_usage"
|
||||
);
|
||||
const beforeCache = importCalls.length;
|
||||
ui.cacheResetButton.click();
|
||||
await sleep(40);
|
||||
assert.ok(
|
||||
importCalls.slice(beforeCache).some((call) => call.url.includes("character_presets/clear_cache")),
|
||||
"the cache button calls clear_cache"
|
||||
);
|
||||
|
||||
assert.match(styleTextForNow(), /saturn-char-rgb-border/, "the RGB rank animation is defined");
|
||||
|
||||
function styleTextForNow() {
|
||||
return document.getElementById("saturn-character-presets-style").textContent;
|
||||
}
|
||||
|
||||
const styleText = document.getElementById("saturn-character-presets-style").textContent;
|
||||
assert.match(styleText, /width:\s*var\(--saturn-char-tile-size, 80px\)/, "tiles use a fixed width");
|
||||
assert.match(styleText, /aspect-ratio:\s*3 \/ 4/, "tiles keep the LoRA loader card ratio");
|
||||
assert.match(
|
||||
styleText,
|
||||
/\.saturn-char-thumb-preview \{[^}]*aspect-ratio:\s*3 \/ 4/s,
|
||||
"the editor preview uses the same 3:4 ratio as the tiles"
|
||||
);
|
||||
assert.match(
|
||||
styleText,
|
||||
/\.saturn-char-editor-fields \.saturn-char-cols \{[^}]*flex:\s*1/s,
|
||||
"the prompt fields grow into the remaining editor height"
|
||||
);
|
||||
assert.doesNotMatch(styleText, /minmax\(96px/, "the width-stretched grid is gone");
|
||||
|
||||
// Manual selection mirrors the Visual LoRA Loader: a plain click selects only
|
||||
// that card, Ctrl/Cmd toggles, and Shift selects the visual range.
|
||||
const clickCard = (cardId, init = {}) =>
|
||||
ui.grid
|
||||
.querySelector(`[data-card-id="${cardId}"]`)
|
||||
.dispatchEvent({ type: "click", preventDefault() {}, stopPropagation() {}, ...init });
|
||||
|
||||
clickCard("char1");
|
||||
assert.deepEqual(JSON.parse(hidden.value), ["char1"], "a plain click selects the card");
|
||||
assert.ok(
|
||||
ui.grid.querySelector('[data-card-id="char1"]').classList.contains("selected"),
|
||||
"selected tiles get the green ring state"
|
||||
);
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="char1"] .saturn-char-check').textContent,
|
||||
"1",
|
||||
"the badge shows the selection order"
|
||||
);
|
||||
|
||||
clickCard("folder/char3", { ctrlKey: true });
|
||||
assert.deepEqual(JSON.parse(hidden.value), ["char1", "folder/char3"], "Ctrl-click adds a card");
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="char1"] .saturn-char-check').textContent,
|
||||
"1",
|
||||
"first pick stays 1"
|
||||
);
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="folder/char3"] .saturn-char-check').textContent,
|
||||
"2",
|
||||
"second pick becomes 2"
|
||||
);
|
||||
|
||||
clickCard("folder/char3", { ctrlKey: true });
|
||||
assert.deepEqual(JSON.parse(hidden.value), ["char1"], "Ctrl-clicking a selected card removes it");
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="folder/char3"] .saturn-char-check').textContent,
|
||||
"",
|
||||
"Ctrl-deselecting clears the badge"
|
||||
);
|
||||
|
||||
clickCard("char1");
|
||||
assert.deepEqual(JSON.parse(hidden.value), [], "clicking the only selected card clears the selection");
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="char1"] .saturn-char-check').textContent,
|
||||
"",
|
||||
"the badge clears on deselect"
|
||||
);
|
||||
|
||||
clickCard("char1");
|
||||
clickCard("char2", { shiftKey: true });
|
||||
assert.deepEqual(
|
||||
JSON.parse(hidden.value),
|
||||
["char1", "char2"],
|
||||
"Shift-click adds every card visually between the anchor and the click"
|
||||
);
|
||||
|
||||
clickCard("char2");
|
||||
assert.deepEqual(JSON.parse(hidden.value), ["char2"], "a plain click replaces a multi-selection");
|
||||
clickCard("char2");
|
||||
assert.deepEqual(JSON.parse(hidden.value), [], "plain click clears the only selected card again");
|
||||
|
||||
// Stale selections from deleted or moved cards are pruned on refresh.
|
||||
hidden.value = JSON.stringify(["char1", "ghost"]);
|
||||
assert.equal(ui.pruneSelection(new Set(["char1"])), 1, "pruneSelection reports removed ids");
|
||||
assert.deepEqual(JSON.parse(hidden.value), ["char1"], "only surviving cards stay selected");
|
||||
hidden.value = "[]";
|
||||
|
||||
// Sorting: usage sort puts the most used card first (within the root group).
|
||||
node.widgets.find((widget) => widget.name === "folder_position").value = "Root Presets First";
|
||||
node.widgets.find((widget) => widget.name === "sort_presets_by").value = "Usage (High to Low)";
|
||||
ui.render();
|
||||
assert.equal(
|
||||
ui.grid.querySelectorAll(".saturn-char-tile")[0].getAttribute("data-card-id"),
|
||||
"char2",
|
||||
"usage sort orders tiles"
|
||||
);
|
||||
node.widgets.find((widget) => widget.name === "folder_position").value = "Folders First";
|
||||
node.widgets.find((widget) => widget.name === "sort_presets_by").value = "Name (A-Z)";
|
||||
ui.render();
|
||||
assert.equal(
|
||||
ui.grid.querySelectorAll(".saturn-char-tile")[0].getAttribute("data-card-id"),
|
||||
"folder/char3",
|
||||
"folders-first grouping puts folder cards on top"
|
||||
);
|
||||
|
||||
// Folder sorting: the folder-level sort reorders groups independently of the
|
||||
// per-card sort, matching the Visual LoRA Loader's sort_folders_by options.
|
||||
const originalCards = ui.cards;
|
||||
ui.cards = [
|
||||
...originalCards,
|
||||
{ id: "zebra/char4", name: "Char4", folder: "zebra", loras: [], usage: 50, mtime: 4 },
|
||||
];
|
||||
node.widgets.find((widget) => widget.name === "sort_folders_by").value = "Total Usage (High to Low)";
|
||||
ui.render();
|
||||
const sortedHeaders = ui.grid.querySelectorAll(".saturn-char-folder-header");
|
||||
assert.equal(
|
||||
sortedHeaders[0].getAttribute("data-folder"),
|
||||
"zebra",
|
||||
"total usage folder sort puts the busiest folder first"
|
||||
);
|
||||
node.widgets.find((widget) => widget.name === "sort_folders_by").value = "Name (A-Z)";
|
||||
ui.cards = originalCards;
|
||||
ui.render();
|
||||
|
||||
// Folder groups: headers, counts, collapse state and select-all.
|
||||
const rootHeader = ui.grid.querySelector('.saturn-char-folder-header[data-folder="Root"]');
|
||||
const folderHeader = ui.grid.querySelector('.saturn-char-folder-header[data-folder="folder"]');
|
||||
assert.ok(rootHeader, "unfiled cards get a Root group");
|
||||
assert.ok(folderHeader, "folder cards get their own group");
|
||||
const rootGroup = rootHeader.parentElement;
|
||||
assert.equal(rootGroup.querySelectorAll(".saturn-char-tile").length, 2, "the Root group holds both unfiled cards");
|
||||
assert.equal(folderHeader.parentElement.querySelectorAll(".saturn-char-tile").length, 1, "the folder group holds its card");
|
||||
assert.equal(rootHeader.querySelector(".saturn-char-folder-count").textContent, "(2)", "the header shows the card count");
|
||||
|
||||
rootHeader.click();
|
||||
assert.ok(rootGroup.classList.contains("collapsed"), "clicking a header collapses its group");
|
||||
assert.ok(rootHeader.classList.contains("collapsed"), "the header arrow flips when collapsed");
|
||||
assert.equal(window.localStorage.getItem(ui.folderStorageKey("Root")), "true", "collapse state is persisted");
|
||||
rootHeader.click();
|
||||
assert.ok(!rootGroup.classList.contains("collapsed"), "clicking again expands the group");
|
||||
assert.equal(window.localStorage.getItem(ui.folderStorageKey("Root")), "false", "expanded state is persisted");
|
||||
|
||||
const rootCheckbox = rootHeader.querySelector(".saturn-char-folder-checkbox");
|
||||
rootCheckbox.checked = true;
|
||||
rootCheckbox.click();
|
||||
assert.deepEqual(JSON.parse(hidden.value).sort(), ["char1", "char2"], "the folder checkbox selects every card in the group");
|
||||
assert.ok(rootCheckbox.checked, "the folder checkbox stays ticked when the whole group is selected");
|
||||
rootCheckbox.checked = false;
|
||||
rootCheckbox.click();
|
||||
assert.deepEqual(JSON.parse(hidden.value), [], "unticking the folder clears its cards");
|
||||
|
||||
// Mode and view controls drive the hidden native widgets.
|
||||
ui.modeSelect.value = "Random";
|
||||
ui.modeSelect.dispatchEvent({ type: "change" });
|
||||
assert.equal(node.widgets.find((widget) => widget.name === "selection_mode").value, "Random", "Mode updates selection_mode");
|
||||
assert.match(ui.statusEl.textContent, /random/i, "Mode updates the hint line");
|
||||
ui.modeSelect.value = "Sequential";
|
||||
ui.modeSelect.dispatchEvent({ type: "change" });
|
||||
|
||||
ui.showAllButton.click();
|
||||
assert.equal(node.widgets.find((widget) => widget.name === "display_mode").value, "Show All", "View updates display_mode");
|
||||
assert.ok(ui.grid.classList.contains("show-all"), "Show All removes the scroll container");
|
||||
assert.ok(!ui.grid.classList.contains("scrollable"), "the grid stops scrolling in Show All");
|
||||
ui.scrollButton.click();
|
||||
assert.equal(node.widgets.find((widget) => widget.name === "display_mode").value, "Scrollable", "Scroll restores display_mode");
|
||||
assert.ok(ui.grid.classList.contains("scrollable"), "the grid scrolls again");
|
||||
|
||||
// Connected text: the grid is disabled for clicking and the first trigger wins.
|
||||
app.graph.links = { 1: { id: 1, origin_id: 9, origin_slot: 0 } };
|
||||
node.inputs = [{ name: "text", link: 1 }];
|
||||
const origin = { id: 9, widgets: [{ name: "text", value: "I am Char2 and Char1" }] };
|
||||
app.graph.getNodeById = (id) => (String(id) === "9" ? origin : null);
|
||||
|
||||
ui.updateDetectPreview();
|
||||
assert.ok(ui.detectActive, "a non-empty text input activates detection");
|
||||
assert.ok(ui.grid.classList.contains("detect-mode"), "detection greys out the grid");
|
||||
|
||||
const before = hidden.value;
|
||||
ui.grid.querySelector('[data-card-id="char1"]').click();
|
||||
assert.equal(hidden.value, before, "detection ignores tile clicks");
|
||||
|
||||
ui.lastDetectSignature = null;
|
||||
ui.updateDetectPreview();
|
||||
assert.ok(
|
||||
ui.grid.querySelector('[data-card-id="char2"]').classList.contains("matched"),
|
||||
"the first trigger in the text is highlighted"
|
||||
);
|
||||
assert.ok(
|
||||
!ui.grid.querySelector('[data-card-id="char1"]').classList.contains("matched"),
|
||||
"later triggers stay unhighlighted when match_all is off"
|
||||
);
|
||||
|
||||
node.widgets.find((widget) => widget.name === "match_all").value = true;
|
||||
ui.lastDetectSignature = null;
|
||||
ui.updateDetectPreview();
|
||||
assert.ok(
|
||||
ui.grid.querySelector('[data-card-id="char1"]').classList.contains("matched"),
|
||||
"match_all highlights every trigger in the text"
|
||||
);
|
||||
|
||||
node.widgets.find((widget) => widget.name === "match_all").value = false;
|
||||
ui.lastDetectSignature = null;
|
||||
ui.updateDetectPreview();
|
||||
|
||||
// Upstream text changes are picked up by the watcher.
|
||||
origin.widgets[0].value = "Only Char1 here";
|
||||
ui.updateDetectPreview();
|
||||
assert.ok(
|
||||
ui.grid.querySelector('[data-card-id="char1"]').classList.contains("matched"),
|
||||
"the detect preview follows upstream text changes"
|
||||
);
|
||||
|
||||
// An empty text falls back to manual selection.
|
||||
origin.widgets[0].value = "";
|
||||
ui.updateDetectPreview();
|
||||
assert.ok(!ui.detectActive, "an empty text input disables detection");
|
||||
assert.ok(!ui.grid.classList.contains("detect-mode"), "the grid becomes clickable again");
|
||||
ui.grid.querySelector('[data-card-id="char2"]').click();
|
||||
assert.deepEqual(JSON.parse(hidden.value), ["char2"], "clicks select cards again once text is empty");
|
||||
assert.equal(
|
||||
ui.grid.querySelector('[data-card-id="char2"]').querySelector(".saturn-char-check").textContent,
|
||||
"1",
|
||||
"manual numbering resumes after detection ends"
|
||||
);
|
||||
|
||||
// Post-run feedback: the used card flashes.
|
||||
await handlers["saturnnodes_character_presets_selected"]({
|
||||
detail: { node_id: "3", cards: ["char1"], status: "" },
|
||||
});
|
||||
await sleep(40);
|
||||
assert.ok(
|
||||
ui.grid.querySelector('[data-card-id="char1"]').classList.contains("used"),
|
||||
"the card used by the last run flashes"
|
||||
);
|
||||
assert.match(ui.statusEl.textContent, /Used: Char1/);
|
||||
|
||||
// No-match feedback.
|
||||
await handlers["saturnnodes_character_presets_selected"]({
|
||||
detail: { node_id: "3", cards: [], status: "no-match" },
|
||||
});
|
||||
assert.match(ui.statusEl.textContent, /No character trigger matched/);
|
||||
|
||||
// Another node id must not touch this picker.
|
||||
ui.setStatus("idle");
|
||||
await handlers["saturnnodes_character_presets_selected"]({
|
||||
detail: { node_id: "99", cards: ["char2"], status: "" },
|
||||
});
|
||||
assert.equal(ui.statusEl.textContent, "idle");
|
||||
|
||||
// Import modal: scanning lists LoRA candidates and imports only the ticked ones.
|
||||
await ui.openImportModal();
|
||||
assert.ok(ui.modal, "the import modal opens");
|
||||
|
||||
const importModal = ui.modal;
|
||||
const modalInputs = importModal
|
||||
.querySelectorAll("input")
|
||||
.filter((input) => input.type === "text");
|
||||
assert.equal(modalInputs.length, 2, "the import modal has a LoRA folder and a card folder field");
|
||||
|
||||
const folderInput = modalInputs[0];
|
||||
const targetInput = modalInputs[1];
|
||||
folderInput.value = "krea2/Celebrity";
|
||||
folderInput.dispatchEvent({ type: "input", target: folderInput });
|
||||
assert.equal(targetInput.value, "Celebrity", "the card folder is prefilled from the LoRA folder");
|
||||
|
||||
// The fake DOM does not auto-select the first <option>, so mirror the browser.
|
||||
importModal.querySelectorAll("select")[0].value = "name";
|
||||
|
||||
const findModalButton = (label) =>
|
||||
importModal.querySelectorAll("button").find((button) => button.textContent === label);
|
||||
|
||||
const scanButton = findModalButton("Scan");
|
||||
assert.ok(scanButton, "the import modal exposes a Scan button");
|
||||
scanButton.click();
|
||||
await sleep(40);
|
||||
|
||||
const scanCall = importCalls.find((call) => call.url.includes("import_scan"));
|
||||
assert.deepEqual(
|
||||
scanCall.body,
|
||||
{ folder: "krea2/Celebrity", target_folder: "Celebrity", trigger_mode: "name", overwrite: false },
|
||||
"the scan sends the folder, card folder and trigger mode"
|
||||
);
|
||||
|
||||
const rows = importModal.querySelectorAll(".saturn-char-import-row");
|
||||
assert.equal(rows.length, 2, "the scan lists every candidate");
|
||||
const checkboxIn = (row) => row.querySelectorAll("input").find((input) => input.type === "checkbox");
|
||||
assert.ok(checkboxIn(rows[0]).checked, "new candidates are ticked by default");
|
||||
assert.ok(!checkboxIn(rows[1]).checked, "already imported cards stay unticked");
|
||||
assert.ok(rows[1].classList.contains("exists"), "existing cards are marked in the list");
|
||||
assert.match(
|
||||
importModal.querySelectorAll(".saturn-char-hint").map((node) => node.textContent).join(" "),
|
||||
/2 LoRA\(s\) found/,
|
||||
"the summary reports how many candidates were found"
|
||||
);
|
||||
|
||||
// Names and snippets can be edited per row before importing.
|
||||
const firstRowInputs = rows[0].querySelectorAll("input").filter((input) => input.type === "text");
|
||||
assert.equal(firstRowInputs.length, 2, "import rows expose editable name and snippet fields");
|
||||
firstRowInputs[0].value = "Hero Renamed";
|
||||
firstRowInputs[1].value = "a custom hero snippet";
|
||||
|
||||
const importAction = findModalButton("Import 1");
|
||||
assert.ok(importAction, "the import button counts the ticked candidates");
|
||||
importAction.click();
|
||||
await sleep(40);
|
||||
|
||||
const importCall = importCalls.find((call) => call.url.includes("import_loras"));
|
||||
assert.deepEqual(
|
||||
importCall.body.loras,
|
||||
["krea2/Celebrity/Men/krea2_Hero.safetensors"],
|
||||
"only the ticked LoRA is imported"
|
||||
);
|
||||
assert.deepEqual(
|
||||
importCall.body.overrides,
|
||||
[{
|
||||
lora: "krea2/Celebrity/Men/krea2_Hero.safetensors",
|
||||
name: "Hero Renamed",
|
||||
triggers: ["Hero Renamed"],
|
||||
snippet: "a custom hero snippet",
|
||||
}],
|
||||
"per-row names, triggers and snippets are sent to the importer"
|
||||
);
|
||||
assert.equal(ui.modal, null, "the modal closes after importing");
|
||||
assert.match(ui.statusEl.textContent, /Imported 1 card/, "the picker reports the import result");
|
||||
|
||||
// Card editor: wide two-column top row with the image on the left, id, name,
|
||||
// triggers and prompts beside it, icon image actions and an inline fetch status.
|
||||
await ui.openEditor(null);
|
||||
assert.ok(ui.modal, "the card editor opens");
|
||||
assert.ok(ui.modal.querySelector(".saturn-char-editor-top"), "the editor uses the two-column top row");
|
||||
const editorFields = ui.modal.querySelector(".saturn-char-editor-fields");
|
||||
assert.ok(editorFields, "id and triggers sit beside the image");
|
||||
const editorFieldInputs = editorFields.querySelectorAll("input");
|
||||
const editorIdInput = editorFieldInputs[0];
|
||||
const editorNameInput = editorFieldInputs[1];
|
||||
const editorTriggerInput = editorFieldInputs[2];
|
||||
assert.equal(editorIdInput.value, "New Character", "new cards get a normalized default id");
|
||||
assert.equal(editorNameInput.value, "New Character", "the display name follows the card id by default");
|
||||
assert.equal(editorFieldInputs.length, 3, "the editor exposes id, display name and triggers");
|
||||
assert.equal(
|
||||
editorFields.querySelectorAll(".saturn-char-cols").length,
|
||||
1,
|
||||
"the prompt and negative prompt sit under the triggers beside the image"
|
||||
);
|
||||
assert.equal(
|
||||
ui.modal.querySelectorAll(".saturn-char-icon-btn").length,
|
||||
2,
|
||||
"upload and remove are icon buttons"
|
||||
);
|
||||
const fetchStatus = ui.modal.querySelector(".saturn-char-fetch-status");
|
||||
assert.ok(fetchStatus, "the fetch action has an inline status element");
|
||||
const fetchButton = ui.modal.querySelector(".saturn-char-fetch-btn");
|
||||
assert.equal(fetchButton.textContent, "Fetch CivitAI Image", "the fetch action keeps its label");
|
||||
fetchButton.click();
|
||||
assert.match(fetchStatus.textContent, /Save the card first/, "fetching before saving reports a hint");
|
||||
|
||||
editorIdInput.value = "Krea2/Hero";
|
||||
editorIdInput.dispatchEvent({ type: "input" });
|
||||
assert.equal(editorNameInput.value, "Hero", "the display name follows id edits until it is customized");
|
||||
editorNameInput.value = "Hero Display";
|
||||
editorNameInput.dispatchEvent({ type: "input" });
|
||||
editorTriggerInput.value = "Hero";
|
||||
editorIdInput.value = "Krea2/Hero Display";
|
||||
editorIdInput.dispatchEvent({ type: "input" });
|
||||
assert.equal(editorNameInput.value, "Hero Display", "a custom display name survives later id edits");
|
||||
|
||||
const editorSave = ui.modal
|
||||
.querySelectorAll("button")
|
||||
.find((button) => button.textContent === "Save");
|
||||
editorSave.click();
|
||||
await sleep(40);
|
||||
const saveCall = importCalls.find((call) => call.url.includes("/character_presets/save"));
|
||||
assert.ok(saveCall, "saving posts the card");
|
||||
assert.equal(saveCall.body.card.name, "Hero Display", "the display name is saved separately from the card id");
|
||||
assert.equal(saveCall.body.id, "Krea2/Hero Display", "the card id is saved from the id field");
|
||||
assert.equal(ui.modal, null, "the editor closes after saving");
|
||||
|
||||
ui.allCardIds.add("New Character");
|
||||
assert.equal(ui.suggestNewCardId(), "New Character 2", "repeated new cards get a unique id");
|
||||
ui.folderWidget.value = "Krea2\\";
|
||||
assert.equal(ui.suggestNewCardId(), "Krea2/New Character", "new ids normalize the folder slashes");
|
||||
|
||||
ui.destroy();
|
||||
assert.equal(intervals.length, 1, "the detect watcher is registered once");
|
||||
|
||||
console.log("character presets DOM tests passed");
|
||||
@@ -130,4 +130,96 @@ document.body.appendChild(toolbar);
|
||||
FloatingLatentDock.prototype.updatePosition.call(dock);
|
||||
assert.equal(dock.dockEl.style.bottom, "88px", "dock must sit 8px above the canvas toolbar");
|
||||
|
||||
// 5. Idle dock modes: Auto-Hide and View Image start hidden and only show
|
||||
// once a fresh latent arrives; Keep Dock Visible always shows. View Image
|
||||
// additionally stays up when a decoded final image is displayed.
|
||||
let apiHandlers = {};
|
||||
const makeModeDock = (mode) => {
|
||||
const dockEl = document.createElement("div");
|
||||
dockEl.classList.add("saturn-dock-hidden");
|
||||
const settings = {
|
||||
"03_DockModeWhenIdle": mode,
|
||||
"01_EnableFloatingDock": true,
|
||||
};
|
||||
return {
|
||||
dockEl,
|
||||
btnEl: null,
|
||||
isVisible: true,
|
||||
isGenerating: false,
|
||||
idleHidden: true,
|
||||
runSawLatent: false,
|
||||
displayedFinalImage: false,
|
||||
pendingFinalImage: false,
|
||||
statusBadge: null,
|
||||
lightboxEl: null,
|
||||
getSetting(id, fallback) {
|
||||
const key = Object.keys(settings).find(name => id.includes(name));
|
||||
return key ? settings[key] : fallback;
|
||||
},
|
||||
updateVisibility: FloatingLatentDock.prototype.updateVisibility,
|
||||
requestPositionUpdate() {},
|
||||
updatePosition() {},
|
||||
applyDockMode: FloatingLatentDock.prototype.applyDockMode,
|
||||
getDockMode: FloatingLatentDock.prototype.getDockMode,
|
||||
onExecuted: FloatingLatentDock.prototype.onExecuted,
|
||||
};
|
||||
};
|
||||
const registerHandlers = (target) => {
|
||||
apiHandlers = {};
|
||||
context.api.addEventListener = (name, callback) => {
|
||||
apiHandlers[name] = callback;
|
||||
};
|
||||
FloatingLatentDock.prototype.setupExecutionListeners.call(target);
|
||||
};
|
||||
|
||||
const autoDock = makeModeDock("Auto-Hide");
|
||||
autoDock.applyDockMode();
|
||||
assert.ok(autoDock.dockEl.classList.contains("saturn-dock-hidden"), "Auto-Hide starts hidden");
|
||||
|
||||
registerHandlers(autoDock);
|
||||
assert.equal(typeof apiHandlers.execution_start, "function", "execution_start handler must register");
|
||||
assert.equal(typeof apiHandlers.executed, "function", "executed handler must register");
|
||||
|
||||
apiHandlers.execution_start();
|
||||
assert.ok(
|
||||
autoDock.dockEl.classList.contains("saturn-dock-hidden"),
|
||||
"execution_start must keep Auto-Hide hidden until the first fresh latent"
|
||||
);
|
||||
|
||||
// Simulate the first fresh latent of the run.
|
||||
autoDock.runSawLatent = true;
|
||||
autoDock.idleHidden = false;
|
||||
autoDock.updateVisibility();
|
||||
assert.ok(!autoDock.dockEl.classList.contains("saturn-dock-hidden"), "first latent reveals Auto-Hide");
|
||||
|
||||
apiHandlers.status({ detail: { exec_info: { queue_remaining: 0 } } });
|
||||
assert.ok(autoDock.dockEl.classList.contains("saturn-dock-hidden"), "Auto-Hide hides when the queue finishes");
|
||||
|
||||
const keepDock = makeModeDock("Keep Dock Visible");
|
||||
keepDock.applyDockMode();
|
||||
assert.ok(!keepDock.dockEl.classList.contains("saturn-dock-hidden"), "Keep Dock Visible starts shown");
|
||||
registerHandlers(keepDock);
|
||||
apiHandlers.execution_start();
|
||||
assert.ok(!keepDock.dockEl.classList.contains("saturn-dock-hidden"), "Keep Dock Visible never auto-hides");
|
||||
|
||||
const viewDock = makeModeDock("View Image");
|
||||
viewDock.applyDockMode();
|
||||
assert.ok(viewDock.dockEl.classList.contains("saturn-dock-hidden"), "View Image starts hidden");
|
||||
|
||||
viewDock.displayedFinalImage = true;
|
||||
viewDock.isGenerating = false;
|
||||
viewDock.applyDockMode();
|
||||
assert.ok(!viewDock.dockEl.classList.contains("saturn-dock-hidden"), "View Image shows a decoded final image while idle");
|
||||
|
||||
viewDock.displayedFinalImage = false;
|
||||
viewDock.idleHidden = true;
|
||||
viewDock.isGenerating = false;
|
||||
viewDock.updateVisibility();
|
||||
registerHandlers(viewDock);
|
||||
apiHandlers.execution_start();
|
||||
assert.ok(
|
||||
viewDock.dockEl.classList.contains("saturn-dock-hidden"),
|
||||
"View Image stays hidden until the next run's first latent when no final image is shown"
|
||||
);
|
||||
|
||||
console.log("floating dock DOM regression test passed");
|
||||
|
||||
@@ -15,6 +15,7 @@ DOM_TESTS = [
|
||||
"test_batch_queue_snapshot_dom.mjs",
|
||||
"test_batch_queue_restore_dom.mjs",
|
||||
"test_persistent_queue_claim_dom.mjs",
|
||||
"test_character_presets_dom.mjs",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const app = {
|
||||
};
|
||||
|
||||
let source = stripImports(fs.readFileSync(sourcePath, "utf8"));
|
||||
source += "\nglobalThis.__settingsTest = { reapplyQueueHeightToMountedLists };\n";
|
||||
source += "\nglobalThis.__settingsTest = { reapplyQueueHeightToMountedLists, clampQueueHeight, getQueueHeightViewportLimit };\n";
|
||||
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
@@ -65,7 +65,31 @@ const context = vm.createContext({
|
||||
vm.runInContext(source, context, { filename: sourcePath });
|
||||
|
||||
assert.equal(typeof extension.setup, "function", "settings extension must register");
|
||||
const { reapplyQueueHeightToMountedLists } = context.__settingsTest;
|
||||
const { reapplyQueueHeightToMountedLists, clampQueueHeight, getQueueHeightViewportLimit } = context.__settingsTest;
|
||||
|
||||
// Startup clamping: a height saved on a bigger monitor/window must never
|
||||
// exceed the space available in the current viewport.
|
||||
assert.equal(clampQueueHeight(2000, 800), 800, "stored height is capped to the viewport");
|
||||
assert.equal(clampQueueHeight("540px", 800), 540, "string values keep their pixel number");
|
||||
assert.equal(clampQueueHeight(500, 300), 300, "small viewports cap large heights");
|
||||
assert.equal(clampQueueHeight(50, 800), 120, "heights never drop below the usable minimum");
|
||||
assert.equal(clampQueueHeight(0, 800), 0, "0 keeps Native height");
|
||||
assert.equal(clampQueueHeight("Native", 800), 0, "Native never gets a max-height");
|
||||
assert.equal(
|
||||
getQueueHeightViewportLimit(900),
|
||||
780,
|
||||
"without an overlay top, room is left for browser chrome and the toolbar"
|
||||
);
|
||||
assert.equal(
|
||||
getQueueHeightViewportLimit(900, 100),
|
||||
784,
|
||||
"the overlay top is used to measure the free space below it"
|
||||
);
|
||||
assert.equal(
|
||||
getQueueHeightViewportLimit(200, 190),
|
||||
120,
|
||||
"the viewport limit never goes below the minimum usable height"
|
||||
);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("data-testid", "queue-progress-overlay");
|
||||
|
||||
+156
-14
@@ -13,11 +13,18 @@ import { PreviewManager } from "./js/preview_manager.js";
|
||||
* - Pinned fully to the right (8px).
|
||||
* 3. Takes the exact shape/aspect ratio of the latent being generated.
|
||||
* 4. Full pixel-quality rendering on high-DPI canvas without compression or blur.
|
||||
* 5. Configurable settings (enable/disable, max size, auto-hide when idle).
|
||||
* 5. Configurable settings (enable/disable, max size, idle mode: Auto-Hide,
|
||||
* Keep Dock Visible, or View Image).
|
||||
*/
|
||||
|
||||
const STORAGE_KEY_VISIBLE = "SaturnNodes.LivePreview.FloatingDockVisible";
|
||||
|
||||
// Idle behaviour for the dock. Replaces the old boolean auto-hide setting.
|
||||
const DOCK_MODE_KEY = "SaturnNodes.👁️ Live Preview.03_DockModeWhenIdle";
|
||||
const DOCK_MODE_AUTO_HIDE = "Auto-Hide";
|
||||
const DOCK_MODE_KEEP = "Keep Dock Visible";
|
||||
const DOCK_MODE_VIEW_IMAGE = "View Image";
|
||||
|
||||
// Absolute dock size bounds ("extreme but not absurd"): 100px .. 2000px
|
||||
const DOCK_MIN_SIZE = 100;
|
||||
const DOCK_MAX_SIZE = 2000;
|
||||
@@ -40,6 +47,16 @@ class FloatingLatentDock {
|
||||
this.isGenerating = false;
|
||||
this.latestSamplerId = null;
|
||||
|
||||
// Idle-mode state. `idleHidden` is a transient overlay on top of the
|
||||
// user's visibility preference; `runSawLatent` tracks whether the
|
||||
// current run has produced a preview yet; `displayedFinalImage` marks
|
||||
// that the canvas currently shows a decoded output image.
|
||||
this.idleHidden = false;
|
||||
this.runSawLatent = false;
|
||||
this.displayedFinalImage = false;
|
||||
this.pendingFinalImage = false;
|
||||
this.finalImageToken = 0;
|
||||
|
||||
// Inspecting Lightbox state (matching ComfyUI core MediaLightbox)
|
||||
this.lightboxEl = null;
|
||||
this.lightboxImg = null;
|
||||
@@ -67,6 +84,9 @@ class FloatingLatentDock {
|
||||
localStorage.setItem(STORAGE_KEY_VISIBLE, String(val));
|
||||
} catch (_) {}
|
||||
}
|
||||
// Manually toggling the dock on always reveals it, even when the
|
||||
// current idle mode would normally keep it hidden.
|
||||
if (val) this.idleHidden = false;
|
||||
this.updateVisibility();
|
||||
}
|
||||
|
||||
@@ -78,6 +98,14 @@ class FloatingLatentDock {
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
getDockMode() {
|
||||
const raw = this.getSetting(DOCK_MODE_KEY, DOCK_MODE_AUTO_HIDE);
|
||||
const value = String(raw || "").trim().toLowerCase();
|
||||
if (value === DOCK_MODE_KEEP.toLowerCase() || value === "keep dock visible") return DOCK_MODE_KEEP;
|
||||
if (value === DOCK_MODE_VIEW_IMAGE.toLowerCase() || value === "view image") return DOCK_MODE_VIEW_IMAGE;
|
||||
return DOCK_MODE_AUTO_HIDE;
|
||||
}
|
||||
|
||||
getScreenProfileKey() {
|
||||
if (typeof window === "undefined" || !window.screen) return "default";
|
||||
return `${window.screen.width}x${window.screen.height}`;
|
||||
@@ -113,22 +141,48 @@ class FloatingLatentDock {
|
||||
|
||||
init() {
|
||||
this.injectStyles();
|
||||
// Auto-Hide and View Image both start hidden until the first latent
|
||||
// of the session arrives; Keep Dock Visible starts shown.
|
||||
this.idleHidden = this.getDockMode() !== DOCK_MODE_KEEP;
|
||||
this.buildDockElement();
|
||||
this.registerWithPreviewManager();
|
||||
this.setupExecutionListeners();
|
||||
this.setupPositionObserver();
|
||||
this.injectToolbarButtonLoop();
|
||||
this.setupScreenProfileWatcher();
|
||||
this.applyDockMode();
|
||||
// The SaturnNodes settings extension may register after this one on
|
||||
// some frontend builds. Re-apply once the persisted setting is loaded.
|
||||
setTimeout(() => {
|
||||
if (this.dockEl) this.applyDockMode();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Recompute the transient idle visibility from the current mode.
|
||||
applyDockMode() {
|
||||
const mode = this.getDockMode();
|
||||
if (mode === DOCK_MODE_KEEP) {
|
||||
this.idleHidden = false;
|
||||
} else if (this.isGenerating && this.runSawLatent) {
|
||||
this.idleHidden = false;
|
||||
} else if (mode === DOCK_MODE_VIEW_IMAGE && this.displayedFinalImage && !this.isGenerating) {
|
||||
this.idleHidden = false;
|
||||
} else {
|
||||
this.idleHidden = true;
|
||||
}
|
||||
this.updateVisibility();
|
||||
if (!this.idleHidden) this.requestPositionUpdate();
|
||||
}
|
||||
|
||||
setupScreenProfileWatcher() {
|
||||
let lastScreenKey = this.getScreenProfileKey();
|
||||
window.addEventListener("saturnnodes:dock-mode-change", () => this.applyDockMode());
|
||||
window.addEventListener("resize", () => {
|
||||
const currentKey = this.getScreenProfileKey();
|
||||
if (currentKey !== lastScreenKey) {
|
||||
lastScreenKey = currentKey;
|
||||
if (this.currentImg) {
|
||||
this.onNewPreview(this.currentImg, this.latestSamplerId);
|
||||
this.renderImageToDock(this.currentImg);
|
||||
} else if (this.placeholderEl && this.dockEl) {
|
||||
const mw = this.getMaxWidth();
|
||||
const mh = this.getMaxHeight();
|
||||
@@ -615,7 +669,7 @@ class FloatingLatentDock {
|
||||
|
||||
// Snap back to the exact latent fit within the new limits
|
||||
if (this.currentImg) {
|
||||
this.onNewPreview(this.currentImg, this.latestSamplerId);
|
||||
this.renderImageToDock(this.currentImg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -813,10 +867,21 @@ class FloatingLatentDock {
|
||||
}
|
||||
|
||||
if (this.lightboxBadge) {
|
||||
const statusText = this.isGenerating ? "● Sampling" : "● Live Latent";
|
||||
let statusText = "● Live Latent";
|
||||
let statusColor = "#10b981";
|
||||
if (this.isGenerating) {
|
||||
statusText = "● Sampling";
|
||||
statusColor = "#f59e0b";
|
||||
} else if (this.pendingFinalImage && !this.displayedFinalImage) {
|
||||
statusText = "● Finalizing";
|
||||
statusColor = "#f59e0b";
|
||||
} else if (this.displayedFinalImage) {
|
||||
statusText = "● Final Image";
|
||||
statusColor = "#10b981";
|
||||
}
|
||||
const dimsText = (nw && nh) ? `${nw}×${nh}` : "";
|
||||
this.lightboxBadge.innerHTML = `
|
||||
<span style="color: ${this.isGenerating ? '#f59e0b' : '#10b981'}; font-weight: 600;">${statusText}</span>
|
||||
<span style="color: ${statusColor}; font-weight: 600;">${statusText}</span>
|
||||
${dimsText ? `<span style="opacity: 0.5;">|</span><span>${dimsText}</span>` : ""}
|
||||
`;
|
||||
}
|
||||
@@ -859,13 +924,22 @@ class FloatingLatentDock {
|
||||
|
||||
api.addEventListener("execution_start", () => {
|
||||
this.isGenerating = true;
|
||||
this.runSawLatent = false;
|
||||
this.pendingFinalImage = false;
|
||||
// Cancel any still-pending output load from the previous run, but
|
||||
// keep an already displayed final image on screen until this run's
|
||||
// first latent replaces it.
|
||||
this.finalImageToken += 1;
|
||||
if (this.statusBadge) {
|
||||
this.statusBadge.textContent = "Sampling";
|
||||
this.statusBadge.classList.add("generating");
|
||||
}
|
||||
if (this.getSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false) && this.isVisible) {
|
||||
this.dockEl?.classList.remove("saturn-dock-hidden");
|
||||
// Auto-Hide clears immediately. View Image keeps the previous
|
||||
// final image on screen until this run's first latent arrives.
|
||||
if (this.getDockMode() === DOCK_MODE_AUTO_HIDE) {
|
||||
this.idleHidden = true;
|
||||
}
|
||||
this.updateVisibility();
|
||||
if (this.lightboxEl) {
|
||||
this.updateLightbox();
|
||||
}
|
||||
@@ -877,14 +951,24 @@ class FloatingLatentDock {
|
||||
this.statusBadge.textContent = "Done";
|
||||
this.statusBadge.classList.remove("generating");
|
||||
}
|
||||
if (this.getSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false)) {
|
||||
this.dockEl?.classList.add("saturn-dock-hidden");
|
||||
const mode = this.getDockMode();
|
||||
if (mode === DOCK_MODE_AUTO_HIDE) {
|
||||
this.idleHidden = true;
|
||||
} else if (mode === DOCK_MODE_VIEW_IMAGE) {
|
||||
// Stay visible with the last latent until the decoded output
|
||||
// image replaces it. If nothing was drawn this run, keep the
|
||||
// previous final image (or stay hidden if there is none).
|
||||
this.pendingFinalImage = !this.displayedFinalImage;
|
||||
this.idleHidden = !this.runSawLatent && !this.displayedFinalImage;
|
||||
}
|
||||
this.updateVisibility();
|
||||
if (this.lightboxEl) {
|
||||
this.updateLightbox();
|
||||
}
|
||||
};
|
||||
|
||||
api.addEventListener("executed", (event) => this.onExecuted(event));
|
||||
|
||||
api.addEventListener("status", (e) => {
|
||||
const queueRemaining = e.detail?.exec_info?.queue_remaining ?? 0;
|
||||
if (queueRemaining === 0) {
|
||||
@@ -896,14 +980,73 @@ class FloatingLatentDock {
|
||||
api.addEventListener("execution_error", handleExecutionEnd);
|
||||
}
|
||||
|
||||
// View Image mode: once the final output is decoded, swap the dock canvas
|
||||
// over to it and keep it there until the next run's first latent.
|
||||
onExecuted(event) {
|
||||
if (this.getDockMode() !== DOCK_MODE_VIEW_IMAGE) return;
|
||||
const output = event?.detail?.output;
|
||||
const images = Array.isArray(output?.images) ? output.images : [];
|
||||
if (!images.length) return;
|
||||
// Prefer saved output images over temporary previews.
|
||||
const chosen =
|
||||
[...images].reverse().find((item) => item && item.filename && (item.type === "output" || !item.type)) ||
|
||||
[...images].reverse().find((item) => item && item.filename);
|
||||
if (!chosen) return;
|
||||
|
||||
const token = ++this.finalImageToken;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (token !== this.finalImageToken) return;
|
||||
this.displayedFinalImage = true;
|
||||
this.pendingFinalImage = false;
|
||||
this.idleHidden = false;
|
||||
this.currentImg = img;
|
||||
if (this.titleTextEl) this.titleTextEl.textContent = "Final Image";
|
||||
this.renderImageToDock(img);
|
||||
this.updateVisibility();
|
||||
this.requestPositionUpdate();
|
||||
};
|
||||
img.src = this.buildViewUrl(chosen);
|
||||
}
|
||||
|
||||
buildViewUrl(image) {
|
||||
const params = new URLSearchParams({
|
||||
filename: image.filename || "",
|
||||
subfolder: image.subfolder || "",
|
||||
type: image.type || "output",
|
||||
});
|
||||
const path = `/view?${params.toString()}`;
|
||||
try {
|
||||
if (api?.apiURL) return api.apiURL(path);
|
||||
} catch (_) {}
|
||||
return path;
|
||||
}
|
||||
|
||||
onNewPreview(img, samplerId) {
|
||||
if (!img || !this.canvasEl) return;
|
||||
this.currentImg = img;
|
||||
this.latestSamplerId = samplerId;
|
||||
if (!this.renderImageToDock(img)) return;
|
||||
|
||||
this.runSawLatent = true;
|
||||
this.displayedFinalImage = false;
|
||||
this.pendingFinalImage = false;
|
||||
if (this.titleTextEl) this.titleTextEl.textContent = "Live Latent";
|
||||
// The dock only appears once this run's first fresh latent is drawn,
|
||||
// never on execution_start with the previous run's image on screen.
|
||||
this.idleHidden = false;
|
||||
this.updateVisibility();
|
||||
this.requestPositionUpdate();
|
||||
}
|
||||
|
||||
// Shared canvas renderer used by both live latents and the final decoded
|
||||
// output image in View Image mode.
|
||||
renderImageToDock(img) {
|
||||
if (!img || !this.canvasEl) return false;
|
||||
|
||||
const nw = img.naturalWidth || img.width;
|
||||
const nh = img.naturalHeight || img.height;
|
||||
if (!nw || !nh) return;
|
||||
if (!nw || !nh) return false;
|
||||
|
||||
const maxW = this.getMaxWidth();
|
||||
const maxH = this.getMaxHeight();
|
||||
@@ -951,13 +1094,12 @@ class FloatingLatentDock {
|
||||
if (this.lightboxEl) {
|
||||
this.updateLightbox();
|
||||
}
|
||||
|
||||
this.requestPositionUpdate();
|
||||
return true;
|
||||
}
|
||||
|
||||
updateVisibility() {
|
||||
const isMasterEnabled = this.getSetting("SaturnNodes.👁️ Live Preview.01_EnableFloatingDock", true);
|
||||
const shouldShow = isMasterEnabled && this.isVisible;
|
||||
const shouldShow = isMasterEnabled && this.isVisible && !this.idleHidden;
|
||||
|
||||
if (this.dockEl) {
|
||||
if (shouldShow) {
|
||||
@@ -1045,7 +1187,7 @@ class FloatingLatentDock {
|
||||
}
|
||||
|
||||
requestPositionUpdate() {
|
||||
if (!this.dockEl || this.dockEl.classList.contains("saturn-dock-hidden") || !this.isVisible) return;
|
||||
if (!this.dockEl || this.dockEl.classList.contains("saturn-dock-hidden") || !this.isVisible || this.idleHidden) return;
|
||||
if (this._posUpdatePending) return;
|
||||
this._posUpdatePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -251,6 +251,30 @@ visualStyles.textContent = `
|
||||
border-width: 4px !important;
|
||||
box-shadow: 0 0 12px rgba(0, 255, 102, 0.5);
|
||||
}
|
||||
.lora-order-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 3px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
background: #00ff66;
|
||||
color: #06240f;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.45);
|
||||
}
|
||||
.lora-tile.selected .lora-order-badge {
|
||||
display: flex;
|
||||
}
|
||||
.lora-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1390,7 +1414,8 @@ app.registerExtension({
|
||||
const tiles = viewContainer.querySelectorAll(".lora-tile");
|
||||
tiles.forEach(t => {
|
||||
const name = t.dataset.loraName;
|
||||
if (selectedList.includes(name)) {
|
||||
const orderIndex = selectedList.indexOf(name);
|
||||
if (orderIndex >= 0) {
|
||||
t.classList.add("selected");
|
||||
t.style.borderColor = "#00ff66";
|
||||
} else {
|
||||
@@ -1401,6 +1426,10 @@ app.registerExtension({
|
||||
t.style.borderColor = "";
|
||||
}
|
||||
}
|
||||
const orderBadge = t.querySelector(".lora-order-badge");
|
||||
if (orderBadge) {
|
||||
orderBadge.textContent = orderIndex >= 0 ? String(orderIndex + 1) : "";
|
||||
}
|
||||
});
|
||||
|
||||
updateFolderCheckboxes();
|
||||
@@ -1653,6 +1682,13 @@ app.registerExtension({
|
||||
tile.style.borderColor = "#00ff66";
|
||||
}
|
||||
|
||||
const orderBadge = document.createElement("div");
|
||||
orderBadge.className = "lora-order-badge";
|
||||
const orderIndex = selectedList.indexOf(item.loraName);
|
||||
orderBadge.textContent = orderIndex >= 0 ? String(orderIndex + 1) : "";
|
||||
orderBadge.title = "Selection order (LoRAs are applied in this order)";
|
||||
tile.appendChild(orderBadge);
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.dataset.src = `/saturnnodes/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}` + (bypassThumbs ? "&full=true" : "&size=200");
|
||||
|
||||
@@ -11,6 +11,8 @@ export const SATURN_NODE_TYPES = new Set([
|
||||
"FolderLoraLoaderPretty",
|
||||
"VisualImageLoader",
|
||||
"LoadRecentOutputs",
|
||||
"CharacterPresetPicker",
|
||||
"ApplyCharacterLoRAs",
|
||||
|
||||
// Automation, Flow & Utilities
|
||||
"LoadImageFromFolder",
|
||||
|
||||
+148
-21
@@ -22,6 +22,7 @@ const BACKEND_SETTING_MAP = {
|
||||
"SaturnNodes.🖼️ Visual Loaders.03_TMDBApiKey": "tmdb_api_key",
|
||||
"SaturnNodes.🖼️ Visual Loaders.04_EnableTMDBScraping": "enable_tmdb_scraping",
|
||||
"SaturnNodes.🖼️ Visual Loaders.05_EnableLoraUsage": "enable_lora_usage",
|
||||
"SaturnNodes.🖼️ Visual Loaders.05_EnableCharacterUsage": "enable_character_usage",
|
||||
"SaturnNodes.🖼️ Visual Loaders.08_ImageMetadataScanLimit": "image_loader_metadata_limit",
|
||||
"SaturnNodes.⏸️ Pause Controls.01_DefaultStateOnLaunch": "default_pause_state",
|
||||
"SaturnNodes.⏸️ Pause Controls.02_DefaultPauseAction": "default_pause_mode",
|
||||
@@ -815,6 +816,48 @@ function reapplyQueueHeightToMountedLists(overlay, heightPx, applyFn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QUEUE_HEIGHT_MIN = 120;
|
||||
const QUEUE_HEIGHT_MAX = 1200;
|
||||
|
||||
/**
|
||||
* Clamp a queue overlay height to a sane range and to the space available in
|
||||
* the current viewport. `availableHeight` is the usable viewport limit; pass 0
|
||||
* when the viewport is unknown (then only the absolute range applies).
|
||||
*/
|
||||
function clampQueueHeight(value, availableHeight = 0) {
|
||||
let parsed = NaN;
|
||||
if (typeof value === "number") {
|
||||
parsed = value;
|
||||
} else if (value !== null && value !== undefined && value !== "Native") {
|
||||
const match = String(value).match(/\d+/);
|
||||
if (match) parsed = parseInt(match[0], 10);
|
||||
}
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
||||
|
||||
const viewport = Number(availableHeight) || 0;
|
||||
const ceiling = viewport > 0
|
||||
? Math.max(QUEUE_HEIGHT_MIN, Math.min(QUEUE_HEIGHT_MAX, Math.round(viewport)))
|
||||
: QUEUE_HEIGHT_MAX;
|
||||
return Math.max(QUEUE_HEIGHT_MIN, Math.min(ceiling, Math.round(parsed)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest queue height that still fits the viewport. When the overlay's top is
|
||||
* known, the free space below it is used so the panel never runs off screen.
|
||||
*/
|
||||
function getQueueHeightViewportLimit(viewportHeight, overlayTop = null) {
|
||||
const viewport = Number(viewportHeight) || 0;
|
||||
if (viewport <= 0) return QUEUE_HEIGHT_MAX;
|
||||
|
||||
// Leave room for browser chrome / the ComfyUI toolbar when the overlay
|
||||
// top is not measurable yet (for example during extension setup).
|
||||
let available = viewport - 120;
|
||||
if (Number.isFinite(overlayTop) && overlayTop > 0 && overlayTop < viewport) {
|
||||
available = viewport - overlayTop - 16;
|
||||
}
|
||||
return Math.max(QUEUE_HEIGHT_MIN, Math.min(QUEUE_HEIGHT_MAX, Math.round(available)));
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.SaturnNodes.Settings",
|
||||
async setup() {
|
||||
@@ -943,6 +986,36 @@ app.registerExtension({
|
||||
}
|
||||
});
|
||||
|
||||
// 1.5b Enable Character Preset Usage Tracking (default: true)
|
||||
app.ui.settings.addSetting({
|
||||
id: "SaturnNodes.🖼️ Visual Loaders.05_EnableCharacterUsage",
|
||||
name: "Enable Character Preset Usage Tracking",
|
||||
type: "boolean",
|
||||
defaultValue: getInitialSetting("SaturnNodes.🖼️ Visual Loaders.05_EnableCharacterUsage", true),
|
||||
tooltip: "Toggles tracking and displaying Character Preset Picker usage counts, badges and 'Usage' sorting. Existing usage history is preserved when disabled.",
|
||||
onChange(value) {
|
||||
postSaturnNodesSettings({ enable_character_usage: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.5c Character Preset Rank Borders (default: true)
|
||||
app.ui.settings.addSetting({
|
||||
id: "SaturnNodes.🖼️ Visual Loaders.05b_CharacterRankBorders",
|
||||
name: "Enable Character Preset Rank Borders",
|
||||
type: "boolean",
|
||||
defaultValue: getInitialSetting("SaturnNodes.🖼️ Visual Loaders.05b_CharacterRankBorders", true),
|
||||
tooltip: "Replaces Character Preset Picker usage badges with gold/silver/bronze borders for the 3 most-used cards and an animated RGB border at 1,000+ uses. Turn off to show plain usage count badges again.",
|
||||
onChange() {
|
||||
const nodes = app.graph?._nodes || app.graph?.nodes || [];
|
||||
nodes.forEach((node) => {
|
||||
const picker = node?._saturnCharacterPicker;
|
||||
if (picker && typeof picker.refreshRankBorders === "function") {
|
||||
picker.render?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 1.6 Reset Scrapes Cache Button
|
||||
const renderScrapesCacheBtn = renderSettingButton("🗑️ Clear Scrapes Cache", "⏳ Clearing...", "✅ Cache Reset!", async () => {
|
||||
const resp = await authenticatedFetch("/saturnnodes/scrapes/clear", { method: "POST" });
|
||||
@@ -1081,19 +1154,29 @@ app.registerExtension({
|
||||
}
|
||||
});
|
||||
|
||||
// 10.3 Auto-Hide Dock When Idle (default: false)
|
||||
// 10.3 Dock Mode when Idle (default: Auto-Hide)
|
||||
app.ui.settings.addSetting({
|
||||
id: "SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle",
|
||||
name: "Auto-Hide Dock When Idle",
|
||||
type: "boolean",
|
||||
defaultValue: getInitialSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false),
|
||||
tooltip: "When enabled, the floating dock automatically hides when generation is idle and appears when sampling begins.",
|
||||
id: "SaturnNodes.👁️ Live Preview.03_DockModeWhenIdle",
|
||||
name: "Dock Mode when Idle",
|
||||
type: "combo",
|
||||
options: ["Auto-Hide", "Keep Dock Visible", "View Image"],
|
||||
defaultValue: getInitialSetting("SaturnNodes.👁️ Live Preview.03_DockModeWhenIdle", "Auto-Hide"),
|
||||
tooltip:
|
||||
"Auto-Hide: hide the dock when idle and show it when sampling. " +
|
||||
"Keep Dock Visible: always show the dock. " +
|
||||
"View Image: after a run finishes, show the decoded final output image until the next run's first latent.",
|
||||
onChange(value) {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
localStorage.setItem("Comfy.Settings.SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", JSON.stringify(Boolean(value)));
|
||||
localStorage.setItem(
|
||||
"Comfy.Settings.SaturnNodes.👁️ Live Preview.03_DockModeWhenIdle",
|
||||
JSON.stringify(String(value))
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("saturnnodes:dock-mode-change", { detail: { mode: value } }));
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1310,6 +1393,18 @@ app.registerExtension({
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getAvailableQueueHeight = (overlay) => {
|
||||
let top = null;
|
||||
try {
|
||||
const rect = overlay?.getBoundingClientRect?.();
|
||||
if (rect && Number.isFinite(rect.top)) top = rect.top;
|
||||
} catch (_) {}
|
||||
return getQueueHeightViewportLimit(
|
||||
(typeof window !== "undefined" ? window.innerHeight : 0),
|
||||
top
|
||||
);
|
||||
};
|
||||
|
||||
let saturnQueueHeightPx = 0;
|
||||
|
||||
const ensureQueueHandleStyles = () => {
|
||||
@@ -1356,16 +1451,12 @@ app.registerExtension({
|
||||
// Rewriting a <style> element's text on every pointermove forced a full-document
|
||||
// style recalculation and made resizing extremely laggy.
|
||||
const applyQueueOverlayHeight = (val) => {
|
||||
let px = 0;
|
||||
if (typeof val === "number") {
|
||||
px = val;
|
||||
} else if (val && val !== "Native") {
|
||||
const match = String(val).match(/\d+/);
|
||||
if (match) px = parseInt(match[0], 10);
|
||||
}
|
||||
saturnQueueHeightPx = (px && px > 0) ? Math.min(1200, Math.round(px)) : 0;
|
||||
|
||||
const overlay = document.querySelector('[data-testid="queue-progress-overlay"]');
|
||||
// Never allow a stored height from a larger monitor/window to
|
||||
// overflow the current viewport. The available space is measured
|
||||
// from the overlay's own top edge when it is already mounted.
|
||||
saturnQueueHeightPx = clampQueueHeight(val, getAvailableQueueHeight(overlay));
|
||||
|
||||
const lists = overlay
|
||||
? Array.from(overlay.querySelectorAll(".comfy-list"))
|
||||
: Array.from(document.querySelectorAll(".comfy-list"));
|
||||
@@ -1408,7 +1499,10 @@ app.registerExtension({
|
||||
e.stopPropagation();
|
||||
|
||||
const startY = e.clientY;
|
||||
const startH = overlay.offsetHeight || saturnQueueHeightPx || getEffectiveQueueHeight() || 360;
|
||||
const startH = clampQueueHeight(
|
||||
overlay.offsetHeight || saturnQueueHeightPx || getEffectiveQueueHeight() || 360,
|
||||
getAvailableQueueHeight(overlay)
|
||||
) || 360;
|
||||
handle.classList.add("active");
|
||||
document.body.style.cursor = "ns-resize";
|
||||
|
||||
@@ -1430,7 +1524,13 @@ app.registerExtension({
|
||||
|
||||
const onPointerMove = (me) => {
|
||||
const dy = me.clientY - startY;
|
||||
currentH = Math.max(120, Math.min(1200, Math.round(startH + dy)));
|
||||
currentH = Math.max(
|
||||
QUEUE_HEIGHT_MIN,
|
||||
Math.min(
|
||||
getAvailableQueueHeight(overlay),
|
||||
Math.round(startH + dy)
|
||||
)
|
||||
);
|
||||
if (!rafPending) {
|
||||
rafPending = true;
|
||||
rafId = requestAnimationFrame(applyFrame);
|
||||
@@ -1457,6 +1557,10 @@ app.registerExtension({
|
||||
handle.classList.remove("active");
|
||||
document.body.style.cursor = "";
|
||||
|
||||
// Clamp once more in case the viewport changed mid-drag.
|
||||
currentH = clampQueueHeight(currentH, getAvailableQueueHeight(overlay));
|
||||
applyQueueOverlayHeight(currentH);
|
||||
|
||||
const screenKey = getScreenProfileKey();
|
||||
try {
|
||||
localStorage.setItem(`Comfy.SaturnNodes.QueueHeight.${screenKey}`, String(currentH));
|
||||
@@ -1498,19 +1602,36 @@ app.registerExtension({
|
||||
});
|
||||
queueOverlayObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Listen for screen resolution changes when moving between laptop and 4K display
|
||||
// Listen for screen/window size changes. The height is clamped to the
|
||||
// new available space, and the per-screen value is restored when the
|
||||
// window moves to another monitor.
|
||||
let lastScreenKey = getScreenProfileKey();
|
||||
let queueResizeFrame = 0;
|
||||
const scheduleQueueHeightReapply = () => {
|
||||
if (queueResizeFrame) return;
|
||||
const run = () => {
|
||||
queueResizeFrame = 0;
|
||||
applyQueueOverlayHeight(getEffectiveQueueHeight());
|
||||
};
|
||||
queueResizeFrame = (typeof requestAnimationFrame === "function")
|
||||
? requestAnimationFrame(run)
|
||||
: setTimeout(run, 16);
|
||||
};
|
||||
window.addEventListener("resize", () => {
|
||||
const currentKey = getScreenProfileKey();
|
||||
if (currentKey !== lastScreenKey) {
|
||||
lastScreenKey = currentKey;
|
||||
applyQueueOverlayHeight(getEffectiveQueueHeight());
|
||||
}
|
||||
scheduleQueueHeightReapply();
|
||||
});
|
||||
|
||||
const initialQueueHeight = getEffectiveQueueHeight();
|
||||
applyQueueOverlayHeight(initialQueueHeight);
|
||||
|
||||
// ComfyUI may invoke a setting's onChange while registering it. Ignore
|
||||
// that initial call so a global value from another monitor cannot
|
||||
// overwrite this screen's saved height.
|
||||
let queueSettingsReady = false;
|
||||
app.ui.settings.addSetting({
|
||||
id: "SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight",
|
||||
name: "Visible Queue Items (Height)",
|
||||
@@ -1519,6 +1640,7 @@ app.registerExtension({
|
||||
defaultValue: initialQueueHeight,
|
||||
tooltip: "Limits the visible height of the queue overlay panel in pixels (e.g. 360 ≈ 10 items, 500 ≈ 15 items) with a scrollbar. You can also drag the bottom edge of the queue overlay directly on screen (remembers size per monitor). Set to 0 for Native.",
|
||||
onChange(value) {
|
||||
if (!queueSettingsReady) return;
|
||||
const num = parseInt(value, 10) || 0;
|
||||
const screenKey = getScreenProfileKey();
|
||||
if (typeof localStorage !== "undefined") {
|
||||
@@ -1526,12 +1648,17 @@ app.registerExtension({
|
||||
localStorage.setItem("Comfy.Settings.SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight", JSON.stringify(num));
|
||||
if (num > 0) {
|
||||
localStorage.setItem(`Comfy.SaturnNodes.QueueHeight.${screenKey}`, String(num));
|
||||
} else {
|
||||
localStorage.removeItem(`Comfy.SaturnNodes.QueueHeight.${screenKey}`);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
applyQueueOverlayHeight(num);
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
queueSettingsReady = true;
|
||||
}, 0);
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: 💾 Persistent Queue
|
||||
@@ -1867,7 +1994,7 @@ function setupSettingsFooterObserver() {
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 16px;">🪐</span>
|
||||
<span style="font-weight: 600; color: #e4e4e7;">ComfyUI-SaturnNodes</span>
|
||||
<span style="font-size: 11px; padding: 2px 7px; border-radius: 4px; background: rgba(180, 140, 95, 0.18); color: #decbb2; border: 1px solid rgba(180, 140, 95, 0.35);">v2.6.3 (dev)</span>
|
||||
<span style="font-size: 11px; padding: 2px 7px; border-radius: 4px; background: rgba(180, 140, 95, 0.18); color: #decbb2; border: 1px solid rgba(180, 140, 95, 0.35);">v2.7.0 (dev)</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<a href="https://github.com/KOFiblto/ComfyUI-SaturnNodes" target="_blank" rel="noopener noreferrer" style="color: #decbb2; text-decoration: underline; text-decoration-color: rgba(222, 203, 178, 0.4); text-underline-offset: 3px; font-weight: 500; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12); transition: all 0.2s ease;" onmouseover="this.style.color='#ffffff'; this.style.background='rgba(255,255,255,0.12)'; this.style.borderColor='rgba(255,255,255,0.25)';" onmouseout="this.style.color='#decbb2'; this.style.background='rgba(255,255,255,0.05)'; this.style.borderColor='rgba(255,255,255,0.12)';">
|
||||
|
||||
Reference in New Issue
Block a user