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
+10 -4
View File
@@ -155,11 +155,14 @@ const ru = {
peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет',
peoplePortraitAvatar: 'Аватар',
peoplePortraitHalf: 'По пояс',
peoplePortraitFull: 'В полный рост',
peoplePortraitCustom: 'Свой промпт',
peoplePortraitCustomHint: 'Дополнение к базовому промпту',
peoplePortraitCustomPlaceholder: 'Например: standing in a school hallway, soft window light',
peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
peoplePortraitGenerateHalf: 'Сгенерировать по пояс',
peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
peoplePortraitGenerateCustom: 'Сгенерировать',
peoplePortraitRegenerateCustom: 'Перегенерировать',
peoplePortraitGenerating: 'Генерация…',
peoplePortraitMissing: 'Ещё не сгенерировано.',
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
@@ -412,11 +415,14 @@ const en: Messages = {
peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait',
peoplePortraitAvatar: 'Avatar',
peoplePortraitHalf: 'Waist up',
peoplePortraitFull: 'Full body',
peoplePortraitCustom: 'Custom prompt',
peoplePortraitCustomHint: 'Added to the base prompt',
peoplePortraitCustomPlaceholder: 'For example: standing in a school hallway, soft window light',
peoplePortraitGenerateAvatar: 'Generate avatar',
peoplePortraitGenerateHalf: 'Generate waist up',
peoplePortraitGenerateFull: 'Generate full body',
peoplePortraitGenerateCustom: 'Generate',
peoplePortraitRegenerateCustom: 'Regenerate',
peoplePortraitGenerating: 'Generating…',
peoplePortraitMissing: 'Not generated yet.',
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
+22 -5
View File
@@ -298,8 +298,9 @@ export interface PersonCard {
readonly hasLocker: boolean;
readonly homeCount: number;
readonly hasAvatar: boolean;
readonly hasHalfBody: boolean;
readonly hasCustom: boolean;
readonly hasFullBody: boolean;
readonly customPortraitPrompt: string | null;
}
export interface WornItem {
@@ -401,17 +402,26 @@ export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status');
}
export type PortraitKind = 'avatar' | 'half' | 'full';
export type PortraitKind = 'avatar' | 'full' | 'custom';
export interface PortraitResult {
readonly kind: PortraitKind;
readonly hasAvatar: boolean;
readonly hasHalfBody: boolean;
readonly hasCustom: boolean;
readonly hasFullBody: boolean;
readonly customPortraitPrompt: string | null;
}
export function portraitUrl(schoolId: number, personId: string, kind: PortraitKind): string {
export function portraitUrl(
schoolId: number,
personId: string,
kind: PortraitKind,
cacheBust?: number,
): string {
const params = new URLSearchParams({ kind });
if (cacheBust !== undefined) {
params.set('v', String(cacheBust));
}
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
}
@@ -419,11 +429,18 @@ export async function generatePortrait(
schoolId: number,
personId: string,
kind: PortraitKind,
promptExtra?: string,
): Promise<PortraitResult> {
const params = new URLSearchParams({ kind });
const init: RequestInit = { method: 'POST' };
if (kind === 'custom') {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify({ promptExtra: promptExtra ?? '' });
}
return request<PortraitResult>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`,
{ method: 'POST' },
init,
);
}
+8 -1
View File
@@ -635,7 +635,7 @@ body {
}
.people__portrait--full,
.people__portrait--half {
.people__portrait--custom {
width: min(100%, 384px);
height: auto;
}
@@ -644,6 +644,13 @@ body {
margin-bottom: 16px;
}
.people__portrait-prompt-input {
width: 100%;
min-height: 72px;
resize: vertical;
margin-bottom: 8px;
}
.people__swarm-status {
margin: 0 0 12px;
font-size: 13px;
+18
View File
@@ -11,8 +11,12 @@ interface ElementOptions {
hidden?: boolean;
src?: string;
alt?: string;
placeholder?: string;
value?: string;
rows?: number;
dataset?: Record<string, string>;
onClick?: (event: Event) => void;
onInput?: (event: Event) => void;
}
export function el<K extends keyof HTMLElementTagNameMap>(
@@ -36,6 +40,20 @@ export function el<K extends keyof HTMLElementTagNameMap>(
(element as HTMLImageElement).alt = options.alt;
}
if (options.placeholder !== undefined && 'placeholder' in element) {
(element as HTMLInputElement | HTMLTextAreaElement).placeholder = options.placeholder;
}
if (options.value !== undefined && 'value' in element) {
(element as HTMLInputElement | HTMLTextAreaElement).value = options.value;
}
if (options.rows !== undefined && element instanceof HTMLTextAreaElement) {
element.rows = options.rows;
}
if (options.onInput !== undefined) element.addEventListener('input', options.onInput);
if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
for (const [key, value] of Object.entries(options.dataset ?? {})) {
@@ -73,8 +73,9 @@ function personCard(): PersonCard {
hasLocker: false,
homeCount: 0,
hasAvatar: false,
hasHalfBody: false,
hasCustom: false,
hasFullBody: false,
customPortraitPrompt: null,
};
}
+4 -2
View File
@@ -61,8 +61,9 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
hasLocker: false,
homeCount: 2,
hasAvatar: false,
hasHalfBody: false,
hasCustom: false,
hasFullBody: false,
customPortraitPrompt: null,
...overrides,
};
}
@@ -172,8 +173,9 @@ describe('renderPersonCard', () => {
const panel = root.querySelector('.people__portrait-panel');
expect(panel).not.toBeNull();
expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar'));
expect(panel?.textContent).toContain(t('peoplePortraitGenerateHalf'));
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull'));
expect(panel?.textContent).toContain(t('peoplePortraitCustomHint'));
expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull();
expect(panel?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
});
+59 -9
View File
@@ -31,9 +31,12 @@ export interface RenderPersonCardOptions {
readonly onLogDir?: (dir: PersonLogDir) => void;
readonly onLogPage?: (page: number) => void;
readonly schoolId?: number | null;
readonly onGeneratePortrait?: (kind: PortraitKind) => void;
readonly onGeneratePortrait?: (kind: PortraitKind, promptExtra?: string) => void;
readonly portraitBusy?: PortraitKind | null;
readonly portraitError?: string | null;
readonly customPrompt?: string;
readonly onCustomPromptChange?: (value: string) => void;
readonly customPortraitRevision?: number;
readonly swarmConfigured?: boolean;
/** null while checking or when SwarmUI is not configured. */
readonly swarmConnected?: boolean | null;
@@ -357,6 +360,48 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
);
}
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') }));
parent.append(
el(
'label',
{ class: 'people__field' },
el('span', { class: 'people__label', text: t('peoplePortraitCustomHint') }),
el('textarea', {
class: 'input people__portrait-prompt-input',
rows: 3,
placeholder: t('peoplePortraitCustomPlaceholder'),
value: options.customPrompt ?? card.customPortraitPrompt ?? '',
disabled: (options.portraitBusy ?? null) !== null,
onInput: (event) => {
const target = event.target;
if (target instanceof HTMLTextAreaElement) {
options.onCustomPromptChange?.(target.value);
}
},
}),
),
);
parent.append(portraitCustomPreview(card, options));
const customPrompt = (options.customPrompt ?? card.customPortraitPrompt ?? '').trim();
parent.append(
el('button', {
class: 'button button--small',
type: 'button',
text:
options.portraitBusy === 'custom'
? t('peoplePortraitGenerating')
: card.hasCustom
? t('peoplePortraitRegenerateCustom')
: t('peoplePortraitGenerateCustom'),
disabled:
(options.portraitBusy ?? null) !== null ||
!portraitGenerateEnabled(options) ||
customPrompt.length === 0,
onClick: () => options.onGeneratePortrait?.('custom', customPrompt),
}),
);
if (options.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
} else if (options.swarmConfigured === true && options.swarmConnected === false) {
@@ -370,7 +415,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
}
const PORTRAIT_VARIANTS: readonly {
readonly kind: PortraitKind;
readonly kind: Extract<PortraitKind, 'avatar' | 'full'>;
readonly titleKey: MessageKey;
readonly generateKey: MessageKey;
readonly cssClass: string;
@@ -383,13 +428,6 @@ const PORTRAIT_VARIANTS: readonly {
cssClass: 'people__portrait--avatar',
hasImage: (card) => card.hasAvatar,
},
{
kind: 'half',
titleKey: 'peoplePortraitHalf',
generateKey: 'peoplePortraitGenerateHalf',
cssClass: 'people__portrait--half',
hasImage: (card) => card.hasHalfBody,
},
{
kind: 'full',
titleKey: 'peoplePortraitFull',
@@ -439,6 +477,18 @@ function portraitPreview(
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
}
function portraitCustomPreview(card: PersonCard, options: RenderPersonCardOptions): HTMLElement {
if (card.hasCustom && options.schoolId !== null && options.schoolId !== undefined) {
return el('img', {
class: 'people__portrait people__portrait--custom',
alt: card.fullName,
src: portraitUrl(options.schoolId, card.id, 'custom', options.customPortraitRevision),
});
}
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
}
function cardMeta(card: PersonCard): string {
const bits = [
roleLabels(card.roles),
+29 -4
View File
@@ -25,6 +25,9 @@ export class PersonCardHost {
private tab: PersonCardTab = 'overview';
private portraitBusy: PortraitKind | null = null;
private portraitError: string | null = null;
private customPromptDraft = '';
private customPortraitRevision = 0;
private paintedId: string | null = null;
private swarmConfigured = false;
private swarmConnected: boolean | null = null;
private schoolId: number | null = null;
@@ -63,6 +66,9 @@ export class PersonCardHost {
this.resetTabs();
this.portraitBusy = null;
this.portraitError = null;
this.customPromptDraft = '';
this.customPortraitRevision = 0;
this.paintedId = null;
this.swarmConnected = null;
}
@@ -87,9 +93,16 @@ export class PersonCardHost {
this.painted = card;
if (card === null) {
this.paintedId = null;
return;
}
if (card.id !== this.paintedId) {
this.paintedId = card.id;
this.customPromptDraft = card.customPortraitPrompt ?? '';
this.customPortraitRevision = 0;
}
this.repaint(container, card);
}
@@ -131,9 +144,15 @@ export class PersonCardHost {
void this.loadLog();
},
schoolId: this.schoolId,
onGeneratePortrait: (kind) => void this.generate(kind),
onGeneratePortrait: (kind, promptExtra) => void this.generate(kind, promptExtra),
portraitBusy: this.portraitBusy,
portraitError: this.portraitError,
customPrompt: this.customPromptDraft,
onCustomPromptChange: (value) => {
this.customPromptDraft = value;
this.refreshPainted();
},
customPortraitRevision: this.customPortraitRevision,
swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected,
});
@@ -169,7 +188,7 @@ export class PersonCardHost {
}
}
private async generate(kind: PortraitKind): Promise<void> {
private async generate(kind: PortraitKind, promptExtra?: string): Promise<void> {
const schoolId = this.schoolId;
const personId = this.painted?.id;
if (schoolId === null || personId === undefined || this.portraitBusy !== null) {
@@ -181,13 +200,19 @@ export class PersonCardHost {
this.refreshPainted();
try {
const result = await generatePortrait(schoolId, personId, kind);
const result = await generatePortrait(schoolId, personId, kind, promptExtra);
const card = await fetchPerson(schoolId, personId, getLocale());
if (kind === 'custom') {
this.customPortraitRevision = Date.now();
this.customPromptDraft = result.customPortraitPrompt ?? promptExtra ?? '';
}
this.painted = {
...card,
hasAvatar: result.hasAvatar,
hasHalfBody: result.hasHalfBody,
hasCustom: result.hasCustom,
hasFullBody: result.hasFullBody,
customPortraitPrompt: result.customPortraitPrompt,
};
} catch (error) {
this.portraitError =
+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,