nai-aclab 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,8 @@ API 토큰을 아직 넣고 싶지 않다면 `목업 모드 사용`을 켜둔
68
68
 
69
69
  대부분의 사용자는 `Endpoint`, `Model`, `Sampler`, `Noise Schedule` 같은 값은 기본값 그대로 두면 됩니다.
70
70
 
71
+ ![API 설정 메뉴](docs/screenshots/07-api-settings.png)
72
+
71
73
  ## 기본 사용 순서
72
74
 
73
75
  ### 1. 작가 태그 등록
@@ -91,6 +93,8 @@ artist:sample_a
91
93
 
92
94
  `선택 태그 수`를 비워두면 그 카테고리에 적은 작가 태그를 모두 사용합니다. 일부만 랜덤으로 뽑고 싶을 때만 숫자를 입력하면 됩니다.
93
95
 
96
+ ![작가 태그 메뉴](docs/screenshots/02-artists.png)
97
+
94
98
  ### 2. 프리셋 만들기
95
99
 
96
100
  `프리셋` 메뉴에서 자주 쓸 프롬프트를 저장합니다.
@@ -109,6 +113,8 @@ artist:sample_a
109
113
 
110
114
  NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지 않고 `|`로 나눕니다.
111
115
 
116
+ ![프리셋 메뉴](docs/screenshots/03-presets.png)
117
+
112
118
  ### 3. 이미지 생성
113
119
 
114
120
  `생성` 메뉴에서 사용할 프리셋과 이미지 크기를 고른 뒤 `이미지 생성`을 누릅니다.
@@ -119,6 +125,8 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
119
125
 
120
126
  고정된 가중치를 해제하고 싶으면 생성 메뉴에서 `고정 해제`를 누르면 됩니다.
121
127
 
128
+ ![생성 메뉴](docs/screenshots/01-generate.png)
129
+
122
130
  ## 주요 메뉴 설명
123
131
 
124
132
  ### 생성
@@ -161,12 +169,16 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
161
169
  - 히스토리 선택 삭제 또는 전체 삭제
162
170
  - 이상형 월드컵 실행
163
171
 
172
+ ![히스토리 메뉴](docs/screenshots/05-history.png)
173
+
164
174
  ### 가중치 비교
165
175
 
166
176
  한 히스토리 안에서 생성된 이미지들을 옆으로 훑어보며, 각 이미지에 어떤 작가 가중치가 쓰였는지 비교하는 화면입니다.
167
177
 
168
178
  여기서도 마음에 드는 이미지의 `가중치 불러오기`를 누를 수 있습니다.
169
179
 
180
+ ![가중치 비교 메뉴](docs/screenshots/06-weight-compare.png)
181
+
170
182
  ### 이상형 월드컵
171
183
 
172
184
  히스토리 이미지 중에서 두 장씩 비교하며 가장 마음에 드는 이미지를 고르는 기능입니다.
@@ -193,6 +205,8 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
193
205
 
194
206
  생성 중 멈추고 싶으면 `생성 중지`를 누르면 됩니다. 현재 처리 중인 이미지가 끝난 뒤 남은 생성을 멈춥니다.
195
207
 
208
+ ![타율 테스트 메뉴](docs/screenshots/04-batting-test.png)
209
+
196
210
  ## 이미지 크기
197
211
 
198
212
  생성 메뉴에서 아래 크기 중 하나를 고를 수 있습니다.
@@ -205,9 +219,16 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
205
219
 
206
220
  앱 설정과 생성 이미지는 내 컴퓨터에 저장됩니다.
207
221
 
208
- - Windows: `%APPDATA%\NAI Artist Combination Lab`
209
- - macOS: `~/Library/Application Support/NAI Artist Combination Lab`
210
- - Linux: `~/.local/share/nai-artist-combination-lab`
222
+ 기본적으로 폴더 아래에 `nai_aclab` 폴더를 만들고, 그 안에 설정과 생성 이미지를 함께 저장합니다.
223
+
224
+ - Windows: `C:\Users\사용자이름\nai_aclab`
225
+ - macOS: `~/nai_aclab`
226
+ - Linux: `~/nai_aclab`
227
+
228
+ 폴더 안의 구조는 아래처럼 나뉩니다.
229
+
230
+ - `data`: 앱 설정과 API 토큰 secret 파일
231
+ - `outputs`: 생성된 이미지
211
232
 
212
233
  API 토큰은 일반 설정 파일에 저장하지 않고 별도 secret store에 저장합니다. 앱 화면으로 다시 내려보내지도 않습니다.
213
234
 
package/app.py CHANGED
@@ -23,12 +23,16 @@ except Exception:
23
23
 
24
24
 
25
25
  APP_DIR = Path(__file__).resolve().parent
26
- USER_DIR = Path(
27
- os.environ.get(
28
- "NAI_ARTIST_LAB_USER_DIR",
29
- str(APP_DIR),
30
- )
31
- )
26
+
27
+
28
+ def default_user_dir() -> Path:
29
+ configured = os.environ.get("NAI_ARTIST_LAB_USER_DIR", "").strip()
30
+ if configured:
31
+ return Path(configured).expanduser()
32
+ return Path.home() / "nai_aclab"
33
+
34
+
35
+ USER_DIR = default_user_dir()
32
36
  DATA_DIR = USER_DIR / "data"
33
37
  OUTPUT_DIR = USER_DIR / "outputs"
34
38
  STATE_PATH = DATA_DIR / "app_state.json"
@@ -141,7 +145,8 @@ def output_ref(path_value: str) -> str:
141
145
  path = Path(raw)
142
146
  try:
143
147
  if path.is_absolute():
144
- return str(path.resolve().relative_to(OUTPUT_DIR.resolve())).replace("\\", "/")
148
+ resolved = path.resolve()
149
+ return str(resolved.relative_to(OUTPUT_DIR.resolve())).replace("\\", "/")
145
150
  except (OSError, ValueError):
146
151
  return path.name
147
152
  return raw.replace("\\", "/")
@@ -14,18 +14,7 @@ function userDataDir() {
14
14
  return process.env.NAI_ARTIST_LAB_USER_DIR;
15
15
  }
16
16
 
17
- if (process.platform === "win32") {
18
- return path.join(process.env.APPDATA || os.homedir(), "NAI Artist Combination Lab");
19
- }
20
-
21
- if (process.platform === "darwin") {
22
- return path.join(os.homedir(), "Library", "Application Support", "NAI Artist Combination Lab");
23
- }
24
-
25
- return path.join(
26
- process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
27
- "nai-artist-combination-lab"
28
- );
17
+ return path.join(os.homedir(), "nai_aclab");
29
18
  }
30
19
 
31
20
  function printHelp() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nai-aclab",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Local web UI for experimenting with NovelAI artist tag weight combinations.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/web/index.html CHANGED
@@ -18,16 +18,18 @@
18
18
  </div>
19
19
  <nav class="nav" aria-label="Main">
20
20
  <button class="nav-item active" data-tab="generate">⌘ 생성</button>
21
- <button class="nav-item" data-tab="batting">▦ 타율 테스트</button>
22
- <button class="nav-item" data-tab="artists"># 작가 태그</button>
23
- <button class="nav-item" data-tab="presets">▣ 프리셋</button>
24
- <button class="nav-item" data-tab="settings">⚙ API 설정</button>
25
- <button class="nav-item" data-tab="compare">≋ 가중치 비교</button>
26
- <button class="nav-item" data-tab="history">◈ 히스토리</button>
27
- </nav>
28
- <div class="sidebar-footer">
29
- <span id="saveState">준비됨</span>
30
- </div>
21
+ <button class="nav-item" data-tab="batting">▦ 타율 테스트</button>
22
+ <button class="nav-item" data-tab="artists"># 작가 태그</button>
23
+ <button class="nav-item" data-tab="presets">▣ 프리셋</button>
24
+ <button class="nav-item" data-tab="compare">≋ 가중치 비교</button>
25
+ <button class="nav-item" data-tab="history">◈ 히스토리</button>
26
+ </nav>
27
+ <div class="sidebar-tools">
28
+ <button class="nav-item settings-nav-item" data-tab="settings">⚙ API 설정</button>
29
+ </div>
30
+ <div class="sidebar-footer">
31
+ <span id="saveState">준비됨</span>
32
+ </div>
31
33
  </aside>
32
34
 
33
35
  <main class="workspace">
package/web/main.js CHANGED
@@ -655,15 +655,30 @@ function renderHistory() {
655
655
  const history = state.history[historyIndex];
656
656
  const detail = $("#historyDetail");
657
657
  detail.innerHTML = "";
658
- if (!history) {
659
- detail.innerHTML = `<p>아직 생성 히스토리가 없습니다.</p>`;
660
- return;
661
- }
662
- const modalItems = (history.items || [])
663
- .map((item, index) => enrichedImageItem(item, { history, label: `#${index + 1}` }))
664
- .filter((item) => item.image_url);
665
- (history.items || []).forEach((item, index) => {
666
- detail.appendChild(renderImageCard(item, { history, label: `#${index + 1}`, modalItems }));
658
+ if (!history) {
659
+ detail.innerHTML = `<p>아직 생성 히스토리가 없습니다.</p>`;
660
+ return;
661
+ }
662
+ const actions = document.createElement("div");
663
+ actions.className = "history-detail-actions";
664
+ const info = document.createElement("div");
665
+ const title = document.createElement("strong");
666
+ title.textContent = `${history.base_preset || ""} + ${history.character_preset || ""}`;
667
+ const meta = document.createElement("span");
668
+ meta.textContent = `${history.created_at || ""} · ${(history.items || []).length}장`;
669
+ info.append(title, meta);
670
+ const openButton = document.createElement("button");
671
+ openButton.className = "ghost-button";
672
+ openButton.type = "button";
673
+ openButton.textContent = "저장 폴더 열기";
674
+ openButton.onclick = () => openHistoryFolder(history);
675
+ actions.append(info, openButton);
676
+ detail.appendChild(actions);
677
+ const modalItems = (history.items || [])
678
+ .map((item, index) => enrichedImageItem(item, { history, label: `#${index + 1}` }))
679
+ .filter((item) => item.image_url);
680
+ (history.items || []).forEach((item, index) => {
681
+ detail.appendChild(renderImageCard(item, { history, label: `#${index + 1}`, modalItems }));
667
682
  });
668
683
  }
669
684
 
@@ -1171,7 +1186,7 @@ async function deleteSelectedHistory() {
1171
1186
  setSaveState("히스토리 삭제됨");
1172
1187
  }
1173
1188
 
1174
- async function clearHistory() {
1189
+ async function clearHistory() {
1175
1190
  if (!state.history.length) return;
1176
1191
  const deleteFiles = $("#deleteFilesToggle").checked;
1177
1192
  const ok = window.confirm(`전체 히스토리 ${state.history.length}개를 삭제할까요?${deleteFiles ? " 출력 파일도 함께 삭제됩니다." : ""}`);
@@ -1186,9 +1201,22 @@ async function clearHistory() {
1186
1201
  renderHistory();
1187
1202
  renderCompare();
1188
1203
  setSaveState("히스토리 전체 삭제됨");
1189
- }
1190
-
1191
- function selectAllHistory() {
1204
+ }
1205
+
1206
+ async function openHistoryFolder(history) {
1207
+ if (!history?.id) return;
1208
+ try {
1209
+ await request("/api/history/open", {
1210
+ method: "POST",
1211
+ body: JSON.stringify({ id: history.id }),
1212
+ });
1213
+ setSaveState("저장 폴더를 열었습니다");
1214
+ } catch {
1215
+ setSaveState("저장 폴더를 찾을 수 없습니다");
1216
+ }
1217
+ }
1218
+
1219
+ function selectAllHistory() {
1192
1220
  const allSelected = state.history.length > 0 && state.history.every((item) => selectedHistoryIds.has(item.id));
1193
1221
  selectedHistoryIds = allSelected ? new Set() : new Set(state.history.map((item) => item.id));
1194
1222
  renderHistory();
package/web/styles.css CHANGED
@@ -111,14 +111,44 @@ button {
111
111
  border-color: var(--line);
112
112
  }
113
113
 
114
- .sidebar-footer {
114
+ .sidebar-tools {
115
115
  margin-top: auto;
116
- color: var(--muted);
116
+ display: grid;
117
+ gap: 8px;
118
+ }
119
+
120
+ .settings-nav-item {
121
+ border-color: rgba(94, 224, 184, 0.22);
122
+ background: rgba(94, 224, 184, 0.06);
123
+ }
124
+
125
+ .sidebar-footer {
126
+ color: #dce8e4;
117
127
  font-size: 12px;
118
- padding: 12px;
119
- border: 1px solid var(--line-soft);
128
+ font-weight: 650;
129
+ padding: 12px 12px 12px 14px;
130
+ border: 1px solid rgba(94, 224, 184, 0.24);
131
+ border-left: 3px solid var(--accent);
120
132
  border-radius: 8px;
121
- background: rgba(255, 255, 255, 0.025);
133
+ background:
134
+ linear-gradient(135deg, rgba(94, 224, 184, 0.14), rgba(140, 182, 255, 0.08)),
135
+ rgba(20, 24, 28, 0.82);
136
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24);
137
+ }
138
+
139
+ #saveState {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 8px;
143
+ }
144
+
145
+ #saveState::before {
146
+ content: "";
147
+ width: 7px;
148
+ height: 7px;
149
+ border-radius: 999px;
150
+ background: var(--accent);
151
+ box-shadow: 0 0 14px rgba(94, 224, 184, 0.72);
122
152
  }
123
153
 
124
154
  .workspace {
@@ -690,6 +720,29 @@ pre {
690
720
  gap: 12px;
691
721
  }
692
722
 
723
+ .history-detail-actions {
724
+ grid-column: 1 / -1;
725
+ display: flex;
726
+ justify-content: space-between;
727
+ align-items: center;
728
+ gap: 14px;
729
+ padding: 14px;
730
+ border: 1px solid var(--line-soft);
731
+ border-radius: 8px;
732
+ background: rgba(255, 255, 255, 0.025);
733
+ }
734
+
735
+ .history-detail-actions strong,
736
+ .history-detail-actions span {
737
+ display: block;
738
+ }
739
+
740
+ .history-detail-actions span {
741
+ margin-top: 4px;
742
+ color: var(--muted);
743
+ font-size: 12px;
744
+ }
745
+
693
746
  .live-gallery {
694
747
  display: grid;
695
748
  grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
package/web_app.py CHANGED
@@ -2,9 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import mimetypes
5
+ import os
5
6
  import random
6
7
  import secrets
7
8
  import shutil
9
+ import subprocess
8
10
  import sys
9
11
  import threading
10
12
  import time
@@ -80,17 +82,35 @@ def state_from_dict(data: dict) -> AppState:
80
82
 
81
83
 
82
84
  def media_url(path: str) -> str:
85
+ resolved = resolve_output_path(path)
86
+ if not resolved:
87
+ return ""
83
88
  try:
84
- raw = str(path or "")
85
- candidate = Path(raw)
86
- root = OUTPUT_DIR.resolve()
87
- resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
88
- rel = resolved.relative_to(root)
89
+ rel = resolved.relative_to(OUTPUT_DIR.resolve())
89
90
  except (ValueError, OSError):
90
91
  return ""
91
92
  return "/media/" + urllib.parse.quote(str(rel).replace("\\", "/"))
92
93
 
93
94
 
95
+ def resolve_output_path(path: str, expect_dir: bool = False) -> Path | None:
96
+ raw = str(path or "").strip()
97
+ if not raw:
98
+ return None
99
+ candidate = Path(raw)
100
+ try:
101
+ if candidate.is_absolute():
102
+ resolved = candidate.resolve()
103
+ resolved.relative_to(OUTPUT_DIR.resolve())
104
+ return resolved
105
+ resolved = (OUTPUT_DIR / candidate).resolve()
106
+ resolved.relative_to(OUTPUT_DIR.resolve())
107
+ if resolved.exists() or not expect_dir:
108
+ return resolved
109
+ except (ValueError, OSError):
110
+ return None
111
+ return None
112
+
113
+
94
114
  def safe_child_path(root: Path, rel: str) -> Path | None:
95
115
  try:
96
116
  root_resolved = root.resolve()
@@ -101,6 +121,15 @@ def safe_child_path(root: Path, rel: str) -> Path | None:
101
121
  return None
102
122
 
103
123
 
124
+ def open_in_file_manager(path: Path) -> None:
125
+ if os.name == "nt":
126
+ os.startfile(path) # type: ignore[attr-defined]
127
+ elif sys.platform == "darwin":
128
+ subprocess.Popen(["open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
129
+ else:
130
+ subprocess.Popen(["xdg-open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
131
+
132
+
104
133
  def state_payload(state: AppState) -> dict:
105
134
  data = asdict(state)
106
135
  data.setdefault("api", {})["token"] = ""
@@ -236,10 +265,8 @@ def delete_history_entries(ids: list[str], delete_files: bool) -> AppState:
236
265
  raw_dir = history.get("output_dir", "")
237
266
  if not raw_dir:
238
267
  continue
239
- try:
240
- raw_path = Path(raw_dir)
241
- target = raw_path.resolve() if raw_path.is_absolute() else (OUTPUT_DIR / raw_path).resolve()
242
- except OSError:
268
+ target = resolve_output_path(raw_dir, expect_dir=True)
269
+ if not target:
243
270
  continue
244
271
  if target == output_root or output_root not in target.parents:
245
272
  continue
@@ -248,6 +275,11 @@ def delete_history_entries(ids: list[str], delete_files: bool) -> AppState:
248
275
  return load_state()
249
276
 
250
277
 
278
+ def history_by_id(history_id: str) -> dict | None:
279
+ state = load_state()
280
+ return next((item for item in state.history if item.get("id") == history_id), None)
281
+
282
+
251
283
  def clear_history(delete_files: bool) -> AppState:
252
284
  with STATE_LOCK:
253
285
  state = load_state()
@@ -561,6 +593,14 @@ class Handler(BaseHTTPRequestHandler):
561
593
  elif route == "/api/history/clear":
562
594
  state = clear_history(bool(data.get("delete_files", False)))
563
595
  self.send_json({"state": state_payload(state)})
596
+ elif route == "/api/history/open":
597
+ history = history_by_id(str(data.get("id", "")))
598
+ target = resolve_output_path((history or {}).get("output_dir", ""), expect_dir=True)
599
+ if not history or not target or not target.exists() or not target.is_dir():
600
+ self.send_json({"error": "저장 폴더를 찾을 수 없습니다."}, status=404)
601
+ return
602
+ open_in_file_manager(target)
603
+ self.send_json({"ok": True})
564
604
  else:
565
605
  self.send_error(404)
566
606