From 5d46550f12f633d6da6f98d14b4ce27b89f32430 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 14:43:00 +0300 Subject: [PATCH] checkpoint before checking out main --- docs/design/off-queue/README.md | 1 + .../design/off-queue/portrait-loras-embeds.md | 27 ++++ .../off-queue/61-portrait-loras-embeds.md | 39 +++++ docs/phases/off-queue/README.md | 1 + docs/protocol.md | 5 +- src/HSchool.Client/src/i18n/strings.ts | 6 + src/HSchool.Client/src/net/api.ts | 9 ++ .../src/ui/createSchoolDialog.test.ts | 2 + .../src/ui/swarmUiSettingsDialog.test.ts | 8 +- .../src/ui/swarmUiSettingsDialog.ts | 77 +++++++--- .../Game/PortraitPromptBuilder.cs | 8 +- src/HSchool.Server/Game/SwarmUiClient.cs | 8 +- src/HSchool.Server/Game/SwarmUiDiscovery.cs | 6 +- .../Game/SwarmUiLoraFormatter.cs | 79 +++++++--- .../Game/SwarmUiSettingsStore.cs | 68 +++++++- .../SwarmUiClientTests.cs | 3 + .../SwarmUiLoraFormatterTests.cs | 145 ++++++++++++++++++ .../SwarmUiSettingsStoreTests.cs | 1 + 18 files changed, 429 insertions(+), 64 deletions(-) create mode 100644 docs/design/off-queue/portrait-loras-embeds.md create mode 100644 docs/phases/off-queue/61-portrait-loras-embeds.md create mode 100644 tests/HSchool.Server.Tests/SwarmUiLoraFormatterTests.cs diff --git a/docs/design/off-queue/README.md b/docs/design/off-queue/README.md index 3935fc6..82673d1 100644 --- a/docs/design/off-queue/README.md +++ b/docs/design/off-queue/README.md @@ -10,6 +10,7 @@ | [Что нового](whats-new.md) | 54 | | [Состояние экрана](view-state.md) | 55 | | [Модели портретов](portrait-models.md) | 60 | +| [LoRA и embeddings](portrait-loras-embeds.md) | 61 | Люди: [`../02-people/people.md`](../02-people/people.md). Штат: [`../03-staffing/staffing.md`](../03-staffing/staffing.md). Расписание: [`../04-schedule/schedule.md`](../04-schedule/schedule.md). Присутствие: [`../05-ai/ai.md`](../05-ai/ai.md). diff --git a/docs/design/off-queue/portrait-loras-embeds.md b/docs/design/off-queue/portrait-loras-embeds.md new file mode 100644 index 0000000..86de5e6 --- /dev/null +++ b/docs/design/off-queue/portrait-loras-embeds.md @@ -0,0 +1,27 @@ +Часть [Вне очереди](README.md). Соседей по папке не читать, если задача не про них. + +# LoRA и embeddings на каждом слое + +### Зачем + +Портрет собирается из модели, пресета и типа кадра. LoRA и textual inversion должны +включаться на любом из этих слоёв, а не только в дефолтах модели. + +### Было / Стало / Почему + +**Было.** Positive/negative LoRA только на модели и в оверрайде пресета. В Swarm уходила +одна строка `name,weight,…`. Embeddings из Swarm в пикере не было. Теги `` в +негативном промпте Swarm не загружают сеть. + +**Стало.** LoRA и embeddings (name + weight) на модели, на пресете (добавка) и на каждом +виде кадра. Слои **склеиваются**: модель → пресет → кадр. Все LoRA уходят в +`loras` + `loraweights`. Embeddings — в промпт как `` (вес ≠ 1: +``). Discovery отдаёт список embeddings. По умолчанию списки пустые. + +**Почему.** Swarm грузит LoRA параметрами, embeddings — текстом промпта. Кадр (аватар vs +полный рост) может нуждаться в своей LoRA, не меняя модель. + +### Что не входит + +Whitelist embeddings как у чекпоинтов. ControlNet. Включение PDXL embeds по умолчанию. +Бамп сокета. diff --git a/docs/phases/off-queue/61-portrait-loras-embeds.md b/docs/phases/off-queue/61-portrait-loras-embeds.md new file mode 100644 index 0000000..647ecaf --- /dev/null +++ b/docs/phases/off-queue/61-portrait-loras-embeds.md @@ -0,0 +1,39 @@ +# Фаза 61. LoRA и embeddings + +## Зависимости + +Фаза 60 (каталог моделей и слои промпта). Ветка от `phase/60-portrait-models`. + +## Зачем + +LoRA и embeddings можно поставить на модель, на пресет и на тип кадра. Swarm получает +их тем каналом, который реально загружает сеть. + +## Задачи + +- [ ] Discovery: список `embeddings` из Swarm (`models.Embedding`) +- [ ] Списки LoRA и embeddings (name + weight) на модели, пресете и avatar/custom/full. + Склейка модель → пресет → кадр. Пустые списки в шаблоне +- [ ] Генерация: все LoRA в `loras` и `loraweights`; embeddings в positive/negative как + ``. Не писать `` в промпт +- [ ] UI: комбобоксы из discovery на модели, в спойлере пресета и в секции кадра. `t(...)` +- [ ] HTTP в [`protocol.md`](../../protocol.md), сокет не бампить + +## Тесты, без которых фаза не закрыта + +- [ ] Discovery парсит embeddings; офлайн — пустой список +- [ ] Склейка LoRA/embed: модель + пресет + kind +- [ ] Промпт содержит `` и не содержит ` { negative: '', positiveLoras: [], negativeLoras: [], + positiveEmbeddings: [], + negativeEmbeddings: [], }, ], presets: [ diff --git a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.test.ts b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.test.ts index 80f1b2e..ccfb414 100644 --- a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.test.ts +++ b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.test.ts @@ -43,6 +43,8 @@ function settings(): SwarmUiSettingsFile { negative: '', positiveLoras: [], negativeLoras: [], + positiveEmbeddings: [], + negativeEmbeddings: [], }, { id: 'other.safetensors', @@ -57,6 +59,8 @@ function settings(): SwarmUiSettingsFile { negative: '', positiveLoras: [], negativeLoras: [], + positiveEmbeddings: [], + negativeEmbeddings: [], }, ], presets: [ @@ -80,6 +84,7 @@ function discovery(overrides: Partial = {}): SwarmUiDiscovery connected: true, models: ['template.safetensors', 'not-in-config.safetensors'], loras: [], + embeddings: [], samplers: ['euler'], schedulers: ['normal'], ...overrides, @@ -135,7 +140,8 @@ describe('swarmUiSettingsDialog', () => { expect([...dialog.querySelectorAll('.field__label')].some((node) => node.textContent === t('settingsStyle'))).toBe(true); expect([...dialog.querySelectorAll('.field__label')].some((node) => node.textContent === t('settingsShotType'))).toBe(true); - expect(dialog.querySelector('.settings-model')?.textContent).toContain(t('settingsModelDefaults')); + expect(dialog.querySelector('.settings-model')?.textContent).toContain(t('settingsPositiveEmbeddings')); + expect([...dialog.querySelectorAll('.settings-kind .settings-subtitle')].some((node) => node.textContent === t('settingsPositiveEmbeddings'))).toBe(true); const spoiler = dialog.querySelector('details.settings-overrides'); if (!(spoiler instanceof HTMLDetailsElement)) { throw new Error('overrides spoiler is missing'); diff --git a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts index 6495d8f..6732d91 100644 --- a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts +++ b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts @@ -264,8 +264,10 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise { model.seed = value; }), - loraSection(t('settingsPositiveLoras'), model.positiveLoras), - loraSection(t('settingsNegativeLoras'), model.negativeLoras), + loraSection(t('settingsPositiveLoras'), model.positiveLoras, discovery.loras, t('settingsAddLora')), + loraSection(t('settingsNegativeLoras'), model.negativeLoras, discovery.loras, t('settingsAddLora')), + loraSection(t('settingsPositiveEmbeddings'), model.positiveEmbeddings, discovery.embeddings, t('settingsAddEmbedding')), + loraSection(t('settingsNegativeEmbeddings'), model.negativeEmbeddings, discovery.embeddings, t('settingsAddEmbedding')), ); return host; } @@ -295,16 +297,33 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise { - preset.positiveLoras = positiveLoras; - }); - const negativeHost = loraSection(t('settingsNegativeLoras'), negativeLoras, () => { - preset.negativeLoras = negativeLoras; - }); - body.append(positiveHost, negativeHost); + const bindList = ( + title: string, + current: SwarmUiLoraEntry[] | null | undefined, + names: readonly string[], + addLabel: string, + assign: (items: SwarmUiLoraEntry[]) => void, + ): HTMLElement => { + const items = current ?? []; + return loraSection(title, items, names, addLabel, () => { + assign(items); + }); + }; + + body.append( + bindList(t('settingsPositiveLoras'), preset.positiveLoras, discovery.loras, t('settingsAddLora'), (items) => { + preset.positiveLoras = items; + }), + bindList(t('settingsNegativeLoras'), preset.negativeLoras, discovery.loras, t('settingsAddLora'), (items) => { + preset.negativeLoras = items; + }), + bindList(t('settingsPositiveEmbeddings'), preset.positiveEmbeddings, discovery.embeddings, t('settingsAddEmbedding'), (items) => { + preset.positiveEmbeddings = items; + }), + bindList(t('settingsNegativeEmbeddings'), preset.negativeEmbeddings, discovery.embeddings, t('settingsAddEmbedding'), (items) => { + preset.negativeEmbeddings = items; + }), + ); details.append(body); return details; } @@ -367,32 +386,38 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise void): HTMLElement { + function loraSection( + title: string, + items: SwarmUiLoraEntry[], + names: readonly string[], + addLabel: string, + onMutate?: () => void, + ): HTMLElement { const host = el('div', { class: 'settings-loras' }); const heading = el('h4', { class: 'settings-subtitle', text: title }); const rows = el('div', { class: 'settings-lora-rows' }); const paintRows = (): void => { rows.replaceChildren(); - for (const lora of loras) { - const nameField = choiceField('', lora.name, discovery.loras, (value) => { - lora.name = value; + for (const item of items) { + const nameField = choiceField('', item.name, names, (value) => { + item.name = value; }); - const weightInput = el('input', { class: 'input', type: 'number', value: String(lora.weight) }); + const weightInput = el('input', { class: 'input', type: 'number', value: String(item.weight) }); weightInput.step = '0.05'; weightInput.min = '-4'; weightInput.max = '4'; weightInput.addEventListener('input', () => { - lora.weight = Number(weightInput.value); + item.weight = Number(weightInput.value); }); const remove = el('button', { class: 'button button--small button--danger', type: 'button', text: '×', onClick: () => { - const index = loras.indexOf(lora); + const index = items.indexOf(item); if (index >= 0) { - loras.splice(index, 1); + items.splice(index, 1); } paintRows(); @@ -406,9 +431,9 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise { - loras.push({ name: discovery.loras[0] ?? '', weight: 1 }); + items.push({ name: names[0] ?? '', weight: 1 }); onMutate?.(); paintRows(); }, @@ -419,6 +444,10 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise { preset.shotType = value; }), + loraSection(t('settingsPositiveLoras'), preset.positiveLoras, discovery.loras, t('settingsAddLora')), + loraSection(t('settingsNegativeLoras'), preset.negativeLoras, discovery.loras, t('settingsAddLora')), + loraSection(t('settingsPositiveEmbeddings'), preset.positiveEmbeddings, discovery.embeddings, t('settingsAddEmbedding')), + loraSection(t('settingsNegativeEmbeddings'), preset.negativeEmbeddings, discovery.embeddings, t('settingsAddEmbedding')), ); } diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs index 9ec597e..1cc36f6 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -43,10 +43,12 @@ internal static class PortraitPromptBuilder } } - var positive = string.Join(", ", parts); - var negative = SwarmUiLoraFormatter.AppendLoraTags( + var positive = SwarmUiLoraFormatter.AppendEmbedTags( + string.Join(", ", parts), + profile.PositiveEmbeddings); + var negative = SwarmUiLoraFormatter.AppendEmbedTags( profile.Negative.Trim(), - profile.NegativeLoras); + profile.NegativeEmbeddings); return (positive, negative); } diff --git a/src/HSchool.Server/Game/SwarmUiClient.cs b/src/HSchool.Server/Game/SwarmUiClient.cs index ed51c3a..e1c1760 100644 --- a/src/HSchool.Server/Game/SwarmUiClient.cs +++ b/src/HSchool.Server/Game/SwarmUiClient.cs @@ -128,10 +128,12 @@ internal sealed class SwarmUiClient body["clipstopatlayer"] = -profile.ClipSkip; } - var loras = SwarmUiLoraFormatter.FormatForApi(profile.PositiveLoras); - if (loras is not null) + var (loraNames, loraWeights) = SwarmUiLoraFormatter.FormatForApi( + SwarmUiLoraFormatter.Concat(profile.PositiveLoras, profile.NegativeLoras)); + if (loraNames is not null) { - body["loras"] = loras; + body["loras"] = loraNames; + body["loraweights"] = loraWeights; } using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); diff --git a/src/HSchool.Server/Game/SwarmUiDiscovery.cs b/src/HSchool.Server/Game/SwarmUiDiscovery.cs index 69a76aa..5e0d87f 100644 --- a/src/HSchool.Server/Game/SwarmUiDiscovery.cs +++ b/src/HSchool.Server/Game/SwarmUiDiscovery.cs @@ -6,10 +6,11 @@ internal sealed record SwarmUiDiscovery( bool Connected, IReadOnlyList Models, IReadOnlyList Loras, + IReadOnlyList Embeddings, IReadOnlyList Samplers, IReadOnlyList Schedulers) { - public static SwarmUiDiscovery Offline { get; } = new(false, [], [], [], []); + public static SwarmUiDiscovery Offline { get; } = new(false, [], [], [], [], []); } internal static class SwarmUiDiscoveryParser @@ -18,9 +19,10 @@ internal static class SwarmUiDiscoveryParser { var models = ReadModelNames(root, "Stable-Diffusion"); var loras = ReadModelNames(root, "LoRA"); + var embeddings = ReadModelNames(root, "Embedding"); var samplers = ReadParamValues(root, "sampler"); var schedulers = ReadParamValues(root, "scheduler"); - return new SwarmUiDiscovery(true, models, loras, samplers, schedulers); + return new SwarmUiDiscovery(true, models, loras, embeddings, samplers, schedulers); } private static IReadOnlyList ReadModelNames(JsonElement root, string subtype) diff --git a/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs b/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs index 5c771ad..d4c90a6 100644 --- a/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs +++ b/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs @@ -2,15 +2,11 @@ namespace HSchool.Server.Game; internal static class SwarmUiLoraFormatter { - /// SwarmUI comma-separated lora list: name,weight,name,weight,… - public static string? FormatForApi(IReadOnlyList loras) + /// SwarmUI comma-separated LoRA names and a matching weights string. + public static (string? Names, string? Weights) FormatForApi(IReadOnlyList loras) { - if (loras.Count == 0) - { - return null; - } - - var parts = new List(loras.Count * 2); + var names = new List(); + var weights = new List(); foreach (var lora in loras) { if (string.IsNullOrWhiteSpace(lora.Name)) @@ -18,26 +14,21 @@ internal static class SwarmUiLoraFormatter continue; } - parts.Add(lora.Name.Trim()); - parts.Add(lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture)); + names.Add(lora.Name.Trim()); + weights.Add(lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture)); } - return parts.Count == 0 ? null : string.Join(',', parts); + if (names.Count == 0) + { + return (null, null); + } + + return (string.Join(',', names), string.Join(',', weights)); } - public static string AppendLoraTags(string prompt, IReadOnlyList loras) + public static string AppendEmbedTags(string prompt, IReadOnlyList embeddings) { - if (loras.Count == 0) - { - return prompt; - } - - var tags = loras - .Where(lora => !string.IsNullOrWhiteSpace(lora.Name)) - .Select(lora => - $"") - .ToList(); - + var tags = Tags(embeddings, "embed"); if (tags.Count == 0) { return prompt; @@ -47,4 +38,46 @@ internal static class SwarmUiLoraFormatter ? string.Join(' ', tags) : $"{prompt.TrimEnd()} {string.Join(' ', tags)}"; } + + public static List Concat(params IReadOnlyList?[] layers) + { + var result = new List(); + foreach (var layer in layers) + { + if (layer is null) + { + continue; + } + + foreach (var entry in layer) + { + if (!string.IsNullOrWhiteSpace(entry.Name)) + { + result.Add(entry); + } + } + } + + return result; + } + + private static List Tags(IReadOnlyList entries, string kind) + { + var tags = new List(); + foreach (var entry in entries) + { + if (string.IsNullOrWhiteSpace(entry.Name)) + { + continue; + } + + var name = entry.Name.Trim(); + tags.Add( + entry.Weight == 1 + ? $"<{kind}:{name}>" + : $"<{kind}:{name}:{entry.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture)}>"); + } + + return tags; + } } diff --git a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs index e2ae336..0169ed0 100644 --- a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs +++ b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs @@ -151,6 +151,8 @@ internal sealed class SwarmUiConfigFile { model.PositiveLoras ??= []; model.NegativeLoras ??= []; + model.PositiveEmbeddings ??= []; + model.NegativeEmbeddings ??= []; if (string.IsNullOrWhiteSpace(model.Label)) { model.Label = LabelFromId(model.Id); @@ -359,6 +361,16 @@ internal sealed class SwarmUiConfigFile { preset.NegativeLoras = null; } + + if (SameLoras(preset.PositiveEmbeddings, model.PositiveEmbeddings)) + { + preset.PositiveEmbeddings = null; + } + + if (SameLoras(preset.NegativeEmbeddings, model.NegativeEmbeddings)) + { + preset.NegativeEmbeddings = null; + } } public SwarmUiModelDefinition? FindModel(string id) => @@ -484,6 +496,10 @@ internal sealed class SwarmUiModelDefinition public List? NegativeLoras { get; set; } + public List? PositiveEmbeddings { get; set; } + + public List? NegativeEmbeddings { get; set; } + public void Validate() { if (Steps is < 1 or > 200) @@ -501,8 +517,10 @@ internal sealed class SwarmUiModelDefinition throw new InvalidOperationException($"Model '{Id}' clipSkip must be between 0 and 12."); } - SwarmUiPresetDefinition.ValidateLoras(Id, PositiveLoras, "positive"); - SwarmUiPresetDefinition.ValidateLoras(Id, NegativeLoras, "negative"); + SwarmUiPresetDefinition.ValidateLoras(Id, PositiveLoras, "positive LoRA"); + SwarmUiPresetDefinition.ValidateLoras(Id, NegativeLoras, "negative LoRA"); + SwarmUiPresetDefinition.ValidateLoras(Id, PositiveEmbeddings, "positive embedding"); + SwarmUiPresetDefinition.ValidateLoras(Id, NegativeEmbeddings, "negative embedding"); } public static List Catalog() => @@ -566,6 +584,10 @@ internal sealed class SwarmUiPresetDefinition public List? NegativeLoras { get; set; } + public List? PositiveEmbeddings { get; set; } + + public List? NegativeEmbeddings { get; set; } + public SwarmUiKindPreset? Avatar { get; set; } public SwarmUiKindPreset? Custom { get; set; } @@ -602,8 +624,26 @@ internal sealed class SwarmUiPresetDefinition throw new InvalidOperationException($"Preset '{Id}' clipSkip must be between 0 and 12."); } - ValidateLoras(Id, PositiveLoras, "positive"); - ValidateLoras(Id, NegativeLoras, "negative"); + ValidateLoras(Id, PositiveLoras, "positive LoRA"); + ValidateLoras(Id, NegativeLoras, "negative LoRA"); + ValidateLoras(Id, PositiveEmbeddings, "positive embedding"); + ValidateLoras(Id, NegativeEmbeddings, "negative embedding"); + ValidateKind(Avatar, "avatar"); + ValidateKind(Custom, "custom"); + ValidateKind(FullBody, "fullBody"); + } + + private void ValidateKind(SwarmUiKindPreset? kind, string name) + { + if (kind is null) + { + return; + } + + ValidateLoras($"{Id}.{name}", kind.PositiveLoras, "positive LoRA"); + ValidateLoras($"{Id}.{name}", kind.NegativeLoras, "negative LoRA"); + ValidateLoras($"{Id}.{name}", kind.PositiveEmbeddings, "positive embedding"); + ValidateLoras($"{Id}.{name}", kind.NegativeEmbeddings, "negative embedding"); } internal static void ValidateLoras(string ownerId, IReadOnlyList? loras, string side) @@ -617,12 +657,12 @@ internal sealed class SwarmUiPresetDefinition { if (string.IsNullOrWhiteSpace(lora.Name)) { - throw new InvalidOperationException($"'{ownerId}' has an empty {side} LoRA name."); + throw new InvalidOperationException($"'{ownerId}' has an empty {side} name."); } if (lora.Weight is < -4 or > 4) { - throw new InvalidOperationException($"'{ownerId}' LoRA '{lora.Name}' weight is out of range."); + throw new InvalidOperationException($"'{ownerId}' {side} '{lora.Name}' weight is out of range."); } } } @@ -657,8 +697,10 @@ internal sealed class SwarmUiPresetDefinition kindPreset.ResolvedShotType(), "", negative, - PositiveLoras ?? model.PositiveLoras ?? [], - NegativeLoras ?? model.NegativeLoras ?? [], + SwarmUiLoraFormatter.Concat(model.PositiveLoras, PositiveLoras, kindPreset.PositiveLoras), + SwarmUiLoraFormatter.Concat(model.NegativeLoras, NegativeLoras, kindPreset.NegativeLoras), + SwarmUiLoraFormatter.Concat(model.PositiveEmbeddings, PositiveEmbeddings, kindPreset.PositiveEmbeddings), + SwarmUiLoraFormatter.Concat(model.NegativeEmbeddings, NegativeEmbeddings, kindPreset.NegativeEmbeddings), kindPreset); } @@ -756,6 +798,14 @@ internal sealed class SwarmUiKindPreset /// Legacy kind positive; copied into on load. public string? Positive { get; set; } + public List? PositiveLoras { get; set; } + + public List? NegativeLoras { get; set; } + + public List? PositiveEmbeddings { get; set; } + + public List? NegativeEmbeddings { get; set; } + public void LiftShotType() { if (string.IsNullOrWhiteSpace(ShotType) && !string.IsNullOrWhiteSpace(Positive)) @@ -794,4 +844,6 @@ internal sealed record SwarmUiResolvedProfile( string Negative, IReadOnlyList PositiveLoras, IReadOnlyList NegativeLoras, + IReadOnlyList PositiveEmbeddings, + IReadOnlyList NegativeEmbeddings, SwarmUiKindPreset KindPreset); diff --git a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs index 05cc26b..7c5ea44 100644 --- a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs @@ -86,6 +86,7 @@ public class SwarmUiClientTests preset.ClipSkip = 2; preset.Sampler = "dpmpp_sde"; preset.Scheduler = "karras"; + preset.PositiveLoras = [new SwarmUiLoraEntry { Name = "style.safetensors", Weight = 0.75 }]; var profile = preset.ToProfile(PortraitKind.Avatar); await client.GenerateAsync("a student", "bad", profile, CancellationToken.None); @@ -93,6 +94,8 @@ public class SwarmUiClientTests using var document = JsonDocument.Parse(handler.GenerateBody!); Assert.Equal(-2, document.RootElement.GetProperty("clipstopatlayer").GetInt32()); Assert.False(document.RootElement.TryGetProperty("clipskip", out _)); + Assert.Equal("style.safetensors", document.RootElement.GetProperty("loras").GetString()); + Assert.Equal("0.75", document.RootElement.GetProperty("loraweights").GetString()); } [Fact] diff --git a/tests/HSchool.Server.Tests/SwarmUiLoraFormatterTests.cs b/tests/HSchool.Server.Tests/SwarmUiLoraFormatterTests.cs new file mode 100644 index 0000000..196f717 --- /dev/null +++ b/tests/HSchool.Server.Tests/SwarmUiLoraFormatterTests.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using HSchool.Server.Game; + +namespace HSchool.Server.Tests; + +public class SwarmUiLoraFormatterTests +{ + [Fact] + public void FormatForApi_SplitsNamesAndWeights() + { + var (names, weights) = SwarmUiLoraFormatter.FormatForApi( + [ + new SwarmUiLoraEntry { Name = "style.safetensors", Weight = 0.8 }, + new SwarmUiLoraEntry { Name = "face.safetensors", Weight = 1 }, + ]); + + Assert.Equal("style.safetensors,face.safetensors", names); + Assert.Equal("0.8,1", weights); + } + + [Fact] + public void Concat_StacksModelPresetAndKind() + { + var stacked = SwarmUiLoraFormatter.Concat( + [new SwarmUiLoraEntry { Name = "model-lora", Weight = 1 }], + [new SwarmUiLoraEntry { Name = "preset-lora", Weight = 0.5 }], + [new SwarmUiLoraEntry { Name = "kind-lora", Weight = 0.2 }]); + + Assert.Equal(["model-lora", "preset-lora", "kind-lora"], stacked.Select(entry => entry.Name)); + } + + [Fact] + public void AppendEmbedTags_AddsSwarmEmbedSyntax() + { + var prompt = SwarmUiLoraFormatter.AppendEmbedTags( + "a student", + [ + new SwarmUiLoraEntry { Name = "pos.safetensors", Weight = 1 }, + new SwarmUiLoraEntry { Name = "soft.safetensors", Weight = 0.6 }, + ]); + + Assert.Contains("", prompt, StringComparison.Ordinal); + Assert.Contains("", prompt, StringComparison.Ordinal); + Assert.DoesNotContain(" entry.Name)); + Assert.Equal(["model-embed", "avatar-embed"], profile.PositiveEmbeddings.Select(entry => entry.Name)); + } + + [Fact] + public void Build_PutsEmbedsInPromptAndOmitsLoraTags() + { + var model = new SwarmUiModelDefinition + { + Id = "m.safetensors", + PositiveLoras = [new SwarmUiLoraEntry { Name = "style-lora", Weight = 1 }], + NegativeEmbeddings = [new SwarmUiLoraEntry { Name = "neg-embed", Weight = 1 }], + }; + var preset = SwarmUiPresetDefinition.CreateDefault(); + preset.PositiveEmbeddings = [new SwarmUiLoraEntry { Name = "pos-embed", Weight = 1 }]; + var card = new HSchool.Server.Api.PersonCardResponse( + "f0.c0", + "Maria Ivanova", + "Ivanova", + "Maria", + "", + true, + 16, + new DateTime(2000, 3, 14, 0, 0, 0, DateTimeKind.Utc), + ["student"], + 5, + "A", + "class-1", + null, + null, + [], + [], + [], + [], + null, + null, + new HSchool.Server.Api.PersonFamilyResponse([], [], [], []), + [], + [], + 0f, + 0f); + + var (positive, negative) = PortraitPromptBuilder.Build( + card, + preset.ToProfile(PortraitKind.Avatar, model), + PortraitKind.Avatar); + + Assert.Contains("", positive, StringComparison.Ordinal); + Assert.Contains("", negative, StringComparison.Ordinal); + Assert.DoesNotContain("