diff --git a/Data/RenderConfig.cs b/Data/RenderConfig.cs index 99f2731..df88ef3 100644 --- a/Data/RenderConfig.cs +++ b/Data/RenderConfig.cs @@ -11,6 +11,11 @@ namespace ADOFAIRenderer.Data; [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] public class RenderConfig { + public enum RenderOutputMode + { + Video, PNGSequence + } + /// /// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame). /// Values above render extra frames between video frames, which are not captured @@ -43,6 +48,9 @@ public class RenderConfig /// Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false. public bool ShowJudgementText { get; set; } = false; + + /// Determines whether to export the rendered output as an encoded video or a PNG image sequence. + public RenderOutputMode OutputMode { get; set; } = RenderOutputMode.Video; /// /// Copies every value of into this object; the nested configs are copied value by value @@ -57,6 +65,7 @@ public class RenderConfig RenderHeight = source.RenderHeight; DelayAfterEnd = source.DelayAfterEnd; ShowJudgementText = source.ShowJudgementText; + OutputMode = source.OutputMode; VideoConfig.CopyFrom(source.VideoConfig); AudioConfig.CopyFrom(source.AudioConfig); diff --git a/Localization/Locale/en.json b/Localization/Locale/en.json index 39e42d9..2f92f46 100644 --- a/Localization/Locale/en.json +++ b/Localization/Locale/en.json @@ -36,6 +36,9 @@ "ui.render_config.sample_rate": "Sample Rate (Hz)", "ui.render_config.channels": "Audio Channels", "ui.render_config.audio_bitrate": "Audio Bitrate (kbps)", + "ui.render_config.output_mode": "Output Mode", + "ui.render_config.output_mode_video": "Video", + "ui.render_config.output_mode_png_sequence": "PNG Sequence", "ui.render_config.output_dir": "Output Directory", "ui.render_config.browse": "Browse...", "ui.render_config.reset_default": "Reset to Default", diff --git a/Localization/Locale/ko.json b/Localization/Locale/ko.json index ee2c0c7..6c9c218 100644 --- a/Localization/Locale/ko.json +++ b/Localization/Locale/ko.json @@ -36,6 +36,9 @@ "ui.render_config.sample_rate": "샘플레이트 (Hz)", "ui.render_config.channels": "오디오 채널", "ui.render_config.audio_bitrate": "오디오 비트레이트 (kbps)", + "ui.render_config.output_mode": "출력 모드", + "ui.render_config.output_mode_video": "동영상", + "ui.render_config.output_mode_png_sequence": "PNG 시퀀스", "ui.render_config.output_dir": "출력 디렉토리", "ui.render_config.browse": "찾아보기...", "ui.render_config.reset_default": "기본값으로 초기화", diff --git a/UI/RendererUIManager.cs b/UI/RendererUIManager.cs index 267d963..2a6ebc9 100644 --- a/UI/RendererUIManager.cs +++ b/UI/RendererUIManager.cs @@ -135,6 +135,7 @@ internal class RendererUIManager : MonoBehaviour ObjectActiveBright = new Color(1.000f, 0.294f, 0.079f, 1f), ControlOutlineIdle = new Color(1f, 1f, 1f, 0.4f), ButtonHover = new Color(0.133f, 0.133f, 0.133f, 1.0f), + ButtonPressed = new Color(0.133f, 0.133f, 0.133f, 1.0f), ControlOutlineHover = new Color(1f, 1f, 1f, 0.4f), CardHeader = new Color(0.157f, 0f, 0f, 1f), CardPanel = new Color(0, 0, 0, 0f), diff --git a/UI/SegmentedControl.cs b/UI/SegmentedControl.cs new file mode 100644 index 0000000..39e31a6 --- /dev/null +++ b/UI/SegmentedControl.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using O5Kit.Behaviour; +using O5Kit.Core; +using TMPro; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace ADOFAIRenderer.UI; + +/// +/// Definition of a single option within a . +/// Supports static strings, dynamic providers, or localization keys. +/// +internal class SegmentOption +{ + public T Value { get; set; } + public string? LabelKey { get; set; } + public Func? DynamicLabel { get; set; } + public string? StaticLabel { get; set; } + + public SegmentOption(T value, string labelKey) + { + Value = value; + LabelKey = labelKey; + } + + public SegmentOption(T value, Func dynamicLabel) + { + Value = value; + DynamicLabel = dynamicLabel; + } + + public SegmentOption(T value, string? labelKey = null, string? staticLabel = null) + { + Value = value; + LabelKey = labelKey; + StaticLabel = staticLabel; + } + + public static implicit operator SegmentOption((T value, string labelKey) tuple) + => new(tuple.value, tuple.labelKey); +} + +/// +/// A generalized rounded segmented button group / picker UI component. +/// Visual styling, colors, and dimensions are pulled directly from (matching ). +/// Automatically updates localized labels via . +/// +internal class SegmentedControl : O5Object +{ + private readonly List _items = new(); + private T _value; + private bool _blocked; + + public T Value => _value; + public T DefaultValue { get; } + public IReadOnlyList> Options { get; } + public Action? OnChanged { get; set; } + + /// Resting background color for unselected segments (defaults to Theme.ObjectButton). + public Color NormalColor { get; set; } + + /// Resting background color for the selected segment (defaults to Theme.ObjectActive). + public Color ActiveColor { get; set; } + + /// Border and outline color (defaults to Theme.ControlOutlineIdle). + public Color BorderColor { get; set; } + + private class SegmentItem + { + public SegmentOption Option { get; } + public GameObject RootObject { get; } + public Image Background { get; } + public TextMeshProUGUI Label { get; } + public ITweenHandle? HoverTween { get; set; } + public bool Hovered { get; set; } + public bool Pressed { get; set; } + + public SegmentItem(SegmentOption option, GameObject root, Image bg, TextMeshProUGUI label) + { + Option = option; + RootObject = root; + Background = bg; + Label = label; + } + + public void Tint(O5Context ctx, Color target, float duration) + { + HoverTween?.Kill(); + var bg = Background; + HoverTween = ctx.Tween.TweenColor(() => bg.color, v => bg.color = v, target, duration); + } + } + + public SegmentedControl( + O5Context ctx, + string id, + RectTransform rect, + T defaultValue, + T initialValue, + IReadOnlyList> options, + Action? onChanged = null) + : base(ctx, id, rect) + { + DefaultValue = defaultValue; + _value = initialValue; + Options = options; + OnChanged = onChanged; + + // Pull colors directly from O5Theme, identical to O5Button / O5Toggle + NormalColor = Ctx.Theme.ObjectButton; + ActiveColor = Ctx.Theme.ObjectActive; + BorderColor = Ctx.Theme.ControlOutlineIdle; + + BuildSegments(); + UpdateVisual(true); + } + + /// + /// Creates and attaches a new rounded to . + /// Height matches standard buttons and sliders (). + /// + public static SegmentedControl Create( + O5Context ctx, + Transform parent, + string id, + T defaultValue, + T initialValue, + IReadOnlyList> options, + float? height = null, + Action? onChanged = null) + { + var h = height ?? ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody); + var controlObj = new GameObject($"SegmentedControl_{id}"); + controlObj.transform.SetParent(parent, false); + + var rect = controlObj.AddComponent(); + var le = controlObj.AddComponent(); + le.minHeight = h; + le.preferredHeight = h; + le.flexibleWidth = 1f; + + return new SegmentedControl(ctx, id, rect, defaultValue, initialValue, options, onChanged); + } + + private void BuildSegments() + { + var theme = Ctx.Theme; + + // 1. Masked container for rounded corners + var maskObj = new GameObject("MaskContainer"); + maskObj.transform.SetParent(Rect, false); + + var maskRect = maskObj.AddComponent(); + maskRect.anchorMin = Vector2.zero; + maskRect.anchorMax = Vector2.one; + maskRect.offsetMin = Vector2.zero; + maskRect.offsetMax = Vector2.zero; + + var maskImg = maskObj.AddComponent(); + maskImg.sprite = Ctx.Sprites.RoundedControl; + maskImg.type = Image.Type.Sliced; + maskImg.color = NormalColor; + + var mask = maskObj.AddComponent(); + mask.showMaskGraphic = true; + + // Continuous horizontal layout with no middle divider line + var layout = maskObj.AddComponent(); + layout.padding = new RectOffset(0, 0, 0, 0); + layout.spacing = 0f; + layout.childControlWidth = true; + layout.childControlHeight = true; + layout.childForceExpandWidth = true; + layout.childForceExpandHeight = true; + + for (var i = 0; i < Options.Count; i++) + { + var opt = Options[i]; + var segmentObj = new GameObject($"Segment_{i}_{opt.Value}"); + segmentObj.transform.SetParent(maskRect, false); + + var segRect = segmentObj.AddComponent(); + var segLE = segmentObj.AddComponent(); + segLE.minWidth = 0f; + segLE.preferredWidth = 0f; + segLE.flexibleWidth = 1f; + + var bgImage = segmentObj.AddComponent(); + bgImage.color = NormalColor; + bgImage.raycastTarget = true; + + var textObj = new GameObject("Text"); + textObj.transform.SetParent(segmentObj.transform, false); + + var textRect = textObj.AddComponent(); + textRect.anchorMin = Vector2.zero; + textRect.anchorMax = Vector2.one; + textRect.offsetMin = Vector2.zero; + textRect.offsetMax = Vector2.zero; + + var tmp = textObj.AddComponent(); + tmp.font = Ctx.Fonts.Regular; + tmp.fontSize = theme.FontSizeBody; + tmp.alignment = TextAlignmentOptions.Center; + tmp.characterSpacing = -1f; + tmp.raycastTarget = false; + + // Bind localization + if (!string.IsNullOrEmpty(opt.LabelKey)) + { + LocalizedText.Bind(tmp, opt.LabelKey); + } + else if (opt.DynamicLabel != null) + { + LocalizedText.Bind(tmp, opt.DynamicLabel); + } + else + { + tmp.text = opt.StaticLabel ?? opt.Value?.ToString() ?? string.Empty; + } + + var item = new SegmentItem(opt, segmentObj, bgImage, tmp); + _items.Add(item); + + // Wire pointer events like O5Button + var trigger = segmentObj.AddComponent(); + UnityUtils.AddEvents(trigger, + (EventTriggerType.PointerEnter, _ => OnSegmentHoverEnter(item)), + (EventTriggerType.PointerExit, _ => OnSegmentHoverExit(item)), + (EventTriggerType.PointerDown, e => + { + if (IsLeftPress(e)) + { + OnSegmentPressEnter(item); + } + }), + (EventTriggerType.PointerUp, _ => OnSegmentPressExit(item)), + (EventTriggerType.PointerClick, e => + { + if (IsLeftPress(e)) + { + SelectOption(item.Option.Value); + } + }) + ); + } + + // 2. Rounded outline overlay on top of masked container + var outlineObj = new GameObject("Outline"); + outlineObj.transform.SetParent(Rect, false); + + var outlineRect = outlineObj.AddComponent(); + outlineRect.anchorMin = Vector2.zero; + outlineRect.anchorMax = Vector2.one; + outlineRect.offsetMin = Vector2.zero; + outlineRect.offsetMax = Vector2.zero; + + var outlineImg = outlineObj.AddComponent(); + outlineImg.sprite = Ctx.Sprites.RoundedOutline; + outlineImg.type = Image.Type.Sliced; + outlineImg.color = BorderColor; + outlineImg.raycastTarget = false; + } + + private static bool IsLeftPress(BaseEventData e) => + e is PointerEventData ped && ped.button == PointerEventData.InputButton.Left; + + private void OnSegmentHoverEnter(SegmentItem item) + { + if (IsDisposed || _blocked) return; + item.Hovered = true; + if (item.Pressed) return; + + var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); + var targetColor = isSelected ? Ctx.Theme.ObjectActiveBright : Ctx.Theme.ButtonHover; + item.Tint(Ctx, targetColor, 0.12f); + } + + private void OnSegmentHoverExit(SegmentItem item) + { + if (IsDisposed) return; + item.Hovered = false; + if (item.Pressed) return; + + var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); + var targetColor = isSelected ? ActiveColor : NormalColor; + item.Tint(Ctx, targetColor, 0.12f); + } + + private void OnSegmentPressEnter(SegmentItem item) + { + if (IsDisposed || _blocked) return; + item.Pressed = true; + var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); + var targetColor = isSelected ? Ctx.Theme.ObjectActiveBright : Ctx.Theme.ButtonHover; + item.Tint(Ctx, targetColor, 0.08f); + } + + private void OnSegmentPressExit(SegmentItem item) + { + if (IsDisposed || !item.Pressed) return; + item.Pressed = false; + + var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); + Color settle; + if (isSelected) + { + settle = item.Hovered ? Ctx.Theme.ObjectActiveBright : ActiveColor; + } + else + { + settle = item.Hovered ? Ctx.Theme.ButtonHover : NormalColor; + } + item.Tint(Ctx, settle, 0.12f); + } + + public void SelectOption(T newValue, bool notify = true) + { + if (IsDisposed) return; + if (EqualityComparer.Default.Equals(_value, newValue)) return; + + _value = newValue; + UpdateVisual(false); + + if (notify) + { + OnChanged?.Invoke(_value); + } + } + + public void Set(T value, bool notify = true) + { + if (IsDisposed) return; + if (EqualityComparer.Default.Equals(_value, value)) return; + + _value = value; + UpdateVisual(false); + + if (notify) + { + OnChanged?.Invoke(_value); + } + } + + public void Reset(bool notify = true) + { + Set(DefaultValue, notify); + } + + public override void SetBlocked(bool blocked, bool noAnimate = false) + { + _blocked = blocked; + base.SetBlocked(blocked, noAnimate); + } + + public void UpdateVisual(bool noAnimate = false) + { + if (IsDisposed) return; + + foreach (var item in _items) + { + item.Pressed = false; + var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); + Color targetBg; + if (isSelected) + { + targetBg = item.Hovered ? Ctx.Theme.ObjectActiveBright : ActiveColor; + item.Label.color = Ctx.Theme.Text; + item.Label.fontStyle = FontStyles.Bold; + } + else + { + targetBg = item.Hovered ? Ctx.Theme.ButtonHover : NormalColor; + item.Label.color = Ctx.Theme.TextDim; + item.Label.fontStyle = FontStyles.Normal; + } + + if (noAnimate) + { + item.HoverTween?.Kill(); + item.Background.color = targetBg; + } + else + { + item.Tint(Ctx, targetBg, 0.15f); + } + } + } + + public override void Dispose() + { + if (IsDisposed) return; + + foreach (var item in _items) + { + item.HoverTween?.Kill(); + item.HoverTween = null; + } + OnChanged = null; + base.Dispose(); + } +} diff --git a/UI/Window/RenderConfigWindow.cs b/UI/Window/RenderConfigWindow.cs index 9b0d3e9..c48a3ac 100644 --- a/UI/Window/RenderConfigWindow.cs +++ b/UI/Window/RenderConfigWindow.cs @@ -13,6 +13,7 @@ using Newtonsoft.Json; using O5Kit.Control; using O5Kit.Core; using O5Kit.Factory; +using TMPro; using UnityEngine; using UnityEngine.UI; @@ -105,25 +106,34 @@ internal static class RenderConfigWindow private static O5Slider? _renderHeightSlider; private static O5Slider? _delayAfterEndSlider; private static O5Toggle? _showJudgementToggle; + private static SegmentedControl? _outputModeControl; private static O5Dropdown? _codecDropdown; + private static TextMeshProUGUI? _codecLabel; + private static ITweenHandle? _codecLabelTween; + private static O5Dropdown? _rateControlDropdown; + private static TextMeshProUGUI? _rateControlLabel; + private static ITweenHandle? _rateControlLabelTween; // 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 TextMeshProUGUI? _bitrateLabel; private static ITweenHandle? _bitrateLabelTween; private static O5InputField? _maxBitrateInput; - private static TMPro.TextMeshProUGUI? _maxBitrateLabel; + private static TextMeshProUGUI? _maxBitrateLabel; private static ITweenHandle? _maxBitrateLabelTween; private static O5InputField? _qpInput; - private static TMPro.TextMeshProUGUI? _qpLabel; + private static TextMeshProUGUI? _qpLabel; private static ITweenHandle? _qpLabelTween; - - private static O5Slider? _keyframeIntervalSlider; + private static O5Toggle? _forceSoftwareToggle; + private static TextMeshProUGUI? _forceSoftwareLabel; + private static ITweenHandle? _forceSoftwareLabelTween; + + private static O5Slider? _keyframeIntervalSlider; private static O5Toggle? _renderAudioToggle; private static O5Dropdown? _sampleRateDropdown; @@ -376,7 +386,8 @@ internal static class RenderConfigWindow var redTheme = ctx.Theme with { ObjectButton = ctx.Theme.SoftRed, - ButtonHover = ctx.Theme.SoftRed * 0.8f + ButtonHover = ctx.Theme.SoftRed * 0.8f, + ButtonPressed = ctx.Theme.SoftRed * 0.8f }; var redCtx = new O5Context( config: ctx.Config, @@ -421,7 +432,7 @@ internal static class RenderConfigWindow _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, + defaultValue: 60, min: 0.001, max: 3000, value: 60, format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(renderFpsSnap, value), onChanged: val => { config.RenderFPS = val; MarkConfigModified(); } @@ -431,7 +442,7 @@ internal static class RenderConfigWindow _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, + defaultValue: 60, min: 0.001, max: 600, value: 60, format: "F3", clampMode: ClampMode.All, sliderFilter: value => Snap(videoFpsSnap, value), onChanged: val => { config.VideoFPS = val; MarkConfigModified(); } @@ -478,6 +489,28 @@ internal static class RenderConfigWindow onChanged: val => { config.ShowJudgementText = val; MarkConfigModified(); }, generateLabel: false ); + + // Output Mode (RenderConfig.OutputMode): Video vs PNG Sequence + _outputModeControl = CreateSegmentedField( + ctx: ctx, + parent: scrollContent, + labelKey: "ui.render_config.output_mode", + defaultValue: RenderConfig.RenderOutputMode.Video, + value: config.OutputMode, + options: new SegmentOption[] + { + new(RenderConfig.RenderOutputMode.Video, "ui.render_config.output_mode_video"), + new(RenderConfig.RenderOutputMode.PNGSequence, "ui.render_config.output_mode_png_sequence") + }, + id: "seg_output_mode", + onChanged: val => + { + config.OutputMode = val; + UpdateUIBlock(config); + MarkConfigModified(); + } + ); + // --------------------------------------------------------------------------------------------- // Section 3: Video Encoding card (RenderConfig.VideoConfig) @@ -502,11 +535,11 @@ internal static class RenderConfigWindow "ui.render_config.codec", Codec.H264, config.VideoConfig.Codec, codecs, c => c.ToString(), - "dd_video_codec", + "dd_video_codec", out _codecLabel, c => { config.VideoConfig.Codec = c; - UpdateVideoCodecUI(c, config); + UpdateSampleRateUI(c, config); MarkConfigModified(); }); @@ -517,7 +550,7 @@ internal static class RenderConfigWindow "ui.render_config.bitrate", "30000", config.VideoConfig.Bitrate.ToString(), "input_bitrate", out _bitrateLabel, - TMPro.TMP_InputField.CharacterValidation.Integer, + TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; MarkConfigModified(); } ); @@ -526,7 +559,7 @@ internal static class RenderConfigWindow "ui.render_config.qp", "18", config.VideoConfig.QP.ToString(), "input_qp", out _qpLabel, - TMPro.TMP_InputField.CharacterValidation.Integer, + TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; MarkConfigModified(); } ); @@ -536,11 +569,11 @@ internal static class RenderConfigWindow "ui.render_config.rate_control", RateControlMode.CBR, config.VideoConfig.RateControlMode, rateModes, m => m.ToString(), - "dd_rate_control", + "dd_rate_control", out _rateControlLabel, m => { config.VideoConfig.RateControlMode = m; - UpdateRateControlUI(m); + UpdateUIBlock(config); MarkConfigModified(); } ); @@ -550,7 +583,7 @@ internal static class RenderConfigWindow "ui.render_config.max_bitrate", "50000", config.VideoConfig.MaxBitrate.ToString(), "input_max_bitrate", out _maxBitrateLabel, - TMPro.TMP_InputField.CharacterValidation.Integer, + TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; MarkConfigModified(); } ); @@ -560,7 +593,7 @@ internal static class RenderConfigWindow defaultValue: false, value: config.VideoConfig.ForceSoftwareEncoder, toggleTextKey: "ui.render_config.force_software", - id: "toggle_force_software", + id: "toggle_force_software", out _forceSoftwareLabel, onChanged: val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); }, generateLabel: true ); @@ -826,7 +859,7 @@ internal static class RenderConfigWindow "ui.render_config.random_seed", "", config.AdvancedConfig.RandomSeed?.ToString() ?? "", "input_random_seed", - TMPro.TMP_InputField.CharacterValidation.Integer, + TMP_InputField.CharacterValidation.Integer, val => { if (int.TryParse(val, out var s)) config.AdvancedConfig.RandomSeed = s; @@ -1021,7 +1054,7 @@ internal static class RenderConfigWindow // RenderMod.ExpirationTime (GetVersionText) var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true); LocalizedText.Bind(versionLabel, GetVersionText); - versionLabel.alignment = TMPro.TextAlignmentOptions.Center; + versionLabel.alignment = TextAlignmentOptions.Center; versionLabel.color = ctx.Theme.TextDim; // ReSharper disable once InconsistentNaming @@ -1071,6 +1104,7 @@ internal static class RenderConfigWindow _renderHeightSlider?.Set(config.RenderHeight, false, noFilter: true); _delayAfterEndSlider?.Set(config.DelayAfterEnd, false, noFilter: true); _showJudgementToggle?.Set(config.ShowJudgementText, false); + _outputModeControl?.Set(config.OutputMode, false); _codecDropdown?.Set(config.VideoConfig.Codec, false); _rateControlDropdown?.Set(config.VideoConfig.RateControlMode, false); @@ -1118,8 +1152,8 @@ internal static class RenderConfigWindow // 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); + UpdateSampleRateUI(config.VideoConfig.Codec, config); + UpdateUIBlock(config, noAnimate: true); UpdatePresetLabel(config); } @@ -1252,6 +1286,7 @@ internal static class RenderConfigWindow 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 (_outputModeControl != null) target.OutputMode = _outputModeControl.Value; if (_codecDropdown != null) target.VideoConfig.Codec = _codecDropdown.Value; if (_rateControlDropdown != null) target.VideoConfig.RateControlMode = _rateControlDropdown.Value; @@ -1721,10 +1756,8 @@ internal static class RenderConfigWindow 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; + hLayout.childControlWidth = hLayout.childControlHeight = true; + hLayout.childForceExpandWidth = true; hLayout.childForceExpandHeight = false; // ReSharper disable once InconsistentNaming var panelLE = panelObj.AddComponent(); @@ -1830,8 +1863,8 @@ internal static class RenderConfigWindow showActiveToggle ); - var titleTmp = card.Find("Header/TitleTrigger/TitleText")?.GetComponent() - ?? card.GetComponentsInChildren(true).FirstOrDefault(t => t.gameObject.name == "TitleText"); + var titleTmp = card.Find("Header/TitleTrigger/TitleText")?.GetComponent() + ?? card.GetComponentsInChildren(true).FirstOrDefault(t => t.gameObject.name == "TitleText"); if (titleTmp) { titleTmp.fontSize = 18f; @@ -1848,7 +1881,7 @@ internal static class RenderConfigWindow /// /// Creates a localized text label (default 13 pt, theme dim text color unless is given). /// - private static TMPro.TextMeshProUGUI CreateLabel( + private static TextMeshProUGUI CreateLabel( O5Context ctx, Transform parent, string textKey, @@ -1960,7 +1993,7 @@ internal static class RenderConfigWindow string? defaultValue, string value, string id, - TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, + TMP_InputField.CharacterValidation characterValidation = TMP_InputField.CharacterValidation.None, Action? onChanged = null) { return CreateInputField(ctx, colParent, labelKey, defaultValue, value, id, out _, characterValidation, onChanged); @@ -1987,8 +2020,8 @@ internal static class RenderConfigWindow string? defaultValue, string value, string id, - out TMPro.TextMeshProUGUI label, - TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, + out TextMeshProUGUI label, + TMP_InputField.CharacterValidation characterValidation = TMP_InputField.CharacterValidation.None, Action? onChanged = null) { // Labeled text input: an 18-unit label row (13 pt, dim) above an O5Kit input. The label key also serves as the @@ -2026,28 +2059,48 @@ internal static class RenderConfigWindow // =================================================================================================== // Dependent field rules (rate control, codec) // =================================================================================================== - - /// - /// 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 . - /// - /// Input to block; may be null. - /// Label above the input; may be null. - /// Stored tween handle of this label, replaced by the new tween. - /// True to block (disable) the field. - /// True to apply the change without animation. + private static void SetFieldBlocked( O5InputField? input, - TMPro.TextMeshProUGUI? label, + TextMeshProUGUI? label, ref ITweenHandle? tweenHandle, bool blocked, bool noAnimate = false) { input?.SetBlocked(blocked, noAnimate); - + if (label == null) return; + UpdateLabelDim(label, ref tweenHandle, blocked, noAnimate); + } + private static void SetToggleBlocked( + O5Toggle? toggle, + TextMeshProUGUI? label, + ref ITweenHandle? tweenHandle, + bool blocked, + bool noAnimate = false) + { + toggle?.SetBlocked(blocked, noAnimate); + + if (label == null) return; + UpdateLabelDim(label, ref tweenHandle, blocked, noAnimate); + } + + private static void SetDropdownBlocked( + O5Dropdown? dropdown, + TextMeshProUGUI? label, + ref ITweenHandle? tweenHandle, + bool blocked, + bool noAnimate = false) + { + dropdown?.SetBlocked(blocked, noAnimate); + + if (label == null) return; + UpdateLabelDim(label, ref tweenHandle, blocked, noAnimate); + } + + private static void UpdateLabelDim(TextMeshProUGUI label, ref ITweenHandle? tweenHandle, bool blocked, bool noAnimate = false) + { tweenHandle?.Kill(); var ctx = RendererUIManager.Context; var baseColor = ctx.Theme.TextDim; @@ -2055,41 +2108,20 @@ internal static class RenderConfigWindow ? 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); - } + if (noAnimate) label.color = targetColor; + else tweenHandle = ctx.Tween.TweenColor(label, targetColor, 0.2f); } /// - /// 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. - /// - 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); - } - - /// - /// Replaces the sample rate choices with those supported by the audio codec paired with . + /// Replaces the sample rate choices with those supported by the audio codec paired with . /// 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 when one is given. /// - private static void UpdateVideoCodecUI(Codec codec, RenderConfig? config = null) + private static void UpdateSampleRateUI(Codec videoCodec, RenderConfig? config = null) { if (_sampleRateDropdown == null) return; - _sampleRateDropdown.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]); + _sampleRateDropdown.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[videoCodec]]); if (config != null && _sampleRateDropdown.Value is var currentRate) { @@ -2097,6 +2129,35 @@ internal static class RenderConfigWindow } } + private static void UpdateUIBlock(RenderConfig config, bool noAnimate = false) + { + var blockCodecDropdown = false; + var blockRateControlModeDropdown = false; + var blockBitrateInput = config.VideoConfig.RateControlMode is RateControlMode.CQP; + var blockMaxBitrateInput = config.VideoConfig.RateControlMode is RateControlMode.CBR or RateControlMode.CQP; + // ReSharper disable once InconsistentNaming + var blockQPInput = config.VideoConfig.RateControlMode is RateControlMode.CBR or RateControlMode.VBR; + var blockForceSoftwareToggle = false; + var blockKeyframeIntervalSlider = false; + + var blockVideoEncodingSettings = config.OutputMode is RenderConfig.RenderOutputMode.PNGSequence; + blockCodecDropdown |= blockVideoEncodingSettings; + blockRateControlModeDropdown |= blockVideoEncodingSettings; + blockBitrateInput |= blockVideoEncodingSettings; + blockMaxBitrateInput |= blockVideoEncodingSettings; + blockQPInput |= blockVideoEncodingSettings; + blockForceSoftwareToggle |= blockVideoEncodingSettings; + blockKeyframeIntervalSlider |= blockVideoEncodingSettings; + + SetDropdownBlocked(_codecDropdown, _codecLabel, ref _codecLabelTween, blockCodecDropdown, noAnimate); + SetDropdownBlocked(_rateControlDropdown, _rateControlLabel, ref _rateControlLabelTween, blockRateControlModeDropdown, noAnimate); + SetFieldBlocked(_bitrateInput, _bitrateLabel, ref _bitrateLabelTween, blockBitrateInput, noAnimate); + SetFieldBlocked(_maxBitrateInput, _maxBitrateLabel, ref _maxBitrateLabelTween, blockMaxBitrateInput, noAnimate); + SetFieldBlocked(_qpInput, _qpLabel, ref _qpLabelTween, blockQPInput, noAnimate); + SetToggleBlocked(_forceSoftwareToggle, _forceSoftwareLabel, ref _forceSoftwareLabelTween, blockForceSoftwareToggle, noAnimate); + _keyframeIntervalSlider?.SetBlocked(blockKeyframeIntervalSlider, noAnimate); + } + // =================================================================================================== // More control factory helpers // =================================================================================================== @@ -2154,6 +2215,21 @@ internal static class RenderConfigWindow return input; } + /// Same as the CreateToggleField overload with an out label parameter, without returning the label. + private static O5Toggle CreateToggleField( + O5Context ctx, + Transform colParent, + string labelKey, + bool? defaultValue, + bool value, + string toggleTextKey, + string id, + Action? onChanged = null, + bool generateLabel = true) + { + return CreateToggleField(ctx, colParent, labelKey, defaultValue, value, toggleTextKey, id, out _, onChanged, generateLabel); + } + /// /// Creates an O5Kit toggle whose text is localized with . With /// an 18-unit label row is added above it (text from , @@ -2167,12 +2243,14 @@ internal static class RenderConfigWindow bool value, string toggleTextKey, string id, + out TextMeshProUGUI? label, Action? onChanged = null, bool generateLabel = true) { + label = null; if (generateLabel) { - var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); + 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); @@ -2200,6 +2278,21 @@ internal static class RenderConfigWindow return toggle; } + + /// Same as the CreateDropdownField overload with an out label parameter, without returning the label. + private static O5Dropdown CreateDropdownField( + O5Context ctx, + Transform colParent, + string labelKey, + T defaultValue, + T value, + IReadOnlyList values, + Func display, + string id, + Action? onChanged = null) + { + return CreateDropdownField(ctx, colParent, labelKey, defaultValue, value, values, display, id, out _, onChanged); + } /// /// Creates a labeled dropdown: an 18-unit label row and a container of preferred height 50 units holding the O5Kit @@ -2215,9 +2308,10 @@ internal static class RenderConfigWindow IReadOnlyList values, Func display, string id, + out TextMeshProUGUI label, Action? onChanged = null) { - var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); + label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label.text = LocalizationManager.Get(labelKey); label.color = ctx.Theme.TextDim; LocalizedText.Bind(label, labelKey); @@ -2302,7 +2396,7 @@ internal static class RenderConfigWindow if (header.Find("HexInput") is RectTransform hexInput) { hexInput.anchorMin = new Vector2(0.32f, 0f); - var tmpInput = hexInput.GetComponentInChildren(); + var tmpInput = hexInput.GetComponentInChildren(); if (tmpInput != null && tmpInput.textComponent != null) { tmpInput.textComponent.fontSize = 15f; @@ -2313,6 +2407,103 @@ internal static class RenderConfigWindow return picker; } + /// + /// Creates a labeled segmented control field. When is true, + /// places the label on the left and the segmented control on the right; otherwise stacks them vertically. + /// Both the label and segment buttons automatically track language changes via . + /// + private static SegmentedControl CreateSegmentedField( + O5Context ctx, + Transform parent, + string? labelKey, + T defaultValue, + T value, + IReadOnlyList> options, + string id, + Action? onChanged = null, + bool horizontal = true, + float? height = null) + { + var controlHeight = height ?? ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody); + + var fieldObj = new GameObject($"Field_Seg_{id}"); + fieldObj.transform.SetParent(parent, false); + var fieldRect = fieldObj.AddComponent(); + + if (horizontal) + { + var hLayout = fieldObj.AddComponent(); + hLayout.spacing = 16f; + hLayout.childAlignment = TextAnchor.MiddleLeft; + hLayout.childControlWidth = true; + hLayout.childControlHeight = true; + hLayout.childForceExpandWidth = false; + hLayout.childForceExpandHeight = false; + + // ReSharper disable once InconsistentNaming + var fieldLE = fieldObj.AddComponent(); + fieldLE.preferredHeight = controlHeight; + fieldLE.flexibleWidth = 1f; + + if (!string.IsNullOrEmpty(labelKey)) + { + var label = O5Factory.ControlText(ctx, fieldRect, ctx.Theme.FontSizeBody, 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(); + var csf = label.gameObject.AddComponent(); + csf.horizontalFit = ContentSizeFitter.FitMode.PreferredSize; + csf.verticalFit = ContentSizeFitter.FitMode.Unconstrained; + labelLE.flexibleWidth = 0f; + } + + var segControl = SegmentedControl.Create(ctx, fieldRect, id, defaultValue, value, options, controlHeight, onChanged); + // ReSharper disable once InconsistentNaming + var controlLE = segControl.Rect.GetComponent() ?? segControl.Rect.gameObject.AddComponent(); + controlLE.flexibleWidth = 1f; + controlLE.preferredHeight = controlHeight; + + return segControl; + } + else + { + var vLayout = fieldObj.AddComponent(); + vLayout.spacing = 3f; + vLayout.childControlWidth = true; + vLayout.childControlHeight = true; + vLayout.childForceExpandWidth = true; + vLayout.childForceExpandHeight = false; + + // ReSharper disable once InconsistentNaming + var fieldLE = fieldObj.AddComponent(); + fieldLE.flexibleWidth = 1f; + + if (!string.IsNullOrEmpty(labelKey)) + { + var label = O5Factory.ControlText(ctx, fieldRect, ctx.Theme.FontSizeBody, 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(); + labelLE.preferredHeight = 18f; + labelLE.minHeight = 18f; + } + + var segControl = SegmentedControl.Create(ctx, fieldRect, id, defaultValue, value, options, controlHeight, onChanged); + // ReSharper disable once InconsistentNaming + var controlLE = segControl.Rect.GetComponent() ?? segControl.Rect.gameObject.AddComponent(); + controlLE.flexibleWidth = 1f; + controlLE.preferredHeight = controlHeight; + + return segControl; + } + } + // =================================================================================================== // Window visibility // ===================================================================================================