mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 22:52:12 -03:00
feat(example-images): add NSFW level setting endpoint
Add new POST endpoint `/api/lm/example-images/set-nsfw-level` to allow updating NSFW classification for individual example images. The endpoint supports both regular and custom images, validates required parameters, and updates the corresponding model metadata. This enables users to manually adjust NSFW ratings for better content filtering.
This commit is contained in:
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/delete-example-image", "delete_example_image"),
|
RouteDefinition("POST", "/api/lm/delete-example-image", "delete_example_image"),
|
||||||
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
|
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
|
||||||
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
|
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
|
||||||
|
RouteDefinition("POST", "/api/lm/example-images/set-nsfw-level", "set_example_image_nsfw_level"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ class ExampleImagesManagementHandler:
|
|||||||
async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
|
async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
|
||||||
return await self._processor.delete_custom_image(request)
|
return await self._processor.delete_custom_image(request)
|
||||||
|
|
||||||
|
async def set_example_image_nsfw_level(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
return await self._processor.set_example_image_nsfw_level(request)
|
||||||
|
|
||||||
async def cleanup_example_image_folders(self, request: web.Request) -> web.StreamResponse:
|
async def cleanup_example_image_folders(self, request: web.Request) -> web.StreamResponse:
|
||||||
result = await self._cleanup_service.cleanup_example_image_folders()
|
result = await self._cleanup_service.cleanup_example_image_folders()
|
||||||
|
|
||||||
@@ -160,6 +163,7 @@ class ExampleImagesHandlerSet:
|
|||||||
"force_download_example_images": self.download.force_download_example_images,
|
"force_download_example_images": self.download.force_download_example_images,
|
||||||
"import_example_images": self.management.import_example_images,
|
"import_example_images": self.management.import_example_images,
|
||||||
"delete_example_image": self.management.delete_example_image,
|
"delete_example_image": self.management.delete_example_image,
|
||||||
|
"set_example_image_nsfw_level": self.management.set_example_image_nsfw_level,
|
||||||
"cleanup_example_image_folders": self.management.cleanup_example_image_folders,
|
"cleanup_example_image_folders": self.management.cleanup_example_image_folders,
|
||||||
"open_example_images_folder": self.files.open_example_images_folder,
|
"open_example_images_folder": self.files.open_example_images_folder,
|
||||||
"get_example_image_files": self.files.get_example_image_files,
|
"get_example_image_files": self.files.get_example_image_files,
|
||||||
|
|||||||
@@ -593,5 +593,114 @@ class ExampleImagesProcessor:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def set_example_image_nsfw_level(request: web.Request) -> web.StreamResponse:
|
||||||
|
"""
|
||||||
|
Update the NSFW level for a single example image (regular or custom).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({'success': False, 'error': 'Invalid JSON body'}, status=400)
|
||||||
|
|
||||||
|
model_hash = data.get('model_hash')
|
||||||
|
raw_level = data.get('nsfw_level')
|
||||||
|
source = (data.get('source') or 'civitai').lower()
|
||||||
|
index = data.get('index')
|
||||||
|
image_id = data.get('id')
|
||||||
|
|
||||||
|
if model_hash is None or raw_level is None:
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'Missing required parameters: model_hash and nsfw_level'},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
nsfw_level = int(raw_level)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'nsfw_level must be an integer'}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
if source == 'custom':
|
||||||
|
if not image_id:
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'Custom images require an id field'}, status=400
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
index = int(index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'Regular images require a numeric index'}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
|
||||||
|
model_data = None
|
||||||
|
scanner = None
|
||||||
|
|
||||||
|
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
|
||||||
|
if scan_obj.has_hash(model_hash):
|
||||||
|
cache = await scan_obj.get_cached_data()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get('sha256') == model_hash:
|
||||||
|
model_data = item
|
||||||
|
scanner = scan_obj
|
||||||
|
break
|
||||||
|
if model_data:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not model_data:
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': f"Model with hash {model_hash} not found in cache"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
await MetadataManager.hydrate_model_data(model_data)
|
||||||
|
civitai_data = model_data.setdefault('civitai', {})
|
||||||
|
regular_images = civitai_data.get('images') or []
|
||||||
|
custom_images = civitai_data.get('customImages') or []
|
||||||
|
|
||||||
|
target_image = None
|
||||||
|
if source == 'custom':
|
||||||
|
for image in custom_images:
|
||||||
|
if image.get('id') == image_id:
|
||||||
|
target_image = image
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if 0 <= index < len(regular_images):
|
||||||
|
target_image = regular_images[index]
|
||||||
|
|
||||||
|
if target_image is None:
|
||||||
|
return web.json_response(
|
||||||
|
{'success': False, 'error': 'Target image not found'}, status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
target_image['nsfwLevel'] = nsfw_level
|
||||||
|
civitai_data['images'] = regular_images
|
||||||
|
civitai_data['customImages'] = custom_images
|
||||||
|
|
||||||
|
file_path = model_data.get('file_path')
|
||||||
|
if file_path:
|
||||||
|
model_copy = model_data.copy()
|
||||||
|
model_copy.pop('folder', None)
|
||||||
|
await MetadataManager.save_metadata(file_path, model_copy)
|
||||||
|
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'regular_images': regular_images,
|
||||||
|
'custom_images': custom_images,
|
||||||
|
'model_file_path': model_data.get('file_path', ''),
|
||||||
|
'nsfw_level': nsfw_level
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to update example image NSFW level: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,12 @@
|
|||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-control-btn.set-nsfw-btn:hover {
|
||||||
|
background: var(--warning-color, #f0ad4e);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--warning-color, #f0ad4e);
|
||||||
|
}
|
||||||
|
|
||||||
.media-control-btn.example-delete-btn:hover:not(.disabled) {
|
.media-control-btn.example-delete-btn:hover:not(.disabled) {
|
||||||
background: var(--lora-error);
|
background: var(--lora-error);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
this.modelType = 'checkpoint';
|
this.modelType = 'checkpoint';
|
||||||
this.resetAndReload = resetAndReload;
|
this.resetAndReload = resetAndReload;
|
||||||
|
|
||||||
// Initialize NSFW Level Selector events only if not already initialized
|
this.initNSFWSelector();
|
||||||
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
|
||||||
this.initNSFWSelector();
|
|
||||||
this.nsfwSelector.dataset.initialized = 'true';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implementation needed by the mixin
|
// Implementation needed by the mixin
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
|||||||
this.modelType = 'embedding';
|
this.modelType = 'embedding';
|
||||||
this.resetAndReload = resetAndReload;
|
this.resetAndReload = resetAndReload;
|
||||||
|
|
||||||
// Initialize NSFW Level Selector events only if not already initialized
|
this.initNSFWSelector();
|
||||||
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
|
||||||
this.initNSFWSelector();
|
|
||||||
this.nsfwSelector.dataset.initialized = 'true';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implementation needed by the mixin
|
// Implementation needed by the mixin
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
this.modelType = 'lora';
|
this.modelType = 'lora';
|
||||||
this.resetAndReload = resetAndReload;
|
this.resetAndReload = resetAndReload;
|
||||||
|
|
||||||
// Initialize NSFW Level Selector events only if not already initialized
|
this.initNSFWSelector();
|
||||||
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
|
||||||
this.initNSFWSelector();
|
|
||||||
this.nsfwSelector.dataset.initialized = 'true';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the saveModelMetadata implementation from loraApi
|
// Use the saveModelMetadata implementation from loraApi
|
||||||
|
|||||||
@@ -5,96 +5,38 @@ import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'
|
|||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { MODEL_CONFIG } from '../../api/apiConfig.js';
|
import { MODEL_CONFIG } from '../../api/apiConfig.js';
|
||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
||||||
|
|
||||||
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
||||||
export const ModelContextMenuMixin = {
|
export const ModelContextMenuMixin = {
|
||||||
// NSFW Selector methods
|
// NSFW Selector methods
|
||||||
initNSFWSelector() {
|
initNSFWSelector() {
|
||||||
// Remove any existing event listeners by cloning and replacing elements
|
if (this._nsfwSelectorInitialized) {
|
||||||
// This is a simple way to ensure we don't have duplicate event listeners
|
return;
|
||||||
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
|
|
||||||
const newCloseBtn = closeBtn.cloneNode(true);
|
|
||||||
closeBtn.parentNode.replaceChild(newCloseBtn, closeBtn);
|
|
||||||
newCloseBtn.addEventListener('click', () => {
|
|
||||||
this.nsfwSelector.style.display = 'none';
|
|
||||||
this.resetNSFWSelectorState();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Level buttons
|
|
||||||
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
|
|
||||||
levelButtons.forEach(btn => {
|
|
||||||
// Remove any existing event listeners by cloning and replacing the button
|
|
||||||
const newBtn = btn.cloneNode(true);
|
|
||||||
btn.parentNode.replaceChild(newBtn, btn);
|
|
||||||
|
|
||||||
newBtn.addEventListener('click', async () => {
|
|
||||||
const level = parseInt(newBtn.dataset.level);
|
|
||||||
const mode = this.nsfwSelector.dataset.mode || 'single';
|
|
||||||
|
|
||||||
if (mode === 'bulk') {
|
|
||||||
let bulkFilePaths = [];
|
|
||||||
if (this.nsfwSelector.dataset.bulkFilePaths) {
|
|
||||||
try {
|
|
||||||
bulkFilePaths = JSON.parse(this.nsfwSelector.dataset.bulkFilePaths);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse bulk file paths for content rating', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await bulkManager.setBulkContentRating(level, bulkFilePaths);
|
|
||||||
if (success) {
|
|
||||||
this.nsfwSelector.style.display = 'none';
|
|
||||||
this.resetNSFWSelectorState();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = this.nsfwSelector.dataset.cardPath;
|
|
||||||
|
|
||||||
if (!filePath) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
|
||||||
|
|
||||||
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
|
|
||||||
this.nsfwSelector.style.display = 'none';
|
|
||||||
this.resetNSFWSelectorState();
|
|
||||||
} catch (error) {
|
|
||||||
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close when clicking outside - use a named function so we can remove it later
|
|
||||||
const outsideClickListener = (e) => {
|
|
||||||
if (this.nsfwSelector.style.display === 'block' &&
|
|
||||||
!this.nsfwSelector.contains(e.target) &&
|
|
||||||
!e.target.closest('.context-menu-item[data-action="set-nsfw"], .context-menu-item[data-action="set-content-rating"]')) {
|
|
||||||
this.nsfwSelector.style.display = 'none';
|
|
||||||
this.resetNSFWSelectorState();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove previous listener if it exists
|
|
||||||
if (this._outsideClickListener) {
|
|
||||||
document.removeEventListener('click', this._outsideClickListener);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store and add new listener
|
const selector = getNsfwLevelSelector();
|
||||||
this._outsideClickListener = outsideClickListener;
|
if (!selector) {
|
||||||
document.addEventListener('click', this._outsideClickListener);
|
console.warn('NSFW selector element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._nsfwSelectorInitialized = true;
|
||||||
|
this._nsfwSelector = selector;
|
||||||
},
|
},
|
||||||
|
|
||||||
resetNSFWSelectorState() {
|
resetNSFWSelectorState() {
|
||||||
if (!this.nsfwSelector) return;
|
// maintained for compatibility; no-op with shared selector
|
||||||
delete this.nsfwSelector.dataset.bulkFilePaths;
|
|
||||||
delete this.nsfwSelector.dataset.mode;
|
|
||||||
delete this.nsfwSelector.dataset.cardPath;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
showNSFWLevelSelector(x, y, card) {
|
showNSFWLevelSelector(x, y, card) {
|
||||||
const selector = document.getElementById('nsfwLevelSelector');
|
this.initNSFWSelector();
|
||||||
const currentLevelEl = document.getElementById('currentNSFWLevel');
|
const selector = this._nsfwSelector || getNsfwLevelSelector();
|
||||||
|
|
||||||
|
if (!selector) {
|
||||||
|
console.warn('NSFW selector not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get current NSFW level
|
// Get current NSFW level
|
||||||
let currentLevel = 0;
|
let currentLevel = 0;
|
||||||
@@ -104,44 +46,28 @@ export const ModelContextMenuMixin = {
|
|||||||
|
|
||||||
// Update if we have no recorded level but have a dataset attribute
|
// Update if we have no recorded level but have a dataset attribute
|
||||||
if (!currentLevel && card.dataset.nsfwLevel) {
|
if (!currentLevel && card.dataset.nsfwLevel) {
|
||||||
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
|
currentLevel = parseInt(card.dataset.nsfwLevel, 10) || 0;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error parsing metadata:', err);
|
console.error('Error parsing metadata:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
|
const filePath = card.dataset.filepath;
|
||||||
|
selector.show({
|
||||||
// Position the selector
|
currentLevel,
|
||||||
if (x && y) {
|
onSelect: async (level) => {
|
||||||
const viewportWidth = document.documentElement.clientWidth;
|
if (!filePath) return false;
|
||||||
const viewportHeight = document.documentElement.clientHeight;
|
try {
|
||||||
const selectorRect = selector.getBoundingClientRect();
|
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
||||||
|
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
|
||||||
// Center the selector if no coordinates provided
|
return true;
|
||||||
let finalX = (viewportWidth - selectorRect.width) / 2;
|
} catch (error) {
|
||||||
let finalY = (viewportHeight - selectorRect.height) / 2;
|
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
|
||||||
|
return false;
|
||||||
selector.style.left = `${finalX}px`;
|
}
|
||||||
selector.style.top = `${finalY}px`;
|
},
|
||||||
}
|
onClose: () => this.resetNSFWSelectorState(),
|
||||||
|
|
||||||
// Highlight current level button
|
|
||||||
selector.querySelectorAll('.nsfw-level-btn').forEach(btn => {
|
|
||||||
if (parseInt(btn.dataset.level) === currentLevel) {
|
|
||||||
btn.classList.add('active');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store reference to current card
|
|
||||||
selector.dataset.mode = 'single';
|
|
||||||
selector.dataset.cardPath = card.dataset.filepath;
|
|
||||||
delete selector.dataset.bulkFilePaths;
|
|
||||||
|
|
||||||
// Show selector
|
|
||||||
selector.style.display = 'block';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Civitai re-linking methods
|
// Civitai re-linking methods
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||||
this.modelType = 'recipe';
|
this.modelType = 'recipe';
|
||||||
|
|
||||||
// Initialize NSFW Level Selector events only if not already initialized
|
this.initNSFWSelector();
|
||||||
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
|
||||||
this.initNSFWSelector();
|
|
||||||
this.nsfwSelector.dataset.initialized = 'true';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the updateRecipeMetadata implementation from recipeApi
|
// Use the updateRecipeMetadata implementation from recipeApi
|
||||||
|
|||||||
126
static/js/components/shared/NsfwLevelSelector.js
Normal file
126
static/js/components/shared/NsfwLevelSelector.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { getNSFWLevelName } from '../../utils/uiHelpers.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
let selectorController = null;
|
||||||
|
|
||||||
|
function buildController(selectorElement) {
|
||||||
|
if (!selectorElement) return null;
|
||||||
|
|
||||||
|
const levelButtons = Array.from(selectorElement.querySelectorAll('.nsfw-level-btn'));
|
||||||
|
const closeBtn = selectorElement.querySelector('.close-nsfw-selector');
|
||||||
|
const currentLevelEl = selectorElement.querySelector('#currentNSFWLevel');
|
||||||
|
|
||||||
|
let onSelect = null;
|
||||||
|
let onClose = null;
|
||||||
|
let isOpen = false;
|
||||||
|
let ignoreNextOutside = false;
|
||||||
|
|
||||||
|
const setLabel = (level, multipleLabel) => {
|
||||||
|
if (!currentLevelEl) return;
|
||||||
|
if (multipleLabel) {
|
||||||
|
currentLevelEl.textContent = multipleLabel;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentLevelEl.textContent = getNSFWLevelName(level);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
selectorElement.style.display = 'none';
|
||||||
|
isOpen = false;
|
||||||
|
if (typeof onClose === 'function') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
onSelect = null;
|
||||||
|
onClose = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = ({ currentLevel = 0, onSelect: selectCb, onClose: closeCb, multipleLabel = '' } = {}) => {
|
||||||
|
onSelect = selectCb || null;
|
||||||
|
onClose = closeCb || null;
|
||||||
|
isOpen = true;
|
||||||
|
ignoreNextOutside = true; // ignore the click that triggered open
|
||||||
|
|
||||||
|
// Position near center of viewport
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const viewportHeight = document.documentElement.clientHeight;
|
||||||
|
const rect = selectorElement.getBoundingClientRect();
|
||||||
|
const finalX = Math.max((viewportWidth - rect.width) / 2, 0);
|
||||||
|
const finalY = Math.max((viewportHeight - rect.height) / 2, 0);
|
||||||
|
|
||||||
|
selectorElement.style.left = `${finalX}px`;
|
||||||
|
selectorElement.style.top = `${finalY}px`;
|
||||||
|
|
||||||
|
setLabel(currentLevel, multipleLabel);
|
||||||
|
|
||||||
|
// Highlight selected level (if not showing multiple values)
|
||||||
|
levelButtons.forEach((btn) => {
|
||||||
|
const btnLevel = parseInt(btn.dataset.level || '0', 10);
|
||||||
|
if (multipleLabel) {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
} else if (btnLevel === currentLevel) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
selectorElement.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (ignoreNextOutside) {
|
||||||
|
ignoreNextOutside = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectorElement.contains(e.target)) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
levelButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const level = parseInt(btn.dataset.level || '0', 10);
|
||||||
|
if (typeof onSelect === 'function') {
|
||||||
|
try {
|
||||||
|
const result = await onSelect(level);
|
||||||
|
if (result === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('NSFW selector onSelect failed', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hide();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const showMultiple = (labelKey = 'modals.contentRating.multiple') => {
|
||||||
|
const fallback = 'Multiple values';
|
||||||
|
const text = translate(labelKey, {}, fallback);
|
||||||
|
show({ multipleLabel: text });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
showMultiple,
|
||||||
|
isOpen: () => isOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNsfwLevelSelector() {
|
||||||
|
if (selectorController) {
|
||||||
|
return selectorController;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = document.getElementById('nsfwLevelSelector');
|
||||||
|
selectorController = buildController(element);
|
||||||
|
return selectorController;
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
* Media-specific utility functions for showcase components
|
* Media-specific utility functions for showcase components
|
||||||
* (Moved from uiHelpers.js to better organize code)
|
* (Moved from uiHelpers.js to better organize code)
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
||||||
import { state } from '../../../state/index.js';
|
import { state } from '../../../state/index.js';
|
||||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||||
|
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
||||||
|
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to load local image first, fall back to remote if local fails
|
* Try to load local image first, fall back to remote if local fails
|
||||||
@@ -472,6 +474,9 @@ export function initMediaControlHandlers(container) {
|
|||||||
// Initialize set preview buttons
|
// Initialize set preview buttons
|
||||||
initSetPreviewHandlers(container);
|
initSetPreviewHandlers(container);
|
||||||
|
|
||||||
|
// Initialize NSFW level buttons
|
||||||
|
initSetNsfwHandlers(container);
|
||||||
|
|
||||||
// Media control visibility is now handled in initMetadataPanelHandlers
|
// Media control visibility is now handled in initMetadataPanelHandlers
|
||||||
// Any click handlers or other functionality can still be added here
|
// Any click handlers or other functionality can still be added here
|
||||||
}
|
}
|
||||||
@@ -591,3 +596,142 @@ export function positionAllMediaControls(container) {
|
|||||||
positionMediaControlsInMediaRect(wrapper);
|
positionMediaControlsInMediaRect(wrapper);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
|
||||||
|
if (!mediaWrapper) return;
|
||||||
|
|
||||||
|
const mediaElement = mediaWrapper.querySelector('img, video');
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.dataset.nsfwLevel = String(nsfwLevel);
|
||||||
|
}
|
||||||
|
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
|
||||||
|
|
||||||
|
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||||
|
let overlay = mediaWrapper.querySelector('.nsfw-overlay');
|
||||||
|
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');
|
||||||
|
|
||||||
|
if (shouldBlur) {
|
||||||
|
mediaWrapper.classList.add('nsfw-media-wrapper');
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.classList.add('blurred');
|
||||||
|
}
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.className = 'nsfw-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="nsfw-warning">
|
||||||
|
<p>Mature Content</p>
|
||||||
|
<button class="show-content-btn">Show</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
mediaWrapper.appendChild(overlay);
|
||||||
|
} else {
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toggleBtn) {
|
||||||
|
toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.className = 'toggle-blur-btn showcase-toggle-btn';
|
||||||
|
toggleBtn.title = 'Toggle blur';
|
||||||
|
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
|
||||||
|
mediaWrapper.insertBefore(toggleBtn, mediaWrapper.firstChild);
|
||||||
|
} else {
|
||||||
|
const icon = toggleBtn.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.className = 'fas fa-eye';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mediaWrapper.classList.remove('nsfw-media-wrapper');
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.classList.remove('blurred');
|
||||||
|
}
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (toggleBtn) {
|
||||||
|
const icon = toggleBtn.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.className = 'fas fa-eye-slash';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-bind blur toggles for any newly added elements
|
||||||
|
initNsfwBlurHandlers(mediaWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSetNsfwHandlers(container) {
|
||||||
|
const nsfwButtons = container.querySelectorAll('.set-nsfw-btn');
|
||||||
|
const selector = getNsfwLevelSelector();
|
||||||
|
|
||||||
|
nsfwButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!selector) {
|
||||||
|
console.warn('NSFW selector not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaWrapper = btn.closest('.media-wrapper');
|
||||||
|
const currentLevel = parseInt(mediaWrapper?.dataset.nsfwLevel || '0', 10);
|
||||||
|
const modelHash = document.querySelector('.showcase-section')?.dataset.modelHash;
|
||||||
|
const mediaSource = btn.dataset.mediaSource || 'civitai';
|
||||||
|
const mediaIndex = parseInt(btn.dataset.mediaIndex || '-1', 10);
|
||||||
|
const mediaId = btn.dataset.mediaId || '';
|
||||||
|
|
||||||
|
selector.show({
|
||||||
|
currentLevel,
|
||||||
|
onSelect: async (level) => {
|
||||||
|
if (!modelHash) {
|
||||||
|
showToast('toast.contextMenu.contentRatingFailed', { message: 'Missing model hash' }, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalIcon = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
model_hash: modelHash,
|
||||||
|
nsfw_level: level,
|
||||||
|
source: mediaSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mediaSource === 'custom') {
|
||||||
|
payload.id = mediaId;
|
||||||
|
} else {
|
||||||
|
payload.index = mediaIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/lm/example-images/set-nsfw-level', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to update NSFW level');
|
||||||
|
}
|
||||||
|
|
||||||
|
applyNsfwLevelChange(mediaWrapper, level);
|
||||||
|
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating NSFW level:', error);
|
||||||
|
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalIcon;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
<button class="media-control-btn set-preview-btn" title="Set as preview">
|
<button class="media-control-btn set-preview-btn" title="Set as preview">
|
||||||
<i class="fas fa-image"></i>
|
<i class="fas fa-image"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="media-control-btn set-nsfw-btn"
|
||||||
|
title="Set content rating"
|
||||||
|
data-media-index="${index}"
|
||||||
|
data-media-source="${isCustomImage ? 'custom' : 'civitai'}"
|
||||||
|
data-media-id="${img.id || ''}">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</button>
|
||||||
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
|
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
|
||||||
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
|
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
|
||||||
data-short-id="${img.id || ''}"
|
data-short-id="${img.id || ''}"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
|||||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
import { eventManager } from '../utils/EventManager.js';
|
import { eventManager } from '../utils/EventManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
|
||||||
|
|
||||||
export class BulkManager {
|
export class BulkManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -1051,19 +1052,13 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selector = document.getElementById('nsfwLevelSelector');
|
const selector = getNsfwLevelSelector();
|
||||||
const currentLevelEl = document.getElementById('currentNSFWLevel');
|
if (!selector) {
|
||||||
|
|
||||||
if (!selector || !currentLevelEl) {
|
|
||||||
console.warn('NSFW level selector not found');
|
console.warn('NSFW level selector not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePaths = Array.from(state.selectedModels);
|
const filePaths = Array.from(state.selectedModels);
|
||||||
selector.dataset.mode = 'bulk';
|
|
||||||
selector.dataset.bulkFilePaths = JSON.stringify(filePaths);
|
|
||||||
delete selector.dataset.cardPath;
|
|
||||||
|
|
||||||
const selectedCards = Array.from(document.querySelectorAll('.model-card.selected'));
|
const selectedCards = Array.from(document.querySelectorAll('.model-card.selected'));
|
||||||
const levels = new Set();
|
const levels = new Set();
|
||||||
|
|
||||||
@@ -1091,29 +1086,17 @@ export class BulkManager {
|
|||||||
let highlightLevel = null;
|
let highlightLevel = null;
|
||||||
if (levels.size === 1) {
|
if (levels.size === 1) {
|
||||||
highlightLevel = levels.values().next().value;
|
highlightLevel = levels.values().next().value;
|
||||||
currentLevelEl.textContent = getNSFWLevelName(highlightLevel);
|
|
||||||
} else {
|
|
||||||
currentLevelEl.textContent = translate('modals.contentRating.multiple', {}, 'Multiple values');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selector.querySelectorAll('.nsfw-level-btn').forEach((btn) => {
|
selector.show({
|
||||||
const btnLevel = parseInt(btn.dataset.level, 10);
|
currentLevel: highlightLevel || 0,
|
||||||
if (highlightLevel !== null && btnLevel === highlightLevel) {
|
multipleLabel: levels.size > 1 ? translate('modals.contentRating.multiple', {}, 'Multiple values') : '',
|
||||||
btn.classList.add('active');
|
onSelect: async (level) => {
|
||||||
} else {
|
await this.setBulkContentRating(level, filePaths);
|
||||||
btn.classList.remove('active');
|
// Always allow selector to close after attempting the update
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewportWidth = document.documentElement.clientWidth;
|
|
||||||
const viewportHeight = document.documentElement.clientHeight;
|
|
||||||
const selectorRect = selector.getBoundingClientRect();
|
|
||||||
const finalX = Math.max((viewportWidth - selectorRect.width) / 2, 0);
|
|
||||||
const finalY = Math.max((viewportHeight - selectorRect.height) / 2, 0);
|
|
||||||
|
|
||||||
selector.style.left = `${finalX}px`;
|
|
||||||
selector.style.top = `${finalY}px`;
|
|
||||||
selector.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setBulkContentRating(level, filePaths = null) {
|
async setBulkContentRating(level, filePaths = null) {
|
||||||
@@ -1144,7 +1127,7 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hideSimpleLoading();
|
state.loadingManager?.hide?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (successCount === totalCount) {
|
if (successCount === totalCount) {
|
||||||
@@ -1250,7 +1233,7 @@ export class BulkManager {
|
|||||||
console.error('Error during bulk base model operation:', error);
|
console.error('Error during bulk base model operation:', error);
|
||||||
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
|
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hideSimpleLoading();
|
state.loadingManager?.hide?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,11 +91,16 @@ class StubImportUseCase:
|
|||||||
class StubProcessor:
|
class StubProcessor:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.delete_calls: list[web.Request] = []
|
self.delete_calls: list[web.Request] = []
|
||||||
|
self.nsfw_calls: list[web.Request] = []
|
||||||
|
|
||||||
async def delete_custom_image(self, request: web.Request) -> web.Response:
|
async def delete_custom_image(self, request: web.Request) -> web.Response:
|
||||||
self.delete_calls.append(request)
|
self.delete_calls.append(request)
|
||||||
return web.json_response({"deleted": True})
|
return web.json_response({"deleted": True})
|
||||||
|
|
||||||
|
async def set_example_image_nsfw_level(self, request: web.Request) -> web.Response:
|
||||||
|
self.nsfw_calls.append(request)
|
||||||
|
return web.json_response({"updated": True})
|
||||||
|
|
||||||
|
|
||||||
class StubCleanupService:
|
class StubCleanupService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ class StubExampleImagesProcessor:
|
|||||||
self.calls.append(("delete_custom_image", payload))
|
self.calls.append(("delete_custom_image", payload))
|
||||||
return web.json_response({"operation": "delete_custom_image", "payload": payload})
|
return web.json_response({"operation": "delete_custom_image", "payload": payload})
|
||||||
|
|
||||||
|
async def set_example_image_nsfw_level(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
payload = await request.json()
|
||||||
|
self.calls.append(("set_example_image_nsfw_level", payload))
|
||||||
|
return web.json_response({"operation": "set_example_image_nsfw_level", "payload": payload})
|
||||||
|
|
||||||
|
|
||||||
class StubExampleImagesFileManager:
|
class StubExampleImagesFileManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -246,6 +251,19 @@ async def test_delete_route_delegates_to_processor():
|
|||||||
assert harness.processor.calls == [("delete_custom_image", payload)]
|
assert harness.processor.calls == [("delete_custom_image", payload)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_nsfw_route_delegates_to_processor():
|
||||||
|
payload = {"model_hash": "abc123", "nsfw_level": 4, "index": 0, "source": "civitai"}
|
||||||
|
async with example_images_app() as harness:
|
||||||
|
response = await harness.client.post(
|
||||||
|
"/api/lm/example-images/set-nsfw-level", json=payload
|
||||||
|
)
|
||||||
|
body = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert body == {"operation": "set_example_image_nsfw_level", "payload": payload}
|
||||||
|
assert ("set_example_image_nsfw_level", payload) in harness.processor.calls
|
||||||
|
|
||||||
|
|
||||||
async def test_file_routes_delegate_to_file_manager():
|
async def test_file_routes_delegate_to_file_manager():
|
||||||
open_payload = {"model_hash": "abc123"}
|
open_payload = {"model_hash": "abc123"}
|
||||||
files_params = {"model_hash": "def456"}
|
files_params = {"model_hash": "def456"}
|
||||||
@@ -387,6 +405,10 @@ async def test_management_handler_methods_delegate() -> None:
|
|||||||
self.calls.append(("delete_custom_image", request))
|
self.calls.append(("delete_custom_image", request))
|
||||||
return "delete"
|
return "delete"
|
||||||
|
|
||||||
|
async def set_example_image_nsfw_level(self, request) -> str:
|
||||||
|
self.calls.append(("set_example_image_nsfw_level", request))
|
||||||
|
return "nsfw"
|
||||||
|
|
||||||
recorder = Recorder()
|
recorder = Recorder()
|
||||||
cleanup_service = StubExampleImagesCleanupService()
|
cleanup_service = StubExampleImagesCleanupService()
|
||||||
use_case = StubImportUseCase()
|
use_case = StubImportUseCase()
|
||||||
@@ -458,6 +480,9 @@ def test_handler_set_route_mapping_includes_all_handlers() -> None:
|
|||||||
async def delete_custom_image(self, request):
|
async def delete_custom_image(self, request):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
async def set_example_image_nsfw_level(self, request):
|
||||||
|
return {}
|
||||||
|
|
||||||
download = ExampleImagesDownloadHandler(DummyUseCase(), DummyManager())
|
download = ExampleImagesDownloadHandler(DummyUseCase(), DummyManager())
|
||||||
cleanup_service = StubExampleImagesCleanupService()
|
cleanup_service = StubExampleImagesCleanupService()
|
||||||
management = ExampleImagesManagementHandler(DummyUseCase(), DummyProcessor(), cleanup_service)
|
management = ExampleImagesManagementHandler(DummyUseCase(), DummyProcessor(), cleanup_service)
|
||||||
@@ -479,6 +504,7 @@ def test_handler_set_route_mapping_includes_all_handlers() -> None:
|
|||||||
"force_download_example_images",
|
"force_download_example_images",
|
||||||
"import_example_images",
|
"import_example_images",
|
||||||
"delete_example_image",
|
"delete_example_image",
|
||||||
|
"set_example_image_nsfw_level",
|
||||||
"cleanup_example_image_folders",
|
"cleanup_example_image_folders",
|
||||||
"open_example_images_folder",
|
"open_example_images_folder",
|
||||||
"get_example_image_files",
|
"get_example_image_files",
|
||||||
|
|||||||
Reference in New Issue
Block a user