Add SwarmUI settings management and portrait generation enhancements
- Introduced new API endpoints for managing SwarmUI settings, including fetching and saving presets and age rules. - Updated the portrait generation logic to utilize the new settings structure, allowing for dynamic preset selection based on age. - Enhanced UI components to support SwarmUI settings, including localization for new strings and improved styling for settings sections. - Added tests to verify the functionality of new settings endpoints and portrait generation behavior. This commit lays the groundwork for more flexible and user-friendly portrait generation options.
This commit is contained in:
@@ -11,6 +11,44 @@ const ru = {
|
||||
|
||||
schoolsTitle: 'Школы',
|
||||
createSchool: 'Создать школу',
|
||||
settings: 'Настройки',
|
||||
save: 'Сохранить',
|
||||
settingsTitle: 'SwarmUI — пресеты портретов',
|
||||
settingsActivePreset: 'Пресет по умолчанию',
|
||||
settingsEditPreset: 'Редактируемый пресет',
|
||||
settingsAddPreset: 'Добавить пресет',
|
||||
settingsPresetLabel: 'Название пресета',
|
||||
settingsModel: 'Модель',
|
||||
settingsSteps: 'Steps',
|
||||
settingsCfg: 'CFG scale',
|
||||
settingsClipSkip: 'CLIP skip',
|
||||
settingsSampler: 'Sampler',
|
||||
settingsScheduler: 'Scheduler',
|
||||
settingsSeed: 'Seed',
|
||||
settingsPositive: 'Базовый positive',
|
||||
settingsNegative: 'Negative',
|
||||
settingsPositiveLoras: 'Positive LoRA',
|
||||
settingsNegativeLoras: 'Negative LoRA',
|
||||
settingsAddLora: 'Добавить LoRA',
|
||||
settingsAvatarPreset: 'Аватар',
|
||||
settingsCustomPreset: 'Свой промпт (размеры)',
|
||||
settingsFullBodyPreset: 'В полный рост',
|
||||
settingsWidth: 'Ширина',
|
||||
settingsHeight: 'Высота',
|
||||
settingsKindPositive: 'Positive для вида',
|
||||
settingsAgeRules: 'Правила по возрасту',
|
||||
settingsAgeMin: 'От',
|
||||
settingsAgeMax: 'До',
|
||||
settingsAgePreset: 'Пресет',
|
||||
settingsAddAgeRule: 'Добавить правило',
|
||||
settingsRemoveRule: 'Удалить',
|
||||
settingsRefreshLists: 'Обновить списки из SwarmUI',
|
||||
settingsSwarmReady: 'SwarmUI подключён — списки моделей и семплеров доступны.',
|
||||
settingsSwarmOffline: 'SwarmUI недоступен — поля можно заполнить вручную.',
|
||||
settingsSwarmNotConfigured: 'SwarmUI не настроен на сервере (SwarmUi:BaseUrl).',
|
||||
settingsLoadFailed: 'Не удалось загрузить настройки.',
|
||||
settingsSaveFailed: 'Не удалось сохранить настройки.',
|
||||
settingsDiscoveryFailed: 'Не удалось получить списки из SwarmUI.',
|
||||
emptySchools: 'Пока ни одной школы. Создайте первую.',
|
||||
loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.',
|
||||
createDisabledTitle: 'Удалите одну из школ, чтобы создать новую',
|
||||
@@ -152,7 +190,6 @@ const ru = {
|
||||
peopleLogEvent: 'Событие',
|
||||
peopleLogEmpty: 'Сегодня записей нет.',
|
||||
|
||||
peopleTabOverview: 'Обзор',
|
||||
peopleTabPortrait: 'Портрет',
|
||||
peoplePortraitAvatar: 'Аватар',
|
||||
peoplePortraitFull: 'В полный рост',
|
||||
@@ -178,6 +215,7 @@ const ru = {
|
||||
peoplePortraitPromptNegative: 'Negative',
|
||||
peoplePortraitPromptLoading: 'Сборка промпта…',
|
||||
peoplePortraitPromptFailed: 'Не удалось получить промпт.',
|
||||
peoplePortraitPromptPreset: 'Пресет: {label}',
|
||||
|
||||
modeOverview: 'Обзор',
|
||||
modeManage: 'Управление',
|
||||
@@ -277,6 +315,44 @@ const en: Messages = {
|
||||
|
||||
schoolsTitle: 'Schools',
|
||||
createSchool: 'Create school',
|
||||
settings: 'Settings',
|
||||
save: 'Save',
|
||||
settingsTitle: 'SwarmUI portrait presets',
|
||||
settingsActivePreset: 'Default preset',
|
||||
settingsEditPreset: 'Preset to edit',
|
||||
settingsAddPreset: 'Add preset',
|
||||
settingsPresetLabel: 'Preset name',
|
||||
settingsModel: 'Model',
|
||||
settingsSteps: 'Steps',
|
||||
settingsCfg: 'CFG scale',
|
||||
settingsClipSkip: 'CLIP skip',
|
||||
settingsSampler: 'Sampler',
|
||||
settingsScheduler: 'Scheduler',
|
||||
settingsSeed: 'Seed',
|
||||
settingsPositive: 'Base positive',
|
||||
settingsNegative: 'Negative',
|
||||
settingsPositiveLoras: 'Positive LoRA',
|
||||
settingsNegativeLoras: 'Negative LoRA',
|
||||
settingsAddLora: 'Add LoRA',
|
||||
settingsAvatarPreset: 'Avatar',
|
||||
settingsCustomPreset: 'Custom prompt (size)',
|
||||
settingsFullBodyPreset: 'Full body',
|
||||
settingsWidth: 'Width',
|
||||
settingsHeight: 'Height',
|
||||
settingsKindPositive: 'Kind positive',
|
||||
settingsAgeRules: 'Age rules',
|
||||
settingsAgeMin: 'From',
|
||||
settingsAgeMax: 'To',
|
||||
settingsAgePreset: 'Preset',
|
||||
settingsAddAgeRule: 'Add rule',
|
||||
settingsRemoveRule: 'Remove',
|
||||
settingsRefreshLists: 'Refresh lists from SwarmUI',
|
||||
settingsSwarmReady: 'SwarmUI connected — model and sampler lists are available.',
|
||||
settingsSwarmOffline: 'SwarmUI unreachable — you can still edit fields manually.',
|
||||
settingsSwarmNotConfigured: 'SwarmUI is not configured on the server (SwarmUi:BaseUrl).',
|
||||
settingsLoadFailed: 'Could not load settings.',
|
||||
settingsSaveFailed: 'Could not save settings.',
|
||||
settingsDiscoveryFailed: 'Could not fetch lists from SwarmUI.',
|
||||
emptySchools: 'No schools yet. Create the first one.',
|
||||
loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.',
|
||||
createDisabledTitle: 'Delete a school to create a new one',
|
||||
@@ -418,7 +494,6 @@ const en: Messages = {
|
||||
peopleLogEvent: 'Event',
|
||||
peopleLogEmpty: 'Nothing logged today.',
|
||||
|
||||
peopleTabOverview: 'Overview',
|
||||
peopleTabPortrait: 'Portrait',
|
||||
peoplePortraitAvatar: 'Avatar',
|
||||
peoplePortraitFull: 'Full body',
|
||||
@@ -444,6 +519,7 @@ const en: Messages = {
|
||||
peoplePortraitPromptNegative: 'Negative',
|
||||
peoplePortraitPromptLoading: 'Building prompt…',
|
||||
peoplePortraitPromptFailed: 'Could not load the prompt.',
|
||||
peoplePortraitPromptPreset: 'Preset: {label}',
|
||||
|
||||
modeOverview: 'Overview',
|
||||
modeManage: 'Management',
|
||||
|
||||
@@ -402,6 +402,72 @@ export async function fetchGameStatus(): Promise<GameStatus> {
|
||||
return request<GameStatus>('/api/status');
|
||||
}
|
||||
|
||||
export interface SwarmUiKindPreset {
|
||||
width: number;
|
||||
height: number;
|
||||
positive: string;
|
||||
}
|
||||
|
||||
export interface SwarmUiLoraEntry {
|
||||
name: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface SwarmUiPresetDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
model: string;
|
||||
steps: number;
|
||||
cfgScale: number;
|
||||
clipSkip: number;
|
||||
sampler: string;
|
||||
scheduler: string;
|
||||
seed: number;
|
||||
positive: string;
|
||||
negative: string;
|
||||
positiveLoras: SwarmUiLoraEntry[];
|
||||
negativeLoras: SwarmUiLoraEntry[];
|
||||
avatar: SwarmUiKindPreset;
|
||||
custom: SwarmUiKindPreset;
|
||||
fullBody: SwarmUiKindPreset;
|
||||
}
|
||||
|
||||
export interface SwarmUiAgeRule {
|
||||
minAge: number;
|
||||
maxAge: number;
|
||||
presetId: string;
|
||||
}
|
||||
|
||||
export interface SwarmUiSettingsFile {
|
||||
activePresetId: string;
|
||||
presets: SwarmUiPresetDefinition[];
|
||||
ageRules: SwarmUiAgeRule[];
|
||||
}
|
||||
|
||||
export interface SwarmUiDiscovery {
|
||||
connected: boolean;
|
||||
models: readonly string[];
|
||||
loras: readonly string[];
|
||||
samplers: readonly string[];
|
||||
schedulers: readonly string[];
|
||||
}
|
||||
|
||||
export async function fetchSwarmUiSettings(): Promise<SwarmUiSettingsFile> {
|
||||
return request<SwarmUiSettingsFile>('/api/settings/swarmui');
|
||||
}
|
||||
|
||||
export async function saveSwarmUiSettings(settings: SwarmUiSettingsFile): Promise<SwarmUiSettingsFile> {
|
||||
return request<SwarmUiSettingsFile>('/api/settings/swarmui', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSwarmUiDiscovery(): Promise<SwarmUiDiscovery> {
|
||||
return request<SwarmUiDiscovery>('/api/settings/swarmui/discovery');
|
||||
}
|
||||
|
||||
export type PortraitKind = 'avatar' | 'full' | 'custom';
|
||||
|
||||
export interface PortraitResult {
|
||||
@@ -430,6 +496,8 @@ export interface PortraitPrompt {
|
||||
readonly positive: string;
|
||||
readonly negative: string;
|
||||
readonly promptExtra: string | null;
|
||||
readonly presetId: string;
|
||||
readonly presetLabel: string;
|
||||
}
|
||||
|
||||
export async function fetchPortraitPrompt(
|
||||
|
||||
@@ -683,10 +683,45 @@ body {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.people__portrait-prompt-text--muted {
|
||||
.form--settings {
|
||||
max-height: min(90vh, 960px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
margin: 20px 0 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-age-rule {
|
||||
align-items: end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-lora-row {
|
||||
align-items: end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-lora-row .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.people__portrait-prompt-meta {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
.field--grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.people__swarm-status {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { schoolWord, t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { confirmDialog } from './confirmDialog.ts';
|
||||
import { createSchoolDialog } from './createSchoolDialog.ts';
|
||||
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
|
||||
import { SchoolCard } from './schoolCard.ts';
|
||||
|
||||
interface MainMenuOptions {
|
||||
@@ -35,6 +36,10 @@ export class MainMenu {
|
||||
class: 'button button--primary',
|
||||
type: 'button',
|
||||
});
|
||||
private readonly settingsButton = el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
private readonly limitHint = el('p', { class: 'hint' });
|
||||
private readonly status = el('p', { class: 'hint hint--error' });
|
||||
@@ -47,6 +52,7 @@ export class MainMenu {
|
||||
|
||||
constructor(private readonly options: MainMenuOptions) {
|
||||
this.createButton.addEventListener('click', () => void this.openCreateDialog());
|
||||
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
|
||||
this.status.hidden = true;
|
||||
|
||||
this.root.append(
|
||||
@@ -54,7 +60,7 @@ export class MainMenu {
|
||||
'header',
|
||||
{ class: 'screen__header' },
|
||||
this.title,
|
||||
el('div', { class: 'screen__actions' }, this.createButton),
|
||||
el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton),
|
||||
),
|
||||
this.limitHint,
|
||||
this.status,
|
||||
@@ -73,6 +79,7 @@ export class MainMenu {
|
||||
localize(): void {
|
||||
this.title.textContent = t('schoolsTitle');
|
||||
this.createButton.textContent = t('createSchool');
|
||||
this.settingsButton.textContent = t('settings');
|
||||
this.emptyHint.textContent = t('emptySchools');
|
||||
|
||||
for (const card of this.cards.values()) {
|
||||
|
||||
@@ -489,6 +489,7 @@ function portraitPromptView(
|
||||
{ class: 'people__portrait-prompt-wrap' },
|
||||
toggle,
|
||||
el('div', { class: 'people__portrait-prompt-view' },
|
||||
el('p', { class: 'people__portrait-prompt-meta', text: t('peoplePortraitPromptPreset', { label: prompt.presetLabel || prompt.presetId }) }),
|
||||
el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }),
|
||||
el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }),
|
||||
el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }),
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import {
|
||||
ApiError,
|
||||
fetchGameStatus,
|
||||
fetchSwarmUiDiscovery,
|
||||
fetchSwarmUiSettings,
|
||||
saveSwarmUiSettings,
|
||||
type SwarmUiDiscovery,
|
||||
type SwarmUiLoraEntry,
|
||||
type SwarmUiPresetDefinition,
|
||||
type SwarmUiSettingsFile,
|
||||
} from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { Modal } from './modal.ts';
|
||||
|
||||
export function swarmUiSettingsDialog(): Promise<boolean> {
|
||||
const modal = new Modal<boolean>(false);
|
||||
const error = el('p', { class: 'dialog__error' });
|
||||
error.hidden = true;
|
||||
|
||||
const statusLine = el('p', { class: 'hint' });
|
||||
const editPresetSelect = el('select', { class: 'input' });
|
||||
const activePresetSelect = el('select', { class: 'input' });
|
||||
const formHost = el('div', { class: 'settings-form' });
|
||||
const ageRulesHost = el('div', { class: 'settings-age-rules' });
|
||||
|
||||
let config: SwarmUiSettingsFile | null = null;
|
||||
let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] };
|
||||
let editingPresetId = '';
|
||||
|
||||
const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(false) });
|
||||
const saveButton = el('button', { class: 'button button--primary', type: 'submit' });
|
||||
const addPresetButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
const addAgeRuleButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
const refreshDiscoveryButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'form form--settings' },
|
||||
el('h2', { class: 'dialog__title' }),
|
||||
statusLine,
|
||||
el('div', { class: 'field__row' }, refreshDiscoveryButton),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field__label' }), activePresetSelect),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'field field__row' },
|
||||
el('label', { class: 'field field--grow' }, el('span', { class: 'field__label' }), editPresetSelect),
|
||||
addPresetButton,
|
||||
),
|
||||
formHost,
|
||||
el('h3', { class: 'settings-section-title' }),
|
||||
ageRulesHost,
|
||||
el('div', { class: 'field__row' }, addAgeRuleButton),
|
||||
error,
|
||||
el('div', { class: 'dialog__actions' }, cancelButton, saveButton),
|
||||
);
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
});
|
||||
|
||||
addPresetButton.addEventListener('click', () => {
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `preset-${config.presets.length + 1}`;
|
||||
const source = currentPreset() ?? config.presets[0];
|
||||
config.presets.push({
|
||||
...structuredClone(source),
|
||||
id,
|
||||
label: id,
|
||||
});
|
||||
editingPresetId = id;
|
||||
paintPresetSelectors();
|
||||
paintPresetForm();
|
||||
paintAgeRules();
|
||||
});
|
||||
|
||||
addAgeRuleButton.addEventListener('click', () => {
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.ageRules.push({
|
||||
minAge: 6,
|
||||
maxAge: 11,
|
||||
presetId: config.activePresetId,
|
||||
});
|
||||
paintAgeRules();
|
||||
});
|
||||
|
||||
refreshDiscoveryButton.addEventListener('click', () => void reloadDiscovery());
|
||||
editPresetSelect.addEventListener('change', () => {
|
||||
editingPresetId = editPresetSelect.value;
|
||||
paintPresetForm();
|
||||
});
|
||||
activePresetSelect.addEventListener('change', () => {
|
||||
if (config !== null) {
|
||||
config.activePresetId = activePresetSelect.value;
|
||||
}
|
||||
|
||||
paintAgeRules();
|
||||
});
|
||||
|
||||
const dialog = modal.element;
|
||||
dialog.classList.add('dialog--screen');
|
||||
dialog.append(form);
|
||||
|
||||
void init();
|
||||
|
||||
return modal.open(saveButton);
|
||||
|
||||
async function init(): Promise<void> {
|
||||
localize();
|
||||
try {
|
||||
const [settings, gameStatus, lists] = await Promise.all([
|
||||
fetchSwarmUiSettings(),
|
||||
fetchGameStatus(),
|
||||
fetchSwarmUiDiscovery(),
|
||||
]);
|
||||
config = settings;
|
||||
discovery = lists;
|
||||
editingPresetId = settings.presets[0]?.id ?? '';
|
||||
paintStatus(gameStatus.swarmUiConfigured, gameStatus.swarmUiConnected, lists.connected);
|
||||
paintPresetSelectors();
|
||||
paintPresetForm();
|
||||
paintAgeRules();
|
||||
} catch {
|
||||
showError(t('settingsLoadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadDiscovery(): Promise<void> {
|
||||
refreshDiscoveryButton.disabled = true;
|
||||
try {
|
||||
discovery = await fetchSwarmUiDiscovery();
|
||||
paintStatus(true, discovery.connected, discovery.connected);
|
||||
paintPresetForm();
|
||||
} catch {
|
||||
showError(t('settingsDiscoveryFailed'));
|
||||
} finally {
|
||||
refreshDiscoveryButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncActivePresetFromForm();
|
||||
saveButton.disabled = true;
|
||||
error.hidden = true;
|
||||
try {
|
||||
await saveSwarmUiSettings(config);
|
||||
modal.close(true);
|
||||
} catch (err) {
|
||||
showError(err instanceof ApiError ? err.message : t('settingsSaveFailed'));
|
||||
} finally {
|
||||
saveButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
error.textContent = message;
|
||||
error.hidden = false;
|
||||
}
|
||||
|
||||
function paintStatus(configured: boolean, connected: boolean | null, discoveryConnected: boolean): void {
|
||||
if (!configured) {
|
||||
statusLine.textContent = t('settingsSwarmNotConfigured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (connected === true && discoveryConnected) {
|
||||
statusLine.textContent = t('settingsSwarmReady');
|
||||
return;
|
||||
}
|
||||
|
||||
statusLine.textContent = t('settingsSwarmOffline');
|
||||
}
|
||||
|
||||
function paintPresetSelectors(): void {
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
editPresetSelect.replaceChildren(
|
||||
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
|
||||
);
|
||||
editPresetSelect.value = editingPresetId;
|
||||
|
||||
activePresetSelect.replaceChildren(
|
||||
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
|
||||
);
|
||||
activePresetSelect.value = config.activePresetId;
|
||||
}
|
||||
|
||||
function currentPreset(): SwarmUiPresetDefinition | undefined {
|
||||
return config?.presets.find((preset) => preset.id === editingPresetId);
|
||||
}
|
||||
|
||||
function paintPresetForm(): void {
|
||||
formHost.replaceChildren();
|
||||
const preset = currentPreset();
|
||||
if (preset === null || preset === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
formHost.append(
|
||||
textField(t('settingsPresetLabel'), preset.label, (value) => {
|
||||
preset.label = value;
|
||||
paintPresetSelectors();
|
||||
}),
|
||||
choiceField(t('settingsModel'), preset.model, discovery.models, (value) => {
|
||||
preset.model = value;
|
||||
}),
|
||||
numberField(t('settingsSteps'), preset.steps, (value) => {
|
||||
preset.steps = value;
|
||||
}),
|
||||
numberField(t('settingsCfg'), preset.cfgScale, (value) => {
|
||||
preset.cfgScale = value;
|
||||
}, 0.1),
|
||||
numberField(t('settingsClipSkip'), preset.clipSkip, (value) => {
|
||||
preset.clipSkip = value;
|
||||
}),
|
||||
choiceField(t('settingsSampler'), preset.sampler, discovery.samplers, (value) => {
|
||||
preset.sampler = value;
|
||||
}),
|
||||
choiceField(t('settingsScheduler'), preset.scheduler, discovery.schedulers, (value) => {
|
||||
preset.scheduler = value;
|
||||
}),
|
||||
numberField(t('settingsSeed'), preset.seed, (value) => {
|
||||
preset.seed = value;
|
||||
}),
|
||||
textareaField(t('settingsPositive'), preset.positive, (value) => {
|
||||
preset.positive = value;
|
||||
}),
|
||||
textareaField(t('settingsNegative'), preset.negative, (value) => {
|
||||
preset.negative = value;
|
||||
}),
|
||||
loraSection(t('settingsPositiveLoras'), preset.positiveLoras),
|
||||
loraSection(t('settingsNegativeLoras'), preset.negativeLoras),
|
||||
kindSection(t('settingsAvatarPreset'), preset.avatar),
|
||||
kindSection(t('settingsCustomPreset'), preset.custom),
|
||||
kindSection(t('settingsFullBodyPreset'), preset.fullBody),
|
||||
);
|
||||
}
|
||||
|
||||
function syncActivePresetFromForm(): void {
|
||||
// Values are bound live through closures; nothing extra to scrape from DOM.
|
||||
}
|
||||
|
||||
function paintAgeRules(): void {
|
||||
ageRulesHost.replaceChildren();
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rule of config.ageRules) {
|
||||
const minInput = el('input', { class: 'input', type: 'number', value: String(rule.minAge) });
|
||||
minInput.min = '0';
|
||||
minInput.max = '120';
|
||||
minInput.addEventListener('input', () => {
|
||||
rule.minAge = Number(minInput.value);
|
||||
});
|
||||
|
||||
const maxInput = el('input', { class: 'input', type: 'number', value: String(rule.maxAge) });
|
||||
maxInput.min = '0';
|
||||
maxInput.max = '120';
|
||||
maxInput.addEventListener('input', () => {
|
||||
rule.maxAge = Number(maxInput.value);
|
||||
});
|
||||
|
||||
const presetSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
|
||||
);
|
||||
presetSelect.value = rule.presetId;
|
||||
presetSelect.addEventListener('change', () => {
|
||||
rule.presetId = presetSelect.value;
|
||||
});
|
||||
|
||||
const removeButton = el('button', {
|
||||
class: 'button button--small button--danger',
|
||||
type: 'button',
|
||||
text: t('settingsRemoveRule'),
|
||||
onClick: () => {
|
||||
config!.ageRules = config!.ageRules.filter((entry) => entry !== rule);
|
||||
paintAgeRules();
|
||||
},
|
||||
});
|
||||
|
||||
ageRulesHost.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'settings-age-rule field__row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field__label', text: t('settingsAgeMin') }), minInput),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field__label', text: t('settingsAgeMax') }), maxInput),
|
||||
el('label', { class: 'field field--grow' }, el('span', { class: 'field__label', text: t('settingsAgePreset') }), presetSelect),
|
||||
removeButton,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function loraSection(title: string, loras: SwarmUiLoraEntry[]): HTMLElement {
|
||||
const host = el('div', { class: 'settings-loras' });
|
||||
const heading = el('h4', { class: 'settings-subtitle', text: title });
|
||||
const rows = el('div', { class: 'settings-lora-rows' });
|
||||
|
||||
const paintRows = (): void => {
|
||||
rows.replaceChildren();
|
||||
for (const lora of loras) {
|
||||
const nameField = choiceField('', lora.name, discovery.loras, (value) => {
|
||||
lora.name = value;
|
||||
});
|
||||
const weightInput = el('input', { class: 'input', type: 'number', value: String(lora.weight) });
|
||||
weightInput.step = '0.05';
|
||||
weightInput.min = '-4';
|
||||
weightInput.max = '4';
|
||||
weightInput.addEventListener('input', () => {
|
||||
lora.weight = Number(weightInput.value);
|
||||
});
|
||||
const remove = el('button', {
|
||||
class: 'button button--small button--danger',
|
||||
type: 'button',
|
||||
text: '×',
|
||||
onClick: () => {
|
||||
const index = loras.indexOf(lora);
|
||||
if (index >= 0) {
|
||||
loras.splice(index, 1);
|
||||
}
|
||||
|
||||
paintRows();
|
||||
},
|
||||
});
|
||||
rows.append(el('div', { class: 'field__row settings-lora-row' }, nameField, weightInput, remove));
|
||||
}
|
||||
};
|
||||
|
||||
paintRows();
|
||||
const add = el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('settingsAddLora'),
|
||||
onClick: () => {
|
||||
loras.push({ name: discovery.loras[0] ?? '', weight: 1 });
|
||||
paintRows();
|
||||
},
|
||||
});
|
||||
|
||||
host.append(heading, rows, add);
|
||||
return host;
|
||||
}
|
||||
|
||||
function kindSection(title: string, preset: SwarmUiPresetDefinition['avatar']): HTMLElement {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'settings-kind' },
|
||||
el('h4', { class: 'settings-subtitle', text: title }),
|
||||
numberField(t('settingsWidth'), preset.width, (value) => {
|
||||
preset.width = value;
|
||||
}),
|
||||
numberField(t('settingsHeight'), preset.height, (value) => {
|
||||
preset.height = value;
|
||||
}),
|
||||
textareaField(t('settingsKindPositive'), preset.positive, (value) => {
|
||||
preset.positive = value;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function localize(): void {
|
||||
form.querySelector('.dialog__title')!.textContent = t('settingsTitle');
|
||||
form.querySelectorAll('.field__label')[0]!.textContent = t('settingsActivePreset');
|
||||
form.querySelectorAll('.field__label')[1]!.textContent = t('settingsEditPreset');
|
||||
form.querySelector('.settings-section-title')!.textContent = t('settingsAgeRules');
|
||||
cancelButton.textContent = t('cancel');
|
||||
saveButton.textContent = t('save');
|
||||
addPresetButton.textContent = t('settingsAddPreset');
|
||||
addAgeRuleButton.textContent = t('settingsAddAgeRule');
|
||||
refreshDiscoveryButton.textContent = t('settingsRefreshLists');
|
||||
}
|
||||
}
|
||||
|
||||
function textField(label: string, value: string, onChange: (value: string) => void): HTMLElement {
|
||||
const input = el('input', { class: 'input', type: 'text', value });
|
||||
input.addEventListener('input', () => onChange(input.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
function numberField(
|
||||
label: string,
|
||||
value: number,
|
||||
onChange: (value: number) => void,
|
||||
step = 1,
|
||||
): HTMLElement {
|
||||
const input = el('input', { class: 'input', type: 'number', value: String(value) });
|
||||
input.step = String(step);
|
||||
input.addEventListener('input', () => onChange(Number(input.value)));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
function textareaField(label: string, value: string, onChange: (value: string) => void): HTMLElement {
|
||||
const input = el('textarea', { class: 'input', rows: 3, value });
|
||||
input.addEventListener('input', () => onChange(input.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
function choiceField(
|
||||
label: string,
|
||||
value: string,
|
||||
options: readonly string[],
|
||||
onChange: (value: string) => void,
|
||||
): HTMLElement {
|
||||
const values = options.includes(value) ? options : value.length > 0 ? [value, ...options] : options;
|
||||
if (values.length === 0) {
|
||||
const input = el('input', { class: 'input', type: 'text', value });
|
||||
input.addEventListener('input', () => onChange(input.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
const select = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...values.map((entry) => el('option', { value: entry, text: entry })),
|
||||
);
|
||||
select.value = value;
|
||||
select.addEventListener('change', () => onChange(select.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select);
|
||||
}
|
||||
@@ -295,7 +295,9 @@ internal static class SchoolEndpoints
|
||||
PortraitKindParser.ToApiValue(result.Kind),
|
||||
result.Positive,
|
||||
result.Negative,
|
||||
result.PromptExtra)),
|
||||
result.PromptExtra,
|
||||
result.PresetId,
|
||||
result.PresetLabel)),
|
||||
PortraitPromptBuildOutcome.InvalidPrompt =>
|
||||
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
|
||||
PortraitPromptBuildOutcome.UnknownPerson =>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using HSchool.Server.Game;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
internal static class SettingsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var settings = endpoints.MapGroup("/api/settings");
|
||||
|
||||
settings.MapGet("/swarmui", (SwarmUiSettingsStore store) => Results.Ok(store.Current))
|
||||
.WithName("GetSwarmUiSettings");
|
||||
|
||||
settings.MapPut("/swarmui", (SwarmUiConfigFile body, SwarmUiSettingsStore store) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
body.NormalizeAfterLoad();
|
||||
return Results.Ok(store.Save(body));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-body", ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("PutSwarmUiSettings");
|
||||
|
||||
settings.MapGet("/swarmui/discovery", async (SwarmUiClient swarm, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!swarm.IsConfigured)
|
||||
{
|
||||
return Results.Ok(SwarmUiDiscovery.Offline);
|
||||
}
|
||||
|
||||
var discovery = await swarm.FetchDiscoveryAsync(cancellationToken);
|
||||
return Results.Ok(discovery);
|
||||
})
|
||||
.WithName("GetSwarmUiDiscovery");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult Problem(int status, string code, string detail) =>
|
||||
Results.Problem(detail, statusCode: status, extensions: new Dictionary<string, object?> { ["code"] = code });
|
||||
}
|
||||
@@ -7,16 +7,16 @@ internal static class PortraitPromptBuilder
|
||||
{
|
||||
public static (string Positive, string Negative) Build(
|
||||
PersonCardResponse card,
|
||||
SwarmUiSettings settings,
|
||||
SwarmUiResolvedProfile profile,
|
||||
PortraitKind kind,
|
||||
string? promptExtra = null)
|
||||
{
|
||||
var preset = settings.PresetFor(kind);
|
||||
var kindPreset = profile.KindPreset;
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Positive))
|
||||
if (!string.IsNullOrWhiteSpace(profile.Positive))
|
||||
{
|
||||
parts.Add(settings.Positive.Trim());
|
||||
parts.Add(profile.Positive.Trim());
|
||||
}
|
||||
|
||||
if (kind == PortraitKind.Custom)
|
||||
@@ -26,12 +26,12 @@ internal static class PortraitPromptBuilder
|
||||
parts.Add(promptExtra.Trim());
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(preset.Positive))
|
||||
else if (!string.IsNullOrWhiteSpace(kindPreset.Positive))
|
||||
{
|
||||
parts.Add(preset.Positive.Trim());
|
||||
parts.Add(kindPreset.Positive.Trim());
|
||||
}
|
||||
|
||||
parts.Add(card.Female ? "A young woman" : "A young man");
|
||||
parts.Add(DescribeSubject(card));
|
||||
parts.Add($"age {card.Age}");
|
||||
|
||||
foreach (var row in card.Body)
|
||||
@@ -53,7 +53,25 @@ internal static class PortraitPromptBuilder
|
||||
}
|
||||
|
||||
var positive = string.Join(", ", parts.Where(part => part.Length > 0));
|
||||
var negative = settings.Negative?.Trim() ?? string.Empty;
|
||||
var negative = SwarmUiLoraFormatter.AppendLoraTags(
|
||||
profile.Negative.Trim(),
|
||||
profile.NegativeLoras);
|
||||
|
||||
return (positive, negative);
|
||||
}
|
||||
|
||||
private static string DescribeSubject(PersonCardResponse card)
|
||||
{
|
||||
if (card.Age <= 11)
|
||||
{
|
||||
return card.Female ? "A young girl" : "A young boy";
|
||||
}
|
||||
|
||||
if (card.Age <= 17)
|
||||
{
|
||||
return card.Female ? "A teenage girl" : "A teenage boy";
|
||||
}
|
||||
|
||||
return card.Female ? "A young woman" : "A young man";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace HSchool.Server.Game;
|
||||
internal sealed class PortraitService(
|
||||
SchoolStore store,
|
||||
SwarmUiClient swarm,
|
||||
SwarmUiSettings settings,
|
||||
SwarmUiSettingsStore settingsStore,
|
||||
GameCommandQueue commands,
|
||||
ILogger<PortraitService> logger)
|
||||
{
|
||||
@@ -74,12 +74,15 @@ internal sealed class PortraitService(
|
||||
return PortraitPromptBuildResult.UnknownSchool;
|
||||
}
|
||||
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, resolved);
|
||||
var profile = settingsStore.Resolve(outcome.Card.Age, kind);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved);
|
||||
return PortraitPromptBuildResult.Succeeded(
|
||||
kind,
|
||||
positive,
|
||||
negative,
|
||||
kind == PortraitKind.Custom ? resolved : null);
|
||||
kind == PortraitKind.Custom ? resolved : null,
|
||||
profile.PresetId,
|
||||
profile.PresetLabel);
|
||||
}
|
||||
|
||||
public async Task<PortraitGenerationResult> GenerateAsync(
|
||||
@@ -119,11 +122,12 @@ internal sealed class PortraitService(
|
||||
return PortraitGenerationResult.UnknownSchool;
|
||||
}
|
||||
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, promptExtra);
|
||||
var profile = settingsStore.Resolve(outcome.Card.Age, kind);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken);
|
||||
var bytes = await swarm.GenerateAsync(positive, negative, profile, cancellationToken);
|
||||
store.SavePortrait(schoolId, personId, kind, bytes, kind == PortraitKind.Custom ? promptExtra : null);
|
||||
var flags = Flags(schoolId, personId);
|
||||
var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId);
|
||||
@@ -203,30 +207,36 @@ internal sealed record PortraitPromptBuildResult(
|
||||
PortraitKind Kind,
|
||||
string Positive,
|
||||
string Negative,
|
||||
string? PromptExtra)
|
||||
string? PromptExtra,
|
||||
string PresetId,
|
||||
string PresetLabel)
|
||||
{
|
||||
public static PortraitPromptBuildResult UnknownSchool { get; } =
|
||||
new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null);
|
||||
new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null, "", "");
|
||||
|
||||
public static PortraitPromptBuildResult UnknownPerson { get; } =
|
||||
new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null);
|
||||
new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null, "", "");
|
||||
|
||||
public static PortraitPromptBuildResult InvalidPrompt { get; } =
|
||||
new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null);
|
||||
new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null, "", "");
|
||||
|
||||
public static PortraitPromptBuildResult Succeeded(
|
||||
PortraitKind kind,
|
||||
string positive,
|
||||
string negative,
|
||||
string? promptExtra) =>
|
||||
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra);
|
||||
string? promptExtra,
|
||||
string presetId,
|
||||
string presetLabel) =>
|
||||
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra, presetId, presetLabel);
|
||||
}
|
||||
|
||||
internal sealed record PortraitPromptResponse(
|
||||
string Kind,
|
||||
string Positive,
|
||||
string Negative,
|
||||
string? PromptExtra);
|
||||
string? PromptExtra,
|
||||
string PresetId,
|
||||
string PresetLabel);
|
||||
|
||||
internal enum PortraitGenerationOutcome
|
||||
{
|
||||
|
||||
@@ -55,11 +55,39 @@ internal sealed class SwarmUiClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SwarmUiDiscovery> FetchDiscoveryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
return SwarmUiDiscovery.Offline;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await RunWithSessionAsync(async () =>
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(new { session_id = _sessionId }),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
using var response = await _http.PostAsync("/API/ListT2IParams", content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return SwarmUiDiscoveryParser.Parse(document.RootElement);
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "SwarmUI discovery failed.");
|
||||
return SwarmUiDiscovery.Offline;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]> GenerateAsync(
|
||||
string prompt,
|
||||
string negativePrompt,
|
||||
SwarmUiSettings settings,
|
||||
PortraitKind kind,
|
||||
SwarmUiResolvedProfile profile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
@@ -69,7 +97,7 @@ internal sealed class SwarmUiClient
|
||||
|
||||
return await RunWithSessionAsync(async () =>
|
||||
{
|
||||
var preset = settings.PresetFor(kind);
|
||||
var kind = profile.KindPreset;
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["session_id"] = _sessionId,
|
||||
@@ -77,28 +105,33 @@ internal sealed class SwarmUiClient
|
||||
["donotsave"] = true,
|
||||
["prompt"] = prompt,
|
||||
["negativeprompt"] = negativePrompt,
|
||||
["model"] = settings.Model,
|
||||
["steps"] = settings.Steps,
|
||||
["cfgscale"] = settings.CfgScale,
|
||||
["width"] = preset.Width,
|
||||
["height"] = preset.Height,
|
||||
["seed"] = settings.Seed,
|
||||
["model"] = profile.Model,
|
||||
["steps"] = profile.Steps,
|
||||
["cfgscale"] = profile.CfgScale,
|
||||
["width"] = kind.Width,
|
||||
["height"] = kind.Height,
|
||||
["seed"] = profile.Seed,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Sampler))
|
||||
if (!string.IsNullOrWhiteSpace(profile.Sampler))
|
||||
{
|
||||
body["sampler"] = settings.Sampler;
|
||||
body["sampler"] = profile.Sampler;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Scheduler))
|
||||
if (!string.IsNullOrWhiteSpace(profile.Scheduler))
|
||||
{
|
||||
body["scheduler"] = settings.Scheduler;
|
||||
body["scheduler"] = profile.Scheduler;
|
||||
}
|
||||
|
||||
if (settings.ClipSkip > 0)
|
||||
if (profile.ClipSkip > 0)
|
||||
{
|
||||
// SwarmUI "CLIP Stop At Layer" — clip skip N is layer -N from the end.
|
||||
body["clipstopatlayer"] = -settings.ClipSkip;
|
||||
body["clipstopatlayer"] = -profile.ClipSkip;
|
||||
}
|
||||
|
||||
var loras = SwarmUiLoraFormatter.FormatForApi(profile.PositiveLoras);
|
||||
if (loras is not null)
|
||||
{
|
||||
body["loras"] = loras;
|
||||
}
|
||||
|
||||
using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal sealed record SwarmUiDiscovery(
|
||||
bool Connected,
|
||||
IReadOnlyList<string> Models,
|
||||
IReadOnlyList<string> Loras,
|
||||
IReadOnlyList<string> Samplers,
|
||||
IReadOnlyList<string> Schedulers)
|
||||
{
|
||||
public static SwarmUiDiscovery Offline { get; } = new(false, [], [], [], []);
|
||||
}
|
||||
|
||||
internal static class SwarmUiDiscoveryParser
|
||||
{
|
||||
public static SwarmUiDiscovery Parse(JsonElement root)
|
||||
{
|
||||
var models = ReadModelNames(root, "Stable-Diffusion");
|
||||
var loras = ReadModelNames(root, "LoRA");
|
||||
var samplers = ReadParamValues(root, "sampler");
|
||||
var schedulers = ReadParamValues(root, "scheduler");
|
||||
return new SwarmUiDiscovery(true, models, loras, samplers, schedulers);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadModelNames(JsonElement root, string subtype)
|
||||
{
|
||||
if (!root.TryGetProperty("models", out var models) || models.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!models.TryGetProperty(subtype, out var list))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return ReadStringList(list);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadParamValues(JsonElement root, string paramId)
|
||||
{
|
||||
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach (var entry in list.EnumerateArray())
|
||||
{
|
||||
if (!entry.TryGetProperty("id", out var id) || id.GetString() != paramId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.TryGetProperty("values", out var values) || values.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return ReadStringList(values);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStringList(JsonElement list)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var item in list.EnumerateArray())
|
||||
{
|
||||
switch (item.ValueKind)
|
||||
{
|
||||
case JsonValueKind.String:
|
||||
AddName(names, item.GetString());
|
||||
break;
|
||||
case JsonValueKind.Array when item.GetArrayLength() > 0:
|
||||
AddName(names, item[0].GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private static void AddName(List<string> names, string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!names.Contains(name, StringComparer.Ordinal))
|
||||
{
|
||||
names.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal static class SwarmUiLoraFormatter
|
||||
{
|
||||
/// <summary>SwarmUI comma-separated lora list: name,weight,name,weight,…</summary>
|
||||
public static string? FormatForApi(IReadOnlyList<SwarmUiLoraEntry> loras)
|
||||
{
|
||||
if (loras.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>(loras.Count * 2);
|
||||
foreach (var lora in loras)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lora.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add(lora.Name.Trim());
|
||||
parts.Add(lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? null : string.Join(',', parts);
|
||||
}
|
||||
|
||||
public static string AppendLoraTags(string prompt, IReadOnlyList<SwarmUiLoraEntry> loras)
|
||||
{
|
||||
if (loras.Count == 0)
|
||||
{
|
||||
return prompt;
|
||||
}
|
||||
|
||||
var tags = loras
|
||||
.Where(lora => !string.IsNullOrWhiteSpace(lora.Name))
|
||||
.Select(lora =>
|
||||
$"<lora:{lora.Name.Trim()}:{lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture)}>")
|
||||
.ToList();
|
||||
|
||||
if (tags.Count == 0)
|
||||
{
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(prompt)
|
||||
? string.Join(' ', tags)
|
||||
: $"{prompt.TrimEnd()} {string.Join(' ', tags)}";
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Generation defaults and prompt templates loaded from swarmui.json next to the server.</summary>
|
||||
internal sealed class SwarmUiSettings
|
||||
{
|
||||
public string Model { get; init; } = "";
|
||||
|
||||
public int Steps { get; init; } = 8;
|
||||
|
||||
public double CfgScale { get; init; } = 1;
|
||||
|
||||
public int ClipSkip { get; init; } = 1;
|
||||
|
||||
public string Sampler { get; init; } = "euler";
|
||||
|
||||
public string Scheduler { get; init; } = "";
|
||||
|
||||
public long Seed { get; init; } = -1;
|
||||
|
||||
public string Positive { get; init; } = "";
|
||||
|
||||
public string Negative { get; init; } = "";
|
||||
|
||||
public SwarmUiPreset Avatar { get; init; } = new();
|
||||
|
||||
public SwarmUiPreset Custom { get; init; } = new();
|
||||
|
||||
public SwarmUiPreset FullBody { get; init; } = new();
|
||||
|
||||
public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch
|
||||
{
|
||||
PortraitKind.Avatar => Avatar,
|
||||
PortraitKind.Custom => Custom,
|
||||
PortraitKind.Full => FullBody,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
internal sealed class SwarmUiPreset
|
||||
{
|
||||
public int Width { get; init; } = 512;
|
||||
|
||||
public int Height { get; init; } = 512;
|
||||
|
||||
public string Positive { get; init; } = "";
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static SwarmUiSettings Load(IHostEnvironment environment, ILogger logger)
|
||||
{
|
||||
var path = Path.Combine(environment.ContentRootPath, "swarmui.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogWarning("SwarmUI settings file {Path} is missing; portrait generation will use empty defaults.", path);
|
||||
return new SwarmUiSettings();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<SwarmUiSettings>(json, Json) ?? new SwarmUiSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}.", path);
|
||||
return new SwarmUiSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Loads, saves and serves SwarmUI generation presets from swarmui.json.</summary>
|
||||
internal sealed class SwarmUiSettingsStore(IHostEnvironment environment, ILogger<SwarmUiSettingsStore> logger)
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private SwarmUiConfigFile _config = SwarmUiConfigFile.CreateDefault();
|
||||
|
||||
public string FilePath { get; } = Path.Combine(environment.ContentRootPath, "swarmui.json");
|
||||
|
||||
public SwarmUiConfigFile Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_config = ReadFromDisk();
|
||||
}
|
||||
}
|
||||
|
||||
public SwarmUiConfigFile Save(SwarmUiConfigFile config)
|
||||
{
|
||||
config.Validate();
|
||||
var json = JsonSerializer.Serialize(config, JsonOptions);
|
||||
var temp = FilePath + ".tmp";
|
||||
File.WriteAllText(temp, json);
|
||||
File.Move(temp, FilePath, overwrite: true);
|
||||
lock (_lock)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
logger.LogInformation("SwarmUI settings saved to {Path}.", FilePath);
|
||||
return config;
|
||||
}
|
||||
|
||||
public SwarmUiResolvedProfile Resolve(int age, PortraitKind kind)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _config.Resolve(age, kind);
|
||||
}
|
||||
}
|
||||
|
||||
private SwarmUiConfigFile ReadFromDisk()
|
||||
{
|
||||
if (!File.Exists(FilePath))
|
||||
{
|
||||
logger.LogWarning("SwarmUI settings file {Path} is missing; using built-in defaults.", FilePath);
|
||||
return SwarmUiConfigFile.CreateDefault();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(FilePath);
|
||||
var loaded = JsonSerializer.Deserialize<SwarmUiConfigFile>(json, JsonOptions) ?? SwarmUiConfigFile.CreateDefault();
|
||||
loaded.NormalizeAfterLoad();
|
||||
loaded.Validate();
|
||||
return loaded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}; using built-in defaults.", FilePath);
|
||||
return SwarmUiConfigFile.CreateDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiConfigFile
|
||||
{
|
||||
public string ActivePresetId { get; set; } = "default";
|
||||
|
||||
public List<SwarmUiPresetDefinition> Presets { get; set; } = [];
|
||||
|
||||
public List<SwarmUiAgeRule> AgeRules { get; set; } = [];
|
||||
|
||||
// Legacy flat fields — read for migration only.
|
||||
public string? Model { get; set; }
|
||||
public int? Steps { get; set; }
|
||||
public double? CfgScale { get; set; }
|
||||
public int? ClipSkip { get; set; }
|
||||
public string? Sampler { get; set; }
|
||||
public string? Scheduler { get; set; }
|
||||
public long? Seed { get; set; }
|
||||
public string? Positive { get; set; }
|
||||
public string? Negative { get; set; }
|
||||
public SwarmUiKindPreset? Avatar { get; set; }
|
||||
public SwarmUiKindPreset? Custom { get; set; }
|
||||
public SwarmUiKindPreset? FullBody { get; set; }
|
||||
|
||||
public void NormalizeAfterLoad()
|
||||
{
|
||||
if (Presets.Count == 0 && !string.IsNullOrWhiteSpace(Model))
|
||||
{
|
||||
Presets =
|
||||
[
|
||||
new SwarmUiPresetDefinition
|
||||
{
|
||||
Id = "default",
|
||||
Label = "Default",
|
||||
Model = Model ?? "",
|
||||
Steps = Steps ?? 8,
|
||||
CfgScale = CfgScale ?? 1,
|
||||
ClipSkip = ClipSkip ?? 1,
|
||||
Sampler = Sampler ?? "",
|
||||
Scheduler = Scheduler ?? "",
|
||||
Seed = Seed ?? -1,
|
||||
Positive = Positive ?? "",
|
||||
Negative = Negative ?? "",
|
||||
Avatar = Avatar ?? new SwarmUiKindPreset(),
|
||||
Custom = Custom ?? new SwarmUiKindPreset(),
|
||||
FullBody = FullBody ?? new SwarmUiKindPreset(),
|
||||
},
|
||||
];
|
||||
ActivePresetId = "default";
|
||||
}
|
||||
|
||||
if (Presets.Count == 0)
|
||||
{
|
||||
Presets = [SwarmUiPresetDefinition.CreateDefault()];
|
||||
ActivePresetId = "default";
|
||||
}
|
||||
|
||||
foreach (var preset in Presets)
|
||||
{
|
||||
preset.Avatar ??= new SwarmUiKindPreset();
|
||||
preset.Custom ??= new SwarmUiKindPreset();
|
||||
preset.FullBody ??= new SwarmUiKindPreset();
|
||||
preset.PositiveLoras ??= [];
|
||||
preset.NegativeLoras ??= [];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ActivePresetId) || FindPreset(ActivePresetId) is null)
|
||||
{
|
||||
ActivePresetId = Presets[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Presets.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one preset is required.");
|
||||
}
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var preset in Presets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(preset.Id))
|
||||
{
|
||||
throw new InvalidOperationException("Every preset needs a non-empty id.");
|
||||
}
|
||||
|
||||
if (!ids.Add(preset.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Duplicate preset id '{preset.Id}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(preset.Label))
|
||||
{
|
||||
preset.Label = preset.Id;
|
||||
}
|
||||
|
||||
preset.Validate();
|
||||
}
|
||||
|
||||
if (FindPreset(ActivePresetId) is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Active preset '{ActivePresetId}' does not exist.");
|
||||
}
|
||||
|
||||
foreach (var rule in AgeRules)
|
||||
{
|
||||
if (rule.MinAge > rule.MaxAge)
|
||||
{
|
||||
throw new InvalidOperationException($"Age rule {rule.MinAge}-{rule.MaxAge} is inverted.");
|
||||
}
|
||||
|
||||
if (FindPreset(rule.PresetId) is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Age rule references unknown preset '{rule.PresetId}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SwarmUiResolvedProfile Resolve(int age, PortraitKind kind)
|
||||
{
|
||||
var presetId = ResolvePresetId(age);
|
||||
var preset = FindPreset(presetId) ?? FindPreset(ActivePresetId) ?? Presets[0];
|
||||
return preset.ToProfile(kind);
|
||||
}
|
||||
|
||||
private string ResolvePresetId(int age)
|
||||
{
|
||||
foreach (var rule in AgeRules.OrderBy(rule => rule.MinAge))
|
||||
{
|
||||
if (age >= rule.MinAge && age <= rule.MaxAge)
|
||||
{
|
||||
return rule.PresetId;
|
||||
}
|
||||
}
|
||||
|
||||
return ActivePresetId;
|
||||
}
|
||||
|
||||
private SwarmUiPresetDefinition? FindPreset(string id) =>
|
||||
Presets.FirstOrDefault(preset => string.Equals(preset.Id, id, StringComparison.Ordinal));
|
||||
|
||||
public static SwarmUiConfigFile CreateDefault() =>
|
||||
new()
|
||||
{
|
||||
ActivePresetId = "default",
|
||||
Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()],
|
||||
AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }],
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiPresetDefinition
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
public string Model { get; set; } = "";
|
||||
|
||||
public int Steps { get; set; } = 8;
|
||||
|
||||
public double CfgScale { get; set; } = 1;
|
||||
|
||||
public int ClipSkip { get; set; } = 1;
|
||||
|
||||
public string Sampler { get; set; } = "";
|
||||
|
||||
public string Scheduler { get; set; } = "";
|
||||
|
||||
public long Seed { get; set; } = -1;
|
||||
|
||||
public string Positive { get; set; } = "";
|
||||
|
||||
public string Negative { get; set; } = "";
|
||||
|
||||
public List<SwarmUiLoraEntry>? PositiveLoras { get; set; }
|
||||
|
||||
public List<SwarmUiLoraEntry>? NegativeLoras { get; set; }
|
||||
|
||||
public SwarmUiKindPreset? Avatar { get; set; }
|
||||
|
||||
public SwarmUiKindPreset? Custom { get; set; }
|
||||
|
||||
public SwarmUiKindPreset? FullBody { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Steps is < 1 or > 200)
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' steps must be between 1 and 200.");
|
||||
}
|
||||
|
||||
if (CfgScale is < 0 or > 30)
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' cfgScale must be between 0 and 30.");
|
||||
}
|
||||
|
||||
if (ClipSkip is < 0 or > 12)
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' clipSkip must be between 0 and 12.");
|
||||
}
|
||||
|
||||
ValidateLoras(PositiveLoras, "positive");
|
||||
ValidateLoras(NegativeLoras, "negative");
|
||||
}
|
||||
|
||||
private void ValidateLoras(IReadOnlyList<SwarmUiLoraEntry>? loras, string side)
|
||||
{
|
||||
if (loras is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var lora in loras)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lora.Name))
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' has an empty {side} LoRA name.");
|
||||
}
|
||||
|
||||
if (lora.Weight is < -4 or > 4)
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' LoRA '{lora.Name}' weight is out of range.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SwarmUiResolvedProfile ToProfile(PortraitKind kind) =>
|
||||
new(
|
||||
Id,
|
||||
Label,
|
||||
Model,
|
||||
Steps,
|
||||
CfgScale,
|
||||
ClipSkip,
|
||||
Sampler,
|
||||
Scheduler,
|
||||
Seed,
|
||||
Positive,
|
||||
Negative,
|
||||
PositiveLoras ?? [],
|
||||
NegativeLoras ?? [],
|
||||
KindPresetFor(kind));
|
||||
|
||||
private SwarmUiKindPreset KindPresetFor(PortraitKind kind) => kind switch
|
||||
{
|
||||
PortraitKind.Avatar => Avatar ?? new SwarmUiKindPreset(),
|
||||
PortraitKind.Custom => Custom ?? new SwarmUiKindPreset(),
|
||||
PortraitKind.Full => FullBody ?? new SwarmUiKindPreset(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
public static SwarmUiPresetDefinition CreateDefault() =>
|
||||
new()
|
||||
{
|
||||
Id = "default",
|
||||
Label = "Default",
|
||||
Model = "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
|
||||
Steps = 4,
|
||||
CfgScale = 2,
|
||||
ClipSkip = 2,
|
||||
Sampler = "dpmpp_sde",
|
||||
Scheduler = "karras",
|
||||
Seed = 3346112079,
|
||||
Positive =
|
||||
"cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
|
||||
Negative =
|
||||
"(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
|
||||
Avatar = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 1024,
|
||||
Height = 1024,
|
||||
Positive = "close up, head and shoulders portrait, facing the camera, upper body visible.",
|
||||
},
|
||||
Custom = new SwarmUiKindPreset { Width = 896, Height = 1152 },
|
||||
FullBody = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 896,
|
||||
Height = 1152,
|
||||
Positive = "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible.",
|
||||
},
|
||||
};
|
||||
|
||||
public static SwarmUiPresetDefinition CreateChild()
|
||||
{
|
||||
var adult = CreateDefault();
|
||||
return new SwarmUiPresetDefinition
|
||||
{
|
||||
Id = "child",
|
||||
Label = "Children",
|
||||
Model = adult.Model,
|
||||
Steps = adult.Steps,
|
||||
CfgScale = adult.CfgScale,
|
||||
ClipSkip = adult.ClipSkip,
|
||||
Sampler = adult.Sampler,
|
||||
Scheduler = adult.Scheduler,
|
||||
Seed = adult.Seed,
|
||||
Positive =
|
||||
"cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus",
|
||||
Negative = adult.Negative,
|
||||
Avatar = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 1024,
|
||||
Height = 1024,
|
||||
Positive = "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features.",
|
||||
},
|
||||
Custom = adult.Custom,
|
||||
FullBody = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 896,
|
||||
Height = 1152,
|
||||
Positive = "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiAgeRule
|
||||
{
|
||||
public int MinAge { get; set; }
|
||||
|
||||
public int MaxAge { get; set; }
|
||||
|
||||
public string PresetId { get; set; } = "";
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiKindPreset
|
||||
{
|
||||
public int Width { get; set; } = 512;
|
||||
|
||||
public int Height { get; set; } = 512;
|
||||
|
||||
public string Positive { get; set; } = "";
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiLoraEntry
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public double Weight { get; set; } = 1;
|
||||
}
|
||||
|
||||
internal sealed record SwarmUiResolvedProfile(
|
||||
string PresetId,
|
||||
string PresetLabel,
|
||||
string Model,
|
||||
int Steps,
|
||||
double CfgScale,
|
||||
int ClipSkip,
|
||||
string Sampler,
|
||||
string Scheduler,
|
||||
long Seed,
|
||||
string Positive,
|
||||
string Negative,
|
||||
IReadOnlyList<SwarmUiLoraEntry> PositiveLoras,
|
||||
IReadOnlyList<SwarmUiLoraEntry> NegativeLoras,
|
||||
SwarmUiKindPreset KindPreset);
|
||||
@@ -38,7 +38,14 @@ builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
builder.Services.AddSwarmUi(builder.Configuration);
|
||||
builder.Services.AddSingleton<SwarmUiHealthService>();
|
||||
builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings")));
|
||||
builder.Services.AddSingleton<SwarmUiSettingsStore>(sp =>
|
||||
{
|
||||
var store = new SwarmUiSettingsStore(
|
||||
sp.GetRequiredService<IHostEnvironment>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<SwarmUiSettingsStore>());
|
||||
store.Load();
|
||||
return store;
|
||||
});
|
||||
builder.Services.AddSingleton<PortraitService>();
|
||||
|
||||
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
|
||||
@@ -58,6 +65,7 @@ app.UseWebSockets(new WebSocketOptions
|
||||
});
|
||||
|
||||
app.MapSchoolEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapTimetableEndpoints();
|
||||
app.MapModEndpoints();
|
||||
|
||||
|
||||
@@ -1,25 +1,66 @@
|
||||
{
|
||||
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
|
||||
"steps": 4,
|
||||
"cfgScale": 2,
|
||||
"clipSkip": 2,
|
||||
"sampler": "dpmpp_sde",
|
||||
"scheduler": "karras",
|
||||
"seed": 3346112079,
|
||||
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
|
||||
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo",
|
||||
"avatar": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
|
||||
},
|
||||
"custom": {
|
||||
"width": 896,
|
||||
"height": 1152
|
||||
},
|
||||
"fullBody": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
|
||||
}
|
||||
"activePresetId": "default",
|
||||
"presets": [
|
||||
{
|
||||
"id": "default",
|
||||
"label": "Default",
|
||||
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
|
||||
"steps": 4,
|
||||
"cfgScale": 2,
|
||||
"clipSkip": 2,
|
||||
"sampler": "dpmpp_sde",
|
||||
"scheduler": "karras",
|
||||
"seed": 3346112079,
|
||||
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
|
||||
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
|
||||
"positiveLoras": [],
|
||||
"negativeLoras": [],
|
||||
"avatar": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
|
||||
},
|
||||
"custom": {
|
||||
"width": 896,
|
||||
"height": 1152
|
||||
},
|
||||
"fullBody": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "child",
|
||||
"label": "Children",
|
||||
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
|
||||
"steps": 4,
|
||||
"cfgScale": 2,
|
||||
"clipSkip": 2,
|
||||
"sampler": "dpmpp_sde",
|
||||
"scheduler": "karras",
|
||||
"seed": 3346112079,
|
||||
"positive": "cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus",
|
||||
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
|
||||
"positiveLoras": [],
|
||||
"negativeLoras": [],
|
||||
"avatar": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"positive": "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features."
|
||||
},
|
||||
"custom": {
|
||||
"width": 896,
|
||||
"height": 1152
|
||||
},
|
||||
"fullBody": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible."
|
||||
}
|
||||
}
|
||||
],
|
||||
"ageRules": [
|
||||
{ "minAge": 6, "maxAge": 11, "presetId": "child" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user