nai-aclab 1.0.9 → 1.0.11

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 CHANGED
@@ -316,6 +316,7 @@ class BattingScene:
316
316
  name: str = ""
317
317
  base_preset: str = ""
318
318
  character_preset: str = ""
319
+ image_size: str = "portrait"
319
320
  count: int = 2
320
321
 
321
322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nai-aclab",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Local web UI for experimenting with NovelAI artist tag weight combinations.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/web/index.html CHANGED
@@ -38,10 +38,9 @@
38
38
  <h1 id="pageTitle">생성</h1>
39
39
  <p id="pageSubtitle">프리셋, 작가 조합, 생성 상태를 한 화면에서 조정합니다.</p>
40
40
  </div>
41
- <div class="top-actions">
42
- <button class="ghost-button" id="refreshButton">새로고침</button>
43
- <button class="primary-button" id="generateButton">이미지 생성</button>
44
- </div>
41
+ <div class="top-actions">
42
+ <button class="ghost-button" id="refreshButton">새로고침</button>
43
+ </div>
45
44
  </header>
46
45
 
47
46
  <section id="generate" class="tab-page active">
package/web/main.js CHANGED
@@ -40,23 +40,42 @@ const pageCopy = {
40
40
  history: ["히스토리", "생성 결과를 훑어보고 이상형 월드컵으로 선호 조합을 고릅니다."],
41
41
  };
42
42
 
43
- const apiFields = [
44
- ["token", "API 토큰", "password"],
45
- ["endpoint", "Endpoint", "text"],
46
- ["model", "Model", "text"],
47
- ["steps", "Steps", "number"],
48
- ["scale", "Scale", "number"],
49
- ["guidance_rescale", "Guidance Rescale", "number"],
50
- ["sampler", "Sampler", "text"],
51
- ["noise_schedule", "Noise Schedule", "text"],
52
- ["seed", "Seed (-1=random)", "number"],
53
- ];
54
-
55
- const imageSizeOptions = {
56
- portrait: { label: "Normal Portrait", width: 832, height: 1216 },
57
- landscape: { label: "Landscape", width: 1216, height: 832 },
58
- square: { label: "Square", width: 1024, height: 1024 },
59
- };
43
+ const apiFields = [
44
+ ["token", "API 토큰", "password"],
45
+ ["endpoint", "Endpoint", "text"],
46
+ ["model", "Model", "text"],
47
+ ["steps", "Steps", "number"],
48
+ ["scale", "Scale", "number"],
49
+ ["guidance_rescale", "Guidance Rescale", "number"],
50
+ ["sampler", "Sampler", "select"],
51
+ ["noise_schedule", "Noise Schedule", "select"],
52
+ ["seed", "Seed (-1=random)", "number"],
53
+ ];
54
+
55
+ const apiSelectOptions = {
56
+ sampler: [
57
+ ["k_euler_ancestral", "Euler Ancestral"],
58
+ ["k_euler", "Euler"],
59
+ ["k_dpmpp_2m", "DPM++ 2M"],
60
+ ["k_dpmpp_2s_ancestral", "DPM++ 2S Ancestral"],
61
+ ["k_dpmpp_sde", "DPM++ SDE"],
62
+ ["k_dpm_2", "DPM2"],
63
+ ["k_dpm_fast", "DPM Fast"],
64
+ ["ddim_v3", "DDIM"],
65
+ ],
66
+ noise_schedule: [
67
+ ["karras", "Karras"],
68
+ ["native", "Native"],
69
+ ["exponential", "Exponential"],
70
+ ["polyexponential", "Polyexponential"],
71
+ ],
72
+ };
73
+
74
+ const imageSizeOptions = {
75
+ portrait: { label: "Normal Portrait", width: 832, height: 1216 },
76
+ landscape: { label: "Landscape", width: 1216, height: 832 },
77
+ square: { label: "Square", width: 1024, height: 1024 },
78
+ };
60
79
  const defaultNovelAiEndpoint = "https://image.novelai.net/ai/generate-image";
61
80
 
62
81
  function escapeHtml(value) {
@@ -130,20 +149,26 @@ function scheduleSave() {
130
149
  saveTimer = setTimeout(saveNow, 450);
131
150
  }
132
151
 
133
- async function saveNow() {
134
- if (!state) return;
135
- syncEditorsToState();
136
- try {
137
- const data = await request("/api/state", {
138
- method: "POST",
139
- body: JSON.stringify({ state }),
140
- });
141
- state = data.state;
142
- setSaveState("자동 저장됨");
143
- } catch (error) {
144
- setSaveState("저장 실패");
145
- console.error(error);
146
- }
152
+ async function saveNow() {
153
+ if (!state) return;
154
+ const tokenInput = $("#api_token");
155
+ const hadPendingToken = Boolean(tokenInput?.value.trim());
156
+ syncEditorsToState();
157
+ try {
158
+ const data = await request("/api/state", {
159
+ method: "POST",
160
+ body: JSON.stringify({ state }),
161
+ });
162
+ state = data.state;
163
+ if (hadPendingToken && tokenInput) {
164
+ tokenInput.value = "";
165
+ tokenInput.placeholder = "저장된 토큰을 사용합니다. 새 토큰을 입력하면 교체됩니다.";
166
+ }
167
+ setSaveState(hadPendingToken ? "API 토큰 변경 저장됨" : "자동 저장됨");
168
+ } catch (error) {
169
+ setSaveState("저장 실패");
170
+ console.error(error);
171
+ }
147
172
  }
148
173
 
149
174
  function syncEditorsToState() {
@@ -208,12 +233,13 @@ function syncBattingScenesToState() {
208
233
  return;
209
234
  }
210
235
  state.batting_scenes = rows.map((row, index) => ({
211
- name: row.querySelector(".batting-scene-name")?.value.trim() || `Scene ${index + 1}`,
212
- base_preset: row.querySelector(".batting-scene-base")?.value || "",
213
- character_preset: row.querySelector(".batting-scene-character")?.value || "",
214
- count: Math.max(1, Number(row.querySelector(".batting-scene-count")?.value || 2)),
215
- }));
216
- }
236
+ name: row.querySelector(".batting-scene-name")?.value.trim() || `Scene ${index + 1}`,
237
+ base_preset: row.querySelector(".batting-scene-base")?.value || "",
238
+ character_preset: row.querySelector(".batting-scene-character")?.value || "",
239
+ image_size: normalizeImageSizeKey(row.querySelector(".batting-scene-size")?.value || state.generation?.image_size || "portrait"),
240
+ count: Math.max(1, Number(row.querySelector(".batting-scene-count")?.value || 2)),
241
+ }));
242
+ }
217
243
 
218
244
  function fillSelect(select, items, selectedName) {
219
245
  select.innerHTML = "";
@@ -227,24 +253,39 @@ function fillSelect(select, items, selectedName) {
227
253
  else if (items[0]) select.value = items[0].name;
228
254
  }
229
255
 
230
- function fillImageSizeSelect() {
231
- const select = $("#imageSizeSelect");
232
- if (!select) return;
233
- select.innerHTML = "";
234
- for (const [key, option] of Object.entries(imageSizeOptions)) {
256
+ function fillImageSizeSelect() {
257
+ const select = $("#imageSizeSelect");
258
+ if (!select) return;
259
+ select.innerHTML = "";
260
+ for (const [key, option] of Object.entries(imageSizeOptions)) {
235
261
  const item = document.createElement("option");
236
262
  item.value = key;
237
263
  item.textContent = `${option.label}: ${option.width} x ${option.height}`;
238
264
  select.appendChild(item);
239
265
  }
240
266
  const saved = state.generation.image_size;
241
- select.value = imageSizeOptions[saved] ? saved : "portrait";
242
- }
243
-
244
- function applyImageSizeToState(requestState) {
245
- const selected = imageSizeOptions[requestState.generation?.image_size] || imageSizeOptions.portrait;
246
- requestState.api = requestState.api || {};
247
- requestState.api.width = selected.width;
267
+ select.value = imageSizeOptions[saved] ? saved : "portrait";
268
+ }
269
+
270
+ function normalizeImageSizeKey(value, fallback = "portrait") {
271
+ if (imageSizeOptions[value]) return value;
272
+ return imageSizeOptions[fallback] ? fallback : "portrait";
273
+ }
274
+
275
+ function imageSizeOptionsHtml(selectedKey) {
276
+ const selected = normalizeImageSizeKey(selectedKey);
277
+ return Object.entries(imageSizeOptions)
278
+ .map(([key, option]) => {
279
+ const label = `${option.label}: ${option.width} x ${option.height}`;
280
+ return `<option value="${key}" ${key === selected ? "selected" : ""}>${escapeHtml(label)}</option>`;
281
+ })
282
+ .join("");
283
+ }
284
+
285
+ function applyImageSizeToState(requestState) {
286
+ const selected = imageSizeOptions[requestState.generation?.image_size] || imageSizeOptions.portrait;
287
+ requestState.api = requestState.api || {};
288
+ requestState.api.width = selected.width;
248
289
  requestState.api.height = selected.height;
249
290
  }
250
291
 
@@ -417,17 +458,19 @@ function renderBatting() {
417
458
  root.innerHTML = `<div class="empty-box">아직 등록된 씬이 없습니다. 씬을 추가해보세요.</div>`;
418
459
  return;
419
460
  }
420
- state.batting_scenes.forEach((scene, index) => {
421
- const row = document.createElement("article");
422
- row.className = "batting-scene-row";
423
- row.innerHTML = `
424
- <div class="batting-scene-number">${index + 1}</div>
425
- <label><span>씬 이름</span><input class="batting-scene-name" value="${escapeHtml(scene.name || `Scene ${index + 1}`)}" /></label>
426
- <label><span>베이스 + 퀄리티</span><select class="batting-scene-base">${selectOptionsHtml(state.base_presets, scene.base_preset)}</select></label>
427
- <label><span>캐릭터</span><select class="batting-scene-character">${selectOptionsHtml(state.character_presets, scene.character_preset)}</select></label>
428
- <label><span>생성 개수</span><input class="batting-scene-count" type="number" min="1" max="200" value="${Math.max(1, Number(scene.count || 2))}" /></label>
429
- <button class="icon-button batting-scene-delete" type="button" title="씬 삭제">×</button>
430
- `;
461
+ state.batting_scenes.forEach((scene, index) => {
462
+ const row = document.createElement("article");
463
+ row.className = "batting-scene-row";
464
+ const sceneSize = normalizeImageSizeKey(scene.image_size || state.generation?.image_size || "portrait");
465
+ row.innerHTML = `
466
+ <div class="batting-scene-number">${index + 1}</div>
467
+ <label><span>씬 이름</span><input class="batting-scene-name" value="${escapeHtml(scene.name || `Scene ${index + 1}`)}" /></label>
468
+ <label><span>베이스 + 퀄리티</span><select class="batting-scene-base">${selectOptionsHtml(state.base_presets, scene.base_preset)}</select></label>
469
+ <label><span>캐릭터</span><select class="batting-scene-character">${selectOptionsHtml(state.character_presets, scene.character_preset)}</select></label>
470
+ <label><span>이미지 해상도</span><select class="batting-scene-size">${imageSizeOptionsHtml(sceneSize)}</select></label>
471
+ <label><span>생성 개수</span><input class="batting-scene-count" type="number" min="1" max="200" value="${Math.max(1, Number(scene.count || 2))}" /></label>
472
+ <button class="icon-button batting-scene-delete" type="button" title="씬 삭제">×</button>
473
+ `;
431
474
  row.querySelector(".batting-scene-delete").onclick = () => deleteBattingScene(index);
432
475
  row.querySelectorAll("input, select").forEach((node) => {
433
476
  node.oninput = () => {
@@ -534,19 +577,47 @@ function renderPresets() {
534
577
  bindAutosaveInputs(editors);
535
578
  }
536
579
 
537
- function renderSettings() {
538
- const form = $("#apiForm");
539
- form.innerHTML = "";
540
- for (const [key, label, type] of apiFields) {
541
- const wrap = document.createElement("label");
542
- wrap.innerHTML = `<span>${label}</span><input id="api_${key}" type="${type}" />`;
543
- form.appendChild(wrap);
544
- const input = $(`#api_${key}`);
545
- input.value = key === "token" ? "" : state.api[key] ?? "";
546
- if (key === "token") {
547
- input.placeholder = state.api.token_saved ? "저장된 토큰을 사용합니다" : "API 토큰을 입력하세요";
548
- input.autocomplete = "off";
549
- }
580
+ function renderSettings() {
581
+ const form = $("#apiForm");
582
+ form.innerHTML = "";
583
+ for (const [key, label, type] of apiFields) {
584
+ const wrap = document.createElement("label");
585
+ if (type === "select") {
586
+ wrap.innerHTML = `<span>${label}</span><select id="api_${key}"></select>`;
587
+ } else {
588
+ wrap.innerHTML = `<span>${label}</span><input id="api_${key}" type="${type}" />`;
589
+ }
590
+ form.appendChild(wrap);
591
+ const input = $(`#api_${key}`);
592
+ if (type === "select") {
593
+ input.innerHTML = (apiSelectOptions[key] || [])
594
+ .map(([value, optionLabel]) => `<option value="${escapeHtml(value)}">${escapeHtml(optionLabel)}</option>`)
595
+ .join("");
596
+ const saved = state.api[key] ?? "";
597
+ if (Array.from(input.options).some((option) => option.value === saved)) {
598
+ input.value = saved;
599
+ } else if (saved) {
600
+ const custom = document.createElement("option");
601
+ custom.value = saved;
602
+ custom.textContent = `${saved} (custom)`;
603
+ input.appendChild(custom);
604
+ input.value = saved;
605
+ }
606
+ } else {
607
+ input.value = key === "token" ? "" : state.api[key] ?? "";
608
+ }
609
+ if (key === "token") {
610
+ input.placeholder = state.api.token_saved
611
+ ? "저장된 토큰을 사용합니다. 새 토큰을 입력하면 교체됩니다."
612
+ : "API 토큰을 입력하세요";
613
+ input.autocomplete = "off";
614
+ const help = document.createElement("small");
615
+ help.className = "field-help";
616
+ help.textContent = state.api.token_saved
617
+ ? "보안을 위해 저장된 토큰은 다시 표시하지 않습니다. 새 토큰을 입력하고 설정 저장을 누르면 기존 토큰을 교체합니다."
618
+ : "토큰은 브라우저에 다시 노출하지 않고 로컬에만 저장합니다.";
619
+ wrap.appendChild(help);
620
+ }
550
621
  if (type === "number") input.step = "any";
551
622
  }
552
623
  $("#mockMode").checked = !!state.api.mock_mode;
@@ -1044,11 +1115,12 @@ function addBattingScene() {
1044
1115
  state.batting_scenes = state.batting_scenes || [];
1045
1116
  const nextIndex = state.batting_scenes.length + 1;
1046
1117
  state.batting_scenes.push({
1047
- name: uniqueName(state.batting_scenes, `Scene ${nextIndex}`),
1048
- base_preset: state.generation.base_preset || state.base_presets[0]?.name || "",
1049
- character_preset: state.generation.character_preset || state.character_presets[0]?.name || "",
1050
- count: 2,
1051
- });
1118
+ name: uniqueName(state.batting_scenes, `Scene ${nextIndex}`),
1119
+ base_preset: state.generation.base_preset || state.base_presets[0]?.name || "",
1120
+ character_preset: state.generation.character_preset || state.character_presets[0]?.name || "",
1121
+ image_size: normalizeImageSizeKey(state.generation.image_size || "portrait"),
1122
+ count: 2,
1123
+ });
1052
1124
  renderBatting();
1053
1125
  scheduleSave();
1054
1126
  }
@@ -1561,11 +1633,13 @@ function bindAutosaveInputs(root = document) {
1561
1633
  root.querySelectorAll("input, textarea, select").forEach((node) => {
1562
1634
  if (node.id === "compareHistorySelect") return;
1563
1635
  if (node.id === "deleteFilesToggle" || node.closest("#historyList")) return;
1564
- if (node.id === "api_token") {
1565
- node.oninput = null;
1566
- node.onchange = () => {
1567
- syncEditorsToState();
1568
- scheduleSave();
1636
+ if (node.id === "api_token") {
1637
+ node.oninput = () => {
1638
+ setSaveState(node.value.trim() ? "새 API 토큰 입력됨" : "저장된 API 토큰 유지");
1639
+ };
1640
+ node.onchange = () => {
1641
+ syncEditorsToState();
1642
+ scheduleSave();
1569
1643
  };
1570
1644
  return;
1571
1645
  }
@@ -1591,19 +1665,24 @@ function switchTab(tab) {
1591
1665
  }
1592
1666
  currentTab = tab;
1593
1667
  $$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.tab === tab));
1594
- $$(".tab-page").forEach((page) => page.classList.toggle("active", page.id === tab));
1668
+ $$(".tab-page").forEach((page) => page.classList.toggle("active", page.id === tab));
1595
1669
  $("#pageTitle").textContent = pageCopy[tab][0];
1596
1670
  $("#pageSubtitle").textContent = pageCopy[tab][1];
1597
- $("#generateButton").hidden = tab !== "generate";
1598
1671
  updateJobActionButtons();
1599
1672
  }
1600
1673
 
1601
1674
  async function loadState() {
1602
1675
  const data = await request("/api/state");
1603
- state = data.state;
1604
- state.quality_override_prompt = state.quality_override_prompt || "";
1605
- state.batting_scenes = state.batting_scenes || [];
1606
- selectedHistoryIds = new Set(Array.from(selectedHistoryIds).filter((id) => state.history.some((item) => item.id === id)));
1676
+ state = data.state;
1677
+ state.quality_override_prompt = state.quality_override_prompt || "";
1678
+ state.batting_scenes = state.batting_scenes || [];
1679
+ state.generation = state.generation || {};
1680
+ state.generation.image_size = normalizeImageSizeKey(state.generation.image_size || "portrait");
1681
+ state.batting_scenes = state.batting_scenes.map((scene) => ({
1682
+ ...scene,
1683
+ image_size: normalizeImageSizeKey(scene.image_size || state.generation.image_size),
1684
+ }));
1685
+ selectedHistoryIds = new Set(Array.from(selectedHistoryIds).filter((id) => state.history.some((item) => item.id === id)));
1607
1686
  categoryIndex = Math.min(categoryIndex, Math.max(0, state.categories.length - 1));
1608
1687
  baseIndex = Math.min(baseIndex, Math.max(0, state.base_presets.length - 1));
1609
1688
  charIndex = Math.min(charIndex, Math.max(0, state.character_presets.length - 1));
@@ -1617,11 +1696,10 @@ async function loadState() {
1617
1696
 
1618
1697
  function bindEvents() {
1619
1698
  bindModalImageInteractions();
1620
- $$(".nav-item").forEach((button) => button.onclick = () => switchTab(button.dataset.tab));
1621
- $("#refreshButton").onclick = loadState;
1622
- $("#previewButton").onclick = previewPrompt;
1623
- $("#generateButton").onclick = startGeneration;
1624
- $("#generateButtonInline").onclick = startGeneration;
1699
+ $$(".nav-item").forEach((button) => button.onclick = () => switchTab(button.dataset.tab));
1700
+ $("#refreshButton").onclick = loadState;
1701
+ $("#previewButton").onclick = previewPrompt;
1702
+ $("#generateButtonInline").onclick = startGeneration;
1625
1703
  $("#stopGenerateButton").onclick = stopGeneration;
1626
1704
  $("#clearFixedArtistsButton").onclick = clearFixedArtists;
1627
1705
  $("#addBattingSceneButton").onclick = addBattingScene;
package/web/styles.css CHANGED
@@ -319,6 +319,12 @@ label span,
319
319
  font-size: 12px;
320
320
  }
321
321
 
322
+ .field-help {
323
+ color: var(--muted);
324
+ font-size: 11px;
325
+ line-height: 1.45;
326
+ }
327
+
322
328
  input,
323
329
  select,
324
330
  textarea {
@@ -406,7 +412,7 @@ pre {
406
412
 
407
413
  .batting-scene-row {
408
414
  display: grid;
409
- grid-template-columns: 42px minmax(160px, 1.1fr) minmax(180px, 1fr) minmax(180px, 1fr) 110px 38px;
415
+ grid-template-columns: 42px minmax(140px, 0.9fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(190px, 1fr) 100px 38px;
410
416
  gap: 10px;
411
417
  align-items: end;
412
418
  padding: 12px;
package/web_app.py CHANGED
@@ -48,6 +48,11 @@ JOBS: dict[str, dict] = {}
48
48
  STATE_LOCK = threading.Lock()
49
49
  LOCAL_API_TOKEN = secrets.token_urlsafe(32)
50
50
  ACTIVE_JOB_STATUSES = {"queued", "running", "cancelling"}
51
+ IMAGE_SIZE_DIMENSIONS = {
52
+ "portrait": (832, 1216),
53
+ "landscape": (1216, 832),
54
+ "square": (1024, 1024),
55
+ }
51
56
 
52
57
 
53
58
  def dataclass_from_dict(cls, data: dict):
@@ -349,6 +354,12 @@ def scene_state(state: AppState, scene: BattingScene) -> AppState:
349
354
  request_state = deepcopy(state)
350
355
  request_state.generation.base_preset = scene.base_preset
351
356
  request_state.generation.character_preset = scene.character_preset
357
+ image_size = scene.image_size if scene.image_size in IMAGE_SIZE_DIMENSIONS else request_state.generation.image_size
358
+ if image_size in IMAGE_SIZE_DIMENSIONS:
359
+ width, height = IMAGE_SIZE_DIMENSIONS[image_size]
360
+ request_state.generation.image_size = image_size
361
+ request_state.api.width = width
362
+ request_state.api.height = height
352
363
  request_state.generation.count = scene_count(scene)
353
364
  return request_state
354
365