1905 lines
86 KiB
JavaScript
1905 lines
86 KiB
JavaScript
import { app } from "/scripts/app.js";
|
|
import { api } from "/scripts/api.js";
|
|
import { isSaturnColorsEnabled } from "../saturnnodes_colors.js";
|
|
import { authenticatedFetch } from "./auth_helper.js";
|
|
import { resolveLiveWidgetElement } from "./live_dom.js";
|
|
|
|
// Hash folder name to get a consistent color for the border
|
|
function getFolderBorderColor(folderName) {
|
|
if (!folderName || folderName === "Root" || folderName === "[ NONE ]" || folderName === "[ RANDOM ]") return null;
|
|
let hash = 5381;
|
|
for (let i = 0; i < folderName.length; i++) {
|
|
hash = ((hash << 5) + hash) + folderName.charCodeAt(i);
|
|
}
|
|
// Multiply by a large prime to distribute close values (like "1", "2", "3") widely across the 360 hue spectrum
|
|
let h = Math.abs(hash * 2246822519) % 360;
|
|
// Shift away from green spectrum (80-160) to avoid confusion with the selection highlight
|
|
if (h >= 80 && h <= 160) {
|
|
h = (h + 100) % 360;
|
|
}
|
|
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 = `
|
|
.lora-visual-container.right-aligned-mode .lora-folder-grid,
|
|
.lora-visual-container.right-aligned-mode .lora-none-container {
|
|
display: flex !important;
|
|
flex-wrap: wrap !important;
|
|
justify-content: flex-end !important;
|
|
gap: 8px !important;
|
|
}
|
|
.lora-visual-container .lora-folder-grid,
|
|
.lora-visual-container .lora-none-container {
|
|
display: flex !important;
|
|
flex-wrap: wrap !important;
|
|
justify-content: flex-start !important;
|
|
gap: 8px !important;
|
|
}
|
|
.lora-visual-container .lora-tile {
|
|
width: var(--lora-tile-size, 80px) !important;
|
|
flex-shrink: 0 !important;
|
|
}
|
|
.lora-visual-container {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #111;
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
box-sizing: border-box;
|
|
height: 100%;
|
|
min-height: 0;
|
|
max-height: 100%;
|
|
overflow-y: hidden;
|
|
}
|
|
.lora-grid-container {
|
|
flex: 1 1 0px;
|
|
min-height: 0;
|
|
max-height: 100%;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding-right: 4px;
|
|
}
|
|
.lora-grid-container::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
.lora-grid-container::-webkit-scrollbar-track {
|
|
background: rgba(0,0,0,0.1);
|
|
border-radius: 4px;
|
|
}
|
|
.lora-grid-container::-webkit-scrollbar-thumb {
|
|
background: rgba(255,255,255,0.15);
|
|
border-radius: 4px;
|
|
}
|
|
.lora-grid-container::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255,255,255,0.3);
|
|
}
|
|
.lora-visual-container.grid-layout {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(var(--lora-tile-size, 80px), 1fr));
|
|
}
|
|
.lora-visual-container.flex-layout {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.lora-none-container {
|
|
width: 100%;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(var(--lora-tile-size, 80px), 1fr));
|
|
margin-bottom: 10px;
|
|
}
|
|
.lora-control-bar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
row-gap: 6px;
|
|
padding: 4px 6px;
|
|
background: #181818;
|
|
border: 1px solid #282828;
|
|
border-radius: 5px;
|
|
margin-bottom: 8px;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
.lora-toggle-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.lora-toggle-label {
|
|
font-size: 10px;
|
|
color: #888;
|
|
font-weight: bold;
|
|
margin-right: 4px;
|
|
}
|
|
.lora-toggle-btn {
|
|
background: #252525;
|
|
border: 1px solid #353535;
|
|
color: #aaa;
|
|
font-size: 9px;
|
|
font-weight: bold;
|
|
padding: 3px 8px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease-in-out;
|
|
}
|
|
.lora-toggle-btn:hover {
|
|
background: #303030;
|
|
color: #fff;
|
|
}
|
|
.lora-toggle-btn.active {
|
|
background: #007acc;
|
|
border-color: #0098ff;
|
|
color: #fff;
|
|
box-shadow: 0 0 5px rgba(0, 122, 204, 0.4);
|
|
}
|
|
.lora-folder-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
margin-bottom: 12px;
|
|
width: 100%;
|
|
}
|
|
.lora-folder-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 6px 10px;
|
|
background: linear-gradient(90deg, #1d1d1d, #141414);
|
|
border: 1px solid #262626;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: background 0.15s, border-color 0.15s;
|
|
}
|
|
.lora-folder-header:hover {
|
|
background: linear-gradient(90deg, #242424, #1a1a1a);
|
|
border-color: #383838;
|
|
}
|
|
.lora-folder-toggle {
|
|
font-size: 9px;
|
|
color: #888;
|
|
margin-right: 8px;
|
|
transition: transform 0.2s ease-in-out;
|
|
}
|
|
.lora-folder-header.collapsed .lora-folder-toggle {
|
|
transform: rotate(-90deg);
|
|
}
|
|
.lora-folder-name {
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
color: #d1d1d1;
|
|
flex-grow: 1;
|
|
}
|
|
.lora-folder-count {
|
|
font-size: 10px;
|
|
color: #555;
|
|
margin-right: 10px;
|
|
}
|
|
.lora-folder-checkbox {
|
|
cursor: pointer;
|
|
width: 13px;
|
|
height: 13px;
|
|
accent-color: #007acc;
|
|
margin: 0;
|
|
}
|
|
.lora-folder-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(var(--lora-tile-size, 80px), 1fr));
|
|
gap: 8px;
|
|
padding: 4px 2px;
|
|
}
|
|
.right-aligned-mode .lora-folder-grid {
|
|
direction: rtl;
|
|
}
|
|
.right-aligned-mode .lora-tile {
|
|
direction: ltr;
|
|
}
|
|
.right-aligned-mode .lora-folder-header {
|
|
flex-direction: row-reverse;
|
|
}
|
|
.right-aligned-mode .lora-folder-toggle {
|
|
margin-right: 0;
|
|
margin-left: 8px;
|
|
}
|
|
.right-aligned-mode .lora-folder-count {
|
|
margin-right: 0;
|
|
margin-left: 10px;
|
|
}
|
|
.lora-folder-container.collapsed .lora-folder-grid {
|
|
display: none !important;
|
|
}
|
|
.lora-tile {
|
|
aspect-ratio: 3/4;
|
|
background: #1a1a1a;
|
|
border: 2px solid #333;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
overflow: hidden;
|
|
position: relative;
|
|
box-sizing: border-box;
|
|
transition: all 0.15s ease-in-out;
|
|
}
|
|
.lora-tile:hover {
|
|
border-color: #007acc;
|
|
transform: scale(1.03);
|
|
}
|
|
.lora-tile.selected {
|
|
border-color: #00ff66 !important;
|
|
border-width: 4px !important;
|
|
box-shadow: 0 0 12px rgba(0, 255, 102, 0.5);
|
|
}
|
|
.lora-order-badge {
|
|
position: absolute;
|
|
top: 4px;
|
|
left: 4px;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
padding: 0 3px;
|
|
box-sizing: border-box;
|
|
border-radius: 8px;
|
|
background: #00ff66;
|
|
color: #06240f;
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
line-height: 1;
|
|
pointer-events: none;
|
|
z-index: 3;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.45);
|
|
}
|
|
.lora-tile.selected .lora-order-badge {
|
|
display: flex;
|
|
}
|
|
.lora-tile img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
}
|
|
.lora-tile-fallback {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(135deg, #222, #111);
|
|
color: #888;
|
|
font-size: 10px;
|
|
text-align: center;
|
|
padding: 10px;
|
|
box-sizing: border-box;
|
|
}
|
|
.lora-tile-none {
|
|
background: linear-gradient(135deg, #a80000, #3a0000) !important;
|
|
color: #fff !important;
|
|
font-weight: bold;
|
|
}
|
|
.lora-tile-random {
|
|
background: linear-gradient(135deg, #a85500, #3a1a00) !important;
|
|
color: #fff !important;
|
|
font-weight: bold;
|
|
}
|
|
.lora-tile-label {
|
|
position: absolute;
|
|
bottom: 0;
|
|
width: 100%;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
color: #fff;
|
|
font-size: 10px;
|
|
padding: 4px 2px;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
border-top: 1px solid #222;
|
|
}
|
|
.lora-tile-badge {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
background: rgba(0, 122, 204, 0.85);
|
|
color: #fff;
|
|
font-size: 8px;
|
|
font-weight: bold;
|
|
padding: 1px 4px;
|
|
border-radius: 8px;
|
|
pointer-events: none;
|
|
z-index: 2;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
}
|
|
/* Top 3 Badges styling */
|
|
.lora-tile-badge.rank-1 {
|
|
background: linear-gradient(135deg, #ffd700, #ffaa00) !important;
|
|
color: #000 !important;
|
|
border: 1px solid #fff !important;
|
|
box-shadow: 0 0 6px rgba(255, 215, 0, 0.8) !important;
|
|
}
|
|
.lora-tile-badge.rank-2 {
|
|
background: linear-gradient(135deg, #e0e0e0, #9e9e9e) !important;
|
|
color: #000 !important;
|
|
border: 1px solid #fff !important;
|
|
box-shadow: 0 0 6px rgba(224, 224, 224, 0.8) !important;
|
|
}
|
|
.lora-tile-badge.rank-3 {
|
|
background: linear-gradient(135deg, #cd7f32, #8c521f) !important;
|
|
color: #fff !important;
|
|
border: 1px solid #fff !important;
|
|
box-shadow: 0 0 6px rgba(205, 127, 50, 0.8) !important;
|
|
}
|
|
|
|
/* Over 100x Special Effects & Animations */
|
|
@keyframes pulseGold100 {
|
|
0% { box-shadow: 0 0 8px #ffd700, inset 0 0 4px #ffe600; border-color: #ffd700; }
|
|
50% { box-shadow: 0 0 20px #ffaa00, inset 0 0 10px #ffea00; border-color: #ffffff; }
|
|
100% { box-shadow: 0 0 8px #ffd700, inset 0 0 4px #ffe600; border-color: #ffd700; }
|
|
}
|
|
@keyframes pulseSilver100 {
|
|
0% { box-shadow: 0 0 8px #e2e8f0, inset 0 0 4px #ffffff; border-color: #cbd5e1; }
|
|
50% { box-shadow: 0 0 20px #ffffff, inset 0 0 10px #f8fafc; border-color: #ffffff; }
|
|
100% { box-shadow: 0 0 8px #e2e8f0, inset 0 0 4px #ffffff; border-color: #cbd5e1; }
|
|
}
|
|
@keyframes pulseBronze100 {
|
|
0% { box-shadow: 0 0 8px #cd7f32, inset 0 0 4px #d97706; border-color: #cd7f32; }
|
|
50% { box-shadow: 0 0 20px #f59e0b, inset 0 0 10px #fbbf24; border-color: #ffedd5; }
|
|
100% { box-shadow: 0 0 8px #cd7f32, inset 0 0 4px #d97706; border-color: #cd7f32; }
|
|
}
|
|
@keyframes pulseOver100 {
|
|
0% { box-shadow: 0 0 6px #a855f7; border-color: #a855f7; }
|
|
50% { box-shadow: 0 0 16px #c084fc; border-color: #e9d5ff; }
|
|
100% { box-shadow: 0 0 6px #a855f7; border-color: #a855f7; }
|
|
}
|
|
|
|
.lora-tile.rank-1-100:not(.selected) {
|
|
animation: pulseGold100 2s infinite ease-in-out !important;
|
|
}
|
|
.lora-tile.rank-2-100:not(.selected) {
|
|
animation: pulseSilver100 2s infinite ease-in-out !important;
|
|
}
|
|
.lora-tile.rank-3-100:not(.selected) {
|
|
animation: pulseBronze100 2s infinite ease-in-out !important;
|
|
}
|
|
.lora-tile.over-100:not(.selected) {
|
|
animation: pulseOver100 2s infinite ease-in-out !important;
|
|
}
|
|
|
|
.lora-tile-badge.rank-1-100 {
|
|
background: linear-gradient(135deg, #fff700, #ff8800) !important;
|
|
color: #000 !important;
|
|
border: 1.5px solid #ffffff !important;
|
|
box-shadow: 0 0 10px #ffd700 !important;
|
|
font-weight: 900 !important;
|
|
}
|
|
.lora-tile-badge.rank-2-100 {
|
|
background: linear-gradient(135deg, #ffffff, #64748b) !important;
|
|
color: #000 !important;
|
|
border: 1.5px solid #ffffff !important;
|
|
box-shadow: 0 0 10px #f8fafc !important;
|
|
font-weight: 900 !important;
|
|
}
|
|
.lora-tile-badge.rank-3-100 {
|
|
background: linear-gradient(135deg, #f59e0b, #78350f) !important;
|
|
color: #fff !important;
|
|
border: 1.5px solid #ffffff !important;
|
|
box-shadow: 0 0 10px #f59e0b !important;
|
|
font-weight: 900 !important;
|
|
}
|
|
.lora-tile-badge.over-100 {
|
|
background: linear-gradient(135deg, #a855f7, #581c87) !important;
|
|
color: #fff !important;
|
|
border: 1.5px solid #f3e8ff !important;
|
|
box-shadow: 0 0 10px #c084fc !important;
|
|
font-weight: 900 !important;
|
|
}
|
|
|
|
/* Top 3 Glow styling for non-selected tiles */
|
|
.lora-tile.rank-1:not(.selected) {
|
|
border-color: #ffd700 !important;
|
|
box-shadow: 0 0 8px rgba(255, 215, 0, 0.3) !important;
|
|
}
|
|
.lora-tile.rank-2:not(.selected) {
|
|
border-color: #c0c0c0 !important;
|
|
box-shadow: 0 0 8px rgba(192, 192, 192, 0.3) !important;
|
|
}
|
|
.lora-tile.rank-3:not(.selected) {
|
|
border-color: #cd7f32 !important;
|
|
box-shadow: 0 0 8px rgba(205, 127, 50, 0.3) !important;
|
|
}
|
|
`;
|
|
document.head.appendChild(visualStyles);
|
|
|
|
app.registerExtension({
|
|
name: "ComfyUI.SaturnNodes.FolderLoraLoader",
|
|
async nodeCreated(node) {
|
|
// --- 1. ORIGINAL DROPDOWN NODES ---
|
|
if (node.comfyClass === "FolderLoraLoader" || node.comfyClass === "FolderLoraLoaderPretty" || node.type === "FolderLoraLoader" || node.type === "FolderLoraLoaderPretty") {
|
|
const folderWidget = node.widgets.find(w => w.name === "folder");
|
|
const loraWidget = node.widgets.find(w => w.name === "lora_name");
|
|
|
|
let hiddenWidget = node.widgets.find(w => w.name === "_selected_lora");
|
|
if (!hiddenWidget) {
|
|
hiddenWidget = node.addWidget("text", "_selected_lora", "[ NONE ]", () => {}, { serialize: true });
|
|
}
|
|
hiddenWidget.type = "hidden";
|
|
hiddenWidget.hidden = true;
|
|
hiddenWidget.computeSize = () => [0, 0];
|
|
|
|
if (folderWidget && loraWidget) {
|
|
let activeRequest = null;
|
|
let debounceTimer = null;
|
|
|
|
const updateLoras = async (forceValue = null) => {
|
|
const folder = folderWidget.value || "";
|
|
const pretty = node.comfyClass === "FolderLoraLoaderPretty";
|
|
const currentRequest = Symbol();
|
|
activeRequest = currentRequest;
|
|
|
|
try {
|
|
const response = await api.fetchApi(`/saturnnodes/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=${pretty}`);
|
|
if (activeRequest !== currentRequest) return;
|
|
const data = await response.json();
|
|
|
|
let targetValue = forceValue !== null ? forceValue : (hiddenWidget.value || loraWidget.value);
|
|
loraWidget.options.values = data.names;
|
|
|
|
if (data.names.includes(targetValue)) {
|
|
loraWidget.value = targetValue;
|
|
} else {
|
|
let found = false;
|
|
for (const [displayName, systemPath] of Object.entries(data.mapping)) {
|
|
if (systemPath === targetValue || displayName === targetValue) {
|
|
loraWidget.value = displayName;
|
|
hiddenWidget.value = displayName;
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) {
|
|
loraWidget.value = "[ NONE ]";
|
|
hiddenWidget.value = "[ NONE ]";
|
|
}
|
|
}
|
|
node.triggerSlotEvent?.(0);
|
|
} catch (e) {
|
|
console.error("Failed to fetch filtered LoRAs", e);
|
|
}
|
|
};
|
|
|
|
loraWidget.callback = function(value) {
|
|
hiddenWidget.value = value;
|
|
};
|
|
|
|
folderWidget.callback = () => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => updateLoras(), 350);
|
|
};
|
|
|
|
const originalOnConfigure = node.onConfigure;
|
|
node.onConfigure = function(config) {
|
|
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
|
|
|
const widgetIndex = node.widgets.indexOf(hiddenWidget);
|
|
const savedHiddenValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : hiddenWidget.value;
|
|
|
|
if (savedHiddenValue) {
|
|
hiddenWidget.value = savedHiddenValue;
|
|
loraWidget.options.values = [savedHiddenValue, "[ NONE ]"];
|
|
loraWidget.value = savedHiddenValue;
|
|
setTimeout(() => updateLoras(savedHiddenValue), 100);
|
|
}
|
|
};
|
|
|
|
setTimeout(() => {
|
|
const initValue = hiddenWidget.value || loraWidget.value;
|
|
if (initValue && initValue !== "[ NONE ]") {
|
|
loraWidget.options.values = [initValue, "[ NONE ]"];
|
|
loraWidget.value = initValue;
|
|
}
|
|
updateLoras(initValue);
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
// --- 2. VISUAL PRETTY PICKER V1 ---
|
|
if (node.comfyClass === "FolderLoraLoaderVisualPretty") {
|
|
// Force node size to look wide and accommodate the visual picker viewport
|
|
node.size = [380, 320];
|
|
|
|
node.computeSize = function() {
|
|
return [380, 320];
|
|
};
|
|
|
|
const folderWidget = node.widgets.find(w => w.name === "folder");
|
|
|
|
const getHiddenWidget = (name, defaultVal) => {
|
|
const widgets = node.widgets.filter(w => w.name === name);
|
|
if (widgets.length > 1) {
|
|
for (let i = 1; i < widgets.length; i++) {
|
|
node.widgets.splice(node.widgets.indexOf(widgets[i]), 1);
|
|
}
|
|
}
|
|
if (widgets.length === 1) {
|
|
widgets[0].hidden = true;
|
|
widgets[0].computeSize = () => [0, 0];
|
|
return widgets[0];
|
|
}
|
|
const w = node.addWidget("text", name, defaultVal, () => {}, { serialize: true });
|
|
w.hidden = true;
|
|
w.computeSize = () => [0, 0];
|
|
return w;
|
|
};
|
|
const hiddenWidget = getHiddenWidget("_selected_lora", "[ NONE ]");
|
|
|
|
// Create Visual HTML DOM Element
|
|
const viewContainer = document.createElement("div");
|
|
viewContainer.className = "lora-visual-container grid-layout";
|
|
node.properties = node.properties || {};
|
|
const initialZoom = node.properties["tile_size"] != null
|
|
? node.properties["tile_size"]
|
|
: (localStorage.getItem("comfy_lora_picker_zoom") || "80");
|
|
node.properties["tile_size"] = parseInt(initialZoom);
|
|
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
|
|
|
|
// Embed DOM Widget inside the node container
|
|
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
|
|
getValue() { return hiddenWidget.value; },
|
|
setValue(val) { hiddenWidget.value = val; },
|
|
serialize: false
|
|
});
|
|
|
|
viewContainer.style.setProperty("height", "100%", "important");
|
|
viewContainer.style.setProperty("max-height", "100%", "important");
|
|
viewContainer.style.setProperty("box-sizing", "border-box", "important");
|
|
|
|
domWidget.computeLayoutSize = function() {
|
|
return { minHeight: 160, maxHeight: undefined, minWidth: 300 };
|
|
};
|
|
delete domWidget.computeSize;
|
|
|
|
const onResize = node.onResize;
|
|
node.onResize = function(size) {
|
|
if (onResize) onResize.apply(this, arguments);
|
|
if (size) {
|
|
node.userCustomHeight = size[1];
|
|
}
|
|
};
|
|
|
|
// Add Zoom slider widget
|
|
const zoomWidget = node.addWidget(
|
|
"slider",
|
|
"tile_size",
|
|
parseInt(initialZoom),
|
|
(val) => {
|
|
node.properties = node.properties || {};
|
|
node.properties["tile_size"] = val;
|
|
viewContainer.style.setProperty("--lora-tile-size", `${val}px`);
|
|
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
|
},
|
|
{ min: 50, max: 180, step: 1 }
|
|
);
|
|
zoomWidget.serialize = false;
|
|
|
|
let activeRequest = null;
|
|
let debounceTimer = null;
|
|
|
|
const updateVisualGrid = async () => {
|
|
const folder = folderWidget.value || "";
|
|
const currentRequest = Symbol();
|
|
activeRequest = currentRequest;
|
|
|
|
try {
|
|
const response = await api.fetchApi(`/saturnnodes/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=true`);
|
|
if (activeRequest !== currentRequest) return;
|
|
const data = await response.json();
|
|
|
|
viewContainer.innerHTML = "";
|
|
if (viewContainer._tileObserver) {
|
|
try { viewContainer._tileObserver.disconnect(); } catch (_) {}
|
|
}
|
|
const tileObserver = new IntersectionObserver((entries, observer) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target;
|
|
if (img.dataset.src) {
|
|
img.src = img.dataset.src;
|
|
img.removeAttribute("data-src");
|
|
}
|
|
observer.unobserve(img);
|
|
}
|
|
});
|
|
}, { root: viewContainer, rootMargin: "150px" });
|
|
viewContainer._tileObserver = tileObserver;
|
|
|
|
// Calculate Top 3 Ranks by versionless pretty name
|
|
const getTop3Ranks = () => {
|
|
const prettyCounts = {};
|
|
if (data.usage && data.mapping) {
|
|
Object.entries(data.mapping).forEach(([displayName, systemPath]) => {
|
|
const count = data.usage[systemPath] || 0;
|
|
if (count > 0) {
|
|
let prettyName = displayName;
|
|
if (prettyName.includes(" - ")) {
|
|
prettyName = prettyName.split(" - ", 2)[1];
|
|
}
|
|
prettyName = prettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
|
|
prettyCounts[prettyName] = Math.max(prettyCounts[prettyName] || 0, count);
|
|
}
|
|
});
|
|
}
|
|
|
|
const sorted = Object.entries(prettyCounts)
|
|
.sort((a, b) => b[1] - a[1]);
|
|
|
|
const ranks = { rank1: [], rank2: [], rank3: [] };
|
|
const uniqueCounts = [...new Set(sorted.map(x => x[1]))];
|
|
|
|
sorted.forEach(([name, count]) => {
|
|
if (count === uniqueCounts[0]) ranks.rank1.push(name);
|
|
else if (count === uniqueCounts[1]) ranks.rank2.push(name);
|
|
else if (count === uniqueCounts[2]) ranks.rank3.push(name);
|
|
});
|
|
return ranks;
|
|
};
|
|
const ranks = getTop3Ranks();
|
|
|
|
// Helper to create special tiles (NONE and RANDOM)
|
|
const createSpecialTile = (name, labelText, borderCol, bgClass) => {
|
|
const tile = document.createElement("div");
|
|
tile.className = "lora-tile " + bgClass;
|
|
tile.dataset.loraName = name;
|
|
tile.style.borderColor = borderCol;
|
|
tile.dataset.folderColor = borderCol;
|
|
|
|
const curWidget = getHiddenWidget();
|
|
const hiddenVal = curWidget.value;
|
|
if (hiddenVal === name) {
|
|
tile.className += " selected";
|
|
tile.style.borderColor = "#00ff66";
|
|
}
|
|
|
|
const fallback = document.createElement("div");
|
|
fallback.className = "lora-tile-fallback " + bgClass;
|
|
fallback.innerText = labelText;
|
|
tile.appendChild(fallback);
|
|
|
|
const label = document.createElement("div");
|
|
label.className = "lora-tile-label";
|
|
label.innerText = labelText;
|
|
tile.appendChild(label);
|
|
|
|
tile.addEventListener("click", () => {
|
|
const siblings = viewContainer.querySelectorAll(".lora-tile");
|
|
siblings.forEach(s => {
|
|
s.classList.remove("selected");
|
|
if (s.dataset.folderColor) {
|
|
s.style.borderColor = s.dataset.folderColor;
|
|
} else {
|
|
s.style.borderColor = "";
|
|
}
|
|
});
|
|
tile.classList.add("selected");
|
|
tile.style.borderColor = "#00ff66";
|
|
|
|
const w = getHiddenWidget();
|
|
w.value = name;
|
|
if (w.callback) w.callback(name);
|
|
node.triggerSlotEvent?.(0);
|
|
});
|
|
|
|
viewContainer.appendChild(tile);
|
|
};
|
|
|
|
// NONE and RANDOM tiles at the top
|
|
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;
|
|
|
|
const tile = document.createElement("div");
|
|
tile.className = "lora-tile";
|
|
tile.dataset.loraName = loraName;
|
|
|
|
const subFolder = loraName.includes(" - ") ? loraName.split(" - ")[0] : "";
|
|
const borderCol = getFolderBorderColor(subFolder);
|
|
if (borderCol) {
|
|
tile.style.borderColor = borderCol;
|
|
tile.dataset.folderColor = borderCol;
|
|
}
|
|
|
|
const curWidget = getHiddenWidget();
|
|
if (curWidget.value === loraName) {
|
|
tile.className += " selected";
|
|
tile.style.borderColor = "#00ff66";
|
|
}
|
|
|
|
const img = document.createElement("img");
|
|
img.loading = "lazy";
|
|
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);
|
|
|
|
img.onerror = () => {
|
|
img.remove();
|
|
const fallback = document.createElement("div");
|
|
fallback.className = "lora-tile-fallback";
|
|
fallback.innerText = loraName;
|
|
tile.prepend(fallback);
|
|
};
|
|
tile.appendChild(img);
|
|
|
|
// Rank classification based on versionless pretty name
|
|
let tilePrettyName = loraName;
|
|
if (tilePrettyName.includes(" - ")) {
|
|
tilePrettyName = tilePrettyName.split(" - ", 2)[1];
|
|
}
|
|
tilePrettyName = tilePrettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
|
|
|
|
const systemPath = data.mapping[loraName];
|
|
const usageCount = (data.usage && systemPath) ? (data.usage[systemPath] || 0) : 0;
|
|
const isOver100 = usageCount >= 100;
|
|
|
|
let rankClass = "";
|
|
if (ranks.rank1.includes(tilePrettyName)) {
|
|
rankClass = isOver100 ? "rank-1-100" : "rank-1";
|
|
} else if (ranks.rank2.includes(tilePrettyName)) {
|
|
rankClass = isOver100 ? "rank-2-100" : "rank-2";
|
|
} else if (ranks.rank3.includes(tilePrettyName)) {
|
|
rankClass = isOver100 ? "rank-3-100" : "rank-3";
|
|
} else if (isOver100) {
|
|
rankClass = "over-100";
|
|
}
|
|
|
|
if (rankClass) {
|
|
tile.classList.add(rankClass);
|
|
}
|
|
|
|
if (usageCount > 0) {
|
|
const badge = document.createElement("div");
|
|
badge.className = "lora-tile-badge";
|
|
if (rankClass) badge.classList.add(rankClass);
|
|
badge.innerText = isOver100 ? `🔥 ${usageCount}` : usageCount;
|
|
tile.appendChild(badge);
|
|
}
|
|
|
|
// Add bottom caption label
|
|
const label = document.createElement("div");
|
|
label.className = "lora-tile-label";
|
|
label.innerText = loraName;
|
|
tile.appendChild(label);
|
|
|
|
// Trigger click selection
|
|
tile.addEventListener("click", () => {
|
|
const siblings = viewContainer.querySelectorAll(".lora-tile");
|
|
siblings.forEach(s => {
|
|
s.classList.remove("selected");
|
|
if (s.dataset.folderColor) {
|
|
s.style.borderColor = s.dataset.folderColor;
|
|
} else {
|
|
s.style.borderColor = "";
|
|
}
|
|
});
|
|
tile.classList.add("selected");
|
|
tile.style.borderColor = "#00ff66";
|
|
|
|
const w = getHiddenWidget();
|
|
w.value = loraName;
|
|
if (w.callback) w.callback(loraName);
|
|
node.triggerSlotEvent?.(0);
|
|
});
|
|
|
|
viewContainer.appendChild(tile);
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to build visual picker", e);
|
|
}
|
|
};
|
|
|
|
folderWidget.callback = () => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => updateVisualGrid(), 350);
|
|
};
|
|
|
|
const originalOnConfigure = node.onConfigure;
|
|
node.onConfigure = function(config) {
|
|
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
|
|
|
if (config && config.size && config.size[1]) {
|
|
node.userCustomHeight = config.size[1];
|
|
}
|
|
|
|
const savedTileSize = (config && config.properties && config.properties["tile_size"] != null)
|
|
? config.properties["tile_size"]
|
|
: (node.properties && node.properties["tile_size"] != null ? node.properties["tile_size"] : null);
|
|
|
|
if (savedTileSize != null) {
|
|
node.properties = node.properties || {};
|
|
node.properties["tile_size"] = savedTileSize;
|
|
if (zoomWidget) {
|
|
zoomWidget.value = parseInt(savedTileSize);
|
|
}
|
|
if (viewContainer && viewContainer.style) {
|
|
viewContainer.style.setProperty("--lora-tile-size", `${savedTileSize}px`);
|
|
}
|
|
}
|
|
|
|
const curWidget = hiddenWidget;
|
|
const widgetIndex = node.widgets.indexOf(curWidget);
|
|
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
|
|
|
|
if (savedValue) {
|
|
curWidget.value = savedValue;
|
|
setTimeout(() => updateVisualGrid(), 100);
|
|
}
|
|
};
|
|
|
|
setTimeout(() => updateVisualGrid(), 100);
|
|
}
|
|
|
|
// --- 3. VISUAL PRETTY PICKER V2 (Collapsible folders & Multi-selection) ---
|
|
if (node.comfyClass === "VisualLoraLoader" || node.type === "VisualLoraLoader") {
|
|
node.size = [380, 360];
|
|
|
|
const folderWidget = node.widgets.find(w => w.name === "folder");
|
|
|
|
const getHiddenWidget = () => {
|
|
const widgets = node.widgets.filter(w => w.name === "_selected_lora");
|
|
if (widgets.length > 1) {
|
|
const keep = widgets.find(w => w.value && w.value !== "[ NONE ]" && w.value !== "[]") || widgets[0];
|
|
node.widgets = node.widgets.filter(w => w.name !== "_selected_lora" || w === keep);
|
|
keep.hidden = true;
|
|
return keep;
|
|
}
|
|
if (widgets.length === 1) {
|
|
widgets[0].hidden = true;
|
|
return widgets[0];
|
|
}
|
|
const w = node.addWidget("text", "_selected_lora", "[]", () => {}, { serialize: true });
|
|
w.type = "hidden";
|
|
w.hidden = true;
|
|
w.computeSize = () => [0, 0];
|
|
return w;
|
|
};
|
|
const hiddenWidget = getHiddenWidget();
|
|
|
|
const getHiddenModeWidget = () => {
|
|
const widgets = node.widgets.filter(w => w.name === "_selection_mode");
|
|
if (widgets.length > 1) {
|
|
const keep = widgets.find(w => w.value && w.value !== "All") || widgets[0];
|
|
node.widgets = node.widgets.filter(w => w.name !== "_selection_mode" || w === keep);
|
|
keep.hidden = true;
|
|
return keep;
|
|
}
|
|
if (widgets.length === 1) {
|
|
widgets[0].hidden = true;
|
|
return widgets[0];
|
|
}
|
|
const w = node.addWidget("text", "_selection_mode", "Sequential", () => {}, { serialize: true });
|
|
w.type = "hidden";
|
|
w.hidden = true;
|
|
w.computeSize = () => [0, 0];
|
|
return w;
|
|
};
|
|
const hiddenModeWidget = getHiddenModeWidget();
|
|
|
|
const getHiddenScrapeWidget = () => {
|
|
const widgets = node.widgets.filter(w => w.name === "_scrape_on_new");
|
|
if (widgets.length > 1) {
|
|
const keep = widgets.find(w => w.value && (w.value === "true" || w.value === "false")) || widgets[0];
|
|
node.widgets = node.widgets.filter(w => w.name !== "_scrape_on_new" || w === keep);
|
|
keep.hidden = true;
|
|
return keep;
|
|
}
|
|
if (widgets.length === 1) {
|
|
widgets[0].hidden = true;
|
|
return widgets[0];
|
|
}
|
|
const w = node.addWidget("text", "_scrape_on_new", "true", () => {}, { serialize: true });
|
|
w.type = "hidden";
|
|
w.hidden = true;
|
|
w.computeSize = () => [0, 0];
|
|
return w;
|
|
};
|
|
const hiddenScrapeWidget = getHiddenScrapeWidget();
|
|
|
|
const viewContainer = document.createElement("div");
|
|
viewContainer.className = "lora-visual-container flex-layout";
|
|
node.properties = node.properties || {};
|
|
const initialZoom = node.properties["tile_size"] != null
|
|
? node.properties["tile_size"]
|
|
: (localStorage.getItem("comfy_lora_picker_zoom") || "80");
|
|
node.properties["tile_size"] = parseInt(initialZoom);
|
|
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
|
|
|
|
const gridContainer = document.createElement("div");
|
|
gridContainer.className = "lora-grid-container";
|
|
|
|
// Isolate mouse wheel scroll events from triggering canvas zooming/panning
|
|
viewContainer.addEventListener("wheel", (e) => {
|
|
e.stopPropagation();
|
|
});
|
|
|
|
let userSavedHeight = 420;
|
|
|
|
const applyDisplayMode = (mode) => {
|
|
const isShowAll = mode === "Show All";
|
|
if (isShowAll) {
|
|
viewContainer.style.setProperty("height", "auto", "important");
|
|
viewContainer.style.setProperty("max-height", "none", "important");
|
|
viewContainer.style.setProperty("overflow-y", "visible", "important");
|
|
|
|
gridContainer.style.setProperty("height", "auto", "important");
|
|
gridContainer.style.setProperty("max-height", "none", "important");
|
|
gridContainer.style.setProperty("overflow-y", "visible", "important");
|
|
gridContainer.style.setProperty("flex", "none", "important");
|
|
|
|
setTimeout(() => {
|
|
const contentH = gridContainer.scrollHeight || 0;
|
|
const neededHeight = contentH + 160;
|
|
node.setSize([node.size[0], Math.max(360, neededHeight)]);
|
|
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
|
}, 50);
|
|
} else {
|
|
viewContainer.classList.remove("show-all-mode");
|
|
viewContainer.classList.add("scrollable-mode");
|
|
|
|
viewContainer.style.setProperty("height", "100%", "important");
|
|
viewContainer.style.setProperty("max-height", "100%", "important");
|
|
viewContainer.style.setProperty("overflow-y", "hidden", "important");
|
|
|
|
gridContainer.style.setProperty("height", "100%", "important");
|
|
gridContainer.style.setProperty("max-height", "100%", "important");
|
|
gridContainer.style.setProperty("overflow-y", "auto", "important");
|
|
gridContainer.style.setProperty("flex", "1 1 0px", "important");
|
|
|
|
const targetH = node.userCustomHeight || userSavedHeight || (node.size ? node.size[1] : 420);
|
|
if (targetH && (!node.size || node.size[1] !== targetH)) {
|
|
node.setSize([node.size ? node.size[0] : 380, Math.max(360, targetH)]);
|
|
}
|
|
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
|
}
|
|
};
|
|
|
|
node.computeSize = function() {
|
|
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
|
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
|
|
return [Math.max(380, node.size[0]), Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
|
|
}
|
|
return [Math.max(380, node.size[0]), Math.max(360, node.size ? node.size[1] : 360)];
|
|
};
|
|
|
|
const updateNodeSize = () => {
|
|
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
|
const mode = displayModeWidget ? displayModeWidget.value : "Scrollable";
|
|
applyDisplayMode(mode);
|
|
};
|
|
|
|
// --- Control Bar for Mode Selector ---
|
|
const controlBar = document.createElement("div");
|
|
controlBar.className = "lora-control-bar";
|
|
|
|
const toggleContainer = document.createElement("div");
|
|
toggleContainer.className = "lora-toggle-container";
|
|
|
|
const toggleLabel = document.createElement("span");
|
|
toggleLabel.className = "lora-toggle-label";
|
|
toggleLabel.innerText = "Mode:";
|
|
toggleContainer.appendChild(toggleLabel);
|
|
|
|
const modeSelect = document.createElement("select");
|
|
modeSelect.className = "lora-mode-select";
|
|
modeSelect.style.background = "#252525";
|
|
modeSelect.style.border = "1px solid #353535";
|
|
modeSelect.style.color = "#fff";
|
|
modeSelect.style.fontSize = "10px";
|
|
modeSelect.style.fontWeight = "bold";
|
|
modeSelect.style.padding = "2px 4px";
|
|
modeSelect.style.borderRadius = "4px";
|
|
modeSelect.style.cursor = "pointer";
|
|
|
|
const modes = [
|
|
{ value: "All", label: "All Loras" },
|
|
{ value: "Random", label: "Random (With Dupes)" },
|
|
{ value: "Sequential", label: "Sequential Cycle" },
|
|
{ value: "Random (No Replace)", label: "Random (No Dupes)" }
|
|
];
|
|
|
|
modes.forEach(m => {
|
|
const opt = document.createElement("option");
|
|
opt.value = m.value;
|
|
opt.innerText = m.label;
|
|
modeSelect.appendChild(opt);
|
|
});
|
|
|
|
const syncModeToggleUI = () => {
|
|
const curModeWidget = getHiddenModeWidget();
|
|
if (curModeWidget.value && modes.some(m => m.value === curModeWidget.value)) {
|
|
modeSelect.value = curModeWidget.value;
|
|
} else {
|
|
modeSelect.value = "Sequential";
|
|
}
|
|
};
|
|
|
|
modeSelect.addEventListener("change", (e) => {
|
|
getHiddenModeWidget().value = e.target.value;
|
|
syncModeToggleUI();
|
|
node.triggerSlotEvent?.(0);
|
|
});
|
|
|
|
toggleContainer.appendChild(modeSelect);
|
|
controlBar.appendChild(toggleContainer);
|
|
|
|
// Scrape Toggle Container
|
|
const scrapeToggleContainer = document.createElement("div");
|
|
scrapeToggleContainer.className = "lora-toggle-container";
|
|
|
|
const scrapeLabel = document.createElement("span");
|
|
scrapeLabel.className = "lora-toggle-label";
|
|
scrapeLabel.innerText = "Scrape:";
|
|
scrapeToggleContainer.appendChild(scrapeLabel);
|
|
|
|
const scrapeOnBtn = document.createElement("button");
|
|
scrapeOnBtn.className = "lora-toggle-btn active";
|
|
scrapeOnBtn.innerText = "ON";
|
|
|
|
const scrapeOffBtn = document.createElement("button");
|
|
scrapeOffBtn.className = "lora-toggle-btn";
|
|
scrapeOffBtn.innerText = "OFF";
|
|
|
|
const syncScrapeToggleUI = () => {
|
|
const curScrapeWidget = getHiddenScrapeWidget();
|
|
if (curScrapeWidget.value === "false") {
|
|
scrapeOffBtn.classList.add("active");
|
|
scrapeOnBtn.classList.remove("active");
|
|
} else {
|
|
scrapeOnBtn.classList.add("active");
|
|
scrapeOffBtn.classList.remove("active");
|
|
}
|
|
};
|
|
|
|
scrapeOnBtn.addEventListener("click", () => {
|
|
getHiddenScrapeWidget().value = "true";
|
|
syncScrapeToggleUI();
|
|
updateVisualGrid();
|
|
});
|
|
|
|
scrapeOffBtn.addEventListener("click", () => {
|
|
getHiddenScrapeWidget().value = "false";
|
|
syncScrapeToggleUI();
|
|
updateVisualGrid();
|
|
});
|
|
|
|
scrapeToggleContainer.appendChild(scrapeOnBtn);
|
|
scrapeToggleContainer.appendChild(scrapeOffBtn);
|
|
controlBar.appendChild(scrapeToggleContainer);
|
|
|
|
// --- Maintenance Reset Container (cycle counter / usage counters / thumbnail cache) ---
|
|
const maintContainer = document.createElement("div");
|
|
maintContainer.className = "lora-toggle-container";
|
|
|
|
const maintLabel = document.createElement("span");
|
|
maintLabel.className = "lora-toggle-label";
|
|
maintLabel.innerText = "Reset:";
|
|
maintContainer.appendChild(maintLabel);
|
|
|
|
const postMaintenance = async (route, body) => {
|
|
const resp = await authenticatedFetch(route, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body || {})
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok || (data && data.status === "error")) {
|
|
throw new Error((data && data.message) || `Request failed (${resp.status})`);
|
|
}
|
|
return data;
|
|
};
|
|
|
|
const makeMaintButton = (label, title, onClick) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "lora-toggle-btn";
|
|
btn.innerText = label;
|
|
btn.title = title;
|
|
btn.dataset.defaultLabel = label;
|
|
btn.addEventListener("click", async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (btn.disabled) return;
|
|
try {
|
|
const result = await onClick();
|
|
if (result === false) return;
|
|
btn.innerText = "✅ Done";
|
|
} catch (err) {
|
|
console.error(`[SaturnNodes] '${label}' maintenance action failed:`, err);
|
|
btn.innerText = "⚠️ Failed";
|
|
}
|
|
btn.disabled = true;
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
setTimeout(() => {
|
|
btn.disabled = false;
|
|
btn.innerText = btn.dataset.defaultLabel;
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}, 1600);
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
});
|
|
return btn;
|
|
};
|
|
|
|
// Cycle counter: resets Sequential/Random position + prompt->LoRA assignments for this node
|
|
const cycleResetBtn = makeMaintButton(
|
|
"↺ Counter",
|
|
"Reset this loader's Sequential/Random cycle counter and prompt-to-LoRA assignments",
|
|
async () => {
|
|
const ok = confirm(
|
|
"Reset this LoRA loader's cycle counter?\n\n" +
|
|
"• The next run starts again from the FIRST selected LoRA.\n" +
|
|
"• All prompt→LoRA assignments for this node are cleared.\n\n" +
|
|
"⚠️ Running and queued workflows will pick LoRAs from the start again,\n" +
|
|
"which can mismatch them against the prompt iterator mid-batch.\n" +
|
|
"Only do this between batches (when the queue is empty).\n\nContinue?"
|
|
);
|
|
if (!ok) return false;
|
|
await postMaintenance("/saturnnodes/folder_lora_loader/reset_cycle", { node_id: String(node.id) });
|
|
}
|
|
);
|
|
maintContainer.appendChild(cycleResetBtn);
|
|
|
|
// Usage counters: clears badges + 'Usage' sortings (cosmetic)
|
|
const usageResetBtn = makeMaintButton(
|
|
"🧹 Usage",
|
|
"Reset all LoRA usage counters (badges and 'Usage' sortings)",
|
|
async () => {
|
|
const ok = confirm(
|
|
"Reset all LoRA usage counters?\n\n" +
|
|
"• Every usage badge and 'Usage' sorting starts from 0.\n" +
|
|
"• Cosmetic only - LoRA loading itself is not affected.\n\nContinue?"
|
|
);
|
|
if (!ok) return false;
|
|
await postMaintenance("/saturnnodes/folder_lora_loader/reset_usage");
|
|
updateVisualGrid();
|
|
}
|
|
);
|
|
maintContainer.appendChild(usageResetBtn);
|
|
|
|
// Thumbnail cache: deletes cached WebP previews (regenerated automatically)
|
|
const cacheResetBtn = makeMaintButton(
|
|
"🗑 Cache",
|
|
"Delete cached LoRA thumbnail images (they regenerate automatically)",
|
|
async () => {
|
|
const ok = confirm(
|
|
"Clear the cached LoRA thumbnails?\n\n" +
|
|
"• Cached preview thumbnails are deleted from disk.\n" +
|
|
"• They regenerate automatically when you view the grid.\n\nContinue?"
|
|
);
|
|
if (!ok) return false;
|
|
await postMaintenance("/saturnnodes/folder_lora_loader/clear_cache");
|
|
}
|
|
);
|
|
maintContainer.appendChild(cacheResetBtn);
|
|
|
|
controlBar.appendChild(maintContainer);
|
|
|
|
viewContainer.appendChild(controlBar);
|
|
viewContainer.appendChild(gridContainer);
|
|
|
|
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
|
|
getValue() { return getHiddenWidget().value; },
|
|
setValue(val) { getHiddenWidget().value = val; },
|
|
serialize: false,
|
|
getMinHeight() { return 180; }
|
|
});
|
|
|
|
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
|
|
// Removing computeSize avoids an infinite expansion loop in _arrangeWidgets.
|
|
domWidget.computeLayoutSize = function() {
|
|
return { minHeight: 180, maxHeight: undefined, minWidth: 300 };
|
|
};
|
|
delete domWidget.computeSize;
|
|
|
|
const onResize = node.onResize;
|
|
node.onResize = function(size) {
|
|
if (onResize) onResize.apply(this, arguments);
|
|
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
|
const isShowAll = displayModeWidget && displayModeWidget.value === "Show All";
|
|
if (!isShowAll && size) {
|
|
node.userCustomHeight = size[1];
|
|
userSavedHeight = size[1];
|
|
}
|
|
};
|
|
|
|
const zoomWidget = node.addWidget(
|
|
"slider",
|
|
"tile_size",
|
|
parseInt(initialZoom),
|
|
(val) => {
|
|
node.properties = node.properties || {};
|
|
node.properties["tile_size"] = val;
|
|
viewContainer.style.setProperty("--lora-tile-size", `${val}px`);
|
|
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
|
},
|
|
{ min: 50, max: 180, step: 1 }
|
|
);
|
|
zoomWidget.serialize = false;
|
|
|
|
// Live Selected LoRAs count preview widget matching ResolutionSelector styling
|
|
let loraCountWidget = node.widgets?.find(w => w.name === "lora_count_preview" || w._isLoraCount);
|
|
let loraBadgeSpan = node._loraBadgeSpan;
|
|
if (!loraCountWidget) {
|
|
const loraBadgeEl = document.createElement("div");
|
|
loraBadgeEl.className = "not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground [[readonly]]:bg-component-node-widget-background-disabled border-none rounded-md flex w-full items-center justify-center gap-2 px-2 h-6 col-span-2";
|
|
loraBadgeEl.setAttribute("data-widget-name", "lora_count_preview");
|
|
loraBadgeEl.setAttribute("node-type", "VisualLoraLoader");
|
|
loraBadgeEl.style.cssText = "display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 8px; height: 24px !important; min-height: 24px !important; max-height: 24px !important; flex-grow: 0 !important; flex-shrink: 0 !important; align-self: center !important; overflow: hidden !important; border-radius: 6px; background: var(--component-node-widget-background, rgba(255, 255, 255, 0.06)); color: var(--component-node-foreground, #e4e4e7); font-size: 12px; font-weight: 500; width: 100%; box-sizing: border-box; user-select: none;";
|
|
|
|
loraBadgeSpan = document.createElement("span");
|
|
loraBadgeSpan.className = "text-xs";
|
|
loraBadgeSpan.style.cssText = "font-weight: 600;";
|
|
loraBadgeSpan.textContent = "0 LoRAs Selected";
|
|
loraBadgeEl.appendChild(loraBadgeSpan);
|
|
node._loraBadgeSpan = loraBadgeSpan;
|
|
node._loraBadgeEl = loraBadgeEl;
|
|
|
|
const enforceFixedLoraBadgeSize = () => {
|
|
loraBadgeEl.style.setProperty("height", "24px", "important");
|
|
loraBadgeEl.style.setProperty("min-height", "24px", "important");
|
|
loraBadgeEl.style.setProperty("max-height", "24px", "important");
|
|
loraBadgeEl.style.setProperty("flex-grow", "0", "important");
|
|
loraBadgeEl.style.setProperty("flex-shrink", "0", "important");
|
|
loraBadgeEl.style.setProperty("align-self", "center", "important");
|
|
const widgetContainer = loraBadgeEl.closest?.(".lg-node-widget");
|
|
if (widgetContainer) {
|
|
widgetContainer.style.setProperty("align-self", "center", "important");
|
|
widgetContainer.style.setProperty("flex-grow", "0", "important");
|
|
}
|
|
};
|
|
node._enforceFixedLoraBadgeSize = enforceFixedLoraBadgeSize;
|
|
|
|
loraCountWidget = node.addDOMWidget("lora_count_preview", "preview", loraBadgeEl, {
|
|
serialize: false,
|
|
getValue() { return loraBadgeSpan.textContent; },
|
|
setValue(v) { loraBadgeSpan.textContent = v; }
|
|
});
|
|
loraCountWidget._isLoraCount = true;
|
|
}
|
|
|
|
if (loraCountWidget) {
|
|
// Crucial: shadow computeLayoutSize so ComfyUI Vue deriveWidgetRenderState evaluates hasLayoutSize to false.
|
|
// This forces grid-template-rows to use 'min-content' (24px) instead of 'auto' (which was stealing half the node height).
|
|
Object.defineProperty(loraCountWidget, "computeLayoutSize", {
|
|
value: undefined,
|
|
writable: true,
|
|
configurable: true
|
|
});
|
|
loraCountWidget.hasLayoutSize = false;
|
|
loraCountWidget.computeSize = function() {
|
|
return [node.size ? (node.size[0] - 20) : 200, 24];
|
|
};
|
|
}
|
|
|
|
const updateLoraBadgeColor = () => {
|
|
const enabled = isSaturnColorsEnabled();
|
|
const badgeEl = resolveLiveWidgetElement(
|
|
node._loraBadgeEl,
|
|
node.id,
|
|
"lora_count_preview"
|
|
);
|
|
if (badgeEl) node._loraBadgeEl = badgeEl;
|
|
const badgeSpan = badgeEl?.querySelector?.("span") || node._loraBadgeSpan;
|
|
if (badgeSpan) node._loraBadgeSpan = badgeSpan;
|
|
|
|
if (badgeSpan) {
|
|
badgeSpan.style.color = enabled ? "#f59e0b" : "var(--text-muted, #a1a1aa)";
|
|
}
|
|
if (badgeEl) {
|
|
badgeEl.style.background = enabled
|
|
? "rgba(180, 140, 95, 0.12)"
|
|
: "var(--component-node-widget-background, rgba(255, 255, 255, 0.06))";
|
|
badgeEl.style.border = enabled
|
|
? "1px solid rgba(180, 140, 95, 0.35)"
|
|
: "none";
|
|
}
|
|
};
|
|
node._updateCounterBadgeColor = updateLoraBadgeColor;
|
|
|
|
let activeRequest = null;
|
|
let debounceTimer = null;
|
|
let foldersMap = {};
|
|
|
|
const getSelectedLoras = () => {
|
|
const curWidget = getHiddenWidget();
|
|
let currentSelected = [];
|
|
try {
|
|
currentSelected = JSON.parse(curWidget.value || "[]");
|
|
if (!Array.isArray(currentSelected)) {
|
|
currentSelected = [curWidget.value];
|
|
}
|
|
} catch (e) {
|
|
if (curWidget.value && curWidget.value !== "[ NONE ]" && curWidget.value !== "[]") {
|
|
currentSelected = [curWidget.value];
|
|
} else {
|
|
currentSelected = [];
|
|
}
|
|
}
|
|
// Filter out "[ NONE ]"
|
|
currentSelected = currentSelected.filter(n => n !== "[ NONE ]" && n);
|
|
return currentSelected;
|
|
};
|
|
|
|
const updateLoraCountDisplay = () => {
|
|
node._enforceFixedLoraBadgeSize?.();
|
|
const count = getSelectedLoras().length;
|
|
node._selectedLoraCount = count;
|
|
const label = `${count} LoRA${count === 1 ? "" : "s"} Selected`;
|
|
const liveBadgeEl = resolveLiveWidgetElement(
|
|
node._loraBadgeEl,
|
|
node.id,
|
|
"lora_count_preview"
|
|
);
|
|
if (liveBadgeEl) node._loraBadgeEl = liveBadgeEl;
|
|
const liveBadgeSpan = liveBadgeEl?.querySelector?.("span") || node._loraBadgeSpan;
|
|
if (liveBadgeSpan) {
|
|
node._loraBadgeSpan = liveBadgeSpan;
|
|
liveBadgeSpan.textContent = label;
|
|
}
|
|
if (loraCountWidget) {
|
|
loraCountWidget.value = label;
|
|
}
|
|
updateLoraBadgeColor();
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
};
|
|
|
|
const updateFolderCheckboxes = () => {
|
|
const selectedList = getSelectedLoras();
|
|
|
|
Object.keys(foldersMap).forEach(folder => {
|
|
const headerEl = viewContainer.querySelector(`.lora-folder-header[data-folder="${CSS.escape(folder)}"]`);
|
|
if (!headerEl) return;
|
|
const checkbox = headerEl.querySelector(".lora-folder-checkbox");
|
|
if (!checkbox) return;
|
|
|
|
const items = foldersMap[folder];
|
|
if (!items || items.length === 0) return;
|
|
|
|
const selectedCount = items.filter(item => selectedList.includes(item.loraName)).length;
|
|
|
|
if (selectedCount === items.length) {
|
|
checkbox.checked = true;
|
|
checkbox.indeterminate = false;
|
|
} else if (selectedCount > 0) {
|
|
checkbox.checked = false;
|
|
checkbox.indeterminate = true;
|
|
} else {
|
|
checkbox.checked = false;
|
|
checkbox.indeterminate = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
const updateSelection = (selectedList) => {
|
|
// Filter out "[ NONE ]"
|
|
selectedList = selectedList.filter(n => n !== "[ NONE ]" && n);
|
|
|
|
const curWidget = getHiddenWidget();
|
|
curWidget.value = JSON.stringify(selectedList);
|
|
|
|
const tiles = viewContainer.querySelectorAll(".lora-tile");
|
|
tiles.forEach(t => {
|
|
const name = t.dataset.loraName;
|
|
const orderIndex = selectedList.indexOf(name);
|
|
if (orderIndex >= 0) {
|
|
t.classList.add("selected");
|
|
t.style.borderColor = "#00ff66";
|
|
} else {
|
|
t.classList.remove("selected");
|
|
if (t.dataset.folderColor) {
|
|
t.style.borderColor = t.dataset.folderColor;
|
|
} else {
|
|
t.style.borderColor = "";
|
|
}
|
|
}
|
|
const orderBadge = t.querySelector(".lora-order-badge");
|
|
if (orderBadge) {
|
|
orderBadge.textContent = orderIndex >= 0 ? String(orderIndex + 1) : "";
|
|
}
|
|
});
|
|
|
|
updateFolderCheckboxes();
|
|
updateLoraCountDisplay();
|
|
node.triggerSlotEvent?.(0);
|
|
};
|
|
|
|
const updateVisualGrid = async () => {
|
|
const folder = folderWidget.value || "";
|
|
const scrapeOnNew = getHiddenScrapeWidget().value;
|
|
const currentRequest = Symbol();
|
|
activeRequest = currentRequest;
|
|
|
|
try {
|
|
const response = await api.fetchApi(`/saturnnodes/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=true&scrape_on_new=${encodeURIComponent(scrapeOnNew)}`);
|
|
if (activeRequest !== currentRequest) return;
|
|
const data = await response.json();
|
|
|
|
gridContainer.innerHTML = "";
|
|
|
|
foldersMap = {};
|
|
foldersMap["Root"] = [];
|
|
|
|
const selectedList = getSelectedLoras();
|
|
|
|
// Calculate Top 3 Ranks by versionless pretty name
|
|
const getTop3Ranks = () => {
|
|
const prettyCounts = {};
|
|
if (data.usage && data.mapping) {
|
|
Object.entries(data.mapping).forEach(([displayName, systemPath]) => {
|
|
const count = data.usage[systemPath] || 0;
|
|
if (count > 0) {
|
|
let prettyName = displayName;
|
|
if (prettyName.includes(" - ")) {
|
|
prettyName = prettyName.split(" - ", 2)[1];
|
|
}
|
|
prettyName = prettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
|
|
prettyCounts[prettyName] = Math.max(prettyCounts[prettyName] || 0, count);
|
|
}
|
|
});
|
|
}
|
|
|
|
const sorted = Object.entries(prettyCounts)
|
|
.sort((a, b) => b[1] - a[1]);
|
|
|
|
const ranks = { rank1: [], rank2: [], rank3: [] };
|
|
const uniqueCounts = [...new Set(sorted.map(x => x[1]))];
|
|
|
|
sorted.forEach(([name, count]) => {
|
|
if (count === uniqueCounts[0]) ranks.rank1.push(name);
|
|
else if (count === uniqueCounts[1]) ranks.rank2.push(name);
|
|
else if (count === uniqueCounts[2]) ranks.rank3.push(name);
|
|
});
|
|
return ranks;
|
|
};
|
|
const ranks = getTop3Ranks();
|
|
|
|
// --- Group items by folder ---
|
|
const mtimeMap = data.mtime || {};
|
|
data.names.forEach(loraName => {
|
|
if (loraName === "[ NONE ]") return;
|
|
const parts = loraName.split(" - ");
|
|
const subFolder = parts.length > 1 ? parts[0] : "Root";
|
|
const prettyName = parts.length > 1 ? parts.slice(1).join(" - ") : loraName;
|
|
const systemPath = data.mapping[loraName];
|
|
const usageCount = (data.usage && systemPath) ? (data.usage[systemPath] || 0) : 0;
|
|
|
|
if (!foldersMap[subFolder]) {
|
|
foldersMap[subFolder] = [];
|
|
}
|
|
foldersMap[subFolder].push({ loraName, prettyName, usageCount, systemPath });
|
|
});
|
|
|
|
// Read sorting widgets
|
|
const getWidgetVal = (wName, defaultVal) => {
|
|
const w = node.widgets ? node.widgets.find(x => x.name === wName) : null;
|
|
return w ? w.value : defaultVal;
|
|
};
|
|
const sortLorasMode = getWidgetVal("sort_loras_by", "Name (A-Z)");
|
|
const sortFoldersMode = getWidgetVal("sort_folders_by", "Name (A-Z)");
|
|
const folderPosMode = getWidgetVal("folder_position", "Folders First");
|
|
const contentAlignMode = getWidgetVal("content_alignment", "Left Aligned");
|
|
|
|
if (contentAlignMode === "Right Aligned") {
|
|
viewContainer.classList.add("right-aligned-mode");
|
|
} else {
|
|
viewContainer.classList.remove("right-aligned-mode");
|
|
}
|
|
|
|
// Sort individual LoRAs inside each folder
|
|
Object.keys(foldersMap).forEach(subFolder => {
|
|
foldersMap[subFolder].sort((a, b) => {
|
|
if (sortLorasMode === "Name (Z-A)") {
|
|
return b.prettyName.localeCompare(a.prettyName);
|
|
} else if (sortLorasMode === "Usage (High to Low)") {
|
|
return (b.usageCount || 0) - (a.usageCount || 0) || a.prettyName.localeCompare(b.prettyName);
|
|
} else if (sortLorasMode === "Usage (Low to High)") {
|
|
return (a.usageCount || 0) - (b.usageCount || 0) || a.prettyName.localeCompare(b.prettyName);
|
|
} else if (sortLorasMode === "Date Modified (Newest First)") {
|
|
const timeA = mtimeMap[a.systemPath] || 0;
|
|
const timeB = mtimeMap[b.systemPath] || 0;
|
|
return timeB - timeA || a.prettyName.localeCompare(b.prettyName);
|
|
} else if (sortLorasMode === "Date Modified (Oldest First)") {
|
|
const timeA = mtimeMap[a.systemPath] || 0;
|
|
const timeB = mtimeMap[b.systemPath] || 0;
|
|
return timeA - timeB || a.prettyName.localeCompare(b.prettyName);
|
|
} else { // "Name (A-Z)" default
|
|
return a.prettyName.localeCompare(b.prettyName);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Sort folders (excluding Root for position ordering)
|
|
const nonRootFolders = Object.keys(foldersMap).filter(f => f !== "Root").sort((a, b) => {
|
|
const itemsA = foldersMap[a] || [];
|
|
const itemsB = foldersMap[b] || [];
|
|
const totalA = itemsA.reduce((sum, i) => sum + (i.usageCount || 0), 0);
|
|
const totalB = itemsB.reduce((sum, i) => sum + (i.usageCount || 0), 0);
|
|
const avgA = itemsA.length > 0 ? totalA / itemsA.length : 0;
|
|
const avgB = itemsB.length > 0 ? totalB / itemsB.length : 0;
|
|
|
|
if (sortFoldersMode === "Name (Z-A)") {
|
|
return b.localeCompare(a);
|
|
} else if (sortFoldersMode === "Total Usage (High to Low)") {
|
|
return totalB - totalA || a.localeCompare(b);
|
|
} else if (sortFoldersMode === "Average Usage (High to Low)") {
|
|
return avgB - avgA || a.localeCompare(b);
|
|
} else if (sortFoldersMode === "Total LoRAs (Most First)") {
|
|
return itemsB.length - itemsA.length || a.localeCompare(b);
|
|
} else { // "Name (A-Z)" default
|
|
return a.localeCompare(b);
|
|
}
|
|
});
|
|
|
|
// Combine folder keys based on folder_position
|
|
let sortedFolders = [];
|
|
const hasRoot = foldersMap["Root"] && foldersMap["Root"].length > 0;
|
|
if (folderPosMode === "Root LoRAs First") {
|
|
if (hasRoot) sortedFolders.push("Root");
|
|
sortedFolders.push(...nonRootFolders);
|
|
} else {
|
|
sortedFolders.push(...nonRootFolders);
|
|
if (hasRoot) sortedFolders.push("Root");
|
|
}
|
|
|
|
// Atomic DOM Fragment construction for maximum performance
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
// Setup IntersectionObserver for high-performance lazy image decoding
|
|
const imageObserver = new IntersectionObserver((entries, observer) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target;
|
|
if (img.dataset.src) {
|
|
img.src = img.dataset.src;
|
|
img.removeAttribute("data-src");
|
|
}
|
|
observer.unobserve(img);
|
|
}
|
|
});
|
|
}, { root: gridContainer, rootMargin: "150px" });
|
|
|
|
// --- Render folder groups ---
|
|
const bypassThumbs = getBypassThumbnailDownscaling();
|
|
|
|
sortedFolders.forEach(subFolder => {
|
|
const items = foldersMap[subFolder];
|
|
if (items.length === 0) return;
|
|
|
|
const folderContainer = document.createElement("div");
|
|
folderContainer.className = "lora-folder-container";
|
|
const isCollapsed = localStorage.getItem(`comfy_lora_picker_folder_collapsed_${subFolder}`) === "true";
|
|
if (isCollapsed) {
|
|
folderContainer.classList.add("collapsed");
|
|
}
|
|
|
|
// Folder Header
|
|
const header = document.createElement("div");
|
|
header.className = "lora-folder-header";
|
|
header.dataset.folder = subFolder;
|
|
if (isCollapsed) {
|
|
header.classList.add("collapsed");
|
|
}
|
|
|
|
const toggle = document.createElement("span");
|
|
toggle.className = "lora-folder-toggle";
|
|
toggle.innerText = "▼";
|
|
header.appendChild(toggle);
|
|
|
|
const nameSpan = document.createElement("span");
|
|
nameSpan.className = "lora-folder-name";
|
|
nameSpan.innerText = subFolder;
|
|
header.appendChild(nameSpan);
|
|
|
|
const countSpan = document.createElement("span");
|
|
countSpan.className = "lora-folder-count";
|
|
countSpan.innerText = `(${items.length})`;
|
|
header.appendChild(countSpan);
|
|
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.className = "lora-folder-checkbox";
|
|
checkbox.title = "Select/Deselect all in folder";
|
|
|
|
checkbox.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const isChecked = checkbox.checked;
|
|
let currentSelected = getSelectedLoras();
|
|
|
|
items.forEach(item => {
|
|
if (isChecked) {
|
|
if (!currentSelected.includes(item.loraName)) {
|
|
currentSelected.push(item.loraName);
|
|
}
|
|
} else {
|
|
currentSelected = currentSelected.filter(n => n !== item.loraName);
|
|
}
|
|
});
|
|
|
|
updateSelection(currentSelected);
|
|
});
|
|
|
|
header.appendChild(checkbox);
|
|
|
|
header.addEventListener("click", () => {
|
|
const collapsed = folderContainer.classList.toggle("collapsed");
|
|
header.classList.toggle("collapsed", collapsed);
|
|
localStorage.setItem(`comfy_lora_picker_folder_collapsed_${subFolder}`, collapsed ? "true" : "false");
|
|
});
|
|
|
|
folderContainer.appendChild(header);
|
|
|
|
// Grid containing items of this folder
|
|
const grid = document.createElement("div");
|
|
grid.className = "lora-folder-grid";
|
|
|
|
items.forEach(item => {
|
|
const tile = document.createElement("div");
|
|
tile.className = "lora-tile";
|
|
tile.dataset.loraName = item.loraName;
|
|
|
|
const borderCol = getFolderBorderColor(subFolder);
|
|
if (borderCol) {
|
|
tile.style.borderColor = borderCol;
|
|
tile.dataset.folderColor = borderCol;
|
|
}
|
|
|
|
if (selectedList.includes(item.loraName)) {
|
|
tile.className += " selected";
|
|
tile.style.borderColor = "#00ff66";
|
|
}
|
|
|
|
const orderBadge = document.createElement("div");
|
|
orderBadge.className = "lora-order-badge";
|
|
const orderIndex = selectedList.indexOf(item.loraName);
|
|
orderBadge.textContent = orderIndex >= 0 ? String(orderIndex + 1) : "";
|
|
orderBadge.title = "Selection order (LoRAs are applied in this order)";
|
|
tile.appendChild(orderBadge);
|
|
|
|
const img = document.createElement("img");
|
|
img.loading = "lazy";
|
|
img.dataset.src = `/saturnnodes/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}` + (bypassThumbs ? "&full=true" : "&size=200");
|
|
imageObserver.observe(img);
|
|
|
|
img.onerror = () => {
|
|
img.remove();
|
|
const fallback = document.createElement("div");
|
|
fallback.className = "lora-tile-fallback";
|
|
fallback.innerText = item.prettyName;
|
|
tile.prepend(fallback);
|
|
};
|
|
tile.appendChild(img);
|
|
|
|
let tilePrettyName = item.prettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
|
|
const isOver100 = item.usageCount >= 100;
|
|
|
|
let rankClass = "";
|
|
if (ranks.rank1.includes(tilePrettyName)) {
|
|
rankClass = isOver100 ? "rank-1-100" : "rank-1";
|
|
} else if (ranks.rank2.includes(tilePrettyName)) {
|
|
rankClass = isOver100 ? "rank-2-100" : "rank-2";
|
|
} else if (ranks.rank3.includes(tilePrettyName)) {
|
|
rankClass = isOver100 ? "rank-3-100" : "rank-3";
|
|
} else if (isOver100) {
|
|
rankClass = "over-100";
|
|
}
|
|
|
|
if (rankClass) {
|
|
tile.classList.add(rankClass);
|
|
}
|
|
|
|
if (item.usageCount > 0) {
|
|
const badge = document.createElement("div");
|
|
badge.className = "lora-tile-badge";
|
|
if (rankClass) badge.classList.add(rankClass);
|
|
badge.innerText = isOver100 ? `🔥 ${item.usageCount}` : item.usageCount;
|
|
tile.appendChild(badge);
|
|
}
|
|
|
|
const label = document.createElement("div");
|
|
label.className = "lora-tile-label";
|
|
label.innerText = item.prettyName;
|
|
tile.appendChild(label);
|
|
|
|
tile.addEventListener("click", (e) => {
|
|
let currentSelected = getSelectedLoras();
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
if (currentSelected.includes(item.loraName)) {
|
|
currentSelected = currentSelected.filter(n => n !== item.loraName);
|
|
} else {
|
|
currentSelected.push(item.loraName);
|
|
}
|
|
} else {
|
|
if (currentSelected.length === 1 && currentSelected[0] === item.loraName) {
|
|
currentSelected = [];
|
|
} else {
|
|
currentSelected = [item.loraName];
|
|
}
|
|
}
|
|
|
|
updateSelection(currentSelected);
|
|
});
|
|
|
|
grid.appendChild(tile);
|
|
});
|
|
|
|
folderContainer.appendChild(grid);
|
|
fragment.appendChild(folderContainer);
|
|
});
|
|
|
|
// Append entire DocumentFragment to gridContainer
|
|
gridContainer.appendChild(fragment);
|
|
|
|
// Set initial checkboxes state
|
|
updateFolderCheckboxes();
|
|
setTimeout(() => updateNodeSize(), 50);
|
|
} catch (e) {
|
|
console.error("Failed to build visual picker v2", e);
|
|
}
|
|
};
|
|
|
|
folderWidget.callback = () => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => updateVisualGrid(), 350);
|
|
};
|
|
|
|
["sort_loras_by", "sort_folders_by", "folder_position", "content_alignment"].forEach(wName => {
|
|
const w = node.widgets ? node.widgets.find(x => x.name === wName) : null;
|
|
if (w) {
|
|
const origCb = w.callback;
|
|
w.callback = function() {
|
|
if (origCb) origCb.apply(this, arguments);
|
|
updateVisualGrid();
|
|
};
|
|
}
|
|
});
|
|
|
|
const originalOnConfigure = node.onConfigure;
|
|
node.onConfigure = function(config) {
|
|
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
|
|
|
if (config && config.size && config.size[1]) {
|
|
node.userCustomHeight = config.size[1];
|
|
userSavedHeight = config.size[1];
|
|
}
|
|
|
|
const savedTileSize = (config && config.properties && config.properties["tile_size"] != null)
|
|
? config.properties["tile_size"]
|
|
: (node.properties && node.properties["tile_size"] != null ? node.properties["tile_size"] : null);
|
|
|
|
if (savedTileSize != null) {
|
|
node.properties = node.properties || {};
|
|
node.properties["tile_size"] = savedTileSize;
|
|
if (zoomWidget) {
|
|
zoomWidget.value = parseInt(savedTileSize);
|
|
}
|
|
if (viewContainer && viewContainer.style) {
|
|
viewContainer.style.setProperty("--lora-tile-size", `${savedTileSize}px`);
|
|
}
|
|
}
|
|
|
|
const curWidget = getHiddenWidget();
|
|
const widgetIndex = node.widgets.indexOf(curWidget);
|
|
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
|
|
if (savedValue) {
|
|
curWidget.value = savedValue;
|
|
}
|
|
|
|
const curModeWidget = getHiddenModeWidget();
|
|
const modeIndex = node.widgets.indexOf(curModeWidget);
|
|
const savedMode = (config.widgets_values && modeIndex !== -1) ? config.widgets_values[modeIndex] : curModeWidget.value;
|
|
if (savedMode) {
|
|
curModeWidget.value = savedMode;
|
|
}
|
|
|
|
const curScrapeWidget = getHiddenScrapeWidget();
|
|
const scrapeIndex = node.widgets.indexOf(curScrapeWidget);
|
|
const savedScrape = (config.widgets_values && scrapeIndex !== -1) ? config.widgets_values[scrapeIndex] : curScrapeWidget.value;
|
|
if (savedScrape) {
|
|
curScrapeWidget.value = savedScrape;
|
|
}
|
|
|
|
syncModeToggleUI();
|
|
syncScrapeToggleUI();
|
|
updateLoraCountDisplay();
|
|
setTimeout(() => updateVisualGrid(), 100);
|
|
};
|
|
|
|
const origOnDrawForeground = node.onDrawForeground;
|
|
node.onDrawForeground = function(ctx) {
|
|
if (node.flags?.collapsed) {
|
|
const isSaturn = isSaturnColorsEnabled();
|
|
const count = node._selectedLoraCount ?? getSelectedLoras().length;
|
|
const label = `${count} LoRA${count === 1 ? "" : "s"}`;
|
|
ctx.save();
|
|
ctx.font = "bold 11px Inter, system-ui, sans-serif";
|
|
const badgeText = label;
|
|
const tw = ctx.measureText(badgeText).width;
|
|
const bx = node.size[0] - tw - 16;
|
|
const by = -LiteGraph.NODE_TITLE_HEIGHT + 3;
|
|
|
|
if (isSaturn) {
|
|
ctx.fillStyle = count > 0 ? "rgba(180, 140, 95, 0.25)" : "rgba(255, 255, 255, 0.08)";
|
|
ctx.strokeStyle = count > 0 ? "rgba(180, 140, 95, 0.6)" : "rgba(255, 255, 255, 0.2)";
|
|
} else {
|
|
ctx.fillStyle = count > 0 ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.08)";
|
|
ctx.strokeStyle = count > 0 ? "rgba(255, 255, 255, 0.25)" : "rgba(255, 255, 255, 0.2)";
|
|
}
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
if (ctx.roundRect) {
|
|
ctx.roundRect(bx, by, tw + 10, 18, 4);
|
|
} else {
|
|
ctx.rect(bx, by, tw + 10, 18);
|
|
}
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = isSaturn ? (count > 0 ? "#decbb2" : "#cbd5e1") : (count > 0 ? "#e2e8f0" : "#cbd5e1");
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(badgeText, bx + 5, by + 9);
|
|
ctx.restore();
|
|
}
|
|
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
|
|
};
|
|
|
|
const origOnResize = node.onResize;
|
|
node.onResize = function() {
|
|
if (origOnResize) origOnResize.apply(this, arguments);
|
|
node._enforceFixedLoraBadgeSize?.();
|
|
};
|
|
|
|
syncModeToggleUI();
|
|
syncScrapeToggleUI();
|
|
|
|
setTimeout(() => {
|
|
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
|
if (displayModeWidget) {
|
|
const origCallback = displayModeWidget.callback;
|
|
displayModeWidget.callback = function(val) {
|
|
if (origCallback) origCallback.apply(this, arguments);
|
|
applyDisplayMode(val || displayModeWidget.value);
|
|
};
|
|
applyDisplayMode(displayModeWidget.value || "Scrollable");
|
|
}
|
|
updateVisualGrid();
|
|
updateLoraCountDisplay();
|
|
}, 100);
|
|
}
|
|
}
|
|
}); |