Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2696781d6b | ||
|
|
7517f1fe80 | ||
|
|
8a5638221f | ||
|
|
0b2b274583 | ||
|
|
eacd239d23 | ||
|
|
04ade0dae5 | ||
|
|
3c912c3a1d | ||
|
|
0eefe12131 | ||
|
|
b2b6f6badb | ||
|
|
00cfd979bf | ||
|
|
53dfbe4999 | ||
|
|
88d1ac3666 | ||
|
|
0d5f76bf89 | ||
|
|
8c9fcf5225 | ||
|
|
69fe4ceaf1 |
@@ -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,17 +2,22 @@ name: Publish to ComfyUI Registry
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '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:
|
||||
|
||||
@@ -5,6 +5,38 @@ 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.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
|
||||
|
||||
@@ -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`.
|
||||
@@ -459,7 +459,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>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
__version__ = "2.6.2"
|
||||
__version__ = "2.6.3"
|
||||
|
||||
from .nodes.queue_control import (
|
||||
setup_queue_control_routes,
|
||||
|
||||
+83
-14
@@ -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
|
||||
|
||||
|
||||
+66
-10
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "saturnnodes"
|
||||
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
|
||||
version = "2.6.2"
|
||||
version = "2.6.3"
|
||||
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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -7,9 +7,14 @@ 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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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");
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
+152
-29
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
@@ -685,6 +696,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 +720,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);
|
||||
|
||||
@@ -1552,6 +1564,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;
|
||||
@@ -1641,7 +1655,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?system_path=${encodeURIComponent(item.systemPath)}` + (bypassThumbs ? "&full=true" : "&size=200");
|
||||
imageObserver.observe(img);
|
||||
|
||||
|
||||
@@ -1738,7 +1738,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 +1867,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.6.3 (dev)</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<a href="https://github.com/KOFiblto/ComfyUI-SaturnNodes" target="_blank" rel="noopener noreferrer" style="color: #decbb2; text-decoration: underline; text-decoration-color: rgba(222, 203, 178, 0.4); text-underline-offset: 3px; font-weight: 500; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12); transition: all 0.2s ease;" onmouseover="this.style.color='#ffffff'; this.style.background='rgba(255,255,255,0.12)'; this.style.borderColor='rgba(255,255,255,0.25)';" onmouseout="this.style.color='#decbb2'; this.style.background='rgba(255,255,255,0.05)'; this.style.borderColor='rgba(255,255,255,0.12)';">
|
||||
|
||||
Reference in New Issue
Block a user