Implement progress monitoring for image generation in AIImages mod, enhancing user experience with real-time updates. Add localized strings for new features in English and Russian. Refactor UI components for better organization and clarity. Update AIImages.dll to reflect these changes.

This commit is contained in:
Leonid Pershin
2025-10-26 22:56:38 +03:00
parent b9d7ea0c04
commit d67ec8c0ac
11 changed files with 470 additions and 94 deletions

Binary file not shown.

View File

@@ -42,6 +42,7 @@
<AIImages.Generation.Cancelling>Cancelling generation...</AIImages.Generation.Cancelling>
<AIImages.Generation.Error>Generation error</AIImages.Generation.Error>
<AIImages.Generation.SavedTo>Image saved to: {0}</AIImages.Generation.SavedTo>
<AIImages.Generation.LoadedFromSave>Loaded saved portrait</AIImages.Generation.LoadedFromSave>
<AIImages.Generation.NoImage>No image generated yet.\nClick "Generate Image" to start.</AIImages.Generation.NoImage>
<!-- Settings -->
<AIImages.Settings.ApiSection>API Settings</AIImages.Settings.ApiSection>

View File

@@ -42,6 +42,7 @@
<AIImages.Generation.Cancelling>Отмена генерации...</AIImages.Generation.Cancelling>
<AIImages.Generation.Error>Ошибка генерации</AIImages.Generation.Error>
<AIImages.Generation.SavedTo>Изображение сохранено в: {0}</AIImages.Generation.SavedTo>
<AIImages.Generation.LoadedFromSave>Загружен сохраненный портрет</AIImages.Generation.LoadedFromSave>
<AIImages.Generation.NoImage>Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала.</AIImages.Generation.NoImage>
<!-- Settings -->
<AIImages.Settings.ApiSection>Настройки API</AIImages.Settings.ApiSection>

View File

@@ -0,0 +1,31 @@
using Verse;
namespace AIImages.Components
{
/// <summary>
/// Компонент для хранения данных AI-сгенерированного портрета пешки
/// </summary>
public class PawnPortraitComp : ThingComp
{
/// <summary>
/// Путь к сохраненному портрету
/// </summary>
public string PortraitPath { get; set; }
/// <summary>
/// Есть ли сохраненный портрет
/// </summary>
public bool HasPortrait => !string.IsNullOrEmpty(PortraitPath);
/// <summary>
/// Сохранение/загрузка данных
/// </summary>
public override void PostExposeData()
{
base.PostExposeData();
string portraitPath = PortraitPath;
Scribe_Values.Look(ref portraitPath, "aiPortraitPath", null);
PortraitPath = portraitPath;
}
}
}

View File

@@ -0,0 +1,90 @@
using System.IO;
using AIImages.Components;
using UnityEngine;
using Verse;
namespace AIImages.Helpers
{
/// <summary>
/// Вспомогательный класс для работы с портретами персонажей
/// </summary>
public static class PawnPortraitHelper
{
/// <summary>
/// Получить компонент портрета пешки
/// </summary>
public static PawnPortraitComp GetPortraitComp(Pawn pawn)
{
return pawn?.TryGetComp<PawnPortraitComp>();
}
/// <summary>
/// Сохранить путь к портрету на пешке
/// </summary>
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}");
}
}
/// <summary>
/// Получить путь к портрету пешки
/// </summary>
public static string GetPortraitPath(Pawn pawn)
{
var comp = GetPortraitComp(pawn);
return comp?.PortraitPath;
}
/// <summary>
/// Есть ли у пешки сохраненный портрет
/// </summary>
public static bool HasPortrait(Pawn pawn)
{
var comp = GetPortraitComp(pawn);
return comp != null && comp.HasPortrait;
}
/// <summary>
/// Загрузить портрет пешки как текстуру
/// </summary>
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;
}
}
/// <summary>
/// Очистить портрет пешки
/// </summary>
public static void ClearPortrait(Pawn pawn)
{
var comp = GetPortraitComp(pawn);
if (comp != null)
{
comp.PortraitPath = null;
}
}
}
}

View File

@@ -48,4 +48,35 @@ namespace AIImages.Models
};
}
}
/// <summary>
/// Прогресс генерации изображения
/// </summary>
public class GenerationProgress
{
/// <summary>
/// Процент завершения (0.0 - 1.0)
/// </summary>
public double Progress { get; set; }
/// <summary>
/// Текущий шаг
/// </summary>
public int CurrentStep { get; set; }
/// <summary>
/// Общее количество шагов
/// </summary>
public int TotalSteps { get; set; }
/// <summary>
/// Оставшееся время в секундах (приблизительно)
/// </summary>
public double EtaRelative { get; set; }
/// <summary>
/// Идет ли генерация в данный момент
/// </summary>
public bool IsActive { get; set; }
}
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Reflection;
using AIImages.Components;
using HarmonyLib;
using Verse;
namespace AIImages.Patches
{
/// <summary>
/// Патч для добавления PawnPortraitComp ко всем пешкам
/// </summary>
[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<PawnPortraitComp>() == null
)
{
// Создаем компонент
var comp = new PawnPortraitComp { parent = pawn };
// Инициализируем компонент
comp.Initialize(null);
// Получаем список компонентов через рефлексию и добавляем наш
var compsList = allCompsField.GetValue(pawn) as List<ThingComp>;
if (compsList != null)
{
compsList.Add(comp);
}
}
}
}
}

View File

@@ -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(',');
}

View File

@@ -18,6 +18,11 @@ namespace AIImages.Services
CancellationToken cancellationToken = default
);
/// <summary>
/// Получает прогресс текущей генерации
/// </summary>
Task<GenerationProgress> GetProgressAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Проверяет доступность API
/// </summary>

View File

@@ -137,6 +137,42 @@ namespace AIImages.Services
}
}
public async Task<GenerationProgress> 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<bool> CheckApiAvailability(
string apiEndpoint,
CancellationToken cancellationToken = default

View File

@@ -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();
}
/// <summary>
/// Загружает сохраненный портрет персонажа
/// </summary>
private void LoadSavedPortrait()
{
if (PawnPortraitHelper.HasPortrait(pawn))
{
generatedImage = PawnPortraitHelper.LoadPortrait(pawn);
if (generatedImage != null)
{
generationStatus = "AIImages.Generation.LoadedFromSave".Translate();
}
}
}
/// <summary>
@@ -100,6 +124,11 @@ namespace AIImages
{
this.pawn = newPawn;
RefreshPawnData();
// Очищаем старое изображение при смене персонажа
generatedImage = null;
generationStatus = "";
generationProgress = 0.0;
}
/// <summary>
@@ -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
}
}
/// <summary>
/// Мониторит прогресс генерации и обновляет UI
/// </summary>
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}");
}
}
/// <summary>
/// Запускает генерацию изображения (обертка для безопасного fire-and-forget)
/// </summary>
@@ -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;
}
}
}