mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-26 15:38:52 -03:00
feat: add configurable mature blur threshold setting
Add new setting 'mature_blur_level' with options PG13/R/X/XXX to control which NSFW rating level triggers blur filtering when NSFW blur is enabled. - Backend: update preview selection logic to respect threshold - Frontend: update UI components to use configurable threshold - Settings: add validation and normalization for mature_blur_level - Tests: add coverage for new threshold behavior - Translations: add keys for all supported languages Fixes #867
This commit is contained in:
@@ -179,6 +179,8 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
|
||||
- Context menu for quick actions
|
||||
- Custom notes and usage tips
|
||||
- Multi-folder support
|
||||
- Configurable mature blur threshold (`PG13` / `R` / `X` / `XXX`, default `R+`)
|
||||
- Example: setting threshold to `PG13` blurs `PG13`, `R`, `X`, and `XXX` previews when blur is enabled
|
||||
- Visual progress indicators during initialization
|
||||
|
||||
---
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
|
||||
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
|
||||
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
|
||||
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern"
|
||||
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "Blur NSFW Content",
|
||||
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
|
||||
"showOnlySfw": "Show Only SFW Results",
|
||||
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching"
|
||||
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching",
|
||||
"matureBlurThreshold": "Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "PG13 and above",
|
||||
"r": "R and above (default)",
|
||||
"x": "X and above",
|
||||
"xxx": "XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Autoplay Videos on Hover",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "Difuminar contenido NSFW",
|
||||
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
|
||||
"showOnlySfw": "Mostrar solo resultados SFW",
|
||||
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar"
|
||||
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "Flouter le contenu NSFW",
|
||||
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
|
||||
"showOnlySfw": "Afficher uniquement les résultats SFW",
|
||||
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche"
|
||||
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "טשטש תוכן NSFW",
|
||||
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
|
||||
"showOnlySfw": "הצג רק תוצאות SFW",
|
||||
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש"
|
||||
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
||||
"blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします",
|
||||
"showOnlySfw": "SFWコンテンツのみ表示",
|
||||
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します"
|
||||
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "ホバー時に動画を自動再生",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
||||
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
|
||||
"showOnlySfw": "SFW 결과만 표시",
|
||||
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다"
|
||||
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "Размывать NSFW контент",
|
||||
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
|
||||
"showOnlySfw": "Показывать только SFW результаты",
|
||||
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске"
|
||||
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "模糊 NSFW 内容",
|
||||
"blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片",
|
||||
"showOnlySfw": "仅显示 SFW 结果",
|
||||
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容"
|
||||
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "悬停时自动播放视频",
|
||||
|
||||
@@ -291,7 +291,15 @@
|
||||
"blurNsfwContent": "模糊 NSFW 內容",
|
||||
"blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片",
|
||||
"showOnlySfw": "僅顯示 SFW 結果",
|
||||
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容"
|
||||
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
||||
|
||||
@@ -16,7 +16,7 @@ from ..utils.constants import (
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
@@ -846,9 +846,13 @@ class DownloadManager:
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get("blur_mature_content", True)
|
||||
)
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
|
||||
)
|
||||
selected_image, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
|
||||
preview_url = selected_image.get("url") if selected_image else None
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
|
||||
return None
|
||||
|
||||
blur_mature_content = True
|
||||
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
|
||||
settings = getattr(self, "_settings", None)
|
||||
if settings is not None and hasattr(settings, "get"):
|
||||
try:
|
||||
blur_mature_content = bool(settings.get("blur_mature_content", True))
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings.get("mature_blur_level", "R")}
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive guard
|
||||
blur_mature_content = True
|
||||
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
|
||||
|
||||
selected, _ = select_preview_media(candidates, blur_mature_content=blur_mature_content)
|
||||
selected, _ = select_preview_media(
|
||||
candidates,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
if not selected:
|
||||
return None
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -49,9 +49,13 @@ class PreviewAssetService:
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get("blur_mature_content", True)
|
||||
)
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
|
||||
)
|
||||
first_preview, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
|
||||
if not first_preview:
|
||||
@@ -216,4 +220,3 @@ class PreviewAssetService:
|
||||
if "webm" in content_type:
|
||||
return ".webm"
|
||||
return ".mp4"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequ
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG
|
||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
|
||||
from ..utils.tag_priorities import (
|
||||
PriorityTagEntry,
|
||||
@@ -59,6 +60,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"optimize_example_images": True,
|
||||
"auto_download_example_images": False,
|
||||
"blur_mature_content": True,
|
||||
"mature_blur_level": "R",
|
||||
"autoplay_on_hover": False,
|
||||
"display_density": "default",
|
||||
"card_info_display": "always",
|
||||
@@ -274,6 +276,16 @@ class SettingsManager:
|
||||
self.settings["metadata_refresh_skip_paths"] = []
|
||||
inserted_defaults = True
|
||||
|
||||
had_mature_level = "mature_blur_level" in self.settings
|
||||
raw_mature_level = self.settings.get("mature_blur_level")
|
||||
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
|
||||
if normalized_mature_level != raw_mature_level:
|
||||
self.settings["mature_blur_level"] = normalized_mature_level
|
||||
if had_mature_level:
|
||||
updated_existing = True
|
||||
else:
|
||||
inserted_defaults = True
|
||||
|
||||
for key, value in defaults.items():
|
||||
if key == "priority_tags":
|
||||
continue
|
||||
@@ -608,6 +620,7 @@ class SettingsManager:
|
||||
'optimizeExampleImages': 'optimize_example_images',
|
||||
'autoDownloadExampleImages': 'auto_download_example_images',
|
||||
'blurMatureContent': 'blur_mature_content',
|
||||
'matureBlurLevel': 'mature_blur_level',
|
||||
'autoplayOnHover': 'autoplay_on_hover',
|
||||
'displayDensity': 'display_density',
|
||||
'cardInfoDisplay': 'card_info_display',
|
||||
@@ -860,6 +873,13 @@ class SettingsManager:
|
||||
|
||||
return normalized
|
||||
|
||||
def normalize_mature_blur_level(self, value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().upper()
|
||||
if normalized in VALID_MATURE_BLUR_LEVELS:
|
||||
return normalized
|
||||
return "R"
|
||||
|
||||
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
@@ -1012,6 +1032,8 @@ class SettingsManager:
|
||||
value = self.normalize_auto_organize_exclusions(value)
|
||||
elif key == "metadata_refresh_skip_paths":
|
||||
value = self.normalize_metadata_refresh_skip_paths(value)
|
||||
elif key == "mature_blur_level":
|
||||
value = self.normalize_mature_blur_level(value)
|
||||
self.settings[key] = value
|
||||
portable_switch_pending = False
|
||||
if key == "use_portable_settings" and isinstance(value, bool):
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping, Optional, Sequence, Tuple
|
||||
from typing import Any, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from .constants import NSFW_LEVELS
|
||||
|
||||
PreviewMedia = Mapping[str, object]
|
||||
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
|
||||
|
||||
|
||||
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def resolve_mature_threshold(settings: Mapping[str, Any] | None) -> int:
|
||||
"""Resolve the configured mature blur threshold from settings.
|
||||
|
||||
Allowed values are ``PG13``, ``R``, ``X``, and ``XXX``. Any invalid or
|
||||
missing value falls back to ``R``.
|
||||
"""
|
||||
|
||||
if not isinstance(settings, Mapping):
|
||||
return NSFW_LEVELS.get("R", 4)
|
||||
|
||||
raw_level = settings.get("mature_blur_level", "R")
|
||||
normalized = str(raw_level).strip().upper()
|
||||
if normalized not in VALID_MATURE_BLUR_LEVELS:
|
||||
normalized = "R"
|
||||
return NSFW_LEVELS.get(normalized, NSFW_LEVELS.get("R", 4))
|
||||
|
||||
|
||||
def select_preview_media(
|
||||
images: Sequence[Mapping[str, object]] | None,
|
||||
*,
|
||||
blur_mature_content: bool,
|
||||
mature_threshold: int | None = None,
|
||||
) -> Tuple[Optional[PreviewMedia], int]:
|
||||
"""Select the most appropriate preview media entry.
|
||||
|
||||
When ``blur_mature_content`` is enabled we first try to return the first media
|
||||
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are
|
||||
available we return the media entry with the lowest NSFW level. When the
|
||||
setting is disabled we simply return the first entry.
|
||||
item with an ``nsfwLevel`` lower than the configured mature threshold
|
||||
(defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
|
||||
the media entry with the lowest NSFW level. When the setting is disabled we
|
||||
simply return the first entry.
|
||||
"""
|
||||
|
||||
if not images:
|
||||
@@ -45,7 +65,9 @@ def select_preview_media(
|
||||
if not blur_mature_content:
|
||||
return selected, selected_level
|
||||
|
||||
safe_threshold = NSFW_LEVELS.get("R", 4)
|
||||
safe_threshold = (
|
||||
mature_threshold if isinstance(mature_threshold, int) else NSFW_LEVELS.get("R", 4)
|
||||
)
|
||||
for candidate in candidates:
|
||||
level = _extract_nsfw_level(candidate)
|
||||
if level < safe_threshold:
|
||||
@@ -60,4 +82,4 @@ def select_preview_media(
|
||||
return selected, selected_level
|
||||
|
||||
|
||||
__all__ = ["select_preview_media"]
|
||||
__all__ = ["resolve_mature_threshold", "select_preview_media", "VALID_MATURE_BLUR_LEVELS"]
|
||||
|
||||
@@ -6,7 +6,7 @@ import { modalManager } from '../managers/ModalManager.js';
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
|
||||
|
||||
class RecipeCard {
|
||||
constructor(recipe, clickHandler) {
|
||||
@@ -74,7 +74,8 @@ class RecipeCard {
|
||||
|
||||
// NSFW blur logic - similar to LoraCard
|
||||
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
|
||||
@@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
@@ -478,7 +478,8 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.nsfwLevel = nsfwLevel;
|
||||
|
||||
// Determine if the preview should be blurred based on NSFW level and user settings
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
|
||||
|
||||
/**
|
||||
@@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
|
||||
}
|
||||
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
|
||||
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
let overlay = mediaWrapper.querySelector('.nsfw-overlay');
|
||||
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { modalManager } from '../../../managers/ModalManager.js';
|
||||
import { translate } from '../../../utils/i18nHelpers.js';
|
||||
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
import {
|
||||
initLazyLoading,
|
||||
initNsfwBlurHandlers,
|
||||
@@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) {
|
||||
|
||||
// Check if media should be blurred
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
|
||||
@@ -10,6 +10,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { sidebarManager } from '../components/SidebarManager.js';
|
||||
|
||||
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
@@ -137,11 +139,25 @@ export class SettingsManager {
|
||||
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
||||
);
|
||||
|
||||
merged.mature_blur_level = this.normalizeMatureBlurLevel(
|
||||
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
|
||||
);
|
||||
|
||||
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
normalizeMatureBlurLevel(value) {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (VALID_MATURE_BLUR_LEVELS.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return 'R';
|
||||
}
|
||||
|
||||
normalizePatternList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const sanitized = value
|
||||
@@ -682,6 +698,13 @@ export class SettingsManager {
|
||||
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
|
||||
}
|
||||
|
||||
const matureBlurLevelSelect = document.getElementById('matureBlurLevel');
|
||||
if (matureBlurLevelSelect) {
|
||||
matureBlurLevelSelect.value = this.normalizeMatureBlurLevel(
|
||||
state.global.settings.mature_blur_level
|
||||
);
|
||||
}
|
||||
|
||||
const usePortableCheckbox = document.getElementById('usePortableSettings');
|
||||
if (usePortableCheckbox) {
|
||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||
@@ -1811,7 +1834,9 @@ export class SettingsManager {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const value = element.value;
|
||||
const value = settingKey === 'mature_blur_level'
|
||||
? this.normalizeMatureBlurLevel(element.value)
|
||||
: element.value;
|
||||
|
||||
try {
|
||||
// Update frontend state with mapped keys
|
||||
@@ -1834,7 +1859,12 @@ export class SettingsManager {
|
||||
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
|
||||
if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') {
|
||||
if (
|
||||
settingKey === 'model_name_display'
|
||||
|| settingKey === 'model_card_footer_action'
|
||||
|| settingKey === 'update_flag_strategy'
|
||||
|| settingKey === 'mature_blur_level'
|
||||
) {
|
||||
this.reloadContent();
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
optimize_example_images: true,
|
||||
auto_download_example_images: false,
|
||||
blur_mature_content: true,
|
||||
mature_blur_level: 'R',
|
||||
autoplay_on_hover: false,
|
||||
display_density: 'default',
|
||||
card_info_display: 'always',
|
||||
|
||||
@@ -309,6 +309,15 @@ export const NSFW_LEVELS = {
|
||||
BLOCKED: 32
|
||||
};
|
||||
|
||||
export const VALID_MATURE_BLUR_LEVELS = ['PG13', 'R', 'X', 'XXX'];
|
||||
|
||||
export function getMatureBlurThreshold(settings = {}) {
|
||||
const rawValue = settings?.mature_blur_level;
|
||||
const normalizedValue = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : '';
|
||||
const levelName = VALID_MATURE_BLUR_LEVELS.includes(normalizedValue) ? normalizedValue : 'R';
|
||||
return NSFW_LEVELS[levelName] ?? NSFW_LEVELS.R;
|
||||
}
|
||||
|
||||
// Node type constants
|
||||
export const NODE_TYPES = {
|
||||
LORA_LOADER: 1,
|
||||
|
||||
@@ -281,6 +281,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="matureBlurLevel">
|
||||
{{ t('settings.contentFiltering.matureBlurThreshold') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.contentFiltering.matureBlurThresholdHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="matureBlurLevel"
|
||||
onchange="settingsManager.saveSelectSetting('matureBlurLevel', 'mature_blur_level')">
|
||||
<option value="PG13">{{ t('settings.contentFiltering.matureBlurThresholdOptions.pg13') }}</option>
|
||||
<option value="R">{{ t('settings.contentFiltering.matureBlurThresholdOptions.r') }}</option>
|
||||
<option value="X">{{ t('settings.contentFiltering.matureBlurThresholdOptions.x') }}</option>
|
||||
<option value="XXX">{{ t('settings.contentFiltering.matureBlurThresholdOptions.xxx') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Settings -->
|
||||
|
||||
@@ -15,7 +15,8 @@ describe('state module', () => {
|
||||
expect(defaultSettings).toMatchObject({
|
||||
civitai_api_key: '',
|
||||
language: 'en',
|
||||
blur_mature_content: true
|
||||
blur_mature_content: true,
|
||||
mature_blur_level: 'R'
|
||||
});
|
||||
|
||||
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
|
||||
|
||||
18
tests/frontend/utils/constants.matureBlurThreshold.test.js
Normal file
18
tests/frontend/utils/constants.matureBlurThreshold.test.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../static/js/utils/constants.js';
|
||||
|
||||
describe('getMatureBlurThreshold', () => {
|
||||
it('returns configured PG13 threshold', () => {
|
||||
expect(getMatureBlurThreshold({ mature_blur_level: 'PG13' })).toBe(NSFW_LEVELS.PG13);
|
||||
});
|
||||
|
||||
it('normalizes lowercase values', () => {
|
||||
expect(getMatureBlurThreshold({ mature_blur_level: 'x' })).toBe(NSFW_LEVELS.X);
|
||||
});
|
||||
|
||||
it('falls back to R when value is invalid or missing', () => {
|
||||
expect(getMatureBlurThreshold({ mature_blur_level: 'invalid' })).toBe(NSFW_LEVELS.R);
|
||||
expect(getMatureBlurThreshold({})).toBe(NSFW_LEVELS.R);
|
||||
});
|
||||
});
|
||||
@@ -265,6 +265,32 @@ def test_delete_setting(manager):
|
||||
assert manager.get("example") is None
|
||||
|
||||
|
||||
def test_missing_mature_blur_level_defaults_to_r(tmp_path, monkeypatch):
|
||||
manager = _create_manager_with_settings(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
{
|
||||
"blur_mature_content": True,
|
||||
"folder_paths": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert manager.get("mature_blur_level") == "R"
|
||||
|
||||
|
||||
def test_invalid_mature_blur_level_is_normalized_to_r(tmp_path, monkeypatch):
|
||||
manager = _create_manager_with_settings(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
{
|
||||
"mature_blur_level": "unsafe",
|
||||
"folder_paths": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert manager.get("mature_blur_level") == "R"
|
||||
|
||||
|
||||
def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
|
||||
initial = {
|
||||
"libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}},
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
from py.utils.preview_selection import select_preview_media
|
||||
import pytest
|
||||
|
||||
|
||||
def test_select_preview_prefers_safe_media_when_blurred():
|
||||
images = [
|
||||
{"url": "nsfw", "type": "image", "nsfwLevel": 8},
|
||||
{"url": "mid", "type": "image", "nsfwLevel": 4},
|
||||
{"url": "safe", "type": "image", "nsfwLevel": 1},
|
||||
]
|
||||
|
||||
selected, level = select_preview_media(images, blur_mature_content=True)
|
||||
|
||||
assert selected["url"] == "safe"
|
||||
assert level == 1
|
||||
|
||||
|
||||
def test_select_preview_returns_lowest_when_no_safe_media():
|
||||
images = [
|
||||
{"url": "x", "type": "image", "nsfwLevel": 16},
|
||||
{"url": "r", "type": "image", "nsfwLevel": 4},
|
||||
{"url": "xx", "type": "image", "nsfwLevel": 8},
|
||||
]
|
||||
|
||||
selected, level = select_preview_media(images, blur_mature_content=True)
|
||||
|
||||
assert selected["url"] == "r"
|
||||
assert level == 4
|
||||
from py.utils.constants import NSFW_LEVELS
|
||||
from py.utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
|
||||
def test_select_preview_returns_first_when_blur_disabled():
|
||||
@@ -37,3 +14,36 @@ def test_select_preview_returns_first_when_blur_disabled():
|
||||
|
||||
assert selected["url"] == "nsfw"
|
||||
assert level == 32
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("threshold_name", "expected_url"),
|
||||
[
|
||||
("PG13", "pg"),
|
||||
("R", "pg13"),
|
||||
("X", "r"),
|
||||
("XXX", "x"),
|
||||
],
|
||||
)
|
||||
def test_select_preview_respects_configurable_threshold(threshold_name, expected_url):
|
||||
images = [
|
||||
{"url": "xxx", "type": "image", "nsfwLevel": NSFW_LEVELS["XXX"]},
|
||||
{"url": "x", "type": "image", "nsfwLevel": NSFW_LEVELS["X"]},
|
||||
{"url": "r", "type": "image", "nsfwLevel": NSFW_LEVELS["R"]},
|
||||
{"url": "pg13", "type": "image", "nsfwLevel": NSFW_LEVELS["PG13"]},
|
||||
{"url": "pg", "type": "image", "nsfwLevel": NSFW_LEVELS["PG"]},
|
||||
]
|
||||
|
||||
selected, level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=True,
|
||||
mature_threshold=NSFW_LEVELS[threshold_name],
|
||||
)
|
||||
|
||||
assert selected["url"] == expected_url
|
||||
assert level == next(item["nsfwLevel"] for item in images if item["url"] == expected_url)
|
||||
|
||||
|
||||
def test_resolve_mature_threshold_falls_back_to_r_for_invalid_value():
|
||||
assert resolve_mature_threshold({"mature_blur_level": "invalid"}) == NSFW_LEVELS["R"]
|
||||
assert resolve_mature_threshold({}) == NSFW_LEVELS["R"]
|
||||
|
||||
Reference in New Issue
Block a user