Implement custom portrait generation: update API, UI, and localization to support user-defined prompts for portraits, replacing half-body option with custom variant.
This commit is contained in:
+12
-7
@@ -264,8 +264,9 @@ overload does not slow walking. `hasLocker` is true when the pupil has an assign
|
|||||||
is how many items remain at home, not the list. The people list does not include any of these
|
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`, `hasHalfBody` and `hasFullBody` tell the client whether PNG files already exist on disk for this
|
`hasAvatar`, `hasCustom` 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. `customPortraitPrompt` is the last saved user prompt for the custom variant (null when none).
|
||||||
|
They are filled on the HTTP thread after the worker returns the card; generation does
|
||||||
not happen on this request.
|
not happen on this request.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -318,8 +319,9 @@ not happen on this request.
|
|||||||
"hasLocker": false,
|
"hasLocker": false,
|
||||||
"homeCount": 3,
|
"homeCount": 3,
|
||||||
"hasAvatar": false,
|
"hasAvatar": false,
|
||||||
"hasHalfBody": false,
|
"hasCustom": false,
|
||||||
"hasFullBody": false
|
"hasFullBody": false,
|
||||||
|
"customPortraitPrompt": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -358,15 +360,18 @@ the action or apparel def the caption was built from.
|
|||||||
|
|
||||||
### `GET /api/schools/{id}/people/{personId}/portrait`
|
### `GET /api/schools/{id}/people/{personId}/portrait`
|
||||||
|
|
||||||
Returns a generated PNG when one exists. Query `kind=avatar|half|full` selects head-and-shoulders,
|
Returns a generated PNG when one exists. Query `kind=avatar|custom|full` selects head-and-shoulders,
|
||||||
waist-up or full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`;
|
a custom prompt variant or full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`;
|
||||||
missing file is `404` `portrait-missing`. Invalid `kind` is `400` `invalid-query`. Content-Type
|
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", "hasHalfBody", "hasFullBody" }` and a `Location` header pointing
|
For `kind=custom` the body is `{ "promptExtra": "..." }` — appended to the base SwarmUI prompt and
|
||||||
|
the person's body/clothing; required, non-empty, at most 2000 characters. Avatar and full-body POST
|
||||||
|
need no body.
|
||||||
|
Success is `201` with `{ "kind", "hasAvatar", "hasCustom", "hasFullBody", "customPortraitPrompt" }` and a `Location` header pointing
|
||||||
at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`.
|
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,11 +155,14 @@ const ru = {
|
|||||||
peopleTabOverview: 'Обзор',
|
peopleTabOverview: 'Обзор',
|
||||||
peopleTabPortrait: 'Портрет',
|
peopleTabPortrait: 'Портрет',
|
||||||
peoplePortraitAvatar: 'Аватар',
|
peoplePortraitAvatar: 'Аватар',
|
||||||
peoplePortraitHalf: 'По пояс',
|
|
||||||
peoplePortraitFull: 'В полный рост',
|
peoplePortraitFull: 'В полный рост',
|
||||||
|
peoplePortraitCustom: 'Свой промпт',
|
||||||
|
peoplePortraitCustomHint: 'Дополнение к базовому промпту',
|
||||||
|
peoplePortraitCustomPlaceholder: 'Например: standing in a school hallway, soft window light',
|
||||||
peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
|
peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
|
||||||
peoplePortraitGenerateHalf: 'Сгенерировать по пояс',
|
|
||||||
peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
|
peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
|
||||||
|
peoplePortraitGenerateCustom: 'Сгенерировать',
|
||||||
|
peoplePortraitRegenerateCustom: 'Перегенерировать',
|
||||||
peoplePortraitGenerating: 'Генерация…',
|
peoplePortraitGenerating: 'Генерация…',
|
||||||
peoplePortraitMissing: 'Ещё не сгенерировано.',
|
peoplePortraitMissing: 'Ещё не сгенерировано.',
|
||||||
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
|
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
|
||||||
@@ -412,11 +415,14 @@ const en: Messages = {
|
|||||||
peopleTabOverview: 'Overview',
|
peopleTabOverview: 'Overview',
|
||||||
peopleTabPortrait: 'Portrait',
|
peopleTabPortrait: 'Portrait',
|
||||||
peoplePortraitAvatar: 'Avatar',
|
peoplePortraitAvatar: 'Avatar',
|
||||||
peoplePortraitHalf: 'Waist up',
|
|
||||||
peoplePortraitFull: 'Full body',
|
peoplePortraitFull: 'Full body',
|
||||||
|
peoplePortraitCustom: 'Custom prompt',
|
||||||
|
peoplePortraitCustomHint: 'Added to the base prompt',
|
||||||
|
peoplePortraitCustomPlaceholder: 'For example: standing in a school hallway, soft window light',
|
||||||
peoplePortraitGenerateAvatar: 'Generate avatar',
|
peoplePortraitGenerateAvatar: 'Generate avatar',
|
||||||
peoplePortraitGenerateHalf: 'Generate waist up',
|
|
||||||
peoplePortraitGenerateFull: 'Generate full body',
|
peoplePortraitGenerateFull: 'Generate full body',
|
||||||
|
peoplePortraitGenerateCustom: 'Generate',
|
||||||
|
peoplePortraitRegenerateCustom: 'Regenerate',
|
||||||
peoplePortraitGenerating: 'Generating…',
|
peoplePortraitGenerating: 'Generating…',
|
||||||
peoplePortraitMissing: 'Not generated yet.',
|
peoplePortraitMissing: 'Not generated yet.',
|
||||||
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
|
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
|
||||||
|
|||||||
@@ -298,8 +298,9 @@ 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 hasCustom: boolean;
|
||||||
readonly hasFullBody: boolean;
|
readonly hasFullBody: boolean;
|
||||||
|
readonly customPortraitPrompt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WornItem {
|
export interface WornItem {
|
||||||
@@ -401,17 +402,26 @@ export async function fetchGameStatus(): Promise<GameStatus> {
|
|||||||
return request<GameStatus>('/api/status');
|
return request<GameStatus>('/api/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PortraitKind = 'avatar' | 'half' | 'full';
|
export type PortraitKind = 'avatar' | 'full' | 'custom';
|
||||||
|
|
||||||
export interface PortraitResult {
|
export interface PortraitResult {
|
||||||
readonly kind: PortraitKind;
|
readonly kind: PortraitKind;
|
||||||
readonly hasAvatar: boolean;
|
readonly hasAvatar: boolean;
|
||||||
readonly hasHalfBody: boolean;
|
readonly hasCustom: boolean;
|
||||||
readonly hasFullBody: boolean;
|
readonly hasFullBody: boolean;
|
||||||
|
readonly customPortraitPrompt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function portraitUrl(schoolId: number, personId: string, kind: PortraitKind): string {
|
export function portraitUrl(
|
||||||
|
schoolId: number,
|
||||||
|
personId: string,
|
||||||
|
kind: PortraitKind,
|
||||||
|
cacheBust?: number,
|
||||||
|
): string {
|
||||||
const params = new URLSearchParams({ kind });
|
const params = new URLSearchParams({ kind });
|
||||||
|
if (cacheBust !== undefined) {
|
||||||
|
params.set('v', String(cacheBust));
|
||||||
|
}
|
||||||
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
|
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,11 +429,18 @@ export async function generatePortrait(
|
|||||||
schoolId: number,
|
schoolId: number,
|
||||||
personId: string,
|
personId: string,
|
||||||
kind: PortraitKind,
|
kind: PortraitKind,
|
||||||
|
promptExtra?: string,
|
||||||
): Promise<PortraitResult> {
|
): Promise<PortraitResult> {
|
||||||
const params = new URLSearchParams({ kind });
|
const params = new URLSearchParams({ kind });
|
||||||
|
const init: RequestInit = { method: 'POST' };
|
||||||
|
if (kind === 'custom') {
|
||||||
|
init.headers = { 'Content-Type': 'application/json' };
|
||||||
|
init.body = JSON.stringify({ promptExtra: promptExtra ?? '' });
|
||||||
|
}
|
||||||
|
|
||||||
return request<PortraitResult>(
|
return request<PortraitResult>(
|
||||||
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`,
|
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`,
|
||||||
{ method: 'POST' },
|
init,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -635,7 +635,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.people__portrait--full,
|
.people__portrait--full,
|
||||||
.people__portrait--half {
|
.people__portrait--custom {
|
||||||
width: min(100%, 384px);
|
width: min(100%, 384px);
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
@@ -644,6 +644,13 @@ body {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.people__portrait-prompt-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 72px;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.people__swarm-status {
|
.people__swarm-status {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ interface ElementOptions {
|
|||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
src?: string;
|
src?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
rows?: number;
|
||||||
dataset?: Record<string, string>;
|
dataset?: Record<string, string>;
|
||||||
onClick?: (event: Event) => void;
|
onClick?: (event: Event) => void;
|
||||||
|
onInput?: (event: Event) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function el<K extends keyof HTMLElementTagNameMap>(
|
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
@@ -36,6 +40,20 @@ export function el<K extends keyof HTMLElementTagNameMap>(
|
|||||||
(element as HTMLImageElement).alt = options.alt;
|
(element as HTMLImageElement).alt = options.alt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.placeholder !== undefined && 'placeholder' in element) {
|
||||||
|
(element as HTMLInputElement | HTMLTextAreaElement).placeholder = options.placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.value !== undefined && 'value' in element) {
|
||||||
|
(element as HTMLInputElement | HTMLTextAreaElement).value = options.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.rows !== undefined && element instanceof HTMLTextAreaElement) {
|
||||||
|
element.rows = options.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.onInput !== undefined) element.addEventListener('input', options.onInput);
|
||||||
|
|
||||||
if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
|
if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(options.dataset ?? {})) {
|
for (const [key, value] of Object.entries(options.dataset ?? {})) {
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ function personCard(): PersonCard {
|
|||||||
hasLocker: false,
|
hasLocker: false,
|
||||||
homeCount: 0,
|
homeCount: 0,
|
||||||
hasAvatar: false,
|
hasAvatar: false,
|
||||||
hasHalfBody: false,
|
hasCustom: false,
|
||||||
hasFullBody: false,
|
hasFullBody: false,
|
||||||
|
customPortraitPrompt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
|||||||
hasLocker: false,
|
hasLocker: false,
|
||||||
homeCount: 2,
|
homeCount: 2,
|
||||||
hasAvatar: false,
|
hasAvatar: false,
|
||||||
hasHalfBody: false,
|
hasCustom: false,
|
||||||
hasFullBody: false,
|
hasFullBody: false,
|
||||||
|
customPortraitPrompt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -172,8 +173,9 @@ 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?.textContent).toContain(t('peoplePortraitGenerateFull'));
|
||||||
|
expect(panel?.textContent).toContain(t('peoplePortraitCustomHint'));
|
||||||
|
expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull();
|
||||||
expect(panel?.hasAttribute('hidden')).toBe(false);
|
expect(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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ 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: PortraitKind) => void;
|
readonly onGeneratePortrait?: (kind: PortraitKind, promptExtra?: string) => void;
|
||||||
readonly portraitBusy?: PortraitKind | null;
|
readonly portraitBusy?: PortraitKind | null;
|
||||||
readonly portraitError?: string | null;
|
readonly portraitError?: string | null;
|
||||||
|
readonly customPrompt?: string;
|
||||||
|
readonly onCustomPromptChange?: (value: string) => void;
|
||||||
|
readonly customPortraitRevision?: number;
|
||||||
readonly swarmConfigured?: boolean;
|
readonly swarmConfigured?: boolean;
|
||||||
/** null while checking or when SwarmUI is not configured. */
|
/** null while checking or when SwarmUI is not configured. */
|
||||||
readonly swarmConnected?: boolean | null;
|
readonly swarmConnected?: boolean | null;
|
||||||
@@ -357,6 +360,48 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') }));
|
||||||
|
parent.append(
|
||||||
|
el(
|
||||||
|
'label',
|
||||||
|
{ class: 'people__field' },
|
||||||
|
el('span', { class: 'people__label', text: t('peoplePortraitCustomHint') }),
|
||||||
|
el('textarea', {
|
||||||
|
class: 'input people__portrait-prompt-input',
|
||||||
|
rows: 3,
|
||||||
|
placeholder: t('peoplePortraitCustomPlaceholder'),
|
||||||
|
value: options.customPrompt ?? card.customPortraitPrompt ?? '',
|
||||||
|
disabled: (options.portraitBusy ?? null) !== null,
|
||||||
|
onInput: (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLTextAreaElement) {
|
||||||
|
options.onCustomPromptChange?.(target.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
parent.append(portraitCustomPreview(card, options));
|
||||||
|
const customPrompt = (options.customPrompt ?? card.customPortraitPrompt ?? '').trim();
|
||||||
|
parent.append(
|
||||||
|
el('button', {
|
||||||
|
class: 'button button--small',
|
||||||
|
type: 'button',
|
||||||
|
text:
|
||||||
|
options.portraitBusy === 'custom'
|
||||||
|
? t('peoplePortraitGenerating')
|
||||||
|
: card.hasCustom
|
||||||
|
? t('peoplePortraitRegenerateCustom')
|
||||||
|
: t('peoplePortraitGenerateCustom'),
|
||||||
|
disabled:
|
||||||
|
(options.portraitBusy ?? null) !== null ||
|
||||||
|
!portraitGenerateEnabled(options) ||
|
||||||
|
customPrompt.length === 0,
|
||||||
|
onClick: () => options.onGeneratePortrait?.('custom', customPrompt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (options.swarmConfigured === false) {
|
if (options.swarmConfigured === false) {
|
||||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
||||||
} else if (options.swarmConfigured === true && options.swarmConnected === false) {
|
} else if (options.swarmConfigured === true && options.swarmConnected === false) {
|
||||||
@@ -370,7 +415,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PORTRAIT_VARIANTS: readonly {
|
const PORTRAIT_VARIANTS: readonly {
|
||||||
readonly kind: PortraitKind;
|
readonly kind: Extract<PortraitKind, 'avatar' | 'full'>;
|
||||||
readonly titleKey: MessageKey;
|
readonly titleKey: MessageKey;
|
||||||
readonly generateKey: MessageKey;
|
readonly generateKey: MessageKey;
|
||||||
readonly cssClass: string;
|
readonly cssClass: string;
|
||||||
@@ -383,13 +428,6 @@ const PORTRAIT_VARIANTS: readonly {
|
|||||||
cssClass: 'people__portrait--avatar',
|
cssClass: 'people__portrait--avatar',
|
||||||
hasImage: (card) => card.hasAvatar,
|
hasImage: (card) => card.hasAvatar,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
kind: 'half',
|
|
||||||
titleKey: 'peoplePortraitHalf',
|
|
||||||
generateKey: 'peoplePortraitGenerateHalf',
|
|
||||||
cssClass: 'people__portrait--half',
|
|
||||||
hasImage: (card) => card.hasHalfBody,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
kind: 'full',
|
kind: 'full',
|
||||||
titleKey: 'peoplePortraitFull',
|
titleKey: 'peoplePortraitFull',
|
||||||
@@ -439,6 +477,18 @@ function portraitPreview(
|
|||||||
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
|
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function portraitCustomPreview(card: PersonCard, options: RenderPersonCardOptions): HTMLElement {
|
||||||
|
if (card.hasCustom && options.schoolId !== null && options.schoolId !== undefined) {
|
||||||
|
return el('img', {
|
||||||
|
class: 'people__portrait people__portrait--custom',
|
||||||
|
alt: card.fullName,
|
||||||
|
src: portraitUrl(options.schoolId, card.id, 'custom', options.customPortraitRevision),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
|
||||||
|
}
|
||||||
|
|
||||||
function cardMeta(card: PersonCard): string {
|
function cardMeta(card: PersonCard): string {
|
||||||
const bits = [
|
const bits = [
|
||||||
roleLabels(card.roles),
|
roleLabels(card.roles),
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export class PersonCardHost {
|
|||||||
private tab: PersonCardTab = 'overview';
|
private tab: PersonCardTab = 'overview';
|
||||||
private portraitBusy: PortraitKind | null = null;
|
private portraitBusy: PortraitKind | null = null;
|
||||||
private portraitError: string | null = null;
|
private portraitError: string | null = null;
|
||||||
|
private customPromptDraft = '';
|
||||||
|
private customPortraitRevision = 0;
|
||||||
|
private paintedId: string | null = null;
|
||||||
private swarmConfigured = false;
|
private swarmConfigured = false;
|
||||||
private swarmConnected: boolean | null = null;
|
private swarmConnected: boolean | null = null;
|
||||||
private schoolId: number | null = null;
|
private schoolId: number | null = null;
|
||||||
@@ -63,6 +66,9 @@ export class PersonCardHost {
|
|||||||
this.resetTabs();
|
this.resetTabs();
|
||||||
this.portraitBusy = null;
|
this.portraitBusy = null;
|
||||||
this.portraitError = null;
|
this.portraitError = null;
|
||||||
|
this.customPromptDraft = '';
|
||||||
|
this.customPortraitRevision = 0;
|
||||||
|
this.paintedId = null;
|
||||||
this.swarmConnected = null;
|
this.swarmConnected = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +93,16 @@ export class PersonCardHost {
|
|||||||
this.painted = card;
|
this.painted = card;
|
||||||
|
|
||||||
if (card === null) {
|
if (card === null) {
|
||||||
|
this.paintedId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (card.id !== this.paintedId) {
|
||||||
|
this.paintedId = card.id;
|
||||||
|
this.customPromptDraft = card.customPortraitPrompt ?? '';
|
||||||
|
this.customPortraitRevision = 0;
|
||||||
|
}
|
||||||
|
|
||||||
this.repaint(container, card);
|
this.repaint(container, card);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,9 +144,15 @@ export class PersonCardHost {
|
|||||||
void this.loadLog();
|
void this.loadLog();
|
||||||
},
|
},
|
||||||
schoolId: this.schoolId,
|
schoolId: this.schoolId,
|
||||||
onGeneratePortrait: (kind) => void this.generate(kind),
|
onGeneratePortrait: (kind, promptExtra) => void this.generate(kind, promptExtra),
|
||||||
portraitBusy: this.portraitBusy,
|
portraitBusy: this.portraitBusy,
|
||||||
portraitError: this.portraitError,
|
portraitError: this.portraitError,
|
||||||
|
customPrompt: this.customPromptDraft,
|
||||||
|
onCustomPromptChange: (value) => {
|
||||||
|
this.customPromptDraft = value;
|
||||||
|
this.refreshPainted();
|
||||||
|
},
|
||||||
|
customPortraitRevision: this.customPortraitRevision,
|
||||||
swarmConfigured: this.swarmConfigured,
|
swarmConfigured: this.swarmConfigured,
|
||||||
swarmConnected: this.swarmConnected,
|
swarmConnected: this.swarmConnected,
|
||||||
});
|
});
|
||||||
@@ -169,7 +188,7 @@ export class PersonCardHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generate(kind: PortraitKind): Promise<void> {
|
private async generate(kind: PortraitKind, promptExtra?: string): 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) {
|
||||||
@@ -181,13 +200,19 @@ export class PersonCardHost {
|
|||||||
this.refreshPainted();
|
this.refreshPainted();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await generatePortrait(schoolId, personId, kind);
|
const result = await generatePortrait(schoolId, personId, kind, promptExtra);
|
||||||
const card = await fetchPerson(schoolId, personId, getLocale());
|
const card = await fetchPerson(schoolId, personId, getLocale());
|
||||||
|
if (kind === 'custom') {
|
||||||
|
this.customPortraitRevision = Date.now();
|
||||||
|
this.customPromptDraft = result.customPortraitPrompt ?? promptExtra ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
this.painted = {
|
this.painted = {
|
||||||
...card,
|
...card,
|
||||||
hasAvatar: result.hasAvatar,
|
hasAvatar: result.hasAvatar,
|
||||||
hasHalfBody: result.hasHalfBody,
|
hasCustom: result.hasCustom,
|
||||||
hasFullBody: result.hasFullBody,
|
hasFullBody: result.hasFullBody,
|
||||||
|
customPortraitPrompt: result.customPortraitPrompt,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.portraitError =
|
this.portraitError =
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ 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 HasCustom = false,
|
||||||
bool HasFullBody = false);
|
bool HasFullBody = false,
|
||||||
|
string? CustomPortraitPrompt = null);
|
||||||
|
|
||||||
internal sealed record WornItemResponse(
|
internal sealed record WornItemResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
|
|||||||
@@ -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, half, or full.");
|
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, custom, or full.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
|
var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
|
||||||
@@ -274,6 +274,7 @@ internal static class SchoolEndpoints
|
|||||||
int id,
|
int id,
|
||||||
string personId,
|
string personId,
|
||||||
string? kind,
|
string? kind,
|
||||||
|
GeneratePortraitRequest? body,
|
||||||
PortraitService portraits,
|
PortraitService portraits,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
@@ -284,7 +285,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, half, or full.");
|
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, custom, or full.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!portraits.IsGenerationEnabled)
|
if (!portraits.IsGenerationEnabled)
|
||||||
@@ -292,7 +293,7 @@ internal static class SchoolEndpoints
|
|||||||
return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured.");
|
return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await portraits.GenerateAsync(id, personId, portraitKind, cancellationToken);
|
var result = await portraits.GenerateAsync(id, personId, portraitKind, body?.PromptExtra, cancellationToken);
|
||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
PortraitGenerationOutcome.Succeeded => Results.Created(
|
PortraitGenerationOutcome.Succeeded => Results.Created(
|
||||||
@@ -300,8 +301,11 @@ internal static class SchoolEndpoints
|
|||||||
new PortraitResponse(
|
new PortraitResponse(
|
||||||
PortraitKindParser.ToApiValue(portraitKind),
|
PortraitKindParser.ToApiValue(portraitKind),
|
||||||
result.HasAvatar,
|
result.HasAvatar,
|
||||||
result.HasHalfBody,
|
result.HasCustom,
|
||||||
result.HasFullBody)),
|
result.HasFullBody,
|
||||||
|
result.CustomPortraitPrompt)),
|
||||||
|
PortraitGenerationOutcome.InvalidPrompt =>
|
||||||
|
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
|
||||||
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."),
|
||||||
PortraitGenerationOutcome.NotConfigured =>
|
PortraitGenerationOutcome.NotConfigured =>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace HSchool.Server.Game;
|
|||||||
internal enum PortraitKind
|
internal enum PortraitKind
|
||||||
{
|
{
|
||||||
Avatar,
|
Avatar,
|
||||||
Half,
|
Custom,
|
||||||
Full,
|
Full,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,9 +17,9 @@ internal static class PortraitKindParser
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(value, "half", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(value, "custom", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
kind = PortraitKind.Half;
|
kind = PortraitKind.Custom;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ internal static class PortraitKindParser
|
|||||||
public static string ToApiValue(PortraitKind kind) => kind switch
|
public static string ToApiValue(PortraitKind kind) => kind switch
|
||||||
{
|
{
|
||||||
PortraitKind.Avatar => "avatar",
|
PortraitKind.Avatar => "avatar",
|
||||||
PortraitKind.Half => "half",
|
PortraitKind.Custom => "custom",
|
||||||
PortraitKind.Full => "full",
|
PortraitKind.Full => "full",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ namespace HSchool.Server.Game;
|
|||||||
/// <summary>Turns a person card into a Flux-style English prompt for SwarmUI.</summary>
|
/// <summary>Turns a person card into a Flux-style English prompt for SwarmUI.</summary>
|
||||||
internal static class PortraitPromptBuilder
|
internal static class PortraitPromptBuilder
|
||||||
{
|
{
|
||||||
public static (string Positive, string Negative) Build(PersonCardResponse card, SwarmUiSettings settings, PortraitKind kind)
|
public static (string Positive, string Negative) Build(
|
||||||
|
PersonCardResponse card,
|
||||||
|
SwarmUiSettings settings,
|
||||||
|
PortraitKind kind,
|
||||||
|
string? promptExtra = null)
|
||||||
{
|
{
|
||||||
var preset = settings.PresetFor(kind);
|
var preset = settings.PresetFor(kind);
|
||||||
var parts = new List<string>();
|
var parts = new List<string>();
|
||||||
@@ -15,7 +19,14 @@ internal static class PortraitPromptBuilder
|
|||||||
parts.Add(settings.Positive.Trim());
|
parts.Add(settings.Positive.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(preset.Positive))
|
if (kind == PortraitKind.Custom)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(promptExtra))
|
||||||
|
{
|
||||||
|
parts.Add(promptExtra.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(preset.Positive))
|
||||||
{
|
{
|
||||||
parts.Add(preset.Positive.Trim());
|
parts.Add(preset.Positive.Trim());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ internal sealed class PortraitService(
|
|||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
ILogger<PortraitService> logger)
|
ILogger<PortraitService> logger)
|
||||||
{
|
{
|
||||||
|
public const int MaxCustomPromptLength = 2000;
|
||||||
|
|
||||||
private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
/// <summary>English labels for Swarm prompts, independent of the UI locale.</summary>
|
/// <summary>English labels for Swarm prompts, independent of the UI locale.</summary>
|
||||||
@@ -16,13 +18,20 @@ internal sealed class PortraitService(
|
|||||||
|
|
||||||
public bool IsGenerationEnabled => swarm.IsConfigured;
|
public bool IsGenerationEnabled => swarm.IsConfigured;
|
||||||
|
|
||||||
public (bool HasAvatar, bool HasHalfBody, bool HasFullBody) Flags(int schoolId, string personId) =>
|
public (bool HasAvatar, bool HasCustom, bool HasFullBody) Flags(int schoolId, string personId) =>
|
||||||
store.PortraitFlags(schoolId, personId);
|
store.PortraitFlags(schoolId, personId);
|
||||||
|
|
||||||
public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card)
|
public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card)
|
||||||
{
|
{
|
||||||
var (hasAvatar, hasHalfBody, hasFullBody) = Flags(schoolId, card.Id);
|
var (hasAvatar, hasCustom, hasFullBody) = Flags(schoolId, card.Id);
|
||||||
return card with { HasAvatar = hasAvatar, HasHalfBody = hasHalfBody, HasFullBody = hasFullBody };
|
var customPrompt = hasCustom ? store.TryReadCustomPortraitPrompt(schoolId, card.Id) : null;
|
||||||
|
return card with
|
||||||
|
{
|
||||||
|
HasAvatar = hasAvatar,
|
||||||
|
HasCustom = hasCustom,
|
||||||
|
HasFullBody = hasFullBody,
|
||||||
|
CustomPortraitPrompt = customPrompt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PersonLookupError> EnsurePersonAsync(
|
public async Task<PersonLookupError> EnsurePersonAsync(
|
||||||
@@ -45,6 +54,7 @@ internal sealed class PortraitService(
|
|||||||
int schoolId,
|
int schoolId,
|
||||||
string personId,
|
string personId,
|
||||||
PortraitKind kind,
|
PortraitKind kind,
|
||||||
|
string? promptExtra,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!swarm.IsConfigured)
|
if (!swarm.IsConfigured)
|
||||||
@@ -52,6 +62,20 @@ internal sealed class PortraitService(
|
|||||||
return PortraitGenerationResult.NotConfigured;
|
return PortraitGenerationResult.NotConfigured;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind == PortraitKind.Custom)
|
||||||
|
{
|
||||||
|
promptExtra = promptExtra?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(promptExtra))
|
||||||
|
{
|
||||||
|
return PortraitGenerationResult.InvalidPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promptExtra.Length > MaxCustomPromptLength)
|
||||||
|
{
|
||||||
|
return PortraitGenerationResult.InvalidPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var command = new GameCommand.GetPerson(
|
var command = new GameCommand.GetPerson(
|
||||||
schoolId,
|
schoolId,
|
||||||
personId,
|
personId,
|
||||||
@@ -70,14 +94,20 @@ internal sealed class PortraitService(
|
|||||||
return PortraitGenerationResult.UnknownSchool;
|
return PortraitGenerationResult.UnknownSchool;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind);
|
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, promptExtra);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
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, kind == PortraitKind.Custom ? promptExtra : null);
|
||||||
var flags = Flags(schoolId, personId);
|
var flags = Flags(schoolId, personId);
|
||||||
return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasHalfBody, flags.HasFullBody);
|
var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId);
|
||||||
|
return PortraitGenerationResult.Succeeded(
|
||||||
|
kind,
|
||||||
|
flags.HasAvatar,
|
||||||
|
flags.HasCustom,
|
||||||
|
flags.HasFullBody,
|
||||||
|
savedPrompt);
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -106,6 +136,7 @@ internal enum PortraitGenerationOutcome
|
|||||||
UnknownSchool,
|
UnknownSchool,
|
||||||
UnknownPerson,
|
UnknownPerson,
|
||||||
NotConfigured,
|
NotConfigured,
|
||||||
|
InvalidPrompt,
|
||||||
Unavailable,
|
Unavailable,
|
||||||
TimedOut,
|
TimedOut,
|
||||||
}
|
}
|
||||||
@@ -114,26 +145,42 @@ internal sealed record PortraitGenerationResult(
|
|||||||
PortraitGenerationOutcome Outcome,
|
PortraitGenerationOutcome Outcome,
|
||||||
PortraitKind Kind,
|
PortraitKind Kind,
|
||||||
bool HasAvatar,
|
bool HasAvatar,
|
||||||
bool HasHalfBody,
|
bool HasCustom,
|
||||||
bool HasFullBody)
|
bool HasFullBody,
|
||||||
|
string? CustomPortraitPrompt)
|
||||||
{
|
{
|
||||||
public static PortraitGenerationResult UnknownSchool { get; } =
|
public static PortraitGenerationResult UnknownSchool { get; } =
|
||||||
new(PortraitGenerationOutcome.UnknownSchool, default, false, false, false);
|
new(PortraitGenerationOutcome.UnknownSchool, default, false, false, false, null);
|
||||||
|
|
||||||
public static PortraitGenerationResult UnknownPerson { get; } =
|
public static PortraitGenerationResult UnknownPerson { get; } =
|
||||||
new(PortraitGenerationOutcome.UnknownPerson, default, false, false, false);
|
new(PortraitGenerationOutcome.UnknownPerson, default, false, false, false, null);
|
||||||
|
|
||||||
public static PortraitGenerationResult NotConfigured { get; } =
|
public static PortraitGenerationResult NotConfigured { get; } =
|
||||||
new(PortraitGenerationOutcome.NotConfigured, default, false, false, false);
|
new(PortraitGenerationOutcome.NotConfigured, default, false, false, false, null);
|
||||||
|
|
||||||
|
public static PortraitGenerationResult InvalidPrompt { get; } =
|
||||||
|
new(PortraitGenerationOutcome.InvalidPrompt, default, false, false, false, null);
|
||||||
|
|
||||||
public static PortraitGenerationResult Unavailable { get; } =
|
public static PortraitGenerationResult Unavailable { get; } =
|
||||||
new(PortraitGenerationOutcome.Unavailable, default, false, false, false);
|
new(PortraitGenerationOutcome.Unavailable, default, false, false, false, null);
|
||||||
|
|
||||||
public static PortraitGenerationResult TimedOut { get; } =
|
public static PortraitGenerationResult TimedOut { get; } =
|
||||||
new(PortraitGenerationOutcome.TimedOut, default, false, false, false);
|
new(PortraitGenerationOutcome.TimedOut, default, false, false, false, null);
|
||||||
|
|
||||||
public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasHalfBody, bool hasFullBody) =>
|
public static PortraitGenerationResult Succeeded(
|
||||||
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasHalfBody, hasFullBody);
|
PortraitKind kind,
|
||||||
|
bool hasAvatar,
|
||||||
|
bool hasCustom,
|
||||||
|
bool hasFullBody,
|
||||||
|
string? customPortraitPrompt) =>
|
||||||
|
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasCustom, hasFullBody, customPortraitPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasHalfBody, bool HasFullBody);
|
internal sealed record GeneratePortraitRequest(string? PromptExtra);
|
||||||
|
|
||||||
|
internal sealed record PortraitResponse(
|
||||||
|
string Kind,
|
||||||
|
bool HasAvatar,
|
||||||
|
bool HasCustom,
|
||||||
|
bool HasFullBody,
|
||||||
|
string? CustomPortraitPrompt);
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ 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 HasHalfBody, bool HasFullBody) PortraitFlags(int schoolId, string personId)
|
public (bool HasAvatar, bool HasCustom, bool HasFullBody) PortraitFlags(int schoolId, string personId)
|
||||||
{
|
{
|
||||||
var directory = PortraitsDirectory(schoolId);
|
var directory = PortraitsDirectory(schoolId);
|
||||||
if (!Directory.Exists(directory))
|
if (!Directory.Exists(directory))
|
||||||
@@ -281,10 +281,22 @@ internal sealed class SchoolStore
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
HasPortrait(schoolId, personId, PortraitKind.Avatar),
|
HasPortrait(schoolId, personId, PortraitKind.Avatar),
|
||||||
HasPortrait(schoolId, personId, PortraitKind.Half),
|
HasPortrait(schoolId, personId, PortraitKind.Custom),
|
||||||
HasPortrait(schoolId, personId, PortraitKind.Full));
|
HasPortrait(schoolId, personId, PortraitKind.Full));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string CustomPortraitPromptPath(int schoolId, string personId)
|
||||||
|
{
|
||||||
|
var safeId = SanitizePersonId(personId);
|
||||||
|
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.custom.prompt.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? TryReadCustomPortraitPrompt(int schoolId, string personId)
|
||||||
|
{
|
||||||
|
var path = CustomPortraitPromptPath(schoolId, personId);
|
||||||
|
return File.Exists(path) ? File.ReadAllText(path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
public string PortraitPath(int schoolId, string personId, PortraitKind kind)
|
public string PortraitPath(int schoolId, string personId, PortraitKind kind)
|
||||||
{
|
{
|
||||||
var safeId = SanitizePersonId(personId);
|
var safeId = SanitizePersonId(personId);
|
||||||
@@ -292,13 +304,21 @@ internal sealed class SchoolStore
|
|||||||
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png");
|
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan<byte> png)
|
public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan<byte> png, string? customPrompt = null)
|
||||||
{
|
{
|
||||||
var path = PortraitPath(schoolId, personId, kind);
|
var path = PortraitPath(schoolId, personId, kind);
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
var temp = path + ".tmp";
|
var temp = path + ".tmp";
|
||||||
File.WriteAllBytes(temp, png);
|
File.WriteAllBytes(temp, png);
|
||||||
File.Move(temp, path, overwrite: true);
|
File.Move(temp, path, overwrite: true);
|
||||||
|
|
||||||
|
if (kind == PortraitKind.Custom && customPrompt is not null)
|
||||||
|
{
|
||||||
|
var promptPath = CustomPortraitPromptPath(schoolId, personId);
|
||||||
|
var promptTemp = promptPath + ".tmp";
|
||||||
|
File.WriteAllText(promptTemp, customPrompt);
|
||||||
|
File.Move(promptTemp, promptPath, overwrite: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SanitizePersonId(string personId)
|
private static string SanitizePersonId(string personId)
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ internal sealed class SwarmUiSettings
|
|||||||
|
|
||||||
public SwarmUiPreset Avatar { get; init; } = new();
|
public SwarmUiPreset Avatar { get; init; } = new();
|
||||||
|
|
||||||
public SwarmUiPreset HalfBody { get; init; } = new();
|
public SwarmUiPreset Custom { get; init; } = new();
|
||||||
|
|
||||||
public SwarmUiPreset FullBody { get; init; } = new();
|
public SwarmUiPreset FullBody { get; init; } = new();
|
||||||
|
|
||||||
public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch
|
public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch
|
||||||
{
|
{
|
||||||
PortraitKind.Avatar => Avatar,
|
PortraitKind.Avatar => Avatar,
|
||||||
PortraitKind.Half => HalfBody,
|
PortraitKind.Custom => Custom,
|
||||||
PortraitKind.Full => FullBody,
|
PortraitKind.Full => FullBody,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,10 +13,9 @@
|
|||||||
"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": {
|
"custom": {
|
||||||
"width": 896,
|
"width": 896,
|
||||||
"height": 1152,
|
"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,
|
||||||
|
|||||||
@@ -107,7 +107,12 @@ 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 HasHalfBody, bool HasFullBody);
|
private sealed record PersonCardResponse(
|
||||||
|
string Id,
|
||||||
|
bool HasAvatar,
|
||||||
|
bool HasCustom,
|
||||||
|
bool HasFullBody,
|
||||||
|
string? CustomPortraitPrompt);
|
||||||
|
|
||||||
private sealed record SavesDirectoryResponse(string Path);
|
private sealed record SavesDirectoryResponse(string Path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,19 +43,26 @@ public class PortraitPromptBuilderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_HalfBody_AddsHalfBodyPreset()
|
public void Build_Custom_UsesPromptExtraInsteadOfPreset()
|
||||||
{
|
{
|
||||||
var settings = new SwarmUiSettings
|
var settings = new SwarmUiSettings
|
||||||
{
|
{
|
||||||
Positive = "School photo.",
|
Positive = "School photo.",
|
||||||
HalfBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Waist-up portrait." },
|
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
|
||||||
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
|
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
|
||||||
};
|
};
|
||||||
|
|
||||||
var (halfPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Half);
|
const string extra = "standing in a school hallway, soft window light";
|
||||||
|
var (customPositive, _) = PortraitPromptBuilder.Build(
|
||||||
|
SampleCard(),
|
||||||
|
settings,
|
||||||
|
PortraitKind.Custom,
|
||||||
|
extra);
|
||||||
|
|
||||||
Assert.Contains("Waist-up portrait.", halfPositive, StringComparison.Ordinal);
|
Assert.Contains("School photo.", customPositive, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("Standing full body.", halfPositive, StringComparison.Ordinal);
|
Assert.Contains(extra, customPositive, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("Head and shoulders.", customPositive, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PersonCardResponse SampleCard() =>
|
private static PersonCardResponse SampleCard() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user