diff --git a/docs/design/off-queue/README.md b/docs/design/off-queue/README.md index 82673d1..3c10a73 100644 --- a/docs/design/off-queue/README.md +++ b/docs/design/off-queue/README.md @@ -11,6 +11,7 @@ | [Состояние экрана](view-state.md) | 55 | | [Модели портретов](portrait-models.md) | 60 | | [LoRA и embeddings](portrait-loras-embeds.md) | 61 | +| [Промпт и Krea-модели](portrait-prompt-craft.md) | 62 | Люди: [`../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-prompt-craft.md b/docs/design/off-queue/portrait-prompt-craft.md new file mode 100644 index 0000000..3268d34 --- /dev/null +++ b/docs/design/off-queue/portrait-prompt-craft.md @@ -0,0 +1,32 @@ +Часть [Вне очереди](README.md). Соседей по папке не читать, если задача не про них. + +# Каталог Krea и проза портрета + +### Зачем + +В Swarm появились Krea 2 чекпоинты, а пикер по-прежнему показывает только whitelist. +Дефолт новой школы должен считать картинку как Krea turbo. Внешность и одежда в промпте — +связный английский, а не список тегов: иначе Krea рисует хуже, чем SDXL Lightning. + +### Было / Стало / Почему + +**Было.** В `swarmui.json` четыре SDXL. Пресет `default` — babes XL Lightning. +Сборка: `age 12`, `hair colour black`, `wearing shirt in white`. Живая школа держит копию +шаблона. + +**Стало.** В каталог добавлены `museByStableYogi_v30TurboGguf.gguf` и +`realismByStableYogi_v25INT8Turbo.safetensors` (8 шагов, CFG 1, euler, simple). Пресет +`default` ссылается на Realism v2.5; стиль и тип кадра — короткая проза; negative без +SD-весов. Child остаётся DreamShaper. Слои промпта те же (модель → стиль → кадр → extra → +поза → внешность → одежда). Внешность: *who*, *N years old*, *with black hair, brown eyes…*. +Одежда: *wearing a white shirt*. `Realism v2.0` GGUF в каталог не входит. Живые школы не +поднимаем. + +**Почему.** Пикер = Swarm ∩ каталог, без строки в JSON модели нет. Krea 2 — не SDXL: +другие knobs и проза вместо тегов. Копия в сейве не должна менять уже созданные школы. + +### Что не входит + +`realismByStableYogi_v20TurboGGUF.gguf`. Лифт живых `portraitSettings`. Раздельная сборка +SDXL vs Krea. Редактор «добавить из Swarm». Поза в UI. ControlNet. Бамп сокета. +Перегенерация PNG. diff --git a/docs/phases/off-queue/62-portrait-prompt-craft.md b/docs/phases/off-queue/62-portrait-prompt-craft.md new file mode 100644 index 0000000..12efa7a --- /dev/null +++ b/docs/phases/off-queue/62-portrait-prompt-craft.md @@ -0,0 +1,41 @@ +# Фаза 62. Промпт и Krea-модели + +## Зависимости + +Фазы 60 и 61 (каталог моделей, слои, LoRA/embeddings). + +## Зачем + +Новые Krea-чекпоинты видны в пикере. Новая школа по умолчанию рисует Realism v2.5. +Внешность и одежда в промпте — проза, не теги. + +## Задачи + +- [x] Каталог: Muse GGUF и Realism v2.5 INT8, knobs 8 / CFG 1 / euler / simple. Четыре + SDXL остаются. v2.0 GGUF не добавлять. `swarmui.json` совпадает с `Catalog()` +- [x] Пресет `default` — Realism v2.5; стиль и shotType прозой; negative без + `(word:1.4)`. Child — DreamShaper, как сейчас +- [x] Сборка внешности и одежды: `N years old`, `with black hair`, `wearing a white shirt`. + Не писать `age N`, `hair colour …`, `wearing X in Y`. Слои и custom extra не трогать +- [x] Живые школы не лифтить. Сокет не бампить + +## Тесты, без которых фаза не закрыта + +- [x] `CreateDefault` содержит Muse и Realism v2.5 с 8 / 1 / euler / simple +- [x] Взрослый resolve берёт Realism v2.5; ребёнок — DreamShaper +- [x] Промпт: `years old`, `black hair`, `wearing a white shirt`; нет `age 12`, нет + `Hair colour`, нет `wearing shirt in white` +- [x] Порядок слоёв прежний; custom держит кадр и `promptExtra` +- [x] `GET /api/settings/swarmui` отдаёт оба новых id; create копирует их в сейв + +## Критерий готовности + +- В диалоге создания школы в пикере есть Muse и Realism v2.5 (если Swarm их отдал) +- Новая школа без правок пресета генерирует с Realism v2.5 +- «Показать промпт»: связная внешность и одежда, не список статов карточки +- Уже созданная школа не меняет каталог и дефолтную модель + +## Стоп + +Не бампить сокет. Не лифтить живые сейвы. Не тащить Realism v2.0. Не писать позу в UI. +Не перегенерировать PNG. Не зеркалить все параметры Swarm. diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs index 1cc36f6..4e72fc8 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -1,10 +1,14 @@ +using HSchool.Content; using HSchool.Server.Api; namespace HSchool.Server.Game; -/// Turns a person card into a Flux-style English prompt for SwarmUI. +/// Turns a person card into an English prompt for SwarmUI. internal static class PortraitPromptBuilder { + private const string HairColorId = "HairColor"; + private const string EyeColorId = "EyeColor"; + public static (string Positive, string Negative) Build( PersonCardResponse card, SwarmUiResolvedProfile profile, @@ -22,26 +26,8 @@ internal static class PortraitPromptBuilder } Add(parts, profile.Pose); - Add(parts, DescribeSubject(card)); - parts.Add($"age {card.Age}"); - - foreach (var row in card.Body) - { - Add(parts, $"{row.Label.ToLowerInvariant()} {row.Value.ToLowerInvariant()}"); - } - - foreach (var item in PortraitVisibleWorn.Filter(card.Worn)) - { - var color = item.ColorLabel ?? item.Color; - if (!string.IsNullOrWhiteSpace(color)) - { - Add(parts, $"wearing {item.Label.ToLowerInvariant()} in {color.ToLowerInvariant()}"); - } - else - { - Add(parts, $"wearing {item.Label.ToLowerInvariant()}"); - } - } + Add(parts, DescribeAppearance(card)); + Add(parts, DescribeClothing(card)); var positive = SwarmUiLoraFormatter.AppendEmbedTags( string.Join(", ", parts), @@ -63,6 +49,83 @@ internal static class PortraitPromptBuilder parts.Add(value.Trim()); } + private static string DescribeAppearance(PersonCardResponse card) + { + var parts = new List { DescribeSubject(card), $"{card.Age} years old" }; + + var hair = BodyValue(card, HairColorId); + if (hair is not null) + { + parts.Add($"{hair.ToLowerInvariant()} hair"); + } + + var eyes = BodyValue(card, EyeColorId); + if (eyes is not null) + { + parts.Add($"{eyes.ToLowerInvariant()} eyes"); + } + + var build = BodyValue(card, BodyBuilds.Attribute); + if (build is not null && !build.Equals(BodyBuilds.Average, StringComparison.OrdinalIgnoreCase)) + { + parts.Add($"a {build.ToLowerInvariant()} build"); + } + + var height = BodyValue(card, BodyBuilds.HeightAttribute); + if (height is not null) + { + parts.Add($"{height} cm tall"); + } + + if (parts.Count == 2) + { + return $"{parts[0]}, {parts[1]}"; + } + + return $"{parts[0]}, {parts[1]}, with {string.Join(", ", parts.Skip(2))}"; + } + + private static string? DescribeClothing(PersonCardResponse card) + { + var items = PortraitVisibleWorn.Filter(card.Worn) + .Select(DescribeWornItem) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .ToList(); + if (items.Count == 0) + { + return null; + } + + return $"wearing {JoinList(items)}"; + } + + private static string DescribeWornItem(WornItemResponse item) + { + var noun = item.Label.Trim(); + if (string.IsNullOrWhiteSpace(noun)) + { + noun = item.DefName.Trim(); + } + + noun = noun.ToLowerInvariant(); + var color = (item.ColorLabel ?? item.Color)?.Trim(); + if (!string.IsNullOrWhiteSpace(color)) + { + noun = $"{color.ToLowerInvariant()} {noun}"; + } + + var condition = item.ConditionLabel?.Trim().ToLowerInvariant(); + noun = condition switch + { + "torn" => $"torn {noun}", + "worn" => $"worn {noun}", + "in rags" => $"{noun} in rags", + _ => noun, + }; + + return WithArticle(noun); + } + private static string DescribeSubject(PersonCardResponse card) { if (card.Age <= 11) @@ -77,4 +140,36 @@ internal static class PortraitPromptBuilder return card.Female ? "A young woman" : "A young man"; } + + private static string? BodyValue(PersonCardResponse card, string id) + { + foreach (var row in card.Body) + { + if (row.Id.Equals(id, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(row.Value)) + { + return row.Value.Trim(); + } + } + + return null; + } + + private static string WithArticle(string phrase) + { + if (phrase.Length == 0) + { + return phrase; + } + + var first = char.ToLowerInvariant(phrase[0]); + var article = first is 'a' or 'e' or 'i' or 'o' or 'u' ? "an" : "a"; + return $"{article} {phrase}"; + } + + private static string JoinList(IReadOnlyList items) => items.Count switch + { + 1 => items[0], + 2 => $"{items[0]} and {items[1]}", + _ => $"{string.Join(", ", items.Take(items.Count - 1))}, and {items[^1]}", + }; } diff --git a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs index 52fd36d..295a439 100644 --- a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs +++ b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs @@ -471,6 +471,8 @@ internal sealed class SwarmUiModelDefinition public const string DreamShaperId = "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors"; public const string EpicRealismId = "epicrealismXL_VXIAbeast4SLightning.safetensors"; public const string LustifyId = "lustifyNSFWCheckpoint_v40DMD2.safetensors"; + public const string MuseId = "museByStableYogi_v30TurboGguf.gguf"; + public const string RealismV25Id = "realismByStableYogi_v25INT8Turbo.safetensors"; public string Id { get; set; } = ""; @@ -529,6 +531,8 @@ internal sealed class SwarmUiModelDefinition Lightning(DreamShaperId, "DreamShaper XL Lightning", 4, 2, 2, "dpmpp_sde", "karras", 3346112079), Lightning(EpicRealismId, "epicrealism XL Lightning", 7, 1.5, 0, "euler", "normal", 0), Lightning(LustifyId, "lustify NSFW v40 DMD2", 7, 1.5, 0, "euler", "normal", 0), + Lightning(MuseId, "museByStableYogi v3.0 Turbo GGUF", 8, 1, 0, "euler", "simple", 0), + Lightning(RealismV25Id, "realismByStableYogi v2.5 INT8 Turbo", 8, 1, 0, "euler", "simple", 0), ]; private static SwarmUiModelDefinition Lightning( @@ -736,23 +740,23 @@ internal sealed class SwarmUiPresetDefinition { Id = "default", Label = "Default", - Model = SwarmUiModelDefinition.BabesId, + Model = SwarmUiModelDefinition.RealismV25Id, Style = - "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", + "A cinematic school portrait photograph with natural window light, a neutral background, realistic skin, and sharp focus", Negative = - "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", + "text, signature, watermark, extra limbs, deformed anatomy, extra fingers", Avatar = new SwarmUiKindPreset { Width = 1024, Height = 1024, - ShotType = "close up, head and shoulders portrait, facing the camera, upper body visible.", + ShotType = "Close-up head and shoulders portrait, facing the camera, upper body visible", }, Custom = new SwarmUiKindPreset { Width = 896, Height = 1152 }, FullBody = new SwarmUiKindPreset { Width = 896, Height = 1152, - ShotType = "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible.", + ShotType = "Full-body standing portrait from head to toe, neutral pose, current outfit clearly visible", }, }; diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index c785a20..6b165e1 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -56,19 +56,47 @@ "negative": "", "positiveLoras": [], "negativeLoras": [] + }, + { + "id": "museByStableYogi_v30TurboGguf.gguf", + "label": "museByStableYogi v3.0 Turbo GGUF", + "steps": 8, + "cfgScale": 1, + "clipSkip": 0, + "sampler": "euler", + "scheduler": "simple", + "seed": 0, + "positive": "", + "negative": "", + "positiveLoras": [], + "negativeLoras": [] + }, + { + "id": "realismByStableYogi_v25INT8Turbo.safetensors", + "label": "realismByStableYogi v2.5 INT8 Turbo", + "steps": 8, + "cfgScale": 1, + "clipSkip": 0, + "sampler": "euler", + "scheduler": "simple", + "seed": 0, + "positive": "", + "negative": "", + "positiveLoras": [], + "negativeLoras": [] } ], "presets": [ { "id": "default", "label": "Default", - "model": "babesByStableYogi_v4XLLightning.safetensors", - "style": "cinematic photo, realist detail, detailed character expressions, amazing quality,", - "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, explicit, blurry, deformed, bad anatomy, logo", + "model": "realismByStableYogi_v25INT8Turbo.safetensors", + "style": "A cinematic school portrait photograph with natural window light, a neutral background, realistic skin, and sharp focus", + "negative": "text, signature, watermark, extra limbs, deformed anatomy, extra fingers", "avatar": { "width": 1024, "height": 1024, - "shotType": "close up, head and shoulders portrait, facing the camera, upper body visible, avatar," + "shotType": "Close-up head and shoulders portrait, facing the camera, upper body visible" }, "custom": { "width": 896, @@ -78,7 +106,7 @@ "fullBody": { "width": 896, "height": 1152, - "shotType": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." + "shotType": "Full-body standing portrait from head to toe, neutral pose, current outfit clearly visible" } }, { diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index 91b7117..268ba3c 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -85,7 +85,8 @@ public class PortraitApiTests(AppHostFixture fixture) var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(payload); Assert.Equal("avatar", payload!.Kind); - Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase); + Assert.Contains("years old", payload.Positive, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("age ", payload.Positive, StringComparison.OrdinalIgnoreCase); Assert.False(string.IsNullOrWhiteSpace(payload.Negative)); Assert.False(string.IsNullOrWhiteSpace(payload.PresetId)); } @@ -103,6 +104,8 @@ public class PortraitApiTests(AppHostFixture fixture) Assert.NotNull(settings); Assert.NotEmpty(settings!.Presets); Assert.NotEmpty(settings.Models); + Assert.Contains(settings.Models, model => model.Id == "museByStableYogi_v30TurboGguf.gguf"); + Assert.Contains(settings.Models, model => model.Id == "realismByStableYogi_v25INT8Turbo.safetensors"); Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); } @@ -122,6 +125,14 @@ public class PortraitApiTests(AppHostFixture fixture) Assert.True(list.GetArrayLength() > 0); Assert.True(presets.TryGetProperty("models", out var models)); Assert.True(models.GetArrayLength() > 0); + var ids = models.EnumerateArray().Select(model => model.GetProperty("id").GetString()).ToList(); + Assert.Contains("museByStableYogi_v30TurboGguf.gguf", ids); + Assert.Contains("realismByStableYogi_v25INT8Turbo.safetensors", ids); + var defaultPreset = presets.GetProperty("presets").EnumerateArray() + .First(preset => preset.GetProperty("id").GetString() == "default"); + Assert.Equal( + "realismByStableYogi_v25INT8Turbo.safetensors", + defaultPreset.GetProperty("model").GetString()); } [Fact] diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs index 3b65033..56a9b36 100644 --- a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -7,18 +7,20 @@ namespace HSchool.Server.Tests; public class PortraitPromptBuilderTests { [Fact] - public void Build_IncludesAgeHairAndWornClothing() + public void Build_DescribesAppearanceAndClothingInProse() { var profile = SamplePreset().ToProfile(PortraitKind.Avatar); var card = SampleCard(); var (positive, _) = PortraitPromptBuilder.Build(card, profile, PortraitKind.Avatar); - Assert.Contains("age 12", positive, StringComparison.OrdinalIgnoreCase); - Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase); - Assert.Contains("shirt", positive, StringComparison.OrdinalIgnoreCase); - Assert.Contains("white", positive, StringComparison.OrdinalIgnoreCase); + Assert.Contains("12 years old", positive, StringComparison.Ordinal); + Assert.Contains("black hair", positive, StringComparison.OrdinalIgnoreCase); + Assert.Contains("wearing a white shirt", positive, StringComparison.OrdinalIgnoreCase); Assert.Contains("Head and shoulders.", positive, StringComparison.Ordinal); + Assert.DoesNotContain("age 12", positive, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Hair colour", positive, StringComparison.Ordinal); + Assert.DoesNotContain("wearing shirt in white", positive, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -66,7 +68,7 @@ public class PortraitPromptBuilderTests var styleAt = positive.IndexOf("School photo.", StringComparison.Ordinal); var shotAt = positive.IndexOf("Head and shoulders.", StringComparison.Ordinal); var subjectAt = positive.IndexOf("teenage girl", StringComparison.OrdinalIgnoreCase); - var clothesAt = positive.IndexOf("wearing shirt", StringComparison.OrdinalIgnoreCase); + var clothesAt = positive.IndexOf("wearing a white shirt", StringComparison.OrdinalIgnoreCase); Assert.True(modelAt >= 0 && styleAt > modelAt && shotAt > styleAt && subjectAt > shotAt && clothesAt > subjectAt); Assert.DoesNotContain("pose", positive, StringComparison.OrdinalIgnoreCase); @@ -80,6 +82,31 @@ public class PortraitPromptBuilderTests var (positive, _) = PortraitPromptBuilder.Build(child, profile, PortraitKind.Avatar); Assert.Contains("young girl", positive, StringComparison.OrdinalIgnoreCase); + Assert.Contains("8 years old", positive, StringComparison.Ordinal); + } + + [Fact] + public void Build_TornClothing_PrefixesCondition() + { + var profile = SamplePreset().ToProfile(PortraitKind.Avatar); + var card = SampleCard() with + { + Worn = + [ + new WornItemResponse( + "Shirt", + "Shirt", + "White", + "White", + [new DefLabelResponse(ApparelLayers.Top, "Top")], + 0.2f, + "torn", + [ApparelLayers.Underwear]), + ], + }; + var (positive, _) = PortraitPromptBuilder.Build(card, profile, PortraitKind.Avatar); + + Assert.Contains("wearing a torn white shirt", positive, StringComparison.OrdinalIgnoreCase); } private static SwarmUiPresetDefinition SamplePreset() diff --git a/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs index 9fad570..fa028ed 100644 --- a/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs @@ -23,9 +23,11 @@ public class SwarmUiSettingsStoreTests var profile = config.Resolve(34, PortraitKind.Full); Assert.Equal("default", profile.PresetId); - Assert.Equal(SwarmUiModelDefinition.BabesId, profile.Model); - Assert.Equal(7, profile.Steps); - Assert.Equal(1.5, profile.CfgScale); + Assert.Equal(SwarmUiModelDefinition.RealismV25Id, profile.Model); + Assert.Equal(8, profile.Steps); + Assert.Equal(1, profile.CfgScale); + Assert.Equal("euler", profile.Sampler); + Assert.Equal("simple", profile.Scheduler); } [Fact] @@ -196,7 +198,7 @@ public class SwarmUiSettingsStoreTests } [Fact] - public void CreateDefault_SeedsEveryKnownSwarmModel() + public void CreateDefault_SeedsCatalogModels() { var config = SwarmUiConfigFile.CreateDefault(); var ids = config.Models.Select(model => model.Id).ToList(); @@ -205,5 +207,20 @@ public class SwarmUiSettingsStoreTests Assert.Contains(SwarmUiModelDefinition.DreamShaperId, ids); Assert.Contains(SwarmUiModelDefinition.EpicRealismId, ids); Assert.Contains(SwarmUiModelDefinition.LustifyId, ids); + Assert.Contains(SwarmUiModelDefinition.MuseId, ids); + Assert.Contains(SwarmUiModelDefinition.RealismV25Id, ids); + Assert.DoesNotContain("realismByStableYogi_v20TurboGGUF.gguf", ids); + + var realism = Assert.Single(config.Models, model => model.Id == SwarmUiModelDefinition.RealismV25Id); + Assert.Equal(8, realism.Steps); + Assert.Equal(1, realism.CfgScale); + Assert.Equal("euler", realism.Sampler); + Assert.Equal("simple", realism.Scheduler); + + var muse = Assert.Single(config.Models, model => model.Id == SwarmUiModelDefinition.MuseId); + Assert.Equal(8, muse.Steps); + Assert.Equal(1, muse.CfgScale); + Assert.Equal("euler", muse.Sampler); + Assert.Equal("simple", muse.Scheduler); } }