SHA256
Compare commits
23
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
4e556a4632
|
||
|
|
f1e926f758 | ||
|
|
c62723cf5c
|
||
|
|
fee7077b16
|
||
|
|
9fbf057677
|
||
|
|
f153bf70aa
|
||
|
|
93539307b1
|
||
|
|
c0e902fc1a
|
||
|
|
dd2585ee9f
|
||
|
|
1a672cbf59
|
||
|
|
6d5880fe19
|
||
|
|
72d8d71928
|
||
|
|
413778e165
|
||
|
|
36552511f5
|
||
|
|
a1f9fa36ce
|
||
|
|
00971a6730
|
||
|
|
bd24152a2e
|
||
|
|
7aa8ae4b54
|
||
|
|
dee3b2dbcf
|
||
|
|
7eec3b4660
|
||
|
|
ce70d89cdb
|
||
|
|
7db1905906
|
||
|
|
bfc79ee4d3
|
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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+302
-138
@@ -14,6 +14,7 @@ using ADOFAIRenderer.UI.Window;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.Audio;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using FFmpeg.AutoGen;
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
@@ -91,7 +92,50 @@ internal class RenderManager : MonoBehaviour
|
||||
public long frameCount;
|
||||
public bool skipPressToStart;
|
||||
public string levelPath = "";
|
||||
public bool IsRenderCancelled { get; private set; }
|
||||
private readonly object _abortStateLock = new();
|
||||
private volatile bool _isRenderCancelled;
|
||||
private volatile bool _renderFailed;
|
||||
private volatile string? _failureReason;
|
||||
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public bool IsRenderCancelled => _isRenderCancelled;
|
||||
public bool IsRenderFailed => _renderFailed;
|
||||
|
||||
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
|
||||
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
||||
|
||||
private void MarkCancelled()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
if (!_renderFailed) _isRenderCancelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The first failure reason wins; ignored once the render is already cancelled or failed.
|
||||
private void MarkFailed(string reason)
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
if (_isRenderCancelled || _renderFailed) return;
|
||||
_failureReason = reason;
|
||||
_renderFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAbortState()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
{
|
||||
_isRenderCancelled = false;
|
||||
_renderFailed = false;
|
||||
_failureReason = null;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildAbortMessage() => IsRenderFailed
|
||||
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
||||
: "Rendering was cancelled by user.";
|
||||
public RenderConfig Configuration = null!;
|
||||
|
||||
// Render Sessions State (OOP Instance Fields)
|
||||
@@ -102,7 +146,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 +195,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;
|
||||
@@ -170,8 +215,8 @@ internal class RenderManager : MonoBehaviour
|
||||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
||||
public Thread? RenderThread;
|
||||
public long passCount;
|
||||
public bool endRequested;
|
||||
public bool endSuccessful;
|
||||
public volatile bool endRequested;
|
||||
public volatile bool endSuccessful;
|
||||
public long endFrame = -1;
|
||||
|
||||
// Private Instance Fields
|
||||
@@ -181,6 +226,11 @@ internal class RenderManager : MonoBehaviour
|
||||
private GameObject? _debugMonitorObj;
|
||||
private GameObject? _escapeHandlerObj;
|
||||
internal string? CurrentOutPath;
|
||||
|
||||
// Output file of the current render that is not finished yet.
|
||||
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
|
||||
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
|
||||
private string? _incompleteOutputPath;
|
||||
private bool _renderSetup;
|
||||
|
||||
private void Awake()
|
||||
@@ -195,12 +245,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 +291,32 @@ internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
RenderThread.Join(200);
|
||||
}
|
||||
|
||||
// The encoders are disposed above, which closes the output file. An unfinished file of this render is deleted.
|
||||
DeleteIncompleteOutputFile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the output file of the current render when the render did not finish it (_incompleteOutputPath).
|
||||
/// Files of earlier, finished renders are never deleted.
|
||||
/// Returns true when a file was deleted.
|
||||
/// </summary>
|
||||
private bool DeleteIncompleteOutputFile()
|
||||
{
|
||||
var path = Interlocked.Exchange(ref _incompleteOutputPath, null);
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {path}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete incomplete video: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -283,8 +362,15 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
if (Instance.stage < RenderStage.OnScene) return;
|
||||
if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true })
|
||||
{
|
||||
MarkFailed("the render thread terminated unexpectedly.");
|
||||
CancelRender();
|
||||
return;
|
||||
}
|
||||
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
||||
{
|
||||
MarkFailed("the level failed or the game controller was lost during rendering.");
|
||||
ResetRenderState(success: false);
|
||||
StopFFmpeg(false);
|
||||
EndRender();
|
||||
@@ -333,6 +419,12 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
while (Instance.stage >= RenderStage.Render)
|
||||
{
|
||||
if (RenderThread is not { IsAlive: true })
|
||||
{
|
||||
isThrottling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
int count;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
@@ -362,7 +454,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 +495,6 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
Application.wantsToQuit -= quitDelegate;
|
||||
RenderConfigWindow.CreateWindow();
|
||||
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -439,7 +530,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 +556,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,10 +612,10 @@ internal class RenderManager : MonoBehaviour
|
||||
ImageQueue.Clear();
|
||||
}
|
||||
passCount = 0;
|
||||
IsRenderCancelled = false;
|
||||
ClearAbortState();
|
||||
frameCountForVideo = 0;
|
||||
lastVideoFrameIndex = -1;
|
||||
lastRequestedVideoFrameIndex = -1;
|
||||
Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
|
||||
endRequested = false;
|
||||
endSuccessful = false;
|
||||
FrameAvailableEvent.Reset();
|
||||
@@ -564,6 +665,7 @@ internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
_allVideoFFmpegInstances.Add(videoFFmpeg);
|
||||
}
|
||||
Volatile.Write(ref _incompleteOutputPath, CurrentOutPath);
|
||||
videoFFmpeg.Start(CurrentOutPath);
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -580,113 +682,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 +692,168 @@ 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 or all requested frames have been sent after endRequested.
|
||||
/// </summary>
|
||||
private void EncodeQueuedFrames(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (TryDequeueNextFrame(out var frame))
|
||||
{
|
||||
SendVideoFrame(videoFFmpeg, frame);
|
||||
WriteAudioForFrame(videoFFmpeg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldStopEncodeLoop()) return;
|
||||
|
||||
FrameAvailableEvent.WaitOne(50);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
|
||||
/// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
|
||||
/// Returns false when the queue is empty or the next frame has not arrived yet.
|
||||
/// </summary>
|
||||
private bool TryDequeueNextFrame(out CapturedFrame frame)
|
||||
{
|
||||
frame = default;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
if (ImageQueue.Count == 0) return false;
|
||||
|
||||
var firstKey = ImageQueue.Keys[0];
|
||||
if (firstKey > passCount && Instance.stage < RenderStage.Render)
|
||||
{
|
||||
passCount = firstKey;
|
||||
}
|
||||
|
||||
if (firstKey != passCount) return false;
|
||||
|
||||
frame = ImageQueue[firstKey];
|
||||
ImageQueue.RemoveAt(0);
|
||||
passCount++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
|
||||
/// A frame without data is skipped.
|
||||
/// </summary>
|
||||
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
|
||||
{
|
||||
if (frame.ManagedBuffer != null)
|
||||
{
|
||||
if (frame.ManagedBuffer.Length == 0) return;
|
||||
|
||||
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
|
||||
Interlocked.Increment(ref framesSent);
|
||||
|
||||
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.NativePointer == IntPtr.Zero || frame.Length <= 0) return;
|
||||
|
||||
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
|
||||
Interlocked.Increment(ref framesSent);
|
||||
|
||||
if (frame.ReadbackState != null)
|
||||
{
|
||||
_frameReader?.RecycleState(frame.ReadbackState);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
|
||||
/// Does nothing when audio rendering is off.
|
||||
/// </summary>
|
||||
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
|
||||
|
||||
var frameIdx = (int)(passCount - 1);
|
||||
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
||||
var frameTime = frameIdx / effectiveVideoFps;
|
||||
|
||||
var channels = Configuration.AudioConfig.AudioChannels;
|
||||
var samplesNeeded = syncCalculator.GetNextFrameSamples();
|
||||
|
||||
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
||||
try
|
||||
{
|
||||
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
|
||||
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<float>.Shared.Return(audioBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the render is aborted, or when the end was requested and every requested frame has been sent.
|
||||
/// </summary>
|
||||
private bool ShouldStopEncodeLoop()
|
||||
{
|
||||
if (IsRenderAborted) return true;
|
||||
|
||||
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
|
||||
}
|
||||
|
||||
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
lock (_allVideoFFmpegInstances)
|
||||
{
|
||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||
}
|
||||
_activeVideoFFmpeg = null;
|
||||
}
|
||||
|
||||
public void CancelRender()
|
||||
{
|
||||
ResetRenderState(success: false);
|
||||
@@ -708,7 +867,7 @@ internal class RenderManager : MonoBehaviour
|
||||
endRequested = true;
|
||||
if (!success)
|
||||
{
|
||||
IsRenderCancelled = true;
|
||||
MarkCancelled();
|
||||
}
|
||||
FrameAvailableEvent.Set();
|
||||
skipPressToStart = false;
|
||||
@@ -722,7 +881,8 @@ internal class RenderManager : MonoBehaviour
|
||||
if (success) StartCoroutine(StopRenderCoroutine());
|
||||
else
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
|
||||
var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED";
|
||||
Melon<RenderMod>.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources.");
|
||||
|
||||
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
|
||||
if (RenderThread is { IsAlive: true })
|
||||
@@ -781,30 +941,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 +969,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 +1012,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.
|
||||
@@ -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.6-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.8-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||
|
||||
namespace ADOFAIRenderer;
|
||||
|
||||
@@ -7,7 +7,7 @@ using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.Core.Data;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Localization;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using MelonLoader;
|
||||
using O5Kit.Control;
|
||||
using O5Kit.Core;
|
||||
@@ -840,6 +840,10 @@ internal static class RenderConfigWindow
|
||||
{
|
||||
if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value;
|
||||
if (_videoFPSSlider != null) target.VideoFPS = _videoFPSSlider.Value;
|
||||
|
||||
target.RenderFPS = AlignNTSCFramerate(target.RenderFPS);
|
||||
target.VideoFPS = AlignNTSCFramerate(target.VideoFPS);
|
||||
|
||||
if (_renderWidthSlider != null) target.RenderWidth = (int)Math.Round(_renderWidthSlider.Value);
|
||||
if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value);
|
||||
if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value;
|
||||
@@ -895,6 +899,20 @@ internal static class RenderConfigWindow
|
||||
target.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null)
|
||||
? _windPlanetPicker.Value
|
||||
: null;
|
||||
|
||||
return;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
double AlignNTSCFramerate(double framerate)
|
||||
{
|
||||
const double tolerance = 0.001;
|
||||
|
||||
if (Math.Abs(framerate - 29.97) < tolerance) framerate = 30000.0 / 1001;
|
||||
else if (Math.Abs(framerate - 59.94) < tolerance) framerate = 60000.0 / 1001;
|
||||
else if (Math.Abs(framerate - 23.976) < tolerance || Math.Abs(framerate - 23.98) < tolerance) framerate = 24000.0 / 1001;
|
||||
|
||||
return framerate;
|
||||
}
|
||||
}
|
||||
|
||||
private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom");
|
||||
@@ -1527,10 +1545,10 @@ internal static class RenderConfigWindow
|
||||
Action<bool>? onChanged = null,
|
||||
bool generateLabel = true)
|
||||
{
|
||||
if (generateLabel && !string.IsNullOrEmpty(labelKey))
|
||||
if (generateLabel)
|
||||
{
|
||||
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||
label.text = LocalizationManager.Get(labelKey);
|
||||
label.text = string.IsNullOrEmpty(labelKey) ? "" : LocalizationManager.Get(labelKey);
|
||||
label.color = ctx.Theme.TextDim;
|
||||
LocalizedText.Bind(label, labelKey);
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+317
-133
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+391
-163
@@ -18,12 +18,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// 2. Start(outputPath):
|
||||
/// a) Create AVFormatContext for output container (mp4, etc.)
|
||||
/// b) Add video stream (AVStream)
|
||||
/// c) Try hardware encoders (NVENC → AMF → QSV) → software encoders (libx264, etc.)
|
||||
/// in order to select an available encoder
|
||||
/// c) Try the candidate encoders of the codec in order and select the first one that opens
|
||||
/// (encoders of the active GPU vendor → other hardware encoders → software encoders; see GetEncoderCandidates)
|
||||
/// d) Write file header and initialize frame/scaler buffers
|
||||
///
|
||||
/// 3. WriteFrame*(): Called for every frame
|
||||
/// a) Convert Unity RGBA original frame to NV12/YUV420P using sws_scale
|
||||
/// a) Pre-converted NV12/YUV420P frame (GPU compute shader output): copied into the encoder frame
|
||||
/// RGBA frame: converted to the encoder pixel format with sws_scale (CPU)
|
||||
/// b) (In QSV hw_frames mode) Upload to GPU surface
|
||||
/// c) avcodec_send_frame → avcodec_receive_packet → Write to file
|
||||
///
|
||||
@@ -63,7 +64,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
public AVCodecID AudioCodecId;
|
||||
|
||||
/// <summary>
|
||||
/// Audio bitrate (in bps). If default (0), 128000 bps is used.
|
||||
/// Audio bitrate (in bps). If default (0), 256000 bps is used.
|
||||
/// For AAC, 128000–320000 bps is common.
|
||||
/// </summary>
|
||||
public int Bitrate;
|
||||
@@ -83,20 +84,20 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary>
|
||||
public double FPS;
|
||||
|
||||
/// <summary>Video codec type to use (H264, H265, AV1, VP9).</summary>
|
||||
/// <summary>Video codec type to use (H264, H265, AV1, VP9, VVC).</summary>
|
||||
public Codec Codec;
|
||||
|
||||
/// <summary>Bitrate control mode (CQP, CBR, VBR).</summary>
|
||||
public RateControlMode Mode;
|
||||
|
||||
/// <summary>Quality value used in CQP mode. Lower values mean higher quality. (e.g., H.264 CRF 18 ≈ visually lossless)</summary>
|
||||
public int QualityValue; // CRF
|
||||
public int QualityValue;
|
||||
|
||||
/// <summary>Target bitrate in VBR/CBR mode (in kbps). Converted to bps internally for FFmpeg.</summary>
|
||||
public int TargetBitrate; // VBR
|
||||
public int TargetBitrate;
|
||||
|
||||
/// <summary>Maximum bitrate in VBR mode (in kbps). Determines peak quality limit.</summary>
|
||||
public int MaxBitrate; // VBR
|
||||
public int MaxBitrate;
|
||||
|
||||
/// <summary>GOP (Group of Pictures) size (in seconds). Represents keyframe interval.</summary>
|
||||
public double GopSize;
|
||||
@@ -165,7 +166,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// <summary>Accumulated index of encoded frames. Used as the PTS (Presentation Time Stamp) value.</summary>
|
||||
private long _frameIndex;
|
||||
|
||||
/// <summary>Whether Start() completed successfully and encoding is ready.</summary>
|
||||
/// <summary>
|
||||
/// Whether the encoder accepts frames: set to true when Start() completes,
|
||||
/// and to false when End() runs (End() is not repeated) or Dispose() releases the resources.
|
||||
/// </summary>
|
||||
private bool _isInitialized;
|
||||
|
||||
/// <summary>Whether Dispose() has already been called. Prevents duplicate cleanup.</summary>
|
||||
@@ -182,9 +186,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
private bool _isQsvEncoder;
|
||||
|
||||
/// <summary>
|
||||
/// Target pixel format for the encoder.
|
||||
/// - General encoders (NVENC, AMF, Software): YUV420P (most common 4:2:0 chroma subsampling)
|
||||
/// - QSV encoder: NV12 (Y plane + UV interleaved plane, preferred format for Intel hardware)
|
||||
/// Pixel format of the frames passed to the encoder (output of the color conversion).
|
||||
/// - Hardware encoders (NVENC, AMF, QSV): NV12 (Y plane + UV interleaved plane)
|
||||
/// - libvvenc: YUV420P10LE (10-bit, 3 planes)
|
||||
/// - Other software encoders: YUV420P (3 planes Y/U/V, 4:2:0 chroma subsampling)
|
||||
///
|
||||
/// Reference - Major pixel format relationships:
|
||||
/// RGBA (Unity source, 4-channel 8-bit)
|
||||
@@ -285,7 +290,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// <summary>
|
||||
/// Returns a list of candidate encoders to try for the selected codec in priority order.
|
||||
///
|
||||
/// Priority: Hardware encoders (NVENC → AMF → QSV) → Software encoders (libx264, etc.)
|
||||
/// Order (GPUUtil.GetPrioritizedEncoders):
|
||||
/// 1. Hardware encoders of the active GPU vendor
|
||||
/// 2. The remaining encoders in the declaration order of VideoEncoder.All
|
||||
/// (per codec: NVENC → QSV → AMF → VideoToolbox → software, for the encoders the codec has)
|
||||
/// With ForceSoftware, only the software encoders are returned.
|
||||
///
|
||||
/// - nvenc: Hardware encoder dedicated to NVIDIA GPUs (CUDA-based)
|
||||
/// - amf: Hardware encoder dedicated to AMD GPUs (Advanced Media Framework)
|
||||
@@ -299,6 +308,23 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
return GPUUtil.GetPrioritizedEncoders(_settings.Codec, _settings.ForceSoftware).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands the candidate list into (encoder, input mode) attempts in order.
|
||||
/// A QSV encoder is attempted twice: system memory input mode (useFramePool = false) first,
|
||||
/// then GPU frame pool input mode (useFramePool = true). Other encoders are attempted once (useFramePool = false).
|
||||
/// </summary>
|
||||
private static System.Collections.Generic.IEnumerable<(VideoEncoder Encoder, bool UseFramePool)> ExpandQsvInputModes(VideoEncoder[] candidates)
|
||||
{
|
||||
foreach (var encoder in candidates)
|
||||
{
|
||||
yield return (encoder, false);
|
||||
if (encoder.Kind == EncoderKind.QSV)
|
||||
{
|
||||
yield return (encoder, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an FFmpeg error code (negative integer) to a human-readable string.
|
||||
/// e.g., -1 → "Operation not permitted"
|
||||
@@ -367,7 +393,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
var candidates = GetEncoderCandidates();
|
||||
var openedSuccessfully = false;
|
||||
|
||||
foreach (var encoder in candidates)
|
||||
foreach (var (encoder, useFramePool) in ExpandQsvInputModes(candidates))
|
||||
{
|
||||
// avcodec_find_encoder_by_name: Searches for an encoder by name (e.g., "h264_nvenc").
|
||||
// Returns null if the encoder is not included in the build.
|
||||
@@ -399,11 +425,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// ── Pixel format configuration ──
|
||||
// Video encoders generally expect input in YUV420P or NV12 format.
|
||||
//
|
||||
// • YUV420P: 3 independent planes Y/U/V. Used by most software encoders.
|
||||
// • NV12: Y plane + UV interleaved plane. Preferred by hardware encoders (NVENC, AMF, QSV).
|
||||
// • QSV Special Note: pix_fmt of codec context is AV_PIX_FMT_QSV (hardware surface identifier),
|
||||
// while actual sws_scale output is set to NV12 (software format).
|
||||
// FFmpeg internally handles mapping between NV12 ↔ QSV surfaces.
|
||||
// • YUV420P: 3 independent planes Y/U/V. Used by the software encoders (libvvenc: YUV420P10LE).
|
||||
// • NV12: Y plane + UV interleaved plane. Used by the hardware encoders (NVENC, AMF, QSV).
|
||||
// • QSV: the converted frames are NV12 (selectedPixFmt). The codec context pix_fmt set here is
|
||||
// AV_PIX_FMT_QSV and is replaced in the QSV device setup below according to the input mode:
|
||||
// system memory input mode: pix_fmt = NV12, the encoder receives the NV12 frames directly.
|
||||
// GPU frame pool input mode: pix_fmt stays AV_PIX_FMT_QSV, and SubmitDstFrame() uploads
|
||||
// each NV12 frame to a QSV surface with av_hwframe_transfer_data.
|
||||
//
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/pixfmt_8h.html
|
||||
var selectedPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P;
|
||||
@@ -437,16 +465,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// framerate: Framerate value giving hints to the encoder (reciprocal of time_base).
|
||||
//
|
||||
// RationalUtil: Converts FPS (double) into fractional form (numerator/denominator).
|
||||
// e.g., 29.97 → 30000/1001 (NTSC standard)
|
||||
// e.g., 60.00 -> 60/1
|
||||
//
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
||||
var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS);
|
||||
|
||||
// NTSC
|
||||
if (Math.Abs(_settings.FPS - 29.97) < 0.001) fpsRational = (30000, 1001);
|
||||
if (Math.Abs(_settings.FPS - 59.94) < 0.001) fpsRational = (60000, 1001);
|
||||
if (Math.Abs(_settings.FPS - 23.976) < 0.001 || Math.Abs(_settings.FPS - 23.98) < 0.001) fpsRational = (24000, 1001);
|
||||
|
||||
_pCodecContext->time_base = new AVRational
|
||||
{ num = fpsRational.Denominator, den = fpsRational.Numerator };
|
||||
_pCodecContext->framerate = new AVRational
|
||||
@@ -467,17 +490,21 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
_pCodecContext->gop_size = calculatedGop;
|
||||
}
|
||||
|
||||
// ── Disable B-frames ──
|
||||
// B-frames (Bidirectional predictive frames): Increase compression ratio by referencing before/after frames,
|
||||
// but increase encoding latency and may cause compatibility issues on some encoders.
|
||||
// In real-time recording scenarios, disable them to minimize latency.
|
||||
// ── B-frame configuration ──
|
||||
// B-frames (bidirectional predictive frames) reference both earlier and later frames.
|
||||
// They raise the compression ratio and make the encoder output packets out of presentation order
|
||||
// (dts < pts for reordered frames).
|
||||
//
|
||||
// Reference: has_b_frames is a read-only field internally set by the encoder,
|
||||
// so do not set it directly.
|
||||
// Configuration per encoder:
|
||||
// SVT-AV1: svtav1-params "pred-struct=1" (low delay prediction structure).
|
||||
// Setting max_b_frames on AVCodecContext makes SVT-AV1 report a "bad parameter" error.
|
||||
// libvvenc: max_b_frames = -1 (encoder default GOP structure).
|
||||
// Others: max_b_frames = 0 (no B-frames, frames are encoded in presentation order).
|
||||
//
|
||||
// SVT-AV1 Special Note: Setting max_b_frames directly on AVCodecContext
|
||||
// causes a "bad parameter" error, so control via svtav1-params.
|
||||
// pred-struct=1: Low Delay (IPPP) structure disabling B-frames.
|
||||
// has_b_frames is set by the encoder itself and is not written here.
|
||||
//
|
||||
// Reference: https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Parameters.md
|
||||
// (PredStructure: 0 = all intra, 1 = low delay, 2 = random access, default 2)
|
||||
if (encoder == VideoEncoder.LibSvtAv1)
|
||||
{
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "svtav1-params", "pred-struct=1", 0);
|
||||
@@ -519,9 +546,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// Strategy 3) Create QSV device directly (legacy method)
|
||||
// → Fallback on older drivers/systems
|
||||
//
|
||||
// After obtaining device, attempt using GPU frame pool:
|
||||
// hw_frames_ctx: Pre-allocate frame pool in GPU VRAM (zero-copy, highest performance)
|
||||
// hw_device_ctx: Encoder reads frames directly from system memory (better compatibility)
|
||||
// After obtaining device, the input mode is configured (see ExpandQsvInputModes):
|
||||
// Input mode 1, hw_device_ctx: Encoder receives NV12 frames from system memory (attempted first)
|
||||
// Input mode 2, hw_frames_ctx: Frames are uploaded to a QSV surface pool in GPU memory
|
||||
// (attempted when the encoder cannot be opened in input mode 1)
|
||||
//
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
|
||||
if (encoder.Kind == EncoderKind.QSV)
|
||||
@@ -590,68 +618,104 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Attempt hw_frames_ctx: Pre-create GPU frame pool ──
|
||||
// AVHWFramesContext pre-allocates frame pool in GPU VRAM,
|
||||
// eliminating memory allocation/deallocation overhead for each frame.
|
||||
//
|
||||
// Pros: Zero-copy capability, highest performance
|
||||
// Cons: May fail depending on GPU/driver resolution/format constraints
|
||||
// (e.g. specific texture size constraints on Intel Arc → E_INVALIDARG)
|
||||
//
|
||||
// initial_pool_size: Number of frames to pre-allocate. Matches encoder pipeline depth.
|
||||
// Align resolution to multiples of 32: Texture alignment condition required by QSV hardware.
|
||||
//
|
||||
// Reference:
|
||||
// AVHWFramesContext: https://ffmpeg.org/doxygen/trunk/structAVHWFramesContext.html
|
||||
// av_hwframe_ctx_alloc: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
|
||||
bool framesCtxReady = false;
|
||||
AVBufferRef* hwFramesRef = ffmpeg.av_hwframe_ctx_alloc(hwDeviceCtx);
|
||||
if (hwFramesRef != null)
|
||||
{
|
||||
var framesCtx = (AVHWFramesContext*)hwFramesRef->data;
|
||||
framesCtx->format = AVPixelFormat.AV_PIX_FMT_QSV; // Hardware surface format
|
||||
framesCtx->sw_format = AVPixelFormat.AV_PIX_FMT_NV12; // Software-side format
|
||||
framesCtx->width = (_settings.Width + 31) & ~31; // 32-multiple alignment (bitmask)
|
||||
framesCtx->height = (_settings.Height + 31) & ~31; // 32-multiple alignment (bitmask)
|
||||
framesCtx->initial_pool_size = 4; // Frame pool size
|
||||
|
||||
err = ffmpeg.av_hwframe_ctx_init(hwFramesRef);
|
||||
if (err >= 0)
|
||||
{
|
||||
// Success: Connect hw_frames_ctx to codec context.
|
||||
// Increment ref count with av_buffer_ref to keep valid after hwFramesRef is released.
|
||||
_pCodecContext->hw_frames_ctx = ffmpeg.av_buffer_ref(hwFramesRef);
|
||||
_isQsvEncoder = true;
|
||||
framesCtxReady = true;
|
||||
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: hw_frames_ctx initialization succeeded (GPU frame pool mode)");
|
||||
|
||||
_pHwFrame = ffmpeg.av_frame_alloc();
|
||||
if (_pHwFrame == null)
|
||||
throw new Exception("Failed to allocate QSV hardware frame structure.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: hw_frames_ctx failed — {GetFFmpegErrorString(err)} (Code: {err}). Falling back to hw_device_ctx mode.");
|
||||
}
|
||||
ffmpeg.av_buffer_unref(&hwFramesRef);
|
||||
}
|
||||
|
||||
// ── When hw_frames_ctx fails: Fallback to hw_device_ctx + NV12 system memory ──
|
||||
// In hw_device_ctx mode, the encoder receives NV12 frames from system memory (RAM)
|
||||
// directly and uploads to GPU internally.
|
||||
// Slightly slower than hw_frames_ctx, but compatibility is much better.
|
||||
//
|
||||
// Change pix_fmt to NV12: Use software format directly instead of hardware surface (QSV).
|
||||
// _isQsvEncoder = false: Skip manual upload in WriteFrame().
|
||||
if (!framesCtxReady)
|
||||
if (!useFramePool)
|
||||
{
|
||||
// ── Input mode 1 (primary): hw_device_ctx + NV12 system memory ──
|
||||
// The encoder receives NV12 frames in system memory (RAM) and the QSV runtime uploads them to the GPU.
|
||||
//
|
||||
// pix_fmt is the software format NV12.
|
||||
// _isQsvEncoder = false: WriteFrame() passes the NV12 frame to the encoder without uploading it.
|
||||
_pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_NV12;
|
||||
_pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx);
|
||||
_isQsvEncoder = false; // System memory mode, no manual frame upload required
|
||||
_isQsvEncoder = false;
|
||||
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Using system memory input mode (hw_device_ctx)");
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Input mode 2 (fallback): hw_frames_ctx GPU frame pool ──
|
||||
// Used when the encoder cannot be opened in input mode 1.
|
||||
// AVHWFramesContext pre-allocates a pool of QSV surfaces in GPU memory (pix_fmt AV_PIX_FMT_QSV),
|
||||
// and WriteFrame() uploads each NV12 frame to a surface of the pool (av_hwframe_transfer_data).
|
||||
//
|
||||
// Pool creation depends on the texture types the GPU/driver accepts
|
||||
// (e.g. Intel Arc A770 rejects NV12 textures with D3D11_BIND_RENDER_TARGET → E_INVALIDARG 0x80070057).
|
||||
//
|
||||
// initial_pool_size: Number of surfaces in the pool (QsvFramePoolSize).
|
||||
// The encoder keeps submitted surfaces until they are encoded, so the pool has to hold
|
||||
// the frames in the encoder pipeline plus the frame being uploaded.
|
||||
// Width/height are aligned to multiples of 32: texture alignment condition required by QSV hardware.
|
||||
//
|
||||
// Frame pool memory type (AVQSVFramesContext.frame_type, a combination of MFX_MEMTYPE_* flags):
|
||||
// Windows (D3D11): MFX_MEMTYPE_VIDEO_MEMORY_DECODER_TARGET (0x0010) is tried first, then 0.
|
||||
// 0x0010 creates the D3D11 textures with D3D11_BIND_DECODER.
|
||||
// 0 lets FFmpeg select MFX_MEMTYPE_VIDEO_MEMORY_PROCESSOR_TARGET (0x0020),
|
||||
// which creates the textures with D3D11_BIND_RENDER_TARGET.
|
||||
// Other platforms (VAAPI): 0. FFmpeg sets the memory type of a VAAPI child pool itself.
|
||||
// Both types are accepted by the QSV encoder as video memory input.
|
||||
// When every type fails, the next candidate encoder is tested.
|
||||
//
|
||||
// Reference:
|
||||
// AVHWFramesContext: https://ffmpeg.org/doxygen/trunk/structAVHWFramesContext.html
|
||||
// av_hwframe_ctx_alloc: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
|
||||
// AVQSVFramesContext.frame_type: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.h
|
||||
// frame_type → D3D11 bind flags (qsv_init_child_ctx, qsv_get_d3d11va_bind_flags): https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.c
|
||||
// MFX_MEMTYPE_* values: https://github.com/intel/libvpl/blob/main/api/vpl/mfxstructures.h
|
||||
bool framesCtxReady = false;
|
||||
int[] frameTypes = isWindows
|
||||
? new[] { QsvMemTypeVideoMemoryDecoderTarget, 0 }
|
||||
: new[] { 0 };
|
||||
foreach (var frameType in frameTypes)
|
||||
{
|
||||
AVBufferRef* hwFramesRef = ffmpeg.av_hwframe_ctx_alloc(hwDeviceCtx);
|
||||
if (hwFramesRef == null) break;
|
||||
|
||||
var framesCtx = (AVHWFramesContext*)hwFramesRef->data;
|
||||
framesCtx->format = AVPixelFormat.AV_PIX_FMT_QSV; // Hardware surface format
|
||||
framesCtx->sw_format = AVPixelFormat.AV_PIX_FMT_NV12; // Software-side format
|
||||
framesCtx->width = (_settings.Width + 31) & ~31; // 32-multiple alignment (bitmask)
|
||||
framesCtx->height = (_settings.Height + 31) & ~31; // 32-multiple alignment (bitmask)
|
||||
framesCtx->initial_pool_size = QsvFramePoolSize;
|
||||
SetQsvFrameType(framesCtx, frameType);
|
||||
|
||||
err = ffmpeg.av_hwframe_ctx_init(hwFramesRef);
|
||||
if (err >= 0)
|
||||
{
|
||||
// Success: Connect hw_frames_ctx to codec context.
|
||||
// Increment ref count with av_buffer_ref to keep valid after hwFramesRef is released.
|
||||
_pCodecContext->hw_frames_ctx = ffmpeg.av_buffer_ref(hwFramesRef);
|
||||
_isQsvEncoder = true;
|
||||
framesCtxReady = true;
|
||||
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Using GPU frame pool input mode (hw_frames_ctx, frame_type=0x{frameType:X4}, pool={QsvFramePoolSize})");
|
||||
ffmpeg.av_buffer_unref(&hwFramesRef);
|
||||
|
||||
_pHwFrame = ffmpeg.av_frame_alloc();
|
||||
if (_pHwFrame == null)
|
||||
throw new Exception("Failed to allocate QSV hardware frame structure.");
|
||||
break;
|
||||
}
|
||||
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: hw_frames_ctx failed with frame_type=0x{frameType:X4} — {GetFFmpegErrorString(err)} (Code: {err})");
|
||||
ffmpeg.av_buffer_unref(&hwFramesRef);
|
||||
}
|
||||
|
||||
if (!framesCtxReady)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: GPU frame pool input mode is unavailable. Testing next candidate codec.");
|
||||
ffmpeg.av_buffer_unref(&hwDeviceCtx);
|
||||
{
|
||||
var pCtx = _pCodecContext;
|
||||
ffmpeg.avcodec_free_context(&pCtx);
|
||||
}
|
||||
_pCodecContext = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer ownership of hwDeviceCtx to codec context,
|
||||
// so release local reference (reference count management).
|
||||
// Release the local reference of hwDeviceCtx.
|
||||
// The device stays alive through the references taken above:
|
||||
// system memory input mode: codec context hw_device_ctx (av_buffer_ref)
|
||||
// GPU frame pool input mode: the frames context (av_hwframe_ctx_alloc keeps a device reference),
|
||||
// held by codec context hw_frames_ctx
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__buffer.html
|
||||
ffmpeg.av_buffer_unref(&hwDeviceCtx);
|
||||
}
|
||||
@@ -709,11 +773,19 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
break;
|
||||
}
|
||||
|
||||
// If open fails, free codec context and try next encoder.
|
||||
Melon<RenderMod>.Logger.Warning($"Failed to initialize hardware encoder {encoder.Name}: {GetFFmpegErrorString(openResult)} (Code: {openResult}). Testing next candidate codec.");
|
||||
// If open fails, free codec context and QSV frame state, then try next candidate.
|
||||
var inputMode = encoder.Kind == EncoderKind.QSV ? (useFramePool ? " (GPU frame pool input mode)" : " (system memory input mode)") : "";
|
||||
Melon<RenderMod>.Logger.Warning($"Failed to initialize encoder {encoder.Name}{inputMode}: {GetFFmpegErrorString(openResult)} (Code: {openResult}). Testing next candidate.");
|
||||
var pCodecCtx = _pCodecContext;
|
||||
ffmpeg.avcodec_free_context(&pCodecCtx);
|
||||
_pCodecContext = null;
|
||||
_isQsvEncoder = false;
|
||||
if (_pHwFrame != null)
|
||||
{
|
||||
var pHwFrame = _pHwFrame;
|
||||
ffmpeg.av_frame_free(&pHwFrame);
|
||||
_pHwFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!openedSuccessfully || _pCodecContext == null)
|
||||
@@ -728,6 +800,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0)
|
||||
throw new Exception("Failed to copy codec parameters to stream.");
|
||||
|
||||
// Stream time base: unit of the timestamps stored in the container.
|
||||
// Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks.
|
||||
// When it is left unset, the muxer uses 1/90000, in which the interval of rates such as
|
||||
// 59.94 fps (1501.5 ticks), 23.976 fps (3753.75 ticks) or 165 fps (545.45 ticks) is not an integer,
|
||||
// and the frame durations alternate between two values.
|
||||
// The muxer may still adjust the value in avformat_write_header (e.g. mp4 scales it to at least 10000);
|
||||
// packet timestamps are rescaled to the final value in SendFrameAndWritePackets.
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/mux.c (init_muxer: default 1/90000)
|
||||
_pVideoStream->time_base = _pCodecContext->time_base;
|
||||
|
||||
// ── Step 4.5 (Optional): Initialize audio stream ──
|
||||
// If EncoderSettings.Audio is set, add audio stream.
|
||||
if (_settings.Audio.HasValue)
|
||||
@@ -760,16 +842,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
if (_pSrcFrame == null || _pDstFrame == null || _pPacket == null)
|
||||
throw new Exception("Failed to allocate frame or packet structure.");
|
||||
|
||||
// Set format and size of destination frame, then allocate actual pixel buffer.
|
||||
// av_frame_get_buffer: Allocates memory matching format, width, height and
|
||||
// sets data[]/linesize[] pointers. Second argument 0 means default alignment.
|
||||
_pDstFrame->format = (int)_dstPixFmt;
|
||||
_pDstFrame->width = _settings.Width;
|
||||
_pDstFrame->height = _settings.Height;
|
||||
_pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG;
|
||||
|
||||
if (ffmpeg.av_frame_get_buffer(_pDstFrame, 0) < 0)
|
||||
throw new Exception($"Failed to allocate {_dstPixFmt} frame buffer.");
|
||||
AllocateDstFrameBuffer();
|
||||
|
||||
// sws_getContext: Initialize software scaler (colorspace converter).
|
||||
// Input: RGBA (Unity capture format, 4 bytes/pixel)
|
||||
@@ -778,8 +851,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// Colorspace is converted without resolution change here, but sws_scale
|
||||
// can also perform resizing simultaneously.
|
||||
//
|
||||
// SWS_POINT: Nearest neighbor interpolation. Since only colorspace is converted without resolution change,
|
||||
// interpolation algorithm does not affect output quality, and SWS_POINT is fastest.
|
||||
// SWS_POINT: Nearest neighbor sampling. The luma plane keeps the source resolution (1:1).
|
||||
// The chroma planes are subsampled to half width and height (4:2:0) with the same filter,
|
||||
// so each chroma sample takes one source pixel instead of averaging the 2x2 block.
|
||||
// This scaler is used only by the CPU conversion path (WriteFrame with RGBA input).
|
||||
//
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
|
||||
_pSwsContext = ffmpeg.sws_getContext(
|
||||
@@ -830,7 +905,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// Reference - CQP options per encoder:
|
||||
/// NVENC: "qp" (single value applied to I/P/B frames)
|
||||
/// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately)
|
||||
/// QSV: "global_quality" (Intel unique option)
|
||||
/// QSV: AVCodecContext.global_quality (Intel ICQ mode)
|
||||
/// Software: "crf" (Constant Rate Factor, smarter quality control)
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
||||
@@ -852,11 +927,21 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
_pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L;
|
||||
_pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000;
|
||||
|
||||
// nal-hrd=cbr: x264/x265 dedicated option. Generates CBR bitstream matching
|
||||
// NAL HRD (Hypothetical Reference Decoder) specification. May be required for streaming compatibility.
|
||||
if (encoder == VideoEncoder.Libx264 || encoder == VideoEncoder.Libx265)
|
||||
// HRD (Hypothetical Reference Decoder) signaling for CBR output, used for streaming compatibility.
|
||||
// libx264: "nal-hrd=cbr" writes the NAL HRD information and pads filler data to keep the bitrate constant.
|
||||
// FFmpeg's option description: "Signal HRD information (requires vbv-bufsize; cbr not allowed in .mp4)".
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/libx264.c ("nal-hrd": none/vbr/cbr)
|
||||
// libx265: "x265-params" = "hrd=1" (--hrd: "Enable the signaling of HRD parameters to the decoder")
|
||||
// + "strict-cbr=1" (--strict-cbr: "Enables stricter conditions to control bitrate deviance
|
||||
// from the target bitrate in ABR mode").
|
||||
// Reference: https://x265.readthedocs.io/en/master/cli.html
|
||||
if (encoder == VideoEncoder.Libx264)
|
||||
{
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "nal-hrd", "cbr", 0);
|
||||
TrySetOption("nal-hrd", "cbr");
|
||||
}
|
||||
else if (encoder == VideoEncoder.Libx265)
|
||||
{
|
||||
TrySetOption("x265-params", "hrd=1:strict-cbr=1");
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -880,38 +965,77 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
break;
|
||||
|
||||
case RateControlMode.CQP:
|
||||
// Valid quality range per encoder. The range of the private options is read from the
|
||||
// encoder's option definition (AVOption min/max); a declared minimum of -1 means "auto".
|
||||
// NVENC: "qp" H.264/HEVC 0~51, AV1 0~255
|
||||
// (libavcodec/nvenc_h264.c, nvenc_hevc.c: "qp" -1~51 / libavcodec/nvenc_av1.c: "qp" -1~255)
|
||||
// AMF: "qp_i" H.264/HEVC 0~51, AV1 0~255
|
||||
// (libavcodec/amfenc_h264.c, amfenc_hevc.c: "qp_i" -1~51 / libavcodec/amfenc_av1.c: "qp_i" -1~255)
|
||||
// QSV: AVCodecContext.global_quality 1~51
|
||||
// VVC: "qp" 0~63 (libavcodec/libvvenc.c: "qp" -1~63)
|
||||
// Software: "crf" x264/x265 0~51 (https://x265.readthedocs.io/en/master/cli.html: --crf 0..51.0),
|
||||
// VP9/AV1 0~63 (libavcodec/libvpxenc.c, libaomenc.c: "crf" -1~63 / libavcodec/libsvtav1.c: "crf" 0~63)
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/tree/master/libavcodec
|
||||
int qpMin, qpMax;
|
||||
if (encoder.Kind == EncoderKind.QSV)
|
||||
{
|
||||
// QSV runs in ICQ (Intelligent Constant Quality) mode when global_quality > 0 and rc_max_rate is 0;
|
||||
// global_quality 0 selects the encoder's default CQP. The ICQ quality is clamped to 1~51 by FFmpeg:
|
||||
// libavcodec/qsvenc.c: q->param.mfx.ICQQuality = av_clip(avctx->global_quality, 1, 51);
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/qsvenc.c
|
||||
qpMin = 1;
|
||||
qpMax = 51;
|
||||
}
|
||||
else if (encoder.Kind == EncoderKind.NVENC) (qpMin, qpMax) = GetQualityRange("qp");
|
||||
else if (encoder.Kind == EncoderKind.AMF) (qpMin, qpMax) = GetQualityRange("qp_i");
|
||||
else if (encoder == VideoEncoder.LibVvenc) (qpMin, qpMax) = GetQualityRange("qp");
|
||||
else (qpMin, qpMax) = GetQualityRange("crf"); // Software encoder (libx264, libx265, libvpx-vp9, etc.)
|
||||
|
||||
// Default value 18: Quality close to "visually lossless" in H.264.
|
||||
// Provides similar level of quality in other codecs as well.
|
||||
var qpValue = _settings.QualityValue is >= 0 and <= 51 ? _settings.QualityValue : 18;
|
||||
const int defaultQuality = 18;
|
||||
var requestedQp = _settings.QualityValue;
|
||||
int qpValue;
|
||||
if (requestedQp < 0)
|
||||
{
|
||||
qpValue = Math.Clamp(defaultQuality, qpMin, qpMax);
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Invalid quality value {requestedQp}. Using {qpValue}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
qpValue = Math.Clamp(requestedQp, qpMin, qpMax);
|
||||
if (qpValue != requestedQp)
|
||||
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Quality value {requestedQp} is outside the supported range {qpMin}~{qpMax}. Using {qpValue}.");
|
||||
}
|
||||
|
||||
if (encoder.Kind == EncoderKind.NVENC)
|
||||
{
|
||||
// NVENC: Applies same QP to I/P/B frames with single "qp" option
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0);
|
||||
TrySetOption("qp", qpValue.ToString());
|
||||
}
|
||||
else if (encoder.Kind == EncoderKind.AMF)
|
||||
{
|
||||
// AMF: QP for I-frame (qp_i) and P-frame (qp_p) must be set separately.
|
||||
// Since B-frames are disabled, qp_b is not set.
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_i", qpValue.ToString(), 0);
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_p", qpValue.ToString(), 0);
|
||||
TrySetOption("qp_i", qpValue.ToString());
|
||||
TrySetOption("qp_p", qpValue.ToString());
|
||||
}
|
||||
else if (encoder.Kind == EncoderKind.QSV)
|
||||
{
|
||||
// QSV: Uses ICQ (Intelligent Constant Quality) mode with "global_quality" option
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "global_quality", qpValue.ToString(), 0);
|
||||
// QSV: ICQ (Intelligent Constant Quality) mode, configured with AVCodecContext.global_quality.
|
||||
_pCodecContext->global_quality = qpValue;
|
||||
}
|
||||
else if (encoder == VideoEncoder.LibVvenc)
|
||||
{
|
||||
// libvvenc: Specifies Constant QP mode with "qp" option
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0);
|
||||
TrySetOption("qp", qpValue.ToString());
|
||||
}
|
||||
else // Software encoder (libx264, libx265, libvpx-vp9, etc.)
|
||||
else
|
||||
{
|
||||
// CRF (Constant Rate Factor): Smarter quality control than QP.
|
||||
// Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality.
|
||||
// Range: 0 (lossless) ~ 51 (lowest quality). Typically between 18 and 28.
|
||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "crf", qpValue.ToString(), 0);
|
||||
// Range: 0 (lossless) ~ 51 (lowest quality) for x264/x265, 0~63 for VP9/AV1. Typically between 18 and 28.
|
||||
TrySetOption("crf", qpValue.ToString());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -919,6 +1043,76 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a private option (priv_data) of the encoder.
|
||||
/// Returns true when FFmpeg accepts the option. Otherwise logs a warning with the FFmpeg error and returns false.
|
||||
/// </summary>
|
||||
// ReSharper disable once UnusedMethodReturnValue.Local
|
||||
private bool TrySetOption(string name, string value)
|
||||
{
|
||||
var ret = ffmpeg.av_opt_set(_pCodecContext->priv_data, name, value, 0);
|
||||
if (ret >= 0) return true;
|
||||
|
||||
Melon<RenderMod>.Logger.Warning($"[Encoder] Option '{name}={value}' was not applied: {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MFX_MEMTYPE_VIDEO_MEMORY_DECODER_TARGET (0x0010): frames are in video memory and belong to video decoder render targets.
|
||||
/// Reference: https://github.com/intel/libvpl/blob/main/api/vpl/mfxstructures.h
|
||||
/// </summary>
|
||||
private const int QsvMemTypeVideoMemoryDecoderTarget = 0x0010;
|
||||
|
||||
/// <summary>
|
||||
/// Number of surfaces in the QSV GPU frame pool (AVHWFramesContext.initial_pool_size) of GPU frame pool input mode.
|
||||
/// Same value as the encoder frame pool of the FFmpeg hardware encoding example (initial_pool_size = 20).
|
||||
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/doc/examples/vaapi_encode.c
|
||||
/// </summary>
|
||||
private const int QsvFramePoolSize = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Writes AVQSVFramesContext.frame_type of a QSV frames context (AVHWFramesContext.hwctx).
|
||||
/// FFmpeg.AutoGen has no AVQSVFramesContext type, so the field is written at its offset.
|
||||
/// Field order of AVQSVFramesContext: mfxFrameSurface1* surfaces, int nb_surfaces, int frame_type, mfxFrameInfo* info
|
||||
/// → frame_type is at offset sizeof(pointer) + sizeof(int) (12 bytes on 64-bit).
|
||||
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.h
|
||||
/// </summary>
|
||||
private static void SetQsvFrameType(AVHWFramesContext* framesCtx, int frameType)
|
||||
{
|
||||
if (framesCtx->hwctx == null) return;
|
||||
*(int*)((byte*)framesCtx->hwctx + IntPtr.Size + sizeof(int)) = frameType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Largest declared maximum that is treated as an actual quality range.
|
||||
/// A declared maximum above this value is treated as unbounded, and the fallback range 0~51 is used.
|
||||
/// The largest bounded quality range among the supported encoders is 0~255 (AV1 NVENC/AMF), while
|
||||
/// libx264/libx265 declare "crf" as -1~FLT_MAX (libavcodec/libx264.c, libx265.c).
|
||||
/// </summary>
|
||||
private const double MaxMeaningfulQualityLimit = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the valid range of a private quality option of the encoder from its option definition (AVOption min/max).
|
||||
/// e.g. 0~51 for H.264/HEVC, 0~63 for VP9/AV1/VVC, 0~255 for AV1 NVENC/AMF.
|
||||
/// The lower bound is at least 0 (a declared minimum of -1 means "auto").
|
||||
/// When the option is missing or its declared maximum is unbounded (see <see cref="MaxMeaningfulQualityLimit"/>), returns 0~51,
|
||||
/// the QP scale of H.264/HEVC 8-bit (x264/x265 CRF 0~51).
|
||||
/// Reference: https://x265.readthedocs.io/en/master/cli.html (--crf 0..51.0)
|
||||
/// </summary>
|
||||
/// <param name="optionName">Name of the quality option in priv_data.</param>
|
||||
private (int Min, int Max) GetQualityRange(string optionName)
|
||||
{
|
||||
const int fallbackMax = 51;
|
||||
|
||||
var option = ffmpeg.av_opt_find(_pCodecContext->priv_data, optionName, null, 0, 0);
|
||||
if (option == null || !(option->max > 0 && option->max <= MaxMeaningfulQualityLimit))
|
||||
return (0, fallbackMax);
|
||||
|
||||
var min = Math.Max(0, (int)Math.Ceiling(option->min));
|
||||
var max = (int)Math.Floor(option->max);
|
||||
return min <= max ? (min, max) : (0, fallbackMax);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Write Frame
|
||||
// ================================================================
|
||||
@@ -1018,12 +1212,50 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the format, size and color range of the destination frame (_pDstFrame) and allocates a new pixel buffer for it.
|
||||
/// av_frame_get_buffer allocates memory matching format, width and height and sets the data[]/linesize[] pointers.
|
||||
/// Second argument 0 means default alignment.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_get_buffer)
|
||||
/// </summary>
|
||||
private void AllocateDstFrameBuffer()
|
||||
{
|
||||
_pDstFrame->format = (int)_dstPixFmt;
|
||||
_pDstFrame->width = _settings.Width;
|
||||
_pDstFrame->height = _settings.Height;
|
||||
_pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG;
|
||||
|
||||
var ret = ffmpeg.av_frame_get_buffer(_pDstFrame, 0);
|
||||
if (ret < 0)
|
||||
throw new Exception($"Failed to allocate {_dstPixFmt} frame buffer — {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes the pixel buffer of the destination frame (_pDstFrame) writable before the next frame is written into it.
|
||||
///
|
||||
/// After avcodec_send_frame, the encoder may keep a reference to the buffer of the submitted frame
|
||||
/// until that frame is encoded (e.g. QSV in system memory input mode). The buffer is then shared and not writable
|
||||
/// (av_frame_is_writable returns 0).
|
||||
/// In that case the frame releases its reference (av_frame_unref) and a new buffer is allocated.
|
||||
/// The encoder keeps the previous buffer until it no longer needs it. The previous contents are not copied,
|
||||
/// because every plane is overwritten by the next frame.
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_is_writable, av_frame_unref)
|
||||
/// </summary>
|
||||
private void PrepareDstFrameForWrite()
|
||||
{
|
||||
if (ffmpeg.av_frame_is_writable(_pDstFrame) != 0) return;
|
||||
|
||||
ffmpeg.av_frame_unref(_pDstFrame);
|
||||
AllocateDstFrameBuffer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a frame by directly copying already pre-converted NV12 or YUV420P frame data.
|
||||
/// </summary>
|
||||
private void WritePreConvertedFrame(byte* buffer)
|
||||
{
|
||||
ffmpeg.av_frame_make_writable(_pDstFrame);
|
||||
PrepareDstFrameForWrite();
|
||||
|
||||
var width = _settings.Width;
|
||||
var height = _settings.Height;
|
||||
@@ -1137,20 +1369,17 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// Core logic for actual frame encoding.
|
||||
///
|
||||
/// Processing flow:
|
||||
/// 1. av_frame_make_writable: Check if destination frame buffer is writable
|
||||
/// 1. PrepareDstFrameForWrite: Makes the destination frame buffer writable
|
||||
/// (Allocates a new buffer if encoder is still referencing previous frame's buffer)
|
||||
/// 2. sws_scale: RGBA → YUV420P/NV12 colorspace conversion
|
||||
/// 3. (QSV hw_frames mode) av_hwframe_transfer_data: Upload NV12 → QSV hardware surface
|
||||
/// 4. Pass to encoder after setting PTS
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_make_writable)
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_scale)
|
||||
/// </summary>
|
||||
private void WriteFrame()
|
||||
{
|
||||
// Ensure writable state since encoder might still be referencing previous frame's buffer.
|
||||
// Internally allocates a new buffer if referenced.
|
||||
ffmpeg.av_frame_make_writable(_pDstFrame);
|
||||
PrepareDstFrameForWrite();
|
||||
|
||||
// ── Colorspace conversion: RGBA → YUV420P or NV12 ──
|
||||
// sws_scale parameters explanation:
|
||||
@@ -1180,8 +1409,8 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// av_hwframe_get_buffer: Get an empty hardware frame from hw_frames_ctx frame pool.
|
||||
// av_hwframe_transfer_data: Copy (upload) NV12 software frame data to QSV surface.
|
||||
//
|
||||
// Through this process, the encoder can read frames directly from GPU memory,
|
||||
// minimizing PCIe bandwidth usage.
|
||||
// The frame is copied from system memory to the GPU surface on every call;
|
||||
// the encoder then reads the surface from GPU memory.
|
||||
//
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
|
||||
|
||||
@@ -1311,8 +1540,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// 2. av_write_trailer: Writes container trailer (end-of-file metadata).
|
||||
/// For MP4, the moov atom (index info) is completed here.
|
||||
/// ⚠️ If trailer is not written, the file becomes corrupted and unplayable!
|
||||
/// av_write_trailer also flushes the output buffer and returns the write error of the output (AVIOContext.error), if any.
|
||||
///
|
||||
/// Throws an exception when a flush or the trailer fails (e.g. the disk is full), because the file is then incomplete.
|
||||
/// End() runs once: after it returns or throws, further calls return immediately.
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
|
||||
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavformat/mux.c (av_write_trailer)
|
||||
/// </summary>
|
||||
public void End()
|
||||
{
|
||||
@@ -1320,37 +1554,29 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
if (_disposed || !_isInitialized) return;
|
||||
|
||||
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
|
||||
try
|
||||
{
|
||||
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
|
||||
SendFrameAndWritePackets(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log error occurring during flush process
|
||||
Melon<RenderMod>.Logger.Error($"Error during video encoder flush: {ex.Message}");
|
||||
}
|
||||
|
||||
// 1.5. Flush remaining samples in audio FIFO + flush audio encoder
|
||||
if (_audioEnabled)
|
||||
{
|
||||
try
|
||||
// 1.5. Flush remaining samples in audio FIFO + flush audio encoder
|
||||
if (_audioEnabled)
|
||||
{
|
||||
FlushAudioEncoder();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// 2. Write container trailer (moov atom and file termination metadata)
|
||||
if (_pFormatContext != null)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Error during audio encoder flush: {ex.Message}");
|
||||
var ret = ffmpeg.av_write_trailer(_pFormatContext);
|
||||
if (ret < 0)
|
||||
throw new Exception($"Failed to write the container trailer — {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Write container trailer (moov atom and file termination metadata)
|
||||
if (_pFormatContext != null)
|
||||
finally
|
||||
{
|
||||
ffmpeg.av_write_trailer(_pFormatContext);
|
||||
_isInitialized = false;
|
||||
}
|
||||
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1361,11 +1587,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// <summary>
|
||||
/// Actual resource cleanup logic. Cleanup order is important:
|
||||
///
|
||||
/// 1. Call End() (if not called yet) → flush delayed frames + write trailer
|
||||
/// 2. SwsContext (scaler) → independent, can be freed first
|
||||
/// 3. AVFrame (src/dst) → freed before codec as codec may reference them
|
||||
/// 4. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together
|
||||
/// 5. AVFormatContext (format/IO) → includes closing file handle
|
||||
/// 1. SwsContext (scaler) → independent, can be freed first
|
||||
/// 2. AVFrame (src/dst) → freed before codec as codec may reference them
|
||||
/// 3. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together
|
||||
/// 4. AVFormatContext (format/IO) → includes closing file handle
|
||||
///
|
||||
/// Dispose does not finish the file (no encoder flush, no trailer). A file is complete only after End() returned.
|
||||
/// Without End(), the output file is incomplete and is left to the caller (the renderer deletes it).
|
||||
///
|
||||
/// Reference:
|
||||
/// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
||||
@@ -1379,12 +1607,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Trigger End() safely in case Dispose is called without calling End() first
|
||||
if (_isInitialized)
|
||||
{
|
||||
try { End(); } catch { /* Ignore */ }
|
||||
}
|
||||
_isInitialized = false;
|
||||
|
||||
// Release Sws scaler
|
||||
if (_pSwsContext != null)
|
||||
@@ -1750,8 +1973,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
|
||||
/// <summary>
|
||||
/// Flushes the audio encoder.
|
||||
/// Pads and encodes remaining samples (less than frame_size) in FIFO,
|
||||
/// then sends null frame to output all delayed frames in encoder.
|
||||
/// Sends the samples remaining in the FIFO (fewer than frame_size) as one last short frame,
|
||||
/// then sends a null frame to output all delayed packets of the encoder.
|
||||
///
|
||||
/// The short frame is handled by libavcodec (encode_send_frame_internal):
|
||||
/// encoders with AV_CODEC_CAP_SMALL_LAST_FRAME or AV_CODEC_CAP_VARIABLE_FRAME_SIZE receive it as is,
|
||||
/// other encoders receive it padded with silence up to frame_size (pad_last_frame).
|
||||
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/encode.c
|
||||
/// </summary>
|
||||
private void FlushAudioEncoder()
|
||||
{
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
|
||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
|
||||
{
|
||||
RecycleState(state);
|
||||
return;
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using MelonLoader;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -121,7 +122,7 @@ internal static class GPUUtil
|
||||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
||||
|
||||
var nvidiaVerInt = sub % 10 * 10000 + build;
|
||||
_cachedNvidiaVersion = nvidiaVerInt / 100f;
|
||||
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
|
||||
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
||||
return _cachedNvidiaVersion;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util;
|
||||
namespace ADOFAIRenderer.Util.OS;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
||||
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
||||
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
|
||||
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
private enum GPUMode
|
||||
@@ -189,6 +190,14 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
||||
var targetVideoTime = songTime + vidOffset;
|
||||
|
||||
// When looping is enabled, the playback position is the elapsed time wrapped into [0, Duration).
|
||||
// When it is disabled, the position keeps increasing and the last frame stays on screen after the end.
|
||||
var videoDuration = _decoder.Duration;
|
||||
if (_isLooping && targetVideoTime >= 0 && videoDuration > 0)
|
||||
{
|
||||
targetVideoTime %= videoDuration;
|
||||
}
|
||||
|
||||
if (targetVideoTime < 0)
|
||||
{
|
||||
if (_targetTexture)
|
||||
@@ -307,6 +316,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_computeShader.SetInt(Width, w);
|
||||
_computeShader.SetInt(Height, h);
|
||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||
|
||||
int threadGroupsX = (w + 7) / 8;
|
||||
int threadGroupsY = (h + 7) / 8;
|
||||
@@ -341,6 +351,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_computeShader.SetInt(Width, w);
|
||||
_computeShader.SetInt(Height, h);
|
||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||
|
||||
int threadGroupsX = (w + 7) / 8;
|
||||
int threadGroupsY = (h + 7) / 8;
|
||||
|
||||
Reference in new issue
Block a user