using System; using System.Collections.Generic; using System.IO; using ADOFAIRenderer.API; using ADOFAIRenderer.Core; using ADOFAIRenderer.Data; using ADOFAIRenderer.Localization; using ADOFAIRenderer.Util; using ADOFAIRenderer.Util.FFmpeg; using MelonLoader; using O5Kit.Control; using O5Kit.Core; using O5Kit.Factory; using UnityEngine; using UnityEngine.UI; namespace ADOFAIRenderer.UI.Window; internal static class RenderConfigWindow { private static O5Window? _configWindow; // Controls cache for validation & sync private static O5InputField? _renderFpsInput; private static O5InputField? _videoFpsInput; private static O5InputField? _renderWidthInput; private static O5InputField? _renderHeightInput; private static O5InputField? _delayAfterEndInput; private static O5Toggle? _showJudgementToggle; private static O5Dropdown? _codecDropdown; private static O5Dropdown? _rateControlDropdown; private static O5InputField? _bitrateInput; private static O5InputField? _maxBitrateInput; private static O5InputField? _qpInput; private static O5InputField? _keyframeIntervalInput; private static O5Toggle? _forceSoftwareToggle; private static O5Toggle? _renderAudioToggle; private static O5Dropdown? _sampleRateDropdown; private static O5Dropdown? _channelsDropdown; private static O5InputField? _audioBitrateInput; private static O5InputField? _outputDirInput; private static O5Dropdown? _fileExtensionDropdown; private static O5InputField? _randomSeedInput; private static O5Toggle? _processAudioToggle; private static O5Toggle? _nonTileToggle; public static void CreateWindow() { if (_configWindow == null) { _configWindow = RendererUIManager.Instance.WindowManager.Create(new O5WindowOptions { Id = "RendererMod_RenderConfig", Title = LocalizationManager.Get("ui.render_config.window_title"), Size = new Vector2(580f, 480f), Draggable = true, Resizable = true, ShowCloseButton = true }); _configWindow.CloseRequested += win => { win.Rect.gameObject.SetActive(false); }; BuildUI(_configWindow.Content); } var config = RenderManager.Instance.Configuration ??= new RenderConfig(); ApplyConfigToUI(config); SetWindowActive(true); _configWindow.BringToFront(); } private static void BuildUI(RectTransform content) { var ctx = RendererUIManager.Context; var (viewport, scrollContent, scroller) = O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f); var config = RenderManager.Instance.Configuration ??= new RenderConfig(); EnsureDefaultConfig(config); // 1. RenderFPS & VideoFPS var (row1Left, row1Right) = CreateTwoColumnRow(scrollContent); _renderFpsInput = CreateInputField( ctx, row1Left, LocalizationManager.Get("ui.render_config.render_fps"), "60", config.RenderFPS.ToString("0.##"), "input_render_fps", TMPro.TMP_InputField.CharacterValidation.Decimal, val => { if (double.TryParse(val, out var d) && d > 0) config.RenderFPS = d; } ); _videoFpsInput = CreateInputField( ctx, row1Right, LocalizationManager.Get("ui.render_config.video_fps"), "60", config.VideoFPS.ToString("0.##"), "input_video_fps", TMPro.TMP_InputField.CharacterValidation.Decimal, val => { if (double.TryParse(val, out var d) && d > 0) config.VideoFPS = d; } ); // 2. RenderWidth & RenderHeight var (row2Left, row2Right) = CreateTwoColumnRow(scrollContent); _renderWidthInput = CreateInputField( ctx, row2Left, LocalizationManager.Get("ui.render_config.render_width"), "1920", config.RenderWidth.ToString(), "input_render_width", TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var i) && i > 0) config.RenderWidth = i; } ); _renderHeightInput = CreateInputField( ctx, row2Right, LocalizationManager.Get("ui.render_config.render_height"), "1080", config.RenderHeight.ToString(), "input_render_height", TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var i) && i > 0) config.RenderHeight = i; } ); // 3. Delay After Level End & Show Judgement var (row3Left, row3Right) = CreateTwoColumnRow(scrollContent); _delayAfterEndInput = CreateInputField( ctx, row3Left, LocalizationManager.Get("ui.render_config.delay_after_end"), "1.0", config.DelayAfterEnd.ToString("0.##"), "input_delay_after_end", TMPro.TMP_InputField.CharacterValidation.Decimal, val => { if (double.TryParse(val, out var d) && d >= 0) config.DelayAfterEnd = d; } ); _showJudgementToggle = CreateToggleField( ctx, row3Right, LocalizationManager.Get("ui.render_config.show_judgement"), false, config.ShowJudgementText, LocalizationManager.Get("ui.render_config.show_judgement"), "toggle_show_judgement", val => config.ShowJudgementText = val ); // 4. Video Encoding Config (Collapsible Card) var (_, videoContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.video_encoding"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); var (vRow1Left, vRow1Right) = CreateTwoColumnRow(videoContent); var codecs = (Codec[])Enum.GetValues(typeof(Codec)); _codecDropdown = CreateDropdownField( ctx, vRow1Left, LocalizationManager.Get("ui.render_config.codec"), Codec.H264, config.VideoConfig.Codec, codecs, c => c.ToString(), "dd_video_codec", c => config.VideoConfig.Codec = c ); var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode)); _rateControlDropdown = CreateDropdownField( ctx, vRow1Right, LocalizationManager.Get("ui.render_config.rate_control"), RateControlMode.CBR, config.VideoConfig.RateControlMode, rateModes, m => m.ToString(), "dd_rate_control", m => config.VideoConfig.RateControlMode = m ); var (vRow2Left, vRow2Right) = CreateTwoColumnRow(videoContent); _bitrateInput = CreateInputField( ctx, vRow2Left, LocalizationManager.Get("ui.render_config.bitrate"), "30000", config.VideoConfig.Bitrate.ToString(), "input_bitrate", TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; } ); _maxBitrateInput = CreateInputField( ctx, vRow2Right, LocalizationManager.Get("ui.render_config.max_bitrate"), "50000", config.VideoConfig.MaxBitrate.ToString(), "input_max_bitrate", TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; } ); var (vRow3Left, vRow3Right) = CreateTwoColumnRow(videoContent); _qpInput = CreateInputField( ctx, vRow3Left, LocalizationManager.Get("ui.render_config.qp"), "18", config.VideoConfig.QP.ToString(), "input_qp", TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; } ); _keyframeIntervalInput = CreateInputField( ctx, vRow3Right, LocalizationManager.Get("ui.render_config.keyframe_interval"), "2.0", config.VideoConfig.KeyframeInterval.ToString("0.##"), "input_keyframe_interval", TMPro.TMP_InputField.CharacterValidation.Decimal, val => { if (double.TryParse(val, out var kf) && kf > 0) config.VideoConfig.KeyframeInterval = kf; } ); _forceSoftwareToggle = O5Factory.Toggle( ctx, videoContent, false, config.VideoConfig.ForceSoftwareEncoder, val => config.VideoConfig.ForceSoftwareEncoder = val, LocalizationManager.Get("ui.render_config.force_software"), "toggle_force_software" ); // 5. Audio Encoding Config (Collapsible Card) var (_, audioContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.audio_encoding"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); var (aRow1Left, aRow1Right) = CreateTwoColumnRow(audioContent); _renderAudioToggle = CreateToggleField( ctx, aRow1Left, LocalizationManager.Get("ui.render_config.render_audio"), true, config.AudioConfig.RenderAudio, LocalizationManager.Get("ui.render_config.render_audio"), "toggle_render_audio", val => config.AudioConfig.RenderAudio = val ); var sampleRates = new[] { 44100, 48000 }; _sampleRateDropdown = CreateDropdownField( ctx, aRow1Right, LocalizationManager.Get("ui.render_config.sample_rate"), 44100, config.AudioConfig.AudioSampleRate, sampleRates, r => $"{r} Hz", "dd_sample_rate", r => config.AudioConfig.AudioSampleRate = r ); var (aRow2Left, aRow2Right) = CreateTwoColumnRow(audioContent); var channels = new[] { 1, 2 }; _channelsDropdown = CreateDropdownField( ctx, aRow2Left, LocalizationManager.Get("ui.render_config.channels"), 2, config.AudioConfig.AudioChannels, channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)", "dd_channels", c => config.AudioConfig.AudioChannels = c ); _audioBitrateInput = CreateInputField( ctx, aRow2Right, LocalizationManager.Get("ui.render_config.audio_bitrate"), "256", (config.AudioConfig.AudioBitrate / 1000).ToString(), "input_audio_bitrate", TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var ab) && ab > 0) config.AudioConfig.AudioBitrate = ab * 1000; } ); // 6. Global Settings (Collapsible Card) var (_, globalContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.global_settings"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); _outputDirInput = CreateFullWidthInputField( ctx, globalContent, LocalizationManager.Get("ui.render_config.output_dir"), GlobalSettings.DefaultOutputDirectory, GlobalSettings.EffectiveOutputDirectory, "input_output_dir", val => { var trimmed = val?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(trimmed) || string.Equals(trimmed, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase)) { GlobalSettings.Current.OutputDirectory = null; } else { GlobalSettings.Current.OutputDirectory = trimmed; } } ); var (gBtnLeft, gBtnRight) = CreateTwoColumnRow(globalContent); O5Factory.Button( ctx, gBtnLeft, () => { var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value) ? GlobalSettings.DefaultOutputDirectory : _outputDirInput.Value; FolderDialogUtil.OpenFolderBrowser(initial, selected => { if (!string.IsNullOrWhiteSpace(selected)) { _outputDirInput.Set(selected); GlobalSettings.Current.OutputDirectory = selected; GlobalSettings.Save(); } }); }, LocalizationManager.Get("ui.render_config.browse"), "btn_browse_output" ); O5Factory.Button( ctx, gBtnRight, () => { _outputDirInput.Set(GlobalSettings.DefaultOutputDirectory); GlobalSettings.Current.OutputDirectory = null; GlobalSettings.Save(); }, LocalizationManager.Get("ui.render_config.reset_default"), "btn_reset_output" ); // 7. Advanced Settings (Collapsible Card) var (_, advContent) = O5Factory.Card( ctx, scrollContent, LocalizationManager.Get("ui.render_config.advanced_settings"), activeValue: true, onActiveChanged: null, onDeleteClick: null, showDeleteButton: false, showActiveToggle: false ); var (adRow1Left, adRow1Right) = CreateTwoColumnRow(advContent); var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" }; _fileExtensionDropdown = CreateDropdownField( ctx, adRow1Left, LocalizationManager.Get("ui.render_config.file_extension"), "mkv", config.AdvancedConfig.FileExtension, extensions, e => e, "dd_adv_file_extension", e => { if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; } ); _randomSeedInput = CreateInputField( ctx, adRow1Right, LocalizationManager.Get("ui.render_config.random_seed"), "", config.AdvancedConfig.RandomSeed?.ToString() ?? "", "input_random_seed", TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var s)) config.AdvancedConfig.RandomSeed = s; else if (string.IsNullOrWhiteSpace(val)) config.AdvancedConfig.RandomSeed = null; } ); var (adRow2Left, adRow2Right) = CreateTwoColumnRow(advContent); _processAudioToggle = CreateToggleField( ctx, adRow2Left, LocalizationManager.Get("ui.render_config.process_audio"), true, config.AdvancedConfig.ProcessAudio, LocalizationManager.Get("ui.render_config.process_audio"), "toggle_process_audio", val => config.AdvancedConfig.ProcessAudio = val ); _nonTileToggle = CreateToggleField( ctx, adRow2Right, LocalizationManager.Get("ui.render_config.non_tile"), false, config.AdvancedConfig.NonTile, LocalizationManager.Get("ui.render_config.non_tile"), "toggle_non_tile", val => config.AdvancedConfig.NonTile = val ); // 8. Go Render Button O5Factory.Button( ctx, scrollContent, TryGoRender, LocalizationManager.Get("ui.render_config.go_render"), "btn_go_render", height: 48f ); } private static void EnsureDefaultConfig(RenderConfig config) { if (config.RenderWidth <= 0) config.RenderWidth = Screen.width > 0 ? Screen.width : 1920; if (config.RenderHeight <= 0) config.RenderHeight = Screen.height > 0 ? Screen.height : 1080; if (config.RenderFPS <= 0) config.RenderFPS = 60.0; if (config.VideoFPS <= 0) config.VideoFPS = 60.0; if (config.DelayAfterEnd < 0) config.DelayAfterEnd = 1.0; } private static void ApplyConfigToUI(RenderConfig config) { EnsureDefaultConfig(config); _renderFpsInput?.Set(config.RenderFPS.ToString("0.##"), false); _videoFpsInput?.Set(config.VideoFPS.ToString("0.##"), false); _renderWidthInput?.Set(config.RenderWidth.ToString(), false); _renderHeightInput?.Set(config.RenderHeight.ToString(), false); _delayAfterEndInput?.Set(config.DelayAfterEnd.ToString("0.##"), false); _showJudgementToggle?.Set(config.ShowJudgementText, false); _codecDropdown?.Set(config.VideoConfig.Codec, false); _rateControlDropdown?.Set(config.VideoConfig.RateControlMode, false); _bitrateInput?.Set(config.VideoConfig.Bitrate.ToString(), false); _maxBitrateInput?.Set(config.VideoConfig.MaxBitrate.ToString(), false); _qpInput?.Set(config.VideoConfig.QP.ToString(), false); _keyframeIntervalInput?.Set(config.VideoConfig.KeyframeInterval.ToString("0.##"), false); _forceSoftwareToggle?.Set(config.VideoConfig.ForceSoftwareEncoder, false); _renderAudioToggle?.Set(config.AudioConfig.RenderAudio, false); _sampleRateDropdown?.Set(config.AudioConfig.AudioSampleRate, false); _channelsDropdown?.Set(config.AudioConfig.AudioChannels, false); _audioBitrateInput?.Set((config.AudioConfig.AudioBitrate / 1000).ToString(), false); _outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false); _fileExtensionDropdown?.Set(config.AdvancedConfig.FileExtension, false); _randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false); _processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false); _nonTileToggle?.Set(config.AdvancedConfig.NonTile, false); } private static void TryGoRender() { var config = RenderManager.Instance.Configuration; // Validate RenderFPS if (!double.TryParse(_renderFpsInput?.Value, out var renderFps) || renderFps <= 0) { ShowValidationError("Render FPS must be a positive number."); return; } config.RenderFPS = renderFps; // Validate VideoFPS if (!double.TryParse(_videoFpsInput?.Value, out var videoFps) || videoFps <= 0) { ShowValidationError("Video FPS must be a positive number."); return; } config.VideoFPS = videoFps; // Validate RenderWidth if (!int.TryParse(_renderWidthInput?.Value, out var renderWidth) || renderWidth <= 0) { ShowValidationError("Render Width must be an integer greater than 0."); return; } config.RenderWidth = renderWidth; // Validate RenderHeight if (!int.TryParse(_renderHeightInput?.Value, out var renderHeight) || renderHeight <= 0) { ShowValidationError("Render Height must be an integer greater than 0."); return; } config.RenderHeight = renderHeight; // Validate DelayAfterEnd if (!double.TryParse(_delayAfterEndInput?.Value, out var delay) || delay < 0) { ShowValidationError("Delay After End must be a non-negative number."); return; } config.DelayAfterEnd = delay; // 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; } if (_keyframeIntervalInput != null && double.TryParse(_keyframeIntervalInput.Value, out var kf) && kf > 0) { config.VideoConfig.KeyframeInterval = kf; } // Sync Audio Config if (_audioBitrateInput != null && int.TryParse(_audioBitrateInput.Value, out var ab) && ab > 0) { config.AudioConfig.AudioBitrate = ab * 1000; } // 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 Output Directory if (_outputDirInput != null) { var path = _outputDirInput.Value.Trim(); if (string.IsNullOrWhiteSpace(path) || string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase)) { GlobalSettings.Current.OutputDirectory = null; } else { try { var fullPath = Path.GetFullPath(path); if (!Directory.Exists(fullPath)) { Directory.CreateDirectory(fullPath); } GlobalSettings.Current.OutputDirectory = fullPath; } catch (Exception ex) { ShowValidationError($"Output directory path is invalid:\n{ex.Message}"); return; } } } GlobalSettings.Save(); SetWindowActive(false); new RenderSession().Start(config, RenderManager.Instance.levelPath); } private static void ShowValidationError(string message) { Melon.Logger.Error(message); CommonWarningDialogUI.ShowDialog( LocalizationManager.Get("ui.render_config.validation_title"), message, new DialogButton("OK", () => { }) ); } private static (RectTransform left, RectTransform right) CreateTwoColumnRow(Transform parent, float spacing = 8f) { var rowObj = new GameObject("TwoColRow"); rowObj.transform.SetParent(parent, false); var rowRect = rowObj.AddComponent(); var hLayout = rowObj.AddComponent(); hLayout.spacing = spacing; hLayout.childControlWidth = true; hLayout.childControlHeight = true; hLayout.childForceExpandWidth = true; hLayout.childForceExpandHeight = false; var col1Obj = new GameObject("Col1"); col1Obj.transform.SetParent(rowObj.transform, false); var col1Rect = col1Obj.AddComponent(); var col1Layout = col1Obj.AddComponent(); col1Layout.spacing = 3f; col1Layout.childControlWidth = true; col1Layout.childControlHeight = true; col1Layout.childForceExpandWidth = true; col1Layout.childForceExpandHeight = false; var col2Obj = new GameObject("Col2"); col2Obj.transform.SetParent(rowObj.transform, false); var col2Rect = col2Obj.AddComponent(); var col2Layout = col2Obj.AddComponent(); col2Layout.spacing = 3f; col2Layout.childControlWidth = true; col2Layout.childControlHeight = true; col2Layout.childForceExpandWidth = true; col2Layout.childForceExpandHeight = false; return (col1Rect, col2Rect); } private static O5InputField CreateInputField( O5Context ctx, Transform colParent, string labelText, string? defaultValue, string value, string id, TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, Action? onChanged = null) { var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = labelText; label.color = ctx.Theme.TextDim; // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; var inputField = O5Factory.Input( ctx: ctx, parent: colParent, defaultValue: defaultValue, value: value, onChanged: onChanged, placeholder: labelText, icon: null, id: id ); inputField.InputField.characterValidation = characterValidation; return inputField; } private static O5InputField CreateFullWidthInputField( O5Context ctx, Transform parent, string labelText, string? defaultValue, string value, string id, Action? onChanged = null) { var container = new GameObject($"FullWidth_{id}"); container.transform.SetParent(parent, false); var containerRect = container.AddComponent(); var layout = container.AddComponent(); layout.spacing = 3f; layout.childControlWidth = true; layout.childControlHeight = true; layout.childForceExpandWidth = true; layout.childForceExpandHeight = false; var label = O5Factory.ControlText(ctx, containerRect, 13f, noPad: true); label.text = labelText; label.color = ctx.Theme.TextDim; // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; var input = O5Factory.Input( ctx: ctx, parent: containerRect, defaultValue: defaultValue, value: value, onChanged: onChanged, placeholder: labelText, icon: null, id: id ); return input; } private static O5Toggle CreateToggleField( O5Context ctx, Transform colParent, string labelText, bool? defaultValue, bool value, string toggleText, string id, Action? onChanged = null) { var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = labelText; label.color = ctx.Theme.TextDim; // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; return O5Factory.Toggle( ctx: ctx, parent: colParent, defaultValue: defaultValue, value: value, onChanged: onChanged, text: toggleText, id: id ); } private static O5Dropdown CreateDropdownField( O5Context ctx, Transform colParent, string labelText, T defaultValue, T value, IReadOnlyList values, Func display, string id, Action? onChanged = null) { var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = labelText; label.color = ctx.Theme.TextDim; // ReSharper disable once InconsistentNaming var labelLE = label.gameObject.AddComponent(); labelLE.preferredHeight = 18f; labelLE.minHeight = 18f; var ddContainer = new GameObject($"DD_{id}"); ddContainer.transform.SetParent(colParent, false); var ddRect = ddContainer.AddComponent(); // ReSharper disable once InconsistentNaming var ddLE = ddContainer.AddComponent(); ddLE.preferredHeight = 50f; ddLE.minHeight = 50f; return O5Factory.DropDown( ctx: ctx, parent: ddContainer.transform, defaultValue: defaultValue, value: value, values: values, display: display, onChanged: val => { if (val != null) onChanged?.Invoke(val); }, id: id ); } private static void SetWindowActive(bool enabled) { _configWindow?.Rect.gameObject.SetActive(enabled); } }