From ac0470bbc472f50ca21faff3628517174d7cd062 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 05:16:40 +0300 Subject: [PATCH] Implement custom portrait generation: update API, UI, and localization to support user-defined prompts for portraits, replacing half-body option with custom variant. --- docs/protocol.md | 19 +++-- src/HSchool.Client/src/i18n/strings.ts | 14 +++- src/HSchool.Client/src/net/api.ts | 27 +++++-- src/HSchool.Client/src/style.css | 9 ++- src/HSchool.Client/src/ui/dom.ts | 18 +++++ .../src/ui/managementPanel.test.ts | 3 +- src/HSchool.Client/src/ui/personCard.test.ts | 6 +- src/HSchool.Client/src/ui/personCard.ts | 68 +++++++++++++--- src/HSchool.Client/src/ui/personCardHost.ts | 33 +++++++- src/HSchool.Server/Api/PeopleModels.cs | 5 +- src/HSchool.Server/Api/SchoolEndpoints.cs | 14 ++-- src/HSchool.Server/Game/PortraitKind.cs | 8 +- .../Game/PortraitPromptBuilder.cs | 15 +++- src/HSchool.Server/Game/PortraitService.cs | 79 +++++++++++++++---- src/HSchool.Server/Game/SchoolStore.cs | 26 +++++- src/HSchool.Server/Game/SwarmUiSettings.cs | 4 +- src/HSchool.Server/swarmui.json | 5 +- .../HSchool.AppHost.Tests/PortraitApiTests.cs | 7 +- .../PortraitPromptBuilderTests.cs | 17 ++-- 19 files changed, 301 insertions(+), 76 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index c2fcf70..7d05e0e 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 6920d77..98ce39a 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -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.', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 81f28e6..bd7aec8 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -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 { return request('/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 { 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( `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`, - { method: 'POST' }, + init, ); } diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 2c19207..d795d7a 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -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; diff --git a/src/HSchool.Client/src/ui/dom.ts b/src/HSchool.Client/src/ui/dom.ts index db7b5ae..2f5622d 100644 --- a/src/HSchool.Client/src/ui/dom.ts +++ b/src/HSchool.Client/src/ui/dom.ts @@ -11,8 +11,12 @@ interface ElementOptions { hidden?: boolean; src?: string; alt?: string; + placeholder?: string; + value?: string; + rows?: number; dataset?: Record; onClick?: (event: Event) => void; + onInput?: (event: Event) => void; } export function el( @@ -36,6 +40,20 @@ export function el( (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 ?? {})) { diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index 40af3f7..b7760b2 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -73,8 +73,9 @@ function personCard(): PersonCard { hasLocker: false, homeCount: 0, hasAvatar: false, - hasHalfBody: false, + hasCustom: false, hasFullBody: false, + customPortraitPrompt: null, }; } diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 312e4d8..4218bd1 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -61,8 +61,9 @@ function card(overrides: Partial = {}): 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); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index d57ec77..5c4b65f 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -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; 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), diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts index d4cd252..26cdd0d 100644 --- a/src/HSchool.Client/src/ui/personCardHost.ts +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -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 { + private async generate(kind: PortraitKind, promptExtra?: string): Promise { 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 = diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 5adb761..afaa4e8 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -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, diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 9f86bca..98e5d91 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -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 => diff --git a/src/HSchool.Server/Game/PortraitKind.cs b/src/HSchool.Server/Game/PortraitKind.cs index a9a0c34..1e0184f 100644 --- a/src/HSchool.Server/Game/PortraitKind.cs +++ b/src/HSchool.Server/Game/PortraitKind.cs @@ -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)), }; diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs index 5237352..a87028c 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -5,7 +5,11 @@ namespace HSchool.Server.Game; /// Turns a person card into a Flux-style English prompt for SwarmUI. 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(); @@ -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()); } diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs index 5dc1ffb..8af4ae0 100644 --- a/src/HSchool.Server/Game/PortraitService.cs +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -9,6 +9,8 @@ internal sealed class PortraitService( GameCommandQueue commands, ILogger logger) { + public const int MaxCustomPromptLength = 2000; + private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5); /// English labels for Swarm prompts, independent of the UI locale. @@ -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 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); diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index bad7cc4..ed70576 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -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 png) + public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan 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) diff --git a/src/HSchool.Server/Game/SwarmUiSettings.cs b/src/HSchool.Server/Game/SwarmUiSettings.cs index ca7255c..6bbebfa 100644 --- a/src/HSchool.Server/Game/SwarmUiSettings.cs +++ b/src/HSchool.Server/Game/SwarmUiSettings.cs @@ -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)), }; diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index 51f2449..f6bd2d2 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -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, diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index 0d9d5b8..7f37282 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -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); } diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs index 6379d82..1cbd422 100644 --- a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -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() =>