From 81fd3f61816a32701f8d66ea41c42b1b1c646390 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 14:08:11 +0300 Subject: [PATCH 1/2] 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. --- docs/design/off-queue/README.md | 1 + docs/design/off-queue/portrait-models.md | 43 ++ docs/phases/off-queue/60-portrait-models.md | 45 ++ docs/phases/off-queue/README.md | 1 + docs/protocol.md | 28 +- src/HSchool.Client/src/i18n/strings.ts | 22 +- src/HSchool.Client/src/net/api.ts | 31 +- src/HSchool.Client/src/style.css | 24 + .../src/ui/createSchoolDialog.test.ts | 26 +- .../src/ui/swarmUiSettingsDialog.test.ts | 175 +++++++ .../src/ui/swarmUiSettingsDialog.ts | 175 +++++-- src/HSchool.Server/Api/SchoolEndpoints.cs | 2 +- src/HSchool.Server/Game/GameLoopService.cs | 2 +- .../Game/PortraitPromptBuilder.cs | 39 +- .../Game/SwarmUiSettingsStore.cs | 447 +++++++++++++++--- src/HSchool.Server/swarmui.json | 106 +++-- .../HSchool.AppHost.Tests/PortraitApiTests.cs | 10 +- .../PortraitPromptBuilderTests.cs | 36 +- .../PortraitVisibleWornTests.cs | 2 +- .../SwarmUiSettingsStoreTests.cs | 106 ++++- 20 files changed, 1142 insertions(+), 179 deletions(-) create mode 100644 docs/design/off-queue/portrait-models.md create mode 100644 docs/phases/off-queue/60-portrait-models.md create mode 100644 src/HSchool.Client/src/ui/swarmUiSettingsDialog.test.ts diff --git a/docs/design/off-queue/README.md b/docs/design/off-queue/README.md index e4e9284..3935fc6 100644 --- a/docs/design/off-queue/README.md +++ b/docs/design/off-queue/README.md @@ -9,6 +9,7 @@ | [Пропуск сборки](skip-stale-build.md) | 49 | | [Что нового](whats-new.md) | 54 | | [Состояние экрана](view-state.md) | 55 | +| [Модели портретов](portrait-models.md) | 60 | Люди: [`../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). diff --git a/docs/design/off-queue/portrait-models.md b/docs/design/off-queue/portrait-models.md new file mode 100644 index 0000000..a20fcd2 --- /dev/null +++ b/docs/design/off-queue/portrait-models.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[]`). Старые картинки не трогаем. diff --git a/docs/phases/off-queue/60-portrait-models.md b/docs/phases/off-queue/60-portrait-models.md new file mode 100644 index 0000000..4c4b5fe --- /dev/null +++ b/docs/phases/off-queue/60-portrait-models.md @@ -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 диалога пресетов (создание школы): стиль и кадр сверху; дефолты модели видны; оверрайды + пресета под `
`. Строки через `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. diff --git a/docs/phases/off-queue/README.md b/docs/phases/off-queue/README.md index 6f38520..10e776c 100644 --- a/docs/phases/off-queue/README.md +++ b/docs/phases/off-queue/README.md @@ -19,3 +19,4 @@ | [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу | | [54. Что нового](54-whats-new.md) | ✅ | После входа — окно коммитов с прошлого визита | | [55. Состояние экрана](55-view-state.md) | ✅ | F5 и переходы не сбрасывают школу, вкладки и фильтры | +| [60. Модели портретов](60-portrait-models.md) | 🔄 | Каталог моделей Swarm ∩ конфиг, слои промпта, спойлер оверрайдов | diff --git a/docs/protocol.md b/docs/protocol.md index bceecee..6209739 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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` 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 -the person's body/clothing; required, non-empty, at most 2000 characters. Avatar and full-body POST -need no body. +For `kind=custom` the body is `{ "promptExtra": "..." }` — appended after the shot type (and the +model/style layers), then the person's appearance and clothing; required, non-empty, at most 2000 +characters. Avatar and full-body POST need no body. 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`. 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` 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 -the base prompt; when omitted, the last saved custom prompt is used if one exists. Unknown school +Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended after +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 `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` -Returns the **default** SwarmUI preset template (`swarmui.json`): named presets (model, steps, -sampler, LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules`. A new school copies -this into its save as `portraitSettings`. Living schools generate from that copy, not from this -file. +Returns the **default** SwarmUI template (`swarmui.json`): a `models` catalog (id, default +generation knobs, usually empty base positive/negative), named presets (model id, `style`, extra +negative, optional generation overrides, per-kind size/`shotType`), `activePresetId` and `ageRules`. +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` -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. ### `GET /api/settings/swarmui/discovery` When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns -`{ connected, models, loras, samplers, schedulers }` for the settings UI comboboxes. When SwarmUI -is off or unreachable, `connected` is false and the lists are empty. +`{ connected, models, loras, samplers, schedulers }` for the settings UI. The client hides Swarm +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` diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 91aa25b..d91d617 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -44,8 +44,13 @@ const ru = { settingsSampler: 'Sampler', settingsScheduler: 'Scheduler', settingsSeed: 'Seed', - settingsPositive: 'Базовый positive', - settingsNegative: 'Negative', + settingsStyle: 'Стиль', + settingsPresetNegative: 'Negative пресета', + settingsModelDefaults: 'Дефолты модели', + settingsModelMissing: 'Этой модели нет в каталоге — выберите другую.', + settingsModelPositive: 'Базовый positive модели', + settingsModelNegative: 'Базовый negative модели', + settingsOverrides: 'Настройки генерации пресета', settingsPositiveLoras: 'Positive LoRA', settingsNegativeLoras: 'Negative LoRA', settingsAddLora: 'Добавить LoRA', @@ -54,7 +59,7 @@ const ru = { settingsFullBodyPreset: 'В полный рост', settingsWidth: 'Ширина', settingsHeight: 'Высота', - settingsKindPositive: 'Positive для вида', + settingsShotType: 'Тип кадра', settingsAgeRules: 'Правила по возрасту', settingsAgeMin: 'От', settingsAgeMax: 'До', @@ -417,8 +422,13 @@ const en: Messages = { settingsSampler: 'Sampler', settingsScheduler: 'Scheduler', settingsSeed: 'Seed', - settingsPositive: 'Base positive', - settingsNegative: 'Negative', + settingsStyle: 'Style', + 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', settingsNegativeLoras: 'Negative LoRA', settingsAddLora: 'Add LoRA', @@ -427,7 +437,7 @@ const en: Messages = { settingsFullBodyPreset: 'Full body', settingsWidth: 'Width', settingsHeight: 'Height', - settingsKindPositive: 'Kind positive', + settingsShotType: 'Shot type', settingsAgeRules: 'Age rules', settingsAgeMin: 'From', settingsAgeMax: 'To', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index abb09c1..d0799b4 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -497,7 +497,7 @@ export async function fetchChangelog(since?: string | null): Promise model.id); + if (!discovery.connected || discovery.models.length === 0) { + return catalog; + } + + return discovery.models.filter((name) => catalog.includes(name)); +} + export async function fetchSwarmUiSettings(): Promise { return request('/api/settings/swarmui'); } diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 767df10..dd1886d 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -717,6 +717,30 @@ body { 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 { margin: 20px 0 8px; font-size: 15px; diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.test.ts b/src/HSchool.Client/src/ui/createSchoolDialog.test.ts index c204cf3..cb80e0d 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.test.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.test.ts @@ -114,24 +114,32 @@ describe('createSchoolDialog', () => { vi.mocked(fetchSwarmUiSettings).mockReset(); vi.mocked(fetchSwarmUiSettings).mockResolvedValue({ activePresetId: 'default', - presets: [ + models: [ { - id: 'default', - label: 'Default', - model: 'template.safetensors', + id: 'template.safetensors', + label: 'Template', steps: 4, cfgScale: 1, clipSkip: 1, sampler: 'euler', scheduler: 'normal', seed: -1, - positive: 'base', - negative: 'neg', + positive: '', + negative: '', positiveLoras: [], 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: [], diff --git a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.test.ts b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.test.ts new file mode 100644 index 0000000..80f1b2e --- /dev/null +++ b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.test.ts @@ -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(); + 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 { + 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; + }); +}); diff --git a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts index 1a0cba9..6495d8f 100644 --- a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts +++ b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts @@ -1,9 +1,11 @@ import { + allowedSwarmModels, fetchGameStatus, fetchSwarmUiDiscovery, fetchSwarmUiSettings, type SwarmUiDiscovery, type SwarmUiLoraEntry, + type SwarmUiModelDefinition, type SwarmUiPresetDefinition, type SwarmUiSettingsFile, } from '../net/api.ts'; @@ -196,50 +198,117 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise { preset.label = value; paintPresetSelectors(); }), - choiceField(t('settingsModel'), preset.model, discovery.models, (value) => { + choiceField(t('settingsModel'), preset.model, modelIds, (value) => { preset.model = value; + paintPresetForm(); }), - numberField(t('settingsSteps'), preset.steps, (value) => { - preset.steps = value; + textareaField(t('settingsStyle'), preset.style, (value) => { + preset.style = value; }), - numberField(t('settingsCfg'), preset.cfgScale, (value) => { - preset.cfgScale = value; - }, 0.1), - numberField(t('settingsClipSkip'), preset.clipSkip, (value) => { - preset.clipSkip = value; - }), - choiceField(t('settingsSampler'), preset.sampler, discovery.samplers, (value) => { - preset.sampler = value; - }), - choiceField(t('settingsScheduler'), preset.scheduler, discovery.schedulers, (value) => { - preset.scheduler = value; - }), - numberField(t('settingsSeed'), preset.seed, (value) => { - preset.seed = value; - }), - textareaField(t('settingsPositive'), preset.positive, (value) => { - preset.positive = value; - }), - textareaField(t('settingsNegative'), preset.negative, (value) => { + textareaField(t('settingsPresetNegative'), preset.negative, (value) => { preset.negative = value; }), - loraSection(t('settingsPositiveLoras'), preset.positiveLoras), - loraSection(t('settingsNegativeLoras'), preset.negativeLoras), kindSection(t('settingsAvatarPreset'), preset.avatar), kindSection(t('settingsCustomPreset'), preset.custom), kindSection(t('settingsFullBodyPreset'), preset.fullBody), + 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 { // Values are bound live through closures; nothing extra to scrape from DOM. } @@ -298,7 +367,7 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise void): HTMLElement { const host = el('div', { class: 'settings-loras' }); const heading = el('h4', { class: 'settings-subtitle', text: title }); const rows = el('div', { class: 'settings-lora-rows' }); @@ -340,6 +409,7 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise { loras.push({ name: discovery.loras[0] ?? '', weight: 1 }); + onMutate?.(); paintRows(); }, }); @@ -359,8 +429,8 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise { preset.height = value; }), - textareaField(t('settingsKindPositive'), preset.positive, (value) => { - preset.positive = value; + textareaField(t('settingsShotType'), preset.shotType, (value) => { + preset.shotType = value; }), ); } @@ -424,3 +494,52 @@ function choiceField( select.addEventListener('change', () => onChange(select.value)); 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); +} diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index e64197f..b0bab4e 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -81,8 +81,8 @@ internal static class SchoolEndpoints { try { - request.PortraitSettings.Validate(); portraitSettings = SwarmUiConfigFile.Clone(request.PortraitSettings); + portraitSettings.Validate(); } catch (InvalidOperationException ex) { diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index f857e3c..818119e 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -701,7 +701,7 @@ internal sealed class GameLoopService( save.Presence, save.DressRules, save.Owner, - save.PortraitSettings); + save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings)); worker.Start(); try diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs index 29ddb08..9ec597e 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -11,32 +11,23 @@ internal static class PortraitPromptBuilder PortraitKind kind, string? promptExtra = null) { - var kindPreset = profile.KindPreset; var parts = new List(); - - if (!string.IsNullOrWhiteSpace(profile.Positive)) - { - parts.Add(profile.Positive.Trim()); - } + Add(parts, profile.ModelPositive); + Add(parts, profile.Style); + Add(parts, profile.ShotType); if (kind == PortraitKind.Custom) { - if (!string.IsNullOrWhiteSpace(promptExtra)) - { - parts.Add(promptExtra.Trim()); - } - } - else if (!string.IsNullOrWhiteSpace(kindPreset.Positive)) - { - parts.Add(kindPreset.Positive.Trim()); + Add(parts, promptExtra); } - parts.Add(DescribeSubject(card)); + Add(parts, profile.Pose); + Add(parts, DescribeSubject(card)); parts.Add($"age {card.Age}"); 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)) @@ -44,15 +35,15 @@ internal static class PortraitPromptBuilder var color = item.ColorLabel ?? item.Color; if (!string.IsNullOrWhiteSpace(color)) { - parts.Add($"wearing {item.Label.ToLowerInvariant()} in {color.ToLowerInvariant()}"); + Add(parts, $"wearing {item.Label.ToLowerInvariant()} in {color.ToLowerInvariant()}"); } 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( profile.Negative.Trim(), profile.NegativeLoras); @@ -60,6 +51,16 @@ internal static class PortraitPromptBuilder return (positive, negative); } + private static void Add(List parts, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + parts.Add(value.Trim()); + } + private static string DescribeSubject(PersonCardResponse card) { if (card.Age <= 11) diff --git a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs index 3184de9..e2ae336 100644 --- a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs +++ b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs @@ -90,6 +90,8 @@ internal sealed class SwarmUiConfigFile { public string ActivePresetId { get; set; } = "default"; + public List Models { get; set; } = []; + public List Presets { get; set; } = []; public List AgeRules { get; set; } = []; @@ -135,19 +137,44 @@ internal sealed class SwarmUiConfigFile ActivePresetId = "default"; } - if (Presets.Count == 0) - { - Presets = [SwarmUiPresetDefinition.CreateDefault()]; - ActivePresetId = "default"; - } + LiftModelsFromPresets(); foreach (var preset in Presets) { preset.Avatar ??= new SwarmUiKindPreset(); preset.Custom ??= new SwarmUiKindPreset(); preset.FullBody ??= new SwarmUiKindPreset(); - preset.PositiveLoras ??= []; - preset.NegativeLoras ??= []; + preset.LiftLegacyPromptFields(); + } + + 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) @@ -158,11 +185,32 @@ internal sealed class SwarmUiConfigFile public void Validate() { + if (Models.Count == 0) + { + throw new InvalidOperationException("At least one model is required."); + } + if (Presets.Count == 0) { throw new InvalidOperationException("At least one preset is required."); } + var modelIds = new HashSet(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(StringComparer.Ordinal); foreach (var preset in Presets) { @@ -181,6 +229,11 @@ internal sealed class SwarmUiConfigFile preset.Label = preset.Id; } + if (FindModel(preset.Model) is null) + { + throw new InvalidOperationException($"Preset '{preset.Id}' references unknown model '{preset.Model}'."); + } + preset.Validate(); } @@ -207,9 +260,110 @@ internal sealed class SwarmUiConfigFile { var presetId = ResolvePresetId(age); 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 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) { foreach (var rule in AgeRules.OrderBy(rule => rule.MinAge)) @@ -230,6 +384,7 @@ internal sealed class SwarmUiConfigFile new() { ActivePresetId = "default", + Models = SwarmUiModelDefinition.Catalog(), Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()], AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }], }; @@ -254,6 +409,42 @@ internal sealed class SwarmUiConfigFile return copy; } + internal static string LabelFromId(string id) + { + var name = Path.GetFileNameWithoutExtension(id); + return string.IsNullOrWhiteSpace(name) ? id : name; + } + + internal static List CopyLoras(IReadOnlyList? 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? left, IReadOnlyList? 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() { 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 Label { get; set; } = ""; - public string Model { get; set; } = ""; - public int Steps { get; set; } = 8; public double CfgScale { get; set; } = 1; - public int ClipSkip { get; set; } = 1; + public int ClipSkip { get; set; } = 0; public string Sampler { get; set; } = ""; @@ -290,12 +484,107 @@ internal sealed class SwarmUiPresetDefinition public List? 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 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; } + + /// Legacy preset-wide positive; copied into on load. + public string? Positive { get; set; } + + public List? PositiveLoras { get; set; } + + public List? NegativeLoras { get; set; } + public SwarmUiKindPreset? Avatar { get; set; } public SwarmUiKindPreset? Custom { 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() { 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."); } - ValidateLoras(PositiveLoras, "positive"); - ValidateLoras(NegativeLoras, "negative"); + ValidateLoras(Id, PositiveLoras, "positive"); + ValidateLoras(Id, NegativeLoras, "negative"); } - private void ValidateLoras(IReadOnlyList? loras, string side) + internal static void ValidateLoras(string ownerId, IReadOnlyList? loras, string side) { if (loras is null) { @@ -328,32 +617,50 @@ internal sealed class SwarmUiPresetDefinition { 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) { - 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) => - new( + public SwarmUiResolvedProfile ToProfile(PortraitKind kind, SwarmUiModelDefinition? model = null) + { + 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, Label, - Model, - Steps, - CfgScale, - ClipSkip, - Sampler, - Scheduler, - Seed, - Positive, - Negative, - PositiveLoras ?? [], - NegativeLoras ?? [], - KindPresetFor(kind)); + string.IsNullOrWhiteSpace(Model) ? model.Id : Model, + Steps ?? model.Steps, + CfgScale ?? model.CfgScale, + ClipSkip ?? model.ClipSkip, + Sampler ?? model.Sampler, + Scheduler ?? model.Scheduler, + Seed ?? model.Seed, + model.Positive, + Style, + kindPreset.ResolvedShotType(), + "", + negative, + PositiveLoras ?? model.PositiveLoras ?? [], + NegativeLoras ?? model.NegativeLoras ?? [], + kindPreset); + } private SwarmUiKindPreset KindPresetFor(PortraitKind kind) => kind switch { @@ -363,19 +670,28 @@ internal sealed class SwarmUiPresetDefinition _ => 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() => new() { Id = "default", Label = "Default", - Model = "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", - Steps = 4, - CfgScale = 2, - ClipSkip = 2, - Sampler = "dpmpp_sde", - Scheduler = "karras", - Seed = 3346112079, - Positive = + Model = SwarmUiModelDefinition.BabesId, + Style = "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", Negative = "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", @@ -383,49 +699,41 @@ internal sealed class SwarmUiPresetDefinition { Width = 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 }, FullBody = new SwarmUiKindPreset { Width = 896, 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() - { - var adult = CreateDefault(); - return new SwarmUiPresetDefinition + public static SwarmUiPresetDefinition CreateChild() => + new() { Id = "child", Label = "Children", - Model = adult.Model, - Steps = adult.Steps, - CfgScale = adult.CfgScale, - ClipSkip = adult.ClipSkip, - Sampler = adult.Sampler, - Scheduler = adult.Scheduler, - Seed = adult.Seed, - Positive = + Model = SwarmUiModelDefinition.DreamShaperId, + Style = "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 { Width = 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 { Width = 896, 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 @@ -443,7 +751,23 @@ internal sealed class SwarmUiKindPreset public int Height { get; set; } = 512; - public string Positive { get; set; } = ""; + public string ShotType { get; set; } = ""; + + /// Legacy kind positive; copied into on load. + 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 @@ -463,7 +787,10 @@ internal sealed record SwarmUiResolvedProfile( string Sampler, string Scheduler, long Seed, - string Positive, + string ModelPositive, + string Style, + string ShotType, + string Pose, string Negative, IReadOnlyList PositiveLoras, IReadOnlyList NegativeLoras, diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index 9a1da45..c785a20 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -1,64 +1,106 @@ { "activePresetId": "default", - "presets": [ + "models": [ { - "id": "default", - "label": "Default", - "model": "babesByStableYogi_v4XLLightning.safetensors", + "id": "babesByStableYogi_v4XLLightning.safetensors", + "label": "babesByStableYogi v4 XL Lightning", "steps": 7, "cfgScale": 1.5, "clipSkip": 0, "sampler": "euler", "scheduler": "normal", "seed": 0, - "positive": "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", + "positive": "", + "negative": "", "positiveLoras": [], - "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." - } + "negativeLoras": [] }, { - "id": "child", - "label": "Children", - "model": "DreamShaper_XL_-_Lightning_DPM\u002B\u002B_SDE.safetensors", + "id": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", + "label": "DreamShaper XL Lightning", "steps": 4, "cfgScale": 2, "clipSkip": 2, "sampler": "dpmpp_sde", "scheduler": "karras", "seed": 3346112079, - "positive": "cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus", - "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", + "positive": "", + "negative": "", "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": { "width": 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": { "width": 896, "height": 1152, - "positive": "" + "shotType": "" }, "fullBody": { "width": 896, "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" } ] -} \ No newline at end of file +} diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index 050543e..91b7117 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -102,6 +102,7 @@ public class PortraitApiTests(AppHostFixture fixture) TestContext.Current.CancellationToken); Assert.NotNull(settings); Assert.NotEmpty(settings!.Presets); + Assert.NotEmpty(settings.Models); 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(presets.TryGetProperty("presets", out var list)); Assert.True(list.GetArrayLength() > 0); + Assert.True(presets.TryGetProperty("models", out var models)); + Assert.True(models.GetArrayLength() > 0); } [Fact] @@ -193,9 +196,14 @@ public class PortraitApiTests(AppHostFixture fixture) string PresetId, string PresetLabel); - private sealed record SwarmUiSettingsPayload(string ActivePresetId, IReadOnlyList Presets); + private sealed record SwarmUiSettingsPayload( + string ActivePresetId, + IReadOnlyList Presets, + IReadOnlyList Models); private sealed record SwarmUiPresetPayload(string Id, string Label); + private sealed record SwarmUiModelPayload(string Id); + private sealed record SavesDirectoryResponse(string Path); } diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs index a41cb00..3b65033 100644 --- a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -34,7 +34,7 @@ public class PortraitPromptBuilderTests } [Fact] - public void Build_Custom_UsesPromptExtraInsteadOfPreset() + public void Build_Custom_KeepsShotTypeAndAppendsPromptExtra() { var profile = SamplePreset().ToProfile(PortraitKind.Custom); const string extra = "standing in a school hallway, soft window light"; @@ -45,9 +45,31 @@ public class PortraitPromptBuilderTests extra); Assert.Contains("School photo.", customPositive, StringComparison.Ordinal); + Assert.Contains("Hallway light.", customPositive, StringComparison.Ordinal); Assert.Contains(extra, customPositive, StringComparison.Ordinal); Assert.DoesNotContain("Head and shoulders.", 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] @@ -63,18 +85,24 @@ public class PortraitPromptBuilderTests private static SwarmUiPresetDefinition SamplePreset() { var preset = SwarmUiPresetDefinition.CreateDefault(); - preset.Positive = "School photo."; + preset.Style = "School photo."; preset.Avatar = new SwarmUiKindPreset { Width = 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 { Width = 512, Height = 768, - Positive = "Standing full body.", + ShotType = "Standing full body.", }; return preset; } diff --git a/tests/HSchool.Server.Tests/PortraitVisibleWornTests.cs b/tests/HSchool.Server.Tests/PortraitVisibleWornTests.cs index 65f7cfe..724afe7 100644 --- a/tests/HSchool.Server.Tests/PortraitVisibleWornTests.cs +++ b/tests/HSchool.Server.Tests/PortraitVisibleWornTests.cs @@ -100,7 +100,7 @@ public class PortraitVisibleWornTests public void Build_OmitsCoveredShirtFromPrompt() { var preset = SwarmUiPresetDefinition.CreateDefault(); - preset.Positive = "School photo."; + preset.Style = "School photo."; var card = SampleCard( [ Item("Shirt", ApparelLayers.Top, covers: [ApparelLayers.Underwear], color: "White", colorLabel: "White"), diff --git a/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs index 1bc329d..46b25a5 100644 --- a/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs @@ -11,6 +11,8 @@ public class SwarmUiSettingsStoreTests var profile = config.Resolve(8, PortraitKind.Avatar); Assert.Equal("child", profile.PresetId); + Assert.Equal(SwarmUiModelDefinition.DreamShaperId, profile.Model); + Assert.Equal(4, profile.Steps); } [Fact] @@ -20,6 +22,23 @@ public class SwarmUiSettingsStoreTests var profile = config.Resolve(34, PortraitKind.Full); 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] @@ -30,7 +49,7 @@ public class SwarmUiSettingsStoreTests Model = "legacy.safetensors", Steps = 12, Positive = "legacy prompt", - Avatar = new SwarmUiKindPreset { Width = 640, Height = 640 }, + Avatar = new SwarmUiKindPreset { Width = 640, Height = 640, Positive = "close up." }, }; config.NormalizeAfterLoad(); @@ -38,7 +57,77 @@ public class SwarmUiSettingsStoreTests Assert.Single(config.Presets); Assert.Equal("default", config.Presets[0].Id); 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] @@ -46,6 +135,7 @@ public class SwarmUiSettingsStoreTests { var source = SwarmUiConfigFile.CreateDefault(); source.Presets[0].Model = "mutated.safetensors"; + source.Models[0].Id = "mutated.safetensors"; var copy = SwarmUiConfigFile.Clone(source); copy.Presets[0].Model = "other.safetensors"; @@ -54,4 +144,16 @@ public class SwarmUiSettingsStoreTests Assert.Equal("other.safetensors", copy.Presets[0].Model); 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); + } } From 477f8cf6c9b18d64d68eb31dc9f5aeab57503c34 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 14:09:13 +0300 Subject: [PATCH 2/2] Mark phase 60 portrait-models complete. --- docs/phases/off-queue/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/off-queue/README.md b/docs/phases/off-queue/README.md index 10e776c..3226deb 100644 --- a/docs/phases/off-queue/README.md +++ b/docs/phases/off-queue/README.md @@ -19,4 +19,4 @@ | [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу | | [54. Что нового](54-whats-new.md) | ✅ | После входа — окно коммитов с прошлого визита | | [55. Состояние экрана](55-view-state.md) | ✅ | F5 и переходы не сбрасывают школу, вкладки и фильтры | -| [60. Модели портретов](60-portrait-models.md) | 🔄 | Каталог моделей Swarm ∩ конфиг, слои промпта, спойлер оверрайдов | +| [60. Модели портретов](60-portrait-models.md) | ✅ | Каталог моделей Swarm ∩ конфиг, слои промпта, спойлер оверрайдов |