Add half-body portrait support: update API, UI, and localization for new portrait type
ci / server (push) Failing after 3m40s
ci / client (push) Failing after 14s

This commit is contained in:
Leonid Pershin
2026-08-20 05:10:51 +03:00
parent 9e11c8c693
commit df0aaad403
19 changed files with 155 additions and 64 deletions
+15
View File
@@ -3,6 +3,7 @@ namespace HSchool.Server.Game;
internal enum PortraitKind
{
Avatar,
Half,
Full,
}
@@ -16,6 +17,12 @@ internal static class PortraitKindParser
return true;
}
if (string.Equals(value, "half", StringComparison.OrdinalIgnoreCase))
{
kind = PortraitKind.Half;
return true;
}
if (string.Equals(value, "full", StringComparison.OrdinalIgnoreCase))
{
kind = PortraitKind.Full;
@@ -25,4 +32,12 @@ internal static class PortraitKindParser
kind = default;
return false;
}
public static string ToApiValue(PortraitKind kind) => kind switch
{
PortraitKind.Avatar => "avatar",
PortraitKind.Half => "half",
PortraitKind.Full => "full",
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
}
@@ -7,7 +7,7 @@ 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 preset = settings.PresetFor(kind);
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(settings.Positive))
+13 -12
View File
@@ -16,13 +16,13 @@ internal sealed class PortraitService(
public bool IsGenerationEnabled => swarm.IsConfigured;
public (bool HasAvatar, bool HasFullBody) Flags(int schoolId, string personId) =>
public (bool HasAvatar, bool HasHalfBody, 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 };
var (hasAvatar, hasHalfBody, hasFullBody) = Flags(schoolId, card.Id);
return card with { HasAvatar = hasAvatar, HasHalfBody = hasHalfBody, HasFullBody = hasFullBody };
}
public async Task<PersonLookupError> EnsurePersonAsync(
@@ -77,7 +77,7 @@ internal sealed class PortraitService(
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);
return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasHalfBody, flags.HasFullBody);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
@@ -114,25 +114,26 @@ internal sealed record PortraitGenerationResult(
PortraitGenerationOutcome Outcome,
PortraitKind Kind,
bool HasAvatar,
bool HasHalfBody,
bool HasFullBody)
{
public static PortraitGenerationResult UnknownSchool { get; } =
new(PortraitGenerationOutcome.UnknownSchool, default, false, false);
new(PortraitGenerationOutcome.UnknownSchool, default, false, false, false);
public static PortraitGenerationResult UnknownPerson { get; } =
new(PortraitGenerationOutcome.UnknownPerson, default, false, false);
new(PortraitGenerationOutcome.UnknownPerson, default, false, false, false);
public static PortraitGenerationResult NotConfigured { get; } =
new(PortraitGenerationOutcome.NotConfigured, default, false, false);
new(PortraitGenerationOutcome.NotConfigured, default, false, false, false);
public static PortraitGenerationResult Unavailable { get; } =
new(PortraitGenerationOutcome.Unavailable, default, false, false);
new(PortraitGenerationOutcome.Unavailable, default, false, false, false);
public static PortraitGenerationResult TimedOut { get; } =
new(PortraitGenerationOutcome.TimedOut, default, false, false);
new(PortraitGenerationOutcome.TimedOut, default, false, false, false);
public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasFullBody) =>
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasFullBody);
public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasHalfBody, bool hasFullBody) =>
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasHalfBody, hasFullBody);
}
internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasFullBody);
internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasHalfBody, bool HasFullBody);
+7 -4
View File
@@ -271,21 +271,24 @@ internal sealed class SchoolStore
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)
public (bool HasAvatar, bool HasHalfBody, bool HasFullBody) PortraitFlags(int schoolId, string personId)
{
var directory = PortraitsDirectory(schoolId);
if (!Directory.Exists(directory))
{
return (false, false);
return (false, false, false);
}
return (HasPortrait(schoolId, personId, PortraitKind.Avatar), HasPortrait(schoolId, personId, PortraitKind.Full));
return (
HasPortrait(schoolId, personId, PortraitKind.Avatar),
HasPortrait(schoolId, personId, PortraitKind.Half),
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";
var suffix = PortraitKindParser.ToApiValue(kind);
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png");
}
+1 -1
View File
@@ -69,7 +69,7 @@ internal sealed class SwarmUiClient
return await RunWithSessionAsync(async () =>
{
var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody;
var preset = settings.PresetFor(kind);
var body = new Dictionary<string, object?>
{
["session_id"] = _sessionId,
@@ -25,8 +25,18 @@ internal sealed class SwarmUiSettings
public SwarmUiPreset Avatar { get; init; } = new();
public SwarmUiPreset HalfBody { get; init; } = new();
public SwarmUiPreset FullBody { get; init; } = new();
public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch
{
PortraitKind.Avatar => Avatar,
PortraitKind.Half => HalfBody,
PortraitKind.Full => FullBody,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
internal sealed class SwarmUiPreset
{
public int Width { get; init; } = 512;