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
+12 -7
View File
@@ -264,8 +264,9 @@ overload does not slow walking. `hasLocker` is true when the pupil has an assign
is how many items remain at home, not the list. The people list does not include any of these is how many items remain at home, not the list. The people list does not include any of these
fields. Today's history is a separate GET. fields. Today's history is a separate GET.
`hasAvatar`, `hasHalfBody` and `hasFullBody` tell the client whether PNG files already exist on disk for this `hasAvatar`, `hasCustom` and `hasFullBody` tell the client whether PNG files already exist on disk for this
person. They are filled on the HTTP thread after the worker returns the card; generation does person. `customPortraitPrompt` is the last saved user prompt for the custom variant (null when none).
They are filled on the HTTP thread after the worker returns the card; generation does
not happen on this request. not happen on this request.
```json ```json
@@ -318,8 +319,9 @@ not happen on this request.
"hasLocker": false, "hasLocker": false,
"homeCount": 3, "homeCount": 3,
"hasAvatar": false, "hasAvatar": false,
"hasHalfBody": false, "hasCustom": false,
"hasFullBody": false "hasFullBody": false,
"customPortraitPrompt": null
} }
``` ```
@@ -358,15 +360,18 @@ the action or apparel def the caption was built from.
### `GET /api/schools/{id}/people/{personId}/portrait` ### `GET /api/schools/{id}/people/{personId}/portrait`
Returns a generated PNG when one exists. Query `kind=avatar|half|full` selects head-and-shoulders, Returns a generated PNG when one exists. Query `kind=avatar|custom|full` selects head-and-shoulders,
waist-up or full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`; a custom prompt variant or full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`;
missing file is `404` `portrait-missing`. Invalid `kind` is `400` `invalid-query`. Content-Type missing file is `404` `portrait-missing`. Invalid `kind` is `400` `invalid-query`. Content-Type
is `image/png`. Opening the card does not generate; use POST when the player asks. is `image/png`. Opening the card does not generate; use POST when the player asks.
### `POST /api/schools/{id}/people/{personId}/portrait` ### `POST /api/schools/{id}/people/{personId}/portrait`
Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET. Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET.
Success is `201` with `{ "kind", "hasAvatar", "hasHalfBody", "hasFullBody" }` and a `Location` header pointing For `kind=custom` the body is `{ "promptExtra": "..." }` — appended to the base SwarmUI prompt and
the person's body/clothing; required, non-empty, at most 2000 characters. Avatar and full-body POST
need no body.
Success is `201` with `{ "kind", "hasAvatar", "hasCustom", "hasFullBody", "customPortraitPrompt" }` and a `Location` header pointing
at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`. at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`.
Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files
land under `saves/{id}.portraits/` and survive until the school is deleted. land under `saves/{id}.portraits/` and survive until the school is deleted.
+10 -4
View File
@@ -155,11 +155,14 @@ const ru = {
peopleTabOverview: 'Обзор', peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет', peopleTabPortrait: 'Портрет',
peoplePortraitAvatar: 'Аватар', peoplePortraitAvatar: 'Аватар',
peoplePortraitHalf: 'По пояс',
peoplePortraitFull: 'В полный рост', peoplePortraitFull: 'В полный рост',
peoplePortraitCustom: 'Свой промпт',
peoplePortraitCustomHint: 'Дополнение к базовому промпту',
peoplePortraitCustomPlaceholder: 'Например: standing in a school hallway, soft window light',
peoplePortraitGenerateAvatar: 'Сгенерировать аватар', peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
peoplePortraitGenerateHalf: 'Сгенерировать по пояс',
peoplePortraitGenerateFull: 'Сгенерировать в полный рост', peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
peoplePortraitGenerateCustom: 'Сгенерировать',
peoplePortraitRegenerateCustom: 'Перегенерировать',
peoplePortraitGenerating: 'Генерация…', peoplePortraitGenerating: 'Генерация…',
peoplePortraitMissing: 'Ещё не сгенерировано.', peoplePortraitMissing: 'Ещё не сгенерировано.',
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.', peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
@@ -412,11 +415,14 @@ const en: Messages = {
peopleTabOverview: 'Overview', peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait', peopleTabPortrait: 'Portrait',
peoplePortraitAvatar: 'Avatar', peoplePortraitAvatar: 'Avatar',
peoplePortraitHalf: 'Waist up',
peoplePortraitFull: 'Full body', 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', peoplePortraitGenerateAvatar: 'Generate avatar',
peoplePortraitGenerateHalf: 'Generate waist up',
peoplePortraitGenerateFull: 'Generate full body', peoplePortraitGenerateFull: 'Generate full body',
peoplePortraitGenerateCustom: 'Generate',
peoplePortraitRegenerateCustom: 'Regenerate',
peoplePortraitGenerating: 'Generating…', peoplePortraitGenerating: 'Generating…',
peoplePortraitMissing: 'Not generated yet.', peoplePortraitMissing: 'Not generated yet.',
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.', peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
+22 -5
View File
@@ -298,8 +298,9 @@ export interface PersonCard {
readonly hasLocker: boolean; readonly hasLocker: boolean;
readonly homeCount: number; readonly homeCount: number;
readonly hasAvatar: boolean; readonly hasAvatar: boolean;
readonly hasHalfBody: boolean; readonly hasCustom: boolean;
readonly hasFullBody: boolean; readonly hasFullBody: boolean;
readonly customPortraitPrompt: string | null;
} }
export interface WornItem { export interface WornItem {
@@ -401,17 +402,26 @@ export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status'); return request<GameStatus>('/api/status');
} }
export type PortraitKind = 'avatar' | 'half' | 'full'; export type PortraitKind = 'avatar' | 'full' | 'custom';
export interface PortraitResult { export interface PortraitResult {
readonly kind: PortraitKind; readonly kind: PortraitKind;
readonly hasAvatar: boolean; readonly hasAvatar: boolean;
readonly hasHalfBody: boolean; readonly hasCustom: boolean;
readonly hasFullBody: 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 }); const params = new URLSearchParams({ kind });
if (cacheBust !== undefined) {
params.set('v', String(cacheBust));
}
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`; return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
} }
@@ -419,11 +429,18 @@ export async function generatePortrait(
schoolId: number, schoolId: number,
personId: string, personId: string,
kind: PortraitKind, kind: PortraitKind,
promptExtra?: string,
): Promise<PortraitResult> { ): Promise<PortraitResult> {
const params = new URLSearchParams({ kind }); 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>( return request<PortraitResult>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`, `/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--full,
.people__portrait--half { .people__portrait--custom {
width: min(100%, 384px); width: min(100%, 384px);
height: auto; height: auto;
} }
@@ -644,6 +644,13 @@ body {
margin-bottom: 16px; margin-bottom: 16px;
} }
.people__portrait-prompt-input {
width: 100%;
min-height: 72px;
resize: vertical;
margin-bottom: 8px;
}
.people__swarm-status { .people__swarm-status {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 13px; font-size: 13px;
+18
View File
@@ -11,8 +11,12 @@ interface ElementOptions {
hidden?: boolean; hidden?: boolean;
src?: string; src?: string;
alt?: string; alt?: string;
placeholder?: string;
value?: string;
rows?: number;
dataset?: Record<string, string>; dataset?: Record<string, string>;
onClick?: (event: Event) => void; onClick?: (event: Event) => void;
onInput?: (event: Event) => void;
} }
export function el<K extends keyof HTMLElementTagNameMap>( export function el<K extends keyof HTMLElementTagNameMap>(
@@ -36,6 +40,20 @@ export function el<K extends keyof HTMLElementTagNameMap>(
(element as HTMLImageElement).alt = options.alt; (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); if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
for (const [key, value] of Object.entries(options.dataset ?? {})) { for (const [key, value] of Object.entries(options.dataset ?? {})) {
@@ -73,8 +73,9 @@ function personCard(): PersonCard {
hasLocker: false, hasLocker: false,
homeCount: 0, homeCount: 0,
hasAvatar: false, hasAvatar: false,
hasHalfBody: false, hasCustom: false,
hasFullBody: false, hasFullBody: false,
customPortraitPrompt: null,
}; };
} }
+4 -2
View File
@@ -61,8 +61,9 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
hasLocker: false, hasLocker: false,
homeCount: 2, homeCount: 2,
hasAvatar: false, hasAvatar: false,
hasHalfBody: false, hasCustom: false,
hasFullBody: false, hasFullBody: false,
customPortraitPrompt: null,
...overrides, ...overrides,
}; };
} }
@@ -172,8 +173,9 @@ describe('renderPersonCard', () => {
const panel = root.querySelector('.people__portrait-panel'); const panel = root.querySelector('.people__portrait-panel');
expect(panel).not.toBeNull(); expect(panel).not.toBeNull();
expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar')); expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar'));
expect(panel?.textContent).toContain(t('peoplePortraitGenerateHalf'));
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull')); 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(panel?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true); 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 onLogDir?: (dir: PersonLogDir) => void;
readonly onLogPage?: (page: number) => void; readonly onLogPage?: (page: number) => void;
readonly schoolId?: number | null; readonly schoolId?: number | null;
readonly onGeneratePortrait?: (kind: PortraitKind) => void; readonly onGeneratePortrait?: (kind: PortraitKind, promptExtra?: string) => void;
readonly portraitBusy?: PortraitKind | null; readonly portraitBusy?: PortraitKind | null;
readonly portraitError?: string | null; readonly portraitError?: string | null;
readonly customPrompt?: string;
readonly onCustomPromptChange?: (value: string) => void;
readonly customPortraitRevision?: number;
readonly swarmConfigured?: boolean; readonly swarmConfigured?: boolean;
/** null while checking or when SwarmUI is not configured. */ /** null while checking or when SwarmUI is not configured. */
readonly swarmConnected?: boolean | null; 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) { if (options.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
} else if (options.swarmConfigured === true && options.swarmConnected === false) { } else if (options.swarmConfigured === true && options.swarmConnected === false) {
@@ -370,7 +415,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
} }
const PORTRAIT_VARIANTS: readonly { const PORTRAIT_VARIANTS: readonly {
readonly kind: PortraitKind; readonly kind: Extract<PortraitKind, 'avatar' | 'full'>;
readonly titleKey: MessageKey; readonly titleKey: MessageKey;
readonly generateKey: MessageKey; readonly generateKey: MessageKey;
readonly cssClass: string; readonly cssClass: string;
@@ -383,13 +428,6 @@ const PORTRAIT_VARIANTS: readonly {
cssClass: 'people__portrait--avatar', cssClass: 'people__portrait--avatar',
hasImage: (card) => card.hasAvatar, hasImage: (card) => card.hasAvatar,
}, },
{
kind: 'half',
titleKey: 'peoplePortraitHalf',
generateKey: 'peoplePortraitGenerateHalf',
cssClass: 'people__portrait--half',
hasImage: (card) => card.hasHalfBody,
},
{ {
kind: 'full', kind: 'full',
titleKey: 'peoplePortraitFull', titleKey: 'peoplePortraitFull',
@@ -439,6 +477,18 @@ function portraitPreview(
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') }); 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 { function cardMeta(card: PersonCard): string {
const bits = [ const bits = [
roleLabels(card.roles), roleLabels(card.roles),
+29 -4
View File
@@ -25,6 +25,9 @@ export class PersonCardHost {
private tab: PersonCardTab = 'overview'; private tab: PersonCardTab = 'overview';
private portraitBusy: PortraitKind | null = null; private portraitBusy: PortraitKind | null = null;
private portraitError: string | null = null; private portraitError: string | null = null;
private customPromptDraft = '';
private customPortraitRevision = 0;
private paintedId: string | null = null;
private swarmConfigured = false; private swarmConfigured = false;
private swarmConnected: boolean | null = null; private swarmConnected: boolean | null = null;
private schoolId: number | null = null; private schoolId: number | null = null;
@@ -63,6 +66,9 @@ export class PersonCardHost {
this.resetTabs(); this.resetTabs();
this.portraitBusy = null; this.portraitBusy = null;
this.portraitError = null; this.portraitError = null;
this.customPromptDraft = '';
this.customPortraitRevision = 0;
this.paintedId = null;
this.swarmConnected = null; this.swarmConnected = null;
} }
@@ -87,9 +93,16 @@ export class PersonCardHost {
this.painted = card; this.painted = card;
if (card === null) { if (card === null) {
this.paintedId = null;
return; return;
} }
if (card.id !== this.paintedId) {
this.paintedId = card.id;
this.customPromptDraft = card.customPortraitPrompt ?? '';
this.customPortraitRevision = 0;
}
this.repaint(container, card); this.repaint(container, card);
} }
@@ -131,9 +144,15 @@ export class PersonCardHost {
void this.loadLog(); void this.loadLog();
}, },
schoolId: this.schoolId, schoolId: this.schoolId,
onGeneratePortrait: (kind) => void this.generate(kind), onGeneratePortrait: (kind, promptExtra) => void this.generate(kind, promptExtra),
portraitBusy: this.portraitBusy, portraitBusy: this.portraitBusy,
portraitError: this.portraitError, portraitError: this.portraitError,
customPrompt: this.customPromptDraft,
onCustomPromptChange: (value) => {
this.customPromptDraft = value;
this.refreshPainted();
},
customPortraitRevision: this.customPortraitRevision,
swarmConfigured: this.swarmConfigured, swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected, 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 schoolId = this.schoolId;
const personId = this.painted?.id; const personId = this.painted?.id;
if (schoolId === null || personId === undefined || this.portraitBusy !== null) { if (schoolId === null || personId === undefined || this.portraitBusy !== null) {
@@ -181,13 +200,19 @@ export class PersonCardHost {
this.refreshPainted(); this.refreshPainted();
try { try {
const result = await generatePortrait(schoolId, personId, kind); const result = await generatePortrait(schoolId, personId, kind, promptExtra);
const card = await fetchPerson(schoolId, personId, getLocale()); const card = await fetchPerson(schoolId, personId, getLocale());
if (kind === 'custom') {
this.customPortraitRevision = Date.now();
this.customPromptDraft = result.customPortraitPrompt ?? promptExtra ?? '';
}
this.painted = { this.painted = {
...card, ...card,
hasAvatar: result.hasAvatar, hasAvatar: result.hasAvatar,
hasHalfBody: result.hasHalfBody, hasCustom: result.hasCustom,
hasFullBody: result.hasFullBody, hasFullBody: result.hasFullBody,
customPortraitPrompt: result.customPortraitPrompt,
}; };
} catch (error) { } catch (error) {
this.portraitError = this.portraitError =
+3 -2
View File
@@ -79,8 +79,9 @@ internal sealed record PersonCardResponse(
bool HasLocker = false, bool HasLocker = false,
int HomeCount = 0, int HomeCount = 0,
bool HasAvatar = false, bool HasAvatar = false,
bool HasHalfBody = false, bool HasCustom = false,
bool HasFullBody = false); bool HasFullBody = false,
string? CustomPortraitPrompt = null);
internal sealed record WornItemResponse( internal sealed record WornItemResponse(
string DefName, string DefName,
+9 -5
View File
@@ -246,7 +246,7 @@ internal static class SchoolEndpoints
if (!PortraitKindParser.TryParse(kind, out var portraitKind)) 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); var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
@@ -274,6 +274,7 @@ internal static class SchoolEndpoints
int id, int id,
string personId, string personId,
string? kind, string? kind,
GeneratePortraitRequest? body,
PortraitService portraits, PortraitService portraits,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
@@ -284,7 +285,7 @@ internal static class SchoolEndpoints
if (!PortraitKindParser.TryParse(kind, out var portraitKind)) 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) if (!portraits.IsGenerationEnabled)
@@ -292,7 +293,7 @@ internal static class SchoolEndpoints
return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured."); 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 return result.Outcome switch
{ {
PortraitGenerationOutcome.Succeeded => Results.Created( PortraitGenerationOutcome.Succeeded => Results.Created(
@@ -300,8 +301,11 @@ internal static class SchoolEndpoints
new PortraitResponse( new PortraitResponse(
PortraitKindParser.ToApiValue(portraitKind), PortraitKindParser.ToApiValue(portraitKind),
result.HasAvatar, result.HasAvatar,
result.HasHalfBody, result.HasCustom,
result.HasFullBody)), result.HasFullBody,
result.CustomPortraitPrompt)),
PortraitGenerationOutcome.InvalidPrompt =>
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
PortraitGenerationOutcome.UnknownPerson => PortraitGenerationOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."), Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
PortraitGenerationOutcome.NotConfigured => PortraitGenerationOutcome.NotConfigured =>
+4 -4
View File
@@ -3,7 +3,7 @@ namespace HSchool.Server.Game;
internal enum PortraitKind internal enum PortraitKind
{ {
Avatar, Avatar,
Half, Custom,
Full, Full,
} }
@@ -17,9 +17,9 @@ internal static class PortraitKindParser
return true; return true;
} }
if (string.Equals(value, "half", StringComparison.OrdinalIgnoreCase)) if (string.Equals(value, "custom", StringComparison.OrdinalIgnoreCase))
{ {
kind = PortraitKind.Half; kind = PortraitKind.Custom;
return true; return true;
} }
@@ -36,7 +36,7 @@ internal static class PortraitKindParser
public static string ToApiValue(PortraitKind kind) => kind switch public static string ToApiValue(PortraitKind kind) => kind switch
{ {
PortraitKind.Avatar => "avatar", PortraitKind.Avatar => "avatar",
PortraitKind.Half => "half", PortraitKind.Custom => "custom",
PortraitKind.Full => "full", PortraitKind.Full => "full",
_ => throw new ArgumentOutOfRangeException(nameof(kind)), _ => 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> /// <summary>Turns a person card into a Flux-style English prompt for SwarmUI.</summary>
internal static class PortraitPromptBuilder 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 preset = settings.PresetFor(kind);
var parts = new List<string>(); var parts = new List<string>();
@@ -15,7 +19,14 @@ internal static class PortraitPromptBuilder
parts.Add(settings.Positive.Trim()); 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()); parts.Add(preset.Positive.Trim());
} }
+63 -16
View File
@@ -9,6 +9,8 @@ internal sealed class PortraitService(
GameCommandQueue commands, GameCommandQueue commands,
ILogger<PortraitService> logger) ILogger<PortraitService> logger)
{ {
public const int MaxCustomPromptLength = 2000;
private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5);
/// <summary>English labels for Swarm prompts, independent of the UI locale.</summary> /// <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 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); store.PortraitFlags(schoolId, personId);
public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card) public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card)
{ {
var (hasAvatar, hasHalfBody, hasFullBody) = Flags(schoolId, card.Id); var (hasAvatar, hasCustom, hasFullBody) = Flags(schoolId, card.Id);
return card with { HasAvatar = hasAvatar, HasHalfBody = hasHalfBody, HasFullBody = hasFullBody }; 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( public async Task<PersonLookupError> EnsurePersonAsync(
@@ -45,6 +54,7 @@ internal sealed class PortraitService(
int schoolId, int schoolId,
string personId, string personId,
PortraitKind kind, PortraitKind kind,
string? promptExtra,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!swarm.IsConfigured) if (!swarm.IsConfigured)
@@ -52,6 +62,20 @@ internal sealed class PortraitService(
return PortraitGenerationResult.NotConfigured; 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( var command = new GameCommand.GetPerson(
schoolId, schoolId,
personId, personId,
@@ -70,14 +94,20 @@ internal sealed class PortraitService(
return PortraitGenerationResult.UnknownSchool; return PortraitGenerationResult.UnknownSchool;
} }
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind); var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, promptExtra);
try try
{ {
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken); 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); 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) catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{ {
@@ -106,6 +136,7 @@ internal enum PortraitGenerationOutcome
UnknownSchool, UnknownSchool,
UnknownPerson, UnknownPerson,
NotConfigured, NotConfigured,
InvalidPrompt,
Unavailable, Unavailable,
TimedOut, TimedOut,
} }
@@ -114,26 +145,42 @@ internal sealed record PortraitGenerationResult(
PortraitGenerationOutcome Outcome, PortraitGenerationOutcome Outcome,
PortraitKind Kind, PortraitKind Kind,
bool HasAvatar, bool HasAvatar,
bool HasHalfBody, bool HasCustom,
bool HasFullBody) bool HasFullBody,
string? CustomPortraitPrompt)
{ {
public static PortraitGenerationResult UnknownSchool { get; } = 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; } = 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; } = 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; } = 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; } = 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) => public static PortraitGenerationResult Succeeded(
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasHalfBody, hasFullBody); 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) => public bool HasPortrait(int schoolId, string personId, PortraitKind kind) =>
File.Exists(PortraitPath(schoolId, personId, 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); var directory = PortraitsDirectory(schoolId);
if (!Directory.Exists(directory)) if (!Directory.Exists(directory))
@@ -281,10 +281,22 @@ internal sealed class SchoolStore
return ( return (
HasPortrait(schoolId, personId, PortraitKind.Avatar), HasPortrait(schoolId, personId, PortraitKind.Avatar),
HasPortrait(schoolId, personId, PortraitKind.Half), HasPortrait(schoolId, personId, PortraitKind.Custom),
HasPortrait(schoolId, personId, PortraitKind.Full)); 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) public string PortraitPath(int schoolId, string personId, PortraitKind kind)
{ {
var safeId = SanitizePersonId(personId); var safeId = SanitizePersonId(personId);
@@ -292,13 +304,21 @@ internal sealed class SchoolStore
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png"); 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); var path = PortraitPath(schoolId, personId, kind);
Directory.CreateDirectory(Path.GetDirectoryName(path)!); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temp = path + ".tmp"; var temp = path + ".tmp";
File.WriteAllBytes(temp, png); File.WriteAllBytes(temp, png);
File.Move(temp, path, overwrite: true); 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) 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 Avatar { get; init; } = new();
public SwarmUiPreset HalfBody { get; init; } = new(); public SwarmUiPreset Custom { get; init; } = new();
public SwarmUiPreset FullBody { get; init; } = new(); public SwarmUiPreset FullBody { get; init; } = new();
public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch
{ {
PortraitKind.Avatar => Avatar, PortraitKind.Avatar => Avatar,
PortraitKind.Half => HalfBody, PortraitKind.Custom => Custom,
PortraitKind.Full => FullBody, PortraitKind.Full => FullBody,
_ => throw new ArgumentOutOfRangeException(nameof(kind)), _ => throw new ArgumentOutOfRangeException(nameof(kind)),
}; };
+2 -3
View File
@@ -13,10 +13,9 @@
"height": 1024, "height": 1024,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible." "positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
}, },
"halfBody": { "custom": {
"width": 896, "width": 896,
"height": 1152, "height": 1152
"positive": "waist-up portrait, three-quarter view, facing the camera, outfit visible from waist up, school photo."
}, },
"fullBody": { "fullBody": {
"width": 896, "width": 896,
@@ -107,7 +107,12 @@ public class PortraitApiTests(AppHostFixture fixture)
private sealed record PersonListItem(string Id); private sealed record PersonListItem(string Id);
private sealed record PersonCardResponse(string Id, bool HasAvatar, bool HasHalfBody, bool HasFullBody); private sealed record PersonCardResponse(
string Id,
bool HasAvatar,
bool HasCustom,
bool HasFullBody,
string? CustomPortraitPrompt);
private sealed record SavesDirectoryResponse(string Path); private sealed record SavesDirectoryResponse(string Path);
} }
@@ -43,19 +43,26 @@ public class PortraitPromptBuilderTests
} }
[Fact] [Fact]
public void Build_HalfBody_AddsHalfBodyPreset() public void Build_Custom_UsesPromptExtraInsteadOfPreset()
{ {
var settings = new SwarmUiSettings var settings = new SwarmUiSettings
{ {
Positive = "School photo.", Positive = "School photo.",
HalfBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Waist-up portrait." }, Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." }, FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
}; };
var (halfPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Half); const string extra = "standing in a school hallway, soft window light";
var (customPositive, _) = PortraitPromptBuilder.Build(
SampleCard(),
settings,
PortraitKind.Custom,
extra);
Assert.Contains("Waist-up portrait.", halfPositive, StringComparison.Ordinal); Assert.Contains("School photo.", customPositive, StringComparison.Ordinal);
Assert.DoesNotContain("Standing full body.", halfPositive, StringComparison.Ordinal); Assert.Contains(extra, customPositive, StringComparison.Ordinal);
Assert.DoesNotContain("Head and shoulders.", customPositive, StringComparison.Ordinal);
Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal);
} }
private static PersonCardResponse SampleCard() => private static PersonCardResponse SampleCard() =>