SHA256
Compare commits
8
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
b4c950c9f7
|
||
|
|
723f4446f5
|
||
|
|
6f4c632b41
|
||
|
|
863dcd2357
|
||
|
|
00048b7b06
|
||
|
|
d218bb835d
|
||
|
|
34d766cb4d
|
||
|
|
5d805b85ad
|
No files matched your search
@@ -0,0 +1,302 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Rendering;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.API;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execution context provided to frame modification hooks, managing GPU ping-pong double buffering.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Zero-Allocation Notice:</b> For maximum performance on the render hot path, the pipeline reuses context instances
|
||||||
|
/// across frames. Do not cache the context instance itself across frames.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Execution Frequency Notice:</b> This hook is executed only when a frame is captured for video encoding.
|
||||||
|
/// When <c>RenderFPS</c> exceeds <c>VideoFPS</c>, intermediate game rendering frames are skipped and the hook
|
||||||
|
/// will <b>not</b> be invoked on every rendered game frame.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
|
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||||
|
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
|
||||||
|
public class FrameHookContext
|
||||||
|
{
|
||||||
|
private RenderTexture _originalSource = null!;
|
||||||
|
private RenderTexture _pingRT = null!;
|
||||||
|
private RenderTexture _pongRT = null!;
|
||||||
|
|
||||||
|
private RenderTexture _currentSource = null!;
|
||||||
|
private RenderTexture _currentDestination = null!;
|
||||||
|
private bool _hasModified;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The input <see cref="RenderTexture"/> containing the rendered frame before the current pass.
|
||||||
|
/// </summary>
|
||||||
|
public RenderTexture Source => _currentSource;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The writable destination <see cref="RenderTexture"/> for the current pass.
|
||||||
|
/// </summary>
|
||||||
|
public RenderTexture Destination => _currentDestination;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The active <see cref="RenderTexture"/> representing the current state of the modified frame.
|
||||||
|
/// </summary>
|
||||||
|
public RenderTexture Current => _currentSource;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The original unmodified render texture captured from the game for the current frame before any hooks were applied.
|
||||||
|
/// </summary>
|
||||||
|
public RenderTexture OriginalSource => _originalSource;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether any modifications or swaps have been applied to this frame.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasModified => _hasModified;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The sequential 0-based index of the video frame currently being encoded.
|
||||||
|
/// </summary>
|
||||||
|
public long FrameIndex { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alias for <see cref="FrameIndex"/>. The sequential 0-based index of the video frame.
|
||||||
|
/// </summary>
|
||||||
|
public long VideoFrameIndex => FrameIndex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The sequential 0-based index of the game render frame.
|
||||||
|
/// </summary>
|
||||||
|
public long RenderFrameIndex { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The timestamp of the current frame in seconds relative to video start.
|
||||||
|
/// </summary>
|
||||||
|
public double VideoTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The delta time duration in seconds of a single video frame (equivalent to 1.0 / VideoFPS).
|
||||||
|
/// </summary>
|
||||||
|
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The width of the frame in pixels.
|
||||||
|
/// </summary>
|
||||||
|
public int Width => _currentSource.width;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The height of the frame in pixels.
|
||||||
|
/// </summary>
|
||||||
|
public int Height => _currentSource.height;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configured render frames per second.
|
||||||
|
/// </summary>
|
||||||
|
public double RenderFPS { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The output video frames per second.
|
||||||
|
/// </summary>
|
||||||
|
public double VideoFPS { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of successful modification passes completed for the current frame.
|
||||||
|
/// </summary>
|
||||||
|
public int PassCount { get; private set; }
|
||||||
|
|
||||||
|
internal FrameHookContext(
|
||||||
|
RenderTexture originalSource,
|
||||||
|
RenderTexture pingRT,
|
||||||
|
RenderTexture pongRT,
|
||||||
|
RenderTexture firstDestination,
|
||||||
|
long frameIndex,
|
||||||
|
long renderFrameIndex,
|
||||||
|
double videoTime,
|
||||||
|
double renderFps,
|
||||||
|
double videoFps)
|
||||||
|
{
|
||||||
|
Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Reset(
|
||||||
|
RenderTexture originalSource,
|
||||||
|
RenderTexture pingRT,
|
||||||
|
RenderTexture pongRT,
|
||||||
|
RenderTexture firstDestination,
|
||||||
|
long frameIndex,
|
||||||
|
long renderFrameIndex,
|
||||||
|
double videoTime,
|
||||||
|
double renderFps,
|
||||||
|
double videoFps)
|
||||||
|
{
|
||||||
|
_originalSource = originalSource;
|
||||||
|
_pingRT = pingRT;
|
||||||
|
_pongRT = pongRT;
|
||||||
|
|
||||||
|
_currentSource = originalSource;
|
||||||
|
_currentDestination = firstDestination;
|
||||||
|
_hasModified = false;
|
||||||
|
PassCount = 0;
|
||||||
|
|
||||||
|
FrameIndex = frameIndex;
|
||||||
|
RenderFrameIndex = renderFrameIndex;
|
||||||
|
VideoTime = videoTime;
|
||||||
|
RenderFPS = renderFps;
|
||||||
|
VideoFPS = videoFps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
|
||||||
|
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||||
|
public void Blit(Material? material = null, int pass = -1)
|
||||||
|
{
|
||||||
|
if (material != null)
|
||||||
|
{
|
||||||
|
if (pass >= 0)
|
||||||
|
Graphics.Blit(_currentSource, _currentDestination, material, pass);
|
||||||
|
else
|
||||||
|
Graphics.Blit(_currentSource, _currentDestination, material);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Graphics.Blit(_currentSource, _currentDestination);
|
||||||
|
}
|
||||||
|
|
||||||
|
Swap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blits from a custom source <see cref="Texture"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="customSource">The custom source texture to blit from.</param>
|
||||||
|
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
|
||||||
|
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customSource"/> is null.</exception>
|
||||||
|
public void Blit(Texture customSource, Material? material = null, int pass = -1)
|
||||||
|
{
|
||||||
|
if (customSource == null) throw new ArgumentNullException(nameof(customSource));
|
||||||
|
|
||||||
|
if (material != null)
|
||||||
|
{
|
||||||
|
if (pass >= 0)
|
||||||
|
Graphics.Blit(customSource, _currentDestination, material, pass);
|
||||||
|
else
|
||||||
|
Graphics.Blit(customSource, _currentDestination, material);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Graphics.Blit(customSource, _currentDestination);
|
||||||
|
}
|
||||||
|
|
||||||
|
Swap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the contents of <see cref="Source"/> into <see cref="Destination"/> without swapping buffers.
|
||||||
|
/// Call this before drawing custom overlays into <see cref="Destination"/> if you wish to preserve the background.
|
||||||
|
/// </summary>
|
||||||
|
public void Copy()
|
||||||
|
{
|
||||||
|
Graphics.Blit(_currentSource, _currentDestination);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears <see cref="Destination"/> with the specified color.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
|
||||||
|
public void ClearDestination(Color? clearColor = null)
|
||||||
|
{
|
||||||
|
var prev = RenderTexture.active;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RenderTexture.active = _currentDestination;
|
||||||
|
GL.Clear(false, true, clearColor ?? Color.clear);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RenderTexture.active = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Temporarily activates <see cref="Destination"/> as <see cref="RenderTexture.active"/> to perform custom draw calls,
|
||||||
|
/// safely restoring the previous active RenderTexture afterwards.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="drawAction">The action that performs custom draw operations.</param>
|
||||||
|
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after drawing.</param>
|
||||||
|
/// <param name="preserveBackground">If <c>true</c>, copies <see cref="Source"/> into <see cref="Destination"/> before executing <paramref name="drawAction"/>.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="drawAction"/> is null.</exception>
|
||||||
|
public void DrawToDestination(Action drawAction, bool autoSwap = true, bool preserveBackground = false)
|
||||||
|
{
|
||||||
|
if (drawAction == null) throw new ArgumentNullException(nameof(drawAction));
|
||||||
|
|
||||||
|
if (preserveBackground)
|
||||||
|
{
|
||||||
|
Copy();
|
||||||
|
}
|
||||||
|
|
||||||
|
var prev = RenderTexture.active;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RenderTexture.active = _currentDestination;
|
||||||
|
drawAction();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RenderTexture.active = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoSwap)
|
||||||
|
{
|
||||||
|
Swap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="commandBuffer">The command buffer to execute.</param>
|
||||||
|
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after execution.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="commandBuffer"/> is null.</exception>
|
||||||
|
public void ExecuteCommandBuffer(CommandBuffer commandBuffer, bool autoSwap = true)
|
||||||
|
{
|
||||||
|
if (commandBuffer == null) throw new ArgumentNullException(nameof(commandBuffer));
|
||||||
|
Graphics.ExecuteCommandBuffer(commandBuffer);
|
||||||
|
if (autoSwap)
|
||||||
|
{
|
||||||
|
Swap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Swaps the active <see cref="Source"/> and <see cref="Destination"/> ping-pong buffers.
|
||||||
|
/// Call this after manually writing or rendering into <see cref="Destination"/> so that subsequent hooks or the video encoder read the updated frame.
|
||||||
|
/// </summary>
|
||||||
|
public void Swap()
|
||||||
|
{
|
||||||
|
_hasModified = true;
|
||||||
|
PassCount++;
|
||||||
|
|
||||||
|
var previousDestination = _currentDestination;
|
||||||
|
_currentSource = previousDestination;
|
||||||
|
_currentDestination = (previousDestination == _pingRT) ? _pongRT : _pingRT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="customTexture">The custom texture to set as the active frame.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
|
||||||
|
public void ReplaceCurrent(RenderTexture customTexture)
|
||||||
|
{
|
||||||
|
_currentSource = customTexture ?? throw new ArgumentNullException(nameof(customTexture));
|
||||||
|
_hasModified = true;
|
||||||
|
PassCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.API;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a custom frame modification hook that can modify rendered frames using GPU ping-pong double buffering.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Execution Frequency Note:</b> Frame hooks are only invoked for frames that are actually encoded into the final video (<c>VideoFPS</c>).
|
||||||
|
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped and this hook
|
||||||
|
/// will <b>not</b> be called on every game frame.
|
||||||
|
/// </remarks>
|
||||||
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
|
public interface IFrameHook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invoked for each captured video frame before it is encoded.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method is not called on every rendered game frame if <c>RenderFPS</c> exceeds <c>VideoFPS</c>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
|
||||||
|
void OnRenderFrame(FrameHookContext context);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using ADOFAIRenderer.API;
|
||||||
|
using MelonLoader;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Core;
|
||||||
|
|
||||||
|
internal class FrameHookPipeline : IDisposable
|
||||||
|
{
|
||||||
|
private RenderTexture? _pingRT;
|
||||||
|
private RenderTexture? _pongRT;
|
||||||
|
private RenderTexture? _lastOutputRT;
|
||||||
|
private FrameHookContext? _reusableContext;
|
||||||
|
private int _width;
|
||||||
|
private int _height;
|
||||||
|
private RenderTextureFormat _format;
|
||||||
|
|
||||||
|
public RenderTexture? Process(
|
||||||
|
RenderTexture sourceTexture,
|
||||||
|
long frameIndex,
|
||||||
|
long renderFrameIndex,
|
||||||
|
double videoTime,
|
||||||
|
double renderFps,
|
||||||
|
double videoFps,
|
||||||
|
IReadOnlyList<Action<FrameHookContext>> hooks)
|
||||||
|
{
|
||||||
|
if (hooks.Count == 0 || !sourceTexture || !sourceTexture.IsCreated())
|
||||||
|
return sourceTexture;
|
||||||
|
|
||||||
|
EnsureBuffers(sourceTexture);
|
||||||
|
|
||||||
|
if (!_pingRT || !_pongRT || !_pingRT.IsCreated() || !_pongRT.IsCreated())
|
||||||
|
return sourceTexture;
|
||||||
|
|
||||||
|
var prevActive = RenderTexture.active;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Inter-frame ping-pong safety: start with the buffer that was NOT used as the output
|
||||||
|
// of the previous frame, ensuring the buffer currently being read back is not immediately overwritten.
|
||||||
|
var firstDestination = _lastOutputRT == _pingRT ? _pongRT : _pingRT;
|
||||||
|
|
||||||
|
if (_reusableContext == null)
|
||||||
|
{
|
||||||
|
_reusableContext = new FrameHookContext(
|
||||||
|
sourceTexture,
|
||||||
|
_pingRT,
|
||||||
|
_pongRT,
|
||||||
|
firstDestination,
|
||||||
|
frameIndex,
|
||||||
|
renderFrameIndex,
|
||||||
|
videoTime,
|
||||||
|
renderFps,
|
||||||
|
videoFps
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_reusableContext.Reset(
|
||||||
|
sourceTexture,
|
||||||
|
_pingRT,
|
||||||
|
_pongRT,
|
||||||
|
firstDestination,
|
||||||
|
frameIndex,
|
||||||
|
renderFrameIndex,
|
||||||
|
videoTime,
|
||||||
|
renderFps,
|
||||||
|
videoFps
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < hooks.Count; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hooks[i](_reusableContext);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Error($"[FrameHook] Error executing frame hook #{i} on frame {frameIndex}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = _reusableContext.Current;
|
||||||
|
_lastOutputRT = _reusableContext.HasModified ? result : null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RenderTexture.active = prevActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureBuffers(RenderTexture sourceTexture)
|
||||||
|
{
|
||||||
|
var width = sourceTexture.width;
|
||||||
|
var height = sourceTexture.height;
|
||||||
|
var format = sourceTexture.format;
|
||||||
|
|
||||||
|
if (_pingRT && _pongRT &&
|
||||||
|
_width == width && _height == height && _format == format &&
|
||||||
|
_pingRT.IsCreated() && _pongRT.IsCreated())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseBuffers();
|
||||||
|
|
||||||
|
_width = width;
|
||||||
|
_height = height;
|
||||||
|
_format = format;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var desc = sourceTexture.descriptor;
|
||||||
|
desc.depthBufferBits = 0;
|
||||||
|
desc.msaaSamples = 1;
|
||||||
|
desc.useMipMap = false;
|
||||||
|
desc.autoGenerateMips = false;
|
||||||
|
|
||||||
|
var supportsUav = SystemInfo.supportsComputeShaders &&
|
||||||
|
SystemInfo.SupportsRandomWriteOnRenderTextureFormat(desc.colorFormat);
|
||||||
|
desc.enableRandomWrite = supportsUav;
|
||||||
|
|
||||||
|
_pingRT = new RenderTexture(desc)
|
||||||
|
{
|
||||||
|
name = "ADOFAIRenderer_PingBuffer",
|
||||||
|
filterMode = sourceTexture.filterMode,
|
||||||
|
wrapMode = sourceTexture.wrapMode
|
||||||
|
};
|
||||||
|
_pingRT.Create();
|
||||||
|
|
||||||
|
_pongRT = new RenderTexture(desc)
|
||||||
|
{
|
||||||
|
name = "ADOFAIRenderer_PongBuffer",
|
||||||
|
filterMode = sourceTexture.filterMode,
|
||||||
|
wrapMode = sourceTexture.wrapMode
|
||||||
|
};
|
||||||
|
_pongRT.Create();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ReleaseBuffers();
|
||||||
|
Melon<RenderMod>.Logger.Warning($"[FrameHook] Failed to create descriptor-based PingPong RTs: {ex.Message}. Falling back to standard ARGB32.");
|
||||||
|
_pingRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
|
||||||
|
{
|
||||||
|
name = "ADOFAIRenderer_PingBuffer",
|
||||||
|
filterMode = sourceTexture.filterMode,
|
||||||
|
wrapMode = sourceTexture.wrapMode
|
||||||
|
};
|
||||||
|
_pingRT.Create();
|
||||||
|
|
||||||
|
_pongRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
|
||||||
|
{
|
||||||
|
name = "ADOFAIRenderer_PongBuffer",
|
||||||
|
filterMode = sourceTexture.filterMode,
|
||||||
|
wrapMode = sourceTexture.wrapMode
|
||||||
|
};
|
||||||
|
_pongRT.Create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReleaseBuffers()
|
||||||
|
{
|
||||||
|
if (_pingRT)
|
||||||
|
{
|
||||||
|
if (_pingRT.IsCreated()) _pingRT.Release();
|
||||||
|
UnityEngine.Object.Destroy(_pingRT);
|
||||||
|
_pingRT = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pongRT)
|
||||||
|
{
|
||||||
|
if (_pongRT.IsCreated()) _pongRT.Release();
|
||||||
|
UnityEngine.Object.Destroy(_pongRT);
|
||||||
|
_pongRT = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_width = 0;
|
||||||
|
_height = 0;
|
||||||
|
_lastOutputRT = null;
|
||||||
|
_reusableContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ReleaseBuffers();
|
||||||
|
}
|
||||||
|
}
|
||||||
+203
-10
@@ -2,24 +2,118 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+71
-1
@@ -37,6 +37,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 +65,7 @@ internal static class RenderContext
|
|||||||
OutputPath = null;
|
OutputPath = null;
|
||||||
|
|
||||||
Notification = true;
|
Notification = true;
|
||||||
|
SessionFrameHooks.Clear();
|
||||||
|
|
||||||
EndCondition = EndConditionDefault;
|
EndCondition = EndConditionDefault;
|
||||||
OnComplete = OnCompleteDefault;
|
OnComplete = OnCompleteDefault;
|
||||||
@@ -103,6 +105,49 @@ internal class RenderManager : MonoBehaviour
|
|||||||
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
||||||
|
|
||||||
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;
|
||||||
@@ -160,6 +205,8 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
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.");
|
||||||
@@ -316,7 +363,23 @@ internal class RenderManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
lastVideoFrameIndex = videoFrameIndex;
|
lastVideoFrameIndex = videoFrameIndex;
|
||||||
lastRequestedVideoFrameIndex = videoFrameIndex;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +450,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,6 +462,7 @@ 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}");
|
||||||
|
ResetFrameHookState();
|
||||||
CommonWarningDialogUI.ShowDialog(
|
CommonWarningDialogUI.ShowDialog(
|
||||||
"Encoder Error",
|
"Encoder Error",
|
||||||
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
||||||
@@ -687,6 +755,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
_frameReader?.Dispose();
|
_frameReader?.Dispose();
|
||||||
|
ResetFrameHookState();
|
||||||
|
|
||||||
// Clean up Debug Monitor UI
|
// Clean up Debug Monitor UI
|
||||||
if (_debugMonitorObj)
|
if (_debugMonitorObj)
|
||||||
@@ -776,6 +845,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
_frameReader?.Dispose();
|
_frameReader?.Dispose();
|
||||||
|
ResetFrameHookState();
|
||||||
AudioMixer?.Reset(); AudioMixer = null;
|
AudioMixer?.Reset(); AudioMixer = null;
|
||||||
SyncCalculator.Reset();
|
SyncCalculator.Reset();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts Unity <see cref="Color"/> and nullable <see cref="Color"/> to and from hex string (#RRGGBBAA).
|
||||||
|
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
|
||||||
|
/// </summary>
|
||||||
|
public class ColorJsonConverter : JsonConverter
|
||||||
|
{
|
||||||
|
public override bool CanConvert(Type objectType)
|
||||||
|
{
|
||||||
|
return objectType == typeof(Color) || objectType == typeof(Color?);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
writer.WriteNull();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var color = (Color)value;
|
||||||
|
writer.WriteValue(ToHex(color));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||||
|
{
|
||||||
|
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonToken.Null)
|
||||||
|
{
|
||||||
|
return isNullable ? null : (object)default(Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonToken.String)
|
||||||
|
{
|
||||||
|
var str = (string?)reader.Value;
|
||||||
|
if (TryParseHex(str, out var parsedColor))
|
||||||
|
{
|
||||||
|
return parsedColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNullable ? null : (object)default(Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonToken.StartObject)
|
||||||
|
{
|
||||||
|
var obj = JObject.Load(reader);
|
||||||
|
float r = (float?)obj["r"] ?? 0f;
|
||||||
|
float g = (float?)obj["g"] ?? 0f;
|
||||||
|
float b = (float?)obj["b"] ?? 0f;
|
||||||
|
float a = (float?)obj["a"] ?? 1f;
|
||||||
|
return new Color(r, g, b, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNullable ? null : (object)default(Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToHex(Color color)
|
||||||
|
{
|
||||||
|
byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255);
|
||||||
|
byte g = (byte)Math.Clamp((int)Math.Round(color.g * 255f), 0, 255);
|
||||||
|
byte b = (byte)Math.Clamp((int)Math.Round(color.b * 255f), 0, 255);
|
||||||
|
byte a = (byte)Math.Clamp((int)Math.Round(color.a * 255f), 0, 255);
|
||||||
|
return $"#{r:X2}{g:X2}{b:X2}{a:X2}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseHex(string? hex, out Color color)
|
||||||
|
{
|
||||||
|
color = default;
|
||||||
|
if (string.IsNullOrWhiteSpace(hex)) return false;
|
||||||
|
|
||||||
|
var str = hex.Trim();
|
||||||
|
if (str.StartsWith("#"))
|
||||||
|
{
|
||||||
|
str = str.Substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str.Length == 6)
|
||||||
|
{
|
||||||
|
if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) &&
|
||||||
|
byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) &&
|
||||||
|
byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
|
||||||
|
{
|
||||||
|
color = new Color(r / 255f, g / 255f, b / 255f, 1f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (str.Length == 8)
|
||||||
|
{
|
||||||
|
if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) &&
|
||||||
|
byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) &&
|
||||||
|
byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) &&
|
||||||
|
byte.TryParse(str.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var a))
|
||||||
|
{
|
||||||
|
color = new Color(r / 255f, g / 255f, b / 255f, a / 255f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using 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;
|
||||||
|
}
|
||||||
}␍
|
}␍
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
+1
-1
@@ -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.4-INDEV", "ADOFAI Editor Forum")]
|
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.6-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;
|
||||||
|
|||||||
+75
-149
@@ -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,112 +120,66 @@ 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: () =>
|
||||||
// Define default colors
|
|
||||||
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
|
|
||||||
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
|
|
||||||
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
|
|
||||||
|
|
||||||
var btnImage = btnObj.AddComponent<Image>();
|
|
||||||
btnImage.color = baseColor;
|
|
||||||
btnImage.raycastTarget = true;
|
|
||||||
|
|
||||||
var btn = btnObj.AddComponent<Button>();
|
|
||||||
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
|
|
||||||
|
|
||||||
// Register click callback
|
|
||||||
btn.onClick.AddListener(() =>
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -226,59 +187,24 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
||||||
}
|
}
|
||||||
Destroy(gameObject);
|
Destroy(gameObject);
|
||||||
});
|
},
|
||||||
|
text: btnData.Text,
|
||||||
|
id: $"btn_dialog_{i}",
|
||||||
|
height: 40f
|
||||||
|
);
|
||||||
|
|
||||||
var btnRect = btnObj.GetComponent<RectTransform>();
|
if (btnData.BackgroundColor.HasValue)
|
||||||
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
btnRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
|
|
||||||
float posX = numButtons == 1
|
|
||||||
? 0
|
|
||||||
: startX + i * (buttonWidth + spacing);
|
|
||||||
|
|
||||||
btnRect.anchoredPosition = new Vector2(posX, 0);
|
|
||||||
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
|
|
||||||
|
|
||||||
// Add text label
|
|
||||||
var txtObj = new GameObject("Text");
|
|
||||||
txtObj.transform.SetParent(btnObj.transform, false);
|
|
||||||
txtObj.layer = 5;
|
|
||||||
|
|
||||||
var text = txtObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
text.text = btnData.Text;
|
|
||||||
text.fontSize = 20;
|
|
||||||
text.font = font;
|
|
||||||
text.color = Color.white;
|
|
||||||
text.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var txtRect = txtObj.GetComponent<RectTransform>();
|
|
||||||
txtRect.anchorMin = Vector2.zero;
|
|
||||||
txtRect.anchorMax = Vector2.one;
|
|
||||||
txtRect.offsetMin = Vector2.zero;
|
|
||||||
txtRect.offsetMax = Vector2.zero;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
|
|
||||||
{
|
{
|
||||||
button.transition = Selectable.Transition.ColorTint;
|
btn.NormalColor = btnData.BackgroundColor.Value;
|
||||||
var colors = button.colors;
|
btn.UpdateVisual();
|
||||||
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)
|
var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
{
|
le.flexibleWidth = 1f;
|
||||||
float r = Mathf.Clamp01(color.r * factor);
|
le.preferredHeight = 40f;
|
||||||
float g = Mathf.Clamp01(color.g * factor);
|
}
|
||||||
float b = Mathf.Clamp01(color.b * factor);
|
|
||||||
return new Color(r, g, b, color.a);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+124
-182
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+750
-194
File diff suppressed because it is too large.
Load diff
@@ -68,7 +68,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Candidate hardware device configuration discovered via <c>avcodec_get_hw_config</c>.
|
/// Candidate hardware device configuration discovered via <c>avcodec_get_hw_config</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private struct HwConfigCandidate
|
private record struct HwConfigCandidate
|
||||||
{
|
{
|
||||||
public AVHWDeviceType DeviceType;
|
public AVHWDeviceType DeviceType;
|
||||||
public AVPixelFormat PixelFormat;
|
public AVPixelFormat PixelFormat;
|
||||||
@@ -475,6 +475,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
/// <returns>True if hardware decoding was successfully configured and opened; false to fall back.</returns>
|
/// <returns>True if hardware decoding was successfully configured and opened; false to fall back.</returns>
|
||||||
private bool TryInitializeHwDecoder(AVCodec* decoder, AVStream* stream)
|
private bool TryInitializeHwDecoder(AVCodec* decoder, AVStream* stream)
|
||||||
{
|
{
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
// Collect all supported hardware configs from decoder
|
// Collect all supported hardware configs from decoder
|
||||||
const int AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX = 0x01;
|
const int AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX = 0x01;
|
||||||
var candidates = new List<HwConfigCandidate>();
|
var candidates = new List<HwConfigCandidate>();
|
||||||
@@ -1012,6 +1013,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the next video frame and copies its NV12 Y and interleaved UV channel data to unmanaged memory pointers.
|
/// Reads the next video frame and copies its NV12 Y and interleaved UV channel data to unmanaged memory pointers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
public bool TryReadNV12Frame(
|
public bool TryReadNV12Frame(
|
||||||
IntPtr yBuffer, int yStride,
|
IntPtr yBuffer, int yStride,
|
||||||
IntPtr uvBuffer, int uvStride,
|
IntPtr uvBuffer, int uvStride,
|
||||||
@@ -1064,6 +1066,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
/// For native YUV420P sources, color conversion is bypassed and memory is copied directly.
|
/// For native YUV420P sources, color conversion is bypassed and memory is copied directly.
|
||||||
/// Non-YUV420P frames (including NV12 from HW decoding) or mismatched dimensions are converted via libswscale.
|
/// Non-YUV420P frames (including NV12 from HW decoding) or mismatched dimensions are converted via libswscale.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
public bool TryReadYUV420PFrame(
|
public bool TryReadYUV420PFrame(
|
||||||
byte* yBuffer, int yStride,
|
byte* yBuffer, int yStride,
|
||||||
byte* uBuffer, int uStride,
|
byte* uBuffer, int uStride,
|
||||||
|
|||||||
Reference in new issue
Block a user