diff --git a/Assemblies/AIImages.dll b/Assemblies/AIImages.dll index 9745481..cf9e1cb 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 f19385a..a5af5b9 100644 --- a/Languages/English/Keyed/AIImages.xml +++ b/Languages/English/Keyed/AIImages.xml @@ -42,6 +42,7 @@ Cancelling generation... Generation error Image saved to: {0} + Loaded saved portrait No image generated yet.\nClick "Generate Image" to start. API Settings diff --git a/Languages/Russian/Keyed/AIImages.xml b/Languages/Russian/Keyed/AIImages.xml index 3a753e5..c1f9852 100644 --- a/Languages/Russian/Keyed/AIImages.xml +++ b/Languages/Russian/Keyed/AIImages.xml @@ -42,6 +42,7 @@ Отмена генерации... Ошибка генерации Изображение сохранено в: {0} + Загружен сохраненный портрет Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала. Настройки API diff --git a/Source/AIImages/Components/PawnPortraitComp.cs b/Source/AIImages/Components/PawnPortraitComp.cs new file mode 100644 index 0000000..f159a8a --- /dev/null +++ b/Source/AIImages/Components/PawnPortraitComp.cs @@ -0,0 +1,31 @@ +using Verse; + +namespace AIImages.Components +{ + /// + /// Компонент для хранения данных AI-сгенерированного портрета пешки + /// + public class PawnPortraitComp : ThingComp + { + /// + /// Путь к сохраненному портрету + /// + public string PortraitPath { get; set; } + + /// + /// Есть ли сохраненный портрет + /// + public bool HasPortrait => !string.IsNullOrEmpty(PortraitPath); + + /// + /// Сохранение/загрузка данных + /// + public override void PostExposeData() + { + base.PostExposeData(); + string portraitPath = PortraitPath; + Scribe_Values.Look(ref portraitPath, "aiPortraitPath", null); + PortraitPath = portraitPath; + } + } +} diff --git a/Source/AIImages/Helpers/PawnPortraitHelper.cs b/Source/AIImages/Helpers/PawnPortraitHelper.cs new file mode 100644 index 0000000..39b3240 --- /dev/null +++ b/Source/AIImages/Helpers/PawnPortraitHelper.cs @@ -0,0 +1,90 @@ +using System.IO; +using AIImages.Components; +using UnityEngine; +using Verse; + +namespace AIImages.Helpers +{ + /// + /// Вспомогательный класс для работы с портретами персонажей + /// + public static class PawnPortraitHelper + { + /// + /// Получить компонент портрета пешки + /// + public static PawnPortraitComp GetPortraitComp(Pawn pawn) + { + return pawn?.TryGetComp(); + } + + /// + /// Сохранить путь к портрету на пешке + /// + public static void SavePortraitPath(Pawn pawn, string path) + { + var comp = GetPortraitComp(pawn); + if (comp != null) + { + comp.PortraitPath = path; + Log.Message($"[AI Images] Saved portrait path for {pawn.Name}: {path}"); + } + } + + /// + /// Получить путь к портрету пешки + /// + public static string GetPortraitPath(Pawn pawn) + { + var comp = GetPortraitComp(pawn); + return comp?.PortraitPath; + } + + /// + /// Есть ли у пешки сохраненный портрет + /// + public static bool HasPortrait(Pawn pawn) + { + var comp = GetPortraitComp(pawn); + return comp != null && comp.HasPortrait; + } + + /// + /// Загрузить портрет пешки как текстуру + /// + public static Texture2D LoadPortrait(Pawn pawn) + { + string path = GetPortraitPath(pawn); + + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + return null; + } + + try + { + byte[] imageData = File.ReadAllBytes(path); + Texture2D texture = new Texture2D(2, 2); + texture.LoadImage(imageData); + return texture; + } + catch (System.Exception ex) + { + Log.Warning($"[AI Images] Failed to load portrait for {pawn.Name}: {ex.Message}"); + return null; + } + } + + /// + /// Очистить портрет пешки + /// + public static void ClearPortrait(Pawn pawn) + { + var comp = GetPortraitComp(pawn); + if (comp != null) + { + comp.PortraitPath = null; + } + } + } +} diff --git a/Source/AIImages/Models/GenerationRequest.cs b/Source/AIImages/Models/GenerationRequest.cs index 057949d..eec40e2 100644 --- a/Source/AIImages/Models/GenerationRequest.cs +++ b/Source/AIImages/Models/GenerationRequest.cs @@ -48,4 +48,35 @@ namespace AIImages.Models }; } } + + /// + /// Прогресс генерации изображения + /// + public class GenerationProgress + { + /// + /// Процент завершения (0.0 - 1.0) + /// + public double Progress { get; set; } + + /// + /// Текущий шаг + /// + public int CurrentStep { get; set; } + + /// + /// Общее количество шагов + /// + public int TotalSteps { get; set; } + + /// + /// Оставшееся время в секундах (приблизительно) + /// + public double EtaRelative { get; set; } + + /// + /// Идет ли генерация в данный момент + /// + public bool IsActive { get; set; } + } } diff --git a/Source/AIImages/Patches/PawnPortraitCompPatch.cs b/Source/AIImages/Patches/PawnPortraitCompPatch.cs new file mode 100644 index 0000000..261faf3 --- /dev/null +++ b/Source/AIImages/Patches/PawnPortraitCompPatch.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Reflection; +using AIImages.Components; +using HarmonyLib; +using Verse; + +namespace AIImages.Patches +{ + /// + /// Патч для добавления PawnPortraitComp ко всем пешкам + /// + [HarmonyPatch(typeof(ThingWithComps), nameof(ThingWithComps.InitializeComps))] + public static class PawnPortraitCompPatch + { + private static FieldInfo allCompsField = AccessTools.Field(typeof(ThingWithComps), "comps"); + + [HarmonyPostfix] + public static void AddPortraitComp(ThingWithComps __instance) + { + // Проверяем, является ли объект пешкой-гуманоидом и нет ли уже компонента + if ( + __instance is Pawn pawn + && pawn.RaceProps?.Humanlike == true + && pawn.GetComp() == null + ) + { + // Создаем компонент + var comp = new PawnPortraitComp { parent = pawn }; + + // Инициализируем компонент + comp.Initialize(null); + + // Получаем список компонентов через рефлексию и добавляем наш + var compsList = allCompsField.GetValue(pawn) as List; + if (compsList != null) + { + compsList.Add(comp); + } + } + } + } +} diff --git a/Source/AIImages/Services/AdvancedPromptGenerator.cs b/Source/AIImages/Services/AdvancedPromptGenerator.cs index b927114..8e3d90a 100644 --- a/Source/AIImages/Services/AdvancedPromptGenerator.cs +++ b/Source/AIImages/Services/AdvancedPromptGenerator.cs @@ -63,7 +63,14 @@ namespace AIImages.Services StringBuilder prompt = new StringBuilder(); - // 1. Художественный стиль + // 1. Базовый пользовательский промпт (если указан) - идет первым + if (!string.IsNullOrEmpty(settings.PositivePrompt)) + { + prompt.Append(settings.PositivePrompt); + prompt.Append(", "); + } + + // 2. Художественный стиль if ( ArtStylePrompts.TryGetValue(settings.ArtStyle, out string stylePrompt) && !string.IsNullOrEmpty(stylePrompt) @@ -73,14 +80,14 @@ namespace AIImages.Services prompt.Append(", "); } - // 2. Тип кадра - автоматически добавляем "portrait" для генерации персонажей + // 3. Тип кадра - автоматически добавляем "portrait" для генерации персонажей prompt.Append("portrait, head and shoulders of "); - // 3. Базовое описание (возраст и пол) + // 4. Базовое описание (возраст и пол) prompt.Append(GetAgeAndGenderDescription(appearanceData)); prompt.Append(", "); - // 4. Тип тела + // 5. Тип тела string bodyType = GetBodyTypeDescription(appearanceData.BodyType); if (!string.IsNullOrEmpty(bodyType)) { @@ -88,14 +95,14 @@ namespace AIImages.Services prompt.Append(", "); } - // 5. Цвет кожи + // 6. Цвет кожи string skinTone = ColorDescriptionService.GetSkinToneDescription( appearanceData.SkinColor ); prompt.Append(skinTone); prompt.Append(", "); - // 6. Волосы + // 7. Волосы string hairDescription = GetHairDescription(appearanceData); if (!string.IsNullOrEmpty(hairDescription)) { @@ -103,7 +110,7 @@ namespace AIImages.Services prompt.Append(", "); } - // 7. Настроение и выражение на основе черт характера + // 8. Настроение и выражение на основе черт характера string moodDescription = GetMoodFromTraits(appearanceData.Traits); if (!string.IsNullOrEmpty(moodDescription)) { @@ -111,7 +118,7 @@ namespace AIImages.Services prompt.Append(", "); } - // 8. Одежда + // 9. Одежда string apparelDescription = GetApparelDescription(appearanceData.Apparel); if (!string.IsNullOrEmpty(apparelDescription)) { @@ -119,13 +126,6 @@ namespace AIImages.Services prompt.Append(", "); } - // 9. Базовый пользовательский промпт (если указан) - if (!string.IsNullOrEmpty(settings.PositivePrompt)) - { - prompt.Append(settings.PositivePrompt); - prompt.Append(", "); - } - // 10. Качественные теги prompt.Append(GetQualityTags(settings.ArtStyle)); @@ -136,7 +136,14 @@ namespace AIImages.Services { StringBuilder negativePrompt = new StringBuilder(); - // Базовые негативные промпты + // 1. Пользовательский негативный промпт (если указан) - идет первым + if (!string.IsNullOrEmpty(settings.NegativePrompt)) + { + negativePrompt.Append(settings.NegativePrompt); + negativePrompt.Append(", "); + } + + // 2. Базовые негативные промпты negativePrompt.Append( "ugly, deformed, low quality, blurry, bad anatomy, worst quality, " ); @@ -144,7 +151,7 @@ namespace AIImages.Services "mutated, disfigured, bad proportions, extra limbs, missing limbs, " ); - // Специфичные для стиля негативы + // 3. Специфичные для стиля негативы switch (settings.ArtStyle) { case ArtStyle.Realistic: @@ -159,12 +166,6 @@ namespace AIImages.Services break; } - // Пользовательский негативный промпт - if (!string.IsNullOrEmpty(settings.NegativePrompt)) - { - negativePrompt.Append(settings.NegativePrompt); - } - return negativePrompt.ToString().Trim().TrimEnd(','); } diff --git a/Source/AIImages/Services/IStableDiffusionApiService.cs b/Source/AIImages/Services/IStableDiffusionApiService.cs index e47ef7f..6d77d3c 100644 --- a/Source/AIImages/Services/IStableDiffusionApiService.cs +++ b/Source/AIImages/Services/IStableDiffusionApiService.cs @@ -18,6 +18,11 @@ namespace AIImages.Services CancellationToken cancellationToken = default ); + /// + /// Получает прогресс текущей генерации + /// + Task GetProgressAsync(CancellationToken cancellationToken = default); + /// /// Проверяет доступность API /// diff --git a/Source/AIImages/Services/StableDiffusionNetAdapter.cs b/Source/AIImages/Services/StableDiffusionNetAdapter.cs index 3a89d04..cd8fc8c 100644 --- a/Source/AIImages/Services/StableDiffusionNetAdapter.cs +++ b/Source/AIImages/Services/StableDiffusionNetAdapter.cs @@ -137,6 +137,42 @@ namespace AIImages.Services } } + public async Task GetProgressAsync( + CancellationToken cancellationToken = default + ) + { + ThrowIfDisposed(); + + try + { + // Используем Progress сервис библиотеки + var progress = await _client.Progress.GetProgressAsync(cancellationToken); + + // Маппируем на наш тип + return new GenerationProgress + { + Progress = progress.Progress, + CurrentStep = progress.State?.SamplingStep ?? 0, + TotalSteps = progress.State?.SamplingSteps ?? 0, + EtaRelative = progress.EtaRelative, + IsActive = progress.Progress > 0 && progress.Progress < 1.0, + }; + } + catch (Exception ex) + { + Log.Warning($"[AI Images] Failed to get progress: {ex.Message}"); + // Возвращаем пустой прогресс при ошибке + return new GenerationProgress + { + Progress = 0, + CurrentStep = 0, + TotalSteps = 0, + EtaRelative = 0, + IsActive = false, + }; + } + } + public async Task CheckApiAvailability( string apiEndpoint, CancellationToken cancellationToken = default diff --git a/Source/AIImages/Window_AIImage.cs b/Source/AIImages/Window_AIImage.cs index 68093a3..2aaee78 100644 --- a/Source/AIImages/Window_AIImage.cs +++ b/Source/AIImages/Window_AIImage.cs @@ -36,6 +36,12 @@ namespace AIImages private string generationStatus = ""; private CancellationTokenSource cancellationTokenSource; + // Прогресс генерации + private double generationProgress = 0.0; + private int currentStep = 0; + private int totalSteps = 0; + private double etaSeconds = 0.0; + // Сервисы (получаем через DI) private readonly IPawnDescriptionService pawnDescriptionService; private readonly IPromptGeneratorService promptGeneratorService; @@ -74,6 +80,24 @@ namespace AIImages { appearanceData = pawnDescriptionService.ExtractAppearanceData(pawn); generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings(); + + // Загружаем сохраненный портрет, если есть + LoadSavedPortrait(); + } + + /// + /// Загружает сохраненный портрет персонажа + /// + private void LoadSavedPortrait() + { + if (PawnPortraitHelper.HasPortrait(pawn)) + { + generatedImage = PawnPortraitHelper.LoadPortrait(pawn); + if (generatedImage != null) + { + generationStatus = "AIImages.Generation.LoadedFromSave".Translate(); + } + } } /// @@ -100,6 +124,11 @@ namespace AIImages { this.pawn = newPawn; RefreshPawnData(); + + // Очищаем старое изображение при смене персонажа + generatedImage = null; + generationStatus = ""; + generationProgress = 0.0; } /// @@ -146,6 +175,9 @@ namespace AIImages isGenerating = true; generationStatus = "AIImages.Generation.InProgress".Translate(); + generationProgress = 0.0; + currentStep = 0; + totalSteps = generationSettings.Steps; try { @@ -173,18 +205,41 @@ namespace AIImages Model = AIImagesMod.Settings.apiEndpoint, }; + // Создаем отдельный CancellationTokenSource для мониторинга прогресса + var progressCts = new CancellationTokenSource(); + var progressTask = MonitorProgressAsync(progressCts.Token); + // Генерируем изображение с поддержкой отмены var result = await apiService.GenerateImageAsync( request, cancellationTokenSource.Token ); + // Останавливаем мониторинг прогресса + progressCts.Cancel(); + try + { + await progressTask; + } + catch (OperationCanceledException) + { + // Ожидаемое исключение при остановке мониторинга + } + finally + { + progressCts.Dispose(); + } + if (result.Success) { // Загружаем текстуру generatedImage = new Texture2D(2, 2); generatedImage.LoadImage(result.ImageData); generationStatus = "AIImages.Generation.Success".Translate(); + generationProgress = 1.0; + + // Сохраняем путь к портрету на персонаже + PawnPortraitHelper.SavePortraitPath(pawn, result.SavedPath); Messages.Message( "AIImages.Generation.SavedTo".Translate(result.SavedPath), @@ -218,6 +273,45 @@ namespace AIImages } } + /// + /// Мониторит прогресс генерации и обновляет UI + /// + private async System.Threading.Tasks.Task MonitorProgressAsync( + CancellationToken cancellationToken + ) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var progress = await apiService.GetProgressAsync(cancellationToken); + + if (progress != null && progress.IsActive) + { + generationProgress = progress.Progress; + currentStep = progress.CurrentStep; + totalSteps = progress.TotalSteps; + etaSeconds = progress.EtaRelative; + + Log.Message( + $"[AI Images] Progress: {progress.Progress:P} - Step {progress.CurrentStep}/{progress.TotalSteps} - ETA: {progress.EtaRelative:F1}s" + ); + } + + // Обновляем каждые 500ms + await System.Threading.Tasks.Task.Delay(500, cancellationToken); + } + } + catch (OperationCanceledException) + { + // Ожидаемое исключение при остановке + } + catch (Exception ex) + { + Log.Warning($"[AI Images] Progress monitoring error: {ex.Message}"); + } + } + /// /// Запускает генерацию изображения (обертка для безопасного fire-and-forget) /// @@ -288,6 +382,20 @@ namespace AIImages float contentY = 0f; + // Портрет персонажа (если есть) + if (generatedImage != null) + { + float portraitSize = Mathf.Min(scrollViewRect.width - 20f, 200f); + Rect portraitRect = new Rect( + (scrollViewRect.width - portraitSize) / 2f, + contentY, + portraitSize, + portraitSize + ); + GUI.DrawTexture(portraitRect, generatedImage); + contentY += portraitSize + 15f; + } + // Секция "Внешность" Text.Font = GameFont.Medium; Widgets.Label( @@ -332,77 +440,10 @@ namespace AIImages { float curY = 0f; - // Превью изображения - if (generatedImage != null) - { - float imageSize = Mathf.Min(rect.width, 400f); - Rect imageRect = new Rect( - rect.x + (rect.width - imageSize) / 2f, - rect.y + curY, - imageSize, - imageSize - ); - GUI.DrawTexture(imageRect, generatedImage); - curY += imageSize + 10f; - } - else - { - // Placeholder для изображения - float placeholderSize = Mathf.Min(rect.width, 300f); - Rect placeholderRect = new Rect( - rect.x + (rect.width - placeholderSize) / 2f, - rect.y + 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(rect.x, rect.y + curY, rect.width, statusHeight), - generationStatus - ); - curY += statusHeight + 10f; - } - - // Кнопка генерации/отмены - Text.Font = GameFont.Small; - if (isGenerating) - { - // Показываем кнопку отмены во время генерации - if ( - Widgets.ButtonText( - new Rect(rect.x, rect.y + curY, rect.width, 35f), - "AIImages.Generation.Cancel".Translate() - ) - ) - { - CancelGeneration(); - } - } - else - { - // Показываем кнопку генерации - if ( - Widgets.ButtonText( - new Rect(rect.x, rect.y + curY, rect.width, 35f), - "AIImages.Generation.Generate".Translate() - ) - ) - { - StartGeneration(); - } - } - curY += 40f; + curY = DrawImagePreview(rect, curY); + curY = DrawGenerationStatus(rect, curY); + curY = DrawProgressBar(rect, curY); + curY = DrawGenerationButton(rect, curY); // Промпт секция Text.Font = GameFont.Medium; @@ -488,6 +529,13 @@ namespace AIImages { float height = 0f; + // Портрет персонажа (если есть) + if (generatedImage != null) + { + float portraitSize = Mathf.Min(400f, 200f); + height += portraitSize + 15f; + } + // Заголовок "Внешность" height += 35f; @@ -510,5 +558,95 @@ namespace AIImages return height; } + + private float DrawImagePreview(Rect rect, float curY) + { + if (generatedImage != null) + { + float imageSize = Mathf.Min(rect.width, 400f); + Rect imageRect = new Rect( + rect.x + (rect.width - imageSize) / 2f, + rect.y + curY, + imageSize, + imageSize + ); + GUI.DrawTexture(imageRect, generatedImage); + return curY + imageSize + 10f; + } + + float placeholderSize = Mathf.Min(rect.width, 300f); + Rect placeholderRect = new Rect( + rect.x + (rect.width - placeholderSize) / 2f, + rect.y + 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; + return curY + placeholderSize + 10f; + } + + private float DrawGenerationStatus(Rect rect, float curY) + { + if (string.IsNullOrEmpty(generationStatus)) + return curY; + + Text.Font = GameFont.Small; + float statusHeight = Text.CalcHeight(generationStatus, rect.width); + Widgets.Label( + new Rect(rect.x, rect.y + curY, rect.width, statusHeight), + generationStatus + ); + return curY + statusHeight + 10f; + } + + private float DrawProgressBar(Rect rect, float curY) + { + if (!isGenerating || generationProgress <= 0.0) + return curY; + + Rect progressBarRect = new Rect(rect.x, rect.y + curY, rect.width, 24f); + + string progressText; + if (totalSteps > 0) + { + progressText = + $"{(generationProgress * 100):F1}% - Step {currentStep}/{totalSteps}"; + if (etaSeconds > 0) + { + progressText += $" - ETA: {etaSeconds:F0}s"; + } + } + else + { + progressText = $"{(generationProgress * 100):F1}%"; + } + + Widgets.FillableBar(progressBarRect, (float)generationProgress); + Text.Font = GameFont.Tiny; + Text.Anchor = TextAnchor.MiddleCenter; + Widgets.Label(progressBarRect, progressText); + Text.Anchor = TextAnchor.UpperLeft; + return curY + 30f; + } + + private float DrawGenerationButton(Rect rect, float curY) + { + Text.Font = GameFont.Small; + string buttonLabel = isGenerating + ? "AIImages.Generation.Cancel".Translate() + : "AIImages.Generation.Generate".Translate(); + + if (Widgets.ButtonText(new Rect(rect.x, rect.y + curY, rect.width, 35f), buttonLabel)) + { + if (isGenerating) + CancelGeneration(); + else + StartGeneration(); + } + return curY + 40f; + } } }