Add SwarmUI settings management and portrait generation enhancements

- Introduced new API endpoints for managing SwarmUI settings, including fetching and saving presets and age rules.
- Updated the portrait generation logic to utilize the new settings structure, allowing for dynamic preset selection based on age.
- Enhanced UI components to support SwarmUI settings, including localization for new strings and improved styling for settings sections.
- Added tests to verify the functionality of new settings endpoints and portrait generation behavior.

This commit lays the groundwork for more flexible and user-friendly portrait generation options.
This commit is contained in:
Leonid Pershin
2026-08-20 06:06:45 +03:00
parent e0b7122f3b
commit 5a400be792
32 changed files with 2178 additions and 184 deletions
@@ -86,6 +86,18 @@ public class PortraitApiTests(AppHostFixture fixture)
Assert.Equal("avatar", payload!.Kind);
Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.False(string.IsNullOrWhiteSpace(payload.Negative));
Assert.False(string.IsNullOrWhiteSpace(payload.PresetId));
}
[Fact]
public async Task GetSwarmUiSettings_ReturnsPresets()
{
using var client = fixture.App.CreateHttpClient("server");
var settings = await client.GetFromJsonAsync<SwarmUiSettingsPayload>(TestContext.Current.CancellationToken);
Assert.NotNull(settings);
Assert.NotEmpty(settings!.Presets);
Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId));
}
[Fact]
@@ -134,7 +146,17 @@ public class PortraitApiTests(AppHostFixture fixture)
bool HasFullBody,
string? CustomPortraitPrompt);
private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative, string? PromptExtra);
private sealed record PortraitPromptPayload(
string Kind,
string Positive,
string Negative,
string? PromptExtra,
string PresetId,
string PresetLabel);
private sealed record SwarmUiSettingsPayload(string ActivePresetId, IReadOnlyList<SwarmUiPresetPayload> Presets);
private sealed record SwarmUiPresetPayload(string Id, string Label);
private sealed record SavesDirectoryResponse(string Path);
}
@@ -8,14 +8,10 @@ public class PortraitPromptBuilderTests
[Fact]
public void Build_IncludesAgeHairAndWornClothing()
{
var settings = new SwarmUiSettings
{
Positive = "School photo.",
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
};
var profile = SamplePreset().ToProfile(PortraitKind.Avatar);
var card = SampleCard();
var (positive, _) = PortraitPromptBuilder.Build(card, settings, PortraitKind.Avatar);
var (positive, _) = PortraitPromptBuilder.Build(card, profile, PortraitKind.Avatar);
Assert.Contains("age 12", positive, StringComparison.OrdinalIgnoreCase);
Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase);
@@ -27,15 +23,9 @@ public class PortraitPromptBuilderTests
[Fact]
public void Build_FullBody_AddsFullBodyPreset()
{
var settings = new SwarmUiSettings
{
Positive = "School photo.",
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
};
var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Avatar);
var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Full);
var preset = SamplePreset();
var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Avatar), PortraitKind.Avatar);
var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Full), PortraitKind.Full);
Assert.Contains("Head and shoulders.", avatarPositive, StringComparison.Ordinal);
Assert.DoesNotContain("Standing full body.", avatarPositive, StringComparison.Ordinal);
@@ -45,17 +35,11 @@ public class PortraitPromptBuilderTests
[Fact]
public void Build_Custom_UsesPromptExtraInsteadOfPreset()
{
var settings = new SwarmUiSettings
{
Positive = "School photo.",
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
};
var profile = SamplePreset().ToProfile(PortraitKind.Custom);
const string extra = "standing in a school hallway, soft window light";
var (customPositive, _) = PortraitPromptBuilder.Build(
SampleCard(),
settings,
profile,
PortraitKind.Custom,
extra);
@@ -65,6 +49,35 @@ public class PortraitPromptBuilderTests
Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal);
}
[Fact]
public void Build_ChildAge_UsesChildSubjectDescriptor()
{
var profile = SamplePreset().ToProfile(PortraitKind.Avatar);
var child = SampleCard() with { Age = 8 };
var (positive, _) = PortraitPromptBuilder.Build(child, profile, PortraitKind.Avatar);
Assert.Contains("young girl", positive, StringComparison.OrdinalIgnoreCase);
}
private static SwarmUiPresetDefinition SamplePreset()
{
var preset = SwarmUiPresetDefinition.CreateDefault();
preset.Positive = "School photo.";
preset.Avatar = new SwarmUiKindPreset
{
Width = 512,
Height = 512,
Positive = "Head and shoulders.",
};
preset.FullBody = new SwarmUiKindPreset
{
Width = 512,
Height = 768,
Positive = "Standing full body.",
};
return preset;
}
private static PersonCardResponse SampleCard() =>
new(
"f0.c0",
@@ -22,15 +22,10 @@ public class SwarmUiClientTests
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
NullLogger<SwarmUiClient>.Instance);
var settings = new SwarmUiSettings
{
Model = "model.safetensors",
Steps = 8,
CfgScale = 1,
Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 },
};
var preset = SwarmUiPresetDefinition.CreateDefault();
var profile = preset.ToProfile(PortraitKind.Avatar);
var bytes = await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None);
var bytes = await client.GenerateAsync("a student", "bad", profile, CancellationToken.None);
Assert.Equal([0x89, 0x50, 0x4E, 0x47], bytes.Take(4));
Assert.Contains("/API/GetNewSession", handler.Requests[0]);
@@ -85,18 +80,15 @@ public class SwarmUiClientTests
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
NullLogger<SwarmUiClient>.Instance);
var settings = new SwarmUiSettings
{
Model = "model.safetensors",
Steps = 4,
CfgScale = 2,
ClipSkip = 2,
Sampler = "dpmpp_sde",
Scheduler = "karras",
Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 },
};
var preset = SwarmUiPresetDefinition.CreateDefault();
preset.Steps = 4;
preset.CfgScale = 2;
preset.ClipSkip = 2;
preset.Sampler = "dpmpp_sde";
preset.Scheduler = "karras";
var profile = preset.ToProfile(PortraitKind.Avatar);
await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None);
await client.GenerateAsync("a student", "bad", profile, CancellationToken.None);
using var document = JsonDocument.Parse(handler.GenerateBody!);
Assert.Equal(-2, document.RootElement.GetProperty("clipstopatlayer").GetInt32());
@@ -0,0 +1,43 @@
using HSchool.Server.Game;
namespace HSchool.Server.Tests;
public class SwarmUiSettingsStoreTests
{
[Fact]
public void Resolve_UsesAgeRulePreset()
{
var config = SwarmUiConfigFile.CreateDefault();
var profile = config.Resolve(8, PortraitKind.Avatar);
Assert.Equal("child", profile.PresetId);
}
[Fact]
public void Resolve_FallsBackToActivePresetForAdults()
{
var config = SwarmUiConfigFile.CreateDefault();
var profile = config.Resolve(34, PortraitKind.Full);
Assert.Equal("default", profile.PresetId);
}
[Fact]
public void NormalizeAfterLoad_MigratesLegacyFlatFile()
{
var config = new SwarmUiConfigFile
{
Model = "legacy.safetensors",
Steps = 12,
Positive = "legacy prompt",
Avatar = new SwarmUiKindPreset { Width = 640, Height = 640 },
};
config.NormalizeAfterLoad();
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);
}
}