Files
ADOFAIRenderer/UI/Window/RenderConfigWindow.cs
T

2280 lines
103 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;
/// <summary>
/// The "Render Config" window: edits a <see cref="RenderConfig"/> and the global output directory, manages presets,
/// and starts a render ("Go Render").
/// <para>
/// Opened from the level editor through <c>RenderManager</c> (after the unsaved-changes check) with
/// <see cref="CreateWindow"/>. The window is built once and then hidden/shown; its state lives in static fields for the
/// whole game session. All members run on the main Unity thread.
/// </para>
/// <para>
/// Data flow:
/// <list type="bullet">
/// <item>Each control's change callback writes its value into <see cref="UIConfig"/> right away and calls
/// <see cref="MarkConfigModified"/> (the preset dropdown then shows the Custom label).</item>
/// <item><see cref="ApplyConfigToUI"/> writes a config back into every control without invoking the callbacks
/// (preset selection, window reopen).</item>
/// <item><see cref="SyncUItoConfig"/> reads every control into the config again before saving a preset or rendering,
/// and converts NTSC frame rates (29.97 etc.) to their exact fractions.</item>
/// <item>The output directory is stored in <c>GlobalSettings.Current.OutputDirectory</c> (<c>null</c> = default folder),
/// not in the render config, and is saved to the global settings file.</item>
/// </list>
/// </para>
/// <para>
/// File layout: fields; <see cref="CreateWindow"/>; <see cref="BuildUI"/> (cards in display order: Presets, top section,
/// Video Encoding, Audio Encoding, Global Settings, Advanced Settings, Go Render, version label); config/UI sync and
/// render start; preset handling (Custom label, select, save, delete, language change); layout helpers
/// (columns, field slots, cards); control factory helpers; enable/disable rules for dependent fields; window visibility.
/// </para>
/// <para>
/// Layout sizes are O5Kit canvas units (the canvas scales with the screen); label rows are 18 units, field rows use
/// 3 units of spacing between label and control, dropdown controls are 50 units high.
/// </para>
/// </summary>
internal static class RenderConfigWindow
{
// ===================================================================================================
// Fields
// ===================================================================================================
/// <summary>The window, created by the first <see cref="CreateWindow"/> call and reused (hidden, not destroyed, on close).</summary>
private static O5Window? _configWindow;
// Presets controls
/// <summary>Preset selector: preset names, plus the Custom label at index 0 while the values differ from the preset.</summary>
private static O5Dropdown<string>? _presetDropdown;
/// <summary>Name used by the Save button; set to the selected preset's name when a preset is loaded.</summary>
private static O5InputField? _presetNameInput;
/// <summary>Saves the current values under the name in <see cref="_presetNameInput"/>.</summary>
private static O5Button? _savePresetButton;
/// <summary>
/// Two-step delete button (red theme): the first click arms it ("Confirm?", wider, brighter red) for 3 seconds,
/// a second click within that time deletes the preset.
/// </summary>
private static O5Button? _deletePresetButton;
/// <summary>Layout element of the delete button; preferred width 90 units normally, 110 while armed.</summary>
// ReSharper disable once InconsistentNaming
private static LayoutElement? _deletePresetButtonLE;
/// <summary>True while the delete button is armed and waiting for the confirming click.</summary>
private static bool _isDeletePending;
/// <summary>Coroutine (on RendererUIManager) that disarms the delete button after 3 seconds.</summary>
private static Coroutine? _deleteResetCoroutine;
/// <summary>
/// Name of the preset whose values were last loaded or saved. The Custom label is compared against this preset,
/// and Delete targets it when the dropdown shows the Custom label. <c>null</c> when no presets exist.
/// </summary>
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;
/// <summary>
/// <see cref="_uiConfig"/>, created on first access with the <see cref="RenderConfig"/> initializer values
/// (RenderFPS, VideoFPS, RenderWidth and RenderHeight start at 0; <see cref="EnsureDefaultConfig"/> fills them).
/// </summary>
private static RenderConfig UIConfig => _uiConfig ??= new RenderConfig();
// Controls cache for validation & sync.
// Each field holds the control bound to the RenderConfig property of the same name; ApplyConfigToUI writes them,
// SyncUItoConfig reads them. They stay null until BuildUI runs.
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;
// Bitrate / max bitrate / QP inputs keep their label and the running label color tween, so UpdateRateControlUI
// can dim the label together with the blocked input (see SetFieldBlocked).
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;
/// <summary>
/// Output directory input. Bound to <c>GlobalSettings.Current.OutputDirectory</c>, not to a RenderConfig property.
/// </summary>
private static O5InputField? _outputDirInput;
private static O5Dropdown<string>? _fileExtensionDropdown;
private static O5InputField? _randomSeedInput;
private static O5Toggle? _processAudioToggle;
private static O5Toggle? _nonTileToggle;
private static O5Toggle? _highResBloomCorrectionToggle;
private static O5Toggle? _fixCustomFrameRateToggle;
private static O5Toggle? _disableTileCullingToggle;
private static O5Toggle? _hideFailedDecorationsToggle;
// Planet color overrides: the toggle enables the override, the picker holds the color. A disabled toggle stores
// null in AdvancedConfig (no override is applied by PersistencePatch) and blocks the picker.
private static O5Toggle? _firePlanetToggle;
private static O5ColorPicker? _firePlanetPicker;
private static O5Toggle? _icePlanetToggle;
private static O5ColorPicker? _icePlanetPicker;
private static O5Toggle? _windPlanetToggle;
private static O5ColorPicker? _windPlanetPicker;
// Colors shown in a planet picker when its override is off (and its middle-click default value)
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
// ===================================================================================================
// Window creation
// ===================================================================================================
/// <summary>
/// Shows the window, building it on the first call.
/// <list type="bullet">
/// <item>First call: creates a 900x600 resizable, draggable window with a close button, builds the controls
/// (<see cref="BuildUI"/>) and subscribes <see cref="OnLanguageChanged"/>.</item>
/// <item>Later calls: reloads the preset list from disk and selects <c>GlobalSettings.Current.LastPresetName</c>
/// (or the first preset).</item>
/// </list>
/// Then writes <see cref="UIConfig"/> into the controls, shows the window and brings it to the front.
/// The close button hides the window (closing open color pickers and disarming the delete button first).
/// </summary>
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();
}
// ===================================================================================================
// UI construction
// ===================================================================================================
/// <summary>
/// Builds every control of the window inside a vertical scroll view (10 units between items, 8 units padding).
/// <para>
/// The initial preset is <c>GlobalSettings.Current.LastPresetName</c> if it exists, otherwise the first preset whose
/// name starts with "QHD", otherwise the first preset. Its values are copied into <see cref="UIConfig"/> only when
/// RenderFPS is 0 or less at that point (see the comment at that check). Each control is created with the config
/// value and a change callback that writes back into the config.
/// </para>
/// <para>
/// Sliders use a <c>sliderFilter</c> that snaps dragged values to the nearest entry of a snap list (common frame
/// rates, resolutions, bitrates); values typed into the slider's input box are not snapped.
/// </para>
/// </summary>
/// <param name="content">Content rect of the window.</param>
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);
// Snap lists for slider dragging (Snap picks the entry nearest to the dragged value; order does not matter).
// Render FPS: standard video rates, then multiples of 120 and of 288 up to the 3000 maximum.
// Video FPS: standard video rates, then multiples of 60 and of 72 up to the 600 maximum.
// Audio bitrate: kbps values. Width/height: common resolutions grouped by aspect ratio.
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
};
// ---------------------------------------------------------------------------------------------
// Section 1: Presets card - preset dropdown, then a row with name input, Save and Delete buttons
// ---------------------------------------------------------------------------------------------
// Presets (Collapsible Card)
var (_, presetContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.presets"
);
var presets = PresetManager.GetPresets();
var presetNames = presets.Select(p => p.Name).ToList();
// Initial preset: last used preset if it still exists, else the first "QHD..." preset, else the first preset,
// else "" (no presets on disk)
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;
// Copies the initial preset into a config that has not been filled yet (RenderFPS <= 0).
// EnsureDefaultConfig at the start of BuildUI sets RenderFPS to 60 when it is 0 or less, so on the first build
// this condition is false and the config keeps the defaults (EnsureDefaultConfig values and RenderConfig
// initializers). UpdatePresetLabel then shows the Custom label unless those defaults equal the preset.
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);
// Selecting a preset loads it into the config and all controls (OnPresetSelected)
_presetDropdown = CreateDropdownField(
ctx, presetContent,
"ui.render_config.preset_select",
initialPresetName, initialPresetName,
presetNames, p => p,
"dd_presets",
selectedName =>
{
OnPresetSelected(selectedName, config);
}
);
// Preset actions row (40 units high): name input takes the remaining width, Save and Delete are 90 units wide,
// 8 units apart
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;
// The Delete button is built with a copy of the context whose button color is the theme's SoftRed
// (hover: SoftRed at 80% brightness, alpha also scaled by the Color multiplication)
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
);
// Delete label follows both the language and the armed state ("Delete" / "Confirm?")
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();
// ---------------------------------------------------------------------------------------------
// Section 2: Top section (no card) - two columns, three rows:
// Render FPS | Video FPS
// Render Width | Render Height
// Delay After End| Show Judgement Text
// One left and one right column container hold all three rows, so the column boundary is the same on every row.
// ---------------------------------------------------------------------------------------------
var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f);
// Render FPS (RenderConfig.RenderFPS): game frames simulated per second of render time (the game clock advances
// 1 / RenderFPS seconds per frame), 0.01..3000, 3 decimals
_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(); }
);
// Video FPS (RenderConfig.VideoFPS): frame rate of the output video, 0.01..600, 3 decimals
_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(); }
);
// Render width/height in pixels (RenderConfig.RenderWidth/RenderHeight): slider range 0..15360 / 0..8640,
// typed values are raised to at least 2 (inputFilter). Odd values are rejected later by TryGoRender.
_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(); }
);
// Delay After End (RenderConfig.DelayAfterEnd): seconds of render time recorded after the end condition is met,
// 0..9999.999
_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(); }
);
// Show Judgement Text (RenderConfig.ShowJudgementText): keeps the hit judgement texts visible during the render
_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
);
// ---------------------------------------------------------------------------------------------
// Section 3: Video Encoding card (RenderConfig.VideoConfig)
// Codec | Rate Control Mode
// Bitrate | Max Bitrate
// QP | Force Software Encoder
// Keyframe Interval (full width)
// Bitrate, Max Bitrate and QP are enabled or blocked by the rate control mode (UpdateRateControlUI).
// ---------------------------------------------------------------------------------------------
// 4. Video Encoding Config (Collapsible Card)
var (_, videoContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.video_encoding"
);
var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent);
// Every Codec enum value is offered. Changing the codec also replaces the sample rate choices with those of the
// codec's audio codec (UpdateVideoCodecUI).
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();
});
// Bitrate / Max Bitrate in kbps (label text) and QP: integer inputs. A value that does not parse, or is out of
// range (bitrate <= 0, QP < 0), leaves the config value unchanged; the input box keeps showing the typed text.
_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
);
// Keyframe interval in seconds (VideoConfig.KeyframeInterval), 0.1..10; full card width
_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(); }
);
// ---------------------------------------------------------------------------------------------
// Section 4: Audio Encoding card (RenderConfig.AudioConfig)
// Render Audio | Audio Bitrate (kbps)
// Channels | Sample Rate
// ---------------------------------------------------------------------------------------------
// 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
);
// Audio bitrate: the slider shows kbps (64..384), the config stores bits per second (kbps * 1000)
_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(); }
);
// Output channel count: 1 (mono) or 2 (stereo)
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(); }
);
// Sample rate in Hz, shown as kHz. Choices: the rates supported by the audio codec paired with the selected video
// codec (CodecUtil.AudioCodecMatch -> CodecUtil.AvailableSampleRate).
_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(); }
);
// ---------------------------------------------------------------------------------------------
// Section 5: Global Settings card - output directory (GlobalSettings, shared by all presets)
// Output Directory (full width)
// Browse... | Reset to Default
// ---------------------------------------------------------------------------------------------
// 6. Global Settings (Collapsible Card)
var (_, globalContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.global_settings"
);
// Typing updates GlobalSettings.Current.OutputDirectory in memory only (saved by Go Render, Browse or Reset).
// Empty text or the default folder (case-insensitive) stores null, meaning "use the default folder".
// The input shows EffectiveOutputDirectory: the stored folder if it exists, else the default.
_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.Ordinal))
{
GlobalSettings.Current.OutputDirectory = null;
}
else
{
GlobalSettings.Current.OutputDirectory = trimmed;
}
}
);
var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent);
// Browse: opens the OS folder picker (FolderDialogUtil) at the current folder; a selected folder is written to
// the input and saved to the global settings immediately. FolderDialogUtil runs the callback through the
// caller's SynchronizationContext, i.e. on the main Unity thread; cancel or failure passes null (ignored here).
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();
}
});
}
);
// Reset to Default: shows the default folder and saves null (default) to the global settings immediately
CreateButton(
ctx, globalRight,
"ui.render_config.reset_default",
"btn_reset_output",
() =>
{
_outputDirInput.Set(GlobalSettings.DefaultOutputDirectory);
GlobalSettings.Current.OutputDirectory = null;
GlobalSettings.Save();
}
);
// ---------------------------------------------------------------------------------------------
// Section 6: Advanced Settings card (RenderConfig.AdvancedConfig)
// File Extension | Random Seed
// Process Audio | Non-Tile Mode
// High-Resolution Bloom Correction |
// "Planet Colors" label, then three columns: Fire | Ice | Wind (toggle + color picker each)
// ---------------------------------------------------------------------------------------------
// 7. Advanced Settings (Collapsible Card)
var (_, advContent) = CreateCard(
ctx, scrollContent,
"ui.render_config.advanced_settings"
);
var (advLeft, advRight) = CreateTwoColumnPanel(advContent);
// Output container, chosen by file extension (AdvancedConfig.FileExtension, without the dot)
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();
}
);
// Process Audio (AdvancedConfig.ProcessAudio): answers the game's AudioSource.GetSpectrumData queries from the
// offline song audio at the render time (AudioSpectrumGenerator), so audio-reactive visuals follow the rendered audio
_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
);
// High-Resolution Bloom Correction (AdvancedConfig.HighResBloomCorrection): the game's bloom computes its blur
// size as if the frame were 720 pixels high (VideoBloomPatch)
_highResBloomCorrectionToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.HighResBloomCorrection,
toggleTextKey: "ui.render_config.high_res_bloom_correction",
id: "toggle_high_res_bloom_correction",
onChanged: val =>
{
config.AdvancedConfig.HighResBloomCorrection = val;
MarkConfigModified();
},
generateLabel: false
);
// Random Seed (AdvancedConfig.RandomSeed): integer passed to UnityEngine.Random.InitState before the game scene
// loads; empty text stores null (no seeding). Text that is neither empty nor an integer keeps the previous value.
_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();
}
);
// Non-Tile Mode (AdvancedConfig.NonTile): hides the floor tiles (including hold meshes and the end portal) and the
// planets during the render, leaving the background and decorations
_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
);
// Fix SetFramerate Render Timing (AdvancedConfig.FixCustomFrameRate): defers manual camera rendering to frame end
_fixCustomFrameRateToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight),
labelKey: "",
defaultValue: true,
value: config.AdvancedConfig.FixCustomFrameRate,
toggleTextKey: "ui.render_config.fix_custom_frame_rate",
id: "toggle_fix_custom_frame_rate",
onChanged: val =>
{
config.AdvancedConfig.FixCustomFrameRate = val;
MarkConfigModified();
},
generateLabel: false
);
// Disable Tile Culling (AdvancedConfig.DisableTileCulling): prevents tiles from being disabled outside the frustum
_disableTileCullingToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight),
labelKey: "",
defaultValue: true,
value: config.AdvancedConfig.DisableTileCulling,
toggleTextKey: "ui.render_config.disable_tile_culling",
id: "toggle_disable_tile_culling",
onChanged: val =>
{
config.AdvancedConfig.DisableTileCulling = val;
MarkConfigModified();
},
generateLabel: false
);
// Hide Failed Decorations (AdvancedConfig.HideFailedDecorations): hides placeholder tree decorations when decoration images fail to load
_hideFailedDecorationsToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.HideFailedDecorations,
toggleTextKey: "ui.render_config.hide_failed_decorations",
id: "toggle_hide_failed_decorations",
onChanged: val =>
{
config.AdvancedConfig.HideFailedDecorations = val;
MarkConfigModified();
},
generateLabel: false
);
// 7-1. Planet Color Settings (Optional).
// For each planet: the toggle turns the override on/off (off stores null and blocks/collapses the picker);
// the picker writes its color into the config only while the toggle is on.
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();
}
}
);
// No override stored: start with the picker blocked (second argument: no animation)
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);
}
// ---------------------------------------------------------------------------------------------
// Section 7: Go Render button (48 units high) and version label
// ---------------------------------------------------------------------------------------------
// 8. Go Render Button
CreateButton(
ctx, scrollContent,
"ui.render_config.go_render",
"btn_go_render",
onClick: TryGoRender,
height: 48f
);
// 9. Version Label: 12 pt dim centered text, "ADOFAI Renderer v<version>" plus "(Expired)" after
// RenderMod.ExpirationTime (GetVersionText)
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;
// Returns the entry of array closest to value (first one on ties in array order)
double Snap(double[] array, double value) =>
array.OrderBy(n => Math.Abs(n - value)).First();
}
// ===================================================================================================
// Config <-> UI synchronization and render start
// ===================================================================================================
/// <summary>
/// Replaces unset or invalid top-level values with defaults: 2560x1440 when width/height is 0 or less, 60 FPS when
/// RenderFPS/VideoFPS is 0 or less, 15 seconds when DelayAfterEnd is negative. Other values are not checked.
/// </summary>
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;
}
/// <summary>
/// Writes every value of <paramref name="config"/> (after <see cref="EnsureDefaultConfig"/>) into its control without
/// invoking the change callbacks, refreshes the dependent fields (sample rates, rate control fields) and updates the
/// preset dropdown label. The output directory input is set from <c>GlobalSettings.EffectiveOutputDirectory</c>.
/// </summary>
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);
// Config stores bits per second, the slider shows kbps
_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);
_highResBloomCorrectionToggle?.Set(config.AdvancedConfig.HighResBloomCorrection, false);
_fixCustomFrameRateToggle?.Set(config.AdvancedConfig.FixCustomFrameRate, false);
_disableTileCullingToggle?.Set(config.AdvancedConfig.DisableTileCulling, false);
_hideFailedDecorationsToggle?.Set(config.AdvancedConfig.HideFailedDecorations, false);
// Planet colors: toggle on when an override is stored; the picker shows the stored color or the default and is
// blocked without an override
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);
// The sample rate list depends on the codec. UpdateVideoCodecUI writes the dropdown's resulting value back into
// the config: O5Dropdown.SetValues selects the first listed rate when the current one is not in the new list,
// so a rate the codec does not support becomes the codec's first rate.
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);
}
/// <summary>
/// Go Render button handler: reads the controls into <see cref="UIConfig"/>, validates, saves the global settings,
/// hides the window and starts a <see cref="RenderSession"/> for <c>RenderManager.Instance.levelPath</c>.
/// <para>
/// Validation: width and height must be even, and a non-default output directory must be a valid path; it is
/// converted to a full path and created if missing. On a validation failure an error dialog is shown, the error is
/// logged and the window stays open. The session receives a copy of the config (<c>RenderSession.Start</c>).
/// </para>
/// </summary>
private static void TryGoRender()
{
var config = UIConfig;
SyncUItoConfig(config);
// The encoders use 4:2:0 chroma subsampling, which needs an even width and height.
if (config.RenderWidth % 2 != 0 || config.RenderHeight % 2 != 0)
{
ShowValidationError(LocalizationManager.Get(
"ui.render_config.validation_even_size", config.RenderWidth, config.RenderHeight));
return;
}
// Sync Output Directory: empty or default folder -> null (default); otherwise the full path, created if needed.
// The error message here is fixed English text (the locale key "ui.render_config.invalid_path" is not used).
if (_outputDirInput != null)
{
var path = _outputDirInput.Value.Trim();
if (string.IsNullOrWhiteSpace(path) ||
string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.Ordinal))
{
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);
}
/// <summary>
/// Reads every control into <paramref name="target"/>, applying the same parsing rules as the change callbacks
/// (unparsable or out-of-range numbers keep the previous value; empty seed = null; planet color = picker value only
/// while its toggle is on). Render and video FPS values near 23.976/23.98, 29.97 and 59.94 are replaced by the exact
/// NTSC fractions 24000/1001, 30000/1001 and 60000/1001. The output directory is not handled here.
/// </summary>
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;
if (_highResBloomCorrectionToggle != null) target.AdvancedConfig.HighResBloomCorrection = _highResBloomCorrectionToggle.Value;
if (_fixCustomFrameRateToggle != null) target.AdvancedConfig.FixCustomFrameRate = _fixCustomFrameRateToggle.Value;
if (_disableTileCullingToggle != null) target.AdvancedConfig.DisableTileCulling = _disableTileCullingToggle.Value;
if (_hideFailedDecorationsToggle != null) target.AdvancedConfig.HideFailedDecorations = _hideFailedDecorationsToggle.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;
// Maps rounded NTSC rates to their exact values (N * 1000 / 1001). A value within 0.001 of 29.97, 59.94,
// 23.976 or 23.98 is replaced; any other value is returned unchanged.
// 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;
}
}
// ===================================================================================================
// Preset handling: Custom label, select, save, delete, dropdown reload, language change
// ===================================================================================================
/// <summary>
/// Whether the text is the Custom label of any language. A preset with such a name could not be told apart
/// from the Custom entry of the dropdown after a language change.
/// </summary>
private static bool IsCustomPresetLabel(string text) =>
text == CustomPresetLabel || text == "(Modified)" || text == "(수정됨)";
/// <summary>
/// Dropdown entry shown while the values differ from the last loaded preset, in the current language
/// ("ui.render_config.preset_custom": "(Modified)" in en.json, "(수정됨)" in ko.json).
/// </summary>
private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom");
/// <summary>
/// Shows the Custom label in the preset dropdown: inserts it at index 0 if missing and selects it (without invoking
/// the dropdown callback). Called by every value change callback. Does nothing before the dropdown exists or when the
/// Custom label is already selected.
/// </summary>
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();
}
/// <summary>
/// Preset dropdown callback: loads the selected preset into <paramref name="config"/> and all controls, remembers it
/// as the last preset (saved to the global settings), puts its name in the name input and removes the Custom label.
/// Selecting the Custom label, or a name that is no longer on disk, only disarms the delete button.
/// </summary>
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();
}
/// <summary>
/// Save button handler: reads the controls into <paramref name="config"/> and saves it under the trimmed name from
/// the name input.
/// <list type="bullet">
/// <item>Empty name, or a name equal to a Custom label: a validation dialog is shown, nothing is saved.</item>
/// <item>The name differs from the last loaded preset and an existing preset has the same name or the same file name:
/// an overwrite confirmation dialog is shown; Confirm saves.</item>
/// <item>Otherwise the preset is saved directly (re-saving the loaded preset needs no confirmation).</item>
/// </list>
/// </summary>
private static void OnSavePresetClicked(RenderConfig config)
{
SyncUItoConfig(config);
var name = _presetNameInput?.Value.Trim();
if (string.IsNullOrWhiteSpace(name) || IsCustomPresetLabel(name))
{
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);
}
}
/// <summary>
/// Writes the preset file (<c>PresetManager.SavePreset</c>), stores the name as the last preset in the global
/// settings, and reloads the dropdown with that preset selected (which also removes the Custom label).
/// </summary>
private static void DoSavePreset(string name, RenderConfig config)
{
PresetManager.SavePreset(name, config);
GlobalSettings.Current.LastPresetName = name;
GlobalSettings.Save();
_lastLoadedPresetName = name;
ReloadPresetDropdown(selectName: name);
}
/// <summary>
/// Delete button handler with a two-click confirmation.
/// The target is the selected preset, or the last loaded preset while the Custom label is selected.
/// First click: arms the button (label "Confirm?", width 110 units, color (0.95, 0.25, 0.25)) and starts a
/// 3-second coroutine that disarms it. Second click while armed: disarms and deletes the preset.
/// </summary>
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);
}
/// <summary>
/// Disarms the delete button after 3 seconds. Uses <see cref="WaitForSeconds"/>, i.e. scaled game time, run on
/// RendererUIManager.
/// </summary>
private static System.Collections.IEnumerator ResetDeletePendingAfterDelay()
{
yield return new WaitForSeconds(3f);
ResetDeletePendingState();
}
/// <summary>
/// Disarms the delete button: stops the disarm coroutine, and if the button is armed (or still shows a label other
/// than "Delete") restores the "Delete" label, the 90-unit width and the SoftRed color.
/// </summary>
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();
}
}
/// <summary>
/// Deletes the preset file, then selects the first remaining preset (or none): stores it as the last preset,
/// reloads the dropdown, and loads its values into <paramref name="config"/>, the controls and the name input.
/// With no presets left the config and controls keep their values.
/// </summary>
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);
}
}
/// <summary>
/// Rebuilds the dropdown from the presets on disk and selects <paramref name="selectName"/> (or the first preset
/// when it is null or missing), recording the selection as <see cref="_lastLoadedPresetName"/>. With no presets the
/// dropdown holds only the Custom label and the last preset is cleared. Does not load preset values into the config.
/// </summary>
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();
}
/// <summary>
/// Enables the delete button only when the target preset exists on disk. The target is
/// <paramref name="presetName"/>, else the dropdown value, and the last loaded preset while the Custom label is
/// selected.
/// </summary>
/// <param name="presetName">Explicit target; every current caller passes none.</param>
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);
}
/// <summary>
/// <see cref="LocalizationManager.OnLanguageChanged"/> handler: rebuilds the preset dropdown so that the Custom label
/// appears in the new language. All other texts follow the language through their <see cref="LocalizedText"/> bindings.
/// </summary>
private static void OnLanguageChanged(SystemLanguage language)
{
if (_configWindow == null || _presetDropdown == null) return;
// The preset names are not localized, only the Custom label is. A dropdown value that is not a preset name
// is the Custom label (of the previous language), which is shown only while the values are modified.
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;
}
var current = _presetDropdown.Value;
var isModified = string.IsNullOrEmpty(current) || !names.Contains(current);
_presetDropdown.SetValues(names);
if (isModified)
{
MarkConfigModified();
return;
}
_presetDropdown.Set(current, false);
UpdateDeleteButtonState();
}
/// <summary>
/// Version label text: "ADOFAI Renderer v" + the MelonInfo version, followed by the localized "(Expired)" once the
/// current UTC time reaches <c>RenderMod.ExpirationTime</c>.
/// </summary>
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;
}
/// <summary>
/// Logs <paramref name="message"/> as an error and shows it in a dialog titled "Invalid Settings" (localized) with a
/// single "OK" button (fixed English text).
/// </summary>
private static void ShowValidationError(string message)
{
Melon<RenderMod>.Logger.Error(message);
CommonWarningDialogUI.ShowDialog(
LocalizationManager.Get("ui.render_config.validation_title"),
message,
new DialogButton("OK", () => { })
);
}
// ===================================================================================================
// Layout helpers: column panels, field slots, cards
// ===================================================================================================
/// <summary>
/// Creates one multi-column container for a card (or the top section) and returns its columns. Fields are stacked
/// vertically inside each column, so one panel holds all rows of a card.
/// <list type="bullet">
/// <item>The panel is a <see cref="HorizontalLayoutGroup"/> that stretches to the parent width; the columns are
/// <paramref name="spacing"/> units apart.</item>
/// <item>Every column has preferredWidth 0 and flexibleWidth 1 (see <see cref="CreateColumn"/>), so the width is
/// always split equally (1:1:...:1) regardless of the label or value text lengths, and the column boundaries are at
/// the same x position on every row.</item>
/// <item>Rows line up across columns when the fields in the same row have the same height: put each field in a
/// <see cref="CreateFieldSlot"/> (label 18 + spacing 3 + control; dropdown controls are 50 units high).</item>
/// </list>
/// </summary>
/// <param name="parent">Card content or scroll content.</param>
/// <param name="columnCount">Number of columns, at least 1.</param>
/// <param name="spacing">Horizontal gap between columns, in units.</param>
/// <param name="columnSpacing">Vertical gap between fields inside a column, in units.</param>
/// <returns>The column rects, left to right.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="columnCount"/> is less than 1.</exception>
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;
}
/// <summary>Two-column form of <see cref="CreateMultiColumnPanel"/>; returns the left and right column.</summary>
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]);
}
/// <summary>
/// Creates one column: a <see cref="VerticalLayoutGroup"/> (children stretched to the column width, <paramref name="spacing"/>
/// units apart) with a layout element of min/preferred width 0 and flexible width 1.
/// </summary>
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
// Preferred width 0 with flexible width 1: the HorizontalLayoutGroup distributes the whole width through the
// flexible weights, so every column gets an exact equal share. With non-zero preferred widths (which depend on
// the label and value text lengths) the split would differ from row to row.
var colLE = colObj.AddComponent<LayoutElement>();
colLE.minWidth = 0f;
colLE.preferredWidth = 0f;
colLE.flexibleWidth = 1f;
return colRect;
}
/// <summary>
/// Creates a field slot ("Field") in a column: a vertical stack with 3 units between its children (typically a label
/// and a control), stretched to the column width. The slot's height is the sum of its children's heights.
/// </summary>
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;
}
/// <summary>
/// Creates a collapsible O5Kit card (<c>O5Factory.Card</c>) with a localized title. The title text
/// (child "Header/TitleTrigger/TitleText", or any descendant named "TitleText") is set to 18 pt and bound to
/// <paramref name="titleKey"/> so it follows language changes. The remaining parameters are passed to O5Kit unchanged.
/// </summary>
/// <returns>The card root and the content rect that receives the card's fields.</returns>
// 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);
}
// ===================================================================================================
// Control factory helpers (each binds its texts to localization keys through LocalizedText)
// ===================================================================================================
/// <summary>
/// Creates a localized text label (default 13 pt, theme dim text color unless <paramref name="color"/> is given).
/// </summary>
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;
}
/// <summary>
/// Creates an O5Kit slider with a localized label (the slider's own label; no separate label row).
/// </summary>
/// <param name="ctx">O5Kit context.</param>
/// <param name="parent">Field slot or column that receives the slider.</param>
/// <param name="labelKey">Localization key of the slider label.</param>
/// <param name="id">O5Kit control id.</param>
/// <param name="defaultValue">Value restored by middle click.</param>
/// <param name="min">Minimum value.</param>
/// <param name="max">Maximum value.</param>
/// <param name="value">Initial value.</param>
/// <param name="format">Numeric display format, e.g. "F0", "F3".</param>
/// <param name="clampMode">How values outside min..max are clamped (O5Kit <see cref="ClampMode"/>).</param>
/// <param name="sliderFilter">Applied to dragged values (used for snapping); typed values are not filtered by it.</param>
/// <param name="inputFilter">Applied to values typed into the slider's input box.</param>
/// <param name="onChanged">Called with the new value on every change.</param>
/// <param name="onComplete">Called when an edit is completed.</param>
/// <param name="showFill">Whether the filled part of the track is drawn.</param>
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;
}
/// <summary>
/// Creates an O5Kit button with a localized label. <paramref name="height"/> null uses the theme control height.
/// </summary>
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;
}
/// <summary>Same as the CreateInputField overload with an <c>out</c> label parameter, without returning the label.</summary>
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);
}
/// <summary>
/// Creates a labeled text input in <paramref name="colParent"/> (normally a field slot): an 18-unit label row and an
/// O5Kit input below it. The label and the placeholder are both bound to <paramref name="labelKey"/>.
/// </summary>
/// <param name="ctx">O5Kit context.</param>
/// <param name="colParent">Field slot or column that receives the label and the input.</param>
/// <param name="labelKey">Localization key of the label and the placeholder.</param>
/// <param name="defaultValue">Value restored by middle click; <c>null</c> for none.</param>
/// <param name="value">Initial text.</param>
/// <param name="id">O5Kit control id.</param>
/// <param name="label">Receives the label text component (used to dim the label of a blocked field).</param>
/// <param name="characterValidation">TextMeshPro character filter for typed text, e.g. Integer.</param>
/// <param name="onChanged">Called with the raw text on every change; parsing and validation are up to the caller.</param>
/// <returns>The input field.</returns>
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)
{
// Labeled text input: an 18-unit label row (13 pt, dim) above an O5Kit input. The label key also serves as the
// placeholder. characterValidation restricts typed characters (e.g. Integer). onChanged receives the raw text.
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;
}
// ===================================================================================================
// Dependent field rules (rate control, codec)
// ===================================================================================================
/// <summary>
/// Blocks or unblocks an input and dims its label: a blocked label uses the theme dim text color at 40% of its
/// alpha. The label color changes with a 0.2-second tween (any running tween for that label is killed first), or
/// immediately with <paramref name="noAnimate"/>.
/// </summary>
/// <param name="input">Input to block; may be null.</param>
/// <param name="label">Label above the input; may be null.</param>
/// <param name="tweenHandle">Stored tween handle of this label, replaced by the new tween.</param>
/// <param name="blocked">True to block (disable) the field.</param>
/// <param name="noAnimate">True to apply the change without animation.</param>
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);
}
}
/// <summary>
/// Enables only the video fields used by the rate control mode:
/// CBR - Bitrate; VBR - Bitrate and Max Bitrate; CQP - QP. Values of blocked fields stay in the config.
/// </summary>
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);
}
/// <summary>
/// Replaces the sample rate choices with those supported by the audio codec paired with <paramref name="codec"/>.
/// O5Dropdown.SetValues keeps the current rate when it is in the new list and otherwise selects the first entry;
/// the resulting rate is written to <paramref name="config"/> when one is given.
/// </summary>
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;
}
}
// ===================================================================================================
// More control factory helpers
// ===================================================================================================
/// <summary>
/// Creates a labeled text input that spans the full width of <paramref name="parent"/>: its own vertical container
/// with an 18-unit label row, 3 units of spacing and the input. The label key also serves as the placeholder.
/// </summary>
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;
}
/// <summary>
/// Creates an O5Kit toggle whose text is localized with <paramref name="toggleTextKey"/>. With
/// <paramref name="generateLabel"/> an 18-unit label row is added above it (text from <paramref name="labelKey"/>,
/// empty when the key is empty), which keeps the toggle aligned with labeled fields in the neighboring column.
/// </summary>
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;
}
/// <summary>
/// Creates a labeled dropdown: an 18-unit label row and a container of preferred height 50 units holding the O5Kit
/// dropdown. <paramref name="display"/> converts values to their shown text. The change callback is not invoked for
/// a null value.
/// </summary>
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
);
}
/// <summary>
/// Creates an O5Kit color picker in a container one control row high (theme control height for the body font).
/// The picker's popup is positioned on the window manager canvas. <paramref name="onChanged"/> is used both while
/// editing and on completion. The header is re-proportioned for narrow (three-column) layouts, see below.
/// </summary>
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
);
// Header proportions and font size for multi-column (e.g. three-column) layouts.
// O5Kit's header layout (anchor x ranges: Preview 0..0.5, ToggleArea 0..0.6, HexInput 0.62..1.0; hex font size 19)
// leaves too little width in a narrow column, and the start of the 8-digit RGBA hex text is cut off.
// Here: Preview 0..0.28, ToggleArea 0..0.30, HexInput 0.32..1.0, hex font size 15.
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;
}
// ===================================================================================================
// Window visibility
// ===================================================================================================
/// <summary>Shows or hides the window; hiding first collapses all open color pickers.</summary>
private static void SetWindowActive(bool enabled)
{
if (!enabled)
{
CloseAllPickers();
}
_configWindow?.Rect.gameObject.SetActive(enabled);
}
/// <summary>Collapses the three planet color pickers without animation.</summary>
private static void CloseAllPickers()
{
_firePlanetPicker?.SetExpanded(false, true);
_icePlanetPicker?.SetExpanded(false, true);
_windPlanetPicker?.SetExpanded(false, true);
}
}