Implement portrait generation and retrieval for people in the school system. Added new API endpoints for generating and fetching portraits, including support for avatar and full-body images. Updated the client-side to handle portrait states and display options. Enhanced the person card UI to include tabs for overview and portrait, with appropriate localization strings. Updated solution files to include new test projects. Adjusted server configuration for SwarmUI integration.
This commit is contained in:
@@ -65,7 +65,9 @@ internal sealed record PersonCardResponse(
|
||||
IReadOnlyList<WornItemResponse> Worn,
|
||||
IReadOnlyList<CarriedItemResponse> Carried,
|
||||
float CarryMass,
|
||||
float CarryCapacity);
|
||||
float CarryCapacity,
|
||||
bool HasAvatar = false,
|
||||
bool HasFullBody = false);
|
||||
|
||||
internal sealed record WornItemResponse(
|
||||
string DefName,
|
||||
|
||||
@@ -174,6 +174,7 @@ internal static class SchoolEndpoints
|
||||
string personId,
|
||||
string? lang,
|
||||
GameCommandQueue commands,
|
||||
PortraitService portraits,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
|
||||
@@ -187,13 +188,95 @@ internal static class SchoolEndpoints
|
||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
return outcome.Error switch
|
||||
{
|
||||
PersonLookupError.None when outcome.Card is not null => Results.Ok(outcome.Card),
|
||||
PersonLookupError.None when outcome.Card is not null =>
|
||||
Results.Ok(portraits.WithPortraitFlags(id, outcome.Card)),
|
||||
PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
|
||||
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
|
||||
};
|
||||
})
|
||||
.WithName("GetSchoolPerson");
|
||||
|
||||
schools.MapGet("/{id:int}/people/{personId}/portrait", async (
|
||||
int id,
|
||||
string personId,
|
||||
string? kind,
|
||||
PortraitService portraits,
|
||||
SchoolStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
|
||||
}
|
||||
|
||||
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full.");
|
||||
}
|
||||
|
||||
var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
|
||||
if (lookup == PersonLookupError.UnknownPerson)
|
||||
{
|
||||
return Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school.");
|
||||
}
|
||||
|
||||
if (lookup != PersonLookupError.None)
|
||||
{
|
||||
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||
}
|
||||
|
||||
var path = store.PortraitPath(id, personId, portraitKind);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Problem(StatusCodes.Status404NotFound, "portrait-missing", "That portrait has not been generated yet.");
|
||||
}
|
||||
|
||||
return Results.File(path, "image/png");
|
||||
})
|
||||
.WithName("GetSchoolPersonPortrait");
|
||||
|
||||
schools.MapPost("/{id:int}/people/{personId}/portrait", async (
|
||||
int id,
|
||||
string personId,
|
||||
string? kind,
|
||||
PortraitService portraits,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
|
||||
}
|
||||
|
||||
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full.");
|
||||
}
|
||||
|
||||
if (!portraits.IsGenerationEnabled)
|
||||
{
|
||||
return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured.");
|
||||
}
|
||||
|
||||
var result = await portraits.GenerateAsync(id, personId, portraitKind, cancellationToken);
|
||||
return result.Outcome switch
|
||||
{
|
||||
PortraitGenerationOutcome.Succeeded => Results.Created(
|
||||
$"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={(portraitKind == PortraitKind.Avatar ? "avatar" : "full")}",
|
||||
new PortraitResponse(
|
||||
portraitKind == PortraitKind.Avatar ? "avatar" : "full",
|
||||
result.HasAvatar,
|
||||
result.HasFullBody)),
|
||||
PortraitGenerationOutcome.UnknownPerson =>
|
||||
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
|
||||
PortraitGenerationOutcome.NotConfigured =>
|
||||
Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured."),
|
||||
PortraitGenerationOutcome.TimedOut =>
|
||||
Problem(StatusCodes.Status504GatewayTimeout, "swarmui-timeout", "SwarmUI did not finish in time."),
|
||||
_ => Problem(StatusCodes.Status502BadGateway, "swarmui-unavailable", "SwarmUI could not generate the portrait."),
|
||||
};
|
||||
})
|
||||
.WithName("GenerateSchoolPersonPortrait");
|
||||
|
||||
schools.MapGet("/{id:int}/staffing", (
|
||||
int id,
|
||||
string? lang,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal enum PortraitKind
|
||||
{
|
||||
Avatar,
|
||||
Full,
|
||||
}
|
||||
|
||||
internal static class PortraitKindParser
|
||||
{
|
||||
public static bool TryParse(string? value, out PortraitKind kind)
|
||||
{
|
||||
if (string.Equals(value, "avatar", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = PortraitKind.Avatar;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(value, "full", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = PortraitKind.Full;
|
||||
return true;
|
||||
}
|
||||
|
||||
kind = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using HSchool.Server.Api;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Turns a person card into a Flux-style English prompt for SwarmUI.</summary>
|
||||
internal static class PortraitPromptBuilder
|
||||
{
|
||||
public static (string Positive, string Negative) Build(PersonCardResponse card, SwarmUiSettings settings, PortraitKind kind)
|
||||
{
|
||||
var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody;
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Positive))
|
||||
{
|
||||
parts.Add(settings.Positive.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(preset.Positive))
|
||||
{
|
||||
parts.Add(preset.Positive.Trim());
|
||||
}
|
||||
|
||||
parts.Add(card.Female ? "A young woman" : "A young man");
|
||||
parts.Add($"age {card.Age}");
|
||||
|
||||
foreach (var row in card.Body)
|
||||
{
|
||||
parts.Add($"{row.Label.ToLowerInvariant()} {row.Value.ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
foreach (var item in card.Worn)
|
||||
{
|
||||
var color = item.ColorLabel ?? item.Color;
|
||||
if (!string.IsNullOrWhiteSpace(color))
|
||||
{
|
||||
parts.Add($"wearing {item.Label.ToLowerInvariant()} in {color.ToLowerInvariant()}");
|
||||
}
|
||||
else
|
||||
{
|
||||
parts.Add($"wearing {item.Label.ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
var positive = string.Join(", ", parts.Where(part => part.Length > 0));
|
||||
var negative = settings.Negative?.Trim() ?? string.Empty;
|
||||
return (positive, negative);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using HSchool.Server.Api;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal sealed class PortraitService(
|
||||
SchoolStore store,
|
||||
SwarmUiClient swarm,
|
||||
SwarmUiSettings settings,
|
||||
GameCommandQueue commands,
|
||||
ILogger<PortraitService> logger)
|
||||
{
|
||||
private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>English labels for Swarm prompts, independent of the UI locale.</summary>
|
||||
private const string PromptLocale = "en";
|
||||
|
||||
public bool IsGenerationEnabled => swarm.IsConfigured;
|
||||
|
||||
public (bool HasAvatar, bool HasFullBody) Flags(int schoolId, string personId) =>
|
||||
store.PortraitFlags(schoolId, personId);
|
||||
|
||||
public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card)
|
||||
{
|
||||
var (hasAvatar, hasFullBody) = Flags(schoolId, card.Id);
|
||||
return card with { HasAvatar = hasAvatar, HasFullBody = hasFullBody };
|
||||
}
|
||||
|
||||
public async Task<PersonLookupError> EnsurePersonAsync(
|
||||
int schoolId,
|
||||
string personId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new GameCommand.GetPerson(
|
||||
schoolId,
|
||||
personId,
|
||||
PromptLocale,
|
||||
NewCompletion<PersonCardResult>());
|
||||
commands.Enqueue(command);
|
||||
|
||||
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
|
||||
return outcome.Error;
|
||||
}
|
||||
|
||||
public async Task<PortraitGenerationResult> GenerateAsync(
|
||||
int schoolId,
|
||||
string personId,
|
||||
PortraitKind kind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!swarm.IsConfigured)
|
||||
{
|
||||
return PortraitGenerationResult.NotConfigured;
|
||||
}
|
||||
|
||||
var command = new GameCommand.GetPerson(
|
||||
schoolId,
|
||||
personId,
|
||||
PromptLocale,
|
||||
NewCompletion<PersonCardResult>());
|
||||
commands.Enqueue(command);
|
||||
|
||||
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
|
||||
if (outcome.Error == PersonLookupError.UnknownPerson)
|
||||
{
|
||||
return PortraitGenerationResult.UnknownPerson;
|
||||
}
|
||||
|
||||
if (outcome.Error != PersonLookupError.None || outcome.Card is null)
|
||||
{
|
||||
return PortraitGenerationResult.UnknownSchool;
|
||||
}
|
||||
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken);
|
||||
store.SavePortrait(schoolId, personId, kind, bytes);
|
||||
var flags = Flags(schoolId, personId);
|
||||
return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasFullBody);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(ex, "SwarmUI timed out for school {SchoolId} person {PersonId}.", schoolId, personId);
|
||||
return PortraitGenerationResult.TimedOut;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "SwarmUI request failed for school {SchoolId} person {PersonId}.", schoolId, personId);
|
||||
return PortraitGenerationResult.Unavailable;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Portrait generation failed for school {SchoolId} person {PersonId}.", schoolId, personId);
|
||||
return PortraitGenerationResult.Unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskCompletionSource<T> NewCompletion<T>() =>
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
internal enum PortraitGenerationOutcome
|
||||
{
|
||||
Succeeded,
|
||||
UnknownSchool,
|
||||
UnknownPerson,
|
||||
NotConfigured,
|
||||
Unavailable,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
internal sealed record PortraitGenerationResult(
|
||||
PortraitGenerationOutcome Outcome,
|
||||
PortraitKind Kind,
|
||||
bool HasAvatar,
|
||||
bool HasFullBody)
|
||||
{
|
||||
public static PortraitGenerationResult UnknownSchool { get; } =
|
||||
new(PortraitGenerationOutcome.UnknownSchool, default, false, false);
|
||||
|
||||
public static PortraitGenerationResult UnknownPerson { get; } =
|
||||
new(PortraitGenerationOutcome.UnknownPerson, default, false, false);
|
||||
|
||||
public static PortraitGenerationResult NotConfigured { get; } =
|
||||
new(PortraitGenerationOutcome.NotConfigured, default, false, false);
|
||||
|
||||
public static PortraitGenerationResult Unavailable { get; } =
|
||||
new(PortraitGenerationOutcome.Unavailable, default, false, false);
|
||||
|
||||
public static PortraitGenerationResult TimedOut { get; } =
|
||||
new(PortraitGenerationOutcome.TimedOut, default, false, false);
|
||||
|
||||
public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasFullBody) =>
|
||||
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasFullBody);
|
||||
}
|
||||
|
||||
internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasFullBody);
|
||||
@@ -260,6 +260,55 @@ internal sealed class SchoolStore
|
||||
{
|
||||
File.Delete(timetable);
|
||||
}
|
||||
|
||||
var portraits = PortraitsDirectory(id);
|
||||
if (Directory.Exists(portraits))
|
||||
{
|
||||
Directory.Delete(portraits, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPortrait(int schoolId, string personId, PortraitKind kind) =>
|
||||
File.Exists(PortraitPath(schoolId, personId, kind));
|
||||
|
||||
public (bool HasAvatar, bool HasFullBody) PortraitFlags(int schoolId, string personId)
|
||||
{
|
||||
var directory = PortraitsDirectory(schoolId);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return (false, false);
|
||||
}
|
||||
|
||||
return (HasPortrait(schoolId, personId, PortraitKind.Avatar), HasPortrait(schoolId, personId, PortraitKind.Full));
|
||||
}
|
||||
|
||||
public string PortraitPath(int schoolId, string personId, PortraitKind kind)
|
||||
{
|
||||
var safeId = SanitizePersonId(personId);
|
||||
var suffix = kind == PortraitKind.Avatar ? "avatar" : "full";
|
||||
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png");
|
||||
}
|
||||
|
||||
public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan<byte> png)
|
||||
{
|
||||
var path = PortraitPath(schoolId, personId, kind);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var temp = path + ".tmp";
|
||||
File.WriteAllBytes(temp, png);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
|
||||
private static string SanitizePersonId(string personId)
|
||||
{
|
||||
foreach (var ch in personId)
|
||||
{
|
||||
if (ch is not (>= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '.' or '-' or '_'))
|
||||
{
|
||||
throw new ArgumentException("The person id contains invalid characters.", nameof(personId));
|
||||
}
|
||||
}
|
||||
|
||||
return personId;
|
||||
}
|
||||
|
||||
public RosterDocument? TryReadPeople(int id)
|
||||
@@ -319,6 +368,8 @@ internal sealed class SchoolStore
|
||||
|
||||
private string TimetablePath(int id) => Path.Combine(DirectoryPath, $"{id}.timetable.json");
|
||||
|
||||
private string PortraitsDirectory(int id) => Path.Combine(DirectoryPath, $"{id}.portraits");
|
||||
|
||||
private string IndexPath() => Path.Combine(DirectoryPath, IndexFileName);
|
||||
|
||||
private static void WriteAtomic<T>(string path, T value, JsonSerializerOptions? options = null)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal sealed class SwarmUiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly SwarmUiOptions _options;
|
||||
private readonly ILogger<SwarmUiClient> _logger;
|
||||
private string? _sessionId;
|
||||
|
||||
public SwarmUiClient(HttpClient http, IOptions<SwarmUiOptions> options, ILogger<SwarmUiClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.BaseUrl);
|
||||
|
||||
public async Task<byte[]> GenerateAsync(
|
||||
string prompt,
|
||||
string negativePrompt,
|
||||
SwarmUiSettings settings,
|
||||
PortraitKind kind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
throw new InvalidOperationException("SwarmUI is not configured.");
|
||||
}
|
||||
|
||||
return await RunWithSessionAsync(async () =>
|
||||
{
|
||||
var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody;
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["session_id"] = _sessionId,
|
||||
["images"] = 1,
|
||||
["donotsave"] = true,
|
||||
["prompt"] = prompt,
|
||||
["negativeprompt"] = negativePrompt,
|
||||
["model"] = settings.Model,
|
||||
["steps"] = settings.Steps,
|
||||
["cfgscale"] = settings.CfgScale,
|
||||
["width"] = preset.Width,
|
||||
["height"] = preset.Height,
|
||||
["seed"] = settings.Seed,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Sampler))
|
||||
{
|
||||
body["sampler"] = settings.Sampler;
|
||||
}
|
||||
|
||||
if (settings.ClipSkip > 0)
|
||||
{
|
||||
body["clipskip"] = settings.ClipSkip;
|
||||
}
|
||||
|
||||
using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
using var response = await _http.PostAsync("/API/GenerateText2Image", content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (root.TryGetProperty("error_id", out var errorId)
|
||||
&& errorId.GetString() == "invalid_session_id")
|
||||
{
|
||||
throw new SwarmUiSessionInvalidException();
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("error", out var error))
|
||||
{
|
||||
throw new InvalidOperationException($"SwarmUI error: {error.GetString()}");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("images", out var images) || images.GetArrayLength() == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SwarmUI returned no images.");
|
||||
}
|
||||
|
||||
var first = images[0].GetString() ?? throw new InvalidOperationException("SwarmUI image entry was empty.");
|
||||
return await ReadImageAsync(first, cancellationToken);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadImageAsync(string reference, CancellationToken cancellationToken)
|
||||
{
|
||||
if (reference.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var comma = reference.IndexOf(',', StringComparison.Ordinal);
|
||||
if (comma < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Malformed data URL from SwarmUI.");
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(reference[(comma + 1)..]);
|
||||
}
|
||||
|
||||
var path = reference.StartsWith('/') ? reference : $"/{reference}";
|
||||
using var response = await _http.GetAsync(path, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<T> RunWithSessionAsync<T>(Func<Task<T>> call, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_sessionId))
|
||||
{
|
||||
await RefreshSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await call();
|
||||
}
|
||||
catch (SwarmUiSessionInvalidException)
|
||||
{
|
||||
await RefreshSessionAsync(cancellationToken);
|
||||
return await call();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var content = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||
using var response = await _http.PostAsync("/API/GetNewSession", content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
_sessionId = document.RootElement.GetProperty("session_id").GetString()
|
||||
?? throw new InvalidOperationException("SwarmUI did not return a session_id.");
|
||||
_logger.LogDebug("SwarmUI session refreshed.");
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiSessionInvalidException : Exception;
|
||||
}
|
||||
|
||||
internal static class SwarmUiClientRegistration
|
||||
{
|
||||
public static IServiceCollection AddSwarmUi(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services
|
||||
.AddOptions<SwarmUiOptions>()
|
||||
.Bind(configuration.GetSection(SwarmUiOptions.SectionName))
|
||||
.Validate(options => options.TimeoutSeconds is > 0 and <= 3600, "SwarmUi:TimeoutSeconds must be between 1 and 3600.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient<SwarmUiClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SwarmUiOptions>>().Value;
|
||||
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
{
|
||||
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/");
|
||||
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authorization))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.Authorization);
|
||||
}
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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 long Seed { get; init; } = -1;
|
||||
|
||||
public string Positive { get; init; } = "";
|
||||
|
||||
public string Negative { get; init; } = "";
|
||||
|
||||
public SwarmUiPreset Avatar { get; init; } = new();
|
||||
|
||||
public SwarmUiPreset FullBody { get; init; } = new();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,14 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</None>
|
||||
<None Include="swarmui.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="HSchool.Server.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Net.WebSockets;
|
||||
using HSchool.Server;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Server.Game;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -34,6 +36,9 @@ builder.Services.AddSingleton<ModContent>();
|
||||
builder.Services.AddSingleton<GameSocketHandler>();
|
||||
builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
builder.Services.AddSwarmUi(builder.Configuration);
|
||||
builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings")));
|
||||
builder.Services.AddSingleton<PortraitService>();
|
||||
|
||||
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
|
||||
|
||||
@@ -55,10 +60,16 @@ app.MapSchoolEndpoints();
|
||||
app.MapTimetableEndpoints();
|
||||
app.MapModEndpoints();
|
||||
|
||||
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
|
||||
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients, IOptions<SwarmUiOptions> swarm) =>
|
||||
{
|
||||
var state = loop.SchoolsState;
|
||||
return new GameStatusResponse(loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count);
|
||||
return new GameStatusResponse(
|
||||
loop.CurrentTick,
|
||||
loop.Options.TickRate,
|
||||
state.Schools.Count,
|
||||
state.MaxSchools,
|
||||
clients.Count,
|
||||
!string.IsNullOrWhiteSpace(swarm.Value.BaseUrl));
|
||||
})
|
||||
.WithName("GetGameStatus");
|
||||
|
||||
@@ -102,7 +113,13 @@ app.UseFileServer();
|
||||
app.Run();
|
||||
|
||||
/// <summary>Loop health for dashboards and integration tests.</summary>
|
||||
internal sealed record GameStatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
|
||||
internal sealed record GameStatusResponse(
|
||||
uint Tick,
|
||||
int TickRate,
|
||||
int Schools,
|
||||
int MaxSchools,
|
||||
int Connections,
|
||||
bool SwarmUiConfigured);
|
||||
|
||||
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
|
||||
public partial class Program;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace HSchool.Server;
|
||||
|
||||
/// <summary>Connection to a local SwarmUI instance. Empty <see cref="BaseUrl"/> disables generation.</summary>
|
||||
internal sealed class SwarmUiOptions
|
||||
{
|
||||
public const string SectionName = "SwarmUi";
|
||||
|
||||
public string BaseUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Optional bearer token when SwarmUI requires authorization.</summary>
|
||||
public string Authorization { get; set; } = "";
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 180;
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"SwarmUi": {
|
||||
"BaseUrl": "http://127.0.0.1:7801",
|
||||
"Authorization": "",
|
||||
"TimeoutSeconds": 180
|
||||
},
|
||||
"Simulation": {
|
||||
"TickRate": 20,
|
||||
"MaxSchools": 6,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"model": "pornmasterFlux2Klein_v4TurboFp8.safetensors",
|
||||
"steps": 8,
|
||||
"cfgScale": 1,
|
||||
"clipSkip": 1,
|
||||
"sampler": "euler",
|
||||
"seed": -1,
|
||||
"positive": "School portrait photograph, neutral background, natural lighting, realistic, sharp focus.",
|
||||
"negative": "nsfw, nude, naked, explicit, blurry, deformed, extra limbs, bad anatomy, watermark, text, logo",
|
||||
"avatar": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"positive": "Head and shoulders portrait, facing the camera, upper body visible."
|
||||
},
|
||||
"fullBody": {
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"positive": "Full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user