Author SHA256 Message Date
copecone eb648bbe09 Upgrade Version 2026-10-08 18:33:11 +09:00
copecone f1d56f5f9d Add Planet Color Config 2026-10-08 18:32:44 +09:00
copecone ed61cef4bb Improve Video Encoding Options in RenderConfigWindow 2026-10-08 15:44:40 +09:00
copecone 2cfd14e348 Remove scrDecorationPatch 2026-10-08 15:32:22 +09:00
copecone daed6adcd7 Upgrade Version 2026-10-07 15:50:28 +09:00
copecone 86c8a87a0f Optimize Frame Readback and Encoding Pipeline 2026-10-07 15:48:09 +09:00
copecone 892f1ca7b9 Cleanup FolderDialogUtil 2026-10-07 15:47:31 +09:00
copecone e7ef081e55 Rename FFmpegVideoBGPlayer to VideoBGPlayer 2026-10-07 15:44:35 +09:00
copecone a523efd4fc Simplify Singleton in RendererUIManager 2026-10-07 11:47:40 +09:00
copecone b43c8be5dc Fix Render Height Binding in RenderConfigWindow 2026-10-07 11:47:40 +09:00
copecone 671bdda9a1 Refactor 2026-10-07 11:47:40 +09:00
copecone 8567878f07 Remove Legacy Resource Loading 2026-10-07 11:47:40 +09:00
copecone b900a5444e Finalize O5Kit Theme & Window Generation 2026-10-07 11:47:40 +09:00
copecone 2d70fb5ae8 Add License Exception 2026-10-07 11:47:36 +09:00
copecone 0d9cf44c77 Cleanup 2026-10-06 16:31:53 +09:00
copecone 51626f65c7 Add Version Label to Config Window 2026-10-06 15:30:20 +09:00
copecone 65669d5afd Remove 24kHz Sample Rate 2026-10-06 15:06:21 +09:00
copecone 5929b5ddbe Fix Folder Selection Browser 2026-10-06 15:06:05 +09:00
copecone a7c64e6c27 Add DelayAfterEnd Default Value 2026-10-06 14:01:06 +09:00
copecone 36c569057c Remove 96kHz for Opus 2026-10-06 14:00:54 +09:00
copecone b05e18c8bb Refactor RegistryUtil 2026-10-06 14:00:00 +09:00
copecone 888663aa86 Add Snap to Render Resolution Sliders 2026-10-06 13:53:11 +09:00
copecone 1a8fcaec7d Adjust Config Default Values 2026-10-06 13:52:45 +09:00
copecone 394fe00b25 Refactor Audio Codec & Sample Rate Detection 2026-10-06 13:51:52 +09:00
copecone 48a56cd3f2 Fix NullReferenceException from PlanetRendererPatch.OverrideColor 2026-10-06 01:09:05 +09:00
38 changed files with 2610 additions and 2270 deletions

No files matched your search

+3
View File
@@ -82,6 +82,9 @@
<Reference Include="Newtonsoft.Json">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="UniTask">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UniTask.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
@@ -4,7 +4,7 @@ using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
namespace ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Core.Data;
public class GlobalConfig
{
+11 -8
View File
@@ -1,5 +1,5 @@
using System.Collections;
using System.Reflection;
using Cysharp.Threading.Tasks;
using HarmonyLib;
using UnityEngine;
@@ -9,6 +9,7 @@ namespace ADOFAIRenderer.Core;
internal class PostRenderHook : MonoBehaviour
{
private FieldInfo? _camQuadMeshField;
private MeshRenderer? _camQuadMeshRenderer;
private void Awake()
{
@@ -20,15 +21,17 @@ internal class PostRenderHook : MonoBehaviour
if (RenderManager.Instance)
RenderManager.Instance.OnLastUpdate();
StartCoroutine(PostFrameCoroutine());
PostFrameAsync().Forget();
}
private IEnumerator PostFrameCoroutine()
private async UniTaskVoid PostFrameAsync()
{
yield return new WaitForEndOfFrame();
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
if (RenderManager.Instance)
RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
if (!_camQuadMeshRenderer && scrCamera.instance)
_camQuadMeshRenderer = _camQuadMeshField?.GetValue(scrCamera.instance) as MeshRenderer;
if (RenderManager.Instance && _camQuadMeshRenderer)
RenderManager.Instance.OnPostFrame(_camQuadMeshRenderer.sharedMaterial.mainTexture as RenderTexture);
}
}
}
@@ -177,9 +177,9 @@ internal static class PresetManager
public static void DeletePreset(string name)
{
string dir = GetPresetsDirectory();
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
var dir = GetPresetsDirectory();
var safeName = GetSafeFileName(name);
var filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{
+8 -14
View File
@@ -7,6 +7,7 @@ using System.Buffers;
using System.Diagnostics;
using System.Linq;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.UI.Window;
@@ -116,6 +117,7 @@ internal class RenderManager : MonoBehaviour
public int Length;
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
public ReadbackRequestState? ReadbackState; // Associated state to recycle
public bool IsColorConverted;
}
// Thread and Queue State
@@ -364,9 +366,10 @@ internal class RenderManager : MonoBehaviour
private IEnumerator SetupPlay()
{
if (Configuration.AudioConfig.RenderAudio && Configuration.VideoConfig.Codec is Codec.VP9 or Codec.AV1 or Codec.VVC)
if (Configuration.AudioConfig.RenderAudio)
{
if (Configuration.AudioConfig.AudioSampleRate == 44100) Configuration.AudioConfig.AudioSampleRate = 48000;
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
Configuration.AudioConfig.AudioSampleRate = 48000; // most compatible value
}
for (; countdown > 0; countdown--)
@@ -463,7 +466,7 @@ internal class RenderManager : MonoBehaviour
try
{
var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
var codecId = AudioCodecMatch[Configuration.VideoConfig.Codec];
var codecId = CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec];
var settings = new FFmpegNativeEncoder.EncoderSettings
{
Width = Configuration.RenderWidth,
@@ -544,7 +547,7 @@ internal class RenderManager : MonoBehaviour
{
if (frame.ManagedBuffer.Length > 0)
{
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length);
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
@@ -552,7 +555,7 @@ internal class RenderManager : MonoBehaviour
}
else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
{
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length);
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
if (frame.ReadbackState != null)
@@ -816,13 +819,4 @@ internal class RenderManager : MonoBehaviour
Melon<RenderMod>.Logger.Error($"OnLastUpdate callback caused error: {err}");
}
}
private static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
{
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.H265] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.VP9] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.AV1] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
};
}
+29
View File
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
internal static class CodecUtil
{
public static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
{
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.H265] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.VP9] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.AV1] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
};
public static readonly Dictionary<AVCodecID, List<int>> AvailableSampleRate = new()
{
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
[AVCodecID.AV_CODEC_ID_OPUS] = new List<int> { 48000 }
};
}
-79
View File
@@ -1,79 +0,0 @@
namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for double input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class DoubleLimit
{
/// <summary>
/// Validates the given double value against this limit.
/// </summary>
/// <param name="value">The parsed double value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(double value, out string errorMessage);
}
/// <summary>
/// Requires the double value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyDoubleLimit : DoubleLimit
{
public override bool Validate(double value, out string errorMessage)
{
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "Value must be a positive number greater than 0.";
return false;
}
}
/// <summary>
/// Requires the double value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeDoubleLimit : DoubleLimit
{
// ReSharper disable MemberCanBePrivate.Global
public double? Min { get; }
public double? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeDoubleLimit(double? min, double? max)
{
Min = min;
Max = max;
}
public override bool Validate(double value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"Value must be a number between {Min.Value} and {Max.Value}."
: $"Value must be a number greater than or equal to {Min.Value}.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"Value must be a number between {Min.Value} and {Max.Value}."
: $"Value must be a number less than or equal to {Max.Value}.";
return false;
}
errorMessage = null!;
return true;
}
}
-76
View File
@@ -1,76 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
internal class DoubleInputValidator
{
private struct FieldEntry
{
public List<DoubleLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public DoubleInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="DoubleLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the DoubleField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public DoubleInputValidator AddLimits(string fieldName, params DoubleLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<DoubleLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<DoubleField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"DoubleField '{fieldName}' not found. Skipping validation.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
-87
View File
@@ -1,87 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
/// <summary>
/// Binds a UI Toolkit <see cref="DropdownField"/> to an enum type.
/// Populates choices from enum names and provides typed access to the selected value.
/// </summary>
/// <typeparam name="T">The enum type to bind.</typeparam>
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
internal class EnumDropdownBinder<T> where T : struct, Enum
{
private readonly DropdownField? _dropdown;
private readonly List<T> _values;
private Action<T>? _onValueChanged;
/// <summary>
/// The currently selected enum value.
/// </summary>
public T Value => _dropdown is { index: >= 0 } && _dropdown.index < _values.Count
? _values[_dropdown.index]
: default;
/// <summary>
/// Queries a <see cref="DropdownField"/> by name from the root VisualElement,
/// populates it with enum names, and selects the default value.
/// </summary>
/// <param name="rootVisualElement">The root VisualElement loaded from the UXML bundle.</param>
/// <param name="fieldName">The name of the DropdownField in the UXML.</param>
/// <param name="defaultValue">The initial selected value. Defaults to the first enum member.</param>
public EnumDropdownBinder(VisualElement rootVisualElement, string fieldName, T? defaultValue = null)
{
_values = Enum.GetValues(typeof(T)).Cast<T>().ToList();
_dropdown = rootVisualElement.Q<DropdownField>(fieldName);
if (_dropdown == null)
{
Melon<RenderMod>.Logger.Error(
$"DropdownField '{fieldName}' not found.");
return;
}
_dropdown.choices = _values.Select(v => v.ToString()).ToList();
// Register the callback BEFORE setting index so the initial selection fires it too
_dropdown.RegisterValueChangedCallback(_ =>
{
_onValueChanged?.Invoke(Value);
});
var initial = defaultValue ?? _values[0];
_dropdown.index = _values.IndexOf(initial);
}
/// <summary>
/// Registers a callback that fires when the selection changes, providing the new enum value.
/// </summary>
public void OnValueChanged(Action<T> callback)
{
_onValueChanged = callback;
// Fire immediately with current value so the caller gets the initial state
if (_dropdown != null)
{
callback(Value);
}
}
/// <summary>
/// Sets the selected enum value and updates the dropdown index.
/// </summary>
/// <param name="value">The enum value to set.</param>
public void SetValue(T value)
{
if (_dropdown == null) return;
int idx = _values.IndexOf(value);
if (idx >= 0)
{
_dropdown.index = idx;
}
}
}
-76
View File
@@ -1,76 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
internal class IntegerInputValidator
{
private struct FieldEntry
{
public List<IntegerLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public IntegerInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="IntegerLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the IntegerField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public IntegerInputValidator AddLimits(string fieldName, params IntegerLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<IntegerLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<IntegerField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"IntegerField '{fieldName}' not found. Skipping validation.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
-79
View File
@@ -1,79 +0,0 @@
namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for integer input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class IntegerLimit
{
/// <summary>
/// Validates the given integer value against this limit.
/// </summary>
/// <param name="value">The parsed integer value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(int value, out string errorMessage);
}
/// <summary>
/// Requires the integer value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyIntegerLimit : IntegerLimit
{
public override bool Validate(int value, out string errorMessage)
{
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "Value must be a positive integer greater than 0.";
return false;
}
}
/// <summary>
/// Requires the integer value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeIntegerLimit : IntegerLimit
{
// ReSharper disable MemberCanBePrivate.Global
public int? Min { get; }
public int? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeIntegerLimit(int? min, int? max)
{
Min = min;
Max = max;
}
public override bool Validate(int value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"Value must be an integer between {Min.Value} and {Max.Value}."
: $"Value must be an integer greater than or equal to {Min.Value}.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"Value must be an integer between {Min.Value} and {Max.Value}."
: $"Value must be an integer less than or equal to {Max.Value}.";
return false;
}
errorMessage = null!;
return true;
}
}
+11
View File
@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
CBR,
VBR,
CQP
}
+3 -4
View File
@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Util.FFmpeg;
using UnityEngine;
namespace ADOFAIRenderer.Data;
@@ -17,7 +16,7 @@ public class RenderConfig
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; }
public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } = false;
}
@@ -57,8 +56,8 @@ public class AudioEncodingConfig
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 44100 : value;
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
public int AudioChannels
+42
View File
@@ -0,0 +1,42 @@
# ADOFAIRenderer API & Data Linking Exception (Dynamic Linking Only)
> **Additional Permission under GNU General Public License version 3 section 7**
---
## 1. Legal Terms (Official)
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
- `ADOFAIRenderer.API`
- `ADOFAIRenderer.Data`
and to copy, convey, and distribute the resulting combined work under terms of your choice, without requiring such combined work or independent modules to be covered under the GNU General Public License version 3, provided that you also meet, for each linked independent module, the terms and conditions of the license of that module.
This exception applies exclusively to dynamic linking. Static linking, including but not limited to combining, embedding, compiling this Program into another binary, or merging assemblies (such as via ILMerge or Costura), is explicitly excluded from this exception and remains fully subject to the terms and copyleft requirements of the GNU General Public License version 3.
An **"independent module"** is a separate and distinct software module that is not derived from or based on this Program, other than referencing or communicating with the interfaces and types declared within the designated namespaces above.
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
---
## 2. 조항 해설 (Korean Guide)
본 문서는 GNU General Public License 버전 3 (GPLv3) 제7조(추가적인 허가, Additional Permissions)에 따라 제공되는 공식 예외 조항입니다.
### 핵심 요약
- **동적 링킹(Dynamic Linking)만 허용**:
- 본 예외 조항은 **동적 링킹(런타임 참조 및 어셈블리 동적 로드)**에만 배타적으로 적용됩니다.
- ADOFAIRenderer 또는 그 일부 코드를 대상 모드/프로그램에 직접 병합하거나 포함(ILMerge, Costura, 바이너리 직접 내장/정적 컴파일 등)하는 **정적 링킹(Static Linking)은 본 예외 대상에서 제외**되며, 순수 GPLv3의 카피레프트(전염성) 의무가 그대로 적용됩니다.
- **외부 모드의 라이선스 자유**:
- 제3자 모드 또는 외부 프로그램이 동적 링킹을 통해 `ADOFAIRenderer.API` 및 `ADOFAIRenderer.Data` 네임스페이스의 공개 API, 클래스, 데이터 구조만을 참조·호출하여 연동하는 경우, 해당 모드는 **GPLv3의 카피레프트 의무를 적용받지 않습니다.**
- 외부 모드 개발자는 자신의 모드를 MIT, Apache-2.0, 독점/상용 라이선스 등 원하는 라이선스로 자유롭게 배포할 수 있습니다.
- **ADOFAIRenderer 코어 보호**:
- ADOFAIRenderer 자체의 소스 코드(렌더링 파이프라인, 코어 패치, 내부 유틸리티 등)를 수정하거나 재배포하는 경우에는 본 예외 조항이 적용되지 않으며, 순수 GPLv3 라이선스 조건이 그대로 적용됩니다.
+6 -1
View File
@@ -31,5 +31,10 @@
"ui.render_config.random_seed": "Random Seed (Optional)",
"ui.render_config.validation_title": "Invalid Settings",
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}"
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}",
"ui.render_config.planet_colors": "Planet Colors (Optional)",
"ui.render_config.fire_planet": "Fire Planet",
"ui.render_config.ice_planet": "Ice Planet",
"ui.render_config.wind_planet": "Wind Planet",
"ui.render_config.expired": "(Expired)"
}
+6 -1
View File
@@ -31,5 +31,10 @@
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
"ui.render_config.validation_title": "설정 오류",
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}"
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
"ui.render_config.planet_colors": "행성 색상 설정 (선택 사항)",
"ui.render_config.fire_planet": "불 행성",
"ui.render_config.ice_planet": "얼음 행성",
"ui.render_config.wind_planet": "바람 행성",
"ui.render_config.expired": "(만료됨)"
}
-1
View File
@@ -40,7 +40,6 @@ public static class EndscreenLanternsPatch
for (var i = 0; i < lampSequencesArray.Length; i++)
{
var item = lampSequencesArray.GetValue(i);
if (item == null) continue;
try
{
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
+16
View File
@@ -18,4 +18,20 @@ public static class PersistencePatch
__result = SkipIntroBehavior.Off;
}
}
[HarmonyPostfix]
[HarmonyPatch("GetPlayerColor")]
// ReSharper disable once InconsistentNaming
public static void OverridePlayerColor(bool red, ref PlanetColor __result)
{
if (!RenderManager.Instance || RenderManager.Instance.stage == RenderStage.None) return;
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
__result = red switch
{
true when advConf.firePlanetColor.HasValue => new PlanetColor(advConf.firePlanetColor.Value),
false when advConf.icePlanetColor.HasValue => new PlanetColor(advConf.icePlanetColor.Value),
_ => __result
};
}
}
-30
View File
@@ -1,30 +0,0 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(PlanetRenderer))]
public static class PlanetRendererPatch
{
[HarmonyPrefix]
[HarmonyPatch("SetColor")]
// ReSharper disable once InconsistentNaming
private static void OverrideColor(PlanetRenderer __instance, ref PlanetColor planetColor)
{
if (RenderManager.Instance.stage == RenderStage.None) return;
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
ApplyColor(ref planetColor, scrController.instance.planetRed, advConf.firePlanetColor);
ApplyColor(ref planetColor, scrController.instance.planetBlue, advConf.icePlanetColor);
ApplyColor(ref planetColor, scrController.instance.planetGreen, advConf.windPlanetColor);
return;
void ApplyColor(ref PlanetColor planetColor, scrPlanet targetPlanet, Color? customColor)
{
if (__instance == targetPlanet.planetRenderer && customColor != null)
planetColor = new PlanetColor(customColor.Value);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(PlanetarySystem))]
public static class PlanetarySystemPatch
{
[HarmonyPostfix]
[HarmonyPatch("Init")]
// ReSharper disable once InconsistentNaming
public static void OnInit(PlanetarySystem __instance)
{
ApplyWindPlanetColor(__instance);
}
[HarmonyPostfix]
[HarmonyPatch("ApplyMultiplanetColors")]
// ReSharper disable once InconsistentNaming
public static void OnApplyMultiplanetColors(PlanetarySystem __instance)
{
ApplyWindPlanetColor(__instance);
}
private static void ApplyWindPlanetColor(PlanetarySystem planetarySystem)
{
if (RenderManager.Instance == null || RenderManager.Instance.stage == RenderStage.None) return;
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
if (advConf.windPlanetColor.HasValue && planetarySystem.planetGreen)
{
planetarySystem.planetGreen.planetRenderer.SetColor(new PlanetColor(advConf.windPlanetColor.Value));
}
}
}
+5 -8
View File
@@ -77,11 +77,9 @@ public static class scnGamePatch
if (!RenderManager.IsActive)
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
var oldPlayer = __instance.videoBG.gameObject.GetComponent<VideoBGPlayer>();
if (oldPlayer) Object.Destroy(oldPlayer);
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
@@ -125,10 +123,9 @@ public static class scnGamePatch
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
if (!__instance.videoBG.gameObject.TryGetComponent<VideoBGPlayer>(out var player))
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
player = __instance.videoBG.gameObject.AddComponent<VideoBGPlayer>();
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
-40
View File
@@ -1,40 +0,0 @@
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrDecoration))]
// ReSharper disable once InconsistentNaming
public static class scrDecorationPatch
{
[HarmonyPrefix]
[HarmonyPatch("SetRotation")]
// ReSharper disable once InconsistentNaming
public static bool SetRotationPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
[HarmonyPrefix]
[HarmonyPatch("SetScale")]
// ReSharper disable once InconsistentNaming
public static bool SetScalePrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
[HarmonyPrefix]
[HarmonyPatch("SetOpacity")]
// ReSharper disable once InconsistentNaming
public static bool SetOpacityPrefix(scrDecoration __instance)
{
return __instance != null && __instance;
}
[HarmonyPrefix]
[HarmonyPatch("UpdatePosition")]
// ReSharper disable once InconsistentNaming
public static bool UpdatePositionPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
}
+3 -1
View File
@@ -90,4 +90,6 @@ The compiled mod will be output to `bin/Release/netstandard2.1/ADOFAIRenderer.dl
## 📄 License
This project is licensed under the GNU General Public License v3.0 (GPLv3). See `LICENSE` for details.
This project is licensed under the **GNU General Public License v3.0 with API & Data Linking Exception (GPLv3 with Exception, Dynamic Linking Only)**.
- Third-party mods and external tools that reference or dynamically link to ADOFAIRenderer solely via the `ADOFAIRenderer.API` and `ADOFAIRenderer.Data` namespaces are exempt from GPLv3 copyleft requirements and may be distributed under terms of their author's choice. (Static linking is excluded from this exception.)
- For full license terms and exception details, see [LICENSE](LICENSE) and [LICENSE-EXCEPTION.md](LICENSE-EXCEPTION.md).
+2 -2
View File
@@ -1,7 +1,7 @@
using System;
using ADOFAIRenderer;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.UI;
using FFmpeg.AutoGen;
using MelonLoader;
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
using UnityEngine;
using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.0-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.3-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer;
+1 -41
View File
@@ -85,46 +85,6 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (ModAssets.FontBundle)
{
try
{
font = ModAssets.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// Loading failed
}
}
// Fallback processing
if (font == null)
{
try
{
font = RDString.fontData.fontTMP;
}
catch
{
// ignored
}
}
if (font == null)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
private void Start()
{
// Set UI layer (5)
@@ -159,7 +119,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset();
var font = ModAssets.NanumSquareNeoNormal;
// 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer");
+3 -18
View File
@@ -50,20 +50,7 @@ internal sealed class RendererFontProvider : IFontProvider
internal class RendererUIManager : MonoBehaviour
{
private static RendererUIManager? _instance;
public static RendererUIManager Instance
{
get
{
if (_instance != null) return _instance;
var go = new GameObject("RendererUIManager");
DontDestroyOnLoad(go);
_instance = go.AddComponent<RendererUIManager>();
return _instance;
}
}
public static RendererUIManager Instance = null!;
public static O5Context Context => Instance._context;
public O5WindowManager WindowManager => _windowManager;
@@ -72,7 +59,7 @@ internal class RendererUIManager : MonoBehaviour
private O5WindowManager _windowManager = null!;
private void Awake() {
_instance = this;
Instance = this;
_context = new O5Context(
config: new O5Config {
@@ -89,7 +76,7 @@ internal class RendererUIManager : MonoBehaviour
ObjectBG = new Color(0f, 0f, 0f, 1f),
ObjectButton = new Color(0f, 0f, 0f, 1f),
ObjectActive = new Color(1.000f, 0.294f, 0.079f, 1f),
ObjectActiveBright = new Color(0.812f, 0.827f, 1.000f, 1f),
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),
ControlOutlineHover = new Color(1f, 1f, 1f, 0.4f),
@@ -118,7 +105,5 @@ internal class RendererUIManager : MonoBehaviour
private void OnDestroy() {
_windowManager.Dispose();
_context.Dispose();
if (_instance == this) _instance = null;
}
}
+377 -34
View File
@@ -4,10 +4,10 @@ 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;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using O5Kit.Control;
using O5Kit.Core;
@@ -32,8 +32,17 @@ internal static class RenderConfigWindow
private static O5Dropdown<Codec>? _codecDropdown;
private static O5Dropdown<RateControlMode>? _rateControlDropdown;
private static O5InputField? _bitrateInput;
private static TMPro.TextMeshProUGUI? _bitrateLabel;
private static ITweenHandle? _bitrateLabelTween;
private static O5InputField? _maxBitrateInput;
private static TMPro.TextMeshProUGUI? _maxBitrateLabel;
private static ITweenHandle? _maxBitrateLabelTween;
private static O5InputField? _qpInput;
private static TMPro.TextMeshProUGUI? _qpLabel;
private static ITweenHandle? _qpLabelTween;
private static O5Slider? _keyframeIntervalSlider;
private static O5Toggle? _forceSoftwareToggle;
@@ -49,6 +58,17 @@ internal static class RenderConfigWindow
private static O5Toggle? _processAudioToggle;
private static O5Toggle? _nonTileToggle;
private static O5Toggle? _firePlanetToggle;
private static O5ColorPicker? _firePlanetPicker;
private static O5Toggle? _icePlanetToggle;
private static O5ColorPicker? _icePlanetPicker;
private static O5Toggle? _windPlanetToggle;
private static O5ColorPicker? _windPlanetPicker;
private static readonly Color DefaultFirePlanetColor = new(1f, 0f, 0f, 1f); // #FF0000
private static readonly Color DefaultIcePlanetColor = new(0f, 0f, 1f, 1f); // #0000FF
private static readonly Color DefaultWindPlanetColor = new(0.3f, 0.7f, 0f, 1f); // #4DB300
public static void CreateWindow()
{
if (_configWindow == null)
@@ -57,14 +77,13 @@ internal static class RenderConfigWindow
{
Id = "RendererMod_RenderConfig",
Title = LocalizationManager.Get("ui.render_config.window_title"),
Size = new Vector2(580f, 480f),
Draggable = true,
Resizable = true,
Size = new Vector2(900, 600), Draggable = true, Resizable = true,
ShowCloseButton = true
});
_configWindow.CloseRequested += win =>
{
CloseAllPickers();
win.Rect.gameObject.SetActive(false);
};
@@ -82,7 +101,7 @@ internal static class RenderConfigWindow
private static void BuildUI(RectTransform content)
{
var ctx = RendererUIManager.Context;
var (viewport, scrollContent, scroller) =
var (_, scrollContent, _) =
O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f);
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
@@ -102,6 +121,20 @@ internal static class RenderConfigWindow
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
};
// TODO: Presets
// 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개)
var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f);
@@ -110,7 +143,7 @@ internal static class RenderConfigWindow
defaultValue: 60, min: 0.01, max: 3000, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => renderFpsSnap.OrderBy(n => Math.Abs(n - value)).First(),
sliderFilter: value => Snap(renderFpsSnap, value),
inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; },
text: LocalizationManager.Get("ui.render_config.render_fps"),
id: "slider_render_fps", showFill: true
@@ -121,7 +154,7 @@ internal static class RenderConfigWindow
defaultValue: 60, min: 0.01, max: 600, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => videoFpsSnap.OrderBy(n => Math.Abs(n - value)).First(),
sliderFilter: value => Snap(videoFpsSnap, value),
inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; },
text: LocalizationManager.Get("ui.render_config.video_fps"),
id: "slider_video_fps", showFill: true
@@ -132,7 +165,7 @@ internal static class RenderConfigWindow
defaultValue: 2560, min: 0, max: 15360, value: 2560,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Math.Max(value, 2), inputFilter: value => Math.Max(value, 2),
sliderFilter: value => Snap(renderWidthSnap, value), inputFilter: value => Math.Max(value, 2),
onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); },
text: LocalizationManager.Get("ui.render_config.render_width"),
id: "slider_render_width", showFill: true
@@ -143,8 +176,8 @@ internal static class RenderConfigWindow
defaultValue: 1440, min: 0, max: 8640, value: 1440,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Math.Max(value, 2), inputFilter: value => Math.Max(value, 2),
onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); },
sliderFilter: value => Snap(renderHeightSnap, value), inputFilter: value => Math.Max(value, 2),
onComplete: null, onChanged: val => { config.RenderHeight = (int)Math.Round(val); },
text: LocalizationManager.Get("ui.render_config.render_height"),
id: "slider_render_height", showFill: true
);
@@ -188,21 +221,26 @@ internal static class RenderConfigWindow
Codec.H264, config.VideoConfig.Codec,
codecs, c => c.ToString(),
"dd_video_codec",
c => config.VideoConfig.Codec = c
);
c =>
{
config.VideoConfig.Codec = c;
UpdateVideoCodecUI(c, config);
});
_bitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.bitrate"),
"30000", config.VideoConfig.Bitrate.ToString(),
"input_bitrate",
"input_bitrate", out _bitrateLabel,
TMPro.TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; }
);
_qpInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.qp"),
"18", config.VideoConfig.QP.ToString(),
"input_qp",
"input_qp", out _qpLabel,
TMPro.TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; }
);
@@ -214,23 +252,28 @@ internal static class RenderConfigWindow
RateControlMode.CBR, config.VideoConfig.RateControlMode,
rateModes, m => m.ToString(),
"dd_rate_control",
m => config.VideoConfig.RateControlMode = m
m =>
{
config.VideoConfig.RateControlMode = m;
UpdateRateControlUI(m);
}
);
_maxBitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoRight),
LocalizationManager.Get("ui.render_config.max_bitrate"),
"50000", config.VideoConfig.MaxBitrate.ToString(),
"input_max_bitrate",
"input_max_bitrate", out _maxBitrateLabel,
TMPro.TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; }
);
_forceSoftwareToggle = CreateToggleField(
ctx, CreateFieldSlot(videoRight), "",
true, config.VideoConfig.ForceSoftwareEncoder,
false, config.VideoConfig.ForceSoftwareEncoder,
LocalizationManager.Get("ui.render_config.force_software"),
"toggle_force_software",
val => config.AudioConfig.RenderAudio = val,
val => config.VideoConfig.ForceSoftwareEncoder = val,
generateLabel: true
);
@@ -271,7 +314,7 @@ internal static class RenderConfigWindow
defaultValue: 256, min: 64, max: 384, value: 256,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => audioBitrateSnap.OrderBy(n => Math.Abs(n - value)).First(),
sliderFilter: value => Snap(audioBitrateSnap, value),
inputFilter: null, onComplete: null, onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; },
text: LocalizationManager.Get("ui.render_config.audio_bitrate"),
id: "slider_audio_bitrate", showFill: true
@@ -287,12 +330,11 @@ internal static class RenderConfigWindow
c => config.AudioConfig.AudioChannels = c
);
var sampleRates = new[] { 44100, 48000, 96000 };
_sampleRateDropdown = CreateDropdownField(
ctx, CreateFieldSlot(audioRight),
LocalizationManager.Get("ui.render_config.sample_rate"),
48000, config.AudioConfig.AudioSampleRate,
sampleRates, r => $"{r/1000.0} kHz",
CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz",
"dd_sample_rate",
r => config.AudioConfig.AudioSampleRate = r
);
@@ -339,6 +381,7 @@ internal static class RenderConfigWindow
: _outputDirInput.Value;
FolderDialogUtil.OpenFolderBrowser(initial, selected =>
{
Melon<RenderMod>.Logger.Msg("Output Directory Selected: " + selected);
if (!string.IsNullOrWhiteSpace(selected))
{
_outputDirInput.Set(selected);
@@ -384,6 +427,7 @@ internal static class RenderConfigWindow
"dd_adv_file_extension",
e => { if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; }
);
_processAudioToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft), "",
true, config.AdvancedConfig.ProcessAudio,
@@ -405,6 +449,7 @@ internal static class RenderConfigWindow
else if (string.IsNullOrWhiteSpace(val)) config.AdvancedConfig.RandomSeed = null;
}
);
_nonTileToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight), "",
false, config.AdvancedConfig.NonTile,
@@ -413,6 +458,108 @@ internal static class RenderConfigWindow
val => config.AdvancedConfig.NonTile = val,
generateLabel: false
);
// 7-1. Planet Color Settings (Optional)
var planetColorsLabel = O5Factory.ControlText(ctx, advContent, 13f, noPad: true);
planetColorsLabel.text = LocalizationManager.Get("ui.render_config.planet_colors");
planetColorsLabel.color = ctx.Theme.TextDim;
// 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, "",
false, config.AdvancedConfig.firePlanetColor.HasValue,
LocalizationManager.Get("ui.render_config.fire_planet"),
"toggle_fire_planet",
val =>
{
_firePlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.firePlanetColor = val ? _firePlanetPicker?.Value : null;
if (!val) _firePlanetPicker?.SetExpanded(false);
},
generateLabel: false
);
_firePlanetPicker = CreateColorPickerField(
ctx, fireSlot,
DefaultFirePlanetColor,
config.AdvancedConfig.firePlanetColor ?? DefaultFirePlanetColor,
"cp_fire_planet",
val =>
{
if (_firePlanetToggle?.Value == true)
config.AdvancedConfig.firePlanetColor = val;
}
);
if (!config.AdvancedConfig.firePlanetColor.HasValue)
{
_firePlanetPicker.SetBlocked(true, true);
}
var iceSlot = CreateFieldSlot(iceCol);
_icePlanetToggle = CreateToggleField(
ctx, iceSlot, "",
false, config.AdvancedConfig.icePlanetColor.HasValue,
LocalizationManager.Get("ui.render_config.ice_planet"),
"toggle_ice_planet",
val =>
{
_icePlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.icePlanetColor = val ? _icePlanetPicker?.Value : null;
if (!val) _icePlanetPicker?.SetExpanded(false);
},
generateLabel: false
);
_icePlanetPicker = CreateColorPickerField(
ctx, iceSlot,
DefaultIcePlanetColor,
config.AdvancedConfig.icePlanetColor ?? DefaultIcePlanetColor,
"cp_ice_planet",
val =>
{
if (_icePlanetToggle?.Value == true)
config.AdvancedConfig.icePlanetColor = val;
}
);
if (!config.AdvancedConfig.icePlanetColor.HasValue)
{
_icePlanetPicker.SetBlocked(true, true);
}
var windSlot = CreateFieldSlot(windCol);
_windPlanetToggle = CreateToggleField(
ctx, windSlot, "",
false, config.AdvancedConfig.windPlanetColor.HasValue,
LocalizationManager.Get("ui.render_config.wind_planet"),
"toggle_wind_planet",
val =>
{
_windPlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.windPlanetColor = val ? _windPlanetPicker?.Value : null;
if (!val) _windPlanetPicker?.SetExpanded(false);
},
generateLabel: false
);
_windPlanetPicker = CreateColorPickerField(
ctx, windSlot,
DefaultWindPlanetColor,
config.AdvancedConfig.windPlanetColor ?? DefaultWindPlanetColor,
"cp_wind_planet",
val =>
{
if (_windPlanetToggle?.Value == true)
config.AdvancedConfig.windPlanetColor = val;
}
);
if (!config.AdvancedConfig.windPlanetColor.HasValue)
{
_windPlanetPicker.SetBlocked(true, true);
}
// 8. Go Render Button
O5Factory.Button(
@@ -422,12 +569,31 @@ internal static class RenderConfigWindow
"btn_go_render",
height: 48f
);
// 9. Version Label
var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true);
var versionText = $"ADOFAI Renderer v{Melon<RenderMod>.Instance.Info.Version}";
if (DateTime.UtcNow >= RenderMod.ExpirationTime) versionText += " " + LocalizationManager.Get("ui.render_config.expired");
versionLabel.text = versionText;
versionLabel.alignment = TMPro.TextAlignmentOptions.Center;
versionLabel.color = ctx.Theme.TextDim;
// ReSharper disable once InconsistentNaming
var versionLE = versionLabel.gameObject.AddComponent<LayoutElement>();
versionLE.preferredHeight = 18f;
versionLE.minHeight = 18f;
return;
double Snap(double[] array, double value) =>
array.OrderBy(n => Math.Abs(n - value)).First();
}
private static void EnsureDefaultConfig(RenderConfig config)
{
if (config.RenderWidth <= 0) config.RenderWidth = Screen.width > 0 ? Screen.width : 2560;
if (config.RenderHeight <= 0) config.RenderHeight = Screen.height > 0 ? Screen.height : 1440;
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;
@@ -463,6 +629,24 @@ internal static class RenderConfigWindow
_randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false);
_processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false);
_nonTileToggle?.Set(config.AdvancedConfig.NonTile, false);
var fireColor = config.AdvancedConfig.firePlanetColor;
_firePlanetToggle?.Set(fireColor.HasValue, false);
_firePlanetPicker?.Set(fireColor ?? DefaultFirePlanetColor, false);
_firePlanetPicker?.SetBlocked(!fireColor.HasValue, true);
var iceColor = config.AdvancedConfig.icePlanetColor;
_icePlanetToggle?.Set(iceColor.HasValue, false);
_icePlanetPicker?.Set(iceColor ?? DefaultIcePlanetColor, false);
_icePlanetPicker?.SetBlocked(!iceColor.HasValue, true);
var windColor = config.AdvancedConfig.windPlanetColor;
_windPlanetToggle?.Set(windColor.HasValue, false);
_windPlanetPicker?.Set(windColor ?? DefaultWindPlanetColor, false);
_windPlanetPicker?.SetBlocked(!windColor.HasValue, true);
UpdateVideoCodecUI(config.VideoConfig.Codec, config);
UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true);
}
private static void TryGoRender()
@@ -496,6 +680,17 @@ internal static class RenderConfigWindow
}
}
// Sync Planet Colors
config.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null)
? _firePlanetPicker.Value
: null;
config.AdvancedConfig.icePlanetColor = (_icePlanetToggle?.Value == true && _icePlanetPicker != null)
? _icePlanetPicker.Value
: null;
config.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null)
? _windPlanetPicker.Value
: null;
// Sync Output Directory
if (_outputDirInput != null)
{
@@ -540,16 +735,18 @@ internal static class RenderConfigWindow
);
}
// 카드(또는 상단 섹션)당 하나의 좌/우 컨테이너를 만들어
// 가로 한 줄 두 필드 사이 중앙이 행마다 어긋나지 않도록 한다.
// - 패널 1개 = 중앙선 1개 (행마다 HLG를 만들던 기존 방식의 P1-P2 어긋남 방지)
// - 좌/우 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 50:50
// 카드(또는 상단 섹션)당 하나의 다열 컨테이너를 만들어
// 가로 한 줄 각 필드 사이 중앙이 행마다 어긋나지 않도록 한다.
// - 패널 1개 = 균등 분할 (행마다 HLG를 만들던 기존 방식의 어긋남 방지)
// - 각 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 1:1:...:1 균등 분할
// - 각 필드는 Field 슬롯(label 18 + 간격 3 + 컨트롤 50)으로 높이를 고정해
// 좌/우 스택을 써도 행 단위 Y 정렬이 유지되도록 한다.
private static (RectTransform left, RectTransform right) CreateTwoColumnPanel(
Transform parent, float spacing = 8f, float columnSpacing = 8f)
private static RectTransform[] CreateMultiColumnPanel(
Transform parent, int columnCount, float spacing = 8f, float columnSpacing = 8f)
{
var panelObj = new GameObject("TwoColPanel");
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>();
@@ -567,10 +764,20 @@ internal static class RenderConfigWindow
var panelLE = panelObj.AddComponent<LayoutElement>();
panelLE.flexibleWidth = 1f;
var left = CreateColumn(panelObj.transform, "LeftCol", columnSpacing);
var right = CreateColumn(panelObj.transform, "RightCol", columnSpacing);
var columns = new RectTransform[columnCount];
for (var i = 0; i < columnCount; i++)
{
columns[i] = CreateColumn(panelObj.transform, $"Col{i + 1}", columnSpacing);
}
return (left, right);
return columns;
}
private static (RectTransform left, RectTransform right) CreateTwoColumnPanel(
Transform parent, float spacing = 8f, float columnSpacing = 8f)
{
var cols = CreateMultiColumnPanel(parent, 2, spacing, columnSpacing);
return (cols[0], cols[1]);
}
private static RectTransform CreateColumn(Transform parent, string name, float spacing)
@@ -630,7 +837,21 @@ internal static class RenderConfigWindow
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
Action<string>? onChanged = null)
{
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
return CreateInputField(ctx, colParent, labelText, defaultValue, value, id, out _, characterValidation, onChanged);
}
private static O5InputField CreateInputField(
O5Context ctx,
Transform colParent,
string labelText,
string? defaultValue,
string value,
string id,
out TMPro.TextMeshProUGUI label,
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
Action<string>? onChanged = null)
{
label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = labelText;
label.color = ctx.Theme.TextDim;
@@ -654,6 +875,57 @@ internal static class RenderConfigWindow
return inputField;
}
private static void SetFieldBlocked(
O5InputField? input,
TMPro.TextMeshProUGUI? label,
ref ITweenHandle? tweenHandle,
bool blocked,
bool noAnimate = false)
{
input?.SetBlocked(blocked, noAnimate);
if (label == null) return;
tweenHandle?.Kill();
var ctx = RendererUIManager.Context;
var baseColor = ctx.Theme.TextDim;
var targetColor = blocked
? new Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a * 0.4f)
: baseColor;
if (noAnimate)
{
label.color = targetColor;
}
else
{
tweenHandle = ctx.Tween.TweenColor(label, targetColor, 0.2f);
}
}
private static void UpdateRateControlUI(RateControlMode mode, bool noAnimate = false)
{
bool blockBitrate = mode is RateControlMode.CQP;
bool blockMaxBitrate = mode is RateControlMode.CBR or RateControlMode.CQP;
bool blockQp = mode is RateControlMode.CBR or RateControlMode.VBR;
SetFieldBlocked(_bitrateInput, _bitrateLabel, ref _bitrateLabelTween, blockBitrate, noAnimate);
SetFieldBlocked(_maxBitrateInput, _maxBitrateLabel, ref _maxBitrateLabelTween, blockMaxBitrate, noAnimate);
SetFieldBlocked(_qpInput, _qpLabel, ref _qpLabelTween, blockQp, noAnimate);
}
private static void UpdateVideoCodecUI(Codec codec, RenderConfig? config = null)
{
if (_sampleRateDropdown == null) return;
_sampleRateDropdown.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]);
if (config != null && _sampleRateDropdown.Value is var currentRate)
{
config.AudioConfig.AudioSampleRate = currentRate;
}
}
private static O5InputField CreateFullWidthInputField(
O5Context ctx,
Transform parent,
@@ -771,8 +1043,79 @@ internal static class RenderConfigWindow
);
}
private static O5ColorPicker CreateColorPickerField(
O5Context ctx,
Transform colParent,
Color? defaultValue,
Color value,
string id,
Action<Color>? onChanged = null)
{
var cpContainer = new GameObject($"CP_{id}");
cpContainer.transform.SetParent(colParent, false);
// ReSharper disable once InconsistentNaming
var cpLE = cpContainer.AddComponent<LayoutElement>();
var height = ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody);
cpLE.preferredHeight = height;
cpLE.minHeight = height;
var canvasRect = RendererUIManager.Instance.WindowManager.Canvas.Root.GetComponent<RectTransform>();
var picker = O5Factory.ColorPicker(
ctx: ctx,
parent: cpContainer.transform,
canvasRect: canvasRect,
canvasCamera: null,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
onComplete: onChanged,
id: id
);
// 3열 등 다열 레이아웃 환경에 맞춰 O5ColorPicker 헤더 내부 비율 및 폰트 크기 조정
// O5Kit 기본값(Preview: 0~0.5, HexInput: 0.62~1.0, fontSize: 19)은 너비가 좁을 때
// Hex 문자열(8자리 RGBA) 앞부분이 잘려 보이는 현상이 발생하므로 공간을 재분배합니다.
if (picker.Rect.Find("Header") is RectTransform header)
{
if (header.Find("Preview") is RectTransform preview)
{
preview.anchorMax = new Vector2(0.28f, 1f);
}
if (header.Find("ToggleArea") is RectTransform toggleArea)
{
toggleArea.anchorMax = new Vector2(0.30f, 1f);
}
if (header.Find("HexInput") is RectTransform hexInput)
{
hexInput.anchorMin = new Vector2(0.32f, 0f);
var tmpInput = hexInput.GetComponentInChildren<TMPro.TMP_InputField>();
if (tmpInput != null && tmpInput.textComponent != null)
{
tmpInput.textComponent.fontSize = 15f;
}
}
}
return picker;
}
private static void SetWindowActive(bool enabled)
{
if (!enabled)
{
CloseAllPickers();
}
_configWindow?.Rect.gameObject.SetActive(enabled);
}
private static void CloseAllPickers()
{
_firePlanetPicker?.SetExpanded(false, true);
_icePlanetPicker?.SetExpanded(false, true);
_windPlanetPicker?.SetExpanded(false, true);
}
}
-10
View File
@@ -1,10 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
}
File diff suppressed because it is too large. Load diff
+18 -53
View File
@@ -1,5 +1,6 @@
using System;
using System.Runtime.InteropServices;
using ADOFAIRenderer.Data;
using FFmpeg.AutoGen;
using MelonLoader;
using Unity.Collections;
@@ -973,29 +974,22 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// Encodes a frame from IntPtr (native pointer).
/// Can be used from external native plugins or custom capture sources.
/// </summary>
public void SendDataFromNativeBuffer(IntPtr pointer, int length)
public void SendDataFromNativeBuffer(IntPtr pointer, int length, bool isColorConverted)
{
lock (_lock)
{
if (_disposed || !_isInitialized) return;
var preConvertedSize = _settings.Width * _settings.Height * 3 / 2;
if (!_loggedConversionMode)
{
_loggedConversionMode = true;
Melon<RenderMod>.Logger.Msg(length == preConvertedSize
? "[Encoder] High-speed GPU color conversion enabled (NV12/YUV420P Direct Write, bypassing sws_scale)"
Melon<RenderMod>.Logger.Msg(isColorConverted
? "[Encoder] GPU color conversion enabled (NV12/YUV420P Direct Write, bypassing sws_scale)"
: "[Encoder] CPU color conversion fallback enabled (RGBA Input, using sws_scale)");
}
if (length == preConvertedSize)
{
WritePreConvertedFrame((byte*)pointer.ToPointer());
}
else
{
WriteFrameFromBuffer((byte*)pointer.ToPointer());
}
if (isColorConverted) WritePreConvertedFrame((byte*)pointer.ToPointer());
else WriteFrameFromBuffer((byte*)pointer.ToPointer());
}
}
@@ -1003,31 +997,23 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// Encodes a frame from a managed byte[].
/// Pins the array using fixed keyword to prevent GC from moving it before passing the pointer.
/// </summary>
public void SendDataFromBuffer(byte[] buffer, int length)
public void SendDataFromBuffer(byte[] buffer, int length, bool isColorConverted)
{
lock (_lock)
{
if (_disposed || !_isInitialized) return;
fixed (byte* pBuffer = buffer)
{
var preConvertedSize = _settings.Width * _settings.Height * 3 / 2;
if (!_loggedConversionMode)
{
_loggedConversionMode = true;
Melon<RenderMod>.Logger.Msg(length == preConvertedSize
Melon<RenderMod>.Logger.Msg(isColorConverted
? "[Encoder] High-speed GPU color conversion enabled (NV12/YUV420P Direct Write, bypassing sws_scale)"
: "[Encoder] CPU color conversion fallback enabled (RGBA Input, using sws_scale)");
}
if (length == preConvertedSize)
{
WritePreConvertedFrame(pBuffer);
}
else
{
WriteFrameFromBuffer(pBuffer);
}
if (isColorConverted) WritePreConvertedFrame(pBuffer);
else WriteFrameFromBuffer(pBuffer);
}
}
}
@@ -1144,33 +1130,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
throw new NotSupportedException($"This pixel format ({_dstPixFmt}) does not support direct copying of pre-converted buffers.");
}
// QSV acceleration handling or encoder send
if (_isQsvEncoder)
{
ffmpeg.av_frame_unref(_pHwFrame);
try
{
var ret = ffmpeg.av_hwframe_get_buffer(_pCodecContext->hw_frames_ctx, _pHwFrame, 0);
if (ret < 0)
throw new Exception($"Failed to allocate QSV hardware frame buffer — {GetFFmpegErrorString(ret)} (Code: {ret})");
ret = ffmpeg.av_hwframe_transfer_data(_pHwFrame, _pDstFrame, 0);
if (ret < 0)
throw new Exception($"Failed to upload QSV frame — {GetFFmpegErrorString(ret)} (Code: {ret})");
_pHwFrame->pts = _frameIndex++;
SendFrameAndWritePackets(_pHwFrame);
}
finally
{
ffmpeg.av_frame_unref(_pHwFrame);
}
}
else
{
_pDstFrame->pts = _frameIndex++;
SendFrameAndWritePackets(_pDstFrame);
}
SubmitDstFrame();
}
/// <summary>
@@ -1208,6 +1168,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
_pDstFrame->data, _pDstFrame->linesize
);
SubmitDstFrame();
}
private void SubmitDstFrame()
{
if (_isQsvEncoder)
{
// ── QSV hw_frames_ctx mode: System memory → GPU surface upload ──
@@ -1536,9 +1501,9 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (_pAudioCodecContext == null)
throw new Exception("Failed to allocate audio codec context.");
var sampleRate = audioSettings.SampleRate > 0 ? audioSettings.SampleRate : 44100;
var sampleRate = audioSettings.SampleRate > 0 ? audioSettings.SampleRate : 48000;
var channels = audioSettings.Channels > 0 ? audioSettings.Channels : 2;
var bitrate = audioSettings.Bitrate > 0 ? audioSettings.Bitrate : 128000;
var bitrate = audioSettings.Bitrate > 0 ? audioSettings.Bitrate : 256000;
// Select the first sample format supported by the encoder.
// Most encoders prefer AV_SAMPLE_FMT_FLTP (float planar).
-12
View File
@@ -1,12 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
CBR,
VBR,
CQP
}
}
+106 -106
View File
@@ -2,112 +2,112 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Util.FFmpeg
namespace ADOFAIRenderer.Util.FFmpeg;
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum EncoderKind
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum EncoderKind
{
Software,
NVENC,
AMF,
QSV,
VideoToolbox
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{
// ReSharper disable MemberCanBePrivate.Global
// ── H.264 ──
public static readonly VideoEncoder H264_NVENC = new("h264_nvenc", Codec.H264, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder H264_QSV = new("h264_qsv", Codec.H264, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder H264_AMF = new("h264_amf", Codec.H264, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder H264_VideoToolbox = new("h264_videotoolbox", Codec.H264, GPUVendor.Apple, EncoderKind.VideoToolbox);
public static readonly VideoEncoder Libx264 = new("libx264", Codec.H264, GPUVendor.Unknown, EncoderKind.Software);
// ── H.265 (HEVC) ──
public static readonly VideoEncoder HEVC_NVENC = new("hevc_nvenc", Codec.H265, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder HEVC_QSV = new("hevc_qsv", Codec.H265, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder HEVC_AMF = new("hevc_amf", Codec.H265, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder HEVC_VideoToolbox = new("hevc_videotoolbox", Codec.H265, GPUVendor.Apple, EncoderKind.VideoToolbox);
public static readonly VideoEncoder Libx265 = new("libx265", Codec.H265, GPUVendor.Unknown, EncoderKind.Software);
// ── VP9 ──
public static readonly VideoEncoder VP9_NVENC = new("vp9_nvenc", Codec.VP9, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder VP9_QSV = new("vp9_qsv", Codec.VP9, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder LibvpxVP9 = new("libvpx-vp9", Codec.VP9, GPUVendor.Unknown, EncoderKind.Software);
// ── AV1 ──
public static readonly VideoEncoder AV1_NVENC = new("av1_nvenc", Codec.AV1, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder AV1_QSV = new("av1_qsv", Codec.AV1, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder AV1_AMF = new("av1_amf", Codec.AV1, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder LibSvtAv1 = new("libsvtav1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
public static readonly VideoEncoder LibAomAv1 = new("libaom-av1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
// ── VVC ──
public static readonly VideoEncoder LibVvenc = new("libvvenc", Codec.VVC, GPUVendor.Unknown, EncoderKind.Software);
// ReSharper restore MemberCanBePrivate.Global
public static readonly IReadOnlyList<VideoEncoder> All = new[]
{
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264,
HEVC_NVENC, HEVC_QSV, HEVC_AMF, HEVC_VideoToolbox, Libx265,
VP9_NVENC, VP9_QSV, LibvpxVP9,
AV1_NVENC, AV1_QSV, AV1_AMF, LibSvtAv1, LibAomAv1,
LibVvenc
};
private static readonly Dictionary<string, VideoEncoder> ByName =
All.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase);
public string Name { get; }
public Codec Codec { get; }
public GPUVendor Vendor { get; }
public EncoderKind Kind { get; }
public bool IsHardware => Kind != EncoderKind.Software;
private VideoEncoder(string name, Codec codec, GPUVendor vendor, EncoderKind kind)
{
Name = name;
Codec = codec;
Vendor = vendor;
Kind = kind;
}
public static VideoEncoder? FromName(string name)
{
return string.IsNullOrEmpty(name) ? null : ByName.GetValueOrDefault(name);
}
public bool Equals(VideoEncoder other)
{
if (ReferenceEquals(null, other)) return false;
return ReferenceEquals(this, other) || string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
}
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is VideoEncoder other && Equals(other);
}
public override int GetHashCode()
{
return StringComparer.OrdinalIgnoreCase.GetHashCode(Name);
}
public static bool operator ==(VideoEncoder left, VideoEncoder right)
{
return Equals(left, right);
}
public static bool operator !=(VideoEncoder left, VideoEncoder right)
{
return !Equals(left, right);
}
public override string ToString() => Name;
}
Software,
NVENC,
AMF,
QSV,
VideoToolbox
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{
// ReSharper disable MemberCanBePrivate.Global
// ── H.264 ──
public static readonly VideoEncoder H264_NVENC = new("h264_nvenc", Codec.H264, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder H264_QSV = new("h264_qsv", Codec.H264, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder H264_AMF = new("h264_amf", Codec.H264, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder H264_VideoToolbox = new("h264_videotoolbox", Codec.H264, GPUVendor.Apple, EncoderKind.VideoToolbox);
public static readonly VideoEncoder Libx264 = new("libx264", Codec.H264, GPUVendor.Unknown, EncoderKind.Software);
// ── H.265 (HEVC) ──
public static readonly VideoEncoder HEVC_NVENC = new("hevc_nvenc", Codec.H265, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder HEVC_QSV = new("hevc_qsv", Codec.H265, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder HEVC_AMF = new("hevc_amf", Codec.H265, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder HEVC_VideoToolbox = new("hevc_videotoolbox", Codec.H265, GPUVendor.Apple, EncoderKind.VideoToolbox);
public static readonly VideoEncoder Libx265 = new("libx265", Codec.H265, GPUVendor.Unknown, EncoderKind.Software);
// ── VP9 ──
public static readonly VideoEncoder VP9_NVENC = new("vp9_nvenc", Codec.VP9, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder VP9_QSV = new("vp9_qsv", Codec.VP9, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder LibvpxVP9 = new("libvpx-vp9", Codec.VP9, GPUVendor.Unknown, EncoderKind.Software);
// ── AV1 ──
public static readonly VideoEncoder AV1_NVENC = new("av1_nvenc", Codec.AV1, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder AV1_QSV = new("av1_qsv", Codec.AV1, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder AV1_AMF = new("av1_amf", Codec.AV1, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder LibSvtAv1 = new("libsvtav1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
public static readonly VideoEncoder LibAomAv1 = new("libaom-av1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
// ── VVC ──
public static readonly VideoEncoder LibVvenc = new("libvvenc", Codec.VVC, GPUVendor.Unknown, EncoderKind.Software);
// ReSharper restore MemberCanBePrivate.Global
public static readonly IReadOnlyList<VideoEncoder> All = new[]
{
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264,
HEVC_NVENC, HEVC_QSV, HEVC_AMF, HEVC_VideoToolbox, Libx265,
VP9_NVENC, VP9_QSV, LibvpxVP9,
AV1_NVENC, AV1_QSV, AV1_AMF, LibSvtAv1, LibAomAv1,
LibVvenc
};
private static readonly Dictionary<string, VideoEncoder> ByName =
All.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase);
public string Name { get; }
public Codec Codec { get; }
public GPUVendor Vendor { get; }
public EncoderKind Kind { get; }
public bool IsHardware => Kind != EncoderKind.Software;
private VideoEncoder(string name, Codec codec, GPUVendor vendor, EncoderKind kind)
{
Name = name;
Codec = codec;
Vendor = vendor;
Kind = kind;
}
public static VideoEncoder? FromName(string name)
{
return string.IsNullOrEmpty(name) ? null : ByName.GetValueOrDefault(name);
}
public bool Equals(VideoEncoder other)
{
if (ReferenceEquals(null, other)) return false;
return ReferenceEquals(this, other) || string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
}
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is VideoEncoder other && Equals(other);
}
public override int GetHashCode()
{
return StringComparer.OrdinalIgnoreCase.GetHashCode(Name);
}
public static bool operator ==(VideoEncoder left, VideoEncoder right)
{
return Equals(left, right);
}
public static bool operator !=(VideoEncoder left, VideoEncoder right)
{
return !Equals(left, right);
}
public override string ToString() => Name;
}
-518
View File
@@ -1,518 +0,0 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util
{
// ReSharper disable once InconsistentNaming
internal class FFmpegVideoBGPlayer : MonoBehaviour
{
private static readonly int YTex = Shader.PropertyToID("_YTex");
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
private static readonly int Result = Shader.PropertyToID("_Result");
private static readonly int Width = Shader.PropertyToID("Width");
private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int UTex = Shader.PropertyToID("_UTex");
private static readonly int VTex = Shader.PropertyToID("_VTex");
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
[SuppressMessage("ReSharper", "InconsistentNaming")]
private enum GPUMode
{
None,
YUV420P,
NV12
}
private FFmpegNativeDecoder? _decoder;
// CPU fallback rendering
// ReSharper disable once InconsistentNaming
private Texture2D? _textureRGBA;
// GPU conversion textures
private Texture2D? _textureY;
private Texture2D? _textureU;
private Texture2D? _textureV;
private Texture2D? _textureUV;
// Compute Shader variables
private ComputeShader? _computeShader;
private RenderTexture? _renderTexture;
private int _kernelIndex = -1;
private double _currentFrameTimestamp;
private double _nextFrameTimestamp;
private GPUMode _gpuMode = GPUMode.None;
private RenderTexture? _targetTexture;
private bool _isLooping;
private bool _isInitialized;
private GameObject? _quad;
private Material? _material;
// ReSharper disable once InconsistentNaming
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
{
CleanUp();
_targetTexture = targetTexture;
_isLooping = loop;
if (_targetTexture == null && targetCamera != null)
{
SetupBackgroundQuad(targetCamera, videoBGGo);
}
if (!File.Exists(videoPath))
{
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] Video file does not exist: {videoPath}");
return;
}
try
{
_decoder = new FFmpegNativeDecoder(videoPath);
_computeShader = ModAssets.ColorConversionShader;
var w = _decoder.Width;
var h = _decoder.Height;
var pixFmt = _decoder.PixelFormat;
var gpuInitSuccess = false;
if (_computeShader != null)
{
switch (pixFmt)
{
case AVPixelFormat.AV_PIX_FMT_NV12:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureUV = new Texture2D(w / 2, h / 2, TextureFormat.RG16, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureUV);
_gpuMode = GPUMode.NV12;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader NV12 mode successfully");
}
catch
{
// ignored
}
break;
case AVPixelFormat.AV_PIX_FMT_YUV420P:
case AVPixelFormat.AV_PIX_FMT_YUVJ420P:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureU = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
_textureV = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureU);
ConfigureTexture(_textureV);
_gpuMode = GPUMode.YUV420P;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader YUV420P mode successfully");
}
catch
{
// ignored
}
break;
default:
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Cannot use Compute Shader for colorspace conversion.");
break;
}
}
if (gpuInitSuccess && _computeShader != null)
{
_renderTexture = new RenderTexture(w, h, 0, RenderTextureFormat.ARGB32)
{
enableRandomWrite = true
};
_renderTexture.Create();
}
else
{
_textureRGBA = new Texture2D(w, h, TextureFormat.RGBA32, false);
ConfigureTexture(_textureRGBA);
_gpuMode = GPUMode.None;
Melon<RenderMod>.Logger.Warning("[FFmpegVideoBGPlayer] GPU acceleration unsupported or failed to load, falling back to CPU (RGBA).");
}
_currentFrameTimestamp = 0.0;
_nextFrameTimestamp = 0.0;
_isInitialized = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Successfully initialized - Resolution: {w}x{h}, FPS: {_decoder.FPS:F2}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during initialization: {ex}");
CleanUp();
}
}
private static void ConfigureTexture(Texture2D tex)
{
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
}
private void LateUpdate()
{
if (!_isInitialized || _decoder == null) return;
var cond = scrConductor.instance;
if (!cond || !cond.hasSongStarted) return;
var pitch = cond.song.pitch;
var separateCountdownOffset = cond.separateCountdownTime ?
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
var songTime = (cond.dspTime - cond.dspTimeSong) * pitch - cond.addoffset - separateCountdownOffset;
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
var targetVideoTime = songTime + vidOffset;
if (targetVideoTime < 0)
{
if (_targetTexture)
{
var active = RenderTexture.active;
RenderTexture.active = _targetTexture;
GL.Clear(true, true, Color.black);
RenderTexture.active = active;
}
if (_quad)
{
_quad.SetActive(false);
}
return;
}
if (ADOBase.customLevel)
{
ADOBase.customLevel.ShowTutorialBackground(false);
}
try
{
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
{
_decoder.SeekToTime(targetVideoTime);
_currentFrameTimestamp = targetVideoTime;
_nextFrameTimestamp = targetVideoTime;
}
bool frameUpdated = false;
while (targetVideoTime >= _nextFrameTimestamp)
{
double timestamp = 0.0;
bool success = false;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV)
{
success = _decoder.TryReadNV12Frame(
_textureY.GetRawTextureData<byte>(),
_textureUV.GetRawTextureData<byte>(),
out timestamp
);
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV)
{
success = _decoder.TryReadYUV420PFrame(
_textureY.GetRawTextureData<byte>(),
_textureU.GetRawTextureData<byte>(),
_textureV.GetRawTextureData<byte>(),
out timestamp
);
}
else
{
if (_textureRGBA != null)
{
success = _decoder.TryReadFrame(
_textureRGBA.GetRawTextureData<byte>(),
vflip: true,
out timestamp
);
}
}
if (success)
{
_currentFrameTimestamp = timestamp;
_nextFrameTimestamp = timestamp + (1.0 / _decoder.FPS);
frameUpdated = true;
}
else
{
if (_isLooping)
{
_decoder.SeekToTime(0);
_currentFrameTimestamp = 0;
_nextFrameTimestamp = 1.0 / _decoder.FPS;
break;
}
else
{
break;
}
}
}
if (frameUpdated)
{
RenderToTarget();
}
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during playback update: {ex}");
_isInitialized = false;
}
}
private void RenderToTarget()
{
if (_decoder == null) return;
int w = _decoder.Width;
int h = _decoder.Height;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureUV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UVTex, _textureUV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureU.Apply();
_textureV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UTex, _textureU);
_computeShader.SetTexture(_kernelIndex, VTex, _textureV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_textureRGBA)
{
_textureRGBA.Apply();
if (_targetTexture)
{
Graphics.Blit(_textureRGBA, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _textureRGBA)
{
_material.mainTexture = _textureRGBA;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
}
private void OnDestroy()
{
CleanUp();
}
public void CleanUp()
{
_isInitialized = false;
if (_decoder != null)
{
_decoder.Dispose();
_decoder = null;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Decoder successfully disposed.");
}
if (_textureRGBA != null)
{
Destroy(_textureRGBA);
_textureRGBA = null;
}
if (_textureY != null) Destroy(_textureY);
if (_textureU != null) Destroy(_textureU);
if (_textureV != null) Destroy(_textureV);
if (_textureUV != null) Destroy(_textureUV);
_textureY = null;
_textureU = null;
_textureV = null;
_textureUV = null;
if (_renderTexture != null)
{
_renderTexture.Release();
Destroy(_renderTexture);
_renderTexture = null;
}
if (_quad != null)
{
Destroy(_quad);
_quad = null;
}
if (_material != null)
{
Destroy(_material);
_material = null;
}
}
// ReSharper disable once InconsistentNaming
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
{
_quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
_quad.name = "FFmpegVideoBGQuad";
// ReSharper disable once Unity.UnresolvedComponentOrScriptableObject
var col = _quad.GetComponent("MeshCollider");
if (col) Destroy(col);
_quad.transform.parent = targetCamera.transform;
var zPos = targetCamera.farClipPlane - 0.5f;
_quad.transform.localPosition = new Vector3(0f, 0f, zPos);
_quad.transform.localRotation = Quaternion.identity;
float height;
float width;
if (targetCamera.orthographic)
{
height = 2.0f * targetCamera.orthographicSize;
width = height * targetCamera.aspect;
}
else
{
height = 2.0f * zPos * Mathf.Tan(targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
width = height * targetCamera.aspect;
}
_quad.transform.localScale = new Vector3(width, height, 1.0f);
_quad.layer = videoBGGo.layer;
// Set vertex colors to white so Sprites/Default works properly (otherwise it may draw transparently)
var meshFilter = _quad.GetComponent<MeshFilter>();
if (meshFilter != null && meshFilter.mesh != null)
{
var mesh = meshFilter.mesh;
var colors = new Color[mesh.vertexCount];
for (var i = 0; i < colors.Length; i++)
{
colors[i] = Color.white;
}
mesh.colors = colors;
}
Shader? shader = null;
if (scrCamera.instance != null)
{
try
{
var camQuadMeshField = typeof(scrCamera).GetField("camQuadMesh", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (camQuadMeshField != null)
{
var renderer = camQuadMeshField.GetValue(scrCamera.instance) as MeshRenderer;
if (renderer != null && renderer.sharedMaterial != null)
{
shader = renderer.sharedMaterial.shader;
}
}
}
catch (Exception)
{
// ignored
}
}
if (shader == null) shader = Shader.Find("Sprites/Default");
if (shader == null) shader = Shader.Find("Unlit/Texture");
if (shader == null) shader = Shader.Find("Standard");
_material = new Material(shader)
{
color = Color.white
};
_quad.GetComponent<MeshRenderer>().material = _material;
// Set inactive initially to prevent white flash
_quad.SetActive(false);
}
}
}
+520 -66
View File
@@ -1,104 +1,515 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MelonLoader;
namespace ADOFAIRenderer.Util;
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")]
internal static class FolderDialogUtil
{
private static int _isDialogOpen;
public static void OpenFolderBrowser(string initialDirectory, Action<string?> onSelected)
{
if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0)
{
MelonLogger.Msg("[FolderDialogUtil] Folder dialog is already open.");
return;
}
var syncContext = SynchronizationContext.Current;
Task.Run(() =>
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string? selected = null;
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
selected = OpenWindowsFolderBrowser(initialDirectory);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
selected = OpenMacFolderBrowser(initialDirectory);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
selected = OpenLinuxFolderBrowser(initialDirectory);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}");
}
var ownerHandle = GetGameWindowHandle();
if (syncContext != null)
var thread = new Thread(() =>
{
syncContext.Post(_ => onSelected(selected), null);
}
else
string? selected = null;
int oleHr = -1;
try
{
oleHr = OleInitialize(IntPtr.Zero);
selected = OpenWindowsFolderBrowser(initialDirectory, ownerHandle);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}");
}
finally
{
if (oleHr >= 0)
{
try
{
OleUninitialize();
}
catch
{
// Ignore uninitialize error
}
}
Interlocked.Exchange(ref _isDialogOpen, 0);
}
DispatchResult(syncContext, onSelected, selected);
})
{
IsBackground = true,
Name = "FolderDialog-Win"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
else
{
Task.Run(() =>
{
string? selected = null;
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
selected = OpenMacFolderBrowser(initialDirectory);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
selected = OpenLinuxFolderBrowser(initialDirectory);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}");
}
finally
{
Interlocked.Exchange(ref _isDialogOpen, 0);
}
DispatchResult(syncContext, onSelected, selected);
});
}
}
private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected)
{
if (syncContext != null)
{
syncContext.Post(_ =>
{
try
{
onSelected(selected);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}");
}
}, null);
}
else
{
try
{
onSelected(selected);
}
});
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}");
}
}
}
private static string? OpenWindowsFolderBrowser(string initialDirectory)
#region Windows Implementation
private static readonly Guid CLSID_FileOpenDialog = new("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7");
private static readonly Guid IID_IFileOpenDialog = new("d57c7288-d4ad-4768-be02-9d969532d960");
private static readonly Guid IID_IShellItem = new("43826D1E-E718-42EE-BC55-A1E261C37BFE");
private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7);
[DllImport("user32.dll")]
private static extern IntPtr GetActiveWindow();
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
private static IntPtr GetGameWindowHandle()
{
try
{
var escapedDir = initialDirectory.Replace("'", "''");
var script = $@"
[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null
$f = New-Object System.Windows.Forms.FolderBrowserDialog
$f.Description = 'Select Output Directory'
if ([System.IO.Directory]::Exists('{escapedDir}')) {{
$f.SelectedPath = '{escapedDir}'
}}
$f.ShowNewFolderButton = $true
if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
[Console]::Write($f.SelectedPath)
}}";
var active = GetActiveWindow();
if (active != IntPtr.Zero) return active;
var bytes = System.Text.Encoding.Unicode.GetBytes(script);
var base64 = Convert.ToBase64String(bytes);
var startInfo = new ProcessStartInfo
var currentPid = (uint)Process.GetCurrentProcess().Id;
var fg = GetForegroundWindow();
if (fg != IntPtr.Zero)
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
GetWindowThreadProcessId(fg, out var fgPid);
if (fgPid == currentPid)
{
return fg;
}
}
using var process = Process.Start(startInfo);
if (process == null) return null;
var mainHandle = Process.GetCurrentProcess().MainWindowHandle;
if (mainHandle != IntPtr.Zero)
{
return mainHandle;
}
}
catch
{
// Ignore failure to detect window handle
}
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return IntPtr.Zero;
}
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle)
{
try
{
return OpenWindowsFolderBrowserModern(initialDirectory, ownerHandle);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}");
return null;
MelonLogger.Warning($"[FolderDialogUtil] Modern folder dialog failed ({ex.Message}), falling back to legacy dialog.");
return OpenWindowsFolderBrowserLegacy(initialDirectory, ownerHandle);
}
}
private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle)
{
// 1. Try native CoCreateInstance first (works on Mono and .NET without class registration)
var clsid = CLSID_FileOpenDialog;
var iid = IID_IFileOpenDialog;
int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out var dialog);
if (createHr != 0)
{
// 2. Fallback to Activator via Type.GetTypeFromCLSID
var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog);
if (comType != null)
{
var instance = Activator.CreateInstance(comType);
dialog = instance as IFileOpenDialog;
}
}
if (dialog == null)
{
throw new COMException("Failed to instantiate IFileOpenDialog via COM.", createHr);
}
try
{
dialog.SetOptions(FOS.FOS_PICKFOLDERS | FOS.FOS_FORCEFILESYSTEM | FOS.FOS_PATHMUSTEXIST);
dialog.SetTitle("Select Output Directory");
if (!string.IsNullOrWhiteSpace(initialDirectory))
{
try
{
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized))
{
var shellItemGuid = IID_IShellItem;
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
if (itemHr == 0)
{
try
{
dialog.SetFolder(initialItem);
dialog.SetDefaultFolder(initialItem);
}
finally
{
Marshal.ReleaseComObject(initialItem);
}
}
}
}
catch
{
// Ignore failure to set initial folder
}
}
int hr = dialog.Show(ownerHandle);
if (hr != 0 && hr != ERROR_CANCELLED_HR && ownerHandle != IntPtr.Zero)
{
// If Show failed with ownerHandle (e.g. cross-thread or invalid handle), retry without owner
MelonLogger.Msg($"[FolderDialogUtil] dialog.Show(ownerHandle) returned 0x{hr:X8}, retrying with IntPtr.Zero.");
hr = dialog.Show(IntPtr.Zero);
}
if (hr == ERROR_CANCELLED_HR)
{
return null;
}
if (hr != 0)
{
throw new COMException("IFileOpenDialog.Show failed", hr);
}
dialog.GetResult(out var resultItem);
try
{
if (resultItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var pszPath) == 0 && pszPath != IntPtr.Zero)
{
try
{
var path = Marshal.PtrToStringUni(pszPath);
return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null;
}
finally
{
Marshal.FreeCoTaskMem(pszPath);
}
}
}
finally
{
Marshal.ReleaseComObject(resultItem);
}
return null;
}
finally
{
Marshal.ReleaseComObject(dialog);
}
}
private static string? OpenWindowsFolderBrowserLegacy(string initialDirectory, IntPtr ownerHandle)
{
IntPtr pszDisplayName = Marshal.AllocHGlobal(260 * sizeof(char));
IntPtr initialDirPtr = IntPtr.Zero;
BrowseCallbackProc? callback = null;
IntPtr pidl = IntPtr.Zero;
try
{
var bi = new BROWSEINFO
{
hwndOwner = ownerHandle,
pidlRoot = IntPtr.Zero,
pszDisplayName = pszDisplayName,
lpszTitle = "Select Output Directory",
ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE | BIF_EDITBOX
};
if (!string.IsNullOrWhiteSpace(initialDirectory))
{
try
{
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized))
{
initialDirPtr = Marshal.StringToHGlobalUni(normalized);
callback = (hwnd, uMsg, _, lpData) =>
{
if (uMsg == BFFM_INITIALIZED && lpData != IntPtr.Zero)
{
SendMessage(hwnd, BFFM_SETSELECTIONW, (IntPtr)1, lpData);
}
return 0;
};
bi.lpfn = callback;
bi.lParam = initialDirPtr;
}
}
catch
{
// Ignore failure to prepare initial directory
}
}
pidl = SHBrowseForFolderW(ref bi);
if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero)
{
// If it returned null with ownerHandle, check if retry with IntPtr.Zero is appropriate
// But note: cancelling also returns Zero. We do not redundantly re-prompt if cancelled.
}
if (pidl != IntPtr.Zero)
{
var sb = new StringBuilder(260);
if (SHGetPathFromIDListW(pidl, sb))
{
var path = sb.ToString();
return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null;
}
}
return null;
}
finally
{
if (pidl != IntPtr.Zero)
{
Marshal.FreeCoTaskMem(pidl);
}
if (initialDirPtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(initialDirPtr);
}
if (pszDisplayName != IntPtr.Zero)
{
Marshal.FreeHGlobal(pszDisplayName);
}
GC.KeepAlive(callback);
}
}
#region COM & Win32 Interop Definitions
[Flags]
private enum FOS : uint
{
FOS_NOCHANGEDIR = 0x00000008,
FOS_PICKFOLDERS = 0x00000020,
FOS_FORCEFILESYSTEM = 0x00000040,
FOS_PATHMUSTEXIST = 0x00000800,
FOS_FILEMUSTEXIST = 0x00001000,
}
private enum SIGDN : uint
{
SIGDN_FILESYSPATH = 0x80058000
}
[ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItem
{
void BindToHandler(IntPtr pbc, [In] ref Guid bhid, [In] ref Guid riid, out IntPtr ppv);
void GetParent(out IShellItem ppsi);
[PreserveSig]
int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName);
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
void Compare(IShellItem psi, uint hint, out int piOrder);
}
[ComImport]
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileOpenDialog
{
[PreserveSig]
int Show(IntPtr parent);
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
void SetFileTypeIndex(uint iFileType);
void GetFileTypeIndex(out uint piFileType);
void Advise(IntPtr pfde, out uint pdwCookie);
void Unadvise(uint dwCookie);
void SetOptions(FOS fos);
void GetOptions(out FOS pfos);
void SetDefaultFolder(IShellItem psi);
void SetFolder(IShellItem psi);
void GetFolder(out IShellItem ppsi);
void GetCurrentSelection(out IShellItem ppsi);
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
void GetResult(out IShellItem ppsi);
void AddPlace(IShellItem psi, int fdap);
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
void Close(int hr);
void SetClientGuid(ref Guid guid);
void ClearClientData();
void SetFilter(IntPtr pFilter);
void GetResults(out IntPtr ppenum);
void GetSelectedItems(out IntPtr ppsai);
}
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern int OleInitialize(IntPtr pvReserved);
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern void OleUninitialize();
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern int CoCreateInstance(
[In] ref Guid rclsid,
IntPtr pUnkOuter,
uint dwClsContext,
[In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IFileOpenDialog ppv);
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
private static extern int SHCreateItemFromParsingName(
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
IntPtr pbc,
[In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
private const uint BIF_RETURNONLYFSDIRS = 0x0001;
private const uint BIF_NEWDIALOGSTYLE = 0x0040;
private const uint BIF_EDITBOX = 0x0010;
private const uint BFFM_INITIALIZED = 1;
private const uint BFFM_SETSELECTIONW = 0x0400 + 103;
private delegate int BrowseCallbackProc(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct BROWSEINFO
{
public IntPtr hwndOwner;
public IntPtr pidlRoot;
public IntPtr pszDisplayName;
public string lpszTitle;
public uint ulFlags;
public BrowseCallbackProc? lpfn;
public IntPtr lParam;
public int iImage;
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
#endregion
#endregion
#region macOS Implementation
private static string? OpenMacFolderBrowser(string initialDirectory)
{
try
{
var escaped = initialDirectory.Replace("\"", "\\\"");
var script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location \"{escaped}\")";
string script;
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
var escaped = initialDirectory.Replace("\\", "\\\\").Replace("\"", "\\\"");
script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location (POSIX file \"{escaped}\"))";
}
else
{
script = "POSIX path of (choose folder with prompt \"Select Output Directory\")";
}
var startInfo = new ProcessStartInfo
{
@@ -119,12 +530,17 @@ if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
}
catch
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Mac folder dialog error: {ex.Message}");
return null;
}
}
#endregion
#region Linux Implementation
private static string? OpenLinuxFolderBrowser(string initialDirectory)
{
try
@@ -145,17 +561,55 @@ if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
startInfo.ArgumentList.Add($"--filename={initialDirectory}");
}
using var process = Process.Start(startInfo);
if (process == null) return null;
try
{
using var process = Process.Start(startInfo);
if (process != null)
{
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output))
{
return output;
}
}
}
catch
{
// Fallback to kdialog if zenity fails to start
}
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
var kdialogInfo = new ProcessStartInfo
{
FileName = "kdialog",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
kdialogInfo.ArgumentList.Add("--getexistingdirectory");
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
kdialogInfo.ArgumentList.Add(initialDirectory);
}
kdialogInfo.ArgumentList.Add("--title");
kdialogInfo.ArgumentList.Add("Select Output Directory");
using var kdialogProcess = Process.Start(kdialogInfo);
if (kdialogProcess == null) return null;
var koutput = kdialogProcess.StandardOutput.ReadToEnd().Trim();
kdialogProcess.WaitForExit();
return !string.IsNullOrEmpty(koutput) && Directory.Exists(koutput) ? koutput : null;
}
catch
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Linux folder dialog error: {ex.Message}");
return null;
}
}
#endregion
}
+40 -58
View File
@@ -71,72 +71,55 @@ namespace ADOFAIRenderer.Util
state.IsInFlight = true;
}
// Check if GPU color conversion is enabled
bool useGPUConversion = ModAssets.ColorConversionShader;
AVPixelFormat expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
if (useGPUConversion)
// Check if GPU color conversion is available
var expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
var kernelIndex = -1;
if (ModAssets.ColorConversionShader)
{
expectedFmt = _targetFormat;
// Compute Shader only supports NV12 and YUV420P; fallback to CPU for other formats
if (expectedFmt != AVPixelFormat.AV_PIX_FMT_NV12 && expectedFmt != AVPixelFormat.AV_PIX_FMT_YUV420P)
var kernalName = _targetFormat switch
{
useGPUConversion = false;
}
AVPixelFormat.AV_PIX_FMT_NV12 => "CSMain_NV12",
AVPixelFormat.AV_PIX_FMT_YUV420P => "CSMain_YUV420P",
_ => null
};
if (kernalName != null)
kernelIndex = ModAssets.ColorConversionShader.FindKernel(kernalName);
}
var useGPUConversion = kernelIndex >= 0;
if (useGPUConversion) expectedFmt = _targetFormat;
var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel
state.Setup(currentFrame, bufferSize, useGPUConversion, expectedFmt, texture.width, texture.height);
if (useGPUConversion && state.GPUBuffer != null && ModAssets.ColorConversionShader)
{
int kernelIndex = -1;
if (expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12)
{
kernelIndex = ModAssets.ColorConversionShader.FindKernel("CSMain_NV12");
}
else if (expectedFmt == AVPixelFormat.AV_PIX_FMT_YUV420P)
{
kernelIndex = ModAssets.ColorConversionShader.FindKernel("CSMain_YUV420P");
}
ModAssets.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture);
ModAssets.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
ModAssets.ColorConversionShader.SetInt(Width, texture.width);
ModAssets.ColorConversionShader.SetInt(Height, texture.height);
if (kernelIndex >= 0)
{
ModAssets.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture);
ModAssets.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
ModAssets.ColorConversionShader.SetInt(Width, texture.width);
ModAssets.ColorConversionShader.SetInt(Height, texture.height);
var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12
? (texture.width + 31) / 32
: (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16;
var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12
? (texture.width + 31) / 32
: (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16;
ModAssets.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1);
ModAssets.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1);
AsyncGPUReadback.Request(
state.GPUBuffer,
state.CachedCallback
);
}
else
{
// If kernel is not found, fallback to standard RGBA texture readback
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer,
texture,
0,
state.CachedCallback
);
}
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
);
/*AsyncGPUReadback.Request(
state.GPUBuffer,
state.CachedCallback
);*/
}
else
{
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer,
texture,
0,
state.CachedCallback
ref state.NativeBuffer, texture, 0, state.CachedCallback
);
}
}
@@ -169,16 +152,14 @@ namespace ADOFAIRenderer.Util
}
Melon<RenderMod>.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame.");
var byteSize = state.IsGPUConversion
? state.Width * state.Height * 3 / 2
: state.Width * state.Height * 4;
var byteSize = state.Width * state.Height * 3 / 2;
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
Array.Clear(blackFrame, 0, byteSize);
var capFrame = new RenderManager.CapturedFrame
{
ManagedBuffer = blackFrame,
Length = byteSize
ManagedBuffer = blackFrame, Length = byteSize,
IsColorConverted = true
};
PushToQueue(frame, capFrame);
RecycleState(state);
@@ -187,19 +168,20 @@ namespace ADOFAIRenderer.Util
{
unsafe
{
/*
if (state.IsGPUConversion)
{
var data = request.GetData<byte>();
// Copy async request buffer data to persistent NativeBuffer memory
NativeArray<byte>.Copy(data, state.NativeBuffer, data.Length);
}
*/
var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr();
var capFrame = new RenderManager.CapturedFrame
{
NativePointer = ptr,
Length = state.NativeBuffer.Length,
ReadbackState = state
NativePointer = ptr, Length = state.NativeBuffer.Length,
ReadbackState = state, IsColorConverted = state.IsGPUConversion
};
PushToQueue(frame, capFrame);
}
+113 -113
View File
@@ -2,152 +2,152 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util
namespace ADOFAIRenderer.Util;
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum GPUVendor
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum GPUVendor
{
Unknown, NVIDIA, AMD, Intel, Apple
}
Unknown, NVIDIA, AMD, Intel, Apple
}
internal static class GPUUtil
internal static class GPUUtil
{
private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
private const string IntelPattern =
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
// ReSharper disable once InconsistentNaming
private const string AMDPattern =
@"^AMD Radeon RX [0-9]+(?> XT)?$";
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
private static bool _hasCachedVendor;
private static GPUVendor GetActiveGPUVendor()
{
private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
if (_hasCachedVendor) return _cachedVendor;
private const string IntelPattern =
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
// ReSharper disable once InconsistentNaming
private const string AMDPattern =
@"^AMD Radeon RX [0-9]+(?> XT)?$";
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
private static bool _hasCachedVendor;
private static GPUVendor GetActiveGPUVendor()
{
if (_hasCachedVendor) return _cachedVendor;
var gpuName = SystemInfo.graphicsDeviceName;
Melon<RenderMod>.Logger.Msg($"Active GPU: {gpuName}");
var gpuName = SystemInfo.graphicsDeviceName;
Melon<RenderMod>.Logger.Msg($"Active GPU: {gpuName}");
_cachedVendor = GPUVendor.Unknown;
foreach (Match unused in Regex.Matches(gpuName, NvidiaPattern))
_cachedVendor = GPUVendor.Unknown;
foreach (Match unused in Regex.Matches(gpuName, NvidiaPattern))
{
_cachedVendor = GPUVendor.NVIDIA;
break;
}
if (_cachedVendor == GPUVendor.Unknown)
{
foreach (Match unused in Regex.Matches(gpuName, IntelPattern))
{
_cachedVendor = GPUVendor.NVIDIA;
_cachedVendor = GPUVendor.Intel;
break;
}
}
if (_cachedVendor == GPUVendor.Unknown)
if (_cachedVendor == GPUVendor.Unknown)
{
foreach (Match unused in Regex.Matches(gpuName, AMDPattern))
{
foreach (Match unused in Regex.Matches(gpuName, IntelPattern))
{
_cachedVendor = GPUVendor.Intel;
break;
}
_cachedVendor = GPUVendor.AMD;
break;
}
if (_cachedVendor == GPUVendor.Unknown)
{
foreach (Match unused in Regex.Matches(gpuName, AMDPattern))
{
_cachedVendor = GPUVendor.AMD;
break;
}
}
if (_cachedVendor == GPUVendor.Unknown && Application.platform == RuntimePlatform.OSXPlayer)
{
_cachedVendor = GPUVendor.Apple;
}
_hasCachedVendor = true;
return _cachedVendor;
}
private static double? _cachedNvidiaVersion;
private static bool _hasCachedNvidiaVersion;
public static double? GetNvidiaDriverVersion()
if (_cachedVendor == GPUVendor.Unknown && Application.platform == RuntimePlatform.OSXPlayer)
{
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
_cachedVendor = GPUVendor.Apple;
}
_hasCachedVendor = true;
return _cachedVendor;
}
_hasCachedNvidiaVersion = true;
_cachedNvidiaVersion = null;
private static double? _cachedNvidiaVersion;
private static bool _hasCachedNvidiaVersion;
if (GetActiveGPUVendor() != GPUVendor.NVIDIA) return null;
public static double? GetNvidiaDriverVersion()
{
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
// 1. Try parsing from UnityEngine.SystemInfo.graphicsDeviceVersion
var deviceVersion = SystemInfo.graphicsDeviceVersion;
if (!string.IsNullOrEmpty(deviceVersion))
_hasCachedNvidiaVersion = true;
_cachedNvidiaVersion = null;
if (GetActiveGPUVendor() != GPUVendor.NVIDIA) return null;
// 1. Try parsing from UnityEngine.SystemInfo.graphicsDeviceVersion
var deviceVersion = SystemInfo.graphicsDeviceVersion;
if (!string.IsNullOrEmpty(deviceVersion))
{
var match = Regex.Match(deviceVersion, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
if (match.Success)
{
var match = Regex.Match(deviceVersion, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
if (match.Success)
if (int.TryParse(match.Groups[3].Value, out var sub) && int.TryParse(match.Groups[4].Value, out int build))
{
if (int.TryParse(match.Groups[3].Value, out var sub) && int.TryParse(match.Groups[4].Value, out int build))
{
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from SystemInfo: {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from SystemInfo: {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
}
}
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) return null;
const string classKey = @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
if (subkeys == null) return null;
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) return null;
const string classKey = @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
if (subkeys == null) return null;
foreach (var subkey in subkeys)
{
var subkeyPath = classKey + "\\" + subkey;
var provider = RegistryUtil.GetHKLMValue(subkeyPath, "ProviderName");
if (provider == null || !provider.Contains("NVIDIA")) continue;
foreach (var subkey in subkeys)
{
var subkeyPath = classKey + "\\" + subkey;
var provider = RegistryUtil.GetHKLMValue(subkeyPath, "ProviderName");
if (provider == null || !provider.Contains("NVIDIA")) continue;
var driverVersionStr = RegistryUtil.GetHKLMValue(subkeyPath, "DriverVersion");
if (string.IsNullOrEmpty(driverVersionStr)) continue;
var driverVersionStr = RegistryUtil.GetHKLMValue(subkeyPath, "DriverVersion");
if (string.IsNullOrEmpty(driverVersionStr)) continue;
var regMatch = Regex.Match(driverVersionStr, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
if (!regMatch.Success) continue;
if (!int.TryParse(regMatch.Groups[3].Value, out var sub) ||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
var regMatch = Regex.Match(driverVersionStr, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
if (!regMatch.Success) continue;
if (!int.TryParse(regMatch.Groups[3].Value, out var sub) ||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100f;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
return null;
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100f;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
return null;
}
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
{
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
if (forceSoftware)
{
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
if (forceSoftware)
{
return encoders.Where(e => !e.IsHardware).ToList();
}
var vendor = GetActiveGPUVendor();
var prioritized = new List<VideoEncoder>();
var others = new List<VideoEncoder>();
foreach (var item in encoders)
{
if (item.Vendor == vendor) prioritized.Add(item);
else others.Add(item);
}
prioritized.AddRange(others);
return prioritized;
return encoders.Where(e => !e.IsHardware).ToList();
}
var vendor = GetActiveGPUVendor();
var prioritized = new List<VideoEncoder>();
var others = new List<VideoEncoder>();
foreach (var item in encoders)
{
if (item.Vendor == vendor) prioritized.Add(item);
else others.Add(item);
}
prioritized.AddRange(others);
return prioritized;
}
}
+2
View File
@@ -63,6 +63,7 @@ namespace ADOFAIRenderer.Util
}
}
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
@@ -94,6 +95,7 @@ namespace ADOFAIRenderer.Util
}
}
// ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
+517
View File
@@ -0,0 +1,517 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util;
// ReSharper disable once InconsistentNaming
internal class VideoBGPlayer : MonoBehaviour
{
private static readonly int YTex = Shader.PropertyToID("_YTex");
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
private static readonly int Result = Shader.PropertyToID("_Result");
private static readonly int Width = Shader.PropertyToID("Width");
private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int UTex = Shader.PropertyToID("_UTex");
private static readonly int VTex = Shader.PropertyToID("_VTex");
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
[SuppressMessage("ReSharper", "InconsistentNaming")]
private enum GPUMode
{
None,
YUV420P,
NV12
}
private FFmpegNativeDecoder? _decoder;
// CPU fallback rendering
// ReSharper disable once InconsistentNaming
private Texture2D? _textureRGBA;
// GPU conversion textures
private Texture2D? _textureY;
private Texture2D? _textureU;
private Texture2D? _textureV;
private Texture2D? _textureUV;
// Compute Shader variables
private ComputeShader? _computeShader;
private RenderTexture? _renderTexture;
private int _kernelIndex = -1;
private double _currentFrameTimestamp;
private double _nextFrameTimestamp;
private GPUMode _gpuMode = GPUMode.None;
private RenderTexture? _targetTexture;
private bool _isLooping;
private bool _isInitialized;
private GameObject? _quad;
private Material? _material;
// ReSharper disable once InconsistentNaming
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
{
CleanUp();
_targetTexture = targetTexture;
_isLooping = loop;
if (_targetTexture == null && targetCamera != null)
{
SetupBackgroundQuad(targetCamera, videoBGGo);
}
if (!File.Exists(videoPath))
{
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] Video file does not exist: {videoPath}");
return;
}
try
{
_decoder = new FFmpegNativeDecoder(videoPath);
_computeShader = ModAssets.ColorConversionShader;
var w = _decoder.Width;
var h = _decoder.Height;
var pixFmt = _decoder.PixelFormat;
var gpuInitSuccess = false;
if (_computeShader != null)
{
switch (pixFmt)
{
case AVPixelFormat.AV_PIX_FMT_NV12:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureUV = new Texture2D(w / 2, h / 2, TextureFormat.RG16, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureUV);
_gpuMode = GPUMode.NV12;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader NV12 mode successfully");
}
catch
{
// ignored
}
break;
case AVPixelFormat.AV_PIX_FMT_YUV420P:
case AVPixelFormat.AV_PIX_FMT_YUVJ420P:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureU = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
_textureV = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureU);
ConfigureTexture(_textureV);
_gpuMode = GPUMode.YUV420P;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader YUV420P mode successfully");
}
catch
{
// ignored
}
break;
default:
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Cannot use Compute Shader for colorspace conversion.");
break;
}
}
if (gpuInitSuccess && _computeShader != null)
{
_renderTexture = new RenderTexture(w, h, 0, RenderTextureFormat.ARGB32)
{
enableRandomWrite = true
};
_renderTexture.Create();
}
else
{
_textureRGBA = new Texture2D(w, h, TextureFormat.RGBA32, false);
ConfigureTexture(_textureRGBA);
_gpuMode = GPUMode.None;
Melon<RenderMod>.Logger.Warning("[FFmpegVideoBGPlayer] GPU acceleration unsupported or failed to load, falling back to CPU (RGBA).");
}
_currentFrameTimestamp = 0.0;
_nextFrameTimestamp = 0.0;
_isInitialized = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Successfully initialized - Resolution: {w}x{h}, FPS: {_decoder.FPS:F2}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during initialization: {ex}");
CleanUp();
}
}
private static void ConfigureTexture(Texture2D tex)
{
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
}
private void LateUpdate()
{
if (!_isInitialized || _decoder == null) return;
var cond = scrConductor.instance;
if (!cond || !cond.hasSongStarted) return;
var pitch = cond.song.pitch;
var separateCountdownOffset = cond.separateCountdownTime ?
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
var songTime = (cond.dspTime - cond.dspTimeSong) * pitch - cond.addoffset - separateCountdownOffset;
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
var targetVideoTime = songTime + vidOffset;
if (targetVideoTime < 0)
{
if (_targetTexture)
{
var active = RenderTexture.active;
RenderTexture.active = _targetTexture;
GL.Clear(true, true, Color.black);
RenderTexture.active = active;
}
if (_quad)
{
_quad.SetActive(false);
}
return;
}
if (ADOBase.customLevel)
{
ADOBase.customLevel.ShowTutorialBackground(false);
}
try
{
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
{
_decoder.SeekToTime(targetVideoTime);
_currentFrameTimestamp = targetVideoTime;
_nextFrameTimestamp = targetVideoTime;
}
bool frameUpdated = false;
while (targetVideoTime >= _nextFrameTimestamp)
{
double timestamp = 0.0;
bool success = false;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV)
{
success = _decoder.TryReadNV12Frame(
_textureY.GetRawTextureData<byte>(),
_textureUV.GetRawTextureData<byte>(),
out timestamp
);
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV)
{
success = _decoder.TryReadYUV420PFrame(
_textureY.GetRawTextureData<byte>(),
_textureU.GetRawTextureData<byte>(),
_textureV.GetRawTextureData<byte>(),
out timestamp
);
}
else
{
if (_textureRGBA != null)
{
success = _decoder.TryReadFrame(
_textureRGBA.GetRawTextureData<byte>(),
vflip: true,
out timestamp
);
}
}
if (success)
{
_currentFrameTimestamp = timestamp;
_nextFrameTimestamp = timestamp + (1.0 / _decoder.FPS);
frameUpdated = true;
}
else
{
if (_isLooping)
{
_decoder.SeekToTime(0);
_currentFrameTimestamp = 0;
_nextFrameTimestamp = 1.0 / _decoder.FPS;
break;
}
else
{
break;
}
}
}
if (frameUpdated)
{
RenderToTarget();
}
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during playback update: {ex}");
_isInitialized = false;
}
}
private void RenderToTarget()
{
if (_decoder == null) return;
int w = _decoder.Width;
int h = _decoder.Height;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureUV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UVTex, _textureUV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureU.Apply();
_textureV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UTex, _textureU);
_computeShader.SetTexture(_kernelIndex, VTex, _textureV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_textureRGBA)
{
_textureRGBA.Apply();
if (_targetTexture)
{
Graphics.Blit(_textureRGBA, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _textureRGBA)
{
_material.mainTexture = _textureRGBA;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
}
private void OnDestroy()
{
CleanUp();
}
public void CleanUp()
{
_isInitialized = false;
if (_decoder != null)
{
_decoder.Dispose();
_decoder = null;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Decoder successfully disposed.");
}
if (_textureRGBA != null)
{
Destroy(_textureRGBA);
_textureRGBA = null;
}
if (_textureY != null) Destroy(_textureY);
if (_textureU != null) Destroy(_textureU);
if (_textureV != null) Destroy(_textureV);
if (_textureUV != null) Destroy(_textureUV);
_textureY = null;
_textureU = null;
_textureV = null;
_textureUV = null;
if (_renderTexture != null)
{
_renderTexture.Release();
Destroy(_renderTexture);
_renderTexture = null;
}
if (_quad != null)
{
Destroy(_quad);
_quad = null;
}
if (_material != null)
{
Destroy(_material);
_material = null;
}
}
// ReSharper disable once InconsistentNaming
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
{
_quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
_quad.name = "FFmpegVideoBGQuad";
// ReSharper disable once Unity.UnresolvedComponentOrScriptableObject
var col = _quad.GetComponent("MeshCollider");
if (col) Destroy(col);
_quad.transform.parent = targetCamera.transform;
var zPos = targetCamera.farClipPlane - 0.5f;
_quad.transform.localPosition = new Vector3(0f, 0f, zPos);
_quad.transform.localRotation = Quaternion.identity;
float height;
float width;
if (targetCamera.orthographic)
{
height = 2.0f * targetCamera.orthographicSize;
width = height * targetCamera.aspect;
}
else
{
height = 2.0f * zPos * Mathf.Tan(targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
width = height * targetCamera.aspect;
}
_quad.transform.localScale = new Vector3(width, height, 1.0f);
_quad.layer = videoBGGo.layer;
// Set vertex colors to white so Sprites/Default works properly (otherwise it may draw transparently)
var meshFilter = _quad.GetComponent<MeshFilter>();
if (meshFilter != null && meshFilter.mesh != null)
{
var mesh = meshFilter.mesh;
var colors = new Color[mesh.vertexCount];
for (var i = 0; i < colors.Length; i++)
{
colors[i] = Color.white;
}
mesh.colors = colors;
}
Shader? shader = null;
if (scrCamera.instance != null)
{
try
{
var camQuadMeshField = typeof(scrCamera).GetField("camQuadMesh", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (camQuadMeshField != null)
{
var renderer = camQuadMeshField.GetValue(scrCamera.instance) as MeshRenderer;
if (renderer != null && renderer.sharedMaterial != null)
{
shader = renderer.sharedMaterial.shader;
}
}
}
catch (Exception)
{
// ignored
}
}
if (shader == null) shader = Shader.Find("Sprites/Default");
if (shader == null) shader = Shader.Find("Unlit/Texture");
if (shader == null) shader = Shader.Find("Standard");
_material = new Material(shader)
{
color = Color.white
};
_quad.GetComponent<MeshRenderer>().material = _material;
// Set inactive initially to prevent white flash
_quad.SetActive(false);
}
}