2244 lines
76 KiB
JavaScript
2244 lines
76 KiB
JavaScript
/**
|
||
* SEngine - Reactive LoRA Browser with Image Previews
|
||
*/
|
||
import { app } from "../../scripts/app.js";
|
||
import { api } from "../../scripts/api.js";
|
||
|
||
const NODE_TYPE = "SEngineLoraLoader";
|
||
|
||
// Track download states
|
||
const downloadStates = {};
|
||
|
||
// ============================================================================
|
||
// Styles
|
||
// ============================================================================
|
||
|
||
const STYLES = `
|
||
.sengine-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
background: #1e1e1e;
|
||
color: #e0e0e0;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
font-size: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.sengine-header {
|
||
padding: 16px;
|
||
background: linear-gradient(180deg, #2a2a2a 0%, #222 100%);
|
||
border-bottom: 1px solid #3a3a3a;
|
||
}
|
||
.sengine-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.sengine-title::before { content: "⚡"; }
|
||
.sengine-target {
|
||
padding: 8px 12px;
|
||
background: #1e1e1e;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.sengine-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-top: 12px;
|
||
}
|
||
.sengine-tab {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
border-radius: 6px 6px 0 0;
|
||
background: #1e1e1e;
|
||
color: #888;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.sengine-tab:hover {
|
||
background: #252525;
|
||
color: #aaa;
|
||
}
|
||
.sengine-tab.active {
|
||
background: #2a2a2a;
|
||
color: #5a5;
|
||
border-bottom: 2px solid #5a5;
|
||
}
|
||
.sengine-tab-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
.sengine-upload-area {
|
||
padding: 12px 16px;
|
||
background: #252525;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
.sengine-label {
|
||
font-size: 10px;
|
||
color: #888;
|
||
margin-bottom: 4px;
|
||
display: block;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.sengine-input {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
background: #2a2a2a;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
box-sizing: border-box;
|
||
}
|
||
.sengine-input:focus { border-color: #5a5; outline: none; }
|
||
.sengine-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: #555;
|
||
}
|
||
.sengine-dot.active { background: #5a5; box-shadow: 0 0 6px #5a5; }
|
||
.sengine-target-text { color: #777; }
|
||
.sengine-target-text.active { color: #8c8; }
|
||
.sengine-toolbar {
|
||
padding: 10px 16px;
|
||
display: flex;
|
||
gap: 8px;
|
||
background: #252525;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
.sengine-search {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
background: #1e1e1e;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
}
|
||
.sengine-search:focus { border-color: #5a5; outline: none; }
|
||
.sengine-btn {
|
||
padding: 8px 14px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
background: #3a3a3a;
|
||
color: #fff;
|
||
}
|
||
.sengine-btn:hover { background: #4a4a4a; }
|
||
.sengine-filter-row {
|
||
padding: 8px 16px;
|
||
background: #252525;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
.sengine-filter-container {
|
||
position: relative;
|
||
}
|
||
.sengine-filter-btn {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
background: #1e1e1e;
|
||
color: #ccc;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.sengine-filter-btn:hover { border-color: #555; }
|
||
.sengine-filter-btn.active { border-color: #5a5; }
|
||
.sengine-filter-arrow { font-size: 10px; color: #666; }
|
||
.sengine-filter-dropdown {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
margin-top: 4px;
|
||
background: #2a2a2a;
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
z-index: 1000;
|
||
display: none;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||
}
|
||
.sengine-filter-dropdown.open { display: block; }
|
||
.sengine-filter-option {
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 11px;
|
||
color: #bbb;
|
||
}
|
||
.sengine-filter-option:hover { background: #333; }
|
||
.sengine-filter-option.selected { background: #2a3a2a; color: #8c8; }
|
||
.sengine-filter-checkbox {
|
||
width: 14px;
|
||
height: 14px;
|
||
border: 1px solid #555;
|
||
border-radius: 3px;
|
||
background: #1e1e1e;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
color: #5a5;
|
||
}
|
||
.sengine-filter-option.selected .sengine-filter-checkbox {
|
||
background: #3a5a3a;
|
||
border-color: #5a5;
|
||
}
|
||
.sengine-filter-count {
|
||
margin-left: auto;
|
||
color: #666;
|
||
font-size: 10px;
|
||
}
|
||
.sengine-selected-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-top: 6px;
|
||
}
|
||
.sengine-selected-tag {
|
||
padding: 2px 8px;
|
||
background: #2a3a2a;
|
||
border: 1px solid #5a5;
|
||
border-radius: 10px;
|
||
font-size: 10px;
|
||
color: #8c8;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.sengine-selected-tag-remove {
|
||
cursor: pointer;
|
||
opacity: 0.7;
|
||
}
|
||
.sengine-selected-tag-remove:hover { opacity: 1; }
|
||
.sengine-upload-btn {
|
||
width: 100%;
|
||
padding: 10px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: linear-gradient(135deg, #4a7 0%, #385 100%);
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
.sengine-upload-btn:hover { background: linear-gradient(135deg, #5b8 0%, #496 100%); }
|
||
.sengine-upload-btn:disabled {
|
||
background: #333;
|
||
color: #666;
|
||
cursor: not-allowed;
|
||
}
|
||
.sengine-upload-status {
|
||
margin-top: 8px;
|
||
font-size: 11px;
|
||
color: #888;
|
||
text-align: center;
|
||
}
|
||
.sengine-upload-status.success { color: #5a5; }
|
||
.sengine-upload-status.error { color: #f66; }
|
||
.sengine-modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0,0,0,0.8);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
}
|
||
.sengine-modal {
|
||
background: #2a2a2a;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
max-width: 90vw;
|
||
max-height: 90vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||
}
|
||
.sengine-modal-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
margin-bottom: 12px;
|
||
text-align: center;
|
||
}
|
||
.sengine-modal-subtitle {
|
||
font-size: 11px;
|
||
color: #888;
|
||
margin-bottom: 16px;
|
||
text-align: center;
|
||
}
|
||
.sengine-modal-message {
|
||
font-size: 12px;
|
||
color: #ccc;
|
||
margin-bottom: 20px;
|
||
line-height: 1.5;
|
||
text-align: center;
|
||
}
|
||
.sengine-modal-preview {
|
||
max-width: 80vw;
|
||
max-height: 60vh;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.sengine-modal-buttons {
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: center;
|
||
}
|
||
.sengine-modal-btn {
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
.sengine-modal-btn.primary {
|
||
background: linear-gradient(135deg, #4a7 0%, #385 100%);
|
||
color: #fff;
|
||
}
|
||
.sengine-modal-btn.primary:hover {
|
||
background: linear-gradient(135deg, #5b8 0%, #496 100%);
|
||
}
|
||
.sengine-modal-btn.secondary {
|
||
background: #444;
|
||
color: #fff;
|
||
}
|
||
.sengine-modal-btn.secondary:hover {
|
||
background: #555;
|
||
}
|
||
.sengine-modal-btn.cancel {
|
||
background: #333;
|
||
color: #888;
|
||
}
|
||
.sengine-modal-btn.cancel:hover {
|
||
background: #3a3a3a;
|
||
color: #aaa;
|
||
}
|
||
.sengine-browser {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px;
|
||
background: #1a1a1a;
|
||
}
|
||
.sengine-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 10px;
|
||
}
|
||
.sengine-lora {
|
||
background: #2a2a2a;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
border: 2px solid transparent;
|
||
transition: all 0.15s;
|
||
}
|
||
.sengine-lora:hover {
|
||
background: #333;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||
}
|
||
.sengine-lora.selected { border-color: #5a5; background: #2a3a2a; }
|
||
.sengine-lora-img {
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
object-fit: cover;
|
||
background: #222;
|
||
display: block;
|
||
}
|
||
.sengine-lora-placeholder {
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
background: linear-gradient(135deg, #333 0%, #222 100%);
|
||
}
|
||
.sengine-lora-info { padding: 8px; }
|
||
.sengine-lora-name {
|
||
font-size: 10px;
|
||
color: #bbb;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.sengine-lora.selected .sengine-lora-name { color: #8c8; }
|
||
.sengine-status {
|
||
padding: 8px 16px;
|
||
font-size: 11px;
|
||
color: #666;
|
||
background: #1a1a1a;
|
||
border-top: 1px solid #333;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
.sengine-selected {
|
||
background: #222;
|
||
max-height: 220px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.sengine-selected-header {
|
||
padding: 10px 16px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
background: #252525;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
.sengine-selected-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
.sengine-item {
|
||
padding: 10px;
|
||
margin-bottom: 6px;
|
||
background: #2a2a2a;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.sengine-item:hover { background: #333; }
|
||
.sengine-item-thumb {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
background: #333;
|
||
}
|
||
.sengine-item-details { flex: 1; min-width: 0; }
|
||
.sengine-item-name {
|
||
font-size: 11px;
|
||
color: #ccc;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-bottom: 4px;
|
||
}
|
||
.sengine-item-strength { display: flex; align-items: center; gap: 6px; }
|
||
.sengine-strength-input {
|
||
width: 50px;
|
||
padding: 4px 6px;
|
||
border: 1px solid #444;
|
||
border-radius: 4px;
|
||
background: #1e1e1e;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
text-align: center;
|
||
}
|
||
.sengine-strength-input:focus { border-color: #5a5; outline: none; }
|
||
.sengine-remove {
|
||
width: 24px;
|
||
height: 24px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
color: #666;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.sengine-remove:hover { background: #522; color: #f88; }
|
||
.sengine-reorder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
margin-right: 4px;
|
||
}
|
||
.sengine-reorder-btn {
|
||
width: 20px;
|
||
height: 14px;
|
||
border: none;
|
||
border-radius: 3px;
|
||
background: #333;
|
||
color: #888;
|
||
cursor: pointer;
|
||
font-size: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
}
|
||
.sengine-reorder-btn:hover { background: #444; color: #fff; }
|
||
.sengine-reorder-btn:disabled { opacity: 0.3; cursor: default; }
|
||
.sengine-reorder-btn:disabled:hover { background: #333; color: #888; }
|
||
.sengine-empty {
|
||
padding: 30px 20px;
|
||
text-align: center;
|
||
color: #555;
|
||
font-size: 12px;
|
||
}
|
||
.sengine-loading {
|
||
padding: 40px 20px;
|
||
text-align: center;
|
||
color: #666;
|
||
}
|
||
.sengine-progress {
|
||
height: 16px;
|
||
background: #333;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
.sengine-progress-bar {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #4a4 0%, #5b5 100%);
|
||
border-radius: 8px;
|
||
transition: width 0.2s ease;
|
||
}
|
||
.sengine-progress span {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
font-size: 9px;
|
||
color: #fff;
|
||
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||
}
|
||
.sengine-image-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
.sengine-image-option {
|
||
background: #1e1e1e;
|
||
border: 2px solid #444;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.sengine-image-option:hover {
|
||
border-color: #5a5;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(90,170,90,0.3);
|
||
}
|
||
.sengine-image-option img {
|
||
width: 100%;
|
||
height: 200px;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.sengine-image-label {
|
||
padding: 8px;
|
||
text-align: center;
|
||
font-size: 11px;
|
||
color: #aaa;
|
||
background: #252525;
|
||
}
|
||
.sengine-selected-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.sengine-save-config-btn {
|
||
padding: 4px 8px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: #3a3a3a;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.sengine-save-config-btn:hover {
|
||
background: #4a4a4a;
|
||
transform: scale(1.1);
|
||
}
|
||
.sengine-saved-section {
|
||
padding: 12px 16px;
|
||
background: #252525;
|
||
border-bottom: 1px solid #333;
|
||
max-height: 250px;
|
||
overflow-y: auto;
|
||
}
|
||
.sengine-settings-content {
|
||
flex: 1;
|
||
padding: 16px;
|
||
overflow-y: auto;
|
||
}
|
||
.sengine-settings-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
.sengine-settings-group:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
.sengine-help-text {
|
||
margin-top: 6px;
|
||
font-size: 10px;
|
||
color: #666;
|
||
line-height: 1.4;
|
||
}
|
||
.sengine-saved-header {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.sengine-saved-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.sengine-saved-config {
|
||
display: flex;
|
||
gap: 10px;
|
||
padding: 8px;
|
||
background: #1e1e1e;
|
||
border-radius: 6px;
|
||
border: 1px solid #333;
|
||
transition: all 0.2s;
|
||
cursor: pointer;
|
||
}
|
||
.sengine-saved-config:hover {
|
||
border-color: #5a5;
|
||
background: #252525;
|
||
}
|
||
.sengine-config-thumb {
|
||
width: 60px;
|
||
height: 60px;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
background: #2a2a2a;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.sengine-config-thumb img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.sengine-config-no-thumb {
|
||
font-size: 9px;
|
||
color: #555;
|
||
text-align: center;
|
||
}
|
||
.sengine-config-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
min-width: 0;
|
||
}
|
||
.sengine-config-name {
|
||
font-size: 12px;
|
||
color: #fff;
|
||
font-weight: 500;
|
||
margin-bottom: 4px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.sengine-config-meta {
|
||
font-size: 10px;
|
||
color: #666;
|
||
}
|
||
.sengine-config-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
justify-content: center;
|
||
}
|
||
.sengine-config-load,
|
||
.sengine-config-delete {
|
||
padding: 4px 6px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: #3a3a3a;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.sengine-config-load:hover {
|
||
background: #4a7;
|
||
}
|
||
.sengine-config-delete:hover {
|
||
background: #a44;
|
||
}
|
||
`;
|
||
|
||
// ============================================================================
|
||
// API
|
||
// ============================================================================
|
||
|
||
const sengineAPI = {
|
||
cache: null,
|
||
cacheTime: 0,
|
||
async fetchLoras(apiKey = "", refresh = false) {
|
||
if (!refresh && this.cache && Date.now() - this.cacheTime < 60000) {
|
||
return this.cache;
|
||
}
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (apiKey) params.set("api_key", apiKey);
|
||
if (refresh) params.set("refresh", "true");
|
||
const resp = await api.fetchApi(`/sengine/loras?${params}`);
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
this.cache = data.loras;
|
||
this.cacheTime = Date.now();
|
||
}
|
||
return this.cache || [];
|
||
} catch (e) {
|
||
console.error("[SEngine]", e);
|
||
return this.cache || [];
|
||
}
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// Manager
|
||
// ============================================================================
|
||
|
||
class SEngineManager {
|
||
constructor() {
|
||
this.panel = null;
|
||
this.loras = [];
|
||
this.selectedLoras = [];
|
||
this.searchQuery = "";
|
||
this.selectedTags = [];
|
||
this.apiKey = localStorage.getItem("sengine_api_key") || "";
|
||
this.sessionCookie = localStorage.getItem("sengine_session_cookie") || "";
|
||
this.targetNode = null;
|
||
this.lastGeneratedImages = []; // Array of {filename, subfolder, type}
|
||
this.savedConfigs = this.loadSavedConfigs();
|
||
}
|
||
|
||
getOrCreateNode() {
|
||
let node = app.graph._nodes.find(n => n.type === NODE_TYPE);
|
||
if (!node) {
|
||
node = LiteGraph.createNode(NODE_TYPE);
|
||
const canvas = app.canvas;
|
||
if (canvas) {
|
||
const cx = (-canvas.ds.offset[0] + canvas.canvas.width / 2) / canvas.ds.scale;
|
||
const cy = (-canvas.ds.offset[1] + canvas.canvas.height / 2) / canvas.ds.scale;
|
||
node.pos = [cx - 100, cy - 50];
|
||
} else {
|
||
node.pos = [100, 100];
|
||
}
|
||
app.graph.add(node);
|
||
}
|
||
this.setTargetNode(node);
|
||
return node;
|
||
}
|
||
|
||
setTargetNode(node) {
|
||
this.targetNode = node;
|
||
if (node) {
|
||
// Try to load from widget first, then from property
|
||
const dataWidget = node.widgets?.find(w => w.name === "sengine_data");
|
||
const jsonStr = dataWidget?.value || node.sengineData || "{}";
|
||
|
||
try {
|
||
const data = JSON.parse(jsonStr);
|
||
this.selectedLoras = data.loras || [];
|
||
if (data.api_key) this.apiKey = data.api_key;
|
||
} catch (e) {
|
||
this.selectedLoras = [];
|
||
}
|
||
} else {
|
||
this.selectedLoras = [];
|
||
}
|
||
this.updateUI();
|
||
this.renderGrid();
|
||
this.renderSelected();
|
||
}
|
||
|
||
saveToNode() {
|
||
if (!this.targetNode) return;
|
||
|
||
const data = {
|
||
api_key: this.apiKey,
|
||
loras: this.selectedLoras.map(l => ({
|
||
version_id: l.version_id,
|
||
name: l.name,
|
||
strength: l.strength,
|
||
strength_clip: l.strength_clip,
|
||
file_name: l.file_name,
|
||
download_url: l.download_url || "",
|
||
preview_url: l.preview_url || ""
|
||
}))
|
||
};
|
||
|
||
const jsonStr = JSON.stringify(data);
|
||
|
||
// Store on node property for serialization
|
||
this.targetNode.sengineData = jsonStr;
|
||
|
||
// Also update the actual widget for execution
|
||
const dataWidget = this.targetNode.widgets?.find(w => w.name === "sengine_data");
|
||
if (dataWidget) {
|
||
dataWidget.value = jsonStr;
|
||
}
|
||
|
||
this.rebuildNodeWidgets();
|
||
}
|
||
|
||
rebuildNodeWidgets() {
|
||
const node = this.targetNode;
|
||
if (!node) return;
|
||
|
||
// Keep overall_strength and sengine_data widgets
|
||
const overallWidget = node.widgets?.find(w => w.name === "overall_strength");
|
||
const dataWidget = node.widgets?.find(w => w.name === "sengine_data");
|
||
|
||
// Start with core widgets
|
||
node.widgets = [];
|
||
if (overallWidget) node.widgets.push(overallWidget);
|
||
if (dataWidget) {
|
||
// Hide the data widget
|
||
dataWidget.computeSize = () => [0, -4];
|
||
dataWidget.type = "hidden";
|
||
node.widgets.push(dataWidget);
|
||
}
|
||
|
||
// Add slider for each LoRA
|
||
this.selectedLoras.forEach((lora, idx) => {
|
||
const state = downloadStates[lora.version_id];
|
||
const isDownloading = state?.status === "downloading";
|
||
const isFailed = state?.status === "failed";
|
||
|
||
let displayName = lora.name.length > 16
|
||
? lora.name.substring(0, 14) + ".."
|
||
: lora.name;
|
||
|
||
if (isDownloading) {
|
||
const pct = Math.round((state.progress || 0) * 100);
|
||
displayName = `⏳ ${pct}% ${displayName}`;
|
||
} else if (isFailed) {
|
||
displayName = `❌ ${displayName}`;
|
||
}
|
||
|
||
node.addWidget("number", displayName, lora.strength, (value) => {
|
||
this.selectedLoras[idx].strength = value;
|
||
this.selectedLoras[idx].strength_clip = value;
|
||
this.saveToNode();
|
||
this.renderSelected();
|
||
}, { min: 0, max: 2, step: 0.05, precision: 2 });
|
||
});
|
||
|
||
// Update title and color
|
||
const downloading = this.selectedLoras.filter(l => downloadStates[l.version_id]?.status === "downloading").length;
|
||
const failed = this.selectedLoras.filter(l => downloadStates[l.version_id]?.status === "failed").length;
|
||
|
||
if (downloading > 0) {
|
||
node.title = `SEngine ⏳ ${downloading} downloading...`;
|
||
node.bgcolor = "#2a3a2a";
|
||
} else if (failed > 0) {
|
||
node.title = `SEngine ❌ ${failed} failed`;
|
||
node.bgcolor = "#3a2a2a";
|
||
} else if (this.selectedLoras.length > 0) {
|
||
node.title = `SEngine (${this.selectedLoras.length})`;
|
||
node.bgcolor = null;
|
||
} else {
|
||
node.title = "SEngine LoRA Loader";
|
||
node.bgcolor = null;
|
||
}
|
||
|
||
// Resize (account for overall_strength widget + LoRA sliders)
|
||
const h = 56 + this.selectedLoras.length * 24;
|
||
node.size = [Math.max(node.size?.[0] || 200, 200), Math.max(h, 80)];
|
||
|
||
app.graph?.setDirtyCanvas(true, true);
|
||
}
|
||
|
||
toggleLora(lora) {
|
||
// Check if trying to select (not deselect)
|
||
const idx = this.selectedLoras.findIndex(l => l.version_id === lora.version_id);
|
||
const isSelecting = idx < 0;
|
||
|
||
// If selecting a new LoRA, check if API key is set
|
||
if (isSelecting && (!this.apiKey || this.apiKey.trim() === "")) {
|
||
this.showWarningModal(
|
||
"API Key Required",
|
||
"You need to enter your Civitai API key in the Settings tab before selecting LoRAs. The API key is required for downloading LoRA files from Civitai.",
|
||
"Go to Settings"
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!this.targetNode) {
|
||
this.getOrCreateNode();
|
||
}
|
||
|
||
if (idx >= 0) {
|
||
this.selectedLoras.splice(idx, 1);
|
||
} else {
|
||
this.selectedLoras.push({
|
||
version_id: lora.version_id,
|
||
name: lora.name,
|
||
strength: 1.0,
|
||
strength_clip: 1.0,
|
||
file_name: lora.file_name,
|
||
download_url: lora.download_url || "",
|
||
preview_url: lora.preview_url || ""
|
||
});
|
||
}
|
||
|
||
this.saveToNode();
|
||
this.renderGrid();
|
||
this.renderSelected();
|
||
}
|
||
|
||
updateStrength(idx, value) {
|
||
if (this.selectedLoras[idx]) {
|
||
this.selectedLoras[idx].strength = value;
|
||
this.selectedLoras[idx].strength_clip = value;
|
||
this.saveToNode();
|
||
}
|
||
}
|
||
|
||
removeLora(idx) {
|
||
this.selectedLoras.splice(idx, 1);
|
||
this.saveToNode();
|
||
this.renderGrid();
|
||
this.renderSelected();
|
||
}
|
||
|
||
moveLoraUp(idx) {
|
||
if (idx <= 0 || idx >= this.selectedLoras.length) return;
|
||
const temp = this.selectedLoras[idx];
|
||
this.selectedLoras[idx] = this.selectedLoras[idx - 1];
|
||
this.selectedLoras[idx - 1] = temp;
|
||
this.saveToNode();
|
||
this.renderSelected();
|
||
}
|
||
|
||
moveLoraDown(idx) {
|
||
if (idx < 0 || idx >= this.selectedLoras.length - 1) return;
|
||
const temp = this.selectedLoras[idx];
|
||
this.selectedLoras[idx] = this.selectedLoras[idx + 1];
|
||
this.selectedLoras[idx + 1] = temp;
|
||
this.saveToNode();
|
||
this.renderSelected();
|
||
}
|
||
|
||
toggleTag(tag) {
|
||
const idx = this.selectedTags.indexOf(tag);
|
||
if (idx >= 0) {
|
||
this.selectedTags.splice(idx, 1);
|
||
} else {
|
||
this.selectedTags.push(tag);
|
||
}
|
||
this.renderTags();
|
||
this.renderGrid();
|
||
}
|
||
|
||
clearTags() {
|
||
this.selectedTags = [];
|
||
this.renderTags();
|
||
this.renderGrid();
|
||
}
|
||
|
||
getAllTags() {
|
||
const tagCounts = {};
|
||
this.loras.forEach(lora => {
|
||
(lora.tags || []).forEach(tag => {
|
||
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||
});
|
||
});
|
||
// Sort by count descending
|
||
return Object.entries(tagCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([tag, count]) => ({ tag, count }));
|
||
}
|
||
|
||
// ========== UI ==========
|
||
|
||
createPanel() {
|
||
if (!document.getElementById("sengine-styles")) {
|
||
const styleEl = document.createElement("style");
|
||
styleEl.id = "sengine-styles";
|
||
styleEl.textContent = STYLES;
|
||
document.head.appendChild(styleEl);
|
||
}
|
||
|
||
const panel = document.createElement("div");
|
||
panel.className = "sengine-panel";
|
||
panel.innerHTML = `
|
||
<div class="sengine-header">
|
||
<div class="sengine-title">SEngine LoRAs</div>
|
||
<div class="sengine-tabs">
|
||
<button class="sengine-tab active" data-tab="loras">LoRAs</button>
|
||
<button class="sengine-tab" data-tab="settings">Settings</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- LoRAs Tab -->
|
||
<div class="sengine-tab-content" data-tab="loras">
|
||
<div class="sengine-upload-area">
|
||
<button class="sengine-upload-btn" disabled>
|
||
<span>📤</span>
|
||
<span>Upload Last Image</span>
|
||
</button>
|
||
<div class="sengine-upload-status"></div>
|
||
</div>
|
||
<div class="sengine-saved-section">
|
||
<div class="sengine-saved-header">Saved Configurations</div>
|
||
<div class="sengine-saved-list"></div>
|
||
</div>
|
||
<div class="sengine-toolbar">
|
||
<input type="text" class="sengine-search" placeholder="Search LoRAs...">
|
||
<button class="sengine-btn sengine-refresh">↻</button>
|
||
</div>
|
||
<div class="sengine-filter-row">
|
||
<div class="sengine-filter-container">
|
||
<button class="sengine-filter-btn">
|
||
<span class="sengine-filter-text">Filter by tags...</span>
|
||
<span class="sengine-filter-arrow">▼</span>
|
||
</button>
|
||
<div class="sengine-filter-dropdown"></div>
|
||
</div>
|
||
<div class="sengine-selected-tags"></div>
|
||
</div>
|
||
<div class="sengine-browser">
|
||
<div class="sengine-empty">Click ↻ to load LoRAs</div>
|
||
</div>
|
||
<div class="sengine-status"></div>
|
||
<div class="sengine-selected">
|
||
<div class="sengine-selected-header">
|
||
<span>Selected (<span class="sengine-count">0</span>)</span>
|
||
<button class="sengine-save-config-btn" title="Save Configuration">💾</button>
|
||
</div>
|
||
<div class="sengine-selected-list">
|
||
<div class="sengine-empty">Select LoRAs above</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings Tab -->
|
||
<div class="sengine-tab-content" data-tab="settings" style="display:none;">
|
||
<div class="sengine-settings-content">
|
||
<div class="sengine-settings-group">
|
||
<label class="sengine-label">Node Status</label>
|
||
<div class="sengine-target">
|
||
<div class="sengine-dot"></div>
|
||
<span class="sengine-target-text">Click a LoRA to create node</span>
|
||
</div>
|
||
</div>
|
||
<div class="sengine-settings-group">
|
||
<label class="sengine-label">Civitai API Key</label>
|
||
<input type="password" class="sengine-input sengine-api-key" placeholder="Enter API key for downloads...">
|
||
<div class="sengine-help-text">Required for downloading LoRAs from Civitai</div>
|
||
</div>
|
||
<div class="sengine-settings-group">
|
||
<label class="sengine-label">Civitai Session Cookie</label>
|
||
<input type="password" class="sengine-input sengine-session-cookie" placeholder="__Secure-civitai-token value...">
|
||
<div class="sengine-help-text">Required for uploading images to Civitai. Go to LoRAs tab to upload.</div>
|
||
</div>
|
||
<div class="sengine-settings-group">
|
||
<label class="sengine-label">Memory Management</label>
|
||
<button class="sengine-btn sengine-clear-memory" style="width:100%;">
|
||
Clear LoRA Memory Cache
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this.panel = panel;
|
||
|
||
const apiInput = panel.querySelector(".sengine-api-key");
|
||
apiInput.value = this.apiKey;
|
||
apiInput.oninput = (e) => {
|
||
this.apiKey = e.target.value;
|
||
localStorage.setItem("sengine_api_key", this.apiKey);
|
||
if (this.targetNode) this.saveToNode();
|
||
};
|
||
|
||
panel.querySelector(".sengine-search").oninput = (e) => {
|
||
this.searchQuery = e.target.value;
|
||
this.renderGrid();
|
||
};
|
||
|
||
panel.querySelector(".sengine-refresh").onclick = () => this.loadLoras(true);
|
||
|
||
// Session cookie input
|
||
const cookieInput = panel.querySelector(".sengine-session-cookie");
|
||
cookieInput.value = this.sessionCookie;
|
||
cookieInput.oninput = (e) => {
|
||
this.sessionCookie = e.target.value;
|
||
localStorage.setItem("sengine_session_cookie", this.sessionCookie);
|
||
this.updateUploadButton();
|
||
};
|
||
|
||
// Upload button
|
||
panel.querySelector(".sengine-upload-btn").onclick = () => this.uploadToCivitai();
|
||
|
||
// Tab switching
|
||
panel.querySelectorAll(".sengine-tab").forEach(tab => {
|
||
tab.onclick = () => {
|
||
const tabName = tab.dataset.tab;
|
||
// Update tab buttons
|
||
panel.querySelectorAll(".sengine-tab").forEach(t => t.classList.remove("active"));
|
||
tab.classList.add("active");
|
||
// Update tab content
|
||
panel.querySelectorAll(".sengine-tab-content").forEach(content => {
|
||
content.style.display = content.dataset.tab === tabName ? "flex" : "none";
|
||
});
|
||
};
|
||
});
|
||
|
||
// Save config button
|
||
panel.querySelector(".sengine-save-config-btn").onclick = () => this.showSaveConfigModal();
|
||
|
||
// Clear memory button
|
||
panel.querySelector(".sengine-clear-memory").onclick = async () => {
|
||
const btn = panel.querySelector(".sengine-clear-memory");
|
||
const originalText = btn.textContent;
|
||
btn.textContent = "Clearing...";
|
||
btn.disabled = true;
|
||
try {
|
||
const resp = await api.fetchApi("/sengine/lora-memory/clear", { method: "POST" });
|
||
const result = await resp.json();
|
||
if (result.success) {
|
||
btn.textContent = `Cleared ${result.cleared_count} LoRA(s)!`;
|
||
setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000);
|
||
} else {
|
||
btn.textContent = "Error!";
|
||
setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000);
|
||
}
|
||
} catch (e) {
|
||
btn.textContent = "Error!";
|
||
setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000);
|
||
}
|
||
};
|
||
|
||
// Setup filter dropdown after panel is ready
|
||
setTimeout(() => {
|
||
this.setupFilterDropdown();
|
||
this.updateUploadButton();
|
||
this.renderSavedConfigs();
|
||
}, 0);
|
||
|
||
return panel;
|
||
}
|
||
|
||
updateUI() {
|
||
if (!this.panel) return;
|
||
const dot = this.panel.querySelector(".sengine-dot");
|
||
const text = this.panel.querySelector(".sengine-target-text");
|
||
|
||
if (this.targetNode) {
|
||
dot?.classList.add("active");
|
||
text?.classList.add("active");
|
||
if (text) text.textContent = `Node #${this.targetNode.id}`;
|
||
} else {
|
||
dot?.classList.remove("active");
|
||
text?.classList.remove("active");
|
||
if (text) text.textContent = "Click a LoRA to create node";
|
||
}
|
||
}
|
||
|
||
async loadLoras(refresh = false) {
|
||
const browser = this.panel?.querySelector(".sengine-browser");
|
||
if (browser) browser.innerHTML = '<div class="sengine-loading">Loading LoRAs...</div>';
|
||
this.loras = await sengineAPI.fetchLoras(this.apiKey, refresh);
|
||
this.renderTags();
|
||
this.renderGrid();
|
||
}
|
||
|
||
renderTags() {
|
||
const filterRow = this.panel?.querySelector(".sengine-filter-row");
|
||
const dropdown = this.panel?.querySelector(".sengine-filter-dropdown");
|
||
const filterBtn = this.panel?.querySelector(".sengine-filter-btn");
|
||
const filterText = this.panel?.querySelector(".sengine-filter-text");
|
||
const selectedTagsContainer = this.panel?.querySelector(".sengine-selected-tags");
|
||
|
||
if (!dropdown || !filterRow) return;
|
||
|
||
const allTags = this.getAllTags();
|
||
if (!allTags.length) {
|
||
filterRow.style.display = "none";
|
||
return;
|
||
}
|
||
|
||
filterRow.style.display = "block";
|
||
|
||
// Update button text
|
||
if (filterText) {
|
||
filterText.textContent = this.selectedTags.length > 0
|
||
? `${this.selectedTags.length} tag${this.selectedTags.length > 1 ? 's' : ''} selected`
|
||
: "Filter by tags...";
|
||
}
|
||
if (filterBtn) {
|
||
filterBtn.classList.toggle("active", this.selectedTags.length > 0);
|
||
}
|
||
|
||
// Render dropdown options
|
||
dropdown.innerHTML = "";
|
||
allTags.forEach(({ tag, count }) => {
|
||
const isSelected = this.selectedTags.includes(tag);
|
||
const el = document.createElement("div");
|
||
el.className = `sengine-filter-option ${isSelected ? 'selected' : ''}`;
|
||
el.innerHTML = `
|
||
<span class="sengine-filter-checkbox">${isSelected ? '✓' : ''}</span>
|
||
<span>${tag}</span>
|
||
<span class="sengine-filter-count">${count}</span>
|
||
`;
|
||
el.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.toggleTag(tag);
|
||
};
|
||
dropdown.appendChild(el);
|
||
});
|
||
|
||
// Render selected tags as removable chips
|
||
if (selectedTagsContainer) {
|
||
selectedTagsContainer.innerHTML = "";
|
||
this.selectedTags.forEach(tag => {
|
||
const chip = document.createElement("span");
|
||
chip.className = "sengine-selected-tag";
|
||
chip.innerHTML = `${tag}<span class="sengine-selected-tag-remove">✕</span>`;
|
||
chip.querySelector(".sengine-selected-tag-remove").onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.toggleTag(tag);
|
||
};
|
||
selectedTagsContainer.appendChild(chip);
|
||
});
|
||
}
|
||
}
|
||
|
||
setupFilterDropdown() {
|
||
const filterBtn = this.panel?.querySelector(".sengine-filter-btn");
|
||
const dropdown = this.panel?.querySelector(".sengine-filter-dropdown");
|
||
|
||
if (!filterBtn || !dropdown) return;
|
||
|
||
filterBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
dropdown.classList.toggle("open");
|
||
};
|
||
|
||
// Close dropdown when clicking outside
|
||
document.addEventListener("click", (e) => {
|
||
if (!this.panel?.contains(e.target) || !e.target.closest(".sengine-filter-container")) {
|
||
dropdown.classList.remove("open");
|
||
}
|
||
});
|
||
}
|
||
|
||
loadSavedConfigs() {
|
||
try {
|
||
const saved = localStorage.getItem("sengine_saved_configs");
|
||
return saved ? JSON.parse(saved) : [];
|
||
} catch (e) {
|
||
console.error("[SEngine] Error loading saved configs:", e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
saveSavedConfigs() {
|
||
try {
|
||
localStorage.setItem("sengine_saved_configs", JSON.stringify(this.savedConfigs));
|
||
} catch (e) {
|
||
console.error("[SEngine] Error saving configs:", e);
|
||
}
|
||
}
|
||
|
||
async saveCurrentConfig(name) {
|
||
if (!this.targetNode || this.selectedLoras.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// Get current configuration
|
||
const overallWidget = this.targetNode.widgets?.find(w => w.name === "overall_strength");
|
||
const config = {
|
||
overall_strength: overallWidget?.value ?? 1.0,
|
||
loras: this.selectedLoras.map(l => ({
|
||
version_id: l.version_id,
|
||
name: l.name,
|
||
strength: l.strength,
|
||
strength_clip: l.strength_clip,
|
||
file_name: l.file_name,
|
||
download_url: l.download_url || "",
|
||
preview_url: l.preview_url || ""
|
||
}))
|
||
};
|
||
|
||
// Get thumbnail from last generated image
|
||
let thumbnail = null;
|
||
if (this.lastGeneratedImages.length > 0) {
|
||
const lastImg = this.lastGeneratedImages[this.lastGeneratedImages.length - 1];
|
||
const viewUrl = api.apiURL(`/view?filename=${encodeURIComponent(lastImg.filename)}&type=${lastImg.type}&subfolder=${encodeURIComponent(lastImg.subfolder)}`);
|
||
|
||
try {
|
||
// Create a small thumbnail
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
await new Promise((resolve, reject) => {
|
||
img.onload = resolve;
|
||
img.onerror = reject;
|
||
img.src = viewUrl;
|
||
});
|
||
|
||
const canvas = document.createElement("canvas");
|
||
const ctx = canvas.getContext("2d");
|
||
const maxSize = 200;
|
||
const scale = Math.min(maxSize / img.width, maxSize / img.height);
|
||
canvas.width = img.width * scale;
|
||
canvas.height = img.height * scale;
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
thumbnail = canvas.toDataURL("image/jpeg", 0.7);
|
||
} catch (e) {
|
||
console.error("[SEngine] Error creating thumbnail:", e);
|
||
}
|
||
}
|
||
|
||
// Create saved config entry
|
||
const savedConfig = {
|
||
id: Date.now().toString(),
|
||
name: name,
|
||
timestamp: Date.now(),
|
||
thumbnail: thumbnail,
|
||
config: config
|
||
};
|
||
|
||
this.savedConfigs.push(savedConfig);
|
||
this.saveSavedConfigs();
|
||
this.renderSavedConfigs();
|
||
}
|
||
|
||
loadConfig(configId) {
|
||
const config = this.savedConfigs.find(c => c.id === configId);
|
||
if (!config) return;
|
||
|
||
// Load the configuration
|
||
this.selectedLoras = config.config.loras.map(l => ({ ...l }));
|
||
|
||
// Update or create node
|
||
if (!this.targetNode) {
|
||
this.createNode();
|
||
}
|
||
|
||
if (this.targetNode) {
|
||
// Update overall strength
|
||
const overallWidget = this.targetNode.widgets?.find(w => w.name === "overall_strength");
|
||
if (overallWidget) {
|
||
overallWidget.value = config.config.overall_strength;
|
||
}
|
||
|
||
this.saveToNode();
|
||
}
|
||
|
||
this.renderSelected();
|
||
this.renderGrid();
|
||
}
|
||
|
||
deleteConfig(configId) {
|
||
this.savedConfigs = this.savedConfigs.filter(c => c.id !== configId);
|
||
this.saveSavedConfigs();
|
||
this.renderSavedConfigs();
|
||
}
|
||
|
||
updateUploadButton() {
|
||
const btn = this.panel?.querySelector(".sengine-upload-btn");
|
||
if (!btn) return;
|
||
|
||
const hasImage = this.lastGeneratedImages.length > 0;
|
||
const hasCookie = !!this.sessionCookie;
|
||
btn.disabled = !hasImage || !hasCookie;
|
||
|
||
const label = btn.querySelector("span:last-child");
|
||
if (label) {
|
||
if (!hasCookie) {
|
||
label.textContent = "Enter session cookie";
|
||
} else if (!hasImage) {
|
||
label.textContent = "No image generated yet";
|
||
} else if (this.lastGeneratedImages.length > 1) {
|
||
label.textContent = `Upload Image (${this.lastGeneratedImages.length})`;
|
||
} else {
|
||
label.textContent = "Upload Last Image";
|
||
}
|
||
}
|
||
}
|
||
|
||
extractWorkflowMetadata() {
|
||
/**
|
||
* Extract generation metadata from workflow nodes.
|
||
* Looks for SamplerCustomAdvanced/KSampler and CLIPTextEncode nodes.
|
||
*/
|
||
const metadata = {
|
||
prompt: null,
|
||
negative_prompt: null,
|
||
cfg_scale: null,
|
||
steps: null,
|
||
sampler: null,
|
||
seed: null,
|
||
model_name: null,
|
||
};
|
||
|
||
if (!app.graph) return metadata;
|
||
|
||
for (const node of app.graph._nodes) {
|
||
// Look for sampler nodes
|
||
if (node.type === "SamplerCustomAdvanced" || node.type === "KSampler" || node.type === "KSamplerAdvanced") {
|
||
// Try to get values from widgets
|
||
for (const widget of (node.widgets || [])) {
|
||
if (widget.name === "cfg" || widget.name === "cfg_scale") {
|
||
metadata.cfg_scale = widget.value;
|
||
}
|
||
if (widget.name === "steps") {
|
||
metadata.steps = widget.value;
|
||
}
|
||
if (widget.name === "sampler_name" || widget.name === "sampler") {
|
||
metadata.sampler = widget.value;
|
||
}
|
||
if (widget.name === "seed") {
|
||
metadata.seed = widget.value;
|
||
}
|
||
if (widget.name === "noise_seed") {
|
||
metadata.seed = widget.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for BasicScheduler for steps
|
||
if (node.type === "BasicScheduler") {
|
||
for (const widget of (node.widgets || [])) {
|
||
if (widget.name === "steps") {
|
||
metadata.steps = widget.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for BasicGuider or CFGGuider for cfg
|
||
if (node.type === "BasicGuider" || node.type === "CFGGuider") {
|
||
for (const widget of (node.widgets || [])) {
|
||
if (widget.name === "cfg") {
|
||
metadata.cfg_scale = widget.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for KSamplerSelect for sampler name
|
||
if (node.type === "KSamplerSelect") {
|
||
for (const widget of (node.widgets || [])) {
|
||
if (widget.name === "sampler_name") {
|
||
metadata.sampler = widget.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for RandomNoise for seed
|
||
if (node.type === "RandomNoise") {
|
||
for (const widget of (node.widgets || [])) {
|
||
if (widget.name === "noise_seed") {
|
||
metadata.seed = widget.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for CLIP text encode (positive prompt)
|
||
if (node.type === "CLIPTextEncode") {
|
||
// Determine if this is positive or negative by checking connections
|
||
let isPositive = null;
|
||
|
||
// Check what this node connects to
|
||
if (node.outputs && node.outputs[0] && node.outputs[0].links) {
|
||
for (const linkId of node.outputs[0].links) {
|
||
const link = app.graph.links[linkId];
|
||
if (link) {
|
||
const targetNode = app.graph.getNodeById(link.target_id);
|
||
if (targetNode) {
|
||
const targetInput = targetNode.inputs[link.target_slot];
|
||
if (targetInput) {
|
||
// Check input name
|
||
if (targetInput.name === "positive") {
|
||
isPositive = true;
|
||
} else if (targetInput.name === "negative") {
|
||
isPositive = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get the text value
|
||
for (const widget of (node.widgets || [])) {
|
||
if (widget.name === "text") {
|
||
if (isPositive === true) {
|
||
metadata.prompt = widget.value;
|
||
} else if (isPositive === false) {
|
||
metadata.negative_prompt = widget.value;
|
||
} else {
|
||
// Fallback: if we can't determine, use first as positive, second as negative
|
||
if (metadata.prompt === null) {
|
||
metadata.prompt = widget.value;
|
||
} else if (metadata.negative_prompt === null) {
|
||
metadata.negative_prompt = widget.value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for model loader nodes (UNETLoader, Load Diffusion Model)
|
||
if (node.type === "UNETLoader" || node.type === "Load Diffusion Model") {
|
||
for (const widget of (node.widgets || [])) {
|
||
if (widget.name === "unet_name") {
|
||
metadata.model_name = widget.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Look for LoadImage nodes (indicates img2img workflow)
|
||
// Check various possible node types for image loading
|
||
const imageLoadTypes = ["LoadImage", "Load Image", "LoadImageMask", "LoadImageFromUrl"];
|
||
if (imageLoadTypes.includes(node.type) || node.type?.toLowerCase().includes("loadimage")) {
|
||
console.log("[SEngine] Found image load node:", node.type, node.widgets?.map(w => `${w.name}=${w.value}`));
|
||
for (const widget of (node.widgets || [])) {
|
||
if (widget.name === "image" && widget.value) {
|
||
if (!metadata.source_images) {
|
||
metadata.source_images = [];
|
||
}
|
||
metadata.source_images.push(widget.value);
|
||
console.log("[SEngine] Added source image:", widget.value);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("[SEngine] Extracted metadata:", JSON.stringify(metadata, null, 2));
|
||
console.log("[SEngine] All node types in graph:", app.graph._nodes.map(n => n.type));
|
||
return metadata;
|
||
}
|
||
|
||
showWarningModal(title, message, buttonText = "OK") {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "sengine-modal-overlay";
|
||
|
||
overlay.innerHTML = `
|
||
<div class="sengine-modal" style="max-width:400px;">
|
||
<div class="sengine-modal-title">${title}</div>
|
||
<div class="sengine-modal-message">${message}</div>
|
||
<div class="sengine-modal-buttons">
|
||
<button class="sengine-modal-btn primary" data-action="ok">${buttonText}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
const handleClose = () => {
|
||
document.body.removeChild(overlay);
|
||
// If button says "Go to Settings", switch to settings tab
|
||
if (buttonText === "Go to Settings" && this.panel) {
|
||
const settingsTab = this.panel.querySelector('.sengine-tab[data-tab="settings"]');
|
||
if (settingsTab) {
|
||
settingsTab.click();
|
||
}
|
||
}
|
||
};
|
||
|
||
overlay.querySelector('[data-action="ok"]').onclick = handleClose;
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay) {
|
||
handleClose();
|
||
}
|
||
};
|
||
}
|
||
|
||
showSaveConfigModal() {
|
||
if (!this.targetNode || this.selectedLoras.length === 0) {
|
||
alert("Please select some LoRAs first!");
|
||
return;
|
||
}
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "sengine-modal-overlay";
|
||
|
||
overlay.innerHTML = `
|
||
<div class="sengine-modal" style="max-width:400px;">
|
||
<div class="sengine-modal-title">Save Configuration</div>
|
||
<div class="sengine-modal-subtitle">Enter a name for this configuration</div>
|
||
<input type="text" class="sengine-input" placeholder="Configuration name..." style="margin-bottom:16px;">
|
||
<div class="sengine-modal-buttons">
|
||
<button class="sengine-modal-btn primary" data-action="save">Save</button>
|
||
<button class="sengine-modal-btn cancel" data-action="cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
const input = overlay.querySelector("input");
|
||
input.focus();
|
||
|
||
const handleSave = async () => {
|
||
const name = input.value.trim();
|
||
if (!name) {
|
||
alert("Please enter a name!");
|
||
return;
|
||
}
|
||
document.body.removeChild(overlay);
|
||
await this.saveCurrentConfig(name);
|
||
};
|
||
|
||
input.onkeydown = (e) => {
|
||
if (e.key === "Enter") handleSave();
|
||
};
|
||
|
||
overlay.querySelector('[data-action="save"]').onclick = handleSave;
|
||
overlay.querySelector('[data-action="cancel"]').onclick = () => {
|
||
document.body.removeChild(overlay);
|
||
};
|
||
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay) {
|
||
document.body.removeChild(overlay);
|
||
}
|
||
};
|
||
}
|
||
|
||
renderSavedConfigs() {
|
||
const container = this.panel?.querySelector(".sengine-saved-list");
|
||
if (!container) return;
|
||
|
||
if (this.savedConfigs.length === 0) {
|
||
container.innerHTML = '<div class="sengine-empty" style="padding:12px;text-align:center;">No saved configurations</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.savedConfigs.map(config => {
|
||
const date = new Date(config.timestamp).toLocaleDateString();
|
||
const loraCount = config.config.loras.length;
|
||
const thumbnailHtml = config.thumbnail
|
||
? `<img src="${config.thumbnail}" alt="${config.name}">`
|
||
: '<div class="sengine-config-no-thumb">No preview</div>';
|
||
|
||
return `
|
||
<div class="sengine-saved-config" data-id="${config.id}">
|
||
<div class="sengine-config-thumb">${thumbnailHtml}</div>
|
||
<div class="sengine-config-info">
|
||
<div class="sengine-config-name">${config.name}</div>
|
||
<div class="sengine-config-meta">${loraCount} LoRA${loraCount > 1 ? 's' : ''} • ${date}</div>
|
||
</div>
|
||
<div class="sengine-config-actions">
|
||
<button class="sengine-config-load" title="Load">📂</button>
|
||
<button class="sengine-config-delete" title="Delete">🗑️</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Add event listeners
|
||
container.querySelectorAll(".sengine-saved-config").forEach(el => {
|
||
const id = el.dataset.id;
|
||
el.querySelector(".sengine-config-load").onclick = () => this.loadConfig(id);
|
||
el.querySelector(".sengine-config-delete").onclick = (e) => {
|
||
e.stopPropagation();
|
||
if (confirm("Delete this configuration?")) {
|
||
this.deleteConfig(id);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
showImageSelectionModal(images) {
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "sengine-modal-overlay";
|
||
|
||
const imageGridHtml = images.map((img, idx) => {
|
||
const viewUrl = api.apiURL(`/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`);
|
||
return `
|
||
<div class="sengine-image-option" data-index="${idx}">
|
||
<img src="${viewUrl}" alt="Image ${idx + 1}">
|
||
<div class="sengine-image-label">Image ${idx + 1}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
overlay.innerHTML = `
|
||
<div class="sengine-modal">
|
||
<div class="sengine-modal-title">Select Image to Upload</div>
|
||
<div class="sengine-modal-subtitle">Choose which image from the batch to upload</div>
|
||
<div class="sengine-image-grid">
|
||
${imageGridHtml}
|
||
</div>
|
||
<div class="sengine-modal-buttons">
|
||
<button class="sengine-modal-btn cancel" data-choice="cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
// Handle image selection
|
||
overlay.querySelectorAll(".sengine-image-option").forEach(option => {
|
||
option.onclick = () => {
|
||
const index = parseInt(option.dataset.index);
|
||
document.body.removeChild(overlay);
|
||
resolve(index);
|
||
};
|
||
});
|
||
|
||
// Handle cancel
|
||
overlay.querySelector(".cancel").onclick = () => {
|
||
document.body.removeChild(overlay);
|
||
resolve(null);
|
||
};
|
||
|
||
// Close on overlay click
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay) {
|
||
document.body.removeChild(overlay);
|
||
resolve(null);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
showCompositePreviewModal(compositeBase64, metadata, loraVersionIds, sengineConfig) {
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "sengine-modal-overlay";
|
||
|
||
overlay.innerHTML = `
|
||
<div class="sengine-modal">
|
||
<div class="sengine-modal-title">Image-to-Image Detected</div>
|
||
<div class="sengine-modal-subtitle">Would you like to upload the composite or just the generated image?</div>
|
||
<img class="sengine-modal-preview" src="${compositeBase64}" alt="Composite Preview">
|
||
<div class="sengine-modal-buttons">
|
||
<button class="sengine-modal-btn primary" data-choice="composite">Upload Composite</button>
|
||
<button class="sengine-modal-btn secondary" data-choice="original">Upload Original Only</button>
|
||
<button class="sengine-modal-btn cancel" data-choice="cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
overlay.addEventListener("click", (e) => {
|
||
const choice = e.target.dataset?.choice;
|
||
if (choice) {
|
||
overlay.remove();
|
||
resolve(choice);
|
||
}
|
||
});
|
||
|
||
document.body.appendChild(overlay);
|
||
});
|
||
}
|
||
|
||
async uploadToCivitai() {
|
||
if (this.lastGeneratedImages.length === 0) {
|
||
return;
|
||
}
|
||
|
||
if (!this.sessionCookie || this.sessionCookie.trim() === "") {
|
||
this.showWarningModal(
|
||
"Session Cookie Required",
|
||
"You need to enter your Civitai session cookie in the Settings tab before uploading images. The cookie is required for authenticating with Civitai.",
|
||
"Go to Settings"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const statusEl = this.panel?.querySelector(".sengine-upload-status");
|
||
const btn = this.panel?.querySelector(".sengine-upload-btn");
|
||
|
||
// If multiple images, show selection modal
|
||
let selectedImageIndex = 0;
|
||
if (this.lastGeneratedImages.length > 1) {
|
||
selectedImageIndex = await this.showImageSelectionModal(this.lastGeneratedImages);
|
||
if (selectedImageIndex === null) {
|
||
// User cancelled
|
||
return;
|
||
}
|
||
}
|
||
|
||
const selectedImage = this.lastGeneratedImages[selectedImageIndex];
|
||
|
||
// Get workflow metadata
|
||
const metadata = this.extractWorkflowMetadata();
|
||
|
||
// Get LoRA version IDs from selected loras
|
||
const loraVersionIds = this.selectedLoras.map(l => l.version_id);
|
||
|
||
// Get SEngine configuration
|
||
let sengineConfig = null;
|
||
if (this.targetNode && this.selectedLoras.length > 0) {
|
||
const overallWidget = this.targetNode.widgets?.find(w => w.name === "overall_strength");
|
||
sengineConfig = {
|
||
overall_strength: overallWidget?.value ?? 1.0,
|
||
loras: this.selectedLoras.map(l => ({
|
||
name: l.name,
|
||
strength: l.strength,
|
||
})),
|
||
};
|
||
}
|
||
|
||
// Check if this is img2img and show preview
|
||
let useComposite = false;
|
||
if (metadata.source_images && metadata.source_images.length > 0) {
|
||
if (statusEl) {
|
||
statusEl.textContent = "Creating preview...";
|
||
statusEl.className = "sengine-upload-status";
|
||
}
|
||
|
||
try {
|
||
const previewResponse = await api.fetchApi("/sengine/preview-composite", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
image_path: selectedImage.filename,
|
||
image_subfolder: selectedImage.subfolder,
|
||
image_type: selectedImage.type,
|
||
source_images: metadata.source_images,
|
||
}),
|
||
});
|
||
|
||
const previewResult = await previewResponse.json();
|
||
|
||
if (previewResult.success) {
|
||
const choice = await this.showCompositePreviewModal(
|
||
previewResult.composite_base64,
|
||
metadata,
|
||
loraVersionIds,
|
||
sengineConfig
|
||
);
|
||
|
||
if (choice === "cancel") {
|
||
if (statusEl) {
|
||
statusEl.textContent = "Upload cancelled";
|
||
statusEl.className = "sengine-upload-status";
|
||
}
|
||
return;
|
||
}
|
||
|
||
useComposite = (choice === "composite");
|
||
}
|
||
} catch (e) {
|
||
console.error("[SEngine] Preview error:", e);
|
||
// Continue with original image if preview fails
|
||
}
|
||
}
|
||
|
||
if (statusEl) {
|
||
statusEl.textContent = "Uploading...";
|
||
statusEl.className = "sengine-upload-status";
|
||
}
|
||
if (btn) btn.disabled = true;
|
||
|
||
try {
|
||
const response = await api.fetchApi("/sengine/upload", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
image_path: selectedImage.filename,
|
||
image_subfolder: selectedImage.subfolder,
|
||
image_type: selectedImage.type,
|
||
session_cookie: this.sessionCookie,
|
||
lora_version_ids: loraVersionIds,
|
||
prompt: metadata.prompt,
|
||
negative_prompt: metadata.negative_prompt,
|
||
cfg_scale: metadata.cfg_scale,
|
||
steps: metadata.steps,
|
||
sampler: metadata.sampler,
|
||
seed: metadata.seed,
|
||
model_name: metadata.model_name,
|
||
sengine_config: sengineConfig,
|
||
source_images: metadata.source_images || [],
|
||
use_composite: useComposite,
|
||
}),
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
if (statusEl) {
|
||
statusEl.textContent = `Posted! ${result.post_url}`;
|
||
statusEl.className = "sengine-upload-status success";
|
||
}
|
||
// Open the post in a new tab
|
||
window.open(result.post_url, "_blank");
|
||
} else {
|
||
if (statusEl) {
|
||
statusEl.textContent = `Error: ${result.error}`;
|
||
statusEl.className = "sengine-upload-status error";
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("[SEngine] Upload error:", e);
|
||
if (statusEl) {
|
||
statusEl.textContent = `Error: ${e.message}`;
|
||
statusEl.className = "sengine-upload-status error";
|
||
}
|
||
}
|
||
|
||
if (btn) btn.disabled = false;
|
||
this.updateUploadButton();
|
||
}
|
||
|
||
renderGrid() {
|
||
const browser = this.panel?.querySelector(".sengine-browser");
|
||
const status = this.panel?.querySelector(".sengine-status");
|
||
if (!browser) return;
|
||
|
||
let list = this.loras;
|
||
|
||
// Filter by selected tags (AND logic - must have all selected tags)
|
||
if (this.selectedTags.length > 0) {
|
||
list = list.filter(l =>
|
||
this.selectedTags.every(tag => (l.tags || []).includes(tag))
|
||
);
|
||
}
|
||
|
||
// Filter by search query
|
||
if (this.searchQuery) {
|
||
const q = this.searchQuery.toLowerCase();
|
||
list = list.filter(l =>
|
||
l.name.toLowerCase().includes(q) ||
|
||
(l.trained_words || []).some(w => w.toLowerCase().includes(q)) ||
|
||
(l.tags || []).some(t => t.toLowerCase().includes(q))
|
||
);
|
||
}
|
||
|
||
if (!this.loras.length) {
|
||
browser.innerHTML = '<div class="sengine-empty">Click ↻ to load LoRAs</div>';
|
||
if (status) status.textContent = "";
|
||
return;
|
||
}
|
||
|
||
if (!list.length) {
|
||
browser.innerHTML = '<div class="sengine-empty">No matches found</div>';
|
||
if (status) status.textContent = `0 of ${this.loras.length} LoRAs`;
|
||
return;
|
||
}
|
||
|
||
const grid = document.createElement("div");
|
||
grid.className = "sengine-grid";
|
||
|
||
list.forEach(lora => {
|
||
const isSelected = this.selectedLoras.some(l => l.version_id === lora.version_id);
|
||
const previewUrl = lora.preview_url || "";
|
||
|
||
const el = document.createElement("div");
|
||
el.className = `sengine-lora ${isSelected ? 'selected' : ''}`;
|
||
el.title = lora.name;
|
||
|
||
if (previewUrl) {
|
||
el.innerHTML = `
|
||
<img class="sengine-lora-img" src="${previewUrl}" alt="" loading="lazy">
|
||
<div class="sengine-lora-info">
|
||
<div class="sengine-lora-name">${lora.name}</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
const hue = lora.name.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % 360;
|
||
el.innerHTML = `
|
||
<div class="sengine-lora-placeholder" style="background:linear-gradient(135deg, hsl(${hue},30%,25%) 0%, hsl(${hue},25%,18%) 100%); color:hsl(${hue},45%,55%);">
|
||
${lora.name[0].toUpperCase()}
|
||
</div>
|
||
<div class="sengine-lora-info">
|
||
<div class="sengine-lora-name">${lora.name}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
el.onclick = () => this.toggleLora(lora);
|
||
grid.appendChild(el);
|
||
});
|
||
|
||
browser.innerHTML = "";
|
||
browser.appendChild(grid);
|
||
if (status) status.textContent = `${list.length} of ${this.loras.length} LoRAs`;
|
||
}
|
||
|
||
renderSelected() {
|
||
if (!this.panel) return;
|
||
|
||
const list = this.panel.querySelector(".sengine-selected-list");
|
||
const count = this.panel.querySelector(".sengine-count");
|
||
if (!list) return;
|
||
|
||
if (count) count.textContent = this.selectedLoras.length;
|
||
|
||
if (!this.selectedLoras.length) {
|
||
list.innerHTML = '<div class="sengine-empty">Select LoRAs above</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = "";
|
||
const totalLoras = this.selectedLoras.length;
|
||
this.selectedLoras.forEach((lora, idx) => {
|
||
const state = downloadStates[lora.version_id];
|
||
const isDownloading = state?.status === "downloading";
|
||
const isFailed = state?.status === "failed";
|
||
|
||
const el = document.createElement("div");
|
||
el.className = "sengine-item";
|
||
if (isFailed) el.style.background = "#422";
|
||
|
||
const thumbHtml = lora.preview_url
|
||
? `<img class="sengine-item-thumb" src="${lora.preview_url}" alt="">`
|
||
: `<div class="sengine-item-thumb" style="display:flex;align-items:center;justify-content:center;font-weight:bold;color:#666;">${lora.name[0]}</div>`;
|
||
|
||
let statusHtml = "";
|
||
if (isDownloading) {
|
||
const pct = Math.round((state.progress || 0) * 100);
|
||
statusHtml = `<div class="sengine-progress"><div class="sengine-progress-bar" style="width:${pct}%"></div><span>${pct}%</span></div>`;
|
||
} else if (isFailed) {
|
||
statusHtml = `<div style="color:#f66;font-size:10px;">Download failed</div>`;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<div class="sengine-reorder">
|
||
<button class="sengine-reorder-btn sengine-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">▲</button>
|
||
<button class="sengine-reorder-btn sengine-move-down" ${idx === totalLoras - 1 ? 'disabled' : ''} title="Move down">▼</button>
|
||
</div>
|
||
${thumbHtml}
|
||
<div class="sengine-item-details">
|
||
<div class="sengine-item-name" title="${lora.name}" style="${isFailed ? 'color:#f88;' : ''}">${lora.name}</div>
|
||
${statusHtml || `<div class="sengine-item-strength">
|
||
<input type="number" class="sengine-strength-input" value="${lora.strength.toFixed(2)}" min="0" max="2" step="0.05">
|
||
</div>`}
|
||
</div>
|
||
<button class="sengine-remove">×</button>
|
||
`;
|
||
|
||
const strengthInput = el.querySelector(".sengine-strength-input");
|
||
if (strengthInput) {
|
||
strengthInput.onchange = (e) => {
|
||
const v = Math.max(0, Math.min(2, parseFloat(e.target.value) || 0));
|
||
e.target.value = v.toFixed(2);
|
||
this.updateStrength(idx, v);
|
||
};
|
||
}
|
||
|
||
el.querySelector(".sengine-move-up").onclick = () => this.moveLoraUp(idx);
|
||
el.querySelector(".sengine-move-down").onclick = () => this.moveLoraDown(idx);
|
||
el.querySelector(".sengine-remove").onclick = () => this.removeLora(idx);
|
||
list.appendChild(el);
|
||
});
|
||
}
|
||
}
|
||
|
||
const sengine = new SEngineManager();
|
||
|
||
// ============================================================================
|
||
// Extension
|
||
// ============================================================================
|
||
|
||
app.registerExtension({
|
||
name: "SEngine.LoraLoader",
|
||
|
||
async setup() {
|
||
// Throttle UI updates during download
|
||
let lastUIUpdate = 0;
|
||
const UI_UPDATE_INTERVAL = 500; // Update UI at most every 500ms
|
||
|
||
// Listen for executed nodes to capture generated images
|
||
api.addEventListener("executed", (event) => {
|
||
const output = event.detail?.output;
|
||
if (output?.images && output.images.length > 0) {
|
||
// Store ALL images from the batch
|
||
sengine.lastGeneratedImages = output.images.map(img => ({
|
||
filename: img.filename,
|
||
subfolder: img.subfolder || "",
|
||
type: img.type || "output"
|
||
}));
|
||
sengine.updateUploadButton();
|
||
console.log("[SEngine] Captured", sengine.lastGeneratedImages.length, "image(s)");
|
||
}
|
||
});
|
||
|
||
// Listen for download progress
|
||
api.addEventListener("sengine_progress", (event) => {
|
||
const { version_id, progress, status, name } = event.detail;
|
||
const prevStatus = downloadStates[version_id]?.status;
|
||
downloadStates[version_id] = { progress, status, name };
|
||
|
||
const now = Date.now();
|
||
const statusChanged = status !== prevStatus;
|
||
|
||
// Only update sidebar UI, don't touch node during execution
|
||
if (statusChanged || now - lastUIUpdate > UI_UPDATE_INTERVAL) {
|
||
lastUIUpdate = now;
|
||
sengine.renderSelected();
|
||
}
|
||
|
||
if (status === "complete" || status === "failed") {
|
||
setTimeout(() => {
|
||
delete downloadStates[version_id];
|
||
// Only rebuild node after all downloads done
|
||
if (sengine.targetNode && Object.keys(downloadStates).length === 0) {
|
||
sengine.rebuildNodeWidgets();
|
||
}
|
||
sengine.renderSelected();
|
||
}, 1000);
|
||
}
|
||
});
|
||
|
||
const panel = sengine.createPanel();
|
||
|
||
// Load LoRAs on startup
|
||
sengine.loadLoras();
|
||
|
||
if (app.extensionManager?.registerSidebarTab) {
|
||
app.extensionManager.registerSidebarTab({
|
||
id: "sengine",
|
||
icon: "pi pi-bolt",
|
||
title: "SEngine",
|
||
tooltip: "SEngine LoRA Browser",
|
||
type: "custom",
|
||
render: (el) => {
|
||
el.appendChild(panel);
|
||
}
|
||
});
|
||
} else {
|
||
const btn = document.createElement("button");
|
||
btn.innerHTML = "⚡";
|
||
btn.style.cssText = "position:fixed;bottom:20px;right:20px;width:48px;height:48px;border-radius:50%;background:linear-gradient(135deg,#5a5 0%,#383 100%);color:#fff;border:none;font-size:20px;cursor:pointer;z-index:1000;box-shadow:0 4px 15px rgba(0,0,0,.3);";
|
||
|
||
const floatPanel = document.createElement("div");
|
||
floatPanel.style.cssText = "position:fixed;bottom:78px;right:20px;width:320px;height:520px;border-radius:12px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.4);z-index:999;display:none;";
|
||
floatPanel.appendChild(panel);
|
||
|
||
let open = false;
|
||
btn.onclick = () => {
|
||
open = !open;
|
||
floatPanel.style.display = open ? "block" : "none";
|
||
};
|
||
|
||
document.body.appendChild(btn);
|
||
document.body.appendChild(floatPanel);
|
||
}
|
||
},
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData.name !== NODE_TYPE) return;
|
||
|
||
// Context menu
|
||
const origMenu = nodeType.prototype.getExtraMenuOptions;
|
||
nodeType.prototype.getExtraMenuOptions = function(canvas, options) {
|
||
origMenu?.apply(this, arguments);
|
||
options.push(null, {
|
||
content: "Clear All LoRAs",
|
||
callback: () => {
|
||
this.sengineData = "{}";
|
||
this.title = "SEngine LoRA Loader";
|
||
if (sengine.targetNode === this) {
|
||
sengine.selectedLoras = [];
|
||
sengine.rebuildNodeWidgets();
|
||
sengine.renderGrid();
|
||
sengine.renderSelected();
|
||
}
|
||
app.graph.setDirtyCanvas(true, true);
|
||
}
|
||
});
|
||
};
|
||
|
||
// On created
|
||
const origCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function() {
|
||
origCreated?.apply(this, arguments);
|
||
|
||
// Hide only the sengine_data widget, keep overall_strength visible
|
||
const dataWidget = this.widgets?.find(w => w.name === "sengine_data");
|
||
if (dataWidget) {
|
||
dataWidget.computeSize = () => [0, -4];
|
||
dataWidget.type = "hidden";
|
||
}
|
||
|
||
this.sengineData = "{}";
|
||
sengine.setTargetNode(this);
|
||
};
|
||
|
||
// On selected
|
||
const origSelected = nodeType.prototype.onSelected;
|
||
nodeType.prototype.onSelected = function() {
|
||
origSelected?.apply(this, arguments);
|
||
sengine.setTargetNode(this);
|
||
};
|
||
|
||
// Serialize - save sengineData
|
||
const origSerialize = nodeType.prototype.serialize;
|
||
nodeType.prototype.serialize = function() {
|
||
const data = origSerialize ? origSerialize.call(this) : {};
|
||
data.sengineData = this.sengineData || "{}";
|
||
return data;
|
||
};
|
||
|
||
// Configure - restore sengineData
|
||
const origConfigure = nodeType.prototype.configure;
|
||
nodeType.prototype.configure = function(data) {
|
||
origConfigure?.call(this, data);
|
||
|
||
// Hide only sengine_data widget, keep overall_strength visible
|
||
const dataWidget = this.widgets?.find(w => w.name === "sengine_data");
|
||
if (dataWidget) {
|
||
dataWidget.computeSize = () => [0, -4];
|
||
dataWidget.type = "hidden";
|
||
|
||
// Restore value from sengineData if widget is empty
|
||
if (data.sengineData && (!dataWidget.value || dataWidget.value === "{}")) {
|
||
dataWidget.value = data.sengineData;
|
||
}
|
||
}
|
||
|
||
this.sengineData = data.sengineData || dataWidget?.value || "{}";
|
||
|
||
// Rebuild after a delay
|
||
setTimeout(() => {
|
||
sengine.setTargetNode(this);
|
||
sengine.rebuildNodeWidgets();
|
||
}, 100);
|
||
};
|
||
}
|
||
});
|
||
|
||
console.log("[SEngine] Loaded");
|