nai-aclab 1.0.0 → 1.0.3
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 +9 -27
- package/app.py +145 -11
- package/launcher.py +5 -15
- package/package.json +1 -1
- package/web/index.html +0 -4
- package/web/main.js +15 -3
- package/web_app.py +25 -17
package/README.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# NAI Artist Combination Lab
|
|
2
2
|
|
|
3
|
-
NovelAI 이미지 생성에서 작가 태그 조합과 가중치를 실험하기 위한
|
|
3
|
+
NovelAI 이미지 생성에서 작가 태그 조합과 가중치를 실험하기 위한 브라우저 기반 웹앱입니다.
|
|
4
4
|
작가 태그의 가중치를 랜덤하게 바꿔 여러 이미지를 만들고, 마음에 드는 결과에서 작가 가중치를 다시 불러와 고정한 뒤 여러 프리셋 조합을 테스트할 수 있습니다.
|
|
5
5
|
|
|
6
6
|
## 설치와 실행
|
|
7
7
|
|
|
8
|
-
### npm으로 실행
|
|
9
|
-
|
|
10
8
|
필요한 것:
|
|
11
9
|
|
|
12
10
|
- Node.js 18 이상
|
|
@@ -14,34 +12,19 @@ NovelAI 이미지 생성에서 작가 태그 조합과 가중치를 실험하기
|
|
|
14
12
|
|
|
15
13
|
실행 방법:
|
|
16
14
|
|
|
17
|
-
```powershell
|
|
18
|
-
npm start
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
GitHub에서 처음 받은 뒤에는 프로젝트 폴더에서 위 명령을 실행하면 됩니다. 브라우저가 자동으로 열리고, 앱 사용을 마친 뒤 안내 창을 닫으면 서버가 종료됩니다.
|
|
22
|
-
|
|
23
|
-
npm 패키지로 공개한 뒤에는 아래처럼 바로 실행할 수 있습니다.
|
|
24
|
-
|
|
25
15
|
```powershell
|
|
26
16
|
npx nai-aclab
|
|
27
17
|
```
|
|
28
18
|
|
|
29
|
-
|
|
19
|
+
브라우저가 자동으로 열리고, 터미널이 열려 있는 동안 앱 서버가 유지됩니다. 앱을 종료하려면 터미널에서 `Ctrl+C`를 누르거나 터미널을 닫으면 됩니다.
|
|
30
20
|
|
|
31
|
-
|
|
21
|
+
자주 사용한다면 전역 설치 후 실행할 수도 있습니다.
|
|
32
22
|
|
|
33
23
|
```powershell
|
|
34
|
-
|
|
24
|
+
npm install -g nai-aclab
|
|
25
|
+
nai-aclab
|
|
35
26
|
```
|
|
36
27
|
|
|
37
|
-
브라우저 서버만 직접 실행하려면 아래 명령을 사용합니다.
|
|
38
|
-
|
|
39
|
-
```powershell
|
|
40
|
-
python web_app.py
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
기본 주소는 `http://127.0.0.1:8765`입니다.
|
|
44
|
-
|
|
45
28
|
## 기본 사용 흐름
|
|
46
29
|
|
|
47
30
|
1. `API 설정` 메뉴에서 NovelAI 토큰과 모델 설정을 입력합니다.
|
|
@@ -138,14 +121,13 @@ override가 입력되어 있으면 모든 프리셋 조합에서 override 값을
|
|
|
138
121
|
|
|
139
122
|
토큰을 넣기 전에는 목업 모드로 UI 흐름을 먼저 테스트할 수 있습니다.
|
|
140
123
|
|
|
141
|
-
|
|
124
|
+
API 토큰은 `app_state.json`에 저장하거나 `/api/state` 응답으로 다시 내려보내지 않습니다. 한 번 입력한 토큰은 별도 secret store에 저장되어 다음 실행 때 자동으로 사용됩니다.
|
|
142
125
|
|
|
143
|
-
|
|
126
|
+
생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, UC 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다. 이전 버전에서 생성된 `*_request.json` 파일은 앱 시작 시 자동으로 제거됩니다.
|
|
144
127
|
|
|
145
|
-
|
|
146
|
-
- 생성 이미지와 요청 JSON: `outputs/`
|
|
128
|
+
## 저장 위치
|
|
147
129
|
|
|
148
|
-
|
|
130
|
+
앱 상태, 생성 이미지, 요청 JSON은 아래 위치에 저장됩니다.
|
|
149
131
|
|
|
150
132
|
- Windows: `%APPDATA%\NAI Artist Combination Lab`
|
|
151
133
|
- macOS: `~/Library/Application Support/NAI Artist Combination Lab`
|
package/app.py
CHANGED
|
@@ -32,6 +32,7 @@ USER_DIR = Path(
|
|
|
32
32
|
DATA_DIR = USER_DIR / "data"
|
|
33
33
|
OUTPUT_DIR = USER_DIR / "outputs"
|
|
34
34
|
STATE_PATH = DATA_DIR / "app_state.json"
|
|
35
|
+
TOKEN_SECRET_PATH = DATA_DIR / ("api_token.dpapi" if os.name == "nt" else "api_token.secret")
|
|
35
36
|
DEFAULT_USER_AGENT = (
|
|
36
37
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
37
38
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
@@ -44,6 +45,127 @@ def ensure_dirs() -> None:
|
|
|
44
45
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _windows_protect(data: bytes) -> bytes:
|
|
49
|
+
import ctypes
|
|
50
|
+
from ctypes import wintypes
|
|
51
|
+
|
|
52
|
+
class DataBlob(ctypes.Structure):
|
|
53
|
+
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
|
|
54
|
+
|
|
55
|
+
crypt32 = ctypes.windll.crypt32
|
|
56
|
+
kernel32 = ctypes.windll.kernel32
|
|
57
|
+
buffer = ctypes.create_string_buffer(data)
|
|
58
|
+
in_blob = DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
|
|
59
|
+
out_blob = DataBlob()
|
|
60
|
+
if not crypt32.CryptProtectData(ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob)):
|
|
61
|
+
raise OSError("Failed to protect API token with Windows DPAPI.")
|
|
62
|
+
try:
|
|
63
|
+
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
|
64
|
+
finally:
|
|
65
|
+
kernel32.LocalFree(out_blob.pbData)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _windows_unprotect(data: bytes) -> bytes:
|
|
69
|
+
import ctypes
|
|
70
|
+
from ctypes import wintypes
|
|
71
|
+
|
|
72
|
+
class DataBlob(ctypes.Structure):
|
|
73
|
+
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
|
|
74
|
+
|
|
75
|
+
crypt32 = ctypes.windll.crypt32
|
|
76
|
+
kernel32 = ctypes.windll.kernel32
|
|
77
|
+
buffer = ctypes.create_string_buffer(data)
|
|
78
|
+
in_blob = DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
|
|
79
|
+
out_blob = DataBlob()
|
|
80
|
+
if not crypt32.CryptUnprotectData(ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob)):
|
|
81
|
+
raise OSError("Failed to unprotect API token with Windows DPAPI.")
|
|
82
|
+
try:
|
|
83
|
+
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
|
84
|
+
finally:
|
|
85
|
+
kernel32.LocalFree(out_blob.pbData)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def save_api_token(token: str) -> None:
|
|
89
|
+
token = token.strip()
|
|
90
|
+
if not token:
|
|
91
|
+
return
|
|
92
|
+
ensure_dirs()
|
|
93
|
+
raw = token.encode("utf-8")
|
|
94
|
+
payload = _windows_protect(raw) if os.name == "nt" else raw
|
|
95
|
+
TOKEN_SECRET_PATH.write_bytes(payload)
|
|
96
|
+
if os.name != "nt":
|
|
97
|
+
try:
|
|
98
|
+
TOKEN_SECRET_PATH.chmod(0o600)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_api_token() -> str:
|
|
104
|
+
try:
|
|
105
|
+
payload = TOKEN_SECRET_PATH.read_bytes()
|
|
106
|
+
except OSError:
|
|
107
|
+
return ""
|
|
108
|
+
try:
|
|
109
|
+
raw = _windows_unprotect(payload) if os.name == "nt" else payload
|
|
110
|
+
return raw.decode("utf-8")
|
|
111
|
+
except Exception:
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def has_saved_api_token() -> bool:
|
|
116
|
+
return bool(load_api_token().strip())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cleanup_saved_request_jsons() -> None:
|
|
120
|
+
try:
|
|
121
|
+
for path in OUTPUT_DIR.rglob("*_request.json"):
|
|
122
|
+
if path.is_file():
|
|
123
|
+
path.unlink()
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def state_dict_without_secrets(state: AppState | dict) -> dict:
|
|
129
|
+
data = asdict(state) if not isinstance(state, dict) else dict(state)
|
|
130
|
+
api = dict(data.get("api", {}) or {})
|
|
131
|
+
api["token"] = ""
|
|
132
|
+
data["api"] = api
|
|
133
|
+
data["history"] = [sanitize_history_entry(item) for item in data.get("history", [])]
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def output_ref(path_value: str) -> str:
|
|
138
|
+
raw = str(path_value or "").strip()
|
|
139
|
+
if not raw:
|
|
140
|
+
return ""
|
|
141
|
+
path = Path(raw)
|
|
142
|
+
try:
|
|
143
|
+
if path.is_absolute():
|
|
144
|
+
return str(path.resolve().relative_to(OUTPUT_DIR.resolve())).replace("\\", "/")
|
|
145
|
+
except (OSError, ValueError):
|
|
146
|
+
return path.name
|
|
147
|
+
return raw.replace("\\", "/")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def sanitize_history_item(item: dict) -> dict:
|
|
151
|
+
clean = {
|
|
152
|
+
key: value
|
|
153
|
+
for key, value in dict(item or {}).items()
|
|
154
|
+
if key not in {"prompt", "negative_prompt", "uc_prompt", "request_path", "request_url"}
|
|
155
|
+
}
|
|
156
|
+
if "path" in clean:
|
|
157
|
+
clean["path"] = output_ref(clean.get("path", ""))
|
|
158
|
+
return clean
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def sanitize_history_entry(history: dict) -> dict:
|
|
162
|
+
clean = dict(history or {})
|
|
163
|
+
if "output_dir" in clean:
|
|
164
|
+
clean["output_dir"] = output_ref(clean.get("output_dir", ""))
|
|
165
|
+
clean["items"] = [sanitize_history_item(item) for item in clean.get("items", [])]
|
|
166
|
+
return clean
|
|
167
|
+
|
|
168
|
+
|
|
47
169
|
def now_id() -> str:
|
|
48
170
|
return time.strftime("%Y%m%d_%H%M%S")
|
|
49
171
|
|
|
@@ -223,8 +345,11 @@ def default_state() -> AppState:
|
|
|
223
345
|
|
|
224
346
|
def load_state() -> AppState:
|
|
225
347
|
ensure_dirs()
|
|
348
|
+
cleanup_saved_request_jsons()
|
|
226
349
|
if not STATE_PATH.exists():
|
|
227
|
-
|
|
350
|
+
state = default_state()
|
|
351
|
+
state.api.token = load_api_token()
|
|
352
|
+
return state
|
|
228
353
|
data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
|
229
354
|
base_data = data.get("base_presets", [])
|
|
230
355
|
quality_override = data.get("quality_override_prompt", "")
|
|
@@ -237,9 +362,24 @@ def load_state() -> AppState:
|
|
|
237
362
|
else next((item for item in base_data if item.get("quality_override_prompt")), None)
|
|
238
363
|
)
|
|
239
364
|
quality_override = (fallback_base or {}).get("quality_override_prompt", "")
|
|
240
|
-
api_data = data.get("api", {})
|
|
365
|
+
api_data = dict(data.get("api", {}) or {})
|
|
366
|
+
legacy_token = str(api_data.pop("token", "") or "").strip()
|
|
367
|
+
if legacy_token and not has_saved_api_token():
|
|
368
|
+
save_api_token(legacy_token)
|
|
369
|
+
data["api"] = api_data
|
|
370
|
+
STATE_PATH.write_text(
|
|
371
|
+
json.dumps(state_dict_without_secrets(data), ensure_ascii=False, indent=2),
|
|
372
|
+
encoding="utf-8",
|
|
373
|
+
)
|
|
241
374
|
if api_data.get("user_agent") in (None, "", "NAIArtistCombination/0.1"):
|
|
242
375
|
api_data["user_agent"] = DEFAULT_USER_AGENT
|
|
376
|
+
sanitized_data = state_dict_without_secrets(data)
|
|
377
|
+
if sanitized_data != data:
|
|
378
|
+
STATE_PATH.write_text(
|
|
379
|
+
json.dumps(sanitized_data, ensure_ascii=False, indent=2),
|
|
380
|
+
encoding="utf-8",
|
|
381
|
+
)
|
|
382
|
+
api_data["token"] = load_api_token()
|
|
243
383
|
return AppState(
|
|
244
384
|
categories=[Category(**item) for item in data.get("categories", [])],
|
|
245
385
|
base_presets=[PromptPreset(**item) for item in base_data],
|
|
@@ -250,14 +390,14 @@ def load_state() -> AppState:
|
|
|
250
390
|
api=ApiSettings(**api_data),
|
|
251
391
|
generation=GenerationSettings(**data.get("generation", {})),
|
|
252
392
|
batting_scenes=[BattingScene(**item) for item in data.get("batting_scenes", [])],
|
|
253
|
-
history=data.get("history", []),
|
|
393
|
+
history=[sanitize_history_entry(item) for item in data.get("history", [])],
|
|
254
394
|
)
|
|
255
395
|
|
|
256
396
|
|
|
257
397
|
def save_state(state: AppState) -> None:
|
|
258
398
|
ensure_dirs()
|
|
259
399
|
STATE_PATH.write_text(
|
|
260
|
-
json.dumps(
|
|
400
|
+
json.dumps(state_dict_without_secrets(state), ensure_ascii=False, indent=2),
|
|
261
401
|
encoding="utf-8",
|
|
262
402
|
)
|
|
263
403
|
|
|
@@ -334,8 +474,6 @@ class NovelAIClient:
|
|
|
334
474
|
return
|
|
335
475
|
|
|
336
476
|
payload = self.build_payload(prompt, negative_prompt, uc_prompt)
|
|
337
|
-
request_path = output_path.with_name(output_path.stem + "_request.json")
|
|
338
|
-
request_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
339
477
|
req = urllib.request.Request(
|
|
340
478
|
self.settings.endpoint,
|
|
341
479
|
data=json.dumps(payload).encode("utf-8"),
|
|
@@ -1133,11 +1271,7 @@ class App(tk.Tk if tk else object):
|
|
|
1133
1271
|
prompt, negative, uc_prompt, artists = self.build_prompt()
|
|
1134
1272
|
path = out_dir / f"image_{idx + 1:03}.png"
|
|
1135
1273
|
metadata = {
|
|
1136
|
-
"path": str(path),
|
|
1137
|
-
"request_path": str(path.with_name(path.stem + "_request.json")),
|
|
1138
|
-
"prompt": prompt,
|
|
1139
|
-
"negative_prompt": negative,
|
|
1140
|
-
"uc_prompt": uc_prompt,
|
|
1274
|
+
"path": output_ref(str(path)),
|
|
1141
1275
|
"artists": artists,
|
|
1142
1276
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
1143
1277
|
}
|
package/launcher.py
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import socket
|
|
4
|
-
import threading
|
|
5
|
-
import sys
|
|
6
4
|
import webbrowser
|
|
7
|
-
import ctypes
|
|
8
5
|
from http.server import ThreadingHTTPServer
|
|
9
6
|
|
|
10
7
|
from web_app import Handler
|
|
@@ -28,22 +25,15 @@ def main() -> None:
|
|
|
28
25
|
port = find_port()
|
|
29
26
|
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
30
27
|
url = f"http://127.0.0.1:{port}/"
|
|
31
|
-
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
32
|
-
thread.start()
|
|
33
28
|
|
|
34
29
|
webbrowser.open(url)
|
|
30
|
+
print(f"{APP_NAME} is running at {url}")
|
|
31
|
+
print("Keep this terminal open while using the app. Press Ctrl+C to stop.")
|
|
35
32
|
|
|
36
33
|
try:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
f"브라우저에서 {APP_NAME}가 실행 중입니다.\n\n{url}\n\n앱 사용을 마친 뒤 확인을 누르면 서버가 종료됩니다.",
|
|
41
|
-
APP_NAME,
|
|
42
|
-
0,
|
|
43
|
-
)
|
|
44
|
-
else:
|
|
45
|
-
print(f"{APP_NAME} is running at {url}")
|
|
46
|
-
input("Press Enter to stop the server...")
|
|
34
|
+
server.serve_forever()
|
|
35
|
+
except KeyboardInterrupt:
|
|
36
|
+
print("\nStopping server...")
|
|
47
37
|
finally:
|
|
48
38
|
server.shutdown()
|
|
49
39
|
server.server_close()
|
package/package.json
CHANGED
package/web/index.html
CHANGED
package/web/main.js
CHANGED
|
@@ -534,8 +534,13 @@ function renderSettings() {
|
|
|
534
534
|
const wrap = document.createElement("label");
|
|
535
535
|
wrap.innerHTML = `<span>${label}</span><input id="api_${key}" type="${type}" />`;
|
|
536
536
|
form.appendChild(wrap);
|
|
537
|
-
$(`#api_${key}`)
|
|
538
|
-
|
|
537
|
+
const input = $(`#api_${key}`);
|
|
538
|
+
input.value = key === "token" ? "" : state.api[key] ?? "";
|
|
539
|
+
if (key === "token") {
|
|
540
|
+
input.placeholder = state.api.token_saved ? "저장된 토큰을 사용합니다" : "API 토큰을 입력하세요";
|
|
541
|
+
input.autocomplete = "off";
|
|
542
|
+
}
|
|
543
|
+
if (type === "number") input.step = "any";
|
|
539
544
|
}
|
|
540
545
|
$("#mockMode").checked = !!state.api.mock_mode;
|
|
541
546
|
$("#negativePrompt").value = state.negative_prompt || "";
|
|
@@ -834,7 +839,6 @@ function renderImageModalItem(item) {
|
|
|
834
839
|
const position = modalItems.length > 1 ? ` (${modalIndex + 1} / ${modalItems.length})` : "";
|
|
835
840
|
$("#modalTitle").textContent = `${item.source_label || item.created_at || "이미지"}${position}`;
|
|
836
841
|
$("#modalSubtitle").textContent = [item.source_base_preset, item.source_character_preset].filter(Boolean).join(" + ");
|
|
837
|
-
$("#modalPrompt").textContent = item.prompt || "";
|
|
838
842
|
const artistList = $("#modalArtists");
|
|
839
843
|
artistList.innerHTML = "";
|
|
840
844
|
for (const row of artistWeightRows(item.artists || [])) artistList.appendChild(row);
|
|
@@ -1431,6 +1435,14 @@ function deleteChar() {
|
|
|
1431
1435
|
function bindAutosaveInputs(root = document) {
|
|
1432
1436
|
root.querySelectorAll("input, textarea, select").forEach((node) => {
|
|
1433
1437
|
if (node.id === "compareHistorySelect") return;
|
|
1438
|
+
if (node.id === "api_token") {
|
|
1439
|
+
node.oninput = null;
|
|
1440
|
+
node.onchange = () => {
|
|
1441
|
+
syncEditorsToState();
|
|
1442
|
+
scheduleSave();
|
|
1443
|
+
};
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1434
1446
|
node.oninput = () => {
|
|
1435
1447
|
syncEditorsToState();
|
|
1436
1448
|
if (node.id === "catTags") renderRecognizedTags();
|
package/web_app.py
CHANGED
|
@@ -25,11 +25,17 @@ from app import (
|
|
|
25
25
|
NovelAIClient,
|
|
26
26
|
PromptPreset,
|
|
27
27
|
float_range,
|
|
28
|
+
has_saved_api_token,
|
|
29
|
+
load_api_token,
|
|
28
30
|
load_state,
|
|
29
31
|
now_id,
|
|
32
|
+
output_ref,
|
|
30
33
|
parse_artist_tags,
|
|
31
34
|
safe_path_name,
|
|
35
|
+
save_api_token,
|
|
32
36
|
save_state,
|
|
37
|
+
sanitize_history_entry,
|
|
38
|
+
sanitize_history_item,
|
|
33
39
|
weight_tag,
|
|
34
40
|
)
|
|
35
41
|
|
|
@@ -72,7 +78,9 @@ def state_from_dict(data: dict) -> AppState:
|
|
|
72
78
|
|
|
73
79
|
def media_url(path: str) -> str:
|
|
74
80
|
try:
|
|
75
|
-
|
|
81
|
+
raw = str(path or "")
|
|
82
|
+
candidate = Path(raw)
|
|
83
|
+
rel = candidate.resolve().relative_to(OUTPUT_DIR.resolve()) if candidate.is_absolute() else Path(raw)
|
|
76
84
|
except (ValueError, OSError):
|
|
77
85
|
return ""
|
|
78
86
|
return "/media/" + urllib.parse.quote(str(rel).replace("\\", "/"))
|
|
@@ -80,19 +88,20 @@ def media_url(path: str) -> str:
|
|
|
80
88
|
|
|
81
89
|
def state_payload(state: AppState) -> dict:
|
|
82
90
|
data = asdict(state)
|
|
91
|
+
data.setdefault("api", {})["token"] = ""
|
|
92
|
+
data["api"]["token_saved"] = has_saved_api_token()
|
|
93
|
+
data["history"] = [sanitize_history_entry(item) for item in data.get("history", [])]
|
|
83
94
|
for category in data.get("categories", []):
|
|
84
95
|
category["recognized_tags"] = parse_artist_tags(category.get("tags", []))
|
|
85
96
|
for history in data.get("history", []):
|
|
86
97
|
for item in history.get("items", []):
|
|
87
98
|
item["image_url"] = media_url(item.get("path", ""))
|
|
88
|
-
item["request_url"] = media_url(item.get("request_path", ""))
|
|
89
99
|
return data
|
|
90
100
|
|
|
91
101
|
|
|
92
102
|
def item_payload(item: dict) -> dict:
|
|
93
|
-
data =
|
|
103
|
+
data = sanitize_history_item(item)
|
|
94
104
|
data["image_url"] = media_url(data.get("path", ""))
|
|
95
|
-
data["request_url"] = media_url(data.get("request_path", ""))
|
|
96
105
|
return data
|
|
97
106
|
|
|
98
107
|
|
|
@@ -186,6 +195,12 @@ def build_prompt(state: AppState) -> tuple[str, str, str, list[dict]]:
|
|
|
186
195
|
|
|
187
196
|
def save_incoming_state(data: dict) -> AppState:
|
|
188
197
|
state = state_from_dict(data)
|
|
198
|
+
incoming_token = str((data.get("api", {}) or {}).get("token", "") or "").strip()
|
|
199
|
+
if incoming_token:
|
|
200
|
+
save_api_token(incoming_token)
|
|
201
|
+
state.api.token = incoming_token
|
|
202
|
+
else:
|
|
203
|
+
state.api.token = load_api_token()
|
|
189
204
|
with STATE_LOCK:
|
|
190
205
|
current = load_state()
|
|
191
206
|
state.history = current.history
|
|
@@ -207,7 +222,8 @@ def delete_history_entries(ids: list[str], delete_files: bool) -> AppState:
|
|
|
207
222
|
if not raw_dir:
|
|
208
223
|
continue
|
|
209
224
|
try:
|
|
210
|
-
|
|
225
|
+
raw_path = Path(raw_dir)
|
|
226
|
+
target = raw_path.resolve() if raw_path.is_absolute() else (OUTPUT_DIR / raw_path).resolve()
|
|
211
227
|
except OSError:
|
|
212
228
|
continue
|
|
213
229
|
if target == output_root or output_root not in target.parents:
|
|
@@ -244,11 +260,7 @@ def run_generation(job_id: str, state: AppState) -> None:
|
|
|
244
260
|
prompt, negative, uc_prompt, artists = build_prompt(state)
|
|
245
261
|
path = out_dir / f"image_{idx + 1:03}.png"
|
|
246
262
|
metadata = {
|
|
247
|
-
"path": str(path),
|
|
248
|
-
"request_path": str(path.with_name(path.stem + "_request.json")),
|
|
249
|
-
"prompt": prompt,
|
|
250
|
-
"negative_prompt": negative,
|
|
251
|
-
"uc_prompt": uc_prompt,
|
|
263
|
+
"path": output_ref(str(path)),
|
|
252
264
|
"artists": artists,
|
|
253
265
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
254
266
|
}
|
|
@@ -266,7 +278,7 @@ def run_generation(job_id: str, state: AppState) -> None:
|
|
|
266
278
|
"base_preset": base.name if base else "",
|
|
267
279
|
"character_preset": character.name if character else "",
|
|
268
280
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
269
|
-
"output_dir": str(out_dir),
|
|
281
|
+
"output_dir": output_ref(str(out_dir)),
|
|
270
282
|
"items": items,
|
|
271
283
|
}
|
|
272
284
|
update = {
|
|
@@ -346,11 +358,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
|
|
|
346
358
|
prompt, negative, uc_prompt, artists = build_prompt(current)
|
|
347
359
|
path = scene_dir / f"image_{image_index + 1:03}.png"
|
|
348
360
|
metadata = {
|
|
349
|
-
"path": str(path),
|
|
350
|
-
"request_path": str(path.with_name(path.stem + "_request.json")),
|
|
351
|
-
"prompt": prompt,
|
|
352
|
-
"negative_prompt": negative,
|
|
353
|
-
"uc_prompt": uc_prompt,
|
|
361
|
+
"path": output_ref(str(path)),
|
|
354
362
|
"artists": artists,
|
|
355
363
|
"scene_name": scene_name,
|
|
356
364
|
"scene_index": scene_index,
|
|
@@ -376,7 +384,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
|
|
|
376
384
|
"base_preset": "타율 테스트",
|
|
377
385
|
"character_preset": f"{len(scenes)}개 씬",
|
|
378
386
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
379
|
-
"output_dir": str(out_dir),
|
|
387
|
+
"output_dir": output_ref(str(out_dir)),
|
|
380
388
|
"scenes": [asdict(scene) for scene in scenes],
|
|
381
389
|
"items": items,
|
|
382
390
|
}
|