mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-23 19:51:15 -03:00
Compare commits
2 Commits
v1.0.6
...
5d3ab3bbf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3ab3bbf8 | ||
|
|
d9dc0dba8d |
96
py/config.py
96
py/config.py
@@ -172,6 +172,12 @@ class Config:
|
||||
self.extra_unet_roots: List[str] = []
|
||||
self.extra_embeddings_roots: List[str] = []
|
||||
self.recipes_path: str = ""
|
||||
|
||||
# Load extra folder paths from active library settings before symlink scan
|
||||
# so both primary and extra paths are discovered in a single pass.
|
||||
if not standalone_mode:
|
||||
self._load_extra_paths_from_settings()
|
||||
|
||||
# Scan symbolic links during initialization
|
||||
self._initialize_symlink_mappings()
|
||||
|
||||
@@ -179,6 +185,96 @@ class Config:
|
||||
# Save the paths to settings.json when running in ComfyUI mode
|
||||
self.save_folder_paths_to_settings()
|
||||
|
||||
def _load_extra_paths_from_settings(self) -> None:
|
||||
"""Read extra folder paths from the active library and apply them.
|
||||
|
||||
Called during ``Config.__init__`` before the symlink scan so both primary and
|
||||
extra paths are discovered in a single pass. Mirrors the extra-path
|
||||
portion of ``_apply_library_paths`` without replacing the primary roots
|
||||
that were already resolved from ComfyUI's ``folder_paths``.
|
||||
"""
|
||||
try:
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
library_name = settings_manager.get_active_library_name()
|
||||
libraries = settings_manager.get_libraries()
|
||||
|
||||
if not library_name or library_name not in libraries:
|
||||
return
|
||||
|
||||
library_config = libraries[library_name]
|
||||
if not isinstance(library_config, dict):
|
||||
return
|
||||
|
||||
extra_folder_paths = library_config.get("extra_folder_paths")
|
||||
if not isinstance(extra_folder_paths, dict):
|
||||
return
|
||||
|
||||
extra_lora = extra_folder_paths.get("loras", []) or []
|
||||
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
|
||||
extra_unet = extra_folder_paths.get("unet", []) or []
|
||||
extra_embedding = extra_folder_paths.get("embeddings", []) or []
|
||||
|
||||
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
|
||||
return
|
||||
|
||||
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
|
||||
self.loras_roots, extra_lora
|
||||
)
|
||||
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora)
|
||||
(
|
||||
_,
|
||||
self.extra_checkpoints_roots,
|
||||
self.extra_unet_roots,
|
||||
) = self._prepare_checkpoint_paths(extra_checkpoint, extra_unet)
|
||||
self.extra_embeddings_roots = self._prepare_embedding_paths(
|
||||
extra_embedding
|
||||
)
|
||||
|
||||
recipes_path = library_config.get("recipes_path", "")
|
||||
if isinstance(recipes_path, str) and recipes_path:
|
||||
self.recipes_path = recipes_path
|
||||
|
||||
if self.extra_loras_roots:
|
||||
logger.info(
|
||||
"Found extra LoRA roots:"
|
||||
+ "\n - "
|
||||
+ "\n - ".join(self.extra_loras_roots)
|
||||
)
|
||||
if self.extra_checkpoints_roots:
|
||||
logger.info(
|
||||
"Found extra checkpoint roots:"
|
||||
+ "\n - "
|
||||
+ "\n - ".join(self.extra_checkpoints_roots)
|
||||
)
|
||||
if self.extra_unet_roots:
|
||||
logger.info(
|
||||
"Found extra diffusion model roots:"
|
||||
+ "\n - "
|
||||
+ "\n - ".join(self.extra_unet_roots)
|
||||
)
|
||||
if self.extra_embeddings_roots:
|
||||
logger.info(
|
||||
"Found extra embedding roots:"
|
||||
+ "\n - "
|
||||
+ "\n - ".join(self.extra_embeddings_roots)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Applied library settings for '%s' with extra paths: loras=%s, "
|
||||
"checkpoints=%s, embeddings=%s",
|
||||
library_name,
|
||||
extra_lora,
|
||||
extra_checkpoint,
|
||||
extra_embedding,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not load extra paths from library settings: %s", exc
|
||||
)
|
||||
|
||||
def save_folder_paths_to_settings(self):
|
||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||
try:
|
||||
|
||||
@@ -184,39 +184,6 @@ class LoraManager:
|
||||
async def _initialize_services(cls):
|
||||
"""Initialize all services using the ServiceRegistry"""
|
||||
try:
|
||||
# Apply library settings to load extra folder paths before scanning
|
||||
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
|
||||
try:
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
library_name = settings_manager.get_active_library_name()
|
||||
libraries = settings_manager.get_libraries()
|
||||
if library_name and library_name in libraries:
|
||||
library_config = libraries[library_name]
|
||||
# Only apply settings if extra paths are not already configured
|
||||
# This preserves values set by tests via monkeypatch
|
||||
extra_paths = library_config.get("extra_folder_paths", {})
|
||||
has_extra_paths = (
|
||||
config.extra_loras_roots
|
||||
or config.extra_checkpoints_roots
|
||||
or config.extra_unet_roots
|
||||
or config.extra_embeddings_roots
|
||||
)
|
||||
if not has_extra_paths and any(extra_paths.values()):
|
||||
config.apply_library_settings(library_config)
|
||||
logger.info(
|
||||
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
|
||||
library_name,
|
||||
extra_paths.get("loras", []),
|
||||
extra_paths.get("checkpoints", []),
|
||||
extra_paths.get("embeddings", []),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to apply library settings during initialization: %s", exc
|
||||
)
|
||||
|
||||
# Initialize CivitaiClient first to ensure it's ready for other services
|
||||
await ServiceRegistry.get_civitai_client()
|
||||
|
||||
|
||||
124
static/css/components/media-viewer.css
Normal file
124
static/css/components/media-viewer.css
Normal file
@@ -0,0 +1,124 @@
|
||||
.media-viewer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.media-viewer-overlay.active {
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
}
|
||||
|
||||
.media-viewer-close {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10001;
|
||||
transition: background 0.2s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.media-viewer-overlay.active .media-viewer-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-viewer-close:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.media-viewer-content-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 90vw;
|
||||
max-height: 95vh;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.media-viewer-media {
|
||||
display: block;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.media-viewer-video {
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.media-viewer-counter {
|
||||
margin-top: 8px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.media-viewer-title {
|
||||
margin-top: 4px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-viewer-nav {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10001;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.media-viewer-overlay.active .media-viewer-nav {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-viewer-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.media-viewer-prev {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.media-viewer-next {
|
||||
right: 16px;
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||
@import 'components/statistics.css'; /* Add statistics component */
|
||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||
@import 'components/media-viewer.css';
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { downloadManager } from '../managers/DownloadManager.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
|
||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||
'prompt',
|
||||
@@ -112,6 +113,23 @@ class RecipeModal {
|
||||
|
||||
// Set up document click handler to close edit fields
|
||||
document.addEventListener('click', (event) => {
|
||||
const recipeModal = document.getElementById('recipeModal');
|
||||
if (recipeModal && recipeModal.style.display !== 'none') {
|
||||
const mediaEl = event.target.closest('.recipe-preview-media');
|
||||
if (mediaEl && mediaEl.tagName) {
|
||||
event.stopPropagation();
|
||||
const isVideo = mediaEl.tagName === 'VIDEO';
|
||||
const url = mediaEl.src || mediaEl.currentSrc;
|
||||
if (url) {
|
||||
openMediaViewer(url, {
|
||||
type: isVideo ? 'video' : 'image',
|
||||
title: document.getElementById('recipeModalTitle')?.textContent || ''
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle title edit
|
||||
const titleEditor = document.getElementById('recipeTitleEditor');
|
||||
if (titleEditor && titleEditor.classList.contains('active') &&
|
||||
|
||||
204
static/js/components/shared/MediaViewer.js
Normal file
204
static/js/components/shared/MediaViewer.js
Normal file
@@ -0,0 +1,204 @@
|
||||
let activeViewer = null;
|
||||
|
||||
function createMediaElement(item) {
|
||||
const { url, type = 'image' } = item;
|
||||
if (type === 'video') {
|
||||
const el = document.createElement('video');
|
||||
el.controls = true;
|
||||
el.autoplay = true;
|
||||
el.loop = true;
|
||||
el.muted = true;
|
||||
el.className = 'media-viewer-media media-viewer-video';
|
||||
el.src = url;
|
||||
return el;
|
||||
}
|
||||
const el = document.createElement('img');
|
||||
el.className = 'media-viewer-media media-viewer-image';
|
||||
el.src = url;
|
||||
el.alt = 'Full size preview';
|
||||
el.draggable = false;
|
||||
return el;
|
||||
}
|
||||
|
||||
function preloadAdjacent(items, index) {
|
||||
[index - 1, index + 1].forEach(i => {
|
||||
if (i >= 0 && i < items.length && items[i].type !== 'video') {
|
||||
const preload = new Image();
|
||||
preload.src = items[i].url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openMediaViewer(arg1, arg2, arg3) {
|
||||
closeMediaViewer();
|
||||
|
||||
let items, currentIndex, title = '';
|
||||
|
||||
if (Array.isArray(arg1)) {
|
||||
items = arg1;
|
||||
currentIndex = typeof arg2 === 'number' ? arg2 : 0;
|
||||
title = (arg3 && arg3.title) || '';
|
||||
} else {
|
||||
items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }];
|
||||
currentIndex = 0;
|
||||
title = (arg2 && arg2.title) || '';
|
||||
}
|
||||
|
||||
if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'media-viewer-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-label', title || 'Media viewer');
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'media-viewer-close';
|
||||
closeBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||
closeBtn.title = 'Close (Esc)';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
closeMediaViewer();
|
||||
});
|
||||
|
||||
const contentContainer = document.createElement('div');
|
||||
contentContainer.className = 'media-viewer-content-container';
|
||||
|
||||
let mediaElement = createMediaElement(items[currentIndex]);
|
||||
contentContainer.appendChild(mediaElement);
|
||||
|
||||
const hasNavigation = items.length > 1;
|
||||
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'media-viewer-counter';
|
||||
counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : '';
|
||||
contentContainer.appendChild(counter);
|
||||
|
||||
if (title) {
|
||||
const titleBar = document.createElement('div');
|
||||
titleBar.className = 'media-viewer-title';
|
||||
titleBar.textContent = title;
|
||||
contentContainer.appendChild(titleBar);
|
||||
}
|
||||
|
||||
let prevBtn, nextBtn;
|
||||
if (hasNavigation) {
|
||||
prevBtn = document.createElement('button');
|
||||
prevBtn.className = 'media-viewer-nav media-viewer-prev';
|
||||
prevBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
|
||||
prevBtn.title = 'Previous (←)';
|
||||
nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'media-viewer-nav media-viewer-next';
|
||||
nextBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||||
nextBtn.title = 'Next (→)';
|
||||
|
||||
const navigate = (delta) => {
|
||||
const newIndex = (currentIndex + delta + items.length) % items.length;
|
||||
currentIndex = newIndex;
|
||||
|
||||
const oldMedia = contentContainer.querySelector('.media-viewer-media');
|
||||
const newMedia = createMediaElement(items[currentIndex]);
|
||||
|
||||
if (oldMedia) {
|
||||
if (oldMedia.tagName === 'VIDEO') {
|
||||
oldMedia.pause();
|
||||
oldMedia.src = '';
|
||||
}
|
||||
oldMedia.replaceWith(newMedia);
|
||||
}
|
||||
mediaElement = newMedia;
|
||||
|
||||
counter.textContent = `${currentIndex + 1} / ${items.length}`;
|
||||
preloadAdjacent(items, currentIndex);
|
||||
};
|
||||
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); });
|
||||
|
||||
overlay.appendChild(prevBtn);
|
||||
overlay.appendChild(nextBtn);
|
||||
}
|
||||
|
||||
overlay.appendChild(closeBtn);
|
||||
overlay.appendChild(contentContainer);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('active');
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
closeMediaViewer();
|
||||
}
|
||||
});
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeMediaViewer();
|
||||
return;
|
||||
}
|
||||
if (hasNavigation) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
prevBtn.click();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
nextBtn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', keyHandler, true);
|
||||
|
||||
activeViewer = { overlay, keyHandler };
|
||||
preloadAdjacent(items, currentIndex);
|
||||
|
||||
if (items[currentIndex].type === 'video') {
|
||||
const recipeVideo = document.getElementById('recipeModalVideo');
|
||||
if (recipeVideo && !recipeVideo.paused) {
|
||||
recipeVideo.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMediaViewer() {
|
||||
if (!activeViewer) return;
|
||||
|
||||
const { overlay, keyHandler } = activeViewer;
|
||||
|
||||
const video = overlay.querySelector('video');
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.src = '';
|
||||
}
|
||||
|
||||
const img = overlay.querySelector('img');
|
||||
if (img) {
|
||||
img.src = '';
|
||||
}
|
||||
|
||||
document.removeEventListener('keydown', keyHandler, true);
|
||||
|
||||
overlay.classList.remove('active');
|
||||
overlay.addEventListener('transitionend', () => {
|
||||
if (overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
setTimeout(() => {
|
||||
if (overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
activeViewer = null;
|
||||
}
|
||||
|
||||
export function isMediaViewerOpen() {
|
||||
return activeViewer !== null;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||
import { openMediaViewer } from '../MediaViewer.js';
|
||||
|
||||
export const showcaseListenerMetrics = {
|
||||
wheelListeners: 0,
|
||||
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
|
||||
initMediaControlHandlers(carousel);
|
||||
positionAllMediaControls(carousel);
|
||||
|
||||
// Click-to-view: open full-size media viewer when clicking showcase images/videos
|
||||
const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
|
||||
const allItems = [];
|
||||
const elementIndexMap = new Map();
|
||||
viewerElements.forEach((el) => {
|
||||
const isVideo = el.tagName === 'VIDEO';
|
||||
const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
|
||||
if (url) {
|
||||
elementIndexMap.set(el, allItems.length);
|
||||
allItems.push({ url, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
});
|
||||
viewerElements.forEach((mediaEl) => {
|
||||
const idx = elementIndexMap.get(mediaEl);
|
||||
if (idx === undefined) return;
|
||||
mediaEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openMediaViewer(allItems, idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Bind scroll-indicator click events
|
||||
bindScrollIndicatorEvents(carousel);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user