SHA256
Compare commits
40
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
72d8d71928
|
||
|
|
413778e165
|
||
|
|
36552511f5
|
||
|
|
a1f9fa36ce
|
||
|
|
00971a6730
|
||
|
|
bd24152a2e
|
||
|
|
7aa8ae4b54
|
||
|
|
dee3b2dbcf
|
||
|
|
7eec3b4660
|
||
|
|
ce70d89cdb
|
||
|
|
7db1905906
|
||
|
|
bfc79ee4d3
|
||
|
|
b4c950c9f7
|
||
|
|
723f4446f5
|
||
|
|
6f4c632b41
|
||
|
|
863dcd2357
|
||
|
|
00048b7b06
|
||
|
|
d218bb835d
|
||
|
|
34d766cb4d
|
||
|
|
5d805b85ad
|
||
|
|
ba83935190
|
||
|
|
d2390665af
|
||
|
|
ceb3190274
|
||
|
|
08b13460ae
|
||
|
|
ed7dd5dba4 | ||
|
|
eb648bbe09
|
||
|
|
f1d56f5f9d
|
||
|
|
ed61cef4bb
|
||
|
|
2cfd14e348
|
||
|
|
daed6adcd7
|
||
|
|
86c8a87a0f
|
||
|
|
892f1ca7b9
|
||
|
|
e7ef081e55
|
||
|
|
a523efd4fc
|
||
|
|
b43c8be5dc
|
||
|
|
671bdda9a1
|
||
|
|
8567878f07
|
||
|
|
b900a5444e
|
||
|
|
2d70fb5ae8
|
||
|
|
0d9cf44c77
|
No files matched your search
+9
-3
@@ -32,9 +32,11 @@ Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
## Agent Metadata
|
||||
.antigravitycli/
|
||||
.agent/
|
||||
GEMINI.md
|
||||
/.antigravitycli/
|
||||
/.agent/
|
||||
/GEMINI.md
|
||||
/.mcp.json
|
||||
/CLAUDE.md
|
||||
|
||||
# Decompiled Game Code
|
||||
*.cs.txt
|
||||
@@ -43,3 +45,7 @@ GEMINI.md
|
||||
/AsyncInputAutoplayFix.md
|
||||
/AudioMixerExplanation.md
|
||||
/O5Theme.md
|
||||
|
||||
# Developer Utility Files
|
||||
/REVIEW_CHECKLIST.md
|
||||
/REVIEW_FFMPEG.md
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace ADOFAIRenderer.API;
|
||||
|
||||
/// <summary>
|
||||
/// Execution context provided to frame modification hooks, managing GPU ping-pong double buffering.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Zero-Allocation Notice:</b> For maximum performance on the render hot path, the pipeline reuses context instances
|
||||
/// across frames. Do not cache the context instance itself across frames.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Execution Frequency Notice:</b> This hook is executed only when a frame is captured for video encoding.
|
||||
/// When <c>RenderFPS</c> exceeds <c>VideoFPS</c>, intermediate game rendering frames are skipped and the hook
|
||||
/// will <b>not</b> be invoked on every rendered game frame.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
|
||||
public class FrameHookContext
|
||||
{
|
||||
private RenderTexture _originalSource = null!;
|
||||
private RenderTexture _pingRT = null!;
|
||||
private RenderTexture _pongRT = null!;
|
||||
|
||||
private RenderTexture _currentSource = null!;
|
||||
private RenderTexture _currentDestination = null!;
|
||||
private bool _hasModified;
|
||||
|
||||
/// <summary>
|
||||
/// The input <see cref="RenderTexture"/> containing the rendered frame before the current pass.
|
||||
/// </summary>
|
||||
public RenderTexture Source => _currentSource;
|
||||
|
||||
/// <summary>
|
||||
/// The writable destination <see cref="RenderTexture"/> for the current pass.
|
||||
/// </summary>
|
||||
public RenderTexture Destination => _currentDestination;
|
||||
|
||||
/// <summary>
|
||||
/// The active <see cref="RenderTexture"/> representing the current state of the modified frame.
|
||||
/// </summary>
|
||||
public RenderTexture Current => _currentSource;
|
||||
|
||||
/// <summary>
|
||||
/// The original unmodified render texture captured from the game for the current frame before any hooks were applied.
|
||||
/// </summary>
|
||||
public RenderTexture OriginalSource => _originalSource;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether any modifications or swaps have been applied to this frame.
|
||||
/// </summary>
|
||||
public bool HasModified => _hasModified;
|
||||
|
||||
/// <summary>
|
||||
/// The sequential 0-based index of the video frame currently being encoded.
|
||||
/// </summary>
|
||||
public long FrameIndex { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Alias for <see cref="FrameIndex"/>. The sequential 0-based index of the video frame.
|
||||
/// </summary>
|
||||
public long VideoFrameIndex => FrameIndex;
|
||||
|
||||
/// <summary>
|
||||
/// The sequential 0-based index of the game render frame.
|
||||
/// </summary>
|
||||
public long RenderFrameIndex { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The timestamp of the current frame in seconds relative to video start.
|
||||
/// </summary>
|
||||
public double VideoTime { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The delta time duration in seconds of a single video frame (equivalent to 1.0 / VideoFPS).
|
||||
/// </summary>
|
||||
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// The width of the frame in pixels.
|
||||
/// </summary>
|
||||
public int Width => _currentSource.width;
|
||||
|
||||
/// <summary>
|
||||
/// The height of the frame in pixels.
|
||||
/// </summary>
|
||||
public int Height => _currentSource.height;
|
||||
|
||||
/// <summary>
|
||||
/// The configured render frames per second.
|
||||
/// </summary>
|
||||
public double RenderFPS { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The output video frames per second.
|
||||
/// </summary>
|
||||
public double VideoFPS { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of successful modification passes completed for the current frame.
|
||||
/// </summary>
|
||||
public int PassCount { get; private set; }
|
||||
|
||||
internal FrameHookContext(
|
||||
RenderTexture originalSource,
|
||||
RenderTexture pingRT,
|
||||
RenderTexture pongRT,
|
||||
RenderTexture firstDestination,
|
||||
long frameIndex,
|
||||
long renderFrameIndex,
|
||||
double videoTime,
|
||||
double renderFps,
|
||||
double videoFps)
|
||||
{
|
||||
Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps);
|
||||
}
|
||||
|
||||
internal void Reset(
|
||||
RenderTexture originalSource,
|
||||
RenderTexture pingRT,
|
||||
RenderTexture pongRT,
|
||||
RenderTexture firstDestination,
|
||||
long frameIndex,
|
||||
long renderFrameIndex,
|
||||
double videoTime,
|
||||
double renderFps,
|
||||
double videoFps)
|
||||
{
|
||||
_originalSource = originalSource;
|
||||
_pingRT = pingRT;
|
||||
_pongRT = pongRT;
|
||||
|
||||
_currentSource = originalSource;
|
||||
_currentDestination = firstDestination;
|
||||
_hasModified = false;
|
||||
PassCount = 0;
|
||||
|
||||
FrameIndex = frameIndex;
|
||||
RenderFrameIndex = renderFrameIndex;
|
||||
VideoTime = videoTime;
|
||||
RenderFPS = renderFps;
|
||||
VideoFPS = videoFps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
||||
/// </summary>
|
||||
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
|
||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||
public void Blit(Material? material = null, int pass = -1)
|
||||
{
|
||||
if (material != null)
|
||||
{
|
||||
if (pass >= 0)
|
||||
Graphics.Blit(_currentSource, _currentDestination, material, pass);
|
||||
else
|
||||
Graphics.Blit(_currentSource, _currentDestination, material);
|
||||
}
|
||||
else
|
||||
{
|
||||
Graphics.Blit(_currentSource, _currentDestination);
|
||||
}
|
||||
|
||||
Swap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blits from a custom source <see cref="Texture"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
||||
/// </summary>
|
||||
/// <param name="customSource">The custom source texture to blit from.</param>
|
||||
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
|
||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customSource"/> is null.</exception>
|
||||
public void Blit(Texture customSource, Material? material = null, int pass = -1)
|
||||
{
|
||||
if (customSource == null) throw new ArgumentNullException(nameof(customSource));
|
||||
|
||||
if (material != null)
|
||||
{
|
||||
if (pass >= 0)
|
||||
Graphics.Blit(customSource, _currentDestination, material, pass);
|
||||
else
|
||||
Graphics.Blit(customSource, _currentDestination, material);
|
||||
}
|
||||
else
|
||||
{
|
||||
Graphics.Blit(customSource, _currentDestination);
|
||||
}
|
||||
|
||||
Swap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the contents of <see cref="Source"/> into <see cref="Destination"/> without swapping buffers.
|
||||
/// Call this before drawing custom overlays into <see cref="Destination"/> if you wish to preserve the background.
|
||||
/// </summary>
|
||||
public void Copy()
|
||||
{
|
||||
Graphics.Blit(_currentSource, _currentDestination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears <see cref="Destination"/> with the specified color.
|
||||
/// </summary>
|
||||
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
|
||||
public void ClearDestination(Color? clearColor = null)
|
||||
{
|
||||
var prev = RenderTexture.active;
|
||||
try
|
||||
{
|
||||
RenderTexture.active = _currentDestination;
|
||||
GL.Clear(false, true, clearColor ?? Color.clear);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = prev;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporarily activates <see cref="Destination"/> as <see cref="RenderTexture.active"/> to perform custom draw calls,
|
||||
/// safely restoring the previous active RenderTexture afterwards.
|
||||
/// </summary>
|
||||
/// <param name="drawAction">The action that performs custom draw operations.</param>
|
||||
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after drawing.</param>
|
||||
/// <param name="preserveBackground">If <c>true</c>, copies <see cref="Source"/> into <see cref="Destination"/> before executing <paramref name="drawAction"/>.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="drawAction"/> is null.</exception>
|
||||
public void DrawToDestination(Action drawAction, bool autoSwap = true, bool preserveBackground = false)
|
||||
{
|
||||
if (drawAction == null) throw new ArgumentNullException(nameof(drawAction));
|
||||
|
||||
if (preserveBackground)
|
||||
{
|
||||
Copy();
|
||||
}
|
||||
|
||||
var prev = RenderTexture.active;
|
||||
try
|
||||
{
|
||||
RenderTexture.active = _currentDestination;
|
||||
drawAction();
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = prev;
|
||||
}
|
||||
|
||||
if (autoSwap)
|
||||
{
|
||||
Swap();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
|
||||
/// </summary>
|
||||
/// <param name="commandBuffer">The command buffer to execute.</param>
|
||||
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after execution.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="commandBuffer"/> is null.</exception>
|
||||
public void ExecuteCommandBuffer(CommandBuffer commandBuffer, bool autoSwap = true)
|
||||
{
|
||||
if (commandBuffer == null) throw new ArgumentNullException(nameof(commandBuffer));
|
||||
Graphics.ExecuteCommandBuffer(commandBuffer);
|
||||
if (autoSwap)
|
||||
{
|
||||
Swap();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swaps the active <see cref="Source"/> and <see cref="Destination"/> ping-pong buffers.
|
||||
/// Call this after manually writing or rendering into <see cref="Destination"/> so that subsequent hooks or the video encoder read the updated frame.
|
||||
/// </summary>
|
||||
public void Swap()
|
||||
{
|
||||
_hasModified = true;
|
||||
PassCount++;
|
||||
|
||||
var previousDestination = _currentDestination;
|
||||
_currentSource = previousDestination;
|
||||
_currentDestination = (previousDestination == _pingRT) ? _pongRT : _pingRT;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
|
||||
/// </summary>
|
||||
/// <param name="customTexture">The custom texture to set as the active frame.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
|
||||
public void ReplaceCurrent(RenderTexture customTexture)
|
||||
{
|
||||
_currentSource = customTexture ?? throw new ArgumentNullException(nameof(customTexture));
|
||||
_hasModified = true;
|
||||
PassCount++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ADOFAIRenderer.API;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a custom frame modification hook that can modify rendered frames using GPU ping-pong double buffering.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Execution Frequency Note:</b> Frame hooks are only invoked for frames that are actually encoded into the final video (<c>VideoFPS</c>).
|
||||
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped and this hook
|
||||
/// will <b>not</b> be called on every game frame.
|
||||
/// </remarks>
|
||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||
public interface IFrameHook
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoked for each captured video frame before it is encoded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is not called on every rendered game frame if <c>RenderFPS</c> exceeds <c>VideoFPS</c>.
|
||||
/// </remarks>
|
||||
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
|
||||
void OnRenderFrame(FrameHookContext context);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Util;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.API;
|
||||
|
||||
@@ -11,8 +13,47 @@ namespace ADOFAIRenderer.API;
|
||||
/// Fluent builder for configuring and starting a render session.
|
||||
/// </summary>
|
||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||
public class RenderSession
|
||||
{
|
||||
private sealed class GlobalHookRegistration : IDisposable
|
||||
{
|
||||
public object Key { get; }
|
||||
public Action<FrameHookContext> Action { get; }
|
||||
private bool _isDisposed;
|
||||
|
||||
public GlobalHookRegistration(object key, Action<FrameHookContext> action)
|
||||
{
|
||||
Key = key;
|
||||
Action = action;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
RemoveGlobalRegistration(this);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SessionHookEntry
|
||||
{
|
||||
public object Key { get; }
|
||||
public Action<FrameHookContext> Action { get; }
|
||||
|
||||
public SessionHookEntry(object key, Action<FrameHookContext> action)
|
||||
{
|
||||
Key = key;
|
||||
Action = action;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly List<GlobalHookRegistration> GlobalFrameHooks = new();
|
||||
private static readonly object GlobalHooksLock = new();
|
||||
private static int _globalHooksVersion;
|
||||
|
||||
private readonly List<SessionHookEntry> _frameHooks = new();
|
||||
private bool _auto = true;
|
||||
|
||||
/// <summary>
|
||||
@@ -126,6 +167,331 @@ public class RenderSession
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom frame modification hook to be executed for each encoded video frame.
|
||||
/// The hook can modify the rendered frame using GPU ping-pong double buffering.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Execution Frequency Note:</b> This hook is invoked only for frames that are captured for the output video.
|
||||
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
|
||||
/// so this hook will <b>not</b> be called on every game frame.
|
||||
/// </remarks>
|
||||
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
||||
public RenderSession AddFrameHook(Action<FrameHookContext> hook)
|
||||
{
|
||||
if (hook == null) throw new ArgumentNullException(nameof(hook));
|
||||
_frameHooks.Add(new SessionHookEntry(hook, hook));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a material-based post-processing pass to be executed for each encoded video frame.
|
||||
/// Automatically performs a GPU blit from source to destination and swaps buffers.
|
||||
/// </summary>
|
||||
/// <param name="material">The material containing the shader to apply.</param>
|
||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="material"/> is null.</exception>
|
||||
public RenderSession AddFrameHook(Material material, int pass = -1)
|
||||
{
|
||||
if (material is null) throw new ArgumentNullException(nameof(material));
|
||||
_frameHooks.Add(new SessionHookEntry(material, context => context.Blit(material, pass)));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom frame modification hook implementing <see cref="IFrameHook"/>.
|
||||
/// </summary>
|
||||
/// <param name="hook">The hook instance to execute.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
||||
public RenderSession AddFrameHook(IFrameHook hook)
|
||||
{
|
||||
if (hook == null) throw new ArgumentNullException(nameof(hook));
|
||||
_frameHooks.Add(new SessionHookEntry(hook, hook.OnRenderFrame));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alias for <see cref="AddFrameHook(Action{FrameHookContext})"/>.
|
||||
/// </summary>
|
||||
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession WithFrameHook(Action<FrameHookContext> hook) => AddFrameHook(hook);
|
||||
|
||||
/// <summary>
|
||||
/// Alias for <see cref="AddFrameHook(Material, int)"/>.
|
||||
/// </summary>
|
||||
/// <param name="material">The material containing the shader to apply.</param>
|
||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession WithFrameHook(Material material, int pass = -1) => AddFrameHook(material, pass);
|
||||
|
||||
/// <summary>
|
||||
/// Alias for <see cref="AddFrameHook(IFrameHook)"/>.
|
||||
/// </summary>
|
||||
/// <param name="hook">The hook instance to execute.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession WithFrameHook(IFrameHook hook) => AddFrameHook(hook);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously added frame modification hook callback from this session.
|
||||
/// </summary>
|
||||
/// <param name="hook">The hook action to remove.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession RemoveFrameHook(Action<FrameHookContext> hook)
|
||||
{
|
||||
var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook) || Equals(e.Action, hook));
|
||||
if (idx >= 0) _frameHooks.RemoveAt(idx);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously added material-based post-processing pass from this session.
|
||||
/// </summary>
|
||||
/// <param name="material">The material to remove.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession RemoveFrameHook(Material material)
|
||||
{
|
||||
if (material)
|
||||
{
|
||||
for (var i = _frameHooks.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (ReferenceEquals(_frameHooks[i].Key, material))
|
||||
{
|
||||
_frameHooks.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously added <see cref="IFrameHook"/> from this session.
|
||||
/// </summary>
|
||||
/// <param name="hook">The hook instance to remove.</param>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession RemoveFrameHook(IFrameHook hook)
|
||||
{
|
||||
var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook));
|
||||
if (idx >= 0) _frameHooks.RemoveAt(idx);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all frame modification hooks configured for this render session.
|
||||
/// </summary>
|
||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||
public RenderSession ClearFrameHooks()
|
||||
{
|
||||
_frameHooks.Clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a global frame modification hook that will execute on every render session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Execution Frequency Note:</b> Global frame hooks are executed only for frames that are captured for the output video.
|
||||
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
|
||||
/// so this hook will <b>not</b> be called on every game frame.
|
||||
/// </remarks>
|
||||
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
||||
public static IDisposable AddGlobalFrameHook(Action<FrameHookContext> hook)
|
||||
{
|
||||
if (hook == null) throw new ArgumentNullException(nameof(hook));
|
||||
var reg = new GlobalHookRegistration(hook, hook);
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
GlobalFrameHooks.Add(reg);
|
||||
_globalHooksVersion++;
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a global material-based post-processing pass that will execute on every render session.
|
||||
/// </summary>
|
||||
/// <param name="material">The material containing the shader to apply.</param>
|
||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="material"/> is null.</exception>
|
||||
public static IDisposable AddGlobalFrameHook(Material material, int pass = -1)
|
||||
{
|
||||
if (material is null) throw new ArgumentNullException(nameof(material));
|
||||
var reg = new GlobalHookRegistration(material, context => context.Blit(material, pass));
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
GlobalFrameHooks.Add(reg);
|
||||
_globalHooksVersion++;
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a global frame modification hook implementing <see cref="IFrameHook"/>.
|
||||
/// </summary>
|
||||
/// <param name="hook">The hook instance to execute.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
||||
public static IDisposable AddGlobalFrameHook(IFrameHook hook)
|
||||
{
|
||||
if (hook == null) throw new ArgumentNullException(nameof(hook));
|
||||
var reg = new GlobalHookRegistration(hook, hook.OnRenderFrame);
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
GlobalFrameHooks.Add(reg);
|
||||
_globalHooksVersion++;
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a previously registered global frame modification hook.
|
||||
/// </summary>
|
||||
/// <param name="hook">The hook action to remove.</param>
|
||||
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
||||
public static bool RemoveGlobalFrameHook(Action<FrameHookContext> hook)
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
var idx = GlobalFrameHooks.FindIndex(r => ReferenceEquals(r.Key, hook) || Equals(r.Action, hook));
|
||||
if (idx >= 0)
|
||||
{
|
||||
GlobalFrameHooks.RemoveAt(idx);
|
||||
_globalHooksVersion++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a previously registered global material-based post-processing pass.
|
||||
/// </summary>
|
||||
/// <param name="material">The material to remove.</param>
|
||||
/// <returns><c>true</c> if any hooks matching <paramref name="material"/> were removed; otherwise, <c>false</c>.</returns>
|
||||
public static bool RemoveGlobalFrameHook(Material material)
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
var removed = false;
|
||||
for (var i = GlobalFrameHooks.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (ReferenceEquals(GlobalFrameHooks[i].Key, material))
|
||||
{
|
||||
GlobalFrameHooks.RemoveAt(i);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
{
|
||||
_globalHooksVersion++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a previously registered global frame modification hook implementing <see cref="IFrameHook"/>.
|
||||
/// </summary>
|
||||
/// <param name="hook">The hook instance to remove.</param>
|
||||
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
||||
public static bool RemoveGlobalFrameHook(IFrameHook hook)
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
var idx = GlobalFrameHooks.FindIndex(r => ReferenceEquals(r.Key, hook));
|
||||
if (idx >= 0)
|
||||
{
|
||||
GlobalFrameHooks.RemoveAt(idx);
|
||||
_globalHooksVersion++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
if (GlobalFrameHooks.Remove(registration))
|
||||
{
|
||||
_globalHooksVersion++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all registered global frame modification hooks.
|
||||
/// </summary>
|
||||
public static void ClearGlobalFrameHooks()
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
if (GlobalFrameHooks.Count > 0)
|
||||
{
|
||||
GlobalFrameHooks.Clear();
|
||||
_globalHooksVersion++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether any global frame modification hooks are currently registered.
|
||||
/// </summary>
|
||||
public static bool HasGlobalFrameHooks
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
return GlobalFrameHooks.Count > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of currently registered global frame modification hooks.
|
||||
/// </summary>
|
||||
public static int GlobalFrameHookCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
return GlobalFrameHooks.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static int GlobalHooksVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
return _globalHooksVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void CopyGlobalFrameHooksTo(List<Action<FrameHookContext>> destination)
|
||||
{
|
||||
lock (GlobalHooksLock)
|
||||
{
|
||||
for (var i = 0; i < GlobalFrameHooks.Count; i++)
|
||||
{
|
||||
destination.Add(GlobalFrameHooks[i].Action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the configured flags and initiates the render session.
|
||||
/// </summary>
|
||||
@@ -144,6 +510,15 @@ public class RenderSession
|
||||
if (_onComplete is not null) RenderContext.OnComplete = _onComplete;
|
||||
if (_onLastUpdate is not null) RenderContext.OnLastUpdate = _onLastUpdate;
|
||||
|
||||
RenderContext.SessionFrameHooks.Clear();
|
||||
if (_frameHooks.Count > 0)
|
||||
{
|
||||
foreach (var t in _frameHooks)
|
||||
{
|
||||
RenderContext.SessionFrameHooks.Add(t.Action);
|
||||
}
|
||||
}
|
||||
|
||||
RenderManager.Instance.levelPath = levelPath;
|
||||
RenderManager.Instance.Configuration = renderConfig;
|
||||
|
||||
|
||||
@@ -4,22 +4,30 @@ using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
namespace ADOFAIRenderer.Core.Data;
|
||||
|
||||
public class GlobalConfig
|
||||
internal class GlobalConfig
|
||||
{
|
||||
public string? OutputDirectory { get; set; }
|
||||
public string? LastPresetName { get; set; }
|
||||
public bool DefaultPresetsInitialized { get; set; }
|
||||
}
|
||||
|
||||
public static class GlobalSettings
|
||||
internal static class GlobalSettings
|
||||
{
|
||||
private static string SettingsFilePath =>
|
||||
Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "global_settings.json");
|
||||
private static string GetGameRootDir()
|
||||
{
|
||||
var root = MelonEnvironment.GameRootDirectory;
|
||||
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
|
||||
}
|
||||
|
||||
public static GlobalConfig Current { get; set; } = new();
|
||||
private static string SettingsFilePath =>
|
||||
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
|
||||
|
||||
public static GlobalConfig Current { get; private set; } = new();
|
||||
|
||||
public static string DefaultOutputDirectory =>
|
||||
Path.Combine(MelonEnvironment.GameRootDirectory, "Render");
|
||||
Path.Combine(GetGameRootDir(), "Render");
|
||||
|
||||
public static string EffectiveOutputDirectory
|
||||
{
|
||||
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ADOFAIRenderer.API;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
internal class FrameHookPipeline : IDisposable
|
||||
{
|
||||
private RenderTexture? _pingRT;
|
||||
private RenderTexture? _pongRT;
|
||||
private RenderTexture? _lastOutputRT;
|
||||
private FrameHookContext? _reusableContext;
|
||||
private int _width;
|
||||
private int _height;
|
||||
private RenderTextureFormat _format;
|
||||
|
||||
public RenderTexture? Process(
|
||||
RenderTexture sourceTexture,
|
||||
long frameIndex,
|
||||
long renderFrameIndex,
|
||||
double videoTime,
|
||||
double renderFps,
|
||||
double videoFps,
|
||||
IReadOnlyList<Action<FrameHookContext>> hooks)
|
||||
{
|
||||
if (hooks.Count == 0 || !sourceTexture || !sourceTexture.IsCreated())
|
||||
return sourceTexture;
|
||||
|
||||
EnsureBuffers(sourceTexture);
|
||||
|
||||
if (!_pingRT || !_pongRT || !_pingRT.IsCreated() || !_pongRT.IsCreated())
|
||||
return sourceTexture;
|
||||
|
||||
var prevActive = RenderTexture.active;
|
||||
try
|
||||
{
|
||||
// Inter-frame ping-pong safety: start with the buffer that was NOT used as the output
|
||||
// of the previous frame, ensuring the buffer currently being read back is not immediately overwritten.
|
||||
var firstDestination = _lastOutputRT == _pingRT ? _pongRT : _pingRT;
|
||||
|
||||
if (_reusableContext == null)
|
||||
{
|
||||
_reusableContext = new FrameHookContext(
|
||||
sourceTexture,
|
||||
_pingRT,
|
||||
_pongRT,
|
||||
firstDestination,
|
||||
frameIndex,
|
||||
renderFrameIndex,
|
||||
videoTime,
|
||||
renderFps,
|
||||
videoFps
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_reusableContext.Reset(
|
||||
sourceTexture,
|
||||
_pingRT,
|
||||
_pongRT,
|
||||
firstDestination,
|
||||
frameIndex,
|
||||
renderFrameIndex,
|
||||
videoTime,
|
||||
renderFps,
|
||||
videoFps
|
||||
);
|
||||
}
|
||||
|
||||
for (var i = 0; i < hooks.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
hooks[i](_reusableContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[FrameHook] Error executing frame hook #{i} on frame {frameIndex}: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
var result = _reusableContext.Current;
|
||||
_lastOutputRT = _reusableContext.HasModified ? result : null;
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = prevActive;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureBuffers(RenderTexture sourceTexture)
|
||||
{
|
||||
var width = sourceTexture.width;
|
||||
var height = sourceTexture.height;
|
||||
var format = sourceTexture.format;
|
||||
|
||||
if (_pingRT && _pongRT &&
|
||||
_width == width && _height == height && _format == format &&
|
||||
_pingRT.IsCreated() && _pongRT.IsCreated())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReleaseBuffers();
|
||||
|
||||
_width = width;
|
||||
_height = height;
|
||||
_format = format;
|
||||
|
||||
try
|
||||
{
|
||||
var desc = sourceTexture.descriptor;
|
||||
desc.depthBufferBits = 0;
|
||||
desc.msaaSamples = 1;
|
||||
desc.useMipMap = false;
|
||||
desc.autoGenerateMips = false;
|
||||
|
||||
var supportsUav = SystemInfo.supportsComputeShaders &&
|
||||
SystemInfo.SupportsRandomWriteOnRenderTextureFormat(desc.colorFormat);
|
||||
desc.enableRandomWrite = supportsUav;
|
||||
|
||||
_pingRT = new RenderTexture(desc)
|
||||
{
|
||||
name = "ADOFAIRenderer_PingBuffer",
|
||||
filterMode = sourceTexture.filterMode,
|
||||
wrapMode = sourceTexture.wrapMode
|
||||
};
|
||||
_pingRT.Create();
|
||||
|
||||
_pongRT = new RenderTexture(desc)
|
||||
{
|
||||
name = "ADOFAIRenderer_PongBuffer",
|
||||
filterMode = sourceTexture.filterMode,
|
||||
wrapMode = sourceTexture.wrapMode
|
||||
};
|
||||
_pongRT.Create();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ReleaseBuffers();
|
||||
Melon<RenderMod>.Logger.Warning($"[FrameHook] Failed to create descriptor-based PingPong RTs: {ex.Message}. Falling back to standard ARGB32.");
|
||||
_pingRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
|
||||
{
|
||||
name = "ADOFAIRenderer_PingBuffer",
|
||||
filterMode = sourceTexture.filterMode,
|
||||
wrapMode = sourceTexture.wrapMode
|
||||
};
|
||||
_pingRT.Create();
|
||||
|
||||
_pongRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
|
||||
{
|
||||
name = "ADOFAIRenderer_PongBuffer",
|
||||
filterMode = sourceTexture.filterMode,
|
||||
wrapMode = sourceTexture.wrapMode
|
||||
};
|
||||
_pongRT.Create();
|
||||
}
|
||||
}
|
||||
|
||||
public void ReleaseBuffers()
|
||||
{
|
||||
if (_pingRT)
|
||||
{
|
||||
if (_pingRT.IsCreated()) _pingRT.Release();
|
||||
UnityEngine.Object.Destroy(_pingRT);
|
||||
_pingRT = null;
|
||||
}
|
||||
|
||||
if (_pongRT)
|
||||
{
|
||||
if (_pongRT.IsCreated()) _pongRT.Release();
|
||||
UnityEngine.Object.Destroy(_pongRT);
|
||||
_pongRT = null;
|
||||
}
|
||||
|
||||
_width = 0;
|
||||
_height = 0;
|
||||
_lastOutputRT = null;
|
||||
_reusableContext = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ReleaseBuffers();
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ADOFAIRenderer.Core.Data;
|
||||
using ADOFAIRenderer.Data;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
public class RenderPreset
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public RenderConfig Config { get; set; } = null!;
|
||||
|
||||
public RenderPreset Clone()
|
||||
{
|
||||
return new RenderPreset
|
||||
{
|
||||
Name = Name,
|
||||
Config = Config.Clone()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class PresetManager
|
||||
{
|
||||
private static readonly List<RenderPreset> DefaultPresets = new()
|
||||
{
|
||||
new RenderPreset
|
||||
{
|
||||
Name = "FHD 60fps (1x)",
|
||||
Config = new RenderConfig
|
||||
{
|
||||
RenderWidth = 1920,
|
||||
RenderHeight = 1080,
|
||||
RenderFPS = 60,
|
||||
VideoFPS = 60,
|
||||
DelayAfterEnd = 15,
|
||||
ShowJudgementText = false,
|
||||
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 20000, MaxBitrate = 35000, QP = 18, KeyframeInterval = 2.0 },
|
||||
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
|
||||
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
|
||||
}
|
||||
},
|
||||
new RenderPreset
|
||||
{
|
||||
Name = "FHD 60fps (4x)",
|
||||
Config = new RenderConfig
|
||||
{
|
||||
RenderWidth = 1920,
|
||||
RenderHeight = 1080,
|
||||
RenderFPS = 240,
|
||||
VideoFPS = 60,
|
||||
DelayAfterEnd = 15,
|
||||
ShowJudgementText = false,
|
||||
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 30000, MaxBitrate = 50000, QP = 18, KeyframeInterval = 2.0 },
|
||||
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
|
||||
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
|
||||
}
|
||||
},
|
||||
new RenderPreset
|
||||
{
|
||||
Name = "QHD 60fps (4x)",
|
||||
Config = new RenderConfig
|
||||
{
|
||||
RenderWidth = 2560,
|
||||
RenderHeight = 1440,
|
||||
RenderFPS = 240,
|
||||
VideoFPS = 60,
|
||||
DelayAfterEnd = 15,
|
||||
ShowJudgementText = false,
|
||||
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 35000, MaxBitrate = 60000, QP = 18, KeyframeInterval = 2.0 },
|
||||
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
|
||||
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
|
||||
}
|
||||
},
|
||||
new RenderPreset
|
||||
{
|
||||
Name = "4K 60fps (4x)",
|
||||
Config = new RenderConfig
|
||||
{
|
||||
RenderWidth = 3840,
|
||||
RenderHeight = 2160,
|
||||
RenderFPS = 240,
|
||||
VideoFPS = 60,
|
||||
DelayAfterEnd = 15,
|
||||
ShowJudgementText = false,
|
||||
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 60000, MaxBitrate = 100000, QP = 18, KeyframeInterval = 2.0 },
|
||||
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
|
||||
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static string GetPresetsDirectory()
|
||||
{
|
||||
string? root = null;
|
||||
try
|
||||
{
|
||||
root = MelonEnvironment.GameRootDirectory;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Outside MelonLoader host environment (e.g. tests)
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(root))
|
||||
{
|
||||
root = AppDomain.CurrentDomain.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets");
|
||||
}
|
||||
|
||||
private static string GetSafeFileName(string name)
|
||||
{
|
||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
||||
}
|
||||
|
||||
public static void EnsureDefaultPresetsCreated()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = GetPresetsDirectory();
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
if (!GlobalSettings.Current.DefaultPresetsInitialized)
|
||||
{
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented
|
||||
};
|
||||
settings.Converters.Add(new ColorJsonConverter());
|
||||
|
||||
foreach (var def in DefaultPresets)
|
||||
{
|
||||
var safeName = GetSafeFileName(def.Name);
|
||||
var filePath = Path.Combine(dir, safeName + ".json");
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(def, settings);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
}
|
||||
|
||||
GlobalSettings.Current.DefaultPresetsInitialized = true;
|
||||
GlobalSettings.Save();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"Error initializing default presets: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void SavePreset(string name, RenderConfig config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("Preset name cannot be empty.");
|
||||
}
|
||||
|
||||
string dir = GetPresetsDirectory();
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var preset = new RenderPreset
|
||||
{
|
||||
Name = name,
|
||||
Config = config.Clone()
|
||||
};
|
||||
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented
|
||||
};
|
||||
settings.Converters.Add(new ColorJsonConverter());
|
||||
|
||||
string json = JsonConvert.SerializeObject(preset, settings);
|
||||
string safeName = GetSafeFileName(name);
|
||||
string filePath = Path.Combine(dir, safeName + ".json");
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
|
||||
public static List<RenderPreset> GetPresets()
|
||||
{
|
||||
EnsureDefaultPresetsCreated();
|
||||
|
||||
var presets = new List<RenderPreset>();
|
||||
var dir = GetPresetsDirectory();
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
foreach (var b in DefaultPresets)
|
||||
{
|
||||
presets.Add(b.Clone());
|
||||
}
|
||||
return presets;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(dir, "*.json");
|
||||
var serializerSettings = new JsonSerializerSettings();
|
||||
serializerSettings.Converters.Add(new ColorJsonConverter());
|
||||
var serializer = JsonSerializer.Create(serializerSettings);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
var jObject = JsonConvert.DeserializeObject<JObject>(json);
|
||||
if (jObject == null) continue;
|
||||
|
||||
var configToken = jObject["Config"];
|
||||
if (configToken is JObject configObj)
|
||||
{
|
||||
TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd");
|
||||
|
||||
// 1. legacy EncodingConfig -> VideoConfig
|
||||
if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
|
||||
{
|
||||
configObj["VideoConfig"] ??= oldEncodingConfigObj;
|
||||
configObj.Remove("EncodingConfig");
|
||||
}
|
||||
|
||||
// 2. legacy top-level Codec -> VideoConfig.Codec
|
||||
var legacyCodecToken = configObj["Codec"];
|
||||
if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null)
|
||||
{
|
||||
if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj["VideoConfig"] = new JObject();
|
||||
}
|
||||
|
||||
var videoConfigObj = (JObject)configObj["VideoConfig"]!;
|
||||
videoConfigObj["Codec"] ??= legacyCodecToken;
|
||||
|
||||
configObj.Remove("Codec");
|
||||
}
|
||||
|
||||
// 3. legacy top-level Audio properties -> AudioConfig
|
||||
string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" };
|
||||
foreach (var prop in legacyAudioProps)
|
||||
{
|
||||
var legacyToken = configObj[prop];
|
||||
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
|
||||
{
|
||||
if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj["AudioConfig"] = new JObject();
|
||||
}
|
||||
|
||||
var audioConfigObj = (JObject)configObj["AudioConfig"]!;
|
||||
audioConfigObj[prop] ??= legacyToken;
|
||||
}
|
||||
|
||||
configObj.Remove(prop);
|
||||
}
|
||||
|
||||
if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj.Remove("AudioConfig");
|
||||
}
|
||||
|
||||
// 4. legacy top-level Advanced properties -> AdvancedConfig
|
||||
string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" };
|
||||
foreach (var prop in legacyAdvancedProps)
|
||||
{
|
||||
var legacyToken = configObj[prop];
|
||||
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
|
||||
{
|
||||
if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj["AdvancedConfig"] = new JObject();
|
||||
}
|
||||
|
||||
var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!;
|
||||
advancedConfigObj[prop] ??= legacyToken;
|
||||
}
|
||||
|
||||
configObj.Remove(prop);
|
||||
}
|
||||
|
||||
if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj.Remove("AdvancedConfig");
|
||||
}
|
||||
}
|
||||
|
||||
var preset = jObject.ToObject<RenderPreset>(serializer);
|
||||
if (preset != null)
|
||||
{
|
||||
var fileBaseName = Path.GetFileNameWithoutExtension(file);
|
||||
if (string.IsNullOrWhiteSpace(preset.Name))
|
||||
{
|
||||
preset.Name = fileBaseName;
|
||||
}
|
||||
|
||||
if (presets.Any(p => string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
presets.Add(preset);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"Error loading preset file '{Path.GetFileName(file)}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Error($"Error retrieving preset list: {ex.Message}");
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
|
||||
{
|
||||
var legacyToken = configObj[keyBefore];
|
||||
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
|
||||
configObj[targetKey] = legacyToken;
|
||||
|
||||
configObj.Remove(keyBefore);
|
||||
}
|
||||
|
||||
public static void DeletePreset(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
|
||||
var dir = GetPresetsDirectory();
|
||||
if (!Directory.Exists(dir)) return;
|
||||
|
||||
var safeName = GetSafeFileName(name);
|
||||
var filePath = Path.Combine(dir, safeName + ".json");
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(dir, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileBaseName = Path.GetFileNameWithoutExtension(file);
|
||||
if (string.Equals(fileBaseName, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(file);
|
||||
return;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(file);
|
||||
var jObject = JsonConvert.DeserializeObject<JObject>(json);
|
||||
var pName = (string?)jObject?["Name"];
|
||||
if (string.Equals(pName, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore read errors during cleanup search
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
-26
@@ -7,12 +7,14 @@ 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;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.Audio;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using FFmpeg.AutoGen;
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
@@ -36,6 +38,7 @@ internal static class RenderContext
|
||||
public static string? OutputPath;
|
||||
|
||||
public static bool Notification = true;
|
||||
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
|
||||
|
||||
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
|
||||
public static Func<bool> EndCondition = EndConditionDefault;
|
||||
@@ -63,6 +66,7 @@ internal static class RenderContext
|
||||
OutputPath = null;
|
||||
|
||||
Notification = true;
|
||||
SessionFrameHooks.Clear();
|
||||
|
||||
EndCondition = EndConditionDefault;
|
||||
OnComplete = OnCompleteDefault;
|
||||
@@ -88,7 +92,50 @@ internal class RenderManager : MonoBehaviour
|
||||
public long frameCount;
|
||||
public bool skipPressToStart;
|
||||
public string levelPath = "";
|
||||
public bool IsRenderCancelled { get; private set; }
|
||||
private readonly object _abortStateLock = new();
|
||||
private volatile bool _isRenderCancelled;
|
||||
private volatile bool _renderFailed;
|
||||
private volatile string? _failureReason;
|
||||
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public bool IsRenderCancelled => _isRenderCancelled;
|
||||
public bool IsRenderFailed => _renderFailed;
|
||||
|
||||
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
|
||||
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
||||
|
||||
private void MarkCancelled()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
if (!_renderFailed) _isRenderCancelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The first failure reason wins; ignored once the render is already cancelled or failed.
|
||||
private void MarkFailed(string reason)
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
if (_isRenderCancelled || _renderFailed) return;
|
||||
_failureReason = reason;
|
||||
_renderFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAbortState()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
_isRenderCancelled = false;
|
||||
_renderFailed = false;
|
||||
_failureReason = null;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildAbortMessage() => IsRenderFailed
|
||||
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
||||
: "Rendering was cancelled by user.";
|
||||
public RenderConfig Configuration = null!;
|
||||
|
||||
// Render Sessions State (OOP Instance Fields)
|
||||
@@ -99,12 +146,56 @@ internal class RenderManager : MonoBehaviour
|
||||
public int frameCountBase;
|
||||
|
||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
||||
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||||
|
||||
private FrameReader? _frameReader;
|
||||
private FrameHookPipeline? _frameHookPipeline;
|
||||
private readonly List<Action<FrameHookContext>> _activeHooksCache = new();
|
||||
private int _cachedGlobalHooksVersion = -1;
|
||||
private bool _hasActiveHooksCached;
|
||||
|
||||
public bool HasActiveFrameHooks
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureActiveHooksUpToDate();
|
||||
return _hasActiveHooksCached;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureActiveHooksUpToDate()
|
||||
{
|
||||
var globalVersion = RenderSession.GlobalHooksVersion;
|
||||
if (_cachedGlobalHooksVersion == globalVersion) return;
|
||||
|
||||
_cachedGlobalHooksVersion = globalVersion;
|
||||
_activeHooksCache.Clear();
|
||||
if (RenderContext.SessionFrameHooks.Count > 0)
|
||||
{
|
||||
_activeHooksCache.AddRange(RenderContext.SessionFrameHooks);
|
||||
}
|
||||
RenderSession.CopyGlobalFrameHooksTo(_activeHooksCache);
|
||||
_hasActiveHooksCached = _activeHooksCache.Count > 0;
|
||||
}
|
||||
|
||||
private List<Action<FrameHookContext>> GetActiveFrameHooks()
|
||||
{
|
||||
EnsureActiveHooksUpToDate();
|
||||
return _activeHooksCache;
|
||||
}
|
||||
|
||||
private void ResetFrameHookState()
|
||||
{
|
||||
_frameHookPipeline?.Dispose();
|
||||
_frameHookPipeline = null;
|
||||
_activeHooksCache.Clear();
|
||||
_cachedGlobalHooksVersion = -1;
|
||||
_hasActiveHooksCached = false;
|
||||
}
|
||||
|
||||
public long frameCountForVideo;
|
||||
public long lastVideoFrameIndex = -1;
|
||||
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||||
public long lastRequestedVideoFrameIndex = -1;
|
||||
public int readbackFailureCount;
|
||||
public int throttleCount;
|
||||
@@ -116,6 +207,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
|
||||
@@ -123,8 +215,8 @@ internal class RenderManager : MonoBehaviour
|
||||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
||||
public Thread? RenderThread;
|
||||
public long passCount;
|
||||
public bool endRequested;
|
||||
public bool endSuccessful;
|
||||
public volatile bool endRequested;
|
||||
public volatile bool endSuccessful;
|
||||
public long endFrame = -1;
|
||||
|
||||
// Private Instance Fields
|
||||
@@ -148,16 +240,21 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
public void ResetRenderState(bool success)
|
||||
{
|
||||
IsRenderCancelled = !success;
|
||||
if (success) _isRenderCancelled = false;
|
||||
else MarkCancelled();
|
||||
stage = RenderStage.None;
|
||||
}
|
||||
|
||||
public void CleanUpActiveRender()
|
||||
{
|
||||
if (stage != RenderStage.None)
|
||||
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
|
||||
ResetRenderState(success: false);
|
||||
|
||||
AudioSpectrumGenerator.Clear();
|
||||
|
||||
ResetFrameHookState();
|
||||
|
||||
if (_activeVideoFFmpeg != null)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Msg("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources.");
|
||||
@@ -234,8 +331,15 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
if (Instance.stage < RenderStage.OnScene) return;
|
||||
if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true })
|
||||
{
|
||||
MarkFailed("the render thread terminated unexpectedly.");
|
||||
CancelRender();
|
||||
return;
|
||||
}
|
||||
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
||||
{
|
||||
MarkFailed("the level failed or the game controller was lost during rendering.");
|
||||
ResetRenderState(success: false);
|
||||
StopFFmpeg(false);
|
||||
EndRender();
|
||||
@@ -284,6 +388,12 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
while (Instance.stage >= RenderStage.Render)
|
||||
{
|
||||
if (RenderThread is not { IsAlive: true })
|
||||
{
|
||||
isThrottling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
int count;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
@@ -313,8 +423,24 @@ internal class RenderManager : MonoBehaviour
|
||||
if (videoFrameIndex > lastVideoFrameIndex)
|
||||
{
|
||||
lastVideoFrameIndex = videoFrameIndex;
|
||||
lastRequestedVideoFrameIndex = videoFrameIndex;
|
||||
_frameReader?.RequestFrameCapture(texture, videoFrameIndex);
|
||||
Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex);
|
||||
|
||||
var targetTexture = texture;
|
||||
if (HasActiveFrameHooks && _frameHookPipeline != null)
|
||||
{
|
||||
var videoTime = effectiveVideoFps > 0 ? videoFrameIndex / effectiveVideoFps : 0.0;
|
||||
targetTexture = _frameHookPipeline.Process(
|
||||
texture,
|
||||
videoFrameIndex,
|
||||
currentFrame,
|
||||
videoTime,
|
||||
renderFPS,
|
||||
effectiveVideoFps,
|
||||
GetActiveFrameHooks()
|
||||
) ?? texture;
|
||||
}
|
||||
|
||||
_frameReader?.RequestFrameCapture(targetTexture, videoFrameIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +464,6 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
Application.wantsToQuit -= quitDelegate;
|
||||
RenderConfigWindow.CreateWindow();
|
||||
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,6 +500,9 @@ internal class RenderManager : MonoBehaviour
|
||||
yield return new WaitForSecondsRealtime(1);
|
||||
}
|
||||
|
||||
// Cancelled or failed during the countdown: do not start the encoder
|
||||
if (stage != RenderStage.OnScene) yield break;
|
||||
|
||||
frameCount = 0; framesSent = 0;
|
||||
_renderStopwatch.Restart();
|
||||
|
||||
@@ -385,6 +513,10 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
_frameReader?.Dispose();
|
||||
_frameReader = null;
|
||||
_frameHookPipeline?.Dispose();
|
||||
_frameHookPipeline = new FrameHookPipeline();
|
||||
_cachedGlobalHooksVersion = -1;
|
||||
EnsureActiveHooksUpToDate();
|
||||
try
|
||||
{
|
||||
StartFFmpegThread();
|
||||
@@ -393,12 +525,20 @@ internal class RenderManager : MonoBehaviour
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
||||
|
||||
// Release the encoder/thread and restore time, input offset, stage and hooks before showing the dialog
|
||||
MarkFailed($"failed to initialize the video encoder: {ex.Message}");
|
||||
ResetRenderState(success: false);
|
||||
StopFFmpeg(false);
|
||||
RestoreRenderEnvironment();
|
||||
|
||||
CommonWarningDialogUI.ShowDialog(
|
||||
"Encoder Error",
|
||||
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
||||
new DialogButton("OK", () =>
|
||||
{
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
})
|
||||
);
|
||||
yield break;
|
||||
@@ -441,10 +581,10 @@ internal class RenderManager : MonoBehaviour
|
||||
ImageQueue.Clear();
|
||||
}
|
||||
passCount = 0;
|
||||
IsRenderCancelled = false;
|
||||
ClearAbortState();
|
||||
frameCountForVideo = 0;
|
||||
lastVideoFrameIndex = -1;
|
||||
lastRequestedVideoFrameIndex = -1;
|
||||
Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
|
||||
endRequested = false;
|
||||
endSuccessful = false;
|
||||
FrameAvailableEvent.Reset();
|
||||
@@ -545,7 +685,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);
|
||||
@@ -553,7 +693,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)
|
||||
@@ -562,14 +702,14 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
if (Configuration.AudioConfig.RenderAudio)
|
||||
if (Configuration.AudioConfig.RenderAudio && SyncCalculator is { } syncCalculator)
|
||||
{
|
||||
var frameIdx = (int)(passCount - 1);
|
||||
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
||||
var frameTime = frameIdx / effectiveVideoFps;
|
||||
|
||||
var channels = Configuration.AudioConfig.AudioChannels;
|
||||
var samplesNeeded = SyncCalculator.GetNextFrameSamples();
|
||||
var samplesNeeded = syncCalculator.GetNextFrameSamples();
|
||||
|
||||
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
||||
try
|
||||
@@ -585,17 +725,17 @@ internal class RenderManager : MonoBehaviour
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRenderCancelled) break;
|
||||
if (IsRenderAborted) break;
|
||||
|
||||
if (endRequested)
|
||||
{
|
||||
if (passCount > lastRequestedVideoFrameIndex) break;
|
||||
if (passCount > Volatile.Read(ref lastRequestedVideoFrameIndex)) break;
|
||||
}
|
||||
|
||||
FrameAvailableEvent.WaitOne(50);
|
||||
}
|
||||
|
||||
if (!IsRenderCancelled) videoFFmpeg.End();
|
||||
if (!IsRenderAborted) videoFFmpeg.End();
|
||||
videoFFmpeg.Dispose();
|
||||
|
||||
lock (_allVideoFFmpegInstances)
|
||||
@@ -604,12 +744,22 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
_activeVideoFFmpeg = null;
|
||||
endSuccessful = !IsRenderCancelled;
|
||||
endSuccessful = !IsRenderAborted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
||||
|
||||
MarkFailed("encoder thread error. See the log for details.");
|
||||
try
|
||||
{
|
||||
videoFFmpeg.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}");
|
||||
}
|
||||
|
||||
lock (_allVideoFFmpegInstances)
|
||||
{
|
||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||
@@ -638,7 +788,7 @@ internal class RenderManager : MonoBehaviour
|
||||
endRequested = true;
|
||||
if (!success)
|
||||
{
|
||||
IsRenderCancelled = true;
|
||||
MarkCancelled();
|
||||
}
|
||||
FrameAvailableEvent.Set();
|
||||
skipPressToStart = false;
|
||||
@@ -652,7 +802,8 @@ internal class RenderManager : MonoBehaviour
|
||||
if (success) StartCoroutine(StopRenderCoroutine());
|
||||
else
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
|
||||
var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED";
|
||||
Melon<RenderMod>.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources.");
|
||||
|
||||
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
|
||||
if (RenderThread is { IsAlive: true })
|
||||
@@ -685,6 +836,7 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
_frameReader?.Dispose();
|
||||
ResetFrameHookState();
|
||||
|
||||
// Clean up Debug Monitor UI
|
||||
if (_debugMonitorObj)
|
||||
@@ -725,15 +877,20 @@ internal class RenderManager : MonoBehaviour
|
||||
CurrentOutPath = null;
|
||||
}
|
||||
|
||||
var result = BuildResult(false,
|
||||
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
|
||||
var result = BuildResult(false, BuildAbortMessage());
|
||||
TriggerOnComplete(result);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator StopRenderCoroutine()
|
||||
{
|
||||
yield return new WaitUntil(() => endSuccessful);
|
||||
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
|
||||
if (!endSuccessful)
|
||||
{
|
||||
MarkFailed("the render thread terminated unexpectedly.");
|
||||
CancelRender();
|
||||
yield break;
|
||||
}
|
||||
|
||||
var result = BuildResult(success: true);
|
||||
TriggerOnComplete(result);
|
||||
@@ -742,6 +899,15 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
private void EndRender()
|
||||
{
|
||||
RestoreRenderEnvironment();
|
||||
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
|
||||
private void RestoreRenderEnvironment()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
Time.captureDeltaTime = 0f;
|
||||
@@ -774,13 +940,13 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
_frameReader?.Dispose();
|
||||
ResetFrameHookState();
|
||||
AudioMixer?.Reset(); AudioMixer = null;
|
||||
SyncCalculator.Reset();
|
||||
SyncCalculator = null;
|
||||
|
||||
AudioSpectrumGenerator.Clear();
|
||||
RenderContext.Reset();
|
||||
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Converts Unity <see cref="Color"/> and nullable <see cref="Color"/> to and from hex string (#RRGGBBAA).
|
||||
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
|
||||
/// </summary>
|
||||
public class ColorJsonConverter : JsonConverter
|
||||
{
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
return objectType == typeof(Color) || objectType == typeof(Color?);
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNull();
|
||||
return;
|
||||
}
|
||||
|
||||
var color = (Color)value;
|
||||
writer.WriteValue(ToHex(color));
|
||||
}
|
||||
|
||||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||
{
|
||||
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
|
||||
|
||||
if (reader.TokenType == JsonToken.Null)
|
||||
{
|
||||
return isNullable ? null : (object)default(Color);
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonToken.String)
|
||||
{
|
||||
var str = (string?)reader.Value;
|
||||
if (TryParseHex(str, out var parsedColor))
|
||||
{
|
||||
return parsedColor;
|
||||
}
|
||||
|
||||
return isNullable ? null : (object)default(Color);
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonToken.StartObject)
|
||||
{
|
||||
var obj = JObject.Load(reader);
|
||||
float r = (float?)obj["r"] ?? 0f;
|
||||
float g = (float?)obj["g"] ?? 0f;
|
||||
float b = (float?)obj["b"] ?? 0f;
|
||||
float a = (float?)obj["a"] ?? 1f;
|
||||
return new Color(r, g, b, a);
|
||||
}
|
||||
|
||||
return isNullable ? null : (object)default(Color);
|
||||
}
|
||||
|
||||
public static string ToHex(Color color)
|
||||
{
|
||||
byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255);
|
||||
byte g = (byte)Math.Clamp((int)Math.Round(color.g * 255f), 0, 255);
|
||||
byte b = (byte)Math.Clamp((int)Math.Round(color.b * 255f), 0, 255);
|
||||
byte a = (byte)Math.Clamp((int)Math.Round(color.a * 255f), 0, 255);
|
||||
return $"#{r:X2}{g:X2}{b:X2}{a:X2}";
|
||||
}
|
||||
|
||||
public static bool TryParseHex(string? hex, out Color color)
|
||||
{
|
||||
color = default;
|
||||
if (string.IsNullOrWhiteSpace(hex)) return false;
|
||||
|
||||
var str = hex.Trim();
|
||||
if (str.StartsWith("#"))
|
||||
{
|
||||
str = str.Substring(1);
|
||||
}
|
||||
|
||||
if (str.Length == 6)
|
||||
{
|
||||
if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) &&
|
||||
byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) &&
|
||||
byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
|
||||
{
|
||||
color = new Color(r / 255f, g / 255f, b / 255f, 1f);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (str.Length == 8)
|
||||
{
|
||||
if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) &&
|
||||
byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) &&
|
||||
byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) &&
|
||||
byte.TryParse(str.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var a))
|
||||
{
|
||||
color = new Color(r / 255f, g / 255f, b / 255f, a / 255f);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
internal class RenderPreset
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public RenderConfig Config { get; set; } = null!;
|
||||
}
|
||||
|
||||
internal static class PresetManager
|
||||
{
|
||||
private static string GetPresetsDirectory()
|
||||
{
|
||||
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
|
||||
}
|
||||
|
||||
private static string GetSafeFileName(string name)
|
||||
{
|
||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
||||
}
|
||||
|
||||
public static void SavePreset(string name, RenderConfig config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("Preset name cannot be empty.");
|
||||
}
|
||||
|
||||
string dir = GetPresetsDirectory();
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var preset = new RenderPreset
|
||||
{
|
||||
Name = name,
|
||||
Config = config
|
||||
};
|
||||
|
||||
string json = JsonConvert.SerializeObject(preset, Formatting.Indented);
|
||||
string safeName = GetSafeFileName(name);
|
||||
string filePath = Path.Combine(dir, safeName + ".json");
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
|
||||
public static List<RenderPreset> GetPresets()
|
||||
{
|
||||
var presets = new List<RenderPreset>();
|
||||
var dir = GetPresetsDirectory();
|
||||
|
||||
if (!Directory.Exists(dir)) return presets;
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(dir, "*.json");
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
var jObject = JsonConvert.DeserializeObject<JObject>(json);
|
||||
if (jObject == null) continue;
|
||||
|
||||
var configToken = jObject["Config"];
|
||||
if (configToken is JObject configObj)
|
||||
{
|
||||
TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd");
|
||||
|
||||
// 1. legacy EncodingConfig -> VideoConfig
|
||||
if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
|
||||
{
|
||||
configObj["VideoConfig"] ??= oldEncodingConfigObj;
|
||||
configObj.Remove("EncodingConfig");
|
||||
}
|
||||
|
||||
// 2. legacy top-level Codec -> VideoConfig.Codec
|
||||
var legacyCodecToken = configObj["Codec"];
|
||||
if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null)
|
||||
{
|
||||
if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj["VideoConfig"] = new JObject();
|
||||
}
|
||||
|
||||
var videoConfigObj = (JObject)configObj["VideoConfig"]!;
|
||||
videoConfigObj["Codec"] ??= legacyCodecToken;
|
||||
|
||||
configObj.Remove("Codec");
|
||||
}
|
||||
|
||||
// 3. legacy top-level Audio properties -> AudioConfig
|
||||
string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" };
|
||||
foreach (var prop in legacyAudioProps)
|
||||
{
|
||||
var legacyToken = configObj[prop];
|
||||
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
|
||||
{
|
||||
if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj["AudioConfig"] = new JObject();
|
||||
}
|
||||
|
||||
var audioConfigObj = (JObject)configObj["AudioConfig"]!;
|
||||
audioConfigObj[prop] ??= legacyToken;
|
||||
}
|
||||
|
||||
configObj.Remove(prop);
|
||||
}
|
||||
|
||||
if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj.Remove("AudioConfig");
|
||||
}
|
||||
|
||||
// 4. legacy top-level Advanced properties -> AdvancedConfig
|
||||
string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" };
|
||||
foreach (var prop in legacyAdvancedProps)
|
||||
{
|
||||
var legacyToken = configObj[prop];
|
||||
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
|
||||
{
|
||||
if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj["AdvancedConfig"] = new JObject();
|
||||
}
|
||||
|
||||
var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!;
|
||||
advancedConfigObj[prop] ??= legacyToken;
|
||||
}
|
||||
|
||||
configObj.Remove(prop);
|
||||
}
|
||||
|
||||
if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null)
|
||||
{
|
||||
configObj.Remove("AdvancedConfig");
|
||||
}
|
||||
}
|
||||
|
||||
var preset = jObject.ToObject<RenderPreset>();
|
||||
if (preset != null && !string.IsNullOrEmpty(preset.Name))
|
||||
{
|
||||
presets.Add(preset);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"Error loading preset file '{Path.GetFileName(file)}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Error($"Error retrieving preset list: {ex.Message}");
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
|
||||
{
|
||||
var legacyToken = configObj[keyBefore];
|
||||
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
|
||||
configObj[targetKey] = legacyToken;
|
||||
|
||||
configObj.Remove(keyBefore);
|
||||
}
|
||||
|
||||
public static void DeletePreset(string name)
|
||||
{
|
||||
string dir = GetPresetsDirectory();
|
||||
string safeName = GetSafeFileName(name);
|
||||
string filePath = Path.Combine(dir, safeName + ".json");
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public enum RateControlMode
|
||||
{
|
||||
CBR,
|
||||
VBR,
|
||||
CQP
|
||||
}
|
||||
+82
-1
@@ -1,5 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
@@ -19,6 +19,28 @@ public class RenderConfig
|
||||
|
||||
public double DelayAfterEnd { get; set; } = 15;
|
||||
public bool ShowJudgementText { get; set; } = false;
|
||||
|
||||
public void CopyFrom(RenderConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RenderFPS = source.RenderFPS;
|
||||
VideoFPS = source.VideoFPS;
|
||||
RenderWidth = source.RenderWidth;
|
||||
RenderHeight = source.RenderHeight;
|
||||
DelayAfterEnd = source.DelayAfterEnd;
|
||||
ShowJudgementText = source.ShowJudgementText;
|
||||
|
||||
VideoConfig.CopyFrom(source.VideoConfig);
|
||||
AudioConfig.CopyFrom(source.AudioConfig);
|
||||
AdvancedConfig.CopyFrom(source.AdvancedConfig);
|
||||
}
|
||||
|
||||
public RenderConfig Clone()
|
||||
{
|
||||
var clone = new RenderConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
@@ -29,9 +51,33 @@ public class AdvancedConfig
|
||||
public bool NonTile { get; set; } = false;
|
||||
public string FileExtension { get; set; } = "mkv";
|
||||
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? firePlanetColor = null;
|
||||
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? icePlanetColor = null;
|
||||
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? windPlanetColor = null;
|
||||
|
||||
public void CopyFrom(AdvancedConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RandomSeed = source.RandomSeed;
|
||||
ProcessAudio = source.ProcessAudio;
|
||||
NonTile = source.NonTile;
|
||||
FileExtension = source.FileExtension;
|
||||
firePlanetColor = source.firePlanetColor;
|
||||
icePlanetColor = source.icePlanetColor;
|
||||
windPlanetColor = source.windPlanetColor;
|
||||
}
|
||||
|
||||
public AdvancedConfig Clone()
|
||||
{
|
||||
var clone = new AdvancedConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
public class VideoEncodingConfig
|
||||
@@ -45,6 +91,25 @@ public class VideoEncodingConfig
|
||||
public int QP { get; set; } = 18;
|
||||
public double KeyframeInterval { get; set; } = 2.0;
|
||||
public bool ForceSoftwareEncoder { get; set; }
|
||||
|
||||
public void CopyFrom(VideoEncodingConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
Codec = source.Codec;
|
||||
RateControlMode = source.RateControlMode;
|
||||
Bitrate = source.Bitrate;
|
||||
MaxBitrate = source.MaxBitrate;
|
||||
QP = source.QP;
|
||||
KeyframeInterval = source.KeyframeInterval;
|
||||
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
|
||||
}
|
||||
|
||||
public VideoEncodingConfig Clone()
|
||||
{
|
||||
var clone = new VideoEncodingConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioEncodingConfig
|
||||
@@ -72,4 +137,20 @@ public class AudioEncodingConfig
|
||||
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
|
||||
set => _audioBitrate = value <= 0 ? 256000 : value;
|
||||
}
|
||||
|
||||
public void CopyFrom(AudioEncodingConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RenderAudio = source.RenderAudio;
|
||||
AudioSampleRate = source.AudioSampleRate;
|
||||
AudioChannels = source.AudioChannels;
|
||||
AudioBitrate = source.AudioBitrate;
|
||||
}
|
||||
|
||||
public AudioEncodingConfig Clone()
|
||||
{
|
||||
var clone = new AudioEncodingConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}␍
|
||||
@@ -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 라이선스 조건이 그대로 적용됩니다.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"ui.render_config.window_title": "Render Config",
|
||||
"ui.render_config.presets": "Presets",
|
||||
"ui.render_config.preset_select": "Preset",
|
||||
"ui.render_config.preset_name_placeholder": "New preset name...",
|
||||
"ui.render_config.preset_save": "Save",
|
||||
"ui.render_config.preset_delete": "Delete",
|
||||
"ui.render_config.preset_delete_confirm": "Confirm?",
|
||||
"ui.render_config.preset_overwrite_title": "Overwrite Preset",
|
||||
"ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?",
|
||||
"ui.render_config.preset_delete_title": "Delete Preset",
|
||||
"ui.render_config.preset_delete_desc": "Are you sure you want to delete preset '{0}'?",
|
||||
"ui.render_config.preset_name_empty": "Please enter a preset name.",
|
||||
"ui.render_config.preset_custom": "(Modified)",
|
||||
"ui.render_config.confirm": "Confirm",
|
||||
"ui.render_config.cancel": "Cancel",
|
||||
"ui.render_config.render_fps": "Render FPS",
|
||||
"ui.render_config.video_fps": "Video FPS",
|
||||
"ui.render_config.render_width": "Render Width",
|
||||
@@ -32,5 +46,9 @@
|
||||
"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.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)"
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
{
|
||||
"ui.render_config.window_title": "렌더 설정",
|
||||
"ui.render_config.presets": "프리셋",
|
||||
"ui.render_config.preset_select": "프리셋 선택",
|
||||
"ui.render_config.preset_name_placeholder": "새 프리셋 이름...",
|
||||
"ui.render_config.preset_save": "저장",
|
||||
"ui.render_config.preset_delete": "삭제",
|
||||
"ui.render_config.preset_delete_confirm": "삭제 확인",
|
||||
"ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기",
|
||||
"ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?",
|
||||
"ui.render_config.preset_delete_title": "프리셋 삭제",
|
||||
"ui.render_config.preset_delete_desc": "'{0}' 프리셋을 정말 삭제하시겠습니까?",
|
||||
"ui.render_config.preset_name_empty": "프리셋 이름을 입력해 주세요.",
|
||||
"ui.render_config.preset_custom": "(수정됨)",
|
||||
"ui.render_config.confirm": "확인",
|
||||
"ui.render_config.cancel": "취소",
|
||||
"ui.render_config.render_fps": "렌더링 FPS",
|
||||
"ui.render_config.video_fps": "영상 FPS",
|
||||
"ui.render_config.render_width": "렌더링 너비",
|
||||
"ui.render_config.render_height": "렌더링 높이",
|
||||
"ui.render_config.delay_after_end": "종료 후 대기 시간 (초)",
|
||||
"ui.render_config.show_judgement": "판정 텍스트 표시",
|
||||
"ui.render_config.video_encoding": "Video Encoding Config",
|
||||
"ui.render_config.audio_encoding": "Audio Encoding Config",
|
||||
"ui.render_config.global_settings": "Global Settings",
|
||||
"ui.render_config.advanced_settings": "Advanced Settings",
|
||||
"ui.render_config.go_render": "렌더 시작",
|
||||
"ui.render_config.video_encoding": "비디오 인코딩 설정",
|
||||
"ui.render_config.audio_encoding": "오디오 인코딩 설정",
|
||||
"ui.render_config.global_settings": "전역 설정",
|
||||
"ui.render_config.advanced_settings": "고급 설정",
|
||||
"ui.render_config.go_render": "렌더링하러 가다",
|
||||
"ui.render_config.codec": "코덱",
|
||||
"ui.render_config.rate_control": "비트레이트 제어 모드",
|
||||
"ui.render_config.bitrate": "비트레이트 (kbps)",
|
||||
@@ -32,5 +46,9 @@
|
||||
"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.planet_colors": "행성 색상 설정 (선택 사항)",
|
||||
"ui.render_config.fire_planet": "불 행성",
|
||||
"ui.render_config.ice_planet": "얼음 행성",
|
||||
"ui.render_config.wind_planet": "바람 행성",
|
||||
"ui.render_config.expired": "(만료됨)"
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 (targetPlanet != null && __instance == targetPlanet.planetRenderer && customColor != null)
|
||||
planetColor = new PlanetColor(customColor.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
By decoupling the game's simulation timestep from wall-clock time, ADOFAI Renderer enables creators to produce smooth, ultra-high-definition game videos with perfect audio sync, regardless of hardware limitations or level complexity.
|
||||
|
||||
> Generative AI technology was partially utilized in the development of this mod.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
@@ -90,4 +92,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
@@ -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.1-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.7-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||
|
||||
namespace ADOFAIRenderer;
|
||||
|
||||
+75
-149
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MelonLoader;
|
||||
using O5Kit.Core;
|
||||
using O5Kit.Factory;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.UIElements;
|
||||
using Button = UnityEngine.UI.Button;
|
||||
using Image = UnityEngine.UI.Image;
|
||||
|
||||
namespace ADOFAIRenderer.UI;
|
||||
@@ -29,6 +31,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
{
|
||||
public static CommonWarningDialogUI? Instance { get; private set; }
|
||||
private readonly List<VisualElement> _disabledElements = new();
|
||||
private O5Window? _window;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -37,6 +40,9 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
|
||||
foreach (var elem in _disabledElements)
|
||||
{
|
||||
if (elem != null)
|
||||
@@ -87,9 +93,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
// Canvas setup
|
||||
var canvas = gameObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
|
||||
canvas.sortingOrder = 32766;
|
||||
|
||||
// Responsive Canvas Scaler setup
|
||||
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
@@ -98,13 +103,15 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
|
||||
gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// 1. Create full screen overlay background
|
||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
||||
var ctx = RendererUIManager.Context;
|
||||
|
||||
// 1. Create full screen backdrop (scrim) to block background input
|
||||
var bgPanelObj = new GameObject("Backdrop");
|
||||
bgPanelObj.transform.SetParent(transform, false);
|
||||
bgPanelObj.layer = 5;
|
||||
|
||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
||||
bgImage.raycastTarget = true;
|
||||
|
||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||
@@ -113,112 +120,66 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
bgRect.offsetMin = Vector2.zero;
|
||||
bgRect.offsetMax = Vector2.zero;
|
||||
|
||||
var font = ModAssets.NanumSquareNeoNormal;
|
||||
// 2. Create actual native O5Window
|
||||
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||
{
|
||||
Id = "RendererMod_WarningDialog",
|
||||
Title = title,
|
||||
Size = new Vector2(540, 240),
|
||||
Draggable = true,
|
||||
Resizable = false,
|
||||
ShowCloseButton = false,
|
||||
ShowOutline = true,
|
||||
Padding = new RectOffset(16, 16, 16, 16)
|
||||
});
|
||||
|
||||
// 2. Create modal container
|
||||
var modalContainerObj = new GameObject("ModalContainer");
|
||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
||||
modalContainerObj.layer = 5;
|
||||
// 3. Build content inside window.Content
|
||||
var content = _window.Content;
|
||||
|
||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
|
||||
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||
vLayout.spacing = 16f;
|
||||
vLayout.padding = new RectOffset(16, 16, 12, 12);
|
||||
vLayout.childControlWidth = true;
|
||||
vLayout.childControlHeight = true;
|
||||
vLayout.childForceExpandWidth = true;
|
||||
vLayout.childForceExpandHeight = false;
|
||||
|
||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
||||
modalOutline.effectColor = Color.white;
|
||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
|
||||
|
||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
||||
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
modalRect.sizeDelta = new Vector2(650, 360);
|
||||
|
||||
// 3. Create title text
|
||||
var titleObj = new GameObject("TitleText");
|
||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
titleObj.layer = 5;
|
||||
|
||||
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
|
||||
titleText.text = title;
|
||||
titleText.fontSize = 24;
|
||||
titleText.font = font;
|
||||
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
|
||||
titleText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
||||
titleRect.anchorMin = new Vector2(0, 1);
|
||||
titleRect.anchorMax = new Vector2(1, 1);
|
||||
titleRect.pivot = new Vector2(0.5f, 1);
|
||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
||||
titleRect.sizeDelta = new Vector2(0, 40);
|
||||
|
||||
// 4. Create description text
|
||||
var descObj = new GameObject("DescriptionText");
|
||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
descObj.layer = 5;
|
||||
|
||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
||||
// Description text
|
||||
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||
descText.text = description;
|
||||
descText.fontSize = 20;
|
||||
descText.font = font;
|
||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
||||
descText.alignment = TextAlignmentOptions.Center;
|
||||
descText.lineSpacing = 8;
|
||||
descText.textWrappingMode = TextWrappingModes.Normal;
|
||||
descText.lineSpacing = 6;
|
||||
descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
|
||||
|
||||
var descRect = descObj.GetComponent<RectTransform>();
|
||||
descRect.anchorMin = new Vector2(0, 0.5f);
|
||||
descRect.anchorMax = new Vector2(1, 1);
|
||||
descRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
descRect.anchoredPosition = new Vector2(0, -65);
|
||||
descRect.sizeDelta = new Vector2(-40, 120);
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var descLE = descText.gameObject.AddComponent<LayoutElement>();
|
||||
descLE.flexibleWidth = 1f;
|
||||
descLE.flexibleHeight = 1f;
|
||||
|
||||
// 5. Create button container
|
||||
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
||||
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
buttonContainerObj.layer = 5;
|
||||
// Button action row
|
||||
var btnRowObj = new GameObject("ButtonRow");
|
||||
btnRowObj.transform.SetParent(content, false);
|
||||
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
|
||||
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
|
||||
btnRowLE.preferredHeight = 40f;
|
||||
btnRowLE.flexibleWidth = 1f;
|
||||
|
||||
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
|
||||
btnContainerRect.anchorMin = new Vector2(0, 0);
|
||||
btnContainerRect.anchorMax = new Vector2(1, 0);
|
||||
btnContainerRect.pivot = new Vector2(0.5f, 0);
|
||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
||||
var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
|
||||
hLayout.spacing = 10f;
|
||||
hLayout.childAlignment = TextAnchor.MiddleCenter;
|
||||
hLayout.childControlWidth = true;
|
||||
hLayout.childControlHeight = true;
|
||||
hLayout.childForceExpandWidth = true;
|
||||
hLayout.childForceExpandHeight = true;
|
||||
|
||||
// 6. Dynamic button layout and styling
|
||||
float totalWidth = 560f;
|
||||
float spacing = 15f;
|
||||
float buttonHeight = 45f;
|
||||
int numButtons = buttons.Length;
|
||||
|
||||
float buttonWidth = numButtons == 1
|
||||
? 200f
|
||||
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
|
||||
|
||||
float startX = numButtons == 1
|
||||
? 0
|
||||
: (-totalWidth / 2f) + (buttonWidth / 2f);
|
||||
|
||||
for (int i = 0; i < numButtons; i++)
|
||||
for (var i = 0; i < buttons.Length; i++)
|
||||
{
|
||||
var btnData = buttons[i];
|
||||
var btnObj = new GameObject($"Button_{i}");
|
||||
btnObj.transform.SetParent(buttonContainerObj.transform, false);
|
||||
btnObj.layer = 5;
|
||||
|
||||
// Define default colors
|
||||
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
|
||||
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
|
||||
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
|
||||
|
||||
var btnImage = btnObj.AddComponent<Image>();
|
||||
btnImage.color = baseColor;
|
||||
btnImage.raycastTarget = true;
|
||||
|
||||
var btn = btnObj.AddComponent<Button>();
|
||||
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
|
||||
|
||||
// Register click callback
|
||||
btn.onClick.AddListener(() =>
|
||||
var btn = O5Factory.Button(
|
||||
ctx: ctx,
|
||||
parent: btnRowRect,
|
||||
onClick: () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -226,59 +187,24 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
||||
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
||||
}
|
||||
Destroy(gameObject);
|
||||
});
|
||||
},
|
||||
text: btnData.Text,
|
||||
id: $"btn_dialog_{i}",
|
||||
height: 40f
|
||||
);
|
||||
|
||||
var btnRect = btnObj.GetComponent<RectTransform>();
|
||||
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
btnRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
|
||||
float posX = numButtons == 1
|
||||
? 0
|
||||
: startX + i * (buttonWidth + spacing);
|
||||
|
||||
btnRect.anchoredPosition = new Vector2(posX, 0);
|
||||
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
|
||||
|
||||
// Add text label
|
||||
var txtObj = new GameObject("Text");
|
||||
txtObj.transform.SetParent(btnObj.transform, false);
|
||||
txtObj.layer = 5;
|
||||
|
||||
var text = txtObj.AddComponent<TextMeshProUGUI>();
|
||||
text.text = btnData.Text;
|
||||
text.fontSize = 20;
|
||||
text.font = font;
|
||||
text.color = Color.white;
|
||||
text.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var txtRect = txtObj.GetComponent<RectTransform>();
|
||||
txtRect.anchorMin = Vector2.zero;
|
||||
txtRect.anchorMax = Vector2.one;
|
||||
txtRect.offsetMin = Vector2.zero;
|
||||
txtRect.offsetMax = Vector2.zero;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
|
||||
if (btnData.BackgroundColor.HasValue)
|
||||
{
|
||||
button.transition = Selectable.Transition.ColorTint;
|
||||
var colors = button.colors;
|
||||
colors.normalColor = normalColor;
|
||||
colors.highlightedColor = hoverColor;
|
||||
colors.pressedColor = pressedColor;
|
||||
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
||||
button.colors = colors;
|
||||
btn.NormalColor = btnData.BackgroundColor.Value;
|
||||
btn.UpdateVisual();
|
||||
}
|
||||
|
||||
private Color ApplyColorBrightness(Color color, float factor)
|
||||
{
|
||||
float r = Mathf.Clamp01(color.r * factor);
|
||||
float g = Mathf.Clamp01(color.g * factor);
|
||||
float b = Mathf.Clamp01(color.b * factor);
|
||||
return new Color(r, g, b, color.a);
|
||||
var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||
le.flexibleWidth = 1f;
|
||||
le.preferredHeight = 40f;
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-222
@@ -4,11 +4,13 @@ using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using O5Kit.Core;
|
||||
using O5Kit.Factory;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
@@ -22,6 +24,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
private GameObject _statusTextObj = null!;
|
||||
private TextMeshProUGUI _statusText = null!;
|
||||
|
||||
private O5Window? _window;
|
||||
private bool _isDownloading;
|
||||
private string DownloadUrl => Application.platform switch
|
||||
{
|
||||
@@ -85,46 +88,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)
|
||||
@@ -144,13 +107,15 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
|
||||
gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// 1. Create full screen overlay background
|
||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
||||
var ctx = RendererUIManager.Context;
|
||||
|
||||
// 1. Create full screen backdrop
|
||||
var bgPanelObj = new GameObject("Backdrop");
|
||||
bgPanelObj.transform.SetParent(transform, false);
|
||||
bgPanelObj.layer = 5;
|
||||
|
||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
||||
bgImage.raycastTarget = true;
|
||||
|
||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||
@@ -159,51 +124,31 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
bgRect.offsetMin = Vector2.zero;
|
||||
bgRect.offsetMax = Vector2.zero;
|
||||
|
||||
var font = GetNanumSquareNeoBdFontAsset();
|
||||
// 2. Create actual native O5Window
|
||||
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||
{
|
||||
Id = "RendererMod_FFmpegDownloadDialog",
|
||||
Title = "FFmpeg Library Installation Required",
|
||||
Size = new Vector2(580, 270),
|
||||
Draggable = true,
|
||||
Resizable = false,
|
||||
ShowCloseButton = false,
|
||||
ShowOutline = true,
|
||||
Padding = new RectOffset(16, 16, 16, 16)
|
||||
});
|
||||
|
||||
// 2. Create modal container
|
||||
var modalContainerObj = new GameObject("ModalContainer");
|
||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
||||
modalContainerObj.layer = 5;
|
||||
var content = _window.Content;
|
||||
|
||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
|
||||
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||
vLayout.spacing = 14f;
|
||||
vLayout.padding = new RectOffset(16, 16, 10, 10);
|
||||
vLayout.childControlWidth = true;
|
||||
vLayout.childControlHeight = true;
|
||||
vLayout.childForceExpandWidth = true;
|
||||
vLayout.childForceExpandHeight = false;
|
||||
|
||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
||||
modalOutline.effectColor = Color.white;
|
||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
||||
|
||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
||||
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
modalRect.sizeDelta = new Vector2(600, 320);
|
||||
|
||||
// 4. Create title text
|
||||
var titleObj = new GameObject("TitleText");
|
||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
titleObj.layer = 5;
|
||||
|
||||
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
|
||||
titleText.text = "FFmpeg Library Installation Required";
|
||||
titleText.fontSize = 26;
|
||||
titleText.font = font;
|
||||
titleText.color = Color.white;
|
||||
titleText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
||||
titleRect.anchorMin = new Vector2(0, 1);
|
||||
titleRect.anchorMax = new Vector2(1, 1);
|
||||
titleRect.pivot = new Vector2(0.5f, 1);
|
||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
||||
titleRect.sizeDelta = new Vector2(0, 40);
|
||||
|
||||
// 5. Create description text
|
||||
var descObj = new GameObject("DescriptionText");
|
||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
descObj.layer = 5;
|
||||
|
||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
||||
// 3. Description text
|
||||
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||
var descStrBuilder = new StringBuilder();
|
||||
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
|
||||
if (Application.platform == RuntimePlatform.OSXPlayer)
|
||||
@@ -216,139 +161,104 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
|
||||
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
|
||||
}
|
||||
|
||||
descText.text = descStrBuilder.ToString();
|
||||
descText.fontSize = 16;
|
||||
descText.font = font;
|
||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
||||
descText.alignment = TextAlignmentOptions.Center;
|
||||
descText.lineSpacing = 10;
|
||||
descText.textWrappingMode = TextWrappingModes.Normal;
|
||||
descText.lineSpacing = 5;
|
||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
||||
|
||||
var descRect = descObj.GetComponent<RectTransform>();
|
||||
descRect.anchorMin = new Vector2(0, 0.5f);
|
||||
descRect.anchorMax = new Vector2(1, 1);
|
||||
descRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
descRect.anchoredPosition = new Vector2(0, -60);
|
||||
descRect.sizeDelta = new Vector2(-40, 100);
|
||||
var descLE = descText.gameObject.AddComponent<LayoutElement>();
|
||||
descLE.flexibleWidth = 1f;
|
||||
descLE.flexibleHeight = 1f;
|
||||
|
||||
// 6. Create button container
|
||||
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
||||
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
// 4. Button container
|
||||
_buttonContainerObj = new GameObject("ButtonContainer");
|
||||
_buttonContainerObj.transform.SetParent(content, false);
|
||||
_buttonContainerObj.layer = 5;
|
||||
|
||||
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>();
|
||||
btnContainerRect.anchorMin = new Vector2(0, 0);
|
||||
btnContainerRect.anchorMax = new Vector2(1, 0);
|
||||
btnContainerRect.pivot = new Vector2(0.5f, 0);
|
||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
||||
var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
|
||||
btnContainerLE.preferredHeight = 40f;
|
||||
btnContainerLE.flexibleWidth = 1f;
|
||||
|
||||
// 7. Create download button
|
||||
var downloadBtnObj = new GameObject("DownloadButton");
|
||||
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
||||
downloadBtnObj.layer = 5;
|
||||
var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
|
||||
btnLayout.spacing = 10f;
|
||||
btnLayout.childAlignment = TextAnchor.MiddleCenter;
|
||||
btnLayout.childControlWidth = true;
|
||||
btnLayout.childControlHeight = true;
|
||||
btnLayout.childForceExpandWidth = true;
|
||||
btnLayout.childForceExpandHeight = true;
|
||||
|
||||
var downloadImg = downloadBtnObj.AddComponent<Image>();
|
||||
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
|
||||
downloadImg.raycastTarget = true;
|
||||
Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
|
||||
? StartDownloadProcess
|
||||
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
|
||||
|
||||
var downloadBtn = downloadBtnObj.AddComponent<Button>();
|
||||
SetupButtonStyle(downloadBtn, new Color(0.0f, 0.8f, 1.0f, 1.0f), new Color(0.2f, 0.9f, 1.0f, 1.0f), new Color(0.0f, 0.6f, 0.8f, 1.0f));
|
||||
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess);
|
||||
else
|
||||
{
|
||||
downloadBtn.onClick.AddListener(OpenFFmpegWebsite);
|
||||
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }
|
||||
}
|
||||
var downloadBtn = O5Factory.Button(
|
||||
ctx: ctx,
|
||||
parent: _buttonContainerObj.transform,
|
||||
onClick: onDownloadClick,
|
||||
text: Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website",
|
||||
id: "btn_ffmpeg_download",
|
||||
height: 40f
|
||||
);
|
||||
downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
|
||||
downloadBtn.UpdateVisual();
|
||||
|
||||
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>();
|
||||
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
|
||||
downloadBtnRect.anchoredPosition = new Vector2(-15, 0);
|
||||
downloadBtnRect.sizeDelta = new Vector2(180, 45);
|
||||
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||
downloadBtnLE.flexibleWidth = 1f;
|
||||
downloadBtnLE.preferredHeight = 40f;
|
||||
|
||||
var downloadTextObj = new GameObject("DownloadText");
|
||||
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
|
||||
downloadTextObj.layer = 5;
|
||||
var cancelBtn = O5Factory.Button(
|
||||
ctx: ctx,
|
||||
parent: _buttonContainerObj.transform,
|
||||
onClick: CancelProcess,
|
||||
text: "Cancel",
|
||||
id: "btn_ffmpeg_cancel",
|
||||
height: 40f
|
||||
);
|
||||
var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||
cancelBtnLE.flexibleWidth = 1f;
|
||||
cancelBtnLE.preferredHeight = 40f;
|
||||
|
||||
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
|
||||
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
|
||||
downloadText.fontSize = 20;
|
||||
downloadText.font = font;
|
||||
downloadText.color = Color.white;
|
||||
downloadText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>();
|
||||
downloadTextRect.anchorMin = Vector2.zero;
|
||||
downloadTextRect.anchorMax = Vector2.one;
|
||||
downloadTextRect.offsetMin = Vector2.zero;
|
||||
downloadTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 8. Create cancel button
|
||||
var cancelBtnObj = new GameObject("CancelButton");
|
||||
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
||||
cancelBtnObj.layer = 5;
|
||||
|
||||
var cancelImg = cancelBtnObj.AddComponent<Image>();
|
||||
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f);
|
||||
cancelImg.raycastTarget = true;
|
||||
|
||||
var cancelBtn = cancelBtnObj.AddComponent<Button>();
|
||||
SetupButtonStyle(cancelBtn, new Color(0.2f, 0.2f, 0.23f, 1f), new Color(0.25f, 0.25f, 0.28f, 1f), new Color(0.15f, 0.15f, 0.18f, 1f));
|
||||
cancelBtn.onClick.AddListener(CancelProcess);
|
||||
|
||||
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>();
|
||||
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
cancelBtnRect.pivot = new Vector2(0f, 0.5f);
|
||||
cancelBtnRect.anchoredPosition = new Vector2(15, 0);
|
||||
cancelBtnRect.sizeDelta = new Vector2(180, 45);
|
||||
|
||||
var cancelTextObj = new GameObject("CancelText");
|
||||
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false);
|
||||
cancelTextObj.layer = 5;
|
||||
|
||||
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
|
||||
cancelText.text = "Cancel";
|
||||
cancelText.fontSize = 20;
|
||||
cancelText.font = font;
|
||||
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
|
||||
cancelText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var cancelTextRect = cancelTextObj.GetComponent<RectTransform>();
|
||||
cancelTextRect.anchorMin = Vector2.zero;
|
||||
cancelTextRect.anchorMax = Vector2.one;
|
||||
cancelTextRect.offsetMin = Vector2.zero;
|
||||
cancelTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 9. Create progress bar container
|
||||
// 5. Progress bar container
|
||||
_progressBarObj = new GameObject("ProgressBarContainer");
|
||||
_progressBarObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
_progressBarObj.transform.SetParent(content, false);
|
||||
_progressBarObj.layer = 5;
|
||||
_progressBarObj.SetActive(false);
|
||||
|
||||
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
|
||||
pbLE.preferredHeight = 24f;
|
||||
pbLE.flexibleWidth = 1f;
|
||||
|
||||
var barBgImg = _progressBarObj.AddComponent<Image>();
|
||||
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
|
||||
barBgImg.sprite = ctx.Sprites.RoundedControl;
|
||||
barBgImg.type = Image.Type.Sliced;
|
||||
barBgImg.color = ctx.Theme.ObjectBG;
|
||||
|
||||
var barBgOutline = _progressBarObj.AddComponent<Outline>();
|
||||
barBgOutline.effectColor = Color.white;
|
||||
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
||||
var barOutlineObj = new GameObject("BarOutline");
|
||||
barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
|
||||
barOutlineObj.layer = 5;
|
||||
|
||||
var barRect = _progressBarObj.GetComponent<RectTransform>();
|
||||
barRect.anchorMin = new Vector2(0.5f, 0);
|
||||
barRect.anchorMax = new Vector2(0.5f, 0);
|
||||
barRect.pivot = new Vector2(0.5f, 0);
|
||||
barRect.anchoredPosition = new Vector2(0, 75);
|
||||
barRect.sizeDelta = new Vector2(480, 24);
|
||||
var barOutlineImg = barOutlineObj.AddComponent<Image>();
|
||||
barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
|
||||
barOutlineImg.type = Image.Type.Sliced;
|
||||
barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
|
||||
barOutlineImg.raycastTarget = false;
|
||||
|
||||
// 10. Create progress bar fill image
|
||||
var barOutlineRect = barOutlineObj.GetComponent<RectTransform>();
|
||||
barOutlineRect.anchorMin = Vector2.zero;
|
||||
barOutlineRect.anchorMax = Vector2.one;
|
||||
barOutlineRect.offsetMin = Vector2.zero;
|
||||
barOutlineRect.offsetMax = Vector2.zero;
|
||||
|
||||
// Progress bar fill image
|
||||
_progressBarFillObj = new GameObject("ProgressBarFill");
|
||||
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
||||
_progressBarFillObj.layer = 5;
|
||||
|
||||
var fillImg = _progressBarFillObj.AddComponent<Image>();
|
||||
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
|
||||
fillImg.sprite = ctx.Sprites.RoundedControl;
|
||||
fillImg.type = Image.Type.Sliced;
|
||||
fillImg.color = ctx.Theme.ObjectActiveBright;
|
||||
|
||||
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
|
||||
_fillRect.anchorMin = new Vector2(0, 0);
|
||||
@@ -357,20 +267,17 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
_fillRect.anchoredPosition = Vector2.zero;
|
||||
_fillRect.sizeDelta = Vector2.zero;
|
||||
|
||||
// 11. Create progress text
|
||||
// Progress text
|
||||
var progressTextObj = new GameObject("ProgressBarText");
|
||||
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
||||
progressTextObj.layer = 5;
|
||||
|
||||
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
||||
_progressText.fontSize = 16;
|
||||
_progressText.font = font;
|
||||
_progressText.fontSize = 13;
|
||||
_progressText.font = ctx.Fonts.Regular;
|
||||
_progressText.color = Color.white;
|
||||
_progressText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var progressTextOutline = progressTextObj.AddComponent<Outline>();
|
||||
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
|
||||
progressTextOutline.effectDistance = new Vector2(1, -1);
|
||||
_progressText.raycastTarget = false;
|
||||
|
||||
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
|
||||
progressTextRect.anchorMin = Vector2.zero;
|
||||
@@ -378,35 +285,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
progressTextRect.offsetMin = Vector2.zero;
|
||||
progressTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 12. Create status text
|
||||
// 6. Status text
|
||||
_statusTextObj = new GameObject("StatusText");
|
||||
_statusTextObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
_statusTextObj.transform.SetParent(content, false);
|
||||
_statusTextObj.layer = 5;
|
||||
_statusTextObj.SetActive(false);
|
||||
|
||||
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
|
||||
statusLE.preferredHeight = 22f;
|
||||
statusLE.flexibleWidth = 1f;
|
||||
|
||||
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
|
||||
_statusText.fontSize = 16;
|
||||
_statusText.font = font;
|
||||
_statusText.fontSize = 14;
|
||||
_statusText.font = ctx.Fonts.Regular;
|
||||
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
|
||||
_statusText.alignment = TextAlignmentOptions.Center;
|
||||
_statusText.raycastTarget = false;
|
||||
|
||||
var statusRect = _statusTextObj.GetComponent<RectTransform>();
|
||||
statusRect.anchorMin = new Vector2(0, 0);
|
||||
statusRect.anchorMax = new Vector2(1, 0);
|
||||
statusRect.pivot = new Vector2(0.5f, 0);
|
||||
statusRect.anchoredPosition = new Vector2(0, 35);
|
||||
statusRect.sizeDelta = new Vector2(0, 30);
|
||||
}
|
||||
|
||||
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
|
||||
{
|
||||
button.transition = Selectable.Transition.ColorTint;
|
||||
var colors = button.colors;
|
||||
colors.normalColor = normalColor;
|
||||
colors.highlightedColor = hoverColor;
|
||||
colors.pressedColor = pressedColor;
|
||||
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
||||
button.colors = colors;
|
||||
statusRect.anchorMin = Vector2.zero;
|
||||
statusRect.anchorMax = Vector2.one;
|
||||
statusRect.offsetMin = Vector2.zero;
|
||||
statusRect.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -683,6 +583,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
_cts?.Dispose();
|
||||
if (Instance == this)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using ADOFAIRenderer.Localization;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
|
||||
/// automatically updating the displayed text whenever the active language changes.
|
||||
/// </summary>
|
||||
internal class LocalizedText : MonoBehaviour
|
||||
{
|
||||
private TextMeshProUGUI? _textComponent;
|
||||
private string? _key;
|
||||
private Func<string>? _dynamicProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the localization key. Setting this will clear any dynamic provider.
|
||||
/// </summary>
|
||||
public string? Key
|
||||
{
|
||||
get => _key;
|
||||
set
|
||||
{
|
||||
_key = value;
|
||||
_dynamicProvider = null;
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a dynamic string provider function.
|
||||
/// </summary>
|
||||
public void SetDynamic(Func<string> provider)
|
||||
{
|
||||
_dynamicProvider = provider;
|
||||
_key = null;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
EnsureComponent();
|
||||
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
|
||||
}
|
||||
|
||||
private void HandleLanguageChanged(SystemLanguage language)
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void EnsureComponent()
|
||||
{
|
||||
if (_textComponent == null)
|
||||
{
|
||||
_textComponent = GetComponent<TextMeshProUGUI>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the text using either the dynamic provider or the static localization key.
|
||||
/// </summary>
|
||||
public void Refresh()
|
||||
{
|
||||
EnsureComponent();
|
||||
if (_textComponent == null) return;
|
||||
|
||||
if (_dynamicProvider != null)
|
||||
{
|
||||
_textComponent.text = _dynamicProvider();
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_key))
|
||||
{
|
||||
_textComponent.text = LocalizationManager.Get(_key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds a TextMeshProUGUI component to a localization key.
|
||||
/// </summary>
|
||||
public static LocalizedText Bind(TextMeshProUGUI target, string key)
|
||||
{
|
||||
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
|
||||
component._textComponent = target;
|
||||
component.Key = key;
|
||||
return component;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
|
||||
/// </summary>
|
||||
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
|
||||
{
|
||||
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
|
||||
component._textComponent = target;
|
||||
component.SetDynamic(provider);
|
||||
return component;
|
||||
}
|
||||
}
|
||||
+3
-18
@@ -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;
|
||||
}
|
||||
}
|
||||
+1066
-195
File diff suppressed because it is too large.
Load diff
@@ -108,7 +108,7 @@ internal static class AudioSpectrumGenerator
|
||||
return;
|
||||
}
|
||||
|
||||
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
|
||||
var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
|
||||
|
||||
lock (CachedSpectrums)
|
||||
{
|
||||
|
||||
@@ -15,11 +15,6 @@ internal class AudioSyncCalculator
|
||||
{
|
||||
_sampleRate = sampleRate;
|
||||
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
||||
|
||||
// NTSC
|
||||
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
|
||||
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
|
||||
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
[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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
+998
-334
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ADOFAIRenderer.Data;
|
||||
using FFmpeg.AutoGen;
|
||||
using MelonLoader;
|
||||
using Unity.Collections;
|
||||
@@ -82,7 +83,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary>
|
||||
public double FPS;
|
||||
|
||||
/// <summary>Video codec type to use (H264, H265, AV1, VP9).</summary>
|
||||
/// <summary>Video codec type to use (H264, H265, AV1, VP9, VVC).</summary>
|
||||
public Codec Codec;
|
||||
|
||||
/// <summary>Bitrate control mode (CQP, CBR, VBR).</summary>
|
||||
@@ -436,16 +437,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// framerate: Framerate value giving hints to the encoder (reciprocal of time_base).
|
||||
//
|
||||
// RationalUtil: Converts FPS (double) into fractional form (numerator/denominator).
|
||||
// e.g., 29.97 → 30000/1001 (NTSC standard)
|
||||
// e.g., 60.00 -> 60/1
|
||||
//
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
||||
var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS);
|
||||
|
||||
// NTSC
|
||||
if (Math.Abs(_settings.FPS - 29.97) < 0.001) fpsRational = (30000, 1001);
|
||||
if (Math.Abs(_settings.FPS - 59.94) < 0.001) fpsRational = (60000, 1001);
|
||||
if (Math.Abs(_settings.FPS - 23.976) < 0.001 || Math.Abs(_settings.FPS - 23.98) < 0.001) fpsRational = (24000, 1001);
|
||||
|
||||
_pCodecContext->time_base = new AVRational
|
||||
{ num = fpsRational.Denominator, den = fpsRational.Numerator };
|
||||
_pCodecContext->framerate = new AVRational
|
||||
@@ -727,6 +723,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0)
|
||||
throw new Exception("Failed to copy codec parameters to stream.");
|
||||
|
||||
// Stream time base: unit of the timestamps stored in the container.
|
||||
// Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks.
|
||||
// When it is left unset, the muxer uses 1/90000, in which the interval of rates such as
|
||||
// 59.94 fps (1501.5 ticks), 23.976 fps (3753.75 ticks) or 165 fps (545.45 ticks) is not an integer,
|
||||
// and the frame durations alternate between two values.
|
||||
// The muxer may still adjust the value in avformat_write_header (e.g. mp4 scales it to at least 10000);
|
||||
// packet timestamps are rescaled to the final value in SendFrameAndWritePackets.
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/mux.c (init_muxer: default 1/90000)
|
||||
_pVideoStream->time_base = _pCodecContext->time_base;
|
||||
|
||||
// ── Step 4.5 (Optional): Initialize audio stream ──
|
||||
// If EncoderSettings.Audio is set, add audio stream.
|
||||
if (_settings.Audio.HasValue)
|
||||
@@ -829,7 +835,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// Reference - CQP options per encoder:
|
||||
/// NVENC: "qp" (single value applied to I/P/B frames)
|
||||
/// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately)
|
||||
/// QSV: "global_quality" (Intel unique option)
|
||||
/// QSV: AVCodecContext.global_quality (Intel ICQ mode)
|
||||
/// Software: "crf" (Constant Rate Factor, smarter quality control)
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
||||
@@ -851,11 +857,21 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
_pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L;
|
||||
_pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000;
|
||||
|
||||
// nal-hrd=cbr: x264/x265 dedicated option. Generates CBR bitstream matching
|
||||
// NAL HRD (Hypothetical Reference Decoder) specification. May be required for streaming compatibility.
|
||||
if (encoder == VideoEncoder.Libx264 || encoder == VideoEncoder.Libx265)
|
||||
// HRD (Hypothetical Reference Decoder) signaling for CBR output, used for streaming compatibility.
|
||||
// libx264: "nal-hrd=cbr" writes the NAL HRD information and pads filler data to keep the bitrate constant.
|
||||
// FFmpeg's option description: "Signal HRD information (requires vbv-bufsize; cbr not allowed in .mp4)".
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/libx264.c ("nal-hrd": none/vbr/cbr)
|
||||
// libx265: "x265-params" = "hrd=1" (--hrd: "Enable the signaling of HRD parameters to the decoder")
|
||||
// + "strict-cbr=1" (--strict-cbr: "Enables stricter conditions to control bitrate deviance
|
||||
// from the target bitrate in ABR mode").
|
||||
// Reference: https://x265.readthedocs.io/en/master/cli.html
|
||||
if (encoder == VideoEncoder.Libx264)
|
||||
{
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "nal-hrd", "cbr", 0);
|
||||
TrySetOption("nal-hrd", "cbr");
|
||||
}
|
||||
else if (encoder == VideoEncoder.Libx265)
|
||||
{
|
||||
TrySetOption("x265-params", "hrd=1:strict-cbr=1");
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -879,38 +895,77 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
break;
|
||||
|
||||
case RateControlMode.CQP:
|
||||
// Valid quality range per encoder. The range of the private options is read from the
|
||||
// encoder's option definition (AVOption min/max); a declared minimum of -1 means "auto".
|
||||
// NVENC: "qp" H.264/HEVC 0~51, AV1 0~255
|
||||
// (libavcodec/nvenc_h264.c, nvenc_hevc.c: "qp" -1~51 / libavcodec/nvenc_av1.c: "qp" -1~255)
|
||||
// AMF: "qp_i" H.264/HEVC 0~51, AV1 0~255
|
||||
// (libavcodec/amfenc_h264.c, amfenc_hevc.c: "qp_i" -1~51 / libavcodec/amfenc_av1.c: "qp_i" -1~255)
|
||||
// QSV: AVCodecContext.global_quality 1~51
|
||||
// VVC: "qp" 0~63 (libavcodec/libvvenc.c: "qp" -1~63)
|
||||
// Software: "crf" x264/x265 0~51 (https://x265.readthedocs.io/en/master/cli.html: --crf 0..51.0),
|
||||
// VP9/AV1 0~63 (libavcodec/libvpxenc.c, libaomenc.c: "crf" -1~63 / libavcodec/libsvtav1.c: "crf" 0~63)
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/tree/master/libavcodec
|
||||
int qpMin, qpMax;
|
||||
if (encoder.Kind == EncoderKind.QSV)
|
||||
{
|
||||
// QSV runs in ICQ (Intelligent Constant Quality) mode when global_quality > 0 and rc_max_rate is 0;
|
||||
// global_quality 0 selects the encoder's default CQP. The ICQ quality is clamped to 1~51 by FFmpeg:
|
||||
// libavcodec/qsvenc.c: q->param.mfx.ICQQuality = av_clip(avctx->global_quality, 1, 51);
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/qsvenc.c
|
||||
qpMin = 1;
|
||||
qpMax = 51;
|
||||
}
|
||||
else if (encoder.Kind == EncoderKind.NVENC) (qpMin, qpMax) = GetQualityRange("qp");
|
||||
else if (encoder.Kind == EncoderKind.AMF) (qpMin, qpMax) = GetQualityRange("qp_i");
|
||||
else if (encoder == VideoEncoder.LibVvenc) (qpMin, qpMax) = GetQualityRange("qp");
|
||||
else (qpMin, qpMax) = GetQualityRange("crf"); // Software encoder (libx264, libx265, libvpx-vp9, etc.)
|
||||
|
||||
// Default value 18: Quality close to "visually lossless" in H.264.
|
||||
// Provides similar level of quality in other codecs as well.
|
||||
var qpValue = _settings.QualityValue is >= 0 and <= 51 ? _settings.QualityValue : 18;
|
||||
const int defaultQuality = 18;
|
||||
var requestedQp = _settings.QualityValue;
|
||||
int qpValue;
|
||||
if (requestedQp < 0)
|
||||
{
|
||||
qpValue = Math.Clamp(defaultQuality, qpMin, qpMax);
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Invalid quality value {requestedQp}. Using {qpValue}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
qpValue = Math.Clamp(requestedQp, qpMin, qpMax);
|
||||
if (qpValue != requestedQp)
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Quality value {requestedQp} is outside the supported range {qpMin}~{qpMax}. Using {qpValue}.");
|
||||
}
|
||||
|
||||
if (encoder.Kind == EncoderKind.NVENC)
|
||||
{
|
||||
// NVENC: Applies same QP to I/P/B frames with single "qp" option
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0);
|
||||
TrySetOption("qp", qpValue.ToString());
|
||||
}
|
||||
else if (encoder.Kind == EncoderKind.AMF)
|
||||
{
|
||||
// AMF: QP for I-frame (qp_i) and P-frame (qp_p) must be set separately.
|
||||
// Since B-frames are disabled, qp_b is not set.
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_i", qpValue.ToString(), 0);
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_p", qpValue.ToString(), 0);
|
||||
TrySetOption("qp_i", qpValue.ToString());
|
||||
TrySetOption("qp_p", qpValue.ToString());
|
||||
}
|
||||
else if (encoder.Kind == EncoderKind.QSV)
|
||||
{
|
||||
// QSV: Uses ICQ (Intelligent Constant Quality) mode with "global_quality" option
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "global_quality", qpValue.ToString(), 0);
|
||||
// QSV: ICQ (Intelligent Constant Quality) mode, configured with AVCodecContext.global_quality.
|
||||
_pCodecContext->global_quality = qpValue;
|
||||
}
|
||||
else if (encoder == VideoEncoder.LibVvenc)
|
||||
{
|
||||
// libvvenc: Specifies Constant QP mode with "qp" option
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0);
|
||||
TrySetOption("qp", qpValue.ToString());
|
||||
}
|
||||
else // Software encoder (libx264, libx265, libvpx-vp9, etc.)
|
||||
else
|
||||
{
|
||||
// CRF (Constant Rate Factor): Smarter quality control than QP.
|
||||
// Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality.
|
||||
// Range: 0 (lossless) ~ 51 (lowest quality). Typically between 18 and 28.
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "crf", qpValue.ToString(), 0);
|
||||
// Range: 0 (lossless) ~ 51 (lowest quality) for x264/x265, 0~63 for VP9/AV1. Typically between 18 and 28.
|
||||
TrySetOption("crf", qpValue.ToString());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -918,6 +973,49 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a private option (priv_data) of the encoder.
|
||||
/// Returns true when FFmpeg accepts the option. Otherwise logs a warning with the FFmpeg error and returns false.
|
||||
/// </summary>
|
||||
private bool TrySetOption(string name, string value)
|
||||
{
|
||||
var ret = ffmpeg.av_opt_set(_pCodecContext->priv_data, name, value, 0);
|
||||
if (ret >= 0) return true;
|
||||
|
||||
Melon<RenderMod>.Logger.Warning($"[Encoder] Option '{name}={value}' was not applied: {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Largest declared maximum that is treated as an actual quality range.
|
||||
/// A declared maximum above this value is treated as unbounded, and the fallback range 0~51 is used.
|
||||
/// The largest bounded quality range among the supported encoders is 0~255 (AV1 NVENC/AMF), while
|
||||
/// libx264/libx265 declare "crf" as -1~FLT_MAX (libavcodec/libx264.c, libx265.c).
|
||||
/// </summary>
|
||||
private const double MaxMeaningfulQualityLimit = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the valid range of a private quality option of the encoder from its option definition (AVOption min/max).
|
||||
/// e.g. 0~51 for H.264/HEVC, 0~63 for VP9/AV1/VVC, 0~255 for AV1 NVENC/AMF.
|
||||
/// The lower bound is at least 0 (a declared minimum of -1 means "auto").
|
||||
/// When the option is missing or its declared maximum is unbounded (see <see cref="MaxMeaningfulQualityLimit"/>), returns 0~51,
|
||||
/// the QP scale of H.264/HEVC 8-bit (x264/x265 CRF 0~51).
|
||||
/// Reference: https://x265.readthedocs.io/en/master/cli.html (--crf 0..51.0)
|
||||
/// </summary>
|
||||
/// <param name="optionName">Name of the quality option in priv_data.</param>
|
||||
private (int Min, int Max) GetQualityRange(string optionName)
|
||||
{
|
||||
const int fallbackMax = 51;
|
||||
|
||||
var option = ffmpeg.av_opt_find(_pCodecContext->priv_data, optionName, null, 0, 0);
|
||||
if (option == null || !(option->max > 0 && option->max <= MaxMeaningfulQualityLimit))
|
||||
return (0, fallbackMax);
|
||||
|
||||
var min = Math.Max(0, (int)Math.Ceiling(option->min));
|
||||
var max = (int)Math.Floor(option->max);
|
||||
return min <= max ? (min, max) : (0, fallbackMax);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Write Frame
|
||||
// ================================================================
|
||||
@@ -973,29 +1071,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 +1094,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 +1227,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 +1265,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 ──
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public enum RateControlMode
|
||||
{
|
||||
CBR,
|
||||
VBR,
|
||||
CQP
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ 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")]
|
||||
public enum EncoderKind
|
||||
internal enum EncoderKind
|
||||
{
|
||||
Software,
|
||||
NVENC,
|
||||
@@ -110,4 +111,3 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-62
@@ -27,11 +27,33 @@ namespace ADOFAIRenderer.Util
|
||||
private bool _isInitialized;
|
||||
private volatile bool _isDisposed;
|
||||
|
||||
private readonly AVPixelFormat _targetFormat;
|
||||
private readonly ComputeShader? _computeShader;
|
||||
private readonly int _kernelIndex = -1;
|
||||
private readonly bool _useGPUConversion;
|
||||
private readonly AVPixelFormat _expectedFormat;
|
||||
|
||||
public FrameReader(AVPixelFormat targetFormat)
|
||||
{
|
||||
_targetFormat = targetFormat;
|
||||
_computeShader = ModAssets.ColorConversionShader;
|
||||
if (_computeShader)
|
||||
{
|
||||
var kernelName = targetFormat switch
|
||||
{
|
||||
AVPixelFormat.AV_PIX_FMT_NV12 => "CSMain_NV12",
|
||||
AVPixelFormat.AV_PIX_FMT_YUV420P => "CSMain_YUV420P",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (kernelName != null)
|
||||
{
|
||||
try { _kernelIndex = _computeShader.FindKernel(kernelName); }
|
||||
catch { _kernelIndex = -1; }
|
||||
}
|
||||
}
|
||||
|
||||
_useGPUConversion = _kernelIndex >= 0;
|
||||
_expectedFormat = _useGPUConversion ? targetFormat : AVPixelFormat.AV_PIX_FMT_RGBA;
|
||||
|
||||
InitializePool();
|
||||
}
|
||||
|
||||
@@ -71,72 +93,35 @@ 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
useGPUConversion = false;
|
||||
}
|
||||
}
|
||||
|
||||
var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel
|
||||
state.Setup(currentFrame, bufferSize, useGPUConversion, expectedFmt, texture.width, texture.height);
|
||||
state.Setup(currentFrame, bufferSize, _useGPUConversion, _expectedFormat, texture.width, texture.height);
|
||||
|
||||
if (useGPUConversion && state.GPUBuffer != null && ModAssets.ColorConversionShader)
|
||||
if (_useGPUConversion && state.GPUBuffer != null && _computeShader)
|
||||
{
|
||||
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");
|
||||
}
|
||||
_computeShader.SetTexture(_kernelIndex, SourceTexture, texture);
|
||||
_computeShader.SetBuffer(_kernelIndex, DestinationBuffer, state.GPUBuffer);
|
||||
_computeShader.SetInt(Width, texture.width);
|
||||
_computeShader.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
|
||||
var threadGroupsX = _expectedFormat == 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);
|
||||
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
||||
|
||||
AsyncGPUReadback.Request(
|
||||
AsyncGPUReadback.RequestIntoNativeArray(
|
||||
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
|
||||
);
|
||||
/*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
|
||||
);
|
||||
}
|
||||
);*/
|
||||
}
|
||||
else
|
||||
{
|
||||
AsyncGPUReadback.RequestIntoNativeArray(
|
||||
ref state.NativeBuffer,
|
||||
texture,
|
||||
0,
|
||||
state.CachedCallback
|
||||
ref state.NativeBuffer, texture, 0, state.CachedCallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -155,7 +140,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
|
||||
{
|
||||
RecycleState(state);
|
||||
return;
|
||||
@@ -169,16 +154,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 +170,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);
|
||||
}
|
||||
|
||||
+5
-4
@@ -2,12 +2,14 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
namespace ADOFAIRenderer.Util;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
internal enum GPUVendor
|
||||
{
|
||||
@@ -120,7 +122,7 @@ namespace ADOFAIRenderer.Util
|
||||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
||||
|
||||
var nvidiaVerInt = sub % 10 * 10000 + build;
|
||||
_cachedNvidiaVersion = nvidiaVerInt / 100f;
|
||||
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
|
||||
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
||||
return _cachedNvidiaVersion;
|
||||
}
|
||||
@@ -150,4 +152,3 @@ namespace ADOFAIRenderer.Util
|
||||
return prioritized;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
@@ -7,8 +8,10 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util;
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||
internal static class FolderDialogUtil
|
||||
{
|
||||
private static int _isDialogOpen;
|
||||
@@ -187,14 +190,12 @@ internal static class FolderDialogUtil
|
||||
|
||||
private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle)
|
||||
{
|
||||
IFileOpenDialog? dialog = null;
|
||||
|
||||
// 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 dialog);
|
||||
int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out var dialog);
|
||||
|
||||
if (createHr != 0 || dialog == null)
|
||||
if (createHr != 0)
|
||||
{
|
||||
// 2. Fallback to Activator via Type.GetTypeFromCLSID
|
||||
var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog);
|
||||
@@ -224,7 +225,7 @@ internal static class FolderDialogUtil
|
||||
{
|
||||
var shellItemGuid = IID_IShellItem;
|
||||
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
|
||||
if (itemHr == 0 && initialItem != null)
|
||||
if (itemHr == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -263,7 +264,6 @@ internal static class FolderDialogUtil
|
||||
}
|
||||
|
||||
dialog.GetResult(out var resultItem);
|
||||
if (resultItem == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -319,7 +319,7 @@ internal static class FolderDialogUtil
|
||||
if (Directory.Exists(normalized))
|
||||
{
|
||||
initialDirPtr = Marshal.StringToHGlobalUni(normalized);
|
||||
callback = (hwnd, uMsg, lParam, lpData) =>
|
||||
callback = (hwnd, uMsg, _, lpData) =>
|
||||
{
|
||||
if (uMsg == BFFM_INITIALIZED && lpData != IntPtr.Zero)
|
||||
{
|
||||
@@ -0,0 +1,577 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
/// <summary>
|
||||
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
||||
/// <para>
|
||||
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
|
||||
/// No PowerShell, no UWP, no WinRT dependencies.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class OSNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a native desktop notification.
|
||||
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
||||
/// the folder when the notification is clicked/dismissed.
|
||||
/// </summary>
|
||||
/// <param name="title">The notification title.</param>
|
||||
/// <param name="message">The notification body text.</param>
|
||||
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
|
||||
public static void Show(string title, string message, string? folderPath = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
WindowsNotification.Send(title, message, folderPath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
MacNotification.Send(title, message);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
LinuxNotification.Send(title, message, folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
||||
|
||||
/// <summary>
|
||||
/// Windows notification with two strategies:
|
||||
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
||||
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
||||
/// </summary>
|
||||
private static class WindowsNotification
|
||||
{
|
||||
public static void Send(string title, string message, string? folderPath)
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TrySendToast(title, message, folderPath))
|
||||
{
|
||||
SendBalloon(title, message, folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "OSNotification-Win"
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
#region Toast via PowerShell
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to show a Windows Toast notification via PowerShell.
|
||||
/// Returns true if the process launched successfully, false otherwise.
|
||||
/// </summary>
|
||||
private static bool TrySendToast(string title, string message, string? folderPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
||||
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
||||
|
||||
string toastXml;
|
||||
string psScript;
|
||||
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
var escapedFolder = folderPath.Replace("'", "''");
|
||||
|
||||
toastXml =
|
||||
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
||||
"<visual><binding template=\"ToastGeneric\">" +
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual>" +
|
||||
"<actions>" +
|
||||
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||
"</actions></toast>";
|
||||
|
||||
psScript =
|
||||
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
||||
"$null = New-Item -Path $protocolPath -Force\n" +
|
||||
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
||||
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
||||
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
||||
"$null = New-Item -Path $cmdPath -Force\n" +
|
||||
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||
$"$xml.LoadXml('{toastXml}')\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||
}
|
||||
else
|
||||
{
|
||||
toastXml =
|
||||
"<toast><visual><binding template=\"ToastGeneric\">" +
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual></toast>";
|
||||
|
||||
psScript =
|
||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||
$"$xml.LoadXml('{toastXml}')\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||
}
|
||||
|
||||
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
||||
string base64 = Convert.ToBase64String(bytes);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
// If PowerShell exited with error, treat as failure
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string stderr = process.StandardError.ReadToEnd().Trim();
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Balloon via Shell_NotifyIcon
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
private const int NIM_ADD = 0x00;
|
||||
private const int NIM_DELETE = 0x02;
|
||||
|
||||
private const int NIF_MESSAGE = 0x01;
|
||||
private const int NIF_ICON = 0x02;
|
||||
private const int NIF_TIP = 0x04;
|
||||
private const int NIF_INFO = 0x10;
|
||||
|
||||
private const int NIIF_INFO = 0x01;
|
||||
|
||||
private const int WM_USER = 0x0400;
|
||||
private const int WM_TRAYICON = WM_USER + 1;
|
||||
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
||||
|
||||
private const int WS_OVERLAPPED = 0x00000000;
|
||||
private const uint WM_CLOSE = 0x0010;
|
||||
private const int TRAY_ICON_UID = 29471;
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr CreateWindowExW(
|
||||
int dwExStyle, string lpClassName, string lpWindowName,
|
||||
int dwStyle, int x, int y, int nWidth, int nHeight,
|
||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct NotifyIconDataW
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public int uID;
|
||||
public int uFlags;
|
||||
public int uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string szTip;
|
||||
public int dwState;
|
||||
public int dwStateMask;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string szInfo;
|
||||
public int uTimeoutOrVersion;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string szInfoTitle;
|
||||
public int dwInfoFlags;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Msg
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int pt_x;
|
||||
public int pt_y;
|
||||
}
|
||||
|
||||
private static void SendBalloon(string title, string message, string? folderPath)
|
||||
{
|
||||
if (title.Length > 63) title = title.Substring(0, 63);
|
||||
if (message.Length > 255) message = message.Substring(0, 255);
|
||||
|
||||
IntPtr hWnd = IntPtr.Zero;
|
||||
var nid = new NotifyIconDataW();
|
||||
|
||||
try
|
||||
{
|
||||
var hwndMessage = new IntPtr(-3);
|
||||
hWnd = CreateWindowExW(
|
||||
0, "Static", "ADOFAIRendererNotify",
|
||||
WS_OVERLAPPED, 0, 0, 0, 0,
|
||||
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
||||
return;
|
||||
}
|
||||
|
||||
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
||||
|
||||
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
||||
nid.hWnd = hWnd;
|
||||
nid.uID = TRAY_ICON_UID;
|
||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
||||
nid.uCallbackMessage = WM_TRAYICON;
|
||||
nid.hIcon = hIcon;
|
||||
nid.szTip = "ADOFAI Renderer";
|
||||
nid.szInfo = message;
|
||||
nid.szInfoTitle = title;
|
||||
nid.dwInfoFlags = NIIF_INFO;
|
||||
nid.uTimeoutOrVersion = 4;
|
||||
|
||||
Shell_NotifyIconW(NIM_ADD, ref nid);
|
||||
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
var capturedFolder = folderPath;
|
||||
var closeTimer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
||||
catch { /* ignored */ }
|
||||
}, null, 12000, Timeout.Infinite);
|
||||
|
||||
try
|
||||
{
|
||||
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
||||
{
|
||||
if (msg.message == WM_CLOSE)
|
||||
break;
|
||||
|
||||
if (msg.message == WM_TRAYICON)
|
||||
{
|
||||
int notifyMsg = (int)(long)msg.lParam;
|
||||
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo();
|
||||
psi.FileName = capturedFolder;
|
||||
psi.UseShellExecute = true;
|
||||
Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning(
|
||||
$"[OSNotification] Cannot open folder: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessageW(ref msg);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
closeTimer.Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (hWnd != IntPtr.Zero)
|
||||
{
|
||||
nid.uFlags = 0;
|
||||
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
||||
DestroyWindow(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region macOS — osascript
|
||||
|
||||
/// <summary>
|
||||
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
||||
/// </summary>
|
||||
private static class MacNotification
|
||||
{
|
||||
public static void Send(string title, string message)
|
||||
{
|
||||
// AppleScript string escaping: backslash and double-quote
|
||||
string escapedTitle = EscapeAppleScript(title);
|
||||
string escapedMessage = EscapeAppleScript(message);
|
||||
|
||||
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
||||
// Each argument is passed directly to the process without shell interpretation.
|
||||
var script =
|
||||
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-e");
|
||||
startInfo.ArgumentList.Add(script);
|
||||
|
||||
RunProcess(startInfo);
|
||||
}
|
||||
|
||||
private static string EscapeAppleScript(string value)
|
||||
{
|
||||
// In AppleScript string literals, escape backslash and double-quote
|
||||
return value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Linux — notify-send / gdbus
|
||||
|
||||
/// <summary>
|
||||
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
||||
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
||||
/// </summary>
|
||||
private static class LinuxNotification
|
||||
{
|
||||
public static void Send(string title, string message, string? folderPath)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
ShowWithAction(title, message, folderPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowSimple(title, message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowSimple(string title, string message)
|
||||
{
|
||||
// Try notify-send first
|
||||
if (TryNotifySend(title, message))
|
||||
return;
|
||||
|
||||
// Fallback to gdbus
|
||||
TryGdbus(title, message);
|
||||
}
|
||||
|
||||
private static void ShowWithAction(string title, string message, string folderPath)
|
||||
{
|
||||
// Run on a background thread because notify-send with --action blocks
|
||||
// until the notification is dismissed or the action is clicked.
|
||||
System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "notify-send",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
if (output == "open_folder")
|
||||
{
|
||||
var openInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xdg-open",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
openInfo.ArgumentList.Add(folderPath);
|
||||
|
||||
RunProcess(openInfo);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
||||
|
||||
// Fallback: show without action
|
||||
ShowSimple(title, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryNotifySend(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "notify-send",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
RunProcess(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryGdbus(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Direct D-Bus call to org.freedesktop.Notifications
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "gdbus",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("call");
|
||||
startInfo.ArgumentList.Add("--session");
|
||||
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
||||
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
||||
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
||||
startInfo.ArgumentList.Add("0"); // replaces_id
|
||||
startInfo.ArgumentList.Add(""); // app_icon
|
||||
startInfo.ArgumentList.Add(title); // summary
|
||||
startInfo.ArgumentList.Add(message); // body
|
||||
startInfo.ArgumentList.Add("[]"); // actions
|
||||
startInfo.ArgumentList.Add("{}"); // hints
|
||||
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
||||
|
||||
RunProcess(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared utility
|
||||
|
||||
/// <summary>
|
||||
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
||||
/// </summary>
|
||||
private static void RunProcess(ProcessStartInfo startInfo)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
internal static class RegistryUtil
|
||||
{
|
||||
private static readonly Type? RegistryKeyType;
|
||||
private static readonly Type? RegistryHiveType;
|
||||
private static readonly Type? RegistryViewType;
|
||||
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
|
||||
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
|
||||
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
|
||||
private static readonly System.Reflection.MethodInfo? GetValueMethod;
|
||||
|
||||
static RegistryUtil()
|
||||
{
|
||||
try
|
||||
{
|
||||
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
||||
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
||||
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
||||
|
||||
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
||||
{
|
||||
var mscorlib = typeof(object).Assembly;
|
||||
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
||||
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
||||
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
||||
}
|
||||
|
||||
if (RegistryKeyType != null)
|
||||
{
|
||||
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
||||
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
||||
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
||||
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
private static void CloseKey(object? key)
|
||||
{
|
||||
if (key == null || RegistryKeyType == null) return;
|
||||
try
|
||||
{
|
||||
if (key is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
var closeMethod = RegistryKeyType.GetMethod("Close");
|
||||
closeMethod?.Invoke(key, null);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||
|
||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||
if (baseKey == null) return null;
|
||||
|
||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
||||
if (key == null)
|
||||
{
|
||||
CloseKey(baseKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
||||
CloseKey(key);
|
||||
CloseKey(baseKey);
|
||||
return subkeyNames;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string? GetHKLMValue(string keyPath, string valueName)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||
|
||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||
if (baseKey == null) return null;
|
||||
|
||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
||||
if (key == null)
|
||||
{
|
||||
CloseKey(baseKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
||||
CloseKey(key);
|
||||
CloseKey(baseKey);
|
||||
return value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
||||
/// <para>
|
||||
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
|
||||
/// No PowerShell, no UWP, no WinRT dependencies.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class OSNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a native desktop notification.
|
||||
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
||||
/// the folder when the notification is clicked/dismissed.
|
||||
/// </summary>
|
||||
/// <param name="title">The notification title.</param>
|
||||
/// <param name="message">The notification body text.</param>
|
||||
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
|
||||
public static void Show(string title, string message, string? folderPath = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
WindowsNotification.Send(title, message, folderPath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
MacNotification.Send(title, message);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
LinuxNotification.Send(title, message, folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
||||
|
||||
/// <summary>
|
||||
/// Windows notification with two strategies:
|
||||
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
||||
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
||||
/// </summary>
|
||||
private static class WindowsNotification
|
||||
{
|
||||
public static void Send(string title, string message, string? folderPath)
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TrySendToast(title, message, folderPath))
|
||||
{
|
||||
SendBalloon(title, message, folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "OSNotification-Win"
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
#region Toast via PowerShell
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to show a Windows Toast notification via PowerShell.
|
||||
/// Returns true if the process launched successfully, false otherwise.
|
||||
/// </summary>
|
||||
private static bool TrySendToast(string title, string message, string? folderPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
||||
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
||||
|
||||
string toastXml;
|
||||
string psScript;
|
||||
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
var escapedFolder = folderPath.Replace("'", "''");
|
||||
|
||||
toastXml =
|
||||
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
||||
"<visual><binding template=\"ToastGeneric\">" +
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual>" +
|
||||
"<actions>" +
|
||||
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||
"</actions></toast>";
|
||||
|
||||
psScript =
|
||||
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
||||
"$null = New-Item -Path $protocolPath -Force\n" +
|
||||
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
||||
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
||||
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
||||
"$null = New-Item -Path $cmdPath -Force\n" +
|
||||
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||
$"$xml.LoadXml('{toastXml}')\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||
}
|
||||
else
|
||||
{
|
||||
toastXml =
|
||||
"<toast><visual><binding template=\"ToastGeneric\">" +
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual></toast>";
|
||||
|
||||
psScript =
|
||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||
$"$xml.LoadXml('{toastXml}')\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||
}
|
||||
|
||||
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
||||
string base64 = Convert.ToBase64String(bytes);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
// If PowerShell exited with error, treat as failure
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string stderr = process.StandardError.ReadToEnd().Trim();
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Balloon via Shell_NotifyIcon
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
private const int NIM_ADD = 0x00;
|
||||
private const int NIM_DELETE = 0x02;
|
||||
|
||||
private const int NIF_MESSAGE = 0x01;
|
||||
private const int NIF_ICON = 0x02;
|
||||
private const int NIF_TIP = 0x04;
|
||||
private const int NIF_INFO = 0x10;
|
||||
|
||||
private const int NIIF_INFO = 0x01;
|
||||
|
||||
private const int WM_USER = 0x0400;
|
||||
private const int WM_TRAYICON = WM_USER + 1;
|
||||
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
||||
|
||||
private const int WS_OVERLAPPED = 0x00000000;
|
||||
private const uint WM_CLOSE = 0x0010;
|
||||
private const int TRAY_ICON_UID = 29471;
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr CreateWindowExW(
|
||||
int dwExStyle, string lpClassName, string lpWindowName,
|
||||
int dwStyle, int x, int y, int nWidth, int nHeight,
|
||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct NotifyIconDataW
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public int uID;
|
||||
public int uFlags;
|
||||
public int uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string szTip;
|
||||
public int dwState;
|
||||
public int dwStateMask;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string szInfo;
|
||||
public int uTimeoutOrVersion;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string szInfoTitle;
|
||||
public int dwInfoFlags;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Msg
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int pt_x;
|
||||
public int pt_y;
|
||||
}
|
||||
|
||||
private static void SendBalloon(string title, string message, string? folderPath)
|
||||
{
|
||||
if (title.Length > 63) title = title.Substring(0, 63);
|
||||
if (message.Length > 255) message = message.Substring(0, 255);
|
||||
|
||||
IntPtr hWnd = IntPtr.Zero;
|
||||
var nid = new NotifyIconDataW();
|
||||
|
||||
try
|
||||
{
|
||||
var hwndMessage = new IntPtr(-3);
|
||||
hWnd = CreateWindowExW(
|
||||
0, "Static", "ADOFAIRendererNotify",
|
||||
WS_OVERLAPPED, 0, 0, 0, 0,
|
||||
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
||||
return;
|
||||
}
|
||||
|
||||
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
||||
|
||||
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
||||
nid.hWnd = hWnd;
|
||||
nid.uID = TRAY_ICON_UID;
|
||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
||||
nid.uCallbackMessage = WM_TRAYICON;
|
||||
nid.hIcon = hIcon;
|
||||
nid.szTip = "ADOFAI Renderer";
|
||||
nid.szInfo = message;
|
||||
nid.szInfoTitle = title;
|
||||
nid.dwInfoFlags = NIIF_INFO;
|
||||
nid.uTimeoutOrVersion = 4;
|
||||
|
||||
Shell_NotifyIconW(NIM_ADD, ref nid);
|
||||
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
var capturedFolder = folderPath;
|
||||
var closeTimer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
||||
catch { /* ignored */ }
|
||||
}, null, 12000, Timeout.Infinite);
|
||||
|
||||
try
|
||||
{
|
||||
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
||||
{
|
||||
if (msg.message == WM_CLOSE)
|
||||
break;
|
||||
|
||||
if (msg.message == WM_TRAYICON)
|
||||
{
|
||||
int notifyMsg = (int)(long)msg.lParam;
|
||||
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo();
|
||||
psi.FileName = capturedFolder;
|
||||
psi.UseShellExecute = true;
|
||||
Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning(
|
||||
$"[OSNotification] Cannot open folder: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessageW(ref msg);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
closeTimer.Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (hWnd != IntPtr.Zero)
|
||||
{
|
||||
nid.uFlags = 0;
|
||||
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
||||
DestroyWindow(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region macOS — osascript
|
||||
|
||||
/// <summary>
|
||||
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
||||
/// </summary>
|
||||
private static class MacNotification
|
||||
{
|
||||
public static void Send(string title, string message)
|
||||
{
|
||||
// AppleScript string escaping: backslash and double-quote
|
||||
string escapedTitle = EscapeAppleScript(title);
|
||||
string escapedMessage = EscapeAppleScript(message);
|
||||
|
||||
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
||||
// Each argument is passed directly to the process without shell interpretation.
|
||||
var script =
|
||||
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-e");
|
||||
startInfo.ArgumentList.Add(script);
|
||||
|
||||
RunProcess(startInfo);
|
||||
}
|
||||
|
||||
private static string EscapeAppleScript(string value)
|
||||
{
|
||||
// In AppleScript string literals, escape backslash and double-quote
|
||||
return value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Linux — notify-send / gdbus
|
||||
|
||||
/// <summary>
|
||||
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
||||
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
||||
/// </summary>
|
||||
private static class LinuxNotification
|
||||
{
|
||||
public static void Send(string title, string message, string? folderPath)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
ShowWithAction(title, message, folderPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowSimple(title, message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowSimple(string title, string message)
|
||||
{
|
||||
// Try notify-send first
|
||||
if (TryNotifySend(title, message))
|
||||
return;
|
||||
|
||||
// Fallback to gdbus
|
||||
TryGdbus(title, message);
|
||||
}
|
||||
|
||||
private static void ShowWithAction(string title, string message, string folderPath)
|
||||
{
|
||||
// Run on a background thread because notify-send with --action blocks
|
||||
// until the notification is dismissed or the action is clicked.
|
||||
System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "notify-send",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
if (output == "open_folder")
|
||||
{
|
||||
var openInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xdg-open",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
openInfo.ArgumentList.Add(folderPath);
|
||||
|
||||
RunProcess(openInfo);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
||||
|
||||
// Fallback: show without action
|
||||
ShowSimple(title, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryNotifySend(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "notify-send",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
RunProcess(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryGdbus(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Direct D-Bus call to org.freedesktop.Notifications
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "gdbus",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("call");
|
||||
startInfo.ArgumentList.Add("--session");
|
||||
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
||||
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
||||
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
||||
startInfo.ArgumentList.Add("0"); // replaces_id
|
||||
startInfo.ArgumentList.Add(""); // app_icon
|
||||
startInfo.ArgumentList.Add(title); // summary
|
||||
startInfo.ArgumentList.Add(message); // body
|
||||
startInfo.ArgumentList.Add("[]"); // actions
|
||||
startInfo.ArgumentList.Add("{}"); // hints
|
||||
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
||||
|
||||
RunProcess(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared utility
|
||||
|
||||
/// <summary>
|
||||
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
||||
/// </summary>
|
||||
private static void RunProcess(ProcessStartInfo startInfo)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
internal static class RegistryUtil
|
||||
{
|
||||
private static readonly Type? RegistryKeyType;
|
||||
private static readonly Type? RegistryHiveType;
|
||||
private static readonly Type? RegistryViewType;
|
||||
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
|
||||
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
|
||||
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
|
||||
private static readonly System.Reflection.MethodInfo? GetValueMethod;
|
||||
|
||||
static RegistryUtil()
|
||||
{
|
||||
try
|
||||
{
|
||||
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
||||
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
||||
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
||||
|
||||
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
||||
{
|
||||
var mscorlib = typeof(object).Assembly;
|
||||
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
||||
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
||||
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
||||
}
|
||||
|
||||
if (RegistryKeyType != null)
|
||||
{
|
||||
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
||||
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
||||
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
||||
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
private static void CloseKey(object? key)
|
||||
{
|
||||
if (key == null || RegistryKeyType == null) return;
|
||||
try
|
||||
{
|
||||
if (key is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
var closeMethod = RegistryKeyType.GetMethod("Close");
|
||||
closeMethod?.Invoke(key, null);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||
|
||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||
if (baseKey == null) return null;
|
||||
|
||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
||||
if (key == null)
|
||||
{
|
||||
CloseKey(baseKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
||||
CloseKey(key);
|
||||
CloseKey(baseKey);
|
||||
return subkeyNames;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string? GetHKLMValue(string keyPath, string valueName)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||
|
||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||
if (baseKey == null) return null;
|
||||
|
||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
||||
if (key == null)
|
||||
{
|
||||
CloseKey(baseKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
||||
CloseKey(key);
|
||||
CloseKey(baseKey);
|
||||
return value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user