Implement custom portrait generation: update API, UI, and localization to support user-defined prompts for portraits, replacing half-body option with custom variant.
ci / server (push) Failing after 3m38s
ci / client (push) Failing after 14s

This commit is contained in:
Leonid Pershin
2026-08-20 05:16:40 +03:00
parent df0aaad403
commit ac0470bbc4
19 changed files with 301 additions and 76 deletions
+3 -2
View File
@@ -79,8 +79,9 @@ internal sealed record PersonCardResponse(
bool HasLocker = false,
int HomeCount = 0,
bool HasAvatar = false,
bool HasHalfBody = false,
bool HasFullBody = false);
bool HasCustom = false,
bool HasFullBody = false,
string? CustomPortraitPrompt = null);
internal sealed record WornItemResponse(
string DefName,
+9 -5
View File
@@ -246,7 +246,7 @@ internal static class SchoolEndpoints
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, half, or full.");
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, custom, or full.");
}
var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
@@ -274,6 +274,7 @@ internal static class SchoolEndpoints
int id,
string personId,
string? kind,
GeneratePortraitRequest? body,
PortraitService portraits,
CancellationToken cancellationToken) =>
{
@@ -284,7 +285,7 @@ internal static class SchoolEndpoints
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, half, or full.");
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, custom, or full.");
}
if (!portraits.IsGenerationEnabled)
@@ -292,7 +293,7 @@ internal static class SchoolEndpoints
return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured.");
}
var result = await portraits.GenerateAsync(id, personId, portraitKind, cancellationToken);
var result = await portraits.GenerateAsync(id, personId, portraitKind, body?.PromptExtra, cancellationToken);
return result.Outcome switch
{
PortraitGenerationOutcome.Succeeded => Results.Created(
@@ -300,8 +301,11 @@ internal static class SchoolEndpoints
new PortraitResponse(
PortraitKindParser.ToApiValue(portraitKind),
result.HasAvatar,
result.HasHalfBody,
result.HasFullBody)),
result.HasCustom,
result.HasFullBody,
result.CustomPortraitPrompt)),
PortraitGenerationOutcome.InvalidPrompt =>
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
PortraitGenerationOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
PortraitGenerationOutcome.NotConfigured =>
+4 -4
View File
@@ -3,7 +3,7 @@ namespace HSchool.Server.Game;
internal enum PortraitKind
{
Avatar,
Half,
Custom,
Full,
}
@@ -17,9 +17,9 @@ internal static class PortraitKindParser
return true;
}
if (string.Equals(value, "half", StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, "custom", StringComparison.OrdinalIgnoreCase))
{
kind = PortraitKind.Half;
kind = PortraitKind.Custom;
return true;
}
@@ -36,7 +36,7 @@ internal static class PortraitKindParser
public static string ToApiValue(PortraitKind kind) => kind switch
{
PortraitKind.Avatar => "avatar",
PortraitKind.Half => "half",
PortraitKind.Custom => "custom",
PortraitKind.Full => "full",
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
@@ -5,7 +5,11 @@ 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)
public static (string Positive, string Negative) Build(
PersonCardResponse card,
SwarmUiSettings settings,
PortraitKind kind,
string? promptExtra = null)
{
var preset = settings.PresetFor(kind);
var parts = new List<string>();
@@ -15,7 +19,14 @@ internal static class PortraitPromptBuilder
parts.Add(settings.Positive.Trim());
}
if (!string.IsNullOrWhiteSpace(preset.Positive))
if (kind == PortraitKind.Custom)
{
if (!string.IsNullOrWhiteSpace(promptExtra))
{
parts.Add(promptExtra.Trim());
}
}
else if (!string.IsNullOrWhiteSpace(preset.Positive))
{
parts.Add(preset.Positive.Trim());
}
+63 -16
View File
@@ -9,6 +9,8 @@ internal sealed class PortraitService(
GameCommandQueue commands,
ILogger<PortraitService> logger)
{
public const int MaxCustomPromptLength = 2000;
private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5);
/// <summary>English labels for Swarm prompts, independent of the UI locale.</summary>
@@ -16,13 +18,20 @@ internal sealed class PortraitService(
public bool IsGenerationEnabled => swarm.IsConfigured;
public (bool HasAvatar, bool HasHalfBody, bool HasFullBody) Flags(int schoolId, string personId) =>
public (bool HasAvatar, bool HasCustom, bool HasFullBody) Flags(int schoolId, string personId) =>
store.PortraitFlags(schoolId, personId);
public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card)
{
var (hasAvatar, hasHalfBody, hasFullBody) = Flags(schoolId, card.Id);
return card with { HasAvatar = hasAvatar, HasHalfBody = hasHalfBody, HasFullBody = hasFullBody };
var (hasAvatar, hasCustom, hasFullBody) = Flags(schoolId, card.Id);
var customPrompt = hasCustom ? store.TryReadCustomPortraitPrompt(schoolId, card.Id) : null;
return card with
{
HasAvatar = hasAvatar,
HasCustom = hasCustom,
HasFullBody = hasFullBody,
CustomPortraitPrompt = customPrompt,
};
}
public async Task<PersonLookupError> EnsurePersonAsync(
@@ -45,6 +54,7 @@ internal sealed class PortraitService(
int schoolId,
string personId,
PortraitKind kind,
string? promptExtra,
CancellationToken cancellationToken)
{
if (!swarm.IsConfigured)
@@ -52,6 +62,20 @@ internal sealed class PortraitService(
return PortraitGenerationResult.NotConfigured;
}
if (kind == PortraitKind.Custom)
{
promptExtra = promptExtra?.Trim();
if (string.IsNullOrWhiteSpace(promptExtra))
{
return PortraitGenerationResult.InvalidPrompt;
}
if (promptExtra.Length > MaxCustomPromptLength)
{
return PortraitGenerationResult.InvalidPrompt;
}
}
var command = new GameCommand.GetPerson(
schoolId,
personId,
@@ -70,14 +94,20 @@ internal sealed class PortraitService(
return PortraitGenerationResult.UnknownSchool;
}
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind);
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, promptExtra);
try
{
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken);
store.SavePortrait(schoolId, personId, kind, bytes);
store.SavePortrait(schoolId, personId, kind, bytes, kind == PortraitKind.Custom ? promptExtra : null);
var flags = Flags(schoolId, personId);
return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasHalfBody, flags.HasFullBody);
var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId);
return PortraitGenerationResult.Succeeded(
kind,
flags.HasAvatar,
flags.HasCustom,
flags.HasFullBody,
savedPrompt);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
@@ -106,6 +136,7 @@ internal enum PortraitGenerationOutcome
UnknownSchool,
UnknownPerson,
NotConfigured,
InvalidPrompt,
Unavailable,
TimedOut,
}
@@ -114,26 +145,42 @@ internal sealed record PortraitGenerationResult(
PortraitGenerationOutcome Outcome,
PortraitKind Kind,
bool HasAvatar,
bool HasHalfBody,
bool HasFullBody)
bool HasCustom,
bool HasFullBody,
string? CustomPortraitPrompt)
{
public static PortraitGenerationResult UnknownSchool { get; } =
new(PortraitGenerationOutcome.UnknownSchool, default, false, false, false);
new(PortraitGenerationOutcome.UnknownSchool, default, false, false, false, null);
public static PortraitGenerationResult UnknownPerson { get; } =
new(PortraitGenerationOutcome.UnknownPerson, default, false, false, false);
new(PortraitGenerationOutcome.UnknownPerson, default, false, false, false, null);
public static PortraitGenerationResult NotConfigured { get; } =
new(PortraitGenerationOutcome.NotConfigured, default, false, false, false);
new(PortraitGenerationOutcome.NotConfigured, default, false, false, false, null);
public static PortraitGenerationResult InvalidPrompt { get; } =
new(PortraitGenerationOutcome.InvalidPrompt, default, false, false, false, null);
public static PortraitGenerationResult Unavailable { get; } =
new(PortraitGenerationOutcome.Unavailable, default, false, false, false);
new(PortraitGenerationOutcome.Unavailable, default, false, false, false, null);
public static PortraitGenerationResult TimedOut { get; } =
new(PortraitGenerationOutcome.TimedOut, default, false, false, false);
new(PortraitGenerationOutcome.TimedOut, default, false, false, false, null);
public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasHalfBody, bool hasFullBody) =>
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasHalfBody, hasFullBody);
public static PortraitGenerationResult Succeeded(
PortraitKind kind,
bool hasAvatar,
bool hasCustom,
bool hasFullBody,
string? customPortraitPrompt) =>
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasCustom, hasFullBody, customPortraitPrompt);
}
internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasHalfBody, bool HasFullBody);
internal sealed record GeneratePortraitRequest(string? PromptExtra);
internal sealed record PortraitResponse(
string Kind,
bool HasAvatar,
bool HasCustom,
bool HasFullBody,
string? CustomPortraitPrompt);
+23 -3
View File
@@ -271,7 +271,7 @@ internal sealed class SchoolStore
public bool HasPortrait(int schoolId, string personId, PortraitKind kind) =>
File.Exists(PortraitPath(schoolId, personId, kind));
public (bool HasAvatar, bool HasHalfBody, bool HasFullBody) PortraitFlags(int schoolId, string personId)
public (bool HasAvatar, bool HasCustom, bool HasFullBody) PortraitFlags(int schoolId, string personId)
{
var directory = PortraitsDirectory(schoolId);
if (!Directory.Exists(directory))
@@ -281,10 +281,22 @@ internal sealed class SchoolStore
return (
HasPortrait(schoolId, personId, PortraitKind.Avatar),
HasPortrait(schoolId, personId, PortraitKind.Half),
HasPortrait(schoolId, personId, PortraitKind.Custom),
HasPortrait(schoolId, personId, PortraitKind.Full));
}
public string CustomPortraitPromptPath(int schoolId, string personId)
{
var safeId = SanitizePersonId(personId);
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.custom.prompt.txt");
}
public string? TryReadCustomPortraitPrompt(int schoolId, string personId)
{
var path = CustomPortraitPromptPath(schoolId, personId);
return File.Exists(path) ? File.ReadAllText(path) : null;
}
public string PortraitPath(int schoolId, string personId, PortraitKind kind)
{
var safeId = SanitizePersonId(personId);
@@ -292,13 +304,21 @@ internal sealed class SchoolStore
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png");
}
public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan<byte> png)
public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan<byte> png, string? customPrompt = null)
{
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);
if (kind == PortraitKind.Custom && customPrompt is not null)
{
var promptPath = CustomPortraitPromptPath(schoolId, personId);
var promptTemp = promptPath + ".tmp";
File.WriteAllText(promptTemp, customPrompt);
File.Move(promptTemp, promptPath, overwrite: true);
}
}
private static string SanitizePersonId(string personId)
+2 -2
View File
@@ -25,14 +25,14 @@ internal sealed class SwarmUiSettings
public SwarmUiPreset Avatar { get; init; } = new();
public SwarmUiPreset HalfBody { 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.Half => HalfBody,
PortraitKind.Custom => Custom,
PortraitKind.Full => FullBody,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
+2 -3
View File
@@ -13,10 +13,9 @@
"height": 1024,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
},
"halfBody": {
"custom": {
"width": 896,
"height": 1152,
"positive": "waist-up portrait, three-quarter view, facing the camera, outfit visible from waist up, school photo."
"height": 1152
},
"fullBody": {
"width": 896,