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/README.md +164 -0
- package/app.py +1247 -0
- package/bin/nai-artist-lab.js +115 -0
- package/launcher.py +53 -0
- package/package.json +25 -0
- package/web/index.html +413 -0
- package/web/main.js +1529 -0
- package/web/styles.css +1089 -0
- package/web_app.py +511 -0
package/web/main.js
ADDED
|
@@ -0,0 +1,1529 @@
|
|
|
1
|
+
const $ = (selector) => document.querySelector(selector);
|
|
2
|
+
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
|
3
|
+
|
|
4
|
+
let state = null;
|
|
5
|
+
let currentTab = "generate";
|
|
6
|
+
let categoryIndex = 0;
|
|
7
|
+
let baseIndex = 0;
|
|
8
|
+
let charIndex = 0;
|
|
9
|
+
let historyIndex = 0;
|
|
10
|
+
let saveTimer = null;
|
|
11
|
+
let worldcupRound = [];
|
|
12
|
+
let worldcupNextRound = [];
|
|
13
|
+
let worldcupPair = [];
|
|
14
|
+
let worldcupRoundSize = 0;
|
|
15
|
+
let worldcupRoundNumber = 0;
|
|
16
|
+
let worldcupMatchNumber = 0;
|
|
17
|
+
let worldcupRunnerUp = null;
|
|
18
|
+
let selectedHistoryIds = new Set();
|
|
19
|
+
let liveItems = [];
|
|
20
|
+
let compareHistoryValue = "__all__";
|
|
21
|
+
let modalItem = null;
|
|
22
|
+
let modalItems = [];
|
|
23
|
+
let modalIndex = -1;
|
|
24
|
+
let modalZoom = 1;
|
|
25
|
+
let modalPanX = 0;
|
|
26
|
+
let modalPanY = 0;
|
|
27
|
+
let modalDragging = false;
|
|
28
|
+
let modalDragStart = { x: 0, y: 0, panX: 0, panY: 0 };
|
|
29
|
+
let presetPickerKind = null;
|
|
30
|
+
let activeJobs = { generate: null, batting: null };
|
|
31
|
+
|
|
32
|
+
const pageCopy = {
|
|
33
|
+
generate: ["생성", "프리셋, 작가 조합, 생성 상태를 한 화면에서 조정합니다."],
|
|
34
|
+
batting: ["타율 테스트", "고정 작가가중치로 여러 씬의 프리셋 조합을 비교 생성합니다."],
|
|
35
|
+
artists: ["작가 태그", "카테고리별 작가 풀과 랜덤 가중치 범위를 관리합니다."],
|
|
36
|
+
presets: ["프리셋", "베이스, 퀄리티, 캐릭터 프롬프트를 저장하고 조합합니다."],
|
|
37
|
+
settings: ["API 설정", "NovelAI 요청 파라미터와 Undesired Content를 관리합니다."],
|
|
38
|
+
compare: ["가중치 비교", "생성 이미지별 작가태그 가중치를 나란히 확인합니다."],
|
|
39
|
+
history: ["히스토리", "생성 결과를 훑어보고 이상형 월드컵으로 선호 조합을 고릅니다."],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const apiFields = [
|
|
43
|
+
["token", "API 토큰", "password"],
|
|
44
|
+
["endpoint", "Endpoint", "text"],
|
|
45
|
+
["model", "Model", "text"],
|
|
46
|
+
["steps", "Steps", "number"],
|
|
47
|
+
["scale", "Scale", "number"],
|
|
48
|
+
["guidance_rescale", "Guidance Rescale", "number"],
|
|
49
|
+
["sampler", "Sampler", "text"],
|
|
50
|
+
["noise_schedule", "Noise Schedule", "text"],
|
|
51
|
+
["seed", "Seed (-1=random)", "number"],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const imageSizeOptions = {
|
|
55
|
+
portrait: { label: "Normal Portrait", width: 832, height: 1216 },
|
|
56
|
+
landscape: { label: "Landscape", width: 1216, height: 832 },
|
|
57
|
+
square: { label: "Square", width: 1024, height: 1024 },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function escapeHtml(value) {
|
|
61
|
+
return String(value ?? "")
|
|
62
|
+
.replaceAll("&", "&")
|
|
63
|
+
.replaceAll("<", "<")
|
|
64
|
+
.replaceAll(">", ">")
|
|
65
|
+
.replaceAll('"', """)
|
|
66
|
+
.replaceAll("'", "'");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseArtistTags(textOrLines) {
|
|
70
|
+
const text = Array.isArray(textOrLines) ? textOrLines.join("\n") : String(textOrLines || "");
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const tags = [];
|
|
73
|
+
for (const chunk of text.split(/[,\n\r]+/)) {
|
|
74
|
+
let item = chunk.trim();
|
|
75
|
+
if (!item) continue;
|
|
76
|
+
item = item.replace(/^[+-]?\d+(?:\.\d+)?\s*::\s*/, "");
|
|
77
|
+
const lower = item.toLowerCase();
|
|
78
|
+
const start = lower.indexOf("artist:");
|
|
79
|
+
if (start < 0) continue;
|
|
80
|
+
let tag = item.slice(start).replace(/\s*::\s*$/, "").trim();
|
|
81
|
+
if (tag && !seen.has(tag)) {
|
|
82
|
+
seen.add(tag);
|
|
83
|
+
tags.push(tag);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return tags;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderRecognizedTags() {
|
|
90
|
+
const tags = parseArtistTags($("#catTags")?.value || "");
|
|
91
|
+
const root = $("#recognizedTags");
|
|
92
|
+
if (!root) return;
|
|
93
|
+
$("#recognizedCount").textContent = `${tags.length}개`;
|
|
94
|
+
root.innerHTML = "";
|
|
95
|
+
if (!tags.length) {
|
|
96
|
+
root.innerHTML = `<span>아직 인식된 artist 태그가 없습니다.</span>`;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const tag of tags) {
|
|
100
|
+
const chip = document.createElement("span");
|
|
101
|
+
chip.className = "tag-chip";
|
|
102
|
+
chip.textContent = tag;
|
|
103
|
+
root.appendChild(chip);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function setSaveState(text) {
|
|
108
|
+
$("#saveState").textContent = text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function request(path, options = {}) {
|
|
112
|
+
const response = await fetch(path, {
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
...options,
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok) throw new Error(await response.text());
|
|
117
|
+
return response.json();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scheduleSave() {
|
|
121
|
+
setSaveState("저장 대기 중...");
|
|
122
|
+
clearTimeout(saveTimer);
|
|
123
|
+
saveTimer = setTimeout(saveNow, 450);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function saveNow() {
|
|
127
|
+
if (!state) return;
|
|
128
|
+
syncEditorsToState();
|
|
129
|
+
try {
|
|
130
|
+
const data = await request("/api/state", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: JSON.stringify({ state }),
|
|
133
|
+
});
|
|
134
|
+
state = data.state;
|
|
135
|
+
setSaveState("자동 저장됨");
|
|
136
|
+
} catch (error) {
|
|
137
|
+
setSaveState("저장 실패");
|
|
138
|
+
console.error(error);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function syncEditorsToState() {
|
|
143
|
+
if (!state) return;
|
|
144
|
+
state.generation.base_preset = $("#baseSelect").value;
|
|
145
|
+
state.generation.character_preset = $("#charSelect").value;
|
|
146
|
+
state.generation.count = Math.max(1, Number($("#countInput").value || 1));
|
|
147
|
+
state.generation.image_size = $("#imageSizeSelect").value || "portrait";
|
|
148
|
+
state.generation.fixed_artists = state.generation.fixed_artists || [];
|
|
149
|
+
syncBattingScenesToState();
|
|
150
|
+
|
|
151
|
+
const category = state.categories[categoryIndex];
|
|
152
|
+
if (category) {
|
|
153
|
+
category.name = $("#catName").value.trim() || "이름 없는 카테고리";
|
|
154
|
+
category.min_weight = Number($("#catMin").value || 0);
|
|
155
|
+
category.max_weight = Number($("#catMax").value || 0);
|
|
156
|
+
category.granule = Number($("#catGranule").value || 0.1);
|
|
157
|
+
category.picks = $("#catPicks").value.trim() ? Math.max(1, Number($("#catPicks").value)) : 0;
|
|
158
|
+
category.tags = $("#catTags").value.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const base = state.base_presets[baseIndex];
|
|
162
|
+
if (base) {
|
|
163
|
+
const oldName = base.name;
|
|
164
|
+
base.name = $("#baseName").value.trim() || "이름 없는 베이스";
|
|
165
|
+
renameRecentPreset("base", oldName, base.name);
|
|
166
|
+
renameBattingScenePreset("base", oldName, base.name);
|
|
167
|
+
updatePresetNameSurfaces("base", oldName, base.name);
|
|
168
|
+
base.prompt = $("#basePrompt").value.trim();
|
|
169
|
+
base.quality_prompt = $("#qualityPrompt").value.trim();
|
|
170
|
+
}
|
|
171
|
+
state.quality_override_prompt = $("#qualityOverridePrompt").value.trim();
|
|
172
|
+
|
|
173
|
+
const character = state.character_presets[charIndex];
|
|
174
|
+
if (character) {
|
|
175
|
+
const oldName = character.name;
|
|
176
|
+
character.name = $("#charName").value.trim() || "이름 없는 캐릭터";
|
|
177
|
+
renameRecentPreset("character", oldName, character.name);
|
|
178
|
+
renameBattingScenePreset("character", oldName, character.name);
|
|
179
|
+
updatePresetNameSurfaces("character", oldName, character.name);
|
|
180
|
+
character.prompts = [0, 1, 2].map((i) => $(`#charPrompt${i}`).value.trim());
|
|
181
|
+
character.negatives = [0, 1, 2].map((i) => $(`#charNegative${i}`).value.trim());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
state.negative_prompt = $("#negativePrompt").value.trim();
|
|
185
|
+
state.uc_prompt = $("#ucPrompt").value.trim();
|
|
186
|
+
|
|
187
|
+
for (const [key, , type] of apiFields) {
|
|
188
|
+
const input = $(`#api_${key}`);
|
|
189
|
+
if (!input) continue;
|
|
190
|
+
state.api[key] = type === "number" ? Number(input.value || 0) : input.value;
|
|
191
|
+
}
|
|
192
|
+
state.api.mock_mode = $("#mockMode").checked;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function syncBattingScenesToState() {
|
|
196
|
+
const root = $("#battingSceneList");
|
|
197
|
+
if (!state || !root) return;
|
|
198
|
+
const rows = $$(".batting-scene-row");
|
|
199
|
+
if (!rows.length && !root.dataset.rendered) {
|
|
200
|
+
state.batting_scenes = state.batting_scenes || [];
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
state.batting_scenes = rows.map((row, index) => ({
|
|
204
|
+
name: row.querySelector(".batting-scene-name")?.value.trim() || `Scene ${index + 1}`,
|
|
205
|
+
base_preset: row.querySelector(".batting-scene-base")?.value || "",
|
|
206
|
+
character_preset: row.querySelector(".batting-scene-character")?.value || "",
|
|
207
|
+
count: Math.max(1, Number(row.querySelector(".batting-scene-count")?.value || 2)),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function fillSelect(select, items, selectedName) {
|
|
212
|
+
select.innerHTML = "";
|
|
213
|
+
for (const item of items) {
|
|
214
|
+
const option = document.createElement("option");
|
|
215
|
+
option.value = item.name;
|
|
216
|
+
option.textContent = item.name;
|
|
217
|
+
select.appendChild(option);
|
|
218
|
+
}
|
|
219
|
+
if (items.some((item) => item.name === selectedName)) select.value = selectedName;
|
|
220
|
+
else if (items[0]) select.value = items[0].name;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function fillImageSizeSelect() {
|
|
224
|
+
const select = $("#imageSizeSelect");
|
|
225
|
+
if (!select) return;
|
|
226
|
+
select.innerHTML = "";
|
|
227
|
+
for (const [key, option] of Object.entries(imageSizeOptions)) {
|
|
228
|
+
const item = document.createElement("option");
|
|
229
|
+
item.value = key;
|
|
230
|
+
item.textContent = `${option.label}: ${option.width} x ${option.height}`;
|
|
231
|
+
select.appendChild(item);
|
|
232
|
+
}
|
|
233
|
+
const saved = state.generation.image_size;
|
|
234
|
+
select.value = imageSizeOptions[saved] ? saved : "portrait";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function applyImageSizeToState(requestState) {
|
|
238
|
+
const selected = imageSizeOptions[requestState.generation?.image_size] || imageSizeOptions.portrait;
|
|
239
|
+
requestState.api = requestState.api || {};
|
|
240
|
+
requestState.api.width = selected.width;
|
|
241
|
+
requestState.api.height = selected.height;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function rememberPreset(kind, name) {
|
|
245
|
+
if (!name || !state?.generation) return;
|
|
246
|
+
const key = kind === "base" ? "recent_base_presets" : "recent_character_presets";
|
|
247
|
+
const recent = (state.generation[key] || []).filter((item) => item && item !== name);
|
|
248
|
+
state.generation[key] = [name, ...recent].slice(0, 5);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renameRecentPreset(kind, oldName, newName) {
|
|
252
|
+
if (!oldName || !newName || oldName === newName || !state?.generation) return;
|
|
253
|
+
const key = kind === "base" ? "recent_base_presets" : "recent_character_presets";
|
|
254
|
+
state.generation[key] = (state.generation[key] || []).map((item) => (item === oldName ? newName : item));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function renameBattingScenePreset(kind, oldName, newName) {
|
|
258
|
+
if (!oldName || !newName || oldName === newName || !state) return;
|
|
259
|
+
const key = kind === "base" ? "base_preset" : "character_preset";
|
|
260
|
+
state.batting_scenes = (state.batting_scenes || []).map((scene) => ({
|
|
261
|
+
...scene,
|
|
262
|
+
[key]: scene[key] === oldName ? newName : scene[key],
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function renameSelectOptions(selector, oldName, newName) {
|
|
267
|
+
if (!oldName || !newName || oldName === newName) return;
|
|
268
|
+
$$(selector).forEach((select) => {
|
|
269
|
+
const wasSelected = select.value === oldName;
|
|
270
|
+
Array.from(select.options || []).forEach((option) => {
|
|
271
|
+
if (option.value === oldName) {
|
|
272
|
+
option.value = newName;
|
|
273
|
+
option.textContent = newName;
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
if (wasSelected) select.value = newName;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function refreshPresetButtonLabels(kind) {
|
|
281
|
+
const items = kind === "base" ? state.base_presets : state.character_presets;
|
|
282
|
+
const roots = [kind === "base" ? $("#baseList") : $("#charList")];
|
|
283
|
+
if (presetPickerKind === kind) roots.push($("#presetPickerList"));
|
|
284
|
+
roots.filter(Boolean).forEach((root) => {
|
|
285
|
+
root.querySelectorAll(".list-item").forEach((button) => {
|
|
286
|
+
const index = Number(button.dataset.index);
|
|
287
|
+
if (Number.isInteger(index) && items[index]) button.textContent = items[index].name;
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function updatePresetNameSurfaces(kind, oldName, newName) {
|
|
293
|
+
if (!oldName || !newName || oldName === newName) return;
|
|
294
|
+
refreshPresetButtonLabels(kind);
|
|
295
|
+
if (kind === "base") {
|
|
296
|
+
renameSelectOptions("#baseSelect, .batting-scene-base", oldName, newName);
|
|
297
|
+
} else {
|
|
298
|
+
renameSelectOptions("#charSelect, .batting-scene-character", oldName, newName);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function uniqueName(items, baseName) {
|
|
303
|
+
const names = new Set((items || []).map((item) => item.name));
|
|
304
|
+
if (!names.has(baseName)) return baseName;
|
|
305
|
+
let index = 2;
|
|
306
|
+
while (names.has(`${baseName} ${index}`)) index += 1;
|
|
307
|
+
return `${baseName} ${index}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function recentPresetIndexes(items, selectedIndex, recentNames = []) {
|
|
311
|
+
const indexes = [];
|
|
312
|
+
const addIndex = (index) => {
|
|
313
|
+
if (index >= 0 && index < items.length && !indexes.includes(index)) indexes.push(index);
|
|
314
|
+
};
|
|
315
|
+
addIndex(selectedIndex);
|
|
316
|
+
for (const name of recentNames || []) addIndex(items.findIndex((item) => item.name === name));
|
|
317
|
+
for (let index = 0; index < items.length && indexes.length < 5; index += 1) addIndex(index);
|
|
318
|
+
return indexes.slice(0, 5);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function selectPreset(kind, index) {
|
|
322
|
+
syncEditorsToState();
|
|
323
|
+
if (kind === "base") {
|
|
324
|
+
baseIndex = index;
|
|
325
|
+
rememberPreset("base", state.base_presets[index]?.name);
|
|
326
|
+
} else {
|
|
327
|
+
charIndex = index;
|
|
328
|
+
rememberPreset("character", state.character_presets[index]?.name);
|
|
329
|
+
}
|
|
330
|
+
renderPresets();
|
|
331
|
+
renderGenerate();
|
|
332
|
+
scheduleSave();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function renderPresetButtons(root, items, selectedIndex, indexes, kind) {
|
|
336
|
+
root.innerHTML = "";
|
|
337
|
+
for (const index of indexes) {
|
|
338
|
+
const preset = items[index];
|
|
339
|
+
const button = document.createElement("button");
|
|
340
|
+
button.className = `list-item ${index === selectedIndex ? "active" : ""}`;
|
|
341
|
+
button.dataset.kind = kind;
|
|
342
|
+
button.dataset.index = String(index);
|
|
343
|
+
button.textContent = preset.name;
|
|
344
|
+
button.onclick = () => selectPreset(kind, index);
|
|
345
|
+
root.appendChild(button);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function currentFixedArtists() {
|
|
350
|
+
return state?.generation?.fixed_artists || [];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function renderFixedArtistMode() {
|
|
354
|
+
const label = $("#fixedArtistModeLabel");
|
|
355
|
+
const summary = $("#fixedArtistSummary");
|
|
356
|
+
const list = $("#fixedArtistList");
|
|
357
|
+
const clearButton = $("#clearFixedArtistsButton");
|
|
358
|
+
if (!label || !summary || !list || !clearButton) return;
|
|
359
|
+
|
|
360
|
+
const artists = currentFixedArtists();
|
|
361
|
+
const isFixed = artists.length > 0;
|
|
362
|
+
clearButton.disabled = !isFixed;
|
|
363
|
+
label.classList.toggle("fixed", isFixed);
|
|
364
|
+
label.textContent = isFixed ? "작가태그 가중치 고정 모드" : "랜덤 가중치 모드";
|
|
365
|
+
summary.textContent = isFixed
|
|
366
|
+
? `${artists.length}개의 작가태그 가중치를 고정해서 생성합니다.`
|
|
367
|
+
: "작가 태그는 생성할 때마다 랜덤으로 선택됩니다.";
|
|
368
|
+
list.innerHTML = "";
|
|
369
|
+
if (!isFixed) {
|
|
370
|
+
list.innerHTML = `<p>고정된 작가태그가 없습니다.</p>`;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
for (const row of artistWeightRows(artists)) list.appendChild(row);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function renderBattingFixedArtists() {
|
|
377
|
+
const label = $("#battingFixedArtistModeLabel");
|
|
378
|
+
const summary = $("#battingFixedArtistSummary");
|
|
379
|
+
const list = $("#battingFixedArtistList");
|
|
380
|
+
if (!label || !summary || !list) return;
|
|
381
|
+
const artists = currentFixedArtists();
|
|
382
|
+
const isFixed = artists.length > 0;
|
|
383
|
+
label.classList.toggle("fixed", isFixed);
|
|
384
|
+
label.textContent = isFixed ? "고정 가중치 모드" : "랜덤 가중치 모드";
|
|
385
|
+
summary.textContent = isFixed
|
|
386
|
+
? `${artists.length}개의 작가태그 가중치를 모든 씬에 고정해서 테스트합니다.`
|
|
387
|
+
: "히스토리나 가중치 비교에서 마음에 드는 이미지의 가중치를 먼저 불러오세요.";
|
|
388
|
+
list.innerHTML = "";
|
|
389
|
+
if (!isFixed) {
|
|
390
|
+
list.innerHTML = `<p>고정된 작가태그가 없습니다.</p>`;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
for (const row of artistWeightRows(artists)) list.appendChild(row);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function selectOptionsHtml(items, selectedName) {
|
|
397
|
+
return (items || [])
|
|
398
|
+
.map((item) => `<option value="${escapeHtml(item.name)}" ${item.name === selectedName ? "selected" : ""}>${escapeHtml(item.name)}</option>`)
|
|
399
|
+
.join("");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function renderBatting() {
|
|
403
|
+
state.batting_scenes = state.batting_scenes || [];
|
|
404
|
+
renderBattingFixedArtists();
|
|
405
|
+
const root = $("#battingSceneList");
|
|
406
|
+
if (!root) return;
|
|
407
|
+
root.dataset.rendered = "true";
|
|
408
|
+
root.innerHTML = "";
|
|
409
|
+
if (!state.batting_scenes.length) {
|
|
410
|
+
root.innerHTML = `<div class="empty-box">아직 등록된 씬이 없습니다. 씬을 추가해보세요.</div>`;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
state.batting_scenes.forEach((scene, index) => {
|
|
414
|
+
const row = document.createElement("article");
|
|
415
|
+
row.className = "batting-scene-row";
|
|
416
|
+
row.innerHTML = `
|
|
417
|
+
<div class="batting-scene-number">${index + 1}</div>
|
|
418
|
+
<label><span>씬 이름</span><input class="batting-scene-name" value="${escapeHtml(scene.name || `Scene ${index + 1}`)}" /></label>
|
|
419
|
+
<label><span>베이스 + 퀄리티</span><select class="batting-scene-base">${selectOptionsHtml(state.base_presets, scene.base_preset)}</select></label>
|
|
420
|
+
<label><span>캐릭터</span><select class="batting-scene-character">${selectOptionsHtml(state.character_presets, scene.character_preset)}</select></label>
|
|
421
|
+
<label><span>생성 개수</span><input class="batting-scene-count" type="number" min="1" max="200" value="${Math.max(1, Number(scene.count || 2))}" /></label>
|
|
422
|
+
<button class="icon-button batting-scene-delete" type="button" title="씬 삭제">×</button>
|
|
423
|
+
`;
|
|
424
|
+
row.querySelector(".batting-scene-delete").onclick = () => deleteBattingScene(index);
|
|
425
|
+
row.querySelectorAll("input, select").forEach((node) => {
|
|
426
|
+
node.oninput = () => {
|
|
427
|
+
syncBattingScenesToState();
|
|
428
|
+
scheduleSave();
|
|
429
|
+
};
|
|
430
|
+
node.onchange = () => {
|
|
431
|
+
syncBattingScenesToState();
|
|
432
|
+
scheduleSave();
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
root.appendChild(row);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function renderAll() {
|
|
440
|
+
renderGenerate();
|
|
441
|
+
renderBatting();
|
|
442
|
+
renderArtists();
|
|
443
|
+
renderPresets();
|
|
444
|
+
renderSettings();
|
|
445
|
+
renderHistory();
|
|
446
|
+
renderCompare();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderGenerate() {
|
|
450
|
+
state.generation.recent_base_presets = state.generation.recent_base_presets || [];
|
|
451
|
+
state.generation.recent_character_presets = state.generation.recent_character_presets || [];
|
|
452
|
+
state.generation.image_size = imageSizeOptions[state.generation.image_size] ? state.generation.image_size : "portrait";
|
|
453
|
+
fillSelect($("#baseSelect"), state.base_presets, state.generation.base_preset);
|
|
454
|
+
fillSelect($("#charSelect"), state.character_presets, state.generation.character_preset);
|
|
455
|
+
fillImageSizeSelect();
|
|
456
|
+
$("#countInput").value = state.generation.count || 1;
|
|
457
|
+
state.generation.fixed_artists = state.generation.fixed_artists || [];
|
|
458
|
+
renderFixedArtistMode();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function renderArtists() {
|
|
462
|
+
const list = $("#categoryList");
|
|
463
|
+
list.innerHTML = "";
|
|
464
|
+
state.categories.forEach((category, index) => {
|
|
465
|
+
const button = document.createElement("button");
|
|
466
|
+
button.className = `list-item ${index === categoryIndex ? "active" : ""}`;
|
|
467
|
+
button.textContent = category.name;
|
|
468
|
+
button.onclick = () => {
|
|
469
|
+
syncEditorsToState();
|
|
470
|
+
categoryIndex = index;
|
|
471
|
+
renderArtists();
|
|
472
|
+
scheduleSave();
|
|
473
|
+
};
|
|
474
|
+
list.appendChild(button);
|
|
475
|
+
});
|
|
476
|
+
const category = state.categories[categoryIndex];
|
|
477
|
+
$("#catName").value = category?.name || "";
|
|
478
|
+
$("#catMin").value = category?.min_weight ?? "";
|
|
479
|
+
$("#catMax").value = category?.max_weight ?? "";
|
|
480
|
+
$("#catGranule").value = category?.granule ?? "";
|
|
481
|
+
$("#catPicks").value = category?.picks > 0 ? category.picks : "";
|
|
482
|
+
$("#catTags").value = (category?.tags || []).join("\n");
|
|
483
|
+
renderRecognizedTags();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function renderPresets() {
|
|
487
|
+
state.generation.recent_base_presets = state.generation.recent_base_presets || [];
|
|
488
|
+
state.generation.recent_character_presets = state.generation.recent_character_presets || [];
|
|
489
|
+
const baseList = $("#baseList");
|
|
490
|
+
renderPresetButtons(
|
|
491
|
+
baseList,
|
|
492
|
+
state.base_presets,
|
|
493
|
+
baseIndex,
|
|
494
|
+
recentPresetIndexes(state.base_presets, baseIndex, state.generation.recent_base_presets),
|
|
495
|
+
"base"
|
|
496
|
+
);
|
|
497
|
+
const base = state.base_presets[baseIndex];
|
|
498
|
+
$("#baseName").value = base?.name || "";
|
|
499
|
+
$("#basePrompt").value = base?.prompt || "";
|
|
500
|
+
$("#qualityPrompt").value = base?.quality_prompt || "";
|
|
501
|
+
$("#qualityOverridePrompt").value = state.quality_override_prompt || "";
|
|
502
|
+
|
|
503
|
+
const charList = $("#charList");
|
|
504
|
+
renderPresetButtons(
|
|
505
|
+
charList,
|
|
506
|
+
state.character_presets,
|
|
507
|
+
charIndex,
|
|
508
|
+
recentPresetIndexes(state.character_presets, charIndex, state.generation.recent_character_presets),
|
|
509
|
+
"character"
|
|
510
|
+
);
|
|
511
|
+
const character = state.character_presets[charIndex];
|
|
512
|
+
$("#charName").value = character?.name || "";
|
|
513
|
+
const editors = $("#characterEditors");
|
|
514
|
+
editors.innerHTML = "";
|
|
515
|
+
for (let i = 0; i < 3; i += 1) {
|
|
516
|
+
const box = document.createElement("div");
|
|
517
|
+
box.className = "character-box";
|
|
518
|
+
box.innerHTML = `
|
|
519
|
+
<h3>캐릭터 ${i + 1}</h3>
|
|
520
|
+
<label class="text-label"><span>프롬프트</span><textarea id="charPrompt${i}" spellcheck="false"></textarea></label>
|
|
521
|
+
<label class="text-label"><span>네거티브 프롬프트</span><textarea id="charNegative${i}" spellcheck="false"></textarea></label>
|
|
522
|
+
`;
|
|
523
|
+
editors.appendChild(box);
|
|
524
|
+
$(`#charPrompt${i}`).value = character?.prompts?.[i] || "";
|
|
525
|
+
$(`#charNegative${i}`).value = character?.negatives?.[i] || "";
|
|
526
|
+
}
|
|
527
|
+
bindAutosaveInputs(editors);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function renderSettings() {
|
|
531
|
+
const form = $("#apiForm");
|
|
532
|
+
form.innerHTML = "";
|
|
533
|
+
for (const [key, label, type] of apiFields) {
|
|
534
|
+
const wrap = document.createElement("label");
|
|
535
|
+
wrap.innerHTML = `<span>${label}</span><input id="api_${key}" type="${type}" />`;
|
|
536
|
+
form.appendChild(wrap);
|
|
537
|
+
$(`#api_${key}`).value = state.api[key] ?? "";
|
|
538
|
+
if (type === "number") $(`#api_${key}`).step = "any";
|
|
539
|
+
}
|
|
540
|
+
$("#mockMode").checked = !!state.api.mock_mode;
|
|
541
|
+
$("#negativePrompt").value = state.negative_prompt || "";
|
|
542
|
+
$("#ucPrompt").value = state.uc_prompt || "";
|
|
543
|
+
bindAutosaveInputs(form);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function canReuseArtists(item) {
|
|
547
|
+
return Array.isArray(item?.artists) && item.artists.length > 0;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function renderArtistActionButton(item) {
|
|
551
|
+
if (!canReuseArtists(item)) return "";
|
|
552
|
+
return `<button class="mini-button artist-reuse-button" type="button">가중치 불러오기</button>`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function enrichedImageItem(item, context = {}) {
|
|
556
|
+
return {
|
|
557
|
+
...item,
|
|
558
|
+
source_base_preset: item.source_base_preset || context.history?.base_preset || "",
|
|
559
|
+
source_character_preset: item.source_character_preset || context.history?.character_preset || "",
|
|
560
|
+
source_label: item.scene_name ? `${context.label || item.source_label || ""} · ${item.scene_name}` : context.label || item.source_label || "",
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function renderImageCard(item, context = {}) {
|
|
565
|
+
const card = document.createElement("article");
|
|
566
|
+
card.className = `image-card ${item.error ? "error" : ""}`;
|
|
567
|
+
const enriched = enrichedImageItem(item, context);
|
|
568
|
+
const modalList = context.modalItems || [enriched];
|
|
569
|
+
card.innerHTML = `
|
|
570
|
+
${item.image_url ? `<img src="${item.image_url}" alt="generated image" />` : ""}
|
|
571
|
+
<div>${item.error ? item.error : item.created_at || "생성 완료"}</div>
|
|
572
|
+
`;
|
|
573
|
+
const img = card.querySelector("img");
|
|
574
|
+
if (img) img.onclick = () => openImageModal(enriched, modalList);
|
|
575
|
+
const meta = card.querySelector("div");
|
|
576
|
+
if (meta) {
|
|
577
|
+
const label = meta.textContent;
|
|
578
|
+
meta.className = "image-card-meta";
|
|
579
|
+
meta.textContent = "";
|
|
580
|
+
const labelNode = document.createElement("span");
|
|
581
|
+
labelNode.textContent = label;
|
|
582
|
+
meta.appendChild(labelNode);
|
|
583
|
+
if (canReuseArtists(item)) {
|
|
584
|
+
const button = document.createElement("button");
|
|
585
|
+
button.className = "mini-button artist-reuse-button";
|
|
586
|
+
button.type = "button";
|
|
587
|
+
button.textContent = "가중치 불러오기";
|
|
588
|
+
button.onclick = (event) => {
|
|
589
|
+
event.stopPropagation();
|
|
590
|
+
generateFromImage(enriched);
|
|
591
|
+
};
|
|
592
|
+
meta.appendChild(button);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return card;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function renderLiveGallery(items = liveItems, rootSelector = "#liveGallery") {
|
|
599
|
+
if (rootSelector === "#liveGallery") liveItems = items || [];
|
|
600
|
+
const root = $(rootSelector);
|
|
601
|
+
if (!root) return;
|
|
602
|
+
root.innerHTML = "";
|
|
603
|
+
const galleryItems = items || [];
|
|
604
|
+
if (!galleryItems.length) {
|
|
605
|
+
root.innerHTML = `<p>아직 생성된 이미지가 없습니다.</p>`;
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const modalItems = galleryItems.filter((item) => item.image_url);
|
|
609
|
+
for (const item of galleryItems) {
|
|
610
|
+
root.appendChild(renderImageCard(item, { modalItems }));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function renderHistory() {
|
|
615
|
+
const list = $("#historyList");
|
|
616
|
+
list.innerHTML = "";
|
|
617
|
+
state.history.forEach((history, index) => {
|
|
618
|
+
const row = document.createElement("div");
|
|
619
|
+
row.className = "history-list-row";
|
|
620
|
+
const checkbox = document.createElement("input");
|
|
621
|
+
checkbox.type = "checkbox";
|
|
622
|
+
checkbox.checked = selectedHistoryIds.has(history.id);
|
|
623
|
+
checkbox.onchange = () => {
|
|
624
|
+
if (checkbox.checked) selectedHistoryIds.add(history.id);
|
|
625
|
+
else selectedHistoryIds.delete(history.id);
|
|
626
|
+
};
|
|
627
|
+
const button = document.createElement("button");
|
|
628
|
+
button.className = `list-item ${index === historyIndex ? "active" : ""}`;
|
|
629
|
+
button.innerHTML = `<strong>${history.base_preset} + ${history.character_preset}</strong><br><span>${history.created_at} · ${(history.items || []).length}장</span>`;
|
|
630
|
+
button.onclick = () => {
|
|
631
|
+
historyIndex = index;
|
|
632
|
+
renderHistory();
|
|
633
|
+
};
|
|
634
|
+
row.appendChild(checkbox);
|
|
635
|
+
row.appendChild(button);
|
|
636
|
+
list.appendChild(row);
|
|
637
|
+
});
|
|
638
|
+
const history = state.history[historyIndex];
|
|
639
|
+
const detail = $("#historyDetail");
|
|
640
|
+
detail.innerHTML = "";
|
|
641
|
+
if (!history) {
|
|
642
|
+
detail.innerHTML = `<p>아직 생성 히스토리가 없습니다.</p>`;
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const modalItems = (history.items || [])
|
|
646
|
+
.map((item, index) => enrichedImageItem(item, { history, label: `#${index + 1}` }))
|
|
647
|
+
.filter((item) => item.image_url);
|
|
648
|
+
(history.items || []).forEach((item, index) => {
|
|
649
|
+
detail.appendChild(renderImageCard(item, { history, label: `#${index + 1}`, modalItems }));
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function compareItems() {
|
|
654
|
+
const histories = compareHistoryValue === "__all__"
|
|
655
|
+
? state.history
|
|
656
|
+
: [state.history[Number(compareHistoryValue)]].filter(Boolean);
|
|
657
|
+
return histories.flatMap((history) =>
|
|
658
|
+
(history.items || []).map((item, index) => ({
|
|
659
|
+
...item,
|
|
660
|
+
compare_label: `${history.created_at || ""} #${index + 1}${item.scene_name ? ` · ${item.scene_name}` : ""}`,
|
|
661
|
+
compare_history: `${history.base_preset || ""} + ${history.character_preset || ""}`,
|
|
662
|
+
source_base_preset: item.source_base_preset || history.base_preset || "",
|
|
663
|
+
source_character_preset: item.source_character_preset || history.character_preset || "",
|
|
664
|
+
source_label: `${history.created_at || ""} #${index + 1}${item.scene_name ? ` · ${item.scene_name}` : ""}`,
|
|
665
|
+
}))
|
|
666
|
+
).filter((item) => item.image_url && Array.isArray(item.artists));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function renderCompare() {
|
|
670
|
+
const select = $("#compareHistorySelect");
|
|
671
|
+
if (!select) return;
|
|
672
|
+
select.onchange = (event) => {
|
|
673
|
+
compareHistoryValue = event.target.value;
|
|
674
|
+
renderCompare();
|
|
675
|
+
};
|
|
676
|
+
const previous = compareHistoryValue;
|
|
677
|
+
select.innerHTML = "";
|
|
678
|
+
const allOption = document.createElement("option");
|
|
679
|
+
allOption.value = "__all__";
|
|
680
|
+
allOption.textContent = `전체 히스토리 (${state.history.length})`;
|
|
681
|
+
select.appendChild(allOption);
|
|
682
|
+
state.history.forEach((history, index) => {
|
|
683
|
+
const option = document.createElement("option");
|
|
684
|
+
option.value = String(index);
|
|
685
|
+
option.textContent = `${history.created_at} · ${history.base_preset} + ${history.character_preset} · ${(history.items || []).length}장`;
|
|
686
|
+
select.appendChild(option);
|
|
687
|
+
});
|
|
688
|
+
compareHistoryValue = previous === "__all__" || state.history[Number(previous)] ? previous : "__all__";
|
|
689
|
+
select.value = compareHistoryValue;
|
|
690
|
+
|
|
691
|
+
const items = compareItems();
|
|
692
|
+
renderCompareStrip(items);
|
|
693
|
+
renderCompareMatrix(items);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function renderCompareStrip(items) {
|
|
697
|
+
const root = $("#compareStrip");
|
|
698
|
+
root.innerHTML = "";
|
|
699
|
+
if (!items.length) {
|
|
700
|
+
root.innerHTML = `<p>비교할 이미지가 없습니다.</p>`;
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
for (const item of items) {
|
|
704
|
+
const card = document.createElement("article");
|
|
705
|
+
card.className = "compare-card";
|
|
706
|
+
const rows = document.createElement("div");
|
|
707
|
+
rows.className = "artist-weight-list";
|
|
708
|
+
for (const artist of item.artists || []) {
|
|
709
|
+
const row = document.createElement("div");
|
|
710
|
+
row.className = "artist-weight-row";
|
|
711
|
+
const tag = document.createElement("span");
|
|
712
|
+
tag.textContent = artist.tag || "";
|
|
713
|
+
const weight = document.createElement("span");
|
|
714
|
+
weight.className = "weight-pill";
|
|
715
|
+
weight.textContent = artist.weight ?? "";
|
|
716
|
+
row.append(tag, weight);
|
|
717
|
+
rows.appendChild(row);
|
|
718
|
+
}
|
|
719
|
+
const body = document.createElement("div");
|
|
720
|
+
body.className = "compare-card-body";
|
|
721
|
+
const title = document.createElement("h3");
|
|
722
|
+
title.textContent = item.compare_label;
|
|
723
|
+
const subtitle = document.createElement("p");
|
|
724
|
+
subtitle.textContent = item.compare_history;
|
|
725
|
+
const actions = document.createElement("div");
|
|
726
|
+
actions.className = "compare-card-actions";
|
|
727
|
+
const reuse = document.createElement("button");
|
|
728
|
+
reuse.className = "mini-button";
|
|
729
|
+
reuse.type = "button";
|
|
730
|
+
reuse.textContent = "가중치 불러오기";
|
|
731
|
+
reuse.onclick = () => generateFromImage(item);
|
|
732
|
+
actions.appendChild(reuse);
|
|
733
|
+
body.append(title, subtitle, actions, rows);
|
|
734
|
+
card.innerHTML = `<img src="${item.image_url}" alt="generated image" />`;
|
|
735
|
+
card.querySelector("img").onclick = () => openImageModal(item, items);
|
|
736
|
+
card.appendChild(body);
|
|
737
|
+
root.appendChild(card);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function renderCompareMatrix(items) {
|
|
742
|
+
const root = $("#compareMatrix");
|
|
743
|
+
root.innerHTML = "";
|
|
744
|
+
if (!items.length) {
|
|
745
|
+
root.innerHTML = `<p>비교할 이미지가 없습니다.</p>`;
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const tags = [];
|
|
749
|
+
const seen = new Set();
|
|
750
|
+
for (const item of items) {
|
|
751
|
+
for (const artist of item.artists || []) {
|
|
752
|
+
if (!seen.has(artist.tag)) {
|
|
753
|
+
seen.add(artist.tag);
|
|
754
|
+
tags.push(artist.tag);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const table = document.createElement("table");
|
|
759
|
+
const thead = document.createElement("thead");
|
|
760
|
+
const headRow = document.createElement("tr");
|
|
761
|
+
const first = document.createElement("th");
|
|
762
|
+
first.textContent = "artist tag";
|
|
763
|
+
headRow.appendChild(first);
|
|
764
|
+
items.forEach((item, index) => {
|
|
765
|
+
const th = document.createElement("th");
|
|
766
|
+
th.textContent = `#${index + 1}`;
|
|
767
|
+
headRow.appendChild(th);
|
|
768
|
+
});
|
|
769
|
+
thead.appendChild(headRow);
|
|
770
|
+
table.appendChild(thead);
|
|
771
|
+
|
|
772
|
+
const tbody = document.createElement("tbody");
|
|
773
|
+
for (const tag of tags) {
|
|
774
|
+
const row = document.createElement("tr");
|
|
775
|
+
const tagCell = document.createElement("td");
|
|
776
|
+
tagCell.textContent = tag;
|
|
777
|
+
row.appendChild(tagCell);
|
|
778
|
+
for (const item of items) {
|
|
779
|
+
const cell = document.createElement("td");
|
|
780
|
+
const artist = (item.artists || []).find((entry) => entry.tag === tag);
|
|
781
|
+
cell.textContent = artist ? artist.weight : "-";
|
|
782
|
+
row.appendChild(cell);
|
|
783
|
+
}
|
|
784
|
+
tbody.appendChild(row);
|
|
785
|
+
}
|
|
786
|
+
table.appendChild(tbody);
|
|
787
|
+
root.appendChild(table);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function artistWeightRows(artists = []) {
|
|
791
|
+
return artists.map((artist) => {
|
|
792
|
+
const row = document.createElement("div");
|
|
793
|
+
row.className = "artist-weight-row";
|
|
794
|
+
const tag = document.createElement("span");
|
|
795
|
+
tag.textContent = artist.tag || "";
|
|
796
|
+
const weight = document.createElement("span");
|
|
797
|
+
weight.className = "weight-pill";
|
|
798
|
+
weight.textContent = artist.weight ?? "";
|
|
799
|
+
row.append(tag, weight);
|
|
800
|
+
return row;
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function modalItemIndex(item, items) {
|
|
805
|
+
return items.findIndex((entry) =>
|
|
806
|
+
(entry.path && item.path && entry.path === item.path) ||
|
|
807
|
+
(entry.image_url && item.image_url && entry.image_url === item.image_url)
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function openImageModal(item, items = null) {
|
|
812
|
+
if (!item?.image_url) return;
|
|
813
|
+
modalItems = (items || [item]).filter((entry) => entry?.image_url);
|
|
814
|
+
modalIndex = modalItemIndex(item, modalItems);
|
|
815
|
+
if (modalIndex < 0) {
|
|
816
|
+
modalItems = [item];
|
|
817
|
+
modalIndex = 0;
|
|
818
|
+
}
|
|
819
|
+
$("#imageModal").hidden = false;
|
|
820
|
+
renderImageModalItem(modalItems[modalIndex]);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function renderImageModalItem(item) {
|
|
824
|
+
modalItem = item;
|
|
825
|
+
resetModalTransform();
|
|
826
|
+
const image = $("#modalImage");
|
|
827
|
+
image.onload = () => {
|
|
828
|
+
resetModalTransform();
|
|
829
|
+
fitModalImageToView();
|
|
830
|
+
};
|
|
831
|
+
image.src = item.image_url;
|
|
832
|
+
image.alt = item.source_label || "selected image";
|
|
833
|
+
if (image.complete) requestAnimationFrame(fitModalImageToView);
|
|
834
|
+
const position = modalItems.length > 1 ? ` (${modalIndex + 1} / ${modalItems.length})` : "";
|
|
835
|
+
$("#modalTitle").textContent = `${item.source_label || item.created_at || "이미지"}${position}`;
|
|
836
|
+
$("#modalSubtitle").textContent = [item.source_base_preset, item.source_character_preset].filter(Boolean).join(" + ");
|
|
837
|
+
$("#modalPrompt").textContent = item.prompt || "";
|
|
838
|
+
const artistList = $("#modalArtists");
|
|
839
|
+
artistList.innerHTML = "";
|
|
840
|
+
for (const row of artistWeightRows(item.artists || [])) artistList.appendChild(row);
|
|
841
|
+
$("#modalGenerateButton").disabled = !canReuseArtists(item);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function applyModalTransform() {
|
|
845
|
+
const image = $("#modalImage");
|
|
846
|
+
image.style.transform = `translate(${modalPanX}px, ${modalPanY}px) scale(${modalZoom})`;
|
|
847
|
+
image.classList.toggle("zoomed", modalZoom > 1.01);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function fitModalImageToView() {
|
|
851
|
+
const modal = $("#imageModal");
|
|
852
|
+
const image = $("#modalImage");
|
|
853
|
+
const view = $(".image-modal-view");
|
|
854
|
+
if (modal.hidden || !image.naturalWidth || !image.naturalHeight) return;
|
|
855
|
+
const rect = view.getBoundingClientRect();
|
|
856
|
+
const maxWidth = Math.max(1, rect.width - 18);
|
|
857
|
+
const maxHeight = Math.max(1, rect.height - 18);
|
|
858
|
+
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight);
|
|
859
|
+
image.style.width = `${Math.floor(image.naturalWidth * scale)}px`;
|
|
860
|
+
image.style.height = `${Math.floor(image.naturalHeight * scale)}px`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function resetModalTransform() {
|
|
864
|
+
modalZoom = 1;
|
|
865
|
+
modalPanX = 0;
|
|
866
|
+
modalPanY = 0;
|
|
867
|
+
modalDragging = false;
|
|
868
|
+
applyModalTransform();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function zoomModalImage(delta, originEvent = null) {
|
|
872
|
+
const previousZoom = modalZoom;
|
|
873
|
+
const nextZoom = Math.min(6, Math.max(1, modalZoom * (delta > 0 ? 1.12 : 0.88)));
|
|
874
|
+
if (Math.abs(nextZoom - previousZoom) < 0.001) return;
|
|
875
|
+
if (originEvent && previousZoom > 0) {
|
|
876
|
+
const image = $("#modalImage");
|
|
877
|
+
const rect = image.getBoundingClientRect();
|
|
878
|
+
const offsetX = originEvent.clientX - (rect.left + rect.width / 2);
|
|
879
|
+
const offsetY = originEvent.clientY - (rect.top + rect.height / 2);
|
|
880
|
+
const ratio = nextZoom / previousZoom;
|
|
881
|
+
modalPanX = offsetX - (offsetX - modalPanX) * ratio;
|
|
882
|
+
modalPanY = offsetY - (offsetY - modalPanY) * ratio;
|
|
883
|
+
}
|
|
884
|
+
modalZoom = nextZoom;
|
|
885
|
+
if (modalZoom <= 1.01) {
|
|
886
|
+
modalZoom = 1;
|
|
887
|
+
modalPanX = 0;
|
|
888
|
+
modalPanY = 0;
|
|
889
|
+
}
|
|
890
|
+
applyModalTransform();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function navigateImageModal(delta) {
|
|
894
|
+
if ($("#imageModal").hidden || modalItems.length <= 1) return;
|
|
895
|
+
modalIndex = (modalIndex + delta + modalItems.length) % modalItems.length;
|
|
896
|
+
renderImageModalItem(modalItems[modalIndex]);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function bindModalImageInteractions() {
|
|
900
|
+
const image = $("#modalImage");
|
|
901
|
+
const view = $(".image-modal-view");
|
|
902
|
+
view.onwheel = (event) => {
|
|
903
|
+
if ($("#imageModal").hidden) return;
|
|
904
|
+
event.preventDefault();
|
|
905
|
+
zoomModalImage(event.deltaY < 0 ? 1 : -1, event);
|
|
906
|
+
};
|
|
907
|
+
image.onmousedown = (event) => {
|
|
908
|
+
if (modalZoom <= 1.01) return;
|
|
909
|
+
event.preventDefault();
|
|
910
|
+
modalDragging = true;
|
|
911
|
+
modalDragStart = {
|
|
912
|
+
x: event.clientX,
|
|
913
|
+
y: event.clientY,
|
|
914
|
+
panX: modalPanX,
|
|
915
|
+
panY: modalPanY,
|
|
916
|
+
};
|
|
917
|
+
};
|
|
918
|
+
window.addEventListener("mousemove", (event) => {
|
|
919
|
+
if (!modalDragging) return;
|
|
920
|
+
modalPanX = modalDragStart.panX + (event.clientX - modalDragStart.x);
|
|
921
|
+
modalPanY = modalDragStart.panY + (event.clientY - modalDragStart.y);
|
|
922
|
+
applyModalTransform();
|
|
923
|
+
});
|
|
924
|
+
window.addEventListener("mouseup", () => {
|
|
925
|
+
modalDragging = false;
|
|
926
|
+
});
|
|
927
|
+
window.addEventListener("resize", () => {
|
|
928
|
+
if ($("#imageModal").hidden) return;
|
|
929
|
+
resetModalTransform();
|
|
930
|
+
fitModalImageToView();
|
|
931
|
+
});
|
|
932
|
+
view.onclick = (event) => event.stopPropagation();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function openPresetPicker(kind) {
|
|
936
|
+
presetPickerKind = kind;
|
|
937
|
+
const items = kind === "base" ? state.base_presets : state.character_presets;
|
|
938
|
+
const selectedIndex = kind === "base" ? baseIndex : charIndex;
|
|
939
|
+
$("#presetPickerTitle").textContent = kind === "base" ? "베이스 + 퀄리티 프리셋" : "캐릭터 프리셋";
|
|
940
|
+
$("#presetPickerSubtitle").textContent = "전체 목록에서 사용할 프리셋을 선택하세요.";
|
|
941
|
+
const list = $("#presetPickerList");
|
|
942
|
+
list.innerHTML = "";
|
|
943
|
+
items.forEach((preset, index) => {
|
|
944
|
+
const button = document.createElement("button");
|
|
945
|
+
button.className = `list-item ${index === selectedIndex ? "active" : ""}`;
|
|
946
|
+
button.dataset.kind = kind;
|
|
947
|
+
button.dataset.index = String(index);
|
|
948
|
+
button.textContent = preset.name;
|
|
949
|
+
button.onclick = () => {
|
|
950
|
+
selectPreset(kind, index);
|
|
951
|
+
closePresetPicker();
|
|
952
|
+
};
|
|
953
|
+
list.appendChild(button);
|
|
954
|
+
});
|
|
955
|
+
$("#presetPickerModal").hidden = false;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function closePresetPicker() {
|
|
959
|
+
$("#presetPickerModal").hidden = true;
|
|
960
|
+
presetPickerKind = null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function closeImageModal() {
|
|
964
|
+
const modal = $("#imageModal");
|
|
965
|
+
modal.hidden = true;
|
|
966
|
+
modalItem = null;
|
|
967
|
+
modalItems = [];
|
|
968
|
+
modalIndex = -1;
|
|
969
|
+
resetModalTransform();
|
|
970
|
+
$("#modalImage").removeAttribute("src");
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function generateFromImage(item) {
|
|
974
|
+
if (!canReuseArtists(item)) return;
|
|
975
|
+
closeImageModal();
|
|
976
|
+
state.generation.fixed_artists = item.artists.map((artist) => ({
|
|
977
|
+
category: artist.category || "fixed",
|
|
978
|
+
tag: artist.tag || "",
|
|
979
|
+
weight: Number(artist.weight ?? 1),
|
|
980
|
+
prompt: artist.prompt || "",
|
|
981
|
+
})).filter((artist) => artist.tag);
|
|
982
|
+
if (item.source_base_preset) state.generation.base_preset = item.source_base_preset;
|
|
983
|
+
if (item.source_character_preset) state.generation.character_preset = item.source_character_preset;
|
|
984
|
+
renderGenerate();
|
|
985
|
+
renderBatting();
|
|
986
|
+
switchTab("generate");
|
|
987
|
+
previewPrompt();
|
|
988
|
+
scheduleSave();
|
|
989
|
+
setSaveState("작가태그 가중치 고정됨");
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function clearFixedArtists() {
|
|
993
|
+
if (!state?.generation) return;
|
|
994
|
+
state.generation.fixed_artists = [];
|
|
995
|
+
renderGenerate();
|
|
996
|
+
renderBatting();
|
|
997
|
+
previewPrompt();
|
|
998
|
+
scheduleSave();
|
|
999
|
+
setSaveState("랜덤 가중치 모드");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function addBattingScene() {
|
|
1003
|
+
syncEditorsToState();
|
|
1004
|
+
state.batting_scenes = state.batting_scenes || [];
|
|
1005
|
+
const nextIndex = state.batting_scenes.length + 1;
|
|
1006
|
+
state.batting_scenes.push({
|
|
1007
|
+
name: uniqueName(state.batting_scenes, `Scene ${nextIndex}`),
|
|
1008
|
+
base_preset: state.generation.base_preset || state.base_presets[0]?.name || "",
|
|
1009
|
+
character_preset: state.generation.character_preset || state.character_presets[0]?.name || "",
|
|
1010
|
+
count: 2,
|
|
1011
|
+
});
|
|
1012
|
+
renderBatting();
|
|
1013
|
+
scheduleSave();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function deleteBattingScene(index) {
|
|
1017
|
+
syncBattingScenesToState();
|
|
1018
|
+
state.batting_scenes.splice(index, 1);
|
|
1019
|
+
renderBatting();
|
|
1020
|
+
scheduleSave();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function battingStateForRequest() {
|
|
1024
|
+
syncBattingScenesToState();
|
|
1025
|
+
const requestState = generationStateForRequest();
|
|
1026
|
+
requestState.batting_scenes = state.batting_scenes || [];
|
|
1027
|
+
return requestState;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function setBattingRunning(running, cancelling = false) {
|
|
1031
|
+
const startButton = $("#startBattingButton");
|
|
1032
|
+
const addButton = $("#addBattingSceneButton");
|
|
1033
|
+
const stopButton = $("#stopBattingButton");
|
|
1034
|
+
if (startButton) startButton.disabled = running;
|
|
1035
|
+
if (addButton) addButton.disabled = running;
|
|
1036
|
+
if (stopButton) {
|
|
1037
|
+
stopButton.disabled = !running || cancelling;
|
|
1038
|
+
stopButton.textContent = cancelling ? "중지 중" : "생성 중지";
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function startBattingTest() {
|
|
1043
|
+
syncEditorsToState();
|
|
1044
|
+
if (!currentFixedArtists().length) {
|
|
1045
|
+
window.alert("타율 테스트는 작가태그 가중치를 고정한 상태에서 실행하는 기능입니다. 먼저 히스토리나 가중치 비교에서 가중치를 불러와 주세요.");
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
syncBattingScenesToState();
|
|
1049
|
+
if (!state.batting_scenes?.length) {
|
|
1050
|
+
window.alert("테스트할 씬을 먼저 추가해 주세요.");
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
$("#battingJobLog").textContent = "타율 테스트를 시작합니다...\n";
|
|
1054
|
+
$("#battingProgressText").textContent = "생성 중";
|
|
1055
|
+
$("#battingProgressCount").textContent = "0 / 0";
|
|
1056
|
+
$("#battingProgressBar").style.width = "0%";
|
|
1057
|
+
setProgressActive(true, "#battingProgress");
|
|
1058
|
+
renderLiveGallery([], "#battingLiveGallery");
|
|
1059
|
+
setBattingRunning(true);
|
|
1060
|
+
try {
|
|
1061
|
+
const data = await request("/api/batting/generate", {
|
|
1062
|
+
method: "POST",
|
|
1063
|
+
body: JSON.stringify({ state: battingStateForRequest() }),
|
|
1064
|
+
});
|
|
1065
|
+
activeJobs.batting = data.job_id;
|
|
1066
|
+
pollJob(data.job_id, jobTargets("batting"));
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
setBattingRunning(false);
|
|
1069
|
+
$("#battingProgressText").textContent = "실패";
|
|
1070
|
+
$("#battingJobLog").textContent += `시작 실패: ${error.message}\n`;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async function stopBattingTest() {
|
|
1075
|
+
if (!activeJobs.batting) return;
|
|
1076
|
+
$("#stopBattingButton").disabled = true;
|
|
1077
|
+
$("#battingProgressText").textContent = "중지 요청";
|
|
1078
|
+
await request("/api/job/cancel", {
|
|
1079
|
+
method: "POST",
|
|
1080
|
+
body: JSON.stringify({ job_id: activeJobs.batting }),
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function deleteSelectedHistory() {
|
|
1085
|
+
if (!selectedHistoryIds.size) {
|
|
1086
|
+
setSaveState("삭제할 히스토리를 선택하세요");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const deleteFiles = $("#deleteFilesToggle").checked;
|
|
1090
|
+
const ok = window.confirm(`${selectedHistoryIds.size}개 히스토리를 삭제할까요?${deleteFiles ? " 출력 파일도 함께 삭제됩니다." : ""}`);
|
|
1091
|
+
if (!ok) return;
|
|
1092
|
+
const data = await request("/api/history/delete", {
|
|
1093
|
+
method: "POST",
|
|
1094
|
+
body: JSON.stringify({ ids: Array.from(selectedHistoryIds), delete_files: deleteFiles }),
|
|
1095
|
+
});
|
|
1096
|
+
state = data.state;
|
|
1097
|
+
selectedHistoryIds.clear();
|
|
1098
|
+
historyIndex = Math.min(historyIndex, Math.max(0, state.history.length - 1));
|
|
1099
|
+
renderHistory();
|
|
1100
|
+
renderCompare();
|
|
1101
|
+
setSaveState("히스토리 삭제됨");
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function clearHistory() {
|
|
1105
|
+
if (!state.history.length) return;
|
|
1106
|
+
const deleteFiles = $("#deleteFilesToggle").checked;
|
|
1107
|
+
const ok = window.confirm(`전체 히스토리 ${state.history.length}개를 삭제할까요?${deleteFiles ? " 출력 파일도 함께 삭제됩니다." : ""}`);
|
|
1108
|
+
if (!ok) return;
|
|
1109
|
+
const data = await request("/api/history/clear", {
|
|
1110
|
+
method: "POST",
|
|
1111
|
+
body: JSON.stringify({ delete_files: deleteFiles }),
|
|
1112
|
+
});
|
|
1113
|
+
state = data.state;
|
|
1114
|
+
selectedHistoryIds.clear();
|
|
1115
|
+
historyIndex = 0;
|
|
1116
|
+
renderHistory();
|
|
1117
|
+
renderCompare();
|
|
1118
|
+
setSaveState("히스토리 전체 삭제됨");
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function selectAllHistory() {
|
|
1122
|
+
const allSelected = state.history.length > 0 && state.history.every((item) => selectedHistoryIds.has(item.id));
|
|
1123
|
+
selectedHistoryIds = allSelected ? new Set() : new Set(state.history.map((item) => item.id));
|
|
1124
|
+
renderHistory();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function previewPrompt() {
|
|
1128
|
+
syncEditorsToState();
|
|
1129
|
+
const data = await request("/api/preview", {
|
|
1130
|
+
method: "POST",
|
|
1131
|
+
body: JSON.stringify({ state }),
|
|
1132
|
+
});
|
|
1133
|
+
$("#promptPreview").textContent = data.prompt || "";
|
|
1134
|
+
$("#negativePreview").textContent = data.negative || "";
|
|
1135
|
+
$("#ucPreview").textContent = data.uc || "";
|
|
1136
|
+
setSaveState("미리보기 갱신됨");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function setProgressActive(active, selector = ".progress") {
|
|
1140
|
+
$(selector)?.classList.toggle("active", !!active);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function generationStateForRequest() {
|
|
1144
|
+
const requestState = JSON.parse(JSON.stringify(state));
|
|
1145
|
+
requestState.generation = requestState.generation || {};
|
|
1146
|
+
requestState.generation.count = Math.max(1, Number($("#countInput").value || requestState.generation.count || 1));
|
|
1147
|
+
requestState.generation.fixed_artists = currentFixedArtists();
|
|
1148
|
+
applyImageSizeToState(requestState);
|
|
1149
|
+
return requestState;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function startGeneration() {
|
|
1153
|
+
syncEditorsToState();
|
|
1154
|
+
setProgressActive(true);
|
|
1155
|
+
$("#jobLog").textContent = "생성 작업을 시작합니다...\n";
|
|
1156
|
+
if (currentFixedArtists().length) $("#jobLog").textContent = "고정 작가가중치로 생성 작업을 시작합니다...\n";
|
|
1157
|
+
liveItems = [];
|
|
1158
|
+
renderLiveGallery();
|
|
1159
|
+
const data = await request("/api/generate", {
|
|
1160
|
+
method: "POST",
|
|
1161
|
+
body: JSON.stringify({ state: generationStateForRequest() }),
|
|
1162
|
+
});
|
|
1163
|
+
pollJob(data.job_id, jobTargets("generate"));
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function jobTargets(kind) {
|
|
1167
|
+
if (kind === "batting") {
|
|
1168
|
+
return {
|
|
1169
|
+
kind,
|
|
1170
|
+
finalTab: "batting",
|
|
1171
|
+
gallery: "#battingLiveGallery",
|
|
1172
|
+
log: "#battingJobLog",
|
|
1173
|
+
progress: "#battingProgress",
|
|
1174
|
+
progressText: "#battingProgressText",
|
|
1175
|
+
progressCount: "#battingProgressCount",
|
|
1176
|
+
progressBar: "#battingProgressBar",
|
|
1177
|
+
stopButton: "#stopBattingButton",
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
kind,
|
|
1182
|
+
finalTab: "generate",
|
|
1183
|
+
gallery: "#liveGallery",
|
|
1184
|
+
log: "#jobLog",
|
|
1185
|
+
progress: ".progress",
|
|
1186
|
+
progressText: "#progressText",
|
|
1187
|
+
progressCount: "#progressCount",
|
|
1188
|
+
progressBar: "#progressBar",
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async function pollJob(jobId, targets = jobTargets("generate")) {
|
|
1193
|
+
const data = await request(`/api/job?id=${encodeURIComponent(jobId)}`);
|
|
1194
|
+
const job = data.job;
|
|
1195
|
+
const total = job.total || 0;
|
|
1196
|
+
const progress = job.progress || 0;
|
|
1197
|
+
const terminal = ["done", "missing", "cancelled"].includes(job.status);
|
|
1198
|
+
const statusText = {
|
|
1199
|
+
done: "완료",
|
|
1200
|
+
missing: "작업 없음",
|
|
1201
|
+
cancelled: "중지됨",
|
|
1202
|
+
cancelling: "중지 중",
|
|
1203
|
+
queued: "대기 중",
|
|
1204
|
+
running: "생성 중",
|
|
1205
|
+
}[job.status] || "생성 중";
|
|
1206
|
+
$(targets.progressText).textContent = statusText;
|
|
1207
|
+
setProgressActive(!terminal && job.status !== "queued", targets.progress);
|
|
1208
|
+
$(targets.progressCount).textContent = `${progress} / ${total}`;
|
|
1209
|
+
$(targets.progressBar).style.width = total ? `${(progress / total) * 100}%` : "0%";
|
|
1210
|
+
$(targets.log).textContent = (job.log || []).join("\n");
|
|
1211
|
+
renderLiveGallery(job.items || [], targets.gallery);
|
|
1212
|
+
if (targets.kind === "batting") {
|
|
1213
|
+
activeJobs.batting = terminal ? null : jobId;
|
|
1214
|
+
setBattingRunning(!terminal, job.status === "cancelling");
|
|
1215
|
+
}
|
|
1216
|
+
if (!terminal) {
|
|
1217
|
+
setTimeout(() => pollJob(jobId, targets), 900);
|
|
1218
|
+
} else {
|
|
1219
|
+
setProgressActive(false, targets.progress);
|
|
1220
|
+
if (targets.kind === "batting") {
|
|
1221
|
+
activeJobs.batting = null;
|
|
1222
|
+
setBattingRunning(false);
|
|
1223
|
+
}
|
|
1224
|
+
await loadState();
|
|
1225
|
+
switchTab(targets.finalTab);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function legacyNextPairUnused() {
|
|
1230
|
+
return;
|
|
1231
|
+
const root = $("#worldcup");
|
|
1232
|
+
root.innerHTML = "";
|
|
1233
|
+
if (worldcupQueue.length === 0) return;
|
|
1234
|
+
if (worldcupQueue.length === 1) {
|
|
1235
|
+
const winner = worldcupQueue[0];
|
|
1236
|
+
root.innerHTML = `<article class="worldcup-card"><img src="${winner.image_url}" alt="winner" /><button class="primary-button">우승</button></article>`;
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
currentPair = [worldcupQueue.pop(), worldcupQueue.pop()];
|
|
1240
|
+
currentPair.forEach((item, index) => {
|
|
1241
|
+
const card = document.createElement("article");
|
|
1242
|
+
card.className = "worldcup-card";
|
|
1243
|
+
card.innerHTML = `<img src="${item.image_url}" alt="candidate" /><button class="primary-button">${index === 0 ? "왼쪽" : "오른쪽"} 선택</button>`;
|
|
1244
|
+
card.querySelector("img").onclick = () => openImageModal(item, worldcupPair);
|
|
1245
|
+
card.querySelector("button").onclick = () => {
|
|
1246
|
+
worldcupQueue.unshift(item);
|
|
1247
|
+
worldcupQueue.sort(() => Math.random() - 0.5);
|
|
1248
|
+
nextPair();
|
|
1249
|
+
};
|
|
1250
|
+
root.appendChild(card);
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function shuffleItems(items) {
|
|
1255
|
+
const result = [...items];
|
|
1256
|
+
for (let i = result.length - 1; i > 0; i -= 1) {
|
|
1257
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
1258
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
1259
|
+
}
|
|
1260
|
+
return result;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function updateWorldcupStatus(message = "") {
|
|
1264
|
+
const status = $("#worldcupStatus");
|
|
1265
|
+
if (!status) return;
|
|
1266
|
+
if (!worldcupRoundSize) {
|
|
1267
|
+
status.innerHTML = "";
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
const totalMatches = Math.max(1, Math.floor(worldcupRoundSize / 2));
|
|
1271
|
+
const currentMatch = Math.min(worldcupMatchNumber + 1, totalMatches);
|
|
1272
|
+
const roundLabel = worldcupRoundSize === 1 ? "결승 결과" : `${worldcupRoundSize}강`;
|
|
1273
|
+
status.innerHTML = `
|
|
1274
|
+
<strong>${roundLabel}</strong>
|
|
1275
|
+
<span>라운드 ${worldcupRoundNumber}</span>
|
|
1276
|
+
<span>매치 ${currentMatch} / ${totalMatches}</span>
|
|
1277
|
+
${message ? `<em>${message}</em>` : ""}
|
|
1278
|
+
`;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function startWorldcup() {
|
|
1282
|
+
const history = state.history[historyIndex];
|
|
1283
|
+
const candidates = (history?.items || [])
|
|
1284
|
+
.map((item, index) => ({
|
|
1285
|
+
...item,
|
|
1286
|
+
worldcup_no: index + 1,
|
|
1287
|
+
source_base_preset: history?.base_preset || "",
|
|
1288
|
+
source_character_preset: history?.character_preset || "",
|
|
1289
|
+
source_label: `#${index + 1}`,
|
|
1290
|
+
}))
|
|
1291
|
+
.filter((item) => item.image_url);
|
|
1292
|
+
worldcupRound = shuffleItems(candidates);
|
|
1293
|
+
worldcupNextRound = [];
|
|
1294
|
+
worldcupPair = [];
|
|
1295
|
+
worldcupRunnerUp = null;
|
|
1296
|
+
worldcupRoundSize = worldcupRound.length;
|
|
1297
|
+
worldcupRoundNumber = 1;
|
|
1298
|
+
worldcupMatchNumber = 0;
|
|
1299
|
+
nextPair();
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function nextPair() {
|
|
1303
|
+
const root = $("#worldcup");
|
|
1304
|
+
root.innerHTML = "";
|
|
1305
|
+
if (!worldcupRoundSize) {
|
|
1306
|
+
updateWorldcupStatus();
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (worldcupRound.length === 0) {
|
|
1310
|
+
if (worldcupNextRound.length === 1) {
|
|
1311
|
+
showWorldcupWinner(worldcupNextRound[0]);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
worldcupRound = shuffleItems(worldcupNextRound);
|
|
1315
|
+
worldcupNextRound = [];
|
|
1316
|
+
worldcupRoundSize = worldcupRound.length;
|
|
1317
|
+
worldcupRoundNumber += 1;
|
|
1318
|
+
worldcupMatchNumber = 0;
|
|
1319
|
+
}
|
|
1320
|
+
if (worldcupRound.length === 1) {
|
|
1321
|
+
worldcupNextRound.push(worldcupRound.pop());
|
|
1322
|
+
nextPair();
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
worldcupPair = [worldcupRound.pop(), worldcupRound.pop()];
|
|
1326
|
+
updateWorldcupStatus();
|
|
1327
|
+
worldcupPair.forEach((item, index) => {
|
|
1328
|
+
const card = document.createElement("article");
|
|
1329
|
+
card.className = "worldcup-card";
|
|
1330
|
+
card.innerHTML = `
|
|
1331
|
+
<div class="worldcup-card-header">
|
|
1332
|
+
<span class="worldcup-badge">#${item.worldcup_no}</span>
|
|
1333
|
+
<span>${index === 0 ? "왼쪽 후보" : "오른쪽 후보"}</span>
|
|
1334
|
+
</div>
|
|
1335
|
+
<img src="${item.image_url}" alt="candidate #${item.worldcup_no}" />
|
|
1336
|
+
<button class="primary-button">#${item.worldcup_no} 선택</button>
|
|
1337
|
+
`;
|
|
1338
|
+
card.querySelector("button").onclick = () => {
|
|
1339
|
+
if (worldcupRoundSize === 2) {
|
|
1340
|
+
worldcupRunnerUp = worldcupPair.find((candidate) => candidate !== item) || null;
|
|
1341
|
+
}
|
|
1342
|
+
worldcupNextRound.push(item);
|
|
1343
|
+
worldcupMatchNumber += 1;
|
|
1344
|
+
nextPair();
|
|
1345
|
+
};
|
|
1346
|
+
root.appendChild(card);
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function showWorldcupWinner(winner) {
|
|
1351
|
+
const root = $("#worldcup");
|
|
1352
|
+
worldcupRoundSize = 1;
|
|
1353
|
+
updateWorldcupStatus("탈락한 이미지는 다시 후보로 들어오지 않습니다.");
|
|
1354
|
+
const finalists = [
|
|
1355
|
+
{ item: winner, label: "우승", className: "winner-card" },
|
|
1356
|
+
{ item: worldcupRunnerUp, label: "준우승", className: "runner-up-card" },
|
|
1357
|
+
].filter((entry) => entry.item);
|
|
1358
|
+
const finalistItems = finalists.map((entry) => entry.item);
|
|
1359
|
+
root.innerHTML = "";
|
|
1360
|
+
for (const entry of finalists) {
|
|
1361
|
+
const card = document.createElement("article");
|
|
1362
|
+
card.className = `worldcup-card ${entry.className}`;
|
|
1363
|
+
card.innerHTML = `
|
|
1364
|
+
<div class="worldcup-card-header">
|
|
1365
|
+
<span class="worldcup-badge">#${entry.item.worldcup_no}</span>
|
|
1366
|
+
<span>${entry.label}</span>
|
|
1367
|
+
</div>
|
|
1368
|
+
<img src="${entry.item.image_url}" alt="${entry.label} #${entry.item.worldcup_no}" />
|
|
1369
|
+
<button class="primary-button worldcup-load-button">가중치 불러오기</button>
|
|
1370
|
+
`;
|
|
1371
|
+
card.querySelector("img").onclick = () => openImageModal(entry.item, finalistItems);
|
|
1372
|
+
card.querySelector(".worldcup-load-button").onclick = () => generateFromImage(entry.item);
|
|
1373
|
+
root.appendChild(card);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function addCategory() {
|
|
1378
|
+
syncEditorsToState();
|
|
1379
|
+
state.categories.push({ name: uniqueName(state.categories, "새 카테고리"), tags: [], min_weight: 0.5, max_weight: 1.2, granule: 0.1, picks: 0 });
|
|
1380
|
+
categoryIndex = state.categories.length - 1;
|
|
1381
|
+
renderArtists();
|
|
1382
|
+
scheduleSave();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function deleteCategory() {
|
|
1386
|
+
if (!state.categories[categoryIndex]) return;
|
|
1387
|
+
state.categories.splice(categoryIndex, 1);
|
|
1388
|
+
categoryIndex = Math.max(0, categoryIndex - 1);
|
|
1389
|
+
renderArtists();
|
|
1390
|
+
scheduleSave();
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function addBase() {
|
|
1394
|
+
syncEditorsToState();
|
|
1395
|
+
state.base_presets.push({ name: uniqueName(state.base_presets, "새 베이스"), prompt: "", quality_prompt: "" });
|
|
1396
|
+
baseIndex = state.base_presets.length - 1;
|
|
1397
|
+
rememberPreset("base", state.base_presets[baseIndex].name);
|
|
1398
|
+
renderPresets();
|
|
1399
|
+
renderGenerate();
|
|
1400
|
+
scheduleSave();
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function deleteBase() {
|
|
1404
|
+
if (!state.base_presets[baseIndex]) return;
|
|
1405
|
+
state.base_presets.splice(baseIndex, 1);
|
|
1406
|
+
baseIndex = Math.max(0, baseIndex - 1);
|
|
1407
|
+
renderPresets();
|
|
1408
|
+
renderGenerate();
|
|
1409
|
+
scheduleSave();
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function addChar() {
|
|
1413
|
+
syncEditorsToState();
|
|
1414
|
+
state.character_presets.push({ name: uniqueName(state.character_presets, "새 캐릭터"), prompts: ["", "", ""], negatives: ["", "", ""] });
|
|
1415
|
+
charIndex = state.character_presets.length - 1;
|
|
1416
|
+
rememberPreset("character", state.character_presets[charIndex].name);
|
|
1417
|
+
renderPresets();
|
|
1418
|
+
renderGenerate();
|
|
1419
|
+
scheduleSave();
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function deleteChar() {
|
|
1423
|
+
if (!state.character_presets[charIndex]) return;
|
|
1424
|
+
state.character_presets.splice(charIndex, 1);
|
|
1425
|
+
charIndex = Math.max(0, charIndex - 1);
|
|
1426
|
+
renderPresets();
|
|
1427
|
+
renderGenerate();
|
|
1428
|
+
scheduleSave();
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function bindAutosaveInputs(root = document) {
|
|
1432
|
+
root.querySelectorAll("input, textarea, select").forEach((node) => {
|
|
1433
|
+
if (node.id === "compareHistorySelect") return;
|
|
1434
|
+
node.oninput = () => {
|
|
1435
|
+
syncEditorsToState();
|
|
1436
|
+
if (node.id === "catTags") renderRecognizedTags();
|
|
1437
|
+
scheduleSave();
|
|
1438
|
+
};
|
|
1439
|
+
node.onchange = () => {
|
|
1440
|
+
syncEditorsToState();
|
|
1441
|
+
if (node.id === "baseSelect") rememberPreset("base", state.generation.base_preset);
|
|
1442
|
+
if (node.id === "charSelect") rememberPreset("character", state.generation.character_preset);
|
|
1443
|
+
scheduleSave();
|
|
1444
|
+
if (node.id === "baseSelect" || node.id === "charSelect" || node.id === "countInput" || node.id === "imageSizeSelect") previewPrompt();
|
|
1445
|
+
};
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function switchTab(tab) {
|
|
1450
|
+
if (state && tab === "batting") {
|
|
1451
|
+
syncEditorsToState();
|
|
1452
|
+
renderBatting();
|
|
1453
|
+
}
|
|
1454
|
+
currentTab = tab;
|
|
1455
|
+
$$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.tab === tab));
|
|
1456
|
+
$$(".tab-page").forEach((page) => page.classList.toggle("active", page.id === tab));
|
|
1457
|
+
$("#pageTitle").textContent = pageCopy[tab][0];
|
|
1458
|
+
$("#pageSubtitle").textContent = pageCopy[tab][1];
|
|
1459
|
+
$("#generateButton").hidden = tab !== "generate";
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
async function loadState() {
|
|
1463
|
+
const data = await request("/api/state");
|
|
1464
|
+
state = data.state;
|
|
1465
|
+
state.quality_override_prompt = state.quality_override_prompt || "";
|
|
1466
|
+
state.batting_scenes = state.batting_scenes || [];
|
|
1467
|
+
selectedHistoryIds = new Set(Array.from(selectedHistoryIds).filter((id) => state.history.some((item) => item.id === id)));
|
|
1468
|
+
categoryIndex = Math.min(categoryIndex, Math.max(0, state.categories.length - 1));
|
|
1469
|
+
baseIndex = Math.min(baseIndex, Math.max(0, state.base_presets.length - 1));
|
|
1470
|
+
charIndex = Math.min(charIndex, Math.max(0, state.character_presets.length - 1));
|
|
1471
|
+
historyIndex = Math.min(historyIndex, Math.max(0, state.history.length - 1));
|
|
1472
|
+
renderAll();
|
|
1473
|
+
bindAutosaveInputs(document);
|
|
1474
|
+
switchTab(currentTab);
|
|
1475
|
+
await previewPrompt();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function bindEvents() {
|
|
1479
|
+
bindModalImageInteractions();
|
|
1480
|
+
$$(".nav-item").forEach((button) => button.onclick = () => switchTab(button.dataset.tab));
|
|
1481
|
+
$("#refreshButton").onclick = loadState;
|
|
1482
|
+
$("#previewButton").onclick = previewPrompt;
|
|
1483
|
+
$("#generateButton").onclick = startGeneration;
|
|
1484
|
+
$("#generateButtonInline").onclick = startGeneration;
|
|
1485
|
+
$("#clearFixedArtistsButton").onclick = clearFixedArtists;
|
|
1486
|
+
$("#addBattingSceneButton").onclick = addBattingScene;
|
|
1487
|
+
$("#startBattingButton").onclick = startBattingTest;
|
|
1488
|
+
$("#stopBattingButton").onclick = stopBattingTest;
|
|
1489
|
+
$("#basePresetAllButton").onclick = () => openPresetPicker("base");
|
|
1490
|
+
$("#charPresetAllButton").onclick = () => openPresetPicker("character");
|
|
1491
|
+
$("#addCategoryButton").onclick = addCategory;
|
|
1492
|
+
$("#deleteCategoryButton").onclick = deleteCategory;
|
|
1493
|
+
$("#addBaseButton").onclick = addBase;
|
|
1494
|
+
$("#deleteBaseButton").onclick = deleteBase;
|
|
1495
|
+
$("#addCharButton").onclick = addChar;
|
|
1496
|
+
$("#deleteCharButton").onclick = deleteChar;
|
|
1497
|
+
$("#saveSettingsButton").onclick = saveNow;
|
|
1498
|
+
$("#startWorldcupButton").onclick = startWorldcup;
|
|
1499
|
+
$("#selectAllHistoryButton").onclick = selectAllHistory;
|
|
1500
|
+
$("#deleteSelectedHistoryButton").onclick = deleteSelectedHistory;
|
|
1501
|
+
$("#clearHistoryButton").onclick = clearHistory;
|
|
1502
|
+
$("#modalCloseButton").onclick = closeImageModal;
|
|
1503
|
+
$("#imageModalBackdrop").onclick = closeImageModal;
|
|
1504
|
+
$("#presetPickerCloseButton").onclick = closePresetPicker;
|
|
1505
|
+
$("#presetPickerBackdrop").onclick = closePresetPicker;
|
|
1506
|
+
$("#modalGenerateButton").onclick = () => generateFromImage(modalItem);
|
|
1507
|
+
document.addEventListener("keydown", (event) => {
|
|
1508
|
+
if (event.key === "Escape" && !$("#imageModal").hidden) closeImageModal();
|
|
1509
|
+
if (event.key === "Escape" && !$("#presetPickerModal").hidden) closePresetPicker();
|
|
1510
|
+
if (!$("#imageModal").hidden && event.key === "ArrowLeft") {
|
|
1511
|
+
event.preventDefault();
|
|
1512
|
+
navigateImageModal(-1);
|
|
1513
|
+
}
|
|
1514
|
+
if (!$("#imageModal").hidden && event.key === "ArrowRight") {
|
|
1515
|
+
event.preventDefault();
|
|
1516
|
+
navigateImageModal(1);
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
$("#compareHistorySelect").onchange = (event) => {
|
|
1520
|
+
compareHistoryValue = event.target.value;
|
|
1521
|
+
renderCompare();
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
bindEvents();
|
|
1526
|
+
loadState().catch((error) => {
|
|
1527
|
+
console.error(error);
|
|
1528
|
+
setSaveState("초기화 실패");
|
|
1529
|
+
});
|