38 Commits
Author SHA256 Message Date
copecone e9b23b981f Add FFmpeg Audio File Reader 2026-10-13 00:30:07 +09:00
copecone a472c6f72e Add Chunked Audio Clip Store 2026-10-13 00:30:02 +09:00
copecone 4e556a4632 Cleanup Comments 2026-10-12 19:48:15 +09:00
copecone f1e926f758 Cleanup LICENSE-EXCEPTION.md 2026-10-12 10:25:34 +09:00
copecone c62723cf5c Hide Win Texts And Detailed Results During Render 2026-10-12 02:03:57 +09:00
copecone fee7077b16 Split Render Thread Body Into Methods 2026-10-12 02:03:45 +09:00
copecone 9fbf057677 Hide Difficulty Selector During Render 2026-10-12 02:03:29 +09:00
copecone f153bf70aa Upgrade Version 2026-10-12 00:10:50 +09:00
copecone 93539307b1 Fix Incomplete Output File Handling 2026-10-11 23:53:41 +09:00
copecone c0e902fc1a Reallocate Shared Video Frame Buffer Without Copying 2026-10-11 23:28:47 +09:00
copecone dd2585ee9f Prefer QSV System Memory Input With GPU Frame Pool Fallback 2026-10-11 23:12:57 +09:00
copecone 1a672cbf59 Fix Looping Video Background Seeking Every Frame 2026-10-11 22:51:02 +09:00
copecone 6d5880fe19 Fix YUV to RGB Color Matrix Selection 2026-10-11 22:46:31 +09:00
copecone 72d8d71928 Upgrade Version 2026-10-11 20:26:41 +09:00
copecone 413778e165 Fix Decoder Forward Seek Returning One Frame Ahead 2026-10-11 20:24:11 +09:00
copecone 36552511f5 Fix Decoder Invalid Packet Handling 2026-10-11 19:59:22 +09:00
copecone a1f9fa36ce Fix Video Stream Time Base Causing Frame Duration Jitter 2026-10-11 19:47:55 +09:00
copecone 00971a6730 Fix Encoder Quality Options Not Being Applied 2026-10-11 18:13:42 +09:00
copecone bd24152a2e Move NTSC Handling 2026-10-11 16:45:18 +09:00
copecone 7aa8ae4b54 Move OS-dependent Utilities Into OS Package 2026-10-11 16:44:04 +09:00
copecone dee3b2dbcf Fix Render State Cleanup on Initialization Failure 2026-10-11 16:08:00 +09:00
copecone 7eec3b4660 Fix Render Freeze on Encoder Thread Failure 2026-10-11 15:46:52 +09:00
copecone ce70d89cdb Fix Potential Floating Point Error in NVIDIA Graphics Driver Version Detection 2026-10-11 12:54:18 +09:00
copecone 7db1905906 Add .mcp.json to .gitignore 2026-10-10 22:30:26 +09:00
copecone bfc79ee4d3 Fix Label Generation Condition 2026-10-10 22:25:45 +09:00
copecone b4c950c9f7 Upgrade Version 2026-10-10 16:38:12 +09:00
copecone 723f4446f5 Add Two-Step Confirmation for Preset Deletion in RenderConfigWindow 2026-10-10 16:28:00 +09:00
copecone 6f4c632b41 Implement Dynamic Localization in RenderConfigWindow 2026-10-10 16:25:15 +09:00
copecone 863dcd2357 Utilize O5Kit System in Warning and Download Dialogs 2026-10-10 15:57:40 +09:00
copecone 00048b7b06 Upgrade Version 2026-10-10 02:01:12 +09:00
copecone d218bb835d Add Preset System 2026-10-10 01:51:38 +09:00
copecone 34d766cb4d Add Custom Frame Modification Hook API for Video Rendering 2026-10-09 20:31:23 +09:00
copecone 5d805b85ad Refactor FFmpegNativeDecoder 2026-10-09 20:24:28 +09:00
copecone ba83935190 Merge remote-tracking branch 'origin/main' 2026-10-09 16:01:34 +09:00
copecone d2390665af Upgrade Version 2026-10-09 16:01:23 +09:00
copecone ceb3190274 Add Hardware Decoding Prototype 2026-10-09 16:01:00 +09:00
copecone 08b13460ae Optimize FrameReader by Caching Color Conversion Kernel 2026-10-09 14:39:30 +09:00
copecone ed7dd5dba4 Add Generative AI Usage 2026-10-09 14:14:36 +09:00
45 changed files with 6948 additions and 1991 deletions

No files matched your search

+9 -3
View File
@@ -32,9 +32,11 @@ Thumbs.db
desktop.ini desktop.ini
## Agent Metadata ## Agent Metadata
.antigravitycli/ /.antigravitycli/
.agent/ /.agent/
GEMINI.md /GEMINI.md
/.mcp.json
/CLAUDE.md
# Decompiled Game Code # Decompiled Game Code
*.cs.txt *.cs.txt
@@ -43,3 +45,7 @@ GEMINI.md
/AsyncInputAutoplayFix.md /AsyncInputAutoplayFix.md
/AudioMixerExplanation.md /AudioMixerExplanation.md
/O5Theme.md /O5Theme.md
# Developer Utility Files
/REVIEW_CHECKLIST.md
/REVIEW_FFMPEG.md
+302
View File
@@ -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++;
}
}
+24
View File
@@ -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);
}
+375
View File
@@ -1,9 +1,11 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data; using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util; using ADOFAIRenderer.Util;
using UnityEngine;
namespace ADOFAIRenderer.API; namespace ADOFAIRenderer.API;
@@ -11,8 +13,47 @@ namespace ADOFAIRenderer.API;
/// Fluent builder for configuring and starting a render session. /// Fluent builder for configuring and starting a render session.
/// </summary> /// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global")] [SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderSession 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; private bool _auto = true;
/// <summary> /// <summary>
@@ -126,6 +167,331 @@ public class RenderSession
return this; 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> /// <summary>
/// Applies the configured flags and initiates the render session. /// Applies the configured flags and initiates the render session.
/// </summary> /// </summary>
@@ -144,6 +510,15 @@ public class RenderSession
if (_onComplete is not null) RenderContext.OnComplete = _onComplete; if (_onComplete is not null) RenderContext.OnComplete = _onComplete;
if (_onLastUpdate is not null) RenderContext.OnLastUpdate = _onLastUpdate; 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.levelPath = levelPath;
RenderManager.Instance.Configuration = renderConfig; RenderManager.Instance.Configuration = renderConfig;
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -6
View File
@@ -6,20 +6,28 @@ using Newtonsoft.Json;
namespace ADOFAIRenderer.Core.Data; namespace ADOFAIRenderer.Core.Data;
public class GlobalConfig internal class GlobalConfig
{ {
public string? OutputDirectory { get; set; } 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 => private static string GetGameRootDir()
Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "global_settings.json"); {
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 => public static string DefaultOutputDirectory =>
Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); Path.Combine(GetGameRootDir(), "Render");
public static string EffectiveOutputDirectory public static string EffectiveOutputDirectory
{ {
+189
View File
@@ -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
View File
@@ -2,24 +2,118 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using MelonLoader; using MelonLoader;
using MelonLoader.Utils; using MelonLoader.Utils;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Data; namespace ADOFAIRenderer.Core;
internal class RenderPreset public class RenderPreset
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!; 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() 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) private static string GetSafeFileName(string name)
@@ -27,6 +121,45 @@ internal static class PresetManager
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_')); 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) public static void SavePreset(string name, RenderConfig config)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
@@ -43,10 +176,16 @@ internal static class PresetManager
var preset = new RenderPreset var preset = new RenderPreset
{ {
Name = name, 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 safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json"); string filePath = Path.Combine(dir, safeName + ".json");
File.WriteAllText(filePath, json); File.WriteAllText(filePath, json);
@@ -54,14 +193,26 @@ internal static class PresetManager
public static List<RenderPreset> GetPresets() public static List<RenderPreset> GetPresets()
{ {
EnsureDefaultPresetsCreated();
var presets = new List<RenderPreset>(); var presets = new List<RenderPreset>();
var dir = GetPresetsDirectory(); var dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
if (!Directory.Exists(dir)) return presets; {
foreach (var b in DefaultPresets)
{
presets.Add(b.Clone());
}
return presets;
}
try try
{ {
var files = Directory.GetFiles(dir, "*.json"); 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) foreach (var file in files)
{ {
try try
@@ -146,9 +297,20 @@ internal static class PresetManager
} }
} }
var preset = jObject.ToObject<RenderPreset>(); var preset = jObject.ToObject<RenderPreset>(serializer);
if (preset != null && !string.IsNullOrEmpty(preset.Name)) 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); presets.Add(preset);
} }
} }
@@ -177,13 +339,44 @@ internal static class PresetManager
public static void DeletePreset(string name) public static void DeletePreset(string name)
{ {
if (string.IsNullOrWhiteSpace(name)) return;
var dir = GetPresetsDirectory(); var dir = GetPresetsDirectory();
if (!Directory.Exists(dir)) return;
var safeName = GetSafeFileName(name); var safeName = GetSafeFileName(name);
var filePath = Path.Combine(dir, safeName + ".json"); var filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath)) if (File.Exists(filePath))
{ {
File.Delete(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
}
} }
} }
} }
+370 -136
View File
@@ -14,6 +14,7 @@ using ADOFAIRenderer.UI.Window;
using ADOFAIRenderer.Util; using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.FFmpeg; using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using FFmpeg.AutoGen; using FFmpeg.AutoGen;
using HarmonyLib; using HarmonyLib;
using MelonLoader; using MelonLoader;
@@ -37,6 +38,7 @@ internal static class RenderContext
public static string? OutputPath; public static string? OutputPath;
public static bool Notification = true; public static bool Notification = true;
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won; private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
public static Func<bool> EndCondition = EndConditionDefault; public static Func<bool> EndCondition = EndConditionDefault;
@@ -64,6 +66,7 @@ internal static class RenderContext
OutputPath = null; OutputPath = null;
Notification = true; Notification = true;
SessionFrameHooks.Clear();
EndCondition = EndConditionDefault; EndCondition = EndConditionDefault;
OnComplete = OnCompleteDefault; OnComplete = OnCompleteDefault;
@@ -89,7 +92,50 @@ internal class RenderManager : MonoBehaviour
public long frameCount; public long frameCount;
public bool skipPressToStart; public bool skipPressToStart;
public string levelPath = ""; 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!; public RenderConfig Configuration = null!;
// Render Sessions State (OOP Instance Fields) // Render Sessions State (OOP Instance Fields)
@@ -100,12 +146,56 @@ internal class RenderManager : MonoBehaviour
public int frameCountBase; public int frameCountBase;
public OfflineAudioMixer? AudioMixer { get; private set; } public OfflineAudioMixer? AudioMixer { get; private set; }
private AudioSyncCalculator SyncCalculator { get; set; } = null!; private AudioSyncCalculator? SyncCalculator { get; set; }
private FrameReader? _frameReader; 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 frameCountForVideo;
public long lastVideoFrameIndex = -1; 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 long lastRequestedVideoFrameIndex = -1;
public int readbackFailureCount; public int readbackFailureCount;
public int throttleCount; public int throttleCount;
@@ -125,8 +215,8 @@ internal class RenderManager : MonoBehaviour
internal AutoResetEvent FrameAvailableEvent = new(false); internal AutoResetEvent FrameAvailableEvent = new(false);
public Thread? RenderThread; public Thread? RenderThread;
public long passCount; public long passCount;
public bool endRequested; public volatile bool endRequested;
public bool endSuccessful; public volatile bool endSuccessful;
public long endFrame = -1; public long endFrame = -1;
// Private Instance Fields // Private Instance Fields
@@ -136,6 +226,11 @@ internal class RenderManager : MonoBehaviour
private GameObject? _debugMonitorObj; private GameObject? _debugMonitorObj;
private GameObject? _escapeHandlerObj; private GameObject? _escapeHandlerObj;
internal string? CurrentOutPath; 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 bool _renderSetup;
private void Awake() private void Awake()
@@ -150,16 +245,21 @@ internal class RenderManager : MonoBehaviour
public void ResetRenderState(bool success) public void ResetRenderState(bool success)
{ {
IsRenderCancelled = !success; if (success) _isRenderCancelled = false;
else MarkCancelled();
stage = RenderStage.None; stage = RenderStage.None;
} }
public void CleanUpActiveRender() public void CleanUpActiveRender()
{ {
if (stage != RenderStage.None)
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
ResetRenderState(success: false); ResetRenderState(success: false);
AudioSpectrumGenerator.Clear(); AudioSpectrumGenerator.Clear();
ResetFrameHookState();
if (_activeVideoFFmpeg != null) if (_activeVideoFFmpeg != null)
{ {
Melon<RenderMod>.Logger.Msg("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources."); 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); 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() private void Update()
@@ -236,8 +362,15 @@ internal class RenderManager : MonoBehaviour
} }
if (Instance.stage < RenderStage.OnScene) return; 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) 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); ResetRenderState(success: false);
StopFFmpeg(false); StopFFmpeg(false);
EndRender(); EndRender();
@@ -286,6 +419,12 @@ internal class RenderManager : MonoBehaviour
while (Instance.stage >= RenderStage.Render) while (Instance.stage >= RenderStage.Render)
{ {
if (RenderThread is not { IsAlive: true })
{
isThrottling = false;
return;
}
int count; int count;
lock (ImageQueue) lock (ImageQueue)
{ {
@@ -315,8 +454,24 @@ internal class RenderManager : MonoBehaviour
if (videoFrameIndex > lastVideoFrameIndex) if (videoFrameIndex > lastVideoFrameIndex)
{ {
lastVideoFrameIndex = videoFrameIndex; lastVideoFrameIndex = videoFrameIndex;
lastRequestedVideoFrameIndex = videoFrameIndex; Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex);
_frameReader?.RequestFrameCapture(texture, 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; Application.wantsToQuit -= quitDelegate;
RenderConfigWindow.CreateWindow(); RenderConfigWindow.CreateWindow();
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
}); });
} }
@@ -377,6 +531,9 @@ internal class RenderManager : MonoBehaviour
yield return new WaitForSecondsRealtime(1); 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; frameCount = 0; framesSent = 0;
_renderStopwatch.Restart(); _renderStopwatch.Restart();
@@ -387,6 +544,10 @@ internal class RenderManager : MonoBehaviour
_frameReader?.Dispose(); _frameReader?.Dispose();
_frameReader = null; _frameReader = null;
_frameHookPipeline?.Dispose();
_frameHookPipeline = new FrameHookPipeline();
_cachedGlobalHooksVersion = -1;
EnsureActiveHooksUpToDate();
try try
{ {
StartFFmpegThread(); StartFFmpegThread();
@@ -395,12 +556,20 @@ internal class RenderManager : MonoBehaviour
catch (Exception ex) catch (Exception ex)
{ {
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {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( CommonWarningDialogUI.ShowDialog(
"Encoder Error", "Encoder Error",
$"Failed to initialize video encoder.\nError: {ex.Message}", $"Failed to initialize video encoder.\nError: {ex.Message}",
new DialogButton("OK", () => new DialogButton("OK", () =>
{ {
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
}) })
); );
yield break; yield break;
@@ -443,10 +612,10 @@ internal class RenderManager : MonoBehaviour
ImageQueue.Clear(); ImageQueue.Clear();
} }
passCount = 0; passCount = 0;
IsRenderCancelled = false; ClearAbortState();
frameCountForVideo = 0; frameCountForVideo = 0;
lastVideoFrameIndex = -1; lastVideoFrameIndex = -1;
lastRequestedVideoFrameIndex = -1; Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
endRequested = false; endRequested = false;
endSuccessful = false; endSuccessful = false;
FrameAvailableEvent.Reset(); FrameAvailableEvent.Reset();
@@ -496,6 +665,7 @@ internal class RenderManager : MonoBehaviour
{ {
_allVideoFFmpegInstances.Add(videoFFmpeg); _allVideoFFmpegInstances.Add(videoFFmpeg);
} }
Volatile.Write(ref _incompleteOutputPath, CurrentOutPath);
videoFFmpeg.Start(CurrentOutPath); videoFFmpeg.Start(CurrentOutPath);
} }
catch (Exception) catch (Exception)
@@ -512,113 +682,8 @@ internal class RenderManager : MonoBehaviour
throw; throw;
} }
RenderThread = new Thread(() => var encoder = videoFFmpeg!;
{ RenderThread = new Thread(() => RunRenderThread(encoder))
try
{
while (true)
{
CapturedFrame frame = default;
bool hasFrame = false;
lock (ImageQueue)
{
if (ImageQueue.Count > 0)
{
var firstKey = ImageQueue.Keys[0];
if (firstKey > passCount && Instance.stage < RenderStage.Render)
{
passCount = firstKey;
}
if (firstKey == passCount)
{
frame = ImageQueue[firstKey];
ImageQueue.RemoveAt(0);
passCount++;
hasFrame = true;
}
}
}
if (hasFrame)
{
if (frame.ManagedBuffer != null)
{
if (frame.ManagedBuffer.Length > 0)
{
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
}
}
else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
{
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
if (frame.ReadbackState != null)
{
_frameReader?.RecycleState(frame.ReadbackState);
}
}
if (Configuration.AudioConfig.RenderAudio)
{
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 audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
try
{
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
}
finally
{
ArrayPool<float>.Shared.Return(audioBuffer);
}
}
continue;
}
if (IsRenderCancelled) break;
if (endRequested)
{
if (passCount > lastRequestedVideoFrameIndex) break;
}
FrameAvailableEvent.WaitOne(50);
}
if (!IsRenderCancelled) videoFFmpeg.End();
videoFFmpeg.Dispose();
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_activeVideoFFmpeg = null;
endSuccessful = !IsRenderCancelled;
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
videoFFmpeg.Dispose();
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_activeVideoFFmpeg = null;
}
})
{ {
IsBackground = true, IsBackground = true,
Priority = ThreadPriority.AboveNormal Priority = ThreadPriority.AboveNormal
@@ -627,6 +692,168 @@ internal class RenderManager : MonoBehaviour
RenderThread.Start(); RenderThread.Start();
} }
/// <summary>
/// Body of the render thread. Sends queued frames and their audio to the encoder until the render ends,
/// finishes the output file, and releases the encoder. An exception is handled as a render failure.
/// </summary>
private void RunRenderThread(FFmpegNativeEncoder videoFFmpeg)
{
try
{
EncodeQueuedFrames(videoFFmpeg);
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();
UnregisterEncoder(videoFFmpeg);
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}");
}
UnregisterEncoder(videoFFmpeg);
}
}
/// <summary>
/// Takes frames from ImageQueue in passCount order and sends each one to the encoder, followed by the audio
/// samples of that frame. Waits on FrameAvailableEvent (50ms) while no frame is ready.
/// Returns when the render is aborted or all requested frames have been sent after endRequested.
/// </summary>
private void EncodeQueuedFrames(FFmpegNativeEncoder videoFFmpeg)
{
while (true)
{
if (TryDequeueNextFrame(out var frame))
{
SendVideoFrame(videoFFmpeg, frame);
WriteAudioForFrame(videoFFmpeg);
continue;
}
if (ShouldStopEncodeLoop()) return;
FrameAvailableEvent.WaitOne(50);
}
}
/// <summary>
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
/// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
/// Returns false when the queue is empty or the next frame has not arrived yet.
/// </summary>
private bool TryDequeueNextFrame(out CapturedFrame frame)
{
frame = default;
lock (ImageQueue)
{
if (ImageQueue.Count == 0) return false;
var firstKey = ImageQueue.Keys[0];
if (firstKey > passCount && Instance.stage < RenderStage.Render)
{
passCount = firstKey;
}
if (firstKey != passCount) return false;
frame = ImageQueue[firstKey];
ImageQueue.RemoveAt(0);
passCount++;
return true;
}
}
/// <summary>
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
/// A frame without data is skipped.
/// </summary>
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
{
if (frame.ManagedBuffer != null)
{
if (frame.ManagedBuffer.Length == 0) return;
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
return;
}
if (frame.NativePointer == IntPtr.Zero || frame.Length <= 0) return;
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
if (frame.ReadbackState != null)
{
_frameReader?.RecycleState(frame.ReadbackState);
}
}
/// <summary>
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
/// Does nothing when audio rendering is off.
/// </summary>
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
{
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
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 audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
try
{
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
}
finally
{
ArrayPool<float>.Shared.Return(audioBuffer);
}
}
/// <summary>
/// True when the render is aborted, or when the end was requested and every requested frame has been sent.
/// </summary>
private bool ShouldStopEncodeLoop()
{
if (IsRenderAborted) return true;
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
}
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
{
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_activeVideoFFmpeg = null;
}
public void CancelRender() public void CancelRender()
{ {
ResetRenderState(success: false); ResetRenderState(success: false);
@@ -640,7 +867,7 @@ internal class RenderManager : MonoBehaviour
endRequested = true; endRequested = true;
if (!success) if (!success)
{ {
IsRenderCancelled = true; MarkCancelled();
} }
FrameAvailableEvent.Set(); FrameAvailableEvent.Set();
skipPressToStart = false; skipPressToStart = false;
@@ -654,7 +881,8 @@ internal class RenderManager : MonoBehaviour
if (success) StartCoroutine(StopRenderCoroutine()); if (success) StartCoroutine(StopRenderCoroutine());
else 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 // 1. Wait for render background thread to finish processing remaining queued frames and terminate
if (RenderThread is { IsAlive: true }) if (RenderThread is { IsAlive: true })
@@ -687,6 +915,7 @@ internal class RenderManager : MonoBehaviour
} }
_frameReader?.Dispose(); _frameReader?.Dispose();
ResetFrameHookState();
// Clean up Debug Monitor UI // Clean up Debug Monitor UI
if (_debugMonitorObj) if (_debugMonitorObj)
@@ -712,30 +941,26 @@ internal class RenderManager : MonoBehaviour
scrController.instance.noFailInfiniteMargin = false; scrController.instance.noFailInfiniteMargin = false;
} }
// 6. Immediately delete cancelled incomplete temporary video file // 6. Immediately delete the incomplete video file of this render
if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath)) 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; CurrentOutPath = null;
} }
var result = BuildResult(false, var result = BuildResult(false, BuildAbortMessage());
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
TriggerOnComplete(result); TriggerOnComplete(result);
} }
} }
private IEnumerator StopRenderCoroutine() 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); var result = BuildResult(success: true);
TriggerOnComplete(result); TriggerOnComplete(result);
@@ -744,6 +969,15 @@ internal class RenderManager : MonoBehaviour
} }
private void EndRender() 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.timeScale = 1f;
Time.captureDeltaTime = 0f; Time.captureDeltaTime = 0f;
@@ -776,13 +1010,13 @@ internal class RenderManager : MonoBehaviour
} }
_frameReader?.Dispose(); _frameReader?.Dispose();
ResetFrameHookState();
AudioMixer?.Reset(); AudioMixer = null; AudioMixer?.Reset(); AudioMixer = null;
SyncCalculator.Reset(); SyncCalculator = null;
AudioSpectrumGenerator.Clear();
RenderContext.Reset(); RenderContext.Reset();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
} }
private RenderResult BuildResult(bool success, string? errorMessage = null) private RenderResult BuildResult(bool success, string? errorMessage = null)
+109
View File
@@ -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;
}
}
+82
View File
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Data; namespace ADOFAIRenderer.Data;
@@ -18,6 +19,28 @@ public class RenderConfig
public double DelayAfterEnd { get; set; } = 15; public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } = false; 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")] [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
@@ -28,9 +51,33 @@ public class AdvancedConfig
public bool NonTile { get; set; } = false; public bool NonTile { get; set; } = false;
public string FileExtension { get; set; } = "mkv"; public string FileExtension { get; set; } = "mkv";
[JsonConverter(typeof(ColorJsonConverter))]
public Color? firePlanetColor = null; public Color? firePlanetColor = null;
[JsonConverter(typeof(ColorJsonConverter))]
public Color? icePlanetColor = null; public Color? icePlanetColor = null;
[JsonConverter(typeof(ColorJsonConverter))]
public Color? windPlanetColor = null; 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 public class VideoEncodingConfig
@@ -44,6 +91,25 @@ public class VideoEncodingConfig
public int QP { get; set; } = 18; public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0; public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; } 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 public class AudioEncodingConfig
@@ -71,4 +137,20 @@ public class AudioEncodingConfig
get => _audioBitrate <= 0 ? 256000 : _audioBitrate; get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value; 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;
}
}␍ }␍
-20
View File
@@ -4,8 +4,6 @@
--- ---
## 1. Legal Terms (Official)
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination. Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces: As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
@@ -22,21 +20,3 @@ An **"independent module"** is a separate and distinct software module that is n
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3. This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version. In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
---
## 2. 조항 해설 (Korean Guide)
본 문서는 GNU General Public License 버전 3 (GPLv3) 제7조(추가적인 허가, Additional Permissions)에 따라 제공되는 공식 예외 조항입니다.
### 핵심 요약
- **동적 링킹(Dynamic Linking)만 허용**:
- 본 예외 조항은 **동적 링킹(런타임 참조 및 어셈블리 동적 로드)**에만 배타적으로 적용됩니다.
- ADOFAIRenderer 또는 그 일부 코드를 대상 모드/프로그램에 직접 병합하거나 포함(ILMerge, Costura, 바이너리 직접 내장/정적 컴파일 등)하는 **정적 링킹(Static Linking)은 본 예외 대상에서 제외**되며, 순수 GPLv3의 카피레프트(전염성) 의무가 그대로 적용됩니다.
- **외부 모드의 라이선스 자유**:
- 제3자 모드 또는 외부 프로그램이 동적 링킹을 통해 `ADOFAIRenderer.API` 및 `ADOFAIRenderer.Data` 네임스페이스의 공개 API, 클래스, 데이터 구조만을 참조·호출하여 연동하는 경우, 해당 모드는 **GPLv3의 카피레프트 의무를 적용받지 않습니다.**
- 외부 모드 개발자는 자신의 모드를 MIT, Apache-2.0, 독점/상용 라이선스 등 원하는 라이선스로 자유롭게 배포할 수 있습니다.
- **ADOFAIRenderer 코어 보호**:
- ADOFAIRenderer 자체의 소스 코드(렌더링 파이프라인, 코어 패치, 내부 유틸리티 등)를 수정하거나 재배포하는 경우에는 본 예외 조항이 적용되지 않으며, 순수 GPLv3 라이선스 조건이 그대로 적용됩니다.
+14
View File
@@ -1,5 +1,19 @@
{ {
"ui.render_config.window_title": "Render Config", "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.render_fps": "Render FPS",
"ui.render_config.video_fps": "Video FPS", "ui.render_config.video_fps": "Video FPS",
"ui.render_config.render_width": "Render Width", "ui.render_config.render_width": "Render Width",
+19 -5
View File
@@ -1,16 +1,30 @@
{ {
"ui.render_config.window_title": "렌더 설정", "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.render_fps": "렌더링 FPS",
"ui.render_config.video_fps": "영상 FPS", "ui.render_config.video_fps": "영상 FPS",
"ui.render_config.render_width": "렌더링 너비", "ui.render_config.render_width": "렌더링 너비",
"ui.render_config.render_height": "렌더링 높이", "ui.render_config.render_height": "렌더링 높이",
"ui.render_config.delay_after_end": "종료 후 대기 시간 (초)", "ui.render_config.delay_after_end": "종료 후 대기 시간 (초)",
"ui.render_config.show_judgement": "판정 텍스트 표시", "ui.render_config.show_judgement": "판정 텍스트 표시",
"ui.render_config.video_encoding": "Video Encoding Config", "ui.render_config.video_encoding": "비디오 인코딩 설정",
"ui.render_config.audio_encoding": "Audio Encoding Config", "ui.render_config.audio_encoding": "오디오 인코딩 설정",
"ui.render_config.global_settings": "Global Settings", "ui.render_config.global_settings": "전역 설정",
"ui.render_config.advanced_settings": "Advanced Settings", "ui.render_config.advanced_settings": "고급 설정",
"ui.render_config.go_render": "렌더 시작", "ui.render_config.go_render": "렌더링하러 가다",
"ui.render_config.codec": "코덱", "ui.render_config.codec": "코덱",
"ui.render_config.rate_control": "비트레이트 제어 모드", "ui.render_config.rate_control": "비트레이트 제어 모드",
"ui.render_config.bitrate": "비트레이트 (kbps)", "ui.render_config.bitrate": "비트레이트 (kbps)",
+25
View File
@@ -0,0 +1,25 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(DetailedResults))]
// ReSharper disable once InconsistentNaming
public static class DetailedResultsPatch
{
[HarmonyPrefix]
[HarmonyPatch("Show")]
// ReSharper disable once InconsistentNaming
public static bool PreventShow(DetailedResults __instance)
{
if (!RenderManager.IsActive) return true;
if (__instance)
{
__instance.enabled = false;
if (__instance.gameObject) __instance.gameObject.SetActive(false);
}
return false;
}
}
+34
View File
@@ -41,4 +41,38 @@ public static class scrControllerPatch
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
} }
[HarmonyPostfix]
[HarmonyPatch("OnLandOnPortal")]
public static void PreventWinTextsPostfix(scrController __instance)
{
if (!RenderManager.IsActive || __instance == null) return;
if (__instance.txtCongrats)
{
__instance.txtCongrats.text = string.Empty;
__instance.txtCongrats.gameObject.SetActive(false);
}
if (__instance.txtAprilCongrats)
{
__instance.txtAprilCongrats.text = string.Empty;
__instance.txtAprilCongrats.gameObject.SetActive(false);
}
if (__instance.txtAllStrictClear)
{
__instance.txtAllStrictClear.text = string.Empty;
__instance.txtAllStrictClear.gameObject.SetActive(false);
}
if (__instance.detailedResults)
{
__instance.detailedResults.enabled = false;
if (__instance.detailedResults.gameObject != null)
{
__instance.detailedResults.gameObject.SetActive(false);
}
}
}
} }
+6
View File
@@ -14,6 +14,12 @@ public class scrPressToStartPatch
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true; if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
RenderManager.Instance.skipPressToStart = false; RenderManager.Instance.skipPressToStart = false;
// scrPressToStart.ShowText ends with ShowDifficultyContainer(mode). Skipping ShowText skips that call too,
// which leaves the difficulty selector active and clickable. DontShow deactivates difficultyContainer
// and makes the keyboard difficulty shortcuts (difficultyUIMode != DontShow checks) do nothing.
if (scrUIController.instance)
scrUIController.instance.ShowDifficultyContainer(DifficultyUIMode.DontShow);
return false; return false;
} }
} }
+2
View File
@@ -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. 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 ## ✨ Key Features
+1 -1
View File
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
using UnityEngine; using UnityEngine;
using Object = UnityEngine.Object; 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")] [assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer; namespace ADOFAIRenderer;
+84 -158
View File
@@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using MelonLoader;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using UnityEngine.UIElements; using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button;
using Image = UnityEngine.UI.Image; using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI; namespace ADOFAIRenderer.UI;
@@ -29,6 +31,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
{ {
public static CommonWarningDialogUI? Instance { get; private set; } public static CommonWarningDialogUI? Instance { get; private set; }
private readonly List<VisualElement> _disabledElements = new(); private readonly List<VisualElement> _disabledElements = new();
private O5Window? _window;
private void Awake() private void Awake()
{ {
@@ -37,6 +40,9 @@ internal class CommonWarningDialogUI : MonoBehaviour
private void OnDestroy() private void OnDestroy()
{ {
_window?.Dispose();
_window = null;
foreach (var elem in _disabledElements) foreach (var elem in _disabledElements)
{ {
if (elem != null) if (elem != null)
@@ -87,9 +93,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
// Canvas setup // Canvas setup
var canvas = gameObject.AddComponent<Canvas>(); var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767) canvas.sortingOrder = 32766;
// Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>(); var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080); scaler.referenceResolution = new Vector2(1920, 1080);
@@ -98,13 +103,15 @@ internal class CommonWarningDialogUI : MonoBehaviour
gameObject.AddComponent<GraphicRaycaster>(); gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background var ctx = RendererUIManager.Context;
var bgPanelObj = new GameObject("BackgroundPanel");
// 1. Create full screen backdrop (scrim) to block background input
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false); bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5; bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>(); 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; bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>(); var bgRect = bgPanelObj.GetComponent<RectTransform>();
@@ -113,172 +120,91 @@ internal class CommonWarningDialogUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero; bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = 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 // 3. Build content inside window.Content
var modalContainerObj = new GameObject("ModalContainer"); var content = _window.Content;
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>(); var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI 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>(); // Description text
modalOutline.effectColor = Color.white; var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
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>();
descText.text = description; 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.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>(); // ReSharper disable once InconsistentNaming
descRect.anchorMin = new Vector2(0, 0.5f); var descLE = descText.gameObject.AddComponent<LayoutElement>();
descRect.anchorMax = new Vector2(1, 1); descLE.flexibleWidth = 1f;
descRect.pivot = new Vector2(0.5f, 0.5f); descLE.flexibleHeight = 1f;
descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120);
// 5. Create button container // Button action row
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform)); var btnRowObj = new GameObject("ButtonRow");
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false); btnRowObj.transform.SetParent(content, false);
buttonContainerObj.layer = 5; var btnRowRect = btnRowObj.AddComponent<RectTransform>();
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
btnRowLE.preferredHeight = 40f;
btnRowLE.flexibleWidth = 1f;
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>(); var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
btnContainerRect.anchorMin = new Vector2(0, 0); hLayout.spacing = 10f;
btnContainerRect.anchorMax = new Vector2(1, 0); hLayout.childAlignment = TextAnchor.MiddleCenter;
btnContainerRect.pivot = new Vector2(0.5f, 0); hLayout.childControlWidth = true;
btnContainerRect.anchoredPosition = new Vector2(0, 35); hLayout.childControlHeight = true;
btnContainerRect.sizeDelta = new Vector2(0, 50); hLayout.childForceExpandWidth = true;
hLayout.childForceExpandHeight = true;
// 6. Dynamic button layout and styling for (var i = 0; i < buttons.Length; i++)
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++)
{ {
var btnData = buttons[i]; var btnData = buttons[i];
var btnObj = new GameObject($"Button_{i}"); var btn = O5Factory.Button(
btnObj.transform.SetParent(buttonContainerObj.transform, false); ctx: ctx,
btnObj.layer = 5; parent: btnRowRect,
onClick: () =>
{
try
{
btnData.OnClick();
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
},
text: btnData.Text,
id: $"btn_dialog_{i}",
height: 40f
);
// Define default colors if (btnData.BackgroundColor.HasValue)
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(() =>
{ {
try btn.NormalColor = btnData.BackgroundColor.Value;
{ btn.UpdateVisual();
btnData.OnClick(); }
}
catch (Exception ex)
{
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
});
var btnRect = btnObj.GetComponent<RectTransform>(); var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
btnRect.anchorMin = new Vector2(0.5f, 0.5f); le.flexibleWidth = 1f;
btnRect.anchorMax = new Vector2(0.5f, 0.5f); le.preferredHeight = 40f;
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)
{
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;
}
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);
}
} }
+124 -182
View File
@@ -4,11 +4,13 @@ using System.IO.Compression;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using MelonLoader;
using MelonLoader.Utils;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using MelonLoader;
using MelonLoader.Utils;
namespace ADOFAIRenderer.UI; namespace ADOFAIRenderer.UI;
@@ -22,6 +24,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
private GameObject _statusTextObj = null!; private GameObject _statusTextObj = null!;
private TextMeshProUGUI _statusText = null!; private TextMeshProUGUI _statusText = null!;
private O5Window? _window;
private bool _isDownloading; private bool _isDownloading;
private string DownloadUrl => Application.platform switch private string DownloadUrl => Application.platform switch
{ {
@@ -104,13 +107,15 @@ internal class FFmpegDownloadUI : MonoBehaviour
gameObject.AddComponent<GraphicRaycaster>(); gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background var ctx = RendererUIManager.Context;
var bgPanelObj = new GameObject("BackgroundPanel");
// 1. Create full screen backdrop
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false); bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5; bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>(); 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; bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>(); var bgRect = bgPanelObj.GetComponent<RectTransform>();
@@ -119,51 +124,31 @@ internal class FFmpegDownloadUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero; bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = 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 content = _window.Content;
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>(); var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); 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>(); // 3. Description text
modalOutline.effectColor = Color.white; var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
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>();
var descStrBuilder = new StringBuilder(); var descStrBuilder = new StringBuilder();
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video."); descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
if (Application.platform == RuntimePlatform.OSXPlayer) 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("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>"); descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
} }
descText.text = descStrBuilder.ToString(); 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.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>(); var descLE = descText.gameObject.AddComponent<LayoutElement>();
descRect.anchorMin = new Vector2(0, 0.5f); descLE.flexibleWidth = 1f;
descRect.anchorMax = new Vector2(1, 1); descLE.flexibleHeight = 1f;
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -60);
descRect.sizeDelta = new Vector2(-40, 100);
// 6. Create button container // 4. Button container
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform)); _buttonContainerObj = new GameObject("ButtonContainer");
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false); _buttonContainerObj.transform.SetParent(content, false);
_buttonContainerObj.layer = 5; _buttonContainerObj.layer = 5;
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>(); var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
btnContainerRect.anchorMin = new Vector2(0, 0); btnContainerLE.preferredHeight = 40f;
btnContainerRect.anchorMax = new Vector2(1, 0); btnContainerLE.flexibleWidth = 1f;
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 7. Create download button var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
var downloadBtnObj = new GameObject("DownloadButton"); btnLayout.spacing = 10f;
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false); btnLayout.childAlignment = TextAnchor.MiddleCenter;
downloadBtnObj.layer = 5; btnLayout.childControlWidth = true;
btnLayout.childControlHeight = true;
btnLayout.childForceExpandWidth = true;
btnLayout.childForceExpandHeight = true;
var downloadImg = downloadBtnObj.AddComponent<Image>(); Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue ? StartDownloadProcess
downloadImg.raycastTarget = true; : () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
var downloadBtn = downloadBtnObj.AddComponent<Button>(); var downloadBtn = O5Factory.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)); ctx: ctx,
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess); parent: _buttonContainerObj.transform,
else onClick: onDownloadClick,
{ text: Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website",
downloadBtn.onClick.AddListener(OpenFFmpegWebsite); id: "btn_ffmpeg_download",
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); } height: 40f
} );
downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
downloadBtn.UpdateVisual();
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>(); var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f); downloadBtnLE.flexibleWidth = 1f;
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f); downloadBtnLE.preferredHeight = 40f;
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
downloadBtnRect.anchoredPosition = new Vector2(-15, 0);
downloadBtnRect.sizeDelta = new Vector2(180, 45);
var downloadTextObj = new GameObject("DownloadText"); var cancelBtn = O5Factory.Button(
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false); ctx: ctx,
downloadTextObj.layer = 5; 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>(); // 5. Progress bar container
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
_progressBarObj = new GameObject("ProgressBarContainer"); _progressBarObj = new GameObject("ProgressBarContainer");
_progressBarObj.transform.SetParent(modalContainerObj.transform, false); _progressBarObj.transform.SetParent(content, false);
_progressBarObj.layer = 5; _progressBarObj.layer = 5;
_progressBarObj.SetActive(false); _progressBarObj.SetActive(false);
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
pbLE.preferredHeight = 24f;
pbLE.flexibleWidth = 1f;
var barBgImg = _progressBarObj.AddComponent<Image>(); 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>(); var barOutlineObj = new GameObject("BarOutline");
barBgOutline.effectColor = Color.white; barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f); barOutlineObj.layer = 5;
var barRect = _progressBarObj.GetComponent<RectTransform>(); var barOutlineImg = barOutlineObj.AddComponent<Image>();
barRect.anchorMin = new Vector2(0.5f, 0); barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
barRect.anchorMax = new Vector2(0.5f, 0); barOutlineImg.type = Image.Type.Sliced;
barRect.pivot = new Vector2(0.5f, 0); barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
barRect.anchoredPosition = new Vector2(0, 75); barOutlineImg.raycastTarget = false;
barRect.sizeDelta = new Vector2(480, 24);
// 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 = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false); _progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5; _progressBarFillObj.layer = 5;
var fillImg = _progressBarFillObj.AddComponent<Image>(); 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 = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0); _fillRect.anchorMin = new Vector2(0, 0);
@@ -317,20 +267,17 @@ internal class FFmpegDownloadUI : MonoBehaviour
_fillRect.anchoredPosition = Vector2.zero; _fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = Vector2.zero; _fillRect.sizeDelta = Vector2.zero;
// 11. Create progress text // Progress text
var progressTextObj = new GameObject("ProgressBarText"); var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false); progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5; progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>(); _progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.fontSize = 16; _progressText.fontSize = 13;
_progressText.font = font; _progressText.font = ctx.Fonts.Regular;
_progressText.color = Color.white; _progressText.color = Color.white;
_progressText.alignment = TextAlignmentOptions.Center; _progressText.alignment = TextAlignmentOptions.Center;
_progressText.raycastTarget = false;
var progressTextOutline = progressTextObj.AddComponent<Outline>();
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
progressTextOutline.effectDistance = new Vector2(1, -1);
var progressTextRect = progressTextObj.GetComponent<RectTransform>(); var progressTextRect = progressTextObj.GetComponent<RectTransform>();
progressTextRect.anchorMin = Vector2.zero; progressTextRect.anchorMin = Vector2.zero;
@@ -338,35 +285,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
progressTextRect.offsetMin = Vector2.zero; progressTextRect.offsetMin = Vector2.zero;
progressTextRect.offsetMax = Vector2.zero; progressTextRect.offsetMax = Vector2.zero;
// 12. Create status text // 6. Status text
_statusTextObj = new GameObject("StatusText"); _statusTextObj = new GameObject("StatusText");
_statusTextObj.transform.SetParent(modalContainerObj.transform, false); _statusTextObj.transform.SetParent(content, false);
_statusTextObj.layer = 5; _statusTextObj.layer = 5;
_statusTextObj.SetActive(false); _statusTextObj.SetActive(false);
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
statusLE.preferredHeight = 22f;
statusLE.flexibleWidth = 1f;
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>(); _statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
_statusText.fontSize = 16; _statusText.fontSize = 14;
_statusText.font = font; _statusText.font = ctx.Fonts.Regular;
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f); _statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
_statusText.alignment = TextAlignmentOptions.Center; _statusText.alignment = TextAlignmentOptions.Center;
_statusText.raycastTarget = false;
var statusRect = _statusTextObj.GetComponent<RectTransform>(); var statusRect = _statusTextObj.GetComponent<RectTransform>();
statusRect.anchorMin = new Vector2(0, 0); statusRect.anchorMin = Vector2.zero;
statusRect.anchorMax = new Vector2(1, 0); statusRect.anchorMax = Vector2.one;
statusRect.pivot = new Vector2(0.5f, 0); statusRect.offsetMin = Vector2.zero;
statusRect.anchoredPosition = new Vector2(0, 35); statusRect.offsetMax = Vector2.zero;
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;
} }
private void Update() private void Update()
@@ -643,6 +583,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
private void OnDestroy() private void OnDestroy()
{ {
_window?.Dispose();
_window = null;
_cts?.Dispose(); _cts?.Dispose();
if (Instance == this) if (Instance == this)
{ {
+112
View File
@@ -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;
}
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -108,7 +108,7 @@ internal static class AudioSpectrumGenerator
return; return;
} }
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1; var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums) lock (CachedSpectrums)
{ {
-5
View File
@@ -15,11 +15,6 @@ internal class AudioSyncCalculator
{ {
_sampleRate = sampleRate; _sampleRate = sampleRate;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps); _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() public void Reset()
+608
View File
@@ -0,0 +1,608 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Read position registered on an <see cref="AudioClipSource"/> by one consumer (a playing voice or the spectrum generator).
/// For a windowed source, the chunk containing <see cref="Position"/> and the following chunks
/// (<see cref="AudioClipSource.SlotCount"/> chunks in total) are kept loaded.
/// </summary>
internal sealed class AudioCursor
{
private readonly AudioClipSource _source;
private long _position;
private volatile bool _loop;
internal AudioCursor(AudioClipSource source, long position, bool loop)
{
_source = source;
_position = position;
_loop = loop;
}
/// <summary>
/// Frame in the source at the output sample rate (0 ~ TotalFrames - 1).
/// Written by the consumer that reads the source; read by the decoder thread.
/// Moving into another chunk wakes the decoder thread.
/// </summary>
public long Position
{
get => Interlocked.Read(ref _position);
set
{
var previous = Interlocked.Exchange(ref _position, value);
if (previous / _source.FramesPerChunk != value / _source.FramesPerChunk)
_source.OnCursorMoved();
}
}
/// <summary>When true, the chunk after the last chunk is chunk 0.</summary>
public bool Loop
{
get => _loop;
set
{
_loop = value;
_source.OnCursorMoved();
}
}
}
/// <summary>
/// One loaded chunk of an <see cref="AudioClipSource"/>, held by a consumer between
/// <see cref="AudioClipSource.TryAcquire"/> and <see cref="AudioClipSource.Release"/>.
/// The chunk is not freed while held.
/// </summary>
internal readonly struct ChunkView
{
/// <summary>Interleaved samples. Frame f of the source is at (f - StartFrame) * Channels.</summary>
public readonly float[] Data;
/// <summary>Source frame of the first frame in <see cref="Data"/>.</summary>
public readonly long StartFrame;
/// <summary>Valid frames in this chunk. Frames past the end of the source are not included.</summary>
public readonly int FrameCount;
/// <summary>Channel count of the stored samples (same as the source clip).</summary>
public readonly int Channels;
public readonly int ChunkIndex;
internal ChunkView(float[] data, long startFrame, int frameCount, int channels, int chunkIndex)
{
Data = data;
StartFrame = startFrame;
FrameCount = frameCount;
Channels = channels;
ChunkIndex = chunkIndex;
}
public bool IsValid => Data != null;
public long EndFrame => StartFrame + FrameCount;
public bool Contains(long frame) => frame >= StartFrame && frame < StartFrame + FrameCount;
/// <summary>
/// Sample of <paramref name="frame"/> mapped to output channel <paramref name="outChannel"/> of <paramref name="outChannels"/>.
/// Mapping: mono output averages all source channels; a mono source is copied to every output channel;
/// otherwise output channel c takes source channel c, and output channels beyond the source take source channel 0.
/// <paramref name="frame"/> must be inside this chunk.
/// </summary>
public float GetSample(long frame, int outChannel, int outChannels)
{
var baseIndex = (int)(frame - StartFrame) * Channels;
if (Channels == 1)
return Data[baseIndex];
if (outChannels == 1)
{
var sum = 0f;
for (var c = 0; c < Channels; c++)
sum += Data[baseIndex + c];
return sum / Channels;
}
return Data[baseIndex + (outChannel < Channels ? outChannel : 0)];
}
}
/// <summary>
/// How an <see cref="AudioClipSource"/> keeps its chunks.
/// </summary>
internal enum ChunkPolicy
{
/// <summary>
/// Only the chunks required by registered cursors (current chunk and the next SlotCount - 1 chunks per cursor),
/// chunks requested by a waiting consumer, and chunks currently held are kept. Other chunks are freed and reloaded on demand.
/// Used for readers that can seek (files decoded with FFmpeg) when the clip is longer than one chunk.
/// </summary>
Windowed,
/// <summary>
/// All chunks are loaded once, in order from chunk 0, starting when the source is created, and kept until the store is disposed.
/// Used for forward-only readers (clip data copied from Unity, released as it is consumed) and for clips of one chunk or less.
/// </summary>
Resident
}
/// <summary>
/// Decoded audio of one clip, resampled to the output sample rate and stored in fixed-size chunks.
/// Channel count stays as in the clip.
///
/// Chunks: chunk k covers output frames [k * FramesPerChunk, (k + 1) * FramesPerChunk), where
/// <see cref="FramesPerChunk"/> = <see cref="ChunkFloats"/> / Channels (rounded down; a frame never spans two chunks).
/// Windowed chunks use pooled arrays of ChunkFloats floats; resident chunks use arrays of their exact length.
/// See <see cref="ChunkPolicy"/> for which chunks are kept.
///
/// Threads: cursors and TryAcquire/Release may be used from any thread.
/// Loading and freeing run only on the store's decoder thread (<see cref="AudioClipStore"/>), through
/// <see cref="FreeUnused"/>, <see cref="TryGetNextLoad"/> and <see cref="LoadChunk"/>.
/// </summary>
internal sealed class AudioClipSource
{
/// <summary>Floats per chunk array: 2^22 floats = 16 MiB.</summary>
public const int ChunkFloats = 1 << 22;
/// <summary>Chunks kept per cursor of a windowed source: the chunk containing the cursor and the next SlotCount - 1 chunks.</summary>
public const int SlotCount = 3;
/// <summary>Load priority of a chunk a consumer is waiting for (lowest value is loaded first).</summary>
internal const int PriorityDemanded = 0;
/// <summary>Load priority of slot s of a cursor window is PriorityWindow + s.</summary>
internal const int PriorityWindow = 1;
/// <summary>Load priority of resident chunks nobody is waiting for.</summary>
internal const int PriorityResident = PriorityWindow + SlotCount;
/// <summary>
/// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start,
/// otherwise opens another stream up to this count, otherwise restarts the least recently used stream.
/// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/mp3 with the clip harness).
/// </summary>
public const int MaxDecodeStreams = 4;
/// <summary>Decode stream limit of this source (<see cref="MaxDecodeStreams"/> unless changed for tests).</summary>
internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams;
private readonly AudioClipStore _store;
private readonly IPCMReader _firstReader;
private readonly Func<IPCMReader?>? _openReader;
// Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set.
private readonly object _sync = new();
private readonly float[]?[] _chunks;
private readonly int[] _holdCounts;
private readonly List<AudioCursor> _cursors = new();
private readonly HashSet<int> _demanded = new();
private readonly HashSet<int> _loaded = new();
private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded
// Guarded by _loadLock: decode streams.
private readonly object _loadLock = new();
private readonly List<DecodeStream> _streams = new();
private long _streamUseCounter;
private bool _openFailed;
private volatile bool _isFailed;
public string Key { get; }
/// <summary>Channel count of the stored samples (same as the clip).</summary>
public int Channels { get; }
/// <summary>Sample rate of the stored samples in Hz (the output sample rate).</summary>
public int Frequency { get; }
/// <summary>Frame count at <see cref="Frequency"/>: ceil(clip frames * Frequency / clip rate).</summary>
public long TotalFrames { get; }
/// <summary>Clip length in seconds: clip frames / clip rate.</summary>
public double Duration { get; }
public int FramesPerChunk { get; }
public int ChunkCount { get; }
public ChunkPolicy Policy { get; }
public bool IsResident => Policy == ChunkPolicy.Resident;
/// <summary>True after loading failed. A failed source returns no chunks.</summary>
public bool IsFailed => _isFailed;
/// <summary>True when every chunk of a resident source is loaded.</summary>
public bool IsFullyLoaded
{
get
{
lock (_sync) return _loaded.Count == ChunkCount;
}
}
/// <summary>Number of chunk loads performed (for diagnostics).</summary>
public int LoadCount { get; private set; }
/// <summary>Decode streams opened so far (for diagnostics).</summary>
public int StreamCount
{
get
{
lock (_loadLock) return _streams.Count;
}
}
/// <param name="store">Store that owns this source.</param>
/// <param name="key">Clip name or path the source is registered under.</param>
/// <param name="reader">First reader; also defines the clip format.</param>
/// <param name="openReader">Opens another reader of the same clip for an additional decode stream, or null when not possible.</param>
/// <param name="outputRate">Output sample rate in Hz.</param>
internal AudioClipSource(AudioClipStore store, string key, IPCMReader reader, Func<IPCMReader?>? openReader, int outputRate)
{
if (reader.Channels <= 0) throw new ArgumentException("Reader channel count must be positive.", nameof(reader));
if (reader.SampleRate <= 0) throw new ArgumentException("Reader sample rate must be positive.", nameof(reader));
if (reader.TotalFrames < 0) throw new ArgumentException("Reader frame count must not be negative.", nameof(reader));
_store = store;
_firstReader = reader;
_openReader = openReader;
Key = key;
Channels = reader.Channels;
Frequency = outputRate;
Duration = (double)reader.TotalFrames / reader.SampleRate;
TotalFrames = CeilDiv(reader.TotalFrames * outputRate, reader.SampleRate);
FramesPerChunk = ChunkFloats / Channels;
ChunkCount = (int)CeilDiv(TotalFrames, FramesPerChunk);
Policy = reader.IsForwardOnly || ChunkCount <= 1 ? ChunkPolicy.Resident : ChunkPolicy.Windowed;
_chunks = new float[ChunkCount][];
_holdCounts = new int[ChunkCount];
_streams.Add(new DecodeStream(reader, outputRate));
}
// ── Cursors ──
/// <summary>Registers a read position. The decoder thread loads the chunks it requires.</summary>
public AudioCursor AddCursor(long position, bool loop)
{
var cursor = new AudioCursor(this, position, loop);
lock (_sync)
{
_cursors.Add(cursor);
}
_store.RequestService();
return cursor;
}
public void RemoveCursor(AudioCursor cursor)
{
lock (_sync)
{
_cursors.Remove(cursor);
}
_store.RequestService();
}
internal void OnCursorMoved()
{
if (!IsResident) _store.RequestService();
}
// ── Consumer access ──
/// <summary>Chunk index containing <paramref name="frame"/> (0 ~ TotalFrames - 1).</summary>
public int ChunkIndexOf(long frame) => (int)(frame / FramesPerChunk);
/// <summary>
/// Holds the chunk containing <paramref name="frame"/> when it is loaded.
/// When it is not loaded, records a load request (kept until the chunk is acquired), wakes the decoder thread and returns false.
/// Every successful call must be paired with <see cref="Release"/>.
/// </summary>
public bool TryAcquire(long frame, out ChunkView view)
{
view = default;
if (_isFailed || frame < 0 || frame >= TotalFrames) return false;
var index = ChunkIndexOf(frame);
lock (_sync)
{
var data = _chunks[index];
if (data != null)
{
_demanded.Remove(index);
_holdCounts[index]++;
view = CreateView(index, data);
return true;
}
_demanded.Add(index);
}
_store.RequestService();
return false;
}
public void Release(in ChunkView view)
{
if (!view.IsValid) return;
lock (_sync)
{
if (_holdCounts[view.ChunkIndex] > 0)
_holdCounts[view.ChunkIndex]--;
}
}
private ChunkView CreateView(int index, float[] data)
{
var start = (long)index * FramesPerChunk;
var count = (int)Math.Min(FramesPerChunk, TotalFrames - start);
return new ChunkView(data, start, count, Channels, index);
}
// ── Decoder thread ──
/// <summary>
/// Windowed: frees loaded chunks that are not required (see <see cref="ChunkPolicy.Windowed"/>) and not held.
/// Resident: does nothing. Decoder thread only.
/// </summary>
internal void FreeUnused()
{
if (IsResident) return;
List<float[]>? freed = null;
lock (_sync)
{
if (_loaded.Count == 0) return;
List<int>? remove = null;
foreach (var index in _loaded)
{
if (_holdCounts[index] > 0 || IsRequired(index)) continue;
(remove ??= new List<int>()).Add(index);
}
if (remove == null) return;
foreach (var index in remove)
{
(freed ??= new List<float[]>()).Add(_chunks[index]!);
_chunks[index] = null;
_loaded.Remove(index);
}
}
foreach (var data in freed!)
_store.ReturnChunkArray(data);
}
/// <summary>
/// Next chunk to load and its priority (lower loads first), or false when nothing is missing.
/// Windowed: demanded chunks, then each cursor's window by slot.
/// Resident: the next chunk in order; priority is <see cref="PriorityDemanded"/> while a consumer is waiting on this source.
/// Decoder thread only.
/// </summary>
internal bool TryGetNextLoad(out int index, out int priority)
{
index = -1;
priority = int.MaxValue;
if (_isFailed) return false;
lock (_sync)
{
if (IsResident)
{
if (_residentLoadedCount >= ChunkCount) return false;
index = _residentLoadedCount;
priority = _demanded.Count > 0 ? PriorityDemanded : PriorityResident;
return true;
}
foreach (var demanded in _demanded)
{
if (_chunks[demanded] != null) continue;
index = demanded;
priority = PriorityDemanded;
return true;
}
foreach (var cursor in _cursors)
{
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
for (var slot = 0; slot < SlotCount; slot++)
{
if (!TryWindowIndex(first, slot, loop, out var candidate)) break;
if (_chunks[candidate] != null) continue;
if (PriorityWindow + slot < priority)
{
index = candidate;
priority = PriorityWindow + slot;
}
break;
}
}
return index >= 0;
}
}
/// <summary>True when a demand or a cursor window covers <paramref name="index"/>. Caller holds _sync.</summary>
private bool IsRequired(int index)
{
if (_demanded.Contains(index)) return true;
foreach (var cursor in _cursors)
{
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
for (var slot = 0; slot < SlotCount; slot++)
{
if (!TryWindowIndex(first, slot, loop, out var candidate)) break;
if (candidate == index) return true;
}
}
return false;
}
private bool TryGetWindowStart(AudioCursor cursor, out int first, out bool loop)
{
var position = cursor.Position;
loop = cursor.Loop;
first = 0;
if (position < 0) position = 0;
if (position >= TotalFrames)
{
if (!loop || TotalFrames == 0) return false;
position %= TotalFrames;
}
first = ChunkIndexOf(position);
return true;
}
private bool TryWindowIndex(int first, int slot, bool loop, out int index)
{
index = first + slot;
if (index < ChunkCount) return true;
if (!loop) return false;
index %= ChunkCount;
return true;
}
/// <summary>
/// Loads chunk <paramref name="index"/> if it is not loaded. Resident sources load only the next chunk in order.
/// Decoder thread only.
/// </summary>
/// <returns>True when this call loaded the chunk.</returns>
internal bool LoadChunk(int index)
{
if (_isFailed || index < 0 || index >= ChunkCount) return false;
lock (_loadLock)
{
lock (_sync)
{
if (_chunks[index] != null) return false;
if (IsResident && index != _residentLoadedCount)
throw new InvalidOperationException($"Resident clip '{Key}' loads chunks in order: expected {_residentLoadedCount}, got {index}.");
}
float[]? data = null;
try
{
var start = (long)index * FramesPerChunk;
var frames = (int)Math.Min(FramesPerChunk, TotalFrames - start);
data = IsResident ? _store.AllocateExactArray(frames * Channels) : _store.RentChunkArray();
var stream = SelectStream(start);
stream.LastUse = ++_streamUseCounter;
stream.Fill(start, frames, data);
}
catch (OutOfMemoryException e)
{
ReturnArray(data);
Fail();
_store.ReportFatal(this, e);
return false;
}
catch (Exception e)
{
ReturnArray(data);
Fail();
_store.ReportSourceFailure(this, e);
return false;
}
lock (_sync)
{
_chunks[index] = data;
_loaded.Add(index);
if (IsResident) _residentLoadedCount++;
}
LoadCount++;
// A resident source never reads again after its last chunk; the reader releases its data now.
if (IsResident && _residentLoadedCount == ChunkCount)
DisposeStreams();
}
_store.NotifyChunkLoaded();
return true;
}
/// <summary>
/// Decode stream for a load starting at output frame <paramref name="start"/>:
/// the stream positioned at <paramref name="start"/>; otherwise a new stream while fewer than <see cref="DecodeStreamLimit"/>
/// are open (windowed sources only); otherwise the least recently used stream. Caller holds _loadLock.
/// </summary>
private DecodeStream SelectStream(long start)
{
DecodeStream? oldest = null;
foreach (var stream in _streams)
{
if (stream.NextOutFrame == start) return stream;
if (oldest == null || stream.LastUse < oldest.LastUse) oldest = stream;
}
if (!IsResident && !_openFailed && _openReader != null && _streams.Count < DecodeStreamLimit)
{
IPCMReader? reader = null;
try
{
reader = _openReader();
if (reader != null)
{
var stream = new DecodeStream(reader, Frequency);
_streams.Add(stream);
return stream;
}
}
catch (Exception e)
{
reader?.Dispose();
Melon<RenderMod>.Logger.Warning($"[AudioClipSource] Could not open another decode stream for '{Key}', reusing open streams: {e.Message}");
}
_openFailed = true;
}
return oldest!;
}
private void Fail()
{
_isFailed = true;
if (IsResident) DisposeStreams();
}
private void DisposeStreams()
{
foreach (var stream in _streams)
stream.Dispose();
_streams.Clear();
}
private void ReturnArray(float[]? data)
{
if (data == null) return;
if (IsResident) _store.FreeExactArray(data);
else _store.ReturnChunkArray(data);
}
/// <summary>Releases all chunk arrays and readers. Called by the store after the decoder thread stopped.</summary>
internal void DisposeInternal()
{
lock (_loadLock)
{
lock (_sync)
{
foreach (var index in _loaded)
{
ReturnArray(_chunks[index]);
_chunks[index] = null;
}
_loaded.Clear();
_cursors.Clear();
_demanded.Clear();
}
DisposeStreams();
_firstReader.Dispose();
}
}
private static long CeilDiv(long a, long b) => (a + b - 1) / b;
}
+337
View File
@@ -0,0 +1,337 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Clip sources of one render, keyed by clip name or path, with a shared pool of chunk arrays and one decoder thread.
///
/// Threads:
/// - Decoder thread (<see cref="Start"/>): the only thread that decodes, resamples, loads and frees chunks.
/// Each step loads one chunk, the one with the lowest priority value over all sources
/// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order).
/// - Consumers (render thread, main thread for the spectrum): <see cref="Acquire"/> waits for the decoder thread and never loads.
/// - Main thread: <see cref="GetOrAdd"/> creates readers (for Unity clips this copies the clip data).
///
/// Memory has no limit; current and peak usage are tracked and logged by <see cref="LogMemoryUsage"/>.
/// An <see cref="OutOfMemoryException"/> while loading is kept in <see cref="FatalError"/> and rethrown by <see cref="Acquire"/>.
/// </summary>
internal sealed class AudioClipStore : IDisposable
{
/// <summary>Longest wait of the decoder thread between checks when nothing signals it, in milliseconds.</summary>
private const int DecoderIdleWaitMs = 50;
/// <summary>Longest wait of a consumer between checks when no chunk is loaded, in milliseconds.</summary>
private const int ConsumerWaitMs = 50;
private readonly Dictionary<string, AudioClipSource?> _sources = new();
private readonly List<AudioClipSource> _sourceList = new();
private readonly Stack<float[]> _chunkPool = new();
private readonly object _poolLock = new();
private readonly object _signal = new();
private readonly AutoResetEvent _serviceRequested = new(false);
private Thread? _decoderThread;
private volatile bool _stopRequested;
private long _allocatedBytes;
private long _peakBytes;
private volatile Exception? _fatalError;
private volatile bool _disposed;
/// <summary>Sample rate every source is resampled to, in Hz.</summary>
public int OutputRate { get; }
/// <summary>Bytes of sample arrays currently allocated (loaded chunks, pooled chunk arrays and copied Unity clip data).</summary>
public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes);
/// <summary>Highest value of <see cref="AllocatedBytes"/> since creation.</summary>
public long PeakBytes => Interlocked.Read(ref _peakBytes);
/// <summary>Out-of-memory error raised while loading, or null.</summary>
public Exception? FatalError => _fatalError;
public AudioClipStore(int outputRate)
{
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
OutputRate = outputRate;
}
// ── Decoder thread ──
/// <summary>Starts the decoder thread. Called once.</summary>
public void Start()
{
if (_decoderThread != null) throw new InvalidOperationException("Decoder thread already started.");
_decoderThread = new Thread(DecoderLoop)
{
Name = "Audio Clip Decoder",
IsBackground = true
};
_decoderThread.Start();
}
private void DecoderLoop()
{
try
{
while (!_stopRequested)
{
if (!LoadNext())
_serviceRequested.WaitOne(DecoderIdleWaitMs);
}
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Decoder thread stopped: {e}");
_fatalError ??= e;
NotifyChunkLoaded();
}
}
/// <summary>
/// Frees unused windowed chunks, then loads the single chunk with the lowest priority value over all sources.
/// Runs on the decoder thread (tests may call it directly while the thread is not started).
/// </summary>
/// <returns>True when a chunk was loaded or a load was attempted.</returns>
internal bool LoadNext()
{
AudioClipSource[] sources;
lock (_sources)
{
if (_disposed) return false;
sources = _sourceList.ToArray();
}
AudioClipSource? best = null;
var bestIndex = -1;
var bestPriority = int.MaxValue;
foreach (var source in sources)
{
source.FreeUnused();
if (!source.TryGetNextLoad(out var index, out var priority) || priority >= bestPriority) continue;
best = source;
bestIndex = index;
bestPriority = priority;
}
if (best == null) return false;
best.LoadChunk(bestIndex);
return true;
}
internal void RequestService() => _serviceRequested.Set();
// ── Sources ──
/// <summary>
/// Returns the source registered under <paramref name="key"/>, creating it with <paramref name="createReader"/> on first use.
/// A key whose reader could not be created is remembered as null and not retried.
/// For readers that can seek, <paramref name="createReader"/> is kept and called again on the decoder thread
/// to open additional decode streams (see <see cref="AudioClipSource.MaxDecodeStreams"/>).
/// </summary>
public AudioClipSource? GetOrAdd(string key, Func<IPCMReader?> createReader)
{
lock (_sources)
{
if (_disposed) return null;
if (_sources.TryGetValue(key, out var existing)) return existing;
}
AudioClipSource? source = null;
try
{
var reader = createReader();
if (reader != null)
{
try
{
source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : createReader, OutputRate);
}
catch
{
reader.Dispose();
throw;
}
}
}
catch (OutOfMemoryException e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while opening clip '{key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {e}");
_fatalError ??= e;
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to open clip '{key}': {e}");
}
lock (_sources)
{
if (_disposed || _sources.TryGetValue(key, out var raced))
{
source?.DisposeInternal();
return _disposed ? null : _sources[key];
}
_sources[key] = source;
if (source != null) _sourceList.Add(source);
}
if (source != null)
{
Melon<RenderMod>.Logger.Msg($"[AudioClipStore] Opened clip '{key}': {source.Channels}ch, {source.Duration:F3}s, {source.TotalFrames} frames at {OutputRate}Hz, {source.ChunkCount} chunk(s), {source.Policy}");
RequestService();
}
return source;
}
public bool TryGet(string key, out AudioClipSource? source)
{
lock (_sources)
{
return _sources.TryGetValue(key, out source);
}
}
// ── Consumers ──
/// <summary>
/// Holds the chunk containing <paramref name="frame"/>, waiting for the decoder thread until it is loaded.
/// Returns an invalid view when the source failed, the frame is outside the source, or the store was disposed.
/// </summary>
/// <exception cref="InvalidOperationException">Loading ran out of memory or the decoder thread stopped.</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was canceled while waiting.</exception>
public ChunkView Acquire(AudioClipSource source, long frame, CancellationToken cancellationToken = default)
{
if (frame < 0 || frame >= source.TotalFrames) return default;
while (true)
{
ThrowIfFatal();
if (source.IsFailed || _disposed) return default;
if (source.TryAcquire(frame, out var view)) return view;
lock (_signal)
{
if (source.TryAcquire(frame, out view)) return view;
if (source.IsFailed || _fatalError != null || _disposed) continue;
Monitor.Wait(_signal, ConsumerWaitMs);
}
cancellationToken.ThrowIfCancellationRequested();
}
}
private void ThrowIfFatal()
{
var error = _fatalError;
if (error != null)
throw new InvalidOperationException("Audio clip loading failed: " + error.Message, error);
}
internal void NotifyChunkLoaded()
{
lock (_signal)
{
Monitor.PulseAll(_signal);
}
}
internal void ReportSourceFailure(AudioClipSource source, Exception error)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}");
NotifyChunkLoaded();
}
internal void ReportFatal(AudioClipSource source, OutOfMemoryException error)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while loading clip '{source.Key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {error}");
_fatalError ??= error;
NotifyChunkLoaded();
}
// ── Arrays ──
internal float[] RentChunkArray()
{
lock (_poolLock)
{
if (_chunkPool.Count > 0) return _chunkPool.Pop();
}
var data = new float[AudioClipSource.ChunkFloats];
AddAllocated(data.LongLength * sizeof(float));
return data;
}
internal void ReturnChunkArray(float[] data)
{
if (_disposed)
{
AddAllocated(-data.LongLength * sizeof(float));
return;
}
lock (_poolLock)
{
_chunkPool.Push(data);
}
}
internal float[] AllocateExactArray(int floats)
{
var data = new float[floats];
AddAllocated(data.LongLength * sizeof(float));
return data;
}
internal void FreeExactArray(float[] data) => AddAllocated(-data.LongLength * sizeof(float));
private void AddAllocated(long bytes)
{
var current = Interlocked.Add(ref _allocatedBytes, bytes);
long peak;
while (current > (peak = Interlocked.Read(ref _peakBytes)))
{
if (Interlocked.CompareExchange(ref _peakBytes, current, peak) == peak) break;
}
}
public void LogMemoryUsage()
{
int sourceCount;
lock (_sources) sourceCount = _sourceList.Count;
Melon<RenderMod>.Logger.Msg($"[AudioClipStore] Clips: {sourceCount}, sample memory now {AllocatedBytes / 1048576.0:F1} MiB, peak {PeakBytes / 1048576.0:F1} MiB");
}
/// <summary>Stops the decoder thread, then releases every source and pooled array.</summary>
public void Dispose()
{
AudioClipSource[] sources;
lock (_sources)
{
if (_disposed) return;
_disposed = true;
sources = _sourceList.ToArray();
}
_stopRequested = true;
_serviceRequested.Set();
NotifyChunkLoaded();
_decoderThread?.Join();
LogMemoryUsage();
lock (_sources)
{
_sourceList.Clear();
_sources.Clear();
}
foreach (var source in sources)
source.DisposeInternal();
lock (_poolLock)
{
while (_chunkPool.Count > 0)
AddAllocated(-_chunkPool.Pop().LongLength * sizeof(float));
}
}
}
+181
View File
@@ -0,0 +1,181 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// One reader plus its resampler, producing output-rate frames of a clip as a continuous stream.
/// Consecutive <see cref="Fill"/> calls whose ranges follow each other continue the stream (reader position and resampler state);
/// any other range starts a new stream near the requested position.
///
/// Restart: the input position is moved back by a preroll and down to a multiple of in_rate / gcd(in_rate, out_rate),
/// where input frame k * (in_rate / gcd) maps exactly to output frame k * (out_rate / gcd);
/// the resampler is reset and output before the requested frame is discarded.
/// Measured with the clip harness: output is bit-identical to one continuous conversion of the whole clip.
/// Used by one thread at a time (the store's decoder thread).
/// </summary>
internal sealed class DecodeStream : IDisposable
{
/// <summary>Input frames read from the reader per resampler call.</summary>
private const int ReadBlockFrames = 4096;
private readonly PCMResampler? _resampler;
private readonly int _channels;
private readonly int _outputRate;
private readonly float[] _readBuffer;
private float[]? _discardBuffer;
private bool _flushing;
private bool _ended;
private readonly long _inputStep;
private readonly long _outputStep;
private readonly long _prerollInputFrames;
public IPCMReader Reader { get; }
/// <summary>Output frame the stream continues at, or -1 when the stream is not positioned.</summary>
public long NextOutFrame { get; private set; } = -1;
/// <summary>Value of the owner's use counter at the last <see cref="Fill"/> (for least-recently-used selection).</summary>
public long LastUse { get; set; }
/// <summary>Number of times the stream started anew (seek + resampler reset).</summary>
public int RestartCount { get; private set; }
public DecodeStream(IPCMReader reader, int outputRate)
{
Reader = reader;
_channels = reader.Channels;
_outputRate = outputRate;
_readBuffer = new float[ReadBlockFrames * _channels];
if (reader.SampleRate != outputRate)
{
_resampler = new PCMResampler(_channels, reader.SampleRate, outputRate);
var gcd = GCD(reader.SampleRate, outputRate);
_inputStep = reader.SampleRate / gcd;
_outputStep = outputRate / gcd;
// History fed before the first kept output frame when a stream starts mid-clip.
// The filter spans about filter_size input frames per output frame at ratios up to 1:1
// and filter_size * (input rate / output rate) input frames when downsampling.
var ratio = Math.Max(1L, (reader.SampleRate + outputRate - 1L) / outputRate);
_prerollInputFrames = 4L * _resampler.FilterSize * ratio + _inputStep;
}
else
{
_inputStep = 1;
_outputStep = 1;
}
}
/// <summary>
/// Writes output frames [start, start + frames) into <paramref name="data"/> and zeros the rest of the array.
/// Frames past the end of the decoded input are zero.
/// </summary>
public unsafe void Fill(long start, int frames, float[] data)
{
var channels = _channels;
var filled = 0;
if (_resampler == null)
{
if (Reader.Position != start)
{
Reader.Seek(start);
RestartCount++;
}
while (filled < frames)
{
var request = Math.Min(ReadBlockFrames, frames - filled);
var read = Reader.Read(_readBuffer, request);
if (read <= 0) break;
Array.Copy(_readBuffer, 0, data, filled * channels, read * channels);
filled += read;
}
Array.Clear(data, filled * channels, data.Length - filled * channels);
NextOutFrame = start + frames;
return;
}
long discard = 0;
if (NextOutFrame != start)
{
// Input frame of the requested output frame, moved back by the preroll and down to a multiple of _inputStep.
var inputFrame = start * Reader.SampleRate / _outputRate - _prerollInputFrames;
var step = inputFrame > 0 ? inputFrame / _inputStep : 0;
_resampler.Reset();
Reader.Seek(step * _inputStep);
_flushing = false;
_ended = false;
discard = start - step * _outputStep;
RestartCount++;
}
fixed (float* pIn = _readBuffer)
fixed (float* pOut = data)
{
while (discard > 0 && !_ended)
{
_discardBuffer ??= new float[ReadBlockFrames * channels];
fixed (float* pDiscard = _discardBuffer)
{
var capacity = (int)Math.Min(ReadBlockFrames, discard);
discard -= Pull(pIn, pDiscard, capacity);
}
}
while (filled < frames && !_ended)
filled += Pull(pIn, pOut + (long)filled * channels, frames - filled);
}
Array.Clear(data, filled * channels, data.Length - filled * channels);
NextOutFrame = _ended ? -1 : start + frames;
}
/// <summary>
/// Writes 1 ~ <paramref name="capacity"/> resampled frames to <paramref name="output"/>, reading input as needed.
/// Returns 0 and sets _ended once the input and the resampler's flushed tail are exhausted.
/// </summary>
private unsafe int Pull(float* input, float* output, int capacity)
{
var resampler = _resampler!;
if (!_flushing)
{
while (true)
{
var produced = resampler.Convert(input, 0, output, capacity);
if (produced > 0) return produced;
var read = Reader.Read(_readBuffer, ReadBlockFrames);
if (read <= 0)
{
_flushing = true;
break;
}
produced = resampler.Convert(input, read, output, capacity);
if (produced > 0) return produced;
}
}
// Input is exhausted: take the resampler's remaining tail until it returns 0.
var flushed = resampler.Flush(output, capacity);
if (flushed > 0) return flushed;
_ended = true;
return 0;
}
public void Dispose()
{
_resampler?.Dispose();
Reader.Dispose();
}
private static int GCD(int a, int b)
{
while (b != 0) (a, b) = (b, a % b);
return a;
}
}
+327
View File
@@ -0,0 +1,327 @@
using System;
using System.Runtime.InteropServices;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// <see cref="IPCMReader"/> that decodes the best audio stream of a file with libavformat/libavcodec.
/// Output keeps the stream's sample rate and channel count; libswresample converts only the sample format to interleaved float.
///
/// Frame positions: frame 0 is the first sample the decoder outputs for the stream's start time.
/// The position of the first frame decoded after opening or seeking is taken from its timestamp
/// (best_effort_timestamp - stream start_time, in samples); following frames are counted sequentially.
/// Seek moves the demuxer to <see cref="SeekMarginSeconds"/> before the target and discards decoded samples before the target.
///
/// <see cref="TotalFrames"/> comes from the stream duration (or the container duration) and may differ from the decoded length;
/// reads stop at whichever ends first.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__decoding.html (send/receive API)
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html (av_read_frame, av_seek_frame)
/// </summary>
internal sealed unsafe class FFmpegPCMReader : IPCMReader
{
/// <summary>
/// Time the demuxer is moved before a seek target, in seconds. Decoders that depend on previous packets
/// (for example the MP3 bit reservoir) produce settled output after this margin; the samples in it are discarded.
/// </summary>
private const double SeekMarginSeconds = 0.25;
private AVFormatContext* _format;
private AVCodecContext* _codec;
private SwrContext* _swr;
private AVPacket* _packet;
private AVFrame* _frame;
private readonly int _streamIndex;
private readonly AVRational _timeBase;
private readonly long _startPts;
// Decoded samples not yet returned: frames [_pendingStart, _pendingStart + _pendingCount) at _pending[_pendingOffset * Channels].
private float[] _pending = Array.Empty<float>();
private int _pendingOffset;
private int _pendingCount;
private long _pendingStart;
private long _nextDecodedFrame; // Frame index of the next decoded frame when counting sequentially
private bool _syncToTimestamp; // True until the first frame after open/seek sets _nextDecodedFrame from its timestamp
private bool _flushSent;
private bool _endOfStream;
public string Path { get; }
public int Channels { get; }
public int SampleRate { get; }
public long TotalFrames { get; }
public bool IsForwardOnly => false;
public long Position { get; private set; }
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
public int SeekCount { get; private set; }
public FFmpegPCMReader(string path)
{
Path = path;
try
{
AVFormatContext* format = null;
var ret = ffmpeg.avformat_open_input(&format, path, null, null);
if (ret < 0) throw new InvalidOperationException($"Failed to open audio file '{path}': {ErrorString(ret)}");
_format = format;
ret = ffmpeg.avformat_find_stream_info(_format, null);
if (ret < 0) throw new InvalidOperationException($"Failed to read stream info of '{path}': {ErrorString(ret)}");
AVCodec* decoder = null;
_streamIndex = ffmpeg.av_find_best_stream(_format, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
if (_streamIndex < 0 || decoder == null) throw new InvalidOperationException($"No decodable audio stream in '{path}': {ErrorString(_streamIndex)}");
for (var i = 0; i < (int)_format->nb_streams; i++)
{
if (i != _streamIndex) _format->streams[i]->discard = AVDiscard.AVDISCARD_ALL;
}
var stream = _format->streams[_streamIndex];
_timeBase = stream->time_base;
_startPts = stream->start_time != ffmpeg.AV_NOPTS_VALUE ? stream->start_time : 0;
_codec = ffmpeg.avcodec_alloc_context3(decoder);
if (_codec == null) throw new InvalidOperationException("avcodec_alloc_context3 failed.");
ret = ffmpeg.avcodec_parameters_to_context(_codec, stream->codecpar);
if (ret < 0) throw new InvalidOperationException($"avcodec_parameters_to_context failed: {ErrorString(ret)}");
_codec->pkt_timebase = _timeBase;
ret = ffmpeg.avcodec_open2(_codec, decoder, null);
if (ret < 0) throw new InvalidOperationException($"Failed to open audio decoder for '{path}': {ErrorString(ret)}");
Channels = _codec->ch_layout.nb_channels;
SampleRate = _codec->sample_rate;
if (Channels <= 0 || SampleRate <= 0) throw new InvalidOperationException($"Invalid audio format in '{path}': {Channels}ch {SampleRate}Hz.");
TotalFrames = EstimateTotalFrames(stream);
SwrContext* swr = null;
ret = ffmpeg.swr_alloc_set_opts2(&swr,
&_codec->ch_layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, SampleRate,
&_codec->ch_layout, _codec->sample_fmt, SampleRate, 0, null);
if (ret < 0 || swr == null) throw new InvalidOperationException($"swr_alloc_set_opts2 failed: {ErrorString(ret)}");
_swr = swr;
ret = ffmpeg.swr_init(_swr);
if (ret < 0) throw new InvalidOperationException($"swr_init failed: {ErrorString(ret)}");
_packet = ffmpeg.av_packet_alloc();
_frame = ffmpeg.av_frame_alloc();
if (_packet == null || _frame == null) throw new InvalidOperationException("Failed to allocate packet/frame.");
_syncToTimestamp = true;
}
catch
{
Dispose();
throw;
}
}
private long EstimateTotalFrames(AVStream* stream)
{
if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0)
return ffmpeg.av_rescale_q(stream->duration, _timeBase, new AVRational { num = 1, den = SampleRate });
if (_format->duration != ffmpeg.AV_NOPTS_VALUE && _format->duration > 0)
return ffmpeg.av_rescale(_format->duration, SampleRate, ffmpeg.AV_TIME_BASE);
throw new InvalidOperationException($"Audio file '{Path}' has no duration.");
}
public void Seek(long frame)
{
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
if (frame == Position) return;
var marginFrames = (long)(SeekMarginSeconds * SampleRate);
// Target inside the pending block or less than the seek margin after it: decode forward and discard.
if (!_syncToTimestamp && frame >= _pendingStart + _pendingOffset
&& frame <= _pendingStart + _pendingOffset + _pendingCount + marginFrames)
{
Position = frame;
return;
}
var targetTs = _startPts + ffmpeg.av_rescale_q(Math.Max(0, frame - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase);
var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
ret = ffmpeg.av_seek_frame(_format, _streamIndex, _startPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
throw new InvalidOperationException($"Failed to seek '{Path}' to frame {frame}: {ErrorString(ret)}");
ffmpeg.avcodec_flush_buffers(_codec);
SeekCount++;
_pendingCount = 0;
_pendingOffset = 0;
_syncToTimestamp = true;
_flushSent = false;
_endOfStream = false;
Position = frame;
}
public int Read(float[] buffer, int frameCount)
{
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
var written = 0;
while (written < limit)
{
if (_pendingCount == 0 || Position >= _pendingStart + _pendingOffset + _pendingCount)
{
if (!DecodeNextFrame()) break;
continue;
}
var pendingPosition = _pendingStart + _pendingOffset;
if (Position > pendingPosition)
{
// Discard decoded samples before the read position (after a seek).
var skip = (int)Math.Min(Position - pendingPosition, _pendingCount);
_pendingOffset += skip;
_pendingCount -= skip;
continue;
}
if (Position < pendingPosition)
{
// A gap in the decoded timestamps: fill with silence up to the next decoded sample.
var gap = (int)Math.Min(pendingPosition - Position, limit - written);
Array.Clear(buffer, written * Channels, gap * Channels);
written += gap;
Position += gap;
continue;
}
var count = Math.Min(_pendingCount, limit - written);
Array.Copy(_pending, _pendingOffset * Channels, buffer, written * Channels, count * Channels);
_pendingOffset += count;
_pendingCount -= count;
written += count;
Position += count;
}
return written;
}
/// <summary>Decodes the next audio frame into the pending buffer. Returns false at the end of the stream.</summary>
private bool DecodeNextFrame()
{
while (!_endOfStream)
{
var ret = ffmpeg.avcodec_receive_frame(_codec, _frame);
if (ret == 0)
{
StoreFrame();
ffmpeg.av_frame_unref(_frame);
return true;
}
if (ret == ffmpeg.AVERROR_EOF)
{
_endOfStream = true;
break;
}
if (ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
throw new InvalidOperationException($"Audio decoding failed in '{Path}': {ErrorString(ret)}");
if (_flushSent)
{
_endOfStream = true;
break;
}
ret = ffmpeg.av_read_frame(_format, _packet);
if (ret < 0)
{
if (ret != ffmpeg.AVERROR_EOF)
throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}");
ffmpeg.avcodec_send_packet(_codec, null);
_flushSent = true;
continue;
}
try
{
if (_packet->stream_index != _streamIndex) continue;
ret = ffmpeg.avcodec_send_packet(_codec, _packet);
// A corrupt packet is skipped; decoding continues with the next one.
if (ret < 0 && ret != ffmpeg.AVERROR_INVALIDDATA && ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
throw new InvalidOperationException($"Failed to send audio packet of '{Path}': {ErrorString(ret)}");
}
finally
{
ffmpeg.av_packet_unref(_packet);
}
}
return false;
}
private void StoreFrame()
{
var samples = _frame->nb_samples;
if (samples <= 0) return;
if (_syncToTimestamp)
{
var pts = _frame->best_effort_timestamp;
_nextDecodedFrame = pts != ffmpeg.AV_NOPTS_VALUE
? ffmpeg.av_rescale_q(pts - _startPts, _timeBase, new AVRational { num = 1, den = SampleRate })
: Position;
_syncToTimestamp = false;
}
if (_pending.Length < samples * Channels)
_pending = new float[samples * Channels];
fixed (float* pPending = _pending)
{
var outPlane = (byte*)pPending;
var converted = ffmpeg.swr_convert(_swr, &outPlane, samples, _frame->extended_data, samples);
if (converted < 0)
throw new InvalidOperationException($"Sample format conversion failed in '{Path}': {ErrorString(converted)}");
samples = converted;
}
_pendingStart = _nextDecodedFrame;
_pendingOffset = 0;
_pendingCount = samples;
_nextDecodedFrame += samples;
}
public void Dispose()
{
if (_frame != null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_packet != null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_swr != null)
{
var swr = _swr;
ffmpeg.swr_free(&swr);
_swr = null;
}
if (_codec != null)
{
var codec = _codec;
ffmpeg.avcodec_free_context(&codec);
_codec = null;
}
if (_format != null)
{
var format = _format;
ffmpeg.avformat_close_input(&format);
_format = null;
}
}
private static string ErrorString(int error)
{
var buffer = stackalloc byte[1024];
ffmpeg.av_strerror(error, buffer, 1024);
return $"{Marshal.PtrToStringAnsi((IntPtr)buffer)} ({error})";
}
}
+42
View File
@@ -0,0 +1,42 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Sequential reader of decoded PCM at the clip's own sample rate and channel count.
/// Samples are interleaved 32-bit float. Positions and counts are in frames (one frame = one sample per channel).
/// Used by one thread at a time (the store's decoder thread). <see cref="IDisposable.Dispose"/> may be called more than once.
/// </summary>
internal interface IPCMReader : IDisposable
{
/// <summary>Channel count of the decoded audio (1 or more).</summary>
int Channels { get; }
/// <summary>Sample rate of the decoded audio in Hz.</summary>
int SampleRate { get; }
/// <summary>Total frame count of the clip at <see cref="SampleRate"/>.</summary>
long TotalFrames { get; }
/// <summary>
/// True when the data can be read only once, from frame 0 to the end (data is released as it is read).
/// The source then keeps every chunk (<see cref="ChunkPolicy.Resident"/>).
/// </summary>
bool IsForwardOnly { get; }
/// <summary>Frame index that the next <see cref="Read"/> starts at.</summary>
long Position { get; }
/// <summary>
/// Moves the read position to <paramref name="frame"/> (0 ~ <see cref="TotalFrames"/>).
/// A forward-only reader accepts only frames whose data has not been released.
/// </summary>
void Seek(long frame);
/// <summary>
/// Reads up to <paramref name="frameCount"/> frames into <paramref name="buffer"/> starting at index 0 and advances the position.
/// <paramref name="buffer"/> must hold at least frameCount * Channels floats.
/// </summary>
/// <returns>Frames read. 0 at the end of the clip.</returns>
int Read(float[] buffer, int frameCount);
}
+83
View File
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Forward-only <see cref="IPCMReader"/> over PCM held in memory as a list of blocks.
/// Block b holds frames [b * FramesPerBlock, (b + 1) * FramesPerBlock) interleaved; the last block may be shorter.
/// Each block is released (passed to the release callback and dropped) as soon as the read position passes its end,
/// and all remaining blocks are released on <see cref="Dispose"/>.
/// </summary>
internal sealed class MemoryPCMReader : IPCMReader
{
private readonly float[]?[] _blocks;
private readonly Action<float[]>? _onBlockReleased;
private int _releasedCount; // Blocks [0, _releasedCount) are released
public int Channels { get; }
public int SampleRate { get; }
public long TotalFrames { get; }
public bool IsForwardOnly => true;
public long Position { get; private set; }
/// <summary>Frames per block (all blocks except the last).</summary>
public int FramesPerBlock { get; }
public MemoryPCMReader(IReadOnlyList<float[]> blocks, int framesPerBlock, int channels, int sampleRate, long totalFrames,
Action<float[]>? onBlockReleased)
{
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
if (framesPerBlock <= 0) throw new ArgumentOutOfRangeException(nameof(framesPerBlock));
if (totalFrames > (long)blocks.Count * framesPerBlock) throw new ArgumentException("Blocks hold fewer frames than totalFrames.", nameof(blocks));
_blocks = new float[blocks.Count][];
for (var i = 0; i < blocks.Count; i++) _blocks[i] = blocks[i];
_onBlockReleased = onBlockReleased;
FramesPerBlock = framesPerBlock;
Channels = channels;
SampleRate = sampleRate;
TotalFrames = totalFrames;
}
public void Seek(long frame)
{
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
if (frame < (long)_releasedCount * FramesPerBlock)
throw new InvalidOperationException($"Frame {frame} was already released by the forward-only reader.");
Position = frame;
}
public int Read(float[] buffer, int frameCount)
{
var total = (int)Math.Min(frameCount, TotalFrames - Position);
if (total <= 0) return 0;
var written = 0;
while (written < total)
{
var blockIndex = (int)(Position / FramesPerBlock);
var block = _blocks[blockIndex] ?? throw new InvalidOperationException($"Block {blockIndex} was already released.");
var offset = (int)(Position - (long)blockIndex * FramesPerBlock);
var count = Math.Min(total - written, FramesPerBlock - offset);
Array.Copy(block, offset * Channels, buffer, written * Channels, count * Channels);
written += count;
Position += count;
ReleaseBlocksBefore((int)(Position / FramesPerBlock));
}
return written;
}
private void ReleaseBlocksBefore(int blockIndex)
{
while (_releasedCount < blockIndex && _releasedCount < _blocks.Length)
{
var block = _blocks[_releasedCount];
_blocks[_releasedCount] = null;
_releasedCount++;
if (block != null) _onBlockReleased?.Invoke(block);
}
}
public void Dispose() => ReleaseBlocksBefore(_blocks.Length);
}
+106
View File
@@ -0,0 +1,106 @@
using System;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Sample rate converter for interleaved 32-bit float PCM (libswresample).
/// Input and output have the same channel count; only the sample rate changes.
/// One instance keeps filter state between calls, so consecutive <see cref="Convert"/> calls form one continuous stream.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html
/// </summary>
internal sealed unsafe class PCMResampler : IDisposable
{
private SwrContext* _swr;
private bool _flushed;
public int Channels { get; }
public int InputRate { get; }
public int OutputRate { get; }
/// <summary>Value of the SwrContext "filter_size" option (filter length per phase in taps at a 1:1 ratio).</summary>
public int FilterSize { get; }
public PCMResampler(int channels, int inputRate, int outputRate)
{
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
if (inputRate <= 0) throw new ArgumentOutOfRangeException(nameof(inputRate));
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
Channels = channels;
InputRate = inputRate;
OutputRate = outputRate;
AVChannelLayout layout;
ffmpeg.av_channel_layout_default(&layout, channels);
SwrContext* swr = null;
var ret = ffmpeg.swr_alloc_set_opts2(
&swr,
&layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, outputRate,
&layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, inputRate,
0, null);
ffmpeg.av_channel_layout_uninit(&layout);
if (ret < 0 || swr == null)
throw new InvalidOperationException($"swr_alloc_set_opts2 failed ({ret}).");
_swr = swr;
long filterSize;
FilterSize = ffmpeg.av_opt_get_int(_swr, "filter_size", 0, &filterSize) >= 0 ? (int)filterSize : 32;
Reset();
}
/// <summary>Discards all buffered input and filter history. The next call starts a new stream.</summary>
public void Reset()
{
// swr_init on an initialized context re-initializes it.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html (swr_init, swr_close)
var ret = ffmpeg.swr_init(_swr);
if (ret < 0)
throw new InvalidOperationException($"swr_init failed ({ret}).");
_flushed = false;
}
/// <summary>
/// Feeds <paramref name="inputFrames"/> frames and writes up to <paramref name="outputCapacity"/> frames.
/// Input that does not fit in the output is buffered inside the context and returned by later calls.
/// <paramref name="inputFrames"/> may be 0 to take only buffered output.
/// </summary>
/// <returns>Frames written to <paramref name="output"/>.</returns>
public int Convert(float* input, int inputFrames, float* output, int outputCapacity)
{
if (_flushed) throw new InvalidOperationException("Resampler was flushed. Call Reset first.");
// swr_convert treats a null input array as a flush request, so a non-null pointer is passed even for 0 frames.
float dummy = 0;
var inPlane = (byte*)(input != null ? input : &dummy);
var outPlane = (byte*)output;
var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, &inPlane, inputFrames);
if (ret < 0)
throw new InvalidOperationException($"swr_convert failed ({ret}).");
return ret;
}
/// <summary>
/// Ends the input stream and writes up to <paramref name="outputCapacity"/> remaining frames.
/// Call repeatedly until it returns 0. <see cref="Convert"/> is not allowed afterwards until <see cref="Reset"/>.
/// </summary>
public int Flush(float* output, int outputCapacity)
{
_flushed = true;
var outPlane = (byte*)output;
var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, null, 0);
if (ret < 0)
throw new InvalidOperationException($"swr_convert (flush) failed ({ret}).");
return ret;
}
public void Dispose()
{
if (_swr == null) return;
var swr = _swr;
ffmpeg.swr_free(&swr);
_swr = null;
}
}
+55
View File
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Copies a Unity <see cref="AudioClip"/> into memory for clips without a file on disk
/// (built-in hit sounds from Resources, official/DLC level audio).
/// <see cref="AudioClip.GetData(float[], int)"/> runs on the main thread only, so the whole clip is copied once on the main thread
/// and the decoder thread reads the copy through a <see cref="MemoryPCMReader"/>.
/// Streaming clips (<see cref="AudioClipLoadType.Streaming"/>) are rejected because GetData does not read them.
/// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html
/// </summary>
internal static class UnityClipLoader
{
/// <summary>
/// Copies <paramref name="clip"/> in blocks of <see cref="AudioClipSource.ChunkFloats"/> floats or less
/// (FramesPerBlock = ChunkFloats / channels frames per GetData call). Main thread only.
/// Block memory is counted in <paramref name="store"/> and released block by block as the decoder thread reads it.
/// </summary>
public static MemoryPCMReader Load(AudioClip clip, AudioClipStore store)
{
if (!clip) throw new ArgumentNullException(nameof(clip));
if (clip.loadType == AudioClipLoadType.Streaming)
throw new NotSupportedException($"AudioClip '{clip.name}' is a streaming clip; GetData cannot read it.");
if (clip.loadState == AudioDataLoadState.Unloaded)
clip.LoadAudioData();
var channels = clip.channels;
var totalFrames = (long)clip.samples;
var framesPerBlock = AudioClipSource.ChunkFloats / channels;
var blocks = new List<float[]>();
try
{
for (long start = 0; start < totalFrames; start += framesPerBlock)
{
var frames = (int)Math.Min(framesPerBlock, totalFrames - start);
var block = store.AllocateExactArray(frames * channels);
blocks.Add(block);
// GetData fills the whole array; the offset is in frames (samples per channel).
if (!clip.GetData(block, (int)start))
throw new InvalidOperationException($"AudioClip.GetData failed for '{clip.name}' at frame {start} (load state {clip.loadState}).");
}
}
catch
{
foreach (var block in blocks) store.FreeExactArray(block);
throw;
}
return new MemoryPCMReader(blocks, framesPerBlock, channels, clip.frequency, totalFrames, store.FreeExactArray);
}
}
File diff suppressed because it is too large. Load diff
+391 -163
View File
@@ -18,12 +18,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// 2. Start(outputPath): /// 2. Start(outputPath):
/// a) Create AVFormatContext for output container (mp4, etc.) /// a) Create AVFormatContext for output container (mp4, etc.)
/// b) Add video stream (AVStream) /// b) Add video stream (AVStream)
/// c) Try hardware encoders (NVENC → AMF → QSV) → software encoders (libx264, etc.) /// c) Try the candidate encoders of the codec in order and select the first one that opens
/// in order to select an available encoder /// (encoders of the active GPU vendor → other hardware encoders → software encoders; see GetEncoderCandidates)
/// d) Write file header and initialize frame/scaler buffers /// d) Write file header and initialize frame/scaler buffers
/// ///
/// 3. WriteFrame*(): Called for every frame /// 3. WriteFrame*(): Called for every frame
/// a) Convert Unity RGBA original frame to NV12/YUV420P using sws_scale /// a) Pre-converted NV12/YUV420P frame (GPU compute shader output): copied into the encoder frame
/// RGBA frame: converted to the encoder pixel format with sws_scale (CPU)
/// b) (In QSV hw_frames mode) Upload to GPU surface /// b) (In QSV hw_frames mode) Upload to GPU surface
/// c) avcodec_send_frame → avcodec_receive_packet → Write to file /// c) avcodec_send_frame → avcodec_receive_packet → Write to file
/// ///
@@ -63,7 +64,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
public AVCodecID AudioCodecId; public AVCodecID AudioCodecId;
/// <summary> /// <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. /// For AAC, 128000–320000 bps is common.
/// </summary> /// </summary>
public int Bitrate; public int Bitrate;
@@ -83,20 +84,20 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary> /// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary>
public double FPS; 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; public Codec Codec;
/// <summary>Bitrate control mode (CQP, CBR, VBR).</summary> /// <summary>Bitrate control mode (CQP, CBR, VBR).</summary>
public RateControlMode Mode; public RateControlMode Mode;
/// <summary>Quality value used in CQP mode. Lower values mean higher quality. (e.g., H.264 CRF 18 ≈ visually lossless)</summary> /// <summary>Quality value used in CQP mode. Lower values mean higher quality. (e.g., H.264 CRF 18 ≈ visually lossless)</summary>
public int QualityValue; // CRF public int QualityValue;
/// <summary>Target bitrate in VBR/CBR mode (in kbps). Converted to bps internally for FFmpeg.</summary> /// <summary>Target bitrate in VBR/CBR mode (in kbps). Converted to bps internally for FFmpeg.</summary>
public int TargetBitrate; // VBR public int TargetBitrate;
/// <summary>Maximum bitrate in VBR mode (in kbps). Determines peak quality limit.</summary> /// <summary>Maximum bitrate in VBR mode (in kbps). Determines peak quality limit.</summary>
public int MaxBitrate; // VBR public int MaxBitrate;
/// <summary>GOP (Group of Pictures) size (in seconds). Represents keyframe interval.</summary> /// <summary>GOP (Group of Pictures) size (in seconds). Represents keyframe interval.</summary>
public double GopSize; public double GopSize;
@@ -165,7 +166,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// <summary>Accumulated index of encoded frames. Used as the PTS (Presentation Time Stamp) value.</summary> /// <summary>Accumulated index of encoded frames. Used as the PTS (Presentation Time Stamp) value.</summary>
private long _frameIndex; private long _frameIndex;
/// <summary>Whether Start() completed successfully and encoding is ready.</summary> /// <summary>
/// Whether the encoder accepts frames: set to true when Start() completes,
/// and to false when End() runs (End() is not repeated) or Dispose() releases the resources.
/// </summary>
private bool _isInitialized; private bool _isInitialized;
/// <summary>Whether Dispose() has already been called. Prevents duplicate cleanup.</summary> /// <summary>Whether Dispose() has already been called. Prevents duplicate cleanup.</summary>
@@ -182,9 +186,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
private bool _isQsvEncoder; private bool _isQsvEncoder;
/// <summary> /// <summary>
/// Target pixel format for the encoder. /// Pixel format of the frames passed to the encoder (output of the color conversion).
/// - General encoders (NVENC, AMF, Software): YUV420P (most common 4:2:0 chroma subsampling) /// - Hardware encoders (NVENC, AMF, QSV): NV12 (Y plane + UV interleaved plane)
/// - QSV encoder: NV12 (Y plane + UV interleaved plane, preferred format for Intel hardware) /// - libvvenc: YUV420P10LE (10-bit, 3 planes)
/// - Other software encoders: YUV420P (3 planes Y/U/V, 4:2:0 chroma subsampling)
/// ///
/// Reference - Major pixel format relationships: /// Reference - Major pixel format relationships:
/// RGBA (Unity source, 4-channel 8-bit) /// RGBA (Unity source, 4-channel 8-bit)
@@ -285,7 +290,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// <summary> /// <summary>
/// Returns a list of candidate encoders to try for the selected codec in priority order. /// Returns a list of candidate encoders to try for the selected codec in priority order.
/// ///
/// Priority: Hardware encoders (NVENC → AMF → QSV) → Software encoders (libx264, etc.) /// Order (GPUUtil.GetPrioritizedEncoders):
/// 1. Hardware encoders of the active GPU vendor
/// 2. The remaining encoders in the declaration order of VideoEncoder.All
/// (per codec: NVENC → QSV → AMF → VideoToolbox → software, for the encoders the codec has)
/// With ForceSoftware, only the software encoders are returned.
/// ///
/// - nvenc: Hardware encoder dedicated to NVIDIA GPUs (CUDA-based) /// - nvenc: Hardware encoder dedicated to NVIDIA GPUs (CUDA-based)
/// - amf: Hardware encoder dedicated to AMD GPUs (Advanced Media Framework) /// - amf: Hardware encoder dedicated to AMD GPUs (Advanced Media Framework)
@@ -299,6 +308,23 @@ namespace ADOFAIRenderer.Util.FFmpeg
return GPUUtil.GetPrioritizedEncoders(_settings.Codec, _settings.ForceSoftware).ToArray(); 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> /// <summary>
/// Converts an FFmpeg error code (negative integer) to a human-readable string. /// Converts an FFmpeg error code (negative integer) to a human-readable string.
/// e.g., -1 → "Operation not permitted" /// e.g., -1 → "Operation not permitted"
@@ -367,7 +393,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
var candidates = GetEncoderCandidates(); var candidates = GetEncoderCandidates();
var openedSuccessfully = false; 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"). // 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. // Returns null if the encoder is not included in the build.
@@ -399,11 +425,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
// ── Pixel format configuration ── // ── Pixel format configuration ──
// Video encoders generally expect input in YUV420P or NV12 format. // Video encoders generally expect input in YUV420P or NV12 format.
// //
// • YUV420P: 3 independent planes Y/U/V. Used by most software encoders. // • YUV420P: 3 independent planes Y/U/V. Used by the software encoders (libvvenc: YUV420P10LE).
// • NV12: Y plane + UV interleaved plane. Preferred by hardware encoders (NVENC, AMF, QSV). // • NV12: Y plane + UV interleaved plane. Used by the hardware encoders (NVENC, AMF, QSV).
// • QSV Special Note: pix_fmt of codec context is AV_PIX_FMT_QSV (hardware surface identifier), // • QSV: the converted frames are NV12 (selectedPixFmt). The codec context pix_fmt set here is
// while actual sws_scale output is set to NV12 (software format). // AV_PIX_FMT_QSV and is replaced in the QSV device setup below according to the input mode:
// FFmpeg internally handles mapping between NV12 ↔ QSV surfaces. // system memory input mode: pix_fmt = NV12, the encoder receives the NV12 frames directly.
// GPU frame pool input mode: pix_fmt stays AV_PIX_FMT_QSV, and SubmitDstFrame() uploads
// each NV12 frame to a QSV surface with av_hwframe_transfer_data.
// //
// Reference: https://ffmpeg.org/doxygen/trunk/pixfmt_8h.html // Reference: https://ffmpeg.org/doxygen/trunk/pixfmt_8h.html
var selectedPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P; var selectedPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P;
@@ -437,16 +465,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
// framerate: Framerate value giving hints to the encoder (reciprocal of time_base). // framerate: Framerate value giving hints to the encoder (reciprocal of time_base).
// //
// RationalUtil: Converts FPS (double) into fractional form (numerator/denominator). // 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 // Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS); 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 _pCodecContext->time_base = new AVRational
{ num = fpsRational.Denominator, den = fpsRational.Numerator }; { num = fpsRational.Denominator, den = fpsRational.Numerator };
_pCodecContext->framerate = new AVRational _pCodecContext->framerate = new AVRational
@@ -467,17 +490,21 @@ namespace ADOFAIRenderer.Util.FFmpeg
_pCodecContext->gop_size = calculatedGop; _pCodecContext->gop_size = calculatedGop;
} }
// ── Disable B-frames ── // ── B-frame configuration ──
// B-frames (Bidirectional predictive frames): Increase compression ratio by referencing before/after frames, // B-frames (bidirectional predictive frames) reference both earlier and later frames.
// but increase encoding latency and may cause compatibility issues on some encoders. // They raise the compression ratio and make the encoder output packets out of presentation order
// In real-time recording scenarios, disable them to minimize latency. // (dts < pts for reordered frames).
// //
// Reference: has_b_frames is a read-only field internally set by the encoder, // Configuration per encoder:
// so do not set it directly. // SVT-AV1: svtav1-params "pred-struct=1" (low delay prediction structure).
// Setting max_b_frames on AVCodecContext makes SVT-AV1 report a "bad parameter" error.
// libvvenc: max_b_frames = -1 (encoder default GOP structure).
// Others: max_b_frames = 0 (no B-frames, frames are encoded in presentation order).
// //
// SVT-AV1 Special Note: Setting max_b_frames directly on AVCodecContext // has_b_frames is set by the encoder itself and is not written here.
// causes a "bad parameter" error, so control via svtav1-params. //
// pred-struct=1: Low Delay (IPPP) structure disabling B-frames. // Reference: https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Parameters.md
// (PredStructure: 0 = all intra, 1 = low delay, 2 = random access, default 2)
if (encoder == VideoEncoder.LibSvtAv1) if (encoder == VideoEncoder.LibSvtAv1)
{ {
ffmpeg.av_opt_set(_pCodecContext->priv_data, "svtav1-params", "pred-struct=1", 0); ffmpeg.av_opt_set(_pCodecContext->priv_data, "svtav1-params", "pred-struct=1", 0);
@@ -519,9 +546,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
// Strategy 3) Create QSV device directly (legacy method) // Strategy 3) Create QSV device directly (legacy method)
// → Fallback on older drivers/systems // → Fallback on older drivers/systems
// //
// After obtaining device, attempt using GPU frame pool: // After obtaining device, the input mode is configured (see ExpandQsvInputModes):
// hw_frames_ctx: Pre-allocate frame pool in GPU VRAM (zero-copy, highest performance) // Input mode 1, hw_device_ctx: Encoder receives NV12 frames from system memory (attempted first)
// hw_device_ctx: Encoder reads frames directly from system memory (better compatibility) // 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 // Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
if (encoder.Kind == EncoderKind.QSV) if (encoder.Kind == EncoderKind.QSV)
@@ -590,68 +618,104 @@ namespace ADOFAIRenderer.Util.FFmpeg
continue; continue;
} }
// ── Attempt hw_frames_ctx: Pre-create GPU frame pool ── if (!useFramePool)
// AVHWFramesContext pre-allocates frame pool in GPU VRAM,
// eliminating memory allocation/deallocation overhead for each frame.
//
// 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)
//
// 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.
//
// Reference:
// AVHWFramesContext: https://ffmpeg.org/doxygen/trunk/structAVHWFramesContext.html
// av_hwframe_ctx_alloc: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
bool framesCtxReady = false;
AVBufferRef* hwFramesRef = ffmpeg.av_hwframe_ctx_alloc(hwDeviceCtx);
if (hwFramesRef != null)
{
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
err = ffmpeg.av_hwframe_ctx_init(hwFramesRef);
if (err >= 0)
{
// Success: Connect hw_frames_ctx to codec context.
// Increment ref count with av_buffer_ref to keep valid after hwFramesRef is released.
_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)");
_pHwFrame = ffmpeg.av_frame_alloc();
if (_pHwFrame == null)
throw new Exception("Failed to allocate QSV hardware frame structure.");
}
else
{
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: hw_frames_ctx failed — {GetFFmpegErrorString(err)} (Code: {err}). Falling back to hw_device_ctx mode.");
}
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)
{ {
// ── 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.
//
// 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->pix_fmt = AVPixelFormat.AV_PIX_FMT_NV12;
_pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx); _pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx);
_isQsvEncoder = false; // System memory mode, no manual frame upload required _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).
//
// 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;
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 = QsvFramePoolSize;
SetQsvFrameType(framesCtx, frameType);
err = ffmpeg.av_hwframe_ctx_init(hwFramesRef);
if (err >= 0)
{
// Success: Connect hw_frames_ctx to codec context.
// Increment ref count with av_buffer_ref to keep valid after hwFramesRef is released.
_pCodecContext->hw_frames_ctx = ffmpeg.av_buffer_ref(hwFramesRef);
_isQsvEncoder = true;
framesCtxReady = true;
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;
}
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);
}
if (!framesCtxReady)
{
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, // Release the local reference of hwDeviceCtx.
// so release local reference (reference count management). // The device stays alive through the references taken above:
// system memory input mode: codec context hw_device_ctx (av_buffer_ref)
// GPU frame pool input mode: the frames context (av_hwframe_ctx_alloc keeps a device reference),
// held by codec context hw_frames_ctx
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__buffer.html // Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__buffer.html
ffmpeg.av_buffer_unref(&hwDeviceCtx); ffmpeg.av_buffer_unref(&hwDeviceCtx);
} }
@@ -709,11 +773,19 @@ namespace ADOFAIRenderer.Util.FFmpeg
break; break;
} }
// If open fails, free codec context and try next encoder. // If open fails, free codec context and QSV frame state, then try next candidate.
Melon<RenderMod>.Logger.Warning($"Failed to initialize hardware encoder {encoder.Name}: {GetFFmpegErrorString(openResult)} (Code: {openResult}). Testing next candidate codec."); 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; var pCodecCtx = _pCodecContext;
ffmpeg.avcodec_free_context(&pCodecCtx); ffmpeg.avcodec_free_context(&pCodecCtx);
_pCodecContext = null; _pCodecContext = null;
_isQsvEncoder = false;
if (_pHwFrame != null)
{
var pHwFrame = _pHwFrame;
ffmpeg.av_frame_free(&pHwFrame);
_pHwFrame = null;
}
} }
if (!openedSuccessfully || _pCodecContext == null) if (!openedSuccessfully || _pCodecContext == null)
@@ -728,6 +800,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0) if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0)
throw new Exception("Failed to copy codec parameters to stream."); 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 ── // ── Step 4.5 (Optional): Initialize audio stream ──
// If EncoderSettings.Audio is set, add audio stream. // If EncoderSettings.Audio is set, add audio stream.
if (_settings.Audio.HasValue) if (_settings.Audio.HasValue)
@@ -760,16 +842,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (_pSrcFrame == null || _pDstFrame == null || _pPacket == null) if (_pSrcFrame == null || _pDstFrame == null || _pPacket == null)
throw new Exception("Failed to allocate frame or packet structure."); throw new Exception("Failed to allocate frame or packet structure.");
// Set format and size of destination frame, then allocate actual pixel buffer. AllocateDstFrameBuffer();
// 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.");
// sws_getContext: Initialize software scaler (colorspace converter). // sws_getContext: Initialize software scaler (colorspace converter).
// Input: RGBA (Unity capture format, 4 bytes/pixel) // Input: RGBA (Unity capture format, 4 bytes/pixel)
@@ -778,8 +851,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
// Colorspace is converted without resolution change here, but sws_scale // Colorspace is converted without resolution change here, but sws_scale
// can also perform resizing simultaneously. // can also perform resizing simultaneously.
// //
// SWS_POINT: Nearest neighbor interpolation. Since only colorspace is converted without resolution change, // SWS_POINT: Nearest neighbor sampling. The luma plane keeps the source resolution (1:1).
// interpolation algorithm does not affect output quality, and SWS_POINT is fastest. // The chroma planes are subsampled to half width and height (4:2:0) with the same filter,
// so each chroma sample takes one source pixel instead of averaging the 2x2 block.
// This scaler is used only by the CPU conversion path (WriteFrame with RGBA input).
// //
// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html // Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
_pSwsContext = ffmpeg.sws_getContext( _pSwsContext = ffmpeg.sws_getContext(
@@ -830,7 +905,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// Reference - CQP options per encoder: /// Reference - CQP options per encoder:
/// NVENC: "qp" (single value applied to I/P/B frames) /// NVENC: "qp" (single value applied to I/P/B frames)
/// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately) /// 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) /// Software: "crf" (Constant Rate Factor, smarter quality control)
/// ///
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html /// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
@@ -852,11 +927,21 @@ namespace ADOFAIRenderer.Util.FFmpeg
_pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L; _pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L;
_pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000; _pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000;
// nal-hrd=cbr: x264/x265 dedicated option. Generates CBR bitstream matching // HRD (Hypothetical Reference Decoder) signaling for CBR output, used for streaming compatibility.
// NAL HRD (Hypothetical Reference Decoder) specification. May be required for streaming compatibility. // libx264: "nal-hrd=cbr" writes the NAL HRD information and pads filler data to keep the bitrate constant.
if (encoder == VideoEncoder.Libx264 || encoder == VideoEncoder.Libx265) // 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; break;
@@ -880,38 +965,77 @@ namespace ADOFAIRenderer.Util.FFmpeg
break; break;
case RateControlMode.CQP: 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. // Default value 18: Quality close to "visually lossless" in H.264.
// Provides similar level of quality in other codecs as well. // 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) if (encoder.Kind == EncoderKind.NVENC)
{ {
// NVENC: Applies same QP to I/P/B frames with single "qp" option // 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) else if (encoder.Kind == EncoderKind.AMF)
{ {
// AMF: QP for I-frame (qp_i) and P-frame (qp_p) must be set separately. // 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. // Since B-frames are disabled, qp_b is not set.
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_i", qpValue.ToString(), 0); TrySetOption("qp_i", qpValue.ToString());
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_p", qpValue.ToString(), 0); TrySetOption("qp_p", qpValue.ToString());
} }
else if (encoder.Kind == EncoderKind.QSV) else if (encoder.Kind == EncoderKind.QSV)
{ {
// QSV: Uses ICQ (Intelligent Constant Quality) mode with "global_quality" option // QSV: ICQ (Intelligent Constant Quality) mode, configured with AVCodecContext.global_quality.
ffmpeg.av_opt_set(_pCodecContext->priv_data, "global_quality", qpValue.ToString(), 0); _pCodecContext->global_quality = qpValue;
} }
else if (encoder == VideoEncoder.LibVvenc) else if (encoder == VideoEncoder.LibVvenc)
{ {
// libvvenc: Specifies Constant QP mode with "qp" option // 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. // CRF (Constant Rate Factor): Smarter quality control than QP.
// Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality. // Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality.
// Range: 0 (lossless) ~ 51 (lowest quality). Typically between 18 and 28. // Range: 0 (lossless) ~ 51 (lowest quality) for x264/x265, 0~63 for VP9/AV1. Typically between 18 and 28.
ffmpeg.av_opt_set(_pCodecContext->priv_data, "crf", qpValue.ToString(), 0); TrySetOption("crf", qpValue.ToString());
} }
break; break;
default: default:
@@ -919,6 +1043,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 // Write Frame
// ================================================================ // ================================================================
@@ -1018,12 +1212,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> /// <summary>
/// Encodes a frame by directly copying already pre-converted NV12 or YUV420P frame data. /// Encodes a frame by directly copying already pre-converted NV12 or YUV420P frame data.
/// </summary> /// </summary>
private void WritePreConvertedFrame(byte* buffer) private void WritePreConvertedFrame(byte* buffer)
{ {
ffmpeg.av_frame_make_writable(_pDstFrame); PrepareDstFrameForWrite();
var width = _settings.Width; var width = _settings.Width;
var height = _settings.Height; var height = _settings.Height;
@@ -1137,20 +1369,17 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// Core logic for actual frame encoding. /// Core logic for actual frame encoding.
/// ///
/// Processing flow: /// 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) /// (Allocates a new buffer if encoder is still referencing previous frame's buffer)
/// 2. sws_scale: RGBA → YUV420P/NV12 colorspace conversion /// 2. sws_scale: RGBA → YUV420P/NV12 colorspace conversion
/// 3. (QSV hw_frames mode) av_hwframe_transfer_data: Upload NV12 → QSV hardware surface /// 3. (QSV hw_frames mode) av_hwframe_transfer_data: Upload NV12 → QSV hardware surface
/// 4. Pass to encoder after setting PTS /// 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) /// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_scale)
/// </summary> /// </summary>
private void WriteFrame() private void WriteFrame()
{ {
// Ensure writable state since encoder might still be referencing previous frame's buffer. PrepareDstFrameForWrite();
// Internally allocates a new buffer if referenced.
ffmpeg.av_frame_make_writable(_pDstFrame);
// ── Colorspace conversion: RGBA → YUV420P or NV12 ── // ── Colorspace conversion: RGBA → YUV420P or NV12 ──
// sws_scale parameters explanation: // sws_scale parameters explanation:
@@ -1180,8 +1409,8 @@ namespace ADOFAIRenderer.Util.FFmpeg
// av_hwframe_get_buffer: Get an empty hardware frame from hw_frames_ctx frame pool. // av_hwframe_get_buffer: Get an empty hardware frame from hw_frames_ctx frame pool.
// av_hwframe_transfer_data: Copy (upload) NV12 software frame data to QSV surface. // av_hwframe_transfer_data: Copy (upload) NV12 software frame data to QSV surface.
// //
// Through this process, the encoder can read frames directly from GPU memory, // The frame is copied from system memory to the GPU surface on every call;
// minimizing PCIe bandwidth usage. // the encoder then reads the surface from GPU memory.
// //
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html // Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
@@ -1311,8 +1540,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// 2. av_write_trailer: Writes container trailer (end-of-file metadata). /// 2. av_write_trailer: Writes container trailer (end-of-file metadata).
/// For MP4, the moov atom (index info) is completed here. /// For MP4, the moov atom (index info) is completed here.
/// ⚠️ If trailer is not written, the file becomes corrupted and unplayable! /// ⚠️ 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://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> /// </summary>
public void End() public void End()
{ {
@@ -1320,37 +1554,29 @@ namespace ADOFAIRenderer.Util.FFmpeg
{ {
if (_disposed || !_isInitialized) return; if (_disposed || !_isInitialized) return;
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
try try
{ {
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
SendFrameAndWritePackets(null); 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 // 1.5. Flush remaining samples in audio FIFO + flush audio encoder
if (_audioEnabled) if (_audioEnabled)
{
try
{ {
FlushAudioEncoder(); FlushAudioEncoder();
} }
catch (Exception ex)
// 2. Write container trailer (moov atom and file termination metadata)
if (_pFormatContext != null)
{ {
Melon<RenderMod>.Logger.Error($"Error during audio encoder flush: {ex.Message}"); var ret = ffmpeg.av_write_trailer(_pFormatContext);
if (ret < 0)
throw new Exception($"Failed to write the container trailer — {GetFFmpegErrorString(ret)} (Code: {ret})");
} }
} }
finally
// 2. Write container trailer (moov atom and file termination metadata)
if (_pFormatContext != null)
{ {
ffmpeg.av_write_trailer(_pFormatContext); _isInitialized = false;
} }
_isInitialized = false;
} }
} }
@@ -1361,11 +1587,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// <summary> /// <summary>
/// Actual resource cleanup logic. Cleanup order is important: /// Actual resource cleanup logic. Cleanup order is important:
/// ///
/// 1. Call End() (if not called yet) → flush delayed frames + write trailer /// 1. SwsContext (scaler) → independent, can be freed first
/// 2. SwsContext (scaler) → independent, can be freed first /// 2. AVFrame (src/dst) → freed before codec as codec may reference them
/// 3. AVFrame (src/dst) → freed before codec as codec may reference them /// 3. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together
/// 4. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together /// 4. AVFormatContext (format/IO) → includes closing file handle
/// 5. 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: /// Reference:
/// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html /// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
@@ -1379,12 +1607,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
{ {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
_isInitialized = false;
// Trigger End() safely in case Dispose is called without calling End() first
if (_isInitialized)
{
try { End(); } catch { /* Ignore */ }
}
// Release Sws scaler // Release Sws scaler
if (_pSwsContext != null) if (_pSwsContext != null)
@@ -1750,8 +1973,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// <summary> /// <summary>
/// Flushes the audio encoder. /// Flushes the audio encoder.
/// Pads and encodes remaining samples (less than frame_size) in FIFO, /// Sends the samples remaining in the FIFO (fewer than frame_size) as one last short frame,
/// then sends null frame to output all delayed frames in encoder. /// then sends a null frame to output all delayed packets of the encoder.
///
/// The short frame is handled by libavcodec (encode_send_frame_internal):
/// encoders with AV_CODEC_CAP_SMALL_LAST_FRAME or AV_CODEC_CAP_VARIABLE_FRAME_SIZE receive it as is,
/// other encoders receive it padded with silence up to frame_size (pad_last_frame).
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/encode.c
/// </summary> /// </summary>
private void FlushAudioEncoder() private void FlushAudioEncoder()
{ {
+33 -31
View File
@@ -27,11 +27,33 @@ namespace ADOFAIRenderer.Util
private bool _isInitialized; private bool _isInitialized;
private volatile bool _isDisposed; 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) 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(); InitializePool();
} }
@@ -71,42 +93,22 @@ namespace ADOFAIRenderer.Util
state.IsInFlight = true; 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 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); _computeShader.SetTexture(_kernelIndex, SourceTexture, texture);
ModAssets.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer); _computeShader.SetBuffer(_kernelIndex, DestinationBuffer, state.GPUBuffer);
ModAssets.ColorConversionShader.SetInt(Width, texture.width); _computeShader.SetInt(Width, texture.width);
ModAssets.ColorConversionShader.SetInt(Height, texture.height); _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 + 31) / 32
: (texture.width + 63) / 64; : (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16; var threadGroupsY = (texture.height + 15) / 16;
ModAssets.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1); _computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
AsyncGPUReadback.RequestIntoNativeArray( AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback 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); RecycleState(state);
return; return;
+2 -1
View File
@@ -4,6 +4,7 @@ using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ADOFAIRenderer.Data; using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util.FFmpeg; using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
@@ -121,7 +122,7 @@ internal static class GPUUtil
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue; !int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
var nvidiaVerInt = sub % 10 * 10000 + build; 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}"); Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion; return _cachedNvidiaVersion;
} }
@@ -8,7 +8,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MelonLoader; using MelonLoader;
namespace ADOFAIRenderer.Util; namespace ADOFAIRenderer.Util.OS;
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")] [SuppressMessage("ReSharper", "UnusedMember.Local")]
+583
View File
@@ -0,0 +1,583 @@
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>: Toast notification shown by a hidden <c>powershell.exe</c> process
/// (WinRT <c>ToastNotificationManager</c>, script passed with <c>-EncodedCommand</c>).
/// With a folder path, the script also registers the <c>adofai-renderer://</c> URL protocol under
/// <c>HKCU\Software\Classes\adofai-renderer</c> (open command: <c>explorer.exe "&lt;folder&gt;"</c>)
/// so the toast button opens the folder; the key is overwritten on each notification and stays registered.
/// When PowerShell fails, a Win32 <c>Shell_NotifyIcon</c> balloon is shown via P/Invoke.
/// </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.
/// With a folder path, registers the adofai-renderer:// protocol in HKCU first (see class summary).
/// Waits up to 5 seconds for the process. Returns true when it exits with code 0,
/// false when it exits with another code, does not exit within 5 seconds, or fails to start.
/// </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
}
+129
View File
@@ -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;
}
}
}
-578
View File
@@ -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
}
}
-130
View File
@@ -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;
}
}
}
}
+11
View File
@@ -20,6 +20,7 @@ internal class VideoBGPlayer : MonoBehaviour
private static readonly int UTex = Shader.PropertyToID("_UTex"); private static readonly int UTex = Shader.PropertyToID("_UTex");
private static readonly int VTex = Shader.PropertyToID("_VTex"); private static readonly int VTex = Shader.PropertyToID("_VTex");
private static readonly int ColorRange = Shader.PropertyToID("ColorRange"); private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
private enum GPUMode private enum GPUMode
@@ -189,6 +190,14 @@ internal class VideoBGPlayer : MonoBehaviour
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0; var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
var targetVideoTime = songTime + vidOffset; 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 (targetVideoTime < 0)
{ {
if (_targetTexture) if (_targetTexture)
@@ -307,6 +316,7 @@ internal class VideoBGPlayer : MonoBehaviour
_computeShader.SetInt(Width, w); _computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h); _computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0); _computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
int threadGroupsX = (w + 7) / 8; int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8; int threadGroupsY = (h + 7) / 8;
@@ -341,6 +351,7 @@ internal class VideoBGPlayer : MonoBehaviour
_computeShader.SetInt(Width, w); _computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h); _computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0); _computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
int threadGroupsX = (w + 7) / 8; int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8; int threadGroupsY = (h + 7) / 8;