Add half-body portrait support: update API, UI, and localization for new portrait type
This commit is contained in:
@@ -79,6 +79,7 @@ internal sealed record PersonCardResponse(
|
||||
bool HasLocker = false,
|
||||
int HomeCount = 0,
|
||||
bool HasAvatar = false,
|
||||
bool HasHalfBody = false,
|
||||
bool HasFullBody = false);
|
||||
|
||||
internal sealed record WornItemResponse(
|
||||
|
||||
@@ -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 or full.");
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, half, or full.");
|
||||
}
|
||||
|
||||
var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
|
||||
@@ -284,7 +284,7 @@ internal static class SchoolEndpoints
|
||||
|
||||
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full.");
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, half, or full.");
|
||||
}
|
||||
|
||||
if (!portraits.IsGenerationEnabled)
|
||||
@@ -296,10 +296,11 @@ internal static class SchoolEndpoints
|
||||
return result.Outcome switch
|
||||
{
|
||||
PortraitGenerationOutcome.Succeeded => Results.Created(
|
||||
$"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={(portraitKind == PortraitKind.Avatar ? "avatar" : "full")}",
|
||||
$"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={PortraitKindParser.ToApiValue(portraitKind)}",
|
||||
new PortraitResponse(
|
||||
portraitKind == PortraitKind.Avatar ? "avatar" : "full",
|
||||
PortraitKindParser.ToApiValue(portraitKind),
|
||||
result.HasAvatar,
|
||||
result.HasHalfBody,
|
||||
result.HasFullBody)),
|
||||
PortraitGenerationOutcome.UnknownPerson =>
|
||||
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
"height": 1024,
|
||||
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
|
||||
},
|
||||
"halfBody": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": "waist-up portrait, three-quarter view, facing the camera, outfit visible from waist up, school photo."
|
||||
},
|
||||
"fullBody": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
|
||||
Reference in New Issue
Block a user