diff --git a/Core/Data/GlobalSettings.cs b/Core/Data/GlobalSettings.cs index 5c2b385..398f88e 100644 --- a/Core/Data/GlobalSettings.cs +++ b/Core/Data/GlobalSettings.cs @@ -6,20 +6,28 @@ using Newtonsoft.Json; namespace ADOFAIRenderer.Core.Data; -public class GlobalConfig +internal class GlobalConfig { public string? OutputDirectory { get; set; } + public string? LastPresetName { get; set; } + public bool DefaultPresetsInitialized { get; set; } } -public static class GlobalSettings +internal static class GlobalSettings { - private static string SettingsFilePath => - Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "global_settings.json"); + private static string GetGameRootDir() + { + var root = MelonEnvironment.GameRootDirectory; + return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root; + } - public static GlobalConfig Current { get; set; } = new(); + private static string SettingsFilePath => + Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json"); + + public static GlobalConfig Current { get; private set; } = new(); public static string DefaultOutputDirectory => - Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); + Path.Combine(GetGameRootDir(), "Render"); public static string EffectiveOutputDirectory { diff --git a/Core/PresetManager.cs b/Core/PresetManager.cs index f0b2bde..afcf8bc 100644 --- a/Core/PresetManager.cs +++ b/Core/PresetManager.cs @@ -2,24 +2,118 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using ADOFAIRenderer.Core.Data; +using ADOFAIRenderer.Data; using MelonLoader; using MelonLoader.Utils; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace ADOFAIRenderer.Data; +namespace ADOFAIRenderer.Core; -internal class RenderPreset +public class RenderPreset { public string Name { get; set; } = string.Empty; public RenderConfig Config { get; set; } = null!; + + public RenderPreset Clone() + { + return new RenderPreset + { + Name = Name, + Config = Config.Clone() + }; + } } -internal static class PresetManager +public static class PresetManager { + private static readonly List DefaultPresets = new() + { + new RenderPreset + { + Name = "FHD 60fps (1x)", + Config = new RenderConfig + { + RenderWidth = 1920, + RenderHeight = 1080, + RenderFPS = 60, + VideoFPS = 60, + DelayAfterEnd = 15, + ShowJudgementText = false, + VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 20000, MaxBitrate = 35000, QP = 18, KeyframeInterval = 2.0 }, + AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 }, + AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" } + } + }, + new RenderPreset + { + Name = "FHD 60fps (4x)", + Config = new RenderConfig + { + RenderWidth = 1920, + RenderHeight = 1080, + RenderFPS = 240, + VideoFPS = 60, + DelayAfterEnd = 15, + ShowJudgementText = false, + VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 30000, MaxBitrate = 50000, QP = 18, KeyframeInterval = 2.0 }, + AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 }, + AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" } + } + }, + new RenderPreset + { + Name = "QHD 60fps (4x)", + Config = new RenderConfig + { + RenderWidth = 2560, + RenderHeight = 1440, + RenderFPS = 240, + VideoFPS = 60, + DelayAfterEnd = 15, + ShowJudgementText = false, + VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 35000, MaxBitrate = 60000, QP = 18, KeyframeInterval = 2.0 }, + AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 }, + AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" } + } + }, + new RenderPreset + { + Name = "4K 60fps (4x)", + Config = new RenderConfig + { + RenderWidth = 3840, + RenderHeight = 2160, + RenderFPS = 240, + VideoFPS = 60, + DelayAfterEnd = 15, + ShowJudgementText = false, + VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 60000, MaxBitrate = 100000, QP = 18, KeyframeInterval = 2.0 }, + AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 }, + AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" } + } + } + }; + private static string GetPresetsDirectory() { - return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets"); + string? root = null; + try + { + root = MelonEnvironment.GameRootDirectory; + } + catch + { + // Outside MelonLoader host environment (e.g. tests) + } + + if (string.IsNullOrEmpty(root)) + { + root = AppDomain.CurrentDomain.BaseDirectory; + } + + return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets"); } private static string GetSafeFileName(string name) @@ -27,6 +121,45 @@ internal static class PresetManager return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_')); } + public static void EnsureDefaultPresetsCreated() + { + try + { + var dir = GetPresetsDirectory(); + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + if (!GlobalSettings.Current.DefaultPresetsInitialized) + { + var settings = new JsonSerializerSettings + { + Formatting = Formatting.Indented + }; + settings.Converters.Add(new ColorJsonConverter()); + + foreach (var def in DefaultPresets) + { + var safeName = GetSafeFileName(def.Name); + var filePath = Path.Combine(dir, safeName + ".json"); + if (!File.Exists(filePath)) + { + var json = JsonConvert.SerializeObject(def, settings); + File.WriteAllText(filePath, json); + } + } + + GlobalSettings.Current.DefaultPresetsInitialized = true; + GlobalSettings.Save(); + } + } + catch (Exception ex) + { + MelonLogger.Warning($"Error initializing default presets: {ex.Message}"); + } + } + public static void SavePreset(string name, RenderConfig config) { if (string.IsNullOrWhiteSpace(name)) @@ -43,10 +176,16 @@ internal static class PresetManager var preset = new RenderPreset { Name = name, - Config = config + Config = config.Clone() }; - string json = JsonConvert.SerializeObject(preset, Formatting.Indented); + var settings = new JsonSerializerSettings + { + Formatting = Formatting.Indented + }; + settings.Converters.Add(new ColorJsonConverter()); + + string json = JsonConvert.SerializeObject(preset, settings); string safeName = GetSafeFileName(name); string filePath = Path.Combine(dir, safeName + ".json"); File.WriteAllText(filePath, json); @@ -54,14 +193,26 @@ internal static class PresetManager public static List GetPresets() { + EnsureDefaultPresetsCreated(); + var presets = new List(); var dir = GetPresetsDirectory(); - - if (!Directory.Exists(dir)) return presets; + if (!Directory.Exists(dir)) + { + foreach (var b in DefaultPresets) + { + presets.Add(b.Clone()); + } + return presets; + } try { var files = Directory.GetFiles(dir, "*.json"); + var serializerSettings = new JsonSerializerSettings(); + serializerSettings.Converters.Add(new ColorJsonConverter()); + var serializer = JsonSerializer.Create(serializerSettings); + foreach (var file in files) { try @@ -146,9 +297,20 @@ internal static class PresetManager } } - var preset = jObject.ToObject(); - if (preset != null && !string.IsNullOrEmpty(preset.Name)) + var preset = jObject.ToObject(serializer); + if (preset != null) { + var fileBaseName = Path.GetFileNameWithoutExtension(file); + if (string.IsNullOrWhiteSpace(preset.Name)) + { + preset.Name = fileBaseName; + } + + if (presets.Any(p => string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + presets.Add(preset); } } @@ -177,13 +339,44 @@ internal static class PresetManager public static void DeletePreset(string name) { + if (string.IsNullOrWhiteSpace(name)) return; + var dir = GetPresetsDirectory(); + if (!Directory.Exists(dir)) return; + var safeName = GetSafeFileName(name); var filePath = Path.Combine(dir, safeName + ".json"); if (File.Exists(filePath)) { File.Delete(filePath); + return; + } + + foreach (var file in Directory.GetFiles(dir, "*.json")) + { + try + { + var fileBaseName = Path.GetFileNameWithoutExtension(file); + if (string.Equals(fileBaseName, name, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(file); + return; + } + + var json = File.ReadAllText(file); + var jObject = JsonConvert.DeserializeObject(json); + var pName = (string?)jObject?["Name"]; + if (string.Equals(pName, name, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(file); + return; + } + } + catch + { + // Ignore read errors during cleanup search + } } } } \ No newline at end of file diff --git a/Data/ColorJsonConverter.cs b/Data/ColorJsonConverter.cs new file mode 100644 index 0000000..374ff85 --- /dev/null +++ b/Data/ColorJsonConverter.cs @@ -0,0 +1,109 @@ +using System; +using System.Globalization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace ADOFAIRenderer.Data; + +/// +/// Converts Unity and nullable to and from hex string (#RRGGBBAA). +/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString). +/// +public class ColorJsonConverter : JsonConverter +{ + public override bool CanConvert(Type objectType) + { + return objectType == typeof(Color) || objectType == typeof(Color?); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + + var color = (Color)value; + writer.WriteValue(ToHex(color)); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType; + + if (reader.TokenType == JsonToken.Null) + { + return isNullable ? null : (object)default(Color); + } + + if (reader.TokenType == JsonToken.String) + { + var str = (string?)reader.Value; + if (TryParseHex(str, out var parsedColor)) + { + return parsedColor; + } + + return isNullable ? null : (object)default(Color); + } + + if (reader.TokenType == JsonToken.StartObject) + { + var obj = JObject.Load(reader); + float r = (float?)obj["r"] ?? 0f; + float g = (float?)obj["g"] ?? 0f; + float b = (float?)obj["b"] ?? 0f; + float a = (float?)obj["a"] ?? 1f; + return new Color(r, g, b, a); + } + + return isNullable ? null : (object)default(Color); + } + + public static string ToHex(Color color) + { + byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255); + byte g = (byte)Math.Clamp((int)Math.Round(color.g * 255f), 0, 255); + byte b = (byte)Math.Clamp((int)Math.Round(color.b * 255f), 0, 255); + byte a = (byte)Math.Clamp((int)Math.Round(color.a * 255f), 0, 255); + return $"#{r:X2}{g:X2}{b:X2}{a:X2}"; + } + + public static bool TryParseHex(string? hex, out Color color) + { + color = default; + if (string.IsNullOrWhiteSpace(hex)) return false; + + var str = hex.Trim(); + if (str.StartsWith("#")) + { + str = str.Substring(1); + } + + if (str.Length == 6) + { + if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) && + byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) && + byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + { + color = new Color(r / 255f, g / 255f, b / 255f, 1f); + return true; + } + } + else if (str.Length == 8) + { + if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) && + byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) && + byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) && + byte.TryParse(str.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var a)) + { + color = new Color(r / 255f, g / 255f, b / 255f, a / 255f); + return true; + } + } + + return false; + } +} diff --git a/Data/RenderConfig.cs b/Data/RenderConfig.cs index 381e6d7..832db43 100644 --- a/Data/RenderConfig.cs +++ b/Data/RenderConfig.cs @@ -1,74 +1,156 @@ -using System.Diagnostics.CodeAnalysis; -using UnityEngine; - -namespace ADOFAIRenderer.Data; - -[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] -public class RenderConfig -{ - public double RenderFPS { get; set; } - public double VideoFPS { get; set; } - - public int RenderWidth { get; set; } - public int RenderHeight { get; set; } - - public VideoEncodingConfig VideoConfig { get; set; } = new(); - public AudioEncodingConfig AudioConfig { get; set; } = new(); - public AdvancedConfig AdvancedConfig { get; set; } = new(); - - public double DelayAfterEnd { get; set; } = 15; - public bool ShowJudgementText { get; set; } = false; -} - -[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] -public class AdvancedConfig -{ - public int? RandomSeed { get; set; } = null; - public bool ProcessAudio { get; set; } = true; - public bool NonTile { get; set; } = false; - public string FileExtension { get; set; } = "mkv"; - - public Color? firePlanetColor = null; - public Color? icePlanetColor = null; - public Color? windPlanetColor = null; -} - -public class VideoEncodingConfig -{ - public Codec Codec { get; set; } = Codec.H264; - public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR; - - public int Bitrate { get; set; } = 30000; - public int MaxBitrate { get; set; } = 50000; - // ReSharper disable once InconsistentNaming - public int QP { get; set; } = 18; - public double KeyframeInterval { get; set; } = 2.0; - public bool ForceSoftwareEncoder { get; set; } -} - -public class AudioEncodingConfig -{ - private int _audioSampleRate = 48000; - private int _audioChannels = 2; - private int _audioBitrate = 256000; - - public bool RenderAudio { get; set; } = true; - - public int AudioSampleRate - { - get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate; - set => _audioSampleRate = value <= 0 ? 48000 : value; - } - - public int AudioChannels - { - get => _audioChannels <= 0 ? 2 : _audioChannels; - set => _audioChannels = value <= 0 ? 2 : value; - } - - public int AudioBitrate - { - get => _audioBitrate <= 0 ? 256000 : _audioBitrate; - set => _audioBitrate = value <= 0 ? 256000 : value; - } -} \ No newline at end of file +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; +using UnityEngine; + +namespace ADOFAIRenderer.Data; + +[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] +public class RenderConfig +{ + public double RenderFPS { get; set; } + public double VideoFPS { get; set; } + + public int RenderWidth { get; set; } + public int RenderHeight { get; set; } + + public VideoEncodingConfig VideoConfig { get; set; } = new(); + public AudioEncodingConfig AudioConfig { get; set; } = new(); + public AdvancedConfig AdvancedConfig { get; set; } = new(); + + public double DelayAfterEnd { get; set; } = 15; + public bool ShowJudgementText { get; set; } = false; + + public void CopyFrom(RenderConfig? source) + { + if (source == null) return; + RenderFPS = source.RenderFPS; + VideoFPS = source.VideoFPS; + RenderWidth = source.RenderWidth; + RenderHeight = source.RenderHeight; + DelayAfterEnd = source.DelayAfterEnd; + ShowJudgementText = source.ShowJudgementText; + + VideoConfig.CopyFrom(source.VideoConfig); + AudioConfig.CopyFrom(source.AudioConfig); + AdvancedConfig.CopyFrom(source.AdvancedConfig); + } + + public RenderConfig Clone() + { + var clone = new RenderConfig(); + clone.CopyFrom(this); + return clone; + } +} + +[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] +public class AdvancedConfig +{ + public int? RandomSeed { get; set; } = null; + public bool ProcessAudio { get; set; } = true; + public bool NonTile { get; set; } = false; + public string FileExtension { get; set; } = "mkv"; + + [JsonConverter(typeof(ColorJsonConverter))] + public Color? firePlanetColor = null; + + [JsonConverter(typeof(ColorJsonConverter))] + public Color? icePlanetColor = null; + + [JsonConverter(typeof(ColorJsonConverter))] + public Color? windPlanetColor = null; + + public void CopyFrom(AdvancedConfig? source) + { + if (source == null) return; + RandomSeed = source.RandomSeed; + ProcessAudio = source.ProcessAudio; + NonTile = source.NonTile; + FileExtension = source.FileExtension; + firePlanetColor = source.firePlanetColor; + icePlanetColor = source.icePlanetColor; + windPlanetColor = source.windPlanetColor; + } + + public AdvancedConfig Clone() + { + var clone = new AdvancedConfig(); + clone.CopyFrom(this); + return clone; + } +} + +public class VideoEncodingConfig +{ + public Codec Codec { get; set; } = Codec.H264; + public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR; + + public int Bitrate { get; set; } = 30000; + public int MaxBitrate { get; set; } = 50000; + // ReSharper disable once InconsistentNaming + public int QP { get; set; } = 18; + public double KeyframeInterval { get; set; } = 2.0; + public bool ForceSoftwareEncoder { get; set; } + + public void CopyFrom(VideoEncodingConfig? source) + { + if (source == null) return; + Codec = source.Codec; + RateControlMode = source.RateControlMode; + Bitrate = source.Bitrate; + MaxBitrate = source.MaxBitrate; + QP = source.QP; + KeyframeInterval = source.KeyframeInterval; + ForceSoftwareEncoder = source.ForceSoftwareEncoder; + } + + public VideoEncodingConfig Clone() + { + var clone = new VideoEncodingConfig(); + clone.CopyFrom(this); + return clone; + } +} + +public class AudioEncodingConfig +{ + private int _audioSampleRate = 48000; + private int _audioChannels = 2; + private int _audioBitrate = 256000; + + public bool RenderAudio { get; set; } = true; + + public int AudioSampleRate + { + get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate; + set => _audioSampleRate = value <= 0 ? 48000 : value; + } + + public int AudioChannels + { + get => _audioChannels <= 0 ? 2 : _audioChannels; + set => _audioChannels = value <= 0 ? 2 : value; + } + + public int AudioBitrate + { + get => _audioBitrate <= 0 ? 256000 : _audioBitrate; + set => _audioBitrate = value <= 0 ? 256000 : value; + } + + public void CopyFrom(AudioEncodingConfig? source) + { + if (source == null) return; + RenderAudio = source.RenderAudio; + AudioSampleRate = source.AudioSampleRate; + AudioChannels = source.AudioChannels; + AudioBitrate = source.AudioBitrate; + } + + public AudioEncodingConfig Clone() + { + var clone = new AudioEncodingConfig(); + clone.CopyFrom(this); + return clone; + } +} \ No newline at end of file diff --git a/Localization/Locale/en.json b/Localization/Locale/en.json index e3af036..dd18ac8 100644 --- a/Localization/Locale/en.json +++ b/Localization/Locale/en.json @@ -1,5 +1,18 @@ { "ui.render_config.window_title": "Render Config", + "ui.render_config.presets": "Presets", + "ui.render_config.preset_select": "Preset", + "ui.render_config.preset_name_placeholder": "New preset name...", + "ui.render_config.preset_save": "Save", + "ui.render_config.preset_delete": "Delete", + "ui.render_config.preset_overwrite_title": "Overwrite Preset", + "ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?", + "ui.render_config.preset_delete_title": "Delete Preset", + "ui.render_config.preset_delete_desc": "Are you sure you want to delete preset '{0}'?", + "ui.render_config.preset_name_empty": "Please enter a preset name.", + "ui.render_config.preset_custom": "(Modified)", + "ui.render_config.confirm": "Confirm", + "ui.render_config.cancel": "Cancel", "ui.render_config.render_fps": "Render FPS", "ui.render_config.video_fps": "Video FPS", "ui.render_config.render_width": "Render Width", diff --git a/Localization/Locale/ko.json b/Localization/Locale/ko.json index 1ac92e3..6d8563a 100644 --- a/Localization/Locale/ko.json +++ b/Localization/Locale/ko.json @@ -1,15 +1,28 @@ { "ui.render_config.window_title": "렌더 설정", + "ui.render_config.presets": "프리셋", + "ui.render_config.preset_select": "프리셋 선택", + "ui.render_config.preset_name_placeholder": "새 프리셋 이름...", + "ui.render_config.preset_save": "저장", + "ui.render_config.preset_delete": "삭제", + "ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기", + "ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?", + "ui.render_config.preset_delete_title": "프리셋 삭제", + "ui.render_config.preset_delete_desc": "'{0}' 프리셋을 정말 삭제하시겠습니까?", + "ui.render_config.preset_name_empty": "프리셋 이름을 입력해 주세요.", + "ui.render_config.preset_custom": "(수정됨)", + "ui.render_config.confirm": "확인", + "ui.render_config.cancel": "취소", "ui.render_config.render_fps": "렌더링 FPS", "ui.render_config.video_fps": "영상 FPS", "ui.render_config.render_width": "렌더링 너비", "ui.render_config.render_height": "렌더링 높이", "ui.render_config.delay_after_end": "종료 후 대기 시간 (초)", "ui.render_config.show_judgement": "판정 텍스트 표시", - "ui.render_config.video_encoding": "Video Encoding Config", - "ui.render_config.audio_encoding": "Audio Encoding Config", - "ui.render_config.global_settings": "Global Settings", - "ui.render_config.advanced_settings": "Advanced Settings", + "ui.render_config.video_encoding": "비디오 인코딩 설정", + "ui.render_config.audio_encoding": "오디오 인코딩 설정", + "ui.render_config.global_settings": "전역 설정", + "ui.render_config.advanced_settings": "고급 설정", "ui.render_config.go_render": "렌더 시작", "ui.render_config.codec": "코덱", "ui.render_config.rate_control": "비트레이트 제어 모드", diff --git a/UI/Window/RenderConfigWindow.cs b/UI/Window/RenderConfigWindow.cs index cf5d5e7..9963feb 100644 --- a/UI/Window/RenderConfigWindow.cs +++ b/UI/Window/RenderConfigWindow.cs @@ -21,6 +21,13 @@ internal static class RenderConfigWindow { private static O5Window? _configWindow; + // Presets controls + private static O5Dropdown? _presetDropdown; + private static O5InputField? _presetNameInput; + private static O5Button? _savePresetButton; + private static O5Button? _deletePresetButton; + private static string? _lastLoadedPresetName; + // Controls cache for validation & sync private static O5Slider? _renderFpsSlider; private static O5Slider? _videoFPSSlider; @@ -89,6 +96,10 @@ internal static class RenderConfigWindow BuildUI(_configWindow.Content); } + else + { + ReloadPresetDropdown(GlobalSettings.Current.LastPresetName); + } // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract var config = RenderManager.Instance.Configuration ??= new RenderConfig(); @@ -133,7 +144,117 @@ internal static class RenderConfigWindow 864, 960, 1536, 2048, 3072 // 4:3 }; - // TODO: Presets + // Presets (Collapsible Card) + var (_, presetContent) = O5Factory.Card( + ctx, scrollContent, + LocalizationManager.Get("ui.render_config.presets"), + activeValue: true, + onActiveChanged: null, + onDeleteClick: null, + showDeleteButton: false, + showActiveToggle: false + ); + + var presets = PresetManager.GetPresets(); + var presetNames = presets.Select(p => p.Name).ToList(); + var initialPresetName = GlobalSettings.Current.LastPresetName; + if (string.IsNullOrEmpty(initialPresetName) || !presetNames.Contains(initialPresetName)) + { + initialPresetName = presetNames.FirstOrDefault(n => n.StartsWith("QHD", StringComparison.OrdinalIgnoreCase)) + ?? (presetNames.Count > 0 ? presetNames[0] : ""); + } + _lastLoadedPresetName = initialPresetName; + + if (config.RenderFPS <= 0) + { + var initialPreset = presets.FirstOrDefault(p => string.Equals(p.Name, initialPresetName, StringComparison.OrdinalIgnoreCase)) ?? presets.FirstOrDefault(); + if (initialPreset != null) + { + config.CopyFrom(initialPreset.Config); + } + } + EnsureDefaultConfig(config); + + _presetDropdown = CreateDropdownField( + ctx, presetContent, + LocalizationManager.Get("ui.render_config.preset_select"), + initialPresetName, initialPresetName, + presetNames, p => p, + "dd_presets", + selectedName => + { + OnPresetSelected(selectedName, config); + } + ); + + var actionsRowObj = new GameObject("PresetActionsRow"); + actionsRowObj.transform.SetParent(presetContent, false); + var actionsRowRect = actionsRowObj.AddComponent(); + var actionsHLayout = actionsRowObj.AddComponent(); + actionsHLayout.spacing = 8f; + actionsHLayout.childAlignment = TextAnchor.MiddleCenter; + actionsHLayout.childControlWidth = true; + actionsHLayout.childControlHeight = true; + actionsHLayout.childForceExpandWidth = false; + actionsHLayout.childForceExpandHeight = false; + + // ReSharper disable once InconsistentNaming + var actionsRowLE = actionsRowObj.AddComponent(); + actionsRowLE.preferredHeight = 40f; + actionsRowLE.flexibleWidth = 1f; + + _presetNameInput = O5Factory.Input( + ctx: ctx, + parent: actionsRowRect, + defaultValue: "", + value: initialPresetName, + onChanged: null, + placeholder: LocalizationManager.Get("ui.render_config.preset_name_placeholder"), + icon: null, + id: "input_preset_name" + ); + var inputLE = _presetNameInput.Rect.GetComponent() ?? _presetNameInput.Rect.gameObject.AddComponent(); + inputLE.flexibleWidth = 1f; + inputLE.preferredHeight = 40f; + + _savePresetButton = O5Factory.Button( + ctx: ctx, + parent: actionsRowRect, + onClick: () => OnSavePresetClicked(config), + text: LocalizationManager.Get("ui.render_config.preset_save"), + id: "btn_preset_save", + height: 40f + ); + var saveLE = _savePresetButton.Rect.GetComponent() ?? _savePresetButton.Rect.gameObject.AddComponent(); + saveLE.preferredWidth = 90f; + saveLE.flexibleWidth = 0f; + saveLE.preferredHeight = 40f; + + var redTheme = ctx.Theme with + { + ObjectButton = ctx.Theme.SoftRed, + ButtonHover = ctx.Theme.SoftRed * 0.8f + }; + var redCtx = new O5Context( + config: ctx.Config, + theme: redTheme, + fonts: ctx.Fonts, + tween: ctx.Tween + ); + _deletePresetButton = O5Factory.Button( + ctx: redCtx, + parent: actionsRowRect, + onClick: () => OnDeletePresetClicked(config), + text: LocalizationManager.Get("ui.render_config.preset_delete"), + id: "btn_preset_delete", + height: 40f + ); + var deleteLE = _deletePresetButton.Rect.GetComponent() ?? _deletePresetButton.Rect.gameObject.AddComponent(); + deleteLE.preferredWidth = 90f; + deleteLE.flexibleWidth = 0f; + deleteLE.preferredHeight = 40f; + + UpdateDeleteButtonState(); // 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개) var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f); @@ -144,7 +265,7 @@ internal static class RenderConfigWindow format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(renderFpsSnap, value), - inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; }, + inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.render_fps"), id: "slider_render_fps", showFill: true ); @@ -155,7 +276,7 @@ internal static class RenderConfigWindow format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(videoFpsSnap, value), - inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; }, + inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.video_fps"), id: "slider_video_fps", showFill: true ); @@ -166,7 +287,7 @@ internal static class RenderConfigWindow format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(renderWidthSnap, value), inputFilter: value => Math.Max(value, 2), - onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); }, + onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.render_width"), id: "slider_render_width", showFill: true ); @@ -177,7 +298,7 @@ internal static class RenderConfigWindow format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(renderHeightSnap, value), inputFilter: value => Math.Max(value, 2), - onComplete: null, onChanged: val => { config.RenderHeight = (int)Math.Round(val); }, + onComplete: null, onChanged: val => { config.RenderHeight = (int)Math.Round(val); MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.render_height"), id: "slider_render_height", showFill: true ); @@ -188,7 +309,7 @@ internal static class RenderConfigWindow format: "F3", clampMode: ClampMode.All, sliderFilter: null, inputFilter: null, - onComplete: null, onChanged: val => { config.DelayAfterEnd = val; }, + onComplete: null, onChanged: val => { config.DelayAfterEnd = val; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.delay_after_end"), id: "slider_delay_after_end", showFill: true ); @@ -198,7 +319,7 @@ internal static class RenderConfigWindow false, config.ShowJudgementText, LocalizationManager.Get("ui.render_config.show_judgement"), "toggle_show_judgement", - val => config.ShowJudgementText = val, + val => { config.ShowJudgementText = val; MarkConfigModified(); }, generateLabel: false ); @@ -225,6 +346,7 @@ internal static class RenderConfigWindow { config.VideoConfig.Codec = c; UpdateVideoCodecUI(c, config); + MarkConfigModified(); }); _bitrateInput = CreateInputField( @@ -233,7 +355,7 @@ internal static class RenderConfigWindow "30000", config.VideoConfig.Bitrate.ToString(), "input_bitrate", out _bitrateLabel, TMPro.TMP_InputField.CharacterValidation.Integer, - val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; } + val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; MarkConfigModified(); } ); _qpInput = CreateInputField( @@ -242,7 +364,7 @@ internal static class RenderConfigWindow "18", config.VideoConfig.QP.ToString(), "input_qp", out _qpLabel, TMPro.TMP_InputField.CharacterValidation.Integer, - val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; } + val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; MarkConfigModified(); } ); var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode)); @@ -256,6 +378,7 @@ internal static class RenderConfigWindow { config.VideoConfig.RateControlMode = m; UpdateRateControlUI(m); + MarkConfigModified(); } ); @@ -265,7 +388,7 @@ internal static class RenderConfigWindow "50000", config.VideoConfig.MaxBitrate.ToString(), "input_max_bitrate", out _maxBitrateLabel, TMPro.TMP_InputField.CharacterValidation.Integer, - val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; } + val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; MarkConfigModified(); } ); _forceSoftwareToggle = CreateToggleField( @@ -273,7 +396,7 @@ internal static class RenderConfigWindow false, config.VideoConfig.ForceSoftwareEncoder, LocalizationManager.Get("ui.render_config.force_software"), "toggle_force_software", - val => config.VideoConfig.ForceSoftwareEncoder = val, + val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); }, generateLabel: true ); @@ -283,7 +406,7 @@ internal static class RenderConfigWindow format: "F1", clampMode: ClampMode.All, sliderFilter: null, inputFilter: null, - onComplete: null, onChanged: val => { config.VideoConfig.KeyframeInterval = val; }, + onComplete: null, onChanged: val => { config.VideoConfig.KeyframeInterval = val; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.keyframe_interval"), id: "slider_keyframe_interval", showFill: true ); @@ -305,7 +428,7 @@ internal static class RenderConfigWindow true, config.AudioConfig.RenderAudio, LocalizationManager.Get("ui.render_config.render_audio"), "toggle_render_audio", - val => config.AudioConfig.RenderAudio = val, + val => { config.AudioConfig.RenderAudio = val; MarkConfigModified(); }, generateLabel: false ); @@ -315,7 +438,7 @@ internal static class RenderConfigWindow format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(audioBitrateSnap, value), - inputFilter: null, onComplete: null, onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; }, + inputFilter: null, onComplete: null, onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; MarkConfigModified(); }, text: LocalizationManager.Get("ui.render_config.audio_bitrate"), id: "slider_audio_bitrate", showFill: true ); @@ -327,7 +450,7 @@ internal static class RenderConfigWindow 2, config.AudioConfig.AudioChannels, channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)", "dd_channels", - c => config.AudioConfig.AudioChannels = c + c => { config.AudioConfig.AudioChannels = c; MarkConfigModified(); } ); _sampleRateDropdown = CreateDropdownField( @@ -336,7 +459,7 @@ internal static class RenderConfigWindow 48000, config.AudioConfig.AudioSampleRate, CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz", "dd_sample_rate", - r => config.AudioConfig.AudioSampleRate = r + r => { config.AudioConfig.AudioSampleRate = r; MarkConfigModified(); } ); // 6. Global Settings (Collapsible Card) @@ -425,7 +548,11 @@ internal static class RenderConfigWindow "mkv", config.AdvancedConfig.FileExtension, extensions, e => e, "dd_adv_file_extension", - e => { if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; } + e => + { + if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; + MarkConfigModified(); + } ); _processAudioToggle = CreateToggleField( @@ -433,7 +560,11 @@ internal static class RenderConfigWindow true, config.AdvancedConfig.ProcessAudio, LocalizationManager.Get("ui.render_config.process_audio"), "toggle_process_audio", - val => config.AdvancedConfig.ProcessAudio = val, + val => + { + config.AdvancedConfig.ProcessAudio = val; + MarkConfigModified(); + }, generateLabel: false ); @@ -447,6 +578,7 @@ internal static class RenderConfigWindow { if (int.TryParse(val, out var s)) config.AdvancedConfig.RandomSeed = s; else if (string.IsNullOrWhiteSpace(val)) config.AdvancedConfig.RandomSeed = null; + MarkConfigModified(); } ); @@ -455,7 +587,11 @@ internal static class RenderConfigWindow false, config.AdvancedConfig.NonTile, LocalizationManager.Get("ui.render_config.non_tile"), "toggle_non_tile", - val => config.AdvancedConfig.NonTile = val, + val => + { + config.AdvancedConfig.NonTile = val; + MarkConfigModified(); + }, generateLabel: false ); @@ -482,6 +618,7 @@ internal static class RenderConfigWindow _firePlanetPicker?.SetBlocked(!val); config.AdvancedConfig.firePlanetColor = val ? _firePlanetPicker?.Value : null; if (!val) _firePlanetPicker?.SetExpanded(false); + MarkConfigModified(); }, generateLabel: false ); @@ -493,7 +630,10 @@ internal static class RenderConfigWindow val => { if (_firePlanetToggle?.Value == true) + { config.AdvancedConfig.firePlanetColor = val; + MarkConfigModified(); + } } ); if (!config.AdvancedConfig.firePlanetColor.HasValue) @@ -512,6 +652,7 @@ internal static class RenderConfigWindow _icePlanetPicker?.SetBlocked(!val); config.AdvancedConfig.icePlanetColor = val ? _icePlanetPicker?.Value : null; if (!val) _icePlanetPicker?.SetExpanded(false); + MarkConfigModified(); }, generateLabel: false ); @@ -523,7 +664,10 @@ internal static class RenderConfigWindow val => { if (_icePlanetToggle?.Value == true) + { config.AdvancedConfig.icePlanetColor = val; + MarkConfigModified(); + } } ); if (!config.AdvancedConfig.icePlanetColor.HasValue) @@ -542,6 +686,7 @@ internal static class RenderConfigWindow _windPlanetPicker?.SetBlocked(!val); config.AdvancedConfig.windPlanetColor = val ? _windPlanetPicker?.Value : null; if (!val) _windPlanetPicker?.SetExpanded(false); + MarkConfigModified(); }, generateLabel: false ); @@ -553,7 +698,10 @@ internal static class RenderConfigWindow val => { if (_windPlanetToggle?.Value == true) + { config.AdvancedConfig.windPlanetColor = val; + MarkConfigModified(); + } } ); if (!config.AdvancedConfig.windPlanetColor.HasValue) @@ -647,49 +795,19 @@ internal static class RenderConfigWindow UpdateVideoCodecUI(config.VideoConfig.Codec, config); UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true); + + if (!string.IsNullOrEmpty(GlobalSettings.Current.LastPresetName)) + { + _presetDropdown?.Set(GlobalSettings.Current.LastPresetName, false); + _lastLoadedPresetName = GlobalSettings.Current.LastPresetName; + UpdateDeleteButtonState(); + } } private static void TryGoRender() { var config = RenderManager.Instance.Configuration; - - // Sync Video Config - if (_bitrateInput != null && int.TryParse(_bitrateInput.Value, out var bitrate) && bitrate > 0) - { - config.VideoConfig.Bitrate = bitrate; - } - if (_maxBitrateInput != null && int.TryParse(_maxBitrateInput.Value, out var maxBitrate) && maxBitrate > 0) - { - config.VideoConfig.MaxBitrate = maxBitrate; - } - if (_qpInput != null && int.TryParse(_qpInput.Value, out var qp) && qp >= 0) - { - config.VideoConfig.QP = qp; - } - - // Sync Advanced Config - if (_randomSeedInput != null) - { - if (int.TryParse(_randomSeedInput.Value, out var seed)) - { - config.AdvancedConfig.RandomSeed = seed; - } - else if (string.IsNullOrWhiteSpace(_randomSeedInput.Value)) - { - config.AdvancedConfig.RandomSeed = null; - } - } - - // Sync Planet Colors - config.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null) - ? _firePlanetPicker.Value - : null; - config.AdvancedConfig.icePlanetColor = (_icePlanetToggle?.Value == true && _icePlanetPicker != null) - ? _icePlanetPicker.Value - : null; - config.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null) - ? _windPlanetPicker.Value - : null; + SyncUItoConfig(config); // Sync Output Directory if (_outputDirInput != null) @@ -725,6 +843,250 @@ internal static class RenderConfigWindow new RenderSession().Start(config, RenderManager.Instance.levelPath); } + private static void SyncUItoConfig(RenderConfig target) + { + if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value; + if (_videoFPSSlider != null) target.VideoFPS = _videoFPSSlider.Value; + if (_renderWidthSlider != null) target.RenderWidth = (int)Math.Round(_renderWidthSlider.Value); + if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value); + if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value; + if (_showJudgementToggle != null) target.ShowJudgementText = _showJudgementToggle.Value; + + if (_codecDropdown != null) target.VideoConfig.Codec = _codecDropdown.Value; + if (_rateControlDropdown != null) target.VideoConfig.RateControlMode = _rateControlDropdown.Value; + + if (_bitrateInput != null && int.TryParse(_bitrateInput.Value, out var bitrate) && bitrate > 0) + { + target.VideoConfig.Bitrate = bitrate; + } + if (_maxBitrateInput != null && int.TryParse(_maxBitrateInput.Value, out var maxBitrate) && maxBitrate > 0) + { + target.VideoConfig.MaxBitrate = maxBitrate; + } + if (_qpInput != null && int.TryParse(_qpInput.Value, out var qp) && qp >= 0) + { + target.VideoConfig.QP = qp; + } + if (_keyframeIntervalSlider != null) target.VideoConfig.KeyframeInterval = _keyframeIntervalSlider.Value; + if (_forceSoftwareToggle != null) target.VideoConfig.ForceSoftwareEncoder = _forceSoftwareToggle.Value; + + if (_renderAudioToggle != null) target.AudioConfig.RenderAudio = _renderAudioToggle.Value; + if (_sampleRateDropdown != null) target.AudioConfig.AudioSampleRate = _sampleRateDropdown.Value; + if (_channelsDropdown != null) target.AudioConfig.AudioChannels = _channelsDropdown.Value; + if (_audioBitrateSlider != null) target.AudioConfig.AudioBitrate = (int)Math.Round(_audioBitrateSlider.Value) * 1000; + + if (_fileExtensionDropdown != null && !string.IsNullOrEmpty(_fileExtensionDropdown.Value)) + target.AdvancedConfig.FileExtension = _fileExtensionDropdown.Value; + + if (_randomSeedInput != null) + { + if (int.TryParse(_randomSeedInput.Value, out var seed)) + { + target.AdvancedConfig.RandomSeed = seed; + } + else if (string.IsNullOrWhiteSpace(_randomSeedInput.Value)) + { + target.AdvancedConfig.RandomSeed = null; + } + } + if (_processAudioToggle != null) target.AdvancedConfig.ProcessAudio = _processAudioToggle.Value; + if (_nonTileToggle != null) target.AdvancedConfig.NonTile = _nonTileToggle.Value; + + // Planet Colors + target.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null) + ? _firePlanetPicker.Value + : null; + target.AdvancedConfig.icePlanetColor = (_icePlanetToggle?.Value == true && _icePlanetPicker != null) + ? _icePlanetPicker.Value + : null; + target.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null) + ? _windPlanetPicker.Value + : null; + } + + private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom"); + + private static void MarkConfigModified() + { + if (_presetDropdown == null) return; + var customLabel = CustomPresetLabel; + if (_presetDropdown.Value == customLabel) return; + + var currentValues = _presetDropdown.Values; + if (!currentValues.Contains(customLabel)) + { + var list = new List(currentValues); + list.Insert(0, customLabel); + _presetDropdown.SetValues(list); + } + + _presetDropdown.Set(customLabel, false); + UpdateDeleteButtonState(); + } + + private static void OnPresetSelected(string? selectedName, RenderConfig config) + { + if (string.IsNullOrEmpty(selectedName) || selectedName == CustomPresetLabel) return; + var preset = PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, selectedName, StringComparison.OrdinalIgnoreCase)); + if (preset == null) return; + + GlobalSettings.Current.LastPresetName = preset.Name; + GlobalSettings.Save(); + _lastLoadedPresetName = preset.Name; + + config.CopyFrom(preset.Config); + ApplyConfigToUI(config); + + _presetNameInput?.Set(preset.Name, false); + + if (_presetDropdown != null && _presetDropdown.Values.Contains(CustomPresetLabel)) + { + var cleaned = _presetDropdown.Values.Where(v => v != CustomPresetLabel).ToList(); + _presetDropdown.SetValues(cleaned); + _presetDropdown.Set(preset.Name, false); + } + + UpdateDeleteButtonState(); + } + + private static void OnSavePresetClicked(RenderConfig config) + { + SyncUItoConfig(config); + + var name = _presetNameInput?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(name) || name == CustomPresetLabel) + { + CommonWarningDialogUI.ShowDialog( + LocalizationManager.Get("ui.render_config.validation_title"), + LocalizationManager.Get("ui.render_config.preset_name_empty"), + new DialogButton(LocalizationManager.Get("ui.render_config.confirm"), () => { }) + ); + return; + } + + bool isCurrentPreset = !string.IsNullOrEmpty(_lastLoadedPresetName) && + string.Equals(_lastLoadedPresetName, name, StringComparison.OrdinalIgnoreCase); + + var existing = PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing != null && !isCurrentPreset) + { + CommonWarningDialogUI.ShowDialog( + LocalizationManager.Get("ui.render_config.preset_overwrite_title"), + LocalizationManager.Get("ui.render_config.preset_overwrite_desc", name), + new DialogButton( + LocalizationManager.Get("ui.render_config.confirm"), + () => DoSavePreset(name, config) + ), + new DialogButton( + LocalizationManager.Get("ui.render_config.cancel"), + () => { } + ) + ); + } + else + { + DoSavePreset(name, config); + } + } + + private static void DoSavePreset(string name, RenderConfig config) + { + PresetManager.SavePreset(name, config); + GlobalSettings.Current.LastPresetName = name; + GlobalSettings.Save(); + _lastLoadedPresetName = name; + ReloadPresetDropdown(selectName: name); + } + + private static void OnDeletePresetClicked(RenderConfig config) + { + var targetPresetName = _presetDropdown?.Value; + if (string.IsNullOrWhiteSpace(targetPresetName) || targetPresetName == CustomPresetLabel) + { + targetPresetName = _lastLoadedPresetName; + if (string.IsNullOrWhiteSpace(targetPresetName) || targetPresetName == CustomPresetLabel) + { + return; + } + } + + var ctx = RendererUIManager.Context; + CommonWarningDialogUI.ShowDialog( + LocalizationManager.Get("ui.render_config.preset_delete_title"), + LocalizationManager.Get("ui.render_config.preset_delete_desc", targetPresetName), + new DialogButton( + LocalizationManager.Get("ui.render_config.confirm"), + () => DoDeletePreset(targetPresetName, config), + ctx.Theme.SoftRed + ), + new DialogButton( + LocalizationManager.Get("ui.render_config.cancel"), + () => { } + ) + ); + } + + private static void DoDeletePreset(string name, RenderConfig config) + { + PresetManager.DeletePreset(name); + var presets = PresetManager.GetPresets(); + var fallbackName = presets.Count > 0 ? presets[0].Name : ""; + GlobalSettings.Current.LastPresetName = fallbackName; + GlobalSettings.Save(); + _lastLoadedPresetName = fallbackName; + + ReloadPresetDropdown(selectName: fallbackName); + + var fallbackPreset = presets.FirstOrDefault(p => string.Equals(p.Name, fallbackName, StringComparison.OrdinalIgnoreCase)); + if (fallbackPreset != null) + { + config.CopyFrom(fallbackPreset.Config); + ApplyConfigToUI(config); + _presetNameInput?.Set(fallbackPreset.Name, false); + } + } + + private static void ReloadPresetDropdown(string? selectName = null) + { + var presets = PresetManager.GetPresets(); + var names = presets.Select(p => p.Name).ToList(); + if (names.Count == 0) + { + _presetDropdown?.SetValues(new List { CustomPresetLabel }); + _presetDropdown?.Set(CustomPresetLabel, false); + _lastLoadedPresetName = null; + UpdateDeleteButtonState(); + return; + } + + _presetDropdown?.SetValues(names); + + var targetName = selectName; + if (string.IsNullOrEmpty(targetName) || !names.Contains(targetName)) + { + targetName = names[0]; + } + + _presetDropdown?.Set(targetName, false); + _lastLoadedPresetName = targetName; + UpdateDeleteButtonState(); + } + + private static void UpdateDeleteButtonState(string? presetName = null) + { + var targetName = presetName ?? _presetDropdown?.Value; + if (targetName == CustomPresetLabel) + { + targetName = _lastLoadedPresetName; + } + + bool canDelete = !string.IsNullOrEmpty(targetName) && + targetName != CustomPresetLabel && + PresetManager.GetPresets().Any(p => string.Equals(p.Name, targetName, StringComparison.OrdinalIgnoreCase)); + + _deletePresetButton?.SetBlocked(!canDelete); + } + private static void ShowValidationError(string message) { Melon.Logger.Error(message);