SHA256
Compare commits
31
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
f153bf70aa
|
||
|
|
93539307b1
|
||
|
|
c0e902fc1a
|
||
|
|
dd2585ee9f
|
||
|
|
1a672cbf59
|
||
|
|
6d5880fe19
|
||
|
|
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 |
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
|
||||
@@ -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;
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,20 +6,28 @@ using Newtonsoft.Json;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
+203
-10
@@ -2,24 +2,118 @@ 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.Data;
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
internal class RenderPreset
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PresetManager
|
||||
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()
|
||||
{
|
||||
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
|
||||
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)
|
||||
@@ -27,6 +121,45 @@ internal static class PresetManager
|
||||
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))
|
||||
@@ -43,10 +176,16 @@ internal static class PresetManager
|
||||
var preset = new RenderPreset
|
||||
{
|
||||
Name = name,
|
||||
Config = config
|
||||
Config = config.Clone()
|
||||
};
|
||||
|
||||
string json = JsonConvert.SerializeObject(preset, Formatting.Indented);
|
||||
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);
|
||||
@@ -54,14 +193,26 @@ internal static class PresetManager
|
||||
|
||||
public static List<RenderPreset> GetPresets()
|
||||
{
|
||||
EnsureDefaultPresetsCreated();
|
||||
|
||||
var presets = new List<RenderPreset>();
|
||||
var dir = GetPresetsDirectory();
|
||||
|
||||
if (!Directory.Exists(dir)) return presets;
|
||||
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
|
||||
@@ -146,9 +297,20 @@ internal static class PresetManager
|
||||
}
|
||||
}
|
||||
|
||||
var preset = jObject.ToObject<RenderPreset>();
|
||||
if (preset != null && !string.IsNullOrEmpty(preset.Name))
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -177,13 +339,44 @@ internal static class PresetManager
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+228
-35
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -37,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;
|
||||
@@ -64,6 +66,7 @@ internal static class RenderContext
|
||||
OutputPath = null;
|
||||
|
||||
Notification = true;
|
||||
SessionFrameHooks.Clear();
|
||||
|
||||
EndCondition = EndConditionDefault;
|
||||
OnComplete = OnCompleteDefault;
|
||||
@@ -89,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)
|
||||
@@ -100,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;
|
||||
@@ -125,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
|
||||
@@ -136,6 +226,11 @@ internal class RenderManager : MonoBehaviour
|
||||
private GameObject? _debugMonitorObj;
|
||||
private GameObject? _escapeHandlerObj;
|
||||
internal string? CurrentOutPath;
|
||||
|
||||
// Output file of the current render that is not finished yet.
|
||||
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
|
||||
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
|
||||
private string? _incompleteOutputPath;
|
||||
private bool _renderSetup;
|
||||
|
||||
private void Awake()
|
||||
@@ -150,16 +245,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.");
|
||||
@@ -191,6 +291,32 @@ internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
RenderThread.Join(200);
|
||||
}
|
||||
|
||||
// The encoders are disposed above, which closes the output file. An unfinished file of this render is deleted.
|
||||
DeleteIncompleteOutputFile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the output file of the current render when the render did not finish it (_incompleteOutputPath).
|
||||
/// Files of earlier, finished renders are never deleted.
|
||||
/// Returns true when a file was deleted.
|
||||
/// </summary>
|
||||
private bool DeleteIncompleteOutputFile()
|
||||
{
|
||||
var path = Interlocked.Exchange(ref _incompleteOutputPath, null);
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {path}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete incomplete video: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -236,8 +362,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();
|
||||
@@ -286,6 +419,12 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
while (Instance.stage >= RenderStage.Render)
|
||||
{
|
||||
if (RenderThread is not { IsAlive: true })
|
||||
{
|
||||
isThrottling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
int count;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
@@ -315,8 +454,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +495,6 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
Application.wantsToQuit -= quitDelegate;
|
||||
RenderConfigWindow.CreateWindow();
|
||||
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -377,6 +531,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();
|
||||
|
||||
@@ -387,6 +544,10 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
_frameReader?.Dispose();
|
||||
_frameReader = null;
|
||||
_frameHookPipeline?.Dispose();
|
||||
_frameHookPipeline = new FrameHookPipeline();
|
||||
_cachedGlobalHooksVersion = -1;
|
||||
EnsureActiveHooksUpToDate();
|
||||
try
|
||||
{
|
||||
StartFFmpegThread();
|
||||
@@ -395,12 +556,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;
|
||||
@@ -443,10 +612,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();
|
||||
@@ -496,6 +665,7 @@ internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
_allVideoFFmpegInstances.Add(videoFFmpeg);
|
||||
}
|
||||
Volatile.Write(ref _incompleteOutputPath, CurrentOutPath);
|
||||
videoFFmpeg.Start(CurrentOutPath);
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -564,14 +734,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
|
||||
@@ -587,17 +757,23 @@ 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)
|
||||
{
|
||||
// End throws when the file cannot be finished (e.g. the trailer cannot be written),
|
||||
// which is handled as a render failure by the catch block below.
|
||||
videoFFmpeg.End();
|
||||
Volatile.Write(ref _incompleteOutputPath, null);
|
||||
}
|
||||
videoFFmpeg.Dispose();
|
||||
|
||||
lock (_allVideoFFmpegInstances)
|
||||
@@ -606,12 +782,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);
|
||||
@@ -640,7 +826,7 @@ internal class RenderManager : MonoBehaviour
|
||||
endRequested = true;
|
||||
if (!success)
|
||||
{
|
||||
IsRenderCancelled = true;
|
||||
MarkCancelled();
|
||||
}
|
||||
FrameAvailableEvent.Set();
|
||||
skipPressToStart = false;
|
||||
@@ -654,7 +840,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 })
|
||||
@@ -687,6 +874,7 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
_frameReader?.Dispose();
|
||||
ResetFrameHookState();
|
||||
|
||||
// Clean up Debug Monitor UI
|
||||
if (_debugMonitorObj)
|
||||
@@ -712,30 +900,26 @@ internal class RenderManager : MonoBehaviour
|
||||
scrController.instance.noFailInfiniteMargin = false;
|
||||
}
|
||||
|
||||
// 6. Immediately delete cancelled incomplete temporary video file
|
||||
if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath))
|
||||
// 6. Immediately delete the incomplete video file of this render
|
||||
if (DeleteIncompleteOutputFile())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(CurrentOutPath);
|
||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {CurrentOutPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete temporary video: {ex.Message}");
|
||||
}
|
||||
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);
|
||||
@@ -744,6 +928,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;
|
||||
@@ -776,13 +969,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,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,4 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
@@ -18,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")]
|
||||
@@ -28,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
|
||||
@@ -44,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
|
||||
@@ -71,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;
|
||||
}
|
||||
}␍
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.3-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.8-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
-182
@@ -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
|
||||
{
|
||||
@@ -104,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>();
|
||||
@@ -119,51 +124,31 @@ internal class FFmpegDownloadUI : 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_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)
|
||||
@@ -176,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);
|
||||
@@ -317,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;
|
||||
@@ -338,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()
|
||||
@@ -643,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;
|
||||
}
|
||||
}
|
||||
+768
-194
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()
|
||||
|
||||
+1152
-333
File diff suppressed because it is too large.
Load diff
@@ -63,7 +63,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
public AVCodecID AudioCodecId;
|
||||
|
||||
/// <summary>
|
||||
/// Audio bitrate (in bps). If default (0), 128000 bps is used.
|
||||
/// Audio bitrate (in bps). If default (0), 256000 bps is used.
|
||||
/// For AAC, 128000–320000 bps is common.
|
||||
/// </summary>
|
||||
public int Bitrate;
|
||||
@@ -83,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>
|
||||
@@ -299,6 +299,23 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
return GPUUtil.GetPrioritizedEncoders(_settings.Codec, _settings.ForceSoftware).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands the candidate list into (encoder, input mode) attempts in order.
|
||||
/// A QSV encoder is attempted twice: system memory input mode (useFramePool = false) first,
|
||||
/// then GPU frame pool input mode (useFramePool = true). Other encoders are attempted once (useFramePool = false).
|
||||
/// </summary>
|
||||
private static System.Collections.Generic.IEnumerable<(VideoEncoder Encoder, bool UseFramePool)> ExpandQsvInputModes(VideoEncoder[] candidates)
|
||||
{
|
||||
foreach (var encoder in candidates)
|
||||
{
|
||||
yield return (encoder, false);
|
||||
if (encoder.Kind == EncoderKind.QSV)
|
||||
{
|
||||
yield return (encoder, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an FFmpeg error code (negative integer) to a human-readable string.
|
||||
/// e.g., -1 → "Operation not permitted"
|
||||
@@ -367,7 +384,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
var candidates = GetEncoderCandidates();
|
||||
var openedSuccessfully = false;
|
||||
|
||||
foreach (var encoder in candidates)
|
||||
foreach (var (encoder, useFramePool) in ExpandQsvInputModes(candidates))
|
||||
{
|
||||
// avcodec_find_encoder_by_name: Searches for an encoder by name (e.g., "h264_nvenc").
|
||||
// Returns null if the encoder is not included in the build.
|
||||
@@ -437,16 +454,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
|
||||
@@ -519,9 +531,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// Strategy 3) Create QSV device directly (legacy method)
|
||||
// → Fallback on older drivers/systems
|
||||
//
|
||||
// After obtaining device, attempt using GPU frame pool:
|
||||
// hw_frames_ctx: Pre-allocate frame pool in GPU VRAM (zero-copy, highest performance)
|
||||
// hw_device_ctx: Encoder reads frames directly from system memory (better compatibility)
|
||||
// After obtaining device, the input mode is configured (see ExpandQsvInputModes):
|
||||
// Input mode 1, hw_device_ctx: Encoder receives NV12 frames from system memory (attempted first)
|
||||
// Input mode 2, hw_frames_ctx: Frames are uploaded to a QSV surface pool in GPU memory
|
||||
// (attempted when the encoder cannot be opened in input mode 1)
|
||||
//
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
|
||||
if (encoder.Kind == EncoderKind.QSV)
|
||||
@@ -590,30 +603,64 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Attempt hw_frames_ctx: Pre-create GPU frame pool ──
|
||||
// AVHWFramesContext pre-allocates frame pool in GPU VRAM,
|
||||
// eliminating memory allocation/deallocation overhead for each frame.
|
||||
if (!useFramePool)
|
||||
{
|
||||
// ── Input mode 1 (primary): hw_device_ctx + NV12 system memory ──
|
||||
// The encoder receives NV12 frames in system memory (RAM) and the QSV runtime uploads them to the GPU.
|
||||
//
|
||||
// Pros: Zero-copy capability, highest performance
|
||||
// Cons: May fail depending on GPU/driver resolution/format constraints
|
||||
// (e.g. specific texture size constraints on Intel Arc → E_INVALIDARG)
|
||||
// pix_fmt is the software format NV12.
|
||||
// _isQsvEncoder = false: WriteFrame() passes the NV12 frame to the encoder without uploading it.
|
||||
_pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_NV12;
|
||||
_pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx);
|
||||
_isQsvEncoder = false;
|
||||
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Using system memory input mode (hw_device_ctx)");
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Input mode 2 (fallback): hw_frames_ctx GPU frame pool ──
|
||||
// Used when the encoder cannot be opened in input mode 1.
|
||||
// AVHWFramesContext pre-allocates a pool of QSV surfaces in GPU memory (pix_fmt AV_PIX_FMT_QSV),
|
||||
// and WriteFrame() uploads each NV12 frame to a surface of the pool (av_hwframe_transfer_data).
|
||||
//
|
||||
// initial_pool_size: Number of frames to pre-allocate. Matches encoder pipeline depth.
|
||||
// Align resolution to multiples of 32: Texture alignment condition required by QSV hardware.
|
||||
// Pool creation depends on the texture types the GPU/driver accepts
|
||||
// (e.g. Intel Arc A770 rejects NV12 textures with D3D11_BIND_RENDER_TARGET → E_INVALIDARG 0x80070057).
|
||||
//
|
||||
// initial_pool_size: Number of surfaces in the pool (QsvFramePoolSize).
|
||||
// The encoder keeps submitted surfaces until they are encoded, so the pool has to hold
|
||||
// the frames in the encoder pipeline plus the frame being uploaded.
|
||||
// Width/height are aligned to multiples of 32: texture alignment condition required by QSV hardware.
|
||||
//
|
||||
// Frame pool memory type (AVQSVFramesContext.frame_type, a combination of MFX_MEMTYPE_* flags):
|
||||
// Windows (D3D11): MFX_MEMTYPE_VIDEO_MEMORY_DECODER_TARGET (0x0010) is tried first, then 0.
|
||||
// 0x0010 creates the D3D11 textures with D3D11_BIND_DECODER.
|
||||
// 0 lets FFmpeg select MFX_MEMTYPE_VIDEO_MEMORY_PROCESSOR_TARGET (0x0020),
|
||||
// which creates the textures with D3D11_BIND_RENDER_TARGET.
|
||||
// Other platforms (VAAPI): 0. FFmpeg sets the memory type of a VAAPI child pool itself.
|
||||
// Both types are accepted by the QSV encoder as video memory input.
|
||||
// When every type fails, the next candidate encoder is tested.
|
||||
//
|
||||
// Reference:
|
||||
// AVHWFramesContext: https://ffmpeg.org/doxygen/trunk/structAVHWFramesContext.html
|
||||
// av_hwframe_ctx_alloc: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
|
||||
// AVQSVFramesContext.frame_type: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.h
|
||||
// frame_type → D3D11 bind flags (qsv_init_child_ctx, qsv_get_d3d11va_bind_flags): https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.c
|
||||
// MFX_MEMTYPE_* values: https://github.com/intel/libvpl/blob/main/api/vpl/mfxstructures.h
|
||||
bool framesCtxReady = false;
|
||||
AVBufferRef* hwFramesRef = ffmpeg.av_hwframe_ctx_alloc(hwDeviceCtx);
|
||||
if (hwFramesRef != null)
|
||||
int[] frameTypes = isWindows
|
||||
? new[] { QsvMemTypeVideoMemoryDecoderTarget, 0 }
|
||||
: new[] { 0 };
|
||||
foreach (var frameType in frameTypes)
|
||||
{
|
||||
AVBufferRef* hwFramesRef = ffmpeg.av_hwframe_ctx_alloc(hwDeviceCtx);
|
||||
if (hwFramesRef == null) break;
|
||||
|
||||
var framesCtx = (AVHWFramesContext*)hwFramesRef->data;
|
||||
framesCtx->format = AVPixelFormat.AV_PIX_FMT_QSV; // Hardware surface format
|
||||
framesCtx->sw_format = AVPixelFormat.AV_PIX_FMT_NV12; // Software-side format
|
||||
framesCtx->width = (_settings.Width + 31) & ~31; // 32-multiple alignment (bitmask)
|
||||
framesCtx->height = (_settings.Height + 31) & ~31; // 32-multiple alignment (bitmask)
|
||||
framesCtx->initial_pool_size = 4; // Frame pool size
|
||||
framesCtx->initial_pool_size = QsvFramePoolSize;
|
||||
SetQsvFrameType(framesCtx, frameType);
|
||||
|
||||
err = ffmpeg.av_hwframe_ctx_init(hwFramesRef);
|
||||
if (err >= 0)
|
||||
@@ -623,31 +670,30 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
_pCodecContext->hw_frames_ctx = ffmpeg.av_buffer_ref(hwFramesRef);
|
||||
_isQsvEncoder = true;
|
||||
framesCtxReady = true;
|
||||
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: hw_frames_ctx initialization succeeded (GPU frame pool mode)");
|
||||
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Using GPU frame pool input mode (hw_frames_ctx, frame_type=0x{frameType:X4}, pool={QsvFramePoolSize})");
|
||||
ffmpeg.av_buffer_unref(&hwFramesRef);
|
||||
|
||||
_pHwFrame = ffmpeg.av_frame_alloc();
|
||||
if (_pHwFrame == null)
|
||||
throw new Exception("Failed to allocate QSV hardware frame structure.");
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: hw_frames_ctx failed — {GetFFmpegErrorString(err)} (Code: {err}). Falling back to hw_device_ctx mode.");
|
||||
}
|
||||
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: hw_frames_ctx failed with frame_type=0x{frameType:X4} — {GetFFmpegErrorString(err)} (Code: {err})");
|
||||
ffmpeg.av_buffer_unref(&hwFramesRef);
|
||||
}
|
||||
|
||||
// ── When hw_frames_ctx fails: Fallback to hw_device_ctx + NV12 system memory ──
|
||||
// In hw_device_ctx mode, the encoder receives NV12 frames from system memory (RAM)
|
||||
// directly and uploads to GPU internally.
|
||||
// Slightly slower than hw_frames_ctx, but compatibility is much better.
|
||||
//
|
||||
// Change pix_fmt to NV12: Use software format directly instead of hardware surface (QSV).
|
||||
// _isQsvEncoder = false: Skip manual upload in WriteFrame().
|
||||
if (!framesCtxReady)
|
||||
{
|
||||
_pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_NV12;
|
||||
_pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx);
|
||||
_isQsvEncoder = false; // System memory mode, no manual frame upload required
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: GPU frame pool input mode is unavailable. Testing next candidate codec.");
|
||||
ffmpeg.av_buffer_unref(&hwDeviceCtx);
|
||||
{
|
||||
var pCtx = _pCodecContext;
|
||||
ffmpeg.avcodec_free_context(&pCtx);
|
||||
}
|
||||
_pCodecContext = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer ownership of hwDeviceCtx to codec context,
|
||||
@@ -709,11 +755,19 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
break;
|
||||
}
|
||||
|
||||
// If open fails, free codec context and try next encoder.
|
||||
Melon<RenderMod>.Logger.Warning($"Failed to initialize hardware encoder {encoder.Name}: {GetFFmpegErrorString(openResult)} (Code: {openResult}). Testing next candidate codec.");
|
||||
// If open fails, free codec context and QSV frame state, then try next candidate.
|
||||
var inputMode = encoder.Kind == EncoderKind.QSV ? (useFramePool ? " (GPU frame pool input mode)" : " (system memory input mode)") : "";
|
||||
Melon<RenderMod>.Logger.Warning($"Failed to initialize encoder {encoder.Name}{inputMode}: {GetFFmpegErrorString(openResult)} (Code: {openResult}). Testing next candidate.");
|
||||
var pCodecCtx = _pCodecContext;
|
||||
ffmpeg.avcodec_free_context(&pCodecCtx);
|
||||
_pCodecContext = null;
|
||||
_isQsvEncoder = false;
|
||||
if (_pHwFrame != null)
|
||||
{
|
||||
var pHwFrame = _pHwFrame;
|
||||
ffmpeg.av_frame_free(&pHwFrame);
|
||||
_pHwFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!openedSuccessfully || _pCodecContext == null)
|
||||
@@ -728,6 +782,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)
|
||||
@@ -760,16 +824,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
if (_pSrcFrame == null || _pDstFrame == null || _pPacket == null)
|
||||
throw new Exception("Failed to allocate frame or packet structure.");
|
||||
|
||||
// Set format and size of destination frame, then allocate actual pixel buffer.
|
||||
// av_frame_get_buffer: Allocates memory matching format, width, height and
|
||||
// sets data[]/linesize[] pointers. Second argument 0 means default alignment.
|
||||
_pDstFrame->format = (int)_dstPixFmt;
|
||||
_pDstFrame->width = _settings.Width;
|
||||
_pDstFrame->height = _settings.Height;
|
||||
_pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG;
|
||||
|
||||
if (ffmpeg.av_frame_get_buffer(_pDstFrame, 0) < 0)
|
||||
throw new Exception($"Failed to allocate {_dstPixFmt} frame buffer.");
|
||||
AllocateDstFrameBuffer();
|
||||
|
||||
// sws_getContext: Initialize software scaler (colorspace converter).
|
||||
// Input: RGBA (Unity capture format, 4 bytes/pixel)
|
||||
@@ -830,7 +885,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
|
||||
@@ -852,11 +907,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;
|
||||
@@ -880,38 +945,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:
|
||||
@@ -919,6 +1023,76 @@ 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>
|
||||
// ReSharper disable once UnusedMethodReturnValue.Local
|
||||
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>
|
||||
/// MFX_MEMTYPE_VIDEO_MEMORY_DECODER_TARGET (0x0010): frames are in video memory and belong to video decoder render targets.
|
||||
/// Reference: https://github.com/intel/libvpl/blob/main/api/vpl/mfxstructures.h
|
||||
/// </summary>
|
||||
private const int QsvMemTypeVideoMemoryDecoderTarget = 0x0010;
|
||||
|
||||
/// <summary>
|
||||
/// Number of surfaces in the QSV GPU frame pool (AVHWFramesContext.initial_pool_size) of GPU frame pool input mode.
|
||||
/// Same value as the encoder frame pool of the FFmpeg hardware encoding example (initial_pool_size = 20).
|
||||
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/doc/examples/vaapi_encode.c
|
||||
/// </summary>
|
||||
private const int QsvFramePoolSize = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Writes AVQSVFramesContext.frame_type of a QSV frames context (AVHWFramesContext.hwctx).
|
||||
/// FFmpeg.AutoGen has no AVQSVFramesContext type, so the field is written at its offset.
|
||||
/// Field order of AVQSVFramesContext: mfxFrameSurface1* surfaces, int nb_surfaces, int frame_type, mfxFrameInfo* info
|
||||
/// → frame_type is at offset sizeof(pointer) + sizeof(int) (12 bytes on 64-bit).
|
||||
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.h
|
||||
/// </summary>
|
||||
private static void SetQsvFrameType(AVHWFramesContext* framesCtx, int frameType)
|
||||
{
|
||||
if (framesCtx->hwctx == null) return;
|
||||
*(int*)((byte*)framesCtx->hwctx + IntPtr.Size + sizeof(int)) = frameType;
|
||||
}
|
||||
|
||||
/// <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
|
||||
// ================================================================
|
||||
@@ -1018,12 +1192,50 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the format, size and color range of the destination frame (_pDstFrame) and allocates a new pixel buffer for it.
|
||||
/// av_frame_get_buffer allocates memory matching format, width and height and sets the data[]/linesize[] pointers.
|
||||
/// Second argument 0 means default alignment.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_get_buffer)
|
||||
/// </summary>
|
||||
private void AllocateDstFrameBuffer()
|
||||
{
|
||||
_pDstFrame->format = (int)_dstPixFmt;
|
||||
_pDstFrame->width = _settings.Width;
|
||||
_pDstFrame->height = _settings.Height;
|
||||
_pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG;
|
||||
|
||||
var ret = ffmpeg.av_frame_get_buffer(_pDstFrame, 0);
|
||||
if (ret < 0)
|
||||
throw new Exception($"Failed to allocate {_dstPixFmt} frame buffer — {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes the pixel buffer of the destination frame (_pDstFrame) writable before the next frame is written into it.
|
||||
///
|
||||
/// After avcodec_send_frame, the encoder may keep a reference to the buffer of the submitted frame
|
||||
/// until that frame is encoded (e.g. QSV in system memory input mode). The buffer is then shared and not writable
|
||||
/// (av_frame_is_writable returns 0).
|
||||
/// In that case the frame releases its reference (av_frame_unref) and a new buffer is allocated.
|
||||
/// The encoder keeps the previous buffer until it no longer needs it. The previous contents are not copied,
|
||||
/// because every plane is overwritten by the next frame.
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_is_writable, av_frame_unref)
|
||||
/// </summary>
|
||||
private void PrepareDstFrameForWrite()
|
||||
{
|
||||
if (ffmpeg.av_frame_is_writable(_pDstFrame) != 0) return;
|
||||
|
||||
ffmpeg.av_frame_unref(_pDstFrame);
|
||||
AllocateDstFrameBuffer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a frame by directly copying already pre-converted NV12 or YUV420P frame data.
|
||||
/// </summary>
|
||||
private void WritePreConvertedFrame(byte* buffer)
|
||||
{
|
||||
ffmpeg.av_frame_make_writable(_pDstFrame);
|
||||
PrepareDstFrameForWrite();
|
||||
|
||||
var width = _settings.Width;
|
||||
var height = _settings.Height;
|
||||
@@ -1137,20 +1349,17 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// Core logic for actual frame encoding.
|
||||
///
|
||||
/// Processing flow:
|
||||
/// 1. av_frame_make_writable: Check if destination frame buffer is writable
|
||||
/// 1. PrepareDstFrameForWrite: Makes the destination frame buffer writable
|
||||
/// (Allocates a new buffer if encoder is still referencing previous frame's buffer)
|
||||
/// 2. sws_scale: RGBA → YUV420P/NV12 colorspace conversion
|
||||
/// 3. (QSV hw_frames mode) av_hwframe_transfer_data: Upload NV12 → QSV hardware surface
|
||||
/// 4. Pass to encoder after setting PTS
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_make_writable)
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_scale)
|
||||
/// </summary>
|
||||
private void WriteFrame()
|
||||
{
|
||||
// Ensure writable state since encoder might still be referencing previous frame's buffer.
|
||||
// Internally allocates a new buffer if referenced.
|
||||
ffmpeg.av_frame_make_writable(_pDstFrame);
|
||||
PrepareDstFrameForWrite();
|
||||
|
||||
// ── Colorspace conversion: RGBA → YUV420P or NV12 ──
|
||||
// sws_scale parameters explanation:
|
||||
@@ -1311,8 +1520,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// 2. av_write_trailer: Writes container trailer (end-of-file metadata).
|
||||
/// For MP4, the moov atom (index info) is completed here.
|
||||
/// ⚠️ If trailer is not written, the file becomes corrupted and unplayable!
|
||||
/// av_write_trailer also flushes the output buffer and returns the write error of the output (AVIOContext.error), if any.
|
||||
///
|
||||
/// Throws an exception when a flush or the trailer fails (e.g. the disk is full), because the file is then incomplete.
|
||||
/// End() runs once: after it returns or throws, further calls return immediately.
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
|
||||
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavformat/mux.c (av_write_trailer)
|
||||
/// </summary>
|
||||
public void End()
|
||||
{
|
||||
@@ -1320,39 +1534,31 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
if (_disposed || !_isInitialized) return;
|
||||
|
||||
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
|
||||
try
|
||||
{
|
||||
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
|
||||
SendFrameAndWritePackets(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log error occurring during flush process
|
||||
Melon<RenderMod>.Logger.Error($"Error during video encoder flush: {ex.Message}");
|
||||
}
|
||||
|
||||
// 1.5. Flush remaining samples in audio FIFO + flush audio encoder
|
||||
if (_audioEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
FlushAudioEncoder();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Error during audio encoder flush: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Write container trailer (moov atom and file termination metadata)
|
||||
if (_pFormatContext != null)
|
||||
{
|
||||
ffmpeg.av_write_trailer(_pFormatContext);
|
||||
var ret = ffmpeg.av_write_trailer(_pFormatContext);
|
||||
if (ret < 0)
|
||||
throw new Exception($"Failed to write the container trailer — {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||
}
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Resource Cleanup (Dispose)
|
||||
@@ -1361,11 +1567,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// <summary>
|
||||
/// Actual resource cleanup logic. Cleanup order is important:
|
||||
///
|
||||
/// 1. Call End() (if not called yet) → flush delayed frames + write trailer
|
||||
/// 2. SwsContext (scaler) → independent, can be freed first
|
||||
/// 3. AVFrame (src/dst) → freed before codec as codec may reference them
|
||||
/// 4. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together
|
||||
/// 5. AVFormatContext (format/IO) → includes closing file handle
|
||||
/// 1. SwsContext (scaler) → independent, can be freed first
|
||||
/// 2. AVFrame (src/dst) → freed before codec as codec may reference them
|
||||
/// 3. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together
|
||||
/// 4. AVFormatContext (format/IO) → includes closing file handle
|
||||
///
|
||||
/// Dispose does not finish the file (no encoder flush, no trailer). A file is complete only after End() returned.
|
||||
/// Without End(), the output file is incomplete and is left to the caller (the renderer deletes it).
|
||||
///
|
||||
/// Reference:
|
||||
/// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
||||
@@ -1379,12 +1587,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Trigger End() safely in case Dispose is called without calling End() first
|
||||
if (_isInitialized)
|
||||
{
|
||||
try { End(); } catch { /* Ignore */ }
|
||||
}
|
||||
_isInitialized = false;
|
||||
|
||||
// Release Sws scaler
|
||||
if (_pSwsContext != null)
|
||||
|
||||
+33
-31
@@ -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,42 +93,22 @@ namespace ADOFAIRenderer.Util
|
||||
state.IsInFlight = true;
|
||||
}
|
||||
|
||||
// Check if GPU color conversion is available
|
||||
var expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
|
||||
|
||||
var kernelIndex = -1;
|
||||
if (ModAssets.ColorConversionShader)
|
||||
{
|
||||
var kernalName = _targetFormat switch
|
||||
{
|
||||
AVPixelFormat.AV_PIX_FMT_NV12 => "CSMain_NV12",
|
||||
AVPixelFormat.AV_PIX_FMT_YUV420P => "CSMain_YUV420P",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (kernalName != null)
|
||||
kernelIndex = ModAssets.ColorConversionShader.FindKernel(kernalName);
|
||||
}
|
||||
|
||||
var useGPUConversion = kernelIndex >= 0;
|
||||
if (useGPUConversion) expectedFmt = _targetFormat;
|
||||
|
||||
var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel
|
||||
state.Setup(currentFrame, bufferSize, useGPUConversion, expectedFmt, texture.width, texture.height);
|
||||
state.Setup(currentFrame, bufferSize, _useGPUConversion, _expectedFormat, texture.width, texture.height);
|
||||
|
||||
if (useGPUConversion && state.GPUBuffer != null && ModAssets.ColorConversionShader)
|
||||
if (_useGPUConversion && state.GPUBuffer != null && _computeShader)
|
||||
{
|
||||
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);
|
||||
_computeShader.SetTexture(_kernelIndex, SourceTexture, texture);
|
||||
_computeShader.SetBuffer(_kernelIndex, DestinationBuffer, state.GPUBuffer);
|
||||
_computeShader.SetInt(Width, texture.width);
|
||||
_computeShader.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.RequestIntoNativeArray(
|
||||
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
|
||||
@@ -138,7 +140,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
|
||||
{
|
||||
RecycleState(state);
|
||||
return;
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -121,7 +122,7 @@ internal static class GPUUtil
|
||||
!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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util;
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
||||
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
||||
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
|
||||
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
private enum GPUMode
|
||||
@@ -189,6 +190,14 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
||||
var targetVideoTime = songTime + vidOffset;
|
||||
|
||||
// When looping is enabled, the playback position is the elapsed time wrapped into [0, Duration).
|
||||
// When it is disabled, the position keeps increasing and the last frame stays on screen after the end.
|
||||
var videoDuration = _decoder.Duration;
|
||||
if (_isLooping && targetVideoTime >= 0 && videoDuration > 0)
|
||||
{
|
||||
targetVideoTime %= videoDuration;
|
||||
}
|
||||
|
||||
if (targetVideoTime < 0)
|
||||
{
|
||||
if (_targetTexture)
|
||||
@@ -307,6 +316,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_computeShader.SetInt(Width, w);
|
||||
_computeShader.SetInt(Height, h);
|
||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||
|
||||
int threadGroupsX = (w + 7) / 8;
|
||||
int threadGroupsY = (h + 7) / 8;
|
||||
@@ -341,6 +351,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_computeShader.SetInt(Width, w);
|
||||
_computeShader.SetInt(Height, h);
|
||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||
|
||||
int threadGroupsX = (w + 7) / 8;
|
||||
int threadGroupsY = (h + 7) / 8;
|
||||
|
||||
Reference in new issue
Block a user