using System; using System.Collections.Generic; using System.IO; using System.Linq; using ADOFAIRenderer.API; using ADOFAIRenderer.Core; using ADOFAIRenderer.Core.Data; using ADOFAIRenderer.Data; using ADOFAIRenderer.Localization; using ADOFAIRenderer.Util; using ADOFAIRenderer.Util.FFmpeg; using HarmonyLib; 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 O5Slider? _renderFpsSlider; private static O5Slider? _videoFPSSlider; private static O5Slider? _renderWidthSlider; private static O5Slider? _renderHeightSlider; private static O5Slider? _delayAfterEndSlider; private static O5Toggle? _showJudgementToggle; private static O5Dropdown? _codecDropdown; private static O5Dropdown? _rateControlDropdown; private static O5InputField? _bitrateInput; private static TMPro.TextMeshProUGUI? _bitrateLabel; private static ITweenHandle? _bitrateLabelTween; private static O5InputField? _maxBitrateInput; private static TMPro.TextMeshProUGUI? _maxBitrateLabel; private static ITweenHandle? _maxBitrateLabelTween; private static O5InputField? _qpInput; private static TMPro.TextMeshProUGUI? _qpLabel; private static ITweenHandle? _qpLabelTween; private static O5Slider? _keyframeIntervalSlider; private static O5Toggle? _forceSoftwareToggle; private static O5Toggle? _renderAudioToggle; private static O5Dropdown? _sampleRateDropdown; private static O5Dropdown? _channelsDropdown; private static O5Slider? _audioBitrateSlider; 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(900, 600), Draggable = true, Resizable = true, ShowCloseButton = true }); _configWindow.CloseRequested += win => { win.Rect.gameObject.SetActive(false); }; BuildUI(_configWindow.Content); } // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract var config = RenderManager.Instance.Configuration ??= new RenderConfig(); ApplyConfigToUI(config); SetWindowActive(true); _configWindow.BringToFront(); } private static void BuildUI(RectTransform content) { var ctx = RendererUIManager.Context; var (_, scrollContent, _) = O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f); // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract var config = RenderManager.Instance.Configuration ??= new RenderConfig(); EnsureDefaultConfig(config); double[] renderFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 144, 120, 240, 360, 480, 600, 720, // +120 288, 576, 864, 1152, 1440, 1728, // +288 3000 // MAX }; double[] videoFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 72, 120, 180, 240, 300, 360, 420, 480, 540, // +60 144, 216, 288, 432, 504, 576, // +72 600 // MAX }; double[] audioBitrateSnap = { 64, 96, 128, 192, 256, 320, 384 }; double[] renderWidthSnap = { 256, 426, 640, 854, 960, 1280, 1366, 1600, 1920, 2048, 2560, 3200, 3840, 4096, 5120, 5760, 6016, 7680, 10240, 15360, // 16:9 230, 384, 576, 768, 1152, 1440, 1680, 2880, 3072, // 16:10 192, 320, 480, 800, 1024, 1400, 2732, 6400, 11520 // 4:3 }; double[] renderHeightSnap = { 144, 240, 360, 480, 540, 720, 768, 900, 1080, 1152, 1440, 1800, 2160, 2304, 2880, 3240, 3384, 4320, 5760, 8640, // 16:9 400, 600, 800, 1050, 1200, 1600, 1920, 2400, 3200, 4800, 9600, // 16:10 864, 960, 1536, 2048, 3072 // 4:3 }; // TODO: Presets // 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개) var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f); _renderFpsSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topLeft), defaultValue: 60, min: 0.01, max: 3000, value: 60, format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(renderFpsSnap, value), inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; }, text: LocalizationManager.Get("ui.render_config.render_fps"), id: "slider_render_fps", showFill: true ); _videoFPSSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topRight), defaultValue: 60, min: 0.01, max: 600, value: 60, format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(videoFpsSnap, value), inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; }, text: LocalizationManager.Get("ui.render_config.video_fps"), id: "slider_video_fps", showFill: true ); _renderWidthSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topLeft), defaultValue: 2560, min: 0, max: 15360, value: 2560, 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); }, text: LocalizationManager.Get("ui.render_config.render_width"), id: "slider_render_width", showFill: true ); _renderHeightSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topRight), defaultValue: 1440, min: 0, max: 8640, value: 1440, 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); }, text: LocalizationManager.Get("ui.render_config.render_height"), id: "slider_render_height", showFill: true ); _delayAfterEndSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(topLeft), defaultValue: 15, min: 0, max: 9999.999, value: 15, format: "F3", clampMode: ClampMode.All, sliderFilter: null, inputFilter: null, onComplete: null, onChanged: val => { config.DelayAfterEnd = val; }, text: LocalizationManager.Get("ui.render_config.delay_after_end"), id: "slider_delay_after_end", showFill: true ); _showJudgementToggle = CreateToggleField( ctx, CreateFieldSlot(topRight), "", false, config.ShowJudgementText, LocalizationManager.Get("ui.render_config.show_judgement"), "toggle_show_judgement", val => config.ShowJudgementText = val, generateLabel: false ); // 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 (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent); var codecs = (Codec[])Enum.GetValues(typeof(Codec)); _codecDropdown = CreateDropdownField( ctx, CreateFieldSlot(videoLeft), LocalizationManager.Get("ui.render_config.codec"), Codec.H264, config.VideoConfig.Codec, codecs, c => c.ToString(), "dd_video_codec", c => { config.VideoConfig.Codec = c; UpdateVideoCodecUI(c, config); }); _bitrateInput = CreateInputField( ctx, CreateFieldSlot(videoLeft), LocalizationManager.Get("ui.render_config.bitrate"), "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; } ); _qpInput = CreateInputField( ctx, CreateFieldSlot(videoLeft), LocalizationManager.Get("ui.render_config.qp"), "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; } ); var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode)); _rateControlDropdown = CreateDropdownField( ctx, CreateFieldSlot(videoRight), LocalizationManager.Get("ui.render_config.rate_control"), RateControlMode.CBR, config.VideoConfig.RateControlMode, rateModes, m => m.ToString(), "dd_rate_control", m => { config.VideoConfig.RateControlMode = m; UpdateRateControlUI(m); } ); _maxBitrateInput = CreateInputField( ctx, CreateFieldSlot(videoRight), LocalizationManager.Get("ui.render_config.max_bitrate"), "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; } ); _forceSoftwareToggle = CreateToggleField( ctx, CreateFieldSlot(videoRight), "", false, config.VideoConfig.ForceSoftwareEncoder, LocalizationManager.Get("ui.render_config.force_software"), "toggle_force_software", val => config.VideoConfig.ForceSoftwareEncoder = val, generateLabel: true ); _keyframeIntervalSlider = O5Factory.Slider( ctx: ctx, parent: videoContent, defaultValue: 2, min: 0.1, max: 10, value: 2, format: "F1", clampMode: ClampMode.All, sliderFilter: null, inputFilter: null, onComplete: null, onChanged: val => { config.VideoConfig.KeyframeInterval = val; }, text: LocalizationManager.Get("ui.render_config.keyframe_interval"), id: "slider_keyframe_interval", showFill: true ); // 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 (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent); _renderAudioToggle = CreateToggleField( ctx, CreateFieldSlot(audioLeft), "", true, config.AudioConfig.RenderAudio, LocalizationManager.Get("ui.render_config.render_audio"), "toggle_render_audio", val => config.AudioConfig.RenderAudio = val, generateLabel: false ); _audioBitrateSlider = O5Factory.Slider( ctx: ctx, parent: CreateFieldSlot(audioRight), defaultValue: 256, min: 64, max: 384, value: 256, format: "F0", clampMode: ClampMode.All, sliderFilter: value => Snap(audioBitrateSnap, value), inputFilter: null, onComplete: null, onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; }, text: LocalizationManager.Get("ui.render_config.audio_bitrate"), id: "slider_audio_bitrate", showFill: true ); var channels = new[] { 1, 2 }; _channelsDropdown = CreateDropdownField( ctx, CreateFieldSlot(audioLeft), 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 ); _sampleRateDropdown = CreateDropdownField( ctx, CreateFieldSlot(audioRight), LocalizationManager.Get("ui.render_config.sample_rate"), 48000, config.AudioConfig.AudioSampleRate, CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz", "dd_sample_rate", r => config.AudioConfig.AudioSampleRate = r ); // 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 (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent); O5Factory.Button( ctx, globalLeft, () => { var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value) ? GlobalSettings.DefaultOutputDirectory : _outputDirInput.Value; FolderDialogUtil.OpenFolderBrowser(initial, selected => { Melon.Logger.Msg("Output Directory Selected: " + 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, globalRight, () => { _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 (advLeft, advRight) = CreateTwoColumnPanel(advContent); var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" }; _fileExtensionDropdown = CreateDropdownField( ctx, CreateFieldSlot(advLeft), 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; } ); _processAudioToggle = CreateToggleField( ctx, CreateFieldSlot(advLeft), "", true, config.AdvancedConfig.ProcessAudio, LocalizationManager.Get("ui.render_config.process_audio"), "toggle_process_audio", val => config.AdvancedConfig.ProcessAudio = val, generateLabel: false ); _randomSeedInput = CreateInputField( ctx, CreateFieldSlot(advRight), 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; } ); _nonTileToggle = CreateToggleField( ctx, CreateFieldSlot(advRight), "", false, config.AdvancedConfig.NonTile, LocalizationManager.Get("ui.render_config.non_tile"), "toggle_non_tile", val => config.AdvancedConfig.NonTile = val, generateLabel: false ); // TODO: Fire Planet, Ice Planet, Wind Planet Color Settings (Each Optional) // 8. Go Render Button O5Factory.Button( ctx, scrollContent, TryGoRender, LocalizationManager.Get("ui.render_config.go_render"), "btn_go_render", height: 48f ); // 9. Version Label var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true); var versionText = $"ADOFAI Renderer v{Melon.Instance.Info.Version}"; if (DateTime.UtcNow >= RenderMod.ExpirationTime) versionText += " " + LocalizationManager.Get("ui.render_config.expired"); versionLabel.text = versionText; versionLabel.alignment = TMPro.TextAlignmentOptions.Center; versionLabel.color = ctx.Theme.TextDim; // ReSharper disable once InconsistentNaming var versionLE = versionLabel.gameObject.AddComponent(); versionLE.preferredHeight = 18f; versionLE.minHeight = 18f; return; double Snap(double[] array, double value) => array.OrderBy(n => Math.Abs(n - value)).First(); } private static void EnsureDefaultConfig(RenderConfig config) { if (config.RenderWidth <= 0) config.RenderWidth = 2560; if (config.RenderHeight <= 0) config.RenderHeight = 1440; if (config.RenderFPS <= 0) config.RenderFPS = 60.0; if (config.VideoFPS <= 0) config.VideoFPS = 60.0; if (config.DelayAfterEnd < 0) config.DelayAfterEnd = 15.0; } private static void ApplyConfigToUI(RenderConfig config) { EnsureDefaultConfig(config); _renderFpsSlider?.Set((float) config.RenderFPS, false); _videoFPSSlider?.Set((float) config.VideoFPS, false); _renderWidthSlider?.Set(config.RenderWidth, false); _renderHeightSlider?.Set(config.RenderHeight, false); _delayAfterEndSlider?.Set((float) config.DelayAfterEnd, 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); _keyframeIntervalSlider?.Set(config.VideoConfig.KeyframeInterval, 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); _audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, 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); UpdateVideoCodecUI(config.VideoConfig.Codec, config); UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true); } 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 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", () => { }) ); } // 카드(또는 상단 섹션)당 하나의 좌/우 컨테이너를 만들어 // 가로 한 줄 두 필드 사이 중앙이 행마다 어긋나지 않도록 한다. // - 패널 1개 = 중앙선 1개 (행마다 HLG를 만들던 기존 방식의 P1-P2 어긋남 방지) // - 좌/우 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 50:50 // - 각 필드는 Field 슬롯(label 18 + 간격 3 + 컨트롤 50)으로 높이를 고정해 // 좌/우 스택을 써도 행 단위 Y 정렬이 유지되도록 한다. private static (RectTransform left, RectTransform right) CreateTwoColumnPanel( Transform parent, float spacing = 8f, float columnSpacing = 8f) { var panelObj = new GameObject("TwoColPanel"); panelObj.transform.SetParent(parent, false); panelObj.AddComponent(); var hLayout = panelObj.AddComponent(); hLayout.spacing = spacing; hLayout.padding = new RectOffset(0, 0, 0, 0); hLayout.childAlignment = TextAnchor.UpperCenter; hLayout.childControlWidth = true; hLayout.childControlHeight = true; hLayout.childForceExpandWidth = true; hLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming var panelLE = panelObj.AddComponent(); panelLE.flexibleWidth = 1f; var left = CreateColumn(panelObj.transform, "LeftCol", columnSpacing); var right = CreateColumn(panelObj.transform, "RightCol", columnSpacing); return (left, right); } private static RectTransform CreateColumn(Transform parent, string name, float spacing) { var colObj = new GameObject(name); colObj.transform.SetParent(parent, false); var colRect = colObj.AddComponent(); var colLayout = colObj.AddComponent(); colLayout.spacing = spacing; colLayout.padding = new RectOffset(0, 0, 0, 0); colLayout.childControlWidth = true; colLayout.childControlHeight = true; colLayout.childForceExpandWidth = true; colLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming // 핵심: 좌/우 선호 너비를 0으로 강제해 항상 정확히 절반씩 나누도록 한다. // (기존 행마다 HLG 방식은 라벨/값 텍스트 길이에 따라 preferred가 달라져 // 중앙이 (W + P1 - P2) / 2 로 행마다 틀어졌음) var colLE = colObj.AddComponent(); colLE.minWidth = 0f; colLE.preferredWidth = 0f; colLE.flexibleWidth = 1f; return colRect; } private static RectTransform CreateFieldSlot(Transform column) { var slotObj = new GameObject("Field"); slotObj.transform.SetParent(column, false); var slotRect = slotObj.AddComponent(); var slotLayout = slotObj.AddComponent(); slotLayout.spacing = 3f; slotLayout.padding = new RectOffset(0, 0, 0, 0); slotLayout.childControlWidth = true; slotLayout.childControlHeight = true; slotLayout.childForceExpandWidth = true; slotLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming var slotLE = slotObj.AddComponent(); slotLE.flexibleWidth = 1f; return slotRect; } 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) { return CreateInputField(ctx, colParent, labelText, defaultValue, value, id, out _, characterValidation, onChanged); } private static O5InputField CreateInputField( O5Context ctx, Transform colParent, string labelText, string? defaultValue, string value, string id, out TMPro.TextMeshProUGUI label, TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, Action? onChanged = null) { 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 void SetFieldBlocked( O5InputField? input, TMPro.TextMeshProUGUI? label, ref ITweenHandle? tweenHandle, bool blocked, bool noAnimate = false) { input?.SetBlocked(blocked, noAnimate); if (label == null) return; tweenHandle?.Kill(); var ctx = RendererUIManager.Context; var baseColor = ctx.Theme.TextDim; var targetColor = blocked ? new Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a * 0.4f) : baseColor; if (noAnimate) { label.color = targetColor; } else { tweenHandle = ctx.Tween.TweenColor(label, targetColor, 0.2f); } } private static void UpdateRateControlUI(RateControlMode mode, bool noAnimate = false) { bool blockBitrate = mode is RateControlMode.CQP; bool blockMaxBitrate = mode is RateControlMode.CBR or RateControlMode.CQP; bool blockQp = mode is RateControlMode.CBR or RateControlMode.VBR; SetFieldBlocked(_bitrateInput, _bitrateLabel, ref _bitrateLabelTween, blockBitrate, noAnimate); SetFieldBlocked(_maxBitrateInput, _maxBitrateLabel, ref _maxBitrateLabelTween, blockMaxBitrate, noAnimate); SetFieldBlocked(_qpInput, _qpLabel, ref _qpLabelTween, blockQp, noAnimate); } private static void UpdateVideoCodecUI(Codec codec, RenderConfig? config = null) { if (_sampleRateDropdown == null) return; _sampleRateDropdown.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]); if (config != null && _sampleRateDropdown.Value is var currentRate) { config.AudioConfig.AudioSampleRate = currentRate; } } 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, bool generateLabel = true) { if (generateLabel) { 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); // ReSharper disable once InconsistentNaming var ddLE = ddContainer.AddComponent(); ddLE.preferredHeight = 50f; ddLE.minHeight = 0f; 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); } }