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:
+12
-7
@@ -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
|
||||
fields. Today's history is a separate GET.
|
||||
|
||||
`hasAvatar`, `hasHalfBody` 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
|
||||
`hasAvatar`, `hasCustom` and `hasFullBody` tell the client whether PNG files already exist on disk for this
|
||||
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.
|
||||
|
||||
```json
|
||||
@@ -318,8 +319,9 @@ not happen on this request.
|
||||
"hasLocker": false,
|
||||
"homeCount": 3,
|
||||
"hasAvatar": false,
|
||||
"hasHalfBody": false,
|
||||
"hasFullBody": false
|
||||
"hasCustom": 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`
|
||||
|
||||
Returns a generated PNG when one exists. Query `kind=avatar|half|full` selects head-and-shoulders,
|
||||
waist-up or full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`;
|
||||
Returns a generated PNG when one exists. Query `kind=avatar|custom|full` selects head-and-shoulders,
|
||||
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
|
||||
is `image/png`. Opening the card does not generate; use POST when the player asks.
|
||||
|
||||
### `POST /api/schools/{id}/people/{personId}/portrait`
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -107,7 +107,12 @@ public class PortraitApiTests(AppHostFixture fixture)
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -43,19 +43,26 @@ public class PortraitPromptBuilderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_HalfBody_AddsHalfBodyPreset()
|
||||
public void Build_Custom_UsesPromptExtraInsteadOfPreset()
|
||||
{
|
||||
var settings = new SwarmUiSettings
|
||||
{
|
||||
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." },
|
||||
};
|
||||
|
||||
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.DoesNotContain("Standing full body.", halfPositive, StringComparison.Ordinal);
|
||||
Assert.Contains("School photo.", customPositive, 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() =>
|
||||
|
||||
Reference in New Issue
Block a user