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:
Will Miao
2026-03-26 18:24:47 +08:00
parent 3b001a6cd8
commit ceeab0c998
28 changed files with 320 additions and 59 deletions

View File

@@ -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
---

View File

@@ -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",

View File

@@ -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",
@@ -1758,4 +1766,4 @@
"retry": "Retry"
}
}
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "נגן וידאו אוטומטית בריחוף",

View File

@@ -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": "ホバー時に動画を自動再生",

View File

@@ -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": "호버 시 비디오 자동 재생",

View File

@@ -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": "Автовоспроизведение видео при наведении",

View File

@@ -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": "悬停时自动播放视频",

View File

@@ -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": "滑鼠懸停自動播放影片",

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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):

View File

@@ -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"]

View File

@@ -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');

View File

@@ -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');
}

View File

@@ -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');

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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,

View File

@@ -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 -->

View File

@@ -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);

View 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);
});
});

View File

@@ -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": ""}},

View File

@@ -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"]