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:
@@ -155,11 +155,14 @@ const ru = {
|
||||
peopleTabOverview: 'Обзор',
|
||||
peopleTabPortrait: 'Портрет',
|
||||
peoplePortraitAvatar: 'Аватар',
|
||||
peoplePortraitHalf: 'По пояс',
|
||||
peoplePortraitFull: 'В полный рост',
|
||||
peoplePortraitCustom: 'Свой промпт',
|
||||
peoplePortraitCustomHint: 'Дополнение к базовому промпту',
|
||||
peoplePortraitCustomPlaceholder: 'Например: standing in a school hallway, soft window light',
|
||||
peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
|
||||
peoplePortraitGenerateHalf: 'Сгенерировать по пояс',
|
||||
peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
|
||||
peoplePortraitGenerateCustom: 'Сгенерировать',
|
||||
peoplePortraitRegenerateCustom: 'Перегенерировать',
|
||||
peoplePortraitGenerating: 'Генерация…',
|
||||
peoplePortraitMissing: 'Ещё не сгенерировано.',
|
||||
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
|
||||
@@ -412,11 +415,14 @@ const en: Messages = {
|
||||
peopleTabOverview: 'Overview',
|
||||
peopleTabPortrait: 'Portrait',
|
||||
peoplePortraitAvatar: 'Avatar',
|
||||
peoplePortraitHalf: 'Waist up',
|
||||
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',
|
||||
peoplePortraitGenerateHalf: 'Generate waist up',
|
||||
peoplePortraitGenerateFull: 'Generate full body',
|
||||
peoplePortraitGenerateCustom: 'Generate',
|
||||
peoplePortraitRegenerateCustom: 'Regenerate',
|
||||
peoplePortraitGenerating: 'Generating…',
|
||||
peoplePortraitMissing: 'Not generated yet.',
|
||||
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
|
||||
|
||||
@@ -298,8 +298,9 @@ export interface PersonCard {
|
||||
readonly hasLocker: boolean;
|
||||
readonly homeCount: number;
|
||||
readonly hasAvatar: boolean;
|
||||
readonly hasHalfBody: boolean;
|
||||
readonly hasCustom: boolean;
|
||||
readonly hasFullBody: boolean;
|
||||
readonly customPortraitPrompt: string | null;
|
||||
}
|
||||
|
||||
export interface WornItem {
|
||||
@@ -401,17 +402,26 @@ export async function fetchGameStatus(): Promise<GameStatus> {
|
||||
return request<GameStatus>('/api/status');
|
||||
}
|
||||
|
||||
export type PortraitKind = 'avatar' | 'half' | 'full';
|
||||
export type PortraitKind = 'avatar' | 'full' | 'custom';
|
||||
|
||||
export interface PortraitResult {
|
||||
readonly kind: PortraitKind;
|
||||
readonly hasAvatar: boolean;
|
||||
readonly hasHalfBody: boolean;
|
||||
readonly hasCustom: 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 });
|
||||
if (cacheBust !== undefined) {
|
||||
params.set('v', String(cacheBust));
|
||||
}
|
||||
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -419,11 +429,18 @@ export async function generatePortrait(
|
||||
schoolId: number,
|
||||
personId: string,
|
||||
kind: PortraitKind,
|
||||
promptExtra?: string,
|
||||
): Promise<PortraitResult> {
|
||||
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>(
|
||||
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`,
|
||||
{ method: 'POST' },
|
||||
init,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -635,7 +635,7 @@ body {
|
||||
}
|
||||
|
||||
.people__portrait--full,
|
||||
.people__portrait--half {
|
||||
.people__portrait--custom {
|
||||
width: min(100%, 384px);
|
||||
height: auto;
|
||||
}
|
||||
@@ -644,6 +644,13 @@ body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.people__portrait-prompt-input {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.people__swarm-status {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -11,8 +11,12 @@ interface ElementOptions {
|
||||
hidden?: boolean;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
rows?: number;
|
||||
dataset?: Record<string, string>;
|
||||
onClick?: (event: Event) => void;
|
||||
onInput?: (event: Event) => void;
|
||||
}
|
||||
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
@@ -36,6 +40,20 @@ export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
(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);
|
||||
|
||||
for (const [key, value] of Object.entries(options.dataset ?? {})) {
|
||||
|
||||
@@ -73,8 +73,9 @@ function personCard(): PersonCard {
|
||||
hasLocker: false,
|
||||
homeCount: 0,
|
||||
hasAvatar: false,
|
||||
hasHalfBody: false,
|
||||
hasCustom: false,
|
||||
hasFullBody: false,
|
||||
customPortraitPrompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,9 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
||||
hasLocker: false,
|
||||
homeCount: 2,
|
||||
hasAvatar: false,
|
||||
hasHalfBody: false,
|
||||
hasCustom: false,
|
||||
hasFullBody: false,
|
||||
customPortraitPrompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -172,8 +173,9 @@ 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?.textContent).toContain(t('peoplePortraitCustomHint'));
|
||||
expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull();
|
||||
expect(panel?.hasAttribute('hidden')).toBe(false);
|
||||
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -31,9 +31,12 @@ export interface RenderPersonCardOptions {
|
||||
readonly onLogDir?: (dir: PersonLogDir) => void;
|
||||
readonly onLogPage?: (page: number) => void;
|
||||
readonly schoolId?: number | null;
|
||||
readonly onGeneratePortrait?: (kind: PortraitKind) => void;
|
||||
readonly onGeneratePortrait?: (kind: PortraitKind, promptExtra?: string) => void;
|
||||
readonly portraitBusy?: PortraitKind | null;
|
||||
readonly portraitError?: string | null;
|
||||
readonly customPrompt?: string;
|
||||
readonly onCustomPromptChange?: (value: string) => void;
|
||||
readonly customPortraitRevision?: number;
|
||||
readonly swarmConfigured?: boolean;
|
||||
/** null while checking or when SwarmUI is not configured. */
|
||||
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) {
|
||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
||||
} else if (options.swarmConfigured === true && options.swarmConnected === false) {
|
||||
@@ -370,7 +415,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
|
||||
}
|
||||
|
||||
const PORTRAIT_VARIANTS: readonly {
|
||||
readonly kind: PortraitKind;
|
||||
readonly kind: Extract<PortraitKind, 'avatar' | 'full'>;
|
||||
readonly titleKey: MessageKey;
|
||||
readonly generateKey: MessageKey;
|
||||
readonly cssClass: string;
|
||||
@@ -383,13 +428,6 @@ const PORTRAIT_VARIANTS: readonly {
|
||||
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',
|
||||
@@ -439,6 +477,18 @@ function portraitPreview(
|
||||
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 {
|
||||
const bits = [
|
||||
roleLabels(card.roles),
|
||||
|
||||
@@ -25,6 +25,9 @@ export class PersonCardHost {
|
||||
private tab: PersonCardTab = 'overview';
|
||||
private portraitBusy: PortraitKind | null = null;
|
||||
private portraitError: string | null = null;
|
||||
private customPromptDraft = '';
|
||||
private customPortraitRevision = 0;
|
||||
private paintedId: string | null = null;
|
||||
private swarmConfigured = false;
|
||||
private swarmConnected: boolean | null = null;
|
||||
private schoolId: number | null = null;
|
||||
@@ -63,6 +66,9 @@ export class PersonCardHost {
|
||||
this.resetTabs();
|
||||
this.portraitBusy = null;
|
||||
this.portraitError = null;
|
||||
this.customPromptDraft = '';
|
||||
this.customPortraitRevision = 0;
|
||||
this.paintedId = null;
|
||||
this.swarmConnected = null;
|
||||
}
|
||||
|
||||
@@ -87,9 +93,16 @@ export class PersonCardHost {
|
||||
this.painted = card;
|
||||
|
||||
if (card === null) {
|
||||
this.paintedId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.id !== this.paintedId) {
|
||||
this.paintedId = card.id;
|
||||
this.customPromptDraft = card.customPortraitPrompt ?? '';
|
||||
this.customPortraitRevision = 0;
|
||||
}
|
||||
|
||||
this.repaint(container, card);
|
||||
}
|
||||
|
||||
@@ -131,9 +144,15 @@ export class PersonCardHost {
|
||||
void this.loadLog();
|
||||
},
|
||||
schoolId: this.schoolId,
|
||||
onGeneratePortrait: (kind) => void this.generate(kind),
|
||||
onGeneratePortrait: (kind, promptExtra) => void this.generate(kind, promptExtra),
|
||||
portraitBusy: this.portraitBusy,
|
||||
portraitError: this.portraitError,
|
||||
customPrompt: this.customPromptDraft,
|
||||
onCustomPromptChange: (value) => {
|
||||
this.customPromptDraft = value;
|
||||
this.refreshPainted();
|
||||
},
|
||||
customPortraitRevision: this.customPortraitRevision,
|
||||
swarmConfigured: this.swarmConfigured,
|
||||
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 personId = this.painted?.id;
|
||||
if (schoolId === null || personId === undefined || this.portraitBusy !== null) {
|
||||
@@ -181,13 +200,19 @@ export class PersonCardHost {
|
||||
this.refreshPainted();
|
||||
|
||||
try {
|
||||
const result = await generatePortrait(schoolId, personId, kind);
|
||||
const result = await generatePortrait(schoolId, personId, kind, promptExtra);
|
||||
const card = await fetchPerson(schoolId, personId, getLocale());
|
||||
if (kind === 'custom') {
|
||||
this.customPortraitRevision = Date.now();
|
||||
this.customPromptDraft = result.customPortraitPrompt ?? promptExtra ?? '';
|
||||
}
|
||||
|
||||
this.painted = {
|
||||
...card,
|
||||
hasAvatar: result.hasAvatar,
|
||||
hasHalfBody: result.hasHalfBody,
|
||||
hasCustom: result.hasCustom,
|
||||
hasFullBody: result.hasFullBody,
|
||||
customPortraitPrompt: result.customPortraitPrompt,
|
||||
};
|
||||
} catch (error) {
|
||||
this.portraitError =
|
||||
|
||||
Reference in New Issue
Block a user