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:
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user