Files
ADOFAIRenderer/UI/Window/RenderConfigWindow.cs
T

1759 lines
67 KiB
C#

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.OS;
using MelonLoader;
using Newtonsoft.Json;
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;
// Presets controls
private static O5Dropdown<string>? _presetDropdown;
private static O5InputField? _presetNameInput;
private static O5Button? _savePresetButton;
private static O5Button? _deletePresetButton;
// ReSharper disable once InconsistentNaming
private static LayoutElement? _deletePresetButtonLE;
private static bool _isDeletePending;
private static Coroutine? _deleteResetCoroutine;
private static string? _lastLoadedPresetName;
/// <summary>
/// Configuration edited by this window. Kept for the whole game session; a render started from the window
/// receives a copy of it (RenderSession.Start), so values adjusted during the render are not written back here.
/// </summary>
private static RenderConfig? _uiConfig;
private static RenderConfig UiConfig => _uiConfig ??= new RenderConfig();
// 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<Codec>? _codecDropdown;
private static O5Dropdown<RateControlMode>? _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<int>? _sampleRateDropdown;
private static O5Dropdown<int>? _channelsDropdown;
private static O5Slider? _audioBitrateSlider;
private static O5InputField? _outputDirInput;
private static O5Dropdown<string>? _fileExtensionDropdown;
private static O5InputField? _randomSeedInput;
private static O5Toggle? _processAudioToggle;
private static O5Toggle? _nonTileToggle;
private static O5Toggle? _firePlanetToggle;
private static O5ColorPicker? _firePlanetPicker;
private static O5Toggle? _icePlanetToggle;
private static O5ColorPicker? _icePlanetPicker;
private static O5Toggle? _windPlanetToggle;
private static O5ColorPicker? _windPlanetPicker;
private static readonly Color DefaultFirePlanetColor = new(1f, 0f, 0f, 1f); // #FF0000
private static readonly Color DefaultIcePlanetColor = new(0f, 0f, 1f, 1f); // #0000FF
private static readonly Color DefaultWindPlanetColor = new(0.3f, 0.7f, 0f, 1f); // #4DB300
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
});
if (_configWindow.TitleText != null)
{
LocalizedText.Bind(_configWindow.TitleText, "ui.render_config.window_title");
}
_configWindow.CloseRequested += win =>
{
CloseAllPickers();
ResetDeletePendingState();
win.Rect.gameObject.SetActive(false);
};
BuildUI(_configWindow.Content);
LocalizationManager.OnLanguageChanged += OnLanguageChanged;
}
else
{
ReloadPresetDropdown(GlobalSettings.Current.LastPresetName);
}
var config = UiConfig;
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);
var config = UiConfig;
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
};
// Presets (Collapsible Card)
var (_, presetContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.presets"
);
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,
"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<RectTransform>();
var actionsHLayout = actionsRowObj.AddComponent<HorizontalLayoutGroup>();
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<LayoutElement>();
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"
);
if (_presetNameInput.Placeholder != null)
{
LocalizedText.Bind(_presetNameInput.Placeholder, "ui.render_config.preset_name_placeholder");
}
// ReSharper disable once InconsistentNaming
var inputLE = _presetNameInput.Rect.GetComponent<LayoutElement>() ?? _presetNameInput.Rect.gameObject.AddComponent<LayoutElement>();
inputLE.flexibleWidth = 1f;
inputLE.preferredHeight = 40f;
_savePresetButton = CreateButton(
ctx: ctx,
parent: actionsRowRect,
textKey: "ui.render_config.preset_save",
id: "btn_preset_save",
onClick: () => OnSavePresetClicked(config),
height: 40f
);
// ReSharper disable once InconsistentNaming
var saveLE = _savePresetButton.Rect.GetComponent<LayoutElement>() ?? _savePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
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 = CreateButton(
ctx: redCtx,
parent: actionsRowRect,
textKey: "ui.render_config.preset_delete",
id: "btn_preset_delete",
onClick: () => OnDeletePresetClicked(config),
height: 40f
);
if (_deletePresetButton.Label != null)
{
LocalizedText.Bind(_deletePresetButton.Label, () =>
_isDeletePending
? LocalizationManager.Get("ui.render_config.preset_delete_confirm")
: LocalizationManager.Get("ui.render_config.preset_delete"));
}
_deletePresetButtonLE = _deletePresetButton.Rect.GetComponent<LayoutElement>() ?? _deletePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
_deletePresetButtonLE.preferredWidth = 90f;
_deletePresetButtonLE.flexibleWidth = 0f;
_deletePresetButtonLE.preferredHeight = 40f;
UpdateDeleteButtonState();
// 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개)
var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f);
_renderFpsSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topLeft),
labelKey: "ui.render_config.render_fps", id: "slider_render_fps",
defaultValue: 60, min: 0.01, max: 3000, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => Snap(renderFpsSnap, value),
onChanged: val => { config.RenderFPS = val; MarkConfigModified(); }
);
_videoFPSSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topRight),
labelKey: "ui.render_config.video_fps", id: "slider_video_fps",
defaultValue: 60, min: 0.01, max: 600, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => Snap(videoFpsSnap, value),
onChanged: val => { config.VideoFPS = val; MarkConfigModified(); }
);
_renderWidthSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topLeft),
labelKey: "ui.render_config.render_width", id: "slider_render_width",
defaultValue: 2560, min: 0, max: 15360, value: 2560,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Snap(renderWidthSnap, value), inputFilter: value => Math.Max(value, 2),
onChanged: val => { config.RenderWidth = (int)Math.Round(val); MarkConfigModified(); }
);
_renderHeightSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topRight),
labelKey: "ui.render_config.render_height", id: "slider_render_height",
defaultValue: 1440, min: 0, max: 8640, value: 1440,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Snap(renderHeightSnap, value), inputFilter: value => Math.Max(value, 2),
onChanged: val => { config.RenderHeight = (int)Math.Round(val); MarkConfigModified(); }
);
_delayAfterEndSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topLeft),
labelKey: "ui.render_config.delay_after_end", id: "slider_delay_after_end",
defaultValue: 15, min: 0, max: 9999.999, value: 15,
format: "F3", clampMode: ClampMode.All,
onChanged: val => { config.DelayAfterEnd = val; MarkConfigModified(); }
);
_showJudgementToggle = CreateToggleField(
ctx, CreateFieldSlot(topRight),
labelKey: "",
defaultValue: false,
value: config.ShowJudgementText,
toggleTextKey: "ui.render_config.show_judgement",
id: "toggle_show_judgement",
onChanged: val => { config.ShowJudgementText = val; MarkConfigModified(); },
generateLabel: false
);
// 4. Video Encoding Config (Collapsible Card)
var (_, videoContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.video_encoding"
);
var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent);
var codecs = (Codec[])Enum.GetValues(typeof(Codec));
_codecDropdown = CreateDropdownField(
ctx, CreateFieldSlot(videoLeft),
"ui.render_config.codec",
Codec.H264, config.VideoConfig.Codec,
codecs, c => c.ToString(),
"dd_video_codec",
c =>
{
config.VideoConfig.Codec = c;
UpdateVideoCodecUI(c, config);
MarkConfigModified();
});
_bitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
"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; MarkConfigModified(); }
);
_qpInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
"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; MarkConfigModified(); }
);
var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode));
_rateControlDropdown = CreateDropdownField(
ctx, CreateFieldSlot(videoRight),
"ui.render_config.rate_control",
RateControlMode.CBR, config.VideoConfig.RateControlMode,
rateModes, m => m.ToString(),
"dd_rate_control",
m =>
{
config.VideoConfig.RateControlMode = m;
UpdateRateControlUI(m);
MarkConfigModified();
}
);
_maxBitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoRight),
"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; MarkConfigModified(); }
);
_forceSoftwareToggle = CreateToggleField(
ctx, CreateFieldSlot(videoRight),
labelKey: "",
defaultValue: false,
value: config.VideoConfig.ForceSoftwareEncoder,
toggleTextKey: "ui.render_config.force_software",
id: "toggle_force_software",
onChanged: val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); },
generateLabel: true
);
_keyframeIntervalSlider = CreateSlider(
ctx: ctx, parent: videoContent,
labelKey: "ui.render_config.keyframe_interval", id: "slider_keyframe_interval",
defaultValue: 2, min: 0.1, max: 10, value: 2,
format: "F1", clampMode: ClampMode.All,
onChanged: val => { config.VideoConfig.KeyframeInterval = val; MarkConfigModified(); }
);
// 5. Audio Encoding Config (Collapsible Card)
var (_, audioContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.audio_encoding"
);
var (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent);
_renderAudioToggle = CreateToggleField(
ctx, CreateFieldSlot(audioLeft),
labelKey: "",
defaultValue: true,
value: config.AudioConfig.RenderAudio,
toggleTextKey: "ui.render_config.render_audio",
id: "toggle_render_audio",
onChanged: val => { config.AudioConfig.RenderAudio = val; MarkConfigModified(); },
generateLabel: false
);
_audioBitrateSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(audioRight),
labelKey: "ui.render_config.audio_bitrate", id: "slider_audio_bitrate",
defaultValue: 256, min: 64, max: 384, value: 256,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Snap(audioBitrateSnap, value),
onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; MarkConfigModified(); }
);
var channels = new[] { 1, 2 };
_channelsDropdown = CreateDropdownField(
ctx, CreateFieldSlot(audioLeft),
"ui.render_config.channels",
2, config.AudioConfig.AudioChannels,
channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)",
"dd_channels",
c => { config.AudioConfig.AudioChannels = c; MarkConfigModified(); }
);
_sampleRateDropdown = CreateDropdownField(
ctx, CreateFieldSlot(audioRight),
"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; MarkConfigModified(); }
);
// 6. Global Settings (Collapsible Card)
var (_, globalContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.global_settings"
);
_outputDirInput = CreateFullWidthInputField(
ctx, globalContent,
"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);
CreateButton(
ctx, globalLeft,
"ui.render_config.browse",
"btn_browse_output",
() =>
{
var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value)
? GlobalSettings.DefaultOutputDirectory
: _outputDirInput.Value;
FolderDialogUtil.OpenFolderBrowser(initial, selected =>
{
Melon<RenderMod>.Logger.Msg("Output Directory Selected: " + selected);
if (!string.IsNullOrWhiteSpace(selected))
{
_outputDirInput.Set(selected);
GlobalSettings.Current.OutputDirectory = selected;
GlobalSettings.Save();
}
});
}
);
CreateButton(
ctx, globalRight,
"ui.render_config.reset_default",
"btn_reset_output",
() =>
{
_outputDirInput.Set(GlobalSettings.DefaultOutputDirectory);
GlobalSettings.Current.OutputDirectory = null;
GlobalSettings.Save();
}
);
// 7. Advanced Settings (Collapsible Card)
var (_, advContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.advanced_settings"
);
var (advLeft, advRight) = CreateTwoColumnPanel(advContent);
var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" };
_fileExtensionDropdown = CreateDropdownField(
ctx, CreateFieldSlot(advLeft),
"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;
MarkConfigModified();
}
);
_processAudioToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft),
labelKey: "",
defaultValue: true,
value: config.AdvancedConfig.ProcessAudio,
toggleTextKey: "ui.render_config.process_audio",
id: "toggle_process_audio",
onChanged: val =>
{
config.AdvancedConfig.ProcessAudio = val;
MarkConfigModified();
},
generateLabel: false
);
_randomSeedInput = CreateInputField(
ctx, CreateFieldSlot(advRight),
"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;
MarkConfigModified();
}
);
_nonTileToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.NonTile,
toggleTextKey: "ui.render_config.non_tile",
id: "toggle_non_tile",
onChanged: val =>
{
config.AdvancedConfig.NonTile = val;
MarkConfigModified();
},
generateLabel: false
);
// 7-1. Planet Color Settings (Optional)
var planetColorsLabel = CreateLabel(ctx, advContent, "ui.render_config.planet_colors");
// ReSharper disable once InconsistentNaming
var planetLabelLE = planetColorsLabel.gameObject.AddComponent<LayoutElement>();
planetLabelLE.preferredHeight = 18f;
planetLabelLE.minHeight = 18f;
var planetCols = CreateMultiColumnPanel(advContent, 3);
var (fireCol, iceCol, windCol) = (planetCols[0], planetCols[1], planetCols[2]);
var fireSlot = CreateFieldSlot(fireCol);
_firePlanetToggle = CreateToggleField(
ctx, fireSlot,
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.firePlanetColor.HasValue,
toggleTextKey: "ui.render_config.fire_planet",
id: "toggle_fire_planet",
onChanged: val =>
{
_firePlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.firePlanetColor = val ? _firePlanetPicker?.Value : null;
if (!val) _firePlanetPicker?.SetExpanded(false);
MarkConfigModified();
},
generateLabel: false
);
_firePlanetPicker = CreateColorPickerField(
ctx, fireSlot,
DefaultFirePlanetColor,
config.AdvancedConfig.firePlanetColor ?? DefaultFirePlanetColor,
"cp_fire_planet",
val =>
{
if (_firePlanetToggle?.Value == true)
{
config.AdvancedConfig.firePlanetColor = val;
MarkConfigModified();
}
}
);
if (!config.AdvancedConfig.firePlanetColor.HasValue)
{
_firePlanetPicker.SetBlocked(true, true);
}
var iceSlot = CreateFieldSlot(iceCol);
_icePlanetToggle = CreateToggleField(
ctx, iceSlot,
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.icePlanetColor.HasValue,
toggleTextKey: "ui.render_config.ice_planet",
id: "toggle_ice_planet",
onChanged: val =>
{
_icePlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.icePlanetColor = val ? _icePlanetPicker?.Value : null;
if (!val) _icePlanetPicker?.SetExpanded(false);
MarkConfigModified();
},
generateLabel: false
);
_icePlanetPicker = CreateColorPickerField(
ctx, iceSlot,
DefaultIcePlanetColor,
config.AdvancedConfig.icePlanetColor ?? DefaultIcePlanetColor,
"cp_ice_planet",
val =>
{
if (_icePlanetToggle?.Value == true)
{
config.AdvancedConfig.icePlanetColor = val;
MarkConfigModified();
}
}
);
if (!config.AdvancedConfig.icePlanetColor.HasValue)
{
_icePlanetPicker.SetBlocked(true, true);
}
var windSlot = CreateFieldSlot(windCol);
_windPlanetToggle = CreateToggleField(
ctx, windSlot,
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.windPlanetColor.HasValue,
toggleTextKey: "ui.render_config.wind_planet",
id: "toggle_wind_planet",
onChanged: val =>
{
_windPlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.windPlanetColor = val ? _windPlanetPicker?.Value : null;
if (!val) _windPlanetPicker?.SetExpanded(false);
MarkConfigModified();
},
generateLabel: false
);
_windPlanetPicker = CreateColorPickerField(
ctx, windSlot,
DefaultWindPlanetColor,
config.AdvancedConfig.windPlanetColor ?? DefaultWindPlanetColor,
"cp_wind_planet",
val =>
{
if (_windPlanetToggle?.Value == true)
{
config.AdvancedConfig.windPlanetColor = val;
MarkConfigModified();
}
}
);
if (!config.AdvancedConfig.windPlanetColor.HasValue)
{
_windPlanetPicker.SetBlocked(true, true);
}
// 8. Go Render Button
CreateButton(
ctx, scrollContent,
"ui.render_config.go_render",
"btn_go_render",
onClick: TryGoRender,
height: 48f
);
// 9. Version Label
var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true);
LocalizedText.Bind(versionLabel, GetVersionText);
versionLabel.alignment = TMPro.TextAlignmentOptions.Center;
versionLabel.color = ctx.Theme.TextDim;
// ReSharper disable once InconsistentNaming
var versionLE = versionLabel.gameObject.AddComponent<LayoutElement>();
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);
// Sliders show the config values as they are (noFilter: true).
// O5Slider.Set applies the slider filter (snapping to the nearest snap value) unless noFilter is set;
// the filter is meant for dragging, and typed values are not snapped either (O5Slider.SetFromInput).
_renderFpsSlider?.Set(config.RenderFPS, false, noFilter: true);
_videoFPSSlider?.Set(config.VideoFPS, false, noFilter: true);
_renderWidthSlider?.Set(config.RenderWidth, false, noFilter: true);
_renderHeightSlider?.Set(config.RenderHeight, false, noFilter: true);
_delayAfterEndSlider?.Set(config.DelayAfterEnd, false, noFilter: true);
_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, noFilter: true);
_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, noFilter: true);
_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);
var fireColor = config.AdvancedConfig.firePlanetColor;
_firePlanetToggle?.Set(fireColor.HasValue, false);
_firePlanetPicker?.Set(fireColor ?? DefaultFirePlanetColor, false);
_firePlanetPicker?.SetBlocked(!fireColor.HasValue, true);
var iceColor = config.AdvancedConfig.icePlanetColor;
_icePlanetToggle?.Set(iceColor.HasValue, false);
_icePlanetPicker?.Set(iceColor ?? DefaultIcePlanetColor, false);
_icePlanetPicker?.SetBlocked(!iceColor.HasValue, true);
var windColor = config.AdvancedConfig.windPlanetColor;
_windPlanetToggle?.Set(windColor.HasValue, false);
_windPlanetPicker?.Set(windColor ?? DefaultWindPlanetColor, false);
_windPlanetPicker?.SetBlocked(!windColor.HasValue, true);
UpdateVideoCodecUI(config.VideoConfig.Codec, config);
UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true);
UpdatePresetLabel(config);
}
/// <summary>
/// Sets the preset dropdown from the config values.
/// Every value equals the last loaded preset (_lastLoadedPresetName): the preset name is shown.
/// Any value differs (edited, or the preset file changed): the Custom label is shown (MarkConfigModified),
/// the same state as after editing a value in the window.
/// </summary>
private static void UpdatePresetLabel(RenderConfig config)
{
if (_presetDropdown == null) return;
var presetName = _lastLoadedPresetName;
var preset = string.IsNullOrEmpty(presetName)
? null
: PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, presetName, StringComparison.OrdinalIgnoreCase));
if (preset == null || !ConfigValuesEqual(config, preset.Config))
{
MarkConfigModified();
return;
}
if (_presetDropdown.Values.Contains(CustomPresetLabel))
{
_presetDropdown.SetValues(_presetDropdown.Values.Where(v => v != CustomPresetLabel).ToList());
}
_presetDropdown.Set(preset.Name, false);
UpdateDeleteButtonState();
}
/// <summary>
/// Compares two configs by their serialized JSON (the same form as the preset files), so every saved value is compared.
/// </summary>
private static bool ConfigValuesEqual(RenderConfig a, RenderConfig b)
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(new ColorJsonConverter());
return JsonConvert.SerializeObject(a, settings) == JsonConvert.SerializeObject(b, settings);
}
private static void TryGoRender()
{
var config = UiConfig;
SyncUItoConfig(config);
// 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 SyncUItoConfig(RenderConfig target)
{
if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value;
if (_videoFPSSlider != null) target.VideoFPS = _videoFPSSlider.Value;
target.RenderFPS = AlignNTSCFramerate(target.RenderFPS);
target.VideoFPS = AlignNTSCFramerate(target.VideoFPS);
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;
return;
// ReSharper disable once InconsistentNaming
double AlignNTSCFramerate(double framerate)
{
const double tolerance = 0.001;
if (Math.Abs(framerate - 29.97) < tolerance) framerate = 30000.0 / 1001;
else if (Math.Abs(framerate - 59.94) < tolerance) framerate = 60000.0 / 1001;
else if (Math.Abs(framerate - 23.976) < tolerance || Math.Abs(framerate - 23.98) < tolerance) framerate = 24000.0 / 1001;
return framerate;
}
}
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<string>(currentValues);
list.Insert(0, customLabel);
_presetDropdown.SetValues(list);
}
_presetDropdown.Set(customLabel, false);
UpdateDeleteButtonState();
}
private static void OnPresetSelected(string? selectedName, RenderConfig config)
{
ResetDeletePendingState();
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);
// A preset with another name can share the file of this name (PresetManager.GetSafeFileName), e.g. "a/b" and "a_b".
var fileName = PresetManager.GetSafeFileName(name);
var existing = PresetManager.GetPresets().FirstOrDefault(p =>
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(PresetManager.GetSafeFileName(p.Name), fileName, StringComparison.OrdinalIgnoreCase));
if (existing != null && !isCurrentPreset)
{
CommonWarningDialogUI.ShowDialog(
LocalizationManager.Get("ui.render_config.preset_overwrite_title"),
LocalizationManager.Get("ui.render_config.preset_overwrite_desc", existing.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;
}
}
if (!_isDeletePending)
{
_isDeletePending = true;
if (_deletePresetButtonLE != null) _deletePresetButtonLE.preferredWidth = 110f;
if (_deletePresetButton != null)
{
if (_deletePresetButton.Label != null)
{
_deletePresetButton.Label.text = LocalizationManager.Get("ui.render_config.preset_delete_confirm");
}
_deletePresetButton.NormalColor = new Color(0.95f, 0.25f, 0.25f, 1f);
_deletePresetButton.UpdateVisual();
}
if (_deleteResetCoroutine != null)
{
RendererUIManager.Instance.StopCoroutine(_deleteResetCoroutine);
}
_deleteResetCoroutine = RendererUIManager.Instance.StartCoroutine(ResetDeletePendingAfterDelay());
return;
}
ResetDeletePendingState();
DoDeletePreset(targetPresetName, config);
}
private static System.Collections.IEnumerator ResetDeletePendingAfterDelay()
{
yield return new WaitForSeconds(3f);
ResetDeletePendingState();
}
private static void ResetDeletePendingState()
{
if (_deleteResetCoroutine != null)
{
RendererUIManager.Instance.StopCoroutine(_deleteResetCoroutine);
_deleteResetCoroutine = null;
}
if (!_isDeletePending && (_deletePresetButton == null || _deletePresetButton.Label?.text == LocalizationManager.Get("ui.render_config.preset_delete")))
{
return;
}
_isDeletePending = false;
if (_deletePresetButtonLE) _deletePresetButtonLE.preferredWidth = 90f;
if (_deletePresetButton != null)
{
if (_deletePresetButton.Label != null)
{
_deletePresetButton.Label.text = LocalizationManager.Get("ui.render_config.preset_delete");
}
_deletePresetButton.NormalColor = RendererUIManager.Context.Theme.SoftRed;
_deletePresetButton.UpdateVisual();
}
}
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)
{
ResetDeletePendingState();
var presets = PresetManager.GetPresets();
var names = presets.Select(p => p.Name).ToList();
if (names.Count == 0)
{
_presetDropdown?.SetValues(new List<string> { 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 OnLanguageChanged(SystemLanguage language)
{
if (_configWindow == null) return;
if (_presetDropdown == null) return;
// The preset names are not localized, the Custom label is. The list is rebuilt from the preset names
// (which drops the Custom label of the previous language), then the label is decided from the values:
// the Custom label of the new language only when the values differ from the last loaded preset.
var names = PresetManager.GetPresets().Select(p => p.Name).ToList();
if (names.Count == 0)
{
_presetDropdown.SetValues(new List<string> { CustomPresetLabel });
_presetDropdown.Set(CustomPresetLabel, false);
UpdateDeleteButtonState();
return;
}
_presetDropdown.SetValues(names);
var loadedName = _lastLoadedPresetName;
if (!string.IsNullOrEmpty(loadedName) && names.Contains(loadedName))
{
_presetDropdown.Set(loadedName, false);
}
UpdatePresetLabel(UiConfig);
}
private static string GetVersionText()
{
var versionText = $"ADOFAI Renderer v{Melon<RenderMod>.Instance.Info.Version}";
if (DateTime.UtcNow >= RenderMod.ExpirationTime)
versionText += " " + LocalizationManager.Get("ui.render_config.expired");
return versionText;
}
private static void ShowValidationError(string message)
{
Melon<RenderMod>.Logger.Error(message);
CommonWarningDialogUI.ShowDialog(
LocalizationManager.Get("ui.render_config.validation_title"),
message,
new DialogButton("OK", () => { })
);
}
// 카드(또는 상단 섹션)당 하나의 다열 컨테이너를 만들어
// 가로 한 줄 각 필드 사이 중앙이 행마다 어긋나지 않도록 한다.
// - 패널 1개 = 균등 분할 (행마다 HLG를 만들던 기존 방식의 어긋남 방지)
// - 각 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 1:1:...:1 균등 분할
// - 각 필드는 Field 슬롯(label 18 + 간격 3 + 컨트롤 50)으로 높이를 고정해
// 좌/우 스택을 써도 행 단위 Y 정렬이 유지되도록 한다.
private static RectTransform[] CreateMultiColumnPanel(
Transform parent, int columnCount, float spacing = 8f, float columnSpacing = 8f)
{
if (columnCount < 1) throw new ArgumentOutOfRangeException(nameof(columnCount), "Column count must be at least 1.");
var panelObj = new GameObject($"{columnCount}ColPanel");
panelObj.transform.SetParent(parent, false);
panelObj.AddComponent<RectTransform>();
var hLayout = panelObj.AddComponent<HorizontalLayoutGroup>();
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<LayoutElement>();
panelLE.flexibleWidth = 1f;
var columns = new RectTransform[columnCount];
for (var i = 0; i < columnCount; i++)
{
columns[i] = CreateColumn(panelObj.transform, $"Col{i + 1}", columnSpacing);
}
return columns;
}
private static (RectTransform left, RectTransform right) CreateTwoColumnPanel(
Transform parent, float spacing = 8f, float columnSpacing = 8f)
{
var cols = CreateMultiColumnPanel(parent, 2, spacing, columnSpacing);
return (cols[0], cols[1]);
}
private static RectTransform CreateColumn(Transform parent, string name, float spacing)
{
var colObj = new GameObject(name);
colObj.transform.SetParent(parent, false);
var colRect = colObj.AddComponent<RectTransform>();
var colLayout = colObj.AddComponent<VerticalLayoutGroup>();
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<LayoutElement>();
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<RectTransform>();
var slotLayout = slotObj.AddComponent<VerticalLayoutGroup>();
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<LayoutElement>();
slotLE.flexibleWidth = 1f;
return slotRect;
}
// ReSharper disable once UnusedTupleComponentInReturnValue
private static (RectTransform card, RectTransform content) CreateCard(
O5Context ctx,
Transform parent,
string titleKey,
bool activeValue = true,
Action<bool>? onActiveChanged = null,
Action? onDeleteClick = null,
bool showDeleteButton = false,
bool showActiveToggle = false)
{
var (card, content) = O5Factory.Card(
ctx,
parent,
LocalizationManager.Get(titleKey),
activeValue,
onActiveChanged,
onDeleteClick,
showDeleteButton,
showActiveToggle
);
var titleTmp = card.Find("Header/TitleTrigger/TitleText")?.GetComponent<TMPro.TextMeshProUGUI>()
?? card.GetComponentsInChildren<TMPro.TextMeshProUGUI>(true).FirstOrDefault(t => t.gameObject.name == "TitleText");
if (titleTmp)
{
titleTmp.fontSize = 18f;
LocalizedText.Bind(titleTmp, titleKey);
}
return (card, content);
}
private static TMPro.TextMeshProUGUI CreateLabel(
O5Context ctx,
Transform parent,
string textKey,
float fontSize = 13f,
bool noPad = true,
Color? color = null)
{
var label = O5Factory.ControlText(ctx, parent, fontSize, noPad: noPad);
label.text = LocalizationManager.Get(textKey);
label.color = color ?? ctx.Theme.TextDim;
LocalizedText.Bind(label, textKey);
return label;
}
private static O5Slider CreateSlider(
O5Context ctx,
Transform parent,
string labelKey,
string id,
double defaultValue,
double min,
double max,
double value,
string format = "F0",
ClampMode clampMode = ClampMode.All,
Func<double, double>? sliderFilter = null,
Func<double, double>? inputFilter = null,
Action<double>? onChanged = null,
Action<double>? onComplete = null,
bool showFill = true)
{
var slider = O5Factory.Slider(
ctx: ctx,
parent: parent,
defaultValue: defaultValue,
min: min,
max: max,
value: value,
format: format,
clampMode: clampMode,
sliderFilter: sliderFilter,
inputFilter: inputFilter,
onComplete: onComplete,
onChanged: onChanged,
text: LocalizationManager.Get(labelKey),
id: id,
showFill: showFill
);
if (slider.Label != null)
{
LocalizedText.Bind(slider.Label, labelKey);
}
return slider;
}
private static O5Button CreateButton(
O5Context ctx,
Transform parent,
string textKey,
string id,
Action onClick,
float? height = null)
{
var button = O5Factory.Button(
ctx: ctx,
parent: parent,
onClick: onClick,
text: LocalizationManager.Get(textKey),
id: id,
height: height
);
if (button.Label != null)
{
LocalizedText.Bind(button.Label, textKey);
}
return button;
}
private static O5InputField CreateInputField(
O5Context ctx,
Transform colParent,
string labelKey,
string? defaultValue,
string value,
string id,
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
Action<string>? onChanged = null)
{
return CreateInputField(ctx, colParent, labelKey, defaultValue, value, id, out _, characterValidation, onChanged);
}
private static O5InputField CreateInputField(
O5Context ctx,
Transform colParent,
string labelKey,
string? defaultValue,
string value,
string id,
out TMPro.TextMeshProUGUI label,
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
Action<string>? onChanged = null)
{
label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
labelLE.preferredHeight = 18f;
labelLE.minHeight = 18f;
var inputField = O5Factory.Input(
ctx: ctx,
parent: colParent,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
placeholder: LocalizationManager.Get(labelKey),
icon: null,
id: id
);
if (inputField.Placeholder != null)
{
LocalizedText.Bind(inputField.Placeholder, labelKey);
}
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 labelKey,
string? defaultValue,
string value,
string id,
Action<string>? onChanged = null)
{
var container = new GameObject($"FullWidth_{id}");
container.transform.SetParent(parent, false);
var containerRect = container.AddComponent<RectTransform>();
var layout = container.AddComponent<VerticalLayoutGroup>();
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 = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
labelLE.preferredHeight = 18f;
labelLE.minHeight = 18f;
var input = O5Factory.Input(
ctx: ctx,
parent: containerRect,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
placeholder: LocalizationManager.Get(labelKey),
icon: null,
id: id
);
if (input.Placeholder != null)
{
LocalizedText.Bind(input.Placeholder, labelKey);
}
return input;
}
private static O5Toggle CreateToggleField(
O5Context ctx,
Transform colParent,
string labelKey,
bool? defaultValue,
bool value,
string toggleTextKey,
string id,
Action<bool>? onChanged = null,
bool generateLabel = true)
{
if (generateLabel)
{
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = string.IsNullOrEmpty(labelKey) ? "" : LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
labelLE.preferredHeight = 18f;
labelLE.minHeight = 18f;
}
var toggle = O5Factory.Toggle(
ctx: ctx,
parent: colParent,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
text: !string.IsNullOrEmpty(toggleTextKey) ? LocalizationManager.Get(toggleTextKey) : "",
id: id
);
if (toggle.Label != null && !string.IsNullOrEmpty(toggleTextKey))
{
LocalizedText.Bind(toggle.Label, toggleTextKey);
}
return toggle;
}
private static O5Dropdown<T> CreateDropdownField<T>(
O5Context ctx,
Transform colParent,
string labelKey,
T defaultValue,
T value,
IReadOnlyList<T> values,
Func<T, string> display,
string id,
Action<T>? onChanged = null)
{
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
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<LayoutElement>();
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 O5ColorPicker CreateColorPickerField(
O5Context ctx,
Transform colParent,
Color? defaultValue,
Color value,
string id,
Action<Color>? onChanged = null)
{
var cpContainer = new GameObject($"CP_{id}");
cpContainer.transform.SetParent(colParent, false);
// ReSharper disable once InconsistentNaming
var cpLE = cpContainer.AddComponent<LayoutElement>();
var height = ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody);
cpLE.preferredHeight = height;
cpLE.minHeight = height;
var canvasRect = RendererUIManager.Instance.WindowManager.Canvas.Root.GetComponent<RectTransform>();
var picker = O5Factory.ColorPicker(
ctx: ctx,
parent: cpContainer.transform,
canvasRect: canvasRect,
canvasCamera: null,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
onComplete: onChanged,
id: id
);
// 3열 등 다열 레이아웃 환경에 맞춰 O5ColorPicker 헤더 내부 비율 및 폰트 크기 조정
// O5Kit 기본값(Preview: 0~0.5, HexInput: 0.62~1.0, fontSize: 19)은 너비가 좁을 때
// Hex 문자열(8자리 RGBA) 앞부분이 잘려 보이는 현상이 발생하므로 공간을 재분배합니다.
if (picker.Rect.Find("Header") is RectTransform header)
{
if (header.Find("Preview") is RectTransform preview)
{
preview.anchorMax = new Vector2(0.28f, 1f);
}
if (header.Find("ToggleArea") is RectTransform toggleArea)
{
toggleArea.anchorMax = new Vector2(0.30f, 1f);
}
if (header.Find("HexInput") is RectTransform hexInput)
{
hexInput.anchorMin = new Vector2(0.32f, 0f);
var tmpInput = hexInput.GetComponentInChildren<TMPro.TMP_InputField>();
if (tmpInput != null && tmpInput.textComponent != null)
{
tmpInput.textComponent.fontSize = 15f;
}
}
}
return picker;
}
private static void SetWindowActive(bool enabled)
{
if (!enabled)
{
CloseAllPickers();
}
_configWindow?.Rect.gameObject.SetActive(enabled);
}
private static void CloseAllPickers()
{
_firePlanetPicker?.SetExpanded(false, true);
_icePlanetPicker?.SetExpanded(false, true);
_windPlanetPicker?.SetExpanded(false, true);
}
}