Add half-body portrait support: update API, UI, and localization for new portrait type
This commit is contained in:
+5
-4
@@ -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.
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
parent.append(portraitPreview(card, options, variant));
|
||||||
el('button', {
|
parent.append(
|
||||||
class: 'button button--small',
|
el('button', {
|
||||||
type: 'button',
|
class: 'button button--small',
|
||||||
text: options.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'),
|
type: 'button',
|
||||||
disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options),
|
text: options.portraitBusy === variant.kind ? t('peoplePortraitGenerating') : t(variant.generateKey),
|
||||||
onClick: () => options.onGeneratePortrait?.('avatar'),
|
disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options),
|
||||||
}),
|
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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user