diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index 0c08f0b..c531035 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -366,7 +366,7 @@ internal class RenderManager : MonoBehaviour { if (Configuration.AudioConfig.RenderAudio && Configuration.VideoConfig.Codec is Codec.VP9 or Codec.AV1 or Codec.VVC) { - Configuration.AudioConfig.AudioSampleRate = 48000; + if (Configuration.AudioConfig.AudioSampleRate == 44100) Configuration.AudioConfig.AudioSampleRate = 48000; } for (; countdown > 0; countdown--) @@ -775,7 +775,7 @@ internal class RenderManager : MonoBehaviour _frameReader?.Dispose(); AudioMixer?.Reset(); AudioMixer = null; SyncCalculator.Reset(); - + RenderContext.Reset(); ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); diff --git a/Data/RenderConfig.cs b/Data/RenderConfig.cs index b6629d8..a1eb91c 100644 --- a/Data/RenderConfig.cs +++ b/Data/RenderConfig.cs @@ -44,7 +44,7 @@ public class VideoEncodingConfig public class AudioEncodingConfig { - private int _audioSampleRate = 44100; + private int _audioSampleRate = 48000; private int _audioChannels = 2; private int _audioBitrate = 256000; diff --git a/UI/RendererUIManager.cs b/UI/RendererUIManager.cs index 7644820..866bb79 100644 --- a/UI/RendererUIManager.cs +++ b/UI/RendererUIManager.cs @@ -80,7 +80,29 @@ internal class RendererUIManager : MonoBehaviour MiddleClickToDefault = true, SliderSensitivity = 1.0f }, - theme: O5Theme.Dark, + theme: O5Theme.Dark with + { + PanelBG = new Color(0.067f, 0.067f, 0.067f, 1f), + TopBar = new Color(0f, 0f, 0f, 0.6f), + MenuBG = new Color(0f, 0f, 0f, 0.6f), + MenuHover = new Color(1.000f, 0.294f, 0.079f, .6f), + ObjectBG = new Color(0f, 0f, 0f, 1f), + ObjectButton = new Color(0f, 0f, 0f, 1f), + ObjectActive = new Color(1.000f, 0.294f, 0.079f, 1f), + ObjectActiveBright = new Color(0.812f, 0.827f, 1.000f, 1f), + ControlOutlineIdle = new Color(1f, 1f, 1f, 0.4f), + ButtonHover = new Color(0.133f, 0.133f, 0.133f, 1.0f), + ControlOutlineHover = new Color(1f, 1f, 1f, 0.4f), + CardHeader = new Color(0.157f, 0f, 0f, 1f), + CardPanel = new Color(0, 0, 0, 0f), + SoftRed = new Color(1.000f, 0.576f, 0.590f, 1f), + MathOk = new Color(0.408f, 0.827f, 0.569f, 1f), + MathWarn = new Color(0.964f, 0.647f, 0.315f, 1f), + MathErr = new Color(1.000f, 0.576f, 0.590f, 1f), + CornerRadius = 12, OutlineWidth = 2, ControlHeight = 40, FontSizeBody = 18, FontSizeH1 = 48, + Outline = new Color(1f, 1f, 1f, 0.2f), + TextDimAlpha = 1f + }, fonts: new RendererFontProvider() ); diff --git a/UI/Window/RenderConfigWindow.cs b/UI/Window/RenderConfigWindow.cs index cda2d9e..e803a68 100644 --- a/UI/Window/RenderConfigWindow.cs +++ b/UI/Window/RenderConfigWindow.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using ADOFAIRenderer.API; using ADOFAIRenderer.Core; using ADOFAIRenderer.Data; @@ -21,11 +22,11 @@ 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 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; @@ -33,13 +34,13 @@ internal static class RenderConfigWindow private static O5InputField? _bitrateInput; private static O5InputField? _maxBitrateInput; private static O5InputField? _qpInput; - private static O5InputField? _keyframeIntervalInput; + private static O5Slider? _keyframeIntervalSlider; private static O5Toggle? _forceSoftwareToggle; private static O5Toggle? _renderAudioToggle; private static O5Dropdown? _sampleRateDropdown; private static O5Dropdown? _channelsDropdown; - private static O5InputField? _audioBitrateInput; + private static O5Slider? _audioBitrateSlider; private static O5InputField? _outputDirInput; @@ -69,7 +70,8 @@ internal static class RenderConfigWindow BuildUI(_configWindow.Content); } - + + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract var config = RenderManager.Instance.Configuration ??= new RenderConfig(); ApplyConfigToUI(config); @@ -83,58 +85,88 @@ internal static class RenderConfigWindow var (viewport, scrollContent, scroller) = 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 }; + // 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개) var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f); - _renderFpsInput = CreateInputField( - ctx, CreateFieldSlot(topLeft), - 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; } + + _renderFpsSlider = O5Factory.Slider( + ctx: ctx, parent: CreateFieldSlot(topLeft), + defaultValue: 60, min: 0.01, max: 3000, value: 60, + format: "F3", clampMode: ClampMode.All, + + sliderFilter: value => renderFpsSnap.OrderBy(n => Math.Abs(n - value)).First(), + inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; }, + text: LocalizationManager.Get("ui.render_config.render_fps"), + id: "slider_render_fps", showFill: true ); - _renderWidthInput = CreateInputField( - ctx, CreateFieldSlot(topLeft), - 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; } + + _videoFPSSlider = O5Factory.Slider( + ctx: ctx, parent: CreateFieldSlot(topRight), + defaultValue: 60, min: 0.01, max: 600, value: 60, + format: "F3", clampMode: ClampMode.All, + + sliderFilter: value => videoFpsSnap.OrderBy(n => Math.Abs(n - value)).First(), + inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; }, + text: LocalizationManager.Get("ui.render_config.video_fps"), + id: "slider_video_fps", showFill: true ); - _delayAfterEndInput = CreateInputField( - ctx, CreateFieldSlot(topLeft), - 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; } + + _renderWidthSlider = O5Factory.Slider( + ctx: ctx, parent: CreateFieldSlot(topLeft), + defaultValue: 2560, min: 0, max: 15360, value: 2560, + format: "F0", clampMode: ClampMode.All, + + sliderFilter: value => Math.Max(value, 2), 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 ); - _videoFpsInput = CreateInputField( - ctx, CreateFieldSlot(topRight), - 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; } + + _renderHeightSlider = O5Factory.Slider( + ctx: ctx, parent: CreateFieldSlot(topRight), + defaultValue: 1440, min: 0, max: 8640, value: 1440, + format: "F0", clampMode: ClampMode.All, + + sliderFilter: value => Math.Max(value, 2), inputFilter: value => Math.Max(value, 2), + onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); }, + text: LocalizationManager.Get("ui.render_config.render_height"), + id: "slider_render_height", showFill: true ); - _renderHeightInput = CreateInputField( - ctx, CreateFieldSlot(topRight), - 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; } + + _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), - LocalizationManager.Get("ui.render_config.show_judgement"), + ctx, CreateFieldSlot(topRight), "", false, config.ShowJudgementText, LocalizationManager.Get("ui.render_config.show_judgement"), "toggle_show_judgement", - val => config.ShowJudgementText = val + val => config.ShowJudgementText = val, + generateLabel: false ); // 4. Video Encoding Config (Collapsible Card) @@ -192,21 +224,25 @@ internal static class RenderConfigWindow TMPro.TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; } ); - _keyframeIntervalInput = CreateInputField( - ctx, CreateFieldSlot(videoRight), - 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 = CreateToggleField( + ctx, CreateFieldSlot(videoRight), "", + true, config.VideoConfig.ForceSoftwareEncoder, + LocalizationManager.Get("ui.render_config.force_software"), + "toggle_force_software", + val => config.AudioConfig.RenderAudio = val, + generateLabel: true ); - _forceSoftwareToggle = O5Factory.Toggle( - ctx, videoContent, - false, config.VideoConfig.ForceSoftwareEncoder, - val => config.VideoConfig.ForceSoftwareEncoder = val, - LocalizationManager.Get("ui.render_config.force_software"), - "toggle_force_software" + _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) @@ -222,13 +258,25 @@ internal static class RenderConfigWindow var (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent); _renderAudioToggle = CreateToggleField( - ctx, CreateFieldSlot(audioLeft), - LocalizationManager.Get("ui.render_config.render_audio"), + ctx, CreateFieldSlot(audioLeft), "", true, config.AudioConfig.RenderAudio, LocalizationManager.Get("ui.render_config.render_audio"), "toggle_render_audio", - val => config.AudioConfig.RenderAudio = val + 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 => audioBitrateSnap.OrderBy(n => Math.Abs(n - value)).First(), + 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), @@ -239,23 +287,15 @@ internal static class RenderConfigWindow c => config.AudioConfig.AudioChannels = c ); - var sampleRates = new[] { 44100, 48000 }; + var sampleRates = new[] { 44100, 48000, 96000 }; _sampleRateDropdown = CreateDropdownField( ctx, CreateFieldSlot(audioRight), LocalizationManager.Get("ui.render_config.sample_rate"), - 44100, config.AudioConfig.AudioSampleRate, - sampleRates, r => $"{r} Hz", + 48000, config.AudioConfig.AudioSampleRate, + sampleRates, r => $"{r/1000.0} kHz", "dd_sample_rate", r => config.AudioConfig.AudioSampleRate = r ); - _audioBitrateInput = CreateInputField( - ctx, CreateFieldSlot(audioRight), - 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( @@ -345,12 +385,12 @@ internal static class RenderConfigWindow e => { if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; } ); _processAudioToggle = CreateToggleField( - ctx, CreateFieldSlot(advLeft), - LocalizationManager.Get("ui.render_config.process_audio"), + ctx, CreateFieldSlot(advLeft), "", true, config.AdvancedConfig.ProcessAudio, LocalizationManager.Get("ui.render_config.process_audio"), "toggle_process_audio", - val => config.AdvancedConfig.ProcessAudio = val + val => config.AdvancedConfig.ProcessAudio = val, + generateLabel: false ); _randomSeedInput = CreateInputField( @@ -366,12 +406,12 @@ internal static class RenderConfigWindow } ); _nonTileToggle = CreateToggleField( - ctx, CreateFieldSlot(advRight), - LocalizationManager.Get("ui.render_config.non_tile"), + ctx, CreateFieldSlot(advRight), "", false, config.AdvancedConfig.NonTile, LocalizationManager.Get("ui.render_config.non_tile"), "toggle_non_tile", - val => config.AdvancedConfig.NonTile = val + val => config.AdvancedConfig.NonTile = val, + generateLabel: false ); // 8. Go Render Button @@ -386,22 +426,22 @@ internal static class RenderConfigWindow 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.RenderWidth <= 0) config.RenderWidth = Screen.width > 0 ? Screen.width : 2560; + if (config.RenderHeight <= 0) config.RenderHeight = Screen.height > 0 ? Screen.height : 1440; if (config.RenderFPS <= 0) config.RenderFPS = 60.0; if (config.VideoFPS <= 0) config.VideoFPS = 60.0; - if (config.DelayAfterEnd < 0) config.DelayAfterEnd = 1.0; + if (config.DelayAfterEnd < 0) config.DelayAfterEnd = 15.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); + _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); @@ -409,13 +449,13 @@ internal static class RenderConfigWindow _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); + _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); - _audioBitrateInput?.Set((config.AudioConfig.AudioBitrate / 1000).ToString(), false); + _audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false); _outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false); @@ -429,46 +469,6 @@ internal static class RenderConfigWindow { 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) { @@ -482,16 +482,6 @@ internal static class RenderConfigWindow { 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) @@ -573,6 +563,7 @@ internal static class RenderConfigWindow hLayout.childForceExpandWidth = true; hLayout.childForceExpandHeight = false; + // ReSharper disable once InconsistentNaming var panelLE = panelObj.AddComponent(); panelLE.flexibleWidth = 1f; @@ -596,6 +587,7 @@ internal static class RenderConfigWindow colLayout.childForceExpandWidth = true; colLayout.childForceExpandHeight = false; + // ReSharper disable once InconsistentNaming // 핵심: 좌/우 선호 너비를 0으로 강제해 항상 정확히 절반씩 나누도록 한다. // (기존 행마다 HLG 방식은 라벨/값 텍스트 길이에 따라 preferred가 달라져 // 중앙이 (W + P1 - P2) / 2 로 행마다 틀어졌음) @@ -621,6 +613,7 @@ internal static class RenderConfigWindow slotLayout.childForceExpandWidth = true; slotLayout.childForceExpandHeight = false; + // ReSharper disable once InconsistentNaming var slotLE = slotObj.AddComponent(); slotLE.flexibleWidth = 1f; @@ -712,16 +705,20 @@ internal static class RenderConfigWindow bool value, string toggleText, string id, - Action? onChanged = null) + Action? onChanged = null, + bool generateLabel = true) { - 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; + 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, @@ -756,12 +753,11 @@ internal static class RenderConfigWindow 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; + ddLE.minHeight = 0f; return O5Factory.DropDown( ctx: ctx,