diff --git a/Assemblies/AIImages.dll b/Assemblies/AIImages.dll index 3ae3f55..35453bd 100644 Binary files a/Assemblies/AIImages.dll and b/Assemblies/AIImages.dll differ diff --git a/Languages/English/Keyed/AIImages.xml b/Languages/English/Keyed/AIImages.xml index 7091f44..28a0bce 100644 --- a/Languages/English/Keyed/AIImages.xml +++ b/Languages/English/Keyed/AIImages.xml @@ -2,19 +2,21 @@ AI Image - Open AI Image window + Open AI Image window to generate character portraits - AI Image Window + AI Image Generator Character: {0} + Refresh Appearance Appearance information unavailable Gender: {0} Age: {0} years Body type: {0} - Skin color: RGB({0}, {1}, {2}) + Skin tone: {0} Hairstyle: {0} - Hair color: RGB({0}, {1}, {2}) + Hair color: {0} + Beard: {0} Traits: Apparel @@ -24,9 +26,45 @@ Quality: {0} Material: {0} Durability: {0}/{1} ({2}%) - Color: RGB({0}, {1}, {2}) + Color: {0} Stable Diffusion Prompt Copy Prompt Copied! + + Generate Image + Generating... + Generating image, please wait... + Image generated successfully! + Generation failed + Image saved to: {0} + No image generated yet.\nClick "Generate Image" to start. + + API Settings + Configure connection to Stable Diffusion API + API Endpoint + Test Connection + Load Available Models + Successfully connected to API! + Failed to connect to API. Check endpoint and ensure Stable Diffusion WebUI is running. + Loaded {0} models from API + No models found. Check API connection. + Generation Settings + Configure image generation parameters + Art Style + Shot Type + Sampling Steps + CFG Scale + Width + Height + Sampler + Prompts + Base prompts that will be added to all generations + Base Positive Prompt + Base Negative Prompt + Options + Auto-load models on startup + Show technical information + Save generation history + Save Path diff --git a/Languages/Russian/Keyed/AIImages.xml b/Languages/Russian/Keyed/AIImages.xml index dc4375d..fcdd1d7 100644 --- a/Languages/Russian/Keyed/AIImages.xml +++ b/Languages/Russian/Keyed/AIImages.xml @@ -1,22 +1,24 @@ - - AI Изображение - Открыть окно AI изображения - - Окно AI изображения + + AI Портрет + Открыть окно генерации AI портретов персонажа + + Генератор AI Изображений Персонаж: {0} - + Обновить + Внешность Информация о внешности недоступна Пол: {0} Возраст: {0} лет Тип тела: {0} - Цвет кожи: RGB({0}, {1}, {2}) + Тон кожи: {0} Прическа: {0} - Цвет волос: RGB({0}, {1}, {2}) + Цвет волос: {0} + Борода: {0} Черты характера: - + Одежда Информация об одежде недоступна Персонаж ничего не носит @@ -24,9 +26,45 @@ Качество: {0} Материал: {0} Прочность: {0}/{1} ({2}%) - Цвет: RGB({0}, {1}, {2}) - - Промпт для Stable Diffusion + Цвет: {0} + + Промпт Stable Diffusion Копировать промпт Скопировано! + + Сгенерировать изображение + Генерация... + Генерируется изображение, пожалуйста подождите... + Изображение успешно сгенерировано! + Ошибка генерации + Изображение сохранено в: {0} + Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала. + + Настройки API + Настройка подключения к API Stable Diffusion + Адрес API + Проверить соединение + Загрузить доступные модели + Успешное подключение к API! + Не удалось подключиться к API. Проверьте адрес и убедитесь, что Stable Diffusion WebUI запущен. + Загружено {0} моделей из API + Модели не найдены. Проверьте подключение к API. + Настройки генерации + Настройка параметров генерации изображений + Художественный стиль + Тип кадра + Количество шагов сэмплирования + CFG Scale + Ширина + Высота + Сэмплер + Промпты + Базовые промпты, которые будут добавлены ко всем генерациям + Базовый позитивный промпт + Базовый негативный промпт + Опции + Автоматически загружать модели при запуске + Показывать техническую информацию + Сохранять историю генераций + Путь для сохранения diff --git a/Source/AIImages/AIImagesMod.cs b/Source/AIImages/AIImagesMod.cs index 4aac809..8700b50 100644 --- a/Source/AIImages/AIImagesMod.cs +++ b/Source/AIImages/AIImagesMod.cs @@ -1,19 +1,59 @@ +using AIImages.Services; +using AIImages.Settings; using HarmonyLib; +using UnityEngine; using Verse; namespace AIImages { /// - /// Main mod class that initializes Harmony patches + /// Main mod class with settings support + /// + public class AIImagesMod : Mod + { + public static AIImagesModSettings Settings { get; private set; } + + // Singleton сервисы + public static IPawnDescriptionService PawnDescriptionService { get; private set; } + public static IPromptGeneratorService PromptGeneratorService { get; private set; } + public static IStableDiffusionApiService ApiService { get; private set; } + + public AIImagesMod(ModContentPack content) + : base(content) + { + Settings = GetSettings(); + + // Инициализируем сервисы + PawnDescriptionService = new PawnDescriptionService(); + PromptGeneratorService = new AdvancedPromptGenerator(); + ApiService = new StableDiffusionApiService(Settings.savePath); + + Log.Message("[AI Images] Mod initialized successfully with settings"); + } + + public override void DoSettingsWindowContents(Rect inRect) + { + AIImagesSettingsUI.DoSettingsWindowContents(inRect, Settings); + base.DoSettingsWindowContents(inRect); + } + + public override string SettingsCategory() + { + return "AI Images"; + } + } + + /// + /// Static constructor for Harmony patches /// [StaticConstructorOnStartup] - public static class AIImagesMod + public static class AIImagesHarmonyPatcher { - static AIImagesMod() + static AIImagesHarmonyPatcher() { var harmony = new Harmony("Mrleo1nid.aiimages"); harmony.PatchAll(); - Log.Message("[AI Images] Mod initialized successfully"); + Log.Message("[AI Images] Harmony patches applied successfully"); } } } diff --git a/Source/AIImages/Models/GenerationRequest.cs b/Source/AIImages/Models/GenerationRequest.cs new file mode 100644 index 0000000..3843729 --- /dev/null +++ b/Source/AIImages/Models/GenerationRequest.cs @@ -0,0 +1,50 @@ +namespace AIImages.Models +{ + /// + /// Запрос на генерацию изображения + /// + public class GenerationRequest + { + public string Prompt { get; set; } + public string NegativePrompt { get; set; } + public int Steps { get; set; } + public float CfgScale { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public string Sampler { get; set; } + public int Seed { get; set; } + public string Model { get; set; } + } + + /// + /// Результат генерации изображения + /// + public class GenerationResult + { + public bool Success { get; set; } + public byte[] ImageData { get; set; } + public string ErrorMessage { get; set; } + public string SavedPath { get; set; } + public GenerationRequest Request { get; set; } + + public static GenerationResult Failure(string error) + { + return new GenerationResult { Success = false, ErrorMessage = error }; + } + + public static GenerationResult SuccessResult( + byte[] imageData, + string savedPath, + GenerationRequest request + ) + { + return new GenerationResult + { + Success = true, + ImageData = imageData, + SavedPath = savedPath, + Request = request, + }; + } + } +} diff --git a/Source/AIImages/Models/PawnAppearanceData.cs b/Source/AIImages/Models/PawnAppearanceData.cs new file mode 100644 index 0000000..41c3e91 --- /dev/null +++ b/Source/AIImages/Models/PawnAppearanceData.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using RimWorld; +using UnityEngine; +using Verse; + +namespace AIImages.Models +{ + /// + /// Модель данных внешности персонажа для генерации промптов + /// + public class PawnAppearanceData + { + public string Name { get; set; } + public Gender Gender { get; set; } + public int Age { get; set; } + public string BodyType { get; set; } + public Color SkinColor { get; set; } + public string HairStyle { get; set; } + public Color HairColor { get; set; } + public List Traits { get; set; } + public List Apparel { get; set; } + + public PawnAppearanceData() + { + Traits = new List(); + Apparel = new List(); + } + } + + /// + /// Данные об одежде персонажа + /// + public class ApparelData + { + public string Label { get; set; } + public string Material { get; set; } + public QualityCategory? Quality { get; set; } + public Color Color { get; set; } + public string LayerType { get; set; } + public int Durability { get; set; } + public int MaxDurability { get; set; } + + public float DurabilityPercent => + MaxDurability > 0 ? (float)Durability / MaxDurability : 1f; + } +} diff --git a/Source/AIImages/Models/StableDiffusionSettings.cs b/Source/AIImages/Models/StableDiffusionSettings.cs new file mode 100644 index 0000000..0b684de --- /dev/null +++ b/Source/AIImages/Models/StableDiffusionSettings.cs @@ -0,0 +1,62 @@ +namespace AIImages.Models +{ + /// + /// Настройки для генерации изображений через Stable Diffusion + /// + public class StableDiffusionSettings + { + public string PositivePrompt { get; set; } + public string NegativePrompt { get; set; } + public int Steps { get; set; } + public float CfgScale { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public string Sampler { get; set; } + public int Seed { get; set; } + public string Model { get; set; } + public ArtStyle ArtStyle { get; set; } + public ShotType ShotType { get; set; } + + public StableDiffusionSettings() + { + // Значения по умолчанию + Steps = 30; + CfgScale = 7.5f; + Width = 512; + Height = 768; + Sampler = "Euler a"; + Seed = -1; // Случайный seed + ArtStyle = ArtStyle.Realistic; + ShotType = ShotType.Portrait; + PositivePrompt = ""; + NegativePrompt = "ugly, deformed, low quality, blurry, bad anatomy, worst quality"; + } + } + + /// + /// Художественный стиль изображения + /// + public enum ArtStyle + { + Realistic, + SemiRealistic, + Anime, + ConceptArt, + DigitalPainting, + OilPainting, + Sketch, + CellShaded, + } + + /// + /// Тип кадра/композиции + /// + public enum ShotType + { + Portrait, // Портрет (голова и плечи) + HalfBody, // Половина тела + FullBody, // Полное тело + CloseUp, // Крупный план + ThreeQuarter, // Три четверти + } +} diff --git a/Source/AIImages/Services/AdvancedPromptGenerator.cs b/Source/AIImages/Services/AdvancedPromptGenerator.cs new file mode 100644 index 0000000..cda4066 --- /dev/null +++ b/Source/AIImages/Services/AdvancedPromptGenerator.cs @@ -0,0 +1,353 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using AIImages.Models; +using RimWorld; +using Verse; + +namespace AIImages.Services +{ + /// + /// Продвинутый генератор промптов для Stable Diffusion + /// + public class AdvancedPromptGenerator : IPromptGeneratorService + { + private static readonly Dictionary TraitToMood = new Dictionary< + string, + string + > + { + { "Kind", "warm smile, gentle expression, friendly eyes" }, + { "Bloodlust", "intense gaze, fierce expression, aggressive posture" }, + { "Psychopath", "cold eyes, emotionless face, calculating expression" }, + { "Pessimist", "sad eyes, worried expression, downcast gaze" }, + { "Optimist", "bright smile, hopeful expression, cheerful demeanor" }, + { "Nervous", "anxious expression, tense posture, worried eyes" }, + { "Careful", "focused expression, attentive gaze, composed posture" }, + { "Brave", "confident expression, determined gaze, strong posture" }, + { "Wimp", "fearful eyes, nervous expression, timid posture" }, + { "Greedy", "cunning expression, calculating eyes, sly smile" }, + { "Jealous", "envious gaze, bitter expression, tense face" }, + { "Ascetic", "serene expression, calm demeanor, peaceful eyes" }, + { "Beautiful", "stunning features, attractive appearance, graceful" }, + { "Ugly", "rough features, weathered appearance" }, + { "Pretty", "attractive features, pleasant appearance, charming" }, + }; + + private static readonly Dictionary ArtStylePrompts = new Dictionary< + ArtStyle, + string + > + { + { ArtStyle.Realistic, "photorealistic, hyperrealistic, realistic photo, photography" }, + { ArtStyle.SemiRealistic, "semi-realistic, detailed illustration, realistic art" }, + { ArtStyle.Anime, "anime style, manga style, anime character" }, + { + ArtStyle.ConceptArt, + "concept art, digital art, artstation, professional concept design" + }, + { ArtStyle.DigitalPainting, "digital painting, painterly, brush strokes, artistic" }, + { ArtStyle.OilPainting, "oil painting, traditional painting, canvas, fine art" }, + { ArtStyle.Sketch, "pencil sketch, hand drawn, sketch art, line art" }, + { ArtStyle.CellShaded, "cell shaded, flat colors, toon shading, stylized" }, + }; + + private static readonly Dictionary ShotTypePrompts = new Dictionary< + ShotType, + string + > + { + { ShotType.Portrait, "portrait, head and shoulders" }, + { ShotType.HalfBody, "half body shot, waist up" }, + { ShotType.FullBody, "full body, full length" }, + { ShotType.CloseUp, "close up, face focus, detailed face" }, + { ShotType.ThreeQuarter, "three-quarter view, 3/4 view" }, + }; + + public string GeneratePositivePrompt( + PawnAppearanceData appearanceData, + StableDiffusionSettings settings + ) + { + if (appearanceData == null) + return "portrait of a person"; + + StringBuilder prompt = new StringBuilder(); + + // 1. Художественный стиль + if (ArtStylePrompts.TryGetValue(settings.ArtStyle, out string stylePrompt)) + { + prompt.Append(stylePrompt); + prompt.Append(", "); + } + + // 2. Тип кадра + if (ShotTypePrompts.TryGetValue(settings.ShotType, out string shotPrompt)) + { + prompt.Append(shotPrompt); + prompt.Append(" of "); + } + + // 3. Базовое описание (возраст и пол) + prompt.Append(GetAgeAndGenderDescription(appearanceData)); + prompt.Append(", "); + + // 4. Тип тела + string bodyType = GetBodyTypeDescription(appearanceData.BodyType); + if (!string.IsNullOrEmpty(bodyType)) + { + prompt.Append(bodyType); + prompt.Append(", "); + } + + // 5. Цвет кожи + string skinTone = ColorDescriptionService.GetSkinToneDescription( + appearanceData.SkinColor + ); + prompt.Append(skinTone); + prompt.Append(", "); + + // 6. Волосы + string hairDescription = GetHairDescription(appearanceData); + if (!string.IsNullOrEmpty(hairDescription)) + { + prompt.Append(hairDescription); + prompt.Append(", "); + } + + // 7. Настроение и выражение на основе черт характера + string moodDescription = GetMoodFromTraits(appearanceData.Traits); + if (!string.IsNullOrEmpty(moodDescription)) + { + prompt.Append(moodDescription); + prompt.Append(", "); + } + + // 8. Одежда + string apparelDescription = GetApparelDescription(appearanceData.Apparel); + if (!string.IsNullOrEmpty(apparelDescription)) + { + prompt.Append(apparelDescription); + prompt.Append(", "); + } + + // 9. Базовый пользовательский промпт (если указан) + if (!string.IsNullOrEmpty(settings.PositivePrompt)) + { + prompt.Append(settings.PositivePrompt); + prompt.Append(", "); + } + + // 10. Качественные теги + prompt.Append(GetQualityTags(settings.ArtStyle)); + + return prompt.ToString().Trim().TrimEnd(','); + } + + public string GenerateNegativePrompt(StableDiffusionSettings settings) + { + StringBuilder negativePrompt = new StringBuilder(); + + // Базовые негативные промпты + negativePrompt.Append( + "ugly, deformed, low quality, blurry, bad anatomy, worst quality, " + ); + negativePrompt.Append( + "mutated, disfigured, bad proportions, extra limbs, missing limbs, " + ); + + // Специфичные для стиля негативы + if ( + settings.ArtStyle == ArtStyle.Realistic + || settings.ArtStyle == ArtStyle.SemiRealistic + ) + { + negativePrompt.Append("cartoon, anime, painting, drawing, illustration, "); + } + else if (settings.ArtStyle == ArtStyle.Anime) + { + negativePrompt.Append("realistic, photo, photography, 3d, "); + } + + // Пользовательский негативный промпт + if (!string.IsNullOrEmpty(settings.NegativePrompt)) + { + negativePrompt.Append(settings.NegativePrompt); + } + + return negativePrompt.ToString().Trim().TrimEnd(','); + } + + public string GetFullPromptDescription( + PawnAppearanceData appearanceData, + StableDiffusionSettings settings + ) + { + StringBuilder description = new StringBuilder(); + + description.AppendLine("=== Positive Prompt ==="); + description.AppendLine(GeneratePositivePrompt(appearanceData, settings)); + description.AppendLine(); + + description.AppendLine("=== Negative Prompt ==="); + description.AppendLine(GenerateNegativePrompt(settings)); + description.AppendLine(); + + description.AppendLine("=== Technical Parameters ==="); + description.AppendLine($"Steps: {settings.Steps}"); + description.AppendLine($"CFG Scale: {settings.CfgScale}"); + description.AppendLine($"Size: {settings.Width}x{settings.Height}"); + description.AppendLine($"Sampler: {settings.Sampler}"); + description.AppendLine( + $"Seed: {(settings.Seed == -1 ? "Random" : settings.Seed.ToString())}" + ); + + return description.ToString(); + } + + private string GetAgeAndGenderDescription(PawnAppearanceData data) + { + string ageGroup = data.Age switch + { + < 18 => "young", + < 25 => "young adult", + < 35 => "adult", + < 50 => "middle-aged", + < 65 => "mature", + _ => "elderly", + }; + + string genderLabel = data.Gender switch + { + Gender.Male => "man", + Gender.Female => "woman", + _ => "person", + }; + return $"{ageGroup} {genderLabel}"; + } + + private string GetBodyTypeDescription(string bodyType) + { + if (string.IsNullOrEmpty(bodyType)) + return ""; + + return bodyType.ToLower() switch + { + "thin" => "slender build, lean physique", + "hulk" => "muscular build, strong physique, athletic body", + "fat" => "heavyset build, stocky physique", + "female" => "feminine build", + "male" => "masculine build", + _ => "average build", + }; + } + + private string GetHairDescription(PawnAppearanceData data) + { + if (string.IsNullOrEmpty(data.HairStyle)) + return ""; + + StringBuilder hair = new StringBuilder(); + + // Цвет волос + string hairColor = ColorDescriptionService.GetHairColorDescription(data.HairColor); + hair.Append(hairColor); + hair.Append(" "); + + // Стиль прически (упрощаем сложные названия) + string style = data + .HairStyle.ToLower() + .Replace("_", " ") + .Replace("shaved", "very short") + .Replace("mohawk", "mohawk hairstyle"); + + hair.Append(style); + hair.Append(" hair"); + + return hair.ToString(); + } + + private string GetMoodFromTraits(List traits) + { + if (traits == null || !traits.Any()) + return "neutral expression"; + + // Ищем черты, которые влияют на внешний вид + foreach (var trait in traits) + { + string traitDefName = trait.def.defName; + if (TraitToMood.TryGetValue(traitDefName, out string mood)) + { + return mood; + } + } + + return "calm expression"; + } + + private string GetApparelDescription(List apparel) + { + if (apparel == null || !apparel.Any()) + return "simple clothes"; + + StringBuilder apparelDesc = new StringBuilder("wearing "); + + // Берем топ 5 наиболее заметных предметов одежды + var visibleApparel = apparel.Take(5).ToList(); + + List items = new List(); + foreach (var item in visibleApparel) + { + StringBuilder itemDesc = new StringBuilder(); + + // Цвет (если не белый) + if (item.Color != UnityEngine.Color.white) + { + string colorDesc = ColorDescriptionService.GetApparelColorDescription( + item.Color + ); + itemDesc.Append(colorDesc); + itemDesc.Append(" "); + } + + // Материал + if (!string.IsNullOrEmpty(item.Material)) + { + itemDesc.Append(item.Material.ToLower()); + itemDesc.Append(" "); + } + + // Название предмета + itemDesc.Append(item.Label.ToLower()); + + items.Add(itemDesc.ToString()); + } + + apparelDesc.Append(string.Join(", ", items)); + + return apparelDesc.ToString(); + } + + private string GetQualityTags(ArtStyle style) + { + var baseTags = "highly detailed, professional, masterpiece, best quality"; + + if (style == ArtStyle.Realistic || style == ArtStyle.SemiRealistic) + { + return $"{baseTags}, professional photography, 8k uhd, dslr, high quality, sharp focus"; + } + else if (style == ArtStyle.Anime) + { + return $"{baseTags}, anime masterpiece, high resolution, vibrant colors"; + } + else if (style == ArtStyle.ConceptArt) + { + return $"{baseTags}, trending on artstation, professional digital art"; + } + else + { + return baseTags; + } + } + } +} diff --git a/Source/AIImages/Services/ColorDescriptionService.cs b/Source/AIImages/Services/ColorDescriptionService.cs new file mode 100644 index 0000000..5e1c5c7 --- /dev/null +++ b/Source/AIImages/Services/ColorDescriptionService.cs @@ -0,0 +1,165 @@ +using UnityEngine; + +namespace AIImages.Services +{ + /// + /// Сервис для умного определения цветов (вместо RGB значений) + /// + public static class ColorDescriptionService + { + /// + /// Получает текстовое описание цвета волос + /// + public static string GetHairColorDescription(Color color) + { + float h, + s, + v; + Color.RGBToHSV(color, out h, out s, out v); + + // Проверяем на оттенки серого + if (s < 0.15f) + { + return GetGrayscaleDescription(v); + } + + // Определяем оттенок + string hueDescription = GetHueDescription(h); + string brightnessModifier = GetBrightnessModifier(v, s); + + return $"{brightnessModifier}{hueDescription}".Trim(); + } + + /// + /// Получает текстовое описание цвета кожи + /// + public static string GetSkinToneDescription(Color color) + { + // Вычисляем яркость + float brightness = (color.r + color.g + color.b) / 3f; + + // Определяем оттенок кожи + if (brightness >= 0.85f) + return "very fair skin"; + else if (brightness >= 0.75f) + return "fair skin"; + else if (brightness >= 0.65f) + return "light skin"; + else if (brightness >= 0.55f) + return "medium skin"; + else if (brightness >= 0.45f) + return "olive skin"; + else if (brightness >= 0.35f) + return "tan skin"; + else if (brightness >= 0.25f) + return "brown skin"; + else if (brightness >= 0.15f) + return "dark brown skin"; + else + return "very dark skin"; + } + + /// + /// Получает описание цвета одежды + /// + public static string GetApparelColorDescription(Color color) + { + float h, + s, + v; + Color.RGBToHSV(color, out h, out s, out v); + + // Проверяем на оттенки серого + if (s < 0.1f) + { + return GetGrayscaleDescription(v); + } + + // Определяем оттенок + string hueDescription = GetHueDescription(h); + + // Модификаторы насыщенности и яркости + string saturationModifier = ""; + if (s < 0.3f) + saturationModifier = "pale "; + else if (s > 0.8f) + saturationModifier = "vivid "; + + string brightnessModifier = ""; + if (v < 0.3f) + brightnessModifier = "dark "; + else if (v > 0.8f) + brightnessModifier = "bright "; + + return $"{brightnessModifier}{saturationModifier}{hueDescription}".Trim(); + } + + private static string GetGrayscaleDescription(float value) + { + if (value >= 0.95f) + return "white"; + else if (value >= 0.8f) + return "light gray"; + else if (value >= 0.6f) + return "gray"; + else if (value >= 0.4f) + return "dark gray"; + else if (value >= 0.2f) + return "charcoal"; + else + return "black"; + } + + private static string GetHueDescription(float hue) + { + // Hue from 0 to 1 + if (hue < 0.05f || hue >= 0.95f) + return "red"; + if (hue < 0.083f) + return "orange-red"; + if (hue < 0.15f) + return "orange"; + if (hue < 0.19f) + return "golden"; + if (hue < 0.22f) + return "yellow"; + if (hue < 0.35f) + return "yellow-green"; + if (hue < 0.45f) + return "green"; + if (hue < 0.52f) + return "cyan"; + if (hue < 0.58f) + return "light blue"; + if (hue < 0.65f) + return "blue"; + if (hue < 0.72f) + return "deep blue"; + if (hue < 0.78f) + return "purple"; + if (hue < 0.85f) + return "violet"; + if (hue < 0.92f) + return "magenta"; + + return "pink"; + } + + private static string GetBrightnessModifier(float value, float saturation) + { + // Для волос используем специальные термины + if (value < 0.2f) + return "jet black "; + else if (value < 0.3f) + return "black "; + else if (value < 0.45f) + return "dark "; + else if (value > 0.85f && saturation < 0.3f) + return "platinum "; + else if (value > 0.75f) + return "light "; + else + return ""; + } + } +} diff --git a/Source/AIImages/Services/IPawnDescriptionService.cs b/Source/AIImages/Services/IPawnDescriptionService.cs new file mode 100644 index 0000000..d8948f4 --- /dev/null +++ b/Source/AIImages/Services/IPawnDescriptionService.cs @@ -0,0 +1,26 @@ +using AIImages.Models; +using Verse; + +namespace AIImages.Services +{ + /// + /// Интерфейс сервиса для извлечения данных о внешности персонажа + /// + public interface IPawnDescriptionService + { + /// + /// Извлекает данные о внешности персонажа + /// + PawnAppearanceData ExtractAppearanceData(Pawn pawn); + + /// + /// Получает текстовое описание внешности для отображения в UI + /// + string GetAppearanceDescription(Pawn pawn); + + /// + /// Получает текстовое описание одежды для отображения в UI + /// + string GetApparelDescription(Pawn pawn); + } +} diff --git a/Source/AIImages/Services/IPromptGeneratorService.cs b/Source/AIImages/Services/IPromptGeneratorService.cs new file mode 100644 index 0000000..34e4301 --- /dev/null +++ b/Source/AIImages/Services/IPromptGeneratorService.cs @@ -0,0 +1,31 @@ +using AIImages.Models; + +namespace AIImages.Services +{ + /// + /// Интерфейс сервиса для генерации промптов Stable Diffusion + /// + public interface IPromptGeneratorService + { + /// + /// Генерирует позитивный промпт на основе данных о персонаже + /// + string GeneratePositivePrompt( + PawnAppearanceData appearanceData, + StableDiffusionSettings settings + ); + + /// + /// Генерирует негативный промпт на основе настроек + /// + string GenerateNegativePrompt(StableDiffusionSettings settings); + + /// + /// Получает полное описание промпта (позитивный + негативный) для отображения + /// + string GetFullPromptDescription( + PawnAppearanceData appearanceData, + StableDiffusionSettings settings + ); + } +} diff --git a/Source/AIImages/Services/IStableDiffusionApiService.cs b/Source/AIImages/Services/IStableDiffusionApiService.cs new file mode 100644 index 0000000..247175c --- /dev/null +++ b/Source/AIImages/Services/IStableDiffusionApiService.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using AIImages.Models; + +namespace AIImages.Services +{ + /// + /// Интерфейс сервиса для работы с Stable Diffusion API + /// + public interface IStableDiffusionApiService + { + /// + /// Генерирует изображение на основе запроса + /// + Task GenerateImageAsync(GenerationRequest request); + + /// + /// Проверяет доступность API + /// + Task CheckApiAvailability(string apiEndpoint); + + /// + /// Получает список доступных моделей с API + /// + Task> GetAvailableModels(string apiEndpoint); + + /// + /// Получает список доступных сэмплеров + /// + Task> GetAvailableSamplers(string apiEndpoint); + } +} diff --git a/Source/AIImages/Services/PawnDescriptionService.cs b/Source/AIImages/Services/PawnDescriptionService.cs new file mode 100644 index 0000000..2dace17 --- /dev/null +++ b/Source/AIImages/Services/PawnDescriptionService.cs @@ -0,0 +1,183 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using AIImages.Models; +using RimWorld; +using Verse; + +namespace AIImages.Services +{ + /// + /// Сервис для извлечения и описания данных о внешности персонажа + /// + public class PawnDescriptionService : IPawnDescriptionService + { + public PawnAppearanceData ExtractAppearanceData(Pawn pawn) + { + if (pawn?.story == null) + return null; + + var data = new PawnAppearanceData + { + Name = pawn.Name?.ToStringShort ?? "Unknown", + Gender = pawn.gender, + Age = pawn.ageTracker.AgeBiologicalYears, + BodyType = pawn.story.bodyType?.defName, + SkinColor = pawn.story.SkinColor, + HairStyle = pawn.story.hairDef?.label, + HairColor = pawn.story.HairColor, + }; + + // Извлекаем черты характера + if (pawn.story.traits?.allTraits != null) + { + data.Traits.AddRange(pawn.story.traits.allTraits); + } + + // Извлекаем одежду + if (pawn.apparel?.WornApparel != null) + { + foreach (var apparel in pawn.apparel.WornApparel) + { + var apparelData = new ApparelData + { + Label = apparel.def.label, + Material = apparel.Stuff?.label, + Color = apparel.DrawColor, + LayerType = apparel.def.apparel?.LastLayer.ToString(), + Durability = apparel.HitPoints, + MaxDurability = apparel.MaxHitPoints, + }; + + if (apparel.TryGetQuality(out QualityCategory quality)) + { + apparelData.Quality = quality; + } + + data.Apparel.Add(apparelData); + } + } + + return data; + } + + public string GetAppearanceDescription(Pawn pawn) + { + if (pawn?.story == null) + return "AIImages.Appearance.NoInfo".Translate(); + + StringBuilder sb = new StringBuilder(); + + // Пол + sb.AppendLine("AIImages.Appearance.Gender".Translate(pawn.gender.GetLabel())); + + // Возраст + sb.AppendLine("AIImages.Appearance.Age".Translate(pawn.ageTracker.AgeBiologicalYears)); + + // Тип тела + if (pawn.story.bodyType != null) + { + sb.AppendLine( + "AIImages.Appearance.BodyType".Translate(pawn.story.bodyType.defName) + ); + } + + // Цвет кожи (с умным описанием) + if (pawn.story.SkinColor != null) + { + string skinDescription = ColorDescriptionService.GetSkinToneDescription( + pawn.story.SkinColor + ); + sb.AppendLine("AIImages.Appearance.SkinTone".Translate(skinDescription)); + } + + // Волосы + if (pawn.story.hairDef != null) + { + sb.AppendLine("AIImages.Appearance.Hairstyle".Translate(pawn.story.hairDef.label)); + if (pawn.story.HairColor != null) + { + string hairColorDescription = ColorDescriptionService.GetHairColorDescription( + pawn.story.HairColor + ); + sb.AppendLine( + "AIImages.Appearance.HairColorDesc".Translate(hairColorDescription) + ); + } + } + + // Черты характера + if (pawn.story.traits?.allTraits != null && pawn.story.traits.allTraits.Any()) + { + sb.AppendLine("\n" + "AIImages.Appearance.Traits".Translate()); + foreach (var trait in pawn.story.traits.allTraits) + { + sb.AppendLine($" • {trait.LabelCap}"); + } + } + + return sb.ToString(); + } + + public string GetApparelDescription(Pawn pawn) + { + if (pawn?.apparel == null) + return "AIImages.Apparel.NoInfo".Translate(); + + StringBuilder sb = new StringBuilder(); + List wornApparel = pawn.apparel.WornApparel; + + if (wornApparel == null || !wornApparel.Any()) + { + sb.AppendLine("AIImages.Apparel.NoClothes".Translate()); + } + else + { + sb.AppendLine("AIImages.Apparel.ListHeader".Translate(wornApparel.Count) + "\n"); + foreach (Apparel apparel in wornApparel) + { + FormatApparelItem(sb, apparel); + } + } + + return sb.ToString(); + } + + private void FormatApparelItem(StringBuilder sb, Apparel apparel) + { + sb.AppendLine($"• {apparel.LabelCap}"); + + if (apparel.TryGetQuality(out QualityCategory quality)) + { + sb.AppendLine("AIImages.Apparel.Quality".Translate(quality.GetLabel())); + } + + if (apparel.Stuff != null) + { + sb.AppendLine("AIImages.Apparel.Material".Translate(apparel.Stuff.LabelCap)); + } + + if (apparel.HitPoints < apparel.MaxHitPoints) + { + int percentage = (int)((float)apparel.HitPoints / apparel.MaxHitPoints * 100); + sb.AppendLine( + "AIImages.Apparel.Durability".Translate( + apparel.HitPoints, + apparel.MaxHitPoints, + percentage + ) + ); + } + + if (apparel.DrawColor != UnityEngine.Color.white) + { + string colorDesc = ColorDescriptionService.GetApparelColorDescription( + apparel.DrawColor + ); + sb.AppendLine("AIImages.Apparel.ColorDesc".Translate(colorDesc)); + } + + sb.AppendLine(); + } + } +} diff --git a/Source/AIImages/Services/StableDiffusionApiService.cs b/Source/AIImages/Services/StableDiffusionApiService.cs new file mode 100644 index 0000000..34a409a --- /dev/null +++ b/Source/AIImages/Services/StableDiffusionApiService.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AIImages.Models; +using Newtonsoft.Json; +using Verse; + +namespace AIImages.Services +{ + /// + /// Сервис для работы с Stable Diffusion API (AUTOMATIC1111 WebUI) + /// + public class StableDiffusionApiService : IStableDiffusionApiService + { + private readonly HttpClient httpClient; + private readonly string saveFolderPath; + + public StableDiffusionApiService(string savePath = "AIImages/Generated") + { + httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + + // Определяем путь для сохранения + saveFolderPath = Path.Combine(GenFilePaths.SaveDataFolderPath, savePath); + + // Создаем папку, если не существует + if (!Directory.Exists(saveFolderPath)) + { + Directory.CreateDirectory(saveFolderPath); + } + } + + public async Task GenerateImageAsync(GenerationRequest request) + { + try + { + Log.Message( + $"[AI Images] Starting image generation with prompt: {request.Prompt.Substring(0, Math.Min(50, request.Prompt.Length))}..." + ); + + // Формируем JSON запрос для AUTOMATIC1111 API + var apiRequest = new + { + prompt = request.Prompt, + negative_prompt = request.NegativePrompt, + steps = request.Steps, + cfg_scale = request.CfgScale, + width = request.Width, + height = request.Height, + sampler_name = request.Sampler, + seed = request.Seed, + save_images = false, + send_images = true, + }; + + string jsonRequest = JsonConvert.SerializeObject(apiRequest); + var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); + + // Отправляем запрос + string endpoint = $"{request.Model}/sdapi/v1/txt2img"; + HttpResponseMessage response = await httpClient.PostAsync(endpoint, content); + + if (!response.IsSuccessStatusCode) + { + string errorContent = await response.Content.ReadAsStringAsync(); + Log.Error( + $"[AI Images] API request failed: {response.StatusCode} - {errorContent}" + ); + return GenerationResult.Failure($"API Error: {response.StatusCode}"); + } + + string jsonResponse = await response.Content.ReadAsStringAsync(); + var apiResponse = JsonConvert.DeserializeObject(jsonResponse); + + if (apiResponse?.images == null || apiResponse.images.Length == 0) + { + return GenerationResult.Failure("No images returned from API"); + } + + // Декодируем изображение из base64 + byte[] imageData = Convert.FromBase64String(apiResponse.images[0]); + + // Сохраняем изображение + string fileName = $"pawn_{DateTime.Now:yyyyMMdd_HHmmss}.png"; + string fullPath = Path.Combine(saveFolderPath, fileName); + await File.WriteAllBytesAsync(fullPath, imageData); + + Log.Message($"[AI Images] Image generated successfully and saved to: {fullPath}"); + + return GenerationResult.SuccessResult(imageData, fullPath, request); + } + catch (TaskCanceledException) + { + return GenerationResult.Failure("Request timeout. Generation took too long."); + } + catch (HttpRequestException ex) + { + Log.Error($"[AI Images] HTTP error: {ex.Message}"); + return GenerationResult.Failure($"Connection error: {ex.Message}"); + } + catch (Exception ex) + { + Log.Error($"[AI Images] Unexpected error: {ex.Message}\n{ex.StackTrace}"); + return GenerationResult.Failure($"Error: {ex.Message}"); + } + } + + public async Task CheckApiAvailability(string apiEndpoint) + { + try + { + string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models"; + HttpResponseMessage response = await httpClient.GetAsync(endpoint); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + Log.Warning($"[AI Images] API check failed: {ex.Message}"); + return false; + } + } + + public async Task> GetAvailableModels(string apiEndpoint) + { + try + { + string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models"; + HttpResponseMessage response = await httpClient.GetAsync(endpoint); + + if (!response.IsSuccessStatusCode) + return new List(); + + string jsonResponse = await response.Content.ReadAsStringAsync(); + var models = JsonConvert.DeserializeObject>(jsonResponse); + + var modelNames = new List(); + if (models != null) + { + foreach (var model in models) + { + modelNames.Add(model.title ?? model.model_name); + } + } + + Log.Message($"[AI Images] Found {modelNames.Count} models"); + return modelNames; + } + catch (Exception ex) + { + Log.Error($"[AI Images] Failed to load models: {ex.Message}"); + return new List(); + } + } + + public async Task> GetAvailableSamplers(string apiEndpoint) + { + try + { + string endpoint = $"{apiEndpoint}/sdapi/v1/samplers"; + HttpResponseMessage response = await httpClient.GetAsync(endpoint); + + if (!response.IsSuccessStatusCode) + return GetDefaultSamplers(); + + string jsonResponse = await response.Content.ReadAsStringAsync(); + var samplers = JsonConvert.DeserializeObject>(jsonResponse); + + var samplerNames = new List(); + if (samplers != null) + { + foreach (var sampler in samplers) + { + samplerNames.Add(sampler.name); + } + } + + Log.Message($"[AI Images] Found {samplerNames.Count} samplers"); + return samplerNames; + } + catch (Exception ex) + { + Log.Warning($"[AI Images] Failed to load samplers: {ex.Message}"); + return GetDefaultSamplers(); + } + } + + private List GetDefaultSamplers() + { + return new List + { + "Euler a", + "Euler", + "LMS", + "Heun", + "DPM2", + "DPM2 a", + "DPM++ 2S a", + "DPM++ 2M", + "DPM++ SDE", + "DPM fast", + "DPM adaptive", + "LMS Karras", + "DPM2 Karras", + "DPM2 a Karras", + "DPM++ 2S a Karras", + "DPM++ 2M Karras", + "DPM++ SDE Karras", + "DDIM", + "PLMS", + }; + } + + // Вспомогательные классы для десериализации JSON ответов +#pragma warning disable S3459, S1144 // Properties set by JSON deserializer + private sealed class Txt2ImgResponse + { + public string[] images { get; set; } + } + + private sealed class SdModel + { + public string title { get; set; } + public string model_name { get; set; } + } + + private sealed class SdSampler + { + public string name { get; set; } + } +#pragma warning restore S3459, S1144 + } +} diff --git a/Source/AIImages/Settings/AIImagesModSettings.cs b/Source/AIImages/Settings/AIImagesModSettings.cs new file mode 100644 index 0000000..621726e --- /dev/null +++ b/Source/AIImages/Settings/AIImagesModSettings.cs @@ -0,0 +1,94 @@ +using AIImages.Models; +using Verse; + +namespace AIImages.Settings +{ + /// + /// Настройки мода AI Images + /// +#pragma warning disable S1104 // Fields required for RimWorld's Scribe serialization system + public class AIImagesModSettings : ModSettings + { + // API настройки + public string apiEndpoint = "http://127.0.0.1:7860"; + public string selectedModel = ""; + public string selectedSampler = "Euler a"; + + // Настройки генерации + public int steps = 30; + public float cfgScale = 7.5f; + public int width = 512; + public int height = 768; + public int seed = -1; + + // Промпты + public string basePositivePrompt = ""; + public string baseNegativePrompt = + "ugly, deformed, low quality, blurry, bad anatomy, worst quality"; + + // Художественный стиль + public ArtStyle artStyle = ArtStyle.Realistic; + public ShotType shotType = ShotType.Portrait; + + // Путь для сохранения + public string savePath = "AIImages/Generated"; + + // Флаги + public bool autoLoadModels = true; + public bool showTechnicalInfo = true; + public bool saveGenerationHistory = true; + + public override void ExposeData() + { + Scribe_Values.Look(ref apiEndpoint, "apiEndpoint", "http://127.0.0.1:7860"); + Scribe_Values.Look(ref selectedModel, "selectedModel", ""); + Scribe_Values.Look(ref selectedSampler, "selectedSampler", "Euler a"); + + Scribe_Values.Look(ref steps, "steps", 30); + Scribe_Values.Look(ref cfgScale, "cfgScale", 7.5f); + Scribe_Values.Look(ref width, "width", 512); + Scribe_Values.Look(ref height, "height", 768); + Scribe_Values.Look(ref seed, "seed", -1); + + Scribe_Values.Look(ref basePositivePrompt, "basePositivePrompt", ""); + Scribe_Values.Look( + ref baseNegativePrompt, + "baseNegativePrompt", + "ugly, deformed, low quality, blurry, bad anatomy, worst quality" + ); + + Scribe_Values.Look(ref artStyle, "artStyle", ArtStyle.Realistic); + Scribe_Values.Look(ref shotType, "shotType", ShotType.Portrait); + + Scribe_Values.Look(ref savePath, "savePath", "AIImages/Generated"); + + Scribe_Values.Look(ref autoLoadModels, "autoLoadModels", true); + Scribe_Values.Look(ref showTechnicalInfo, "showTechnicalInfo", true); + Scribe_Values.Look(ref saveGenerationHistory, "saveGenerationHistory", true); + + base.ExposeData(); + } + + /// + /// Создает объект StableDiffusionSettings из настроек мода + /// + public StableDiffusionSettings ToStableDiffusionSettings() + { + return new StableDiffusionSettings + { + Steps = steps, + CfgScale = cfgScale, + Width = width, + Height = height, + Sampler = selectedSampler, + Seed = seed, + Model = selectedModel, + ArtStyle = artStyle, + ShotType = shotType, + PositivePrompt = basePositivePrompt, + NegativePrompt = baseNegativePrompt, + }; + } + } +#pragma warning restore S1104 +} diff --git a/Source/AIImages/UI/AIImagesSettingsUI.cs b/Source/AIImages/UI/AIImagesSettingsUI.cs new file mode 100644 index 0000000..f09e81a --- /dev/null +++ b/Source/AIImages/UI/AIImagesSettingsUI.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AIImages.Models; +using AIImages.Settings; +using RimWorld; +using UnityEngine; +using Verse; + +namespace AIImages +{ + /// + /// UI для настроек мода в меню настроек RimWorld + /// + public static class AIImagesSettingsUI + { + private static Vector2 scrollPosition = Vector2.zero; + private static string stepsBuffer; + private static string widthBuffer; + private static string heightBuffer; + + public static void DoSettingsWindowContents(Rect inRect, AIImagesModSettings settings) + { + // Инициализируем буферы при первом вызове + if (string.IsNullOrEmpty(stepsBuffer)) + { + stepsBuffer = settings.steps.ToString(); + widthBuffer = settings.width.ToString(); + heightBuffer = settings.height.ToString(); + } + + Listing_Standard listingStandard = new Listing_Standard(); + Rect viewRect = new Rect(0f, 0f, inRect.width - 20f, 1200f); + + Widgets.BeginScrollView(inRect, ref scrollPosition, viewRect); + listingStandard.Begin(viewRect); + + // === API Settings === + listingStandard.Label( + "AIImages.Settings.ApiSection".Translate(), + -1f, + "AIImages.Settings.ApiSectionTooltip".Translate() + ); + listingStandard.GapLine(); + + listingStandard.Label("AIImages.Settings.ApiEndpoint".Translate() + ":"); + settings.apiEndpoint = listingStandard.TextEntry(settings.apiEndpoint); + listingStandard.Gap(8f); + + // Кнопка проверки подключения + if (listingStandard.ButtonText("AIImages.Settings.TestConnection".Translate())) + { + _ = TestApiConnection(settings.apiEndpoint); + } + + // Кнопка загрузки моделей + if (listingStandard.ButtonText("AIImages.Settings.LoadModels".Translate())) + { + _ = LoadModelsFromApi(settings); + } + + listingStandard.Gap(12f); + + // === Generation Settings === + listingStandard.Label( + "AIImages.Settings.GenerationSection".Translate(), + -1f, + "AIImages.Settings.GenerationSectionTooltip".Translate() + ); + listingStandard.GapLine(); + + // Art Style + if ( + listingStandard.ButtonTextLabeled( + "AIImages.Settings.ArtStyle".Translate(), + settings.artStyle.ToString() + ) + ) + { + List styleOptions = new List(); + foreach (ArtStyle style in Enum.GetValues(typeof(ArtStyle))) + { + ArtStyle localStyle = style; + styleOptions.Add( + new FloatMenuOption(style.ToString(), () => settings.artStyle = localStyle) + ); + } + Find.WindowStack.Add(new FloatMenu(styleOptions)); + } + + // Shot Type + if ( + listingStandard.ButtonTextLabeled( + "AIImages.Settings.ShotType".Translate(), + settings.shotType.ToString() + ) + ) + { + List shotOptions = new List(); + foreach (ShotType shot in Enum.GetValues(typeof(ShotType))) + { + ShotType localShot = shot; + shotOptions.Add( + new FloatMenuOption(shot.ToString(), () => settings.shotType = localShot) + ); + } + Find.WindowStack.Add(new FloatMenu(shotOptions)); + } + + listingStandard.Gap(8f); + + // Steps + listingStandard.Label("AIImages.Settings.Steps".Translate() + $": {settings.steps}"); + settings.steps = (int)listingStandard.Slider(settings.steps, 1, 150); + listingStandard.Gap(8f); + + // CFG Scale + listingStandard.Label( + "AIImages.Settings.CfgScale".Translate() + $": {settings.cfgScale:F1}" + ); + settings.cfgScale = listingStandard.Slider(settings.cfgScale, 1f, 30f); + listingStandard.Gap(8f); + + // Width + listingStandard.Label("AIImages.Settings.Width".Translate() + ":"); + widthBuffer = listingStandard.TextEntry(widthBuffer); + if (int.TryParse(widthBuffer, out int width)) + { + settings.width = Mathf.Clamp(width, 64, 2048); + } + + // Height + listingStandard.Label("AIImages.Settings.Height".Translate() + ":"); + heightBuffer = listingStandard.TextEntry(heightBuffer); + if (int.TryParse(heightBuffer, out int height)) + { + settings.height = Mathf.Clamp(height, 64, 2048); + } + + // Common size presets + listingStandard.Gap(4f); + Rect presetRect = listingStandard.GetRect(30f); + if (Widgets.ButtonText(new Rect(presetRect.x, presetRect.y, 80f, 30f), "512x512")) + { + settings.width = 512; + settings.height = 512; + widthBuffer = "512"; + heightBuffer = "512"; + } + if (Widgets.ButtonText(new Rect(presetRect.x + 85f, presetRect.y, 80f, 30f), "512x768")) + { + settings.width = 512; + settings.height = 768; + widthBuffer = "512"; + heightBuffer = "768"; + } + if ( + Widgets.ButtonText(new Rect(presetRect.x + 170f, presetRect.y, 80f, 30f), "768x768") + ) + { + settings.width = 768; + settings.height = 768; + widthBuffer = "768"; + heightBuffer = "768"; + } + + listingStandard.Gap(12f); + + // Sampler + listingStandard.Label("AIImages.Settings.Sampler".Translate() + ":"); + settings.selectedSampler = listingStandard.TextEntry(settings.selectedSampler); + listingStandard.Gap(12f); + + // === Prompts === + listingStandard.Label( + "AIImages.Settings.PromptsSection".Translate(), + -1f, + "AIImages.Settings.PromptsSectionTooltip".Translate() + ); + listingStandard.GapLine(); + + listingStandard.Label("AIImages.Settings.BasePositivePrompt".Translate() + ":"); + settings.basePositivePrompt = listingStandard.TextEntry(settings.basePositivePrompt, 3); + listingStandard.Gap(8f); + + listingStandard.Label("AIImages.Settings.BaseNegativePrompt".Translate() + ":"); + settings.baseNegativePrompt = listingStandard.TextEntry(settings.baseNegativePrompt, 3); + listingStandard.Gap(12f); + + // === Options === + listingStandard.Label("AIImages.Settings.OptionsSection".Translate()); + listingStandard.GapLine(); + + listingStandard.CheckboxLabeled( + "AIImages.Settings.AutoLoadModels".Translate(), + ref settings.autoLoadModels + ); + listingStandard.CheckboxLabeled( + "AIImages.Settings.ShowTechnicalInfo".Translate(), + ref settings.showTechnicalInfo + ); + listingStandard.CheckboxLabeled( + "AIImages.Settings.SaveHistory".Translate(), + ref settings.saveGenerationHistory + ); + + listingStandard.Gap(12f); + + // Save path + listingStandard.Label("AIImages.Settings.SavePath".Translate() + ":"); + settings.savePath = listingStandard.TextEntry(settings.savePath); + + listingStandard.End(); + Widgets.EndScrollView(); + } + + private static async System.Threading.Tasks.Task TestApiConnection(string endpoint) + { + try + { + Log.Message($"[AI Images] Testing connection to {endpoint}..."); + bool available = await AIImagesMod.ApiService.CheckApiAvailability(endpoint); + + if (available) + { + Messages.Message( + "AIImages.Settings.ConnectionSuccess".Translate(), + MessageTypeDefOf.PositiveEvent + ); + } + else + { + Messages.Message( + "AIImages.Settings.ConnectionFailed".Translate(), + MessageTypeDefOf.RejectInput + ); + } + } + catch (Exception ex) + { + Messages.Message($"Error: {ex.Message}", MessageTypeDefOf.RejectInput); + } + } + + private static async System.Threading.Tasks.Task LoadModelsFromApi( + AIImagesModSettings settings + ) + { + try + { + Log.Message("[AI Images] Loading models from API..."); + var models = await AIImagesMod.ApiService.GetAvailableModels(settings.apiEndpoint); + + if (models.Count > 0) + { + Messages.Message( + "AIImages.Settings.ModelsLoaded".Translate(models.Count), + MessageTypeDefOf.PositiveEvent + ); + + // Если модель не выбрана, выбираем первую + if (string.IsNullOrEmpty(settings.selectedModel) && models.Count > 0) + { + settings.selectedModel = models[0]; + } + } + else + { + Messages.Message( + "AIImages.Settings.NoModelsFound".Translate(), + MessageTypeDefOf.RejectInput + ); + } + } + catch (Exception ex) + { + Messages.Message( + $"Error loading models: {ex.Message}", + MessageTypeDefOf.RejectInput + ); + } + } + } +} diff --git a/Source/AIImages/Window_AIImage.cs b/Source/AIImages/Window_AIImage.cs index 20a9576..901177e 100644 --- a/Source/AIImages/Window_AIImage.cs +++ b/Source/AIImages/Window_AIImage.cs @@ -1,6 +1,6 @@ -using System.Collections.Generic; +using System; using System.Linq; -using System.Text; +using AIImages.Models; using RimWorld; using UnityEngine; using Verse; @@ -10,7 +10,7 @@ using Verse; namespace AIImages { /// - /// Empty window that opens when clicking the pawn button + /// Окно для просмотра персонажа и генерации AI изображений /// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Style", @@ -25,29 +25,48 @@ namespace AIImages public class Window_AIImage : Window { private Pawn pawn; + private PawnAppearanceData appearanceData; + private StableDiffusionSettings generationSettings; + + private Texture2D generatedImage; + private bool isGenerating = false; + private string generationStatus = ""; public Window_AIImage(Pawn pawn) { this.pawn = pawn; this.doCloseX = true; this.doCloseButton = true; - this.forcePause = false; // Не ставим игру на паузу - this.absorbInputAroundWindow = false; // Не блокируем клики вне окна - this.draggable = true; // Делаем окно перемещаемым - this.preventCameraMotion = false; // Не блокируем управление камерой + this.forcePause = false; + this.absorbInputAroundWindow = false; + this.draggable = true; + this.preventCameraMotion = false; + + // Извлекаем данные персонажа + RefreshPawnData(); } - public override Vector2 InitialSize => new Vector2(700f, 700f); + public override Vector2 InitialSize => new Vector2(900f, 800f); private Vector2 scrollPosition = Vector2.zero; private float copiedMessageTime = 0f; + /// + /// Обновляет данные персонажа + /// + private void RefreshPawnData() + { + appearanceData = AIImagesMod.PawnDescriptionService.ExtractAppearanceData(pawn); + generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings(); + } + /// /// Обновляет текущую пешку в окне /// public void UpdatePawn(Pawn newPawn) { this.pawn = newPawn; + RefreshPawnData(); } /// @@ -70,7 +89,7 @@ namespace AIImages // Если выбрана новая колонистская пешка, обновляем окно if (selectedPawn != null && selectedPawn != pawn) { - pawn = selectedPawn; + UpdatePawn(selectedPawn); } // Уменьшаем таймер сообщения о копировании @@ -81,274 +100,71 @@ namespace AIImages } /// - /// Получает описание внешности персонажа + /// Асинхронная генерация изображения /// - private string GetAppearanceDescription() + private async System.Threading.Tasks.Task GenerateImage() { - if (pawn?.story == null) - return "AIImages.Appearance.NoInfo".Translate(); + if (isGenerating) + return; - StringBuilder sb = new StringBuilder(); + isGenerating = true; + generationStatus = "AIImages.Generation.InProgress".Translate(); - // Пол - sb.AppendLine("AIImages.Appearance.Gender".Translate(pawn.gender.GetLabel())); - - // Возраст - sb.AppendLine("AIImages.Appearance.Age".Translate(pawn.ageTracker.AgeBiologicalYears)); - - // Тип тела - if (pawn.story.bodyType != null) + try { - sb.AppendLine( - "AIImages.Appearance.BodyType".Translate(pawn.story.bodyType.defName) + // Генерируем промпты + string positivePrompt = AIImagesMod.PromptGeneratorService.GeneratePositivePrompt( + appearanceData, + generationSettings ); - } - - // Цвет кожи - if (pawn.story.SkinColor != null) - { - Color skinColor = pawn.story.SkinColor; - sb.AppendLine( - "AIImages.Appearance.SkinColor".Translate( - skinColor.r.ToString("F2"), - skinColor.g.ToString("F2"), - skinColor.b.ToString("F2") - ) + string negativePrompt = AIImagesMod.PromptGeneratorService.GenerateNegativePrompt( + generationSettings ); - } - // Волосы - if (pawn.story.hairDef != null) - { - sb.AppendLine("AIImages.Appearance.Hairstyle".Translate(pawn.story.hairDef.label)); - if (pawn.story.HairColor != null) + // Создаем запрос + var request = new GenerationRequest { - sb.AppendLine( - "AIImages.Appearance.HairColor".Translate( - pawn.story.HairColor.r.ToString("F2"), - pawn.story.HairColor.g.ToString("F2"), - pawn.story.HairColor.b.ToString("F2") - ) + Prompt = positivePrompt, + NegativePrompt = negativePrompt, + Steps = generationSettings.Steps, + CfgScale = generationSettings.CfgScale, + Width = generationSettings.Width, + Height = generationSettings.Height, + Sampler = generationSettings.Sampler, + Seed = generationSettings.Seed, + Model = AIImagesMod.Settings.apiEndpoint, + }; + + // Генерируем изображение + var result = await AIImagesMod.ApiService.GenerateImageAsync(request); + + if (result.Success) + { + // Загружаем текстуру + generatedImage = new Texture2D(2, 2); + generatedImage.LoadImage(result.ImageData); + generationStatus = "AIImages.Generation.Success".Translate(); + + Messages.Message( + "AIImages.Generation.SavedTo".Translate(result.SavedPath), + MessageTypeDefOf.PositiveEvent ); } - } - - // Черты характера - if (pawn.story.traits?.allTraits != null && pawn.story.traits.allTraits.Any()) - { - sb.AppendLine("\n" + "AIImages.Appearance.Traits".Translate()); - foreach (var trait in pawn.story.traits.allTraits) + else { - sb.AppendLine($" • {trait.LabelCap}"); + generationStatus = + $"AIImages.Generation.Failed".Translate() + ": {result.ErrorMessage}"; + Messages.Message(generationStatus, MessageTypeDefOf.RejectInput); } } - - return sb.ToString(); - } - - /// - /// Получает описание одежды персонажа - /// - private string GetApparelDescription() - { - if (pawn?.apparel == null) - return "AIImages.Apparel.NoInfo".Translate(); - - StringBuilder sb = new StringBuilder(); - List wornApparel = pawn.apparel.WornApparel; - - if (wornApparel == null || !wornApparel.Any()) + catch (Exception ex) { - sb.AppendLine("AIImages.Apparel.NoClothes".Translate()); + generationStatus = $"Error: {ex.Message}"; + Log.Error($"[AI Images] Generation error: {ex}"); } - else + finally { - sb.AppendLine("AIImages.Apparel.ListHeader".Translate(wornApparel.Count) + "\n"); - foreach (Apparel apparel in wornApparel) - { - FormatApparelItem(sb, apparel); - } - } - - return sb.ToString(); - } - - /// - /// Форматирует информацию об одном предмете одежды - /// - private void FormatApparelItem(StringBuilder sb, Apparel apparel) - { - sb.AppendLine($"• {apparel.LabelCap}"); - - if (apparel.TryGetQuality(out QualityCategory quality)) - { - sb.AppendLine("AIImages.Apparel.Quality".Translate(quality.GetLabel())); - } - - if (apparel.Stuff != null) - { - sb.AppendLine("AIImages.Apparel.Material".Translate(apparel.Stuff.LabelCap)); - } - - if (apparel.HitPoints < apparel.MaxHitPoints) - { - int percentage = (int)((float)apparel.HitPoints / apparel.MaxHitPoints * 100); - sb.AppendLine( - "AIImages.Apparel.Durability".Translate( - apparel.HitPoints, - apparel.MaxHitPoints, - percentage - ) - ); - } - - if (apparel.DrawColor != Color.white) - { - sb.AppendLine( - "AIImages.Apparel.Color".Translate( - apparel.DrawColor.r.ToString("F2"), - apparel.DrawColor.g.ToString("F2"), - apparel.DrawColor.b.ToString("F2") - ) - ); - } - - sb.AppendLine(); - } - - /// - /// Генерирует промпт для Stable Diffusion на основе внешности персонажа - /// - private string GenerateStableDiffusionPrompt() - { - if (pawn?.story == null) - return "portrait of a person"; - - StringBuilder prompt = new StringBuilder("portrait of a "); - - prompt.Append(GetAgeAndGenderDescription()); - prompt.Append(GetBodyTypeDescription()); - prompt.Append(GetSkinToneDescription()); - prompt.Append(GetHairDescription()); - prompt.Append(GetApparelPromptDescription()); - prompt.Append( - "realistic, detailed, high quality, professional lighting, 8k, photorealistic" - ); - - return prompt.ToString(); - } - - private string GetAgeAndGenderDescription() - { - string ageGroup = pawn.ageTracker.AgeBiologicalYears switch - { - < 18 => "young", - < 30 => "young adult", - < 50 => "middle-aged", - _ => "mature", - }; - return $"{ageGroup} {pawn.gender.GetLabel()}, "; - } - - private string GetBodyTypeDescription() - { - if (pawn.story.bodyType == null) - return ""; - - string bodyDesc = pawn.story.bodyType.defName.ToLower() switch - { - "thin" => "slender build", - "hulk" => "muscular build", - "fat" => "heavyset build", - _ => "average build", - }; - return $"{bodyDesc}, "; - } - - private string GetSkinToneDescription() - { - if (pawn.story.SkinColor == null) - return ""; - - float brightness = - (pawn.story.SkinColor.r + pawn.story.SkinColor.g + pawn.story.SkinColor.b) / 3f; - string skinTone = brightness switch - { - >= 0.8f => "fair skin", - >= 0.6f => "light skin", - >= 0.4f => "olive skin", - >= 0.2f => "brown skin", - _ => "dark skin", - }; - return $"{skinTone}, "; - } - - private string GetHairDescription() - { - if (pawn.story.hairDef == null) - return ""; - - string result = $"{pawn.story.hairDef.label.ToLower()} hair"; - if (pawn.story.HairColor != null) - { - result += $", {GetColorDescription(pawn.story.HairColor)} hair color"; - } - return result + ", "; - } - - private string GetApparelPromptDescription() - { - if (pawn.apparel?.WornApparel == null || !pawn.apparel.WornApparel.Any()) - return ""; - - List items = pawn - .apparel.WornApparel.Take(3) - .Select(a => - a.Stuff != null - ? $"{a.Stuff.label.ToLower()} {a.def.label.ToLower()}" - : a.def.label.ToLower() - ) - .ToList(); - - return $"wearing {string.Join(", ", items)}, "; - } - - /// - /// Получает текстовое описание цвета - /// - private string GetColorDescription(Color color) - { - // Определяем доминирующий цвет - float max = Mathf.Max(color.r, color.g, color.b); - float min = Mathf.Min(color.r, color.g, color.b); - float diff = max - min; - - if (diff < 0.1f) - { - // Оттенки серого - return max switch - { - > 0.8f => "white", - > 0.6f => "light gray", - > 0.4f => "gray", - > 0.2f => "dark gray", - _ => "black", - }; - } - - // Цветные - const float epsilon = 0.001f; - if (Mathf.Abs(color.r - max) < epsilon) - { - return color.g > color.b ? "orange" : "red"; - } - else if (Mathf.Abs(color.g - max) < epsilon) - { - return color.r > color.b ? "yellow" : "green"; - } - else - { - return color.r > color.g ? "purple" : "blue"; + isGenerating = false; } } @@ -376,16 +192,29 @@ namespace AIImages Widgets.DrawLineHorizontal(0f, curY, inRect.width); curY += 10f; - // Область для прокрутки контента - Rect scrollRect = new Rect(0f, curY, inRect.width, inRect.height - curY); - Rect scrollViewRect = new Rect( - 0f, - 0f, - scrollRect.width - 20f, - CalculateContentHeight() - ); + // Разделяем на две колонки: левая - информация, правая - изображение + float leftColumnWidth = inRect.width * 0.55f; + float rightColumnWidth = inRect.width * 0.42f; + float columnGap = inRect.width * 0.03f; - Widgets.BeginScrollView(scrollRect, ref scrollPosition, scrollViewRect); + // Левая колонка - прокручиваемая информация + Rect leftColumnRect = new Rect(0f, curY, leftColumnWidth, inRect.height - curY); + DrawLeftColumn(leftColumnRect); + + // Правая колонка - превью и управление + Rect rightColumnRect = new Rect( + leftColumnWidth + columnGap, + curY, + rightColumnWidth, + inRect.height - curY + ); + DrawRightColumn(rightColumnRect); + } + + private void DrawLeftColumn(Rect rect) + { + Rect scrollViewRect = new Rect(0f, 0f, rect.width - 20f, CalculateContentHeight()); + Widgets.BeginScrollView(rect, ref scrollPosition, scrollViewRect); float contentY = 0f; @@ -398,7 +227,9 @@ namespace AIImages contentY += 35f; Text.Font = GameFont.Small; - string appearanceText = GetAppearanceDescription(); + string appearanceText = AIImagesMod.PawnDescriptionService.GetAppearanceDescription( + pawn + ); float appearanceHeight = Text.CalcHeight(appearanceText, scrollViewRect.width - 30f); Widgets.Label( new Rect(20f, contentY, scrollViewRect.width - 30f, appearanceHeight), @@ -419,60 +250,133 @@ namespace AIImages contentY += 35f; Text.Font = GameFont.Small; - string apparelText = GetApparelDescription(); + string apparelText = AIImagesMod.PawnDescriptionService.GetApparelDescription(pawn); float apparelHeight = Text.CalcHeight(apparelText, scrollViewRect.width - 30f); Widgets.Label( new Rect(20f, contentY, scrollViewRect.width - 30f, apparelHeight), apparelText ); - contentY += apparelHeight + 20f; - // Разделитель - Widgets.DrawLineHorizontal(10f, contentY, scrollViewRect.width - 20f); - contentY += 15f; + Widgets.EndScrollView(); + } - // Секция "Stable Diffusion Промпт" + private void DrawRightColumn(Rect rect) + { + float curY = 0f; + + // Превью изображения + if (generatedImage != null) + { + float imageSize = Mathf.Min(rect.width, 400f); + Rect imageRect = new Rect( + (rect.width - imageSize) / 2f, + curY, + imageSize, + imageSize + ); + GUI.DrawTexture(imageRect, generatedImage); + curY += imageSize + 10f; + } + else + { + // Placeholder для изображения + float placeholderSize = Mathf.Min(rect.width, 300f); + Rect placeholderRect = new Rect( + (rect.width - placeholderSize) / 2f, + curY, + placeholderSize, + placeholderSize + ); + Widgets.DrawBoxSolid(placeholderRect, new Color(0.2f, 0.2f, 0.2f)); + Text.Anchor = TextAnchor.MiddleCenter; + Widgets.Label(placeholderRect, "AIImages.Generation.NoImage".Translate()); + Text.Anchor = TextAnchor.UpperLeft; + curY += placeholderSize + 10f; + } + + // Статус генерации + if (!string.IsNullOrEmpty(generationStatus)) + { + Text.Font = GameFont.Small; + float statusHeight = Text.CalcHeight(generationStatus, rect.width); + Widgets.Label(new Rect(0f, curY, rect.width, statusHeight), generationStatus); + curY += statusHeight + 10f; + } + + // Кнопка генерации + Text.Font = GameFont.Small; + if ( + Widgets.ButtonText( + new Rect(0f, curY, rect.width, 35f), + isGenerating + ? "AIImages.Generation.Generating".Translate() + : "AIImages.Generation.Generate".Translate() + ) && !isGenerating + ) + { + _ = GenerateImage(); + } + curY += 40f; + + // Промпт секция Text.Font = GameFont.Medium; Widgets.Label( - new Rect(10f, contentY, scrollViewRect.width - 20f, 30f), + new Rect(0f, curY, rect.width, 30f), "AIImages.Prompt.SectionTitle".Translate() ); - contentY += 35f; + curY += 35f; - // Промпт текст - Text.Font = GameFont.Small; - string promptText = GenerateStableDiffusionPrompt(); - float promptHeight = Text.CalcHeight(promptText, scrollViewRect.width - 30f); - Widgets.Label( - new Rect(20f, contentY, scrollViewRect.width - 30f, promptHeight), - promptText + // Получаем промпт + Text.Font = GameFont.Tiny; + string promptText = AIImagesMod.PromptGeneratorService.GeneratePositivePrompt( + appearanceData, + generationSettings ); - contentY += promptHeight + 10f; - // Кнопка копирования - Rect copyButtonRect = new Rect(20f, contentY, 150f, 30f); + float promptHeight = Mathf.Min(Text.CalcHeight(promptText, rect.width), 150f); + Rect promptRect = new Rect(0f, curY, rect.width, promptHeight); - if (Widgets.ButtonText(copyButtonRect, "AIImages.Prompt.CopyButton".Translate())) + // Рисуем промпт в скроллируемой области если он длинный + Widgets.DrawBoxSolid(promptRect, new Color(0.1f, 0.1f, 0.1f, 0.5f)); + Widgets.Label(promptRect.ContractedBy(5f), promptText); + curY += promptHeight + 10f; + + // Кнопка копирования промпта + if ( + Widgets.ButtonText( + new Rect(0f, curY, rect.width / 2f - 5f, 30f), + "AIImages.Prompt.CopyButton".Translate() + ) + ) { GUIUtility.systemCopyBuffer = promptText; - copiedMessageTime = 2f; // Показываем сообщение на 2 секунды + copiedMessageTime = 2f; + } + + // Кнопка обновления данных + if ( + Widgets.ButtonText( + new Rect(rect.width / 2f + 5f, curY, rect.width / 2f - 5f, 30f), + "AIImages.Window.Refresh".Translate() + ) + ) + { + RefreshPawnData(); } // Сообщение о копировании if (copiedMessageTime > 0f) { - Rect copiedRect = new Rect(copyButtonRect.xMax + 10f, contentY, 100f, 30f); - GUI.color = new Color(0f, 1f, 0f, copiedMessageTime / 2f); // Затухающий зеленый - Widgets.Label(copiedRect, "AIImages.Prompt.Copied".Translate()); + curY += 35f; + GUI.color = new Color(0f, 1f, 0f, copiedMessageTime / 2f); + Widgets.Label( + new Rect(0f, curY, rect.width, 25f), + "AIImages.Prompt.Copied".Translate() + ); GUI.color = Color.white; } - - Widgets.EndScrollView(); } - /// - /// Вычисляет высоту всего контента для прокрутки - /// private float CalculateContentHeight() { float height = 0f; @@ -481,8 +385,10 @@ namespace AIImages height += 35f; // Текст внешности - string appearanceText = GetAppearanceDescription(); - height += Text.CalcHeight(appearanceText, 640f) + 20f; + string appearanceText = AIImagesMod.PawnDescriptionService.GetAppearanceDescription( + pawn + ); + height += Text.CalcHeight(appearanceText, 400f) + 20f; // Разделитель height += 15f; @@ -491,21 +397,11 @@ namespace AIImages height += 35f; // Текст одежды - string apparelText = GetApparelDescription(); - height += Text.CalcHeight(apparelText, 640f) + 20f; + string apparelText = AIImagesMod.PawnDescriptionService.GetApparelDescription(pawn); + height += Text.CalcHeight(apparelText, 400f) + 20f; - // Разделитель - height += 15f; - - // Заголовок "Промпт" - height += 35f; - - // Текст промпта - string promptText = GenerateStableDiffusionPrompt(); - height += Text.CalcHeight(promptText, 640f) + 10f; - - // Кнопка и отступ - height += 30f + 20f; + // Дополнительный отступ + height += 50f; return height; }