nai-aclab 1.0.9 → 1.0.10

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.10",
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) {
@@ -208,12 +227,13 @@ function syncBattingScenesToState() {
208
227
  return;
209
228
  }
210
229
  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
- }
230
+ name: row.querySelector(".batting-scene-name")?.value.trim() || `Scene ${index + 1}`,
231
+ base_preset: row.querySelector(".batting-scene-base")?.value || "",
232
+ character_preset: row.querySelector(".batting-scene-character")?.value || "",
233
+ image_size: normalizeImageSizeKey(row.querySelector(".batting-scene-size")?.value || state.generation?.image_size || "portrait"),
234
+ count: Math.max(1, Number(row.querySelector(".batting-scene-count")?.value || 2)),
235
+ }));
236
+ }
217
237
 
218
238
  function fillSelect(select, items, selectedName) {
219
239
  select.innerHTML = "";
@@ -227,24 +247,39 @@ function fillSelect(select, items, selectedName) {
227
247
  else if (items[0]) select.value = items[0].name;
228
248
  }
229
249
 
230
- function fillImageSizeSelect() {
231
- const select = $("#imageSizeSelect");
232
- if (!select) return;
233
- select.innerHTML = "";
234
- for (const [key, option] of Object.entries(imageSizeOptions)) {
250
+ function fillImageSizeSelect() {
251
+ const select = $("#imageSizeSelect");
252
+ if (!select) return;
253
+ select.innerHTML = "";
254
+ for (const [key, option] of Object.entries(imageSizeOptions)) {
235
255
  const item = document.createElement("option");
236
256
  item.value = key;
237
257
  item.textContent = `${option.label}: ${option.width} x ${option.height}`;
238
258
  select.appendChild(item);
239
259
  }
240
260
  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;
261
+ select.value = imageSizeOptions[saved] ? saved : "portrait";
262
+ }
263
+
264
+ function normalizeImageSizeKey(value, fallback = "portrait") {
265
+ if (imageSizeOptions[value]) return value;
266
+ return imageSizeOptions[fallback] ? fallback : "portrait";
267
+ }
268
+
269
+ function imageSizeOptionsHtml(selectedKey) {
270
+ const selected = normalizeImageSizeKey(selectedKey);
271
+ return Object.entries(imageSizeOptions)
272
+ .map(([key, option]) => {
273
+ const label = `${option.label}: ${option.width} x ${option.height}`;
274
+ return `<option value="${key}" ${key === selected ? "selected" : ""}>${escapeHtml(label)}</option>`;
275
+ })
276
+ .join("");
277
+ }
278
+
279
+ function applyImageSizeToState(requestState) {
280
+ const selected = imageSizeOptions[requestState.generation?.image_size] || imageSizeOptions.portrait;
281
+ requestState.api = requestState.api || {};
282
+ requestState.api.width = selected.width;
248
283
  requestState.api.height = selected.height;
249
284
  }
250
285
 
@@ -417,17 +452,19 @@ function renderBatting() {
417
452
  root.innerHTML = `<div class="empty-box">아직 등록된 씬이 없습니다. 씬을 추가해보세요.</div>`;
418
453
  return;
419
454
  }
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
- `;
455
+ state.batting_scenes.forEach((scene, index) => {
456
+ const row = document.createElement("article");
457
+ row.className = "batting-scene-row";
458
+ const sceneSize = normalizeImageSizeKey(scene.image_size || state.generation?.image_size || "portrait");
459
+ row.innerHTML = `
460
+ <div class="batting-scene-number">${index + 1}</div>
461
+ <label><span>씬 이름</span><input class="batting-scene-name" value="${escapeHtml(scene.name || `Scene ${index + 1}`)}" /></label>
462
+ <label><span>베이스 + 퀄리티</span><select class="batting-scene-base">${selectOptionsHtml(state.base_presets, scene.base_preset)}</select></label>
463
+ <label><span>캐릭터</span><select class="batting-scene-character">${selectOptionsHtml(state.character_presets, scene.character_preset)}</select></label>
464
+ <label><span>이미지 해상도</span><select class="batting-scene-size">${imageSizeOptionsHtml(sceneSize)}</select></label>
465
+ <label><span>생성 개수</span><input class="batting-scene-count" type="number" min="1" max="200" value="${Math.max(1, Number(scene.count || 2))}" /></label>
466
+ <button class="icon-button batting-scene-delete" type="button" title="씬 삭제">×</button>
467
+ `;
431
468
  row.querySelector(".batting-scene-delete").onclick = () => deleteBattingScene(index);
432
469
  row.querySelectorAll("input, select").forEach((node) => {
433
470
  node.oninput = () => {
@@ -534,18 +571,38 @@ function renderPresets() {
534
571
  bindAutosaveInputs(editors);
535
572
  }
536
573
 
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";
574
+ function renderSettings() {
575
+ const form = $("#apiForm");
576
+ form.innerHTML = "";
577
+ for (const [key, label, type] of apiFields) {
578
+ const wrap = document.createElement("label");
579
+ if (type === "select") {
580
+ wrap.innerHTML = `<span>${label}</span><select id="api_${key}"></select>`;
581
+ } else {
582
+ wrap.innerHTML = `<span>${label}</span><input id="api_${key}" type="${type}" />`;
583
+ }
584
+ form.appendChild(wrap);
585
+ const input = $(`#api_${key}`);
586
+ if (type === "select") {
587
+ input.innerHTML = (apiSelectOptions[key] || [])
588
+ .map(([value, optionLabel]) => `<option value="${escapeHtml(value)}">${escapeHtml(optionLabel)}</option>`)
589
+ .join("");
590
+ const saved = state.api[key] ?? "";
591
+ if (Array.from(input.options).some((option) => option.value === saved)) {
592
+ input.value = saved;
593
+ } else if (saved) {
594
+ const custom = document.createElement("option");
595
+ custom.value = saved;
596
+ custom.textContent = `${saved} (custom)`;
597
+ input.appendChild(custom);
598
+ input.value = saved;
599
+ }
600
+ } else {
601
+ input.value = key === "token" ? "" : state.api[key] ?? "";
602
+ }
603
+ if (key === "token") {
604
+ input.placeholder = state.api.token_saved ? "저장된 토큰을 사용합니다" : "API 토큰을 입력하세요";
605
+ input.autocomplete = "off";
549
606
  }
550
607
  if (type === "number") input.step = "any";
551
608
  }
@@ -1044,11 +1101,12 @@ function addBattingScene() {
1044
1101
  state.batting_scenes = state.batting_scenes || [];
1045
1102
  const nextIndex = state.batting_scenes.length + 1;
1046
1103
  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
- });
1104
+ name: uniqueName(state.batting_scenes, `Scene ${nextIndex}`),
1105
+ base_preset: state.generation.base_preset || state.base_presets[0]?.name || "",
1106
+ character_preset: state.generation.character_preset || state.character_presets[0]?.name || "",
1107
+ image_size: normalizeImageSizeKey(state.generation.image_size || "portrait"),
1108
+ count: 2,
1109
+ });
1052
1110
  renderBatting();
1053
1111
  scheduleSave();
1054
1112
  }
@@ -1591,19 +1649,24 @@ function switchTab(tab) {
1591
1649
  }
1592
1650
  currentTab = tab;
1593
1651
  $$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.tab === tab));
1594
- $$(".tab-page").forEach((page) => page.classList.toggle("active", page.id === tab));
1652
+ $$(".tab-page").forEach((page) => page.classList.toggle("active", page.id === tab));
1595
1653
  $("#pageTitle").textContent = pageCopy[tab][0];
1596
1654
  $("#pageSubtitle").textContent = pageCopy[tab][1];
1597
- $("#generateButton").hidden = tab !== "generate";
1598
1655
  updateJobActionButtons();
1599
1656
  }
1600
1657
 
1601
1658
  async function loadState() {
1602
1659
  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)));
1660
+ state = data.state;
1661
+ state.quality_override_prompt = state.quality_override_prompt || "";
1662
+ state.batting_scenes = state.batting_scenes || [];
1663
+ state.generation = state.generation || {};
1664
+ state.generation.image_size = normalizeImageSizeKey(state.generation.image_size || "portrait");
1665
+ state.batting_scenes = state.batting_scenes.map((scene) => ({
1666
+ ...scene,
1667
+ image_size: normalizeImageSizeKey(scene.image_size || state.generation.image_size),
1668
+ }));
1669
+ selectedHistoryIds = new Set(Array.from(selectedHistoryIds).filter((id) => state.history.some((item) => item.id === id)));
1607
1670
  categoryIndex = Math.min(categoryIndex, Math.max(0, state.categories.length - 1));
1608
1671
  baseIndex = Math.min(baseIndex, Math.max(0, state.base_presets.length - 1));
1609
1672
  charIndex = Math.min(charIndex, Math.max(0, state.character_presets.length - 1));
@@ -1617,11 +1680,10 @@ async function loadState() {
1617
1680
 
1618
1681
  function bindEvents() {
1619
1682
  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;
1683
+ $$(".nav-item").forEach((button) => button.onclick = () => switchTab(button.dataset.tab));
1684
+ $("#refreshButton").onclick = loadState;
1685
+ $("#previewButton").onclick = previewPrompt;
1686
+ $("#generateButtonInline").onclick = startGeneration;
1625
1687
  $("#stopGenerateButton").onclick = stopGeneration;
1626
1688
  $("#clearFixedArtistsButton").onclick = clearFixedArtists;
1627
1689
  $("#addBattingSceneButton").onclick = addBattingScene;
package/web/styles.css CHANGED
@@ -406,7 +406,7 @@ pre {
406
406
 
407
407
  .batting-scene-row {
408
408
  display: grid;
409
- grid-template-columns: 42px minmax(160px, 1.1fr) minmax(180px, 1fr) minmax(180px, 1fr) 110px 38px;
409
+ grid-template-columns: 42px minmax(140px, 0.9fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(190px, 1fr) 100px 38px;
410
410
  gap: 10px;
411
411
  align-items: end;
412
412
  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