nai-aclab 1.0.1 → 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 +4 -0
- package/app.py +145 -11
- 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
|
@@ -121,6 +121,10 @@ override가 입력되어 있으면 모든 프리셋 조합에서 override 값을
|
|
|
121
121
|
|
|
122
122
|
토큰을 넣기 전에는 목업 모드로 UI 흐름을 먼저 테스트할 수 있습니다.
|
|
123
123
|
|
|
124
|
+
API 토큰은 `app_state.json`에 저장하거나 `/api/state` 응답으로 다시 내려보내지 않습니다. 한 번 입력한 토큰은 별도 secret store에 저장되어 다음 실행 때 자동으로 사용됩니다.
|
|
125
|
+
|
|
126
|
+
생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, UC 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다. 이전 버전에서 생성된 `*_request.json` 파일은 앱 시작 시 자동으로 제거됩니다.
|
|
127
|
+
|
|
124
128
|
## 저장 위치
|
|
125
129
|
|
|
126
130
|
앱 상태, 생성 이미지, 요청 JSON은 아래 위치에 저장됩니다.
|
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/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
|
}
|