Implement custom portrait generation: update API, UI, and localization to support user-defined prompts for portraits, replacing half-body option with custom variant.
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user