Dinamush commited on
Commit
ee170ea
·
1 Parent(s): 5c7d5ce

feat: implement SFW classify mode and NSFW tag management

Browse files

Add 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 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
- for tag in payload.selected_tags:
2195
- value = tag.strip()
2196
- if not value:
2197
- continue
2198
- if tagging_domain == "real_life":
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
- continue
2203
- tax = resolve_taxonomy_folder(value)
2204
- if tax is not None:
2205
- if tax.folder not in cleaned_tags:
2206
- cleaned_tags.append(tax.folder)
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 = ?, max_inference_workers = ?, inference_batch_size = ?,
 
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
- : "Only these tags compete for folder assignment. Changes auto-save."}
 
 
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
- settings.tagging_domain === "real_life"
1125
- ? "Type creampie, BBC, hotwife…"
1126
- : "Type Voyeur, loli, Pokemon…"
 
 
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="stats">
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 type="button" onClick={() => removeSelectedTag(tag)} aria-label={`Remove ${tag}`}>
 
 
 
 
 
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
- mockState.settings = { ...defaultSettings, ...body };
 
 
 
 
 
 
 
 
 
 
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;