Feat: refine picker badges/actions and add dock idle modes

This commit is contained in:
KOFiblto
2026-11-11 01:54:28 +01:00
parent 08d37f680e
commit 5cc4a7742e
8 changed files with 388 additions and 162 deletions
+5 -2
View File
@@ -15,7 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Character Preset Usage Tracking**: New `ENABLE_CHARACTER_USAGE` setting (ComfyUI Settings → `Enable Character Preset Usage Tracking`) gates usage counting, tile badges and the `Usage` sort options independently from LoRA usage. Disabling it keeps the existing `character_usage.json` history. The picker now also prunes selections whose card was deleted or moved, using the full card-id list returned by `get_presets`.
- **Character-Driven LoRA Usage**: Applying a character card's LoRA stack through **Apply Character LoRAs** now increments the same `lora_usage.json` counters used by the Visual LoRA Loader, so character renders update LoRA badges and usage sorting too.
- **Character Picker Alignment & Maintenance**: The picker now has **Left / Right** content alignment (`content_alignment`), plus confirmed **↺ Counter**, **🧹 Usage** and **🗑 Cache** reset buttons that clear the per-node cycle state, `character_usage.json`, or the shared thumbnail cache without touching cards or LoRA files.
- **Character Picker Rank Borders**: Gold/silver/bronze borders mark the three most-used cards and an animated RGB border marks cards with 1,000+ uses, replacing the usage count badges. Controlled by the new `SaturnNodes.🖼️ Visual Loaders.05b_CharacterRankBorders` setting (default on); when disabled, plain usage badges return.
- **Character Picker Rank Borders**: Gold/silver/bronze borders mark the three most-used cards and an animated RGB border marks cards with 1,000+ uses, at 150% of the normal tile border thickness. Controlled by the new `SaturnNodes.🖼️ Visual Loaders.05b_CharacterRankBorders` setting (default on); usage count badges stay visible in every mode.
- **Character Picker Corner Usage Badges**: Usage counts now render as a flap tucked into the top-right corner of each tile (flush with the top and right edges, rounded on the inner edge) whose size grows logarithmically with the usage count.
- **Picker Control-Bar Card Actions**: **Edit**, **Duplicate** and **Delete** moved from per-tile overlays into the top control bar; the icon buttons are enabled only when exactly one card is selected and greyed out otherwise.
- **Floating Dock Idle Modes**: New `Dock Mode when Idle` setting with three choices — `Auto-Hide`, `Keep Dock Visible`, and `View Image`. `View Image` keeps the last latent on screen, swaps to the decoded final output image from the `executed` event once it has loaded, and holds it until the next run's first latent. `Auto-Hide` and `View Image` keep the dock hidden at startup until the first latent arrives.
- **Character Picker Selection Count**: A line under the card grid now reports how many cards are selected, e.g. `3 cards selected`, updating on every click, range, folder toggle or prune.
- **Character Preview Scraping**: The hidden `_scrape_on_new` flag starts the shared CivitAI/TMDB preview scraper after saving or importing cards (and on picker refresh), with no visible button.
- **Selection-Order Badges**: Both the Character Preset Picker and the Visual LoRA Loader now number selected cards in the order they were clicked, matching the order the LoRAs are actually applied.
@@ -29,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Picker Selection & Pruning**: A plain click now selects only that card, **Ctrl/Cmd+click** toggles individual cards and **Shift+click** selects the visual range from the last clicked card (matching the Visual LoRA Loader); clicking the only selected card clears it. Deleted or moved cards are pruned from the selection on refresh instead of staying stuck as an invisible selection.
- **New Card Id Suggestions**: New cards start from the current folder with a normalized single-slash id and a unique suffix, so repeated **New** clicks no longer suggest `Krea2\/New Character` or overwrite the previous card.
- **Native Picker Widgets**: The folder filter is a real ComfyUI `folder` widget again (empty = all presets) instead of a DOM dropdown, and a native `tile_size` slider (`50`–`180`, saved per node) controls the card width.
- **Picker Tile Actions**: **Edit / Duplicate / Delete** moved from a cover overlay into a compact top-row overlay on each tile, and the green trigger line under the card name is gone. **Duplicate** now opens a prefilled editor with `(1)` appended to the id instead of writing a copy immediately.
- **Picker Tile Actions**: **Edit / Duplicate / Delete** live in the top control bar (rather than per-tile overlays) and require exactly one selected card. **Duplicate** opens a prefilled editor with `(1)` appended to the id instead of writing a copy immediately.
- **Multi-Trigger Cards & Prompt Snippets**: Cards support a canonical `triggers` list (the legacy single `trigger` is still read and written as the first entry). Prompt detection checks every trigger, and the new **Prompt snippet** field becomes the `positive` output; cards without a snippet fall back to their display name, so existing cards keep working unchanged.
### Removed
+5 -5
View File
@@ -405,10 +405,10 @@ Cards are plain JSON files with an optional sidecar image, stored in `ComfyUI/us
#### Picker UI
The card grid mirrors the **Visual LoRA Loader**: a compact control bar, collapsible folder groups, and fixed-width `3:4` tiles that wrap instead of stretching with the node width.
- **Folder groups**: cards are grouped under their folder name with a card count, a collapse arrow, and a select-all checkbox. Collapse state is remembered per folder.
- **Control bar**: **Mode** (run behavior), **Sort** (per-folder ordering), **Folders** (folder-group ordering: name, total usage, average usage or LoRA count), **Align** (`Left` / `Right` content alignment), **View** (`Scroll` / `Show All`), **Reset** (`↺ Counter`, `🧹 Usage`, `🗑 Cache`, each with a confirmation prompt), and icon-only **New** (`+`), **Refresh** (`↻`) and **Open folder** (`📂`) actions. The folder filter and tile-size slider are native node widgets below the picker, like the **Visual LoRA Loader**.
- **Usage borders**: when Character Preset usage tracking and rank borders are enabled, the three most-used cards get gold/silver/bronze borders and cards with **1,000+ uses** get an animated RGB border instead of usage count badges.
- **Control bar**: **Mode** (run behavior), **Sort** (per-folder ordering), **Folders** (folder-group ordering: name, total usage, average usage or LoRA count), **Align** (`Left` / `Right` content alignment), **View** (`Scroll` / `Show All`), **Reset** (`↺ Counter`, `🧹 Usage`, `🗑 Cache`, each with a confirmation prompt), and icon-only **New** (`+`), **Refresh** (`↻`), **Open folder** (`📂`), **Edit** (`✎`), **Duplicate** (`⧉`) and **Delete** (`✕`) actions. The card actions enable only when exactly one card is selected. The folder filter and tile-size slider are native node widgets below the picker, like the **Visual LoRA Loader**.
- **Usage badges & borders**: usage counts always render as a flap tucked into the top-right corner, flush with the top/right edges and rounded on the inner edge, growing with the usage count. When Character Preset usage tracking and rank borders are enabled, the three most-used cards additionally get gold/silver/bronze borders and cards with **1,000+ uses** get an animated RGB border, both 150% of the normal border thickness.
- **Selection count**: a line under the grid reports how many cards are currently selected (for example `3 cards selected`).
- **Click** a card to select only it (green ring + selection-order number), **Ctrl/Cmd+click** to add or remove individual cards, and **Shift+click** to select every card visually between the last clicked card and the one clicked. Clicking the only selected card again clears the selection; **double-click** edits a card, or use the **Edit / Duplicate / Delete** buttons in the tile's top row.
- **Click** a card to select only it (green ring + selection-order number), **Ctrl/Cmd+click** to add or remove individual cards, and **Shift+click** to select every card visually between the last clicked card and the one clicked. Clicking the only selected card again clears the selection; **double-click** edits a card, or use the **✎ Edit**, **⧉ Duplicate** and **✕ Delete** icons in the top control bar, which enable only when exactly one card is selected.
- **Duplicate** opens the editor prefilled with a copy whose id gets a `(1)` suffix; adjust it before saving.
- Manual selection is shown with a green ring and a numbered badge in click order; connected-text matches are shown with a green wash and a `MATCH` pill, and the grid stops accepting clicks.
- After every run the cards that were actually used flash on the canvas, so you can see what detection picked.
@@ -454,7 +454,7 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download media preview images from TMDB.
- **`Enable LoRA Usage Tracking`** (`boolean`, *Default: true*): Toggle LoRA usage counting and visual rank badges (🔥, Gold, Silver, Bronze) in the picker.
- **`Enable Character Preset Usage Tracking`** (`boolean`, *Default: true*): Track and display Character Preset Picker usage counts, tile badges and the `Usage` sort options. Existing history is preserved when disabled.
- **`Enable Character Preset Rank Borders`** (`boolean`, *Default: true*): Replace Character Preset Picker usage badges with gold/silver/bronze borders for the 3 most-used cards and an animated RGB border at 1,000+ uses. Disable to show plain usage count badges again.
- **`Enable Character Preset Rank Borders`** (`boolean`, *Default: true*): Add gold/silver/bronze borders for the 3 most-used Character Preset Picker cards and an animated RGB border at 1,000+ uses. These rings are 150% of the normal tile border thickness and sit alongside the normal usage badges. Disable to remove the rings; the usage badges always stay visible.
- **`Reset Failed Scrapes Cache`** (*Button: `🗑️ Clear Scrapes Cache`*): Clears failed scrape history so Civitai/TMDB can retry downloading missing preview images.
- **`Disable Thumbnail Downscaling (Serve Raw)`** (`boolean`, *Default: false*): Serves full-resolution original preview images instead of generating downscaled WebP thumbnails (downscaled WebP thumbnails render faster in the picker and are recommended).
- **`Image Loader Metadata Scan Limit`** (`number`, *Default: 128*, 0–2000): Number of newest images in a folder scanned for embedded prompts by the Visual Image Loader (`0` disables extraction).
@@ -466,7 +466,7 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
- **`Enable Floating Latent Dock`** (`boolean`, *Default: true*): Adds the floating picture-in-picture Live Latent preview dock (bottom-right corner) plus a canvas toolbar toggle button.
- **`Floating Dock Max Width`** (`slider`, *Default: 280*, 1px steps): Maximum dock width box in pixels (100–2000). Drag the dock's top-left corner to set a free-form width×height box; the dragged dimensions are saved back into this setting, and the dock then snaps to the exact latent aspect ratio without distortion.
- **`Floating Dock Max Height`** (`slider`, *Default: 280*, 1px steps): Maximum dock height box in pixels (100–2000). Drag the dock's top-left corner to set a free-form width×height box; the dragged dimensions are saved back into this setting, and the dock then snaps to the exact latent aspect ratio without distortion.
- **`Auto-Hide Dock When Idle`** (`boolean`, *Default: false*): Hides the dock while idle and reveals it automatically when sampling begins.
- **`Dock Mode when Idle`** (`combo`, *Default: `Auto-Hide`*): Chooses what the floating dock does between runs. `Auto-Hide` hides it while idle and reveals it on the first fresh latent. `Keep Dock Visible` leaves it on screen at all times. `View Image` keeps the last latent visible, then swaps to the decoded final output image once it is written and keeps showing it until the next run's first latent. At startup both `Auto-Hide` and `View Image` keep the dock hidden until the first latent arrives.
</details>
<details>
+3 -2
View File
@@ -95,7 +95,7 @@ Keep characters as **cards** instead of remembering LoRA filenames and trigger w
3. Connect the picker's `loras` (or `loras_json`) output to **`🪐 🧬 Apply Character LoRAs`**, and route `positive`/`negative` into your prompt encoder. The picker itself never touches `MODEL`/`CLIP`, so it can sit anywhere in the graph.
4. **Clicking cards**: a normal click selects only that card; hold **Ctrl/Cmd** to add or remove individual cards, or **Shift** to select every card visually between the last clicked card and the one you click (same as the Visual LoRA Loader). Clicking the only selected card again clears the selection. Each selected card shows a numbered badge with its selection order. Cards are grouped by folder; click a folder header to collapse it, or its checkbox to select the whole folder at once. Choose how cards are consumed with the **Mode** dropdown in the control bar — `Sequential` cycles through the clicked cards, `Random` picks one, `Random (No Replace)` uses all of them before repeating, `All` uses every clicked card at once (in click order).
5. **Finding cards**: use the native **folder** widget below the picker (empty = every preset) and **Scroll / Show All** to switch between a fixed-height scrolling grid and a node that grows to fit every card. The native **tile_size** slider changes the card size, and the tiles keep a fixed `3:4` ratio, so resizing the node only changes how many cards fit per row. Use **Align → Left/Right** to anchor folder headers and tiles to either edge; a line under the grid shows how many cards are currently selected.
6. **Maintenance & usage borders**: **↺ Counter** resets this picker's Sequential/Random cycle, **🧹 Usage** clears all character usage counters, and **🗑 Cache** deletes cached thumbnails — each asks for confirmation first. With rank borders enabled, the three most-used cards glow gold/silver/bronze and cards with 1,000+ uses get an animated RGB border instead of usage count badges.
6. **Maintenance, badges & actions**: **↺ Counter** resets this picker's Sequential/Random cycle, **🧹 Usage** clears all character usage counters, and **🗑 Cache** deletes cached thumbnails — each asks for confirmation first. Usage badges always show, tucked into the top-right corner as a rounded flap that grows with the usage count. With rank borders enabled, the three most-used cards also glow gold/silver/bronze and cards with 1,000+ uses get an animated RGB border (both 150% of the normal border thickness). Use the **✎ / ⧉ / ✕** icons in the top bar to edit, duplicate or delete the selected card; they are enabled only when exactly one card is selected.
7. **Prompt-driven picking**: wire your prompt text into the `text` input and the picker highlights the card whose trigger appears in it (the earliest trigger in the text wins; enable `match_all` for several). While text is connected the grid is read-only; disconnect it or clear the text and your clicked cards are used again. After each run the card that was actually used flashes on the canvas.
8. The card's **prompt snippet** is what the `positive` output emits. Triggers are only used for detection; they are not injected into the prompt. A card without a snippet falls back to its display name.
@@ -136,7 +136,8 @@ Several nodes across this suite (e.g. *Load Image From Folder*, *Recent Outputs*
Click the standard ComfyUI Settings gear ⚙️ and search for `SaturnNodes` to access the suite's configurations. Here you can:
- Safely enter and mask API Keys for Civitai and TMDB scraping.
- Toggle LoRA and Character Preset usage tracking (counts, badges and `Usage` sorting); disabling either keeps the existing history.
- Toggle **Enable Character Preset Rank Borders** to switch between glowing gold/silver/bronze + 1,000-use RGB tile borders and plain usage count badges.
- Toggle **Enable Character Preset Rank Borders** to add gold/silver/bronze + 1,000-use RGB rings on top of the always-visible (and usage-sized) corner badges.
- Choose the **Dock Mode when Idle** for the floating Live Latent dock: `Auto-Hide`, `Keep Dock Visible`, or `View Image` (shows the decoded final output until the next run's first latent).
- Change the colors of the Pause Queue buttons via built-in color pickers.
- Adjust the default boot behavior for the Persistent Queue (e.g., forcing ComfyUI to always boot in a Paused state), retry limits for failed queue restores, and whether the last manual pause state survives a restart.
- Tune the Visual Image Loader metadata scan limit, restore nested output folders, show recorded generation times on restored assets, and trigger **Restore Now** without restarting.
+50 -14
View File
@@ -281,7 +281,7 @@ assert.ok(node.widgets.find((widget) => widget.name === "tile_size"), "the picke
const tiles = ui.grid.querySelectorAll(".saturn-char-tile");
assert.equal(tiles.length, 3, "every card renders a tile");
assert.equal(ui.grid.querySelectorAll(".saturn-char-trigger").length, 0, "tiles no longer show a trigger line");
assert.ok(tiles[0].querySelector(".saturn-char-actions"), "tile actions live in the top overlay");
assert.equal(tiles[0].querySelector(".saturn-char-actions"), null, "tile actions moved out of the tile overlays");
assert.ok(ui.grid.classList.contains("scrollable"), "default display mode scrolls");
assert.equal(ui.grid.querySelectorAll(".saturn-char-folder-grid").length, 2, "tiles live in folder groups");
@@ -311,6 +311,9 @@ assert.deepEqual(
"\uFF0B",
"\u21BB",
"\uD83D\uDCC2",
"\u270e",
"\u29c9",
"\u2715",
],
"the extra picker actions use icon buttons"
);
@@ -342,21 +345,53 @@ hidden.value = "[]";
ui.updateSelectionStyles();
assert.equal(ui.selectionCountEl.textContent, "No cards selected", "empty selection is explicit");
// Rank borders: the most-used card gets the gold ring; usage badges are
// replaced while the setting is enabled.
// Edit/Duplicate/Delete live in the top control bar and stay disabled unless
// exactly one card is selected.
hidden.value = "[]";
ui.updateSelectionStyles();
assert.ok(ui.editCardButton.disabled && ui.duplicateCardButton.disabled && ui.deleteCardButton.disabled,
"card actions are disabled with no selection");
hidden.value = JSON.stringify(["char1", "char2"]);
ui.updateSelectionStyles();
assert.ok(ui.editCardButton.disabled && ui.duplicateCardButton.disabled && ui.deleteCardButton.disabled,
"card actions are disabled with a multi-selection");
hidden.value = JSON.stringify(["char1"]);
ui.updateSelectionStyles();
assert.ok(!ui.editCardButton.disabled && !ui.duplicateCardButton.disabled && !ui.deleteCardButton.disabled,
"card actions enable for exactly one selected card");
ui.editCardButton.click();
await sleep(0);
assert.ok(ui.modal, "the edit action opens the selected card's editor");
ui.modal.remove();
ui.modal = null;
hidden.value = "[]";
ui.updateSelectionStyles();
// Rank borders: the most-used card gets the gold ring while normal badges
// (including the missing-LoRA warning) stay visible.
assert.equal(
ui.grid.querySelector('[data-card-id="char2"]').classList.contains("rank-1"),
true,
"the most-used card gets the rank-1 border"
);
assert.equal(
ui.grid.querySelector('[data-card-id="char2"] .saturn-char-badge.usage'),
null,
"rank borders replace usage count badges"
assert.ok(
ui.grid.querySelector('[data-card-id="char2"] .saturn-char-badge.warn'),
"warning badges remain visible with rank borders enabled"
);
// A 1000+ usage card gets the animated RGB border instead of a badge.
// Usage badges are kept and scale with the usage count.
const originalUsage = CARDS[0].usage;
CARDS[0].usage = 5;
ui.render();
const rankedUsage = ui.grid.querySelector('[data-card-id="char1"] .saturn-char-badge.usage');
assert.ok(rankedUsage, "usage badges are kept when rank borders are enabled");
assert.equal(rankedUsage.textContent, "5", "the usage badge shows the count");
assert.ok(
parseFloat(rankedUsage.style.getPropertyValue("--usage-scale")) > 1,
"the usage badge grows with the usage count"
);
// A 1000+ usage card gets the animated RGB border and keeps its badge.
CARDS[0].usage = 1200;
ui.render();
assert.equal(
@@ -364,19 +399,20 @@ assert.equal(
true,
"1000+ usage adds the RGB border class"
);
assert.equal(
ui.grid.querySelector('[data-card-id="char1"] .saturn-char-badge.usage'),
null,
"the 1k border also suppresses the usage badge"
const bigBadge = ui.grid.querySelector('[data-card-id="char1"] .saturn-char-badge.usage');
assert.ok(bigBadge, "the 1k border keeps the usage badge");
assert.ok(
parseFloat(bigBadge.style.getPropertyValue("--usage-scale")) > 1.5,
"large usage counts make the corner badge noticeably bigger"
);
// Disabling the setting restores plain usage badges and removes rank rings.
// Disabling the setting keeps the badges and removes the rank rings.
app.ui.settings.values["SaturnNodes.🖼️ Visual Loaders.05b_CharacterRankBorders"] = false;
CARDS[0].usage = 5;
ui.render();
assert.ok(
ui.grid.querySelector('[data-card-id="char1"] .saturn-char-badge.usage'),
"disabled rank borders restore the usage badge"
"usage badges stay visible with rank borders disabled"
);
assert.equal(
ui.grid.querySelector('[data-card-id="char1"]').classList.contains("rank-1"),
+64 -32
View File
@@ -130,64 +130,96 @@ document.body.appendChild(toolbar);
FloatingLatentDock.prototype.updatePosition.call(dock);
assert.equal(dock.dockEl.style.bottom, "88px", "dock must sit 8px above the canvas toolbar");
// 5. Auto-hide: a run start must not reveal the dock (it would show the
// previous run's latent). Only a fresh preview of the current run reveals it.
const makeAutoHideDock = () => {
// 5. Idle dock modes: Auto-Hide and View Image start hidden and only show
// once a fresh latent arrives; Keep Dock Visible always shows. View Image
// additionally stays up when a decoded final image is displayed.
let apiHandlers = {};
const makeModeDock = (mode) => {
const dockEl = document.createElement("div");
dockEl.classList.add("saturn-dock-hidden");
const settings = {
"03_AutoHideWhenIdle": true,
"03_DockModeWhenIdle": mode,
"01_EnableFloatingDock": true,
};
return {
dockEl,
btnEl: null,
isVisible: true,
isGenerating: false,
idleHidden: true,
runSawLatent: false,
displayedFinalImage: false,
pendingFinalImage: false,
statusBadge: null,
lightboxEl: null,
getSetting(id, fallback) {
const key = Object.keys(settings).find(name => id.includes(name));
return key ? settings[key] : fallback;
},
updateVisibility: FloatingLatentDock.prototype.updateVisibility,
requestPositionUpdate() {},
updatePosition() {},
applyDockMode: FloatingLatentDock.prototype.applyDockMode,
getDockMode: FloatingLatentDock.prototype.getDockMode,
onExecuted: FloatingLatentDock.prototype.onExecuted,
};
};
const autoDock = makeAutoHideDock();
const apiHandlers = {};
context.api.addEventListener = (name, callback) => {
apiHandlers[name] = callback;
const registerHandlers = (target) => {
apiHandlers = {};
context.api.addEventListener = (name, callback) => {
apiHandlers[name] = callback;
};
FloatingLatentDock.prototype.setupExecutionListeners.call(target);
};
FloatingLatentDock.prototype.setupExecutionListeners.call(autoDock);
assert.equal(typeof apiHandlers.execution_start, "function", "execution_start handler must register");
assert.equal(
FloatingLatentDock.prototype.maybeRevealForPreview.call(autoDock),
false,
"a preview outside a running prompt (page-load replay) must not reveal the dock"
);
assert.ok(autoDock.dockEl.classList.contains("saturn-dock-hidden"));
const autoDock = makeModeDock("Auto-Hide");
autoDock.applyDockMode();
assert.ok(autoDock.dockEl.classList.contains("saturn-dock-hidden"), "Auto-Hide starts hidden");
registerHandlers(autoDock);
assert.equal(typeof apiHandlers.execution_start, "function", "execution_start handler must register");
assert.equal(typeof apiHandlers.executed, "function", "executed handler must register");
apiHandlers.execution_start();
assert.ok(
autoDock.dockEl.classList.contains("saturn-dock-hidden"),
"execution_start must keep the dock hidden instead of revealing the stale latent"
"execution_start must keep Auto-Hide hidden until the first fresh latent"
);
autoDock.isGenerating = true;
assert.equal(
FloatingLatentDock.prototype.maybeRevealForPreview.call(autoDock),
true,
"the first fresh preview of the run reveals the dock"
);
assert.ok(!autoDock.dockEl.classList.contains("saturn-dock-hidden"));
// Simulate the first fresh latent of the run.
autoDock.runSawLatent = true;
autoDock.idleHidden = false;
autoDock.updateVisibility();
assert.ok(!autoDock.dockEl.classList.contains("saturn-dock-hidden"), "first latent reveals Auto-Hide");
autoDock.isVisible = false;
autoDock.dockEl.classList.add("saturn-dock-hidden");
assert.equal(
FloatingLatentDock.prototype.maybeRevealForPreview.call(autoDock),
false,
"a user-hidden dock must stay hidden"
apiHandlers.status({ detail: { exec_info: { queue_remaining: 0 } } });
assert.ok(autoDock.dockEl.classList.contains("saturn-dock-hidden"), "Auto-Hide hides when the queue finishes");
const keepDock = makeModeDock("Keep Dock Visible");
keepDock.applyDockMode();
assert.ok(!keepDock.dockEl.classList.contains("saturn-dock-hidden"), "Keep Dock Visible starts shown");
registerHandlers(keepDock);
apiHandlers.execution_start();
assert.ok(!keepDock.dockEl.classList.contains("saturn-dock-hidden"), "Keep Dock Visible never auto-hides");
const viewDock = makeModeDock("View Image");
viewDock.applyDockMode();
assert.ok(viewDock.dockEl.classList.contains("saturn-dock-hidden"), "View Image starts hidden");
viewDock.displayedFinalImage = true;
viewDock.isGenerating = false;
viewDock.applyDockMode();
assert.ok(!viewDock.dockEl.classList.contains("saturn-dock-hidden"), "View Image shows a decoded final image while idle");
viewDock.displayedFinalImage = false;
viewDock.idleHidden = true;
viewDock.isGenerating = false;
viewDock.updateVisibility();
registerHandlers(viewDock);
apiHandlers.execution_start();
assert.ok(
viewDock.dockEl.classList.contains("saturn-dock-hidden"),
"View Image stays hidden until the next run's first latent when no final image is shown"
);
assert.ok(autoDock.dockEl.classList.contains("saturn-dock-hidden"));
console.log("floating dock DOM regression test passed");
+155 -30
View File
@@ -13,11 +13,18 @@ import { PreviewManager } from "./js/preview_manager.js";
* - Pinned fully to the right (8px).
* 3. Takes the exact shape/aspect ratio of the latent being generated.
* 4. Full pixel-quality rendering on high-DPI canvas without compression or blur.
* 5. Configurable settings (enable/disable, max size, auto-hide when idle).
* 5. Configurable settings (enable/disable, max size, idle mode: Auto-Hide,
* Keep Dock Visible, or View Image).
*/
const STORAGE_KEY_VISIBLE = "SaturnNodes.LivePreview.FloatingDockVisible";
// Idle behaviour for the dock. Replaces the old boolean auto-hide setting.
const DOCK_MODE_KEY = "SaturnNodes.👁️ Live Preview.03_DockModeWhenIdle";
const DOCK_MODE_AUTO_HIDE = "Auto-Hide";
const DOCK_MODE_KEEP = "Keep Dock Visible";
const DOCK_MODE_VIEW_IMAGE = "View Image";
// Absolute dock size bounds ("extreme but not absurd"): 100px .. 2000px
const DOCK_MIN_SIZE = 100;
const DOCK_MAX_SIZE = 2000;
@@ -40,6 +47,16 @@ class FloatingLatentDock {
this.isGenerating = false;
this.latestSamplerId = null;
// Idle-mode state. `idleHidden` is a transient overlay on top of the
// user's visibility preference; `runSawLatent` tracks whether the
// current run has produced a preview yet; `displayedFinalImage` marks
// that the canvas currently shows a decoded output image.
this.idleHidden = false;
this.runSawLatent = false;
this.displayedFinalImage = false;
this.pendingFinalImage = false;
this.finalImageToken = 0;
// Inspecting Lightbox state (matching ComfyUI core MediaLightbox)
this.lightboxEl = null;
this.lightboxImg = null;
@@ -67,6 +84,9 @@ class FloatingLatentDock {
localStorage.setItem(STORAGE_KEY_VISIBLE, String(val));
} catch (_) {}
}
// Manually toggling the dock on always reveals it, even when the
// current idle mode would normally keep it hidden.
if (val) this.idleHidden = false;
this.updateVisibility();
}
@@ -78,6 +98,14 @@ class FloatingLatentDock {
return defaultVal;
}
getDockMode() {
const raw = this.getSetting(DOCK_MODE_KEY, DOCK_MODE_AUTO_HIDE);
const value = String(raw || "").trim().toLowerCase();
if (value === DOCK_MODE_KEEP.toLowerCase() || value === "keep dock visible") return DOCK_MODE_KEEP;
if (value === DOCK_MODE_VIEW_IMAGE.toLowerCase() || value === "view image") return DOCK_MODE_VIEW_IMAGE;
return DOCK_MODE_AUTO_HIDE;
}
getScreenProfileKey() {
if (typeof window === "undefined" || !window.screen) return "default";
return `${window.screen.width}x${window.screen.height}`;
@@ -113,22 +141,48 @@ class FloatingLatentDock {
init() {
this.injectStyles();
// Auto-Hide and View Image both start hidden until the first latent
// of the session arrives; Keep Dock Visible starts shown.
this.idleHidden = this.getDockMode() !== DOCK_MODE_KEEP;
this.buildDockElement();
this.registerWithPreviewManager();
this.setupExecutionListeners();
this.setupPositionObserver();
this.injectToolbarButtonLoop();
this.setupScreenProfileWatcher();
this.applyDockMode();
// The SaturnNodes settings extension may register after this one on
// some frontend builds. Re-apply once the persisted setting is loaded.
setTimeout(() => {
if (this.dockEl) this.applyDockMode();
}, 1000);
}
// Recompute the transient idle visibility from the current mode.
applyDockMode() {
const mode = this.getDockMode();
if (mode === DOCK_MODE_KEEP) {
this.idleHidden = false;
} else if (this.isGenerating && this.runSawLatent) {
this.idleHidden = false;
} else if (mode === DOCK_MODE_VIEW_IMAGE && this.displayedFinalImage && !this.isGenerating) {
this.idleHidden = false;
} else {
this.idleHidden = true;
}
this.updateVisibility();
if (!this.idleHidden) this.requestPositionUpdate();
}
setupScreenProfileWatcher() {
let lastScreenKey = this.getScreenProfileKey();
window.addEventListener("saturnnodes:dock-mode-change", () => this.applyDockMode());
window.addEventListener("resize", () => {
const currentKey = this.getScreenProfileKey();
if (currentKey !== lastScreenKey) {
lastScreenKey = currentKey;
if (this.currentImg) {
this.onNewPreview(this.currentImg, this.latestSamplerId);
this.renderImageToDock(this.currentImg);
} else if (this.placeholderEl && this.dockEl) {
const mw = this.getMaxWidth();
const mh = this.getMaxHeight();
@@ -615,7 +669,7 @@ class FloatingLatentDock {
// Snap back to the exact latent fit within the new limits
if (this.currentImg) {
this.onNewPreview(this.currentImg, this.latestSamplerId);
this.renderImageToDock(this.currentImg);
}
};
@@ -813,10 +867,21 @@ class FloatingLatentDock {
}
if (this.lightboxBadge) {
const statusText = this.isGenerating ? "● Sampling" : "● Live Latent";
let statusText = "● Live Latent";
let statusColor = "#10b981";
if (this.isGenerating) {
statusText = "● Sampling";
statusColor = "#f59e0b";
} else if (this.pendingFinalImage && !this.displayedFinalImage) {
statusText = "● Finalizing";
statusColor = "#f59e0b";
} else if (this.displayedFinalImage) {
statusText = "● Final Image";
statusColor = "#10b981";
}
const dimsText = (nw && nh) ? `${nw}×${nh}` : "";
this.lightboxBadge.innerHTML = `
<span style="color: ${this.isGenerating ? '#f59e0b' : '#10b981'}; font-weight: 600;">${statusText}</span>
<span style="color: ${statusColor}; font-weight: 600;">${statusText}</span>
${dimsText ? `<span style="opacity: 0.5;">|</span><span>${dimsText}</span>` : ""}
`;
}
@@ -859,16 +924,22 @@ class FloatingLatentDock {
api.addEventListener("execution_start", () => {
this.isGenerating = true;
this.runSawLatent = false;
this.pendingFinalImage = false;
// Cancel any still-pending output load from the previous run, but
// keep an already displayed final image on screen until this run's
// first latent replaces it.
this.finalImageToken += 1;
if (this.statusBadge) {
this.statusBadge.textContent = "Sampling";
this.statusBadge.classList.add("generating");
}
if (this.getSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false) && this.isVisible) {
// The canvas still holds the previous run's latent. Keep the dock
// hidden until the first fresh preview of this run arrives, so it
// never pops up showing stale pixels.
this.dockEl?.classList.add("saturn-dock-hidden");
// Auto-Hide clears immediately. View Image keeps the previous
// final image on screen until this run's first latent arrives.
if (this.getDockMode() === DOCK_MODE_AUTO_HIDE) {
this.idleHidden = true;
}
this.updateVisibility();
if (this.lightboxEl) {
this.updateLightbox();
}
@@ -880,14 +951,24 @@ class FloatingLatentDock {
this.statusBadge.textContent = "Done";
this.statusBadge.classList.remove("generating");
}
if (this.getSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false)) {
this.dockEl?.classList.add("saturn-dock-hidden");
const mode = this.getDockMode();
if (mode === DOCK_MODE_AUTO_HIDE) {
this.idleHidden = true;
} else if (mode === DOCK_MODE_VIEW_IMAGE) {
// Stay visible with the last latent until the decoded output
// image replaces it. If nothing was drawn this run, keep the
// previous final image (or stay hidden if there is none).
this.pendingFinalImage = !this.displayedFinalImage;
this.idleHidden = !this.runSawLatent && !this.displayedFinalImage;
}
this.updateVisibility();
if (this.lightboxEl) {
this.updateLightbox();
}
};
api.addEventListener("executed", (event) => this.onExecuted(event));
api.addEventListener("status", (e) => {
const queueRemaining = e.detail?.exec_info?.queue_remaining ?? 0;
if (queueRemaining === 0) {
@@ -899,14 +980,73 @@ class FloatingLatentDock {
api.addEventListener("execution_error", handleExecutionEnd);
}
// View Image mode: once the final output is decoded, swap the dock canvas
// over to it and keep it there until the next run's first latent.
onExecuted(event) {
if (this.getDockMode() !== DOCK_MODE_VIEW_IMAGE) return;
const output = event?.detail?.output;
const images = Array.isArray(output?.images) ? output.images : [];
if (!images.length) return;
// Prefer saved output images over temporary previews.
const chosen =
[...images].reverse().find((item) => item && item.filename && (item.type === "output" || !item.type)) ||
[...images].reverse().find((item) => item && item.filename);
if (!chosen) return;
const token = ++this.finalImageToken;
const img = new Image();
img.onload = () => {
if (token !== this.finalImageToken) return;
this.displayedFinalImage = true;
this.pendingFinalImage = false;
this.idleHidden = false;
this.currentImg = img;
if (this.titleTextEl) this.titleTextEl.textContent = "Final Image";
this.renderImageToDock(img);
this.updateVisibility();
this.requestPositionUpdate();
};
img.src = this.buildViewUrl(chosen);
}
buildViewUrl(image) {
const params = new URLSearchParams({
filename: image.filename || "",
subfolder: image.subfolder || "",
type: image.type || "output",
});
const path = `/view?${params.toString()}`;
try {
if (api?.apiURL) return api.apiURL(path);
} catch (_) {}
return path;
}
onNewPreview(img, samplerId) {
if (!img || !this.canvasEl) return;
this.currentImg = img;
this.latestSamplerId = samplerId;
if (!this.renderImageToDock(img)) return;
this.runSawLatent = true;
this.displayedFinalImage = false;
this.pendingFinalImage = false;
if (this.titleTextEl) this.titleTextEl.textContent = "Live Latent";
// The dock only appears once this run's first fresh latent is drawn,
// never on execution_start with the previous run's image on screen.
this.idleHidden = false;
this.updateVisibility();
this.requestPositionUpdate();
}
// Shared canvas renderer used by both live latents and the final decoded
// output image in View Image mode.
renderImageToDock(img) {
if (!img || !this.canvasEl) return false;
const nw = img.naturalWidth || img.width;
const nh = img.naturalHeight || img.height;
if (!nw || !nh) return;
if (!nw || !nh) return false;
const maxW = this.getMaxWidth();
const maxH = this.getMaxHeight();
@@ -954,27 +1094,12 @@ class FloatingLatentDock {
if (this.lightboxEl) {
this.updateLightbox();
}
// Auto-hide mode: the dock only appears once this run's first fresh
// latent is drawn, never on execution_start with the previous run's
// image still on the canvas.
this.maybeRevealForPreview();
this.requestPositionUpdate();
}
maybeRevealForPreview() {
if (!this.isGenerating) return false;
if (!this.isVisible) return false;
if (!this.getSetting("SaturnNodes.👁️ Live Preview.01_EnableFloatingDock", true)) return false;
if (!this.getSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false)) return false;
this.dockEl?.classList.remove("saturn-dock-hidden");
return true;
}
updateVisibility() {
const isMasterEnabled = this.getSetting("SaturnNodes.👁️ Live Preview.01_EnableFloatingDock", true);
const shouldShow = isMasterEnabled && this.isVisible;
const shouldShow = isMasterEnabled && this.isVisible && !this.idleHidden;
if (this.dockEl) {
if (shouldShow) {
@@ -1062,7 +1187,7 @@ class FloatingLatentDock {
}
requestPositionUpdate() {
if (!this.dockEl || this.dockEl.classList.contains("saturn-dock-hidden") || !this.isVisible) return;
if (!this.dockEl || this.dockEl.classList.contains("saturn-dock-hidden") || !this.isVisible || this.idleHidden) return;
if (this._posUpdatePending) return;
this._posUpdatePending = true;
requestAnimationFrame(() => {
+89 -70
View File
@@ -38,6 +38,15 @@ function getRankBordersEnabled() {
return true;
}
// The corner usage badge grows with the usage count. A logarithmic curve keeps
// low counts subtle while letting heavily used cards stand out, capped so the
// label never swallows the thumbnail.
function usageBadgeScale(usage) {
const n = Math.max(0, Number(usage) || 0);
if (n <= 0) return 1;
return Math.min(2.1, 1 + Math.log10(n + 1) * 0.42);
}
const MODE_HINTS = {
Sequential: "Cycles through the selected cards, one per run.",
Random: "Picks one random card from the selection each run.",
@@ -426,48 +435,57 @@ const PICKER_CSS = `
z-index: 3;
}
.saturn-char-tile.matched .saturn-char-match-pill { display: block; }
/* Usage badge: a corner flap flush with the top and right edges, rounded
along the inner (bottom-left) edge. Its size grows with the usage count
via the --usage-scale custom property. */
.saturn-char-badge {
position: absolute;
top: 4px;
right: 4px;
top: 0;
right: 0;
z-index: 2;
padding: 1px 4px;
border-radius: 8px;
background: rgba(0, 122, 204, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: calc(2px * var(--usage-scale, 1)) calc(6px * var(--usage-scale, 1)) calc(3px * var(--usage-scale, 1)) calc(9px * var(--usage-scale, 1));
border-radius: 0 0 0 calc(11px * var(--usage-scale, 1));
background: rgba(0, 122, 204, 0.9);
color: #fff;
font-size: 8px;
font-size: calc(8px * var(--usage-scale, 1));
font-weight: bold;
line-height: 1.15;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
box-shadow: -1px 1px 3px rgba(0, 0, 0, 0.35);
}
.saturn-char-badge.warn {
--usage-scale: 1;
background: rgba(168, 0, 0, 0.9);
}
/* Rank borders replace the usage badges: gold / silver / bronze for the
three most-used cards, plus an animated RGB ring at 1000+ uses. */
/* Rank borders add gold / silver / bronze rings for the three most-used
cards, plus an animated RGB ring at 1000+ uses. They are 150% of the
normal tile border thickness (2px -> 3px). */
.saturn-char-tile.rank-1:not(.selected) {
border-width: 3px !important;
border-color: #ffd700 !important;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.55);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.55);
}
.saturn-char-tile.rank-2:not(.selected) {
border-width: 3px !important;
border-color: #d7dde6 !important;
box-shadow: 0 0 10px rgba(215, 221, 230, 0.5);
box-shadow: 0 0 15px rgba(215, 221, 230, 0.5);
}
.saturn-char-tile.rank-3:not(.selected) {
border-width: 3px !important;
border-color: #cd7f32 !important;
box-shadow: 0 0 10px rgba(205, 127, 50, 0.5);
box-shadow: 0 0 15px rgba(205, 127, 50, 0.5);
}
.saturn-char-tile.usage-1k:not(.selected) {
border-width: 3px !important;
border-color: #ff0055 !important;
animation: saturn-char-rgb-border 4s linear infinite !important;
}
@keyframes saturn-char-rgb-border {
0% { border-color: #ff0055 !important; box-shadow: 0 0 12px rgba(255, 0, 85, 0.8); }
25% { border-color: #ffb300 !important; box-shadow: 0 0 12px rgba(255, 179, 0, 0.8); }
50% { border-color: #19e68c !important; box-shadow: 0 0 12px rgba(25, 230, 140, 0.8); }
75% { border-color: #00a3ff !important; box-shadow: 0 0 12px rgba(0, 163, 255, 0.8); }
100% { border-color: #ff0055 !important; box-shadow: 0 0 12px rgba(255, 0, 85, 0.8); }
0% { border-color: #ff0055 !important; box-shadow: 0 0 18px rgba(255, 0, 85, 0.8); }
25% { border-color: #ffb300 !important; box-shadow: 0 0 18px rgba(255, 179, 0, 0.8); }
50% { border-color: #19e68c !important; box-shadow: 0 0 18px rgba(25, 230, 140, 0.8); }
75% { border-color: #00a3ff !important; box-shadow: 0 0 18px rgba(0, 163, 255, 0.8); }
100% { border-color: #ff0055 !important; box-shadow: 0 0 18px rgba(255, 0, 85, 0.8); }
}
.saturn-char-selection-count {
flex: 0 0 auto;
@@ -480,33 +498,6 @@ const PICKER_CSS = `
overflow: hidden;
text-overflow: ellipsis;
}
.saturn-char-actions {
position: absolute;
top: 0;
left: 0;
right: 0;
display: none;
align-items: center;
justify-content: flex-end;
gap: 3px;
padding: 3px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0));
z-index: 4;
}
.saturn-char-tile:hover .saturn-char-actions { display: flex; }
.saturn-char-actions button {
width: 18px;
height: 18px;
padding: 0;
border: 1px solid #353535;
border-radius: 4px;
background: rgba(32, 32, 32, 0.95);
color: #fff;
font-size: 10px;
line-height: 1;
cursor: pointer;
}
.saturn-char-actions button:hover { background: #007acc; border-color: #0098ff; }
.saturn-char-empty {
width: 100%;
padding: 14px 8px;
@@ -1140,7 +1131,44 @@ class CharacterPickerUI {
}
});
actionGroup.append(this.newButton, this.refreshButton, this.openFolderButton);
// Card actions operate on the single selected card. They sit in the
// top control bar and stay disabled unless exactly one card is
// selected.
this.editCardButton = el("button", "saturn-char-toggle-btn icon", "\u270e");
this.editCardButton.title = "Edit the selected card (select exactly one card)";
this.editCardButton.setAttribute?.("aria-label", "Edit selected card");
this.editCardButton.disabled = true;
this.editCardButton.addEventListener("click", () => {
const card = this.getSingleSelectedCard();
if (card) this.openEditor(card);
});
this.duplicateCardButton = el("button", "saturn-char-toggle-btn icon", "\u29c9");
this.duplicateCardButton.title = "Duplicate the selected card (select exactly one card)";
this.duplicateCardButton.setAttribute?.("aria-label", "Duplicate selected card");
this.duplicateCardButton.disabled = true;
this.duplicateCardButton.addEventListener("click", () => {
const card = this.getSingleSelectedCard();
if (card) this.duplicateCard(card);
});
this.deleteCardButton = el("button", "saturn-char-toggle-btn icon", "\u2715");
this.deleteCardButton.title = "Delete the selected card (select exactly one card)";
this.deleteCardButton.setAttribute?.("aria-label", "Delete selected card");
this.deleteCardButton.disabled = true;
this.deleteCardButton.addEventListener("click", () => {
const card = this.getSingleSelectedCard();
if (card) this.deleteCard(card);
});
actionGroup.append(
this.newButton,
this.refreshButton,
this.openFolderButton,
this.editCardButton,
this.duplicateCardButton,
this.deleteCardButton
);
controlBar.appendChild(actionGroup);
this.statusEl = el("div", "saturn-char-status", "");
@@ -1416,9 +1444,21 @@ class CharacterPickerUI {
});
}
getSingleSelectedCard() {
const selected = readSelected(this.hiddenWidget);
if (selected.length !== 1) return null;
const pool = this.renderedCards || this.cards || [];
return pool.find((card) => card.id === selected[0]) || null;
}
updateSelectionCount() {
const selected = readSelected(this.hiddenWidget);
const single = selected.length === 1 ? this.getSingleSelectedCard() : null;
if (this.editCardButton) this.editCardButton.disabled = !single;
if (this.duplicateCardButton) this.duplicateCardButton.disabled = !single;
if (this.deleteCardButton) this.deleteCardButton.disabled = !single;
if (!this.selectionCountEl) return;
const count = readSelected(this.hiddenWidget).length;
const count = selected.length;
this.selectionCountEl.textContent = count === 0
? "No cards selected"
: `${count} card${count === 1 ? "" : "s"} selected`;
@@ -1696,38 +1736,17 @@ class CharacterPickerUI {
if (missing.length) {
badge = el("div", "saturn-char-badge warn", `\u26a0 ${missing.length}`);
badge.title = `Missing LoRA files:\n${missing.join("\n")}`;
} else if (card.usage && !getRankBordersEnabled()) {
} else if (card.usage) {
badge = el("div", "saturn-char-badge usage", String(card.usage));
badge.style.setProperty("--usage-scale", String(usageBadgeScale(card.usage)));
badge.title = `Used ${card.usage} time${card.usage === 1 ? "" : "s"}`;
}
const label = el("div", "saturn-char-label");
label.appendChild(el("div", "saturn-char-name", card.name));
const actions = el("div", "saturn-char-actions");
const editButton = el("button", null, "\u270e");
editButton.title = "Edit card";
editButton.addEventListener("click", (event) => {
event.stopPropagation();
this.openEditor(card);
});
const duplicateButton = el("button", null, "\u29c9");
duplicateButton.title = "Duplicate card";
duplicateButton.addEventListener("click", (event) => {
event.stopPropagation();
this.duplicateCard(card);
});
const deleteButton = el("button", null, "\u2715");
deleteButton.title = "Delete card";
deleteButton.addEventListener("click", (event) => {
event.stopPropagation();
this.deleteCard(card);
});
actions.append(editButton, duplicateButton, deleteButton);
tile.append(thumb, label, orderBadge, matchPill);
if (badge) tile.appendChild(badge);
tile.appendChild(actions);
if (selected.has(card.id)) tile.classList.add("selected");
if (this.matchedIds.has(card.id)) tile.classList.add("matched");
+17 -7
View File
@@ -1154,19 +1154,29 @@ app.registerExtension({
}
});
// 10.3 Auto-Hide Dock When Idle (default: false)
// 10.3 Dock Mode when Idle (default: Auto-Hide)
app.ui.settings.addSetting({
id: "SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle",
name: "Auto-Hide Dock When Idle",
type: "boolean",
defaultValue: getInitialSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false),
tooltip: "When enabled, the floating dock automatically hides when generation is idle and appears when sampling begins.",
id: "SaturnNodes.👁️ Live Preview.03_DockModeWhenIdle",
name: "Dock Mode when Idle",
type: "combo",
options: ["Auto-Hide", "Keep Dock Visible", "View Image"],
defaultValue: getInitialSetting("SaturnNodes.👁️ Live Preview.03_DockModeWhenIdle", "Auto-Hide"),
tooltip:
"Auto-Hide: hide the dock when idle and show it when sampling. " +
"Keep Dock Visible: always show the dock. " +
"View Image: after a run finishes, show the decoded final output image until the next run's first latent.",
onChange(value) {
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem("Comfy.Settings.SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", JSON.stringify(Boolean(value)));
localStorage.setItem(
"Comfy.Settings.SaturnNodes.👁️ Live Preview.03_DockModeWhenIdle",
JSON.stringify(String(value))
);
} catch (_) {}
}
try {
window.dispatchEvent(new CustomEvent("saturnnodes:dock-mode-change", { detail: { mode: value } }));
} catch (_) {}
}
});