Implement portrait prompt API and UI enhancements. Added a new endpoint to fetch portrait prompts, updated the client to handle prompt display, and improved localization for prompt-related text. Enhanced styling for prompt elements in the UI.
ci / server (push) Failing after 3m40s
ci / client (push) Failing after 15s

This commit is contained in:
Leonid Pershin
2026-08-20 05:27:01 +03:00
parent a214378c6e
commit 3fa6ce95df
13 changed files with 401 additions and 34 deletions
+17
View File
@@ -376,6 +376,23 @@ at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swa
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.
### `GET /api/schools/{id}/people/{personId}/portrait/prompt`
Returns the positive and negative prompts SwarmUI would receive, without generating an image.
Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended to
the base prompt; when omitted, the last saved custom prompt is used if one exists. Unknown school
is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `kind` is
`400` `invalid-query`; custom without a usable prompt is `400` `invalid-body`.
```json
{
"kind": "avatar",
"positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …",
"negative": "(low quality, worst quality:1.4), …",
"promptExtra": null
}
```
`GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured) `GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured)
so the client can disable generate buttons and show reachability without trying POST first. so the client can disable generate buttons and show reachability without trying POST first.
+7
View File
@@ -7,6 +7,9 @@
"": { "": {
"name": "hschool-client", "name": "hschool-client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"hschool-client": "file:"
},
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"happy-dom": "^20.11.2", "happy-dom": "^20.11.2",
@@ -594,6 +597,10 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/hschool-client": {
"resolved": "",
"link": true
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+3
View File
@@ -20,5 +20,8 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.2.1", "vite": "^8.2.1",
"vitest": "^4.1.10" "vitest": "^4.1.10"
},
"dependencies": {
"hschool-client": "file:"
} }
} }
+12
View File
@@ -172,6 +172,12 @@ const ru = {
peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи', peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи',
peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.', peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.',
peoplePortraitFailed: 'Не удалось сгенерировать портрет.', peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
peoplePortraitShowPrompt: 'Показать итоговый промпт',
peoplePortraitHidePrompt: 'Скрыть промпт',
peoplePortraitPromptPositive: 'Positive',
peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Сборка промпта…',
peoplePortraitPromptFailed: 'Не удалось получить промпт.',
modeOverview: 'Обзор', modeOverview: 'Обзор',
modeManage: 'Управление', modeManage: 'Управление',
@@ -432,6 +438,12 @@ const en: Messages = {
peoplePortraitSwarmDisconnected: 'SwarmUI: unreachable', peoplePortraitSwarmDisconnected: 'SwarmUI: unreachable',
peoplePortraitSwarmOffline: 'SwarmUI is unreachable — check that the service is running.', peoplePortraitSwarmOffline: 'SwarmUI is unreachable — check that the service is running.',
peoplePortraitFailed: 'Could not generate the portrait.', peoplePortraitFailed: 'Could not generate the portrait.',
peoplePortraitShowPrompt: 'Show final prompt',
peoplePortraitHidePrompt: 'Hide prompt',
peoplePortraitPromptPositive: 'Positive',
peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Building prompt…',
peoplePortraitPromptFailed: 'Could not load the prompt.',
modeOverview: 'Overview', modeOverview: 'Overview',
modeManage: 'Management', modeManage: 'Management',
+23
View File
@@ -425,6 +425,29 @@ export function portraitUrl(
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`; return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
} }
export interface PortraitPrompt {
readonly kind: PortraitKind;
readonly positive: string;
readonly negative: string;
readonly promptExtra: string | null;
}
export async function fetchPortraitPrompt(
schoolId: number,
personId: string,
kind: PortraitKind,
promptExtra?: string,
): Promise<PortraitPrompt> {
const params = new URLSearchParams({ kind });
if (kind === 'custom' && promptExtra !== undefined && promptExtra.length > 0) {
params.set('promptExtra', promptExtra);
}
return request<PortraitPrompt>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait/prompt?${params.toString()}`,
);
}
export async function generatePortrait( export async function generatePortrait(
schoolId: number, schoolId: number,
personId: string, personId: string,
+36
View File
@@ -651,6 +651,42 @@ body {
margin-bottom: 8px; margin-bottom: 8px;
} }
.people__portrait-prompt-wrap {
margin: -8px 0 16px;
}
.people__portrait-prompt-toggle {
margin-bottom: 8px;
}
.people__portrait-prompt-loading {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-muted, #666);
}
.people__portrait-prompt-view {
margin-bottom: 8px;
}
.people__portrait-prompt-text {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-raised);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.people__portrait-prompt-text--muted {
color: var(--text-muted, #666);
}
.people__swarm-status { .people__swarm-status {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 13px; font-size: 13px;
@@ -176,6 +176,7 @@ describe('renderPersonCard', () => {
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull')); expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull'));
expect(panel?.textContent).toContain(t('peoplePortraitCustomHint')); expect(panel?.textContent).toContain(t('peoplePortraitCustomHint'));
expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull(); expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull();
expect(panel?.textContent).toContain(t('peoplePortraitShowPrompt'));
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);
}); });
+61 -1
View File
@@ -7,7 +7,7 @@ import type {
PersonRel, PersonRel,
PersonRole, PersonRole,
} from '../net/api.ts'; } from '../net/api.ts';
import { portraitUrl, type PortraitKind } from '../net/api.ts'; import { portraitUrl, type PortraitKind, type PortraitPrompt } 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';
@@ -37,6 +37,11 @@ export interface RenderPersonCardOptions {
readonly customPrompt?: string; readonly customPrompt?: string;
readonly onCustomPromptChange?: (value: string) => void; readonly onCustomPromptChange?: (value: string) => void;
readonly customPortraitRevision?: number; readonly customPortraitRevision?: number;
readonly portraitPromptOpen?: PortraitKind | null;
readonly portraitPrompts?: Partial<Record<PortraitKind, PortraitPrompt>>;
readonly portraitPromptLoading?: PortraitKind | null;
readonly portraitPromptError?: string | null;
readonly onShowPortraitPrompt?: (kind: PortraitKind, promptExtra?: string) => void;
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;
@@ -358,6 +363,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
onClick: () => options.onGeneratePortrait?.(variant.kind), onClick: () => options.onGeneratePortrait?.(variant.kind),
}), }),
); );
parent.append(portraitPromptView(variant.kind, options));
} }
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') })); parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') }));
@@ -401,6 +407,9 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
onClick: () => options.onGeneratePortrait?.('custom', customPrompt), onClick: () => options.onGeneratePortrait?.('custom', customPrompt),
}), }),
); );
if (customPrompt.length > 0 || card.hasCustom) {
parent.append(portraitPromptView('custom', options, customPrompt.length > 0 ? customPrompt : undefined));
}
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') }));
@@ -412,6 +421,11 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) { if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) {
parent.append(el('p', { class: 'panel__error', text: portraitError })); parent.append(el('p', { class: 'panel__error', text: portraitError }));
} }
const promptError = options.portraitPromptError;
if (promptError !== undefined && promptError !== null && promptError.length > 0) {
parent.append(el('p', { class: 'panel__error', text: promptError }));
}
} }
const PORTRAIT_VARIANTS: readonly { const PORTRAIT_VARIANTS: readonly {
@@ -437,6 +451,52 @@ const PORTRAIT_VARIANTS: readonly {
}, },
]; ];
function portraitPromptView(
kind: PortraitKind,
options: RenderPersonCardOptions,
promptExtra?: string,
): HTMLElement {
const open = options.portraitPromptOpen === kind;
const loading = options.portraitPromptLoading === kind;
const prompt = options.portraitPrompts?.[kind];
const toggle = el('button', {
class: 'button button--small people__portrait-prompt-toggle',
type: 'button',
text: open ? t('peoplePortraitHidePrompt') : t('peoplePortraitShowPrompt'),
disabled: (options.portraitPromptLoading ?? null) !== null && !loading,
onClick: () => options.onShowPortraitPrompt?.(kind, promptExtra),
});
if (!open && !loading) {
return el('div', { class: 'people__portrait-prompt-wrap' }, toggle);
}
if (loading) {
return el(
'div',
{ class: 'people__portrait-prompt-wrap' },
toggle,
el('p', { class: 'people__portrait-prompt-loading', text: t('peoplePortraitPromptLoading') }),
);
}
if (prompt === undefined) {
return el('div', { class: 'people__portrait-prompt-wrap' }, toggle);
}
return el(
'div',
{ class: 'people__portrait-prompt-wrap' },
toggle,
el('div', { class: 'people__portrait-prompt-view' },
el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }),
el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }),
el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }),
el('pre', { class: 'people__portrait-prompt-text people__portrait-prompt-text--muted', text: prompt.negative }),
),
);
}
function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean { function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean {
if (options.swarmConfigured !== true) { if (options.swarmConfigured !== true) {
return false; return false;
@@ -3,12 +3,14 @@ import {
fetchGameStatus, fetchGameStatus,
fetchPerson, fetchPerson,
fetchPersonLog, fetchPersonLog,
fetchPortraitPrompt,
generatePortrait, generatePortrait,
type PersonCard, type PersonCard,
type PersonLogDir, type PersonLogDir,
type PersonLogPage, type PersonLogPage,
type PersonLogQuery, type PersonLogQuery,
type PortraitKind, type PortraitKind,
type PortraitPrompt,
} 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';
@@ -28,6 +30,10 @@ export class PersonCardHost {
private customPromptDraft = ''; private customPromptDraft = '';
private customPortraitRevision = 0; private customPortraitRevision = 0;
private paintedId: string | null = null; private paintedId: string | null = null;
private portraitPromptOpen: PortraitKind | null = null;
private portraitPrompts: Partial<Record<PortraitKind, PortraitPrompt>> = {};
private portraitPromptLoading: PortraitKind | null = null;
private portraitPromptError: 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;
@@ -69,6 +75,10 @@ export class PersonCardHost {
this.customPromptDraft = ''; this.customPromptDraft = '';
this.customPortraitRevision = 0; this.customPortraitRevision = 0;
this.paintedId = null; this.paintedId = null;
this.portraitPromptOpen = null;
this.portraitPrompts = {};
this.portraitPromptLoading = null;
this.portraitPromptError = null;
this.swarmConnected = null; this.swarmConnected = null;
} }
@@ -101,6 +111,9 @@ export class PersonCardHost {
this.paintedId = card.id; this.paintedId = card.id;
this.customPromptDraft = card.customPortraitPrompt ?? ''; this.customPromptDraft = card.customPortraitPrompt ?? '';
this.customPortraitRevision = 0; this.customPortraitRevision = 0;
this.portraitPromptOpen = null;
this.portraitPrompts = {};
this.portraitPromptError = null;
} }
this.repaint(container, card); this.repaint(container, card);
@@ -150,9 +163,19 @@ export class PersonCardHost {
customPrompt: this.customPromptDraft, customPrompt: this.customPromptDraft,
onCustomPromptChange: (value) => { onCustomPromptChange: (value) => {
this.customPromptDraft = value; this.customPromptDraft = value;
if (this.portraitPromptOpen === 'custom') {
this.portraitPromptOpen = null;
delete this.portraitPrompts.custom;
}
this.refreshPainted(); this.refreshPainted();
}, },
customPortraitRevision: this.customPortraitRevision, customPortraitRevision: this.customPortraitRevision,
portraitPromptOpen: this.portraitPromptOpen,
portraitPrompts: this.portraitPrompts,
portraitPromptLoading: this.portraitPromptLoading,
portraitPromptError: this.portraitPromptError,
onShowPortraitPrompt: (kind, promptExtra) => void this.togglePortraitPrompt(kind, promptExtra),
swarmConfigured: this.swarmConfigured, swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected, swarmConnected: this.swarmConnected,
}); });
@@ -188,6 +211,36 @@ export class PersonCardHost {
} }
} }
private async togglePortraitPrompt(kind: PortraitKind, promptExtra?: string): Promise<void> {
const schoolId = this.schoolId;
const personId = this.painted?.id;
if (schoolId === null || personId === undefined || this.portraitPromptLoading !== null) {
return;
}
if (this.portraitPromptOpen === kind) {
this.portraitPromptOpen = null;
this.portraitPromptError = null;
this.refreshPainted();
return;
}
this.portraitPromptLoading = kind;
this.portraitPromptError = null;
this.refreshPainted();
try {
const prompt = await fetchPortraitPrompt(schoolId, personId, kind, promptExtra);
this.portraitPrompts[kind] = prompt;
this.portraitPromptOpen = kind;
} catch {
this.portraitPromptError = t('peoplePortraitPromptFailed');
} finally {
this.portraitPromptLoading = null;
this.refreshPainted();
}
}
private async generate(kind: PortraitKind, promptExtra?: string): 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;
+35
View File
@@ -270,6 +270,41 @@ internal static class SchoolEndpoints
}) })
.WithName("GetSchoolPersonPortrait"); .WithName("GetSchoolPersonPortrait");
schools.MapGet("/{id:int}/people/{personId}/portrait/prompt", async (
int id,
string personId,
string? kind,
string? promptExtra,
PortraitService portraits,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, custom, or full.");
}
var result = await portraits.BuildPromptAsync(id, personId, portraitKind, promptExtra, cancellationToken);
return result.Outcome switch
{
PortraitPromptBuildOutcome.Succeeded => Results.Ok(new PortraitPromptResponse(
PortraitKindParser.ToApiValue(result.Kind),
result.Positive,
result.Negative,
result.PromptExtra)),
PortraitPromptBuildOutcome.InvalidPrompt =>
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
PortraitPromptBuildOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
})
.WithName("GetSchoolPersonPortraitPrompt");
schools.MapPost("/{id:int}/people/{personId}/portrait", async ( schools.MapPost("/{id:int}/people/{personId}/portrait", async (
int id, int id,
string personId, string personId,
+106 -8
View File
@@ -50,6 +50,38 @@ internal sealed class PortraitService(
return outcome.Error; return outcome.Error;
} }
public async Task<PortraitPromptBuildResult> BuildPromptAsync(
int schoolId,
string personId,
PortraitKind kind,
string? promptExtra,
CancellationToken cancellationToken)
{
var resolved = ResolveCustomPromptExtra(schoolId, personId, kind, promptExtra);
if (kind == PortraitKind.Custom && resolved is null)
{
return PortraitPromptBuildResult.InvalidPrompt;
}
var outcome = await LookupPersonAsync(schoolId, personId, cancellationToken);
if (outcome.Error == PersonLookupError.UnknownPerson)
{
return PortraitPromptBuildResult.UnknownPerson;
}
if (outcome.Error != PersonLookupError.None || outcome.Card is null)
{
return PortraitPromptBuildResult.UnknownSchool;
}
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, resolved);
return PortraitPromptBuildResult.Succeeded(
kind,
positive,
negative,
kind == PortraitKind.Custom ? resolved : null);
}
public async Task<PortraitGenerationResult> GenerateAsync( public async Task<PortraitGenerationResult> GenerateAsync(
int schoolId, int schoolId,
string personId, string personId,
@@ -76,14 +108,7 @@ internal sealed class PortraitService(
} }
} }
var command = new GameCommand.GetPerson( var outcome = await LookupPersonAsync(schoolId, personId, cancellationToken);
schoolId,
personId,
PromptLocale,
NewCompletion<PersonCardResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
if (outcome.Error == PersonLookupError.UnknownPerson) if (outcome.Error == PersonLookupError.UnknownPerson)
{ {
return PortraitGenerationResult.UnknownPerson; return PortraitGenerationResult.UnknownPerson;
@@ -126,10 +151,83 @@ internal sealed class PortraitService(
} }
} }
private async Task<PersonCardResult> LookupPersonAsync(
int schoolId,
string personId,
CancellationToken cancellationToken)
{
var command = new GameCommand.GetPerson(
schoolId,
personId,
PromptLocale,
NewCompletion<PersonCardResult>());
commands.Enqueue(command);
return await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
}
private string? ResolveCustomPromptExtra(int schoolId, string personId, PortraitKind kind, string? promptExtra)
{
if (kind != PortraitKind.Custom)
{
return null;
}
promptExtra = promptExtra?.Trim();
if (string.IsNullOrWhiteSpace(promptExtra))
{
promptExtra = store.TryReadCustomPortraitPrompt(schoolId, personId)?.Trim();
}
if (string.IsNullOrWhiteSpace(promptExtra) || promptExtra.Length > MaxCustomPromptLength)
{
return null;
}
return promptExtra;
}
private static TaskCompletionSource<T> NewCompletion<T>() => private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously); new(TaskCreationOptions.RunContinuationsAsynchronously);
} }
internal enum PortraitPromptBuildOutcome
{
Succeeded,
UnknownSchool,
UnknownPerson,
InvalidPrompt,
}
internal sealed record PortraitPromptBuildResult(
PortraitPromptBuildOutcome Outcome,
PortraitKind Kind,
string Positive,
string Negative,
string? PromptExtra)
{
public static PortraitPromptBuildResult UnknownSchool { get; } =
new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null);
public static PortraitPromptBuildResult UnknownPerson { get; } =
new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null);
public static PortraitPromptBuildResult InvalidPrompt { get; } =
new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null);
public static PortraitPromptBuildResult Succeeded(
PortraitKind kind,
string positive,
string negative,
string? promptExtra) =>
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra);
}
internal sealed record PortraitPromptResponse(
string Kind,
string Positive,
string Negative,
string? PromptExtra);
internal enum PortraitGenerationOutcome internal enum PortraitGenerationOutcome
{ {
Succeeded, Succeeded,
+25 -25
View File
@@ -1,25 +1,25 @@
{ {
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", "model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
"steps": 4, "steps": 4,
"cfgScale": 2, "cfgScale": 2,
"clipSkip": 2, "clipSkip": 2,
"sampler": "dpmpp_sde", "sampler": "dpmpp_sde",
"scheduler": "karras", "scheduler": "karras",
"seed": 3346112079, "seed": 3346112079,
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", "positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo",
"avatar": { "avatar": {
"width": 1024, "width": 1024,
"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."
}, },
"custom": { "custom": {
"width": 896, "width": 896,
"height": 1152 "height": 1152
}, },
"fullBody": { "fullBody": {
"width": 896, "width": 896,
"height": 1152, "height": 1152,
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." "positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
} }
} }
@@ -68,6 +68,26 @@ public class PortraitApiTests(AppHostFixture fixture)
Assert.Equal("swarmui-not-configured", await SchoolApiTests.ProblemCodeAsync(response)); Assert.Equal("swarmui-not-configured", await SchoolApiTests.ProblemCodeAsync(response));
} }
[Fact]
public async Task GetPortraitPrompt_ReturnsBuiltPositiveAndNegative()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Промпт", Start);
var personId = await FirstPersonIdAsync(client, school.Id);
using var response = await client.GetAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait/prompt?kind=avatar",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<PortraitPromptPayload>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Equal("avatar", payload!.Kind);
Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.False(string.IsNullOrWhiteSpace(payload.Negative));
}
[Fact] [Fact]
public async Task DeleteSchool_RemovesPortraitDirectory() public async Task DeleteSchool_RemovesPortraitDirectory()
{ {
@@ -114,5 +134,7 @@ public class PortraitApiTests(AppHostFixture fixture)
bool HasFullBody, bool HasFullBody,
string? CustomPortraitPrompt); string? CustomPortraitPrompt);
private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative, string? PromptExtra);
private sealed record SavesDirectoryResponse(string Path); private sealed record SavesDirectoryResponse(string Path);
} }