nai-aclab 1.1.0 → 1.1.1
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 -3
- package/app.py +14 -23
- package/package.json +1 -1
- package/web/index.html +5 -14
- package/web/main.js +4 -7
- package/web_app.py +10 -12
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ nai-aclab
|
|
|
66
66
|
|
|
67
67
|
API 토큰을 아직 넣고 싶지 않다면 `목업 모드 사용`을 켜둔 채로 UI만 먼저 테스트할 수 있습니다. 목업 모드에서는 실제 NovelAI API를 쓰지 않고 테스트용 이미지를 만듭니다.
|
|
68
68
|
|
|
69
|
-
대부분의 사용자는 `Endpoint`, `Model`, `Sampler`, `Noise Schedule` 같은 값은 기본값 그대로 두면 됩니다.
|
|
69
|
+
대부분의 사용자는 `Endpoint`, `Model`, `Sampler`, `Noise Schedule` 같은 값은 기본값 그대로 두면 됩니다. 처음 설치하면 이미지 모델은 `NAI Diffusion V5 Full`로 설정됩니다.
|
|
70
70
|
|
|
71
71
|

|
|
72
72
|
|
|
@@ -103,7 +103,8 @@ artist:sample_a
|
|
|
103
103
|
- 퀄리티 프롬프트: 품질 관련 태그
|
|
104
104
|
- 캐릭터 프롬프트: 캐릭터별 외형과 행동
|
|
105
105
|
- 네거티브 프롬프트: 피하고 싶은 요소
|
|
106
|
-
|
|
106
|
+
|
|
107
|
+
공통 네거티브 프롬프트는 NovelAI의 UC에도 같은 내용으로 자동 전송됩니다.
|
|
107
108
|
|
|
108
109
|
최종 프롬프트는 아래 순서로 조립됩니다.
|
|
109
110
|
|
|
@@ -232,7 +233,7 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
232
233
|
|
|
233
234
|
API 토큰은 일반 설정 파일에 저장하지 않고 별도 secret store에 저장합니다. 앱 화면으로 다시 내려보내지도 않습니다.
|
|
234
235
|
|
|
235
|
-
생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트,
|
|
236
|
+
생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다.
|
|
236
237
|
|
|
237
238
|
NovelAI가 이미지 파일 안에 저장하는 생성 정보는 그대로 남을 수 있습니다. 이 정보는 이미지 자체의 메타데이터입니다.
|
|
238
239
|
|
package/app.py
CHANGED
|
@@ -131,6 +131,7 @@ def cleanup_saved_request_jsons() -> None:
|
|
|
131
131
|
|
|
132
132
|
def state_dict_without_secrets(state: AppState | dict) -> dict:
|
|
133
133
|
data = asdict(state) if not isinstance(state, dict) else dict(state)
|
|
134
|
+
data.pop("uc_prompt", None)
|
|
134
135
|
api = dict(data.get("api", {}) or {})
|
|
135
136
|
api["token"] = ""
|
|
136
137
|
data["api"] = api
|
|
@@ -286,7 +287,7 @@ class ApiSettings:
|
|
|
286
287
|
token: str = ""
|
|
287
288
|
endpoint: str = "https://image.novelai.net/ai/generate-image"
|
|
288
289
|
user_agent: str = DEFAULT_USER_AGENT
|
|
289
|
-
model: str = "nai-diffusion-
|
|
290
|
+
model: str = "nai-diffusion-5-full"
|
|
290
291
|
width: int = 832
|
|
291
292
|
height: int = 1216
|
|
292
293
|
steps: int = 28
|
|
@@ -327,7 +328,6 @@ class AppState:
|
|
|
327
328
|
character_presets: list[CharacterPreset] = field(default_factory=list)
|
|
328
329
|
quality_override_prompt: str = ""
|
|
329
330
|
negative_prompt: str = "lowres, bad anatomy, bad hands, text, error, missing fingers"
|
|
330
|
-
uc_prompt: str = ""
|
|
331
331
|
api: ApiSettings = field(default_factory=ApiSettings)
|
|
332
332
|
generation: GenerationSettings = field(default_factory=GenerationSettings)
|
|
333
333
|
batting_scenes: list[BattingScene] = field(default_factory=list)
|
|
@@ -392,7 +392,6 @@ def load_state() -> AppState:
|
|
|
392
392
|
character_presets=[CharacterPreset(**item) for item in data.get("character_presets", [])],
|
|
393
393
|
quality_override_prompt=quality_override,
|
|
394
394
|
negative_prompt=data.get("negative_prompt", ""),
|
|
395
|
-
uc_prompt=data.get("uc_prompt", data.get("negative_prompt", "")),
|
|
396
395
|
api=ApiSettings(**api_data),
|
|
397
396
|
generation=GenerationSettings(**data.get("generation", {})),
|
|
398
397
|
batting_scenes=[BattingScene(**item) for item in data.get("batting_scenes", [])],
|
|
@@ -412,7 +411,7 @@ class NovelAIClient:
|
|
|
412
411
|
def __init__(self, settings: ApiSettings):
|
|
413
412
|
self.settings = settings
|
|
414
413
|
|
|
415
|
-
def build_payload(self, prompt: str, negative_prompt: str,
|
|
414
|
+
def build_payload(self, prompt: str, negative_prompt: str, seed: int | None = None) -> dict:
|
|
416
415
|
actual_seed = seed if seed is not None else (
|
|
417
416
|
self.settings.seed if self.settings.seed >= 0 else random.randint(0, 2**32 - 1)
|
|
418
417
|
)
|
|
@@ -468,18 +467,18 @@ class NovelAIClient:
|
|
|
468
467
|
"wonky_vibe_correlation": True,
|
|
469
468
|
"stream": "none",
|
|
470
469
|
"version": 1,
|
|
471
|
-
"uc":
|
|
472
|
-
"negative_prompt":
|
|
470
|
+
"uc": negative_prompt,
|
|
471
|
+
"negative_prompt": negative_prompt,
|
|
473
472
|
"request_type": "PromptGenerateRequest",
|
|
474
473
|
},
|
|
475
474
|
}
|
|
476
475
|
|
|
477
|
-
def generate(self, prompt: str, negative_prompt: str,
|
|
476
|
+
def generate(self, prompt: str, negative_prompt: str, output_path: Path) -> None:
|
|
478
477
|
if self.settings.mock_mode or not self.settings.token.strip():
|
|
479
478
|
write_placeholder_png(output_path, prompt, self.settings.width, self.settings.height)
|
|
480
479
|
return
|
|
481
480
|
|
|
482
|
-
payload = self.build_payload(prompt, negative_prompt
|
|
481
|
+
payload = self.build_payload(prompt, negative_prompt)
|
|
483
482
|
req = urllib.request.Request(
|
|
484
483
|
self.settings.endpoint,
|
|
485
484
|
data=json.dumps(payload).encode("utf-8"),
|
|
@@ -794,11 +793,7 @@ class App(tk.Tk if tk else object):
|
|
|
794
793
|
neg_box.pack(fill=BOTH, expand=True, pady=12)
|
|
795
794
|
self.negative_text = tk.Text(neg_box, height=8, wrap="word")
|
|
796
795
|
self.negative_text.pack(fill=BOTH, expand=True)
|
|
797
|
-
|
|
798
|
-
uc_box.pack(fill=BOTH, expand=True, pady=(0, 12))
|
|
799
|
-
self.uc_text = tk.Text(uc_box, height=5, wrap="word")
|
|
800
|
-
self.uc_text.pack(fill=BOTH, expand=True)
|
|
801
|
-
ttk.Button(uc_box, text="네거티브 / UC 저장", command=self.save_negative).pack(anchor="e", pady=6)
|
|
796
|
+
ttk.Button(neg_box, text="네거티브 프롬프트 저장", command=self.save_negative).pack(anchor="e", pady=6)
|
|
802
797
|
|
|
803
798
|
def refresh_all(self) -> None:
|
|
804
799
|
self.refresh_categories()
|
|
@@ -1171,8 +1166,6 @@ class App(tk.Tk if tk else object):
|
|
|
1171
1166
|
var.set(getattr(api, key))
|
|
1172
1167
|
self.negative_text.delete("1.0", END)
|
|
1173
1168
|
self.negative_text.insert("1.0", self.state_data.negative_prompt)
|
|
1174
|
-
self.uc_text.delete("1.0", END)
|
|
1175
|
-
self.uc_text.insert("1.0", self.state_data.uc_prompt)
|
|
1176
1169
|
|
|
1177
1170
|
def save_api_settings(self, show_message: bool = True) -> None:
|
|
1178
1171
|
api = self.state_data.api
|
|
@@ -1184,7 +1177,6 @@ class App(tk.Tk if tk else object):
|
|
|
1184
1177
|
|
|
1185
1178
|
def save_negative(self) -> None:
|
|
1186
1179
|
self.state_data.negative_prompt = self.negative_text.get("1.0", END).strip()
|
|
1187
|
-
self.state_data.uc_prompt = self.uc_text.get("1.0", END).strip()
|
|
1188
1180
|
save_state(self.state_data)
|
|
1189
1181
|
|
|
1190
1182
|
def random_artist_tags(self) -> list[dict]:
|
|
@@ -1200,7 +1192,7 @@ class App(tk.Tk if tk else object):
|
|
|
1200
1192
|
result.append({"category": cat.name, "tag": tag, "weight": weight, "prompt": weight_tag(tag, weight)})
|
|
1201
1193
|
return result
|
|
1202
1194
|
|
|
1203
|
-
def build_prompt(self) -> tuple[str, str,
|
|
1195
|
+
def build_prompt(self) -> tuple[str, str, list[dict]]:
|
|
1204
1196
|
base = self.find_base()
|
|
1205
1197
|
char = self.find_char()
|
|
1206
1198
|
artists = self.random_artist_tags()
|
|
@@ -1226,11 +1218,10 @@ class App(tk.Tk if tk else object):
|
|
|
1226
1218
|
if char:
|
|
1227
1219
|
negative.extend(n.strip() for n in char.negatives if n.strip())
|
|
1228
1220
|
negative_prompt = ", ".join(n for n in negative if n)
|
|
1229
|
-
|
|
1230
|
-
return " | ".join(prompt_parts), negative_prompt, uc_prompt, artists
|
|
1221
|
+
return " | ".join(prompt_parts), negative_prompt, artists
|
|
1231
1222
|
|
|
1232
1223
|
def preview_prompt(self) -> None:
|
|
1233
|
-
prompt, negative,
|
|
1224
|
+
prompt, negative, artists = self.build_prompt()
|
|
1234
1225
|
base = self.find_base()
|
|
1235
1226
|
char = self.find_char()
|
|
1236
1227
|
self.prompt_preview.delete("1.0", END)
|
|
@@ -1249,7 +1240,7 @@ class App(tk.Tk if tk else object):
|
|
|
1249
1240
|
for idx, char_prompt in enumerate(char.prompts, start=1):
|
|
1250
1241
|
if char_prompt.strip():
|
|
1251
1242
|
self.prompt_preview.insert(END, f"{idx}. {char_prompt.strip()}\n")
|
|
1252
|
-
self.prompt_preview.insert(END, f"\n[Negative]\n{negative}\n
|
|
1243
|
+
self.prompt_preview.insert(END, f"\n[Negative / UC]\n{negative}\n")
|
|
1253
1244
|
|
|
1254
1245
|
def start_generation(self) -> None:
|
|
1255
1246
|
count = max(1, int(self.count_var.get()))
|
|
@@ -1274,7 +1265,7 @@ class App(tk.Tk if tk else object):
|
|
|
1274
1265
|
client = NovelAIClient(self.state_data.api)
|
|
1275
1266
|
items = []
|
|
1276
1267
|
for idx in range(count):
|
|
1277
|
-
prompt, negative,
|
|
1268
|
+
prompt, negative, artists = self.build_prompt()
|
|
1278
1269
|
path = out_dir / f"image_{idx + 1:03}.png"
|
|
1279
1270
|
metadata = {
|
|
1280
1271
|
"path": output_ref(str(path)),
|
|
@@ -1282,7 +1273,7 @@ class App(tk.Tk if tk else object):
|
|
|
1282
1273
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
1283
1274
|
}
|
|
1284
1275
|
try:
|
|
1285
|
-
client.generate(prompt, negative,
|
|
1276
|
+
client.generate(prompt, negative, path)
|
|
1286
1277
|
items.append(metadata)
|
|
1287
1278
|
self.after(0, self.log_line, f"{idx + 1}/{count} 완료: {path.name}")
|
|
1288
1279
|
except Exception as exc:
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -130,14 +130,10 @@
|
|
|
130
130
|
<strong>Prompt</strong>
|
|
131
131
|
<pre id="promptPreview"></pre>
|
|
132
132
|
</div>
|
|
133
|
-
<div>
|
|
134
|
-
<strong>Negative</strong>
|
|
135
|
-
<pre id="negativePreview"></pre>
|
|
136
|
-
</div>
|
|
137
|
-
<div>
|
|
138
|
-
<strong>UC</strong>
|
|
139
|
-
<pre id="ucPreview"></pre>
|
|
140
|
-
</div>
|
|
133
|
+
<div>
|
|
134
|
+
<strong>Negative / UC</strong>
|
|
135
|
+
<pre id="negativePreview"></pre>
|
|
136
|
+
</div>
|
|
141
137
|
</div>
|
|
142
138
|
</section>
|
|
143
139
|
|
|
@@ -337,12 +333,7 @@
|
|
|
337
333
|
<label class="text-label">
|
|
338
334
|
<span>공통 네거티브 프롬프트</span>
|
|
339
335
|
<textarea id="negativePrompt" spellcheck="false"></textarea>
|
|
340
|
-
<small class="field-help">모든 캐릭터에 공통으로
|
|
341
|
-
</label>
|
|
342
|
-
<label class="text-label">
|
|
343
|
-
<span>UC 프롬프트 (선택)</span>
|
|
344
|
-
<textarea id="ucPrompt" spellcheck="false" placeholder="비워두면 공통 네거티브 프롬프트를 그대로 사용합니다."></textarea>
|
|
345
|
-
<small class="field-help">NovelAI의 legacy UC에 별도 내용을 보내고 싶을 때만 입력하세요.</small>
|
|
336
|
+
<small class="field-help">모든 캐릭터에 공통으로 적용되며 NovelAI의 UC에도 같은 내용이 전송됩니다.</small>
|
|
346
337
|
</label>
|
|
347
338
|
</section>
|
|
348
339
|
</div>
|
package/web/main.js
CHANGED
|
@@ -256,8 +256,7 @@ function syncEditorsToState() {
|
|
|
256
256
|
character.negatives = [0, 1, 2].map((i) => $(`#charNegative${i}`).value.trim());
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
state.negative_prompt = $("#negativePrompt").value.trim();
|
|
260
|
-
state.uc_prompt = $("#ucPrompt").value.trim();
|
|
259
|
+
state.negative_prompt = $("#negativePrompt").value.trim();
|
|
261
260
|
|
|
262
261
|
for (const [key, , type] of apiFields) {
|
|
263
262
|
const input = $(`#api_${key}`);
|
|
@@ -827,7 +826,6 @@ function renderSettings() {
|
|
|
827
826
|
? "토큰 저장됨"
|
|
828
827
|
: "토큰 필요";
|
|
829
828
|
$("#negativePrompt").value = state.negative_prompt || "";
|
|
830
|
-
$("#ucPrompt").value = state.uc_prompt || "";
|
|
831
829
|
bindAutosaveInputs(primaryForm);
|
|
832
830
|
bindAutosaveInputs(advancedForm);
|
|
833
831
|
}
|
|
@@ -1551,10 +1549,9 @@ async function previewPrompt() {
|
|
|
1551
1549
|
method: "POST",
|
|
1552
1550
|
body: JSON.stringify({ state }),
|
|
1553
1551
|
});
|
|
1554
|
-
$("#promptPreview").textContent = data.prompt || "";
|
|
1555
|
-
$("#negativePreview").textContent = data.negative || "";
|
|
1556
|
-
|
|
1557
|
-
setSaveState("미리보기 갱신됨");
|
|
1552
|
+
$("#promptPreview").textContent = data.prompt || "";
|
|
1553
|
+
$("#negativePreview").textContent = data.negative || "";
|
|
1554
|
+
setSaveState("미리보기 갱신됨");
|
|
1558
1555
|
}
|
|
1559
1556
|
|
|
1560
1557
|
function setProgressActive(active, selector = ".progress") {
|
package/web_app.py
CHANGED
|
@@ -78,7 +78,6 @@ def state_from_dict(data: dict) -> AppState:
|
|
|
78
78
|
character_presets=[dataclass_from_dict(CharacterPreset, item) for item in data.get("character_presets", [])],
|
|
79
79
|
quality_override_prompt=quality_override,
|
|
80
80
|
negative_prompt=data.get("negative_prompt", ""),
|
|
81
|
-
uc_prompt=data.get("uc_prompt", data.get("negative_prompt", "")),
|
|
82
81
|
api=dataclass_from_dict(ApiSettings, data.get("api", {})),
|
|
83
82
|
generation=dataclass_from_dict(GenerationSettings, data.get("generation", {})),
|
|
84
83
|
batting_scenes=[dataclass_from_dict(BattingScene, item) for item in data.get("batting_scenes", [])],
|
|
@@ -210,7 +209,7 @@ def fixed_artist_tags(state: AppState) -> list[dict]:
|
|
|
210
209
|
return result
|
|
211
210
|
|
|
212
211
|
|
|
213
|
-
def build_prompt(state: AppState) -> tuple[str, str,
|
|
212
|
+
def build_prompt(state: AppState) -> tuple[str, str, list[dict]]:
|
|
214
213
|
base = selected_base(state)
|
|
215
214
|
character = selected_character(state)
|
|
216
215
|
artists = fixed_artist_tags(state) or random_artist_tags(state)
|
|
@@ -238,8 +237,7 @@ def build_prompt(state: AppState) -> tuple[str, str, str, list[dict]]:
|
|
|
238
237
|
if character:
|
|
239
238
|
negative.extend(item.strip() for item in character.negatives if item.strip())
|
|
240
239
|
negative_prompt = ", ".join(item for item in negative if item)
|
|
241
|
-
|
|
242
|
-
return " | ".join(prompt_parts), negative_prompt, uc_prompt, artists
|
|
240
|
+
return " | ".join(prompt_parts), negative_prompt, artists
|
|
243
241
|
|
|
244
242
|
|
|
245
243
|
def save_incoming_state(data: dict) -> AppState:
|
|
@@ -313,7 +311,7 @@ def run_generation(job_id: str, state: AppState) -> None:
|
|
|
313
311
|
cancelled = True
|
|
314
312
|
JOBS[job_id]["log"].append("중지 요청으로 남은 생성을 건너뜁니다.")
|
|
315
313
|
break
|
|
316
|
-
prompt, negative,
|
|
314
|
+
prompt, negative, artists = build_prompt(state)
|
|
317
315
|
path = out_dir / f"image_{idx + 1:03}.png"
|
|
318
316
|
metadata = {
|
|
319
317
|
"path": output_ref(str(path)),
|
|
@@ -321,7 +319,7 @@ def run_generation(job_id: str, state: AppState) -> None:
|
|
|
321
319
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
322
320
|
}
|
|
323
321
|
try:
|
|
324
|
-
client.generate(prompt, negative,
|
|
322
|
+
client.generate(prompt, negative, path)
|
|
325
323
|
JOBS[job_id]["log"].append(f"{idx + 1}/{count} 완료: {path.name}")
|
|
326
324
|
except Exception as exc:
|
|
327
325
|
metadata["error"] = str(exc)
|
|
@@ -417,7 +415,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
|
|
|
417
415
|
cancelled = True
|
|
418
416
|
JOBS[job_id]["log"].append(f"{scene_name}: 중지 요청으로 남은 이미지를 건너뜁니다.")
|
|
419
417
|
break
|
|
420
|
-
prompt, negative,
|
|
418
|
+
prompt, negative, artists = build_prompt(current)
|
|
421
419
|
path = scene_dir / f"image_{image_index + 1:03}.png"
|
|
422
420
|
metadata = {
|
|
423
421
|
"path": output_ref(str(path)),
|
|
@@ -432,7 +430,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
|
|
|
432
430
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
433
431
|
}
|
|
434
432
|
try:
|
|
435
|
-
client.generate(prompt, negative,
|
|
433
|
+
client.generate(prompt, negative, path)
|
|
436
434
|
JOBS[job_id]["log"].append(f"{scene_name} {image_index + 1}/{count} 완료: {path.name}")
|
|
437
435
|
except Exception as exc:
|
|
438
436
|
metadata["error"] = str(exc)
|
|
@@ -536,8 +534,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
536
534
|
self.send_error(403)
|
|
537
535
|
return
|
|
538
536
|
state = load_state()
|
|
539
|
-
prompt, negative,
|
|
540
|
-
self.send_json({"prompt": prompt, "negative": negative, "
|
|
537
|
+
prompt, negative, artists = build_prompt(state)
|
|
538
|
+
self.send_json({"prompt": prompt, "negative": negative, "artists": artists})
|
|
541
539
|
elif route == "/api/job":
|
|
542
540
|
if not self.authorized_api_request():
|
|
543
541
|
self.send_error(403)
|
|
@@ -566,8 +564,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
566
564
|
self.send_json({"state": state_payload(state)})
|
|
567
565
|
elif route == "/api/preview":
|
|
568
566
|
state = save_incoming_state(data.get("state", data))
|
|
569
|
-
prompt, negative,
|
|
570
|
-
self.send_json({"prompt": prompt, "negative": negative, "
|
|
567
|
+
prompt, negative, artists = build_prompt(state)
|
|
568
|
+
self.send_json({"prompt": prompt, "negative": negative, "artists": artists})
|
|
571
569
|
elif route == "/api/generate":
|
|
572
570
|
if active_job():
|
|
573
571
|
self.send_json({"error": "이미 생성 작업이 진행 중입니다."}, status=409)
|