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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user