SHA256
Compare commits
42
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
985a2d241e
|
||
|
|
994bfde46d
|
||
|
|
369f6f9a0d
|
||
|
|
520d18846b
|
||
|
|
22bd38d008
|
||
|
|
27e7327c1e
|
||
|
|
2152625e45
|
||
|
|
aefa1409c1
|
||
|
|
e802b6a08a
|
||
|
|
c8050abbc9
|
||
|
|
45a7637a36
|
||
|
|
6955762641
|
||
|
|
ea8d4c91ac
|
||
|
|
5080e7ea1d
|
||
|
|
4e556a4632
|
||
|
|
f1e926f758 | ||
|
|
c62723cf5c
|
||
|
|
fee7077b16
|
||
|
|
9fbf057677
|
||
|
|
f153bf70aa
|
||
|
|
93539307b1
|
||
|
|
c0e902fc1a
|
||
|
|
dd2585ee9f
|
||
|
|
1a672cbf59
|
||
|
|
6d5880fe19
|
||
|
|
72d8d71928
|
||
|
|
413778e165
|
||
|
|
36552511f5
|
||
|
|
a1f9fa36ce
|
||
|
|
00971a6730
|
||
|
|
bd24152a2e
|
||
|
|
7aa8ae4b54
|
||
|
|
dee3b2dbcf
|
||
|
|
7eec3b4660
|
||
|
|
ce70d89cdb
|
||
|
|
7db1905906
|
||
|
|
bfc79ee4d3
|
||
|
|
b4c950c9f7
|
||
|
|
723f4446f5
|
||
|
|
6f4c632b41
|
||
|
|
863dcd2357
|
||
|
|
00048b7b06
|
No files matched your search
+10
-4
@@ -32,9 +32,11 @@ Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
## Agent Metadata
|
||||
.antigravitycli/
|
||||
.agent/
|
||||
GEMINI.md
|
||||
/.antigravitycli/
|
||||
/.agent/
|
||||
/GEMINI.md
|
||||
/.mcp.json
|
||||
/CLAUDE.md
|
||||
|
||||
# Decompiled Game Code
|
||||
*.cs.txt
|
||||
@@ -42,4 +44,8 @@ GEMINI.md
|
||||
# Developer Description Files
|
||||
/AsyncInputAutoplayFix.md
|
||||
/AudioMixerExplanation.md
|
||||
/O5Theme.md
|
||||
/O5Theme.md
|
||||
|
||||
# Developer Utility Files
|
||||
/REVIEW_CHECKLIST.md
|
||||
/REVIEW_FFMPEG.md
|
||||
@@ -520,7 +520,9 @@ public class RenderSession
|
||||
}
|
||||
|
||||
RenderManager.Instance.levelPath = levelPath;
|
||||
RenderManager.Instance.Configuration = renderConfig;
|
||||
// The render uses its own copy of the configuration: values adjusted during the render
|
||||
// (e.g. the audio sample rate in SetupPlay) stay in this copy and do not change the caller's object.
|
||||
RenderManager.Instance.Configuration = renderConfig.Clone();
|
||||
|
||||
RenderManager.Instance.stage = RenderStage.Load;
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
using ADOFAIRenderer.Localization;
|
||||
using ADOFAIRenderer.UI;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -45,18 +46,18 @@ internal class FirstUpdateHook : MonoBehaviour
|
||||
var currentVersion = driverVer.Value.ToString("F2");
|
||||
|
||||
CommonWarningDialogUI.ShowDialog(
|
||||
"NVIDIA Graphics Driver Update Recommended",
|
||||
$"Currently detected NVIDIA graphics driver version: <b><color=#ff5555>{currentVersion}</color></b>\n\nDriver version <b><color=#55ff55>610.00 or higher</color></b> is required to render video using hardware acceleration (NVENC).\n\nIf the driver version is lower, encoding may fail or fallback to software (CPU) encoding, significantly slowing down rendering. Please update your graphics driver.",
|
||||
new DialogButton("Update Driver", () =>
|
||||
LocalizationManager.Get("ui.driver_warning.title"),
|
||||
LocalizationManager.Get("ui.driver_warning.body", currentVersion),
|
||||
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_update"), () =>
|
||||
{
|
||||
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
|
||||
Application.Quit();
|
||||
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
|
||||
new DialogButton("Disable Feature", () =>
|
||||
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_disable"), () =>
|
||||
{
|
||||
RenderMod.IsFeatureDisabled = true;
|
||||
}),
|
||||
new DialogButton("Continue Anyway", () =>
|
||||
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_continue"), () =>
|
||||
{
|
||||
// ignored
|
||||
})
|
||||
|
||||
@@ -116,7 +116,11 @@ public static class PresetManager
|
||||
return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets");
|
||||
}
|
||||
|
||||
private static string GetSafeFileName(string name)
|
||||
/// <summary>
|
||||
/// File name (without extension) of the preset file for the given preset name: every character that is invalid
|
||||
/// in a file name is replaced with '_'. Different preset names can map to the same file name (e.g. "a/b" and "a_b").
|
||||
/// </summary>
|
||||
internal static string GetSafeFileName(string name)
|
||||
{
|
||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
||||
}
|
||||
|
||||
+387
-147
@@ -5,7 +5,6 @@ using System.IO;
|
||||
using System.Threading;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using ADOFAIRenderer.API;
|
||||
using ADOFAIRenderer.Core.Data;
|
||||
using ADOFAIRenderer.Data;
|
||||
@@ -14,6 +13,7 @@ using ADOFAIRenderer.UI.Window;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.Audio;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using FFmpeg.AutoGen;
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
@@ -80,7 +80,27 @@ internal class RenderManager : MonoBehaviour
|
||||
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
|
||||
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of frames in ImageQueue. The main thread waits (throttles) while the queue holds more frames.
|
||||
/// </summary>
|
||||
public const int MaxQueuedFrames = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound of the readback buffer bytes of the frames in ImageQueue (4 GiB).
|
||||
/// The frame limit becomes min(MaxQueuedFrames, MaxQueuedBytes / bytes per frame) (see ComputeQueueFrameLimit),
|
||||
/// so it only lowers the frame count at resolutions where MaxQueuedFrames frames exceed this size.
|
||||
/// </summary>
|
||||
public const long MaxQueuedBytes = 4L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Wait time of the throttle loop (OnPostFrame) for the render thread to take a frame from ImageQueue.
|
||||
/// When it elapses without a dequeue, AsyncGPUReadback.WaitAllRequests() is called once so the readbacks
|
||||
/// still in flight complete (the render thread may be waiting for one of them to fill a gap in ImageQueue).
|
||||
/// </summary>
|
||||
private const int ThrottleWaitMilliseconds = 10;
|
||||
|
||||
/// <summary>Frame limit of ImageQueue applied by the last throttle check (MaxQueuedFrames or lower).</summary>
|
||||
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
|
||||
|
||||
// Statistics
|
||||
public long framesSent;
|
||||
@@ -91,7 +111,50 @@ internal class RenderManager : MonoBehaviour
|
||||
public long frameCount;
|
||||
public bool skipPressToStart;
|
||||
public string levelPath = "";
|
||||
public bool IsRenderCancelled { get; private set; }
|
||||
private readonly object _abortStateLock = new();
|
||||
private volatile bool _isRenderCancelled;
|
||||
private volatile bool _renderFailed;
|
||||
private volatile string? _failureReason;
|
||||
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public bool IsRenderCancelled => _isRenderCancelled;
|
||||
public bool IsRenderFailed => _renderFailed;
|
||||
|
||||
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
|
||||
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
||||
|
||||
private void MarkCancelled()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
if (!_renderFailed) _isRenderCancelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The first failure reason wins; ignored once the render is already cancelled or failed.
|
||||
private void MarkFailed(string reason)
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
if (_isRenderCancelled || _renderFailed) return;
|
||||
_failureReason = reason;
|
||||
_renderFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAbortState()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
_isRenderCancelled = false;
|
||||
_renderFailed = false;
|
||||
_failureReason = null;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildAbortMessage() => IsRenderFailed
|
||||
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
||||
: "Rendering was cancelled by user.";
|
||||
public RenderConfig Configuration = null!;
|
||||
|
||||
// Render Sessions State (OOP Instance Fields)
|
||||
@@ -102,7 +165,7 @@ internal class RenderManager : MonoBehaviour
|
||||
public int frameCountBase;
|
||||
|
||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
||||
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||||
|
||||
private FrameReader? _frameReader;
|
||||
private FrameHookPipeline? _frameHookPipeline;
|
||||
@@ -151,6 +214,7 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
public long frameCountForVideo;
|
||||
public long lastVideoFrameIndex = -1;
|
||||
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||||
public long lastRequestedVideoFrameIndex = -1;
|
||||
public int readbackFailureCount;
|
||||
public int throttleCount;
|
||||
@@ -168,10 +232,12 @@ internal class RenderManager : MonoBehaviour
|
||||
// Thread and Queue State
|
||||
internal SortedList<long, CapturedFrame> ImageQueue = new();
|
||||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
||||
// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
|
||||
internal AutoResetEvent FrameDequeuedEvent = new(false);
|
||||
public Thread? RenderThread;
|
||||
public long passCount;
|
||||
public bool endRequested;
|
||||
public bool endSuccessful;
|
||||
public volatile bool endRequested;
|
||||
public volatile bool endSuccessful;
|
||||
public long endFrame = -1;
|
||||
|
||||
// Private Instance Fields
|
||||
@@ -181,6 +247,11 @@ internal class RenderManager : MonoBehaviour
|
||||
private GameObject? _debugMonitorObj;
|
||||
private GameObject? _escapeHandlerObj;
|
||||
internal string? CurrentOutPath;
|
||||
|
||||
// Output file of the current render that is not finished yet.
|
||||
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
|
||||
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
|
||||
private string? _incompleteOutputPath;
|
||||
private bool _renderSetup;
|
||||
|
||||
private void Awake()
|
||||
@@ -195,12 +266,15 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
public void ResetRenderState(bool success)
|
||||
{
|
||||
IsRenderCancelled = !success;
|
||||
if (success) _isRenderCancelled = false;
|
||||
else MarkCancelled();
|
||||
stage = RenderStage.None;
|
||||
}
|
||||
|
||||
public void CleanUpActiveRender()
|
||||
{
|
||||
if (stage != RenderStage.None)
|
||||
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
|
||||
ResetRenderState(success: false);
|
||||
|
||||
AudioSpectrumGenerator.Clear();
|
||||
@@ -238,6 +312,32 @@ internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
RenderThread.Join(200);
|
||||
}
|
||||
|
||||
// The encoders are disposed above, which closes the output file. An unfinished file of this render is deleted.
|
||||
DeleteIncompleteOutputFile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the output file of the current render when the render did not finish it (_incompleteOutputPath).
|
||||
/// Files of earlier, finished renders are never deleted.
|
||||
/// Returns true when a file was deleted.
|
||||
/// </summary>
|
||||
private bool DeleteIncompleteOutputFile()
|
||||
{
|
||||
var path = Interlocked.Exchange(ref _incompleteOutputPath, null);
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {path}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete incomplete video: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -283,8 +383,15 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
if (Instance.stage < RenderStage.OnScene) return;
|
||||
if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true })
|
||||
{
|
||||
MarkFailed("the render thread terminated unexpectedly.");
|
||||
CancelRender();
|
||||
return;
|
||||
}
|
||||
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
||||
{
|
||||
MarkFailed("the level failed or the game controller was lost during rendering.");
|
||||
ResetRenderState(success: false);
|
||||
StopFFmpeg(false);
|
||||
EndRender();
|
||||
@@ -310,36 +417,89 @@ internal class RenderManager : MonoBehaviour
|
||||
frameCount++;
|
||||
}
|
||||
|
||||
// Floors that own a hold renderer, collected from scrLevelMaker.listFloors (see RefreshHoldFloors).
|
||||
private readonly List<scrFloor> _holdFloors = new();
|
||||
private List<scrFloor>? _holdFloorsSource;
|
||||
private int _holdFloorsSourceCount = -1;
|
||||
|
||||
public void OnLastUpdate()
|
||||
{
|
||||
if (Instance.stage < RenderStage.Load) return;
|
||||
if (!Configuration.AdvancedConfig.NonTile) return;
|
||||
foreach (var floor in scrLevelMaker.instance.listFloors
|
||||
.Where(floor => floor.holdRenderer && floor.holdRenderer.m_meshRenderer.enabled))
|
||||
|
||||
if (Configuration.AdvancedConfig.NonTile)
|
||||
{
|
||||
floor.holdRenderer.m_meshRenderer.enabled = false;
|
||||
// scrHoldRenderer.UpdateColor sets m_meshRenderer.enabled = true every time it runs
|
||||
// (scrHoldRenderer.cs, called from scrPlanet/scrPlayer), so the hold meshes are disabled again every frame.
|
||||
RefreshHoldFloors();
|
||||
foreach (var floor in _holdFloors)
|
||||
{
|
||||
if (!floor || !floor.holdRenderer) continue;
|
||||
var meshRenderer = floor.holdRenderer.m_meshRenderer;
|
||||
if (meshRenderer && meshRenderer.enabled) meshRenderer.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
TriggerOnLastUpdate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the hold floor list when scrLevelMaker.listFloors is a different list or has a different count
|
||||
/// (hold renderers are created together with the floors in scrLevelMaker).
|
||||
/// </summary>
|
||||
private void RefreshHoldFloors()
|
||||
{
|
||||
var floors = scrLevelMaker.instance.listFloors;
|
||||
if (ReferenceEquals(floors, _holdFloorsSource) && floors.Count == _holdFloorsSourceCount) return;
|
||||
|
||||
_holdFloors.Clear();
|
||||
foreach (var floor in floors)
|
||||
{
|
||||
if (floor && floor.holdRenderer) _holdFloors.Add(floor);
|
||||
}
|
||||
_holdFloorsSource = floors;
|
||||
_holdFloorsSourceCount = floors.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frame limit of ImageQueue for the given capture texture: MaxQueuedBytes divided by the readback bytes of
|
||||
/// one frame (YUV 4:2:0 when the GPU conversion is active, RGBA otherwise), within 4 to MaxQueuedFrames.
|
||||
/// </summary>
|
||||
private int ComputeQueueFrameLimit(RenderTexture texture)
|
||||
{
|
||||
var frameBytes = _frameReader?.GetFrameBufferBytes(texture.width, texture.height)
|
||||
?? (long)texture.width * texture.height * 4;
|
||||
if (frameBytes <= 0) return MaxQueuedFrames;
|
||||
|
||||
return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes));
|
||||
}
|
||||
|
||||
// ReSharper disable once Unity.IncorrectMethodSignature
|
||||
public void OnPostFrame(RenderTexture? texture)
|
||||
{
|
||||
if (Instance.stage < RenderStage.Render || !texture) return;
|
||||
|
||||
// [Backpressure Throttling]
|
||||
// The main thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
|
||||
// removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop
|
||||
// re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds).
|
||||
isThrottling = false;
|
||||
|
||||
var frameLimit = QueueFrameLimit = ComputeQueueFrameLimit(texture!);
|
||||
|
||||
while (Instance.stage >= RenderStage.Render)
|
||||
{
|
||||
if (RenderThread is not { IsAlive: true })
|
||||
{
|
||||
isThrottling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
int count;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
count = ImageQueue.Count;
|
||||
}
|
||||
|
||||
if (count <= MaxQueuedFrames) break;
|
||||
if (count <= frameLimit) break;
|
||||
|
||||
if (!isThrottling)
|
||||
{
|
||||
@@ -347,7 +507,10 @@ internal class RenderManager : MonoBehaviour
|
||||
throttleCount++;
|
||||
}
|
||||
|
||||
AsyncGPUReadback.WaitAllRequests();
|
||||
if (!FrameDequeuedEvent.WaitOne(ThrottleWaitMilliseconds))
|
||||
{
|
||||
AsyncGPUReadback.WaitAllRequests();
|
||||
}
|
||||
}
|
||||
isThrottling = false;
|
||||
|
||||
@@ -362,7 +525,7 @@ internal class RenderManager : MonoBehaviour
|
||||
if (videoFrameIndex > lastVideoFrameIndex)
|
||||
{
|
||||
lastVideoFrameIndex = videoFrameIndex;
|
||||
lastRequestedVideoFrameIndex = videoFrameIndex;
|
||||
Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex);
|
||||
|
||||
var targetTexture = texture;
|
||||
if (HasActiveFrameHooks && _frameHookPipeline != null)
|
||||
@@ -403,7 +566,6 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
Application.wantsToQuit -= quitDelegate;
|
||||
RenderConfigWindow.CreateWindow();
|
||||
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -439,7 +601,10 @@ internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
yield return new WaitForSecondsRealtime(1);
|
||||
}
|
||||
|
||||
|
||||
// Cancelled or failed during the countdown: do not start the encoder
|
||||
if (stage != RenderStage.OnScene) yield break;
|
||||
|
||||
frameCount = 0; framesSent = 0;
|
||||
_renderStopwatch.Restart();
|
||||
|
||||
@@ -462,13 +627,20 @@ internal class RenderManager : MonoBehaviour
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
||||
ResetFrameHookState();
|
||||
|
||||
// Release the encoder/thread and restore time, input offset, stage and hooks before showing the dialog
|
||||
MarkFailed($"failed to initialize the video encoder: {ex.Message}");
|
||||
ResetRenderState(success: false);
|
||||
StopFFmpeg(false);
|
||||
RestoreRenderEnvironment();
|
||||
|
||||
CommonWarningDialogUI.ShowDialog(
|
||||
"Encoder Error",
|
||||
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
||||
new DialogButton("OK", () =>
|
||||
{
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
})
|
||||
);
|
||||
yield break;
|
||||
@@ -511,13 +683,14 @@ internal class RenderManager : MonoBehaviour
|
||||
ImageQueue.Clear();
|
||||
}
|
||||
passCount = 0;
|
||||
IsRenderCancelled = false;
|
||||
ClearAbortState();
|
||||
frameCountForVideo = 0;
|
||||
lastVideoFrameIndex = -1;
|
||||
lastRequestedVideoFrameIndex = -1;
|
||||
Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
|
||||
endRequested = false;
|
||||
endSuccessful = false;
|
||||
FrameAvailableEvent.Reset();
|
||||
FrameDequeuedEvent.Reset();
|
||||
|
||||
var renderFPS = Configuration.RenderFPS;
|
||||
var videoFPS = Configuration.VideoFPS;
|
||||
@@ -564,6 +737,7 @@ internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
_allVideoFFmpegInstances.Add(videoFFmpeg);
|
||||
}
|
||||
Volatile.Write(ref _incompleteOutputPath, CurrentOutPath);
|
||||
videoFFmpeg.Start(CurrentOutPath);
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -580,113 +754,8 @@ internal class RenderManager : MonoBehaviour
|
||||
throw;
|
||||
}
|
||||
|
||||
RenderThread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
CapturedFrame frame = default;
|
||||
bool hasFrame = false;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
if (ImageQueue.Count > 0)
|
||||
{
|
||||
var firstKey = ImageQueue.Keys[0];
|
||||
|
||||
if (firstKey > passCount && Instance.stage < RenderStage.Render)
|
||||
{
|
||||
passCount = firstKey;
|
||||
}
|
||||
|
||||
if (firstKey == passCount)
|
||||
{
|
||||
frame = ImageQueue[firstKey];
|
||||
ImageQueue.RemoveAt(0);
|
||||
passCount++;
|
||||
hasFrame = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFrame)
|
||||
{
|
||||
if (frame.ManagedBuffer != null)
|
||||
{
|
||||
if (frame.ManagedBuffer.Length > 0)
|
||||
{
|
||||
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
|
||||
Interlocked.Increment(ref framesSent);
|
||||
|
||||
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
|
||||
}
|
||||
}
|
||||
else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
|
||||
{
|
||||
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
|
||||
Interlocked.Increment(ref framesSent);
|
||||
|
||||
if (frame.ReadbackState != null)
|
||||
{
|
||||
_frameReader?.RecycleState(frame.ReadbackState);
|
||||
}
|
||||
}
|
||||
|
||||
if (Configuration.AudioConfig.RenderAudio)
|
||||
{
|
||||
var frameIdx = (int)(passCount - 1);
|
||||
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
||||
var frameTime = frameIdx / effectiveVideoFps;
|
||||
|
||||
var channels = Configuration.AudioConfig.AudioChannels;
|
||||
var samplesNeeded = SyncCalculator.GetNextFrameSamples();
|
||||
|
||||
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
||||
try
|
||||
{
|
||||
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
|
||||
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<float>.Shared.Return(audioBuffer);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRenderCancelled) break;
|
||||
|
||||
if (endRequested)
|
||||
{
|
||||
if (passCount > lastRequestedVideoFrameIndex) break;
|
||||
}
|
||||
|
||||
FrameAvailableEvent.WaitOne(50);
|
||||
}
|
||||
|
||||
if (!IsRenderCancelled) videoFFmpeg.End();
|
||||
videoFFmpeg.Dispose();
|
||||
|
||||
lock (_allVideoFFmpegInstances)
|
||||
{
|
||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||
}
|
||||
|
||||
_activeVideoFFmpeg = null;
|
||||
endSuccessful = !IsRenderCancelled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
||||
videoFFmpeg.Dispose();
|
||||
lock (_allVideoFFmpegInstances)
|
||||
{
|
||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||
}
|
||||
_activeVideoFFmpeg = null;
|
||||
}
|
||||
})
|
||||
var encoder = videoFFmpeg;
|
||||
RenderThread = new Thread(() => RunRenderThread(encoder))
|
||||
{
|
||||
IsBackground = true,
|
||||
Priority = ThreadPriority.AboveNormal
|
||||
@@ -695,6 +764,172 @@ internal class RenderManager : MonoBehaviour
|
||||
RenderThread.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body of the render thread. Sends queued frames and their audio to the encoder until the render ends,
|
||||
/// finishes the output file, and releases the encoder. An exception is handled as a render failure.
|
||||
/// </summary>
|
||||
private void RunRenderThread(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
try
|
||||
{
|
||||
EncodeQueuedFrames(videoFFmpeg);
|
||||
|
||||
if (!IsRenderAborted)
|
||||
{
|
||||
// End throws when the file cannot be finished (e.g. the trailer cannot be written),
|
||||
// which is handled as a render failure by the catch block below.
|
||||
videoFFmpeg.End();
|
||||
Volatile.Write(ref _incompleteOutputPath, null);
|
||||
}
|
||||
videoFFmpeg.Dispose();
|
||||
UnregisterEncoder(videoFFmpeg);
|
||||
|
||||
endSuccessful = !IsRenderAborted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
||||
|
||||
MarkFailed("encoder thread error. See the log for details.");
|
||||
try
|
||||
{
|
||||
videoFFmpeg.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}");
|
||||
}
|
||||
UnregisterEncoder(videoFFmpeg);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes frames from ImageQueue in passCount order and sends each one to the encoder, followed by the audio
|
||||
/// samples of that frame. Waits on FrameAvailableEvent (50ms) while no frame is ready.
|
||||
/// Returns when the render is aborted (checked before every frame, frames left in ImageQueue are released
|
||||
/// by the cleanup after the thread ends) or all requested frames have been sent after endRequested.
|
||||
/// </summary>
|
||||
private void EncodeQueuedFrames(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (IsRenderAborted) return;
|
||||
|
||||
if (TryDequeueNextFrame(out var frame))
|
||||
{
|
||||
FrameDequeuedEvent.Set();
|
||||
SendVideoFrame(videoFFmpeg, frame);
|
||||
WriteAudioForFrame(videoFFmpeg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldStopEncodeLoop()) return;
|
||||
|
||||
FrameAvailableEvent.WaitOne(50);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
|
||||
/// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
|
||||
/// Returns false when the queue is empty or the next frame has not arrived yet.
|
||||
/// </summary>
|
||||
private bool TryDequeueNextFrame(out CapturedFrame frame)
|
||||
{
|
||||
frame = default;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
if (ImageQueue.Count == 0) return false;
|
||||
|
||||
var firstKey = ImageQueue.Keys[0];
|
||||
if (firstKey > passCount && Instance.stage < RenderStage.Render)
|
||||
{
|
||||
passCount = firstKey;
|
||||
}
|
||||
|
||||
if (firstKey != passCount) return false;
|
||||
|
||||
frame = ImageQueue[firstKey];
|
||||
ImageQueue.RemoveAt(0);
|
||||
passCount++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
|
||||
/// A frame without data is skipped.
|
||||
/// </summary>
|
||||
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
|
||||
{
|
||||
if (frame.ManagedBuffer != null)
|
||||
{
|
||||
if (frame.ManagedBuffer.Length == 0) return;
|
||||
|
||||
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
|
||||
Interlocked.Increment(ref framesSent);
|
||||
|
||||
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.NativePointer == IntPtr.Zero || frame.Length <= 0) return;
|
||||
|
||||
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
|
||||
Interlocked.Increment(ref framesSent);
|
||||
|
||||
if (frame.ReadbackState != null)
|
||||
{
|
||||
_frameReader?.RecycleState(frame.ReadbackState);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
|
||||
/// Does nothing when audio rendering is off.
|
||||
/// </summary>
|
||||
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
|
||||
|
||||
var frameIdx = (int)(passCount - 1);
|
||||
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
||||
var frameTime = frameIdx / effectiveVideoFps;
|
||||
|
||||
var channels = Configuration.AudioConfig.AudioChannels;
|
||||
var samplesNeeded = syncCalculator.GetNextFrameSamples();
|
||||
|
||||
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
||||
try
|
||||
{
|
||||
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
|
||||
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<float>.Shared.Return(audioBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the render is aborted, or when the end was requested and every requested frame has been sent.
|
||||
/// </summary>
|
||||
private bool ShouldStopEncodeLoop()
|
||||
{
|
||||
if (IsRenderAborted) return true;
|
||||
|
||||
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
|
||||
}
|
||||
|
||||
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
lock (_allVideoFFmpegInstances)
|
||||
{
|
||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||
}
|
||||
_activeVideoFFmpeg = null;
|
||||
}
|
||||
|
||||
public void CancelRender()
|
||||
{
|
||||
ResetRenderState(success: false);
|
||||
@@ -708,7 +943,7 @@ internal class RenderManager : MonoBehaviour
|
||||
endRequested = true;
|
||||
if (!success)
|
||||
{
|
||||
IsRenderCancelled = true;
|
||||
MarkCancelled();
|
||||
}
|
||||
FrameAvailableEvent.Set();
|
||||
skipPressToStart = false;
|
||||
@@ -722,9 +957,10 @@ internal class RenderManager : MonoBehaviour
|
||||
if (success) StartCoroutine(StopRenderCoroutine());
|
||||
else
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
|
||||
var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED";
|
||||
Melon<RenderMod>.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources.");
|
||||
|
||||
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
|
||||
// 1. Wait (up to 2s) for the render background thread to see the abort flag and terminate
|
||||
if (RenderThread is { IsAlive: true })
|
||||
{
|
||||
RenderThread.Join(2000);
|
||||
@@ -781,30 +1017,26 @@ internal class RenderManager : MonoBehaviour
|
||||
scrController.instance.noFailInfiniteMargin = false;
|
||||
}
|
||||
|
||||
// 6. Immediately delete cancelled incomplete temporary video file
|
||||
if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath))
|
||||
// 6. Immediately delete the incomplete video file of this render
|
||||
if (DeleteIncompleteOutputFile())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(CurrentOutPath);
|
||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {CurrentOutPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete temporary video: {ex.Message}");
|
||||
}
|
||||
CurrentOutPath = null;
|
||||
}
|
||||
|
||||
var result = BuildResult(false,
|
||||
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
|
||||
var result = BuildResult(false, BuildAbortMessage());
|
||||
TriggerOnComplete(result);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator StopRenderCoroutine()
|
||||
{
|
||||
yield return new WaitUntil(() => endSuccessful);
|
||||
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
|
||||
if (!endSuccessful)
|
||||
{
|
||||
MarkFailed("the render thread terminated unexpectedly.");
|
||||
CancelRender();
|
||||
yield break;
|
||||
}
|
||||
|
||||
var result = BuildResult(success: true);
|
||||
TriggerOnComplete(result);
|
||||
@@ -813,6 +1045,15 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
private void EndRender()
|
||||
{
|
||||
RestoreRenderEnvironment();
|
||||
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
|
||||
private void RestoreRenderEnvironment()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
Time.captureDeltaTime = 0f;
|
||||
@@ -847,12 +1088,11 @@ internal class RenderManager : MonoBehaviour
|
||||
_frameReader?.Dispose();
|
||||
ResetFrameHookState();
|
||||
AudioMixer?.Reset(); AudioMixer = null;
|
||||
SyncCalculator.Reset();
|
||||
|
||||
SyncCalculator = null;
|
||||
|
||||
AudioSpectrumGenerator.Clear();
|
||||
RenderContext.Reset();
|
||||
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
||||
|
||||
+1
-21
@@ -4,8 +4,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 1. Legal Terms (Official)
|
||||
|
||||
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
|
||||
|
||||
As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
|
||||
@@ -21,22 +19,4 @@ An **"independent module"** is a separate and distinct software module that is n
|
||||
|
||||
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
|
||||
|
||||
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
|
||||
|
||||
---
|
||||
|
||||
## 2. 조항 해설 (Korean Guide)
|
||||
|
||||
본 문서는 GNU General Public License 버전 3 (GPLv3) 제7조(추가적인 허가, Additional Permissions)에 따라 제공되는 공식 예외 조항입니다.
|
||||
|
||||
### 핵심 요약
|
||||
|
||||
- **동적 링킹(Dynamic Linking)만 허용**:
|
||||
- 본 예외 조항은 **동적 링킹(런타임 참조 및 어셈블리 동적 로드)**에만 배타적으로 적용됩니다.
|
||||
- ADOFAIRenderer 또는 그 일부 코드를 대상 모드/프로그램에 직접 병합하거나 포함(ILMerge, Costura, 바이너리 직접 내장/정적 컴파일 등)하는 **정적 링킹(Static Linking)은 본 예외 대상에서 제외**되며, 순수 GPLv3의 카피레프트(전염성) 의무가 그대로 적용됩니다.
|
||||
- **외부 모드의 라이선스 자유**:
|
||||
- 제3자 모드 또는 외부 프로그램이 동적 링킹을 통해 `ADOFAIRenderer.API` 및 `ADOFAIRenderer.Data` 네임스페이스의 공개 API, 클래스, 데이터 구조만을 참조·호출하여 연동하는 경우, 해당 모드는 **GPLv3의 카피레프트 의무를 적용받지 않습니다.**
|
||||
- 외부 모드 개발자는 자신의 모드를 MIT, Apache-2.0, 독점/상용 라이선스 등 원하는 라이선스로 자유롭게 배포할 수 있습니다.
|
||||
- **ADOFAIRenderer 코어 보호**:
|
||||
- ADOFAIRenderer 자체의 소스 코드(렌더링 파이프라인, 코어 패치, 내부 유틸리티 등)를 수정하거나 재배포하는 경우에는 본 예외 조항이 적용되지 않으며, 순수 GPLv3 라이선스 조건이 그대로 적용됩니다.
|
||||
|
||||
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
|
||||
@@ -5,6 +5,7 @@
|
||||
"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",
|
||||
@@ -44,10 +45,33 @@
|
||||
"ui.render_config.random_seed": "Random Seed (Optional)",
|
||||
"ui.render_config.validation_title": "Invalid Settings",
|
||||
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
|
||||
"ui.render_config.validation_even_size": "Render width and height must be even numbers (current: {0} x {1}).",
|
||||
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}",
|
||||
"ui.render_config.planet_colors": "Planet Colors (Optional)",
|
||||
"ui.render_config.fire_planet": "Fire Planet",
|
||||
"ui.render_config.ice_planet": "Ice Planet",
|
||||
"ui.render_config.wind_planet": "Wind Planet",
|
||||
"ui.render_config.expired": "(Expired)"
|
||||
"ui.render_config.expired": "(Expired)",
|
||||
"ui.ffmpeg_download.title": "FFmpeg Library Installation Required",
|
||||
"ui.ffmpeg_download.desc_required": "FFmpeg shared libraries are required to render video.",
|
||||
"ui.ffmpeg_download.desc_mac_build": "For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.",
|
||||
"ui.ffmpeg_download.desc_mac_open": "Click the button below to open the official FFmpeg website.",
|
||||
"ui.ffmpeg_download.desc_download": "Click the button below to automatically download and install from the internet.",
|
||||
"ui.ffmpeg_download.desc_size": "<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>",
|
||||
"ui.ffmpeg_download.btn_download": "Download & Install",
|
||||
"ui.ffmpeg_download.btn_open_site": "Open Website",
|
||||
"ui.ffmpeg_download.btn_cancel": "Cancel",
|
||||
"ui.ffmpeg_download.btn_retry": "Retry",
|
||||
"ui.ffmpeg_download.status_starting": "Starting download...",
|
||||
"ui.ffmpeg_download.status_downloading": "Downloading... ({0}%)",
|
||||
"ui.ffmpeg_download.status_extracting": "Extracting and copying library files...",
|
||||
"ui.ffmpeg_download.progress_extracting": "Extracting...",
|
||||
"ui.ffmpeg_download.status_complete": "FFmpeg installation complete!",
|
||||
"ui.ffmpeg_download.status_cancelled": "Download was cancelled.",
|
||||
"ui.ffmpeg_download.status_failed": "Installation failed: {0}",
|
||||
"ui.driver_warning.title": "NVIDIA Graphics Driver Update Recommended",
|
||||
"ui.driver_warning.body": "Currently detected NVIDIA graphics driver version: <b><color=#ff5555>{0}</color></b>\n\nDriver version <b><color=#55ff55>610.00 or higher</color></b> is required to render video using hardware acceleration (NVENC).\n\nIf the driver version is lower, encoding may fail or fallback to software (CPU) encoding, significantly slowing down rendering. Please update your graphics driver.",
|
||||
"ui.driver_warning.btn_update": "Update Driver",
|
||||
"ui.driver_warning.btn_disable": "Disable Feature",
|
||||
"ui.driver_warning.btn_continue": "Continue Anyway"
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"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": "프리셋 삭제",
|
||||
@@ -23,7 +24,7 @@
|
||||
"ui.render_config.audio_encoding": "오디오 인코딩 설정",
|
||||
"ui.render_config.global_settings": "전역 설정",
|
||||
"ui.render_config.advanced_settings": "고급 설정",
|
||||
"ui.render_config.go_render": "렌더 시작",
|
||||
"ui.render_config.go_render": "렌더링하러 가다",
|
||||
"ui.render_config.codec": "코덱",
|
||||
"ui.render_config.rate_control": "비트레이트 제어 모드",
|
||||
"ui.render_config.bitrate": "비트레이트 (kbps)",
|
||||
@@ -44,10 +45,33 @@
|
||||
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
|
||||
"ui.render_config.validation_title": "설정 오류",
|
||||
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
|
||||
"ui.render_config.validation_even_size": "렌더 해상도의 가로와 세로는 짝수여야 해요. (현재: {0} x {1})",
|
||||
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
|
||||
"ui.render_config.planet_colors": "행성 색상 설정 (선택 사항)",
|
||||
"ui.render_config.fire_planet": "불 행성",
|
||||
"ui.render_config.ice_planet": "얼음 행성",
|
||||
"ui.render_config.wind_planet": "바람 행성",
|
||||
"ui.render_config.expired": "(만료됨)"
|
||||
"ui.render_config.expired": "(만료됨)",
|
||||
"ui.ffmpeg_download.title": "FFmpeg 라이브러리 설치 필요",
|
||||
"ui.ffmpeg_download.desc_required": "영상을 렌더링하려면 FFmpeg 공유 라이브러리가 필요해요.",
|
||||
"ui.ffmpeg_download.desc_mac_build": "macOS는 신뢰할 수 있는 공개 빌드가 없어서 FFmpeg 8.1을 직접 빌드해야 해요.",
|
||||
"ui.ffmpeg_download.desc_mac_open": "아래 버튼을 누르면 FFmpeg 공식 웹사이트가 열려요.",
|
||||
"ui.ffmpeg_download.desc_download": "아래 버튼을 누르면 인터넷에서 자동으로 내려받아 설치해요.",
|
||||
"ui.ffmpeg_download.desc_size": "<color=#aaaaaa><size=13>(다운로드 크기: 약 70MB, UserLibs 폴더에 압축 해제)</size></color>",
|
||||
"ui.ffmpeg_download.btn_download": "다운로드 및 설치",
|
||||
"ui.ffmpeg_download.btn_open_site": "웹사이트 열기",
|
||||
"ui.ffmpeg_download.btn_cancel": "취소",
|
||||
"ui.ffmpeg_download.btn_retry": "다시 시도",
|
||||
"ui.ffmpeg_download.status_starting": "다운로드를 시작하는 중...",
|
||||
"ui.ffmpeg_download.status_downloading": "다운로드 중... ({0}%)",
|
||||
"ui.ffmpeg_download.status_extracting": "라이브러리 파일을 압축 해제하고 복사하는 중...",
|
||||
"ui.ffmpeg_download.progress_extracting": "압축 해제 중...",
|
||||
"ui.ffmpeg_download.status_complete": "FFmpeg 설치가 끝났어요!",
|
||||
"ui.ffmpeg_download.status_cancelled": "다운로드가 취소되었어요.",
|
||||
"ui.ffmpeg_download.status_failed": "설치에 실패했어요: {0}",
|
||||
"ui.driver_warning.title": "NVIDIA 그래픽 드라이버 업데이트 권장",
|
||||
"ui.driver_warning.body": "현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{0}</color></b>\n\n하드웨어 가속(NVENC)으로 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요해요.\n\n드라이버 버전이 더 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 바뀌어 렌더링이 훨씬 느려질 수 있어요. 그래픽 드라이버를 업데이트해 주세요.",
|
||||
"ui.driver_warning.btn_update": "드라이버 업데이트",
|
||||
"ui.driver_warning.btn_disable": "기능 끄기",
|
||||
"ui.driver_warning.btn_continue": "그래도 계속"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(DetailedResults))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class DetailedResultsPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("Show")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static bool PreventShow(DetailedResults __instance)
|
||||
{
|
||||
if (!RenderManager.IsActive) return true;
|
||||
|
||||
if (__instance)
|
||||
{
|
||||
__instance.enabled = false;
|
||||
if (__instance.gameObject) __instance.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -41,4 +41,38 @@ public static class scrControllerPatch
|
||||
{
|
||||
return !RenderManager.IsActive;
|
||||
}
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("OnLandOnPortal")]
|
||||
public static void PreventWinTextsPostfix(scrController __instance)
|
||||
{
|
||||
if (!RenderManager.IsActive || __instance == null) return;
|
||||
|
||||
if (__instance.txtCongrats)
|
||||
{
|
||||
__instance.txtCongrats.text = string.Empty;
|
||||
__instance.txtCongrats.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (__instance.txtAprilCongrats)
|
||||
{
|
||||
__instance.txtAprilCongrats.text = string.Empty;
|
||||
__instance.txtAprilCongrats.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (__instance.txtAllStrictClear)
|
||||
{
|
||||
__instance.txtAllStrictClear.text = string.Empty;
|
||||
__instance.txtAllStrictClear.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (__instance.detailedResults)
|
||||
{
|
||||
__instance.detailedResults.enabled = false;
|
||||
if (__instance.detailedResults.gameObject != null)
|
||||
{
|
||||
__instance.detailedResults.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ public class scrPressToStartPatch
|
||||
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
|
||||
|
||||
RenderManager.Instance.skipPressToStart = false;
|
||||
|
||||
// scrPressToStart.ShowText ends with ShowDifficultyContainer(mode). Skipping ShowText skips that call too,
|
||||
// which leaves the difficulty selector active and clickable. DontShow deactivates difficultyContainer
|
||||
// and makes the keyboard difficulty shortcuts (difficultyUIMode != DontShow checks) do nothing.
|
||||
if (scrUIController.instance)
|
||||
scrUIController.instance.ShowDifficultyContainer(DifficultyUIMode.DontShow);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.4-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.4.0-BETA", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||
|
||||
namespace ADOFAIRenderer;
|
||||
|
||||
+94
-168
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MelonLoader;
|
||||
using O5Kit.Core;
|
||||
using O5Kit.Factory;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.UIElements;
|
||||
using Button = UnityEngine.UI.Button;
|
||||
using Image = UnityEngine.UI.Image;
|
||||
|
||||
namespace ADOFAIRenderer.UI;
|
||||
@@ -29,6 +31,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
{
|
||||
public static CommonWarningDialogUI? Instance { get; private set; }
|
||||
private readonly List<VisualElement> _disabledElements = new();
|
||||
private O5Window? _window;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -37,6 +40,9 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
|
||||
foreach (var elem in _disabledElements)
|
||||
{
|
||||
if (elem != null)
|
||||
@@ -83,202 +89,122 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
|
||||
// Set UI layer (5)
|
||||
gameObject.layer = 5;
|
||||
|
||||
|
||||
// Canvas setup
|
||||
var canvas = gameObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
|
||||
|
||||
// Responsive Canvas Scaler setup
|
||||
canvas.sortingOrder = 32766;
|
||||
|
||||
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
scaler.matchWidthOrHeight = 0.5f;
|
||||
|
||||
|
||||
gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// 1. Create full screen overlay background
|
||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
||||
|
||||
var ctx = RendererUIManager.Context;
|
||||
|
||||
// 1. Create full screen backdrop (scrim) to block background input
|
||||
var bgPanelObj = new GameObject("Backdrop");
|
||||
bgPanelObj.transform.SetParent(transform, false);
|
||||
bgPanelObj.layer = 5;
|
||||
|
||||
|
||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
||||
bgImage.raycastTarget = true;
|
||||
|
||||
|
||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||
bgRect.anchorMin = Vector2.zero;
|
||||
bgRect.anchorMax = Vector2.one;
|
||||
bgRect.offsetMin = Vector2.zero;
|
||||
bgRect.offsetMax = Vector2.zero;
|
||||
|
||||
var font = ModAssets.NanumSquareNeoNormal;
|
||||
|
||||
// 2. Create modal container
|
||||
var modalContainerObj = new GameObject("ModalContainer");
|
||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
||||
modalContainerObj.layer = 5;
|
||||
|
||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
|
||||
|
||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
||||
modalOutline.effectColor = Color.white;
|
||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
|
||||
|
||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
||||
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
modalRect.sizeDelta = new Vector2(650, 360);
|
||||
|
||||
// 3. Create title text
|
||||
var titleObj = new GameObject("TitleText");
|
||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
titleObj.layer = 5;
|
||||
|
||||
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
|
||||
titleText.text = title;
|
||||
titleText.fontSize = 24;
|
||||
titleText.font = font;
|
||||
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
|
||||
titleText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
||||
titleRect.anchorMin = new Vector2(0, 1);
|
||||
titleRect.anchorMax = new Vector2(1, 1);
|
||||
titleRect.pivot = new Vector2(0.5f, 1);
|
||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
||||
titleRect.sizeDelta = new Vector2(0, 40);
|
||||
|
||||
// 4. Create description text
|
||||
var descObj = new GameObject("DescriptionText");
|
||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
descObj.layer = 5;
|
||||
|
||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
||||
|
||||
// 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)
|
||||
});
|
||||
|
||||
// 3. Build content inside window.Content
|
||||
var content = _window.Content;
|
||||
|
||||
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||
vLayout.spacing = 16f;
|
||||
vLayout.padding = new RectOffset(16, 16, 12, 12);
|
||||
vLayout.childControlWidth = true;
|
||||
vLayout.childControlHeight = true;
|
||||
vLayout.childForceExpandWidth = true;
|
||||
vLayout.childForceExpandHeight = false;
|
||||
|
||||
// Description text
|
||||
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||
descText.text = description;
|
||||
descText.fontSize = 20;
|
||||
descText.font = font;
|
||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
||||
descText.alignment = TextAlignmentOptions.Center;
|
||||
descText.lineSpacing = 8;
|
||||
|
||||
var descRect = descObj.GetComponent<RectTransform>();
|
||||
descRect.anchorMin = new Vector2(0, 0.5f);
|
||||
descRect.anchorMax = new Vector2(1, 1);
|
||||
descRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
descRect.anchoredPosition = new Vector2(0, -65);
|
||||
descRect.sizeDelta = new Vector2(-40, 120);
|
||||
|
||||
// 5. Create button container
|
||||
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
||||
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
buttonContainerObj.layer = 5;
|
||||
|
||||
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
|
||||
btnContainerRect.anchorMin = new Vector2(0, 0);
|
||||
btnContainerRect.anchorMax = new Vector2(1, 0);
|
||||
btnContainerRect.pivot = new Vector2(0.5f, 0);
|
||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
||||
descText.textWrappingMode = TextWrappingModes.Normal;
|
||||
descText.lineSpacing = 6;
|
||||
descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
|
||||
|
||||
// 6. Dynamic button layout and styling
|
||||
float totalWidth = 560f;
|
||||
float spacing = 15f;
|
||||
float buttonHeight = 45f;
|
||||
int numButtons = buttons.Length;
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var descLE = descText.gameObject.AddComponent<LayoutElement>();
|
||||
descLE.flexibleWidth = 1f;
|
||||
descLE.flexibleHeight = 1f;
|
||||
|
||||
float buttonWidth = numButtons == 1
|
||||
? 200f
|
||||
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
|
||||
// Button action row
|
||||
var btnRowObj = new GameObject("ButtonRow");
|
||||
btnRowObj.transform.SetParent(content, false);
|
||||
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
|
||||
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
|
||||
btnRowLE.preferredHeight = 40f;
|
||||
btnRowLE.flexibleWidth = 1f;
|
||||
|
||||
float startX = numButtons == 1
|
||||
? 0
|
||||
: (-totalWidth / 2f) + (buttonWidth / 2f);
|
||||
var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
|
||||
hLayout.spacing = 10f;
|
||||
hLayout.childAlignment = TextAnchor.MiddleCenter;
|
||||
hLayout.childControlWidth = true;
|
||||
hLayout.childControlHeight = true;
|
||||
hLayout.childForceExpandWidth = true;
|
||||
hLayout.childForceExpandHeight = true;
|
||||
|
||||
for (int i = 0; i < numButtons; i++)
|
||||
for (var i = 0; i < buttons.Length; i++)
|
||||
{
|
||||
var btnData = buttons[i];
|
||||
var btnObj = new GameObject($"Button_{i}");
|
||||
btnObj.transform.SetParent(buttonContainerObj.transform, false);
|
||||
btnObj.layer = 5;
|
||||
var btn = O5Factory.Button(
|
||||
ctx: ctx,
|
||||
parent: btnRowRect,
|
||||
onClick: () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
btnData.OnClick();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
||||
}
|
||||
Destroy(gameObject);
|
||||
},
|
||||
text: btnData.Text,
|
||||
id: $"btn_dialog_{i}",
|
||||
height: 40f
|
||||
);
|
||||
|
||||
// Define default colors
|
||||
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(() =>
|
||||
if (btnData.BackgroundColor.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
btnData.OnClick();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
||||
}
|
||||
Destroy(gameObject);
|
||||
});
|
||||
|
||||
var btnRect = btnObj.GetComponent<RectTransform>();
|
||||
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
btnRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
|
||||
float posX = numButtons == 1
|
||||
? 0
|
||||
: startX + i * (buttonWidth + spacing);
|
||||
|
||||
btnRect.anchoredPosition = new Vector2(posX, 0);
|
||||
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
|
||||
btn.NormalColor = btnData.BackgroundColor.Value;
|
||||
btn.UpdateVisual();
|
||||
}
|
||||
|
||||
// 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;
|
||||
var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||
le.flexibleWidth = 1f;
|
||||
le.preferredHeight = 40f;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
|
||||
{
|
||||
button.transition = Selectable.Transition.ColorTint;
|
||||
var colors = button.colors;
|
||||
colors.normalColor = normalColor;
|
||||
colors.highlightedColor = hoverColor;
|
||||
colors.pressedColor = pressedColor;
|
||||
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
||||
button.colors = colors;
|
||||
}
|
||||
|
||||
private Color ApplyColorBrightness(Color color, float factor)
|
||||
{
|
||||
float r = Mathf.Clamp01(color.r * factor);
|
||||
float g = Mathf.Clamp01(color.g * factor);
|
||||
float b = Mathf.Clamp01(color.b * factor);
|
||||
return new Color(r, g, b, color.a);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@ internal class DebugMonitorUI : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {RenderManager.MaxQueuedFrames} frames");
|
||||
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {(RenderManager.Instance ? RenderManager.Instance.QueueFrameLimit : RenderManager.MaxQueuedFrames)} frames");
|
||||
sb.AppendLine($"• Rendered Frames: <b>{(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}</b>");
|
||||
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
|
||||
|
||||
|
||||
+163
-220
@@ -1,14 +1,17 @@
|
||||
using System;
|
||||
using ADOFAIRenderer.Localization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using O5Kit.Core;
|
||||
using O5Kit.Factory;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
@@ -22,6 +25,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
private GameObject _statusTextObj = null!;
|
||||
private TextMeshProUGUI _statusText = null!;
|
||||
|
||||
private O5Window? _window;
|
||||
private bool _isDownloading;
|
||||
private string DownloadUrl => Application.platform switch
|
||||
{
|
||||
@@ -103,14 +107,16 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
scaler.matchWidthOrHeight = 0.5f;
|
||||
|
||||
gameObject.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// 1. Create full screen overlay background
|
||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
||||
|
||||
var ctx = RendererUIManager.Context;
|
||||
|
||||
// 1. Create full screen backdrop
|
||||
var bgPanelObj = new GameObject("Backdrop");
|
||||
bgPanelObj.transform.SetParent(transform, false);
|
||||
bgPanelObj.layer = 5;
|
||||
|
||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
||||
bgImage.raycastTarget = true;
|
||||
|
||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||
@@ -119,254 +125,189 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
bgRect.offsetMin = Vector2.zero;
|
||||
bgRect.offsetMax = Vector2.zero;
|
||||
|
||||
var font = ModAssets.NanumSquareNeoNormal;
|
||||
|
||||
// 2. Create modal container
|
||||
var modalContainerObj = new GameObject("ModalContainer");
|
||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
||||
modalContainerObj.layer = 5;
|
||||
|
||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
|
||||
|
||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
||||
modalOutline.effectColor = Color.white;
|
||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
||||
|
||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
||||
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
modalRect.sizeDelta = new Vector2(600, 320);
|
||||
|
||||
// 4. Create title text
|
||||
var titleObj = new GameObject("TitleText");
|
||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
titleObj.layer = 5;
|
||||
|
||||
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
|
||||
titleText.text = "FFmpeg Library Installation Required";
|
||||
titleText.fontSize = 26;
|
||||
titleText.font = font;
|
||||
titleText.color = Color.white;
|
||||
titleText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
||||
titleRect.anchorMin = new Vector2(0, 1);
|
||||
titleRect.anchorMax = new Vector2(1, 1);
|
||||
titleRect.pivot = new Vector2(0.5f, 1);
|
||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
||||
titleRect.sizeDelta = new Vector2(0, 40);
|
||||
|
||||
// 5. Create description text
|
||||
var descObj = new GameObject("DescriptionText");
|
||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
descObj.layer = 5;
|
||||
|
||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
||||
// 2. Create actual native O5Window
|
||||
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||
{
|
||||
Id = "RendererMod_FFmpegDownloadDialog",
|
||||
Title = LocalizationManager.Get("ui.ffmpeg_download.title"),
|
||||
Size = new Vector2(580, 270),
|
||||
Draggable = true,
|
||||
Resizable = false,
|
||||
ShowCloseButton = false,
|
||||
ShowOutline = true,
|
||||
Padding = new RectOffset(16, 16, 16, 16)
|
||||
});
|
||||
|
||||
var content = _window.Content;
|
||||
|
||||
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||
vLayout.spacing = 14f;
|
||||
vLayout.padding = new RectOffset(16, 16, 10, 10);
|
||||
vLayout.childControlWidth = true;
|
||||
vLayout.childControlHeight = true;
|
||||
vLayout.childForceExpandWidth = true;
|
||||
vLayout.childForceExpandHeight = false;
|
||||
|
||||
// 3. Description text
|
||||
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||
var descStrBuilder = new StringBuilder();
|
||||
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
|
||||
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_required"));
|
||||
if (Application.platform == RuntimePlatform.OSXPlayer)
|
||||
{
|
||||
descStrBuilder.AppendLine("For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.");
|
||||
descStrBuilder.AppendLine("Click the button below to open the official FFmpeg website.");
|
||||
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_build"));
|
||||
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_open"));
|
||||
}
|
||||
else
|
||||
{
|
||||
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(LocalizationManager.Get("ui.ffmpeg_download.desc_download"));
|
||||
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_size"));
|
||||
}
|
||||
|
||||
descText.text = descStrBuilder.ToString();
|
||||
descText.fontSize = 16;
|
||||
descText.font = font;
|
||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
||||
descText.alignment = TextAlignmentOptions.Center;
|
||||
descText.lineSpacing = 10;
|
||||
|
||||
var descRect = descObj.GetComponent<RectTransform>();
|
||||
descRect.anchorMin = new Vector2(0, 0.5f);
|
||||
descRect.anchorMax = new Vector2(1, 1);
|
||||
descRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
descRect.anchoredPosition = new Vector2(0, -60);
|
||||
descRect.sizeDelta = new Vector2(-40, 100);
|
||||
|
||||
// 6. Create button container
|
||||
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
||||
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
_buttonContainerObj.layer = 5;
|
||||
|
||||
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>();
|
||||
btnContainerRect.anchorMin = new Vector2(0, 0);
|
||||
btnContainerRect.anchorMax = new Vector2(1, 0);
|
||||
btnContainerRect.pivot = new Vector2(0.5f, 0);
|
||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
||||
|
||||
// 7. Create download button
|
||||
var downloadBtnObj = new GameObject("DownloadButton");
|
||||
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
||||
downloadBtnObj.layer = 5;
|
||||
|
||||
var downloadImg = downloadBtnObj.AddComponent<Image>();
|
||||
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
|
||||
downloadImg.raycastTarget = true;
|
||||
|
||||
var downloadBtn = downloadBtnObj.AddComponent<Button>();
|
||||
SetupButtonStyle(downloadBtn, new Color(0.0f, 0.8f, 1.0f, 1.0f), new Color(0.2f, 0.9f, 1.0f, 1.0f), new Color(0.0f, 0.6f, 0.8f, 1.0f));
|
||||
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess);
|
||||
else
|
||||
{
|
||||
downloadBtn.onClick.AddListener(OpenFFmpegWebsite);
|
||||
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }
|
||||
}
|
||||
descText.textWrappingMode = TextWrappingModes.Normal;
|
||||
descText.lineSpacing = 5;
|
||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
||||
|
||||
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>();
|
||||
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
|
||||
downloadBtnRect.anchoredPosition = new Vector2(-15, 0);
|
||||
downloadBtnRect.sizeDelta = new Vector2(180, 45);
|
||||
|
||||
var downloadTextObj = new GameObject("DownloadText");
|
||||
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
|
||||
downloadTextObj.layer = 5;
|
||||
|
||||
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
|
||||
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
|
||||
downloadText.fontSize = 20;
|
||||
downloadText.font = font;
|
||||
downloadText.color = Color.white;
|
||||
downloadText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>();
|
||||
downloadTextRect.anchorMin = Vector2.zero;
|
||||
downloadTextRect.anchorMax = Vector2.one;
|
||||
downloadTextRect.offsetMin = Vector2.zero;
|
||||
downloadTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 8. Create cancel button
|
||||
var cancelBtnObj = new GameObject("CancelButton");
|
||||
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
||||
cancelBtnObj.layer = 5;
|
||||
|
||||
var cancelImg = cancelBtnObj.AddComponent<Image>();
|
||||
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f);
|
||||
cancelImg.raycastTarget = true;
|
||||
|
||||
var cancelBtn = cancelBtnObj.AddComponent<Button>();
|
||||
SetupButtonStyle(cancelBtn, new Color(0.2f, 0.2f, 0.23f, 1f), new Color(0.25f, 0.25f, 0.28f, 1f), new Color(0.15f, 0.15f, 0.18f, 1f));
|
||||
cancelBtn.onClick.AddListener(CancelProcess);
|
||||
|
||||
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>();
|
||||
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
cancelBtnRect.pivot = new Vector2(0f, 0.5f);
|
||||
cancelBtnRect.anchoredPosition = new Vector2(15, 0);
|
||||
cancelBtnRect.sizeDelta = new Vector2(180, 45);
|
||||
|
||||
var cancelTextObj = new GameObject("CancelText");
|
||||
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false);
|
||||
cancelTextObj.layer = 5;
|
||||
|
||||
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
|
||||
cancelText.text = "Cancel";
|
||||
cancelText.fontSize = 20;
|
||||
cancelText.font = font;
|
||||
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
|
||||
cancelText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var cancelTextRect = cancelTextObj.GetComponent<RectTransform>();
|
||||
cancelTextRect.anchorMin = Vector2.zero;
|
||||
cancelTextRect.anchorMax = Vector2.one;
|
||||
cancelTextRect.offsetMin = Vector2.zero;
|
||||
cancelTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 9. Create progress bar container
|
||||
var descLE = descText.gameObject.AddComponent<LayoutElement>();
|
||||
descLE.flexibleWidth = 1f;
|
||||
descLE.flexibleHeight = 1f;
|
||||
|
||||
// 4. Button container
|
||||
_buttonContainerObj = new GameObject("ButtonContainer");
|
||||
_buttonContainerObj.transform.SetParent(content, false);
|
||||
_buttonContainerObj.layer = 5;
|
||||
|
||||
var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
|
||||
btnContainerLE.preferredHeight = 40f;
|
||||
btnContainerLE.flexibleWidth = 1f;
|
||||
|
||||
var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
|
||||
btnLayout.spacing = 10f;
|
||||
btnLayout.childAlignment = TextAnchor.MiddleCenter;
|
||||
btnLayout.childControlWidth = true;
|
||||
btnLayout.childControlHeight = true;
|
||||
btnLayout.childForceExpandWidth = true;
|
||||
btnLayout.childForceExpandHeight = true;
|
||||
|
||||
Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
|
||||
? StartDownloadProcess
|
||||
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
|
||||
|
||||
var downloadBtn = O5Factory.Button(
|
||||
ctx: ctx,
|
||||
parent: _buttonContainerObj.transform,
|
||||
onClick: onDownloadClick,
|
||||
text: LocalizationManager.Get(Application.platform != RuntimePlatform.OSXPlayer ? "ui.ffmpeg_download.btn_download" : "ui.ffmpeg_download.btn_open_site"),
|
||||
id: "btn_ffmpeg_download",
|
||||
height: 40f
|
||||
);
|
||||
downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
|
||||
downloadBtn.UpdateVisual();
|
||||
|
||||
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||
downloadBtnLE.flexibleWidth = 1f;
|
||||
downloadBtnLE.preferredHeight = 40f;
|
||||
|
||||
var cancelBtn = O5Factory.Button(
|
||||
ctx: ctx,
|
||||
parent: _buttonContainerObj.transform,
|
||||
onClick: CancelProcess,
|
||||
text: LocalizationManager.Get("ui.ffmpeg_download.btn_cancel"),
|
||||
id: "btn_ffmpeg_cancel",
|
||||
height: 40f
|
||||
);
|
||||
var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||
cancelBtnLE.flexibleWidth = 1f;
|
||||
cancelBtnLE.preferredHeight = 40f;
|
||||
|
||||
// 5. Progress bar container
|
||||
_progressBarObj = new GameObject("ProgressBarContainer");
|
||||
_progressBarObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
_progressBarObj.transform.SetParent(content, false);
|
||||
_progressBarObj.layer = 5;
|
||||
_progressBarObj.SetActive(false);
|
||||
|
||||
|
||||
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
|
||||
pbLE.preferredHeight = 24f;
|
||||
pbLE.flexibleWidth = 1f;
|
||||
|
||||
var barBgImg = _progressBarObj.AddComponent<Image>();
|
||||
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
|
||||
|
||||
var barBgOutline = _progressBarObj.AddComponent<Outline>();
|
||||
barBgOutline.effectColor = Color.white;
|
||||
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
||||
|
||||
var barRect = _progressBarObj.GetComponent<RectTransform>();
|
||||
barRect.anchorMin = new Vector2(0.5f, 0);
|
||||
barRect.anchorMax = new Vector2(0.5f, 0);
|
||||
barRect.pivot = new Vector2(0.5f, 0);
|
||||
barRect.anchoredPosition = new Vector2(0, 75);
|
||||
barRect.sizeDelta = new Vector2(480, 24);
|
||||
|
||||
// 10. Create progress bar fill image
|
||||
barBgImg.sprite = ctx.Sprites.RoundedControl;
|
||||
barBgImg.type = Image.Type.Sliced;
|
||||
barBgImg.color = ctx.Theme.ObjectBG;
|
||||
|
||||
var barOutlineObj = new GameObject("BarOutline");
|
||||
barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
|
||||
barOutlineObj.layer = 5;
|
||||
|
||||
var barOutlineImg = barOutlineObj.AddComponent<Image>();
|
||||
barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
|
||||
barOutlineImg.type = Image.Type.Sliced;
|
||||
barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
|
||||
barOutlineImg.raycastTarget = false;
|
||||
|
||||
var barOutlineRect = barOutlineObj.GetComponent<RectTransform>();
|
||||
barOutlineRect.anchorMin = Vector2.zero;
|
||||
barOutlineRect.anchorMax = Vector2.one;
|
||||
barOutlineRect.offsetMin = Vector2.zero;
|
||||
barOutlineRect.offsetMax = Vector2.zero;
|
||||
|
||||
// Progress bar fill image
|
||||
_progressBarFillObj = new GameObject("ProgressBarFill");
|
||||
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
||||
_progressBarFillObj.layer = 5;
|
||||
|
||||
|
||||
var fillImg = _progressBarFillObj.AddComponent<Image>();
|
||||
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
|
||||
|
||||
fillImg.sprite = ctx.Sprites.RoundedControl;
|
||||
fillImg.type = Image.Type.Sliced;
|
||||
fillImg.color = ctx.Theme.ObjectActiveBright;
|
||||
|
||||
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
|
||||
_fillRect.anchorMin = new Vector2(0, 0);
|
||||
_fillRect.anchorMax = new Vector2(0, 1);
|
||||
_fillRect.pivot = new Vector2(0, 0.5f);
|
||||
_fillRect.anchoredPosition = Vector2.zero;
|
||||
_fillRect.sizeDelta = Vector2.zero;
|
||||
|
||||
// 11. Create progress text
|
||||
|
||||
// Progress text
|
||||
var progressTextObj = new GameObject("ProgressBarText");
|
||||
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
||||
progressTextObj.layer = 5;
|
||||
|
||||
|
||||
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
||||
_progressText.fontSize = 16;
|
||||
_progressText.font = font;
|
||||
_progressText.fontSize = 13;
|
||||
_progressText.font = ctx.Fonts.Regular;
|
||||
_progressText.color = Color.white;
|
||||
_progressText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var progressTextOutline = progressTextObj.AddComponent<Outline>();
|
||||
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
|
||||
progressTextOutline.effectDistance = new Vector2(1, -1);
|
||||
|
||||
_progressText.raycastTarget = false;
|
||||
|
||||
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
|
||||
progressTextRect.anchorMin = Vector2.zero;
|
||||
progressTextRect.anchorMax = Vector2.one;
|
||||
progressTextRect.offsetMin = Vector2.zero;
|
||||
progressTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
// 12. Create status text
|
||||
|
||||
// 6. Status text
|
||||
_statusTextObj = new GameObject("StatusText");
|
||||
_statusTextObj.transform.SetParent(modalContainerObj.transform, false);
|
||||
_statusTextObj.transform.SetParent(content, false);
|
||||
_statusTextObj.layer = 5;
|
||||
_statusTextObj.SetActive(false);
|
||||
|
||||
|
||||
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
|
||||
statusLE.preferredHeight = 22f;
|
||||
statusLE.flexibleWidth = 1f;
|
||||
|
||||
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
|
||||
_statusText.fontSize = 16;
|
||||
_statusText.font = font;
|
||||
_statusText.fontSize = 14;
|
||||
_statusText.font = ctx.Fonts.Regular;
|
||||
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
|
||||
_statusText.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var statusRect = _statusTextObj.GetComponent<RectTransform>();
|
||||
statusRect.anchorMin = new Vector2(0, 0);
|
||||
statusRect.anchorMax = new Vector2(1, 0);
|
||||
statusRect.pivot = new Vector2(0.5f, 0);
|
||||
statusRect.anchoredPosition = new Vector2(0, 35);
|
||||
statusRect.sizeDelta = new Vector2(0, 30);
|
||||
}
|
||||
_statusText.raycastTarget = false;
|
||||
|
||||
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;
|
||||
var statusRect = _statusTextObj.GetComponent<RectTransform>();
|
||||
statusRect.anchorMin = Vector2.zero;
|
||||
statusRect.anchorMax = Vector2.one;
|
||||
statusRect.offsetMin = Vector2.zero;
|
||||
statusRect.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -401,7 +342,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
string tempZipPath = "";
|
||||
try
|
||||
{
|
||||
UpdateStatus("Starting download...", new Color(0.8f, 0.8f, 0.8f));
|
||||
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_starting"), new Color(0.8f, 0.8f, 0.8f));
|
||||
UpdateProgress(0f, "0%");
|
||||
|
||||
var userLibsDir = MelonEnvironment.UserLibsDirectory;
|
||||
@@ -421,19 +362,19 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
var progressReporter = new Progress<float>(percent =>
|
||||
{
|
||||
UpdateProgress(percent, $"{percent * 100f:F1}%");
|
||||
UpdateStatus($"Downloading... ({(percent * 100f):F1}%)", new Color(0f, 0.8f, 1f));
|
||||
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_downloading", $"{percent * 100f:F1}"), new Color(0f, 0.8f, 1f));
|
||||
});
|
||||
|
||||
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
|
||||
|
||||
// Extract after download complete
|
||||
UpdateStatus("Extracting and copying library files...", new Color(0.9f, 0.9f, 0.2f));
|
||||
UpdateProgress(0.99f, "Extracting...");
|
||||
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_extracting"), new Color(0.9f, 0.9f, 0.2f));
|
||||
UpdateProgress(0.99f, LocalizationManager.Get("ui.ffmpeg_download.progress_extracting"));
|
||||
|
||||
await ExtractDllsAsync(tempZipPath, userLibsDir);
|
||||
|
||||
// Update completion status
|
||||
UpdateStatus("FFmpeg installation complete!", new Color(0.2f, 0.9f, 0.2f));
|
||||
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_complete"), new Color(0.2f, 0.9f, 0.2f));
|
||||
UpdateProgress(1f, "100%");
|
||||
|
||||
await Task.Delay(1000);
|
||||
@@ -455,12 +396,12 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
UpdateStatus("Download was cancelled.", new Color(0.9f, 0.5f, 0.2f));
|
||||
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_cancelled"), new Color(0.9f, 0.5f, 0.2f));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
|
||||
UpdateStatus($"Installation failed: {ex.Message}", new Color(1f, 0.3f, 0.3f));
|
||||
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_failed", ex.Message), new Color(1f, 0.3f, 0.3f));
|
||||
|
||||
// Reset buttons on failure
|
||||
ExecuteOnMainThread(() =>
|
||||
@@ -470,7 +411,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
|
||||
if (downloadBtnText)
|
||||
{
|
||||
downloadBtnText.text = "Retry";
|
||||
downloadBtnText.text = LocalizationManager.Get("ui.ffmpeg_download.btn_retry");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -643,6 +584,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
_cts?.Dispose();
|
||||
if (Instance == this)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using ADOFAIRenderer.Localization;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
|
||||
/// automatically updating the displayed text whenever the active language changes.
|
||||
/// </summary>
|
||||
internal class LocalizedText : MonoBehaviour
|
||||
{
|
||||
private TextMeshProUGUI? _textComponent;
|
||||
private string? _key;
|
||||
private Func<string>? _dynamicProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the localization key. Setting this will clear any dynamic provider.
|
||||
/// </summary>
|
||||
public string? Key
|
||||
{
|
||||
get => _key;
|
||||
set
|
||||
{
|
||||
_key = value;
|
||||
_dynamicProvider = null;
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a dynamic string provider function.
|
||||
/// </summary>
|
||||
public void SetDynamic(Func<string> provider)
|
||||
{
|
||||
_dynamicProvider = provider;
|
||||
_key = null;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
EnsureComponent();
|
||||
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
|
||||
}
|
||||
|
||||
private void HandleLanguageChanged(SystemLanguage language)
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void EnsureComponent()
|
||||
{
|
||||
if (_textComponent == null)
|
||||
{
|
||||
_textComponent = GetComponent<TextMeshProUGUI>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the text using either the dynamic provider or the static localization key.
|
||||
/// </summary>
|
||||
public void Refresh()
|
||||
{
|
||||
EnsureComponent();
|
||||
if (_textComponent == null) return;
|
||||
|
||||
if (_dynamicProvider != null)
|
||||
{
|
||||
_textComponent.text = _dynamicProvider();
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_key))
|
||||
{
|
||||
_textComponent.text = LocalizationManager.Get(_key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds a TextMeshProUGUI component to a localization key.
|
||||
/// </summary>
|
||||
public static LocalizedText Bind(TextMeshProUGUI target, string key)
|
||||
{
|
||||
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
|
||||
component._textComponent = target;
|
||||
component.Key = key;
|
||||
return component;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
|
||||
/// </summary>
|
||||
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
|
||||
{
|
||||
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
|
||||
component._textComponent = target;
|
||||
component.SetDynamic(provider);
|
||||
return component;
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,15 @@ internal class RendererUIManager : MonoBehaviour
|
||||
);
|
||||
|
||||
_windowManager = O5WindowManager.Create(_context, transform, "RendererMod_Canvas");
|
||||
|
||||
// The O5Kit canvas is created with sortingOrder 32767. It is lowered so that the dialogs of
|
||||
// CommonWarningDialogUI (32766) are drawn above the windows of this manager.
|
||||
var windowCanvas = transform.Find("RendererMod_Canvas")?.GetComponent<Canvas>();
|
||||
if (windowCanvas != null) windowCanvas.sortingOrder = WindowCanvasSortingOrder;
|
||||
}
|
||||
|
||||
private const int WindowCanvasSortingOrder = 32000;
|
||||
|
||||
private void Update() {
|
||||
O5Object.TickAll();
|
||||
Context.Tooltip.Tick();
|
||||
|
||||
+493
-201
File diff suppressed because it is too large.
Load diff
@@ -108,7 +108,7 @@ internal static class AudioSpectrumGenerator
|
||||
return;
|
||||
}
|
||||
|
||||
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
|
||||
var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
|
||||
|
||||
lock (CachedSpectrums)
|
||||
{
|
||||
|
||||
@@ -15,11 +15,6 @@ internal class AudioSyncCalculator
|
||||
{
|
||||
_sampleRate = sampleRate;
|
||||
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
||||
|
||||
// NTSC
|
||||
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
|
||||
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
|
||||
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
|
||||
+320
-136
@@ -23,7 +23,7 @@ namespace ADOFAIRenderer.Util.FFmpeg;
|
||||
/// 2. Codec & Hardware Device Discovery:
|
||||
/// a) avcodec_find_decoder: Locate codec implementation by codec ID.
|
||||
/// b) avcodec_get_hw_config: Query supported hardware configurations for the decoder.
|
||||
/// c) Prioritized HW device creation (D3D11VA → DXVA2 → QSV → CUDA → D3D12VA → VAAPI → VideoToolbox).
|
||||
/// c) HW device creation for the reported configurations, in the order of PreferredHwDeviceTypes.
|
||||
/// d) Attach hw_device_ctx and register custom get_format callback.
|
||||
/// e) If HW initialization fails or is unsupported, fall back to CPU software decoding
|
||||
/// with multi-threading enabled (thread_count = 0, FF_THREAD_FRAME).
|
||||
@@ -35,8 +35,10 @@ namespace ADOFAIRenderer.Util.FFmpeg;
|
||||
/// c) (HW Transfer) For hardware frames, av_hwframe_transfer_data downloads GPU surface data
|
||||
/// into system RAM as NV12 (av_frame_copy_props preserves timestamps).
|
||||
/// d) (Output Dispatch):
|
||||
/// - TryReadNV12Frame: Zero-copy direct memory transfer for NV12 frames, sws_scale fallback.
|
||||
/// - TryReadYUV420PFrame: Zero-copy planar copy for YUV420P/YUVJ420P, sws_scale fallback.
|
||||
/// - TryReadNV12Frame: NV12 frames are copied row by row (Buffer.MemoryCopy) into the output buffers,
|
||||
/// other formats are converted with sws_scale.
|
||||
/// - TryReadYUV420PFrame: YUV420P/YUVJ420P planes are copied row by row into the output buffers,
|
||||
/// other formats are converted with sws_scale.
|
||||
/// - TryReadFrame: sws_scale conversion to RGBA32 with optional vertical flip (vflip).
|
||||
///
|
||||
/// 4. Seeking & Scrubbing (SeekToTime / SeekToFrame):
|
||||
@@ -76,9 +78,12 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Preferred hardware device types in priority order.
|
||||
/// Modern Windows platforms prioritize D3D11VA for wide driver support and direct integration,
|
||||
/// followed by DXVA2 (DirectX 9 / legacy), Intel QSV, NVIDIA CUDA, and DirectX 12 D3D12VA.
|
||||
/// Non-Windows systems fall back to VAAPI or VideoToolbox.
|
||||
/// The candidates are the hardware configurations the selected decoder reports (avcodec_get_hw_config),
|
||||
/// sorted by this list; configurations not in the list follow in reported order.
|
||||
/// Windows: D3D11VA, then DXVA2 (DirectX 9), CUDA and D3D12VA. Other platforms: VAAPI or VideoToolbox.
|
||||
/// The QSV entry matches only decoders that report a QSV configuration. The native decoders returned by
|
||||
/// avcodec_find_decoder report DXVA2/D3D11VA/D3D12VA/CUDA/VAAPI/Vulkan in the bundled FFmpeg 8 build;
|
||||
/// the QSV decoders (h264_qsv, etc.) are separate decoders selected by name.
|
||||
/// </summary>
|
||||
private static readonly AVHWDeviceType[] PreferredHwDeviceTypes = new[]
|
||||
{
|
||||
@@ -191,6 +196,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
private bool _isOpened;
|
||||
private bool _disposed;
|
||||
private bool _isDraining;
|
||||
|
||||
/// <summary>Number of video packets skipped because the decoder rejected them as invalid data.</summary>
|
||||
private int _skippedPacketCount;
|
||||
|
||||
/// <summary>Number of skipped packets for which a warning is logged.</summary>
|
||||
private const int MaxLoggedSkippedPackets = 5;
|
||||
|
||||
private readonly object _lock = new();
|
||||
|
||||
// Fields to detect SwsContext updates
|
||||
@@ -199,6 +211,12 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
private int _srcWidth;
|
||||
private int _srcHeight;
|
||||
|
||||
/// <summary>YUV→RGB matrix id (<c>SWS_CS_*</c>) applied to the context. 0 when no matrix is set (non-RGBA output).</summary>
|
||||
private int _srcSwsColorspace;
|
||||
|
||||
/// <summary>Whether the source range applied to the context is the full range.</summary>
|
||||
private bool _srcFullRange;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Public Properties
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -289,6 +307,59 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Color matrix id used to convert the YUV frames of this stream to RGB, selected from the colorspace tag of the stream.
|
||||
/// The ids are shared with the <c>ColorMatrix</c> uniform of ColorConversion.compute:
|
||||
/// 0 = BT.709 (also used when the stream has no usable tag), 1 = BT.601, 2 = BT.2020 (non-constant luminance).
|
||||
/// </summary>
|
||||
public int ColorMatrix
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pCodecContext != null && _pCodecContext->colorspace != AVColorSpace.AVCOL_SPC_UNSPECIFIED)
|
||||
{
|
||||
return GetColorMatrixId(_pCodecContext->colorspace);
|
||||
}
|
||||
if (_pFormatContext != null && _videoStreamIndex >= 0)
|
||||
{
|
||||
return GetColorMatrixId(_pFormatContext->streams[_videoStreamIndex]->codecpar->color_space);
|
||||
}
|
||||
return ColorMatrixBT709;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Color matrix id of BT.709.</summary>
|
||||
// ReSharper disable InconsistentNaming
|
||||
public const int ColorMatrixBT709 = 0;
|
||||
|
||||
/// <summary>Color matrix id of BT.601 (SMPTE 170M / BT.470 BG).</summary>
|
||||
public const int ColorMatrixBT601 = 1;
|
||||
|
||||
/// <summary>Color matrix id of BT.2020 (non-constant luminance).</summary>
|
||||
public const int ColorMatrixBT2020 = 2;
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
/// <summary>
|
||||
/// Converts a colorspace tag to a color matrix id (see <see cref="ColorMatrix"/>).
|
||||
/// Tags that have no matrix of their own (unspecified, RGB, other standards) use BT.709.
|
||||
/// </summary>
|
||||
private static int GetColorMatrixId(AVColorSpace colorspace)
|
||||
{
|
||||
switch (colorspace)
|
||||
{
|
||||
case AVColorSpace.AVCOL_SPC_SMPTE170M:
|
||||
case AVColorSpace.AVCOL_SPC_BT470BG:
|
||||
return ColorMatrixBT601;
|
||||
case AVColorSpace.AVCOL_SPC_BT2020_NCL:
|
||||
return ColorMatrixBT2020;
|
||||
default:
|
||||
return ColorMatrixBT709;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether hardware-accelerated decoding is currently active.
|
||||
/// </summary>
|
||||
@@ -371,6 +442,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
if (_videoStreamIndex == -1)
|
||||
throw new Exception("Failed to find video stream.");
|
||||
|
||||
// Only the video stream is used. The demuxer does not need to return the packets of the other streams
|
||||
// (audio, subtitles, ...) once they are marked as discarded.
|
||||
// Reference: AVStream.discard - "Selects which packets can be discarded at will and do not need to be demuxed."
|
||||
// https://ffmpeg.org/doxygen/trunk/structAVStream.html
|
||||
for (int i = 0; i < _pFormatContext->nb_streams; i++)
|
||||
{
|
||||
if (i != _videoStreamIndex)
|
||||
_pFormatContext->streams[i]->discard = AVDiscard.AVDISCARD_ALL;
|
||||
}
|
||||
|
||||
var stream = _pFormatContext->streams[_videoStreamIndex];
|
||||
_timeBase = stream->time_base;
|
||||
|
||||
@@ -441,9 +522,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
TotalFrames = 0;
|
||||
}
|
||||
|
||||
Duration = _pFormatContext->duration > 0
|
||||
? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE
|
||||
: (stream->duration > 0 ? stream->duration * ffmpeg.av_q2d(stream->time_base) : 0);
|
||||
// Duration of the video stream (AVStream.duration, in the stream time base).
|
||||
// When the stream does not carry a duration, the container duration (AVFormatContext.duration,
|
||||
// in AV_TIME_BASE units, the longest of all streams) is used.
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/structAVStream.html, https://ffmpeg.org/doxygen/trunk/structAVFormatContext.html
|
||||
Duration = stream->duration > 0
|
||||
? stream->duration * ffmpeg.av_q2d(stream->time_base)
|
||||
: _pFormatContext->duration > 0 ? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE : 0;
|
||||
|
||||
// 9. Allocate frame and packet buffers
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
||||
@@ -581,9 +666,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
_getFormatDelegate = GetFormat;
|
||||
_pCodecContext->get_format = _getFormatDelegate;
|
||||
|
||||
// Hardware decoding requires single-threaded decoding (thread_count = 1).
|
||||
// Multi-threading (thread_count = 0 / FF_THREAD_FRAME) is incompatible with fixed-function
|
||||
// GPU decoders and causes surface pool allocation exhaustion or crashes in get_format.
|
||||
// Hardware decoding runs with one decoder thread (thread_count = 1).
|
||||
// The GPU performs the decoding; the decoder thread only parses packets and submits them.
|
||||
// With frame threading, libavcodec adds one hardware surface per thread to the frame pool
|
||||
// (avcodec_get_hw_frames_parameters). The software fallback in GetFormat keeps this thread count.
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/decode.c
|
||||
_pCodecContext->thread_count = 1;
|
||||
|
||||
// Attempt opening codec context with hardware configuration
|
||||
@@ -655,6 +742,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
/// <summary>
|
||||
/// Reads packets from the container until a packet belonging to the video stream is found,
|
||||
/// then sends it to the decoder via <c>avcodec_send_packet</c>.
|
||||
/// A packet that the decoder rejects as invalid data (<c>AVERROR_INVALIDDATA</c>, e.g. a damaged packet)
|
||||
/// is skipped and the next packet is read. Any other decoder error is thrown.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
|
||||
/// </summary>
|
||||
/// <returns>True if a video packet was sent; false on EOF.</returns>
|
||||
@@ -670,6 +759,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
{
|
||||
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
|
||||
ffmpeg.av_packet_unref(_pPacket);
|
||||
|
||||
if (ret == ffmpeg.AVERROR_INVALIDDATA)
|
||||
{
|
||||
// The warning is logged for the first few packets only (a damaged file can have many).
|
||||
_skippedPacketCount++;
|
||||
if (_skippedPacketCount <= MaxLoggedSkippedPackets)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"[FFmpegNativeDecoder] Skipped an invalid video packet ({_skippedPacketCount}): {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ret < 0)
|
||||
{
|
||||
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
|
||||
@@ -743,12 +844,67 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily initializes or updates the <see cref="SwsContext"/> when source/destination formats or dimensions change.
|
||||
/// Selects the YUV→RGB conversion matrix (libswscale <c>SWS_CS_*</c>) from the colorspace tag of a frame.
|
||||
/// The matrix set is the same as that of the compute shader path (ColorConversion.compute, <c>ColorMatrix</c> uniform):
|
||||
/// BT.709 (also for frames without a usable tag), BT.601 and BT.2020, so both paths give the same colors.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_getCoefficients)
|
||||
/// </summary>
|
||||
private static int GetSwsColorspace(AVColorSpace colorspace)
|
||||
{
|
||||
switch (GetColorMatrixId(colorspace))
|
||||
{
|
||||
case ColorMatrixBT601:
|
||||
return ffmpeg.SWS_CS_ITU601;
|
||||
case ColorMatrixBT2020:
|
||||
return ffmpeg.SWS_CS_BT2020;
|
||||
default:
|
||||
return ffmpeg.SWS_CS_ITU709;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the Y/U/V samples of the frame use the full range (0-255) instead of the limited range (16-235 / 16-240).
|
||||
/// The range is given by the frame's color_range tag or by a deprecated full range pixel format (YUVJ*).
|
||||
/// </summary>
|
||||
private static bool IsFullRange(AVFrame* frame)
|
||||
{
|
||||
if (frame->color_range == AVColorRange.AVCOL_RANGE_JPEG) return true;
|
||||
|
||||
switch ((AVPixelFormat)frame->format)
|
||||
{
|
||||
case AVPixelFormat.AV_PIX_FMT_YUVJ420P:
|
||||
case AVPixelFormat.AV_PIX_FMT_YUVJ422P:
|
||||
case AVPixelFormat.AV_PIX_FMT_YUVJ444P:
|
||||
case AVPixelFormat.AV_PIX_FMT_YUVJ440P:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily initializes or updates the <see cref="SwsContext"/> when source/destination formats, dimensions,
|
||||
/// or (for RGBA output) the colorspace / range of the source frame change.
|
||||
/// For RGBA output the YUV→RGB matrix and the source range are taken from <paramref name="colorFrame"/>.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
|
||||
/// </summary>
|
||||
private void InitOrUpdateSwsContext(AVPixelFormat srcFormat, int srcWidth, int srcHeight, AVPixelFormat dstFormat)
|
||||
/// <param name="srcFormat">Pixel format of the source frame.</param>
|
||||
/// <param name="srcWidth">Width of the source frame in pixels.</param>
|
||||
/// <param name="srcHeight">Height of the source frame in pixels.</param>
|
||||
/// <param name="dstFormat">Pixel format of the output.</param>
|
||||
/// <param name="colorFrame">Source frame whose colorspace / range tags are applied. Required for RGBA output.</param>
|
||||
private void InitOrUpdateSwsContext(AVPixelFormat srcFormat, int srcWidth, int srcHeight, AVPixelFormat dstFormat, AVFrame* colorFrame = null)
|
||||
{
|
||||
if (_pSwsContext != null && _srcPixFmt == srcFormat && _srcWidth == srcWidth && _srcHeight == srcHeight && _dstPixFmt == dstFormat)
|
||||
var swsColorspace = 0;
|
||||
var fullRange = false;
|
||||
if (colorFrame != null && dstFormat == AVPixelFormat.AV_PIX_FMT_RGBA)
|
||||
{
|
||||
swsColorspace = GetSwsColorspace(colorFrame->colorspace);
|
||||
fullRange = IsFullRange(colorFrame);
|
||||
}
|
||||
|
||||
if (_pSwsContext != null && _srcPixFmt == srcFormat && _srcWidth == srcWidth && _srcHeight == srcHeight && _dstPixFmt == dstFormat
|
||||
&& _srcSwsColorspace == swsColorspace && _srcFullRange == fullRange)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -763,6 +919,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
_srcWidth = srcWidth;
|
||||
_srcHeight = srcHeight;
|
||||
_dstPixFmt = dstFormat;
|
||||
_srcSwsColorspace = swsColorspace;
|
||||
_srcFullRange = fullRange;
|
||||
|
||||
_pSwsContext = ffmpeg.sws_getContext(
|
||||
srcWidth, srcHeight, srcFormat,
|
||||
@@ -774,6 +932,44 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
{
|
||||
throw new Exception($"Failed to initialize SwsContext (src: {srcFormat} {srcWidth}x{srcHeight} -> dst: {dstFormat} {Width}x{Height}).");
|
||||
}
|
||||
|
||||
if (swsColorspace != 0)
|
||||
{
|
||||
ApplySwsColorspace(swsColorspace, fullRange);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the YUV→RGB matrix and the source range of <see cref="_pSwsContext"/>.
|
||||
/// The destination range, brightness, contrast and saturation keep the values of the context.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_setColorspaceDetails)
|
||||
/// </summary>
|
||||
/// <param name="swsColorspace">Matrix id (<c>SWS_CS_*</c>) of the source.</param>
|
||||
/// <param name="fullRange">True when the source uses the full range.</param>
|
||||
private void ApplySwsColorspace(int swsColorspace, bool fullRange)
|
||||
{
|
||||
int* currentInvTable;
|
||||
int* currentTable;
|
||||
int srcRange, dstRange, brightness, contrast, saturation;
|
||||
|
||||
// Returns a negative value when the context cannot change its colorspace details (e.g. YUV to YUV conversion).
|
||||
if (ffmpeg.sws_getColorspaceDetails(_pSwsContext, ¤tInvTable, &srcRange, ¤tTable, &dstRange,
|
||||
&brightness, &contrast, &saturation) < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var coefficients = ffmpeg.sws_getCoefficients(swsColorspace);
|
||||
var invTable = new int_array4();
|
||||
var table = new int_array4();
|
||||
for (uint i = 0; i < 4; i++)
|
||||
{
|
||||
invTable[i] = coefficients[i];
|
||||
table[i] = currentTable[i];
|
||||
}
|
||||
|
||||
ffmpeg.sws_setColorspaceDetails(_pSwsContext, invTable, fullRange ? 1 : 0, table, dstRange,
|
||||
brightness, contrast, saturation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -865,7 +1061,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
if (frame == null) return false;
|
||||
|
||||
timestamp = GetTimestampAndUpdateIndex(frame);
|
||||
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_RGBA);
|
||||
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_RGBA, frame);
|
||||
|
||||
ClearConvertedFramePointers();
|
||||
if (vflip)
|
||||
@@ -929,7 +1125,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="yBuffer">Pointer to destination buffer for Y plane (Width * Height bytes).</param>
|
||||
/// <param name="yStride">Row stride in bytes for Y plane.</param>
|
||||
/// <param name="uvBuffer">Pointer to destination buffer for interleaved UV plane (Width * (Height / 2) bytes).</param>
|
||||
/// <param name="uvBuffer">Pointer to destination buffer for interleaved UV plane (((Width + 1) / 2) * 2 * ((Height + 1) / 2) bytes).</param>
|
||||
/// <param name="uvStride">Row stride in bytes for UV plane.</param>
|
||||
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
|
||||
/// <returns>True if a frame was successfully retrieved; false on EOF.</returns>
|
||||
@@ -982,7 +1178,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
int w = Width;
|
||||
int h = Height;
|
||||
int uvH = (h + 1) / 2;
|
||||
int uvRowBytes = (w / 2) * 2;
|
||||
int uvRowBytes = ((w + 1) / 2) * 2;
|
||||
|
||||
// Copy Y plane (row by row to safely skip linesize GPU padding)
|
||||
for (int y = 0; y < h; y++)
|
||||
@@ -1043,7 +1239,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
int w = Width;
|
||||
int h = Height;
|
||||
int uvH = (h + 1) / 2;
|
||||
int uvRowBytes = (w / 2) * 2;
|
||||
int uvRowBytes = ((w + 1) / 2) * 2;
|
||||
|
||||
if (yBuffer.Length < (long)w * h || uvBuffer.Length < (long)uvRowBytes * uvH)
|
||||
{
|
||||
@@ -1212,61 +1408,94 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Decodes and discards a specified number of frames immediately without GPU-to-CPU transfer overhead.
|
||||
/// Maximum distance in frames over which <see cref="SeekToTime"/> moves forward by decoding.
|
||||
/// Decoding forward needs no keyframe seek and no decoder flush, which is cheaper than seeking for short distances.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of frames to discard.</param>
|
||||
private void SkipFrames(int count)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_isOpened || _disposed) return;
|
||||
if (count <= 0) return;
|
||||
private const int MaxForwardDecodeFrames = 60;
|
||||
|
||||
int skipped = 0;
|
||||
if (_hasCachedFrame)
|
||||
/// <summary>
|
||||
/// Decodes forward from the current decoder position until the first frame whose presentation time is at least
|
||||
/// <paramref name="timeSeconds"/> minus half a frame, and keeps that frame in <see cref="_pCachedFrame"/>
|
||||
/// for the next <c>TryRead*</c> call.
|
||||
/// Frames before the target are discarded without GPU-to-host memory transfer; only the target frame is transferred.
|
||||
/// When the end of the stream is reached before the target, no frame is cached.
|
||||
/// </summary>
|
||||
/// <param name="timeSeconds">Target time in seconds.</param>
|
||||
private void DecodeToTarget(double timeSeconds)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
||||
if (ret == 0)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||
_hasCachedFrame = false;
|
||||
skipped++;
|
||||
double currentPts = GetFrameTimestamp(_pDecodedFrame);
|
||||
|
||||
// Check with half-frame margin to accommodate floating point rounding
|
||||
if (currentPts >= timeSeconds - (0.5 / FPS))
|
||||
{
|
||||
// Cache the first matching frame in system RAM for immediate consumption
|
||||
if (_isHwDecoding && _pDecodedFrame->format == (int)_hwPixFmt)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||
int transferRet = ffmpeg.av_hwframe_transfer_data(_pCachedFrame, _pDecodedFrame, 0);
|
||||
if (transferRet < 0)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||
throw new Exception($"Failed to transfer cached hardware frame during seek: {GetFFmpegErrorString(transferRet)}");
|
||||
}
|
||||
|
||||
ffmpeg.av_frame_copy_props(_pCachedFrame, _pDecodedFrame);
|
||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||
|
||||
_downloadedPixFmt = (AVPixelFormat)_pCachedFrame->format;
|
||||
}
|
||||
else
|
||||
{
|
||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||
ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame);
|
||||
}
|
||||
|
||||
_hasCachedFrame = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Discard non-matching intermediate frame without transferring to CPU
|
||||
GetTimestampAndUpdateIndex(_pDecodedFrame);
|
||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (skipped < count)
|
||||
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
||||
if (ret == 0)
|
||||
{
|
||||
GetTimestampAndUpdateIndex(_pDecodedFrame);
|
||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (_isDraining) return;
|
||||
|
||||
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
if (!ReadAndSendPacket())
|
||||
{
|
||||
if (_isDraining) break;
|
||||
|
||||
if (!ReadAndSendPacket())
|
||||
{
|
||||
_isDraining = true;
|
||||
ffmpeg.avcodec_send_packet(_pCodecContext, null);
|
||||
}
|
||||
}
|
||||
else if (ret == ffmpeg.AVERROR_EOF)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"Error skipping frames: {GetFFmpegErrorString(ret)}");
|
||||
_isDraining = true;
|
||||
ffmpeg.avcodec_send_packet(_pCodecContext, null);
|
||||
}
|
||||
}
|
||||
else if (ret == ffmpeg.AVERROR_EOF)
|
||||
{
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"Error during seek decoding: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeks to a specific playback time in seconds using backward keyframe seek followed by forward decode.
|
||||
/// Intermediate frames are discarded without GPU-to-host memory transfer to maximize seek speed;
|
||||
/// only the target frame is transferred and preserved in <see cref="_pCachedFrame"/>.
|
||||
/// Moves the playback position to a time in seconds.
|
||||
/// The next <c>TryRead*</c> call returns the first frame whose presentation time is at least
|
||||
/// <paramref name="timeSeconds"/> minus half a frame (the frame nearest to the target time).
|
||||
/// Both paths below return that same frame:
|
||||
/// - Forward decode: the target is ahead of the current position by less than <see cref="MaxForwardDecodeFrames"/> frames.
|
||||
/// Frames are decoded and discarded up to the target.
|
||||
/// - Keyframe seek: all other cases (target before the current position, far ahead, or the target frame already read).
|
||||
/// A backward keyframe seek is followed by a forward decode up to the target.
|
||||
/// The target frame is transferred and preserved in <see cref="_pCachedFrame"/>; when it is already there, nothing is decoded.
|
||||
/// </summary>
|
||||
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
|
||||
public void SeekToTime(double timeSeconds)
|
||||
@@ -1276,13 +1505,38 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
if (!_isOpened || _disposed) return;
|
||||
|
||||
if (timeSeconds < 0) timeSeconds = 0;
|
||||
double targetFrameIndex = timeSeconds * FPS;
|
||||
|
||||
// If already at target index or very close future, skip frames instead of seek to avoid I-frame seek latency
|
||||
bool useSkip = targetFrameIndex >= _currentFrameIndex && targetFrameIndex - _currentFrameIndex < 60;
|
||||
if (useSkip)
|
||||
// Earliest presentation time that satisfies the target (target minus half a frame).
|
||||
double threshold = timeSeconds - (0.5 / FPS);
|
||||
|
||||
// Index of the first frame that satisfies the target. The epsilon keeps a frame that lies exactly on the
|
||||
// margin from being excluded by floating point rounding.
|
||||
double targetFrameIndex = Math.Ceiling(timeSeconds * FPS - 0.5 - 1e-6);
|
||||
|
||||
if (_hasCachedFrame)
|
||||
{
|
||||
SkipFrames((int)(targetFrameIndex - _currentFrameIndex));
|
||||
// A frame is already positioned for the next read (e.g. by a previous SeekToTime call).
|
||||
double cachedPts = GetFrameTimestamp(_pCachedFrame);
|
||||
if (cachedPts >= threshold)
|
||||
{
|
||||
// The cached frame is the first frame that satisfies the target.
|
||||
// When the target lies before it, the frames in between are no longer available and a keyframe seek is required.
|
||||
if (cachedPts - threshold < 1.0 / FPS) return;
|
||||
}
|
||||
else if (targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
|
||||
{
|
||||
// The cached frame lies before the target: drop it and continue decoding forward.
|
||||
GetTimestampAndUpdateIndex(_pCachedFrame);
|
||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||
_hasCachedFrame = false;
|
||||
DecodeToTarget(timeSeconds);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (targetFrameIndex > _currentFrameIndex && targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
|
||||
{
|
||||
// The target frame has not been read yet and is close: decode forward instead of seeking.
|
||||
DecodeToTarget(timeSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1310,77 +1564,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
_isDraining = false;
|
||||
|
||||
// Decode frames until reaching the target presentation timestamp
|
||||
while (true)
|
||||
{
|
||||
ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
||||
if (ret == 0)
|
||||
{
|
||||
double currentPts = GetFrameTimestamp(_pDecodedFrame);
|
||||
|
||||
// Check with half-frame margin to accommodate floating point rounding
|
||||
if (currentPts >= timeSeconds - (0.5 / FPS))
|
||||
{
|
||||
// Cache the first matching frame in system RAM for immediate consumption
|
||||
if (_isHwDecoding && _pDecodedFrame->format == (int)_hwPixFmt)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||
int transferRet = ffmpeg.av_hwframe_transfer_data(_pCachedFrame, _pDecodedFrame, 0);
|
||||
if (transferRet < 0)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||
throw new Exception($"Failed to transfer cached hardware frame during seek: {GetFFmpegErrorString(transferRet)}");
|
||||
}
|
||||
|
||||
ffmpeg.av_frame_copy_props(_pCachedFrame, _pDecodedFrame);
|
||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||
|
||||
_downloadedPixFmt = (AVPixelFormat)_pCachedFrame->format;
|
||||
}
|
||||
else
|
||||
{
|
||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||
ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame);
|
||||
}
|
||||
|
||||
_hasCachedFrame = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Discard non-matching intermediate frame without transferring to CPU
|
||||
long ptsValue = _pDecodedFrame->best_effort_timestamp != ffmpeg.AV_NOPTS_VALUE
|
||||
? _pDecodedFrame->best_effort_timestamp
|
||||
: _pDecodedFrame->pts;
|
||||
if (ptsValue == ffmpeg.AV_NOPTS_VALUE)
|
||||
{
|
||||
_currentFrameIndex++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentFrameIndex = (long)Math.Round(currentPts * FPS);
|
||||
}
|
||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
if (_isDraining) break;
|
||||
|
||||
if (!ReadAndSendPacket())
|
||||
{
|
||||
_isDraining = true;
|
||||
ffmpeg.avcodec_send_packet(_pCodecContext, null);
|
||||
}
|
||||
}
|
||||
else if (ret == ffmpeg.AVERROR_EOF)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"Error during seek decoding: {GetFFmpegErrorString(ret)}");
|
||||
}
|
||||
}
|
||||
DecodeToTarget(timeSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+536
-196
File diff suppressed because it is too large.
Load diff
+43
-6
@@ -73,6 +73,16 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bytes of the readback buffer of one frame: Width x Height x 3 / 2 when the GPU conversion to NV12 / YUV420P
|
||||
/// is used, Width x Height x 4 (RGBA) otherwise. The same size as ReadbackRequestState.Setup uses.
|
||||
/// </summary>
|
||||
public long GetFrameBufferBytes(int width, int height)
|
||||
{
|
||||
var pixels = (long)width * height;
|
||||
return _useGPUConversion ? pixels * 3 / 2 : pixels * 4;
|
||||
}
|
||||
|
||||
public void RequestFrameCapture(Texture texture, long currentFrame)
|
||||
{
|
||||
if (!RenderManager.IsCapturingActive) return;
|
||||
@@ -140,7 +150,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
|
||||
{
|
||||
RecycleState(state);
|
||||
return;
|
||||
@@ -154,14 +164,11 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
Melon<RenderMod>.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame.");
|
||||
|
||||
var byteSize = state.Width * state.Height * 3 / 2;
|
||||
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
|
||||
Array.Clear(blackFrame, 0, byteSize);
|
||||
|
||||
var blackFrame = CreateBlackFrame(state, out var byteSize);
|
||||
var capFrame = new RenderManager.CapturedFrame
|
||||
{
|
||||
ManagedBuffer = blackFrame, Length = byteSize,
|
||||
IsColorConverted = true
|
||||
IsColorConverted = state.IsGPUConversion
|
||||
};
|
||||
PushToQueue(frame, capFrame);
|
||||
RecycleState(state);
|
||||
@@ -190,6 +197,36 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a black frame in the same format as the frames of this reader, from ArrayPool<byte>.Shared
|
||||
/// (returned to the pool by the render thread after encoding).
|
||||
///
|
||||
/// GPU conversion (NV12 / YUV420P, full range): Width × Height × 3 / 2 bytes.
|
||||
/// Y plane (Width × Height bytes) = 0, U/V data (the rest) = 128.
|
||||
/// 128 is the zero chroma value of the 8-bit full range: the U/V offset 0.5 of RGB2YUV_BT709_Full
|
||||
/// in ColorConversion.compute, which the encoder input of this path uses.
|
||||
/// Both NV12 (interleaved UV) and YUV420P (U plane, then V plane) store all chroma after the Y plane,
|
||||
/// so the same fill applies to both.
|
||||
/// No GPU conversion (RGBA input, converted by the encoder): Width × Height × 4 bytes, all 0.
|
||||
/// </summary>
|
||||
private static byte[] CreateBlackFrame(ReadbackRequestState state, out int byteSize)
|
||||
{
|
||||
var lumaSize = state.Width * state.Height;
|
||||
byteSize = state.IsGPUConversion ? lumaSize * 3 / 2 : lumaSize * 4;
|
||||
|
||||
var frame = ArrayPool<byte>.Shared.Rent(byteSize);
|
||||
if (state.IsGPUConversion)
|
||||
{
|
||||
Array.Clear(frame, 0, lumaSize);
|
||||
frame.AsSpan(lumaSize, byteSize - lumaSize).Fill(128);
|
||||
}
|
||||
else
|
||||
{
|
||||
Array.Clear(frame, 0, byteSize);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
internal void RecycleState(ReadbackRequestState state)
|
||||
{
|
||||
lock (_poolLock)
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -121,7 +122,7 @@ internal static class GPUUtil
|
||||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
||||
|
||||
var nvidiaVerInt = sub % 10 * 10000 + build;
|
||||
_cachedNvidiaVersion = nvidiaVerInt / 100f;
|
||||
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
|
||||
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
||||
return _cachedNvidiaVersion;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util;
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||
@@ -0,0 +1,583 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
/// <summary>
|
||||
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
||||
/// <para>
|
||||
/// - <b>Windows</b>: Toast notification shown by a hidden <c>powershell.exe</c> process
|
||||
/// (WinRT <c>ToastNotificationManager</c>, script passed with <c>-EncodedCommand</c>).
|
||||
/// With a folder path, the script also registers the <c>adofai-renderer://</c> URL protocol under
|
||||
/// <c>HKCU\Software\Classes\adofai-renderer</c> (open command: <c>explorer.exe "<folder>"</c>)
|
||||
/// so the toast button opens the folder; the key is overwritten on each notification and stays registered.
|
||||
/// When PowerShell fails, a Win32 <c>Shell_NotifyIcon</c> balloon is shown via P/Invoke.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class OSNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a native desktop notification.
|
||||
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
||||
/// the folder when the notification is clicked/dismissed.
|
||||
/// </summary>
|
||||
/// <param name="title">The notification title.</param>
|
||||
/// <param name="message">The notification body text.</param>
|
||||
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
|
||||
public static void Show(string title, string message, string? folderPath = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
WindowsNotification.Send(title, message, folderPath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
MacNotification.Send(title, message);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
LinuxNotification.Send(title, message, folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
||||
|
||||
/// <summary>
|
||||
/// Windows notification with two strategies:
|
||||
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
||||
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
||||
/// </summary>
|
||||
private static class WindowsNotification
|
||||
{
|
||||
public static void Send(string title, string message, string? folderPath)
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TrySendToast(title, message, folderPath))
|
||||
{
|
||||
SendBalloon(title, message, folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "OSNotification-Win"
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
#region Toast via PowerShell
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to show a Windows Toast notification via PowerShell.
|
||||
/// With a folder path, registers the adofai-renderer:// protocol in HKCU first (see class summary).
|
||||
/// Waits up to 5 seconds for the process. Returns true when it exits with code 0,
|
||||
/// false when it exits with another code, does not exit within 5 seconds, or fails to start.
|
||||
/// </summary>
|
||||
private static bool TrySendToast(string title, string message, string? folderPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
||||
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
||||
|
||||
string toastXml;
|
||||
string psScript;
|
||||
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
var escapedFolder = folderPath.Replace("'", "''");
|
||||
|
||||
toastXml =
|
||||
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
||||
"<visual><binding template=\"ToastGeneric\">" +
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual>" +
|
||||
"<actions>" +
|
||||
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||
"</actions></toast>";
|
||||
|
||||
psScript =
|
||||
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
||||
"$null = New-Item -Path $protocolPath -Force\n" +
|
||||
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
||||
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
||||
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
||||
"$null = New-Item -Path $cmdPath -Force\n" +
|
||||
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||
$"$xml.LoadXml('{toastXml}')\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||
}
|
||||
else
|
||||
{
|
||||
toastXml =
|
||||
"<toast><visual><binding template=\"ToastGeneric\">" +
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual></toast>";
|
||||
|
||||
psScript =
|
||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||
$"$xml.LoadXml('{toastXml}')\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||
}
|
||||
|
||||
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
||||
string base64 = Convert.ToBase64String(bytes);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
// If PowerShell exited with error, treat as failure
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string stderr = process.StandardError.ReadToEnd().Trim();
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Balloon via Shell_NotifyIcon
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
private const int NIM_ADD = 0x00;
|
||||
private const int NIM_DELETE = 0x02;
|
||||
|
||||
private const int NIF_MESSAGE = 0x01;
|
||||
private const int NIF_ICON = 0x02;
|
||||
private const int NIF_TIP = 0x04;
|
||||
private const int NIF_INFO = 0x10;
|
||||
|
||||
private const int NIIF_INFO = 0x01;
|
||||
|
||||
private const int WM_USER = 0x0400;
|
||||
private const int WM_TRAYICON = WM_USER + 1;
|
||||
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
||||
|
||||
private const int WS_OVERLAPPED = 0x00000000;
|
||||
private const uint WM_CLOSE = 0x0010;
|
||||
private const int TRAY_ICON_UID = 29471;
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr CreateWindowExW(
|
||||
int dwExStyle, string lpClassName, string lpWindowName,
|
||||
int dwStyle, int x, int y, int nWidth, int nHeight,
|
||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct NotifyIconDataW
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public int uID;
|
||||
public int uFlags;
|
||||
public int uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string szTip;
|
||||
public int dwState;
|
||||
public int dwStateMask;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string szInfo;
|
||||
public int uTimeoutOrVersion;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string szInfoTitle;
|
||||
public int dwInfoFlags;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Msg
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int pt_x;
|
||||
public int pt_y;
|
||||
}
|
||||
|
||||
private static void SendBalloon(string title, string message, string? folderPath)
|
||||
{
|
||||
if (title.Length > 63) title = title.Substring(0, 63);
|
||||
if (message.Length > 255) message = message.Substring(0, 255);
|
||||
|
||||
IntPtr hWnd = IntPtr.Zero;
|
||||
var nid = new NotifyIconDataW();
|
||||
|
||||
try
|
||||
{
|
||||
var hwndMessage = new IntPtr(-3);
|
||||
hWnd = CreateWindowExW(
|
||||
0, "Static", "ADOFAIRendererNotify",
|
||||
WS_OVERLAPPED, 0, 0, 0, 0,
|
||||
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
||||
return;
|
||||
}
|
||||
|
||||
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
||||
|
||||
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
||||
nid.hWnd = hWnd;
|
||||
nid.uID = TRAY_ICON_UID;
|
||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
||||
nid.uCallbackMessage = WM_TRAYICON;
|
||||
nid.hIcon = hIcon;
|
||||
nid.szTip = "ADOFAI Renderer";
|
||||
nid.szInfo = message;
|
||||
nid.szInfoTitle = title;
|
||||
nid.dwInfoFlags = NIIF_INFO;
|
||||
nid.uTimeoutOrVersion = 4;
|
||||
|
||||
Shell_NotifyIconW(NIM_ADD, ref nid);
|
||||
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
var capturedFolder = folderPath;
|
||||
var closeTimer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
||||
catch { /* ignored */ }
|
||||
}, null, 12000, Timeout.Infinite);
|
||||
|
||||
try
|
||||
{
|
||||
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
||||
{
|
||||
if (msg.message == WM_CLOSE)
|
||||
break;
|
||||
|
||||
if (msg.message == WM_TRAYICON)
|
||||
{
|
||||
int notifyMsg = (int)(long)msg.lParam;
|
||||
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo();
|
||||
psi.FileName = capturedFolder;
|
||||
psi.UseShellExecute = true;
|
||||
Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning(
|
||||
$"[OSNotification] Cannot open folder: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessageW(ref msg);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
closeTimer.Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (hWnd != IntPtr.Zero)
|
||||
{
|
||||
nid.uFlags = 0;
|
||||
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
||||
DestroyWindow(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region macOS — osascript
|
||||
|
||||
/// <summary>
|
||||
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
||||
/// </summary>
|
||||
private static class MacNotification
|
||||
{
|
||||
public static void Send(string title, string message)
|
||||
{
|
||||
// AppleScript string escaping: backslash and double-quote
|
||||
string escapedTitle = EscapeAppleScript(title);
|
||||
string escapedMessage = EscapeAppleScript(message);
|
||||
|
||||
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
||||
// Each argument is passed directly to the process without shell interpretation.
|
||||
var script =
|
||||
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-e");
|
||||
startInfo.ArgumentList.Add(script);
|
||||
|
||||
RunProcess(startInfo);
|
||||
}
|
||||
|
||||
private static string EscapeAppleScript(string value)
|
||||
{
|
||||
// In AppleScript string literals, escape backslash and double-quote
|
||||
return value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Linux — notify-send / gdbus
|
||||
|
||||
/// <summary>
|
||||
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
||||
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
||||
/// </summary>
|
||||
private static class LinuxNotification
|
||||
{
|
||||
public static void Send(string title, string message, string? folderPath)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
ShowWithAction(title, message, folderPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowSimple(title, message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowSimple(string title, string message)
|
||||
{
|
||||
// Try notify-send first
|
||||
if (TryNotifySend(title, message))
|
||||
return;
|
||||
|
||||
// Fallback to gdbus
|
||||
TryGdbus(title, message);
|
||||
}
|
||||
|
||||
private static void ShowWithAction(string title, string message, string folderPath)
|
||||
{
|
||||
// Run on a background thread because notify-send with --action blocks
|
||||
// until the notification is dismissed or the action is clicked.
|
||||
System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "notify-send",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
if (output == "open_folder")
|
||||
{
|
||||
var openInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xdg-open",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
openInfo.ArgumentList.Add(folderPath);
|
||||
|
||||
RunProcess(openInfo);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
||||
|
||||
// Fallback: show without action
|
||||
ShowSimple(title, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryNotifySend(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "notify-send",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
RunProcess(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryGdbus(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Direct D-Bus call to org.freedesktop.Notifications
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "gdbus",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("call");
|
||||
startInfo.ArgumentList.Add("--session");
|
||||
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
||||
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
||||
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
||||
startInfo.ArgumentList.Add("0"); // replaces_id
|
||||
startInfo.ArgumentList.Add(""); // app_icon
|
||||
startInfo.ArgumentList.Add(title); // summary
|
||||
startInfo.ArgumentList.Add(message); // body
|
||||
startInfo.ArgumentList.Add("[]"); // actions
|
||||
startInfo.ArgumentList.Add("{}"); // hints
|
||||
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
||||
|
||||
RunProcess(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared utility
|
||||
|
||||
/// <summary>
|
||||
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
||||
/// </summary>
|
||||
private static void RunProcess(ProcessStartInfo startInfo)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
internal static class RegistryUtil
|
||||
{
|
||||
private static readonly Type? RegistryKeyType;
|
||||
private static readonly Type? RegistryHiveType;
|
||||
private static readonly Type? RegistryViewType;
|
||||
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
|
||||
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
|
||||
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
|
||||
private static readonly System.Reflection.MethodInfo? GetValueMethod;
|
||||
|
||||
static RegistryUtil()
|
||||
{
|
||||
try
|
||||
{
|
||||
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
||||
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
||||
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
||||
|
||||
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
||||
{
|
||||
var mscorlib = typeof(object).Assembly;
|
||||
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
||||
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
||||
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
||||
}
|
||||
|
||||
if (RegistryKeyType != null)
|
||||
{
|
||||
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
||||
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
||||
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
||||
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
private static void CloseKey(object? key)
|
||||
{
|
||||
if (key == null || RegistryKeyType == null) return;
|
||||
try
|
||||
{
|
||||
if (key is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
var closeMethod = RegistryKeyType.GetMethod("Close");
|
||||
closeMethod?.Invoke(key, null);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||
|
||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||
if (baseKey == null) return null;
|
||||
|
||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
||||
if (key == null)
|
||||
{
|
||||
CloseKey(baseKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
||||
CloseKey(key);
|
||||
CloseKey(baseKey);
|
||||
return subkeyNames;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string? GetHKLMValue(string keyPath, string valueName)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||
|
||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||
if (baseKey == null) return null;
|
||||
|
||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
||||
if (key == null)
|
||||
{
|
||||
CloseKey(baseKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
||||
CloseKey(key);
|
||||
CloseKey(baseKey);
|
||||
return value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
||||
/// <para>
|
||||
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
|
||||
/// No PowerShell, no UWP, no WinRT dependencies.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class OSNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a native desktop notification.
|
||||
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
||||
/// the folder when the notification is clicked/dismissed.
|
||||
/// </summary>
|
||||
/// <param name="title">The notification title.</param>
|
||||
/// <param name="message">The notification body text.</param>
|
||||
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
|
||||
public static void Show(string title, string message, string? folderPath = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
WindowsNotification.Send(title, message, folderPath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
MacNotification.Send(title, message);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
LinuxNotification.Send(title, message, folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
||||
|
||||
/// <summary>
|
||||
/// Windows notification with two strategies:
|
||||
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
||||
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
||||
/// </summary>
|
||||
private static class WindowsNotification
|
||||
{
|
||||
public static void Send(string title, string message, string? folderPath)
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TrySendToast(title, message, folderPath))
|
||||
{
|
||||
SendBalloon(title, message, folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "OSNotification-Win"
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
#region Toast via PowerShell
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to show a Windows Toast notification via PowerShell.
|
||||
/// Returns true if the process launched successfully, false otherwise.
|
||||
/// </summary>
|
||||
private static bool TrySendToast(string title, string message, string? folderPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
||||
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
||||
|
||||
string toastXml;
|
||||
string psScript;
|
||||
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
var escapedFolder = folderPath.Replace("'", "''");
|
||||
|
||||
toastXml =
|
||||
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
||||
"<visual><binding template=\"ToastGeneric\">" +
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual>" +
|
||||
"<actions>" +
|
||||
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||
"</actions></toast>";
|
||||
|
||||
psScript =
|
||||
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
||||
"$null = New-Item -Path $protocolPath -Force\n" +
|
||||
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
||||
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
||||
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
||||
"$null = New-Item -Path $cmdPath -Force\n" +
|
||||
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||
$"$xml.LoadXml('{toastXml}')\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||
}
|
||||
else
|
||||
{
|
||||
toastXml =
|
||||
"<toast><visual><binding template=\"ToastGeneric\">" +
|
||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||
"</binding></visual></toast>";
|
||||
|
||||
psScript =
|
||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||
$"$xml.LoadXml('{toastXml}')\n" +
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||
}
|
||||
|
||||
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
||||
string base64 = Convert.ToBase64String(bytes);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
// If PowerShell exited with error, treat as failure
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string stderr = process.StandardError.ReadToEnd().Trim();
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Balloon via Shell_NotifyIcon
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
private const int NIM_ADD = 0x00;
|
||||
private const int NIM_DELETE = 0x02;
|
||||
|
||||
private const int NIF_MESSAGE = 0x01;
|
||||
private const int NIF_ICON = 0x02;
|
||||
private const int NIF_TIP = 0x04;
|
||||
private const int NIF_INFO = 0x10;
|
||||
|
||||
private const int NIIF_INFO = 0x01;
|
||||
|
||||
private const int WM_USER = 0x0400;
|
||||
private const int WM_TRAYICON = WM_USER + 1;
|
||||
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
||||
|
||||
private const int WS_OVERLAPPED = 0x00000000;
|
||||
private const uint WM_CLOSE = 0x0010;
|
||||
private const int TRAY_ICON_UID = 29471;
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr CreateWindowExW(
|
||||
int dwExStyle, string lpClassName, string lpWindowName,
|
||||
int dwStyle, int x, int y, int nWidth, int nHeight,
|
||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct NotifyIconDataW
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public int uID;
|
||||
public int uFlags;
|
||||
public int uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string szTip;
|
||||
public int dwState;
|
||||
public int dwStateMask;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string szInfo;
|
||||
public int uTimeoutOrVersion;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string szInfoTitle;
|
||||
public int dwInfoFlags;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Msg
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int pt_x;
|
||||
public int pt_y;
|
||||
}
|
||||
|
||||
private static void SendBalloon(string title, string message, string? folderPath)
|
||||
{
|
||||
if (title.Length > 63) title = title.Substring(0, 63);
|
||||
if (message.Length > 255) message = message.Substring(0, 255);
|
||||
|
||||
IntPtr hWnd = IntPtr.Zero;
|
||||
var nid = new NotifyIconDataW();
|
||||
|
||||
try
|
||||
{
|
||||
var hwndMessage = new IntPtr(-3);
|
||||
hWnd = CreateWindowExW(
|
||||
0, "Static", "ADOFAIRendererNotify",
|
||||
WS_OVERLAPPED, 0, 0, 0, 0,
|
||||
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
||||
return;
|
||||
}
|
||||
|
||||
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
||||
|
||||
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
||||
nid.hWnd = hWnd;
|
||||
nid.uID = TRAY_ICON_UID;
|
||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
||||
nid.uCallbackMessage = WM_TRAYICON;
|
||||
nid.hIcon = hIcon;
|
||||
nid.szTip = "ADOFAI Renderer";
|
||||
nid.szInfo = message;
|
||||
nid.szInfoTitle = title;
|
||||
nid.dwInfoFlags = NIIF_INFO;
|
||||
nid.uTimeoutOrVersion = 4;
|
||||
|
||||
Shell_NotifyIconW(NIM_ADD, ref nid);
|
||||
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
var capturedFolder = folderPath;
|
||||
var closeTimer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
||||
catch { /* ignored */ }
|
||||
}, null, 12000, Timeout.Infinite);
|
||||
|
||||
try
|
||||
{
|
||||
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
||||
{
|
||||
if (msg.message == WM_CLOSE)
|
||||
break;
|
||||
|
||||
if (msg.message == WM_TRAYICON)
|
||||
{
|
||||
int notifyMsg = (int)(long)msg.lParam;
|
||||
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo();
|
||||
psi.FileName = capturedFolder;
|
||||
psi.UseShellExecute = true;
|
||||
Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning(
|
||||
$"[OSNotification] Cannot open folder: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessageW(ref msg);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
closeTimer.Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (hWnd != IntPtr.Zero)
|
||||
{
|
||||
nid.uFlags = 0;
|
||||
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
||||
DestroyWindow(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region macOS — osascript
|
||||
|
||||
/// <summary>
|
||||
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
||||
/// </summary>
|
||||
private static class MacNotification
|
||||
{
|
||||
public static void Send(string title, string message)
|
||||
{
|
||||
// AppleScript string escaping: backslash and double-quote
|
||||
string escapedTitle = EscapeAppleScript(title);
|
||||
string escapedMessage = EscapeAppleScript(message);
|
||||
|
||||
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
||||
// Each argument is passed directly to the process without shell interpretation.
|
||||
var script =
|
||||
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-e");
|
||||
startInfo.ArgumentList.Add(script);
|
||||
|
||||
RunProcess(startInfo);
|
||||
}
|
||||
|
||||
private static string EscapeAppleScript(string value)
|
||||
{
|
||||
// In AppleScript string literals, escape backslash and double-quote
|
||||
return value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Linux — notify-send / gdbus
|
||||
|
||||
/// <summary>
|
||||
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
||||
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
||||
/// </summary>
|
||||
private static class LinuxNotification
|
||||
{
|
||||
public static void Send(string title, string message, string? folderPath)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
{
|
||||
ShowWithAction(title, message, folderPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowSimple(title, message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowSimple(string title, string message)
|
||||
{
|
||||
// Try notify-send first
|
||||
if (TryNotifySend(title, message))
|
||||
return;
|
||||
|
||||
// Fallback to gdbus
|
||||
TryGdbus(title, message);
|
||||
}
|
||||
|
||||
private static void ShowWithAction(string title, string message, string folderPath)
|
||||
{
|
||||
// Run on a background thread because notify-send with --action blocks
|
||||
// until the notification is dismissed or the action is clicked.
|
||||
System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "notify-send",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
if (output == "open_folder")
|
||||
{
|
||||
var openInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xdg-open",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
openInfo.ArgumentList.Add(folderPath);
|
||||
|
||||
RunProcess(openInfo);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
||||
|
||||
// Fallback: show without action
|
||||
ShowSimple(title, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryNotifySend(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "notify-send",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("-a");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||
startInfo.ArgumentList.Add(title);
|
||||
startInfo.ArgumentList.Add(message);
|
||||
|
||||
RunProcess(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryGdbus(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Direct D-Bus call to org.freedesktop.Notifications
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "gdbus",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
startInfo.ArgumentList.Add("call");
|
||||
startInfo.ArgumentList.Add("--session");
|
||||
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
||||
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
||||
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
||||
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
||||
startInfo.ArgumentList.Add("0"); // replaces_id
|
||||
startInfo.ArgumentList.Add(""); // app_icon
|
||||
startInfo.ArgumentList.Add(title); // summary
|
||||
startInfo.ArgumentList.Add(message); // body
|
||||
startInfo.ArgumentList.Add("[]"); // actions
|
||||
startInfo.ArgumentList.Add("{}"); // hints
|
||||
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
||||
|
||||
RunProcess(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared utility
|
||||
|
||||
/// <summary>
|
||||
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
||||
/// </summary>
|
||||
private static void RunProcess(ProcessStartInfo startInfo)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util
|
||||
{
|
||||
internal static class RegistryUtil
|
||||
{
|
||||
private static readonly Type? RegistryKeyType;
|
||||
private static readonly Type? RegistryHiveType;
|
||||
private static readonly Type? RegistryViewType;
|
||||
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
|
||||
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
|
||||
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
|
||||
private static readonly System.Reflection.MethodInfo? GetValueMethod;
|
||||
|
||||
static RegistryUtil()
|
||||
{
|
||||
try
|
||||
{
|
||||
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
||||
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
||||
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
||||
|
||||
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
||||
{
|
||||
var mscorlib = typeof(object).Assembly;
|
||||
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
||||
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
||||
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
||||
}
|
||||
|
||||
if (RegistryKeyType != null)
|
||||
{
|
||||
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
||||
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
||||
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
||||
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
private static void CloseKey(object? key)
|
||||
{
|
||||
if (key == null || RegistryKeyType == null) return;
|
||||
try
|
||||
{
|
||||
if (key is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
var closeMethod = RegistryKeyType.GetMethod("Close");
|
||||
closeMethod?.Invoke(key, null);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||
|
||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||
if (baseKey == null) return null;
|
||||
|
||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
||||
if (key == null)
|
||||
{
|
||||
CloseKey(baseKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
||||
CloseKey(key);
|
||||
CloseKey(baseKey);
|
||||
return subkeyNames;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static string? GetHKLMValue(string keyPath, string valueName)
|
||||
{
|
||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||
|
||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||
if (baseKey == null) return null;
|
||||
|
||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
||||
if (key == null)
|
||||
{
|
||||
CloseKey(baseKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
||||
CloseKey(key);
|
||||
CloseKey(baseKey);
|
||||
return value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -20,6 +20,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
||||
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
||||
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
|
||||
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
private enum GPUMode
|
||||
@@ -96,7 +97,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
|
||||
|
||||
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
||||
_textureUV = new Texture2D(w / 2, h / 2, TextureFormat.RG16, false);
|
||||
_textureUV = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.RG16, false);
|
||||
ConfigureTexture(_textureY);
|
||||
ConfigureTexture(_textureUV);
|
||||
|
||||
@@ -117,8 +118,8 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
|
||||
|
||||
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
||||
_textureU = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
|
||||
_textureV = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
|
||||
_textureU = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.R8, false);
|
||||
_textureV = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.R8, false);
|
||||
ConfigureTexture(_textureY);
|
||||
ConfigureTexture(_textureU);
|
||||
ConfigureTexture(_textureV);
|
||||
@@ -189,6 +190,14 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
||||
var targetVideoTime = songTime + vidOffset;
|
||||
|
||||
// When looping is enabled, the playback position is the elapsed time wrapped into [0, Duration).
|
||||
// When it is disabled, the position keeps increasing and the last frame stays on screen after the end.
|
||||
var videoDuration = _decoder.Duration;
|
||||
if (_isLooping && targetVideoTime >= 0 && videoDuration > 0)
|
||||
{
|
||||
targetVideoTime %= videoDuration;
|
||||
}
|
||||
|
||||
if (targetVideoTime < 0)
|
||||
{
|
||||
if (_targetTexture)
|
||||
@@ -307,6 +316,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_computeShader.SetInt(Width, w);
|
||||
_computeShader.SetInt(Height, h);
|
||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||
|
||||
int threadGroupsX = (w + 7) / 8;
|
||||
int threadGroupsY = (h + 7) / 8;
|
||||
@@ -341,6 +351,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_computeShader.SetInt(Width, w);
|
||||
_computeShader.SetInt(Height, h);
|
||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||
|
||||
int threadGroupsX = (w + 7) / 8;
|
||||
int threadGroupsY = (h + 7) / 8;
|
||||
|
||||
Reference in new issue
Block a user