Split SwarmUI portraits into a model catalog and layered prompts.

Picker is Swarm intersect config, preset style/shot inherit model defaults, and living saves lift the old preset shape.
This commit is contained in:
Leonid Pershin
2026-08-20 14:08:11 +03:00
parent cc9ea18480
commit 81fd3f6181
20 changed files with 1142 additions and 179 deletions
+1
View File
@@ -9,6 +9,7 @@
| [Пропуск сборки](skip-stale-build.md) | 49 | | [Пропуск сборки](skip-stale-build.md) | 49 |
| [Что нового](whats-new.md) | 54 | | [Что нового](whats-new.md) | 54 |
| [Состояние экрана](view-state.md) | 55 | | [Состояние экрана](view-state.md) | 55 |
| [Модели портретов](portrait-models.md) | 60 |
Люди: [`../02-people/people.md`](../02-people/people.md). Штат: [`../03-staffing/staffing.md`](../03-staffing/staffing.md). Люди: [`../02-people/people.md`](../02-people/people.md). Штат: [`../03-staffing/staffing.md`](../03-staffing/staffing.md).
Расписание: [`../04-schedule/schedule.md`](../04-schedule/schedule.md). Присутствие: [`../05-ai/ai.md`](../05-ai/ai.md). Расписание: [`../04-schedule/schedule.md`](../04-schedule/schedule.md). Присутствие: [`../05-ai/ai.md`](../05-ai/ai.md).
+43
View File
@@ -0,0 +1,43 @@
Часть [Вне очереди](README.md). Соседей по папке не читать, если задача не про них.
# Модели Swarm и слои промпта
### Зачем
Пикер моделей не должен предлагать то, чего нет в нашем конфиге. Дефолты генерации живут
на модели; пресет задаёт стиль и тип кадра и может переопределить дефолты. Промпт собирается
слоями, а не одним «базовым» текстом пресета.
### Было / Стало / Почему
**Было.** Пресет сам держал модель, steps/cfg/sampler и `positive`. Список Swarm только
наполнял комбобоксы. «Свой промпт» у custom **заменял** текст кадра. `portraitSettings` в
сейве — копия этого файла.
**Стало.** В конфиге каталог `models` (whitelist) и пресеты. Пикер = Swarm ∩ каталог; при
офлайне Swarm — все id из каталога. У модели: закрытый набор настроек генерации (steps, cfg,
clip skip, sampler, scheduler, seed, LoRA) и обычно пустые base positive/negative. У пресета:
модель, **стиль** (бывший `positive`), доп. negative, размеры и **тип кадра** на avatar/custom/full.
Оверрайды генерации у пресета — nullable, в UI под спойлером. Промпт:
1. base positive модели
2. стиль пресета
3. тип кадра
4. «Свой промпт» (`promptExtra`, только custom; после кадра, не вместо)
5. поза (слот пустой, без поля в UI)
6. внешность: кто это, возраст, тело
7. одежда
8. хвост пустой
Negative: модель + необязательное дополнение пресета. Ширина/высота остаются на виде кадра.
**Почему.** Модель задаёт «как считать картинку»; пресет — «какой это школьный снимок».
Гость по-прежнему рисует с копии школы, не с глобального файла.
### Что не входит
Зеркало всех `ListT2IParams`. Кнопка настроек в меню. Редактор каталога «добавить модель из
Swarm». Поза в UI. Бамп версии сокета. Перегенерация уже лежащих PNG.
Живые школы: `NormalizeAfterLoad` поднимает старый пресет (`positive``style`, kind
`positive``shotType`, уникальные `model``models[]`). Старые картинки не трогаем.
@@ -0,0 +1,45 @@
# Фаза 60. Модели портретов
## Зависимости
Нет. Портреты и `swarmui.json` уже живут вне среза.
## Зачем
Пикер показывает только модели из нашего каталога, которые есть в Swarm. Дефолты генерации
на модели; пресет даёт стиль и тип кадра и может переопределить дефолты под спойлером.
Промпт собирается слоями.
## Задачи
- [x] `swarmui.json`: каталог `models` на все четыре модели из текущего Swarm; пресеты ссылаются
на модель, `style` вместо бывшего base `positive`, `shotType` на виде кадра
- [x] Пикер = Swarm ∩ каталог; Swarm офлайн — id из каталога. Чего нет в конфиге, с пикера нет
- [x] Сборка промпта: модель → стиль → кадр → свой промпт → пустая поза → внешность (кто, возраст,
тело) → одежда. Custom **не** заменяет тип кадра. Negative: модель + доп. пресета
- [x] UI диалога пресетов (создание школы): стиль и кадр сверху; дефолты модели видны; оверрайды
пресета под `<details>`. Строки через `t(...)`
- [x] Живой сейв поднимается в `NormalizeAfterLoad`. HTTP `GET`/`PUT /api/settings/swarmui` и
`portraitSettings` — новая форма. Сокет не бампить. [`protocol.md`](../../protocol.md) тем же
коммитом
## Тесты, без которых фаза не закрыта
- [x] Лифт старого пресета: `positive``style`, kind `positive``shotType`, модель в `models`
- [x] Resolve без оверрайда берёт steps/cfg с модели; с оверрайдом — с пресета
- [x] Пикер: пересечение Swarm ∩ каталог; офлайн — каталог; лишняя модель Swarm отброшена
- [x] Промпт: порядок слоёв; custom держит тип кадра и `promptExtra`; пустая поза не попадает в текст
- [x] `GET /api/settings/swarmui` отдаёт `models`; create копирует их в сейв
- [x] Диалог: поле стиля, спойлер оверрайдов, в селекте модели нет id вне каталога при живом Swarm
## Критерий готовности
- В диалоге создания школы в пикере только модели из `swarmui.json`, которые ответил Swarm
- Смена модели подставляет её дефолты; пресет может перебить их под спойлером
- «Показать промпт» на карточке: стиль, затем кадр, затем тело и одежда; свой промпт после кадра
- Старая школа открывается и генерирует без ручного правок сейва
## Стоп
Не бампить сокет. Не зеркалить все параметры Swarm. Не возвращать кнопку настроек в меню.
Не перегенерировать PNG. Не писать позу в UI.
+1
View File
@@ -19,3 +19,4 @@
| [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу | | [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу |
| [54. Что нового](54-whats-new.md) | ✅ | После входа — окно коммитов с прошлого визита | | [54. Что нового](54-whats-new.md) | ✅ | После входа — окно коммитов с прошлого визита |
| [55. Состояние экрана](55-view-state.md) | ✅ | F5 и переходы не сбрасывают школу, вкладки и фильтры | | [55. Состояние экрана](55-view-state.md) | ✅ | F5 и переходы не сбрасывают школу, вкладки и фильтры |
| [60. Модели портретов](60-portrait-models.md) | 🔄 | Каталог моделей Swarm ∩ конфиг, слои промпта, спойлер оверрайдов |
+16 -12
View File
@@ -495,9 +495,9 @@ is `image/png`. Opening the card does not generate; use POST when the player ask
### `POST /api/schools/{id}/people/{personId}/portrait` ### `POST /api/schools/{id}/people/{personId}/portrait`
Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET. Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET.
For `kind=custom` the body is `{ "promptExtra": "..." }` — appended to the base SwarmUI prompt and For `kind=custom` the body is `{ "promptExtra": "..." }` — appended after the shot type (and the
the person's body/clothing; required, non-empty, at most 2000 characters. Avatar and full-body POST model/style layers), then the person's appearance and clothing; required, non-empty, at most 2000
need no body. characters. Avatar and full-body POST need no body.
Success is `201` with `{ "kind", "hasAvatar", "hasCustom", "hasFullBody", "customPortraitPrompt" }` and a `Location` header pointing Success is `201` with `{ "kind", "hasAvatar", "hasCustom", "hasFullBody", "customPortraitPrompt" }` and a `Location` header pointing
at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`. at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`.
Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files
@@ -506,8 +506,8 @@ land under `saves/{id}.portraits/` and survive until the school is deleted.
### `GET /api/schools/{id}/people/{personId}/portrait/prompt` ### `GET /api/schools/{id}/people/{personId}/portrait/prompt`
Returns the positive and negative prompts SwarmUI would receive, without generating an image. Returns the positive and negative prompts SwarmUI would receive, without generating an image.
Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended to Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended after
the base prompt; when omitted, the last saved custom prompt is used if one exists. Unknown school the shot type; when omitted, the last saved custom prompt is used if one exists. Unknown school
is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `kind` is is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `kind` is
`400` `invalid-query`; custom without a usable prompt is `400` `invalid-body`. `400` `invalid-query`; custom without a usable prompt is `400` `invalid-body`.
@@ -527,21 +527,25 @@ so the client can disable generate buttons and show reachability without trying
### `GET /api/settings/swarmui` ### `GET /api/settings/swarmui`
Returns the **default** SwarmUI preset template (`swarmui.json`): named presets (model, steps, Returns the **default** SwarmUI template (`swarmui.json`): a `models` catalog (id, default
sampler, LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules`. A new school copies generation knobs, usually empty base positive/negative), named presets (model id, `style`, extra
this into its save as `portraitSettings`. Living schools generate from that copy, not from this negative, optional generation overrides, per-kind size/`shotType`), `activePresetId` and `ageRules`.
file. The model picker in the UI is Swarm's list intersected with `models`. A new school copies this into
its save as `portraitSettings`. Living schools generate from that copy, not from this file. Older
saves without `models` lift on load (`positive``style`, kind `positive``shotType`).
### `PUT /api/settings/swarmui` ### `PUT /api/settings/swarmui`
Replaces the default template after validation. Invalid preset ids, age rules or numeric ranges Replaces the default template after validation. Invalid model/preset ids, age rules or numeric ranges
return `400` `invalid-body`. Already-created schools keep the copy they were created with. return `400` `invalid-body`. Already-created schools keep the copy they were created with.
### `GET /api/settings/swarmui/discovery` ### `GET /api/settings/swarmui/discovery`
When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns
`{ connected, models, loras, samplers, schedulers }` for the settings UI comboboxes. When SwarmUI `{ connected, models, loras, samplers, schedulers }` for the settings UI. The client hides Swarm
is off or unreachable, `connected` is false and the lists are empty. models that are missing from the template catalog. When SwarmUI is off or unreachable, `connected`
is false and the lists are empty; the picker then shows the catalog ids so fields can be filled
manually.
### `GET /api/schools/{id}/dress-rules` ### `GET /api/schools/{id}/dress-rules`
+16 -6
View File
@@ -44,8 +44,13 @@ const ru = {
settingsSampler: 'Sampler', settingsSampler: 'Sampler',
settingsScheduler: 'Scheduler', settingsScheduler: 'Scheduler',
settingsSeed: 'Seed', settingsSeed: 'Seed',
settingsPositive: 'Базовый positive', settingsStyle: 'Стиль',
settingsNegative: 'Negative', settingsPresetNegative: 'Negative пресета',
settingsModelDefaults: 'Дефолты модели',
settingsModelMissing: 'Этой модели нет в каталоге — выберите другую.',
settingsModelPositive: 'Базовый positive модели',
settingsModelNegative: 'Базовый negative модели',
settingsOverrides: 'Настройки генерации пресета',
settingsPositiveLoras: 'Positive LoRA', settingsPositiveLoras: 'Positive LoRA',
settingsNegativeLoras: 'Negative LoRA', settingsNegativeLoras: 'Negative LoRA',
settingsAddLora: 'Добавить LoRA', settingsAddLora: 'Добавить LoRA',
@@ -54,7 +59,7 @@ const ru = {
settingsFullBodyPreset: 'В полный рост', settingsFullBodyPreset: 'В полный рост',
settingsWidth: 'Ширина', settingsWidth: 'Ширина',
settingsHeight: 'Высота', settingsHeight: 'Высота',
settingsKindPositive: 'Positive для вида', settingsShotType: 'Тип кадра',
settingsAgeRules: 'Правила по возрасту', settingsAgeRules: 'Правила по возрасту',
settingsAgeMin: 'От', settingsAgeMin: 'От',
settingsAgeMax: 'До', settingsAgeMax: 'До',
@@ -417,8 +422,13 @@ const en: Messages = {
settingsSampler: 'Sampler', settingsSampler: 'Sampler',
settingsScheduler: 'Scheduler', settingsScheduler: 'Scheduler',
settingsSeed: 'Seed', settingsSeed: 'Seed',
settingsPositive: 'Base positive', settingsStyle: 'Style',
settingsNegative: 'Negative', settingsPresetNegative: 'Preset negative',
settingsModelDefaults: 'Model defaults',
settingsModelMissing: 'This model is not in the catalog — pick another.',
settingsModelPositive: 'Model base positive',
settingsModelNegative: 'Model base negative',
settingsOverrides: 'Preset generation overrides',
settingsPositiveLoras: 'Positive LoRA', settingsPositiveLoras: 'Positive LoRA',
settingsNegativeLoras: 'Negative LoRA', settingsNegativeLoras: 'Negative LoRA',
settingsAddLora: 'Add LoRA', settingsAddLora: 'Add LoRA',
@@ -427,7 +437,7 @@ const en: Messages = {
settingsFullBodyPreset: 'Full body', settingsFullBodyPreset: 'Full body',
settingsWidth: 'Width', settingsWidth: 'Width',
settingsHeight: 'Height', settingsHeight: 'Height',
settingsKindPositive: 'Kind positive', settingsShotType: 'Shot type',
settingsAgeRules: 'Age rules', settingsAgeRules: 'Age rules',
settingsAgeMin: 'From', settingsAgeMin: 'From',
settingsAgeMax: 'To', settingsAgeMax: 'To',
+28 -3
View File
@@ -497,7 +497,7 @@ export async function fetchChangelog(since?: string | null): Promise<ChangelogRe
export interface SwarmUiKindPreset { export interface SwarmUiKindPreset {
width: number; width: number;
height: number; height: number;
positive: string; shotType: string;
} }
export interface SwarmUiLoraEntry { export interface SwarmUiLoraEntry {
@@ -505,10 +505,9 @@ export interface SwarmUiLoraEntry {
weight: number; weight: number;
} }
export interface SwarmUiPresetDefinition { export interface SwarmUiModelDefinition {
id: string; id: string;
label: string; label: string;
model: string;
steps: number; steps: number;
cfgScale: number; cfgScale: number;
clipSkip: number; clipSkip: number;
@@ -519,6 +518,22 @@ export interface SwarmUiPresetDefinition {
negative: string; negative: string;
positiveLoras: SwarmUiLoraEntry[]; positiveLoras: SwarmUiLoraEntry[];
negativeLoras: SwarmUiLoraEntry[]; negativeLoras: SwarmUiLoraEntry[];
}
export interface SwarmUiPresetDefinition {
id: string;
label: string;
model: string;
style: string;
negative: string;
steps?: number | null;
cfgScale?: number | null;
clipSkip?: number | null;
sampler?: string | null;
scheduler?: string | null;
seed?: number | null;
positiveLoras?: SwarmUiLoraEntry[] | null;
negativeLoras?: SwarmUiLoraEntry[] | null;
avatar: SwarmUiKindPreset; avatar: SwarmUiKindPreset;
custom: SwarmUiKindPreset; custom: SwarmUiKindPreset;
fullBody: SwarmUiKindPreset; fullBody: SwarmUiKindPreset;
@@ -532,6 +547,7 @@ export interface SwarmUiAgeRule {
export interface SwarmUiSettingsFile { export interface SwarmUiSettingsFile {
activePresetId: string; activePresetId: string;
models: SwarmUiModelDefinition[];
presets: SwarmUiPresetDefinition[]; presets: SwarmUiPresetDefinition[];
ageRules: SwarmUiAgeRule[]; ageRules: SwarmUiAgeRule[];
} }
@@ -544,6 +560,15 @@ export interface SwarmUiDiscovery {
schedulers: readonly string[]; schedulers: readonly string[];
} }
export function allowedSwarmModels(settings: SwarmUiSettingsFile, discovery: SwarmUiDiscovery): string[] {
const catalog = settings.models.map((model) => model.id);
if (!discovery.connected || discovery.models.length === 0) {
return catalog;
}
return discovery.models.filter((name) => catalog.includes(name));
}
export async function fetchSwarmUiSettings(): Promise<SwarmUiSettingsFile> { export async function fetchSwarmUiSettings(): Promise<SwarmUiSettingsFile> {
return request<SwarmUiSettingsFile>('/api/settings/swarmui'); return request<SwarmUiSettingsFile>('/api/settings/swarmui');
} }
+24
View File
@@ -717,6 +717,30 @@ body {
overflow: auto; overflow: auto;
} }
.settings-model {
margin-top: 16px;
padding-top: 8px;
border-top: 1px solid var(--border, #ddd);
}
.settings-overrides {
margin: 16px 0 8px;
padding: 8px 12px 12px;
border: 1px solid var(--border, #ddd);
border-radius: 8px;
background: var(--panel-muted, #f7f7f7);
}
.settings-overrides > summary {
cursor: pointer;
font-weight: 600;
font-size: 14px;
}
.settings-overrides__body {
margin-top: 12px;
}
.settings-section-title { .settings-section-title {
margin: 20px 0 8px; margin: 20px 0 8px;
font-size: 15px; font-size: 15px;
@@ -114,24 +114,32 @@ describe('createSchoolDialog', () => {
vi.mocked(fetchSwarmUiSettings).mockReset(); vi.mocked(fetchSwarmUiSettings).mockReset();
vi.mocked(fetchSwarmUiSettings).mockResolvedValue({ vi.mocked(fetchSwarmUiSettings).mockResolvedValue({
activePresetId: 'default', activePresetId: 'default',
presets: [ models: [
{ {
id: 'default', id: 'template.safetensors',
label: 'Default', label: 'Template',
model: 'template.safetensors',
steps: 4, steps: 4,
cfgScale: 1, cfgScale: 1,
clipSkip: 1, clipSkip: 1,
sampler: 'euler', sampler: 'euler',
scheduler: 'normal', scheduler: 'normal',
seed: -1, seed: -1,
positive: 'base', positive: '',
negative: 'neg', negative: '',
positiveLoras: [], positiveLoras: [],
negativeLoras: [], negativeLoras: [],
avatar: { width: 512, height: 512, positive: '' }, },
custom: { width: 512, height: 512, positive: '' }, ],
fullBody: { width: 512, height: 512, positive: '' }, presets: [
{
id: 'default',
label: 'Default',
model: 'template.safetensors',
style: 'base',
negative: 'neg',
avatar: { width: 512, height: 512, shotType: '' },
custom: { width: 512, height: 512, shotType: '' },
fullBody: { width: 512, height: 512, shotType: '' },
}, },
], ],
ageRules: [], ageRules: [],
@@ -0,0 +1,175 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
allowedSwarmModels,
fetchGameStatus,
fetchSwarmUiDiscovery,
fetchSwarmUiSettings,
type SwarmUiDiscovery,
type SwarmUiSettingsFile,
} from '../net/api.ts';
import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
vi.mock('../net/api.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../net/api.ts')>();
return {
...actual,
fetchGameStatus: vi.fn(),
fetchSwarmUiDiscovery: vi.fn(),
fetchSwarmUiSettings: vi.fn(),
};
});
const initialLocale = getLocale();
function settings(): SwarmUiSettingsFile {
return {
activePresetId: 'default',
models: [
{
id: 'template.safetensors',
label: 'Template',
steps: 4,
cfgScale: 1,
clipSkip: 1,
sampler: 'euler',
scheduler: 'normal',
seed: -1,
positive: '',
negative: '',
positiveLoras: [],
negativeLoras: [],
},
{
id: 'other.safetensors',
label: 'Other',
steps: 8,
cfgScale: 2,
clipSkip: 0,
sampler: 'euler',
scheduler: 'normal',
seed: 0,
positive: '',
negative: '',
positiveLoras: [],
negativeLoras: [],
},
],
presets: [
{
id: 'default',
label: 'Default',
model: 'template.safetensors',
style: 'cinematic',
negative: 'neg',
avatar: { width: 512, height: 512, shotType: 'close up' },
custom: { width: 512, height: 512, shotType: '' },
fullBody: { width: 512, height: 512, shotType: 'full body' },
},
],
ageRules: [],
};
}
function discovery(overrides: Partial<SwarmUiDiscovery> = {}): SwarmUiDiscovery {
return {
connected: true,
models: ['template.safetensors', 'not-in-config.safetensors'],
loras: [],
samplers: ['euler'],
schedulers: ['normal'],
...overrides,
};
}
describe('allowedSwarmModels', () => {
it('returns the Swarm ∩ catalog intersection when connected', () => {
expect(allowedSwarmModels(settings(), discovery())).toEqual(['template.safetensors']);
});
it('returns the catalog when Swarm is offline', () => {
expect(allowedSwarmModels(settings(), discovery({ connected: false, models: [] }))).toEqual([
'template.safetensors',
'other.safetensors',
]);
});
});
describe('swarmUiSettingsDialog', () => {
beforeEach(() => {
setLocale('en');
vi.mocked(fetchGameStatus).mockReset();
vi.mocked(fetchSwarmUiDiscovery).mockReset();
vi.mocked(fetchSwarmUiSettings).mockReset();
vi.mocked(fetchGameStatus).mockResolvedValue({
tick: 0,
tickRate: 20,
schools: 0,
maxSchools: 2,
connections: 1,
swarmUiConfigured: true,
swarmUiConnected: true,
});
vi.mocked(fetchSwarmUiDiscovery).mockResolvedValue(discovery());
});
afterEach(() => {
document.body.replaceChildren();
setLocale(initialLocale);
});
it('shows style, shot type, model defaults and a collapsed overrides spoiler', async () => {
const opened = swarmUiSettingsDialog(settings());
const dialog = await vi.waitFor(() => {
const node = document.querySelector('dialog');
if (node === null || ![...node.querySelectorAll('.field__label')].some((entry) => entry.textContent === t('settingsStyle'))) {
throw new Error('settings form is not painted');
}
return node;
});
expect([...dialog.querySelectorAll('.field__label')].some((node) => node.textContent === t('settingsStyle'))).toBe(true);
expect([...dialog.querySelectorAll('.field__label')].some((node) => node.textContent === t('settingsShotType'))).toBe(true);
expect(dialog.querySelector('.settings-model')?.textContent).toContain(t('settingsModelDefaults'));
const spoiler = dialog.querySelector('details.settings-overrides');
if (!(spoiler instanceof HTMLDetailsElement)) {
throw new Error('overrides spoiler is missing');
}
expect(spoiler.open).toBe(false);
expect(spoiler.querySelector('summary')?.textContent).toBe(t('settingsOverrides'));
void opened;
});
it('omits Swarm models that are not in the catalog', async () => {
const opened = swarmUiSettingsDialog(settings());
const dialog = await vi.waitFor(() => {
const node = document.querySelector('dialog');
const label = node === null
? undefined
: [...node.querySelectorAll('.field__label')].find((entry) => entry.textContent === t('settingsModel'));
if (node === null || label === undefined) {
throw new Error('model field is not painted');
}
return node;
});
const label = [...dialog.querySelectorAll('.field__label')].find((node) => node.textContent === t('settingsModel'));
const select = label?.parentElement?.querySelector('select');
if (!(select instanceof HTMLSelectElement)) {
throw new Error('model select is missing');
}
const values = [...select.options].map((option) => option.value);
expect(values).toContain('template.safetensors');
expect(values).not.toContain('not-in-config.safetensors');
expect(values).not.toContain('other.safetensors');
void opened;
});
});
@@ -1,9 +1,11 @@
import { import {
allowedSwarmModels,
fetchGameStatus, fetchGameStatus,
fetchSwarmUiDiscovery, fetchSwarmUiDiscovery,
fetchSwarmUiSettings, fetchSwarmUiSettings,
type SwarmUiDiscovery, type SwarmUiDiscovery,
type SwarmUiLoraEntry, type SwarmUiLoraEntry,
type SwarmUiModelDefinition,
type SwarmUiPresetDefinition, type SwarmUiPresetDefinition,
type SwarmUiSettingsFile, type SwarmUiSettingsFile,
} from '../net/api.ts'; } from '../net/api.ts';
@@ -196,50 +198,117 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
function paintPresetForm(): void { function paintPresetForm(): void {
formHost.replaceChildren(); formHost.replaceChildren();
const preset = currentPreset(); const preset = currentPreset();
if (preset === null || preset === undefined) { if (config === null || preset === undefined) {
return; return;
} }
const model = currentModel(preset);
const modelIds = allowedSwarmModels(config, discovery);
formHost.append( formHost.append(
textField(t('settingsPresetLabel'), preset.label, (value) => { textField(t('settingsPresetLabel'), preset.label, (value) => {
preset.label = value; preset.label = value;
paintPresetSelectors(); paintPresetSelectors();
}), }),
choiceField(t('settingsModel'), preset.model, discovery.models, (value) => { choiceField(t('settingsModel'), preset.model, modelIds, (value) => {
preset.model = value; preset.model = value;
paintPresetForm();
}), }),
numberField(t('settingsSteps'), preset.steps, (value) => { textareaField(t('settingsStyle'), preset.style, (value) => {
preset.steps = value; preset.style = value;
}), }),
numberField(t('settingsCfg'), preset.cfgScale, (value) => { textareaField(t('settingsPresetNegative'), preset.negative, (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; preset.negative = value;
}), }),
loraSection(t('settingsPositiveLoras'), preset.positiveLoras),
loraSection(t('settingsNegativeLoras'), preset.negativeLoras),
kindSection(t('settingsAvatarPreset'), preset.avatar), kindSection(t('settingsAvatarPreset'), preset.avatar),
kindSection(t('settingsCustomPreset'), preset.custom), kindSection(t('settingsCustomPreset'), preset.custom),
kindSection(t('settingsFullBodyPreset'), preset.fullBody), kindSection(t('settingsFullBodyPreset'), preset.fullBody),
modelDefaultsSection(model),
overrideSection(preset, model),
); );
} }
function currentModel(preset: SwarmUiPresetDefinition): SwarmUiModelDefinition | undefined {
return config?.models.find((entry) => entry.id === preset.model);
}
function modelDefaultsSection(model: SwarmUiModelDefinition | undefined): HTMLElement {
const host = el('section', { class: 'settings-model' });
host.append(el('h4', { class: 'settings-subtitle', text: t('settingsModelDefaults') }));
if (model === undefined) {
host.append(el('p', { class: 'hint', text: t('settingsModelMissing') }));
return host;
}
host.append(
textareaField(t('settingsModelPositive'), model.positive, (value) => {
model.positive = value;
}),
textareaField(t('settingsModelNegative'), model.negative, (value) => {
model.negative = value;
}),
numberField(t('settingsSteps'), model.steps, (value) => {
model.steps = value;
}),
numberField(t('settingsCfg'), model.cfgScale, (value) => {
model.cfgScale = value;
}, 0.1),
numberField(t('settingsClipSkip'), model.clipSkip, (value) => {
model.clipSkip = value;
}),
choiceField(t('settingsSampler'), model.sampler, discovery.samplers, (value) => {
model.sampler = value;
}),
choiceField(t('settingsScheduler'), model.scheduler, discovery.schedulers, (value) => {
model.scheduler = value;
}),
numberField(t('settingsSeed'), model.seed, (value) => {
model.seed = value;
}),
loraSection(t('settingsPositiveLoras'), model.positiveLoras),
loraSection(t('settingsNegativeLoras'), model.negativeLoras),
);
return host;
}
function overrideSection(preset: SwarmUiPresetDefinition, model: SwarmUiModelDefinition | undefined): HTMLElement {
const details = el('details', { class: 'settings-overrides' });
details.append(el('summary', { text: t('settingsOverrides') }));
const body = el('div', { class: 'settings-overrides__body' });
body.append(
nullableNumberField(t('settingsSteps'), preset.steps, model?.steps, (value) => {
preset.steps = value;
}),
nullableNumberField(t('settingsCfg'), preset.cfgScale, model?.cfgScale, (value) => {
preset.cfgScale = value;
}, 0.1),
nullableNumberField(t('settingsClipSkip'), preset.clipSkip, model?.clipSkip, (value) => {
preset.clipSkip = value;
}),
nullableChoiceField(t('settingsSampler'), preset.sampler, model?.sampler ?? '', discovery.samplers, (value) => {
preset.sampler = value;
}),
nullableChoiceField(t('settingsScheduler'), preset.scheduler, model?.scheduler ?? '', discovery.schedulers, (value) => {
preset.scheduler = value;
}),
nullableNumberField(t('settingsSeed'), preset.seed, model?.seed, (value) => {
preset.seed = value;
}),
);
const positiveLoras = preset.positiveLoras ?? [];
const negativeLoras = preset.negativeLoras ?? [];
preset.positiveLoras = preset.positiveLoras ?? null;
const positiveHost = loraSection(t('settingsPositiveLoras'), positiveLoras, () => {
preset.positiveLoras = positiveLoras;
});
const negativeHost = loraSection(t('settingsNegativeLoras'), negativeLoras, () => {
preset.negativeLoras = negativeLoras;
});
body.append(positiveHost, negativeHost);
details.append(body);
return details;
}
function syncActivePresetFromForm(): void { function syncActivePresetFromForm(): void {
// Values are bound live through closures; nothing extra to scrape from DOM. // Values are bound live through closures; nothing extra to scrape from DOM.
} }
@@ -298,7 +367,7 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
} }
} }
function loraSection(title: string, loras: SwarmUiLoraEntry[]): HTMLElement { function loraSection(title: string, loras: SwarmUiLoraEntry[], onMutate?: () => void): HTMLElement {
const host = el('div', { class: 'settings-loras' }); const host = el('div', { class: 'settings-loras' });
const heading = el('h4', { class: 'settings-subtitle', text: title }); const heading = el('h4', { class: 'settings-subtitle', text: title });
const rows = el('div', { class: 'settings-lora-rows' }); const rows = el('div', { class: 'settings-lora-rows' });
@@ -340,6 +409,7 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
text: t('settingsAddLora'), text: t('settingsAddLora'),
onClick: () => { onClick: () => {
loras.push({ name: discovery.loras[0] ?? '', weight: 1 }); loras.push({ name: discovery.loras[0] ?? '', weight: 1 });
onMutate?.();
paintRows(); paintRows();
}, },
}); });
@@ -359,8 +429,8 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
numberField(t('settingsHeight'), preset.height, (value) => { numberField(t('settingsHeight'), preset.height, (value) => {
preset.height = value; preset.height = value;
}), }),
textareaField(t('settingsKindPositive'), preset.positive, (value) => { textareaField(t('settingsShotType'), preset.shotType, (value) => {
preset.positive = value; preset.shotType = value;
}), }),
); );
} }
@@ -424,3 +494,52 @@ function choiceField(
select.addEventListener('change', () => onChange(select.value)); select.addEventListener('change', () => onChange(select.value));
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select); return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select);
} }
function nullableNumberField(
label: string,
value: number | null | undefined,
inherited: number | undefined,
onChange: (value: number | null) => void,
step = 1,
): HTMLElement {
const input = el('input', { class: 'input', type: 'number' });
input.step = String(step);
if (value !== null && value !== undefined) {
input.value = String(value);
}
if (inherited !== undefined) {
input.placeholder = String(inherited);
}
input.addEventListener('input', () => {
onChange(input.value.trim() === '' ? null : Number(input.value));
});
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
}
function nullableChoiceField(
label: string,
value: string | null | undefined,
inherited: string,
options: readonly string[],
onChange: (value: string | null) => void,
): HTMLElement {
const inherit = inherited.length > 0 ? inherited : '—';
const values = options.filter((entry) => entry !== inherited);
const select = el(
'select',
{ class: 'input' },
el('option', { value: '', text: inherit }),
...values.map((entry) => el('option', { value: entry, text: entry })),
);
if (value !== null && value !== undefined && value.length > 0 && !options.includes(value) && value !== inherited) {
select.append(el('option', { value, text: value }));
}
select.value = value !== null && value !== undefined && value.length > 0 ? value : '';
select.addEventListener('change', () => {
onChange(select.value === '' ? null : select.value);
});
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select);
}
+1 -1
View File
@@ -81,8 +81,8 @@ internal static class SchoolEndpoints
{ {
try try
{ {
request.PortraitSettings.Validate();
portraitSettings = SwarmUiConfigFile.Clone(request.PortraitSettings); portraitSettings = SwarmUiConfigFile.Clone(request.PortraitSettings);
portraitSettings.Validate();
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
+1 -1
View File
@@ -701,7 +701,7 @@ internal sealed class GameLoopService(
save.Presence, save.Presence,
save.DressRules, save.DressRules,
save.Owner, save.Owner,
save.PortraitSettings); save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings));
worker.Start(); worker.Start();
try try
@@ -11,32 +11,23 @@ internal static class PortraitPromptBuilder
PortraitKind kind, PortraitKind kind,
string? promptExtra = null) string? promptExtra = null)
{ {
var kindPreset = profile.KindPreset;
var parts = new List<string>(); var parts = new List<string>();
Add(parts, profile.ModelPositive);
if (!string.IsNullOrWhiteSpace(profile.Positive)) Add(parts, profile.Style);
{ Add(parts, profile.ShotType);
parts.Add(profile.Positive.Trim());
}
if (kind == PortraitKind.Custom) if (kind == PortraitKind.Custom)
{ {
if (!string.IsNullOrWhiteSpace(promptExtra)) Add(parts, promptExtra);
{
parts.Add(promptExtra.Trim());
}
}
else if (!string.IsNullOrWhiteSpace(kindPreset.Positive))
{
parts.Add(kindPreset.Positive.Trim());
} }
parts.Add(DescribeSubject(card)); Add(parts, profile.Pose);
Add(parts, DescribeSubject(card));
parts.Add($"age {card.Age}"); parts.Add($"age {card.Age}");
foreach (var row in card.Body) foreach (var row in card.Body)
{ {
parts.Add($"{row.Label.ToLowerInvariant()} {row.Value.ToLowerInvariant()}"); Add(parts, $"{row.Label.ToLowerInvariant()} {row.Value.ToLowerInvariant()}");
} }
foreach (var item in PortraitVisibleWorn.Filter(card.Worn)) foreach (var item in PortraitVisibleWorn.Filter(card.Worn))
@@ -44,15 +35,15 @@ internal static class PortraitPromptBuilder
var color = item.ColorLabel ?? item.Color; var color = item.ColorLabel ?? item.Color;
if (!string.IsNullOrWhiteSpace(color)) if (!string.IsNullOrWhiteSpace(color))
{ {
parts.Add($"wearing {item.Label.ToLowerInvariant()} in {color.ToLowerInvariant()}"); Add(parts, $"wearing {item.Label.ToLowerInvariant()} in {color.ToLowerInvariant()}");
} }
else else
{ {
parts.Add($"wearing {item.Label.ToLowerInvariant()}"); Add(parts, $"wearing {item.Label.ToLowerInvariant()}");
} }
} }
var positive = string.Join(", ", parts.Where(part => part.Length > 0)); var positive = string.Join(", ", parts);
var negative = SwarmUiLoraFormatter.AppendLoraTags( var negative = SwarmUiLoraFormatter.AppendLoraTags(
profile.Negative.Trim(), profile.Negative.Trim(),
profile.NegativeLoras); profile.NegativeLoras);
@@ -60,6 +51,16 @@ internal static class PortraitPromptBuilder
return (positive, negative); return (positive, negative);
} }
private static void Add(List<string> parts, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
parts.Add(value.Trim());
}
private static string DescribeSubject(PersonCardResponse card) private static string DescribeSubject(PersonCardResponse card)
{ {
if (card.Age <= 11) if (card.Age <= 11)
+387 -60
View File
@@ -90,6 +90,8 @@ internal sealed class SwarmUiConfigFile
{ {
public string ActivePresetId { get; set; } = "default"; public string ActivePresetId { get; set; } = "default";
public List<SwarmUiModelDefinition> Models { get; set; } = [];
public List<SwarmUiPresetDefinition> Presets { get; set; } = []; public List<SwarmUiPresetDefinition> Presets { get; set; } = [];
public List<SwarmUiAgeRule> AgeRules { get; set; } = []; public List<SwarmUiAgeRule> AgeRules { get; set; } = [];
@@ -135,19 +137,44 @@ internal sealed class SwarmUiConfigFile
ActivePresetId = "default"; ActivePresetId = "default";
} }
if (Presets.Count == 0) LiftModelsFromPresets();
{
Presets = [SwarmUiPresetDefinition.CreateDefault()];
ActivePresetId = "default";
}
foreach (var preset in Presets) foreach (var preset in Presets)
{ {
preset.Avatar ??= new SwarmUiKindPreset(); preset.Avatar ??= new SwarmUiKindPreset();
preset.Custom ??= new SwarmUiKindPreset(); preset.Custom ??= new SwarmUiKindPreset();
preset.FullBody ??= new SwarmUiKindPreset(); preset.FullBody ??= new SwarmUiKindPreset();
preset.PositiveLoras ??= []; preset.LiftLegacyPromptFields();
preset.NegativeLoras ??= []; }
foreach (var model in Models)
{
model.PositiveLoras ??= [];
model.NegativeLoras ??= [];
if (string.IsNullOrWhiteSpace(model.Label))
{
model.Label = LabelFromId(model.Id);
}
}
if (Models.Count == 0)
{
Models = SwarmUiModelDefinition.Catalog();
}
foreach (var preset in Presets)
{
if (string.IsNullOrWhiteSpace(preset.Model))
{
preset.Model = Models[0].Id;
}
ClearMatchingOverrides(preset);
}
if (Presets.Count == 0)
{
return;
} }
if (string.IsNullOrWhiteSpace(ActivePresetId) || FindPreset(ActivePresetId) is null) if (string.IsNullOrWhiteSpace(ActivePresetId) || FindPreset(ActivePresetId) is null)
@@ -158,11 +185,32 @@ internal sealed class SwarmUiConfigFile
public void Validate() public void Validate()
{ {
if (Models.Count == 0)
{
throw new InvalidOperationException("At least one model is required.");
}
if (Presets.Count == 0) if (Presets.Count == 0)
{ {
throw new InvalidOperationException("At least one preset is required."); throw new InvalidOperationException("At least one preset is required.");
} }
var modelIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var model in Models)
{
if (string.IsNullOrWhiteSpace(model.Id))
{
throw new InvalidOperationException("Every model needs a non-empty id.");
}
if (!modelIds.Add(model.Id))
{
throw new InvalidOperationException($"Duplicate model id '{model.Id}'.");
}
model.Validate();
}
var ids = new HashSet<string>(StringComparer.Ordinal); var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var preset in Presets) foreach (var preset in Presets)
{ {
@@ -181,6 +229,11 @@ internal sealed class SwarmUiConfigFile
preset.Label = preset.Id; preset.Label = preset.Id;
} }
if (FindModel(preset.Model) is null)
{
throw new InvalidOperationException($"Preset '{preset.Id}' references unknown model '{preset.Model}'.");
}
preset.Validate(); preset.Validate();
} }
@@ -207,9 +260,110 @@ internal sealed class SwarmUiConfigFile
{ {
var presetId = ResolvePresetId(age); var presetId = ResolvePresetId(age);
var preset = FindPreset(presetId) ?? FindPreset(ActivePresetId) ?? Presets[0]; var preset = FindPreset(presetId) ?? FindPreset(ActivePresetId) ?? Presets[0];
return preset.ToProfile(kind); var model = FindModel(preset.Model) ?? Models[0];
return preset.ToProfile(kind, model);
} }
public IReadOnlyList<string> AllowedModelIds(SwarmUiDiscovery discovery)
{
var catalog = Models
.Select(model => model.Id)
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToList();
if (!discovery.Connected || discovery.Models.Count == 0)
{
return catalog;
}
return discovery.Models
.Where(name => catalog.Contains(name, StringComparer.Ordinal))
.ToList();
}
private void LiftModelsFromPresets()
{
if (Models.Count > 0)
{
return;
}
foreach (var preset in Presets)
{
if (string.IsNullOrWhiteSpace(preset.Model) || FindModel(preset.Model) is not null)
{
continue;
}
Models.Add(new SwarmUiModelDefinition
{
Id = preset.Model,
Label = LabelFromId(preset.Model),
Steps = preset.Steps ?? 8,
CfgScale = preset.CfgScale ?? 1,
ClipSkip = preset.ClipSkip ?? 0,
Sampler = preset.Sampler ?? "",
Scheduler = preset.Scheduler ?? "",
Seed = preset.Seed ?? -1,
Positive = "",
Negative = "",
PositiveLoras = CopyLoras(preset.PositiveLoras),
NegativeLoras = CopyLoras(preset.NegativeLoras),
});
}
}
private void ClearMatchingOverrides(SwarmUiPresetDefinition preset)
{
var model = FindModel(preset.Model);
if (model is null)
{
return;
}
if (preset.Steps == model.Steps)
{
preset.Steps = null;
}
if (preset.CfgScale == model.CfgScale)
{
preset.CfgScale = null;
}
if (preset.ClipSkip == model.ClipSkip)
{
preset.ClipSkip = null;
}
if (string.Equals(preset.Sampler, model.Sampler, StringComparison.Ordinal))
{
preset.Sampler = null;
}
if (string.Equals(preset.Scheduler, model.Scheduler, StringComparison.Ordinal))
{
preset.Scheduler = null;
}
if (preset.Seed == model.Seed)
{
preset.Seed = null;
}
if (SameLoras(preset.PositiveLoras, model.PositiveLoras))
{
preset.PositiveLoras = null;
}
if (SameLoras(preset.NegativeLoras, model.NegativeLoras))
{
preset.NegativeLoras = null;
}
}
public SwarmUiModelDefinition? FindModel(string id) =>
Models.FirstOrDefault(model => string.Equals(model.Id, id, StringComparison.Ordinal));
private string ResolvePresetId(int age) private string ResolvePresetId(int age)
{ {
foreach (var rule in AgeRules.OrderBy(rule => rule.MinAge)) foreach (var rule in AgeRules.OrderBy(rule => rule.MinAge))
@@ -230,6 +384,7 @@ internal sealed class SwarmUiConfigFile
new() new()
{ {
ActivePresetId = "default", ActivePresetId = "default",
Models = SwarmUiModelDefinition.Catalog(),
Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()], Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()],
AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }], AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }],
}; };
@@ -254,6 +409,42 @@ internal sealed class SwarmUiConfigFile
return copy; return copy;
} }
internal static string LabelFromId(string id)
{
var name = Path.GetFileNameWithoutExtension(id);
return string.IsNullOrWhiteSpace(name) ? id : name;
}
internal static List<SwarmUiLoraEntry> CopyLoras(IReadOnlyList<SwarmUiLoraEntry>? source)
{
if (source is null || source.Count == 0)
{
return [];
}
return source.Select(lora => new SwarmUiLoraEntry { Name = lora.Name, Weight = lora.Weight }).ToList();
}
internal static bool SameLoras(IReadOnlyList<SwarmUiLoraEntry>? left, IReadOnlyList<SwarmUiLoraEntry>? right)
{
var a = left ?? [];
var b = right ?? [];
if (a.Count != b.Count)
{
return false;
}
for (var i = 0; i < a.Count; i++)
{
if (!string.Equals(a[i].Name, b[i].Name, StringComparison.Ordinal) || a[i].Weight != b[i].Weight)
{
return false;
}
}
return true;
}
private static readonly JsonSerializerOptions CloneJson = new() private static readonly JsonSerializerOptions CloneJson = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -262,19 +453,22 @@ internal sealed class SwarmUiConfigFile
}; };
} }
internal sealed class SwarmUiPresetDefinition internal sealed class SwarmUiModelDefinition
{ {
public const string BabesId = "babesByStableYogi_v4XLLightning.safetensors";
public const string DreamShaperId = "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors";
public const string EpicRealismId = "epicrealismXL_VXIAbeast4SLightning.safetensors";
public const string LustifyId = "lustifyNSFWCheckpoint_v40DMD2.safetensors";
public string Id { get; set; } = ""; public string Id { get; set; } = "";
public string Label { get; set; } = ""; public string Label { get; set; } = "";
public string Model { get; set; } = "";
public int Steps { get; set; } = 8; public int Steps { get; set; } = 8;
public double CfgScale { get; set; } = 1; public double CfgScale { get; set; } = 1;
public int ClipSkip { get; set; } = 1; public int ClipSkip { get; set; } = 0;
public string Sampler { get; set; } = ""; public string Sampler { get; set; } = "";
@@ -290,12 +484,107 @@ internal sealed class SwarmUiPresetDefinition
public List<SwarmUiLoraEntry>? NegativeLoras { get; set; } public List<SwarmUiLoraEntry>? NegativeLoras { get; set; }
public void Validate()
{
if (Steps is < 1 or > 200)
{
throw new InvalidOperationException($"Model '{Id}' steps must be between 1 and 200.");
}
if (CfgScale is < 0 or > 30)
{
throw new InvalidOperationException($"Model '{Id}' cfgScale must be between 0 and 30.");
}
if (ClipSkip is < 0 or > 12)
{
throw new InvalidOperationException($"Model '{Id}' clipSkip must be between 0 and 12.");
}
SwarmUiPresetDefinition.ValidateLoras(Id, PositiveLoras, "positive");
SwarmUiPresetDefinition.ValidateLoras(Id, NegativeLoras, "negative");
}
public static List<SwarmUiModelDefinition> Catalog() =>
[
Lightning(BabesId, "babesByStableYogi v4 XL Lightning", 7, 1.5, 0, "euler", "normal", 0),
Lightning(DreamShaperId, "DreamShaper XL Lightning", 4, 2, 2, "dpmpp_sde", "karras", 3346112079),
Lightning(EpicRealismId, "epicrealism XL Lightning", 7, 1.5, 0, "euler", "normal", 0),
Lightning(LustifyId, "lustify NSFW v40 DMD2", 7, 1.5, 0, "euler", "normal", 0),
];
private static SwarmUiModelDefinition Lightning(
string id,
string label,
int steps,
double cfg,
int clipSkip,
string sampler,
string scheduler,
long seed) =>
new()
{
Id = id,
Label = label,
Steps = steps,
CfgScale = cfg,
ClipSkip = clipSkip,
Sampler = sampler,
Scheduler = scheduler,
Seed = seed,
};
}
internal sealed class SwarmUiPresetDefinition
{
public string Id { get; set; } = "";
public string Label { get; set; } = "";
public string Model { get; set; } = "";
public string Style { get; set; } = "";
public string Negative { 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; }
/// <summary>Legacy preset-wide positive; copied into <see cref="Style"/> on load.</summary>
public string? Positive { get; set; }
public List<SwarmUiLoraEntry>? PositiveLoras { get; set; }
public List<SwarmUiLoraEntry>? NegativeLoras { get; set; }
public SwarmUiKindPreset? Avatar { get; set; } public SwarmUiKindPreset? Avatar { get; set; }
public SwarmUiKindPreset? Custom { get; set; } public SwarmUiKindPreset? Custom { get; set; }
public SwarmUiKindPreset? FullBody { get; set; } public SwarmUiKindPreset? FullBody { get; set; }
public void LiftLegacyPromptFields()
{
if (string.IsNullOrWhiteSpace(Style) && !string.IsNullOrWhiteSpace(Positive))
{
Style = Positive;
}
Positive = null;
Avatar?.LiftShotType();
Custom?.LiftShotType();
FullBody?.LiftShotType();
}
public void Validate() public void Validate()
{ {
if (Steps is < 1 or > 200) if (Steps is < 1 or > 200)
@@ -313,11 +602,11 @@ internal sealed class SwarmUiPresetDefinition
throw new InvalidOperationException($"Preset '{Id}' clipSkip must be between 0 and 12."); throw new InvalidOperationException($"Preset '{Id}' clipSkip must be between 0 and 12.");
} }
ValidateLoras(PositiveLoras, "positive"); ValidateLoras(Id, PositiveLoras, "positive");
ValidateLoras(NegativeLoras, "negative"); ValidateLoras(Id, NegativeLoras, "negative");
} }
private void ValidateLoras(IReadOnlyList<SwarmUiLoraEntry>? loras, string side) internal static void ValidateLoras(string ownerId, IReadOnlyList<SwarmUiLoraEntry>? loras, string side)
{ {
if (loras is null) if (loras is null)
{ {
@@ -328,32 +617,50 @@ internal sealed class SwarmUiPresetDefinition
{ {
if (string.IsNullOrWhiteSpace(lora.Name)) if (string.IsNullOrWhiteSpace(lora.Name))
{ {
throw new InvalidOperationException($"Preset '{Id}' has an empty {side} LoRA name."); throw new InvalidOperationException($"'{ownerId}' has an empty {side} LoRA name.");
} }
if (lora.Weight is < -4 or > 4) if (lora.Weight is < -4 or > 4)
{ {
throw new InvalidOperationException($"Preset '{Id}' LoRA '{lora.Name}' weight is out of range."); throw new InvalidOperationException($"'{ownerId}' LoRA '{lora.Name}' weight is out of range.");
} }
} }
} }
public SwarmUiResolvedProfile ToProfile(PortraitKind kind) => public SwarmUiResolvedProfile ToProfile(PortraitKind kind, SwarmUiModelDefinition? model = null)
new( {
model ??= new SwarmUiModelDefinition
{
Id = Model,
Steps = 8,
CfgScale = 1,
ClipSkip = 0,
Sampler = "",
Scheduler = "",
Seed = -1,
};
var kindPreset = KindPresetFor(kind);
var negative = JoinPrompts(model.Negative, Negative);
return new SwarmUiResolvedProfile(
Id, Id,
Label, Label,
Model, string.IsNullOrWhiteSpace(Model) ? model.Id : Model,
Steps, Steps ?? model.Steps,
CfgScale, CfgScale ?? model.CfgScale,
ClipSkip, ClipSkip ?? model.ClipSkip,
Sampler, Sampler ?? model.Sampler,
Scheduler, Scheduler ?? model.Scheduler,
Seed, Seed ?? model.Seed,
Positive, model.Positive,
Negative, Style,
PositiveLoras ?? [], kindPreset.ResolvedShotType(),
NegativeLoras ?? [], "",
KindPresetFor(kind)); negative,
PositiveLoras ?? model.PositiveLoras ?? [],
NegativeLoras ?? model.NegativeLoras ?? [],
kindPreset);
}
private SwarmUiKindPreset KindPresetFor(PortraitKind kind) => kind switch private SwarmUiKindPreset KindPresetFor(PortraitKind kind) => kind switch
{ {
@@ -363,19 +670,28 @@ internal sealed class SwarmUiPresetDefinition
_ => throw new ArgumentOutOfRangeException(nameof(kind)), _ => throw new ArgumentOutOfRangeException(nameof(kind)),
}; };
private static string JoinPrompts(string left, string right)
{
if (string.IsNullOrWhiteSpace(left))
{
return right.Trim();
}
if (string.IsNullOrWhiteSpace(right))
{
return left.Trim();
}
return $"{left.Trim()}, {right.Trim()}";
}
public static SwarmUiPresetDefinition CreateDefault() => public static SwarmUiPresetDefinition CreateDefault() =>
new() new()
{ {
Id = "default", Id = "default",
Label = "Default", Label = "Default",
Model = "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", Model = SwarmUiModelDefinition.BabesId,
Steps = 4, Style =
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", "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
Negative = Negative =
"(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
@@ -383,49 +699,41 @@ internal sealed class SwarmUiPresetDefinition
{ {
Width = 1024, Width = 1024,
Height = 1024, Height = 1024,
Positive = "close up, head and shoulders portrait, facing the camera, upper body visible.", ShotType = "close up, head and shoulders portrait, facing the camera, upper body visible.",
}, },
Custom = new SwarmUiKindPreset { Width = 896, Height = 1152 }, Custom = new SwarmUiKindPreset { Width = 896, Height = 1152 },
FullBody = new SwarmUiKindPreset FullBody = new SwarmUiKindPreset
{ {
Width = 896, Width = 896,
Height = 1152, Height = 1152,
Positive = "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible.", ShotType = "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible.",
}, },
}; };
public static SwarmUiPresetDefinition CreateChild() public static SwarmUiPresetDefinition CreateChild() =>
{ new()
var adult = CreateDefault();
return new SwarmUiPresetDefinition
{ {
Id = "child", Id = "child",
Label = "Children", Label = "Children",
Model = adult.Model, Model = SwarmUiModelDefinition.DreamShaperId,
Steps = adult.Steps, Style =
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", "cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus",
Negative = adult.Negative, 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 Avatar = new SwarmUiKindPreset
{ {
Width = 1024, Width = 1024,
Height = 1024, Height = 1024,
Positive = "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features.", ShotType = "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features.",
}, },
Custom = adult.Custom, Custom = new SwarmUiKindPreset { Width = 896, Height = 1152 },
FullBody = new SwarmUiKindPreset FullBody = new SwarmUiKindPreset
{ {
Width = 896, Width = 896,
Height = 1152, Height = 1152,
Positive = "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible.", ShotType = "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible.",
}, },
}; };
}
} }
internal sealed class SwarmUiAgeRule internal sealed class SwarmUiAgeRule
@@ -443,7 +751,23 @@ internal sealed class SwarmUiKindPreset
public int Height { get; set; } = 512; public int Height { get; set; } = 512;
public string Positive { get; set; } = ""; public string ShotType { get; set; } = "";
/// <summary>Legacy kind positive; copied into <see cref="ShotType"/> on load.</summary>
public string? Positive { get; set; }
public void LiftShotType()
{
if (string.IsNullOrWhiteSpace(ShotType) && !string.IsNullOrWhiteSpace(Positive))
{
ShotType = Positive;
}
Positive = null;
}
public string ResolvedShotType() =>
string.IsNullOrWhiteSpace(ShotType) ? (Positive ?? "") : ShotType;
} }
internal sealed class SwarmUiLoraEntry internal sealed class SwarmUiLoraEntry
@@ -463,7 +787,10 @@ internal sealed record SwarmUiResolvedProfile(
string Sampler, string Sampler,
string Scheduler, string Scheduler,
long Seed, long Seed,
string Positive, string ModelPositive,
string Style,
string ShotType,
string Pose,
string Negative, string Negative,
IReadOnlyList<SwarmUiLoraEntry> PositiveLoras, IReadOnlyList<SwarmUiLoraEntry> PositiveLoras,
IReadOnlyList<SwarmUiLoraEntry> NegativeLoras, IReadOnlyList<SwarmUiLoraEntry> NegativeLoras,
+74 -32
View File
@@ -1,64 +1,106 @@
{ {
"activePresetId": "default", "activePresetId": "default",
"presets": [ "models": [
{ {
"id": "default", "id": "babesByStableYogi_v4XLLightning.safetensors",
"label": "Default", "label": "babesByStableYogi v4 XL Lightning",
"model": "babesByStableYogi_v4XLLightning.safetensors",
"steps": 7, "steps": 7,
"cfgScale": 1.5, "cfgScale": 1.5,
"clipSkip": 0, "clipSkip": 0,
"sampler": "euler", "sampler": "euler",
"scheduler": "normal", "scheduler": "normal",
"seed": 0, "seed": 0,
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality,", "positive": "",
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, explicit, blurry, deformed, bad anatomy, logo", "negative": "",
"positiveLoras": [], "positiveLoras": [],
"negativeLoras": [], "negativeLoras": []
"avatar": {
"width": 1024,
"height": 1024,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible, avatar,"
},
"custom": {
"width": 896,
"height": 1152,
"positive": ""
},
"fullBody": {
"width": 896,
"height": 1152,
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
}
}, },
{ {
"id": "child", "id": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
"label": "Children", "label": "DreamShaper XL Lightning",
"model": "DreamShaper_XL_-_Lightning_DPM\u002B\u002B_SDE.safetensors",
"steps": 4, "steps": 4,
"cfgScale": 2, "cfgScale": 2,
"clipSkip": 2, "clipSkip": 2,
"sampler": "dpmpp_sde", "sampler": "dpmpp_sde",
"scheduler": "karras", "scheduler": "karras",
"seed": 3346112079, "seed": 3346112079,
"positive": "cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus", "positive": "",
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", "negative": "",
"positiveLoras": [], "positiveLoras": [],
"negativeLoras": [], "negativeLoras": []
},
{
"id": "epicrealismXL_VXIAbeast4SLightning.safetensors",
"label": "epicrealism XL Lightning",
"steps": 7,
"cfgScale": 1.5,
"clipSkip": 0,
"sampler": "euler",
"scheduler": "normal",
"seed": 0,
"positive": "",
"negative": "",
"positiveLoras": [],
"negativeLoras": []
},
{
"id": "lustifyNSFWCheckpoint_v40DMD2.safetensors",
"label": "lustify NSFW v40 DMD2",
"steps": 7,
"cfgScale": 1.5,
"clipSkip": 0,
"sampler": "euler",
"scheduler": "normal",
"seed": 0,
"positive": "",
"negative": "",
"positiveLoras": [],
"negativeLoras": []
}
],
"presets": [
{
"id": "default",
"label": "Default",
"model": "babesByStableYogi_v4XLLightning.safetensors",
"style": "cinematic photo, realist detail, detailed character expressions, amazing quality,",
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, explicit, blurry, deformed, bad anatomy, logo",
"avatar": { "avatar": {
"width": 1024, "width": 1024,
"height": 1024, "height": 1024,
"positive": "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features." "shotType": "close up, head and shoulders portrait, facing the camera, upper body visible, avatar,"
}, },
"custom": { "custom": {
"width": 896, "width": 896,
"height": 1152, "height": 1152,
"positive": "" "shotType": ""
}, },
"fullBody": { "fullBody": {
"width": 896, "width": 896,
"height": 1152, "height": 1152,
"positive": "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible." "shotType": "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",
"style": "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",
"avatar": {
"width": 1024,
"height": 1024,
"shotType": "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features."
},
"custom": {
"width": 896,
"height": 1152,
"shotType": ""
},
"fullBody": {
"width": 896,
"height": 1152,
"shotType": "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible."
} }
} }
], ],
@@ -69,4 +111,4 @@
"presetId": "child" "presetId": "child"
} }
] ]
} }
@@ -102,6 +102,7 @@ public class PortraitApiTests(AppHostFixture fixture)
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
Assert.NotNull(settings); Assert.NotNull(settings);
Assert.NotEmpty(settings!.Presets); Assert.NotEmpty(settings!.Presets);
Assert.NotEmpty(settings.Models);
Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId));
} }
@@ -119,6 +120,8 @@ public class PortraitApiTests(AppHostFixture fixture)
Assert.True(document.RootElement.TryGetProperty("portraitSettings", out var presets)); Assert.True(document.RootElement.TryGetProperty("portraitSettings", out var presets));
Assert.True(presets.TryGetProperty("presets", out var list)); Assert.True(presets.TryGetProperty("presets", out var list));
Assert.True(list.GetArrayLength() > 0); Assert.True(list.GetArrayLength() > 0);
Assert.True(presets.TryGetProperty("models", out var models));
Assert.True(models.GetArrayLength() > 0);
} }
[Fact] [Fact]
@@ -193,9 +196,14 @@ public class PortraitApiTests(AppHostFixture fixture)
string PresetId, string PresetId,
string PresetLabel); string PresetLabel);
private sealed record SwarmUiSettingsPayload(string ActivePresetId, IReadOnlyList<SwarmUiPresetPayload> Presets); private sealed record SwarmUiSettingsPayload(
string ActivePresetId,
IReadOnlyList<SwarmUiPresetPayload> Presets,
IReadOnlyList<SwarmUiModelPayload> Models);
private sealed record SwarmUiPresetPayload(string Id, string Label); private sealed record SwarmUiPresetPayload(string Id, string Label);
private sealed record SwarmUiModelPayload(string Id);
private sealed record SavesDirectoryResponse(string Path); private sealed record SavesDirectoryResponse(string Path);
} }
@@ -34,7 +34,7 @@ public class PortraitPromptBuilderTests
} }
[Fact] [Fact]
public void Build_Custom_UsesPromptExtraInsteadOfPreset() public void Build_Custom_KeepsShotTypeAndAppendsPromptExtra()
{ {
var profile = SamplePreset().ToProfile(PortraitKind.Custom); var profile = SamplePreset().ToProfile(PortraitKind.Custom);
const string extra = "standing in a school hallway, soft window light"; const string extra = "standing in a school hallway, soft window light";
@@ -45,9 +45,31 @@ public class PortraitPromptBuilderTests
extra); extra);
Assert.Contains("School photo.", customPositive, StringComparison.Ordinal); Assert.Contains("School photo.", customPositive, StringComparison.Ordinal);
Assert.Contains("Hallway light.", customPositive, StringComparison.Ordinal);
Assert.Contains(extra, customPositive, StringComparison.Ordinal); Assert.Contains(extra, customPositive, StringComparison.Ordinal);
Assert.DoesNotContain("Head and shoulders.", customPositive, StringComparison.Ordinal); Assert.DoesNotContain("Head and shoulders.", customPositive, StringComparison.Ordinal);
Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal); Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal);
Assert.True(
customPositive.IndexOf("Hallway light.", StringComparison.Ordinal)
< customPositive.IndexOf(extra, StringComparison.Ordinal));
}
[Fact]
public void Build_OrdersLayers_ModelStyleShotAppearanceClothing()
{
var preset = SamplePreset();
var model = new SwarmUiModelDefinition { Id = preset.Model, Positive = "Model base." };
var profile = preset.ToProfile(PortraitKind.Avatar, model);
var (positive, _) = PortraitPromptBuilder.Build(SampleCard(), profile, PortraitKind.Avatar);
var modelAt = positive.IndexOf("Model base.", StringComparison.Ordinal);
var styleAt = positive.IndexOf("School photo.", StringComparison.Ordinal);
var shotAt = positive.IndexOf("Head and shoulders.", StringComparison.Ordinal);
var subjectAt = positive.IndexOf("teenage girl", StringComparison.OrdinalIgnoreCase);
var clothesAt = positive.IndexOf("wearing shirt", StringComparison.OrdinalIgnoreCase);
Assert.True(modelAt >= 0 && styleAt > modelAt && shotAt > styleAt && subjectAt > shotAt && clothesAt > subjectAt);
Assert.DoesNotContain("pose", positive, StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]
@@ -63,18 +85,24 @@ public class PortraitPromptBuilderTests
private static SwarmUiPresetDefinition SamplePreset() private static SwarmUiPresetDefinition SamplePreset()
{ {
var preset = SwarmUiPresetDefinition.CreateDefault(); var preset = SwarmUiPresetDefinition.CreateDefault();
preset.Positive = "School photo."; preset.Style = "School photo.";
preset.Avatar = new SwarmUiKindPreset preset.Avatar = new SwarmUiKindPreset
{ {
Width = 512, Width = 512,
Height = 512, Height = 512,
Positive = "Head and shoulders.", ShotType = "Head and shoulders.",
};
preset.Custom = new SwarmUiKindPreset
{
Width = 512,
Height = 768,
ShotType = "Hallway light.",
}; };
preset.FullBody = new SwarmUiKindPreset preset.FullBody = new SwarmUiKindPreset
{ {
Width = 512, Width = 512,
Height = 768, Height = 768,
Positive = "Standing full body.", ShotType = "Standing full body.",
}; };
return preset; return preset;
} }
@@ -100,7 +100,7 @@ public class PortraitVisibleWornTests
public void Build_OmitsCoveredShirtFromPrompt() public void Build_OmitsCoveredShirtFromPrompt()
{ {
var preset = SwarmUiPresetDefinition.CreateDefault(); var preset = SwarmUiPresetDefinition.CreateDefault();
preset.Positive = "School photo."; preset.Style = "School photo.";
var card = SampleCard( var card = SampleCard(
[ [
Item("Shirt", ApparelLayers.Top, covers: [ApparelLayers.Underwear], color: "White", colorLabel: "White"), Item("Shirt", ApparelLayers.Top, covers: [ApparelLayers.Underwear], color: "White", colorLabel: "White"),
@@ -11,6 +11,8 @@ public class SwarmUiSettingsStoreTests
var profile = config.Resolve(8, PortraitKind.Avatar); var profile = config.Resolve(8, PortraitKind.Avatar);
Assert.Equal("child", profile.PresetId); Assert.Equal("child", profile.PresetId);
Assert.Equal(SwarmUiModelDefinition.DreamShaperId, profile.Model);
Assert.Equal(4, profile.Steps);
} }
[Fact] [Fact]
@@ -20,6 +22,23 @@ public class SwarmUiSettingsStoreTests
var profile = config.Resolve(34, PortraitKind.Full); var profile = config.Resolve(34, PortraitKind.Full);
Assert.Equal("default", profile.PresetId); Assert.Equal("default", profile.PresetId);
Assert.Equal(SwarmUiModelDefinition.BabesId, profile.Model);
Assert.Equal(7, profile.Steps);
}
[Fact]
public void Resolve_PresetOverrideBeatsModelDefaults()
{
var config = SwarmUiConfigFile.CreateDefault();
var preset = config.Presets[0];
preset.Steps = 12;
preset.CfgScale = 3.5;
var profile = config.Resolve(34, PortraitKind.Avatar);
Assert.Equal(12, profile.Steps);
Assert.Equal(3.5, profile.CfgScale);
Assert.Equal(0, profile.ClipSkip);
} }
[Fact] [Fact]
@@ -30,7 +49,7 @@ public class SwarmUiSettingsStoreTests
Model = "legacy.safetensors", Model = "legacy.safetensors",
Steps = 12, Steps = 12,
Positive = "legacy prompt", Positive = "legacy prompt",
Avatar = new SwarmUiKindPreset { Width = 640, Height = 640 }, Avatar = new SwarmUiKindPreset { Width = 640, Height = 640, Positive = "close up." },
}; };
config.NormalizeAfterLoad(); config.NormalizeAfterLoad();
@@ -38,7 +57,77 @@ public class SwarmUiSettingsStoreTests
Assert.Single(config.Presets); Assert.Single(config.Presets);
Assert.Equal("default", config.Presets[0].Id); Assert.Equal("default", config.Presets[0].Id);
Assert.Equal("legacy.safetensors", config.Presets[0].Model); Assert.Equal("legacy.safetensors", config.Presets[0].Model);
Assert.Equal(12, config.Presets[0].Steps); Assert.Equal("legacy prompt", config.Presets[0].Style);
Assert.Null(config.Presets[0].Positive);
Assert.Null(config.Presets[0].Steps);
var model = Assert.Single(config.Models, entry => entry.Id == "legacy.safetensors");
Assert.Equal(12, model.Steps);
Assert.Equal("close up.", config.Presets[0].Avatar!.ShotType);
Assert.Null(config.Presets[0].Avatar!.Positive);
}
[Fact]
public void NormalizeAfterLoad_LiftsLegacyPresetPositiveIntoStyleAndShotType()
{
var config = new SwarmUiConfigFile
{
Presets =
[
new SwarmUiPresetDefinition
{
Id = "default",
Label = "Default",
Model = "a.safetensors",
Steps = 6,
CfgScale = 2,
Positive = "was base prompt",
Negative = "old neg",
Avatar = new SwarmUiKindPreset { Width = 512, Height = 512, Positive = "was kind" },
Custom = new SwarmUiKindPreset { Width = 512, Height = 512 },
FullBody = new SwarmUiKindPreset { Width = 512, Height = 768 },
},
],
};
config.NormalizeAfterLoad();
Assert.Equal("was base prompt", config.Presets[0].Style);
Assert.Equal("was kind", config.Presets[0].Avatar!.ShotType);
Assert.Contains(config.Models, model => model.Id == "a.safetensors");
Assert.Equal("", Assert.Single(config.Models).Positive);
}
[Fact]
public void AllowedModelIds_IntersectsSwarmWithCatalog()
{
var config = SwarmUiConfigFile.CreateDefault();
var discovery = new SwarmUiDiscovery(
true,
[
SwarmUiModelDefinition.BabesId,
"not-in-config.safetensors",
SwarmUiModelDefinition.EpicRealismId,
],
[],
[],
[]);
var allowed = config.AllowedModelIds(discovery);
Assert.Equal(
[SwarmUiModelDefinition.BabesId, SwarmUiModelDefinition.EpicRealismId],
allowed);
Assert.DoesNotContain("not-in-config.safetensors", allowed);
Assert.DoesNotContain(SwarmUiModelDefinition.DreamShaperId, allowed);
}
[Fact]
public void AllowedModelIds_WhenSwarmOffline_ReturnsCatalog()
{
var config = SwarmUiConfigFile.CreateDefault();
var allowed = config.AllowedModelIds(SwarmUiDiscovery.Offline);
Assert.Equal(config.Models.Select(model => model.Id), allowed);
} }
[Fact] [Fact]
@@ -46,6 +135,7 @@ public class SwarmUiSettingsStoreTests
{ {
var source = SwarmUiConfigFile.CreateDefault(); var source = SwarmUiConfigFile.CreateDefault();
source.Presets[0].Model = "mutated.safetensors"; source.Presets[0].Model = "mutated.safetensors";
source.Models[0].Id = "mutated.safetensors";
var copy = SwarmUiConfigFile.Clone(source); var copy = SwarmUiConfigFile.Clone(source);
copy.Presets[0].Model = "other.safetensors"; copy.Presets[0].Model = "other.safetensors";
@@ -54,4 +144,16 @@ public class SwarmUiSettingsStoreTests
Assert.Equal("other.safetensors", copy.Presets[0].Model); Assert.Equal("other.safetensors", copy.Presets[0].Model);
Assert.Equal(source.ActivePresetId, copy.ActivePresetId); Assert.Equal(source.ActivePresetId, copy.ActivePresetId);
} }
[Fact]
public void CreateDefault_SeedsEveryKnownSwarmModel()
{
var config = SwarmUiConfigFile.CreateDefault();
var ids = config.Models.Select(model => model.Id).ToList();
Assert.Contains(SwarmUiModelDefinition.BabesId, ids);
Assert.Contains(SwarmUiModelDefinition.DreamShaperId, ids);
Assert.Contains(SwarmUiModelDefinition.EpicRealismId, ids);
Assert.Contains(SwarmUiModelDefinition.LustifyId, ids);
}
} }