nai-aclab 1.0.0

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/app.py ADDED
@@ -0,0 +1,1247 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import io
5
+ import json
6
+ import os
7
+ import random
8
+ import re
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ import zipfile
14
+ import zlib
15
+ from dataclasses import asdict, dataclass, field
16
+ from pathlib import Path
17
+ try:
18
+ from tkinter import END, LEFT, RIGHT, BOTH, X, Y, filedialog, messagebox, ttk
19
+ import tkinter as tk
20
+ except Exception:
21
+ END = LEFT = RIGHT = BOTH = X = Y = None
22
+ filedialog = messagebox = ttk = tk = None
23
+
24
+
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
+ )
32
+ DATA_DIR = USER_DIR / "data"
33
+ OUTPUT_DIR = USER_DIR / "outputs"
34
+ STATE_PATH = DATA_DIR / "app_state.json"
35
+ DEFAULT_USER_AGENT = (
36
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
37
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
38
+ "Chrome/125.0.0.0 Safari/537.36"
39
+ )
40
+
41
+
42
+ def ensure_dirs() -> None:
43
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
44
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
45
+
46
+
47
+ def now_id() -> str:
48
+ return time.strftime("%Y%m%d_%H%M%S")
49
+
50
+
51
+ def safe_path_name(value: str) -> str:
52
+ cleaned = "".join("_" if ch in '<>:"/\\|?*' else ch for ch in value.strip())
53
+ cleaned = "_".join(part for part in cleaned.split())
54
+ cleaned = cleaned.strip(" ._")
55
+ return cleaned or "untitled"
56
+
57
+
58
+ def float_range(min_value: float, max_value: float, granule: float) -> list[float]:
59
+ if granule <= 0:
60
+ granule = 0.1
61
+ steps = int(round((max_value - min_value) / granule))
62
+ return [round(min_value + i * granule, 3) for i in range(max(0, steps) + 1)]
63
+
64
+
65
+ def weight_tag(tag: str, weight: float) -> str:
66
+ tag = tag.strip()
67
+ if not tag:
68
+ return ""
69
+ return f"{weight:g}::{tag} ::"
70
+
71
+
72
+ def parse_artist_tags(text: str | list[str]) -> list[str]:
73
+ if isinstance(text, list):
74
+ text = "\n".join(str(item) for item in text)
75
+ found: list[str] = []
76
+ seen = set()
77
+ chunks = re.split(r"[,\n\r]+", text)
78
+ for chunk in chunks:
79
+ item = chunk.strip()
80
+ if not item:
81
+ continue
82
+ item = re.sub(r"^[+-]?\d+(?:\.\d+)?\s*::\s*", "", item)
83
+ start = item.lower().find("artist:")
84
+ if start < 0:
85
+ continue
86
+ tag = item[start:].strip()
87
+ tag = re.sub(r"\s*::\s*$", "", tag).strip()
88
+ if tag and tag not in seen:
89
+ seen.add(tag)
90
+ found.append(tag)
91
+ return found
92
+
93
+
94
+ def png_chunk(kind: bytes, data: bytes) -> bytes:
95
+ return (
96
+ len(data).to_bytes(4, "big")
97
+ + kind
98
+ + data
99
+ + zlib.crc32(kind + data).to_bytes(4, "big")
100
+ )
101
+
102
+
103
+ def write_placeholder_png(path: Path, title: str, width: int = 768, height: int = 1024) -> None:
104
+ """Create a deterministic lightweight PNG so the GUI can be tested without API credits."""
105
+ seed = zlib.crc32(title.encode("utf-8"))
106
+ rng = random.Random(seed)
107
+ c1 = (rng.randrange(60, 220), rng.randrange(70, 230), rng.randrange(80, 240))
108
+ c2 = (rng.randrange(20, 160), rng.randrange(30, 170), rng.randrange(40, 180))
109
+ rows = []
110
+ for y in range(height):
111
+ t = y / max(1, height - 1)
112
+ row = bytearray([0])
113
+ for x in range(width):
114
+ wave = (x / width) * 0.18
115
+ mix = min(1, max(0, t + wave))
116
+ row.extend(
117
+ int(c1[i] * (1 - mix) + c2[i] * mix)
118
+ for i in range(3)
119
+ )
120
+ rows.append(bytes(row))
121
+ raw = b"".join(rows)
122
+ png = (
123
+ b"\x89PNG\r\n\x1a\n"
124
+ + png_chunk(b"IHDR", width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x02\x00\x00\x00")
125
+ + png_chunk(b"IDAT", zlib.compress(raw, 9))
126
+ + png_chunk(b"IEND", b"")
127
+ )
128
+ path.write_bytes(png)
129
+
130
+
131
+ @dataclass
132
+ class Category:
133
+ name: str
134
+ tags: list[str]
135
+ min_weight: float
136
+ max_weight: float
137
+ granule: float
138
+ picks: int = 0
139
+
140
+
141
+ @dataclass
142
+ class PromptPreset:
143
+ name: str
144
+ prompt: str = ""
145
+ quality_prompt: str = ""
146
+ quality_override_prompt: str = ""
147
+
148
+
149
+ @dataclass
150
+ class CharacterPreset:
151
+ name: str
152
+ prompts: list[str] = field(default_factory=lambda: ["", "", ""])
153
+ negatives: list[str] = field(default_factory=lambda: ["", "", ""])
154
+ quality_override_prompt: str = ""
155
+
156
+
157
+ @dataclass
158
+ class ApiSettings:
159
+ token: str = ""
160
+ endpoint: str = "https://image.novelai.net/ai/generate-image"
161
+ user_agent: str = DEFAULT_USER_AGENT
162
+ model: str = "nai-diffusion-4-5-curated"
163
+ width: int = 832
164
+ height: int = 1216
165
+ steps: int = 28
166
+ scale: float = 5.0
167
+ uncond_scale: float = 0.0
168
+ guidance_rescale: float = 0.0
169
+ sampler: str = "k_euler_ancestral"
170
+ noise_schedule: str = "karras"
171
+ n_samples: int = 1
172
+ seed: int = -1
173
+ mock_mode: bool = True
174
+
175
+
176
+ @dataclass
177
+ class GenerationSettings:
178
+ base_preset: str = ""
179
+ character_preset: str = ""
180
+ count: int = 4
181
+ image_size: str = "portrait"
182
+ fixed_artists: list[dict] = field(default_factory=list)
183
+ recent_base_presets: list[str] = field(default_factory=list)
184
+ recent_character_presets: list[str] = field(default_factory=list)
185
+
186
+
187
+ @dataclass
188
+ class BattingScene:
189
+ name: str = ""
190
+ base_preset: str = ""
191
+ character_preset: str = ""
192
+ count: int = 2
193
+
194
+
195
+ @dataclass
196
+ class AppState:
197
+ categories: list[Category] = field(default_factory=list)
198
+ base_presets: list[PromptPreset] = field(default_factory=list)
199
+ character_presets: list[CharacterPreset] = field(default_factory=list)
200
+ quality_override_prompt: str = ""
201
+ negative_prompt: str = "lowres, bad anatomy, bad hands, text, error, missing fingers"
202
+ uc_prompt: str = ""
203
+ api: ApiSettings = field(default_factory=ApiSettings)
204
+ generation: GenerationSettings = field(default_factory=GenerationSettings)
205
+ batting_scenes: list[BattingScene] = field(default_factory=list)
206
+ history: list[dict] = field(default_factory=list)
207
+
208
+
209
+ def default_state() -> AppState:
210
+ return AppState(
211
+ categories=[
212
+ Category("메인 그림체 작가", ["artist:example_main", "artist:sample_a"], 1.0, 1.4, 0.05, 0),
213
+ Category("그림체 안정화 작가", ["artist:example_stable", "artist:sample_b"], 0.4, 0.9, 0.1, 0),
214
+ ],
215
+ base_presets=[
216
+ PromptPreset("기본", "", "masterpiece, best quality, very aesthetic, detailed illustration"),
217
+ ],
218
+ character_presets=[
219
+ CharacterPreset("1인 기본", ["1girl, looking at viewer, detailed eyes", "", ""], ["", "", ""]),
220
+ ],
221
+ )
222
+
223
+
224
+ def load_state() -> AppState:
225
+ ensure_dirs()
226
+ if not STATE_PATH.exists():
227
+ return default_state()
228
+ data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
229
+ base_data = data.get("base_presets", [])
230
+ quality_override = data.get("quality_override_prompt", "")
231
+ if not quality_override.strip():
232
+ selected_base_name = data.get("generation", {}).get("base_preset", "")
233
+ selected_base = next((item for item in base_data if item.get("name") == selected_base_name), None)
234
+ fallback_base = (
235
+ selected_base
236
+ if selected_base and selected_base.get("quality_override_prompt")
237
+ else next((item for item in base_data if item.get("quality_override_prompt")), None)
238
+ )
239
+ quality_override = (fallback_base or {}).get("quality_override_prompt", "")
240
+ api_data = data.get("api", {})
241
+ if api_data.get("user_agent") in (None, "", "NAIArtistCombination/0.1"):
242
+ api_data["user_agent"] = DEFAULT_USER_AGENT
243
+ return AppState(
244
+ categories=[Category(**item) for item in data.get("categories", [])],
245
+ base_presets=[PromptPreset(**item) for item in base_data],
246
+ character_presets=[CharacterPreset(**item) for item in data.get("character_presets", [])],
247
+ quality_override_prompt=quality_override,
248
+ negative_prompt=data.get("negative_prompt", ""),
249
+ uc_prompt=data.get("uc_prompt", data.get("negative_prompt", "")),
250
+ api=ApiSettings(**api_data),
251
+ generation=GenerationSettings(**data.get("generation", {})),
252
+ batting_scenes=[BattingScene(**item) for item in data.get("batting_scenes", [])],
253
+ history=data.get("history", []),
254
+ )
255
+
256
+
257
+ def save_state(state: AppState) -> None:
258
+ ensure_dirs()
259
+ STATE_PATH.write_text(
260
+ json.dumps(asdict(state), ensure_ascii=False, indent=2),
261
+ encoding="utf-8",
262
+ )
263
+
264
+
265
+ class NovelAIClient:
266
+ def __init__(self, settings: ApiSettings):
267
+ self.settings = settings
268
+
269
+ def build_payload(self, prompt: str, negative_prompt: str, uc_prompt: str, seed: int | None = None) -> dict:
270
+ actual_seed = seed if seed is not None else (
271
+ self.settings.seed if self.settings.seed >= 0 else random.randint(0, 2**32 - 1)
272
+ )
273
+ return {
274
+ "input": prompt,
275
+ "model": self.settings.model,
276
+ "action": "generate",
277
+ "parameters": {
278
+ "steps": self.settings.steps,
279
+ "height": self.settings.height,
280
+ "width": self.settings.width,
281
+ "scale": self.settings.scale,
282
+ "uncond_scale": self.settings.uncond_scale,
283
+ "cfg_rescale": self.settings.guidance_rescale,
284
+ "seed": actual_seed,
285
+ "n_samples": self.settings.n_samples,
286
+ "noise_schedule": self.settings.noise_schedule,
287
+ "legacy_v3_extend": False,
288
+ "reference_information_extracted_multiple": [],
289
+ "reference_strength_multiple": [],
290
+ "v4_prompt": {
291
+ "caption": {
292
+ "base_caption": prompt,
293
+ "char_captions": [],
294
+ },
295
+ "use_coords": False,
296
+ "use_order": True,
297
+ "legacy_uc": False,
298
+ },
299
+ "v4_negative_prompt": {
300
+ "caption": {
301
+ "base_caption": negative_prompt,
302
+ "char_captions": [],
303
+ },
304
+ "use_coords": False,
305
+ "use_order": False,
306
+ "legacy_uc": False,
307
+ },
308
+ "director_reference_descriptions": [],
309
+ "director_reference_information_extracted": [],
310
+ "sampler": self.settings.sampler,
311
+ "controlnet_strength": 1.0,
312
+ "controlnet_model": None,
313
+ "sm": False,
314
+ "sm_dyn": False,
315
+ "skip_cfg_below_sigma": 0.0,
316
+ "deliberate_euler_ancestral_bug": False,
317
+ "prefer_brownian": True,
318
+ "cfg_sched_eligibility": "enable_for_post_summer_samplers",
319
+ "explike_fine_detail": False,
320
+ "minimize_sigma_inf": False,
321
+ "uncond_per_vibe": True,
322
+ "wonky_vibe_correlation": True,
323
+ "stream": "none",
324
+ "version": 1,
325
+ "uc": uc_prompt,
326
+ "negative_prompt": uc_prompt,
327
+ "request_type": "PromptGenerateRequest",
328
+ },
329
+ }
330
+
331
+ def generate(self, prompt: str, negative_prompt: str, uc_prompt: str, output_path: Path) -> None:
332
+ if self.settings.mock_mode or not self.settings.token.strip():
333
+ write_placeholder_png(output_path, prompt, self.settings.width, self.settings.height)
334
+ return
335
+
336
+ 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
+ req = urllib.request.Request(
340
+ self.settings.endpoint,
341
+ data=json.dumps(payload).encode("utf-8"),
342
+ headers={
343
+ "Authorization": f"Bearer {self.settings.token.strip()}",
344
+ "User-Agent": self.settings.user_agent.strip() or DEFAULT_USER_AGENT,
345
+ "Content-Type": "application/json",
346
+ "Accept": "application/zip, image/png, application/json",
347
+ "Origin": "https://novelai.net",
348
+ "Referer": "https://novelai.net/",
349
+ },
350
+ method="POST",
351
+ )
352
+ try:
353
+ with urllib.request.urlopen(req, timeout=180) as response:
354
+ body = response.read()
355
+ content_type = response.headers.get("Content-Type", "")
356
+ except urllib.error.HTTPError as exc:
357
+ detail = exc.read().decode("utf-8", errors="replace")
358
+ raise RuntimeError(f"NovelAI API 오류 {exc.code}: {detail[:600]}") from exc
359
+
360
+ if "zip" in content_type or body[:2] == b"PK":
361
+ with zipfile.ZipFile(io.BytesIO(body)) as archive:
362
+ image_names = [n for n in archive.namelist() if n.lower().endswith((".png", ".jpg", ".jpeg", ".webp"))]
363
+ if not image_names:
364
+ raise RuntimeError("API 응답 zip에서 이미지 파일을 찾지 못했습니다.")
365
+ output_path.write_bytes(archive.read(image_names[0]))
366
+ elif body.startswith(b"\x89PNG"):
367
+ output_path.write_bytes(body)
368
+ else:
369
+ maybe_json = body.decode("utf-8", errors="ignore")
370
+ if "base64" in maybe_json:
371
+ decoded = json.loads(maybe_json)
372
+ image_b64 = decoded.get("image") or decoded.get("data")
373
+ output_path.write_bytes(base64.b64decode(image_b64))
374
+ else:
375
+ raise RuntimeError(f"알 수 없는 API 응답 형식: {maybe_json[:300]}")
376
+
377
+
378
+ class ScrollFrame(ttk.Frame if ttk else object):
379
+ def __init__(self, parent):
380
+ super().__init__(parent)
381
+ canvas = tk.Canvas(self, highlightthickness=0)
382
+ scroll = ttk.Scrollbar(self, orient="vertical", command=canvas.yview)
383
+ self.content = ttk.Frame(canvas)
384
+ self.content.bind("<Configure>", lambda _: canvas.configure(scrollregion=canvas.bbox("all")))
385
+ canvas.create_window((0, 0), window=self.content, anchor="nw")
386
+ canvas.configure(yscrollcommand=scroll.set)
387
+ canvas.pack(side=LEFT, fill=BOTH, expand=True)
388
+ scroll.pack(side=RIGHT, fill=Y)
389
+
390
+
391
+ class App(tk.Tk if tk else object):
392
+ def __init__(self):
393
+ super().__init__()
394
+ self.title("NAI Artist Combination Lab")
395
+ self.geometry("1220x780")
396
+ self.minsize(1040, 680)
397
+ self.state_data = load_state()
398
+ self.current_category_index: int | None = None
399
+ self.current_base_index: int | None = None
400
+ self.current_char_index: int | None = None
401
+ self.last_tab_id: str | None = None
402
+ self.suppress_preset_events = False
403
+ self.suppress_generation_events = False
404
+ self.preview_images: list[tk.PhotoImage] = []
405
+ self.worldcup_items: list[dict] = []
406
+ self.current_pair: tuple[dict, dict] | None = None
407
+ self._build_ui()
408
+ self.protocol("WM_DELETE_WINDOW", self.on_close)
409
+ self.refresh_all()
410
+
411
+ def _build_ui(self) -> None:
412
+ self.columnconfigure(0, weight=1)
413
+ self.rowconfigure(0, weight=1)
414
+ self.tabs = ttk.Notebook(self)
415
+ self.tabs.grid(row=0, column=0, sticky="nsew")
416
+
417
+ self.generate_tab = ttk.Frame(self.tabs, padding=10)
418
+ self.tags_tab = ttk.Frame(self.tabs, padding=10)
419
+ self.presets_tab = ttk.Frame(self.tabs, padding=10)
420
+ self.history_tab = ttk.Frame(self.tabs, padding=10)
421
+ self.settings_tab = ttk.Frame(self.tabs, padding=10)
422
+ for tab, title in [
423
+ (self.generate_tab, "생성"),
424
+ (self.tags_tab, "작가 태그"),
425
+ (self.presets_tab, "프리셋"),
426
+ (self.history_tab, "히스토리 / 월드컵"),
427
+ (self.settings_tab, "API 설정"),
428
+ ]:
429
+ self.tabs.add(tab, text=title)
430
+ self.last_tab_id = self.tabs.select()
431
+ self.tabs.bind("<<NotebookTabChanged>>", self.on_tab_changed)
432
+
433
+ self._build_generate_tab()
434
+ self._build_tags_tab()
435
+ self._build_presets_tab()
436
+ self._build_history_tab()
437
+ self._build_settings_tab()
438
+
439
+ def _build_generate_tab(self) -> None:
440
+ left = ttk.Frame(self.generate_tab)
441
+ right = ttk.Frame(self.generate_tab)
442
+ left.pack(side=LEFT, fill=BOTH, expand=True)
443
+ right.pack(side=RIGHT, fill=BOTH, expand=True, padx=(12, 0))
444
+
445
+ form = ttk.LabelFrame(left, text="프롬프트 조합", padding=10)
446
+ form.pack(fill=X)
447
+ ttk.Label(form, text="베이스 + 퀄리티 프리셋").grid(row=0, column=0, sticky="w")
448
+ self.base_combo = ttk.Combobox(form, state="readonly")
449
+ self.base_combo.grid(row=0, column=1, sticky="ew", padx=6)
450
+ self.base_combo.bind("<<ComboboxSelected>>", self.on_generation_options_changed)
451
+ ttk.Label(form, text="캐릭터 프롬프트 프리셋").grid(row=1, column=0, sticky="w", pady=6)
452
+ self.char_combo = ttk.Combobox(form, state="readonly")
453
+ self.char_combo.grid(row=1, column=1, sticky="ew", padx=6)
454
+ self.char_combo.bind("<<ComboboxSelected>>", self.on_generation_options_changed)
455
+ ttk.Label(form, text="생성 개수").grid(row=2, column=0, sticky="w")
456
+ self.count_var = tk.IntVar(value=max(1, self.state_data.generation.count))
457
+ self.count_var.trace_add("write", lambda *_: self.on_generation_options_changed())
458
+ ttk.Spinbox(form, from_=1, to=200, textvariable=self.count_var, width=8).grid(row=2, column=1, sticky="w", padx=6)
459
+ form.columnconfigure(1, weight=1)
460
+
461
+ prompt_box = ttk.LabelFrame(left, text="최종 프롬프트 미리보기", padding=10)
462
+ prompt_box.pack(fill=BOTH, expand=True, pady=10)
463
+ self.prompt_preview = tk.Text(prompt_box, height=12, wrap="word")
464
+ self.prompt_preview.pack(fill=BOTH, expand=True)
465
+ buttons = ttk.Frame(left)
466
+ buttons.pack(fill=X)
467
+ ttk.Button(buttons, text="프롬프트 새로 뽑기", command=self.preview_prompt).pack(side=LEFT)
468
+ ttk.Button(buttons, text="이미지 생성 시작", command=self.start_generation).pack(side=LEFT, padx=6)
469
+ ttk.Button(buttons, text="출력 폴더 열기", command=self.pick_output_folder).pack(side=LEFT)
470
+
471
+ self.log = tk.Text(right, height=10, wrap="word")
472
+ self.log.pack(fill=BOTH, expand=True)
473
+ self.progress = ttk.Progressbar(right, mode="determinate")
474
+ self.progress.pack(fill=X, pady=(8, 0))
475
+
476
+ def _build_tags_tab(self) -> None:
477
+ self.category_list = tk.Listbox(self.tags_tab, width=28)
478
+ self.category_list.pack(side=LEFT, fill=Y)
479
+ self.category_list.bind("<<ListboxSelect>>", self.on_category_selected)
480
+ editor = ttk.Frame(self.tags_tab)
481
+ editor.pack(side=LEFT, fill=BOTH, expand=True, padx=(12, 0))
482
+
483
+ row = ttk.Frame(editor)
484
+ row.pack(fill=X)
485
+ ttk.Label(row, text="카테고리명").pack(side=LEFT)
486
+ self.cat_name = tk.StringVar()
487
+ ttk.Entry(row, textvariable=self.cat_name).pack(side=LEFT, fill=X, expand=True, padx=6)
488
+ ttk.Button(row, text="새 카테고리", command=self.new_category).pack(side=LEFT)
489
+ ttk.Button(row, text="저장", command=self.save_category).pack(side=LEFT, padx=4)
490
+ ttk.Button(row, text="삭제", command=self.delete_category).pack(side=LEFT)
491
+
492
+ grid = ttk.Frame(editor)
493
+ grid.pack(fill=X, pady=8)
494
+ self.cat_min = tk.DoubleVar(value=1.0)
495
+ self.cat_max = tk.DoubleVar(value=1.4)
496
+ self.cat_granule = tk.DoubleVar(value=0.05)
497
+ self.cat_picks = tk.StringVar(value="")
498
+ for i, (label, var) in enumerate([
499
+ ("최소 가중치", self.cat_min),
500
+ ("최대 가중치", self.cat_max),
501
+ ("granule", self.cat_granule),
502
+ ("선택 태그 수", self.cat_picks),
503
+ ]):
504
+ ttk.Label(grid, text=label).grid(row=0, column=i * 2, sticky="w", padx=(0, 4))
505
+ ttk.Entry(grid, textvariable=var, width=9).grid(row=0, column=i * 2 + 1, sticky="w", padx=(0, 12))
506
+
507
+ ttk.Label(
508
+ editor,
509
+ text="선택 태그 수: 빈칸이면 이 카테고리의 작가 태그를 모두 포함합니다. 숫자를 넣으면 프롬프트 1개마다 그 개수만큼만 랜덤 선택합니다.",
510
+ wraplength=760,
511
+ ).pack(anchor="w", pady=(0, 8))
512
+
513
+ ttk.Label(editor, text="태그 목록: 한 줄에 하나씩 입력").pack(anchor="w")
514
+ self.cat_tags = tk.Text(editor, wrap="word")
515
+ self.cat_tags.pack(fill=BOTH, expand=True)
516
+
517
+ def _build_presets_tab(self) -> None:
518
+ pane = ttk.PanedWindow(self.presets_tab, orient="horizontal")
519
+ pane.pack(fill=BOTH, expand=True)
520
+ base = ttk.Frame(pane, padding=(0, 0, 8, 0))
521
+ char = ttk.Frame(pane, padding=(8, 0, 0, 0))
522
+ pane.add(base, weight=1)
523
+ pane.add(char, weight=1)
524
+
525
+ ttk.Label(base, text="베이스 + 퀄리티 프롬프트 프리셋").pack(anchor="w")
526
+ self.base_list = tk.Listbox(base, height=7)
527
+ self.base_list.pack(fill=X)
528
+ self.base_list.bind("<<ListboxSelect>>", self.on_base_preset_selected)
529
+ self.base_name = tk.StringVar()
530
+ ttk.Label(base, text="프리셋 이름").pack(anchor="w", pady=(8, 0))
531
+ ttk.Entry(base, textvariable=self.base_name).pack(fill=X, pady=6)
532
+ base_prompt_box = ttk.LabelFrame(base, text="베이스 프롬프트", padding=8)
533
+ base_prompt_box.pack(fill=BOTH, expand=True, pady=(0, 8))
534
+ self.base_text = tk.Text(base_prompt_box, height=8, wrap="word")
535
+ self.base_text.pack(fill=BOTH, expand=True)
536
+ quality_prompt_box = ttk.LabelFrame(base, text="퀄리티 프롬프트", padding=8)
537
+ quality_prompt_box.pack(fill=BOTH, expand=True)
538
+ self.quality_text = tk.Text(quality_prompt_box, height=6, wrap="word")
539
+ self.quality_text.pack(fill=BOTH, expand=True)
540
+ row = ttk.Frame(base)
541
+ row.pack(fill=X, pady=6)
542
+ ttk.Button(row, text="베이스 새로 만들기", command=self.new_base_preset).pack(side=LEFT)
543
+ ttk.Button(row, text="저장", command=self.save_base_preset).pack(side=LEFT, padx=4)
544
+ ttk.Button(row, text="삭제", command=self.delete_base_preset).pack(side=LEFT)
545
+
546
+ ttk.Label(char, text="캐릭터 프롬프트 프리셋").pack(anchor="w")
547
+ ttk.Label(
548
+ char,
549
+ text="생성 시 캐릭터 프롬프트는 쉼표가 아니라 NovelAI V4+ 멀티 캐릭터 문법인 | 로 분리됩니다.",
550
+ wraplength=520,
551
+ ).pack(anchor="w", pady=(0, 6))
552
+ self.char_list = tk.Listbox(char, height=8)
553
+ self.char_list.pack(fill=X)
554
+ self.char_list.bind("<<ListboxSelect>>", self.on_char_preset_selected)
555
+ self.char_name = tk.StringVar()
556
+ ttk.Label(char, text="프리셋 이름").pack(anchor="w", pady=(8, 0))
557
+ ttk.Entry(char, textvariable=self.char_name).pack(fill=X, pady=6)
558
+ self.char_prompts: list[tk.Text] = []
559
+ self.char_negs: list[tk.Text] = []
560
+ for idx in range(3):
561
+ box = ttk.LabelFrame(char, text=f"캐릭터 {idx + 1}", padding=6)
562
+ box.pack(fill=X, pady=3)
563
+ p = tk.Text(box, height=3, wrap="word")
564
+ n = tk.Text(box, height=2, wrap="word")
565
+ ttk.Label(box, text=f"캐릭터 {idx + 1} 프롬프트").pack(anchor="w")
566
+ p.pack(fill=X)
567
+ ttk.Label(box, text=f"캐릭터 {idx + 1} 네거티브 프롬프트").pack(anchor="w")
568
+ n.pack(fill=X)
569
+ self.char_prompts.append(p)
570
+ self.char_negs.append(n)
571
+ row = ttk.Frame(char)
572
+ row.pack(fill=X, pady=6)
573
+ ttk.Button(row, text="캐릭터 새로 만들기", command=self.new_char_preset).pack(side=LEFT)
574
+ ttk.Button(row, text="저장", command=self.save_char_preset).pack(side=LEFT, padx=4)
575
+ ttk.Button(row, text="삭제", command=self.delete_char_preset).pack(side=LEFT)
576
+
577
+ def _build_history_tab(self) -> None:
578
+ top = ttk.Frame(self.history_tab)
579
+ top.pack(fill=X)
580
+ ttk.Label(top, text="히스토리").pack(side=LEFT)
581
+ self.history_combo = ttk.Combobox(top, state="readonly", width=58)
582
+ self.history_combo.pack(side=LEFT, padx=6)
583
+ self.history_combo.bind("<<ComboboxSelected>>", lambda _: self.load_history_selection())
584
+ ttk.Button(top, text="월드컵 시작", command=self.start_worldcup).pack(side=LEFT)
585
+
586
+ body = ttk.PanedWindow(self.history_tab, orient="horizontal")
587
+ body.pack(fill=BOTH, expand=True, pady=8)
588
+ self.history_detail = tk.Text(body, wrap="word", width=46)
589
+ self.worldcup_frame = ttk.Frame(body)
590
+ body.add(self.history_detail, weight=1)
591
+ body.add(self.worldcup_frame, weight=2)
592
+
593
+ self.left_img = ttk.Label(self.worldcup_frame)
594
+ self.right_img = ttk.Label(self.worldcup_frame)
595
+ self.left_img.grid(row=0, column=0, sticky="nsew", padx=8)
596
+ self.right_img.grid(row=0, column=1, sticky="nsew", padx=8)
597
+ ttk.Button(self.worldcup_frame, text="왼쪽 선택", command=lambda: self.pick_worldcup("left")).grid(row=1, column=0, sticky="ew", padx=8, pady=8)
598
+ ttk.Button(self.worldcup_frame, text="오른쪽 선택", command=lambda: self.pick_worldcup("right")).grid(row=1, column=1, sticky="ew", padx=8, pady=8)
599
+ self.worldcup_status = ttk.Label(self.worldcup_frame, text="")
600
+ self.worldcup_status.grid(row=2, column=0, columnspan=2, sticky="ew", padx=8)
601
+ self.worldcup_frame.columnconfigure(0, weight=1)
602
+ self.worldcup_frame.columnconfigure(1, weight=1)
603
+ self.worldcup_frame.rowconfigure(0, weight=1)
604
+
605
+ def _build_settings_tab(self) -> None:
606
+ frame = ttk.Frame(self.settings_tab)
607
+ frame.pack(fill=X)
608
+ self.api_vars = {
609
+ "token": tk.StringVar(),
610
+ "endpoint": tk.StringVar(),
611
+ "user_agent": tk.StringVar(),
612
+ "model": tk.StringVar(),
613
+ "width": tk.IntVar(),
614
+ "height": tk.IntVar(),
615
+ "steps": tk.IntVar(),
616
+ "scale": tk.DoubleVar(),
617
+ "uncond_scale": tk.DoubleVar(),
618
+ "guidance_rescale": tk.DoubleVar(),
619
+ "sampler": tk.StringVar(),
620
+ "noise_schedule": tk.StringVar(),
621
+ "n_samples": tk.IntVar(),
622
+ "seed": tk.IntVar(),
623
+ "mock_mode": tk.BooleanVar(),
624
+ }
625
+ labels = [
626
+ ("API 토큰", "token"),
627
+ ("Endpoint", "endpoint"),
628
+ ("User-Agent", "user_agent"),
629
+ ("Model", "model"),
630
+ ("Width", "width"),
631
+ ("Height", "height"),
632
+ ("Steps", "steps"),
633
+ ("Scale", "scale"),
634
+ ("Uncond Scale", "uncond_scale"),
635
+ ("Guidance Rescale", "guidance_rescale"),
636
+ ("Sampler", "sampler"),
637
+ ("Noise Schedule", "noise_schedule"),
638
+ ("N Samples", "n_samples"),
639
+ ("Seed (-1=random)", "seed"),
640
+ ]
641
+ for r, (label, key) in enumerate(labels):
642
+ ttk.Label(frame, text=label).grid(row=r, column=0, sticky="w", pady=3)
643
+ show = "*" if key == "token" else None
644
+ ttk.Entry(frame, textvariable=self.api_vars[key], show=show).grid(row=r, column=1, sticky="ew", padx=8)
645
+ ttk.Checkbutton(frame, text="목업 모드 사용: 토큰 없이 테스트 이미지 생성", variable=self.api_vars["mock_mode"]).grid(row=len(labels), column=1, sticky="w", pady=8)
646
+ ttk.Button(frame, text="API 설정 저장", command=self.save_api_settings).grid(row=len(labels) + 1, column=1, sticky="w")
647
+ frame.columnconfigure(1, weight=1)
648
+
649
+ neg_box = ttk.LabelFrame(self.settings_tab, text="공통 네거티브 프롬프트", padding=8)
650
+ neg_box.pack(fill=BOTH, expand=True, pady=12)
651
+ self.negative_text = tk.Text(neg_box, height=8, wrap="word")
652
+ self.negative_text.pack(fill=BOTH, expand=True)
653
+ uc_box = ttk.LabelFrame(self.settings_tab, text="UC 프롬프트 (parameters.uc)", padding=8)
654
+ uc_box.pack(fill=BOTH, expand=True, pady=(0, 12))
655
+ self.uc_text = tk.Text(uc_box, height=5, wrap="word")
656
+ self.uc_text.pack(fill=BOTH, expand=True)
657
+ ttk.Button(uc_box, text="네거티브 / UC 저장", command=self.save_negative).pack(anchor="e", pady=6)
658
+
659
+ def refresh_all(self) -> None:
660
+ self.refresh_categories()
661
+ self.refresh_presets()
662
+ self.refresh_api()
663
+ self.refresh_history()
664
+ self.preview_prompt()
665
+
666
+ def log_line(self, text: str) -> None:
667
+ self.log.insert(END, text + "\n")
668
+ self.log.see(END)
669
+
670
+ def selected_index(self, listbox: tk.Listbox) -> int | None:
671
+ sel = listbox.curselection()
672
+ return int(sel[0]) if sel else None
673
+
674
+ def on_close(self) -> None:
675
+ self.save_category_from_editor()
676
+ self.save_base_preset_from_editor()
677
+ self.save_char_preset_from_editor()
678
+ self.save_negative()
679
+ self.save_generation_options()
680
+ self.destroy()
681
+
682
+ def on_tab_changed(self, _event: tk.Event) -> None:
683
+ if self.last_tab_id == str(self.tags_tab):
684
+ self.save_category_from_editor()
685
+ elif self.last_tab_id == str(self.presets_tab):
686
+ self.save_base_preset_from_editor()
687
+ self.save_char_preset_from_editor()
688
+ self.last_tab_id = self.tabs.select()
689
+
690
+ def on_generation_options_changed(self, _event: tk.Event | None = None) -> None:
691
+ if self.suppress_generation_events:
692
+ return
693
+ self.save_generation_options()
694
+ self.preview_prompt()
695
+
696
+ def save_generation_options(self) -> None:
697
+ try:
698
+ count = max(1, int(self.count_var.get()))
699
+ except (tk.TclError, ValueError):
700
+ count = max(1, self.state_data.generation.count)
701
+ self.state_data.generation = GenerationSettings(
702
+ base_preset=self.base_combo.get(),
703
+ character_preset=self.char_combo.get(),
704
+ count=count,
705
+ )
706
+ save_state(self.state_data)
707
+
708
+ def refresh_categories(self) -> None:
709
+ self.category_list.delete(0, END)
710
+ for cat in self.state_data.categories:
711
+ self.category_list.insert(END, cat.name)
712
+ if self.state_data.categories and not self.category_list.curselection():
713
+ self.category_list.selection_set(0)
714
+ self.load_category_at(0)
715
+
716
+ def on_category_selected(self, _event: tk.Event) -> None:
717
+ idx = self.selected_index(self.category_list)
718
+ if idx is None:
719
+ return
720
+ if idx != self.current_category_index:
721
+ self.save_category_from_editor()
722
+ self.load_category_at(idx)
723
+
724
+ def load_category_at(self, idx: int) -> None:
725
+ if idx < 0 or idx >= len(self.state_data.categories):
726
+ return
727
+ cat = self.state_data.categories[idx]
728
+ self.current_category_index = idx
729
+ self.cat_name.set(cat.name)
730
+ self.cat_min.set(cat.min_weight)
731
+ self.cat_max.set(cat.max_weight)
732
+ self.cat_granule.set(cat.granule)
733
+ self.cat_picks.set("" if cat.picks <= 0 else str(cat.picks))
734
+ self.cat_tags.delete("1.0", END)
735
+ self.cat_tags.insert("1.0", "\n".join(cat.tags))
736
+
737
+ def new_category(self) -> None:
738
+ self.save_category_from_editor()
739
+ self.category_list.selection_clear(0, END)
740
+ self.current_category_index = None
741
+ self.cat_name.set("커스텀 카테고리")
742
+ self.cat_min.set(0.5)
743
+ self.cat_max.set(1.2)
744
+ self.cat_granule.set(0.1)
745
+ self.cat_picks.set("")
746
+ self.cat_tags.delete("1.0", END)
747
+
748
+ def category_editor_has_content(self) -> bool:
749
+ name = self.cat_name.get().strip()
750
+ tags = self.cat_tags.get("1.0", END).strip()
751
+ return bool(tags or (name and name != "커스텀 카테고리"))
752
+
753
+ def category_from_editor(self) -> Category:
754
+ min_weight = float(self.cat_min.get())
755
+ max_weight = float(self.cat_max.get())
756
+ granule = float(self.cat_granule.get())
757
+ picks_text = self.cat_picks.get().strip()
758
+ picks = max(1, int(picks_text)) if picks_text else 0
759
+ return Category(
760
+ self.cat_name.get().strip() or "이름 없는 카테고리",
761
+ [line.strip() for line in self.cat_tags.get("1.0", END).splitlines() if line.strip()],
762
+ min_weight,
763
+ max_weight,
764
+ granule,
765
+ picks,
766
+ )
767
+
768
+ def save_category_from_editor(self) -> int | None:
769
+ if self.current_category_index is None and not self.category_editor_has_content():
770
+ return None
771
+ try:
772
+ cat = self.category_from_editor()
773
+ except (tk.TclError, ValueError) as exc:
774
+ messagebox.showwarning("카테고리 저장 실패", f"가중치, granule, 선택 태그 수 값을 확인해주세요. 선택 태그 수는 빈칸 또는 1 이상의 숫자여야 합니다.\n\n{exc}")
775
+ return self.current_category_index
776
+
777
+ if self.current_category_index is None:
778
+ self.state_data.categories.append(cat)
779
+ self.current_category_index = len(self.state_data.categories) - 1
780
+ self.category_list.insert(END, cat.name)
781
+ elif 0 <= self.current_category_index < len(self.state_data.categories):
782
+ self.state_data.categories[self.current_category_index] = cat
783
+ self.category_list.delete(self.current_category_index)
784
+ self.category_list.insert(self.current_category_index, cat.name)
785
+
786
+ save_state(self.state_data)
787
+ self.preview_prompt()
788
+ return self.current_category_index
789
+
790
+ def save_category(self) -> None:
791
+ idx = self.save_category_from_editor()
792
+ if idx is not None:
793
+ self.category_list.selection_clear(0, END)
794
+ self.category_list.selection_set(idx)
795
+
796
+ def delete_category(self) -> None:
797
+ idx = self.current_category_index
798
+ if idx is None:
799
+ return
800
+ del self.state_data.categories[idx]
801
+ self.current_category_index = None
802
+ save_state(self.state_data)
803
+ self.refresh_categories()
804
+
805
+ def refresh_presets(self) -> None:
806
+ base_names = [p.name for p in self.state_data.base_presets]
807
+ char_names = [p.name for p in self.state_data.character_presets]
808
+ self.suppress_generation_events = True
809
+ self.base_combo["values"] = base_names
810
+ self.char_combo["values"] = char_names
811
+ saved_base = self.state_data.generation.base_preset
812
+ saved_char = self.state_data.generation.character_preset
813
+ if base_names:
814
+ self.base_combo.set(saved_base if saved_base in base_names else base_names[0])
815
+ else:
816
+ self.base_combo.set("")
817
+ if char_names:
818
+ self.char_combo.set(saved_char if saved_char in char_names else char_names[0])
819
+ else:
820
+ self.char_combo.set("")
821
+ self.count_var.set(max(1, self.state_data.generation.count))
822
+ self.suppress_generation_events = False
823
+
824
+ self.base_list.delete(0, END)
825
+ for item in base_names:
826
+ self.base_list.insert(END, item)
827
+ self.char_list.delete(0, END)
828
+ for item in char_names:
829
+ self.char_list.insert(END, item)
830
+ self.save_generation_options()
831
+
832
+ def refresh_preset_names(self) -> None:
833
+ base_names = [p.name for p in self.state_data.base_presets]
834
+ char_names = [p.name for p in self.state_data.character_presets]
835
+ self.suppress_generation_events = True
836
+ self.base_combo["values"] = base_names
837
+ self.char_combo["values"] = char_names
838
+ if self.base_combo.get() not in base_names and base_names:
839
+ self.base_combo.set(base_names[0])
840
+ if self.char_combo.get() not in char_names and char_names:
841
+ self.char_combo.set(char_names[0])
842
+ self.suppress_generation_events = False
843
+ self.save_generation_options()
844
+
845
+ def find_base(self) -> PromptPreset | None:
846
+ name = self.base_combo.get()
847
+ return next((p for p in self.state_data.base_presets if p.name == name), None)
848
+
849
+ def find_char(self) -> CharacterPreset | None:
850
+ name = self.char_combo.get()
851
+ return next((p for p in self.state_data.character_presets if p.name == name), None)
852
+
853
+ def on_base_preset_selected(self, _event: tk.Event) -> None:
854
+ if self.suppress_preset_events:
855
+ return
856
+ idx = self.selected_index(self.base_list)
857
+ if idx is None:
858
+ return
859
+ if idx != self.current_base_index:
860
+ self.save_base_preset_from_editor()
861
+ self.load_base_preset_at(idx)
862
+
863
+ def new_base_preset(self) -> None:
864
+ self.save_base_preset_from_editor()
865
+ self.base_list.selection_clear(0, END)
866
+ self.current_base_index = None
867
+ self.base_name.set("새 베이스")
868
+ self.base_text.delete("1.0", END)
869
+ self.quality_text.delete("1.0", END)
870
+
871
+ def load_base_preset(self) -> None:
872
+ idx = self.selected_index(self.base_list)
873
+ if idx is None:
874
+ return
875
+ self.load_base_preset_at(idx)
876
+
877
+ def load_base_preset_at(self, idx: int) -> None:
878
+ if idx < 0 or idx >= len(self.state_data.base_presets):
879
+ return
880
+ preset = self.state_data.base_presets[idx]
881
+ self.current_base_index = idx
882
+ self.base_name.set(preset.name)
883
+ self.base_text.delete("1.0", END)
884
+ self.base_text.insert("1.0", preset.prompt)
885
+ self.quality_text.delete("1.0", END)
886
+ self.quality_text.insert("1.0", preset.quality_prompt)
887
+
888
+ def base_preset_editor_has_content(self) -> bool:
889
+ name = self.base_name.get().strip()
890
+ return bool(
891
+ self.base_text.get("1.0", END).strip()
892
+ or self.quality_text.get("1.0", END).strip()
893
+ or (name and name != "새 베이스")
894
+ )
895
+
896
+ def base_preset_from_editor(self) -> PromptPreset:
897
+ return PromptPreset(
898
+ self.base_name.get().strip() or "이름 없는 베이스",
899
+ self.base_text.get("1.0", END).strip(),
900
+ self.quality_text.get("1.0", END).strip(),
901
+ )
902
+
903
+ def save_base_preset_from_editor(self) -> int | None:
904
+ if self.current_base_index is None and not self.base_preset_editor_has_content():
905
+ return None
906
+ preset = self.base_preset_from_editor()
907
+ if self.current_base_index is None:
908
+ self.state_data.base_presets.append(preset)
909
+ self.current_base_index = len(self.state_data.base_presets) - 1
910
+ self.base_list.insert(END, preset.name)
911
+ elif 0 <= self.current_base_index < len(self.state_data.base_presets):
912
+ self.state_data.base_presets[self.current_base_index] = preset
913
+ self.base_list.delete(self.current_base_index)
914
+ self.base_list.insert(self.current_base_index, preset.name)
915
+ save_state(self.state_data)
916
+ self.base_combo.set(preset.name)
917
+ self.refresh_preset_names()
918
+ self.preview_prompt()
919
+ return self.current_base_index
920
+
921
+ def save_base_preset(self) -> None:
922
+ idx = self.save_base_preset_from_editor()
923
+ if idx is not None:
924
+ self.base_list.selection_clear(0, END)
925
+ self.base_list.selection_set(idx)
926
+
927
+ def delete_base_preset(self) -> None:
928
+ idx = self.current_base_index
929
+ if idx is None:
930
+ return
931
+ del self.state_data.base_presets[idx]
932
+ self.current_base_index = None
933
+ self.base_name.set("")
934
+ self.base_text.delete("1.0", END)
935
+ self.quality_text.delete("1.0", END)
936
+ save_state(self.state_data)
937
+ self.refresh_presets()
938
+
939
+ def on_char_preset_selected(self, _event: tk.Event) -> None:
940
+ if self.suppress_preset_events:
941
+ return
942
+ idx = self.selected_index(self.char_list)
943
+ if idx is None:
944
+ return
945
+ if idx != self.current_char_index:
946
+ self.save_char_preset_from_editor()
947
+ self.load_char_preset_at(idx)
948
+
949
+ def new_char_preset(self) -> None:
950
+ self.save_char_preset_from_editor()
951
+ self.char_list.selection_clear(0, END)
952
+ self.current_char_index = None
953
+ self.char_name.set("새 캐릭터")
954
+ for box in self.char_prompts + self.char_negs:
955
+ box.delete("1.0", END)
956
+
957
+ def load_char_preset(self) -> None:
958
+ idx = self.selected_index(self.char_list)
959
+ if idx is None:
960
+ return
961
+ self.load_char_preset_at(idx)
962
+
963
+ def load_char_preset_at(self, idx: int) -> None:
964
+ if idx < 0 or idx >= len(self.state_data.character_presets):
965
+ return
966
+ preset = self.state_data.character_presets[idx]
967
+ self.current_char_index = idx
968
+ self.char_name.set(preset.name)
969
+ for i in range(3):
970
+ self.char_prompts[i].delete("1.0", END)
971
+ self.char_prompts[i].insert("1.0", preset.prompts[i] if i < len(preset.prompts) else "")
972
+ self.char_negs[i].delete("1.0", END)
973
+ self.char_negs[i].insert("1.0", preset.negatives[i] if i < len(preset.negatives) else "")
974
+
975
+ def char_preset_editor_has_content(self) -> bool:
976
+ name = self.char_name.get().strip()
977
+ prompts = [box.get("1.0", END).strip() for box in self.char_prompts]
978
+ negatives = [box.get("1.0", END).strip() for box in self.char_negs]
979
+ return bool(any(prompts) or any(negatives) or (name and name != "새 캐릭터"))
980
+
981
+ def char_preset_from_editor(self) -> CharacterPreset:
982
+ return CharacterPreset(
983
+ self.char_name.get().strip() or "이름 없는 캐릭터",
984
+ [box.get("1.0", END).strip() for box in self.char_prompts],
985
+ [box.get("1.0", END).strip() for box in self.char_negs],
986
+ )
987
+
988
+ def save_char_preset_from_editor(self) -> int | None:
989
+ if self.current_char_index is None and not self.char_preset_editor_has_content():
990
+ return None
991
+ preset = self.char_preset_from_editor()
992
+ if self.current_char_index is None:
993
+ self.state_data.character_presets.append(preset)
994
+ self.current_char_index = len(self.state_data.character_presets) - 1
995
+ self.char_list.insert(END, preset.name)
996
+ elif 0 <= self.current_char_index < len(self.state_data.character_presets):
997
+ self.state_data.character_presets[self.current_char_index] = preset
998
+ self.char_list.delete(self.current_char_index)
999
+ self.char_list.insert(self.current_char_index, preset.name)
1000
+ save_state(self.state_data)
1001
+ self.char_combo.set(preset.name)
1002
+ self.refresh_preset_names()
1003
+ self.preview_prompt()
1004
+ return self.current_char_index
1005
+
1006
+ def save_char_preset(self) -> None:
1007
+ idx = self.save_char_preset_from_editor()
1008
+ if idx is not None:
1009
+ self.char_list.selection_clear(0, END)
1010
+ self.char_list.selection_set(idx)
1011
+
1012
+ def delete_char_preset(self) -> None:
1013
+ idx = self.current_char_index
1014
+ if idx is None:
1015
+ return
1016
+ del self.state_data.character_presets[idx]
1017
+ self.current_char_index = None
1018
+ self.char_name.set("")
1019
+ for box in self.char_prompts + self.char_negs:
1020
+ box.delete("1.0", END)
1021
+ save_state(self.state_data)
1022
+ self.refresh_presets()
1023
+
1024
+ def refresh_api(self) -> None:
1025
+ api = self.state_data.api
1026
+ for key, var in self.api_vars.items():
1027
+ var.set(getattr(api, key))
1028
+ self.negative_text.delete("1.0", END)
1029
+ self.negative_text.insert("1.0", self.state_data.negative_prompt)
1030
+ self.uc_text.delete("1.0", END)
1031
+ self.uc_text.insert("1.0", self.state_data.uc_prompt)
1032
+
1033
+ def save_api_settings(self, show_message: bool = True) -> None:
1034
+ api = self.state_data.api
1035
+ for key, var in self.api_vars.items():
1036
+ setattr(api, key, var.get())
1037
+ save_state(self.state_data)
1038
+ if show_message:
1039
+ messagebox.showinfo("저장 완료", "API 설정을 저장했습니다.")
1040
+
1041
+ def save_negative(self) -> None:
1042
+ self.state_data.negative_prompt = self.negative_text.get("1.0", END).strip()
1043
+ self.state_data.uc_prompt = self.uc_text.get("1.0", END).strip()
1044
+ save_state(self.state_data)
1045
+
1046
+ def random_artist_tags(self) -> list[dict]:
1047
+ result = []
1048
+ for cat in self.state_data.categories:
1049
+ tags = parse_artist_tags(cat.tags)
1050
+ if not tags:
1051
+ continue
1052
+ weights = float_range(cat.min_weight, cat.max_weight, cat.granule)
1053
+ pick_count = len(tags) if cat.picks <= 0 else min(cat.picks, len(tags))
1054
+ for tag in random.sample(tags, pick_count):
1055
+ weight = random.choice(weights) if weights else cat.min_weight
1056
+ result.append({"category": cat.name, "tag": tag, "weight": weight, "prompt": weight_tag(tag, weight)})
1057
+ return result
1058
+
1059
+ def build_prompt(self) -> tuple[str, str, str, list[dict]]:
1060
+ base = self.find_base()
1061
+ char = self.find_char()
1062
+ artists = self.random_artist_tags()
1063
+ base_chunks = []
1064
+ if base and base.prompt.strip():
1065
+ base_chunks.append(base.prompt.strip())
1066
+ if artists:
1067
+ base_chunks.append(", ".join(item["prompt"] for item in artists))
1068
+ quality_prompt = ""
1069
+ if self.state_data.quality_override_prompt.strip():
1070
+ quality_prompt = self.state_data.quality_override_prompt.strip()
1071
+ elif base and base.quality_prompt.strip():
1072
+ quality_prompt = base.quality_prompt.strip()
1073
+ if quality_prompt:
1074
+ base_chunks.append(quality_prompt)
1075
+ base_prompt = ", ".join(base_chunks)
1076
+ character_prompts = []
1077
+ if char:
1078
+ character_prompts = [p.strip() for p in char.prompts if p.strip()]
1079
+ prompt_parts = [base_prompt] if base_prompt else []
1080
+ prompt_parts.extend(character_prompts)
1081
+ negative = [self.state_data.negative_prompt.strip()]
1082
+ if char:
1083
+ negative.extend(n.strip() for n in char.negatives if n.strip())
1084
+ negative_prompt = ", ".join(n for n in negative if n)
1085
+ uc_prompt = self.state_data.uc_prompt.strip() or negative_prompt
1086
+ return " | ".join(prompt_parts), negative_prompt, uc_prompt, artists
1087
+
1088
+ def preview_prompt(self) -> None:
1089
+ prompt, negative, uc_prompt, artists = self.build_prompt()
1090
+ base = self.find_base()
1091
+ char = self.find_char()
1092
+ self.prompt_preview.delete("1.0", END)
1093
+ self.prompt_preview.insert("1.0", "[Final Prompt]\n")
1094
+ self.prompt_preview.insert(END, f"{prompt}\n\n")
1095
+ self.prompt_preview.insert(END, "[Base Prompt]\n")
1096
+ self.prompt_preview.insert(END, f"{base.prompt if base else ''}\n\n")
1097
+ self.prompt_preview.insert(END, "[Artist Tags]\n")
1098
+ for item in artists:
1099
+ self.prompt_preview.insert(END, f"- {item['category']}: {item['tag']} / {item['weight']}\n")
1100
+ self.prompt_preview.insert(END, "\n")
1101
+ self.prompt_preview.insert(END, "[Quality Prompt]\n")
1102
+ self.prompt_preview.insert(END, f"{base.quality_prompt if base else ''}\n\n")
1103
+ self.prompt_preview.insert(END, "[Character Prompts]\n")
1104
+ if char:
1105
+ for idx, char_prompt in enumerate(char.prompts, start=1):
1106
+ if char_prompt.strip():
1107
+ self.prompt_preview.insert(END, f"{idx}. {char_prompt.strip()}\n")
1108
+ self.prompt_preview.insert(END, f"\n[Negative]\n{negative}\n\n[UC]\n{uc_prompt}\n")
1109
+
1110
+ def start_generation(self) -> None:
1111
+ count = max(1, int(self.count_var.get()))
1112
+ self.save_generation_options()
1113
+ self.save_negative()
1114
+ base = self.find_base()
1115
+ char = self.find_char()
1116
+ if not base or not char:
1117
+ messagebox.showwarning("프리셋 필요", "베이스 프리셋과 캐릭터 프리셋을 선택해주세요.")
1118
+ return
1119
+ self.save_api_settings(show_message=False)
1120
+ run_id = now_id()
1121
+ out_dir = OUTPUT_DIR / f"{run_id}_{safe_path_name(base.name)}_{safe_path_name(char.name)}"
1122
+ out_dir.mkdir(parents=True, exist_ok=True)
1123
+ self.progress["value"] = 0
1124
+ self.progress["maximum"] = count
1125
+ self.log_line(f"생성 시작: {count}장 -> {out_dir}")
1126
+ thread = threading.Thread(target=self.generate_batch, args=(count, out_dir, base.name, char.name), daemon=True)
1127
+ thread.start()
1128
+
1129
+ def generate_batch(self, count: int, out_dir: Path, base_name: str, char_name: str) -> None:
1130
+ client = NovelAIClient(self.state_data.api)
1131
+ items = []
1132
+ for idx in range(count):
1133
+ prompt, negative, uc_prompt, artists = self.build_prompt()
1134
+ path = out_dir / f"image_{idx + 1:03}.png"
1135
+ 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,
1141
+ "artists": artists,
1142
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1143
+ }
1144
+ try:
1145
+ client.generate(prompt, negative, uc_prompt, path)
1146
+ items.append(metadata)
1147
+ self.after(0, self.log_line, f"{idx + 1}/{count} 완료: {path.name}")
1148
+ except Exception as exc:
1149
+ metadata["error"] = str(exc)
1150
+ items.append(metadata)
1151
+ self.after(0, self.log_line, f"{idx + 1}/{count} 실패: {exc}")
1152
+ self.after(0, lambda v=idx + 1: self.progress.configure(value=v))
1153
+
1154
+ history = {
1155
+ "id": out_dir.name,
1156
+ "base_preset": base_name,
1157
+ "character_preset": char_name,
1158
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1159
+ "output_dir": str(out_dir),
1160
+ "items": items,
1161
+ }
1162
+ self.state_data.history.insert(0, history)
1163
+ save_state(self.state_data)
1164
+ self.after(0, self.refresh_history)
1165
+ self.after(0, self.log_line, "생성 작업이 끝났습니다.")
1166
+
1167
+ def pick_output_folder(self) -> None:
1168
+ path = filedialog.askdirectory(initialdir=str(OUTPUT_DIR))
1169
+ if path:
1170
+ self.log_line(f"출력 폴더: {path}")
1171
+
1172
+ def refresh_history(self) -> None:
1173
+ values = [
1174
+ f"{h['created_at']} | {h['base_preset']} + {h['character_preset']} | {len(h.get('items', []))}장"
1175
+ for h in self.state_data.history
1176
+ ]
1177
+ self.history_combo["values"] = values
1178
+ if values:
1179
+ self.history_combo.set(values[0])
1180
+ self.load_history_selection()
1181
+
1182
+ def selected_history(self) -> dict | None:
1183
+ idx = self.history_combo.current()
1184
+ if idx < 0 or idx >= len(self.state_data.history):
1185
+ return None
1186
+ return self.state_data.history[idx]
1187
+
1188
+ def load_history_selection(self) -> None:
1189
+ history = self.selected_history()
1190
+ self.history_detail.delete("1.0", END)
1191
+ if not history:
1192
+ return
1193
+ self.history_detail.insert(END, json.dumps(history, ensure_ascii=False, indent=2))
1194
+
1195
+ def start_worldcup(self) -> None:
1196
+ history = self.selected_history()
1197
+ if not history:
1198
+ return
1199
+ self.worldcup_items = [item for item in history.get("items", []) if Path(item.get("path", "")).exists()]
1200
+ random.shuffle(self.worldcup_items)
1201
+ if len(self.worldcup_items) < 2:
1202
+ self.worldcup_status.configure(text="월드컵을 하려면 이미지가 최소 2장 필요합니다.")
1203
+ return
1204
+ self.next_worldcup_pair()
1205
+
1206
+ def load_photo(self, item: dict) -> tk.PhotoImage:
1207
+ photo = tk.PhotoImage(file=item["path"])
1208
+ max_w, max_h = 420, 560
1209
+ factor = max(1, int(max(photo.width() / max_w, photo.height() / max_h)))
1210
+ if factor > 1:
1211
+ photo = photo.subsample(factor, factor)
1212
+ self.preview_images.append(photo)
1213
+ self.preview_images = self.preview_images[-8:]
1214
+ return photo
1215
+
1216
+ def next_worldcup_pair(self) -> None:
1217
+ if len(self.worldcup_items) == 1:
1218
+ winner = self.worldcup_items[0]
1219
+ self.left_img.configure(image=self.load_photo(winner))
1220
+ self.right_img.configure(image="")
1221
+ self.worldcup_status.configure(text=f"우승: {winner['path']}\n프롬프트: {winner['prompt']}")
1222
+ return
1223
+ if len(self.worldcup_items) < 2:
1224
+ return
1225
+ left = self.worldcup_items.pop()
1226
+ right = self.worldcup_items.pop()
1227
+ self.current_pair = (left, right)
1228
+ self.left_img.configure(image=self.load_photo(left))
1229
+ self.right_img.configure(image=self.load_photo(right))
1230
+ self.worldcup_status.configure(text=f"남은 후보: {len(self.worldcup_items) + 2}")
1231
+
1232
+ def pick_worldcup(self, side: str) -> None:
1233
+ if not self.current_pair:
1234
+ return
1235
+ winner = self.current_pair[0] if side == "left" else self.current_pair[1]
1236
+ self.worldcup_items.insert(0, winner)
1237
+ self.current_pair = None
1238
+ random.shuffle(self.worldcup_items)
1239
+ self.next_worldcup_pair()
1240
+
1241
+
1242
+ if __name__ == "__main__":
1243
+ if tk is None:
1244
+ raise SystemExit("tkinter is not available. Use `python launcher.py` or `python web_app.py` for the web UI.")
1245
+ ensure_dirs()
1246
+ app = App()
1247
+ app.mainloop()