Dinamush commited on
Commit ·
ee170ea
1
Parent(s): 5c7d5ce
feat: implement SFW classify mode and NSFW tag management
Browse filesAdd support for SFW classify mode, which restricts tag selection to SFW categories while parking NSFW tags. Update settings schema and API to handle new selected_tags_nsfw field. Adjust frontend logic to manage tag behavior based on SFW mode status, ensuring a seamless user experience.
- backend/app/api.py +54 -16
- backend/app/schemas.py +5 -0
- backend/app/storage.py +2 -0
- backend/tests/test_api_sfw_classify_mode.py +90 -0
- frontend/src/App.jsx +118 -8
- frontend/src/api.js +13 -1
- frontend/src/styles.css +25 -0
backend/app/api.py
CHANGED
|
@@ -920,6 +920,9 @@ def _infer_batch_with_fallback(
|
|
| 920 |
return [row], elapsed_ms, "single_fallback"
|
| 921 |
|
| 922 |
|
|
|
|
|
|
|
|
|
|
| 923 |
def _settings_from_db() -> AppSettings:
|
| 924 |
row = fetch_one("SELECT * FROM settings WHERE id = 1")
|
| 925 |
if not row:
|
|
@@ -929,12 +932,20 @@ def _settings_from_db() -> AppSettings:
|
|
| 929 |
if not isinstance(selected_tags, list):
|
| 930 |
selected_tags = []
|
| 931 |
selected_tags = [str(t).strip() for t in selected_tags if str(t).strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
tagger_model = str(row.get("tagger_model") or "wd_swinv2_v3").strip()
|
| 933 |
if tagger_model not in {"ml_danbooru", "wd_swinv2_v3", "wd_eva02_large"}:
|
| 934 |
tagger_model = "wd_swinv2_v3"
|
| 935 |
tagging_domain = str(row.get("tagging_domain") or "drawn").strip().lower()
|
| 936 |
if tagging_domain not in {"drawn", "real_life"}:
|
| 937 |
tagging_domain = "drawn"
|
|
|
|
|
|
|
|
|
|
| 938 |
return AppSettings(
|
| 939 |
root_repo=row["root_repo"],
|
| 940 |
categories_root=row["categories_root"],
|
|
@@ -947,7 +958,9 @@ def _settings_from_db() -> AppSettings:
|
|
| 947 |
),
|
| 948 |
hybrid_ml_on_review=bool(row.get("hybrid_ml_on_review", 1)),
|
| 949 |
tagging_domain=tagging_domain, # type: ignore[arg-type]
|
|
|
|
| 950 |
selected_tags=selected_tags,
|
|
|
|
| 951 |
max_inference_workers=int(row.get("max_inference_workers") or 2),
|
| 952 |
inference_batch_size=int(row.get("inference_batch_size") or 4),
|
| 953 |
force_cpu_inference=bool(row.get("force_cpu_inference", 0)),
|
|
@@ -2187,28 +2200,50 @@ def save_settings(payload: SaveSettingsRequest) -> AppSettings:
|
|
| 2187 |
if tagging_domain not in {"drawn", "real_life"}:
|
| 2188 |
tagging_domain = "drawn"
|
| 2189 |
payload.tagging_domain = tagging_domain # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2190 |
|
| 2191 |
known = load_known_tags(TAGS_CSV)
|
| 2192 |
known_by_norm = {normalize_tag_name(t): t for t in known}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2193 |
cleaned_tags: list[str] = []
|
| 2194 |
-
|
| 2195 |
-
|
| 2196 |
-
|
| 2197 |
-
|
| 2198 |
-
|
| 2199 |
rl = resolve_real_life_folder(value)
|
| 2200 |
if rl is not None and rl.folder not in cleaned_tags:
|
| 2201 |
cleaned_tags.append(rl.folder)
|
| 2202 |
-
|
| 2203 |
-
|
| 2204 |
-
|
| 2205 |
-
|
| 2206 |
-
|
| 2207 |
-
continue
|
| 2208 |
-
matched = value if value in known else known_by_norm.get(normalize_tag_name(value))
|
| 2209 |
-
if matched and matched not in cleaned_tags:
|
| 2210 |
-
cleaned_tags.append(matched)
|
| 2211 |
payload.selected_tags = cleaned_tags
|
|
|
|
| 2212 |
try:
|
| 2213 |
execute(
|
| 2214 |
"""
|
|
@@ -2216,8 +2251,9 @@ def save_settings(payload: SaveSettingsRequest) -> AppSettings:
|
|
| 2216 |
SET root_repo = ?, categories_root = ?, confidence_threshold = ?,
|
| 2217 |
default_migrate_mode = ?, scan_recursive = ?, experimental_media_enabled = ?,
|
| 2218 |
experimental_style_detector_enabled = ?, hybrid_ml_on_review = ?,
|
| 2219 |
-
tagging_domain = ?,
|
| 2220 |
-
selected_tags_json = ?,
|
|
|
|
| 2221 |
force_cpu_inference = ?, tagger_model = ?, wd_general_threshold = ?
|
| 2222 |
WHERE id = 1
|
| 2223 |
""",
|
|
@@ -2231,7 +2267,9 @@ def save_settings(payload: SaveSettingsRequest) -> AppSettings:
|
|
| 2231 |
1 if payload.experimental_style_detector_enabled else 0,
|
| 2232 |
1 if payload.hybrid_ml_on_review else 0,
|
| 2233 |
tagging_domain,
|
|
|
|
| 2234 |
to_json(cleaned_tags),
|
|
|
|
| 2235 |
int(payload.max_inference_workers),
|
| 2236 |
int(payload.inference_batch_size),
|
| 2237 |
1 if payload.force_cpu_inference else 0,
|
|
|
|
| 920 |
return [row], elapsed_ms, "single_fallback"
|
| 921 |
|
| 922 |
|
| 923 |
+
SFW_CLASSIFY_FOLDERS: tuple[str, ...] = ("SFW", "scenery")
|
| 924 |
+
|
| 925 |
+
|
| 926 |
def _settings_from_db() -> AppSettings:
|
| 927 |
row = fetch_one("SELECT * FROM settings WHERE id = 1")
|
| 928 |
if not row:
|
|
|
|
| 932 |
if not isinstance(selected_tags, list):
|
| 933 |
selected_tags = []
|
| 934 |
selected_tags = [str(t).strip() for t in selected_tags if str(t).strip()]
|
| 935 |
+
nsfw_raw = row.get("selected_tags_nsfw_json") or "[]"
|
| 936 |
+
selected_tags_nsfw = from_json(nsfw_raw, default=[])
|
| 937 |
+
if not isinstance(selected_tags_nsfw, list):
|
| 938 |
+
selected_tags_nsfw = []
|
| 939 |
+
selected_tags_nsfw = [str(t).strip() for t in selected_tags_nsfw if str(t).strip()]
|
| 940 |
tagger_model = str(row.get("tagger_model") or "wd_swinv2_v3").strip()
|
| 941 |
if tagger_model not in {"ml_danbooru", "wd_swinv2_v3", "wd_eva02_large"}:
|
| 942 |
tagger_model = "wd_swinv2_v3"
|
| 943 |
tagging_domain = str(row.get("tagging_domain") or "drawn").strip().lower()
|
| 944 |
if tagging_domain not in {"drawn", "real_life"}:
|
| 945 |
tagging_domain = "drawn"
|
| 946 |
+
sfw_classify_mode = bool(row.get("sfw_classify_mode", 0))
|
| 947 |
+
if tagging_domain == "real_life":
|
| 948 |
+
sfw_classify_mode = False
|
| 949 |
return AppSettings(
|
| 950 |
root_repo=row["root_repo"],
|
| 951 |
categories_root=row["categories_root"],
|
|
|
|
| 958 |
),
|
| 959 |
hybrid_ml_on_review=bool(row.get("hybrid_ml_on_review", 1)),
|
| 960 |
tagging_domain=tagging_domain, # type: ignore[arg-type]
|
| 961 |
+
sfw_classify_mode=sfw_classify_mode,
|
| 962 |
selected_tags=selected_tags,
|
| 963 |
+
selected_tags_nsfw=selected_tags_nsfw,
|
| 964 |
max_inference_workers=int(row.get("max_inference_workers") or 2),
|
| 965 |
inference_batch_size=int(row.get("inference_batch_size") or 4),
|
| 966 |
force_cpu_inference=bool(row.get("force_cpu_inference", 0)),
|
|
|
|
| 2200 |
if tagging_domain not in {"drawn", "real_life"}:
|
| 2201 |
tagging_domain = "drawn"
|
| 2202 |
payload.tagging_domain = tagging_domain # type: ignore[assignment]
|
| 2203 |
+
sfw_classify_mode = bool(payload.sfw_classify_mode)
|
| 2204 |
+
if tagging_domain == "real_life":
|
| 2205 |
+
sfw_classify_mode = False
|
| 2206 |
+
payload.sfw_classify_mode = sfw_classify_mode
|
| 2207 |
|
| 2208 |
known = load_known_tags(TAGS_CSV)
|
| 2209 |
known_by_norm = {normalize_tag_name(t): t for t in known}
|
| 2210 |
+
|
| 2211 |
+
def _resolve_drawn_tags(raw_tags: list[str]) -> list[str]:
|
| 2212 |
+
cleaned: list[str] = []
|
| 2213 |
+
for tag in raw_tags:
|
| 2214 |
+
value = str(tag).strip()
|
| 2215 |
+
if not value:
|
| 2216 |
+
continue
|
| 2217 |
+
tax = resolve_taxonomy_folder(value)
|
| 2218 |
+
if tax is not None:
|
| 2219 |
+
if tax.folder not in cleaned:
|
| 2220 |
+
cleaned.append(tax.folder)
|
| 2221 |
+
continue
|
| 2222 |
+
matched = value if value in known else known_by_norm.get(normalize_tag_name(value))
|
| 2223 |
+
if matched and matched not in cleaned:
|
| 2224 |
+
cleaned.append(matched)
|
| 2225 |
+
return cleaned
|
| 2226 |
+
|
| 2227 |
+
cleaned_nsfw = _resolve_drawn_tags(list(payload.selected_tags_nsfw or []))
|
| 2228 |
+
# Never park the SFW-mode destinations inside the NSFW stash.
|
| 2229 |
+
cleaned_nsfw = [t for t in cleaned_nsfw if t not in SFW_CLASSIFY_FOLDERS]
|
| 2230 |
+
|
| 2231 |
cleaned_tags: list[str] = []
|
| 2232 |
+
if tagging_domain == "real_life":
|
| 2233 |
+
for tag in payload.selected_tags:
|
| 2234 |
+
value = tag.strip()
|
| 2235 |
+
if not value:
|
| 2236 |
+
continue
|
| 2237 |
rl = resolve_real_life_folder(value)
|
| 2238 |
if rl is not None and rl.folder not in cleaned_tags:
|
| 2239 |
cleaned_tags.append(rl.folder)
|
| 2240 |
+
elif sfw_classify_mode:
|
| 2241 |
+
cleaned_tags = list(SFW_CLASSIFY_FOLDERS)
|
| 2242 |
+
else:
|
| 2243 |
+
cleaned_tags = _resolve_drawn_tags(list(payload.selected_tags or []))
|
| 2244 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2245 |
payload.selected_tags = cleaned_tags
|
| 2246 |
+
payload.selected_tags_nsfw = cleaned_nsfw
|
| 2247 |
try:
|
| 2248 |
execute(
|
| 2249 |
"""
|
|
|
|
| 2251 |
SET root_repo = ?, categories_root = ?, confidence_threshold = ?,
|
| 2252 |
default_migrate_mode = ?, scan_recursive = ?, experimental_media_enabled = ?,
|
| 2253 |
experimental_style_detector_enabled = ?, hybrid_ml_on_review = ?,
|
| 2254 |
+
tagging_domain = ?, sfw_classify_mode = ?,
|
| 2255 |
+
selected_tags_json = ?, selected_tags_nsfw_json = ?,
|
| 2256 |
+
max_inference_workers = ?, inference_batch_size = ?,
|
| 2257 |
force_cpu_inference = ?, tagger_model = ?, wd_general_threshold = ?
|
| 2258 |
WHERE id = 1
|
| 2259 |
""",
|
|
|
|
| 2267 |
1 if payload.experimental_style_detector_enabled else 0,
|
| 2268 |
1 if payload.hybrid_ml_on_review else 0,
|
| 2269 |
tagging_domain,
|
| 2270 |
+
1 if sfw_classify_mode else 0,
|
| 2271 |
to_json(cleaned_tags),
|
| 2272 |
+
to_json(cleaned_nsfw),
|
| 2273 |
int(payload.max_inference_workers),
|
| 2274 |
int(payload.inference_batch_size),
|
| 2275 |
1 if payload.force_cpu_inference else 0,
|
backend/app/schemas.py
CHANGED
|
@@ -26,8 +26,13 @@ class AppSettings(BaseModel):
|
|
| 26 |
hybrid_ml_on_review: bool = True
|
| 27 |
# Drawn/anime WD+ML taxonomy vs isolated real-life adult tagger taxonomy.
|
| 28 |
tagging_domain: TaggingDomain = "drawn"
|
|
|
|
|
|
|
|
|
|
| 29 |
# Shuck3r-style persisted preferences (survive reload / restart).
|
| 30 |
selected_tags: list[str] = Field(default_factory=list)
|
|
|
|
|
|
|
| 31 |
# Shared ORT run lock; preprocess overlaps across workers. Prefer 2 on GPU.
|
| 32 |
max_inference_workers: int = Field(default=2, ge=1, le=16)
|
| 33 |
inference_batch_size: int = Field(default=4, ge=1, le=64)
|
|
|
|
| 26 |
hybrid_ml_on_review: bool = True
|
| 27 |
# Drawn/anime WD+ML taxonomy vs isolated real-life adult tagger taxonomy.
|
| 28 |
tagging_domain: TaggingDomain = "drawn"
|
| 29 |
+
# When true, classify uses only SFW/scenery; NSFW tags are parked.
|
| 30 |
+
# comic is omitted: WD SwinV2 is weak on B&W / lineart comic cues.
|
| 31 |
+
sfw_classify_mode: bool = False
|
| 32 |
# Shuck3r-style persisted preferences (survive reload / restart).
|
| 33 |
selected_tags: list[str] = Field(default_factory=list)
|
| 34 |
+
# Parked NSFW/specialty destinations while sfw_classify_mode is on.
|
| 35 |
+
selected_tags_nsfw: list[str] = Field(default_factory=list)
|
| 36 |
# Shared ORT run lock; preprocess overlaps across workers. Prefer 2 on GPU.
|
| 37 |
max_inference_workers: int = Field(default=2, ge=1, le=16)
|
| 38 |
inference_batch_size: int = Field(default=4, ge=1, le=64)
|
backend/app/storage.py
CHANGED
|
@@ -95,6 +95,8 @@ def _ensure_settings_columns(conn: sqlite3.Connection) -> None:
|
|
| 95 |
"hybrid_ml_on_review": "INTEGER NOT NULL DEFAULT 1",
|
| 96 |
"tagging_domain": "TEXT NOT NULL DEFAULT 'drawn'",
|
| 97 |
"selected_tags_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
|
|
|
|
|
| 98 |
"max_inference_workers": "INTEGER NOT NULL DEFAULT 2",
|
| 99 |
"inference_batch_size": "INTEGER NOT NULL DEFAULT 4",
|
| 100 |
"force_cpu_inference": "INTEGER NOT NULL DEFAULT 0",
|
|
|
|
| 95 |
"hybrid_ml_on_review": "INTEGER NOT NULL DEFAULT 1",
|
| 96 |
"tagging_domain": "TEXT NOT NULL DEFAULT 'drawn'",
|
| 97 |
"selected_tags_json": "TEXT NOT NULL DEFAULT '[]'",
|
| 98 |
+
"selected_tags_nsfw_json": "TEXT NOT NULL DEFAULT '[]'",
|
| 99 |
+
"sfw_classify_mode": "INTEGER NOT NULL DEFAULT 0",
|
| 100 |
"max_inference_workers": "INTEGER NOT NULL DEFAULT 2",
|
| 101 |
"inference_batch_size": "INTEGER NOT NULL DEFAULT 4",
|
| 102 |
"force_cpu_inference": "INTEGER NOT NULL DEFAULT 0",
|
backend/tests/test_api_sfw_classify_mode.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SFW classify mode parks NSFW tags and forces safe destinations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
from app.main import app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _base_settings(tmp_path: Path, **overrides):
|
| 13 |
+
root = tmp_path / "root"
|
| 14 |
+
cats = tmp_path / "cats"
|
| 15 |
+
root.mkdir(exist_ok=True)
|
| 16 |
+
cats.mkdir(exist_ok=True)
|
| 17 |
+
payload = {
|
| 18 |
+
"root_repo": str(root),
|
| 19 |
+
"categories_root": str(cats),
|
| 20 |
+
"confidence_threshold": 0.45,
|
| 21 |
+
"default_migrate_mode": "copy",
|
| 22 |
+
"scan_recursive": True,
|
| 23 |
+
"experimental_media_enabled": False,
|
| 24 |
+
"experimental_style_detector_enabled": False,
|
| 25 |
+
"hybrid_ml_on_review": True,
|
| 26 |
+
"tagging_domain": "drawn",
|
| 27 |
+
"selected_tags": ["loli", "sex", "group_sex"],
|
| 28 |
+
"selected_tags_nsfw": [],
|
| 29 |
+
"sfw_classify_mode": False,
|
| 30 |
+
"max_inference_workers": 2,
|
| 31 |
+
"inference_batch_size": 4,
|
| 32 |
+
"force_cpu_inference": False,
|
| 33 |
+
"tagger_model": "wd_swinv2_v3",
|
| 34 |
+
"wd_general_threshold": 0.35,
|
| 35 |
+
}
|
| 36 |
+
payload.update(overrides)
|
| 37 |
+
return payload
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_sfw_classify_mode_parks_nsfw_and_forces_safe_folders(tmp_path: Path):
|
| 41 |
+
with TestClient(app) as client:
|
| 42 |
+
resp = client.put("/api/settings", json=_base_settings(tmp_path))
|
| 43 |
+
assert resp.status_code == 200
|
| 44 |
+
assert "loli" in resp.json()["selected_tags"]
|
| 45 |
+
|
| 46 |
+
resp = client.put(
|
| 47 |
+
"/api/settings",
|
| 48 |
+
json=_base_settings(
|
| 49 |
+
tmp_path,
|
| 50 |
+
sfw_classify_mode=True,
|
| 51 |
+
selected_tags=["SFW", "scenery"],
|
| 52 |
+
selected_tags_nsfw=["loli", "sex", "group_sex", "milf"],
|
| 53 |
+
),
|
| 54 |
+
)
|
| 55 |
+
assert resp.status_code == 200
|
| 56 |
+
body = resp.json()
|
| 57 |
+
assert body["sfw_classify_mode"] is True
|
| 58 |
+
assert body["selected_tags"] == ["SFW", "scenery"]
|
| 59 |
+
assert "comic" not in body["selected_tags"]
|
| 60 |
+
assert body["selected_tags_nsfw"] == ["loli", "sex", "group_sex", "milf"]
|
| 61 |
+
|
| 62 |
+
resp = client.put(
|
| 63 |
+
"/api/settings",
|
| 64 |
+
json=_base_settings(
|
| 65 |
+
tmp_path,
|
| 66 |
+
sfw_classify_mode=True,
|
| 67 |
+
selected_tags=["loli", "fellatio", "SFW", "comic"],
|
| 68 |
+
selected_tags_nsfw=["loli", "sex"],
|
| 69 |
+
),
|
| 70 |
+
)
|
| 71 |
+
assert resp.status_code == 200
|
| 72 |
+
body = resp.json()
|
| 73 |
+
assert body["selected_tags"] == ["SFW", "scenery"]
|
| 74 |
+
assert "comic" not in body["selected_tags"]
|
| 75 |
+
assert "loli" not in body["selected_tags"]
|
| 76 |
+
|
| 77 |
+
resp = client.put(
|
| 78 |
+
"/api/settings",
|
| 79 |
+
json=_base_settings(
|
| 80 |
+
tmp_path,
|
| 81 |
+
sfw_classify_mode=False,
|
| 82 |
+
selected_tags=["loli", "sex", "group_sex", "milf"],
|
| 83 |
+
selected_tags_nsfw=["loli", "sex", "group_sex", "milf"],
|
| 84 |
+
),
|
| 85 |
+
)
|
| 86 |
+
assert resp.status_code == 200
|
| 87 |
+
body = resp.json()
|
| 88 |
+
assert body["sfw_classify_mode"] is False
|
| 89 |
+
assert "loli" in body["selected_tags"]
|
| 90 |
+
assert "milf" in body["selected_tags"]
|
frontend/src/App.jsx
CHANGED
|
@@ -12,7 +12,9 @@ const DEFAULT_SETTINGS = {
|
|
| 12 |
experimental_style_detector_enabled: false,
|
| 13 |
hybrid_ml_on_review: true,
|
| 14 |
tagging_domain: "drawn",
|
|
|
|
| 15 |
selected_tags: [],
|
|
|
|
| 16 |
max_inference_workers: 2,
|
| 17 |
inference_batch_size: 8,
|
| 18 |
force_cpu_inference: false,
|
|
@@ -61,6 +63,7 @@ const TAGGER_MODELS = [
|
|
| 61 |
},
|
| 62 |
];
|
| 63 |
|
|
|
|
| 64 |
const ACTIVE_RUN_STORAGE_KEY = "imageClassifierActiveRunId";
|
| 65 |
const VIDEO_PREVIEW_EXTS = new Set([
|
| 66 |
".mp4",
|
|
@@ -685,8 +688,78 @@ function App() {
|
|
| 685 |
}
|
| 686 |
}
|
| 687 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
function addSelectedTag(value) {
|
| 689 |
-
if (!value) return;
|
| 690 |
setSelectedTags((prev) => (prev.includes(value) ? prev : [...prev, value]));
|
| 691 |
}
|
| 692 |
|
|
@@ -732,6 +805,7 @@ function App() {
|
|
| 732 |
}
|
| 733 |
|
| 734 |
function removeSelectedTag(value) {
|
|
|
|
| 735 |
setSelectedTags((prev) => prev.filter((t) => t !== value));
|
| 736 |
}
|
| 737 |
|
|
@@ -1029,6 +1103,7 @@ function App() {
|
|
| 1029 |
...settings,
|
| 1030 |
tagging_domain: "real_life",
|
| 1031 |
experimental_media_enabled: true,
|
|
|
|
| 1032 |
})
|
| 1033 |
}
|
| 1034 |
/>
|
|
@@ -1097,8 +1172,21 @@ function App() {
|
|
| 1097 |
<span className="kicker">
|
| 1098 |
{settings.tagging_domain === "real_life"
|
| 1099 |
? "Real-life categories only (isolated from anime tags.csv). Changes auto-save after settings save."
|
| 1100 |
-
:
|
|
|
|
|
|
|
| 1101 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1102 |
<label>
|
| 1103 |
{settings.tagging_domain === "real_life"
|
| 1104 |
? "Real-life category search"
|
|
@@ -1108,9 +1196,11 @@ function App() {
|
|
| 1108 |
onChange={(e) => setTagQuery(e.target.value)}
|
| 1109 |
list="tag-match-suggestions"
|
| 1110 |
autoComplete="off"
|
|
|
|
| 1111 |
onKeyDown={async (e) => {
|
| 1112 |
if (e.key === "Enter") {
|
| 1113 |
e.preventDefault();
|
|
|
|
| 1114 |
setError("");
|
| 1115 |
try {
|
| 1116 |
await addTagsFromInput(tagQuery);
|
|
@@ -1121,9 +1211,11 @@ function App() {
|
|
| 1121 |
}
|
| 1122 |
}}
|
| 1123 |
placeholder={
|
| 1124 |
-
|
| 1125 |
-
? "
|
| 1126 |
-
:
|
|
|
|
|
|
|
| 1127 |
}
|
| 1128 |
/>
|
| 1129 |
<datalist id="tag-match-suggestions">
|
|
@@ -1132,7 +1224,7 @@ function App() {
|
|
| 1132 |
))}
|
| 1133 |
</datalist>
|
| 1134 |
</label>
|
| 1135 |
-
{tagQuery.trim() && (
|
| 1136 |
<div className="tag-suggestions">
|
| 1137 |
{tagOptions.length === 0 ? (
|
| 1138 |
<span className="muted">No matching tags</span>
|
|
@@ -1158,6 +1250,7 @@ function App() {
|
|
| 1158 |
<button
|
| 1159 |
type="button"
|
| 1160 |
className="secondary"
|
|
|
|
| 1161 |
onClick={async () => {
|
| 1162 |
setError("");
|
| 1163 |
try {
|
|
@@ -1227,20 +1320,37 @@ function App() {
|
|
| 1227 |
</>
|
| 1228 |
)}
|
| 1229 |
</p>
|
| 1230 |
-
<div className=
|
| 1231 |
{selectedTags.length === 0 ? (
|
| 1232 |
<span className="muted">No tags selected</span>
|
| 1233 |
) : (
|
| 1234 |
selectedTags.map((tag) => (
|
| 1235 |
<span key={tag} className="chip">
|
| 1236 |
{tag}
|
| 1237 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
×
|
| 1239 |
</button>
|
| 1240 |
</span>
|
| 1241 |
))
|
| 1242 |
)}
|
| 1243 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1244 |
</section>
|
| 1245 |
|
| 1246 |
{runId && runStatus && (
|
|
|
|
| 12 |
experimental_style_detector_enabled: false,
|
| 13 |
hybrid_ml_on_review: true,
|
| 14 |
tagging_domain: "drawn",
|
| 15 |
+
sfw_classify_mode: false,
|
| 16 |
selected_tags: [],
|
| 17 |
+
selected_tags_nsfw: [],
|
| 18 |
max_inference_workers: 2,
|
| 19 |
inference_batch_size: 8,
|
| 20 |
force_cpu_inference: false,
|
|
|
|
| 63 |
},
|
| 64 |
];
|
| 65 |
|
| 66 |
+
const SFW_CLASSIFY_FOLDERS = ["SFW", "scenery"];
|
| 67 |
const ACTIVE_RUN_STORAGE_KEY = "imageClassifierActiveRunId";
|
| 68 |
const VIDEO_PREVIEW_EXTS = new Set([
|
| 69 |
".mp4",
|
|
|
|
| 688 |
}
|
| 689 |
}
|
| 690 |
|
| 691 |
+
const sfwClassifyMode =
|
| 692 |
+
Boolean(settings.sfw_classify_mode) && settings.tagging_domain !== "real_life";
|
| 693 |
+
const parkedNsfwTags = Array.isArray(settings.selected_tags_nsfw)
|
| 694 |
+
? settings.selected_tags_nsfw
|
| 695 |
+
: [];
|
| 696 |
+
|
| 697 |
+
async function setSfwClassifyMode(enabled) {
|
| 698 |
+
if (settings.tagging_domain === "real_life") return;
|
| 699 |
+
setError("");
|
| 700 |
+
skipNextTagPersistRef.current = true;
|
| 701 |
+
if (enabled) {
|
| 702 |
+
const parked = selectedTags.filter((t) => !SFW_CLASSIFY_FOLDERS.includes(t));
|
| 703 |
+
const nextTags = [...SFW_CLASSIFY_FOLDERS];
|
| 704 |
+
const next = {
|
| 705 |
+
...settings,
|
| 706 |
+
sfw_classify_mode: true,
|
| 707 |
+
selected_tags: nextTags,
|
| 708 |
+
selected_tags_nsfw: parked.length ? parked : parkedNsfwTags,
|
| 709 |
+
};
|
| 710 |
+
setSelectedTags(nextTags);
|
| 711 |
+
setSettings(next);
|
| 712 |
+
try {
|
| 713 |
+
const saved = await api.saveSettings(next);
|
| 714 |
+
const merged = { ...DEFAULT_SETTINGS, ...saved };
|
| 715 |
+
setSettings(merged);
|
| 716 |
+
setSelectedTags(
|
| 717 |
+
Array.isArray(saved.selected_tags) ? saved.selected_tags : nextTags
|
| 718 |
+
);
|
| 719 |
+
setSavedSnapshot(
|
| 720 |
+
settingsSnapshot(
|
| 721 |
+
merged,
|
| 722 |
+
Array.isArray(saved.selected_tags) ? saved.selected_tags : nextTags
|
| 723 |
+
)
|
| 724 |
+
);
|
| 725 |
+
} catch (err) {
|
| 726 |
+
setError(`Failed to enable SFW mode: ${err.message}`);
|
| 727 |
+
}
|
| 728 |
+
return;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
const restored =
|
| 732 |
+
parkedNsfwTags.length > 0
|
| 733 |
+
? parkedNsfwTags
|
| 734 |
+
: selectedTags.filter((t) => !SFW_CLASSIFY_FOLDERS.includes(t));
|
| 735 |
+
const next = {
|
| 736 |
+
...settings,
|
| 737 |
+
sfw_classify_mode: false,
|
| 738 |
+
selected_tags: restored,
|
| 739 |
+
selected_tags_nsfw: restored,
|
| 740 |
+
};
|
| 741 |
+
setSelectedTags(restored);
|
| 742 |
+
setSettings(next);
|
| 743 |
+
try {
|
| 744 |
+
const saved = await api.saveSettings(next);
|
| 745 |
+
const merged = { ...DEFAULT_SETTINGS, ...saved };
|
| 746 |
+
setSettings(merged);
|
| 747 |
+
setSelectedTags(
|
| 748 |
+
Array.isArray(saved.selected_tags) ? saved.selected_tags : restored
|
| 749 |
+
);
|
| 750 |
+
setSavedSnapshot(
|
| 751 |
+
settingsSnapshot(
|
| 752 |
+
merged,
|
| 753 |
+
Array.isArray(saved.selected_tags) ? saved.selected_tags : restored
|
| 754 |
+
)
|
| 755 |
+
);
|
| 756 |
+
} catch (err) {
|
| 757 |
+
setError(`Failed to disable SFW mode: ${err.message}`);
|
| 758 |
+
}
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
function addSelectedTag(value) {
|
| 762 |
+
if (!value || sfwClassifyMode) return;
|
| 763 |
setSelectedTags((prev) => (prev.includes(value) ? prev : [...prev, value]));
|
| 764 |
}
|
| 765 |
|
|
|
|
| 805 |
}
|
| 806 |
|
| 807 |
function removeSelectedTag(value) {
|
| 808 |
+
if (sfwClassifyMode) return;
|
| 809 |
setSelectedTags((prev) => prev.filter((t) => t !== value));
|
| 810 |
}
|
| 811 |
|
|
|
|
| 1103 |
...settings,
|
| 1104 |
tagging_domain: "real_life",
|
| 1105 |
experimental_media_enabled: true,
|
| 1106 |
+
sfw_classify_mode: false,
|
| 1107 |
})
|
| 1108 |
}
|
| 1109 |
/>
|
|
|
|
| 1172 |
<span className="kicker">
|
| 1173 |
{settings.tagging_domain === "real_life"
|
| 1174 |
? "Real-life categories only (isolated from anime tags.csv). Changes auto-save after settings save."
|
| 1175 |
+
: sfwClassifyMode
|
| 1176 |
+
? "SFW mode: only SFW / scenery compete. Your NSFW tags stay parked."
|
| 1177 |
+
: "Only these tags compete for folder assignment. Changes auto-save."}
|
| 1178 |
</span>
|
| 1179 |
+
{settings.tagging_domain !== "real_life" ? (
|
| 1180 |
+
<label className="inline-check sfw-mode-toggle">
|
| 1181 |
+
<input
|
| 1182 |
+
type="checkbox"
|
| 1183 |
+
checked={sfwClassifyMode}
|
| 1184 |
+
disabled={runActive || opsLoading.startingRun}
|
| 1185 |
+
onChange={(e) => setSfwClassifyMode(e.target.checked)}
|
| 1186 |
+
/>
|
| 1187 |
+
SFW classify mode (parks NSFW tags; restore when off)
|
| 1188 |
+
</label>
|
| 1189 |
+
) : null}
|
| 1190 |
<label>
|
| 1191 |
{settings.tagging_domain === "real_life"
|
| 1192 |
? "Real-life category search"
|
|
|
|
| 1196 |
onChange={(e) => setTagQuery(e.target.value)}
|
| 1197 |
list="tag-match-suggestions"
|
| 1198 |
autoComplete="off"
|
| 1199 |
+
disabled={sfwClassifyMode}
|
| 1200 |
onKeyDown={async (e) => {
|
| 1201 |
if (e.key === "Enter") {
|
| 1202 |
e.preventDefault();
|
| 1203 |
+
if (sfwClassifyMode) return;
|
| 1204 |
setError("");
|
| 1205 |
try {
|
| 1206 |
await addTagsFromInput(tagQuery);
|
|
|
|
| 1211 |
}
|
| 1212 |
}}
|
| 1213 |
placeholder={
|
| 1214 |
+
sfwClassifyMode
|
| 1215 |
+
? "Tag editing disabled in SFW mode"
|
| 1216 |
+
: settings.tagging_domain === "real_life"
|
| 1217 |
+
? "Type creampie, BBC, hotwife…"
|
| 1218 |
+
: "Type Voyeur, loli, Pokemon…"
|
| 1219 |
}
|
| 1220 |
/>
|
| 1221 |
<datalist id="tag-match-suggestions">
|
|
|
|
| 1224 |
))}
|
| 1225 |
</datalist>
|
| 1226 |
</label>
|
| 1227 |
+
{tagQuery.trim() && !sfwClassifyMode && (
|
| 1228 |
<div className="tag-suggestions">
|
| 1229 |
{tagOptions.length === 0 ? (
|
| 1230 |
<span className="muted">No matching tags</span>
|
|
|
|
| 1250 |
<button
|
| 1251 |
type="button"
|
| 1252 |
className="secondary"
|
| 1253 |
+
disabled={sfwClassifyMode}
|
| 1254 |
onClick={async () => {
|
| 1255 |
setError("");
|
| 1256 |
try {
|
|
|
|
| 1320 |
</>
|
| 1321 |
)}
|
| 1322 |
</p>
|
| 1323 |
+
<div className={`stats${sfwClassifyMode ? " tags-locked" : ""}`}>
|
| 1324 |
{selectedTags.length === 0 ? (
|
| 1325 |
<span className="muted">No tags selected</span>
|
| 1326 |
) : (
|
| 1327 |
selectedTags.map((tag) => (
|
| 1328 |
<span key={tag} className="chip">
|
| 1329 |
{tag}
|
| 1330 |
+
<button
|
| 1331 |
+
type="button"
|
| 1332 |
+
onClick={() => removeSelectedTag(tag)}
|
| 1333 |
+
aria-label={`Remove ${tag}`}
|
| 1334 |
+
disabled={sfwClassifyMode}
|
| 1335 |
+
>
|
| 1336 |
×
|
| 1337 |
</button>
|
| 1338 |
</span>
|
| 1339 |
))
|
| 1340 |
)}
|
| 1341 |
</div>
|
| 1342 |
+
{sfwClassifyMode && parkedNsfwTags.length > 0 ? (
|
| 1343 |
+
<div className="parked-tags">
|
| 1344 |
+
<span className="help">Parked NSFW tags (inactive until SFW mode is off):</span>
|
| 1345 |
+
<div className="stats tags-locked">
|
| 1346 |
+
{parkedNsfwTags.map((tag) => (
|
| 1347 |
+
<span key={`parked-${tag}`} className="chip chip-parked">
|
| 1348 |
+
{tag}
|
| 1349 |
+
</span>
|
| 1350 |
+
))}
|
| 1351 |
+
</div>
|
| 1352 |
+
</div>
|
| 1353 |
+
) : null}
|
| 1354 |
</section>
|
| 1355 |
|
| 1356 |
{runId && runStatus && (
|
frontend/src/api.js
CHANGED
|
@@ -11,7 +11,9 @@ const defaultSettings = {
|
|
| 11 |
experimental_style_detector_enabled: false,
|
| 12 |
hybrid_ml_on_review: true,
|
| 13 |
tagging_domain: "drawn",
|
|
|
|
| 14 |
selected_tags: [],
|
|
|
|
| 15 |
max_inference_workers: 2,
|
| 16 |
inference_batch_size: 8,
|
| 17 |
force_cpu_inference: false,
|
|
@@ -320,7 +322,17 @@ function mockRequest(path, options = {}) {
|
|
| 320 |
return Promise.resolve({ items, count: items.length });
|
| 321 |
}
|
| 322 |
if (path === "/settings" && method === "PUT") {
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
persistMockState();
|
| 325 |
return Promise.resolve(mockState.settings);
|
| 326 |
}
|
|
|
|
| 11 |
experimental_style_detector_enabled: false,
|
| 12 |
hybrid_ml_on_review: true,
|
| 13 |
tagging_domain: "drawn",
|
| 14 |
+
sfw_classify_mode: false,
|
| 15 |
selected_tags: [],
|
| 16 |
+
selected_tags_nsfw: [],
|
| 17 |
max_inference_workers: 2,
|
| 18 |
inference_batch_size: 8,
|
| 19 |
force_cpu_inference: false,
|
|
|
|
| 322 |
return Promise.resolve({ items, count: items.length });
|
| 323 |
}
|
| 324 |
if (path === "/settings" && method === "PUT") {
|
| 325 |
+
const next = { ...defaultSettings, ...mockState.settings, ...body };
|
| 326 |
+
if (next.tagging_domain === "real_life") {
|
| 327 |
+
next.sfw_classify_mode = false;
|
| 328 |
+
}
|
| 329 |
+
if (next.sfw_classify_mode) {
|
| 330 |
+
next.selected_tags = ["SFW", "scenery"];
|
| 331 |
+
}
|
| 332 |
+
if (!Array.isArray(next.selected_tags_nsfw)) {
|
| 333 |
+
next.selected_tags_nsfw = [];
|
| 334 |
+
}
|
| 335 |
+
mockState.settings = next;
|
| 336 |
persistMockState();
|
| 337 |
return Promise.resolve(mockState.settings);
|
| 338 |
}
|
frontend/src/styles.css
CHANGED
|
@@ -453,6 +453,31 @@ button.danger {
|
|
| 453 |
font-weight: 700;
|
| 454 |
}
|
| 455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
.muted {
|
| 457 |
color: var(--muted);
|
| 458 |
font-size: 13px;
|
|
|
|
| 453 |
font-weight: 700;
|
| 454 |
}
|
| 455 |
|
| 456 |
+
.chip button:disabled {
|
| 457 |
+
opacity: 0.35;
|
| 458 |
+
cursor: not-allowed;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.sfw-mode-toggle {
|
| 462 |
+
margin: 8px 0 12px;
|
| 463 |
+
font-weight: 600;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.tags-locked .chip {
|
| 467 |
+
opacity: 0.72;
|
| 468 |
+
filter: grayscale(0.35);
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.chip-parked {
|
| 472 |
+
background: #f1f5f9;
|
| 473 |
+
border-color: #cbd5e1;
|
| 474 |
+
color: #64748b;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.parked-tags {
|
| 478 |
+
margin-top: 10px;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
.muted {
|
| 482 |
color: var(--muted);
|
| 483 |
font-size: 13px;
|