diff --git a/docs/protocol.md b/docs/protocol.md index 941c99b..c2fcf70 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -264,7 +264,7 @@ 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` and `hasFullBody` tell the client whether PNG files already exist on disk for this +`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 not happen on this request. @@ -318,6 +318,7 @@ not happen on this request. "hasLocker": false, "homeCount": 3, "hasAvatar": false, + "hasHalfBody": false, "hasFullBody": false } ``` @@ -357,15 +358,15 @@ 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|full` selects head-and-shoulders 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|half|full` selects head-and-shoulders, +waist-up 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", "hasFullBody" }` and a `Location` header pointing +Success is `201` with `{ "kind", "hasAvatar", "hasHalfBody", "hasFullBody" }` 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 c77b51f..6920d77 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -155,8 +155,10 @@ const ru = { peopleTabOverview: 'Обзор', peopleTabPortrait: 'Портрет', peoplePortraitAvatar: 'Аватар', + peoplePortraitHalf: 'По пояс', peoplePortraitFull: 'В полный рост', peoplePortraitGenerateAvatar: 'Сгенерировать аватар', + peoplePortraitGenerateHalf: 'Сгенерировать по пояс', peoplePortraitGenerateFull: 'Сгенерировать в полный рост', peoplePortraitGenerating: 'Генерация…', peoplePortraitMissing: 'Ещё не сгенерировано.', @@ -410,8 +412,10 @@ const en: Messages = { peopleTabOverview: 'Overview', peopleTabPortrait: 'Portrait', peoplePortraitAvatar: 'Avatar', + peoplePortraitHalf: 'Waist up', peoplePortraitFull: 'Full body', peoplePortraitGenerateAvatar: 'Generate avatar', + peoplePortraitGenerateHalf: 'Generate waist up', peoplePortraitGenerateFull: 'Generate full body', peoplePortraitGenerating: 'Generating…', peoplePortraitMissing: 'Not generated yet.', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 26bf448..81f28e6 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -298,6 +298,7 @@ export interface PersonCard { readonly hasLocker: boolean; readonly homeCount: number; readonly hasAvatar: boolean; + readonly hasHalfBody: boolean; readonly hasFullBody: boolean; } @@ -400,13 +401,16 @@ export async function fetchGameStatus(): Promise { return request('/api/status'); } +export type PortraitKind = 'avatar' | 'half' | 'full'; + export interface PortraitResult { - readonly kind: 'avatar' | 'full'; + readonly kind: PortraitKind; readonly hasAvatar: boolean; + readonly hasHalfBody: boolean; readonly hasFullBody: boolean; } -export function portraitUrl(schoolId: number, personId: string, kind: 'avatar' | 'full'): string { +export function portraitUrl(schoolId: number, personId: string, kind: PortraitKind): string { const params = new URLSearchParams({ kind }); return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`; } @@ -414,7 +418,7 @@ export function portraitUrl(schoolId: number, personId: string, kind: 'avatar' | export async function generatePortrait( schoolId: number, personId: string, - kind: 'avatar' | 'full', + kind: PortraitKind, ): Promise { const params = new URLSearchParams({ kind }); return request( diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 5259110..2c19207 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -634,7 +634,8 @@ body { object-fit: cover; } -.people__portrait--full { +.people__portrait--full, +.people__portrait--half { width: min(100%, 384px); height: auto; } diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index 4cf69ad..40af3f7 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -73,6 +73,7 @@ function personCard(): PersonCard { hasLocker: false, homeCount: 0, hasAvatar: false, + hasHalfBody: false, hasFullBody: false, }; } diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 1e2bf6f..312e4d8 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -61,6 +61,7 @@ function card(overrides: Partial = {}): PersonCard { hasLocker: false, homeCount: 2, hasAvatar: false, + hasHalfBody: false, hasFullBody: false, ...overrides, }; @@ -171,6 +172,8 @@ 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?.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 afbb049..d57ec77 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -7,7 +7,7 @@ import type { PersonRel, PersonRole, } from '../net/api.ts'; -import { portraitUrl } from '../net/api.ts'; +import { portraitUrl, type PortraitKind } from '../net/api.ts'; import { formatGameTimeOfDay } from '../format/gameTime.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { el } from './dom.ts'; @@ -31,8 +31,8 @@ export interface RenderPersonCardOptions { readonly onLogDir?: (dir: PersonLogDir) => void; readonly onLogPage?: (page: number) => void; readonly schoolId?: number | null; - readonly onGeneratePortrait?: (kind: 'avatar' | 'full') => void; - readonly portraitBusy?: 'avatar' | 'full' | null; + readonly onGeneratePortrait?: (kind: PortraitKind) => void; + readonly portraitBusy?: PortraitKind | null; readonly portraitError?: string | null; readonly swarmConfigured?: boolean; /** null while checking or when SwarmUI is not configured. */ @@ -343,29 +343,19 @@ function fillNow( function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void { parent.append(swarmStatusLine(options)); - parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') })); - parent.append(portraitPreview(card, options, 'avatar')); - parent.append( - el('button', { - class: 'button button--small', - type: 'button', - text: options.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'), - disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options), - onClick: () => options.onGeneratePortrait?.('avatar'), - }), - ); - - parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitFull') })); - parent.append(portraitPreview(card, options, 'full')); - parent.append( - el('button', { - class: 'button button--small', - type: 'button', - text: options.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'), - disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options), - onClick: () => options.onGeneratePortrait?.('full'), - }), - ); + for (const variant of PORTRAIT_VARIANTS) { + parent.append(el('h4', { class: 'people__section-title', text: t(variant.titleKey) })); + parent.append(portraitPreview(card, options, variant)); + parent.append( + el('button', { + class: 'button button--small', + type: 'button', + text: options.portraitBusy === variant.kind ? t('peoplePortraitGenerating') : t(variant.generateKey), + disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options), + onClick: () => options.onGeneratePortrait?.(variant.kind), + }), + ); + } if (options.swarmConfigured === false) { parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); @@ -379,6 +369,36 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers } } +const PORTRAIT_VARIANTS: readonly { + readonly kind: PortraitKind; + readonly titleKey: MessageKey; + readonly generateKey: MessageKey; + readonly cssClass: string; + readonly hasImage: (card: PersonCard) => boolean; +}[] = [ + { + kind: 'avatar', + titleKey: 'peoplePortraitAvatar', + generateKey: 'peoplePortraitGenerateAvatar', + 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', + generateKey: 'peoplePortraitGenerateFull', + cssClass: 'people__portrait--full', + hasImage: (card) => card.hasFullBody, + }, +]; + function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean { if (options.swarmConfigured !== true) { return false; @@ -403,13 +423,16 @@ function swarmStatusLine(options: RenderPersonCardOptions): HTMLElement { return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') }); } -function portraitPreview(card: PersonCard, options: RenderPersonCardOptions, kind: 'avatar' | 'full'): HTMLElement { - const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody; - if (hasImage && options.schoolId !== null && options.schoolId !== undefined) { +function portraitPreview( + card: PersonCard, + options: RenderPersonCardOptions, + variant: (typeof PORTRAIT_VARIANTS)[number], +): HTMLElement { + if (variant.hasImage(card) && options.schoolId !== null && options.schoolId !== undefined) { return el('img', { - class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full', + class: `people__portrait ${variant.cssClass}`, alt: card.fullName, - src: portraitUrl(options.schoolId, card.id, kind), + src: portraitUrl(options.schoolId, card.id, variant.kind), }); } diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts index 16eea3a..d4cd252 100644 --- a/src/HSchool.Client/src/ui/personCardHost.ts +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -8,6 +8,7 @@ import { type PersonLogDir, type PersonLogPage, type PersonLogQuery, + type PortraitKind, } from '../net/api.ts'; import { getLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; @@ -22,7 +23,7 @@ import { /** Shared card tab state, portrait generation, Swarm availability and the day log. */ export class PersonCardHost { private tab: PersonCardTab = 'overview'; - private portraitBusy: 'avatar' | 'full' | null = null; + private portraitBusy: PortraitKind | null = null; private portraitError: string | null = null; private swarmConfigured = false; private swarmConnected: boolean | null = null; @@ -168,7 +169,7 @@ export class PersonCardHost { } } - private async generate(kind: 'avatar' | 'full'): Promise { + private async generate(kind: PortraitKind): Promise { const schoolId = this.schoolId; const personId = this.painted?.id; if (schoolId === null || personId === undefined || this.portraitBusy !== null) { @@ -185,6 +186,7 @@ export class PersonCardHost { this.painted = { ...card, hasAvatar: result.hasAvatar, + hasHalfBody: result.hasHalfBody, hasFullBody: result.hasFullBody, }; } catch (error) { diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 6e7023f..5adb761 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -79,6 +79,7 @@ internal sealed record PersonCardResponse( bool HasLocker = false, int HomeCount = 0, bool HasAvatar = false, + bool HasHalfBody = false, bool HasFullBody = false); internal sealed record WornItemResponse( diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 82ca947..9f86bca 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 or full."); + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, half, or full."); } var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken); @@ -284,7 +284,7 @@ internal static class SchoolEndpoints if (!PortraitKindParser.TryParse(kind, out var portraitKind)) { - return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full."); + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, half, or full."); } if (!portraits.IsGenerationEnabled) @@ -296,10 +296,11 @@ internal static class SchoolEndpoints return result.Outcome switch { PortraitGenerationOutcome.Succeeded => Results.Created( - $"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={(portraitKind == PortraitKind.Avatar ? "avatar" : "full")}", + $"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={PortraitKindParser.ToApiValue(portraitKind)}", new PortraitResponse( - portraitKind == PortraitKind.Avatar ? "avatar" : "full", + PortraitKindParser.ToApiValue(portraitKind), result.HasAvatar, + result.HasHalfBody, result.HasFullBody)), PortraitGenerationOutcome.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."), diff --git a/src/HSchool.Server/Game/PortraitKind.cs b/src/HSchool.Server/Game/PortraitKind.cs index 2a0de32..a9a0c34 100644 --- a/src/HSchool.Server/Game/PortraitKind.cs +++ b/src/HSchool.Server/Game/PortraitKind.cs @@ -3,6 +3,7 @@ namespace HSchool.Server.Game; internal enum PortraitKind { Avatar, + Half, Full, } @@ -16,6 +17,12 @@ internal static class PortraitKindParser return true; } + if (string.Equals(value, "half", StringComparison.OrdinalIgnoreCase)) + { + kind = PortraitKind.Half; + return true; + } + if (string.Equals(value, "full", StringComparison.OrdinalIgnoreCase)) { kind = PortraitKind.Full; @@ -25,4 +32,12 @@ internal static class PortraitKindParser kind = default; return false; } + + public static string ToApiValue(PortraitKind kind) => kind switch + { + PortraitKind.Avatar => "avatar", + PortraitKind.Half => "half", + 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 c31236d..5237352 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -7,7 +7,7 @@ internal static class PortraitPromptBuilder { public static (string Positive, string Negative) Build(PersonCardResponse card, SwarmUiSettings settings, PortraitKind kind) { - var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody; + var preset = settings.PresetFor(kind); var parts = new List(); if (!string.IsNullOrWhiteSpace(settings.Positive)) diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs index 55fea08..5dc1ffb 100644 --- a/src/HSchool.Server/Game/PortraitService.cs +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -16,13 +16,13 @@ internal sealed class PortraitService( public bool IsGenerationEnabled => swarm.IsConfigured; - public (bool HasAvatar, bool HasFullBody) Flags(int schoolId, string personId) => + public (bool HasAvatar, bool HasHalfBody, bool HasFullBody) Flags(int schoolId, string personId) => store.PortraitFlags(schoolId, personId); public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card) { - var (hasAvatar, hasFullBody) = Flags(schoolId, card.Id); - return card with { HasAvatar = hasAvatar, HasFullBody = hasFullBody }; + var (hasAvatar, hasHalfBody, hasFullBody) = Flags(schoolId, card.Id); + return card with { HasAvatar = hasAvatar, HasHalfBody = hasHalfBody, HasFullBody = hasFullBody }; } public async Task EnsurePersonAsync( @@ -77,7 +77,7 @@ internal sealed class PortraitService( var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken); store.SavePortrait(schoolId, personId, kind, bytes); var flags = Flags(schoolId, personId); - return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasFullBody); + return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasHalfBody, flags.HasFullBody); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { @@ -114,25 +114,26 @@ internal sealed record PortraitGenerationResult( PortraitGenerationOutcome Outcome, PortraitKind Kind, bool HasAvatar, + bool HasHalfBody, bool HasFullBody) { public static PortraitGenerationResult UnknownSchool { get; } = - new(PortraitGenerationOutcome.UnknownSchool, default, false, false); + new(PortraitGenerationOutcome.UnknownSchool, default, false, false, false); public static PortraitGenerationResult UnknownPerson { get; } = - new(PortraitGenerationOutcome.UnknownPerson, default, false, false); + new(PortraitGenerationOutcome.UnknownPerson, default, false, false, false); public static PortraitGenerationResult NotConfigured { get; } = - new(PortraitGenerationOutcome.NotConfigured, default, false, false); + new(PortraitGenerationOutcome.NotConfigured, default, false, false, false); public static PortraitGenerationResult Unavailable { get; } = - new(PortraitGenerationOutcome.Unavailable, default, false, false); + new(PortraitGenerationOutcome.Unavailable, default, false, false, false); public static PortraitGenerationResult TimedOut { get; } = - new(PortraitGenerationOutcome.TimedOut, default, false, false); + new(PortraitGenerationOutcome.TimedOut, default, false, false, false); - public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasFullBody) => - new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasFullBody); + public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasHalfBody, bool hasFullBody) => + new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasHalfBody, hasFullBody); } -internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasFullBody); +internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasHalfBody, bool HasFullBody); diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 6c816bf..bad7cc4 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -271,21 +271,24 @@ internal sealed class SchoolStore public bool HasPortrait(int schoolId, string personId, PortraitKind kind) => File.Exists(PortraitPath(schoolId, personId, kind)); - public (bool HasAvatar, bool HasFullBody) PortraitFlags(int schoolId, string personId) + public (bool HasAvatar, bool HasHalfBody, bool HasFullBody) PortraitFlags(int schoolId, string personId) { var directory = PortraitsDirectory(schoolId); if (!Directory.Exists(directory)) { - return (false, false); + return (false, false, false); } - return (HasPortrait(schoolId, personId, PortraitKind.Avatar), HasPortrait(schoolId, personId, PortraitKind.Full)); + return ( + HasPortrait(schoolId, personId, PortraitKind.Avatar), + HasPortrait(schoolId, personId, PortraitKind.Half), + HasPortrait(schoolId, personId, PortraitKind.Full)); } public string PortraitPath(int schoolId, string personId, PortraitKind kind) { var safeId = SanitizePersonId(personId); - var suffix = kind == PortraitKind.Avatar ? "avatar" : "full"; + var suffix = PortraitKindParser.ToApiValue(kind); return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png"); } diff --git a/src/HSchool.Server/Game/SwarmUiClient.cs b/src/HSchool.Server/Game/SwarmUiClient.cs index d7fa54b..3ee3b89 100644 --- a/src/HSchool.Server/Game/SwarmUiClient.cs +++ b/src/HSchool.Server/Game/SwarmUiClient.cs @@ -69,7 +69,7 @@ internal sealed class SwarmUiClient return await RunWithSessionAsync(async () => { - var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody; + var preset = settings.PresetFor(kind); var body = new Dictionary { ["session_id"] = _sessionId, diff --git a/src/HSchool.Server/Game/SwarmUiSettings.cs b/src/HSchool.Server/Game/SwarmUiSettings.cs index a990037..ca7255c 100644 --- a/src/HSchool.Server/Game/SwarmUiSettings.cs +++ b/src/HSchool.Server/Game/SwarmUiSettings.cs @@ -25,8 +25,18 @@ internal sealed class SwarmUiSettings public SwarmUiPreset Avatar { get; init; } = new(); + public SwarmUiPreset HalfBody { get; init; } = new(); + public SwarmUiPreset FullBody { get; init; } = new(); + public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch + { + PortraitKind.Avatar => Avatar, + PortraitKind.Half => HalfBody, + PortraitKind.Full => FullBody, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + internal sealed class SwarmUiPreset { public int Width { get; init; } = 512; diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index b68061c..51f2449 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -13,6 +13,11 @@ "height": 1024, "positive": "close up, head and shoulders portrait, facing the camera, upper body visible." }, + "halfBody": { + "width": 896, + "height": 1152, + "positive": "waist-up portrait, three-quarter view, facing the camera, outfit visible from waist up, school photo." + }, "fullBody": { "width": 896, "height": 1152, diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index f052e2b..0d9d5b8 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -107,7 +107,7 @@ public class PortraitApiTests(AppHostFixture fixture) private sealed record PersonListItem(string Id); - private sealed record PersonCardResponse(string Id, bool HasAvatar, bool HasFullBody); + private sealed record PersonCardResponse(string Id, bool HasAvatar, bool HasHalfBody, bool HasFullBody); private sealed record SavesDirectoryResponse(string Path); } diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs index 7e852b7..6379d82 100644 --- a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -42,6 +42,22 @@ public class PortraitPromptBuilderTests Assert.Contains("Standing full body.", fullPositive, StringComparison.Ordinal); } + [Fact] + public void Build_HalfBody_AddsHalfBodyPreset() + { + var settings = new SwarmUiSettings + { + Positive = "School photo.", + HalfBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Waist-up portrait." }, + FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." }, + }; + + var (halfPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Half); + + Assert.Contains("Waist-up portrait.", halfPositive, StringComparison.Ordinal); + Assert.DoesNotContain("Standing full body.", halfPositive, StringComparison.Ordinal); + } + private static PersonCardResponse SampleCard() => new( "f0.c0",