Compare commits

...
34 Commits
Author SHA1 Message Date
KOFiblto 5cc4a7742e Feat: refine picker badges/actions and add dock idle modes 2026-11-11 01:54:28 +01:00
KOFiblto 08d37f680e Feat: add character picker alignment, resets and rank borders 2026-11-10 22:22:38 +01:00
KOFiblto 2ee9b90d76 Fix: repair shifted character picker widget values on configure 2026-11-09 17:06:01 +01:00
KOFiblto edecbf7bfc Fix: self-heal invalid character picker widget values 2026-11-09 16:56:19 +01:00
KOFiblto 53fe2c0f69 Fix: per-screen queue height and picker widget order 2026-11-09 16:55:31 +01:00
KOFiblto 0e9201a4d6 Feat: folder sorting options for character picker 2026-11-09 00:50:49 +01:00
KOFiblto 90cd14b430 Feat: character renders count as LoRA usage 2026-11-07 19:50:58 +01:00
KOFiblto 50c4a7012b Feat: portrait editor preview and auto-growing prompt fields 2026-11-06 19:53:34 +01:00
KOFiblto 4574c910b7 Feat: editable card display name and compact editor layout 2026-11-06 19:44:36 +01:00
KOFiblto aae467f1c7 Fix: picker visual order uses a real array 2026-11-06 19:21:41 +01:00
KOFiblto 557d135c1a Feat: picker selection modifiers, editor layout and usage tracking 2026-11-06 19:01:28 +01:00
KOFiblto d8788642af Feat: multi-trigger cards, prompt snippets and editor polish 2026-11-05 20:22:30 +01:00
KOFiblto 092569c608 Feat: redesign character picker to match visual LoRA loader
- group cards into collapsible folder sections with counts, arrows and select-all checkboxes
- fixed 80px 3:4 tiles that wrap instead of stretching with node width
- loader-style control bar for Mode, Sort, Folder and Scroll/Show All
- flex-fill scrolling grid, Show All grows the node and Scroll restores the height
- extend DOM regression tests and update README, WALKTHROUGH and CHANGELOG
2026-11-04 17:01:50 +01:00
KOFiblto 4a0e20352e Fix: character picker blank grid and missing folder selector
- Relax the picker's read routes (get_presets, get_preview, get_loras) to loopback + same-origin instead of the CSRF token, which plain fetch and <img> requests cannot send; mutating routes keep the strict token check.
- Populate the toolbar folder selector with 'All presets' immediately and widen it so it no longer looks empty while loading.
- Add read-authorization regression tests.
2026-11-02 21:53:37 +01:00
KOFiblto 95b3b0cad6 Fix: latent dock stale preview and LoRA card import
- Keep the floating latent dock hidden at execution_start in auto-hide mode; it now only appears with the first fresh latent of the current run.
- Add an Import modal to the character preset picker that scans an installed LoRA folder, keeps its subfolders as card categories, humanises file names, derives triggers from metadata or the name, skips/merges existing cards and inherits LoRA sidecar previews.
- Store inherited LoRA previews with their real extension and drop stale sidecars.
- Extend picker and dock DOM regression tests.
2026-10-31 19:59:31 +01:00
KOFiblto f9c1ed5a23 Feat: automatic text detection, selection-order badges, registry changelog 2026-10-31 15:07:06 +01:00
KOFiblto 95539b2b8c Feat: character preset picker cards and LoRA applier nodes
Adds a visual character card browser (grid, folders, search, sort, editor modal) plus a companion node that applies each card's LoRA stack to MODEL/CLIP. Detect mode matches trigger words inside an upstream prompt text (Whole Word/Contains/Exact, first hit wins unless match_all is set), and the cards used by a run flash on the canvas.
2026-10-31 14:17:27 +01:00
KOFiblto 965f8646b2 Chore: start 2.7.0 development 2026-10-30 00:07:02 +01:00
KOFiblto a8b5a5aee2 Release: v2.6.3 - Queue snapshot integrity, restore hardening, LoRA picker console fix 2026-10-30 00:03:06 +01:00
KOFiblto 2696781d6b Release: 2.6.3 2026-10-30 00:02:59 +01:00
KOFiblto 7517f1fe80 Test: scan whole files for deprecated settings default argument 2026-10-27 02:25:32 +01:00
KOFiblto 8a5638221f Fix: avoid deprecated settings default argument in LoRA picker 2026-10-27 02:24:00 +01:00
KOFiblto 0b2b274583 Fix: freeze queued prompts at click time and honor linked iterator input 2026-10-27 02:15:08 +01:00
KOFiblto eacd239d23 Docs: validate LoRA loader audit and correct root cause verdicts 2026-10-27 02:04:58 +01:00
KOFiblto 04ade0dae5 Fix: stabilize sequential LoRA cycle and default selection mode 2026-10-27 02:04:54 +01:00
KOFiblto 3c912c3a1d Ci: restrict registry publish to manual workflow_dispatch on main only 2026-10-26 14:10:26 +01:00
KOFiblto 0eefe12131 Docs: document LoRA loader root causes in LORA_LOADER_AUDIT.md and exclude credentials in .comfyignore 2026-10-26 14:06:25 +01:00
KOFiblto b2b6f6badb Fix: correct registry publisher id and guard missing publish token 2026-10-24 21:52:56 +02:00
KOFiblto 00cfd979bf Test: cover corrupt image restore recovery
Verifies that a corrupt output image still produces a usable restored history entry with empty metadata and mtime-only timing, and refreshes the suite count.
2026-10-24 03:47:25 +02:00
KOFiblto 53dfbe4999 Fix: normalize corrupt duration timestamps and clamp batch indexes
Normalizes malformed completed timestamps during duration-store sanitization so allow_nan=False saves cannot fail on a hand-edited record. Clamps out-of-range restored item indexes to the batch bounds and extends the DOM regression test.
2026-10-24 03:45:47 +02:00
KOFiblto 88d1ac3666 Fix: sanitize stored durations and validate batch hex colors
Drops corrupt generation-time entries on load/save so one bad hand-edited record cannot block future writes under allow_nan=False. Restricts batch colors to complete 3/4/6/8-digit hex values and adds cross-day timestamp plus corrupt-store regression tests.
2026-10-24 03:44:08 +02:00
KOFiblto 0d5f76bf89 Docs: document 2.6.3 restore and batch hardening
Adds the 2.6.3 Added/Changed/Fixed sections covering deterministic restore ordering, queue identity repair, duration validation, cancel/wipe recovery ordering, batch metadata sanitization, and the 221-test regression matrix.
2026-10-24 03:41:29 +02:00
KOFiblto 8c9fcf5225 Fix: harden restore ordering, queue identity, and batch metadata
Deterministic mtime/path ordering, restore locking, duration and metadata validation, persistent queue entry repair and ordered create times, cancel/wipe recovery ordering, batch color sanitization, and concurrency-safe batch item indexes. Adds 61 Python tests and 3 Node DOM regression tests.
2026-10-24 03:41:26 +02:00
KOFiblto 69fe4ceaf1 Chore: start 2.6.3 development 2026-10-24 03:15:07 +02:00
42 changed files with 10143 additions and 166 deletions
+7
View File
@@ -11,6 +11,13 @@ AGENTS.md
CLAUDE.md
GEMINI.md
CONTRIBUTING.md
STATUS.md
LORA_LOADER_AUDIT.md
# Environment variables & local credentials
.env
.env.*
*.env
# Python bytecode caches
__pycache__/
+2
View File
@@ -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
+75 -2
View File
@@ -3,17 +3,90 @@ name: Publish to ComfyUI Registry
on:
workflow_dispatch:
push:
branches: [ main ]
branches: [main]
paths:
- 'pyproject.toml'
- pyproject.toml
jobs:
publish:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify registry token
env:
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
run: |
if [ -z "$REGISTRY_ACCESS_TOKEN" ]; then
echo "::error::REGISTRY_ACCESS_TOKEN is not configured. Create an API key at https://registry.comfy.org/nodes for the publisher 'kofiblto' and store it under Settings -> Secrets and variables -> Actions."
exit 1
fi
- name: Publish Custom Node
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
+4 -2
View File
@@ -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.
+75
View File
@@ -5,6 +5,81 @@ 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
- **Restore Regression Test Matrix**: 64 new deterministic Python tests and 3 new Node fake-DOM tests covering DOM injection, cross-day timestamps, equal-mtime ordering, restore limits, corrupt images, batches, cancellation, retries, corrupt state, and recovery. The existing `test_batch_queue_dom.mjs` is now run by the suite as well.
- **Queue Bleed Regression Tests**: `test_batch_queue_snapshot_dom.mjs` models two Queue clicks with an in-flight submission and asserts prompt text/LoRA freezing with live seed merging, and `test_prompt_iterator.py` covers linked-prompt precedence over stale local text.
### Changed
- **Deterministic Restore Ordering**: Output scanning and the Visual Image Loader now tie-break equal modification times by relative path, so bulk-copied or archive-extracted files always restore in the same order and receive stable history numbering.
- **Restored Queue Identity Repair**: `persistent_queue.json` entries are normalized on load and before restore. The top-level `prompt_id` is authoritative, `item[1]` is re-synced before queueing, duplicates collapse to the last occurrence, and malformed entries are dropped instead of crashing recovery.
- **Synthetic Restored Queue Timestamps**: Restored items receive strictly increasing `create_time` values ending just before "now", preserving their saved queue order instead of sharing one timestamp.
- **Best-Effort Asset DB Ingestion**: A failing `app.assets` registration no longer aborts a successful history restore.
- **Serialized Restores**: Startup restore and manual "Restore Now" requests are serialized by a restore lock so overlapping calls cannot interleave history writes or asset ingestion.
### Fixed
- **Restored Duration Validation**: `NaN`, infinite, negative, and non-numeric durations are rejected when recorded and when read, so they can no longer poison restored timestamps or produce invalid JSON.
- **Generation Times Persistence**: `generation_times.json` saves now sanitize corrupt entries, use unique temp files, retry transient Windows `os.replace` locks, serialize with `allow_nan=False`, and clean up leftover temp files.
- **Cancel/Wipe Recovery Loss**: Persistent queue entries are removed only after ComfyUI confirms the cancel or wipe; a failed operation keeps the crash-recovery copy intact.
- **Restore Robustness**: Missing queue mutexes, non-dict histories, history-read failures, malformed retry counters, and invalid restore limits are handled without aborting recovery.
- **EXIF / A1111 Metadata**: EXIF UserComments with BOM/NUL padding and byte-valued A1111 `parameters` now decode to clean prompt text.
- **Batch Color Injection**: Batch colors from `localStorage`/the server are restricted to plain hex values before entering SVG markup; invalid colors fall back to the palette.
- **Batch Counter & Metadata Corruption**: Negative persisted batch counters clamp into the palette range, metadata pruning uses `queuedAt` recency, unknown segment types normalize safely, out-of-range item indexes clamp to the batch bounds, and older server snapshots can no longer overwrite newer local batch metadata.
- **Concurrent Batch Item Indexes**: Concurrent submissions reserve unique `itemIndex` values through a captured batch context and `inFlight` counter, so two prompts can no longer claim the same index or clear the batch early.
- **Recent Output Loading**: `Load Recent Outputs` clamps invalid `amount`/`index` values, tie-breaks equal mtimes by path, and closes image handles after loading.
- **Registry Publisher ID**: `PublisherId` now matches the registry's case-sensitive publisher id `kofiblto` (the previous `KOFiblto` resolved to 404 on `api.comfy.org`), and the publish workflow fails fast with a readable message when `REGISTRY_ACCESS_TOKEN` is missing instead of running a full install first.
- **Sequential LoRA Cycle Reordering**: The Sequential cycle now persists the selection order it started with instead of indexing the live (reorderable) list, so deselecting and reselecting LoRAs can no longer repeat one LoRA and skip another from the same state bucket.
- **Missing LoRA Selection Mode Default**: `load_lora` and `IS_CHANGED` now fall back to `Sequential` (matching `INPUT_TYPES` and the UI) instead of `All`, so API prompts without `_selection_mode` cycle one LoRA per prompt instead of stacking every selected LoRA into one render.
- **Click-to-Queue Prompt Bleed**: Queue clicks made while another batch is still being submitted now serialize the graph at click time. Text, LoRA selections and other node inputs edited afterwards can no longer bleed into the queued prompt, and single-item queues get the same protection as multi-item batches while random seeds still follow the live prompt.
- **Prompt Iterator Linked Input**: A connected `prompt` input now takes precedence over the node's local multiline `text`, so stale local text can no longer be replayed instead of the upstream *Prompt Counter* output.
- **LoRA Picker Console Flood**: The Visual LoRA Loader now reads its thumbnail setting once per picker render and without the deprecated `getSettingValue(id, default)` argument, so opening a large LoRA folder no longer emits a console warning for every tile.
---
## [2.6.2] - 2026-10-24 [Stable]
### Added
+271
View File
@@ -0,0 +1,271 @@
# 🔍 Visual LoRA Loader & Batch Queue Logic Audit
## Root Causes 1–5: Defect Analysis, Locations & Mechanics
> **Status:** Validated against `v2.6.3`. Root Causes **1** and **5** were genuine
> defects and are now fixed. Root Cause **2** is intended persistent-cycle
> behavior and is documented rather than changed. Root Causes **3** and **4** do
> not reproduce with the current submission queue and were corrected below.
---
## Executive Overview of Observed Defects
When a user selects one or more LoRAs, queues a batch of 5, and while they are being queued selects different LoRA(s) and queues a batch of 10, the following symptoms occur:
1. **LoRAs mixing together**: Prompts in Batch 2 receive LoRAs from Batch 1, or prompts in Batch 1 switch to LoRAs from Batch 2 mid-execution.
2. **Duplicate generations**: A LoRA generates twice in a row when sequential single-generation was expected.
3. **Missing LoRAs**: A LoRA that was explicitly selected is skipped and never generated.
4. **Capture bypass**: The previous "workflow capture" (`Batch Graph Snapshot Guard`) failed to prevent these issues.
The audit identified **five candidate root causes**. Validation shows that the
repeat/skip symptoms are explained by Root Causes **1** and **5**, while Root
Cause **2** is by-design cycling; Root Causes **3** and **4** do not produce the
described failures in the current code.
---
## 📌 Root Cause 1: Deterministic Hash Sorting vs. Unsorted Array Indexing
- **File**: [`nodes/lora_loader.py`](nodes/lora_loader.py)
- **Functions**: `select_a_lora(...)`
- **Verdict**: ✅ **Confirmed and fixed**
- **Symptom Produced**: **Same LoRA generated twice; other selected LoRA skipped entirely.**
### What Is Happening
In `select_a_lora`, the persistent state key on disk uses a SHA-256 hash calculated over the **alphabetically sorted** list of choices:
```python
choices_hash = hashlib.sha256(
json.dumps(sorted(valid_choices), ensure_ascii=False).encode("utf-8")
).hexdigest()[:16]
state_key = f"{prefix}_{unique_id}_{choices_hash}"
```
However, the selection indexes into the **unsorted** live list, whose order is
the click/toggle order stored by the frontend (`web/js/folder_lora_loader.js`,
`updateSelection`). Deselecting and reselecting a tile, or using a folder
checkbox, changes that order without changing the hash.
### Failure Scenario:
1. User selects **LoRA A**, then **LoRA B**:
- `valid_choices` = `["A", "B"]`, `sorted(...)` = `["A", "B"]` → `choices_hash` = `H1`.
- Prompt 1 executes at `index = 0` → **A**; saved `index = 1`.
2. The user reorders the selection to **B**, then **A**:
- `valid_choices` = `["B", "A"]`, sorted still `["A", "B"]` → same `H1`, so `index = 1` is loaded.
- `valid_choices[1]` is **A** → **A generated twice, B skipped.**
### Fix Applied
Sequential state now stores the order observed when the cycle started and
indexes into that stored order. Reordering the selection can no longer move the
index onto a different LoRA, so picks are never duplicated or skipped:
```python
new_entry["index"] = index
new_entry["order"] = order
```
The index is also normalized (`index %= len(order)`), which keeps long-lived
state bounded.
---
## 📌 Root Cause 2: Perpetual Inter-Batch Cycle Continuation
- **File**: [`nodes/lora_loader.py`](nodes/lora_loader.py)
- **State**: `user/default/SaturnNodes/lora_loader_state.json`
- **Verdict**: ✅ **Confirmed, but by design — documented, not changed**
- **Symptom Produced**: **Batches starting on arbitrary mid-cycle LoRAs; leftover pools continuing across sessions.**
### What Is Happening
`state_key` is keyed by `(mode, node_id, choices_hash)`; there is **no batch
boundary, run ID, or queue session**. The `index` counter (Sequential) and the
remaining `pool` (Random No-Replace) persist across batches, restarts, and days.
Global state is pruned only by recency (100 most recent buckets).
### Failure Scenario:
1. Three LoRAs (`[A, B, C]`), batch of 5 → A, B, C, A, B; saved `index = 5`.
2. A later batch of 2 resumes at `5 % 3 = 2` → **C, A** instead of A, B.
3. In **Random (No Replace)**, unpopped items from the previous run are consumed
first, so a new batch can start on leftovers from the old pool.
### Why This Is Not Changed
Continuous cycling is the documented "Sequential Cycle" / "Random (No Dupes)"
contract, and `tests/test_lora_loader.py` asserts it across prompts. The suite
already ships a manual `↺ Counter` reset and a per-node prompt→LoRA assignment
map for retries. Resetting automatically per batch would silently change the
documented cycling behavior, so this is treated as expected behavior with the
reset control as the escape hatch.
---
## 📌 Root Cause 3: Premature Snapshot Guard Teardown in Frontend V2
- **File**: [`web/batch_queue.js`](web/batch_queue.js)
- **Functions**: `setupQueueHooks()` (`app.queuePrompt` and `api.queuePrompt` hooks)
- **Verdict**: ❌ **Not reproducible with the current submission queue**
- **Original Claim**: A second batch makes ComfyUI's `app.queuePrompt` return
`false` synchronously, so the wrapper's `finally` clears `activeBatchContext`
before Batch 2's items are serialized, disabling the snapshot guard.
### Why the Claim Does Not Hold
The wrapper defers the original call through `batchSubmissionQueue`:
```javascript
const turn = batchSubmissionQueue.then(async () => {
const batch = getNextBatch(count);
activeBatchContext = batch;
try {
return await origQueuePrompt(number, batchCount, queueNodeIds);
} finally {
activeBatchContext = null;
}
});
batchSubmissionQueue = turn.catch(() => {});
return turn;
```
`batchSubmissionQueue` is updated synchronously before returning, so a second
queue action waits for the previous batch's submission promise to settle. Its
callback — and therefore `origQueuePrompt` — is not invoked while
`processingQueue === true`, so the synchronous `if (this.processingQueue)
return false;` path cannot fire for the second UI batch. This serialization is
present in the released `v2.6.2` as well.
ComfyUI's core behavior quoted in the original analysis is itself correct
(`comfyui_frontend_package/static/assets`): `queuePrompt` pushes to
`queueItems`, returns `false` while already processing, and otherwise loops with
`await graphToPrompt()` per item. The defect was in assuming the wrapper reached
that code immediately.
### Residual Edge Case
The early teardown described above *can* occur if a non-SaturnNodes caller
invokes `app.queuePrompt` while ComfyUI is already processing — the wrapper
would then get the synchronous `false` and clear its context. That is outside
the two-manual-batches scenario and is not addressed here.
---
## 📌 Root Cause 4: Single-Item Batches (`batchCount == 1`) Bypass Snapshot Guard
- **File**: [`web/batch_queue.js`](web/batch_queue.js)
- **Functions**: `api.queuePrompt` hook (`isSnapshotGuardEnabled() && batchContext.batchCount > 1`)
- **Verdict**: ⚠️ **Accurate code, not a defect**
### Why This Is Not a Defect
The snapshot guard exists to keep items **inside one multi-item batch**
consistent while the graph is re-serialized between iterations. A one-item batch
has no later item to protect, so skipping the snapshot is harmless.
Additionally, the guard has never protected the *first* item of any batch: the
snapshot is taken when that item is submitted, not when the user clicked Queue.
Edits made between clicking and submission affect item 0 of a multi-item batch
exactly as they affect a one-item batch. Tightening this would require capturing
the prompt at click time, which is a separate design change, not a
`batchCount > 1` bug.
---
## 📌 Root Cause 5: Function Signature Default Mismatch (`"All"` vs. `"Sequential"`)
- **File**: [`nodes/lora_loader.py`](nodes/lora_loader.py)
- **Functions**: `VisualLoraLoader.INPUT_TYPES`, `VisualLoraLoader.IS_CHANGED`, `VisualLoraLoader.load_lora(...)`
- **Verdict**: ✅ **Confirmed and fixed**
- **Symptom Produced**: **Simultaneous loading of multiple LoRAs (stacking all models into one render).**
### What Is Happening
`INPUT_TYPES` declared the hidden widget default as `"Sequential"`, while both
`IS_CHANGED` and the execution method signature fell back to `"All"`:
```python
# INPUT_TYPES
"_selection_mode": ("STRING", {"default": "Sequential"}),
# IS_CHANGED / load_lora (before the fix)
mode = kwargs.get("_selection_mode", "All")
def load_lora(..., _selection_mode="All", ...)
```
### Failure Scenario:
1. A prompt arrives without `_selection_mode` (raw API call, third-party
client, or hand-built prompt).
2. The backend falls back to `"All"`, so the `share one selection` branch
(`if _selection_mode in ["Random", "Sequential", "Random (No Replace)"]`) is
skipped and `select_a_lora` is never called.
3. Every selected LoRA is loaded onto the model and CLIP in one render instead
of cycling one per prompt.
Normal UI use is unaffected: `web/js/folder_lora_loader.js` always creates a
serialized `_selection_mode` widget defaulting to `"Sequential"`, which is why
the bug only surfaced through API-style prompts. `IS_CHANGED` was also affected:
its `"All"` fallback returned `""` (cacheable) for prompts missing the field.
### Fix Applied
Both fallbacks now match the node definition and UI:
```python
mode = kwargs.get("_selection_mode", "Sequential")
def load_lora(..., _selection_mode="Sequential", ...)
```
An explicit `"All"` sent by the UI still selects every LoRA simultaneously, so
intentional "All Loras" mode is unchanged.
---
## Summary Matrix
| Root Cause | Affected File(s) | Primary Impact | Status |
| :--- | :--- | :--- | :--- |
| **1. Hash vs Indexing Order** | `nodes/lora_loader.py` | Same LoRA selected twice; other selected LoRAs skipped entirely. | ✅ Fixed (stored cycle order) |
| **2. Inter-Batch State Carryover** | `nodes/lora_loader.py` | Prompts start at arbitrary cycle offsets; leftover pools continue. | ✅ By design (manual `↺ Counter` reset) |
| **3. Snapshot Guard Teardown** | `web/batch_queue.js` | — | ❌ Not reproducible; submission queue serializes batches |
| **4. Batch Count == 1 Bypass** | `web/batch_queue.js` | — | ⚠️ Expected; no second item to snapshot |
| **5. Default Mode Mismatch** | `nodes/lora_loader.py` | API prompts silently stack all selected LoRAs. | ✅ Fixed (defaults aligned to `Sequential`) |
### Regression Coverage Added
- `test_sequential_reorder_keeps_cycle_position` — reordering the selection
continues the existing cycle without repeating or skipping a LoRA.
- `test_sequential_changed_selection_starts_fresh_cycle` — changing the selected
set still starts a new bucket.
- `test_missing_selection_mode_defaults_to_sequential` — `INPUT_TYPES`,
`load_lora`, and `IS_CHANGED` agree on the fallback mode.
---
## Follow-up: Click-to-Queue Bleed (reported after the initial audit)
**Symptom:** Queue a batch with LoRA A, then queue more prompts with LoRAs B/C
10× and A again 5× while the earlier batch is still loading, and prompts come
out with text or LoRA selections taken from a different click ("character
bleeding"). Rapid Queue clicks made the effect worse.
**Actual cause (client-side timing, not parallelism):** ComfyUI's
`app.queuePrompt` does not serialize a click immediately when it is already
submitting another batch. It pushes the request onto `queueItems` and returns
`false`; the graph is serialized with `graphToPrompt()` only when that request's
turn starts. SaturnNodes additionally serializes UI queue actions through
`batchSubmissionQueue`. Any canvas edit made between the click and the deferred
serialization was therefore captured by the queued prompt. The old snapshot
guard only froze items **after** item 0 of a multi-item batch, so item 0 and
every single-item queue remained exposed.
**Fix:** `web/batch_queue.js` now captures the graph at click time whenever a
submission is already pending (`pendingSubmissions > 0` or
`app.processingQueue === true`). Item 0 of that batch uses the captured prompt,
later items reuse it as the snapshot, and `mergeDynamicSeeds` keeps randomized
seeds from the live prompt so generated seeds still match the UI. Idle clicks
skip the extra serialization.
**Prompt iterator:** reviewed for the same class of bug. It is not affected by
submission timing because ComfyUI executes prompts serially on the server. One
real bleed source was fixed: the optional `prompt` input was ignored whenever
the node's local `text` widget was non-empty, so stale local characters could
be replayed instead of the connected *Prompt Counter* output. A connected input
now always wins.
**Why the old tests missed it:** the Python tests call selection/iterator
functions directly and the DOM tests never modelled a pending HTTP submission.
The new `tests/test_batch_queue_snapshot_dom.mjs` reproduces the deferred
`graphToPrompt()` order of ComfyUI, and the iterator precedence tests live in
`tests/test_prompt_iterator.py`.
+74 -3
View File
@@ -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>
@@ -459,7 +530,7 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
- **Clean 1D Line:** The first item starts with a top curve (`┌`), middle items are straight lines (`│`), and the last item ends with a bottom curve (`└`). Standalone 1-item batches are rounded at both ends (`(`).
- **In-between Infiltration Detection:** If a different prompt is queued in-between items of a batch, the interrupted batch maintains its open line ends without false curves, while the inserted item is highlighted with its own bracket.
- **Works Standalone or with PersistentQueue:** Operates client-side via `localStorage` when standalone, and syncs with `PersistentQueue` to retain batch relationships after server restarts.
- **`Batch Graph Snapshot Guard`** (`boolean`, *Default: true*): Background prompt and node input snapshotting ensuring canvas edits don't mutate active batches.
- **`Batch Graph Snapshot Guard`** (`boolean`, *Default: true*): Snapshots prompt text, LoRA selections and node inputs when you click Queue (including batches queued behind an in-flight submission), ensuring later canvas edits do not mutate queued prompts. Random seeds continue to randomize.
</details>
<details>
+18
View File
@@ -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
View File
@@ -4,7 +4,7 @@ import subprocess
from aiohttp import web
from server import PromptServer
__version__ = "2.6.2"
__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"),
+83 -14
View File
@@ -1,9 +1,11 @@
import os
import json
import math
import time
import uuid
import heapq
import datetime
import threading
from PIL import Image
import folder_paths
@@ -29,6 +31,13 @@ def is_restored_generation_time_enabled():
val = get_env_setting("SHOW_RESTORED_GENERATION_TIME", "true").lower()
return val in ["true", "1", "yes"]
def _strip_exif_text(text):
"""Strip NUL padding, a UTF BOM and surrounding whitespace from EXIF text."""
value = str(text or "").replace("\x00", "").strip()
if value.startswith("\ufeff"):
value = value[1:].strip()
return value
def _decode_exif_text(raw):
"""Decode an EXIF UserComment, including the standard 8-byte prefix."""
if not raw:
@@ -39,29 +48,31 @@ def _decode_exif_text(raw):
# The EXIF standard / piexif write UTF-16 big-endian here, not a
# BOM-prefixed native-endian string.
try:
return body.decode("utf-16-be", errors="ignore").strip("\x00").strip()
return _strip_exif_text(body.decode("utf-16-be", errors="ignore"))
except Exception:
pass
if prefix.startswith(b"ASCII"):
try:
return body.decode("ascii", errors="ignore").strip("\x00").strip()
return _strip_exif_text(body.decode("ascii", errors="ignore"))
except Exception:
pass
if prefix.startswith(b"JIS"):
try:
return body.decode("shift_jis", errors="ignore").strip("\x00").strip()
return _strip_exif_text(body.decode("shift_jis", errors="ignore"))
except Exception:
pass
try:
return raw.decode("utf-8", errors="ignore").strip("\x00").strip()
return _strip_exif_text(raw.decode("utf-8", errors="ignore"))
except Exception:
return ""
return str(raw).strip()
return _strip_exif_text(raw)
def parse_a1111_positive_prompt(parameters):
"""Extract the positive prompt from an A1111/Forge `parameters` string."""
if not parameters:
return ""
if isinstance(parameters, bytes):
parameters = _decode_exif_text(parameters)
text = str(parameters).replace("\r\n", "\n").replace("\r", "\n").strip()
if not text:
return ""
@@ -81,6 +92,7 @@ def parse_a1111_positive_prompt(parameters):
class AssetsRestoreManager:
def __init__(self):
self._restored_on_launch = False
self._restore_lock = threading.RLock()
self.last_debug_report = {}
self.restored_count = 0
@@ -179,10 +191,26 @@ class AssetsRestoreManager:
"mtime": mtime,
}
# Keep only the top-N candidates (bounded memory), then restore
# chronological order (oldest -> newest) for stable history numbering.
top_images = heapq.nlargest(max(1, int(limit)), iter_images(), key=lambda x: x["mtime"])
top_images.reverse()
try:
safe_limit = max(1, int(limit))
except (TypeError, ValueError):
safe_limit = 64
# Keep only the top-N candidates (bounded memory). Include the
# normalized relative path in the key so equal mtimes (bulk copies,
# archive extraction, FAT timestamp granularity) always resolve to the
# same files and the same order on every scan.
def sort_key(item):
return (
item["mtime"],
item["subfolder"],
item["filename"],
)
top_images = heapq.nlargest(safe_limit, iter_images(), key=sort_key)
# Restore chronological order (oldest -> newest) for stable history
# numbering. Equal timestamps sort by subfolder/filename ascending.
top_images.sort(key=sort_key)
return top_images
def _ingest_assets(self, prepared):
@@ -232,13 +260,26 @@ class AssetsRestoreManager:
return len(registered)
def restore_on_launch(self, server, limit=None, force=False):
"""Populate PromptQueue.history with the newest generated images."""
"""Populate PromptQueue.history with the newest generated images.
Serialized because the Restore Now endpoint can be triggered from
several browser tabs while the startup restore is still running.
"""
with self._restore_lock:
return self._restore_on_launch(server, limit=limit, force=force)
def _restore_on_launch(self, server, limit=None, force=False):
if self._restored_on_launch and not force:
return 0
started_at = time.time()
if limit is None:
limit = get_assets_restore_count()
else:
try:
limit = max(1, min(1000, int(limit)))
except (TypeError, ValueError):
limit = get_assets_restore_count()
enabled = is_assets_restore_enabled()
if not enabled:
@@ -290,8 +331,20 @@ class AssetsRestoreManager:
if positive_prompt:
extra_pnginfo["positive_prompt"] = positive_prompt
mtime_ms = int(item["mtime"] * 1000)
try:
mtime_ms = max(0, int(float(item.get("mtime", 0)) * 1000))
except (TypeError, ValueError, OverflowError):
mtime_ms = 0
duration = get_generation_time(filename, subfolder, item.get("mtime")) if show_generation_time else None
if duration is not None:
try:
duration = float(duration)
except (TypeError, ValueError, OverflowError):
duration = None
if duration is not None and (not math.isfinite(duration) or duration < 0):
duration = None
if duration is not None:
generation_time_matches += 1
execution_start_ms = max(0, mtime_ms - int(duration * 1000))
@@ -350,12 +403,28 @@ class AssetsRestoreManager:
restored_count = 0
if prompt_queue is not None:
with prompt_queue.mutex:
mutex = getattr(prompt_queue, "mutex", None)
if mutex is None:
mutex = threading.RLock()
with mutex:
history = getattr(prompt_queue, "history", None)
if not isinstance(history, dict):
history = {}
try:
prompt_queue.history = history
except Exception:
pass
for entry in prepared:
prompt_queue.history[entry["prompt_id"]] = entry["history_entry"]
history[entry["prompt_id"]] = entry["history_entry"]
restored_count += 1
registered_count = self._ingest_assets(prepared)
# Asset DB ingestion is best-effort: a broken/absent app.assets must
# never prevent the restored history from being usable.
try:
registered_count = self._ingest_assets(prepared)
except Exception as e:
print(f"[SaturnNodes] Asset DB ingestion failed after restore: {e}")
registered_count = 0
self._restored_on_launch = True
self.restored_count = restored_count
File diff suppressed because it is too large Load Diff
+66 -10
View File
@@ -9,6 +9,7 @@ restored history entries can show a real execution time.
import os
import json
import math
import time
import threading
from .utils import get_saturnnodes_user_dir
@@ -28,9 +29,43 @@ def _entry_timestamp(entry):
if not isinstance(entry, dict):
return 0
try:
return int(entry.get("completed", 0))
except (TypeError, ValueError):
value = int(entry.get("completed", 0))
except (TypeError, ValueError, OverflowError):
return 0
return value if value >= 0 else 0
def _sanitize_data(data):
"""Return a JSON-safe copy of the duration store.
Hand-edited or partially written files can contain non-finite, negative or
non-numeric durations. Dropping them here keeps one corrupt entry from
making every future save fail under ``allow_nan=False``.
"""
payload = dict(data) if isinstance(data, dict) else {}
payload.setdefault("version", 1)
entries = {}
raw_entries = payload.get("entries")
if isinstance(raw_entries, dict):
for key, entry in raw_entries.items():
if not isinstance(entry, dict):
continue
try:
duration = float(entry.get("duration"))
except (TypeError, ValueError, OverflowError):
continue
if not math.isfinite(duration) or duration < 0:
continue
cleaned = dict(entry)
cleaned["duration"] = duration
try:
completed = int(entry.get("completed", 0))
except (TypeError, ValueError, OverflowError):
completed = 0
cleaned["completed"] = completed if completed >= 0 else 0
entries[key] = cleaned
payload["entries"] = entries
return payload
def _load_data():
@@ -51,7 +86,7 @@ def _load_data():
with open(GENERATION_TIMES_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict) and isinstance(loaded.get("entries"), dict):
data = loaded
data = _sanitize_data(loaded)
except Exception:
pass
_DATA = data
@@ -61,23 +96,41 @@ def _load_data():
def _save_data(data):
global _DATA_MTIME
temp_file = None
try:
os.makedirs(USER_DIR, exist_ok=True)
temp_file = GENERATION_TIMES_FILE + ".tmp"
data = _sanitize_data(data)
# Unique temp names keep a stale temp file from a crashed process (or a
# second ComfyUI instance) from being clobbered mid-write.
temp_file = f"{GENERATION_TIMES_FILE}.{os.getpid()}.{threading.get_ident()}.tmp"
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"), ensure_ascii=False)
json.dump(data, f, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
f.flush()
try:
os.fsync(f.fileno())
except OSError:
pass
os.replace(temp_file, GENERATION_TIMES_FILE)
for attempt in range(3):
try:
os.replace(temp_file, GENERATION_TIMES_FILE)
temp_file = None
break
except PermissionError:
if attempt == 2:
raise
time.sleep(0.05)
try:
_DATA_MTIME = os.stat(GENERATION_TIMES_FILE).st_mtime_ns
except OSError:
_DATA_MTIME = None
except Exception as e:
print(f"[SaturnNodes] Error saving generation times: {e}")
finally:
if temp_file and os.path.exists(temp_file):
try:
os.remove(temp_file)
except OSError:
pass
def _normalize_ref(filename, subfolder=""):
@@ -90,9 +143,9 @@ def record_generation_time(prompt_id, duration, files, status="success"):
"""Store one prompt's wall-clock duration keyed by every output file."""
try:
duration = round(float(duration), 3)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
return
if duration < 0 or not files:
if not math.isfinite(duration) or duration < 0 or not files:
return
completed_ms = int(time.time() * 1000)
@@ -156,9 +209,12 @@ def get_generation_time(filename, subfolder="", file_mtime=None):
entry = matches[0][1]
if isinstance(entry, dict):
try:
return float(entry.get("duration"))
except (TypeError, ValueError):
duration = float(entry.get("duration"))
except (TypeError, ValueError, OverflowError):
return None
if math.isfinite(duration) and duration >= 0:
return duration
return None
return None
+3 -1
View File
@@ -206,7 +206,9 @@ def scan_image_folder(resolved_folder, metadata_limit=None):
rel_path = os.path.relpath(full_path, resolved_folder).replace("\\", "/")
raw_files.append((rel_path, full_path, mtime))
# Sort files by newest modified first
# Sort files by newest modified first. Pre-sort by relative path and use a
# stable mtime sort so identical timestamps always produce the same order.
raw_files.sort(key=lambda x: x[0])
raw_files.sort(key=lambda x: x[2], reverse=True)
names = []
+17 -6
View File
@@ -65,7 +65,18 @@ class LoadRecentOutputs:
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
return (dummy,)
file_list.sort(key=lambda x: x[1], reverse=True)
try:
amount = max(1, int(amount))
except (TypeError, ValueError, OverflowError):
amount = 1
try:
index = max(0, int(index))
except (TypeError, ValueError, OverflowError):
index = 0
# Newest first, with the path as a deterministic tie-breaker for files
# copied/extracted with identical timestamps.
file_list.sort(key=lambda x: (x[1], x[0]), reverse=True)
recent_files = file_list[:amount]
if "oldest" in str(order).lower() or "chronological" in str(order).lower():
recent_files.reverse()
@@ -74,11 +85,11 @@ class LoadRecentOutputs:
target_file, _ = recent_files[actual_index]
try:
img = Image.open(target_file)
img = ImageOps.exif_transpose(img)
image = img.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image).unsqueeze(0)
with Image.open(target_file) as img:
img = ImageOps.exif_transpose(img)
image = img.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image).unsqueeze(0)
except Exception as e:
print(f"[SaturnNodes] Error loading recent output image {target_file}: {e}")
image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
+21 -3
View File
@@ -864,6 +864,8 @@ def select_a_lora(mode, unique_id, valid_choices, prompt_id=None):
raw_pool = node_state.get("pool", [])
pool = list(raw_pool) if isinstance(raw_pool, (list, tuple)) else []
last_selected = node_state.get("last")
raw_order = node_state.get("order")
order = list(raw_order) if isinstance(raw_order, (list, tuple)) else []
try:
index = max(0, int(node_state.get("index", 0)))
except (TypeError, ValueError):
@@ -873,13 +875,28 @@ def select_a_lora(mode, unique_id, valid_choices, prompt_id=None):
assigned = {}
pool = list(node_state)
last_selected = None
order = []
index = 0
else:
assigned = {}
pool = []
last_selected = None
order = []
index = 0
if mode == "Sequential":
# The state key hashes the *set* of selected LoRAs, so selecting the
# same LoRAs in a different order reuses this bucket. Persist the
# order observed when the cycle started and index into that, because
# indexing the live (reordered) list used to land on a different
# LoRA, repeating one pick and skipping another.
if not order or sorted(order) != sorted(valid_choices):
order = list(valid_choices)
if order:
index %= len(order)
else:
order = []
if prompt_id and prompt_id in assigned:
# Retry/crash recovery: reproduce the exact same selection without
# advancing the cycle or consuming another pool entry.
@@ -891,7 +908,7 @@ def select_a_lora(mode, unique_id, valid_choices, prompt_id=None):
if selected is None:
if mode == "Sequential":
selected = valid_choices[index % len(valid_choices)]
selected = order[index % len(order)] if order else None
index += 1
elif mode == "Random":
import random
@@ -922,6 +939,7 @@ def select_a_lora(mode, unique_id, valid_choices, prompt_id=None):
}
if mode == "Sequential":
new_entry["index"] = index
new_entry["order"] = order
if mode == "Random (No Replace)":
new_entry["pool"] = pool
new_entry["last"] = last_selected
@@ -961,12 +979,12 @@ class VisualLoraLoader(FolderLoraLoaderPretty):
@classmethod
def IS_CHANGED(cls, **kwargs):
mode = kwargs.get("_selection_mode", "All")
mode = kwargs.get("_selection_mode", "Sequential")
if mode in ["Random", "Sequential", "Random (No Replace)"]:
return time.time()
return ""
def load_lora(self, model, clip, folder, strength_model, strength_clip, display_mode="Scrollable", sort_loras_by="Name (A-Z)", sort_folders_by="Name (A-Z)", folder_position="Folders First", content_alignment="Left Aligned", output_format="Filename", custom_regex="", output_name=None, _selected_lora="[]", _selection_mode="All", _scrape_on_new="true", unique_id="default", **kwargs):
def load_lora(self, model, clip, folder, strength_model, strength_clip, display_mode="Scrollable", sort_loras_by="Name (A-Z)", sort_folders_by="Name (A-Z)", folder_position="Folders First", content_alignment="Left Aligned", output_format="Filename", custom_regex="", output_name=None, _selected_lora="[]", _selection_mode="Sequential", _scrape_on_new="true", unique_id="default", **kwargs):
active_format = output_name if output_name is not None else output_format
active_lora = _selected_lora if _selected_lora else "[]"
if active_lora == "[ NONE ]" or active_lora == "[]" or not active_lora:
+9 -1
View File
@@ -328,7 +328,15 @@ class PromptQueueIterator:
unique_id="default",
**kwargs
):
raw_input = text if text else (prompt if prompt is not None else prompt_text)
# A connected `prompt` input is authoritative. Falling back to the
# local `text` widget used to replay stale characters from a previous
# session even though the linked upstream node produced new prompts.
if prompt is not None:
raw_input = prompt
elif text:
raw_input = text
else:
raw_input = prompt_text
text_str = str(raw_input) if raw_input is not None else ""
if not text_str.strip():
return ("", "", 0, False)
+169 -49
View File
@@ -282,7 +282,64 @@ class PersistentQueueManager:
self.load_from_file()
@staticmethod
def _normalize_persisted_payload(data):
def _normalize_entry(raw):
"""Return one repaired persistence entry, or ``None`` if unusable.
The top-level ``prompt_id`` is authoritative because it is the
persistence key used by add/remove. The queued item is only a fallback
for legacy or hand-edited entries that lack it. The restore path
re-syncs ``item[1]`` before queueing so task completion removes the
matching persistence entry.
"""
if not isinstance(raw, dict):
return None
item = raw.get("item")
if not isinstance(item, (list, tuple)) or len(item) < 2:
return None
item_list = list(item)
item_pid = item_list[1]
entry_pid = raw.get("prompt_id")
# The top-level prompt_id is the persistence key. Fall back to the
# queued item only for legacy/hand-edited entries that lack it. The
# restore path re-syncs item[1] before queueing so task_done can always
# find and remove the matching persistence entry.
if entry_pid is None or entry_pid == "":
if item_pid is None or item_pid == "":
return None
entry_pid = str(item_pid)
else:
entry_pid = str(entry_pid)
entry = dict(raw)
entry["prompt_id"] = entry_pid
entry["item"] = item_list
return entry
@classmethod
def _normalize_items(cls, items):
"""Repair entries and drop duplicate prompt ids, keeping the last one."""
normalized = {}
changed = False
for raw in items:
entry = cls._normalize_entry(raw)
if entry is None:
changed = True
continue
pid = entry["prompt_id"]
if pid in normalized:
# add_item() replaces the old entry and appends the new one;
# mirror that "last occurrence wins" rule on load.
normalized.pop(pid, None)
changed = True
normalized[pid] = entry
if entry != raw:
changed = True
return list(normalized.values()), changed
@classmethod
def _normalize_persisted_payload(cls, data):
"""Return ``(items, needs_rewrite, is_foreign)`` for persisted payloads.
- v1 files (pre-2.6.2) are a bare list and are migrated on load.
@@ -291,13 +348,6 @@ class PersistentQueueManager:
caller can keep it read-only instead of destroying a queue it cannot
understand.
"""
def is_valid_item(x):
return (
isinstance(x, dict)
and "prompt_id" in x
and isinstance(x.get("item"), (list, tuple))
)
if isinstance(data, dict):
version = data.get("schema_version")
items = data.get("items")
@@ -310,20 +360,25 @@ class PersistentQueueManager:
"leaving it untouched."
)
return [], False, True
normalized, changed = cls._normalize_items(items)
return (
[x for x in items if is_valid_item(x)],
version != PERSISTENT_QUEUE_SCHEMA_VERSION,
normalized,
version != PERSISTENT_QUEUE_SCHEMA_VERSION or changed,
False,
)
if isinstance(data, list):
# Legacy bare-list format.
return [x for x in data if is_valid_item(x)], True, False
normalized, _ = cls._normalize_items(data)
return normalized, True, False
return [], False, False
def load_from_file(self):
with self.lock:
self._read_only_newer_schema = False
self.batch_meta = {}
self.batch_meta_updated = {}
if not os.path.exists(PERSISTENT_FILE):
self.persistent_items = []
self._prune_batch_meta_locked()
@@ -335,9 +390,10 @@ class PersistentQueueManager:
self._read_only_newer_schema = is_foreign
self.persistent_items = items
for x in self.persistent_items:
if "batch_info" in x and "prompt_id" in x:
batch_info = x.get("batch_info")
if isinstance(batch_info, dict) and "prompt_id" in x:
pid = str(x["prompt_id"])
self.batch_meta[pid] = x["batch_info"]
self.batch_meta[pid] = batch_info
self.batch_meta_updated[pid] = time.time()
if needs_rewrite:
# Rewrite once so the legacy bare-list shape is not read
@@ -371,7 +427,7 @@ class PersistentQueueManager:
self.recently_finished.pop(next(iter(self.recently_finished)))
def set_batch_info(self, prompt_id, batch_info):
if not prompt_id:
if prompt_id is None or prompt_id == "" or not isinstance(batch_info, dict):
return
pid_str = str(prompt_id)
with self.lock:
@@ -460,7 +516,7 @@ class PersistentQueueManager:
if isinstance(item_tuple, (tuple, list)):
item_list = list(item_tuple)
prompt_id = item_list[1] if len(item_list) > 1 else None
if not prompt_id:
if prompt_id is None or prompt_id == "":
return
pid_str = str(prompt_id)
entry = {
@@ -485,7 +541,7 @@ class PersistentQueueManager:
print(f"[SaturnNodes PersistentQueue] Error formatting queue item for persistence: {e}")
def remove_item(self, prompt_id):
if not prompt_id:
if prompt_id is None or prompt_id == "":
return
pid_str = str(prompt_id)
with self.lock:
@@ -557,22 +613,28 @@ class PersistentQueueManager:
if hasattr(queue, "delete_queue_item"):
original_delete = queue.delete_queue_item
def patched_delete(fn, *args, **kwargs):
to_delete = []
try:
with queue.mutex:
to_delete = [item[1] for item in queue.queue if fn(item) and len(item) > 1]
for pid in to_delete:
self.remove_item(pid)
except Exception:
pass
return original_delete(fn, *args, **kwargs)
to_delete = []
res = original_delete(fn, *args, **kwargs)
# Only forget persistence after the in-memory delete succeeded;
# otherwise a failed cancel would silently lose crash recovery.
for pid in to_delete:
self.remove_item(pid)
return res
queue.delete_queue_item = patched_delete
# 4. Patch queue.wipe_queue
if hasattr(queue, "wipe_queue"):
original_wipe = queue.wipe_queue
def patched_wipe(*args, **kwargs):
res = original_wipe(*args, **kwargs)
# Clear persistence only after ComfyUI accepted the wipe.
self.wipe_all()
return original_wipe(*args, **kwargs)
return res
queue.wipe_queue = patched_wipe
# 5. Redundant websocket event hooks
@@ -607,60 +669,115 @@ class PersistentQueueManager:
self.has_claimed_once = True
return
restored_state_setting = get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default")
if restored_state_setting == "Force Paused":
restored_state_setting = str(
get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default")
).strip().lower()
if restored_state_setting == "force paused":
pause_manager.set_pause(True)
elif restored_state_setting == "Force Running":
elif restored_state_setting == "force running":
pause_manager.set_pause(False)
print(f"[SaturnNodes PersistentQueue] Restoring {len(self.persistent_items)} saved queue item(s) to client '{active_client_id}'...")
# Repair duplicates/mismatched prompt ids before restoring. A
# hand-edited file must never queue the same prompt twice or leave an
# entry that task_done cannot remove.
with self.lock:
normalized, repaired = self._normalize_items(list(self.persistent_items))
if repaired:
self.persistent_items = normalized
self._save_dirty = True
if repaired:
self.save_to_file(immediate=True)
snapshot = list(self.persistent_items)
if not snapshot:
self.has_claimed_once = True
return
print(f"[SaturnNodes PersistentQueue] Restoring {len(snapshot)} saved queue item(s) to client '{active_client_id}'...")
try:
max_retries = int(get_env_setting("PERSISTENT_QUEUE_MAX_RETRIES", "0"))
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
max_retries = 0
max_retries = max(0, min(10, max_retries))
self.is_restoring = True
max_number = 0
restored_count = 0
state_changed = repaired
# Restored jobs all used to receive the exact same create_time, which
# left their history order dependent on dict iteration. Keep them
# strictly increasing and just before "now" so newly queued prompts
# still sort after every restored one.
create_time_cursor = int(time.time() * 1000) - len(snapshot)
try:
history = server.prompt_queue.get_history() if hasattr(server.prompt_queue, "get_history") else {}
for entry in list(self.persistent_items):
try:
history = server.prompt_queue.get_history() if hasattr(server.prompt_queue, "get_history") else {}
except Exception as e:
print(f"[SaturnNodes PersistentQueue] Could not read history before restore: {e}")
history = {}
if not isinstance(history, dict):
history = {}
for entry in snapshot:
if not isinstance(entry, dict):
continue
item_list = entry.get("item")
if not isinstance(item_list, (list, tuple)):
continue
item_list = list(item_list)
pid = entry.get("prompt_id")
if pid is None or pid == "":
pid = item_list[1] if len(item_list) > 1 else None
pid_str = str(pid) if pid is not None and pid != "" else ""
try:
pid = entry.get("prompt_id")
# If this prompt is already in history, it finished previously - drop it
if pid and pid in history:
self.remove_item(pid)
if pid_str and pid_str in history:
self.remove_item(pid_str)
state_changed = True
continue
item_list = list(entry.get("item", []))
if len(item_list) < 3 or not item_list[1] or not isinstance(item_list[2], dict):
if len(item_list) < 3 or not pid_str or not isinstance(item_list[2], dict):
# Malformed entry, drop from disk
if pid:
self.remove_item(pid)
if pid_str:
self.remove_item(pid_str)
state_changed = True
continue
# Keep the queued item and the persistence key in lockstep.
if item_list[1] != pid_str:
item_list[1] = pid_str
entry["item"] = item_list
state_changed = True
if len(item_list) > 3 and isinstance(item_list[3], dict):
if active_client_id:
item_list[3]["client_id"] = active_client_id
# Update create_time to current timestamp so resumed jobs sort as newest in history & assets
item_list[3]["create_time"] = int(time.time() * 1000)
# Update create_time so resumed jobs sort as newest in
# history & assets, while preserving their queue order.
create_time_cursor += 1
item_list[3]["create_time"] = create_time_cursor
if isinstance(item_list[0], (int, float)):
max_number = max(max_number, int(item_list[0]))
server.prompt_queue.put(tuple(item_list))
entry["retries"] = 0
if entry.get("retries", 0) != 0:
entry["retries"] = 0
state_changed = True
restored_count += 1
except Exception as e:
pid = entry.get("prompt_id")
retries = int(entry.get("retries", 0)) + 1
try:
retries = int(entry.get("retries", 0)) + 1
except (TypeError, ValueError, OverflowError):
retries = 1
if retries > max_retries:
print(f"[SaturnNodes PersistentQueue] Error restoring prompt {pid}: {e}. Dropping after {retries} attempt(s).")
if pid:
self.remove_item(pid)
print(f"[SaturnNodes PersistentQueue] Error restoring prompt {pid_str}: {e}. Dropping after {retries} attempt(s).")
if pid_str:
self.remove_item(pid_str)
else:
print(f"[SaturnNodes PersistentQueue] Error restoring prompt {pid}: {e}. Retry {retries}/{max_retries} on next launch.")
print(f"[SaturnNodes PersistentQueue] Error restoring prompt {pid_str}: {e}. Retry {retries}/{max_retries} on next launch.")
entry["retries"] = retries
state_changed = True
self.save_to_file(immediate=True)
finally:
self.is_restoring = False
@@ -674,10 +791,13 @@ class PersistentQueueManager:
if active_client_id:
with self.lock:
for entry in self.persistent_items:
if isinstance(entry.get("item"), list) and len(entry["item"]) > 3:
if isinstance(entry["item"][3], dict):
entry["item"][3]["client_id"] = active_client_id
self.save_to_file(immediate=True)
item = entry.get("item") if isinstance(entry, dict) else None
if isinstance(item, (list, tuple)) and len(item) > 3:
if isinstance(item[3], dict):
item[3]["client_id"] = active_client_id
state_changed = True
if state_changed:
self.save_to_file(immediate=True)
# Broadcast updated queue state to client UI
try:
@@ -1038,7 +1158,7 @@ def setup_queue_control_routes(server):
data = await request.json()
pid = data.get("prompt_id")
info = data.get("batch_info")
if pid and info:
if pid and isinstance(info, dict):
persistent_manager.set_batch_info(pid, info)
return web.json_response({"success": True})
except Exception as e:
+3 -3
View File
@@ -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.2"
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"
@@ -21,7 +21,7 @@ Repository = "https://github.com/KOFiblto/ComfyUI-SaturnNodes"
Documentation = "https://github.com/KOFiblto/ComfyUI-SaturnNodes#readme"
[tool.comfy]
PublisherId = "KOFiblto"
PublisherId = "kofiblto"
DisplayName = "ComfyUI-SaturnNodes"
Icon = "https://raw.githubusercontent.com/KOFiblto/ComfyUI-SaturnNodes/main/web/logo.png"
requires-comfyui = ">=0.3.0"
+2 -1
View File
@@ -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);
}
+422
View File
@@ -0,0 +1,422 @@
import json
import os
import shutil
import sys
import tempfile
import threading
import time
import unittest
import uuid
from types import SimpleNamespace
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper # noqa: F401 (redirects SaturnNodes state to a temp dir)
from PIL import Image, PngImagePlugin
from nodes.assets_restore import (
AssetsRestoreManager,
_decode_exif_text,
get_assets_restore_count,
parse_a1111_positive_prompt,
)
class TestAssetsRestoreExtended(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.output_dir = os.path.join(self.temp_dir, "output")
os.makedirs(self.output_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _make_png(self, name, mtime, subfolder="", prompt_json=None, parameters=None):
folder = os.path.join(self.output_dir, subfolder) if subfolder else self.output_dir
os.makedirs(folder, exist_ok=True)
path = os.path.join(folder, name)
info = PngImagePlugin.PngInfo()
if prompt_json is not None:
info.add_text("prompt", json.dumps(prompt_json))
if parameters is not None:
info.add_text("parameters", parameters)
Image.new("RGB", (8, 8), (10, 20, 30)).save(path, pnginfo=info)
os.utime(path, (mtime, mtime))
return path
def _fake_queue(self, history=None, mutex=True):
queue = SimpleNamespace(history=history)
if mutex:
queue.mutex = threading.RLock()
return queue
def _server(self, queue):
return SimpleNamespace(prompt_queue=queue, queue_updated=lambda: None)
def test_scan_equal_mtimes_use_path_tiebreak(self):
base = 1_700_000_000
# Deliberately create in non-sorted order so filesystem iteration order
# cannot accidentally make the test pass.
self._make_png("b.png", base)
self._make_png("a.png", base)
self._make_png("c.png", base, subfolder="sub")
self._make_png("a.png", base, subfolder="sub")
manager = AssetsRestoreManager()
first = manager.scan_output_images(self.output_dir, limit=10)
second = manager.scan_output_images(self.output_dir, limit=10)
expected = [
("a.png", ""),
("b.png", ""),
("a.png", "sub"),
("c.png", "sub"),
]
self.assertEqual([(x["filename"], x["subfolder"]) for x in first], expected)
self.assertEqual(first, second, "repeated scans must return identical ordering")
def test_scan_limit_is_clamped_and_invalid_limits_fall_back(self):
base = 1_700_000_000
for index in range(4):
self._make_png(f"img{index}.png", base + index)
with open(os.path.join(self.output_dir, "notes.txt"), "w", encoding="utf-8") as handle:
handle.write("not an image")
manager = AssetsRestoreManager()
stats = {}
newest_one = manager.scan_output_images(self.output_dir, limit=0, stats=stats)
self.assertEqual([x["filename"] for x in newest_one], ["img3.png"])
self.assertEqual(stats["scanned"], 4, "non-image files must not count as scanned images")
self.assertEqual(
[x["filename"] for x in manager.scan_output_images(self.output_dir, limit=-7)],
["img3.png"],
)
self.assertEqual(
len(manager.scan_output_images(self.output_dir, limit="not-a-number")),
4,
"an invalid limit must fall back to the default scan size",
)
def test_restore_limit_is_clamped_and_invalid_uses_setting(self):
base = 1_700_000_000
self._make_png("one.png", base)
self._make_png("two.png", base + 1)
self._make_png("three.png", base + 2)
manager = AssetsRestoreManager()
queue = self._fake_queue()
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_generation_time", return_value=None), \
patch.object(manager, "_ingest_assets", return_value=0):
self.assertEqual(manager.restore_on_launch(self._server(queue), limit=0, force=True), 1)
manager = AssetsRestoreManager()
queue = self._fake_queue()
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_assets_restore_count", return_value=2), \
patch("nodes.assets_restore.get_generation_time", return_value=None), \
patch.object(manager, "_ingest_assets", return_value=0):
self.assertEqual(
manager.restore_on_launch(self._server(queue), limit="broken", force=True),
2,
)
def test_concurrent_force_restores_are_serialized(self):
self._make_png("image.png", 1_700_000_000)
manager = AssetsRestoreManager()
queue = self._fake_queue()
server = self._server(queue)
intervals = []
intervals_lock = threading.Lock()
def slow_ingest(prepared):
start = time.monotonic()
time.sleep(0.08)
end = time.monotonic()
with intervals_lock:
intervals.append((start, end))
return len(prepared)
errors = []
def run_restore():
try:
manager.restore_on_launch(server, force=True)
except Exception as exc: # pragma: no cover - surfaced via assert below
errors.append(exc)
# Apply the module patches in the main thread. Patching the same target
# from two threads can restore stale values when the contexts unwind in
# a different order and would make this test flaky.
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_generation_time", return_value=None), \
patch.object(manager, "_ingest_assets", side_effect=slow_ingest):
threads = [threading.Thread(target=run_restore) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(10)
self.assertEqual(errors, [])
self.assertEqual(len(intervals), 2)
intervals.sort()
self.assertGreaterEqual(
intervals[1][0],
intervals[0][1] - 0.01,
"the restore lock must prevent overlapping restore/ingest runs",
)
def test_restore_preserves_cross_day_timestamps_and_durations(self):
day = 86_400
base = 1_700_000_000
specs = [
("day-one.png", base, 1.5),
("day-two.png", base + day, 2.0),
("day-three.png", base + 2 * day, 3.25),
]
durations = {}
for name, mtime, duration in specs:
self._make_png(name, mtime)
durations[name] = duration
manager = AssetsRestoreManager()
queue = self._fake_queue()
server = self._server(queue)
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch(
"nodes.assets_restore.get_generation_time",
side_effect=lambda fn, sf="", mt=None: durations.get(fn),
), \
patch.object(manager, "_ingest_assets", return_value=0):
restored = manager.restore_on_launch(server, force=True)
self.assertEqual(restored, 3)
entries = list(queue.history.values())
self.assertEqual(
[entry["prompt"][0] for entry in entries],
[1, 2, 3],
"restored history must be numbered oldest to newest across days",
)
for entry, (name, mtime, duration) in zip(entries, specs):
mtime_ms = int(mtime * 1000)
duration_ms = int(duration * 1000)
self.assertEqual(entry["prompt"][3]["create_time"], mtime_ms, name)
messages = entry["status"]["messages"]
self.assertEqual([message[0] for message in messages], ["execution_start", "execution_success"])
self.assertEqual(messages[0][1]["timestamp"], mtime_ms - duration_ms, name)
self.assertEqual(messages[1][1]["timestamp"], mtime_ms, name)
self.assertEqual(entry["status"]["saturnnodes_duration"], duration, name)
self.assertEqual(
entry["prompt"][3]["extra_pnginfo"]["saturnnodes_generation_time"],
duration,
name,
)
self.assertEqual(
entry["outputs"]["0"]["images"][0]["filename"],
name,
)
def test_restore_recovers_from_corrupt_image(self):
path = os.path.join(self.output_dir, "broken.png")
with open(path, "wb") as handle:
handle.write(b"this is not a PNG")
os.utime(path, (1_700_000_000, 1_700_000_000))
manager = AssetsRestoreManager()
queue = self._fake_queue()
server = self._server(queue)
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_generation_time", return_value=None), \
patch.object(manager, "_ingest_assets", return_value=0):
restored = manager.restore_on_launch(server, force=True)
self.assertEqual(restored, 1)
entry = next(iter(queue.history.values()))
self.assertEqual(entry["prompt"][2], {}, "corrupt metadata must fall back to an empty prompt")
self.assertEqual(entry["outputs"]["0"]["images"][0]["filename"], "broken.png")
self.assertEqual(entry["status"]["status_str"], "success")
self.assertEqual(entry["status"]["messages"], [["execution_start", {"timestamp": 1_700_000_000_000}]])
def test_restore_handles_missing_mutex_and_bad_history(self):
self._make_png("image.png", 1_700_000_000)
manager = AssetsRestoreManager()
queue = self._fake_queue(history=None, mutex=False)
server = self._server(queue)
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_generation_time", return_value=None), \
patch.object(manager, "_ingest_assets", return_value=0):
restored = manager.restore_on_launch(server, force=True)
self.assertEqual(restored, 1)
self.assertIsInstance(queue.history, dict)
self.assertEqual(len(queue.history), 1)
def test_restore_survives_asset_db_ingest_failure(self):
self._make_png("image.png", 1_700_000_000)
manager = AssetsRestoreManager()
queue = self._fake_queue()
server = self._server(queue)
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_generation_time", return_value=None), \
patch.object(manager, "_ingest_assets", side_effect=RuntimeError("asset db unavailable")):
restored = manager.restore_on_launch(server, force=True)
self.assertEqual(restored, 1)
self.assertEqual(manager.last_debug_report["asset_db_registered"], 0)
self.assertEqual(manager.last_debug_report["status"], "success")
def test_restore_ignores_non_finite_or_negative_durations(self):
base = 1_700_000_000
for name in ("nan.png", "inf.png", "neg.png", "ok.png"):
self._make_png(name, base)
base += 1
durations = {
"nan.png": float("nan"),
"inf.png": float("inf"),
"neg.png": -2.5,
"ok.png": 4.0,
}
manager = AssetsRestoreManager()
queue = self._fake_queue()
server = self._server(queue)
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_generation_time", side_effect=lambda fn, sf="", mt=None: durations[fn]), \
patch.object(manager, "_ingest_assets", return_value=0):
restored = manager.restore_on_launch(server, force=True)
self.assertEqual(restored, 4)
entries = {
entry["outputs"]["0"]["images"][0]["filename"]: entry
for entry in queue.history.values()
}
for name in ("nan.png", "inf.png", "neg.png"):
self.assertEqual(len(entries[name]["status"]["messages"]), 1, name)
self.assertNotIn("saturnnodes_duration", entries[name]["status"], name)
self.assertNotIn(
"saturnnodes_generation_time",
entries[name]["prompt"][3]["extra_pnginfo"],
name,
)
self.assertEqual(len(entries["ok.png"]["status"]["messages"]), 2)
self.assertEqual(entries["ok.png"]["status"]["saturnnodes_duration"], 4.0)
def test_restore_disabled_marks_launch_and_skips_scan(self):
manager = AssetsRestoreManager()
queue = self._fake_queue()
server = self._server(queue)
with patch("nodes.assets_restore.is_assets_restore_enabled", return_value=False), \
patch.object(manager, "scan_output_images") as scan:
self.assertEqual(manager.restore_on_launch(server), 0)
self.assertEqual(manager.restore_on_launch(server), 0)
scan.assert_not_called()
self.assertTrue(manager._restored_on_launch)
def test_restore_runs_once_without_force(self):
self._make_png("image.png", 1_700_000_000)
manager = AssetsRestoreManager()
queue = self._fake_queue()
server = self._server(queue)
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_generation_time", return_value=None), \
patch.object(manager, "_ingest_assets", return_value=0), \
patch.object(manager, "scan_output_images", wraps=manager.scan_output_images) as scan:
self.assertEqual(manager.restore_on_launch(server), 1)
self.assertEqual(manager.restore_on_launch(server), 0)
self.assertEqual(scan.call_count, 1)
def test_restored_prompt_ids_are_stable_and_unique(self):
base = 1_700_000_000
self._make_png("same.png", base)
self._make_png("same.png", base + 1, subfolder="one")
self._make_png("same.png", base + 2, subfolder="two")
manager = AssetsRestoreManager()
first_queue = self._fake_queue()
second_queue = self._fake_queue()
for queue in (first_queue, second_queue):
with patch("nodes.assets_restore.folder_paths.get_output_directory", return_value=self.output_dir), \
patch("nodes.assets_restore.is_assets_restore_enabled", return_value=True), \
patch("nodes.assets_restore.is_recursive_assets_scan_enabled", return_value=True), \
patch("nodes.assets_restore.get_generation_time", return_value=None), \
patch.object(manager, "_ingest_assets", return_value=0):
manager.restore_on_launch(self._server(queue), force=True)
first_ids = set(first_queue.history)
second_ids = set(second_queue.history)
self.assertEqual(first_ids, second_ids)
self.assertEqual(len(first_ids), 3)
self.assertEqual(
first_ids,
{
str(uuid.uuid5(uuid.NAMESPACE_URL, f"saturnnodes:{key}"))
for key in ("same.png", "one/same.png", "two/same.png")
},
)
for entry in first_queue.history.values():
self.assertIn(entry["prompt"][1], first_ids)
def test_decode_exif_text_strips_bom_and_nul_padding(self):
unicode_comment = b"UNICODE\x00" + "\ufeffA castle\x00".encode("utf-16-be")
self.assertEqual(_decode_exif_text(unicode_comment), "A castle")
self.assertEqual(
_decode_exif_text(b"ASCII\x00\x00\x00" + b"A\x00B\x00"),
"AB",
)
self.assertEqual(_decode_exif_text("\ufeff padded \x00"), "padded")
self.assertEqual(_decode_exif_text(None), "")
self.assertEqual(_decode_exif_text(b""), "")
def test_parse_a1111_accepts_bytes_and_crlf(self):
raw = b"A cat\r\nNegative prompt: dog\r\nSteps: 20, Sampler: Euler"
self.assertEqual(parse_a1111_positive_prompt(raw), "A cat")
self.assertEqual(
parse_a1111_positive_prompt(b"Line one\r\nLine two\r\nSteps: 4"),
"Line one\nLine two",
)
self.assertEqual(parse_a1111_positive_prompt(None), "")
def test_generation_time_count_setting_clamps(self):
with patch("nodes.assets_restore.get_env_setting", return_value="0"):
self.assertEqual(get_assets_restore_count(), 1)
with patch("nodes.assets_restore.get_env_setting", return_value="100000"):
self.assertEqual(get_assets_restore_count(), 1000)
with patch("nodes.assets_restore.get_env_setting", return_value="not-a-number"):
self.assertEqual(get_assets_restore_count(), 64)
if __name__ == "__main__":
unittest.main()
+339
View File
@@ -0,0 +1,339 @@
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 { FakeMutationObserver, createDocument, stripImports } from "./fake_dom.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const sourcePath = path.join(repoRoot, "web", "batch_queue.js");
let source = stripImports(fs.readFileSync(sourcePath, "utf8"));
source += `
globalThis.__batchTest = {
updateQueueBatchVisuals,
normalizeBatchColor,
normalizeSegmentType,
buildSvgPath,
getBatchRegistry: () => batchRegistry,
};
`;
let clock = 1_000_000;
let timerId = 0;
const timers = [];
function fakeSetTimeout(callback, delay = 0) {
const timer = {
id: ++timerId,
at: clock + Math.max(0, Number(delay) || 0),
callback,
};
timers.push(timer);
return timer.id;
}
function fakeClearTimeout(id) {
const index = timers.findIndex(timer => timer.id === id);
if (index >= 0) timers.splice(index, 1);
}
function advance(ms) {
const end = clock + Math.max(0, Number(ms) || 0);
while (true) {
let nextIndex = -1;
for (let i = 0; i < timers.length; i++) {
if (timers[i].at <= end && (nextIndex < 0 || timers[i].at < timers[nextIndex].at)) {
nextIndex = i;
}
}
if (nextIndex < 0) break;
const [timer] = timers.splice(nextIndex, 1);
clock = Math.max(clock, timer.at);
timer.callback();
}
clock = end;
}
const document = createDocument();
const queueRoot = document.createElement("div");
queueRoot.setAttribute("data-testid", "job-assets-list");
document.body.appendChild(queueRoot);
const storage = new Map();
const localStorage = {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
storage.set(key, String(value));
},
};
function info(promptId, batchId, color, itemIndex, batchCount, queuedAt) {
return {
prompt_id: promptId,
batchId,
batchNumber: 1,
batchCount,
itemIndex,
color,
colorIndex: 0,
queuedAt,
};
}
const seeded = [];
for (let index = 0; index < 260; index++) {
seeded.push(info(`old-${index}`, `old-batch-${index}`, "#10b981", 0, 1, index + 1));
}
seeded.push(info("evil-a", "batch-evil", '#fff" onload="alert(1)', 0, 2, 1000));
seeded.push(info("evil-b", "batch-evil", "</style><script>alert(1)</script>", 1, 2, 1001));
seeded.push(info("bad-count", "batch-bad", "#123456", "not-an-int", -5, 1002));
seeded.push(info("evil-c", "batch-evil-c", '#fff" onload="alert(1)', 0, 1, 1003));
seeded.push(info("low-index", "batch-low-index", "#123456", -5, 3, 1004));
seeded.push(info("high-index", "batch-high-index", "#123456", 99, 3, 1005));
storage.set("saturnnodes_batch_queue_meta", JSON.stringify(seeded));
storage.set("saturnnodes_batch_counter", "-12");
const serverBatchData = {
// Older server data must not replace newer local metadata.
"old-0": info("old-0", "old-batch-0", "#10b981", 0, 1, 0),
// Newer server data must replace older local metadata.
"old-1": info("old-1", "old-batch-1", "#10b981", 0, 1, 9999),
"server-only": info("server-only", "server-batch", "#3b82f6", 0, 1, 2000),
};
const apiEvents = new Map();
const deferred = [];
let nextPid = 0;
const api = {
clientId: "client-test",
queuePrompt: () => new Promise(resolve => {
deferred.push(() => resolve({ prompt_id: `conc-${++nextPid}` }));
}),
fetchApi: async url => {
if (url === "/saturnnodes/batch_queue/data") {
return { ok: true, json: async () => serverBatchData };
}
return null;
},
addEventListener(name, callback) {
if (!apiEvents.has(name)) apiEvents.set(name, []);
apiEvents.get(name).push(callback);
},
};
const extension = { name: "", setup: null };
const app = {
extensionManager: { setting: { get: () => true } },
registerExtension(candidate) {
extension.name = candidate.name;
extension.setup = candidate.setup;
},
async queuePrompt(number, batchCount, queueNodeIds) {
// Both submissions start before either response arrives. This is the
// concurrency window that used to race on prompts.length.
const first = api.queuePrompt(number, { output: {} }, queueNodeIds);
const second = api.queuePrompt(number, { output: {} }, queueNodeIds);
return await Promise.all([first, second]);
},
};
const mathStub = Object.create(Math);
let randomSeed = 0;
mathStub.random = () => ((++randomSeed * 0.6180339887) % 1);
const context = vm.createContext({
console,
app,
api,
authenticatedFetch: async () => ({ ok: true }),
document,
localStorage,
MutationObserver: FakeMutationObserver,
setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout,
Date: { now: () => clock },
Math: mathStub,
JSON,
String,
Number,
Object,
Array,
Map,
Set,
Promise,
parseInt,
isNaN,
Infinity,
});
vm.runInContext(source, context, { filename: sourcePath });
const batchTest = context.__batchTest;
assert.equal(typeof extension.setup, "function", "batch queue extension must register");
await extension.setup();
await new Promise(resolve => setImmediate(resolve));
advance(100);
// ---------------------------------------------------------------------------
// Restored storage is pruned by queuedAt, not by Map insertion order.
// ---------------------------------------------------------------------------
const savedAfterLoad = JSON.parse(storage.get("saturnnodes_batch_queue_meta"));
assert.equal(savedAfterLoad.length, 250, "restored metadata must be pruned to 250 entries");
const savedIds = new Set(savedAfterLoad.map(entry => entry.prompt_id));
assert.equal(savedIds.has("old-0"), false, "oldest restored metadata must be pruned");
assert.equal(savedIds.has("old-259"), true, "newest restored metadata must be kept");
assert.equal(savedIds.has("evil-a"), true, "malicious-color entries must not be dropped");
assert.equal(savedIds.has("server-only"), true, "newer server metadata must be merged");
const registry = batchTest.getBatchRegistry();
assert.equal(registry.get("old-0").queuedAt, 1, "older server snapshot must not overwrite local metadata");
assert.equal(registry.get("old-1").queuedAt, 9999, "newer server snapshot must replace local metadata");
// ---------------------------------------------------------------------------
// Restored rows with injected color strings must render safe hex colors only.
// ---------------------------------------------------------------------------
function makeRow(promptId) {
const row = document.createElement("div");
row.setAttribute("data-job-id", promptId);
queueRoot.appendChild(row);
return row;
}
const evilA = makeRow("evil-a");
const evilB = makeRow("evil-b");
const badCount = makeRow("bad-count");
const lowIndex = makeRow("low-index");
const highIndex = makeRow("high-index");
const duplicateEvilA = makeRow("evil-a");
await batchTest.updateQueueBatchVisuals();
const evilALine = evilA.querySelector(".saturnnodes-batch-line-wrap");
const evilBLine = evilB.querySelector(".saturnnodes-batch-line-wrap");
assert.ok(evilALine, "restored row must receive a batch line");
assert.ok(evilBLine, "restored row must receive a batch line");
assert.match(evilALine.innerHTML, /#10b981/, "invalid color must fall back to the default hex color");
assert.doesNotMatch(evilALine.innerHTML, /alert|onload|<script/i, "color injection must never reach innerHTML");
assert.match(evilBLine.innerHTML, /#10b981/, "markup color must fall back to the default hex color");
assert.doesNotMatch(evilBLine.innerHTML, /alert|<script|<\/style/i, "markup color must never reach innerHTML");
assert.match(
evilALine.innerHTML,
/M 9,6 Q 2,6 2,16 L 2,48/,
"the first visible item of a restored batch must draw the first segment"
);
assert.match(
evilBLine.innerHTML,
/M 2,0 L 2,32 Q 2,42 9,42/,
"the last visible item of a restored batch must draw the last segment"
);
assert.equal(
duplicateEvilA.querySelector(".saturnnodes-batch-line-wrap"),
null,
"a duplicate restored row must not receive a second line"
);
const badLine = badCount.querySelector(".saturnnodes-batch-line-wrap");
assert.ok(badLine, "invalid batch counters must still render a line");
assert.match(badLine.innerHTML, /#123456/, "valid custom hex colors must be preserved");
assert.match(
badLine.innerHTML,
/M 9,6 Q 2,6 2,16 L 2,32 Q 2,42 9,42/,
"negative batchCount and non-integer itemIndex must clamp to a single segment"
);
const lowIndexLine = lowIndex.querySelector(".saturnnodes-batch-line-wrap");
assert.ok(lowIndexLine, "negative itemIndex must still render a line");
assert.match(
lowIndexLine.innerHTML,
/M 9,6 Q 2,6 2,16 L 2,48/,
"a negative itemIndex must clamp to the first segment"
);
const highIndexLine = highIndex.querySelector(".saturnnodes-batch-line-wrap");
assert.ok(highIndexLine, "out-of-range itemIndex must still render a line");
assert.match(
highIndexLine.innerHTML,
/M 2,0 L 2,32 Q 2,42 9,42/,
"an out-of-range itemIndex must clamp to the last segment"
);
// Direct sanitizer checks.
assert.equal(batchTest.normalizeBatchColor("#ABC"), "#ABC");
assert.equal(batchTest.normalizeBatchColor("#ABCD"), "#ABCD");
assert.equal(batchTest.normalizeBatchColor("#123456"), "#123456");
assert.equal(batchTest.normalizeBatchColor("#12345678"), "#12345678");
assert.equal(batchTest.normalizeBatchColor(" #10b981 "), "#10b981");
assert.equal(batchTest.normalizeBatchColor("#12345"), "#10b981");
assert.equal(batchTest.normalizeBatchColor("#1234567"), "#10b981");
assert.equal(batchTest.normalizeBatchColor("red"), "#10b981");
assert.equal(batchTest.normalizeBatchColor("url(javascript:alert(1))"), "#10b981");
assert.equal(batchTest.normalizeSegmentType("bogus"), "single");
assert.equal(batchTest.normalizeSegmentType('"><script>alert(1)</script>'), "single");
assert.equal(batchTest.buildSvgPath("bogus"), batchTest.buildSvgPath("single"));
// ---------------------------------------------------------------------------
// A negative persisted counter must clamp to a valid BATCH_COLORS index.
// ---------------------------------------------------------------------------
const submission = app.queuePrompt(-1, 2, null);
await Promise.resolve();
await Promise.resolve();
assert.equal(deferred.length, 2, "both concurrent submissions must reserve an item index");
deferred.shift()();
deferred.shift()();
await submission;
const afterBatch = JSON.parse(storage.get("saturnnodes_batch_queue_meta"));
const concOne = afterBatch.find(entry => entry.prompt_id === "conc-1");
const concTwo = afterBatch.find(entry => entry.prompt_id === "conc-2");
assert.ok(concOne && concTwo, "both concurrent prompts must be recorded");
assert.equal(concOne.batchId, concTwo.batchId, "concurrent prompts must share one batch");
assert.deepEqual(
[concOne.itemIndex, concTwo.itemIndex].sort((a, b) => a - b),
[0, 1],
"concurrent submissions must reserve unique item indexes"
);
assert.equal(concOne.color, "#10b981", "a negative persisted counter must clamp to color index 0");
assert.equal(concOne.colorIndex, 0);
assert.equal(concTwo.batchCount, 2);
// The completed batch must release the active context, so the next standalone
// submission starts a fresh single-item batch.
const standalone = api.queuePrompt(0, { output: {} }, null);
await Promise.resolve();
await Promise.resolve();
assert.equal(deferred.length, 1, "standalone submission must reach the queue hook");
deferred.shift()();
await standalone;
const afterStandalone = JSON.parse(storage.get("saturnnodes_batch_queue_meta"));
const concThree = afterStandalone.find(entry => entry.prompt_id === "conc-3");
assert.ok(concThree, "standalone prompt must be recorded");
assert.notEqual(concThree.batchId, concOne.batchId, "completed batch context must be cleared");
assert.equal(concThree.batchCount, 1);
assert.equal(concThree.color, "#3b82f6", "the next batch must advance the color counter");
// ---------------------------------------------------------------------------
// Reused restored cards repaint after Vue changes data-job-id.
// ---------------------------------------------------------------------------
const observer = FakeMutationObserver.instances.find(
item => item.target === document.body && item.options?.attributes
);
assert.ok(observer, "queue observer must watch attribute changes");
const reused = makeRow("old-259");
await batchTest.updateQueueBatchVisuals();
assert.ok(reused.querySelector(".saturnnodes-batch-line-wrap"), "reused card must get a line");
reused.setAttribute("data-job-id", "evil-c");
observer.emit([{ target: reused, type: "attributes", attributeName: "data-job-id" }]);
advance(100);
assert.match(
reused.querySelector(".saturnnodes-batch-line-wrap").innerHTML,
/#10b981/,
"reused card must repaint with the new job's safe batch color"
);
console.log("batch queue restore DOM regression test passed");
+309
View File
@@ -0,0 +1,309 @@
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";
// Regression coverage for the queue-path bleed reported in the LoRA loader
// audit follow-up:
//
// Queue prompt 1, then queue prompt 2 while prompt 1 is still being
// submitted, then edit the canvas. ComfyUI serializes prompt 2 only after
// prompt 1's submission turn finishes, so without a click-time snapshot the
// edited text/LoRA selection bleeds into prompt 2.
//
// The fake ComfyUI queue below mirrors the real `app.queuePrompt` loop: it
// pushes items, returns early while `processingQueue` is true, and calls
// `graphToPrompt()` freshly for every submitted item.
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const sourcePath = path.join(repoRoot, "web", "batch_queue.js");
class FakeStyleElement {
constructor() {
this.textContent = "";
this.id = "";
this.children = [];
}
remove() {}
appendChild(child) {
this.children.push(child);
return child;
}
querySelector() {
return null;
}
querySelectorAll() {
return [];
}
}
const document = {
body: new FakeStyleElement(),
head: new FakeStyleElement(),
getElementById() {
return null;
},
createElement() {
return new FakeStyleElement();
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
};
const storage = new Map();
const localStorage = {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
storage.set(key, String(value));
},
};
class FakeMutationObserver {
constructor(callback) {
this.callback = callback;
}
observe() {}
disconnect() {}
}
// --- Fake ComfyUI canvas + queue ---------------------------------------------
const canvas = { text: "", lora: "[]", seed: 1 };
const submissions = [];
const pendingResponses = [];
let graphToPromptCalls = 0;
let nextPromptId = 0;
async function graphToPrompt() {
graphToPromptCalls++;
// Read values synchronously, like the real serializer does.
return {
output: {
"10": {
inputs: {
text: canvas.text,
_selected_lora: canvas.lora,
seed: canvas.seed,
},
},
},
workflow: { nodes: [] },
};
}
async function apiQueuePrompt(number, prompt) {
// Copy the prompt exactly as it will be sent to the server.
submissions.push(JSON.parse(JSON.stringify(prompt)));
await new Promise(resolve => pendingResponses.push(resolve));
nextPromptId++;
return { prompt_id: `pid-${nextPromptId}` };
}
async function comfyQueuePrompt(number, batchCount = 1) {
this.queueItems.push({ number, batchCount });
if (this.processingQueue) return false;
this.processingQueue = true;
try {
while (this.queueItems.length) {
const item = this.queueItems.pop();
for (let r = 0; r < item.batchCount; r++) {
const prompt = await this.graphToPrompt(this.rootGraph);
await api.queuePrompt(item.number, prompt);
}
}
} finally {
this.processingQueue = false;
}
return true;
}
const extension = { setup: null };
const app = {
queuePrompt: comfyQueuePrompt,
graphToPrompt,
graph: { getNodeById: () => null },
queueItems: [],
processingQueue: false,
extensionManager: { setting: { get: () => true } },
registerExtension(candidate) {
extension.setup = candidate.setup;
},
setDirtyCanvas() {},
};
const api = {
queuePrompt: apiQueuePrompt,
fetchApi: async () => null,
addEventListener() {},
};
const source = fs
.readFileSync(sourcePath, "utf8")
.replace(/^import .*$/gm, "")
.replace(/export \{[^}]*\};?\s*$/m, "");
const context = vm.createContext({
console,
app,
api,
authenticatedFetch: async () => null,
document,
localStorage,
MutationObserver: FakeMutationObserver,
setTimeout: (callback) => setTimeout(callback, 0),
clearTimeout,
Date,
Math,
JSON,
String,
Number,
Object,
Array,
Map,
Set,
Promise,
parseInt,
isNaN,
Infinity,
});
vm.runInContext(source, context, { filename: sourcePath });
assert.equal(typeof extension.setup, "function", "batch queue extension must register");
await extension.setup();
const flush = async () => {
for (let i = 0; i < 50; i++) await Promise.resolve();
};
const resolveNext = async () => {
const resolve = pendingResponses.shift();
assert.ok(resolve, "expected a pending API submission");
resolve();
await flush();
};
const resetCanvas = (text, lora = "[]", seed = 1) => {
canvas.text = text;
canvas.lora = lora;
canvas.seed = seed;
};
const promptText = index => submissions[index].output["10"].inputs.text;
const promptLora = index => submissions[index].output["10"].inputs._selected_lora;
const promptSeed = index => submissions[index].output["10"].inputs.seed;
// --- Scenario 1: queued prompt must keep the text/LoRA shown at click time ---
resetCanvas("prompt one text", '["LoRA_A.safetensors"]');
graphToPromptCalls = 0;
// First click starts submission immediately (no extra snapshot needed).
const first = app.queuePrompt(0, 1);
await flush();
assert.equal(app.processingQueue, true, "first submission must be in flight");
assert.equal(graphToPromptCalls, 1, "an idle click must not serialize twice");
// The user immediately clicks Queue again, then keeps editing the canvas
// while the first prompt is still being submitted.
const second = app.queuePrompt(0, 1);
assert.equal(
graphToPromptCalls,
2,
"a queued click during an in-flight submission must snapshot the graph at click time"
);
canvas.text = "prompt two text"; // edited after both clicks
canvas.lora = '["LoRA_B.safetensors"]';
canvas.seed = 2;
await resolveNext(); // finish prompt one
await resolveNext(); // finish prompt two
await Promise.all([first, second]);
await flush();
assert.equal(submissions.length, 2, "both queued prompts must be submitted");
assert.equal(promptText(0), "prompt one text");
assert.equal(promptLora(0), '["LoRA_A.safetensors"]');
assert.equal(
promptText(1),
"prompt one text",
"canvas text edited after the click must not bleed into the queued prompt"
);
assert.equal(
promptLora(1),
'["LoRA_A.safetensors"]',
"LoRA selection edited after the click must not bleed into the queued prompt"
);
// --- Scenario 2: a multi-item batch stays frozen at click time --------------
submissions.length = 0;
resetCanvas("batch base text", '["LoRA_C.safetensors"]', 3);
app.queueItems.length = 0;
const batch = app.queuePrompt(0, 3);
await flush(); // item 0 in flight
canvas.text = "edited mid batch"; // must not leak into items 2/3
canvas.lora = '["LoRA_D.safetensors"]';
canvas.seed = 99;
await resolveNext(); // item 0
await resolveNext(); // item 1
await resolveNext(); // item 2
await batch;
await flush();
assert.equal(submissions.length, 3);
for (let i = 0; i < 3; i++) {
assert.equal(promptText(i), "batch base text", `batch item ${i + 1} text must stay frozen`);
assert.equal(
promptLora(i),
'["LoRA_C.safetensors"]',
`batch item ${i + 1} LoRA must stay frozen`
);
}
// --- Scenario 3: dynamic seeds still follow the live prompt -----------------
submissions.length = 0;
resetCanvas("blocker", "[]", 10);
const blocker = app.queuePrompt(0, 1);
await flush(); // blocker in flight so the next click has a submission backlog
resetCanvas("seed batch", "[]", 11);
const seeded = app.queuePrompt(0, 2);
await flush();
canvas.seed = 12; // simulates the seed widget randomizing before submission
await resolveNext(); // blocker
await resolveNext(); // seeded item 0
await resolveNext(); // seeded item 1
await blocker;
await seeded;
await flush();
assert.equal(submissions.length, 3);
assert.equal(promptSeed(1), 12, "item 0 must use the seed the live prompt queued");
assert.equal(promptSeed(2), 12, "item 1 must use the live seed after merging");
assert.equal(promptText(1), "seed batch");
assert.equal(promptText(2), "seed batch");
// --- Scenario 4: idle clicks keep serializing once and never double-queue ---
submissions.length = 0;
resetCanvas("solo", "[]", 20);
const solo = app.queuePrompt(0, 1);
await flush();
assert.equal(graphToPromptCalls > 0, true);
await resolveNext();
await solo;
assert.equal(submissions.length, 1);
assert.equal(promptText(0), "solo");
console.log("batch queue click-time snapshot DOM test passed");
+710
View File
@@ -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()
+834
View File
@@ -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");
+212
View File
@@ -0,0 +1,212 @@
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 {
FakeMutationObserver,
createDocument,
createWindow,
stripImports,
} from "./fake_dom.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const sourcePath = path.join(repoRoot, "web", "js", "copy_prompt.js");
const document = createDocument();
const window = createWindow(document);
const extension = { name: "", beforeRegisterNodeDef: null };
const app = {
registerExtension(candidate) {
extension.name = candidate.name;
extension.beforeRegisterNodeDef = candidate.beforeRegisterNodeDef;
},
extensionManager: {
setting: { get: () => true },
toast: { add() {} },
},
ui: { settings: { get: () => undefined, getSettingValue: () => undefined } },
graph: { setDirtyCanvas() {} },
};
let source = stripImports(fs.readFileSync(sourcePath, "utf8"));
source += `
globalThis.__copyTest = {
injectCopyPromptNextToDownload,
injectContextMenuCopy,
injectHoverCopyAction,
registerAssetInteraction,
getActiveImageSrc,
scanAndInject,
setCopyImagePrompt: (fn) => { copyImagePrompt = fn; },
};
`;
const copied = [];
const context = vm.createContext({
console,
document,
window,
navigator: {},
app,
api: { addEventListener() {}, fetchApi: async () => null },
MutationObserver: FakeMutationObserver,
setTimeout: () => 0,
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
Math,
JSON,
String,
Number,
Object,
Array,
Map,
Set,
Promise,
Date,
parseInt,
isNaN,
Infinity,
Node: { ELEMENT_NODE: 1 },
MouseEvent: class MouseEvent {
constructor(type, init = {}) {
this.type = type;
this.bubbles = !!init.bubbles;
this.cancelable = !!init.cancelable;
}
},
});
vm.runInContext(source, context, { filename: sourcePath });
assert.equal(typeof extension.beforeRegisterNodeDef, "function", "copy prompt extension must register");
const copyTest = context.__copyTest;
copyTest.setCopyImagePrompt(async src => {
copied.push(src);
return true;
});
const observer = FakeMutationObserver.instances.find(
item => item.target === document.body && item.options?.childList
);
assert.ok(observer, "copy prompt extension must observe restored DOM insertions");
function makeCard(assetId, src, { queue = false, icon = false, attach = true } = {}) {
const card = document.createElement("div");
card.className = "asset-card";
if (assetId) card.setAttribute("data-asset-id", assetId);
if (queue) card.setAttribute("data-job-id", "job-1");
const img = document.createElement("img");
img.src = src;
if (icon) img.classList.add("size-8");
card.appendChild(img);
const bar = document.createElement("div");
const download = document.createElement("button");
download.setAttribute("aria-label", "Download");
bar.appendChild(download);
card.appendChild(bar);
if (attach) document.body.appendChild(card);
return { card, img, bar, download };
}
function insertRestored(card) {
observer.emit([{ type: "childList", target: document.body, addedNodes: [card], removedNodes: [] }]);
}
// 1. A restored asset card inserted after page load receives a copy button
// through the MutationObserver path (not only through the periodic scan).
const restored = makeCard("restored-1", "restored.png", { attach: false });
insertRestored(restored.card);
const restoredButton = restored.bar.querySelector(".saturnnodes-hover-copy");
assert.ok(restoredButton, "restored card must receive a copy button");
await restoredButton.onclick({ preventDefault() {}, stopPropagation() {} });
assert.deepEqual(copied, ["restored.png"], "restored copy button must copy the restored image");
// 2. Restored queue rows must never receive asset-card buttons.
const queueRow = makeCard("queue-1", "queued.png", { queue: true, attach: false });
insertRestored(queueRow.card);
assert.equal(
queueRow.bar.querySelector(".saturnnodes-hover-copy"),
null,
"restored queue rows must not receive asset copy buttons"
);
// 3. Tiny icon images (download/status icons) must not be treated as assets.
const iconCard = makeCard("icon-1", "icon.png", { icon: true, attach: false });
insertRestored(iconCard.card);
assert.equal(
iconCard.bar.querySelector(".saturnnodes-hover-copy"),
null,
"icon-sized images must not receive asset copy buttons"
);
// 4. A restored card reused for another asset must copy the asset currently
// displayed, not the one captured when the button was injected.
const recycled = makeCard("recycled-1", "first.png", { attach: false });
insertRestored(recycled.card);
const recycledButton = recycled.bar.querySelector(".saturnnodes-hover-copy");
assert.ok(recycledButton, "recycled card must start with a copy button");
recycled.card.setAttribute("data-asset-id", "recycled-2");
recycled.img.src = "second.png";
await recycledButton.onclick({ preventDefault() {}, stopPropagation() {} });
assert.deepEqual(copied, ["restored.png", "second.png"], "reused button must copy the live image");
// 5. Re-injecting for the same restored card must not duplicate the button.
copyTest.injectCopyPromptNextToDownload(recycled.download);
assert.equal(
recycled.bar.querySelectorAll(".saturnnodes-hover-copy").length,
1,
"restored card must keep exactly one copy button"
);
// 6. Context menus attached to restored cards inject one copy item and copy
// the currently interacted asset.
const menuCard = makeCard("menu-1", "menu-image.png");
copyTest.registerAssetInteraction(menuCard.img);
const menu = document.createElement("div");
menu.className = "p-contextmenu";
const downloadItem = document.createElement("li");
downloadItem.setAttribute("role", "menuitem");
downloadItem.setAttribute("aria-label", "Download");
const downloadButton = document.createElement("button");
downloadItem.appendChild(downloadButton);
menu.appendChild(downloadItem);
document.body.appendChild(menu);
copyTest.injectContextMenuCopy(menu);
const copyItem = menu.querySelector(".saturnnodes-contextmenu-copy");
assert.ok(copyItem, "restored context menu must receive a copy item");
assert.equal(
menu.querySelectorAll(".saturnnodes-contextmenu-copy").length,
1,
"context menu injection must be idempotent"
);
const beforeMenuClick = copied.length;
copyItem.dispatchEvent({ type: "click", preventDefault() {}, stopPropagation() {} });
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(copied.slice(beforeMenuClick), ["menu-image.png"], "menu must copy the active asset");
// 7. A restored card without an asset id but with a stable image URL still
// resolves its image for menu actions.
const srcCard = makeCard(null, "src-only.png");
copyTest.registerAssetInteraction(srcCard.img);
assert.equal(copyTest.getActiveImageSrc(), "src-only.png");
// 8. The periodic scanner must inject into restored cards even when no
// mutation was observed for them (e.g. restored before the observer ran).
const scanned = makeCard("scanned-1", "scanned.png");
copyTest.scanAndInject();
assert.ok(
scanned.bar.querySelector(".saturnnodes-hover-copy"),
"periodic scan must cover restored cards"
);
console.log("copy prompt restore DOM regression test passed");
+92
View File
@@ -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");
+6
View File
@@ -7,9 +7,15 @@ import unittest
DOM_TESTS = [
"test_power_popup_dom.mjs",
"test_copy_prompt_dom.mjs",
"test_copy_prompt_restore_dom.mjs",
"test_floating_dock_dom.mjs",
"test_widget_guards_dom.mjs",
"test_queue_height_dom.mjs",
"test_batch_queue_dom.mjs",
"test_batch_queue_snapshot_dom.mjs",
"test_batch_queue_restore_dom.mjs",
"test_persistent_queue_claim_dom.mjs",
"test_character_presets_dom.mjs",
]
+46
View File
@@ -0,0 +1,46 @@
import os
import re
import unittest
WEB_ROOT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web"
)
# ComfyUI's settings API warns every time getSettingValue() receives a default
# value as its second argument. Settings definitions already carry defaults, so
# the argument is redundant and floods the browser console when called per item.
DEPRECATED_SECOND_ARGUMENT = re.compile(
r"""getSettingValue\s*\??\.?\s*\(\s*(?:"[^"]*"|'[^']*'|`[^`]*`)\s*,"""
)
class TestFrontendSettingsApi(unittest.TestCase):
def test_no_deprecated_default_value_argument(self):
offenders = []
for root, _dirs, files in os.walk(WEB_ROOT):
for name in sorted(files):
if not name.endswith(".js"):
continue
path = os.path.join(root, name)
with open(path, encoding="utf-8") as handle:
source = handle.read()
for match in DEPRECATED_SECOND_ARGUMENT.finditer(source):
line_number = source.count("\n", 0, match.start()) + 1
relative = os.path.relpath(path, WEB_ROOT)
offenders.append(f"{relative}:{line_number}")
self.assertEqual(
offenders,
[],
msg=(
"getSettingValue(id, defaultValue) is deprecated in ComfyUI and "
"logs a console warning on every call. Read the setting default "
"from its definition instead: "
+ ", ".join(offenders)
),
)
if __name__ == "__main__":
unittest.main()
+239
View File
@@ -0,0 +1,239 @@
import json
import math
import os
import shutil
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper # noqa: F401 (redirects SaturnNodes state to a temp dir)
import nodes.generation_times as generation_times
class TestGenerationTimesExtended(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.old_file = generation_times.GENERATION_TIMES_FILE
self.old_user_dir = generation_times.USER_DIR
self.old_data = generation_times._DATA
self.old_mtime = generation_times._DATA_MTIME
self.old_patched = generation_times._PATCHED
self.old_starts = dict(generation_times._START_TIMES)
generation_times.GENERATION_TIMES_FILE = os.path.join(self.temp_dir, "generation_times.json")
generation_times.USER_DIR = self.temp_dir
generation_times._DATA = None
generation_times._DATA_MTIME = None
generation_times._PATCHED = False
generation_times._START_TIMES.clear()
def tearDown(self):
generation_times.GENERATION_TIMES_FILE = self.old_file
generation_times.USER_DIR = self.old_user_dir
generation_times._DATA = self.old_data
generation_times._DATA_MTIME = self.old_mtime
generation_times._PATCHED = self.old_patched
generation_times._START_TIMES.clear()
generation_times._START_TIMES.update(self.old_starts)
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _write_payload(self, payload):
with open(generation_times.GENERATION_TIMES_FILE, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
generation_times._DATA = None
generation_times._DATA_MTIME = None
def test_rejects_non_finite_negative_and_non_numeric_durations(self):
bad_values = [float("nan"), float("inf"), float("-inf"), -0.001, "not-a-number", None, object()]
for index, value in enumerate(bad_values):
generation_times.record_generation_time(
f"pid-{index}",
value,
[{"filename": f"bad{index}.png", "subfolder": ""}],
)
self.assertFalse(
os.path.exists(generation_times.GENERATION_TIMES_FILE),
"invalid durations must not create a generation-times file",
)
for index in range(len(bad_values)):
self.assertIsNone(generation_times.get_generation_time(f"bad{index}.png"))
def test_rejects_missing_or_malformed_file_entries(self):
generation_times.record_generation_time("pid", 2.0, None)
generation_times.record_generation_time("pid", 2.0, [])
generation_times.record_generation_time("pid", 2.0, [None, "x", {}, {"filename": ""}])
self.assertIsNone(generation_times.get_generation_time(""))
generation_times.record_generation_time("pid", 2.0, [{"filename": "valid.png"}])
self.assertEqual(generation_times.get_generation_time("valid.png"), 2.0)
def test_rounds_and_overwrites_duplicate_file_keys(self):
generation_times.record_generation_time(
"pid-1", 1.23456, [{"filename": "same.png", "subfolder": "sub"}]
)
self.assertEqual(generation_times.get_generation_time("same.png", "sub"), 1.235)
generation_times.record_generation_time(
"pid-2", 9.0, [{"filename": "same.png", "subfolder": "sub"}]
)
with open(generation_times.GENERATION_TIMES_FILE, "r", encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(len(payload["entries"]), 1)
self.assertEqual(payload["entries"]["sub/same.png"]["duration"], 9.0)
self.assertEqual(payload["entries"]["sub/same.png"]["prompt_id"], "pid-2")
def test_pruning_keeps_the_newest_entries(self):
times = iter([1000.0, 1001.0, 1002.0, 1003.0, 1004.0])
with patch.object(generation_times, "MAX_ENTRIES", 3), \
patch("nodes.generation_times.time.time", side_effect=lambda: next(times)):
for index in range(5):
generation_times.record_generation_time(
f"pid-{index}",
float(index),
[{"filename": f"file{index}.png", "subfolder": ""}],
)
with open(generation_times.GENERATION_TIMES_FILE, "r", encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(
set(payload["entries"]),
{"file2.png", "file3.png", "file4.png"},
)
def test_entry_timestamp_handles_malformed_values(self):
self.assertEqual(generation_times._entry_timestamp(None), 0)
self.assertEqual(generation_times._entry_timestamp("x"), 0)
self.assertEqual(generation_times._entry_timestamp({}), 0)
self.assertEqual(generation_times._entry_timestamp({"completed": "bad"}), 0)
self.assertEqual(generation_times._entry_timestamp({"completed": -10}), 0)
self.assertEqual(generation_times._entry_timestamp({"completed": float("inf")}), 0)
self.assertEqual(generation_times._entry_timestamp({"completed": "1234"}), 1234)
def test_get_generation_time_rejects_corrupt_stored_durations(self):
self._write_payload({
"version": 1,
"entries": {
"nan.png": {"duration": float("nan"), "completed": 1},
"inf.png": {"duration": float("inf"), "completed": 1},
"neg.png": {"duration": -4, "completed": 1},
"text.png": {"duration": "fast", "completed": 1},
"ok.png": {"duration": "1.5", "completed": 1},
},
})
for name in ("nan.png", "inf.png", "neg.png", "text.png"):
self.assertIsNone(generation_times.get_generation_time(name), name)
self.assertEqual(generation_times.get_generation_time("ok.png"), 1.5)
def test_load_data_recovers_from_corrupt_or_legacy_payloads(self):
with open(generation_times.GENERATION_TIMES_FILE, "w", encoding="utf-8") as handle:
handle.write("{ this is not json")
data = generation_times._load_data()
self.assertEqual(data, {"version": 1, "entries": {}})
self._write_payload({"entries": {"legacy.png": {"duration": 2.0, "completed": 5}}})
data = generation_times._load_data()
self.assertEqual(data["version"], 1)
self.assertEqual(generation_times.get_generation_time("legacy.png"), 2.0)
def test_corrupt_entries_are_dropped_on_load_and_save(self):
self._write_payload({
"version": 1,
"entries": {
"nan.png": {"duration": float("nan"), "completed": 1},
"inf.png": {"duration": float("inf"), "completed": 1},
"neg.png": {"duration": -4, "completed": 1},
"text.png": {"duration": "fast", "completed": 1},
"ok.png": {"duration": "1.5", "completed": 1},
"nan-completed.png": {"duration": 2.0, "completed": float("nan")},
},
})
data = generation_times._load_data()
self.assertEqual(set(data["entries"]), {"ok.png", "nan-completed.png"})
self.assertEqual(data["entries"]["ok.png"]["duration"], 1.5)
self.assertEqual(data["entries"]["nan-completed.png"]["completed"], 0)
# A corrupt entry must not make every future save fail under
# allow_nan=False. The valid entry and the new one must persist.
generation_times.record_generation_time(
"pid", 3.0, [{"filename": "new.png", "subfolder": ""}]
)
with open(generation_times.GENERATION_TIMES_FILE, "r", encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(
set(payload["entries"]),
{"ok.png", "nan-completed.png", "new.png"},
)
self.assertEqual(payload["entries"]["new.png"]["duration"], 3.0)
self.assertEqual(payload["entries"]["nan-completed.png"]["completed"], 0)
def test_save_retries_windows_replace_lock_and_cleans_temp_file(self):
real_replace = os.replace
attempts = {"count": 0}
def flaky_replace(src, dst):
attempts["count"] += 1
if attempts["count"] < 3:
raise PermissionError("file is locked")
return real_replace(src, dst)
with patch("nodes.generation_times.os.replace", side_effect=flaky_replace), \
patch("nodes.generation_times.time.sleep"):
generation_times._save_data({"version": 1, "entries": {"a.png": {"duration": 1}}})
self.assertEqual(attempts["count"], 3)
self.assertTrue(os.path.exists(generation_times.GENERATION_TIMES_FILE))
self.assertEqual(
[name for name in os.listdir(self.temp_dir) if name.endswith(".tmp")],
[],
)
def test_save_failure_does_not_leave_temp_file_behind(self):
with patch("nodes.generation_times.os.replace", side_effect=PermissionError("locked")), \
patch("nodes.generation_times.time.sleep"):
generation_times._save_data({"version": 1, "entries": {}})
self.assertEqual(
[name for name in os.listdir(self.temp_dir) if name.endswith(".tmp")],
[],
)
def test_moved_file_fallback_handles_invalid_mtime(self):
now = 1_700_000_000.0
with patch("nodes.generation_times.time.time", return_value=now):
generation_times.record_generation_time(
"pid", 6.0, [{"filename": "moved.png", "subfolder": "old"}]
)
# A non-numeric mtime must not crash the fallback path.
self.assertEqual(
generation_times.get_generation_time("moved.png", "new", "not-a-number"),
6.0,
)
def test_extract_output_files_ignores_malformed_history(self):
self.assertEqual(generation_times._extract_output_files(None), [])
self.assertEqual(generation_times._extract_output_files({}), [])
self.assertEqual(
generation_times._extract_output_files({"outputs": {"1": {"images": "bad"}}}),
[],
)
files = generation_times._extract_output_files({
"outputs": {
"1": {"images": [
None,
{"filename": ""},
{"filename": "temp.png", "type": "temp"},
{"filename": "saved.png", "subfolder": "sub"},
]},
"2": "bad",
}
})
self.assertEqual(files, [{"filename": "saved.png", "subfolder": "sub", "type": "output"}])
if __name__ == "__main__":
unittest.main()
+200
View File
@@ -0,0 +1,200 @@
import json
import os
import shutil
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper # noqa: F401
from PIL import Image, PngImagePlugin
import nodes.image_loader as image_loader
from nodes.image_loader import (
decode_bytes_safely,
parse_positive_from_parameters,
scan_image_folder,
trace_positive_prompt_from_graph,
)
class TestImageLoaderExtended(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.cache_file = os.path.join(self.temp_dir, "image_prompts_cache.json")
self.old_cache = image_loader.CACHE_FILE
image_loader.CACHE_FILE = self.cache_file
def tearDown(self):
image_loader.CACHE_FILE = self.old_cache
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _make_png(self, name, mtime, subfolder="", prompt_graph=None, parameters=None):
folder = os.path.join(self.temp_dir, subfolder) if subfolder else self.temp_dir
os.makedirs(folder, exist_ok=True)
path = os.path.join(folder, name)
info = PngImagePlugin.PngInfo()
if prompt_graph is not None:
info.add_text("prompt", json.dumps(prompt_graph))
if parameters is not None:
info.add_text("parameters", parameters)
Image.new("RGB", (6, 6), (30, 40, 50)).save(path, pnginfo=info)
os.utime(path, (mtime, mtime))
return path
def test_equal_mtimes_are_sorted_deterministically(self):
base = 1_700_000_000
self._make_png("b.png", base)
self._make_png("a.png", base)
self._make_png("c.png", base, subfolder="sub")
self._make_png("a.png", base, subfolder="sub")
names, mapping, prompts = scan_image_folder(self.temp_dir, metadata_limit=0)
expected = ["a.png", "b.png", "sub/a.png", "sub/c.png"]
self.assertEqual(names, expected)
self.assertEqual(list(mapping), expected)
self.assertEqual(list(prompts), expected)
self.assertTrue(all(value == "" for value in prompts.values()))
def test_newest_images_come_first_across_subfolders(self):
base = 1_700_000_000
self._make_png("old.png", base)
self._make_png("new.png", base + 50, subfolder="nested")
names, _, _ = scan_image_folder(self.temp_dir, metadata_limit=0)
self.assertEqual(names, ["nested/new.png", "old.png"])
def test_metadata_limit_zero_skips_extraction(self):
self._make_png("one.png", 1_700_000_000)
with patch("nodes.image_loader.extract_metadata_from_image") as extract:
names, _, prompts = scan_image_folder(self.temp_dir, metadata_limit=0)
extract.assert_not_called()
self.assertEqual(names, ["one.png"])
self.assertEqual(prompts, {"one.png": ""})
def test_metadata_limit_only_reads_newest_entries(self):
base = 1_700_000_000
self._make_png("older.png", base)
self._make_png("newer.png", base + 10)
with patch(
"nodes.image_loader.extract_metadata_from_image",
side_effect=lambda path: ("prompt:" + os.path.basename(path), 6, 6),
) as extract:
names, _, prompts = scan_image_folder(self.temp_dir, metadata_limit=1)
self.assertEqual(names, ["newer.png", "older.png"])
self.assertEqual(extract.call_count, 1)
self.assertEqual(prompts["newer.png"], "prompt:newer.png")
self.assertEqual(prompts["older.png"], "")
def test_metadata_cache_is_reused_and_invalidated_by_mtime(self):
path = self._make_png("cached.png", 1_700_000_000)
with patch(
"nodes.image_loader.extract_metadata_from_image",
return_value=("cached prompt", 6, 6),
) as extract:
scan_image_folder(self.temp_dir, metadata_limit=10)
scan_image_folder(self.temp_dir, metadata_limit=10)
self.assertEqual(extract.call_count, 1)
os.utime(path, (1_700_000_100, 1_700_000_100))
scan_image_folder(self.temp_dir, metadata_limit=10)
self.assertEqual(extract.call_count, 2)
def test_png_prompt_metadata_is_extracted(self):
graph = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["2", 0], "negative": ["3", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "a beautiful castle"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "ugly"}},
}
self._make_png("graph.png", 1_700_000_000, prompt_graph=graph)
names, _, prompts = scan_image_folder(self.temp_dir, metadata_limit=10)
self.assertEqual(names, ["graph.png"])
self.assertEqual(prompts["graph.png"], "a beautiful castle")
def test_corrupt_image_does_not_break_the_scan(self):
path = os.path.join(self.temp_dir, "broken.png")
with open(path, "wb") as handle:
handle.write(b"not a png")
os.utime(path, (1_700_000_000, 1_700_000_000))
names, _, prompts = scan_image_folder(self.temp_dir, metadata_limit=10)
self.assertEqual(names, ["broken.png"])
self.assertEqual(prompts["broken.png"], "")
def test_non_image_files_are_ignored(self):
with open(os.path.join(self.temp_dir, "notes.txt"), "w", encoding="utf-8") as handle:
handle.write("not an image")
names, _, prompts = scan_image_folder(self.temp_dir, metadata_limit=10)
self.assertEqual(names, [])
self.assertEqual(prompts, {})
def test_corrupt_cache_file_is_ignored_and_replaced(self):
self._make_png("image.png", 1_700_000_000)
with open(self.cache_file, "w", encoding="utf-8") as handle:
handle.write("{ broken json")
names, _, _ = scan_image_folder(self.temp_dir, metadata_limit=10)
self.assertEqual(names, ["image.png"])
with open(self.cache_file, "r", encoding="utf-8") as handle:
payload = json.load(handle)
self.assertIsInstance(payload, dict)
self.assertEqual(len(payload), 1)
def test_cache_is_pruned_to_256_entries(self):
self._make_png("image.png", 1_700_000_000)
stale = {
os.path.join(self.temp_dir, f"stale-{index}.png"): {
"mtime": 1,
"prompt": f"old-{index}",
}
for index in range(300)
}
with open(self.cache_file, "w", encoding="utf-8") as handle:
json.dump(stale, handle)
scan_image_folder(self.temp_dir, metadata_limit=10)
with open(self.cache_file, "r", encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(len(payload), 256)
self.assertIn(os.path.join(self.temp_dir, "image.png"), payload)
def test_byte_parameters_and_positive_parser(self):
self.assertEqual(
parse_positive_from_parameters(b"A cat\r\nNegative prompt: dog\r\nSteps: 20"),
"A cat",
)
self.assertEqual(parse_positive_from_parameters(None), "")
self.assertEqual(parse_positive_from_parameters("Line one\nSteps: 2"), "Line one")
self.assertEqual(decode_bytes_safely(b"plain"), "plain")
self.assertEqual(decode_bytes_safely(None), "")
def test_graph_tracing_handles_cycles_and_ignores_negative_links(self):
graph = {
"1": {
"class_type": "KSampler",
"inputs": {"positive": ["2", 0], "negative": ["4", 0]},
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello", "positive": ["3", 0]},
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "world", "positive": ["2", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "negative text"}},
}
self.assertEqual(trace_positive_prompt_from_graph(graph), "hello\nworld")
fallback = {"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "fallback text"}}}
self.assertEqual(trace_positive_prompt_from_graph(fallback), "fallback text")
self.assertEqual(trace_positive_prompt_from_graph(None), "")
self.assertEqual(trace_positive_prompt_from_graph({}), "")
if __name__ == "__main__":
unittest.main()
+144
View File
@@ -0,0 +1,144 @@
import os
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper # noqa: F401
import folder_paths
import numpy as np
import torch
from PIL import Image
from nodes.load_recent import LoadRecentOutputs
class TestLoadRecentExtended(unittest.TestCase):
def setUp(self):
self.node = LoadRecentOutputs()
out_dir = folder_paths.get_output_directory()
os.makedirs(out_dir, exist_ok=True)
self.temp_dir = tempfile.mkdtemp(dir=out_dir)
self.rel_dir = os.path.relpath(self.temp_dir, out_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _make_image(self, name, color, mtime):
path = os.path.join(self.temp_dir, name)
Image.new("RGB", (4, 4), color=color).save(path)
os.utime(path, (mtime, mtime))
return path
def _load(self, amount=5, index=0, order="Newest First (0 = latest)", folder=None):
tensor, = self.node.load_single_image(
output_folder=self.rel_dir if folder is None else folder,
amount=amount,
index=index,
order=order,
)
return tensor
def test_equal_mtimes_have_a_deterministic_tiebreak(self):
base = 1_700_000_000
self._make_image("z.png", "red", base)
self._make_image("a.png", "green", base)
self._make_image("m.png", "blue", base)
# Newest-first with equal mtimes sorts by full path descending.
self.assertEqual(tuple(self._load(amount=3, index=0).shape), (1, 4, 4, 3))
colors = [self._load(amount=3, index=i)[0, 0, 0].tolist() for i in range(3)]
self.assertEqual(colors[0][0], 1.0) # z.png red
self.assertEqual(colors[1][2], 1.0) # m.png blue
self.assertAlmostEqual(colors[2][1], 0.5019608, places=5) # a.png green
def test_chronological_order_reverses_the_same_set(self):
base = 1_700_000_000
self._make_image("oldest.png", "red", base)
self._make_image("middle.png", "green", base + 10)
self._make_image("newest.png", "blue", base + 20)
newest_first = [self._load(amount=3, index=i)[0, 0, 0].tolist() for i in range(3)]
oldest_first = [
self._load(amount=3, index=i, order="Chronological (Oldest First)")[0, 0, 0].tolist()
for i in range(3)
]
self.assertEqual(oldest_first, list(reversed(newest_first)))
def test_index_wraps_and_negative_index_clamps_to_zero(self):
base = 1_700_000_000
self._make_image("older.png", "red", base)
self._make_image("newer.png", "blue", base + 10)
wrapped = self._load(amount=2, index=3)[0, 0, 0].tolist()
self.assertEqual(wrapped[0], 1.0, "index 3 must wrap to the second of two images")
clamped = self._load(amount=2, index=-5)[0, 0, 0].tolist()
self.assertEqual(clamped[2], 1.0, "a negative index must clamp to the newest image")
def test_invalid_amount_and_index_fall_back_safely(self):
base = 1_700_000_000
self._make_image("older.png", "red", base)
self._make_image("newer.png", "blue", base + 10)
tensor = self._load(amount="not-a-number", index="not-a-number")
self.assertEqual(tuple(tensor.shape), (1, 4, 4, 3))
self.assertEqual(tensor[0, 0, 0].tolist()[2], 1.0)
tensor = self._load(amount=0, index=0)
self.assertEqual(tensor[0, 0, 0].tolist()[2], 1.0)
def test_amount_larger_than_available_still_wraps(self):
base = 1_700_000_000
self._make_image("only.png", "green", base)
tensor = self._load(amount=100, index=7)
self.assertEqual(tuple(tensor.shape), (1, 4, 4, 3))
self.assertAlmostEqual(float(tensor[0, 0, 0, 1]), 0.5019608, places=5)
def test_missing_or_empty_folder_returns_dummy(self):
missing = os.path.join(self.temp_dir, "does-not-exist")
tensor = self._load(folder=os.path.relpath(missing, folder_paths.get_output_directory()))
self.assertEqual(tuple(tensor.shape), (1, 512, 512, 3))
self.assertEqual(tensor.dtype, torch.float32)
self.assertEqual(float(tensor.sum()), 0.0)
empty = os.path.join(self.temp_dir, "empty")
os.makedirs(empty)
tensor = self._load(folder=os.path.relpath(empty, folder_paths.get_output_directory()))
self.assertEqual(tuple(tensor.shape), (1, 512, 512, 3))
self.assertEqual(float(tensor.sum()), 0.0)
def test_corrupt_image_returns_dummy_instead_of_raising(self):
path = os.path.join(self.temp_dir, "broken.png")
with open(path, "wb") as handle:
handle.write(b"this is not a PNG")
os.utime(path, (1_700_000_000, 1_700_000_000))
tensor = self._load(amount=1, index=0)
self.assertEqual(tuple(tensor.shape), (1, 512, 512, 3))
self.assertEqual(float(tensor.sum()), 0.0)
def test_unsupported_extensions_are_ignored(self):
with open(os.path.join(self.temp_dir, "notes.txt"), "w", encoding="utf-8") as handle:
handle.write("not an image")
tensor = self._load(amount=5, index=0)
self.assertEqual(tuple(tensor.shape), (1, 512, 512, 3))
self.assertEqual(float(tensor.sum()), 0.0)
def test_returned_tensor_is_float_rgb_in_zero_one_range(self):
path = os.path.join(self.temp_dir, "solid.png")
Image.new("RGB", (3, 2), color=(255, 0, 0)).save(path)
os.utime(path, (1_700_000_000, 1_700_000_000))
tensor = self._load(amount=1, index=0)
self.assertEqual(tuple(tensor.shape), (1, 2, 3, 3))
self.assertEqual(tensor.dtype, torch.float32)
self.assertEqual(tensor[0, :, :, 0].tolist(), [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]])
self.assertEqual(tensor[0, :, :, 1].tolist(), [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -3,6 +3,7 @@ import sys
import json
import time
import shutil
import inspect
import tempfile
import unittest
from unittest.mock import patch
@@ -20,6 +21,7 @@ from nodes.lora_loader import (
load_usage_data,
reset_lora_usage_file,
get_file_sha256_cached,
VisualLoraLoader,
)
@@ -66,6 +68,36 @@ class TestLoraSelection(unittest.TestCase):
self.assertEqual(select_a_lora("Sequential", "node1", choices, "p3"), "C")
self.assertEqual(select_a_lora("Sequential", "node1", choices, "p4"), "A")
def test_sequential_reorder_keeps_cycle_position(self):
# The state bucket is keyed by the selected set, so a different
# click/toggle order must not shift the index onto another LoRA.
self.assertEqual(select_a_lora("Sequential", "node_order", ["A", "B", "C"], "o1"), "A")
self.assertEqual(select_a_lora("Sequential", "node_order", ["A", "B", "C"], "o2"), "B")
# Reordering after a reselect must continue the same cycle (C next),
# never repeat B and never skip C.
self.assertEqual(select_a_lora("Sequential", "node_order", ["C", "A", "B"], "o3"), "C")
self.assertEqual(select_a_lora("Sequential", "node_order", ["B", "C", "A"], "o4"), "A")
# Retrying a reordered prompt still replays its original assignment.
self.assertEqual(select_a_lora("Sequential", "node_order", ["A", "C", "B"], "o3"), "C")
def test_sequential_changed_selection_starts_fresh_cycle(self):
self.assertEqual(select_a_lora("Sequential", "node_set", ["A", "B"], "s1"), "A")
self.assertEqual(select_a_lora("Sequential", "node_set", ["A", "B"], "s2"), "B")
# A different selection set is a different state bucket.
self.assertEqual(select_a_lora("Sequential", "node_set", ["A", "B", "C"], "s3"), "A")
def test_missing_selection_mode_defaults_to_sequential(self):
params = inspect.signature(VisualLoraLoader.load_lora).parameters
self.assertEqual(params["_selection_mode"].default, "Sequential")
self.assertEqual(
VisualLoraLoader.INPUT_TYPES()["hidden"]["_selection_mode"][1]["default"],
"Sequential",
)
# IS_CHANGED must invalidate for the same fallback mode, otherwise a
# prompt without _selection_mode would be treated as cacheable "All".
self.assertIsInstance(VisualLoraLoader.IS_CHANGED(), float)
def test_no_replace_cycles_all_items_and_avoids_immediate_repeat(self):
choices = ["A", "B", "C"]
first_cycle = [
+147
View File
@@ -0,0 +1,147 @@
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 { stripImports } from "./fake_dom.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const sourcePath = path.join(repoRoot, "web", "persistent_queue.js");
let clock = 0;
let timerId = 0;
const timers = [];
function fakeSetTimeout(callback, delay = 0) {
const timer = {
id: ++timerId,
at: clock + Math.max(0, Number(delay) || 0),
callback,
};
timers.push(timer);
return timer.id;
}
function advance(ms) {
const end = clock + Math.max(0, Number(ms) || 0);
while (true) {
let nextIndex = -1;
for (let i = 0; i < timers.length; i++) {
if (timers[i].at <= end && (nextIndex < 0 || timers[i].at < timers[nextIndex].at)) {
nextIndex = i;
}
}
if (nextIndex < 0) break;
const [timer] = timers.splice(nextIndex, 1);
clock = Math.max(clock, timer.at);
timer.callback();
}
clock = end;
}
async function flush() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
const apiEvents = new Map();
const fetchCalls = [];
let fetchImpl = async (url, options) => {
fetchCalls.push({ url, options });
return { ok: true };
};
const api = {
clientId: "client-abc",
addEventListener(name, callback) {
if (!apiEvents.has(name)) apiEvents.set(name, []);
apiEvents.get(name).push(callback);
},
};
const extension = { name: "", setup: null };
const app = {
registerExtension(candidate) {
extension.name = candidate.name;
extension.setup = candidate.setup;
},
};
const context = vm.createContext({
console,
app,
api,
authenticatedFetch: (url, options) => fetchImpl(url, options),
setTimeout: fakeSetTimeout,
clearTimeout: () => {},
JSON,
Promise,
String,
Object,
Error,
});
const source = stripImports(fs.readFileSync(sourcePath, "utf8"));
vm.runInContext(source, context, { filename: sourcePath });
assert.equal(typeof extension.setup, "function", "persistent queue extension must register");
await extension.setup();
assert.deepEqual(
[...apiEvents.keys()].sort(),
["executing", "execution_start", "status"],
"persistent queue must listen for queue status events"
);
// No claim before the 500ms startup delay.
advance(499);
await flush();
assert.equal(fetchCalls.length, 0, "queue ownership must not be claimed before startup settles");
advance(1);
await flush();
assert.equal(fetchCalls.length, 1, "queue ownership must be claimed after startup");
assert.equal(fetchCalls[0].url, "/saturnnodes/persistent_queue/claim");
assert.equal(fetchCalls[0].options.method, "POST");
assert.equal(fetchCalls[0].options.headers["Content-Type"], "application/json");
assert.deepEqual(
JSON.parse(fetchCalls[0].options.body),
{ client_id: "client-abc" },
"claim body must carry the API client id"
);
// Status events re-claim ownership (a reconnecting tab takes over recovery).
apiEvents.get("status")[0]();
await flush();
assert.equal(fetchCalls.length, 2, "status events must re-claim queue ownership");
assert.deepEqual(JSON.parse(fetchCalls[1].options.body), { client_id: "client-abc" });
// A client without an id must not send a claim.
api.clientId = "";
apiEvents.get("status")[0]();
await flush();
assert.equal(fetchCalls.length, 2, "clients without a client id must not claim ownership");
// A failed claim is contained and must not break later events.
api.clientId = "client-xyz";
fetchImpl = async () => {
throw new Error("network unavailable");
};
apiEvents.get("status")[0]();
await flush();
assert.equal(fetchCalls.length, 2, "failed claim must not record a successful request");
// Restore a working transport and verify execution events do not throw when
// rgthree-comfy is not installed (the dynamic import is caught internally).
fetchImpl = async (url, options) => {
fetchCalls.push({ url, options });
return { ok: true };
};
apiEvents.get("execution_start")[0]({ detail: {} });
apiEvents.get("executing")[0]({ detail: { prompt_id: "p1" } });
await flush();
advance(1200);
await flush();
assert.equal(fetchCalls.length, 2, "execution events must not claim ownership");
console.log("persistent queue claim DOM regression test passed");
+381
View File
@@ -0,0 +1,381 @@
import json
import os
import sys
import tempfile
import threading
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 # noqa: F401 (redirects SaturnNodes state to a temp dir)
import nodes.queue_control as queue_control
from nodes.queue_control import PersistentQueueManager
def make_entry(prompt_id, priority=0, item_pid=None, create_time=1, extra=None):
item = [
priority,
prompt_id if item_pid is None else item_pid,
{"prompt": {}},
{"client_id": "old-client", "create_time": create_time},
[],
]
entry = {"prompt_id": prompt_id, "item": item}
if extra:
entry.update(extra)
return entry
class FakePromptQueue:
def __init__(self, history=None):
self.history = {} if history is None else history
self.items = []
self.mutex = threading.RLock()
self.currently_running = {}
self.queue = []
self.fail_delete = False
self.fail_wipe = False
self.fail_ids = set()
def get_history(self):
return self.history
def put(self, item):
if item[1] in self.fail_ids:
raise RuntimeError(f"cannot queue {item[1]}")
self.items.append(item)
self.queue.append(item)
def task_done(self, item_id, history_result, status, process_item=None):
self.currently_running.pop(item_id, None)
def delete_queue_item(self, predicate):
if self.fail_delete:
raise RuntimeError("delete failed")
self.queue = [item for item in self.queue if not predicate(item)]
def wipe_queue(self):
if self.fail_wipe:
raise RuntimeError("wipe failed")
self.queue = []
class TestPersistentQueueRestore(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_file = os.path.join(self.temp_dir.name, "persistent_queue.json")
self.patches = [
patch("nodes.queue_control.PERSISTENT_FILE", self.temp_file),
patch("nodes.queue_control.USER_DIR", self.temp_dir.name),
patch("nodes.queue_control.is_persistent_queue_enabled", return_value=True),
patch.object(
PersistentQueueManager,
"_schedule_save_locked",
new=lambda self: None,
),
]
for patcher in self.patches:
patcher.start()
self.manager = PersistentQueueManager()
def tearDown(self):
for patcher in reversed(self.patches):
patcher.stop()
self.temp_dir.cleanup()
def _server(self, queue, number=0):
return SimpleNamespace(
prompt_queue=queue,
number=number,
queue_updated=MagicMock(),
get_queue_status=MagicMock(return_value={}),
send_sync=MagicMock(),
)
def _write_payload(self, payload):
with open(self.temp_file, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
def _read_payload(self):
with open(self.temp_file, "r", encoding="utf-8") as handle:
return json.load(handle)
def test_normalize_entry_uses_top_level_prompt_id(self):
raw = {"prompt_id": "top-level", "item": [0, "different", {"prompt": {}}]}
entry = PersistentQueueManager._normalize_entry(raw)
self.assertEqual(entry["prompt_id"], "top-level")
self.assertEqual(entry["item"][1], "different")
self.assertIsNone(PersistentQueueManager._normalize_entry(None))
self.assertIsNone(PersistentQueueManager._normalize_entry({"prompt_id": "x", "item": "bad"}))
self.assertIsNone(PersistentQueueManager._normalize_entry({"prompt_id": "x", "item": [0]}))
self.assertIsNone(PersistentQueueManager._normalize_entry({"item": [0, ""]}))
self.assertEqual(
PersistentQueueManager._normalize_entry({"item": [0, "from-item"]})["prompt_id"],
"from-item",
)
def test_normalize_items_repairs_and_dedupes_last_wins(self):
first = make_entry("p1", priority=1)
duplicate = make_entry("p1", priority=99)
malformed = {"prompt_id": "bad", "item": "nope"}
items, changed = PersistentQueueManager._normalize_items([first, malformed, duplicate])
self.assertTrue(changed)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["prompt_id"], "p1")
self.assertEqual(items[0]["item"][0], 99)
def test_restore_repairs_mismatched_item_prompt_id(self):
entry = make_entry("top-level", item_pid="wrong-id", priority=4)
self.manager.persistent_items = [entry]
queue = FakePromptQueue()
server = self._server(queue, number=2)
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause"):
self.manager.restore_queue("new-client")
self.assertEqual(len(queue.items), 1)
self.assertEqual(queue.items[0][1], "top-level")
self.assertEqual(self.manager.persistent_items[0]["item"][1], "top-level")
payload = self._read_payload()
self.assertEqual(payload["items"][0]["item"][1], "top-level")
def test_restore_drops_prompts_already_in_history(self):
self.manager.persistent_items = [make_entry("finished"), make_entry("pending", priority=2)]
queue = FakePromptQueue(history={"finished": {"status": "success"}})
server = self._server(queue)
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause"):
self.manager.restore_queue()
self.assertEqual([item[1] for item in queue.items], ["pending"])
self.assertEqual([x["prompt_id"] for x in self.manager.persistent_items], ["pending"])
def test_restore_drops_malformed_entries(self):
self.manager.persistent_items = [
{"prompt_id": "short", "item": [0, "short"]},
{"prompt_id": "bad-output", "item": [0, "bad-output", "not-a-dict"]},
{"prompt_id": "valid", "item": [0, "valid", {"prompt": {}}, {}, []]},
]
queue = FakePromptQueue()
server = self._server(queue)
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause"):
self.manager.restore_queue()
self.assertEqual([item[1] for item in queue.items], ["valid"])
self.assertEqual([x["prompt_id"] for x in self.manager.persistent_items], ["valid"])
def test_restore_assigns_ordered_unique_create_times_and_client_id(self):
self.manager.persistent_items = [
make_entry("p1", priority=10),
make_entry("p2", priority=5),
make_entry("p3", priority=7),
]
queue = FakePromptQueue()
server = self._server(queue, number=3)
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause"), \
patch("nodes.queue_control.time.time", return_value=2_000_000.0):
self.manager.restore_queue("client-42")
create_times = [item[3]["create_time"] for item in queue.items]
self.assertEqual(create_times, [1_999_999_998, 1_999_999_999, 2_000_000_000])
self.assertEqual(create_times, sorted(set(create_times)))
for item in queue.items:
self.assertEqual(item[3]["client_id"], "client-42")
self.assertEqual(server.number, 11, "server priority counter must exceed restored items")
def test_restore_retries_with_malformed_retry_counter(self):
entry = make_entry("flaky", extra={"retries": "not-a-number"})
self.manager.persistent_items = [entry]
queue = FakePromptQueue()
queue.fail_ids.add("flaky")
server = self._server(queue)
def env(key, default):
if key == "PERSISTENT_QUEUE_MAX_RETRIES":
return "2"
return default
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause"), \
patch("nodes.queue_control.get_env_setting", side_effect=env):
self.manager.restore_queue()
self.assertEqual(self.manager.persistent_items[0]["retries"], 1)
self.manager.has_claimed_once = False
self.manager.restore_queue()
self.assertEqual(self.manager.persistent_items[0]["retries"], 2)
self.manager.has_claimed_once = False
self.manager.restore_queue()
self.assertEqual(self.manager.persistent_items, [])
self.assertEqual(queue.items, [])
def test_restore_drops_after_first_failure_when_retries_disabled(self):
self.manager.persistent_items = [make_entry("flaky")]
queue = FakePromptQueue()
queue.fail_ids.add("flaky")
server = self._server(queue)
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause"):
self.manager.restore_queue()
self.assertEqual(self.manager.persistent_items, [])
def test_restore_tolerates_history_read_failure(self):
self.manager.persistent_items = [make_entry("p1")]
queue = FakePromptQueue()
def broken_history():
raise RuntimeError("history unavailable")
queue.get_history = broken_history
server = self._server(queue)
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause"):
self.manager.restore_queue()
self.assertEqual([item[1] for item in queue.items], ["p1"])
def test_restore_force_state_setting_is_case_insensitive(self):
for setting, expected in (("force paused", True), ("FORCE RUNNING", False)):
self.manager.persistent_items = [make_entry("p1")]
self.manager.has_claimed_once = False
queue = FakePromptQueue()
server = self._server(queue)
def env(key, default, value=setting):
if key == "PERSISTENT_QUEUE_RESTORED_STATE":
return value
return default
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause") as set_pause, \
patch("nodes.queue_control.get_env_setting", side_effect=env):
self.manager.restore_queue()
set_pause.assert_called_once_with(expected)
def test_restore_is_skipped_when_disabled_or_empty(self):
queue = FakePromptQueue()
server = self._server(queue)
with patch.object(queue_control.PromptServer, "instance", server), \
patch("nodes.queue_control.is_persistent_queue_enabled", return_value=False):
self.manager.persistent_items = [make_entry("p1")]
self.manager.restore_queue()
self.assertTrue(self.manager.has_claimed_once)
self.assertEqual(queue.items, [])
self.manager.has_claimed_once = False
self.manager.persistent_items = []
with patch.object(queue_control.PromptServer, "instance", server):
self.manager.restore_queue()
self.assertEqual(queue.items, [])
def test_newer_schema_file_is_not_overwritten_by_restore_or_add(self):
original = {
"schema_version": 99,
"items": [{"prompt_id": "future", "item": [0, "future", {}, {}, []], "future_field": True}],
}
self._write_payload(original)
self.manager.load_from_file()
self.assertTrue(self.manager._read_only_newer_schema)
self.assertEqual(self.manager.persistent_items, [])
self.manager.add_item((0, "new", {"prompt": {}}, {}, []))
self.manager.save_to_file(immediate=True)
self.assertEqual(self._read_payload(), original)
def test_load_from_file_rewrites_duplicate_entries(self):
duplicate = make_entry("p1", priority=99)
self._write_payload({
"schema_version": 1,
"items": [make_entry("p1", priority=1), duplicate],
})
manager = PersistentQueueManager()
self.assertEqual(len(manager.persistent_items), 1)
self.assertEqual(manager.persistent_items[0]["item"][0], 99)
self.assertEqual(len(self._read_payload()["items"]), 1)
def test_batch_info_requires_dict_and_ignores_finished_prompts(self):
self.manager.set_batch_info("p1", "not-a-dict")
self.manager.set_batch_info("", {"batchId": "empty"})
self.assertEqual(self.manager.batch_meta, {})
info = {"batchId": "b1", "color": "#10b981"}
self.manager.set_batch_info("p1", info)
self.assertEqual(self.manager.batch_meta["p1"], info)
self.manager.add_item((0, "p1", {"prompt": {}}, {}, []))
self.assertEqual(self.manager.persistent_items[0]["batch_info"], info)
self.manager.remove_item("p1")
self.manager.set_batch_info("p1", {"batchId": "late"})
self.assertNotIn("p1", self.manager.batch_meta)
def test_cancel_and_wipe_remove_persistence_only_after_success(self):
queue = FakePromptQueue()
server = self._server(queue)
self.manager._patched = False
with patch.object(queue_control.PromptServer, "instance", server):
self.manager.patch_server()
queue.put((0, "p1", {"prompt": {}}, {}, []))
self.assertEqual([x["prompt_id"] for x in self.manager.persistent_items], ["p1"])
queue.fail_delete = True
with self.assertRaises(RuntimeError):
queue.delete_queue_item(lambda item: True)
self.assertEqual([x["prompt_id"] for x in self.manager.persistent_items], ["p1"])
queue.fail_delete = False
queue.delete_queue_item(lambda item: True)
self.assertEqual(self.manager.persistent_items, [])
queue.put((0, "p2", {"prompt": {}}, {}, []))
self.assertEqual([x["prompt_id"] for x in self.manager.persistent_items], ["p2"])
queue.fail_wipe = True
with self.assertRaises(RuntimeError):
queue.wipe_queue()
self.assertEqual([x["prompt_id"] for x in self.manager.persistent_items], ["p2"])
queue.fail_wipe = False
queue.wipe_queue()
self.assertEqual(self.manager.persistent_items, [])
queue.put((0, "p3", {"prompt": {}}, {}, []))
queue.currently_running[7] = (0, "p3", {"prompt": {}}, {}, [])
queue.task_done(7, {}, "success")
self.assertEqual(self.manager.persistent_items, [])
def test_restore_syncs_client_id_to_all_entries(self):
self.manager.persistent_items = [make_entry("p1"), make_entry("p2", priority=2)]
queue = FakePromptQueue()
server = self._server(queue)
with patch.object(queue_control.PromptServer, "instance", server), \
patch.object(queue_control.pause_manager, "set_pause"):
self.manager.restore_queue("client-x")
payload = self._read_payload()
self.assertEqual(
[item["item"][3]["client_id"] for item in payload["items"]],
["client-x", "client-x"],
)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -85,6 +85,47 @@ class TestPromptQueueIterator(unittest.TestCase):
self.assertEqual(p, f"Prompt A{expected_idx}")
self.assertEqual(rem_count, 10 - (expected_idx + 1))
def test_linked_prompt_input_wins_over_stale_text(self):
"""
A connected `prompt` input is authoritative; the local multiline `text`
widget must not replay stale characters from an earlier session.
"""
state_db = {}
def mock_load():
return dict(state_db)
def mock_save(new_state):
nonlocal state_db
state_db = dict(new_state)
stale_text = "stale one\n\nstale two"
linked_text = "linked one\n\nlinked two"
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
p0, _, _, _ = self.node.process_queue(
text=stale_text, prompt=linked_text, unique_id="linked_node"
)
p1, _, _, _ = self.node.process_queue(
text=stale_text, prompt=linked_text, unique_id="linked_node"
)
self.assertEqual(p0, "linked one")
self.assertEqual(p1, "linked two")
def test_linked_empty_prompt_does_not_replay_local_text(self):
"""An empty linked prompt produces no run, even if `text` is stale."""
p, remaining, count, exhausted = self.node.process_queue(
text="stale prompt text", prompt="", unique_id="linked_empty"
)
self.assertEqual(p, "")
self.assertEqual(remaining, "")
self.assertEqual(count, 0)
self.assertFalse(exhausted)
def test_multi_pack_isolation_pack_a_and_pack_b(self):
"""
Verify Pack A (10 prompts) and Pack B (5 prompts) run with complete isolation.
+26 -2
View File
@@ -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");
+152 -29
View File
@@ -67,7 +67,9 @@ function loadStorage() {
}
const savedCounter = localStorage.getItem(BATCH_COUNTER_KEY);
if (savedCounter) {
batchCounter = parseInt(savedCounter, 10) || 0;
// A negative or corrupted counter would index outside BATCH_COLORS
// and render an undefined stroke color.
batchCounter = Math.max(0, parseInt(savedCounter, 10) || 0);
}
} catch (e) {
console.warn("[SaturnNodes BatchQueue] Failed to load batch storage:", e);
@@ -76,9 +78,19 @@ function loadStorage() {
function saveStorage() {
try {
// Prune entries to last 250 items to keep localStorage small
// Prune entries to the 250 most recently queued items. Map insertion
// order is not a reliable recency order when metadata for an existing
// prompt is updated in place.
const allEntries = Array.from(batchRegistry.values());
const pruned = allEntries.slice(-250);
const pruned = allEntries
.map((entry, index) => ({ entry, index }))
.sort((a, b) => {
const aTime = Number(a.entry && a.entry.queuedAt) || 0;
const bTime = Number(b.entry && b.entry.queuedAt) || 0;
return aTime - bTime || a.index - b.index;
})
.slice(-250)
.map(item => item.entry);
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
localStorage.setItem(BATCH_COUNTER_KEY, String(batchCounter));
} catch (e) {
@@ -151,6 +163,25 @@ function injectBatchStyles() {
document.head.appendChild(style);
}
// Batch metadata comes from localStorage and the server, so never trust its
// color string as HTML/SVG markup. Only complete 3/4/6/8-digit hex colors are
// accepted (5 and 7-digit values are not valid CSS colors).
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function normalizeBatchColor(color, fallback = BATCH_COLORS[0]) {
if (typeof color === "string") {
const trimmed = color.trim();
if (HEX_COLOR_PATTERN.test(trimmed)) return trimmed;
}
return fallback;
}
function normalizeSegmentType(segmentType) {
return ["single", "first", "middle", "last"].includes(segmentType)
? segmentType
: "single";
}
// Build 1D SVG line path according to segment type:
// 'single': bracket (rounded top & bottom curving right to card)
// 'first': rounded top curving right, straight all the way down
@@ -182,16 +213,19 @@ function renderBatchLineOnElement(element, segmentType, color) {
element.appendChild(wrap);
}
const safeSegment = normalizeSegmentType(segmentType);
const safeColor = normalizeBatchColor(color);
// Avoid rewriting the SVG on every status/executing refresh when the
// visible segment and batch color have not changed.
const signature = `${segmentType}|${color}`;
const signature = `${safeSegment}|${safeColor}`;
if (wrap.dataset.saturnnodesBatchSignature === signature && wrap.innerHTML) return;
wrap.dataset.saturnnodesBatchSignature = signature;
const dPath = buildSvgPath(segmentType);
const dPath = buildSvgPath(safeSegment);
wrap.innerHTML = `
<svg class="saturnnodes-batch-line-svg" viewBox="0 0 10 48" preserveAspectRatio="none">
<path d="${dPath}" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="${dPath}" stroke="${safeColor}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
`;
}
@@ -333,9 +367,15 @@ async function loadBatchFromServer() {
const data = await res.json();
if (data && typeof data === "object") {
for (const [pid, info] of Object.entries(data)) {
if (pid && info) {
batchRegistry.set(String(pid), info);
if (!pid || !info || typeof info !== "object") continue;
const key = String(pid);
const existing = batchRegistry.get(key);
// Never let an older server snapshot overwrite newer local
// metadata for the same prompt.
if (existing && (Number(existing.queuedAt) || 0) > (Number(info.queuedAt) || 0)) {
continue;
}
batchRegistry.set(key, info);
}
saveStorage();
}
@@ -426,20 +466,25 @@ async function updateQueueBatchVisuals() {
}
const batchId = info.batchId;
const color = info.color || BATCH_COLORS[0];
const color = normalizeBatchColor(info.color);
const batchCount = Math.max(1, Number(info.batchCount) || 1);
const rawItemIndex = Number.isInteger(info.itemIndex) ? info.itemIndex : 0;
// Corrupt metadata must not produce a "middle" segment for an index
// outside the batch (e.g. -1 or 99 in a 3-item batch).
const itemIndex = Math.min(Math.max(0, rawItemIndex), batchCount - 1);
// All visible items of this batch in top-to-bottom visual order
const occurrencesOnScreen = batchGroups.get(batchId) || [];
if (occurrencesOnScreen.length <= 1) {
// Only 1 item of this batch currently visible on screen
if ((info.batchCount || 1) === 1) {
if (batchCount === 1) {
renderBatchLineOnElement(el, "single", color);
} else {
// Batch originally had multiple items, but only 1 is currently visible/unexecuted
if (info.itemIndex === 0) {
if (itemIndex === 0) {
renderBatchLineOnElement(el, "first", color);
} else if (info.itemIndex === (info.batchCount - 1)) {
} else if (itemIndex === (batchCount - 1)) {
renderBatchLineOnElement(el, "last", color);
} else {
renderBatchLineOnElement(el, "middle", color);
@@ -465,9 +510,32 @@ async function updateQueueBatchVisuals() {
}
let batchSubmissionQueue = Promise.resolve();
// Number of queue actions whose submission turn has not settled yet. Used to
// detect that ComfyUI will serialize this prompt later instead of immediately.
let pendingSubmissions = 0;
function capturePromptAtClick() {
// Serialize the graph now, while the canvas still shows what the user
// clicked. ComfyUI's own loop calls graphToPrompt() much later when another
// batch is still being submitted, so without this the text and LoRA
// selection edited in the meantime bleed into the queued prompt.
try {
if (app && typeof app.graphToPrompt === "function") {
return Promise.resolve(app.graphToPrompt()).catch(e => {
console.warn("[SaturnNodes BatchQueue] Click-time prompt snapshot failed:", e);
return null;
});
}
} catch (e) {
console.warn("[SaturnNodes BatchQueue] Click-time prompt snapshot failed:", e);
}
return null;
}
// Hook queuePrompt to track batch execution and guard multi-item batches
function setupQueueHooks() {
if (setupQueueHooks._installed) return;
setupQueueHooks._installed = true;
loadStorage();
// 1. Hook app.queuePrompt to know when a batch is initiated
@@ -476,9 +544,21 @@ function setupQueueHooks() {
app.queuePrompt = async function(number, batchCount = 1, queueNodeIds) {
const count = Math.max(1, parseInt(batchCount, 10) || 1);
// Only ComfyUI's own processing loop or this submission queue can
// delay serialization. When idle, ComfyUI snapshots immediately, so
// skipping the extra graphToPrompt() keeps single clicks cheap.
const shouldCapture = isSnapshotGuardEnabled() &&
(pendingSubmissions > 0 || (app && app.processingQueue === true));
const clickPromptPromise = shouldCapture ? capturePromptAtClick() : null;
pendingSubmissions++;
// Chain onto batchSubmissionQueue to ensure sequential batch submissions
const turn = batchSubmissionQueue.then(async () => {
const batch = getNextBatch(count);
if (clickPromptPromise) {
batch.clickPromptPromise = clickPromptPromise;
}
activeBatchContext = batch;
saveStorage();
try {
@@ -488,6 +568,10 @@ function setupQueueHooks() {
}
});
batchSubmissionQueue = turn.catch(() => {});
turn.then(
() => { pendingSubmissions = Math.max(0, pendingSubmissions - 1); },
() => { pendingSubmissions = Math.max(0, pendingSubmissions - 1); }
);
return turn;
};
}
@@ -503,20 +587,53 @@ function setupQueueHooks() {
if (!activeBatchContext) {
activeBatchContext = getNextBatch(1);
}
const itemIndex = activeBatchContext.prompts.length;
// Capture this request's context. A concurrent request may finish
// the batch and clear the global reference while we are awaiting
// the server.
const batchContext = activeBatchContext;
if (!Number.isInteger(batchContext.nextItemIndex)) {
batchContext.nextItemIndex = 0;
}
if (!Number.isInteger(batchContext.inFlight)) {
batchContext.inFlight = 0;
}
const itemIndex = batchContext.nextItemIndex++;
batchContext.inFlight++;
// Batch Graph Snapshot Guard:
// When queueing a batch with batchCount > 1, snapshot on iteration 0 in the background
// so canvas edits (text, LoRA selection, widgets) don't bleed into subsequent items.
if (isSnapshotGuardEnabled() && activeBatchContext && activeBatchContext.batchCount > 1) {
if (itemIndex === 0) {
// Freeze the prompt the user clicked instead of the live canvas.
// Multi-item batches additionally reuse that snapshot for every
// later item so canvas edits cannot bleed into the rest of the batch.
if (isSnapshotGuardEnabled()) {
let clickPrompt = null;
if (itemIndex === 0 && batchContext.clickPromptPromise) {
try {
activeBatchContext.snapshotPrompt = JSON.parse(JSON.stringify(prompt));
clickPrompt = await batchContext.clickPromptPromise;
} catch (e) {
console.warn("[SaturnNodes BatchQueue] Failed to snapshot prompt:", e);
clickPrompt = null;
}
} else if (activeBatchContext.snapshotPrompt) {
promptToSend = mergeDynamicSeeds(activeBatchContext.snapshotPrompt, prompt);
batchContext.clickPromptPromise = null;
}
if (itemIndex === 0) {
if (clickPrompt && clickPrompt.output) {
try {
batchContext.snapshotPrompt = JSON.parse(JSON.stringify(clickPrompt));
} catch (e) {
console.warn("[SaturnNodes BatchQueue] Failed to snapshot prompt:", e);
}
// Keep randomized seeds from the live prompt so the
// generated image matches the seed the UI queued.
promptToSend = mergeDynamicSeeds(batchContext.snapshotPrompt, prompt);
} else if (batchContext.batchCount > 1) {
try {
batchContext.snapshotPrompt = JSON.parse(JSON.stringify(prompt));
} catch (e) {
console.warn("[SaturnNodes BatchQueue] Failed to snapshot prompt:", e);
}
}
} else if (batchContext.snapshotPrompt) {
promptToSend = mergeDynamicSeeds(batchContext.snapshotPrompt, prompt);
}
}
@@ -524,32 +641,38 @@ function setupQueueHooks() {
try {
res = await origApiQueuePrompt(number, promptToSend, targets);
} catch (e) {
if (activeBatchContext && activeBatchContext.prompts.length === 0) {
batchContext.inFlight--;
if (activeBatchContext === batchContext &&
batchContext.nextItemIndex >= batchContext.batchCount &&
batchContext.inFlight === 0) {
activeBatchContext = null;
}
throw e;
}
batchContext.inFlight--;
if (res && res.prompt_id) {
const pid = String(res.prompt_id);
const batchInfo = {
prompt_id: pid,
batchId: activeBatchContext.batchId,
batchNumber: activeBatchContext.batchNumber,
batchCount: activeBatchContext.batchCount,
batchId: batchContext.batchId,
batchNumber: batchContext.batchNumber,
batchCount: batchContext.batchCount,
itemIndex: itemIndex,
color: activeBatchContext.color,
colorIndex: activeBatchContext.colorIndex,
color: batchContext.color,
colorIndex: batchContext.colorIndex,
isQueueFront: number < 0,
queuedAt: Date.now()
};
activeBatchContext.prompts.push(pid);
batchContext.prompts.push(pid);
batchRegistry.set(pid, batchInfo);
saveStorage();
syncBatchToServer(pid, batchInfo);
// If this batch completed its expected prompt count, clear context
if (activeBatchContext.prompts.length >= activeBatchContext.batchCount) {
if (activeBatchContext === batchContext &&
batchContext.nextItemIndex >= batchContext.batchCount &&
batchContext.inFlight === 0) {
activeBatchContext = null;
}
+156 -14
View File
@@ -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
+52 -3
View File
@@ -20,6 +20,17 @@ function getFolderBorderColor(folderName) {
return `hsl(${h}, 65%, 50%)`;
}
// Read the thumbnail setting without the deprecated getSettingValue(id, default)
// second argument, which logs a console warning on every access.
function getBypassThumbnailDownscaling() {
try {
const value = app.ui?.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling");
return value === undefined || value === null ? false : Boolean(value);
} catch (_) {
return false;
}
}
// Global CSS injection for the visual node styling
const visualStyles = document.createElement("style");
visualStyles.textContent = `
@@ -240,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%;
@@ -685,6 +720,8 @@ app.registerExtension({
createSpecialTile("[ NONE ]", "NONE", "#ff3333", "lora-tile-none");
createSpecialTile("[ RANDOM ]", "RANDOM", "#ffaa00", "lora-tile-random");
const bypassThumbs = getBypassThumbnailDownscaling();
data.names.forEach(loraName => {
if (loraName === "[ NONE ]" || loraName === "[ RANDOM ]") return;
@@ -707,7 +744,6 @@ app.registerExtension({
const img = document.createElement("img");
img.loading = "lazy";
const bypassThumbs = app.ui.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false);
img.dataset.src = `/saturnnodes/folder_lora_loader/get_preview?lora=${encodeURIComponent(loraName)}&folder=${encodeURIComponent(folder)}&pretty=true` + (bypassThumbs ? "&full=true" : "&size=200");
tileObserver.observe(img);
@@ -1378,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 {
@@ -1389,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();
@@ -1552,6 +1593,8 @@ app.registerExtension({
}, { root: gridContainer, rootMargin: "150px" });
// --- Render folder groups ---
const bypassThumbs = getBypassThumbnailDownscaling();
sortedFolders.forEach(subFolder => {
const items = foldersMap[subFolder];
if (items.length === 0) return;
@@ -1639,9 +1682,15 @@ 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";
const bypassThumbs = app.ui.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false);
img.dataset.src = `/saturnnodes/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}` + (bypassThumbs ? "&full=true" : "&size=200");
imageObserver.observe(img);
+2
View File
@@ -11,6 +11,8 @@ export const SATURN_NODE_TYPES = new Set([
"FolderLoraLoaderPretty",
"VisualImageLoader",
"LoadRecentOutputs",
"CharacterPresetPicker",
"ApplyCharacterLoRAs",
// Automation, Flow & Utilities
"LoadImageFromFolder",
+149 -22
View File
@@ -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
@@ -1738,7 +1865,7 @@ app.registerExtension({
name: "Batch Graph Snapshot Guard",
type: "boolean",
defaultValue: getInitialSetting("SaturnNodes.🎨 Batch Queue.02_SnapshotGuard", getInitialSetting("SaturnNodes.BatchQueue.SnapshotGuard", true)),
tooltip: "When queueing multi-item batches, snapshots prompt and node inputs (like text and LoRAs) in the background so mid-queue canvas edits do not corrupt queued items. Random seeds continue to randomize."
tooltip: "Snapshots prompt text, LoRA selections and node inputs when you click Queue (or when a batch is queued behind an in-flight submission), so edits made while ComfyUI is still loading cannot corrupt already queued prompts. Random seeds continue to randomize."
});
// =========================================================================
@@ -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.2</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)';">