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
+3 -1
View File
@@ -295,7 +295,9 @@ internal static class SchoolEndpoints
PortraitKindParser.ToApiValue(result.Kind),
result.Positive,
result.Negative,
result.PromptExtra)),
result.PromptExtra,
result.PresetId,
result.PresetLabel)),
PortraitPromptBuildOutcome.InvalidPrompt =>
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
PortraitPromptBuildOutcome.UnknownPerson =>
@@ -0,0 +1,45 @@
using HSchool.Server.Game;
namespace HSchool.Server.Api;
internal static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder endpoints)
{
var settings = endpoints.MapGroup("/api/settings");
settings.MapGet("/swarmui", (SwarmUiSettingsStore store) => Results.Ok(store.Current))
.WithName("GetSwarmUiSettings");
settings.MapPut("/swarmui", (SwarmUiConfigFile body, SwarmUiSettingsStore store) =>
{
try
{
body.NormalizeAfterLoad();
return Results.Ok(store.Save(body));
}
catch (InvalidOperationException ex)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-body", ex.Message);
}
})
.WithName("PutSwarmUiSettings");
settings.MapGet("/swarmui/discovery", async (SwarmUiClient swarm, CancellationToken cancellationToken) =>
{
if (!swarm.IsConfigured)
{
return Results.Ok(SwarmUiDiscovery.Offline);
}
var discovery = await swarm.FetchDiscoveryAsync(cancellationToken);
return Results.Ok(discovery);
})
.WithName("GetSwarmUiDiscovery");
return endpoints;
}
private static IResult Problem(int status, string code, string detail) =>
Results.Problem(detail, statusCode: status, extensions: new Dictionary<string, object?> { ["code"] = code });
}
@@ -7,16 +7,16 @@ internal static class PortraitPromptBuilder
{
public static (string Positive, string Negative) Build(
PersonCardResponse card,
SwarmUiSettings settings,
SwarmUiResolvedProfile profile,
PortraitKind kind,
string? promptExtra = null)
{
var preset = settings.PresetFor(kind);
var kindPreset = profile.KindPreset;
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(settings.Positive))
if (!string.IsNullOrWhiteSpace(profile.Positive))
{
parts.Add(settings.Positive.Trim());
parts.Add(profile.Positive.Trim());
}
if (kind == PortraitKind.Custom)
@@ -26,12 +26,12 @@ internal static class PortraitPromptBuilder
parts.Add(promptExtra.Trim());
}
}
else if (!string.IsNullOrWhiteSpace(preset.Positive))
else if (!string.IsNullOrWhiteSpace(kindPreset.Positive))
{
parts.Add(preset.Positive.Trim());
parts.Add(kindPreset.Positive.Trim());
}
parts.Add(card.Female ? "A young woman" : "A young man");
parts.Add(DescribeSubject(card));
parts.Add($"age {card.Age}");
foreach (var row in card.Body)
@@ -53,7 +53,25 @@ internal static class PortraitPromptBuilder
}
var positive = string.Join(", ", parts.Where(part => part.Length > 0));
var negative = settings.Negative?.Trim() ?? string.Empty;
var negative = SwarmUiLoraFormatter.AppendLoraTags(
profile.Negative.Trim(),
profile.NegativeLoras);
return (positive, negative);
}
private static string DescribeSubject(PersonCardResponse card)
{
if (card.Age <= 11)
{
return card.Female ? "A young girl" : "A young boy";
}
if (card.Age <= 17)
{
return card.Female ? "A teenage girl" : "A teenage boy";
}
return card.Female ? "A young woman" : "A young man";
}
}
+22 -12
View File
@@ -5,7 +5,7 @@ namespace HSchool.Server.Game;
internal sealed class PortraitService(
SchoolStore store,
SwarmUiClient swarm,
SwarmUiSettings settings,
SwarmUiSettingsStore settingsStore,
GameCommandQueue commands,
ILogger<PortraitService> logger)
{
@@ -74,12 +74,15 @@ internal sealed class PortraitService(
return PortraitPromptBuildResult.UnknownSchool;
}
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, resolved);
var profile = settingsStore.Resolve(outcome.Card.Age, kind);
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved);
return PortraitPromptBuildResult.Succeeded(
kind,
positive,
negative,
kind == PortraitKind.Custom ? resolved : null);
kind == PortraitKind.Custom ? resolved : null,
profile.PresetId,
profile.PresetLabel);
}
public async Task<PortraitGenerationResult> GenerateAsync(
@@ -119,11 +122,12 @@ internal sealed class PortraitService(
return PortraitGenerationResult.UnknownSchool;
}
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, promptExtra);
var profile = settingsStore.Resolve(outcome.Card.Age, kind);
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra);
try
{
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken);
var bytes = await swarm.GenerateAsync(positive, negative, profile, cancellationToken);
store.SavePortrait(schoolId, personId, kind, bytes, kind == PortraitKind.Custom ? promptExtra : null);
var flags = Flags(schoolId, personId);
var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId);
@@ -203,30 +207,36 @@ internal sealed record PortraitPromptBuildResult(
PortraitKind Kind,
string Positive,
string Negative,
string? PromptExtra)
string? PromptExtra,
string PresetId,
string PresetLabel)
{
public static PortraitPromptBuildResult UnknownSchool { get; } =
new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null);
new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null, "", "");
public static PortraitPromptBuildResult UnknownPerson { get; } =
new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null);
new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null, "", "");
public static PortraitPromptBuildResult InvalidPrompt { get; } =
new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null);
new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null, "", "");
public static PortraitPromptBuildResult Succeeded(
PortraitKind kind,
string positive,
string negative,
string? promptExtra) =>
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra);
string? promptExtra,
string presetId,
string presetLabel) =>
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra, presetId, presetLabel);
}
internal sealed record PortraitPromptResponse(
string Kind,
string Positive,
string Negative,
string? PromptExtra);
string? PromptExtra,
string PresetId,
string PresetLabel);
internal enum PortraitGenerationOutcome
{
+49 -16
View File
@@ -55,11 +55,39 @@ internal sealed class SwarmUiClient
}
}
public async Task<SwarmUiDiscovery> FetchDiscoveryAsync(CancellationToken cancellationToken)
{
if (!IsConfigured)
{
return SwarmUiDiscovery.Offline;
}
try
{
return await RunWithSessionAsync(async () =>
{
using var content = new StringContent(
JsonSerializer.Serialize(new { session_id = _sessionId }),
Encoding.UTF8,
"application/json");
using var response = await _http.PostAsync("/API/ListT2IParams", content, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return SwarmUiDiscoveryParser.Parse(document.RootElement);
}, cancellationToken);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "SwarmUI discovery failed.");
return SwarmUiDiscovery.Offline;
}
}
public async Task<byte[]> GenerateAsync(
string prompt,
string negativePrompt,
SwarmUiSettings settings,
PortraitKind kind,
SwarmUiResolvedProfile profile,
CancellationToken cancellationToken)
{
if (!IsConfigured)
@@ -69,7 +97,7 @@ internal sealed class SwarmUiClient
return await RunWithSessionAsync(async () =>
{
var preset = settings.PresetFor(kind);
var kind = profile.KindPreset;
var body = new Dictionary<string, object?>
{
["session_id"] = _sessionId,
@@ -77,28 +105,33 @@ internal sealed class SwarmUiClient
["donotsave"] = true,
["prompt"] = prompt,
["negativeprompt"] = negativePrompt,
["model"] = settings.Model,
["steps"] = settings.Steps,
["cfgscale"] = settings.CfgScale,
["width"] = preset.Width,
["height"] = preset.Height,
["seed"] = settings.Seed,
["model"] = profile.Model,
["steps"] = profile.Steps,
["cfgscale"] = profile.CfgScale,
["width"] = kind.Width,
["height"] = kind.Height,
["seed"] = profile.Seed,
};
if (!string.IsNullOrWhiteSpace(settings.Sampler))
if (!string.IsNullOrWhiteSpace(profile.Sampler))
{
body["sampler"] = settings.Sampler;
body["sampler"] = profile.Sampler;
}
if (!string.IsNullOrWhiteSpace(settings.Scheduler))
if (!string.IsNullOrWhiteSpace(profile.Scheduler))
{
body["scheduler"] = settings.Scheduler;
body["scheduler"] = profile.Scheduler;
}
if (settings.ClipSkip > 0)
if (profile.ClipSkip > 0)
{
// SwarmUI "CLIP Stop At Layer" — clip skip N is layer -N from the end.
body["clipstopatlayer"] = -settings.ClipSkip;
body["clipstopatlayer"] = -profile.ClipSkip;
}
var loras = SwarmUiLoraFormatter.FormatForApi(profile.PositiveLoras);
if (loras is not null)
{
body["loras"] = loras;
}
using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
@@ -0,0 +1,97 @@
using System.Text.Json;
namespace HSchool.Server.Game;
internal sealed record SwarmUiDiscovery(
bool Connected,
IReadOnlyList<string> Models,
IReadOnlyList<string> Loras,
IReadOnlyList<string> Samplers,
IReadOnlyList<string> Schedulers)
{
public static SwarmUiDiscovery Offline { get; } = new(false, [], [], [], []);
}
internal static class SwarmUiDiscoveryParser
{
public static SwarmUiDiscovery Parse(JsonElement root)
{
var models = ReadModelNames(root, "Stable-Diffusion");
var loras = ReadModelNames(root, "LoRA");
var samplers = ReadParamValues(root, "sampler");
var schedulers = ReadParamValues(root, "scheduler");
return new SwarmUiDiscovery(true, models, loras, samplers, schedulers);
}
private static IReadOnlyList<string> ReadModelNames(JsonElement root, string subtype)
{
if (!root.TryGetProperty("models", out var models) || models.ValueKind != JsonValueKind.Object)
{
return [];
}
if (!models.TryGetProperty(subtype, out var list))
{
return [];
}
return ReadStringList(list);
}
private static IReadOnlyList<string> ReadParamValues(JsonElement root, string paramId)
{
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array)
{
return [];
}
foreach (var entry in list.EnumerateArray())
{
if (!entry.TryGetProperty("id", out var id) || id.GetString() != paramId)
{
continue;
}
if (!entry.TryGetProperty("values", out var values) || values.ValueKind != JsonValueKind.Array)
{
return [];
}
return ReadStringList(values);
}
return [];
}
private static IReadOnlyList<string> ReadStringList(JsonElement list)
{
var names = new List<string>();
foreach (var item in list.EnumerateArray())
{
switch (item.ValueKind)
{
case JsonValueKind.String:
AddName(names, item.GetString());
break;
case JsonValueKind.Array when item.GetArrayLength() > 0:
AddName(names, item[0].GetString());
break;
}
}
return names;
}
private static void AddName(List<string> names, string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
if (!names.Contains(name, StringComparer.Ordinal))
{
names.Add(name);
}
}
}
@@ -0,0 +1,50 @@
namespace HSchool.Server.Game;
internal static class SwarmUiLoraFormatter
{
/// <summary>SwarmUI comma-separated lora list: name,weight,name,weight,…</summary>
public static string? FormatForApi(IReadOnlyList<SwarmUiLoraEntry> loras)
{
if (loras.Count == 0)
{
return null;
}
var parts = new List<string>(loras.Count * 2);
foreach (var lora in loras)
{
if (string.IsNullOrWhiteSpace(lora.Name))
{
continue;
}
parts.Add(lora.Name.Trim());
parts.Add(lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
return parts.Count == 0 ? null : string.Join(',', parts);
}
public static string AppendLoraTags(string prompt, IReadOnlyList<SwarmUiLoraEntry> loras)
{
if (loras.Count == 0)
{
return prompt;
}
var tags = loras
.Where(lora => !string.IsNullOrWhiteSpace(lora.Name))
.Select(lora =>
$"<lora:{lora.Name.Trim()}:{lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture)}>")
.ToList();
if (tags.Count == 0)
{
return prompt;
}
return string.IsNullOrWhiteSpace(prompt)
? string.Join(' ', tags)
: $"{prompt.TrimEnd()} {string.Join(' ', tags)}";
}
}
@@ -1,75 +0,0 @@
using System.Text.Json;
namespace HSchool.Server.Game;
/// <summary>Generation defaults and prompt templates loaded from swarmui.json next to the server.</summary>
internal sealed class SwarmUiSettings
{
public string Model { get; init; } = "";
public int Steps { get; init; } = 8;
public double CfgScale { get; init; } = 1;
public int ClipSkip { get; init; } = 1;
public string Sampler { get; init; } = "euler";
public string Scheduler { get; init; } = "";
public long Seed { get; init; } = -1;
public string Positive { get; init; } = "";
public string Negative { get; init; } = "";
public SwarmUiPreset Avatar { get; init; } = new();
public SwarmUiPreset Custom { get; init; } = new();
public SwarmUiPreset FullBody { get; init; } = new();
public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch
{
PortraitKind.Avatar => Avatar,
PortraitKind.Custom => Custom,
PortraitKind.Full => FullBody,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
internal sealed class SwarmUiPreset
{
public int Width { get; init; } = 512;
public int Height { get; init; } = 512;
public string Positive { get; init; } = "";
}
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public static SwarmUiSettings Load(IHostEnvironment environment, ILogger logger)
{
var path = Path.Combine(environment.ContentRootPath, "swarmui.json");
if (!File.Exists(path))
{
logger.LogWarning("SwarmUI settings file {Path} is missing; portrait generation will use empty defaults.", path);
return new SwarmUiSettings();
}
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<SwarmUiSettings>(json, Json) ?? new SwarmUiSettings();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}.", path);
return new SwarmUiSettings();
}
}
}
@@ -0,0 +1,443 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace HSchool.Server.Game;
/// <summary>Loads, saves and serves SwarmUI generation presets from swarmui.json.</summary>
internal sealed class SwarmUiSettingsStore(IHostEnvironment environment, ILogger<SwarmUiSettingsStore> logger)
{
private readonly Lock _lock = new();
private SwarmUiConfigFile _config = SwarmUiConfigFile.CreateDefault();
public string FilePath { get; } = Path.Combine(environment.ContentRootPath, "swarmui.json");
public SwarmUiConfigFile Current
{
get
{
lock (_lock)
{
return _config;
}
}
}
public void Load()
{
lock (_lock)
{
_config = ReadFromDisk();
}
}
public SwarmUiConfigFile Save(SwarmUiConfigFile config)
{
config.Validate();
var json = JsonSerializer.Serialize(config, JsonOptions);
var temp = FilePath + ".tmp";
File.WriteAllText(temp, json);
File.Move(temp, FilePath, overwrite: true);
lock (_lock)
{
_config = config;
}
logger.LogInformation("SwarmUI settings saved to {Path}.", FilePath);
return config;
}
public SwarmUiResolvedProfile Resolve(int age, PortraitKind kind)
{
lock (_lock)
{
return _config.Resolve(age, kind);
}
}
private SwarmUiConfigFile ReadFromDisk()
{
if (!File.Exists(FilePath))
{
logger.LogWarning("SwarmUI settings file {Path} is missing; using built-in defaults.", FilePath);
return SwarmUiConfigFile.CreateDefault();
}
try
{
var json = File.ReadAllText(FilePath);
var loaded = JsonSerializer.Deserialize<SwarmUiConfigFile>(json, JsonOptions) ?? SwarmUiConfigFile.CreateDefault();
loaded.NormalizeAfterLoad();
loaded.Validate();
return loaded;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}; using built-in defaults.", FilePath);
return SwarmUiConfigFile.CreateDefault();
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
}
internal sealed class SwarmUiConfigFile
{
public string ActivePresetId { get; set; } = "default";
public List<SwarmUiPresetDefinition> Presets { get; set; } = [];
public List<SwarmUiAgeRule> AgeRules { get; set; } = [];
// Legacy flat fields — read for migration only.
public string? Model { 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; }
public string? Positive { get; set; }
public string? Negative { get; set; }
public SwarmUiKindPreset? Avatar { get; set; }
public SwarmUiKindPreset? Custom { get; set; }
public SwarmUiKindPreset? FullBody { get; set; }
public void NormalizeAfterLoad()
{
if (Presets.Count == 0 && !string.IsNullOrWhiteSpace(Model))
{
Presets =
[
new SwarmUiPresetDefinition
{
Id = "default",
Label = "Default",
Model = Model ?? "",
Steps = Steps ?? 8,
CfgScale = CfgScale ?? 1,
ClipSkip = ClipSkip ?? 1,
Sampler = Sampler ?? "",
Scheduler = Scheduler ?? "",
Seed = Seed ?? -1,
Positive = Positive ?? "",
Negative = Negative ?? "",
Avatar = Avatar ?? new SwarmUiKindPreset(),
Custom = Custom ?? new SwarmUiKindPreset(),
FullBody = FullBody ?? new SwarmUiKindPreset(),
},
];
ActivePresetId = "default";
}
if (Presets.Count == 0)
{
Presets = [SwarmUiPresetDefinition.CreateDefault()];
ActivePresetId = "default";
}
foreach (var preset in Presets)
{
preset.Avatar ??= new SwarmUiKindPreset();
preset.Custom ??= new SwarmUiKindPreset();
preset.FullBody ??= new SwarmUiKindPreset();
preset.PositiveLoras ??= [];
preset.NegativeLoras ??= [];
}
if (string.IsNullOrWhiteSpace(ActivePresetId) || FindPreset(ActivePresetId) is null)
{
ActivePresetId = Presets[0].Id;
}
}
public void Validate()
{
if (Presets.Count == 0)
{
throw new InvalidOperationException("At least one preset is required.");
}
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var preset in Presets)
{
if (string.IsNullOrWhiteSpace(preset.Id))
{
throw new InvalidOperationException("Every preset needs a non-empty id.");
}
if (!ids.Add(preset.Id))
{
throw new InvalidOperationException($"Duplicate preset id '{preset.Id}'.");
}
if (string.IsNullOrWhiteSpace(preset.Label))
{
preset.Label = preset.Id;
}
preset.Validate();
}
if (FindPreset(ActivePresetId) is null)
{
throw new InvalidOperationException($"Active preset '{ActivePresetId}' does not exist.");
}
foreach (var rule in AgeRules)
{
if (rule.MinAge > rule.MaxAge)
{
throw new InvalidOperationException($"Age rule {rule.MinAge}-{rule.MaxAge} is inverted.");
}
if (FindPreset(rule.PresetId) is null)
{
throw new InvalidOperationException($"Age rule references unknown preset '{rule.PresetId}'.");
}
}
}
public SwarmUiResolvedProfile Resolve(int age, PortraitKind kind)
{
var presetId = ResolvePresetId(age);
var preset = FindPreset(presetId) ?? FindPreset(ActivePresetId) ?? Presets[0];
return preset.ToProfile(kind);
}
private string ResolvePresetId(int age)
{
foreach (var rule in AgeRules.OrderBy(rule => rule.MinAge))
{
if (age >= rule.MinAge && age <= rule.MaxAge)
{
return rule.PresetId;
}
}
return ActivePresetId;
}
private SwarmUiPresetDefinition? FindPreset(string id) =>
Presets.FirstOrDefault(preset => string.Equals(preset.Id, id, StringComparison.Ordinal));
public static SwarmUiConfigFile CreateDefault() =>
new()
{
ActivePresetId = "default",
Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()],
AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }],
};
}
internal sealed class SwarmUiPresetDefinition
{
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 string Sampler { get; set; } = "";
public string Scheduler { get; set; } = "";
public long Seed { get; set; } = -1;
public string Positive { get; set; } = "";
public string Negative { 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 Validate()
{
if (Steps is < 1 or > 200)
{
throw new InvalidOperationException($"Preset '{Id}' steps must be between 1 and 200.");
}
if (CfgScale is < 0 or > 30)
{
throw new InvalidOperationException($"Preset '{Id}' cfgScale must be between 0 and 30.");
}
if (ClipSkip is < 0 or > 12)
{
throw new InvalidOperationException($"Preset '{Id}' clipSkip must be between 0 and 12.");
}
ValidateLoras(PositiveLoras, "positive");
ValidateLoras(NegativeLoras, "negative");
}
private void ValidateLoras(IReadOnlyList<SwarmUiLoraEntry>? loras, string side)
{
if (loras is null)
{
return;
}
foreach (var lora in loras)
{
if (string.IsNullOrWhiteSpace(lora.Name))
{
throw new InvalidOperationException($"Preset '{Id}' 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.");
}
}
}
public SwarmUiResolvedProfile ToProfile(PortraitKind kind) =>
new(
Id,
Label,
Model,
Steps,
CfgScale,
ClipSkip,
Sampler,
Scheduler,
Seed,
Positive,
Negative,
PositiveLoras ?? [],
NegativeLoras ?? [],
KindPresetFor(kind));
private SwarmUiKindPreset KindPresetFor(PortraitKind kind) => kind switch
{
PortraitKind.Avatar => Avatar ?? new SwarmUiKindPreset(),
PortraitKind.Custom => Custom ?? new SwarmUiKindPreset(),
PortraitKind.Full => FullBody ?? new SwarmUiKindPreset(),
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
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 =
"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",
Avatar = new SwarmUiKindPreset
{
Width = 1024,
Height = 1024,
Positive = "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.",
},
};
public static SwarmUiPresetDefinition CreateChild()
{
var adult = CreateDefault();
return new SwarmUiPresetDefinition
{
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 =
"cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus",
Negative = adult.Negative,
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.",
},
Custom = adult.Custom,
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.",
},
};
}
}
internal sealed class SwarmUiAgeRule
{
public int MinAge { get; set; }
public int MaxAge { get; set; }
public string PresetId { get; set; } = "";
}
internal sealed class SwarmUiKindPreset
{
public int Width { get; set; } = 512;
public int Height { get; set; } = 512;
public string Positive { get; set; } = "";
}
internal sealed class SwarmUiLoraEntry
{
public string Name { get; set; } = "";
public double Weight { get; set; } = 1;
}
internal sealed record SwarmUiResolvedProfile(
string PresetId,
string PresetLabel,
string Model,
int Steps,
double CfgScale,
int ClipSkip,
string Sampler,
string Scheduler,
long Seed,
string Positive,
string Negative,
IReadOnlyList<SwarmUiLoraEntry> PositiveLoras,
IReadOnlyList<SwarmUiLoraEntry> NegativeLoras,
SwarmUiKindPreset KindPreset);
+9 -1
View File
@@ -38,7 +38,14 @@ builder.Services.AddSingleton<GameLoopService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
builder.Services.AddSwarmUi(builder.Configuration);
builder.Services.AddSingleton<SwarmUiHealthService>();
builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings")));
builder.Services.AddSingleton<SwarmUiSettingsStore>(sp =>
{
var store = new SwarmUiSettingsStore(
sp.GetRequiredService<IHostEnvironment>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger<SwarmUiSettingsStore>());
store.Load();
return store;
});
builder.Services.AddSingleton<PortraitService>();
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
@@ -58,6 +65,7 @@ app.UseWebSockets(new WebSocketOptions
});
app.MapSchoolEndpoints();
app.MapSettingsEndpoints();
app.MapTimetableEndpoints();
app.MapModEndpoints();
+64 -23
View File
@@ -1,25 +1,66 @@
{
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
"steps": 4,
"cfgScale": 2,
"clipSkip": 2,
"sampler": "dpmpp_sde",
"scheduler": "karras",
"seed": 3346112079,
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo",
"avatar": {
"width": 1024,
"height": 1024,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
},
"custom": {
"width": 896,
"height": 1152
},
"fullBody": {
"width": 896,
"height": 1152,
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
}
"activePresetId": "default",
"presets": [
{
"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": "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",
"positiveLoras": [],
"negativeLoras": [],
"avatar": {
"width": 1024,
"height": 1024,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
},
"custom": {
"width": 896,
"height": 1152
},
"fullBody": {
"width": 896,
"height": 1152,
"positive": "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",
"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",
"positiveLoras": [],
"negativeLoras": [],
"avatar": {
"width": 1024,
"height": 1024,
"positive": "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features."
},
"custom": {
"width": 896,
"height": 1152
},
"fullBody": {
"width": 896,
"height": 1152,
"positive": "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible."
}
}
],
"ageRules": [
{ "minAge": 6, "maxAge": 11, "presetId": "child" }
]
}