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
+4
View File
@@ -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.',
+7 -3
View File
@@ -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<GameStatus> {
return request<GameStatus>('/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<PortraitResult> {
const params = new URLSearchParams({ kind });
return request<PortraitResult>(
+2 -1
View File
@@ -634,7 +634,8 @@ body {
object-fit: cover;
}
.people__portrait--full {
.people__portrait--full,
.people__portrait--half {
width: min(100%, 384px);
height: auto;
}
@@ -73,6 +73,7 @@ function personCard(): PersonCard {
hasLocker: false,
homeCount: 0,
hasAvatar: false,
hasHalfBody: false,
hasFullBody: false,
};
}
@@ -61,6 +61,7 @@ function card(overrides: Partial<PersonCard> = {}): 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);
});
+54 -31
View File
@@ -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),
});
}
+4 -2
View File
@@ -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<void> {
private async generate(kind: PortraitKind): Promise<void> {
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) {
+1
View File
@@ -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(
+5 -4
View File
@@ -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."),
+15
View File
@@ -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)),
};
}
@@ -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<string>();
if (!string.IsNullOrWhiteSpace(settings.Positive))
+13 -12
View File
@@ -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<PersonLookupError> 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);
+7 -4
View File
@@ -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");
}
+1 -1
View File
@@ -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<string, object?>
{
["session_id"] = _sessionId,
@@ -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;
+5
View File
@@ -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,