Add half-body portrait support: update API, UI, and localization for new portrait type
ci / server (push) Failing after 3m40s
ci / client (push) Failing after 14s

This commit is contained in:
Leonid Pershin
2026-08-20 05:10:51 +03:00
parent 9e11c8c693
commit df0aaad403
19 changed files with 155 additions and 64 deletions
+5 -4
View File
@@ -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 is how many items remain at home, not the list. The people list does not include any of these
fields. Today's history is a separate GET. fields. Today's history is a separate GET.
`hasAvatar` 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 person. They are filled on the HTTP thread after the worker returns the card; generation does
not happen on this request. not happen on this request.
@@ -318,6 +318,7 @@ not happen on this request.
"hasLocker": false, "hasLocker": false,
"homeCount": 3, "homeCount": 3,
"hasAvatar": false, "hasAvatar": false,
"hasHalfBody": false,
"hasFullBody": false "hasFullBody": false
} }
``` ```
@@ -357,15 +358,15 @@ the action or apparel def the caption was built from.
### `GET /api/schools/{id}/people/{personId}/portrait` ### `GET /api/schools/{id}/people/{personId}/portrait`
Returns a generated PNG when one exists. Query `kind=avatar|full` selects head-and-shoulders or Returns a generated PNG when one exists. Query `kind=avatar|half|full` selects head-and-shoulders,
full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`; 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 missing file is `404` `portrait-missing`. Invalid `kind` is `400` `invalid-query`. Content-Type
is `image/png`. Opening the card does not generate; use POST when the player asks. is `image/png`. Opening the card does not generate; use POST when the player asks.
### `POST /api/schools/{id}/people/{personId}/portrait` ### `POST /api/schools/{id}/people/{personId}/portrait`
Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET. Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET.
Success is `201` with `{ "kind", "hasAvatar", "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`. at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`.
Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files
land under `saves/{id}.portraits/` and survive until the school is deleted. land under `saves/{id}.portraits/` and survive until the school is deleted.
+4
View File
@@ -155,8 +155,10 @@ const ru = {
peopleTabOverview: 'Обзор', peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет', peopleTabPortrait: 'Портрет',
peoplePortraitAvatar: 'Аватар', peoplePortraitAvatar: 'Аватар',
peoplePortraitHalf: 'По пояс',
peoplePortraitFull: 'В полный рост', peoplePortraitFull: 'В полный рост',
peoplePortraitGenerateAvatar: 'Сгенерировать аватар', peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
peoplePortraitGenerateHalf: 'Сгенерировать по пояс',
peoplePortraitGenerateFull: 'Сгенерировать в полный рост', peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
peoplePortraitGenerating: 'Генерация…', peoplePortraitGenerating: 'Генерация…',
peoplePortraitMissing: 'Ещё не сгенерировано.', peoplePortraitMissing: 'Ещё не сгенерировано.',
@@ -410,8 +412,10 @@ const en: Messages = {
peopleTabOverview: 'Overview', peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait', peopleTabPortrait: 'Portrait',
peoplePortraitAvatar: 'Avatar', peoplePortraitAvatar: 'Avatar',
peoplePortraitHalf: 'Waist up',
peoplePortraitFull: 'Full body', peoplePortraitFull: 'Full body',
peoplePortraitGenerateAvatar: 'Generate avatar', peoplePortraitGenerateAvatar: 'Generate avatar',
peoplePortraitGenerateHalf: 'Generate waist up',
peoplePortraitGenerateFull: 'Generate full body', peoplePortraitGenerateFull: 'Generate full body',
peoplePortraitGenerating: 'Generating…', peoplePortraitGenerating: 'Generating…',
peoplePortraitMissing: 'Not generated yet.', peoplePortraitMissing: 'Not generated yet.',
+7 -3
View File
@@ -298,6 +298,7 @@ export interface PersonCard {
readonly hasLocker: boolean; readonly hasLocker: boolean;
readonly homeCount: number; readonly homeCount: number;
readonly hasAvatar: boolean; readonly hasAvatar: boolean;
readonly hasHalfBody: boolean;
readonly hasFullBody: boolean; readonly hasFullBody: boolean;
} }
@@ -400,13 +401,16 @@ export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status'); return request<GameStatus>('/api/status');
} }
export type PortraitKind = 'avatar' | 'half' | 'full';
export interface PortraitResult { export interface PortraitResult {
readonly kind: 'avatar' | 'full'; readonly kind: PortraitKind;
readonly hasAvatar: boolean; readonly hasAvatar: boolean;
readonly hasHalfBody: boolean;
readonly hasFullBody: 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 }); const params = new URLSearchParams({ kind });
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`; 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( export async function generatePortrait(
schoolId: number, schoolId: number,
personId: string, personId: string,
kind: 'avatar' | 'full', kind: PortraitKind,
): Promise<PortraitResult> { ): Promise<PortraitResult> {
const params = new URLSearchParams({ kind }); const params = new URLSearchParams({ kind });
return request<PortraitResult>( return request<PortraitResult>(
+2 -1
View File
@@ -634,7 +634,8 @@ body {
object-fit: cover; object-fit: cover;
} }
.people__portrait--full { .people__portrait--full,
.people__portrait--half {
width: min(100%, 384px); width: min(100%, 384px);
height: auto; height: auto;
} }
@@ -73,6 +73,7 @@ function personCard(): PersonCard {
hasLocker: false, hasLocker: false,
homeCount: 0, homeCount: 0,
hasAvatar: false, hasAvatar: false,
hasHalfBody: false,
hasFullBody: false, hasFullBody: false,
}; };
} }
@@ -61,6 +61,7 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
hasLocker: false, hasLocker: false,
homeCount: 2, homeCount: 2,
hasAvatar: false, hasAvatar: false,
hasHalfBody: false,
hasFullBody: false, hasFullBody: false,
...overrides, ...overrides,
}; };
@@ -171,6 +172,8 @@ describe('renderPersonCard', () => {
const panel = root.querySelector('.people__portrait-panel'); const panel = root.querySelector('.people__portrait-panel');
expect(panel).not.toBeNull(); expect(panel).not.toBeNull();
expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar')); expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar'));
expect(panel?.textContent).toContain(t('peoplePortraitGenerateHalf'));
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull'));
expect(panel?.hasAttribute('hidden')).toBe(false); expect(panel?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true); expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
}); });
+47 -24
View File
@@ -7,7 +7,7 @@ import type {
PersonRel, PersonRel,
PersonRole, PersonRole,
} from '../net/api.ts'; } 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 { formatGameTimeOfDay } from '../format/gameTime.ts';
import { t, type MessageKey } from '../i18n/strings.ts'; import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
@@ -31,8 +31,8 @@ export interface RenderPersonCardOptions {
readonly onLogDir?: (dir: PersonLogDir) => void; readonly onLogDir?: (dir: PersonLogDir) => void;
readonly onLogPage?: (page: number) => void; readonly onLogPage?: (page: number) => void;
readonly schoolId?: number | null; readonly schoolId?: number | null;
readonly onGeneratePortrait?: (kind: 'avatar' | 'full') => void; readonly onGeneratePortrait?: (kind: PortraitKind) => void;
readonly portraitBusy?: 'avatar' | 'full' | null; readonly portraitBusy?: PortraitKind | null;
readonly portraitError?: string | null; readonly portraitError?: string | null;
readonly swarmConfigured?: boolean; readonly swarmConfigured?: boolean;
/** null while checking or when SwarmUI is not configured. */ /** null while checking or when SwarmUI is not configured. */
@@ -343,29 +343,19 @@ function fillNow(
function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void { function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void {
parent.append(swarmStatusLine(options)); parent.append(swarmStatusLine(options));
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') })); for (const variant of PORTRAIT_VARIANTS) {
parent.append(portraitPreview(card, options, 'avatar')); parent.append(el('h4', { class: 'people__section-title', text: t(variant.titleKey) }));
parent.append(portraitPreview(card, options, variant));
parent.append( parent.append(
el('button', { el('button', {
class: 'button button--small', class: 'button button--small',
type: 'button', type: 'button',
text: options.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'), text: options.portraitBusy === variant.kind ? t('peoplePortraitGenerating') : t(variant.generateKey),
disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options), disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options),
onClick: () => options.onGeneratePortrait?.('avatar'), onClick: () => options.onGeneratePortrait?.(variant.kind),
}),
);
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'),
}), }),
); );
}
if (options.swarmConfigured === false) { if (options.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
@@ -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 { function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean {
if (options.swarmConfigured !== true) { if (options.swarmConfigured !== true) {
return false; 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') }); return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') });
} }
function portraitPreview(card: PersonCard, options: RenderPersonCardOptions, kind: 'avatar' | 'full'): HTMLElement { function portraitPreview(
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody; card: PersonCard,
if (hasImage && options.schoolId !== null && options.schoolId !== undefined) { options: RenderPersonCardOptions,
variant: (typeof PORTRAIT_VARIANTS)[number],
): HTMLElement {
if (variant.hasImage(card) && options.schoolId !== null && options.schoolId !== undefined) {
return el('img', { return el('img', {
class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full', class: `people__portrait ${variant.cssClass}`,
alt: card.fullName, alt: card.fullName,
src: portraitUrl(options.schoolId, card.id, kind), src: portraitUrl(options.schoolId, card.id, variant.kind),
}); });
} }
+4 -2
View File
@@ -8,6 +8,7 @@ import {
type PersonLogDir, type PersonLogDir,
type PersonLogPage, type PersonLogPage,
type PersonLogQuery, type PersonLogQuery,
type PortraitKind,
} from '../net/api.ts'; } from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts'; import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
@@ -22,7 +23,7 @@ import {
/** Shared card tab state, portrait generation, Swarm availability and the day log. */ /** Shared card tab state, portrait generation, Swarm availability and the day log. */
export class PersonCardHost { export class PersonCardHost {
private tab: PersonCardTab = 'overview'; private tab: PersonCardTab = 'overview';
private portraitBusy: 'avatar' | 'full' | null = null; private portraitBusy: PortraitKind | null = null;
private portraitError: string | null = null; private portraitError: string | null = null;
private swarmConfigured = false; private swarmConfigured = false;
private swarmConnected: boolean | null = null; private swarmConnected: boolean | null = null;
@@ -168,7 +169,7 @@ export class PersonCardHost {
} }
} }
private async generate(kind: 'avatar' | 'full'): Promise<void> { private async generate(kind: PortraitKind): Promise<void> {
const schoolId = this.schoolId; const schoolId = this.schoolId;
const personId = this.painted?.id; const personId = this.painted?.id;
if (schoolId === null || personId === undefined || this.portraitBusy !== null) { if (schoolId === null || personId === undefined || this.portraitBusy !== null) {
@@ -185,6 +186,7 @@ export class PersonCardHost {
this.painted = { this.painted = {
...card, ...card,
hasAvatar: result.hasAvatar, hasAvatar: result.hasAvatar,
hasHalfBody: result.hasHalfBody,
hasFullBody: result.hasFullBody, hasFullBody: result.hasFullBody,
}; };
} catch (error) { } catch (error) {
+1
View File
@@ -79,6 +79,7 @@ internal sealed record PersonCardResponse(
bool HasLocker = false, bool HasLocker = false,
int HomeCount = 0, int HomeCount = 0,
bool HasAvatar = false, bool HasAvatar = false,
bool HasHalfBody = false,
bool HasFullBody = false); bool HasFullBody = false);
internal sealed record WornItemResponse( internal sealed record WornItemResponse(
+5 -4
View File
@@ -246,7 +246,7 @@ internal static class SchoolEndpoints
if (!PortraitKindParser.TryParse(kind, out var portraitKind)) if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{ {
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full."); return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, half, or full.");
} }
var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken); var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
@@ -284,7 +284,7 @@ internal static class SchoolEndpoints
if (!PortraitKindParser.TryParse(kind, out var portraitKind)) if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{ {
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full."); return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, half, or full.");
} }
if (!portraits.IsGenerationEnabled) if (!portraits.IsGenerationEnabled)
@@ -296,10 +296,11 @@ internal static class SchoolEndpoints
return result.Outcome switch return result.Outcome switch
{ {
PortraitGenerationOutcome.Succeeded => Results.Created( 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( new PortraitResponse(
portraitKind == PortraitKind.Avatar ? "avatar" : "full", PortraitKindParser.ToApiValue(portraitKind),
result.HasAvatar, result.HasAvatar,
result.HasHalfBody,
result.HasFullBody)), result.HasFullBody)),
PortraitGenerationOutcome.UnknownPerson => PortraitGenerationOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."), Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
+15
View File
@@ -3,6 +3,7 @@ namespace HSchool.Server.Game;
internal enum PortraitKind internal enum PortraitKind
{ {
Avatar, Avatar,
Half,
Full, Full,
} }
@@ -16,6 +17,12 @@ internal static class PortraitKindParser
return true; return true;
} }
if (string.Equals(value, "half", StringComparison.OrdinalIgnoreCase))
{
kind = PortraitKind.Half;
return true;
}
if (string.Equals(value, "full", StringComparison.OrdinalIgnoreCase)) if (string.Equals(value, "full", StringComparison.OrdinalIgnoreCase))
{ {
kind = PortraitKind.Full; kind = PortraitKind.Full;
@@ -25,4 +32,12 @@ internal static class PortraitKindParser
kind = default; kind = default;
return false; return false;
} }
public static string ToApiValue(PortraitKind kind) => kind switch
{
PortraitKind.Avatar => "avatar",
PortraitKind.Half => "half",
PortraitKind.Full => "full",
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
} }
@@ -7,7 +7,7 @@ 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)
{ {
var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody; var preset = settings.PresetFor(kind);
var parts = new List<string>(); var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(settings.Positive)) if (!string.IsNullOrWhiteSpace(settings.Positive))
+13 -12
View File
@@ -16,13 +16,13 @@ internal sealed class PortraitService(
public bool IsGenerationEnabled => swarm.IsConfigured; 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); store.PortraitFlags(schoolId, personId);
public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card) public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card)
{ {
var (hasAvatar, hasFullBody) = Flags(schoolId, card.Id); var (hasAvatar, hasHalfBody, hasFullBody) = Flags(schoolId, card.Id);
return card with { HasAvatar = hasAvatar, HasFullBody = hasFullBody }; return card with { HasAvatar = hasAvatar, HasHalfBody = hasHalfBody, HasFullBody = hasFullBody };
} }
public async Task<PersonLookupError> EnsurePersonAsync( public async Task<PersonLookupError> EnsurePersonAsync(
@@ -77,7 +77,7 @@ internal sealed class PortraitService(
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken); var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken);
store.SavePortrait(schoolId, personId, kind, bytes); store.SavePortrait(schoolId, personId, kind, bytes);
var flags = Flags(schoolId, personId); 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) catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{ {
@@ -114,25 +114,26 @@ internal sealed record PortraitGenerationResult(
PortraitGenerationOutcome Outcome, PortraitGenerationOutcome Outcome,
PortraitKind Kind, PortraitKind Kind,
bool HasAvatar, bool HasAvatar,
bool HasHalfBody,
bool HasFullBody) bool HasFullBody)
{ {
public static PortraitGenerationResult UnknownSchool { get; } = public static PortraitGenerationResult UnknownSchool { get; } =
new(PortraitGenerationOutcome.UnknownSchool, default, false, false); new(PortraitGenerationOutcome.UnknownSchool, default, false, false, false);
public static PortraitGenerationResult UnknownPerson { get; } = public static PortraitGenerationResult UnknownPerson { get; } =
new(PortraitGenerationOutcome.UnknownPerson, default, false, false); new(PortraitGenerationOutcome.UnknownPerson, default, false, false, false);
public static PortraitGenerationResult NotConfigured { get; } = public static PortraitGenerationResult NotConfigured { get; } =
new(PortraitGenerationOutcome.NotConfigured, default, false, false); new(PortraitGenerationOutcome.NotConfigured, default, false, false, false);
public static PortraitGenerationResult Unavailable { get; } = public static PortraitGenerationResult Unavailable { get; } =
new(PortraitGenerationOutcome.Unavailable, default, false, false); new(PortraitGenerationOutcome.Unavailable, default, false, false, false);
public static PortraitGenerationResult TimedOut { get; } = 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) => public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasHalfBody, bool hasFullBody) =>
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, 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);
+7 -4
View File
@@ -271,21 +271,24 @@ internal sealed class SchoolStore
public bool HasPortrait(int schoolId, string personId, PortraitKind kind) => public bool HasPortrait(int schoolId, string personId, PortraitKind kind) =>
File.Exists(PortraitPath(schoolId, personId, kind)); File.Exists(PortraitPath(schoolId, personId, kind));
public (bool HasAvatar, bool HasFullBody) PortraitFlags(int schoolId, string personId) public (bool HasAvatar, bool HasHalfBody, bool HasFullBody) PortraitFlags(int schoolId, string personId)
{ {
var directory = PortraitsDirectory(schoolId); var directory = PortraitsDirectory(schoolId);
if (!Directory.Exists(directory)) 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) public string PortraitPath(int schoolId, string personId, PortraitKind kind)
{ {
var safeId = SanitizePersonId(personId); var safeId = SanitizePersonId(personId);
var suffix = kind == PortraitKind.Avatar ? "avatar" : "full"; var suffix = PortraitKindParser.ToApiValue(kind);
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png"); return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png");
} }
+1 -1
View File
@@ -69,7 +69,7 @@ internal sealed class SwarmUiClient
return await RunWithSessionAsync(async () => return await RunWithSessionAsync(async () =>
{ {
var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody; var preset = settings.PresetFor(kind);
var body = new Dictionary<string, object?> var body = new Dictionary<string, object?>
{ {
["session_id"] = _sessionId, ["session_id"] = _sessionId,
@@ -25,8 +25,18 @@ internal sealed class SwarmUiSettings
public SwarmUiPreset Avatar { get; init; } = new(); public SwarmUiPreset Avatar { get; init; } = new();
public SwarmUiPreset HalfBody { get; init; } = new();
public SwarmUiPreset FullBody { 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 internal sealed class SwarmUiPreset
{ {
public int Width { get; init; } = 512; public int Width { get; init; } = 512;
+5
View File
@@ -13,6 +13,11 @@
"height": 1024, "height": 1024,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible." "positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
}, },
"halfBody": {
"width": 896,
"height": 1152,
"positive": "waist-up portrait, three-quarter view, facing the camera, outfit visible from waist up, school photo."
},
"fullBody": { "fullBody": {
"width": 896, "width": 896,
"height": 1152, "height": 1152,
@@ -107,7 +107,7 @@ public class PortraitApiTests(AppHostFixture fixture)
private sealed record PersonListItem(string Id); private sealed record PersonListItem(string Id);
private sealed record PersonCardResponse(string Id, bool HasAvatar, bool HasFullBody); private sealed record PersonCardResponse(string Id, bool HasAvatar, bool HasHalfBody, bool HasFullBody);
private sealed record SavesDirectoryResponse(string Path); private sealed record SavesDirectoryResponse(string Path);
} }
@@ -42,6 +42,22 @@ public class PortraitPromptBuilderTests
Assert.Contains("Standing full body.", fullPositive, StringComparison.Ordinal); 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() => private static PersonCardResponse SampleCard() =>
new( new(
"f0.c0", "f0.c0",