From 43763b0b3517ad36a5f04233d141e3cb6b31a3e6bd5f4c77e2f75957757e5b1c Mon Sep 17 00:00:00 2001 From: copecone Date: Tue, 1 Sep 2026 08:09:55 +0900 Subject: [PATCH] Add Output Path Config Prototype --- Core/RenderManager.cs | 4 +- Data/GlobalSettings.cs | 79 ++++++++++++ RenderMod.cs | 2 + UI/RenderConfigUIManager.cs | 244 ++++++++++++++++++++++++++++++++++-- Util/FolderDialogUtil.cs | 161 ++++++++++++++++++++++++ 5 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 Data/GlobalSettings.cs create mode 100644 Util/FolderDialogUtil.cs diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index c3d0c16..a420e50 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -411,7 +411,7 @@ internal class RenderManager : MonoBehaviour public void SetupCaptureDirectory() { - var renderDir = Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); + var renderDir = GlobalSettings.EffectiveOutputDirectory; FileUtil.CreateDirectory(renderDir); } @@ -525,7 +525,7 @@ internal class RenderManager : MonoBehaviour else { var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") + $".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}"; - CurrentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName); + CurrentOutPath = Path.Combine(GlobalSettings.EffectiveOutputDirectory, fileName); } FFmpegNativeEncoder? videoFFmpeg = null; diff --git a/Data/GlobalSettings.cs b/Data/GlobalSettings.cs new file mode 100644 index 0000000..959aae6 --- /dev/null +++ b/Data/GlobalSettings.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using MelonLoader; +using MelonLoader.Utils; +using Newtonsoft.Json; + +namespace ADOFAIRenderer.Data; + +public class GlobalConfig +{ + public string? OutputDirectory { get; set; } +} + +public static class GlobalSettings +{ + private static string SettingsFilePath => + Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "global_settings.json"); + + public static GlobalConfig Current { get; set; } = new(); + + public static string DefaultOutputDirectory => + Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); + + public static string EffectiveOutputDirectory + { + get + { + if (!string.IsNullOrWhiteSpace(Current.OutputDirectory) && Directory.Exists(Current.OutputDirectory)) + { + return Current.OutputDirectory; + } + + return DefaultOutputDirectory; + } + } + + static GlobalSettings() + { + Load(); + } + + public static void Load() + { + try + { + if (File.Exists(SettingsFilePath)) + { + var json = File.ReadAllText(SettingsFilePath); + Current = JsonConvert.DeserializeObject(json) ?? new GlobalConfig(); + return; + } + } + catch (Exception ex) + { + MelonLogger.Warning($"[GlobalSettings] Failed to load global settings: {ex.Message}"); + } + + Current = new GlobalConfig(); + } + + public static void Save() + { + try + { + var dir = Path.GetDirectoryName(SettingsFilePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + var json = JsonConvert.SerializeObject(Current, Formatting.Indented); + File.WriteAllText(SettingsFilePath, json); + } + catch (Exception ex) + { + MelonLogger.Warning($"[GlobalSettings] Failed to save global settings: {ex.Message}"); + } + } +} diff --git a/RenderMod.cs b/RenderMod.cs index cfc3dea..93b9d35 100644 --- a/RenderMod.cs +++ b/RenderMod.cs @@ -1,6 +1,7 @@ using System; using ADOFAIRenderer; using ADOFAIRenderer.Core; +using ADOFAIRenderer.Data; using ADOFAIRenderer.Util; using FFmpeg.AutoGen; using MelonLoader; @@ -27,6 +28,7 @@ namespace ADOFAIRenderer public override void OnInitializeMelon() { + GlobalSettings.Load(); ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory; var renderManagerObj = new GameObject("Render Manager"); diff --git a/UI/RenderConfigUIManager.cs b/UI/RenderConfigUIManager.cs index c615087..0ee1842 100644 --- a/UI/RenderConfigUIManager.cs +++ b/UI/RenderConfigUIManager.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.IO; using ADOFAIRenderer.Core; using ADOFAIRenderer.Data; using ADOFAIRenderer.Data.Input; +using ADOFAIRenderer.Util; using ADOFAIRenderer.Util.FFmpeg; using MelonLoader; using UnityEngine; @@ -26,7 +28,9 @@ internal class RenderConfigUIManager : MonoBehaviour private Button _audioEncodingConfigButton = null!; private VisualElement _audioEncodingConfigContainer = null!; - + + private Button _globalSettingsButton = null!; + private VisualElement? _globalConfigContainer; private Button _advancedSettingsButton = null!; private VisualElement? _advancedConfigContainer; private Label _versionLabel = null!; @@ -92,19 +96,38 @@ internal class RenderConfigUIManager : MonoBehaviour Melon.Logger.Error("Button 'PresetSettingsButton' not found."); } - var bottomRow = CreateRow(); - bottomRow.pickingMode = PickingMode.Ignore; - bottomRow.style.position = Position.Absolute; - bottomRow.style.right = 0f; - bottomRow.style.bottom = 0f; - bottomRow.style.justifyContent = Justify.FlexEnd; + var rightBottomContainer = new VisualElement + { + name = "RightBottomButtonContainer", + pickingMode = PickingMode.Ignore, + style = + { + position = Position.Absolute, + right = 0f, + bottom = 0f, + flexDirection = FlexDirection.Column, + alignItems = Align.FlexEnd + } + }; + var globalRow = CreateRow(); + globalRow.pickingMode = PickingMode.Ignore; + globalRow.style.justifyContent = Justify.FlexEnd; + _globalSettingsButton = new Button { name = "GlobalSettingsButton", text = "Global Settings..." }; + globalRow.Add(_globalSettingsButton); + rightBottomContainer.Add(globalRow); + + var advancedRow = CreateRow(); + advancedRow.pickingMode = PickingMode.Ignore; + advancedRow.style.justifyContent = Justify.FlexEnd; _advancedSettingsButton = new Button { name = "AdvancedSettingsButton", text = "Advanced Settings..." }; - bottomRow.Add(_advancedSettingsButton); + advancedRow.Add(_advancedSettingsButton); + rightBottomContainer.Add(advancedRow); - (_root.childCount > 0 ? _root[0] : _root).Add(bottomRow); - Melon.Logger.Msg("[UI] AdvancedSettingsButton dynamically created."); + (_root.childCount > 0 ? _root[0] : _root).Add(rightBottomContainer); + Melon.Logger.Msg("[UI] GlobalSettingsButton and AdvancedSettingsButton dynamically created."); + _globalSettingsButton.RegisterCallback(_ => OpenGlobalConfig()); _advancedSettingsButton.RegisterCallback(_ => OpenAdvancedConfig()); var versionText = @@ -143,6 +166,207 @@ internal class RenderConfigUIManager : MonoBehaviour Melon.Logger.Msg("[UI] VersionLabel dynamically created."); } + private void OpenGlobalConfig() + { + var tempGlobal = new GlobalConfig + { + OutputDirectory = GlobalSettings.Current.OutputDirectory + }; + + if (_globalConfigContainer != null && _root.Contains(_globalConfigContainer)) + { + _root.Remove(_globalConfigContainer); + } + + _globalConfigContainer = new VisualElement + { + name = "GlobalSettingsContainer", + style = + { + position = Position.Absolute, + width = Length.Percent(100), + height = Length.Percent(100), + left = 0, + top = 0, + backgroundColor = new StyleColor(new Color(0.15f, 0.15f, 0.15f, 1.0f)), + justifyContent = Justify.Center, + alignItems = Align.Center + } + }; + + // 1. Title Row + var titleRow = CreateRow(); + var titleLabel = new Label("Global Settings") + { + style = + { + fontSize = 24, + color = new StyleColor(Color.white) + } + }; + titleRow.Add(titleLabel); + _globalConfigContainer.Add(titleRow); + + // 2. Output Path Row + var pathRow = CreateRow(); + var pathLabel = CreateFormLabel("Output Directory"); + var defaultDir = GlobalSettings.DefaultOutputDirectory; + var currentDisplayDir = !string.IsNullOrWhiteSpace(tempGlobal.OutputDirectory) + ? tempGlobal.OutputDirectory + : defaultDir; + + var pathField = new TextField + { + name = "GlobalOutputDirectory", + label = "", + value = currentDisplayDir, + style = + { + width = 520f, + fontSize = 12f + } + }; + + var textInput = pathField.Q("unity-text-input"); + if (textInput != null) + { + textInput.style.backgroundColor = new StyleColor(new Color(0.1f, 0.1f, 0.1f, 1.0f)); + textInput.style.borderTopLeftRadius = 6f; + textInput.style.borderTopRightRadius = 6f; + textInput.style.borderBottomLeftRadius = 6f; + textInput.style.borderBottomRightRadius = 6f; + textInput.style.borderTopWidth = 1f; + textInput.style.borderBottomWidth = 1f; + textInput.style.borderLeftWidth = 1f; + textInput.style.borderRightWidth = 1f; + textInput.style.borderTopColor = new StyleColor(new Color(0.3f, 0.3f, 0.35f, 0.8f)); + textInput.style.borderBottomColor = new StyleColor(new Color(0.3f, 0.3f, 0.35f, 0.8f)); + textInput.style.borderLeftColor = new StyleColor(new Color(0.3f, 0.3f, 0.35f, 0.8f)); + textInput.style.borderRightColor = new StyleColor(new Color(0.3f, 0.3f, 0.35f, 0.8f)); + textInput.style.paddingLeft = 8f; + textInput.style.paddingRight = 8f; + textInput.style.fontSize = 12f; + textInput.style.unityTextAlign = TextAnchor.MiddleLeft; + textInput.style.color = new StyleColor(Color.white); + } + + pathRow.Add(pathLabel); + pathRow.Add(pathField); + _globalConfigContainer.Add(pathRow); + + // 3. Action Buttons Row (Browse & Reset) + var actionRow = CreateRow(); + actionRow.style.height = StyleKeyword.Auto; + actionRow.style.maxHeight = StyleKeyword.Null; + actionRow.style.minHeight = StyleKeyword.Null; + actionRow.style.marginBottom = 15f; + + var browseBtn = new Button { text = "Browse..." }; + browseBtn.RegisterCallback(_ => + { + var initial = string.IsNullOrWhiteSpace(pathField.value) ? defaultDir : pathField.value; + FolderDialogUtil.OpenFolderBrowser(initial, selected => + { + if (!string.IsNullOrWhiteSpace(selected)) + { + pathField.value = selected; + } + }); + }); + + var resetBtn = new Button { text = "Reset to Default" }; + resetBtn.RegisterCallback(_ => + { + pathField.value = defaultDir; + }); + + actionRow.Add(browseBtn); + actionRow.Add(resetBtn); + _globalConfigContainer.Add(actionRow); + + // 4. Info Row + var infoRow = CreateRow(); + infoRow.style.height = StyleKeyword.Auto; + infoRow.style.maxHeight = StyleKeyword.Null; + infoRow.style.minHeight = StyleKeyword.Null; + infoRow.style.marginBottom = 10f; + + var infoLabel = new Label("Global output directory applies to all renders unless overridden by API.") + { + style = + { + color = new StyleColor(new Color(0.7f, 0.7f, 0.7f)), + fontSize = 18, + unityTextAlign = TextAnchor.MiddleCenter + } + }; + infoRow.Add(infoLabel); + _globalConfigContainer.Add(infoRow); + + // 5. Control Button Row (Cancel / Save) + var buttonRow = CreateRow(); + var cancelBtn = new Button { text = "Cancel" }; + var saveBtn = new Button { text = "Save" }; + + buttonRow.Add(cancelBtn); + buttonRow.Add(saveBtn); + _globalConfigContainer.Add(buttonRow); + + cancelBtn.RegisterCallback(_ => + { + ToggleGlobalScreens(false); + _root.Remove(_globalConfigContainer); + _globalConfigContainer = null!; + }); + + saveBtn.RegisterCallback(_ => + { + var input = pathField.value?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(input) || + string.Equals(Path.GetFullPath(input).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(defaultDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)) + { + GlobalSettings.Current.OutputDirectory = null; + } + else + { + try + { + var fullPath = Path.GetFullPath(input); + if (!Directory.Exists(fullPath)) + { + Directory.CreateDirectory(fullPath); + } + GlobalSettings.Current.OutputDirectory = fullPath; + } + catch (Exception ex) + { + CommonWarningDialogUI.ShowDialog("Invalid Path", $"Directory path is invalid:\n{ex.Message}", new DialogButton("OK", () => { })); + return; + } + } + + GlobalSettings.Save(); + + ToggleGlobalScreens(false); + _root.Remove(_globalConfigContainer); + _globalConfigContainer = null!; + }); + + _root.Add(_globalConfigContainer); + ToggleGlobalScreens(true); + + void ToggleGlobalScreens(bool showGlobal) + { + if (_root.childCount > 0) + { + _root[0].style.display = showGlobal ? DisplayStyle.None : DisplayStyle.Flex; + } + _globalConfigContainer.style.display = showGlobal ? DisplayStyle.Flex : DisplayStyle.None; + } + } + private void OpenAdvancedConfig() { var config = RenderManager.Instance.Configuration; diff --git a/Util/FolderDialogUtil.cs b/Util/FolderDialogUtil.cs new file mode 100644 index 0000000..a3253a5 --- /dev/null +++ b/Util/FolderDialogUtil.cs @@ -0,0 +1,161 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using MelonLoader; + +namespace ADOFAIRenderer.Util; + +internal static class FolderDialogUtil +{ + public static void OpenFolderBrowser(string initialDirectory, Action onSelected) + { + var syncContext = SynchronizationContext.Current; + + Task.Run(() => + { + string? selected = null; + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + selected = OpenWindowsFolderBrowser(initialDirectory); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + selected = OpenMacFolderBrowser(initialDirectory); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + selected = OpenLinuxFolderBrowser(initialDirectory); + } + } + catch (Exception ex) + { + MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}"); + } + + if (syncContext != null) + { + syncContext.Post(_ => onSelected(selected), null); + } + else + { + onSelected(selected); + } + }); + } + + private static string? OpenWindowsFolderBrowser(string initialDirectory) + { + try + { + var escapedDir = initialDirectory.Replace("'", "''"); + var script = $@" +[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null +$f = New-Object System.Windows.Forms.FolderBrowserDialog +$f.Description = 'Select Output Directory' +if ([System.IO.Directory]::Exists('{escapedDir}')) {{ + $f.SelectedPath = '{escapedDir}' +}} +$f.ShowNewFolderButton = $true +if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ + [Console]::Write($f.SelectedPath) +}}"; + + var bytes = System.Text.Encoding.Unicode.GetBytes(script); + var base64 = Convert.ToBase64String(bytes); + + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}", + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + + using var process = Process.Start(startInfo); + if (process == null) return null; + + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null; + } + catch (Exception ex) + { + MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}"); + return null; + } + } + + private static string? OpenMacFolderBrowser(string initialDirectory) + { + try + { + var escaped = initialDirectory.Replace("\"", "\\\""); + var script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location \"{escaped}\")"; + + var startInfo = new ProcessStartInfo + { + FileName = "osascript", + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + startInfo.ArgumentList.Add("-e"); + startInfo.ArgumentList.Add(script); + + using var process = Process.Start(startInfo); + if (process == null) return null; + + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null; + } + catch + { + return null; + } + } + + private static string? OpenLinuxFolderBrowser(string initialDirectory) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "zenity", + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + startInfo.ArgumentList.Add("--file-selection"); + startInfo.ArgumentList.Add("--directory"); + startInfo.ArgumentList.Add("--title=Select Output Directory"); + if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory)) + { + startInfo.ArgumentList.Add($"--filename={initialDirectory}"); + } + + using var process = Process.Start(startInfo); + if (process == null) return null; + + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null; + } + catch + { + return null; + } + } +}