Merge branch 'phase/60-portrait-models'
ci / server (push) Failing after 3m46s
ci / client (push) Successful in 21s

# Conflicts:
#	docs/phases/off-queue/README.md
This commit is contained in:
Leonid Pershin
2026-08-20 14:46:20 +03:00
20 changed files with 1142 additions and 180 deletions
+16 -6
View File
@@ -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: 'До',
@@ -423,8 +428,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',
@@ -433,7 +443,7 @@ const en: Messages = {
settingsFullBodyPreset: 'Full body',
settingsWidth: 'Width',
settingsHeight: 'Height',
settingsKindPositive: 'Kind positive',
settingsShotType: 'Shot type',
settingsAgeRules: 'Age rules',
settingsAgeMin: 'From',
settingsAgeMax: 'To',
+28 -3
View File
@@ -497,7 +497,7 @@ export async function fetchChangelog(since?: string | null): Promise<ChangelogRe
export interface SwarmUiKindPreset {
width: number;
height: number;
positive: string;
shotType: string;
}
export interface SwarmUiLoraEntry {
@@ -505,10 +505,9 @@ export interface SwarmUiLoraEntry {
weight: number;
}
export interface SwarmUiPresetDefinition {
export interface SwarmUiModelDefinition {
id: string;
label: string;
model: string;
steps: number;
cfgScale: number;
clipSkip: number;
@@ -519,6 +518,22 @@ export interface SwarmUiPresetDefinition {
negative: string;
positiveLoras: 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;
custom: SwarmUiKindPreset;
fullBody: SwarmUiKindPreset;
@@ -532,6 +547,7 @@ export interface SwarmUiAgeRule {
export interface SwarmUiSettingsFile {
activePresetId: string;
models: SwarmUiModelDefinition[];
presets: SwarmUiPresetDefinition[];
ageRules: SwarmUiAgeRule[];
}
@@ -544,6 +560,15 @@ export interface SwarmUiDiscovery {
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> {
return request<SwarmUiSettingsFile>('/api/settings/swarmui');
}
+24
View File
@@ -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;
@@ -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: [],
@@ -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 {
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<Sw
function paintPresetForm(): void {
formHost.replaceChildren();
const preset = currentPreset();
if (preset === null || preset === undefined) {
if (config === null || preset === undefined) {
return;
}
const model = currentModel(preset);
const modelIds = allowedSwarmModels(config, discovery);
formHost.append(
textField(t('settingsPresetLabel'), preset.label, (value) => {
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<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 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<Sw
text: t('settingsAddLora'),
onClick: () => {
loras.push({ name: discovery.loras[0] ?? '', weight: 1 });
onMutate?.();
paintRows();
},
});
@@ -359,8 +429,8 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
numberField(t('settingsHeight'), preset.height, (value) => {
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);
}
+1 -1
View File
@@ -81,8 +81,8 @@ internal static class SchoolEndpoints
{
try
{
request.PortraitSettings.Validate();
portraitSettings = SwarmUiConfigFile.Clone(request.PortraitSettings);
portraitSettings.Validate();
}
catch (InvalidOperationException ex)
{
+1 -1
View File
@@ -721,7 +721,7 @@ internal sealed class GameLoopService(
save.DressRules,
save.SpeechRules,
save.Owner,
save.PortraitSettings);
save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings));
worker.Start();
try
@@ -11,32 +11,23 @@ internal static class PortraitPromptBuilder
PortraitKind kind,
string? promptExtra = null)
{
var kindPreset = profile.KindPreset;
var parts = new List<string>();
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<string> parts, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
parts.Add(value.Trim());
}
private static string DescribeSubject(PersonCardResponse card)
{
if (card.Age <= 11)
+387 -60
View File
@@ -90,6 +90,8 @@ internal sealed class SwarmUiConfigFile
{
public string ActivePresetId { get; set; } = "default";
public List<SwarmUiModelDefinition> Models { get; set; } = [];
public List<SwarmUiPresetDefinition> Presets { get; set; } = [];
public List<SwarmUiAgeRule> 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<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);
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<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)
{
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<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()
{
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<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? 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<SwarmUiLoraEntry>? loras, string side)
internal static void ValidateLoras(string ownerId, IReadOnlyList<SwarmUiLoraEntry>? 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; } = "";
/// <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
@@ -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<SwarmUiLoraEntry> PositiveLoras,
IReadOnlyList<SwarmUiLoraEntry> NegativeLoras,
+74 -32
View File
@@ -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"
}
]
}
}