SHA256
Compare commits
23
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
f153bf70aa
|
||
|
|
93539307b1
|
||
|
|
c0e902fc1a
|
||
|
|
dd2585ee9f
|
||
|
|
1a672cbf59
|
||
|
|
6d5880fe19
|
||
|
|
72d8d71928
|
||
|
|
413778e165
|
||
|
|
36552511f5
|
||
|
|
a1f9fa36ce
|
||
|
|
00971a6730
|
||
|
|
bd24152a2e
|
||
|
|
7aa8ae4b54
|
||
|
|
dee3b2dbcf
|
||
|
|
7eec3b4660
|
||
|
|
ce70d89cdb
|
||
|
|
7db1905906
|
||
|
|
bfc79ee4d3
|
||
|
|
b4c950c9f7
|
||
|
|
723f4446f5
|
||
|
|
6f4c632b41
|
||
|
|
863dcd2357
|
||
|
|
00048b7b06
|
No files matched your search
+9
-3
@@ -32,9 +32,11 @@ Thumbs.db
|
|||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
## Agent Metadata
|
## Agent Metadata
|
||||||
.antigravitycli/
|
/.antigravitycli/
|
||||||
.agent/
|
/.agent/
|
||||||
GEMINI.md
|
/GEMINI.md
|
||||||
|
/.mcp.json
|
||||||
|
/CLAUDE.md
|
||||||
|
|
||||||
# Decompiled Game Code
|
# Decompiled Game Code
|
||||||
*.cs.txt
|
*.cs.txt
|
||||||
@@ -43,3 +45,7 @@ GEMINI.md
|
|||||||
/AsyncInputAutoplayFix.md
|
/AsyncInputAutoplayFix.md
|
||||||
/AudioMixerExplanation.md
|
/AudioMixerExplanation.md
|
||||||
/O5Theme.md
|
/O5Theme.md
|
||||||
|
|
||||||
|
# Developer Utility Files
|
||||||
|
/REVIEW_CHECKLIST.md
|
||||||
|
/REVIEW_FFMPEG.md
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+158
-35
@@ -14,6 +14,7 @@ using ADOFAIRenderer.UI.Window;
|
|||||||
using ADOFAIRenderer.Util;
|
using ADOFAIRenderer.Util;
|
||||||
using ADOFAIRenderer.Util.Audio;
|
using ADOFAIRenderer.Util.Audio;
|
||||||
using ADOFAIRenderer.Util.FFmpeg;
|
using ADOFAIRenderer.Util.FFmpeg;
|
||||||
|
using ADOFAIRenderer.Util.OS;
|
||||||
using FFmpeg.AutoGen;
|
using FFmpeg.AutoGen;
|
||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
@@ -91,7 +92,50 @@ internal class RenderManager : MonoBehaviour
|
|||||||
public long frameCount;
|
public long frameCount;
|
||||||
public bool skipPressToStart;
|
public bool skipPressToStart;
|
||||||
public string levelPath = "";
|
public string levelPath = "";
|
||||||
public bool IsRenderCancelled { get; private set; }
|
private readonly object _abortStateLock = new();
|
||||||
|
private volatile bool _isRenderCancelled;
|
||||||
|
private volatile bool _renderFailed;
|
||||||
|
private volatile string? _failureReason;
|
||||||
|
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
|
public bool IsRenderCancelled => _isRenderCancelled;
|
||||||
|
public bool IsRenderFailed => _renderFailed;
|
||||||
|
|
||||||
|
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
|
||||||
|
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
||||||
|
|
||||||
|
private void MarkCancelled()
|
||||||
|
{
|
||||||
|
lock (_abortStateLock)
|
||||||
|
{
|
||||||
|
if (!_renderFailed) _isRenderCancelled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first failure reason wins; ignored once the render is already cancelled or failed.
|
||||||
|
private void MarkFailed(string reason)
|
||||||
|
{
|
||||||
|
lock (_abortStateLock)
|
||||||
|
{
|
||||||
|
if (_isRenderCancelled || _renderFailed) return;
|
||||||
|
_failureReason = reason;
|
||||||
|
_renderFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearAbortState()
|
||||||
|
{
|
||||||
|
lock (_abortStateLock)
|
||||||
|
{
|
||||||
|
_isRenderCancelled = false;
|
||||||
|
_renderFailed = false;
|
||||||
|
_failureReason = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildAbortMessage() => IsRenderFailed
|
||||||
|
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
||||||
|
: "Rendering was cancelled by user.";
|
||||||
public RenderConfig Configuration = null!;
|
public RenderConfig Configuration = null!;
|
||||||
|
|
||||||
// Render Sessions State (OOP Instance Fields)
|
// Render Sessions State (OOP Instance Fields)
|
||||||
@@ -102,7 +146,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
public int frameCountBase;
|
public int frameCountBase;
|
||||||
|
|
||||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||||
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||||||
|
|
||||||
private FrameReader? _frameReader;
|
private FrameReader? _frameReader;
|
||||||
private FrameHookPipeline? _frameHookPipeline;
|
private FrameHookPipeline? _frameHookPipeline;
|
||||||
@@ -151,6 +195,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
public long frameCountForVideo;
|
public long frameCountForVideo;
|
||||||
public long lastVideoFrameIndex = -1;
|
public long lastVideoFrameIndex = -1;
|
||||||
|
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||||||
public long lastRequestedVideoFrameIndex = -1;
|
public long lastRequestedVideoFrameIndex = -1;
|
||||||
public int readbackFailureCount;
|
public int readbackFailureCount;
|
||||||
public int throttleCount;
|
public int throttleCount;
|
||||||
@@ -170,8 +215,8 @@ internal class RenderManager : MonoBehaviour
|
|||||||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
internal AutoResetEvent FrameAvailableEvent = new(false);
|
||||||
public Thread? RenderThread;
|
public Thread? RenderThread;
|
||||||
public long passCount;
|
public long passCount;
|
||||||
public bool endRequested;
|
public volatile bool endRequested;
|
||||||
public bool endSuccessful;
|
public volatile bool endSuccessful;
|
||||||
public long endFrame = -1;
|
public long endFrame = -1;
|
||||||
|
|
||||||
// Private Instance Fields
|
// Private Instance Fields
|
||||||
@@ -181,6 +226,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
private GameObject? _debugMonitorObj;
|
private GameObject? _debugMonitorObj;
|
||||||
private GameObject? _escapeHandlerObj;
|
private GameObject? _escapeHandlerObj;
|
||||||
internal string? CurrentOutPath;
|
internal string? CurrentOutPath;
|
||||||
|
|
||||||
|
// Output file of the current render that is not finished yet.
|
||||||
|
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
|
||||||
|
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
|
||||||
|
private string? _incompleteOutputPath;
|
||||||
private bool _renderSetup;
|
private bool _renderSetup;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
@@ -195,12 +245,15 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
public void ResetRenderState(bool success)
|
public void ResetRenderState(bool success)
|
||||||
{
|
{
|
||||||
IsRenderCancelled = !success;
|
if (success) _isRenderCancelled = false;
|
||||||
|
else MarkCancelled();
|
||||||
stage = RenderStage.None;
|
stage = RenderStage.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CleanUpActiveRender()
|
public void CleanUpActiveRender()
|
||||||
{
|
{
|
||||||
|
if (stage != RenderStage.None)
|
||||||
|
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
|
||||||
ResetRenderState(success: false);
|
ResetRenderState(success: false);
|
||||||
|
|
||||||
AudioSpectrumGenerator.Clear();
|
AudioSpectrumGenerator.Clear();
|
||||||
@@ -238,6 +291,32 @@ internal class RenderManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
RenderThread.Join(200);
|
RenderThread.Join(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The encoders are disposed above, which closes the output file. An unfinished file of this render is deleted.
|
||||||
|
DeleteIncompleteOutputFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the output file of the current render when the render did not finish it (_incompleteOutputPath).
|
||||||
|
/// Files of earlier, finished renders are never deleted.
|
||||||
|
/// Returns true when a file was deleted.
|
||||||
|
/// </summary>
|
||||||
|
private bool DeleteIncompleteOutputFile()
|
||||||
|
{
|
||||||
|
var path = Interlocked.Exchange(ref _incompleteOutputPath, null);
|
||||||
|
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {path}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete incomplete video: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
@@ -283,8 +362,15 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Instance.stage < RenderStage.OnScene) return;
|
if (Instance.stage < RenderStage.OnScene) return;
|
||||||
|
if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true })
|
||||||
|
{
|
||||||
|
MarkFailed("the render thread terminated unexpectedly.");
|
||||||
|
CancelRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
||||||
{
|
{
|
||||||
|
MarkFailed("the level failed or the game controller was lost during rendering.");
|
||||||
ResetRenderState(success: false);
|
ResetRenderState(success: false);
|
||||||
StopFFmpeg(false);
|
StopFFmpeg(false);
|
||||||
EndRender();
|
EndRender();
|
||||||
@@ -333,6 +419,12 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
while (Instance.stage >= RenderStage.Render)
|
while (Instance.stage >= RenderStage.Render)
|
||||||
{
|
{
|
||||||
|
if (RenderThread is not { IsAlive: true })
|
||||||
|
{
|
||||||
|
isThrottling = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int count;
|
int count;
|
||||||
lock (ImageQueue)
|
lock (ImageQueue)
|
||||||
{
|
{
|
||||||
@@ -362,7 +454,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
if (videoFrameIndex > lastVideoFrameIndex)
|
if (videoFrameIndex > lastVideoFrameIndex)
|
||||||
{
|
{
|
||||||
lastVideoFrameIndex = videoFrameIndex;
|
lastVideoFrameIndex = videoFrameIndex;
|
||||||
lastRequestedVideoFrameIndex = videoFrameIndex;
|
Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex);
|
||||||
|
|
||||||
var targetTexture = texture;
|
var targetTexture = texture;
|
||||||
if (HasActiveFrameHooks && _frameHookPipeline != null)
|
if (HasActiveFrameHooks && _frameHookPipeline != null)
|
||||||
@@ -403,7 +495,6 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
Application.wantsToQuit -= quitDelegate;
|
Application.wantsToQuit -= quitDelegate;
|
||||||
RenderConfigWindow.CreateWindow();
|
RenderConfigWindow.CreateWindow();
|
||||||
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +531,9 @@ internal class RenderManager : MonoBehaviour
|
|||||||
yield return new WaitForSecondsRealtime(1);
|
yield return new WaitForSecondsRealtime(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancelled or failed during the countdown: do not start the encoder
|
||||||
|
if (stage != RenderStage.OnScene) yield break;
|
||||||
|
|
||||||
frameCount = 0; framesSent = 0;
|
frameCount = 0; framesSent = 0;
|
||||||
_renderStopwatch.Restart();
|
_renderStopwatch.Restart();
|
||||||
|
|
||||||
@@ -462,13 +556,20 @@ internal class RenderManager : MonoBehaviour
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
||||||
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(
|
CommonWarningDialogUI.ShowDialog(
|
||||||
"Encoder Error",
|
"Encoder Error",
|
||||||
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
||||||
new DialogButton("OK", () =>
|
new DialogButton("OK", () =>
|
||||||
{
|
{
|
||||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||||
|
Cursor.visible = true;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
yield break;
|
yield break;
|
||||||
@@ -511,10 +612,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
ImageQueue.Clear();
|
ImageQueue.Clear();
|
||||||
}
|
}
|
||||||
passCount = 0;
|
passCount = 0;
|
||||||
IsRenderCancelled = false;
|
ClearAbortState();
|
||||||
frameCountForVideo = 0;
|
frameCountForVideo = 0;
|
||||||
lastVideoFrameIndex = -1;
|
lastVideoFrameIndex = -1;
|
||||||
lastRequestedVideoFrameIndex = -1;
|
Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
|
||||||
endRequested = false;
|
endRequested = false;
|
||||||
endSuccessful = false;
|
endSuccessful = false;
|
||||||
FrameAvailableEvent.Reset();
|
FrameAvailableEvent.Reset();
|
||||||
@@ -564,6 +665,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
_allVideoFFmpegInstances.Add(videoFFmpeg);
|
_allVideoFFmpegInstances.Add(videoFFmpeg);
|
||||||
}
|
}
|
||||||
|
Volatile.Write(ref _incompleteOutputPath, CurrentOutPath);
|
||||||
videoFFmpeg.Start(CurrentOutPath);
|
videoFFmpeg.Start(CurrentOutPath);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
@@ -632,14 +734,14 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Configuration.AudioConfig.RenderAudio)
|
if (Configuration.AudioConfig.RenderAudio && SyncCalculator is { } syncCalculator)
|
||||||
{
|
{
|
||||||
var frameIdx = (int)(passCount - 1);
|
var frameIdx = (int)(passCount - 1);
|
||||||
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
||||||
var frameTime = frameIdx / effectiveVideoFps;
|
var frameTime = frameIdx / effectiveVideoFps;
|
||||||
|
|
||||||
var channels = Configuration.AudioConfig.AudioChannels;
|
var channels = Configuration.AudioConfig.AudioChannels;
|
||||||
var samplesNeeded = SyncCalculator.GetNextFrameSamples();
|
var samplesNeeded = syncCalculator.GetNextFrameSamples();
|
||||||
|
|
||||||
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
||||||
try
|
try
|
||||||
@@ -655,17 +757,23 @@ internal class RenderManager : MonoBehaviour
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsRenderCancelled) break;
|
if (IsRenderAborted) break;
|
||||||
|
|
||||||
if (endRequested)
|
if (endRequested)
|
||||||
{
|
{
|
||||||
if (passCount > lastRequestedVideoFrameIndex) break;
|
if (passCount > Volatile.Read(ref lastRequestedVideoFrameIndex)) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
FrameAvailableEvent.WaitOne(50);
|
FrameAvailableEvent.WaitOne(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsRenderCancelled) videoFFmpeg.End();
|
if (!IsRenderAborted)
|
||||||
|
{
|
||||||
|
// End throws when the file cannot be finished (e.g. the trailer cannot be written),
|
||||||
|
// which is handled as a render failure by the catch block below.
|
||||||
|
videoFFmpeg.End();
|
||||||
|
Volatile.Write(ref _incompleteOutputPath, null);
|
||||||
|
}
|
||||||
videoFFmpeg.Dispose();
|
videoFFmpeg.Dispose();
|
||||||
|
|
||||||
lock (_allVideoFFmpegInstances)
|
lock (_allVideoFFmpegInstances)
|
||||||
@@ -674,12 +782,22 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
_activeVideoFFmpeg = null;
|
_activeVideoFFmpeg = null;
|
||||||
endSuccessful = !IsRenderCancelled;
|
endSuccessful = !IsRenderAborted;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
||||||
|
|
||||||
|
MarkFailed("encoder thread error. See the log for details.");
|
||||||
|
try
|
||||||
|
{
|
||||||
videoFFmpeg.Dispose();
|
videoFFmpeg.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception disposeEx)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
lock (_allVideoFFmpegInstances)
|
lock (_allVideoFFmpegInstances)
|
||||||
{
|
{
|
||||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||||
@@ -708,7 +826,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
endRequested = true;
|
endRequested = true;
|
||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
IsRenderCancelled = true;
|
MarkCancelled();
|
||||||
}
|
}
|
||||||
FrameAvailableEvent.Set();
|
FrameAvailableEvent.Set();
|
||||||
skipPressToStart = false;
|
skipPressToStart = false;
|
||||||
@@ -722,7 +840,8 @@ internal class RenderManager : MonoBehaviour
|
|||||||
if (success) StartCoroutine(StopRenderCoroutine());
|
if (success) StartCoroutine(StopRenderCoroutine());
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
|
var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED";
|
||||||
|
Melon<RenderMod>.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources.");
|
||||||
|
|
||||||
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
|
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
|
||||||
if (RenderThread is { IsAlive: true })
|
if (RenderThread is { IsAlive: true })
|
||||||
@@ -781,30 +900,26 @@ internal class RenderManager : MonoBehaviour
|
|||||||
scrController.instance.noFailInfiniteMargin = false;
|
scrController.instance.noFailInfiniteMargin = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Immediately delete cancelled incomplete temporary video file
|
// 6. Immediately delete the incomplete video file of this render
|
||||||
if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath))
|
if (DeleteIncompleteOutputFile())
|
||||||
{
|
{
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Delete(CurrentOutPath);
|
|
||||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {CurrentOutPath}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete temporary video: {ex.Message}");
|
|
||||||
}
|
|
||||||
CurrentOutPath = null;
|
CurrentOutPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = BuildResult(false,
|
var result = BuildResult(false, BuildAbortMessage());
|
||||||
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
|
|
||||||
TriggerOnComplete(result);
|
TriggerOnComplete(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerator StopRenderCoroutine()
|
private IEnumerator StopRenderCoroutine()
|
||||||
{
|
{
|
||||||
yield return new WaitUntil(() => endSuccessful);
|
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
|
||||||
|
if (!endSuccessful)
|
||||||
|
{
|
||||||
|
MarkFailed("the render thread terminated unexpectedly.");
|
||||||
|
CancelRender();
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
var result = BuildResult(success: true);
|
var result = BuildResult(success: true);
|
||||||
TriggerOnComplete(result);
|
TriggerOnComplete(result);
|
||||||
@@ -813,6 +928,15 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void EndRender()
|
private void EndRender()
|
||||||
|
{
|
||||||
|
RestoreRenderEnvironment();
|
||||||
|
|
||||||
|
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||||
|
Cursor.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
|
||||||
|
private void RestoreRenderEnvironment()
|
||||||
{
|
{
|
||||||
Time.timeScale = 1f;
|
Time.timeScale = 1f;
|
||||||
Time.captureDeltaTime = 0f;
|
Time.captureDeltaTime = 0f;
|
||||||
@@ -847,12 +971,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
_frameReader?.Dispose();
|
_frameReader?.Dispose();
|
||||||
ResetFrameHookState();
|
ResetFrameHookState();
|
||||||
AudioMixer?.Reset(); AudioMixer = null;
|
AudioMixer?.Reset(); AudioMixer = null;
|
||||||
SyncCalculator.Reset();
|
SyncCalculator = null;
|
||||||
|
|
||||||
|
AudioSpectrumGenerator.Clear();
|
||||||
RenderContext.Reset();
|
RenderContext.Reset();
|
||||||
|
|
||||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
|
||||||
Cursor.visible = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"ui.render_config.preset_name_placeholder": "New preset name...",
|
"ui.render_config.preset_name_placeholder": "New preset name...",
|
||||||
"ui.render_config.preset_save": "Save",
|
"ui.render_config.preset_save": "Save",
|
||||||
"ui.render_config.preset_delete": "Delete",
|
"ui.render_config.preset_delete": "Delete",
|
||||||
|
"ui.render_config.preset_delete_confirm": "Confirm?",
|
||||||
"ui.render_config.preset_overwrite_title": "Overwrite Preset",
|
"ui.render_config.preset_overwrite_title": "Overwrite Preset",
|
||||||
"ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?",
|
"ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?",
|
||||||
"ui.render_config.preset_delete_title": "Delete Preset",
|
"ui.render_config.preset_delete_title": "Delete Preset",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"ui.render_config.preset_name_placeholder": "새 프리셋 이름...",
|
"ui.render_config.preset_name_placeholder": "새 프리셋 이름...",
|
||||||
"ui.render_config.preset_save": "저장",
|
"ui.render_config.preset_save": "저장",
|
||||||
"ui.render_config.preset_delete": "삭제",
|
"ui.render_config.preset_delete": "삭제",
|
||||||
|
"ui.render_config.preset_delete_confirm": "삭제 확인",
|
||||||
"ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기",
|
"ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기",
|
||||||
"ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?",
|
"ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?",
|
||||||
"ui.render_config.preset_delete_title": "프리셋 삭제",
|
"ui.render_config.preset_delete_title": "프리셋 삭제",
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
"ui.render_config.audio_encoding": "오디오 인코딩 설정",
|
"ui.render_config.audio_encoding": "오디오 인코딩 설정",
|
||||||
"ui.render_config.global_settings": "전역 설정",
|
"ui.render_config.global_settings": "전역 설정",
|
||||||
"ui.render_config.advanced_settings": "고급 설정",
|
"ui.render_config.advanced_settings": "고급 설정",
|
||||||
"ui.render_config.go_render": "렌더 시작",
|
"ui.render_config.go_render": "렌더링하러 가다",
|
||||||
"ui.render_config.codec": "코덱",
|
"ui.render_config.codec": "코덱",
|
||||||
"ui.render_config.rate_control": "비트레이트 제어 모드",
|
"ui.render_config.rate_control": "비트레이트 제어 모드",
|
||||||
"ui.render_config.bitrate": "비트레이트 (kbps)",
|
"ui.render_config.bitrate": "비트레이트 (kbps)",
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Object = UnityEngine.Object;
|
using Object = UnityEngine.Object;
|
||||||
|
|
||||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.4-INDEV", "ADOFAI Editor Forum")]
|
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.8-INDEV", "ADOFAI Editor Forum")]
|
||||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||||
|
|
||||||
namespace ADOFAIRenderer;
|
namespace ADOFAIRenderer;
|
||||||
|
|||||||
+75
-149
@@ -1,10 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using MelonLoader;
|
||||||
|
using O5Kit.Core;
|
||||||
|
using O5Kit.Factory;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using UnityEngine.UIElements;
|
using UnityEngine.UIElements;
|
||||||
using Button = UnityEngine.UI.Button;
|
|
||||||
using Image = UnityEngine.UI.Image;
|
using Image = UnityEngine.UI.Image;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.UI;
|
namespace ADOFAIRenderer.UI;
|
||||||
@@ -29,6 +31,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
{
|
{
|
||||||
public static CommonWarningDialogUI? Instance { get; private set; }
|
public static CommonWarningDialogUI? Instance { get; private set; }
|
||||||
private readonly List<VisualElement> _disabledElements = new();
|
private readonly List<VisualElement> _disabledElements = new();
|
||||||
|
private O5Window? _window;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
@@ -37,6 +40,9 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
|
_window?.Dispose();
|
||||||
|
_window = null;
|
||||||
|
|
||||||
foreach (var elem in _disabledElements)
|
foreach (var elem in _disabledElements)
|
||||||
{
|
{
|
||||||
if (elem != null)
|
if (elem != null)
|
||||||
@@ -87,9 +93,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
// Canvas setup
|
// Canvas setup
|
||||||
var canvas = gameObject.AddComponent<Canvas>();
|
var canvas = gameObject.AddComponent<Canvas>();
|
||||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
|
canvas.sortingOrder = 32766;
|
||||||
|
|
||||||
// Responsive Canvas Scaler setup
|
|
||||||
var scaler = gameObject.AddComponent<CanvasScaler>();
|
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||||
@@ -98,13 +103,15 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
|
|
||||||
gameObject.AddComponent<GraphicRaycaster>();
|
gameObject.AddComponent<GraphicRaycaster>();
|
||||||
|
|
||||||
// 1. Create full screen overlay background
|
var ctx = RendererUIManager.Context;
|
||||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
|
||||||
|
// 1. Create full screen backdrop (scrim) to block background input
|
||||||
|
var bgPanelObj = new GameObject("Backdrop");
|
||||||
bgPanelObj.transform.SetParent(transform, false);
|
bgPanelObj.transform.SetParent(transform, false);
|
||||||
bgPanelObj.layer = 5;
|
bgPanelObj.layer = 5;
|
||||||
|
|
||||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
||||||
bgImage.raycastTarget = true;
|
bgImage.raycastTarget = true;
|
||||||
|
|
||||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||||
@@ -113,112 +120,66 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
bgRect.offsetMin = Vector2.zero;
|
bgRect.offsetMin = Vector2.zero;
|
||||||
bgRect.offsetMax = Vector2.zero;
|
bgRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
var font = ModAssets.NanumSquareNeoNormal;
|
// 2. Create actual native O5Window
|
||||||
|
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||||
|
{
|
||||||
|
Id = "RendererMod_WarningDialog",
|
||||||
|
Title = title,
|
||||||
|
Size = new Vector2(540, 240),
|
||||||
|
Draggable = true,
|
||||||
|
Resizable = false,
|
||||||
|
ShowCloseButton = false,
|
||||||
|
ShowOutline = true,
|
||||||
|
Padding = new RectOffset(16, 16, 16, 16)
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Create modal container
|
// 3. Build content inside window.Content
|
||||||
var modalContainerObj = new GameObject("ModalContainer");
|
var content = _window.Content;
|
||||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
|
||||||
modalContainerObj.layer = 5;
|
|
||||||
|
|
||||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
|
vLayout.spacing = 16f;
|
||||||
|
vLayout.padding = new RectOffset(16, 16, 12, 12);
|
||||||
|
vLayout.childControlWidth = true;
|
||||||
|
vLayout.childControlHeight = true;
|
||||||
|
vLayout.childForceExpandWidth = true;
|
||||||
|
vLayout.childForceExpandHeight = false;
|
||||||
|
|
||||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
// Description text
|
||||||
modalOutline.effectColor = Color.white;
|
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
|
|
||||||
|
|
||||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
|
||||||
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.sizeDelta = new Vector2(650, 360);
|
|
||||||
|
|
||||||
// 3. Create title text
|
|
||||||
var titleObj = new GameObject("TitleText");
|
|
||||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
|
||||||
titleObj.layer = 5;
|
|
||||||
|
|
||||||
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
titleText.text = title;
|
|
||||||
titleText.fontSize = 24;
|
|
||||||
titleText.font = font;
|
|
||||||
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
|
|
||||||
titleText.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
|
||||||
titleRect.anchorMin = new Vector2(0, 1);
|
|
||||||
titleRect.anchorMax = new Vector2(1, 1);
|
|
||||||
titleRect.pivot = new Vector2(0.5f, 1);
|
|
||||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
|
||||||
titleRect.sizeDelta = new Vector2(0, 40);
|
|
||||||
|
|
||||||
// 4. Create description text
|
|
||||||
var descObj = new GameObject("DescriptionText");
|
|
||||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
|
||||||
descObj.layer = 5;
|
|
||||||
|
|
||||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
descText.text = description;
|
descText.text = description;
|
||||||
descText.fontSize = 20;
|
|
||||||
descText.font = font;
|
|
||||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
|
||||||
descText.alignment = TextAlignmentOptions.Center;
|
descText.alignment = TextAlignmentOptions.Center;
|
||||||
descText.lineSpacing = 8;
|
descText.textWrappingMode = TextWrappingModes.Normal;
|
||||||
|
descText.lineSpacing = 6;
|
||||||
|
descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
|
||||||
|
|
||||||
var descRect = descObj.GetComponent<RectTransform>();
|
// ReSharper disable once InconsistentNaming
|
||||||
descRect.anchorMin = new Vector2(0, 0.5f);
|
var descLE = descText.gameObject.AddComponent<LayoutElement>();
|
||||||
descRect.anchorMax = new Vector2(1, 1);
|
descLE.flexibleWidth = 1f;
|
||||||
descRect.pivot = new Vector2(0.5f, 0.5f);
|
descLE.flexibleHeight = 1f;
|
||||||
descRect.anchoredPosition = new Vector2(0, -65);
|
|
||||||
descRect.sizeDelta = new Vector2(-40, 120);
|
|
||||||
|
|
||||||
// 5. Create button container
|
// Button action row
|
||||||
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
var btnRowObj = new GameObject("ButtonRow");
|
||||||
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
btnRowObj.transform.SetParent(content, false);
|
||||||
buttonContainerObj.layer = 5;
|
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
|
||||||
|
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
|
||||||
|
btnRowLE.preferredHeight = 40f;
|
||||||
|
btnRowLE.flexibleWidth = 1f;
|
||||||
|
|
||||||
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
|
var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
|
||||||
btnContainerRect.anchorMin = new Vector2(0, 0);
|
hLayout.spacing = 10f;
|
||||||
btnContainerRect.anchorMax = new Vector2(1, 0);
|
hLayout.childAlignment = TextAnchor.MiddleCenter;
|
||||||
btnContainerRect.pivot = new Vector2(0.5f, 0);
|
hLayout.childControlWidth = true;
|
||||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
hLayout.childControlHeight = true;
|
||||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
hLayout.childForceExpandWidth = true;
|
||||||
|
hLayout.childForceExpandHeight = true;
|
||||||
|
|
||||||
// 6. Dynamic button layout and styling
|
for (var i = 0; i < buttons.Length; i++)
|
||||||
float totalWidth = 560f;
|
|
||||||
float spacing = 15f;
|
|
||||||
float buttonHeight = 45f;
|
|
||||||
int numButtons = buttons.Length;
|
|
||||||
|
|
||||||
float buttonWidth = numButtons == 1
|
|
||||||
? 200f
|
|
||||||
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
|
|
||||||
|
|
||||||
float startX = numButtons == 1
|
|
||||||
? 0
|
|
||||||
: (-totalWidth / 2f) + (buttonWidth / 2f);
|
|
||||||
|
|
||||||
for (int i = 0; i < numButtons; i++)
|
|
||||||
{
|
{
|
||||||
var btnData = buttons[i];
|
var btnData = buttons[i];
|
||||||
var btnObj = new GameObject($"Button_{i}");
|
var btn = O5Factory.Button(
|
||||||
btnObj.transform.SetParent(buttonContainerObj.transform, false);
|
ctx: ctx,
|
||||||
btnObj.layer = 5;
|
parent: btnRowRect,
|
||||||
|
onClick: () =>
|
||||||
// Define default colors
|
|
||||||
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
|
|
||||||
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
|
|
||||||
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
|
|
||||||
|
|
||||||
var btnImage = btnObj.AddComponent<Image>();
|
|
||||||
btnImage.color = baseColor;
|
|
||||||
btnImage.raycastTarget = true;
|
|
||||||
|
|
||||||
var btn = btnObj.AddComponent<Button>();
|
|
||||||
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
|
|
||||||
|
|
||||||
// Register click callback
|
|
||||||
btn.onClick.AddListener(() =>
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -226,59 +187,24 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
||||||
}
|
}
|
||||||
Destroy(gameObject);
|
Destroy(gameObject);
|
||||||
});
|
},
|
||||||
|
text: btnData.Text,
|
||||||
|
id: $"btn_dialog_{i}",
|
||||||
|
height: 40f
|
||||||
|
);
|
||||||
|
|
||||||
var btnRect = btnObj.GetComponent<RectTransform>();
|
if (btnData.BackgroundColor.HasValue)
|
||||||
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
btnRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
|
|
||||||
float posX = numButtons == 1
|
|
||||||
? 0
|
|
||||||
: startX + i * (buttonWidth + spacing);
|
|
||||||
|
|
||||||
btnRect.anchoredPosition = new Vector2(posX, 0);
|
|
||||||
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
|
|
||||||
|
|
||||||
// Add text label
|
|
||||||
var txtObj = new GameObject("Text");
|
|
||||||
txtObj.transform.SetParent(btnObj.transform, false);
|
|
||||||
txtObj.layer = 5;
|
|
||||||
|
|
||||||
var text = txtObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
text.text = btnData.Text;
|
|
||||||
text.fontSize = 20;
|
|
||||||
text.font = font;
|
|
||||||
text.color = Color.white;
|
|
||||||
text.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var txtRect = txtObj.GetComponent<RectTransform>();
|
|
||||||
txtRect.anchorMin = Vector2.zero;
|
|
||||||
txtRect.anchorMax = Vector2.one;
|
|
||||||
txtRect.offsetMin = Vector2.zero;
|
|
||||||
txtRect.offsetMax = Vector2.zero;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
|
|
||||||
{
|
{
|
||||||
button.transition = Selectable.Transition.ColorTint;
|
btn.NormalColor = btnData.BackgroundColor.Value;
|
||||||
var colors = button.colors;
|
btn.UpdateVisual();
|
||||||
colors.normalColor = normalColor;
|
|
||||||
colors.highlightedColor = hoverColor;
|
|
||||||
colors.pressedColor = pressedColor;
|
|
||||||
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
|
||||||
button.colors = colors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Color ApplyColorBrightness(Color color, float factor)
|
var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
{
|
le.flexibleWidth = 1f;
|
||||||
float r = Mathf.Clamp01(color.r * factor);
|
le.preferredHeight = 40f;
|
||||||
float g = Mathf.Clamp01(color.g * factor);
|
}
|
||||||
float b = Mathf.Clamp01(color.b * factor);
|
|
||||||
return new Color(r, g, b, color.a);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+124
-182
@@ -4,11 +4,13 @@ using System.IO.Compression;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using MelonLoader;
|
||||||
|
using MelonLoader.Utils;
|
||||||
|
using O5Kit.Core;
|
||||||
|
using O5Kit.Factory;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using MelonLoader;
|
|
||||||
using MelonLoader.Utils;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.UI;
|
namespace ADOFAIRenderer.UI;
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
private GameObject _statusTextObj = null!;
|
private GameObject _statusTextObj = null!;
|
||||||
private TextMeshProUGUI _statusText = null!;
|
private TextMeshProUGUI _statusText = null!;
|
||||||
|
|
||||||
|
private O5Window? _window;
|
||||||
private bool _isDownloading;
|
private bool _isDownloading;
|
||||||
private string DownloadUrl => Application.platform switch
|
private string DownloadUrl => Application.platform switch
|
||||||
{
|
{
|
||||||
@@ -104,13 +107,15 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
|
|
||||||
gameObject.AddComponent<GraphicRaycaster>();
|
gameObject.AddComponent<GraphicRaycaster>();
|
||||||
|
|
||||||
// 1. Create full screen overlay background
|
var ctx = RendererUIManager.Context;
|
||||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
|
||||||
|
// 1. Create full screen backdrop
|
||||||
|
var bgPanelObj = new GameObject("Backdrop");
|
||||||
bgPanelObj.transform.SetParent(transform, false);
|
bgPanelObj.transform.SetParent(transform, false);
|
||||||
bgPanelObj.layer = 5;
|
bgPanelObj.layer = 5;
|
||||||
|
|
||||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
||||||
bgImage.raycastTarget = true;
|
bgImage.raycastTarget = true;
|
||||||
|
|
||||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||||
@@ -119,51 +124,31 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
bgRect.offsetMin = Vector2.zero;
|
bgRect.offsetMin = Vector2.zero;
|
||||||
bgRect.offsetMax = Vector2.zero;
|
bgRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
var font = ModAssets.NanumSquareNeoNormal;
|
// 2. Create actual native O5Window
|
||||||
|
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||||
|
{
|
||||||
|
Id = "RendererMod_FFmpegDownloadDialog",
|
||||||
|
Title = "FFmpeg Library Installation Required",
|
||||||
|
Size = new Vector2(580, 270),
|
||||||
|
Draggable = true,
|
||||||
|
Resizable = false,
|
||||||
|
ShowCloseButton = false,
|
||||||
|
ShowOutline = true,
|
||||||
|
Padding = new RectOffset(16, 16, 16, 16)
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Create modal container
|
var content = _window.Content;
|
||||||
var modalContainerObj = new GameObject("ModalContainer");
|
|
||||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
|
||||||
modalContainerObj.layer = 5;
|
|
||||||
|
|
||||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
|
vLayout.spacing = 14f;
|
||||||
|
vLayout.padding = new RectOffset(16, 16, 10, 10);
|
||||||
|
vLayout.childControlWidth = true;
|
||||||
|
vLayout.childControlHeight = true;
|
||||||
|
vLayout.childForceExpandWidth = true;
|
||||||
|
vLayout.childForceExpandHeight = false;
|
||||||
|
|
||||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
// 3. Description text
|
||||||
modalOutline.effectColor = Color.white;
|
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
|
||||||
|
|
||||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
|
||||||
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.sizeDelta = new Vector2(600, 320);
|
|
||||||
|
|
||||||
// 4. Create title text
|
|
||||||
var titleObj = new GameObject("TitleText");
|
|
||||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
|
||||||
titleObj.layer = 5;
|
|
||||||
|
|
||||||
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
titleText.text = "FFmpeg Library Installation Required";
|
|
||||||
titleText.fontSize = 26;
|
|
||||||
titleText.font = font;
|
|
||||||
titleText.color = Color.white;
|
|
||||||
titleText.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
|
||||||
titleRect.anchorMin = new Vector2(0, 1);
|
|
||||||
titleRect.anchorMax = new Vector2(1, 1);
|
|
||||||
titleRect.pivot = new Vector2(0.5f, 1);
|
|
||||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
|
||||||
titleRect.sizeDelta = new Vector2(0, 40);
|
|
||||||
|
|
||||||
// 5. Create description text
|
|
||||||
var descObj = new GameObject("DescriptionText");
|
|
||||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
|
||||||
descObj.layer = 5;
|
|
||||||
|
|
||||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
var descStrBuilder = new StringBuilder();
|
var descStrBuilder = new StringBuilder();
|
||||||
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
|
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
|
||||||
if (Application.platform == RuntimePlatform.OSXPlayer)
|
if (Application.platform == RuntimePlatform.OSXPlayer)
|
||||||
@@ -176,139 +161,104 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
|
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
|
||||||
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
|
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
|
||||||
}
|
}
|
||||||
|
|
||||||
descText.text = descStrBuilder.ToString();
|
descText.text = descStrBuilder.ToString();
|
||||||
descText.fontSize = 16;
|
|
||||||
descText.font = font;
|
|
||||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
|
||||||
descText.alignment = TextAlignmentOptions.Center;
|
descText.alignment = TextAlignmentOptions.Center;
|
||||||
descText.lineSpacing = 10;
|
descText.textWrappingMode = TextWrappingModes.Normal;
|
||||||
|
descText.lineSpacing = 5;
|
||||||
|
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
||||||
|
|
||||||
var descRect = descObj.GetComponent<RectTransform>();
|
var descLE = descText.gameObject.AddComponent<LayoutElement>();
|
||||||
descRect.anchorMin = new Vector2(0, 0.5f);
|
descLE.flexibleWidth = 1f;
|
||||||
descRect.anchorMax = new Vector2(1, 1);
|
descLE.flexibleHeight = 1f;
|
||||||
descRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
descRect.anchoredPosition = new Vector2(0, -60);
|
|
||||||
descRect.sizeDelta = new Vector2(-40, 100);
|
|
||||||
|
|
||||||
// 6. Create button container
|
// 4. Button container
|
||||||
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
_buttonContainerObj = new GameObject("ButtonContainer");
|
||||||
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
_buttonContainerObj.transform.SetParent(content, false);
|
||||||
_buttonContainerObj.layer = 5;
|
_buttonContainerObj.layer = 5;
|
||||||
|
|
||||||
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>();
|
var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
|
||||||
btnContainerRect.anchorMin = new Vector2(0, 0);
|
btnContainerLE.preferredHeight = 40f;
|
||||||
btnContainerRect.anchorMax = new Vector2(1, 0);
|
btnContainerLE.flexibleWidth = 1f;
|
||||||
btnContainerRect.pivot = new Vector2(0.5f, 0);
|
|
||||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
|
||||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
|
||||||
|
|
||||||
// 7. Create download button
|
var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
|
||||||
var downloadBtnObj = new GameObject("DownloadButton");
|
btnLayout.spacing = 10f;
|
||||||
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
btnLayout.childAlignment = TextAnchor.MiddleCenter;
|
||||||
downloadBtnObj.layer = 5;
|
btnLayout.childControlWidth = true;
|
||||||
|
btnLayout.childControlHeight = true;
|
||||||
|
btnLayout.childForceExpandWidth = true;
|
||||||
|
btnLayout.childForceExpandHeight = true;
|
||||||
|
|
||||||
var downloadImg = downloadBtnObj.AddComponent<Image>();
|
Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
|
||||||
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
|
? StartDownloadProcess
|
||||||
downloadImg.raycastTarget = true;
|
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
|
||||||
|
|
||||||
var downloadBtn = downloadBtnObj.AddComponent<Button>();
|
var downloadBtn = O5Factory.Button(
|
||||||
SetupButtonStyle(downloadBtn, new Color(0.0f, 0.8f, 1.0f, 1.0f), new Color(0.2f, 0.9f, 1.0f, 1.0f), new Color(0.0f, 0.6f, 0.8f, 1.0f));
|
ctx: ctx,
|
||||||
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess);
|
parent: _buttonContainerObj.transform,
|
||||||
else
|
onClick: onDownloadClick,
|
||||||
{
|
text: Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website",
|
||||||
downloadBtn.onClick.AddListener(OpenFFmpegWebsite);
|
id: "btn_ffmpeg_download",
|
||||||
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }
|
height: 40f
|
||||||
}
|
);
|
||||||
|
downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
|
||||||
|
downloadBtn.UpdateVisual();
|
||||||
|
|
||||||
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>();
|
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
downloadBtnLE.flexibleWidth = 1f;
|
||||||
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
downloadBtnLE.preferredHeight = 40f;
|
||||||
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
|
|
||||||
downloadBtnRect.anchoredPosition = new Vector2(-15, 0);
|
|
||||||
downloadBtnRect.sizeDelta = new Vector2(180, 45);
|
|
||||||
|
|
||||||
var downloadTextObj = new GameObject("DownloadText");
|
var cancelBtn = O5Factory.Button(
|
||||||
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
|
ctx: ctx,
|
||||||
downloadTextObj.layer = 5;
|
parent: _buttonContainerObj.transform,
|
||||||
|
onClick: CancelProcess,
|
||||||
|
text: "Cancel",
|
||||||
|
id: "btn_ffmpeg_cancel",
|
||||||
|
height: 40f
|
||||||
|
);
|
||||||
|
var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
|
cancelBtnLE.flexibleWidth = 1f;
|
||||||
|
cancelBtnLE.preferredHeight = 40f;
|
||||||
|
|
||||||
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
|
// 5. Progress bar container
|
||||||
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
|
|
||||||
downloadText.fontSize = 20;
|
|
||||||
downloadText.font = font;
|
|
||||||
downloadText.color = Color.white;
|
|
||||||
downloadText.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>();
|
|
||||||
downloadTextRect.anchorMin = Vector2.zero;
|
|
||||||
downloadTextRect.anchorMax = Vector2.one;
|
|
||||||
downloadTextRect.offsetMin = Vector2.zero;
|
|
||||||
downloadTextRect.offsetMax = Vector2.zero;
|
|
||||||
|
|
||||||
// 8. Create cancel button
|
|
||||||
var cancelBtnObj = new GameObject("CancelButton");
|
|
||||||
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
|
||||||
cancelBtnObj.layer = 5;
|
|
||||||
|
|
||||||
var cancelImg = cancelBtnObj.AddComponent<Image>();
|
|
||||||
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f);
|
|
||||||
cancelImg.raycastTarget = true;
|
|
||||||
|
|
||||||
var cancelBtn = cancelBtnObj.AddComponent<Button>();
|
|
||||||
SetupButtonStyle(cancelBtn, new Color(0.2f, 0.2f, 0.23f, 1f), new Color(0.25f, 0.25f, 0.28f, 1f), new Color(0.15f, 0.15f, 0.18f, 1f));
|
|
||||||
cancelBtn.onClick.AddListener(CancelProcess);
|
|
||||||
|
|
||||||
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>();
|
|
||||||
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
cancelBtnRect.pivot = new Vector2(0f, 0.5f);
|
|
||||||
cancelBtnRect.anchoredPosition = new Vector2(15, 0);
|
|
||||||
cancelBtnRect.sizeDelta = new Vector2(180, 45);
|
|
||||||
|
|
||||||
var cancelTextObj = new GameObject("CancelText");
|
|
||||||
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false);
|
|
||||||
cancelTextObj.layer = 5;
|
|
||||||
|
|
||||||
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
cancelText.text = "Cancel";
|
|
||||||
cancelText.fontSize = 20;
|
|
||||||
cancelText.font = font;
|
|
||||||
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
|
|
||||||
cancelText.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var cancelTextRect = cancelTextObj.GetComponent<RectTransform>();
|
|
||||||
cancelTextRect.anchorMin = Vector2.zero;
|
|
||||||
cancelTextRect.anchorMax = Vector2.one;
|
|
||||||
cancelTextRect.offsetMin = Vector2.zero;
|
|
||||||
cancelTextRect.offsetMax = Vector2.zero;
|
|
||||||
|
|
||||||
// 9. Create progress bar container
|
|
||||||
_progressBarObj = new GameObject("ProgressBarContainer");
|
_progressBarObj = new GameObject("ProgressBarContainer");
|
||||||
_progressBarObj.transform.SetParent(modalContainerObj.transform, false);
|
_progressBarObj.transform.SetParent(content, false);
|
||||||
_progressBarObj.layer = 5;
|
_progressBarObj.layer = 5;
|
||||||
_progressBarObj.SetActive(false);
|
_progressBarObj.SetActive(false);
|
||||||
|
|
||||||
|
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
|
||||||
|
pbLE.preferredHeight = 24f;
|
||||||
|
pbLE.flexibleWidth = 1f;
|
||||||
|
|
||||||
var barBgImg = _progressBarObj.AddComponent<Image>();
|
var barBgImg = _progressBarObj.AddComponent<Image>();
|
||||||
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
|
barBgImg.sprite = ctx.Sprites.RoundedControl;
|
||||||
|
barBgImg.type = Image.Type.Sliced;
|
||||||
|
barBgImg.color = ctx.Theme.ObjectBG;
|
||||||
|
|
||||||
var barBgOutline = _progressBarObj.AddComponent<Outline>();
|
var barOutlineObj = new GameObject("BarOutline");
|
||||||
barBgOutline.effectColor = Color.white;
|
barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
barOutlineObj.layer = 5;
|
||||||
|
|
||||||
var barRect = _progressBarObj.GetComponent<RectTransform>();
|
var barOutlineImg = barOutlineObj.AddComponent<Image>();
|
||||||
barRect.anchorMin = new Vector2(0.5f, 0);
|
barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
|
||||||
barRect.anchorMax = new Vector2(0.5f, 0);
|
barOutlineImg.type = Image.Type.Sliced;
|
||||||
barRect.pivot = new Vector2(0.5f, 0);
|
barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
|
||||||
barRect.anchoredPosition = new Vector2(0, 75);
|
barOutlineImg.raycastTarget = false;
|
||||||
barRect.sizeDelta = new Vector2(480, 24);
|
|
||||||
|
|
||||||
// 10. Create progress bar fill image
|
var barOutlineRect = barOutlineObj.GetComponent<RectTransform>();
|
||||||
|
barOutlineRect.anchorMin = Vector2.zero;
|
||||||
|
barOutlineRect.anchorMax = Vector2.one;
|
||||||
|
barOutlineRect.offsetMin = Vector2.zero;
|
||||||
|
barOutlineRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
|
// Progress bar fill image
|
||||||
_progressBarFillObj = new GameObject("ProgressBarFill");
|
_progressBarFillObj = new GameObject("ProgressBarFill");
|
||||||
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
_progressBarFillObj.layer = 5;
|
_progressBarFillObj.layer = 5;
|
||||||
|
|
||||||
var fillImg = _progressBarFillObj.AddComponent<Image>();
|
var fillImg = _progressBarFillObj.AddComponent<Image>();
|
||||||
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
|
fillImg.sprite = ctx.Sprites.RoundedControl;
|
||||||
|
fillImg.type = Image.Type.Sliced;
|
||||||
|
fillImg.color = ctx.Theme.ObjectActiveBright;
|
||||||
|
|
||||||
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
|
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
|
||||||
_fillRect.anchorMin = new Vector2(0, 0);
|
_fillRect.anchorMin = new Vector2(0, 0);
|
||||||
@@ -317,20 +267,17 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
_fillRect.anchoredPosition = Vector2.zero;
|
_fillRect.anchoredPosition = Vector2.zero;
|
||||||
_fillRect.sizeDelta = Vector2.zero;
|
_fillRect.sizeDelta = Vector2.zero;
|
||||||
|
|
||||||
// 11. Create progress text
|
// Progress text
|
||||||
var progressTextObj = new GameObject("ProgressBarText");
|
var progressTextObj = new GameObject("ProgressBarText");
|
||||||
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
progressTextObj.layer = 5;
|
progressTextObj.layer = 5;
|
||||||
|
|
||||||
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
||||||
_progressText.fontSize = 16;
|
_progressText.fontSize = 13;
|
||||||
_progressText.font = font;
|
_progressText.font = ctx.Fonts.Regular;
|
||||||
_progressText.color = Color.white;
|
_progressText.color = Color.white;
|
||||||
_progressText.alignment = TextAlignmentOptions.Center;
|
_progressText.alignment = TextAlignmentOptions.Center;
|
||||||
|
_progressText.raycastTarget = false;
|
||||||
var progressTextOutline = progressTextObj.AddComponent<Outline>();
|
|
||||||
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
|
|
||||||
progressTextOutline.effectDistance = new Vector2(1, -1);
|
|
||||||
|
|
||||||
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
|
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
|
||||||
progressTextRect.anchorMin = Vector2.zero;
|
progressTextRect.anchorMin = Vector2.zero;
|
||||||
@@ -338,35 +285,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
progressTextRect.offsetMin = Vector2.zero;
|
progressTextRect.offsetMin = Vector2.zero;
|
||||||
progressTextRect.offsetMax = Vector2.zero;
|
progressTextRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
// 12. Create status text
|
// 6. Status text
|
||||||
_statusTextObj = new GameObject("StatusText");
|
_statusTextObj = new GameObject("StatusText");
|
||||||
_statusTextObj.transform.SetParent(modalContainerObj.transform, false);
|
_statusTextObj.transform.SetParent(content, false);
|
||||||
_statusTextObj.layer = 5;
|
_statusTextObj.layer = 5;
|
||||||
_statusTextObj.SetActive(false);
|
_statusTextObj.SetActive(false);
|
||||||
|
|
||||||
|
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
|
||||||
|
statusLE.preferredHeight = 22f;
|
||||||
|
statusLE.flexibleWidth = 1f;
|
||||||
|
|
||||||
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
|
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
|
||||||
_statusText.fontSize = 16;
|
_statusText.fontSize = 14;
|
||||||
_statusText.font = font;
|
_statusText.font = ctx.Fonts.Regular;
|
||||||
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
|
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
|
||||||
_statusText.alignment = TextAlignmentOptions.Center;
|
_statusText.alignment = TextAlignmentOptions.Center;
|
||||||
|
_statusText.raycastTarget = false;
|
||||||
|
|
||||||
var statusRect = _statusTextObj.GetComponent<RectTransform>();
|
var statusRect = _statusTextObj.GetComponent<RectTransform>();
|
||||||
statusRect.anchorMin = new Vector2(0, 0);
|
statusRect.anchorMin = Vector2.zero;
|
||||||
statusRect.anchorMax = new Vector2(1, 0);
|
statusRect.anchorMax = Vector2.one;
|
||||||
statusRect.pivot = new Vector2(0.5f, 0);
|
statusRect.offsetMin = Vector2.zero;
|
||||||
statusRect.anchoredPosition = new Vector2(0, 35);
|
statusRect.offsetMax = Vector2.zero;
|
||||||
statusRect.sizeDelta = new Vector2(0, 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
|
|
||||||
{
|
|
||||||
button.transition = Selectable.Transition.ColorTint;
|
|
||||||
var colors = button.colors;
|
|
||||||
colors.normalColor = normalColor;
|
|
||||||
colors.highlightedColor = hoverColor;
|
|
||||||
colors.pressedColor = pressedColor;
|
|
||||||
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
|
||||||
button.colors = colors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
@@ -643,6 +583,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
|
_window?.Dispose();
|
||||||
|
_window = null;
|
||||||
_cts?.Dispose();
|
_cts?.Dispose();
|
||||||
if (Instance == this)
|
if (Instance == this)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System;
|
||||||
|
using ADOFAIRenderer.Localization;
|
||||||
|
using TMPro;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
|
||||||
|
/// automatically updating the displayed text whenever the active language changes.
|
||||||
|
/// </summary>
|
||||||
|
internal class LocalizedText : MonoBehaviour
|
||||||
|
{
|
||||||
|
private TextMeshProUGUI? _textComponent;
|
||||||
|
private string? _key;
|
||||||
|
private Func<string>? _dynamicProvider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the localization key. Setting this will clear any dynamic provider.
|
||||||
|
/// </summary>
|
||||||
|
public string? Key
|
||||||
|
{
|
||||||
|
get => _key;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_key = value;
|
||||||
|
_dynamicProvider = null;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a dynamic string provider function.
|
||||||
|
/// </summary>
|
||||||
|
public void SetDynamic(Func<string> provider)
|
||||||
|
{
|
||||||
|
_dynamicProvider = provider;
|
||||||
|
_key = null;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
EnsureComponent();
|
||||||
|
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleLanguageChanged(SystemLanguage language)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureComponent()
|
||||||
|
{
|
||||||
|
if (_textComponent == null)
|
||||||
|
{
|
||||||
|
_textComponent = GetComponent<TextMeshProUGUI>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the text using either the dynamic provider or the static localization key.
|
||||||
|
/// </summary>
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
EnsureComponent();
|
||||||
|
if (_textComponent == null) return;
|
||||||
|
|
||||||
|
if (_dynamicProvider != null)
|
||||||
|
{
|
||||||
|
_textComponent.text = _dynamicProvider();
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(_key))
|
||||||
|
{
|
||||||
|
_textComponent.text = LocalizationManager.Get(_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a TextMeshProUGUI component to a localization key.
|
||||||
|
/// </summary>
|
||||||
|
public static LocalizedText Bind(TextMeshProUGUI target, string key)
|
||||||
|
{
|
||||||
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||||
|
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
|
||||||
|
component._textComponent = target;
|
||||||
|
component.Key = key;
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
|
||||||
|
/// </summary>
|
||||||
|
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
|
||||||
|
{
|
||||||
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||||
|
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
|
||||||
|
component._textComponent = target;
|
||||||
|
component.SetDynamic(provider);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
}
|
||||||
+394
-182
@@ -7,7 +7,7 @@ using ADOFAIRenderer.Core;
|
|||||||
using ADOFAIRenderer.Core.Data;
|
using ADOFAIRenderer.Core.Data;
|
||||||
using ADOFAIRenderer.Data;
|
using ADOFAIRenderer.Data;
|
||||||
using ADOFAIRenderer.Localization;
|
using ADOFAIRenderer.Localization;
|
||||||
using ADOFAIRenderer.Util;
|
using ADOFAIRenderer.Util.OS;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using O5Kit.Control;
|
using O5Kit.Control;
|
||||||
using O5Kit.Core;
|
using O5Kit.Core;
|
||||||
@@ -26,6 +26,10 @@ internal static class RenderConfigWindow
|
|||||||
private static O5InputField? _presetNameInput;
|
private static O5InputField? _presetNameInput;
|
||||||
private static O5Button? _savePresetButton;
|
private static O5Button? _savePresetButton;
|
||||||
private static O5Button? _deletePresetButton;
|
private static O5Button? _deletePresetButton;
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
private static LayoutElement? _deletePresetButtonLE;
|
||||||
|
private static bool _isDeletePending;
|
||||||
|
private static Coroutine? _deleteResetCoroutine;
|
||||||
private static string? _lastLoadedPresetName;
|
private static string? _lastLoadedPresetName;
|
||||||
|
|
||||||
// Controls cache for validation & sync
|
// Controls cache for validation & sync
|
||||||
@@ -87,14 +91,20 @@ internal static class RenderConfigWindow
|
|||||||
Size = new Vector2(900, 600), Draggable = true, Resizable = true,
|
Size = new Vector2(900, 600), Draggable = true, Resizable = true,
|
||||||
ShowCloseButton = true
|
ShowCloseButton = true
|
||||||
});
|
});
|
||||||
|
if (_configWindow.TitleText != null)
|
||||||
|
{
|
||||||
|
LocalizedText.Bind(_configWindow.TitleText, "ui.render_config.window_title");
|
||||||
|
}
|
||||||
|
|
||||||
_configWindow.CloseRequested += win =>
|
_configWindow.CloseRequested += win =>
|
||||||
{
|
{
|
||||||
CloseAllPickers();
|
CloseAllPickers();
|
||||||
|
ResetDeletePendingState();
|
||||||
win.Rect.gameObject.SetActive(false);
|
win.Rect.gameObject.SetActive(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
BuildUI(_configWindow.Content);
|
BuildUI(_configWindow.Content);
|
||||||
|
LocalizationManager.OnLanguageChanged += OnLanguageChanged;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -145,14 +155,9 @@ internal static class RenderConfigWindow
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Presets (Collapsible Card)
|
// Presets (Collapsible Card)
|
||||||
var (_, presetContent) = O5Factory.Card(
|
var (_, presetContent) = CreateCard(
|
||||||
ctx, scrollContent,
|
ctx, scrollContent,
|
||||||
LocalizationManager.Get("ui.render_config.presets"),
|
"ui.render_config.presets"
|
||||||
activeValue: true,
|
|
||||||
onActiveChanged: null,
|
|
||||||
onDeleteClick: null,
|
|
||||||
showDeleteButton: false,
|
|
||||||
showActiveToggle: false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var presets = PresetManager.GetPresets();
|
var presets = PresetManager.GetPresets();
|
||||||
@@ -177,7 +182,7 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
_presetDropdown = CreateDropdownField(
|
_presetDropdown = CreateDropdownField(
|
||||||
ctx, presetContent,
|
ctx, presetContent,
|
||||||
LocalizationManager.Get("ui.render_config.preset_select"),
|
"ui.render_config.preset_select",
|
||||||
initialPresetName, initialPresetName,
|
initialPresetName, initialPresetName,
|
||||||
presetNames, p => p,
|
presetNames, p => p,
|
||||||
"dd_presets",
|
"dd_presets",
|
||||||
@@ -213,18 +218,24 @@ internal static class RenderConfigWindow
|
|||||||
icon: null,
|
icon: null,
|
||||||
id: "input_preset_name"
|
id: "input_preset_name"
|
||||||
);
|
);
|
||||||
|
if (_presetNameInput.Placeholder != null)
|
||||||
|
{
|
||||||
|
LocalizedText.Bind(_presetNameInput.Placeholder, "ui.render_config.preset_name_placeholder");
|
||||||
|
}
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
var inputLE = _presetNameInput.Rect.GetComponent<LayoutElement>() ?? _presetNameInput.Rect.gameObject.AddComponent<LayoutElement>();
|
var inputLE = _presetNameInput.Rect.GetComponent<LayoutElement>() ?? _presetNameInput.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
inputLE.flexibleWidth = 1f;
|
inputLE.flexibleWidth = 1f;
|
||||||
inputLE.preferredHeight = 40f;
|
inputLE.preferredHeight = 40f;
|
||||||
|
|
||||||
_savePresetButton = O5Factory.Button(
|
_savePresetButton = CreateButton(
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
parent: actionsRowRect,
|
parent: actionsRowRect,
|
||||||
onClick: () => OnSavePresetClicked(config),
|
textKey: "ui.render_config.preset_save",
|
||||||
text: LocalizationManager.Get("ui.render_config.preset_save"),
|
|
||||||
id: "btn_preset_save",
|
id: "btn_preset_save",
|
||||||
|
onClick: () => OnSavePresetClicked(config),
|
||||||
height: 40f
|
height: 40f
|
||||||
);
|
);
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
var saveLE = _savePresetButton.Rect.GetComponent<LayoutElement>() ?? _savePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
|
var saveLE = _savePresetButton.Rect.GetComponent<LayoutElement>() ?? _savePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
saveLE.preferredWidth = 90f;
|
saveLE.preferredWidth = 90f;
|
||||||
saveLE.flexibleWidth = 0f;
|
saveLE.flexibleWidth = 0f;
|
||||||
@@ -241,104 +252,97 @@ internal static class RenderConfigWindow
|
|||||||
fonts: ctx.Fonts,
|
fonts: ctx.Fonts,
|
||||||
tween: ctx.Tween
|
tween: ctx.Tween
|
||||||
);
|
);
|
||||||
_deletePresetButton = O5Factory.Button(
|
_deletePresetButton = CreateButton(
|
||||||
ctx: redCtx,
|
ctx: redCtx,
|
||||||
parent: actionsRowRect,
|
parent: actionsRowRect,
|
||||||
onClick: () => OnDeletePresetClicked(config),
|
textKey: "ui.render_config.preset_delete",
|
||||||
text: LocalizationManager.Get("ui.render_config.preset_delete"),
|
|
||||||
id: "btn_preset_delete",
|
id: "btn_preset_delete",
|
||||||
|
onClick: () => OnDeletePresetClicked(config),
|
||||||
height: 40f
|
height: 40f
|
||||||
);
|
);
|
||||||
var deleteLE = _deletePresetButton.Rect.GetComponent<LayoutElement>() ?? _deletePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
|
if (_deletePresetButton.Label != null)
|
||||||
deleteLE.preferredWidth = 90f;
|
{
|
||||||
deleteLE.flexibleWidth = 0f;
|
LocalizedText.Bind(_deletePresetButton.Label, () =>
|
||||||
deleteLE.preferredHeight = 40f;
|
_isDeletePending
|
||||||
|
? LocalizationManager.Get("ui.render_config.preset_delete_confirm")
|
||||||
|
: LocalizationManager.Get("ui.render_config.preset_delete"));
|
||||||
|
}
|
||||||
|
_deletePresetButtonLE = _deletePresetButton.Rect.GetComponent<LayoutElement>() ?? _deletePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
|
_deletePresetButtonLE.preferredWidth = 90f;
|
||||||
|
_deletePresetButtonLE.flexibleWidth = 0f;
|
||||||
|
_deletePresetButtonLE.preferredHeight = 40f;
|
||||||
|
|
||||||
UpdateDeleteButtonState();
|
UpdateDeleteButtonState();
|
||||||
|
|
||||||
// 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개)
|
// 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개)
|
||||||
var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f);
|
var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f);
|
||||||
|
|
||||||
_renderFpsSlider = O5Factory.Slider(
|
_renderFpsSlider = CreateSlider(
|
||||||
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
||||||
|
labelKey: "ui.render_config.render_fps", id: "slider_render_fps",
|
||||||
defaultValue: 60, min: 0.01, max: 3000, value: 60,
|
defaultValue: 60, min: 0.01, max: 3000, value: 60,
|
||||||
format: "F3", clampMode: ClampMode.All,
|
format: "F3", clampMode: ClampMode.All,
|
||||||
|
|
||||||
sliderFilter: value => Snap(renderFpsSnap, value),
|
sliderFilter: value => Snap(renderFpsSnap, value),
|
||||||
inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; MarkConfigModified(); },
|
onChanged: val => { config.RenderFPS = val; MarkConfigModified(); }
|
||||||
text: LocalizationManager.Get("ui.render_config.render_fps"),
|
|
||||||
id: "slider_render_fps", showFill: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_videoFPSSlider = O5Factory.Slider(
|
_videoFPSSlider = CreateSlider(
|
||||||
ctx: ctx, parent: CreateFieldSlot(topRight),
|
ctx: ctx, parent: CreateFieldSlot(topRight),
|
||||||
|
labelKey: "ui.render_config.video_fps", id: "slider_video_fps",
|
||||||
defaultValue: 60, min: 0.01, max: 600, value: 60,
|
defaultValue: 60, min: 0.01, max: 600, value: 60,
|
||||||
format: "F3", clampMode: ClampMode.All,
|
format: "F3", clampMode: ClampMode.All,
|
||||||
|
|
||||||
sliderFilter: value => Snap(videoFpsSnap, value),
|
sliderFilter: value => Snap(videoFpsSnap, value),
|
||||||
inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; MarkConfigModified(); },
|
onChanged: val => { config.VideoFPS = val; MarkConfigModified(); }
|
||||||
text: LocalizationManager.Get("ui.render_config.video_fps"),
|
|
||||||
id: "slider_video_fps", showFill: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_renderWidthSlider = O5Factory.Slider(
|
_renderWidthSlider = CreateSlider(
|
||||||
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
||||||
|
labelKey: "ui.render_config.render_width", id: "slider_render_width",
|
||||||
defaultValue: 2560, min: 0, max: 15360, value: 2560,
|
defaultValue: 2560, min: 0, max: 15360, value: 2560,
|
||||||
format: "F0", clampMode: ClampMode.All,
|
format: "F0", clampMode: ClampMode.All,
|
||||||
|
|
||||||
sliderFilter: value => Snap(renderWidthSnap, value), inputFilter: value => Math.Max(value, 2),
|
sliderFilter: value => Snap(renderWidthSnap, value), inputFilter: value => Math.Max(value, 2),
|
||||||
onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); MarkConfigModified(); },
|
onChanged: val => { config.RenderWidth = (int)Math.Round(val); MarkConfigModified(); }
|
||||||
text: LocalizationManager.Get("ui.render_config.render_width"),
|
|
||||||
id: "slider_render_width", showFill: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_renderHeightSlider = O5Factory.Slider(
|
_renderHeightSlider = CreateSlider(
|
||||||
ctx: ctx, parent: CreateFieldSlot(topRight),
|
ctx: ctx, parent: CreateFieldSlot(topRight),
|
||||||
|
labelKey: "ui.render_config.render_height", id: "slider_render_height",
|
||||||
defaultValue: 1440, min: 0, max: 8640, value: 1440,
|
defaultValue: 1440, min: 0, max: 8640, value: 1440,
|
||||||
format: "F0", clampMode: ClampMode.All,
|
format: "F0", clampMode: ClampMode.All,
|
||||||
|
|
||||||
sliderFilter: value => Snap(renderHeightSnap, value), inputFilter: value => Math.Max(value, 2),
|
sliderFilter: value => Snap(renderHeightSnap, value), inputFilter: value => Math.Max(value, 2),
|
||||||
onComplete: null, onChanged: val => { config.RenderHeight = (int)Math.Round(val); MarkConfigModified(); },
|
onChanged: val => { config.RenderHeight = (int)Math.Round(val); MarkConfigModified(); }
|
||||||
text: LocalizationManager.Get("ui.render_config.render_height"),
|
|
||||||
id: "slider_render_height", showFill: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_delayAfterEndSlider = O5Factory.Slider(
|
_delayAfterEndSlider = CreateSlider(
|
||||||
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
ctx: ctx, parent: CreateFieldSlot(topLeft),
|
||||||
|
labelKey: "ui.render_config.delay_after_end", id: "slider_delay_after_end",
|
||||||
defaultValue: 15, min: 0, max: 9999.999, value: 15,
|
defaultValue: 15, min: 0, max: 9999.999, value: 15,
|
||||||
format: "F3", clampMode: ClampMode.All,
|
format: "F3", clampMode: ClampMode.All,
|
||||||
|
onChanged: val => { config.DelayAfterEnd = val; MarkConfigModified(); }
|
||||||
sliderFilter: null, inputFilter: null,
|
|
||||||
onComplete: null, onChanged: val => { config.DelayAfterEnd = val; MarkConfigModified(); },
|
|
||||||
text: LocalizationManager.Get("ui.render_config.delay_after_end"),
|
|
||||||
id: "slider_delay_after_end", showFill: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_showJudgementToggle = CreateToggleField(
|
_showJudgementToggle = CreateToggleField(
|
||||||
ctx, CreateFieldSlot(topRight), "",
|
ctx, CreateFieldSlot(topRight),
|
||||||
false, config.ShowJudgementText,
|
labelKey: "",
|
||||||
LocalizationManager.Get("ui.render_config.show_judgement"),
|
defaultValue: false,
|
||||||
"toggle_show_judgement",
|
value: config.ShowJudgementText,
|
||||||
val => { config.ShowJudgementText = val; MarkConfigModified(); },
|
toggleTextKey: "ui.render_config.show_judgement",
|
||||||
|
id: "toggle_show_judgement",
|
||||||
|
onChanged: val => { config.ShowJudgementText = val; MarkConfigModified(); },
|
||||||
generateLabel: false
|
generateLabel: false
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. Video Encoding Config (Collapsible Card)
|
// 4. Video Encoding Config (Collapsible Card)
|
||||||
var (_, videoContent) = O5Factory.Card(
|
var (_, videoContent) = CreateCard(
|
||||||
ctx, scrollContent,
|
ctx, scrollContent,
|
||||||
LocalizationManager.Get("ui.render_config.video_encoding"),
|
"ui.render_config.video_encoding"
|
||||||
activeValue: true,
|
|
||||||
onActiveChanged: null,
|
|
||||||
onDeleteClick: null,
|
|
||||||
showDeleteButton: false,
|
|
||||||
showActiveToggle: false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent);
|
var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent);
|
||||||
var codecs = (Codec[])Enum.GetValues(typeof(Codec));
|
var codecs = (Codec[])Enum.GetValues(typeof(Codec));
|
||||||
_codecDropdown = CreateDropdownField(
|
_codecDropdown = CreateDropdownField(
|
||||||
ctx, CreateFieldSlot(videoLeft),
|
ctx, CreateFieldSlot(videoLeft),
|
||||||
LocalizationManager.Get("ui.render_config.codec"),
|
"ui.render_config.codec",
|
||||||
Codec.H264, config.VideoConfig.Codec,
|
Codec.H264, config.VideoConfig.Codec,
|
||||||
codecs, c => c.ToString(),
|
codecs, c => c.ToString(),
|
||||||
"dd_video_codec",
|
"dd_video_codec",
|
||||||
@@ -351,7 +355,7 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
_bitrateInput = CreateInputField(
|
_bitrateInput = CreateInputField(
|
||||||
ctx, CreateFieldSlot(videoLeft),
|
ctx, CreateFieldSlot(videoLeft),
|
||||||
LocalizationManager.Get("ui.render_config.bitrate"),
|
"ui.render_config.bitrate",
|
||||||
"30000", config.VideoConfig.Bitrate.ToString(),
|
"30000", config.VideoConfig.Bitrate.ToString(),
|
||||||
"input_bitrate", out _bitrateLabel,
|
"input_bitrate", out _bitrateLabel,
|
||||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||||
@@ -360,7 +364,7 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
_qpInput = CreateInputField(
|
_qpInput = CreateInputField(
|
||||||
ctx, CreateFieldSlot(videoLeft),
|
ctx, CreateFieldSlot(videoLeft),
|
||||||
LocalizationManager.Get("ui.render_config.qp"),
|
"ui.render_config.qp",
|
||||||
"18", config.VideoConfig.QP.ToString(),
|
"18", config.VideoConfig.QP.ToString(),
|
||||||
"input_qp", out _qpLabel,
|
"input_qp", out _qpLabel,
|
||||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||||
@@ -370,7 +374,7 @@ internal static class RenderConfigWindow
|
|||||||
var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode));
|
var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode));
|
||||||
_rateControlDropdown = CreateDropdownField(
|
_rateControlDropdown = CreateDropdownField(
|
||||||
ctx, CreateFieldSlot(videoRight),
|
ctx, CreateFieldSlot(videoRight),
|
||||||
LocalizationManager.Get("ui.render_config.rate_control"),
|
"ui.render_config.rate_control",
|
||||||
RateControlMode.CBR, config.VideoConfig.RateControlMode,
|
RateControlMode.CBR, config.VideoConfig.RateControlMode,
|
||||||
rateModes, m => m.ToString(),
|
rateModes, m => m.ToString(),
|
||||||
"dd_rate_control",
|
"dd_rate_control",
|
||||||
@@ -384,7 +388,7 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
_maxBitrateInput = CreateInputField(
|
_maxBitrateInput = CreateInputField(
|
||||||
ctx, CreateFieldSlot(videoRight),
|
ctx, CreateFieldSlot(videoRight),
|
||||||
LocalizationManager.Get("ui.render_config.max_bitrate"),
|
"ui.render_config.max_bitrate",
|
||||||
"50000", config.VideoConfig.MaxBitrate.ToString(),
|
"50000", config.VideoConfig.MaxBitrate.ToString(),
|
||||||
"input_max_bitrate", out _maxBitrateLabel,
|
"input_max_bitrate", out _maxBitrateLabel,
|
||||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||||
@@ -392,61 +396,55 @@ internal static class RenderConfigWindow
|
|||||||
);
|
);
|
||||||
|
|
||||||
_forceSoftwareToggle = CreateToggleField(
|
_forceSoftwareToggle = CreateToggleField(
|
||||||
ctx, CreateFieldSlot(videoRight), "",
|
ctx, CreateFieldSlot(videoRight),
|
||||||
false, config.VideoConfig.ForceSoftwareEncoder,
|
labelKey: "",
|
||||||
LocalizationManager.Get("ui.render_config.force_software"),
|
defaultValue: false,
|
||||||
"toggle_force_software",
|
value: config.VideoConfig.ForceSoftwareEncoder,
|
||||||
val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); },
|
toggleTextKey: "ui.render_config.force_software",
|
||||||
|
id: "toggle_force_software",
|
||||||
|
onChanged: val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); },
|
||||||
generateLabel: true
|
generateLabel: true
|
||||||
);
|
);
|
||||||
|
|
||||||
_keyframeIntervalSlider = O5Factory.Slider(
|
_keyframeIntervalSlider = CreateSlider(
|
||||||
ctx: ctx, parent: videoContent,
|
ctx: ctx, parent: videoContent,
|
||||||
|
labelKey: "ui.render_config.keyframe_interval", id: "slider_keyframe_interval",
|
||||||
defaultValue: 2, min: 0.1, max: 10, value: 2,
|
defaultValue: 2, min: 0.1, max: 10, value: 2,
|
||||||
format: "F1", clampMode: ClampMode.All,
|
format: "F1", clampMode: ClampMode.All,
|
||||||
|
onChanged: val => { config.VideoConfig.KeyframeInterval = val; MarkConfigModified(); }
|
||||||
sliderFilter: null, inputFilter: null,
|
|
||||||
onComplete: null, onChanged: val => { config.VideoConfig.KeyframeInterval = val; MarkConfigModified(); },
|
|
||||||
text: LocalizationManager.Get("ui.render_config.keyframe_interval"),
|
|
||||||
id: "slider_keyframe_interval", showFill: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Audio Encoding Config (Collapsible Card)
|
// 5. Audio Encoding Config (Collapsible Card)
|
||||||
var (_, audioContent) = O5Factory.Card(
|
var (_, audioContent) = CreateCard(
|
||||||
ctx, scrollContent,
|
ctx, scrollContent,
|
||||||
LocalizationManager.Get("ui.render_config.audio_encoding"),
|
"ui.render_config.audio_encoding"
|
||||||
activeValue: true,
|
|
||||||
onActiveChanged: null,
|
|
||||||
onDeleteClick: null,
|
|
||||||
showDeleteButton: false,
|
|
||||||
showActiveToggle: false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent);
|
var (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent);
|
||||||
_renderAudioToggle = CreateToggleField(
|
_renderAudioToggle = CreateToggleField(
|
||||||
ctx, CreateFieldSlot(audioLeft), "",
|
ctx, CreateFieldSlot(audioLeft),
|
||||||
true, config.AudioConfig.RenderAudio,
|
labelKey: "",
|
||||||
LocalizationManager.Get("ui.render_config.render_audio"),
|
defaultValue: true,
|
||||||
"toggle_render_audio",
|
value: config.AudioConfig.RenderAudio,
|
||||||
val => { config.AudioConfig.RenderAudio = val; MarkConfigModified(); },
|
toggleTextKey: "ui.render_config.render_audio",
|
||||||
|
id: "toggle_render_audio",
|
||||||
|
onChanged: val => { config.AudioConfig.RenderAudio = val; MarkConfigModified(); },
|
||||||
generateLabel: false
|
generateLabel: false
|
||||||
);
|
);
|
||||||
|
|
||||||
_audioBitrateSlider = O5Factory.Slider(
|
_audioBitrateSlider = CreateSlider(
|
||||||
ctx: ctx, parent: CreateFieldSlot(audioRight),
|
ctx: ctx, parent: CreateFieldSlot(audioRight),
|
||||||
|
labelKey: "ui.render_config.audio_bitrate", id: "slider_audio_bitrate",
|
||||||
defaultValue: 256, min: 64, max: 384, value: 256,
|
defaultValue: 256, min: 64, max: 384, value: 256,
|
||||||
format: "F0", clampMode: ClampMode.All,
|
format: "F0", clampMode: ClampMode.All,
|
||||||
|
|
||||||
sliderFilter: value => Snap(audioBitrateSnap, value),
|
sliderFilter: value => Snap(audioBitrateSnap, value),
|
||||||
inputFilter: null, onComplete: null, onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; MarkConfigModified(); },
|
onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; MarkConfigModified(); }
|
||||||
text: LocalizationManager.Get("ui.render_config.audio_bitrate"),
|
|
||||||
id: "slider_audio_bitrate", showFill: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var channels = new[] { 1, 2 };
|
var channels = new[] { 1, 2 };
|
||||||
_channelsDropdown = CreateDropdownField(
|
_channelsDropdown = CreateDropdownField(
|
||||||
ctx, CreateFieldSlot(audioLeft),
|
ctx, CreateFieldSlot(audioLeft),
|
||||||
LocalizationManager.Get("ui.render_config.channels"),
|
"ui.render_config.channels",
|
||||||
2, config.AudioConfig.AudioChannels,
|
2, config.AudioConfig.AudioChannels,
|
||||||
channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)",
|
channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)",
|
||||||
"dd_channels",
|
"dd_channels",
|
||||||
@@ -455,7 +453,7 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
_sampleRateDropdown = CreateDropdownField(
|
_sampleRateDropdown = CreateDropdownField(
|
||||||
ctx, CreateFieldSlot(audioRight),
|
ctx, CreateFieldSlot(audioRight),
|
||||||
LocalizationManager.Get("ui.render_config.sample_rate"),
|
"ui.render_config.sample_rate",
|
||||||
48000, config.AudioConfig.AudioSampleRate,
|
48000, config.AudioConfig.AudioSampleRate,
|
||||||
CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz",
|
CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz",
|
||||||
"dd_sample_rate",
|
"dd_sample_rate",
|
||||||
@@ -463,19 +461,14 @@ internal static class RenderConfigWindow
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 6. Global Settings (Collapsible Card)
|
// 6. Global Settings (Collapsible Card)
|
||||||
var (_, globalContent) = O5Factory.Card(
|
var (_, globalContent) = CreateCard(
|
||||||
ctx, scrollContent,
|
ctx, scrollContent,
|
||||||
LocalizationManager.Get("ui.render_config.global_settings"),
|
"ui.render_config.global_settings"
|
||||||
activeValue: true,
|
|
||||||
onActiveChanged: null,
|
|
||||||
onDeleteClick: null,
|
|
||||||
showDeleteButton: false,
|
|
||||||
showActiveToggle: false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_outputDirInput = CreateFullWidthInputField(
|
_outputDirInput = CreateFullWidthInputField(
|
||||||
ctx, globalContent,
|
ctx, globalContent,
|
||||||
LocalizationManager.Get("ui.render_config.output_dir"),
|
"ui.render_config.output_dir",
|
||||||
GlobalSettings.DefaultOutputDirectory,
|
GlobalSettings.DefaultOutputDirectory,
|
||||||
GlobalSettings.EffectiveOutputDirectory,
|
GlobalSettings.EffectiveOutputDirectory,
|
||||||
"input_output_dir",
|
"input_output_dir",
|
||||||
@@ -495,8 +488,10 @@ internal static class RenderConfigWindow
|
|||||||
);
|
);
|
||||||
|
|
||||||
var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent);
|
var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent);
|
||||||
O5Factory.Button(
|
CreateButton(
|
||||||
ctx, globalLeft,
|
ctx, globalLeft,
|
||||||
|
"ui.render_config.browse",
|
||||||
|
"btn_browse_output",
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value)
|
var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value)
|
||||||
@@ -512,39 +507,32 @@ internal static class RenderConfigWindow
|
|||||||
GlobalSettings.Save();
|
GlobalSettings.Save();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
LocalizationManager.Get("ui.render_config.browse"),
|
|
||||||
"btn_browse_output"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
O5Factory.Button(
|
CreateButton(
|
||||||
ctx, globalRight,
|
ctx, globalRight,
|
||||||
|
"ui.render_config.reset_default",
|
||||||
|
"btn_reset_output",
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
_outputDirInput.Set(GlobalSettings.DefaultOutputDirectory);
|
_outputDirInput.Set(GlobalSettings.DefaultOutputDirectory);
|
||||||
GlobalSettings.Current.OutputDirectory = null;
|
GlobalSettings.Current.OutputDirectory = null;
|
||||||
GlobalSettings.Save();
|
GlobalSettings.Save();
|
||||||
},
|
}
|
||||||
LocalizationManager.Get("ui.render_config.reset_default"),
|
|
||||||
"btn_reset_output"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. Advanced Settings (Collapsible Card)
|
// 7. Advanced Settings (Collapsible Card)
|
||||||
var (_, advContent) = O5Factory.Card(
|
var (_, advContent) = CreateCard(
|
||||||
ctx, scrollContent,
|
ctx, scrollContent,
|
||||||
LocalizationManager.Get("ui.render_config.advanced_settings"),
|
"ui.render_config.advanced_settings"
|
||||||
activeValue: true,
|
|
||||||
onActiveChanged: null,
|
|
||||||
onDeleteClick: null,
|
|
||||||
showDeleteButton: false,
|
|
||||||
showActiveToggle: false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var (advLeft, advRight) = CreateTwoColumnPanel(advContent);
|
var (advLeft, advRight) = CreateTwoColumnPanel(advContent);
|
||||||
var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" };
|
var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" };
|
||||||
_fileExtensionDropdown = CreateDropdownField(
|
_fileExtensionDropdown = CreateDropdownField(
|
||||||
ctx, CreateFieldSlot(advLeft),
|
ctx, CreateFieldSlot(advLeft),
|
||||||
LocalizationManager.Get("ui.render_config.file_extension"),
|
"ui.render_config.file_extension",
|
||||||
"mkv", config.AdvancedConfig.FileExtension,
|
"mkv", config.AdvancedConfig.FileExtension,
|
||||||
extensions, e => e,
|
extensions, e => e,
|
||||||
"dd_adv_file_extension",
|
"dd_adv_file_extension",
|
||||||
@@ -556,11 +544,13 @@ internal static class RenderConfigWindow
|
|||||||
);
|
);
|
||||||
|
|
||||||
_processAudioToggle = CreateToggleField(
|
_processAudioToggle = CreateToggleField(
|
||||||
ctx, CreateFieldSlot(advLeft), "",
|
ctx, CreateFieldSlot(advLeft),
|
||||||
true, config.AdvancedConfig.ProcessAudio,
|
labelKey: "",
|
||||||
LocalizationManager.Get("ui.render_config.process_audio"),
|
defaultValue: true,
|
||||||
"toggle_process_audio",
|
value: config.AdvancedConfig.ProcessAudio,
|
||||||
val =>
|
toggleTextKey: "ui.render_config.process_audio",
|
||||||
|
id: "toggle_process_audio",
|
||||||
|
onChanged: val =>
|
||||||
{
|
{
|
||||||
config.AdvancedConfig.ProcessAudio = val;
|
config.AdvancedConfig.ProcessAudio = val;
|
||||||
MarkConfigModified();
|
MarkConfigModified();
|
||||||
@@ -570,7 +560,7 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
_randomSeedInput = CreateInputField(
|
_randomSeedInput = CreateInputField(
|
||||||
ctx, CreateFieldSlot(advRight),
|
ctx, CreateFieldSlot(advRight),
|
||||||
LocalizationManager.Get("ui.render_config.random_seed"),
|
"ui.render_config.random_seed",
|
||||||
"", config.AdvancedConfig.RandomSeed?.ToString() ?? "",
|
"", config.AdvancedConfig.RandomSeed?.ToString() ?? "",
|
||||||
"input_random_seed",
|
"input_random_seed",
|
||||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||||
@@ -583,11 +573,13 @@ internal static class RenderConfigWindow
|
|||||||
);
|
);
|
||||||
|
|
||||||
_nonTileToggle = CreateToggleField(
|
_nonTileToggle = CreateToggleField(
|
||||||
ctx, CreateFieldSlot(advRight), "",
|
ctx, CreateFieldSlot(advRight),
|
||||||
false, config.AdvancedConfig.NonTile,
|
labelKey: "",
|
||||||
LocalizationManager.Get("ui.render_config.non_tile"),
|
defaultValue: false,
|
||||||
"toggle_non_tile",
|
value: config.AdvancedConfig.NonTile,
|
||||||
val =>
|
toggleTextKey: "ui.render_config.non_tile",
|
||||||
|
id: "toggle_non_tile",
|
||||||
|
onChanged: val =>
|
||||||
{
|
{
|
||||||
config.AdvancedConfig.NonTile = val;
|
config.AdvancedConfig.NonTile = val;
|
||||||
MarkConfigModified();
|
MarkConfigModified();
|
||||||
@@ -596,9 +588,7 @@ internal static class RenderConfigWindow
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 7-1. Planet Color Settings (Optional)
|
// 7-1. Planet Color Settings (Optional)
|
||||||
var planetColorsLabel = O5Factory.ControlText(ctx, advContent, 13f, noPad: true);
|
var planetColorsLabel = CreateLabel(ctx, advContent, "ui.render_config.planet_colors");
|
||||||
planetColorsLabel.text = LocalizationManager.Get("ui.render_config.planet_colors");
|
|
||||||
planetColorsLabel.color = ctx.Theme.TextDim;
|
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
var planetLabelLE = planetColorsLabel.gameObject.AddComponent<LayoutElement>();
|
var planetLabelLE = planetColorsLabel.gameObject.AddComponent<LayoutElement>();
|
||||||
planetLabelLE.preferredHeight = 18f;
|
planetLabelLE.preferredHeight = 18f;
|
||||||
@@ -609,11 +599,13 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
var fireSlot = CreateFieldSlot(fireCol);
|
var fireSlot = CreateFieldSlot(fireCol);
|
||||||
_firePlanetToggle = CreateToggleField(
|
_firePlanetToggle = CreateToggleField(
|
||||||
ctx, fireSlot, "",
|
ctx, fireSlot,
|
||||||
false, config.AdvancedConfig.firePlanetColor.HasValue,
|
labelKey: "",
|
||||||
LocalizationManager.Get("ui.render_config.fire_planet"),
|
defaultValue: false,
|
||||||
"toggle_fire_planet",
|
value: config.AdvancedConfig.firePlanetColor.HasValue,
|
||||||
val =>
|
toggleTextKey: "ui.render_config.fire_planet",
|
||||||
|
id: "toggle_fire_planet",
|
||||||
|
onChanged: val =>
|
||||||
{
|
{
|
||||||
_firePlanetPicker?.SetBlocked(!val);
|
_firePlanetPicker?.SetBlocked(!val);
|
||||||
config.AdvancedConfig.firePlanetColor = val ? _firePlanetPicker?.Value : null;
|
config.AdvancedConfig.firePlanetColor = val ? _firePlanetPicker?.Value : null;
|
||||||
@@ -643,11 +635,13 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
var iceSlot = CreateFieldSlot(iceCol);
|
var iceSlot = CreateFieldSlot(iceCol);
|
||||||
_icePlanetToggle = CreateToggleField(
|
_icePlanetToggle = CreateToggleField(
|
||||||
ctx, iceSlot, "",
|
ctx, iceSlot,
|
||||||
false, config.AdvancedConfig.icePlanetColor.HasValue,
|
labelKey: "",
|
||||||
LocalizationManager.Get("ui.render_config.ice_planet"),
|
defaultValue: false,
|
||||||
"toggle_ice_planet",
|
value: config.AdvancedConfig.icePlanetColor.HasValue,
|
||||||
val =>
|
toggleTextKey: "ui.render_config.ice_planet",
|
||||||
|
id: "toggle_ice_planet",
|
||||||
|
onChanged: val =>
|
||||||
{
|
{
|
||||||
_icePlanetPicker?.SetBlocked(!val);
|
_icePlanetPicker?.SetBlocked(!val);
|
||||||
config.AdvancedConfig.icePlanetColor = val ? _icePlanetPicker?.Value : null;
|
config.AdvancedConfig.icePlanetColor = val ? _icePlanetPicker?.Value : null;
|
||||||
@@ -677,11 +671,13 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
var windSlot = CreateFieldSlot(windCol);
|
var windSlot = CreateFieldSlot(windCol);
|
||||||
_windPlanetToggle = CreateToggleField(
|
_windPlanetToggle = CreateToggleField(
|
||||||
ctx, windSlot, "",
|
ctx, windSlot,
|
||||||
false, config.AdvancedConfig.windPlanetColor.HasValue,
|
labelKey: "",
|
||||||
LocalizationManager.Get("ui.render_config.wind_planet"),
|
defaultValue: false,
|
||||||
"toggle_wind_planet",
|
value: config.AdvancedConfig.windPlanetColor.HasValue,
|
||||||
val =>
|
toggleTextKey: "ui.render_config.wind_planet",
|
||||||
|
id: "toggle_wind_planet",
|
||||||
|
onChanged: val =>
|
||||||
{
|
{
|
||||||
_windPlanetPicker?.SetBlocked(!val);
|
_windPlanetPicker?.SetBlocked(!val);
|
||||||
config.AdvancedConfig.windPlanetColor = val ? _windPlanetPicker?.Value : null;
|
config.AdvancedConfig.windPlanetColor = val ? _windPlanetPicker?.Value : null;
|
||||||
@@ -710,20 +706,17 @@ internal static class RenderConfigWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Go Render Button
|
// 8. Go Render Button
|
||||||
O5Factory.Button(
|
CreateButton(
|
||||||
ctx, scrollContent,
|
ctx, scrollContent,
|
||||||
TryGoRender,
|
"ui.render_config.go_render",
|
||||||
LocalizationManager.Get("ui.render_config.go_render"),
|
|
||||||
"btn_go_render",
|
"btn_go_render",
|
||||||
|
onClick: TryGoRender,
|
||||||
height: 48f
|
height: 48f
|
||||||
);
|
);
|
||||||
|
|
||||||
// 9. Version Label
|
// 9. Version Label
|
||||||
var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true);
|
var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true);
|
||||||
var versionText = $"ADOFAI Renderer v{Melon<RenderMod>.Instance.Info.Version}";
|
LocalizedText.Bind(versionLabel, GetVersionText);
|
||||||
if (DateTime.UtcNow >= RenderMod.ExpirationTime) versionText += " " + LocalizationManager.Get("ui.render_config.expired");
|
|
||||||
|
|
||||||
versionLabel.text = versionText;
|
|
||||||
versionLabel.alignment = TMPro.TextAlignmentOptions.Center;
|
versionLabel.alignment = TMPro.TextAlignmentOptions.Center;
|
||||||
versionLabel.color = ctx.Theme.TextDim;
|
versionLabel.color = ctx.Theme.TextDim;
|
||||||
|
|
||||||
@@ -847,6 +840,10 @@ internal static class RenderConfigWindow
|
|||||||
{
|
{
|
||||||
if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value;
|
if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value;
|
||||||
if (_videoFPSSlider != null) target.VideoFPS = _videoFPSSlider.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 (_renderWidthSlider != null) target.RenderWidth = (int)Math.Round(_renderWidthSlider.Value);
|
||||||
if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value);
|
if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value);
|
||||||
if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value;
|
if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value;
|
||||||
@@ -902,6 +899,20 @@ internal static class RenderConfigWindow
|
|||||||
target.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null)
|
target.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null)
|
||||||
? _windPlanetPicker.Value
|
? _windPlanetPicker.Value
|
||||||
: null;
|
: 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");
|
private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom");
|
||||||
@@ -926,6 +937,7 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
private static void OnPresetSelected(string? selectedName, RenderConfig config)
|
private static void OnPresetSelected(string? selectedName, RenderConfig config)
|
||||||
{
|
{
|
||||||
|
ResetDeletePendingState();
|
||||||
if (string.IsNullOrEmpty(selectedName) || selectedName == CustomPresetLabel) return;
|
if (string.IsNullOrEmpty(selectedName) || selectedName == CustomPresetLabel) return;
|
||||||
var preset = PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, selectedName, StringComparison.OrdinalIgnoreCase));
|
var preset = PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, selectedName, StringComparison.OrdinalIgnoreCase));
|
||||||
if (preset == null) return;
|
if (preset == null) return;
|
||||||
@@ -953,7 +965,7 @@ internal static class RenderConfigWindow
|
|||||||
{
|
{
|
||||||
SyncUItoConfig(config);
|
SyncUItoConfig(config);
|
||||||
|
|
||||||
var name = _presetNameInput?.Value?.Trim();
|
var name = _presetNameInput?.Value.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(name) || name == CustomPresetLabel)
|
if (string.IsNullOrWhiteSpace(name) || name == CustomPresetLabel)
|
||||||
{
|
{
|
||||||
CommonWarningDialogUI.ShowDialog(
|
CommonWarningDialogUI.ShowDialog(
|
||||||
@@ -1010,20 +1022,62 @@ internal static class RenderConfigWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ctx = RendererUIManager.Context;
|
if (!_isDeletePending)
|
||||||
CommonWarningDialogUI.ShowDialog(
|
{
|
||||||
LocalizationManager.Get("ui.render_config.preset_delete_title"),
|
_isDeletePending = true;
|
||||||
LocalizationManager.Get("ui.render_config.preset_delete_desc", targetPresetName),
|
if (_deletePresetButtonLE != null) _deletePresetButtonLE.preferredWidth = 110f;
|
||||||
new DialogButton(
|
if (_deletePresetButton != null)
|
||||||
LocalizationManager.Get("ui.render_config.confirm"),
|
{
|
||||||
() => DoDeletePreset(targetPresetName, config),
|
if (_deletePresetButton.Label != null)
|
||||||
ctx.Theme.SoftRed
|
{
|
||||||
),
|
_deletePresetButton.Label.text = LocalizationManager.Get("ui.render_config.preset_delete_confirm");
|
||||||
new DialogButton(
|
}
|
||||||
LocalizationManager.Get("ui.render_config.cancel"),
|
_deletePresetButton.NormalColor = new Color(0.95f, 0.25f, 0.25f, 1f);
|
||||||
() => { }
|
_deletePresetButton.UpdateVisual();
|
||||||
)
|
}
|
||||||
);
|
|
||||||
|
if (_deleteResetCoroutine != null)
|
||||||
|
{
|
||||||
|
RendererUIManager.Instance.StopCoroutine(_deleteResetCoroutine);
|
||||||
|
}
|
||||||
|
_deleteResetCoroutine = RendererUIManager.Instance.StartCoroutine(ResetDeletePendingAfterDelay());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetDeletePendingState();
|
||||||
|
DoDeletePreset(targetPresetName, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Collections.IEnumerator ResetDeletePendingAfterDelay()
|
||||||
|
{
|
||||||
|
yield return new WaitForSeconds(3f);
|
||||||
|
ResetDeletePendingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResetDeletePendingState()
|
||||||
|
{
|
||||||
|
if (_deleteResetCoroutine != null)
|
||||||
|
{
|
||||||
|
RendererUIManager.Instance.StopCoroutine(_deleteResetCoroutine);
|
||||||
|
_deleteResetCoroutine = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isDeletePending && (_deletePresetButton == null || _deletePresetButton.Label?.text == LocalizationManager.Get("ui.render_config.preset_delete")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDeletePending = false;
|
||||||
|
if (_deletePresetButtonLE) _deletePresetButtonLE.preferredWidth = 90f;
|
||||||
|
if (_deletePresetButton != null)
|
||||||
|
{
|
||||||
|
if (_deletePresetButton.Label != null)
|
||||||
|
{
|
||||||
|
_deletePresetButton.Label.text = LocalizationManager.Get("ui.render_config.preset_delete");
|
||||||
|
}
|
||||||
|
_deletePresetButton.NormalColor = RendererUIManager.Context.Theme.SoftRed;
|
||||||
|
_deletePresetButton.UpdateVisual();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DoDeletePreset(string name, RenderConfig config)
|
private static void DoDeletePreset(string name, RenderConfig config)
|
||||||
@@ -1048,6 +1102,7 @@ internal static class RenderConfigWindow
|
|||||||
|
|
||||||
private static void ReloadPresetDropdown(string? selectName = null)
|
private static void ReloadPresetDropdown(string? selectName = null)
|
||||||
{
|
{
|
||||||
|
ResetDeletePendingState();
|
||||||
var presets = PresetManager.GetPresets();
|
var presets = PresetManager.GetPresets();
|
||||||
var names = presets.Select(p => p.Name).ToList();
|
var names = presets.Select(p => p.Name).ToList();
|
||||||
if (names.Count == 0)
|
if (names.Count == 0)
|
||||||
@@ -1087,6 +1142,26 @@ internal static class RenderConfigWindow
|
|||||||
_deletePresetButton?.SetBlocked(!canDelete);
|
_deletePresetButton?.SetBlocked(!canDelete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void OnLanguageChanged(SystemLanguage language)
|
||||||
|
{
|
||||||
|
if (_configWindow == null) return;
|
||||||
|
|
||||||
|
if (_presetDropdown != null)
|
||||||
|
{
|
||||||
|
var cur = _presetDropdown.Value;
|
||||||
|
var isCustom = cur == "(Modified)" || cur == "(수정됨)" || cur == CustomPresetLabel;
|
||||||
|
ReloadPresetDropdown(isCustom ? CustomPresetLabel : cur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetVersionText()
|
||||||
|
{
|
||||||
|
var versionText = $"ADOFAI Renderer v{Melon<RenderMod>.Instance.Info.Version}";
|
||||||
|
if (DateTime.UtcNow >= RenderMod.ExpirationTime)
|
||||||
|
versionText += " " + LocalizationManager.Get("ui.render_config.expired");
|
||||||
|
return versionText;
|
||||||
|
}
|
||||||
|
|
||||||
private static void ShowValidationError(string message)
|
private static void ShowValidationError(string message)
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error(message);
|
Melon<RenderMod>.Logger.Error(message);
|
||||||
@@ -1189,23 +1264,139 @@ internal static class RenderConfigWindow
|
|||||||
return slotRect;
|
return slotRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once UnusedTupleComponentInReturnValue
|
||||||
|
private static (RectTransform card, RectTransform content) CreateCard(
|
||||||
|
O5Context ctx,
|
||||||
|
Transform parent,
|
||||||
|
string titleKey,
|
||||||
|
bool activeValue = true,
|
||||||
|
Action<bool>? onActiveChanged = null,
|
||||||
|
Action? onDeleteClick = null,
|
||||||
|
bool showDeleteButton = false,
|
||||||
|
bool showActiveToggle = false)
|
||||||
|
{
|
||||||
|
var (card, content) = O5Factory.Card(
|
||||||
|
ctx,
|
||||||
|
parent,
|
||||||
|
LocalizationManager.Get(titleKey),
|
||||||
|
activeValue,
|
||||||
|
onActiveChanged,
|
||||||
|
onDeleteClick,
|
||||||
|
showDeleteButton,
|
||||||
|
showActiveToggle
|
||||||
|
);
|
||||||
|
|
||||||
|
var titleTmp = card.Find("Header/TitleTrigger/TitleText")?.GetComponent<TMPro.TextMeshProUGUI>()
|
||||||
|
?? card.GetComponentsInChildren<TMPro.TextMeshProUGUI>(true).FirstOrDefault(t => t.gameObject.name == "TitleText");
|
||||||
|
if (titleTmp)
|
||||||
|
{
|
||||||
|
titleTmp.fontSize = 18f;
|
||||||
|
LocalizedText.Bind(titleTmp, titleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (card, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TMPro.TextMeshProUGUI CreateLabel(
|
||||||
|
O5Context ctx,
|
||||||
|
Transform parent,
|
||||||
|
string textKey,
|
||||||
|
float fontSize = 13f,
|
||||||
|
bool noPad = true,
|
||||||
|
Color? color = null)
|
||||||
|
{
|
||||||
|
var label = O5Factory.ControlText(ctx, parent, fontSize, noPad: noPad);
|
||||||
|
label.text = LocalizationManager.Get(textKey);
|
||||||
|
label.color = color ?? ctx.Theme.TextDim;
|
||||||
|
LocalizedText.Bind(label, textKey);
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static O5Slider CreateSlider(
|
||||||
|
O5Context ctx,
|
||||||
|
Transform parent,
|
||||||
|
string labelKey,
|
||||||
|
string id,
|
||||||
|
double defaultValue,
|
||||||
|
double min,
|
||||||
|
double max,
|
||||||
|
double value,
|
||||||
|
string format = "F0",
|
||||||
|
ClampMode clampMode = ClampMode.All,
|
||||||
|
Func<double, double>? sliderFilter = null,
|
||||||
|
Func<double, double>? inputFilter = null,
|
||||||
|
Action<double>? onChanged = null,
|
||||||
|
Action<double>? onComplete = null,
|
||||||
|
bool showFill = true)
|
||||||
|
{
|
||||||
|
var slider = O5Factory.Slider(
|
||||||
|
ctx: ctx,
|
||||||
|
parent: parent,
|
||||||
|
defaultValue: defaultValue,
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
value: value,
|
||||||
|
format: format,
|
||||||
|
clampMode: clampMode,
|
||||||
|
sliderFilter: sliderFilter,
|
||||||
|
inputFilter: inputFilter,
|
||||||
|
onComplete: onComplete,
|
||||||
|
onChanged: onChanged,
|
||||||
|
text: LocalizationManager.Get(labelKey),
|
||||||
|
id: id,
|
||||||
|
showFill: showFill
|
||||||
|
);
|
||||||
|
|
||||||
|
if (slider.Label != null)
|
||||||
|
{
|
||||||
|
LocalizedText.Bind(slider.Label, labelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return slider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static O5Button CreateButton(
|
||||||
|
O5Context ctx,
|
||||||
|
Transform parent,
|
||||||
|
string textKey,
|
||||||
|
string id,
|
||||||
|
Action onClick,
|
||||||
|
float? height = null)
|
||||||
|
{
|
||||||
|
var button = O5Factory.Button(
|
||||||
|
ctx: ctx,
|
||||||
|
parent: parent,
|
||||||
|
onClick: onClick,
|
||||||
|
text: LocalizationManager.Get(textKey),
|
||||||
|
id: id,
|
||||||
|
height: height
|
||||||
|
);
|
||||||
|
|
||||||
|
if (button.Label != null)
|
||||||
|
{
|
||||||
|
LocalizedText.Bind(button.Label, textKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
private static O5InputField CreateInputField(
|
private static O5InputField CreateInputField(
|
||||||
O5Context ctx,
|
O5Context ctx,
|
||||||
Transform colParent,
|
Transform colParent,
|
||||||
string labelText,
|
string labelKey,
|
||||||
string? defaultValue,
|
string? defaultValue,
|
||||||
string value,
|
string value,
|
||||||
string id,
|
string id,
|
||||||
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
|
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
|
||||||
Action<string>? onChanged = null)
|
Action<string>? onChanged = null)
|
||||||
{
|
{
|
||||||
return CreateInputField(ctx, colParent, labelText, defaultValue, value, id, out _, characterValidation, onChanged);
|
return CreateInputField(ctx, colParent, labelKey, defaultValue, value, id, out _, characterValidation, onChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static O5InputField CreateInputField(
|
private static O5InputField CreateInputField(
|
||||||
O5Context ctx,
|
O5Context ctx,
|
||||||
Transform colParent,
|
Transform colParent,
|
||||||
string labelText,
|
string labelKey,
|
||||||
string? defaultValue,
|
string? defaultValue,
|
||||||
string value,
|
string value,
|
||||||
string id,
|
string id,
|
||||||
@@ -1214,8 +1405,9 @@ internal static class RenderConfigWindow
|
|||||||
Action<string>? onChanged = null)
|
Action<string>? onChanged = null)
|
||||||
{
|
{
|
||||||
label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||||
label.text = labelText;
|
label.text = LocalizationManager.Get(labelKey);
|
||||||
label.color = ctx.Theme.TextDim;
|
label.color = ctx.Theme.TextDim;
|
||||||
|
LocalizedText.Bind(label, labelKey);
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
||||||
@@ -1228,11 +1420,16 @@ internal static class RenderConfigWindow
|
|||||||
defaultValue: defaultValue,
|
defaultValue: defaultValue,
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
placeholder: labelText,
|
placeholder: LocalizationManager.Get(labelKey),
|
||||||
icon: null,
|
icon: null,
|
||||||
id: id
|
id: id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (inputField.Placeholder != null)
|
||||||
|
{
|
||||||
|
LocalizedText.Bind(inputField.Placeholder, labelKey);
|
||||||
|
}
|
||||||
|
|
||||||
inputField.InputField.characterValidation = characterValidation;
|
inputField.InputField.characterValidation = characterValidation;
|
||||||
return inputField;
|
return inputField;
|
||||||
}
|
}
|
||||||
@@ -1291,7 +1488,7 @@ internal static class RenderConfigWindow
|
|||||||
private static O5InputField CreateFullWidthInputField(
|
private static O5InputField CreateFullWidthInputField(
|
||||||
O5Context ctx,
|
O5Context ctx,
|
||||||
Transform parent,
|
Transform parent,
|
||||||
string labelText,
|
string labelKey,
|
||||||
string? defaultValue,
|
string? defaultValue,
|
||||||
string value,
|
string value,
|
||||||
string id,
|
string id,
|
||||||
@@ -1309,8 +1506,9 @@ internal static class RenderConfigWindow
|
|||||||
layout.childForceExpandHeight = false;
|
layout.childForceExpandHeight = false;
|
||||||
|
|
||||||
var label = O5Factory.ControlText(ctx, containerRect, 13f, noPad: true);
|
var label = O5Factory.ControlText(ctx, containerRect, 13f, noPad: true);
|
||||||
label.text = labelText;
|
label.text = LocalizationManager.Get(labelKey);
|
||||||
label.color = ctx.Theme.TextDim;
|
label.color = ctx.Theme.TextDim;
|
||||||
|
LocalizedText.Bind(label, labelKey);
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
||||||
@@ -1323,21 +1521,26 @@ internal static class RenderConfigWindow
|
|||||||
defaultValue: defaultValue,
|
defaultValue: defaultValue,
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
placeholder: labelText,
|
placeholder: LocalizationManager.Get(labelKey),
|
||||||
icon: null,
|
icon: null,
|
||||||
id: id
|
id: id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (input.Placeholder != null)
|
||||||
|
{
|
||||||
|
LocalizedText.Bind(input.Placeholder, labelKey);
|
||||||
|
}
|
||||||
|
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static O5Toggle CreateToggleField(
|
private static O5Toggle CreateToggleField(
|
||||||
O5Context ctx,
|
O5Context ctx,
|
||||||
Transform colParent,
|
Transform colParent,
|
||||||
string labelText,
|
string labelKey,
|
||||||
bool? defaultValue,
|
bool? defaultValue,
|
||||||
bool value,
|
bool value,
|
||||||
string toggleText,
|
string toggleTextKey,
|
||||||
string id,
|
string id,
|
||||||
Action<bool>? onChanged = null,
|
Action<bool>? onChanged = null,
|
||||||
bool generateLabel = true)
|
bool generateLabel = true)
|
||||||
@@ -1345,8 +1548,9 @@ internal static class RenderConfigWindow
|
|||||||
if (generateLabel)
|
if (generateLabel)
|
||||||
{
|
{
|
||||||
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||||
label.text = labelText;
|
label.text = string.IsNullOrEmpty(labelKey) ? "" : LocalizationManager.Get(labelKey);
|
||||||
label.color = ctx.Theme.TextDim;
|
label.color = ctx.Theme.TextDim;
|
||||||
|
LocalizedText.Bind(label, labelKey);
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
||||||
@@ -1354,21 +1558,28 @@ internal static class RenderConfigWindow
|
|||||||
labelLE.minHeight = 18f;
|
labelLE.minHeight = 18f;
|
||||||
}
|
}
|
||||||
|
|
||||||
return O5Factory.Toggle(
|
var toggle = O5Factory.Toggle(
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
parent: colParent,
|
parent: colParent,
|
||||||
defaultValue: defaultValue,
|
defaultValue: defaultValue,
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
text: toggleText,
|
text: !string.IsNullOrEmpty(toggleTextKey) ? LocalizationManager.Get(toggleTextKey) : "",
|
||||||
id: id
|
id: id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (toggle.Label != null && !string.IsNullOrEmpty(toggleTextKey))
|
||||||
|
{
|
||||||
|
LocalizedText.Bind(toggle.Label, toggleTextKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toggle;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static O5Dropdown<T> CreateDropdownField<T>(
|
private static O5Dropdown<T> CreateDropdownField<T>(
|
||||||
O5Context ctx,
|
O5Context ctx,
|
||||||
Transform colParent,
|
Transform colParent,
|
||||||
string labelText,
|
string labelKey,
|
||||||
T defaultValue,
|
T defaultValue,
|
||||||
T value,
|
T value,
|
||||||
IReadOnlyList<T> values,
|
IReadOnlyList<T> values,
|
||||||
@@ -1377,8 +1588,9 @@ internal static class RenderConfigWindow
|
|||||||
Action<T>? onChanged = null)
|
Action<T>? onChanged = null)
|
||||||
{
|
{
|
||||||
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||||
label.text = labelText;
|
label.text = LocalizationManager.Get(labelKey);
|
||||||
label.color = ctx.Theme.TextDim;
|
label.color = ctx.Theme.TextDim;
|
||||||
|
LocalizedText.Bind(label, labelKey);
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
var labelLE = label.gameObject.AddComponent<LayoutElement>();
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ internal static class AudioSpectrumGenerator
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
|
var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
|
||||||
|
|
||||||
lock (CachedSpectrums)
|
lock (CachedSpectrums)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,11 +15,6 @@ internal class AudioSyncCalculator
|
|||||||
{
|
{
|
||||||
_sampleRate = sampleRate;
|
_sampleRate = sampleRate;
|
||||||
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
||||||
|
|
||||||
// NTSC
|
|
||||||
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
|
|
||||||
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
|
|
||||||
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Reset()
|
public void Reset()
|
||||||
|
|||||||
+292
-115
@@ -191,6 +191,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
private bool _isOpened;
|
private bool _isOpened;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private bool _isDraining;
|
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();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
// Fields to detect SwsContext updates
|
// Fields to detect SwsContext updates
|
||||||
@@ -199,6 +206,12 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
private int _srcWidth;
|
private int _srcWidth;
|
||||||
private int _srcHeight;
|
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
|
// Public Properties
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -289,6 +302,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>
|
/// <summary>
|
||||||
/// Gets a value indicating whether hardware-accelerated decoding is currently active.
|
/// Gets a value indicating whether hardware-accelerated decoding is currently active.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -371,6 +437,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
if (_videoStreamIndex == -1)
|
if (_videoStreamIndex == -1)
|
||||||
throw new Exception("Failed to find video stream.");
|
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];
|
var stream = _pFormatContext->streams[_videoStreamIndex];
|
||||||
_timeBase = stream->time_base;
|
_timeBase = stream->time_base;
|
||||||
|
|
||||||
@@ -441,9 +517,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
TotalFrames = 0;
|
TotalFrames = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Duration = _pFormatContext->duration > 0
|
// Duration of the video stream (AVStream.duration, in the stream time base).
|
||||||
? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE
|
// When the stream does not carry a duration, the container duration (AVFormatContext.duration,
|
||||||
: (stream->duration > 0 ? stream->duration * ffmpeg.av_q2d(stream->time_base) : 0);
|
// 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
|
// 9. Allocate frame and packet buffers
|
||||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
||||||
@@ -655,6 +735,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads packets from the container until a packet belonging to the video stream is found,
|
/// 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>.
|
/// 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
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>True if a video packet was sent; false on EOF.</returns>
|
/// <returns>True if a video packet was sent; false on EOF.</returns>
|
||||||
@@ -670,6 +752,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
{
|
{
|
||||||
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
|
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
|
||||||
ffmpeg.av_packet_unref(_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)
|
if (ret < 0)
|
||||||
{
|
{
|
||||||
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
|
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
|
||||||
@@ -743,12 +837,67 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
|
||||||
/// </summary>
|
/// </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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -763,6 +912,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
_srcWidth = srcWidth;
|
_srcWidth = srcWidth;
|
||||||
_srcHeight = srcHeight;
|
_srcHeight = srcHeight;
|
||||||
_dstPixFmt = dstFormat;
|
_dstPixFmt = dstFormat;
|
||||||
|
_srcSwsColorspace = swsColorspace;
|
||||||
|
_srcFullRange = fullRange;
|
||||||
|
|
||||||
_pSwsContext = ffmpeg.sws_getContext(
|
_pSwsContext = ffmpeg.sws_getContext(
|
||||||
srcWidth, srcHeight, srcFormat,
|
srcWidth, srcHeight, srcFormat,
|
||||||
@@ -774,6 +925,44 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
{
|
{
|
||||||
throw new Exception($"Failed to initialize SwsContext (src: {srcFormat} {srcWidth}x{srcHeight} -> dst: {dstFormat} {Width}x{Height}).");
|
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>
|
/// <summary>
|
||||||
@@ -865,7 +1054,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
if (frame == null) return false;
|
if (frame == null) return false;
|
||||||
|
|
||||||
timestamp = GetTimestampAndUpdateIndex(frame);
|
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();
|
ClearConvertedFramePointers();
|
||||||
if (vflip)
|
if (vflip)
|
||||||
@@ -1212,107 +1401,24 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="count">Number of frames to discard.</param>
|
private const int MaxForwardDecodeFrames = 60;
|
||||||
private void SkipFrames(int count)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_isOpened || _disposed) return;
|
|
||||||
if (count <= 0) return;
|
|
||||||
|
|
||||||
int skipped = 0;
|
|
||||||
if (_hasCachedFrame)
|
|
||||||
{
|
|
||||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
|
||||||
_hasCachedFrame = false;
|
|
||||||
skipped++;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (skipped < count)
|
|
||||||
{
|
|
||||||
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
|
||||||
if (ret == 0)
|
|
||||||
{
|
|
||||||
GetTimestampAndUpdateIndex(_pDecodedFrame);
|
|
||||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
|
||||||
skipped++;
|
|
||||||
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 skipping frames: {GetFFmpegErrorString(ret)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeks to a specific playback time in seconds using backward keyframe seek followed by forward decode.
|
/// Decodes forward from the current decoder position until the first frame whose presentation time is at least
|
||||||
/// Intermediate frames are discarded without GPU-to-host memory transfer to maximize seek speed;
|
/// <paramref name="timeSeconds"/> minus half a frame, and keeps that frame in <see cref="_pCachedFrame"/>
|
||||||
/// only the target frame is transferred and preserved 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>
|
/// </summary>
|
||||||
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
|
/// <param name="timeSeconds">Target time in seconds.</param>
|
||||||
public void SeekToTime(double timeSeconds)
|
private void DecodeToTarget(double timeSeconds)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
SkipFrames((int)(targetFrameIndex - _currentFrameIndex));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_hasCachedFrame)
|
|
||||||
{
|
|
||||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
|
||||||
_hasCachedFrame = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase));
|
|
||||||
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
|
||||||
if (ret < 0)
|
|
||||||
{
|
|
||||||
// Fallback to beginning of file on seek failure
|
|
||||||
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
|
||||||
if (ret < 0)
|
|
||||||
{
|
|
||||||
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush decoder internal buffers
|
|
||||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
|
|
||||||
ffmpeg.avcodec_flush_buffers(_pCodecContext);
|
|
||||||
_isDraining = false;
|
|
||||||
|
|
||||||
// Decode frames until reaching the target presentation timestamp
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
||||||
if (ret == 0)
|
if (ret == 0)
|
||||||
{
|
{
|
||||||
double currentPts = GetFrameTimestamp(_pDecodedFrame);
|
double currentPts = GetFrameTimestamp(_pDecodedFrame);
|
||||||
@@ -1343,28 +1449,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_hasCachedFrame = true;
|
_hasCachedFrame = true;
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard non-matching intermediate frame without transferring to CPU
|
// Discard non-matching intermediate frame without transferring to CPU
|
||||||
long ptsValue = _pDecodedFrame->best_effort_timestamp != ffmpeg.AV_NOPTS_VALUE
|
GetTimestampAndUpdateIndex(_pDecodedFrame);
|
||||||
? _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);
|
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||||
{
|
{
|
||||||
if (_isDraining) break;
|
if (_isDraining) return;
|
||||||
|
|
||||||
if (!ReadAndSendPacket())
|
if (!ReadAndSendPacket())
|
||||||
{
|
{
|
||||||
@@ -1374,7 +1470,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
else if (ret == ffmpeg.AVERROR_EOF)
|
else if (ret == ffmpeg.AVERROR_EOF)
|
||||||
{
|
{
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1382,6 +1478,87 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_isOpened || _disposed) return;
|
||||||
|
|
||||||
|
if (timeSeconds < 0) timeSeconds = 0;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasCachedFrame)
|
||||||
|
{
|
||||||
|
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||||
|
_hasCachedFrame = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase));
|
||||||
|
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (ret < 0)
|
||||||
|
{
|
||||||
|
// Fallback to beginning of file on seek failure
|
||||||
|
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (ret < 0)
|
||||||
|
{
|
||||||
|
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush decoder internal buffers
|
||||||
|
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
|
||||||
|
ffmpeg.avcodec_flush_buffers(_pCodecContext);
|
||||||
|
_isDraining = false;
|
||||||
|
|
||||||
|
// Decode frames until reaching the target presentation timestamp
|
||||||
|
DecodeToTarget(timeSeconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
public AVCodecID AudioCodecId;
|
public AVCodecID AudioCodecId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audio bitrate (in bps). If default (0), 128000 bps is used.
|
/// Audio bitrate (in bps). If default (0), 256000 bps is used.
|
||||||
/// For AAC, 128000–320000 bps is common.
|
/// For AAC, 128000–320000 bps is common.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Bitrate;
|
public int Bitrate;
|
||||||
@@ -83,7 +83,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
/// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary>
|
/// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary>
|
||||||
public double FPS;
|
public double FPS;
|
||||||
|
|
||||||
/// <summary>Video codec type to use (H264, H265, AV1, VP9).</summary>
|
/// <summary>Video codec type to use (H264, H265, AV1, VP9, VVC).</summary>
|
||||||
public Codec Codec;
|
public Codec Codec;
|
||||||
|
|
||||||
/// <summary>Bitrate control mode (CQP, CBR, VBR).</summary>
|
/// <summary>Bitrate control mode (CQP, CBR, VBR).</summary>
|
||||||
@@ -299,6 +299,23 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
return GPUUtil.GetPrioritizedEncoders(_settings.Codec, _settings.ForceSoftware).ToArray();
|
return GPUUtil.GetPrioritizedEncoders(_settings.Codec, _settings.ForceSoftware).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expands the candidate list into (encoder, input mode) attempts in order.
|
||||||
|
/// A QSV encoder is attempted twice: system memory input mode (useFramePool = false) first,
|
||||||
|
/// then GPU frame pool input mode (useFramePool = true). Other encoders are attempted once (useFramePool = false).
|
||||||
|
/// </summary>
|
||||||
|
private static System.Collections.Generic.IEnumerable<(VideoEncoder Encoder, bool UseFramePool)> ExpandQsvInputModes(VideoEncoder[] candidates)
|
||||||
|
{
|
||||||
|
foreach (var encoder in candidates)
|
||||||
|
{
|
||||||
|
yield return (encoder, false);
|
||||||
|
if (encoder.Kind == EncoderKind.QSV)
|
||||||
|
{
|
||||||
|
yield return (encoder, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts an FFmpeg error code (negative integer) to a human-readable string.
|
/// Converts an FFmpeg error code (negative integer) to a human-readable string.
|
||||||
/// e.g., -1 → "Operation not permitted"
|
/// e.g., -1 → "Operation not permitted"
|
||||||
@@ -367,7 +384,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
var candidates = GetEncoderCandidates();
|
var candidates = GetEncoderCandidates();
|
||||||
var openedSuccessfully = false;
|
var openedSuccessfully = false;
|
||||||
|
|
||||||
foreach (var encoder in candidates)
|
foreach (var (encoder, useFramePool) in ExpandQsvInputModes(candidates))
|
||||||
{
|
{
|
||||||
// avcodec_find_encoder_by_name: Searches for an encoder by name (e.g., "h264_nvenc").
|
// avcodec_find_encoder_by_name: Searches for an encoder by name (e.g., "h264_nvenc").
|
||||||
// Returns null if the encoder is not included in the build.
|
// Returns null if the encoder is not included in the build.
|
||||||
@@ -437,16 +454,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
// framerate: Framerate value giving hints to the encoder (reciprocal of time_base).
|
// framerate: Framerate value giving hints to the encoder (reciprocal of time_base).
|
||||||
//
|
//
|
||||||
// RationalUtil: Converts FPS (double) into fractional form (numerator/denominator).
|
// RationalUtil: Converts FPS (double) into fractional form (numerator/denominator).
|
||||||
// e.g., 29.97 → 30000/1001 (NTSC standard)
|
// e.g., 60.00 -> 60/1
|
||||||
//
|
//
|
||||||
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
||||||
var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS);
|
var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS);
|
||||||
|
|
||||||
// NTSC
|
|
||||||
if (Math.Abs(_settings.FPS - 29.97) < 0.001) fpsRational = (30000, 1001);
|
|
||||||
if (Math.Abs(_settings.FPS - 59.94) < 0.001) fpsRational = (60000, 1001);
|
|
||||||
if (Math.Abs(_settings.FPS - 23.976) < 0.001 || Math.Abs(_settings.FPS - 23.98) < 0.001) fpsRational = (24000, 1001);
|
|
||||||
|
|
||||||
_pCodecContext->time_base = new AVRational
|
_pCodecContext->time_base = new AVRational
|
||||||
{ num = fpsRational.Denominator, den = fpsRational.Numerator };
|
{ num = fpsRational.Denominator, den = fpsRational.Numerator };
|
||||||
_pCodecContext->framerate = new AVRational
|
_pCodecContext->framerate = new AVRational
|
||||||
@@ -519,9 +531,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
// Strategy 3) Create QSV device directly (legacy method)
|
// Strategy 3) Create QSV device directly (legacy method)
|
||||||
// → Fallback on older drivers/systems
|
// → Fallback on older drivers/systems
|
||||||
//
|
//
|
||||||
// After obtaining device, attempt using GPU frame pool:
|
// After obtaining device, the input mode is configured (see ExpandQsvInputModes):
|
||||||
// hw_frames_ctx: Pre-allocate frame pool in GPU VRAM (zero-copy, highest performance)
|
// Input mode 1, hw_device_ctx: Encoder receives NV12 frames from system memory (attempted first)
|
||||||
// hw_device_ctx: Encoder reads frames directly from system memory (better compatibility)
|
// Input mode 2, hw_frames_ctx: Frames are uploaded to a QSV surface pool in GPU memory
|
||||||
|
// (attempted when the encoder cannot be opened in input mode 1)
|
||||||
//
|
//
|
||||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
|
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
|
||||||
if (encoder.Kind == EncoderKind.QSV)
|
if (encoder.Kind == EncoderKind.QSV)
|
||||||
@@ -590,30 +603,64 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Attempt hw_frames_ctx: Pre-create GPU frame pool ──
|
if (!useFramePool)
|
||||||
// AVHWFramesContext pre-allocates frame pool in GPU VRAM,
|
{
|
||||||
// eliminating memory allocation/deallocation overhead for each frame.
|
// ── Input mode 1 (primary): hw_device_ctx + NV12 system memory ──
|
||||||
|
// The encoder receives NV12 frames in system memory (RAM) and the QSV runtime uploads them to the GPU.
|
||||||
//
|
//
|
||||||
// Pros: Zero-copy capability, highest performance
|
// pix_fmt is the software format NV12.
|
||||||
// Cons: May fail depending on GPU/driver resolution/format constraints
|
// _isQsvEncoder = false: WriteFrame() passes the NV12 frame to the encoder without uploading it.
|
||||||
// (e.g. specific texture size constraints on Intel Arc → E_INVALIDARG)
|
_pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_NV12;
|
||||||
|
_pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx);
|
||||||
|
_isQsvEncoder = false;
|
||||||
|
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Using system memory input mode (hw_device_ctx)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ── Input mode 2 (fallback): hw_frames_ctx GPU frame pool ──
|
||||||
|
// Used when the encoder cannot be opened in input mode 1.
|
||||||
|
// AVHWFramesContext pre-allocates a pool of QSV surfaces in GPU memory (pix_fmt AV_PIX_FMT_QSV),
|
||||||
|
// and WriteFrame() uploads each NV12 frame to a surface of the pool (av_hwframe_transfer_data).
|
||||||
//
|
//
|
||||||
// initial_pool_size: Number of frames to pre-allocate. Matches encoder pipeline depth.
|
// Pool creation depends on the texture types the GPU/driver accepts
|
||||||
// Align resolution to multiples of 32: Texture alignment condition required by QSV hardware.
|
// (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:
|
// Reference:
|
||||||
// AVHWFramesContext: https://ffmpeg.org/doxygen/trunk/structAVHWFramesContext.html
|
// AVHWFramesContext: https://ffmpeg.org/doxygen/trunk/structAVHWFramesContext.html
|
||||||
// av_hwframe_ctx_alloc: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.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;
|
bool framesCtxReady = false;
|
||||||
AVBufferRef* hwFramesRef = ffmpeg.av_hwframe_ctx_alloc(hwDeviceCtx);
|
int[] frameTypes = isWindows
|
||||||
if (hwFramesRef != null)
|
? 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;
|
var framesCtx = (AVHWFramesContext*)hwFramesRef->data;
|
||||||
framesCtx->format = AVPixelFormat.AV_PIX_FMT_QSV; // Hardware surface format
|
framesCtx->format = AVPixelFormat.AV_PIX_FMT_QSV; // Hardware surface format
|
||||||
framesCtx->sw_format = AVPixelFormat.AV_PIX_FMT_NV12; // Software-side format
|
framesCtx->sw_format = AVPixelFormat.AV_PIX_FMT_NV12; // Software-side format
|
||||||
framesCtx->width = (_settings.Width + 31) & ~31; // 32-multiple alignment (bitmask)
|
framesCtx->width = (_settings.Width + 31) & ~31; // 32-multiple alignment (bitmask)
|
||||||
framesCtx->height = (_settings.Height + 31) & ~31; // 32-multiple alignment (bitmask)
|
framesCtx->height = (_settings.Height + 31) & ~31; // 32-multiple alignment (bitmask)
|
||||||
framesCtx->initial_pool_size = 4; // Frame pool size
|
framesCtx->initial_pool_size = QsvFramePoolSize;
|
||||||
|
SetQsvFrameType(framesCtx, frameType);
|
||||||
|
|
||||||
err = ffmpeg.av_hwframe_ctx_init(hwFramesRef);
|
err = ffmpeg.av_hwframe_ctx_init(hwFramesRef);
|
||||||
if (err >= 0)
|
if (err >= 0)
|
||||||
@@ -623,31 +670,30 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
_pCodecContext->hw_frames_ctx = ffmpeg.av_buffer_ref(hwFramesRef);
|
_pCodecContext->hw_frames_ctx = ffmpeg.av_buffer_ref(hwFramesRef);
|
||||||
_isQsvEncoder = true;
|
_isQsvEncoder = true;
|
||||||
framesCtxReady = true;
|
framesCtxReady = true;
|
||||||
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: hw_frames_ctx initialization succeeded (GPU frame pool mode)");
|
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Using GPU frame pool input mode (hw_frames_ctx, frame_type=0x{frameType:X4}, pool={QsvFramePoolSize})");
|
||||||
|
ffmpeg.av_buffer_unref(&hwFramesRef);
|
||||||
|
|
||||||
_pHwFrame = ffmpeg.av_frame_alloc();
|
_pHwFrame = ffmpeg.av_frame_alloc();
|
||||||
if (_pHwFrame == null)
|
if (_pHwFrame == null)
|
||||||
throw new Exception("Failed to allocate QSV hardware frame structure.");
|
throw new Exception("Failed to allocate QSV hardware frame structure.");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: hw_frames_ctx failed with frame_type=0x{frameType:X4} — {GetFFmpegErrorString(err)} (Code: {err})");
|
||||||
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);
|
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 (!framesCtxReady)
|
||||||
{
|
{
|
||||||
_pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_NV12;
|
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: GPU frame pool input mode is unavailable. Testing next candidate codec.");
|
||||||
_pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx);
|
ffmpeg.av_buffer_unref(&hwDeviceCtx);
|
||||||
_isQsvEncoder = false; // System memory mode, no manual frame upload required
|
{
|
||||||
|
var pCtx = _pCodecContext;
|
||||||
|
ffmpeg.avcodec_free_context(&pCtx);
|
||||||
|
}
|
||||||
|
_pCodecContext = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer ownership of hwDeviceCtx to codec context,
|
// Transfer ownership of hwDeviceCtx to codec context,
|
||||||
@@ -709,11 +755,19 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If open fails, free codec context and try next encoder.
|
// If open fails, free codec context and QSV frame state, then try next candidate.
|
||||||
Melon<RenderMod>.Logger.Warning($"Failed to initialize hardware encoder {encoder.Name}: {GetFFmpegErrorString(openResult)} (Code: {openResult}). Testing next candidate codec.");
|
var inputMode = encoder.Kind == EncoderKind.QSV ? (useFramePool ? " (GPU frame pool input mode)" : " (system memory input mode)") : "";
|
||||||
|
Melon<RenderMod>.Logger.Warning($"Failed to initialize encoder {encoder.Name}{inputMode}: {GetFFmpegErrorString(openResult)} (Code: {openResult}). Testing next candidate.");
|
||||||
var pCodecCtx = _pCodecContext;
|
var pCodecCtx = _pCodecContext;
|
||||||
ffmpeg.avcodec_free_context(&pCodecCtx);
|
ffmpeg.avcodec_free_context(&pCodecCtx);
|
||||||
_pCodecContext = null;
|
_pCodecContext = null;
|
||||||
|
_isQsvEncoder = false;
|
||||||
|
if (_pHwFrame != null)
|
||||||
|
{
|
||||||
|
var pHwFrame = _pHwFrame;
|
||||||
|
ffmpeg.av_frame_free(&pHwFrame);
|
||||||
|
_pHwFrame = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!openedSuccessfully || _pCodecContext == null)
|
if (!openedSuccessfully || _pCodecContext == null)
|
||||||
@@ -728,6 +782,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0)
|
if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0)
|
||||||
throw new Exception("Failed to copy codec parameters to stream.");
|
throw new Exception("Failed to copy codec parameters to stream.");
|
||||||
|
|
||||||
|
// Stream time base: unit of the timestamps stored in the container.
|
||||||
|
// Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks.
|
||||||
|
// When it is left unset, the muxer uses 1/90000, in which the interval of rates such as
|
||||||
|
// 59.94 fps (1501.5 ticks), 23.976 fps (3753.75 ticks) or 165 fps (545.45 ticks) is not an integer,
|
||||||
|
// and the frame durations alternate between two values.
|
||||||
|
// The muxer may still adjust the value in avformat_write_header (e.g. mp4 scales it to at least 10000);
|
||||||
|
// packet timestamps are rescaled to the final value in SendFrameAndWritePackets.
|
||||||
|
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/mux.c (init_muxer: default 1/90000)
|
||||||
|
_pVideoStream->time_base = _pCodecContext->time_base;
|
||||||
|
|
||||||
// ── Step 4.5 (Optional): Initialize audio stream ──
|
// ── Step 4.5 (Optional): Initialize audio stream ──
|
||||||
// If EncoderSettings.Audio is set, add audio stream.
|
// If EncoderSettings.Audio is set, add audio stream.
|
||||||
if (_settings.Audio.HasValue)
|
if (_settings.Audio.HasValue)
|
||||||
@@ -760,16 +824,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
if (_pSrcFrame == null || _pDstFrame == null || _pPacket == null)
|
if (_pSrcFrame == null || _pDstFrame == null || _pPacket == null)
|
||||||
throw new Exception("Failed to allocate frame or packet structure.");
|
throw new Exception("Failed to allocate frame or packet structure.");
|
||||||
|
|
||||||
// Set format and size of destination frame, then allocate actual pixel buffer.
|
AllocateDstFrameBuffer();
|
||||||
// av_frame_get_buffer: Allocates memory matching format, width, height and
|
|
||||||
// sets data[]/linesize[] pointers. Second argument 0 means default alignment.
|
|
||||||
_pDstFrame->format = (int)_dstPixFmt;
|
|
||||||
_pDstFrame->width = _settings.Width;
|
|
||||||
_pDstFrame->height = _settings.Height;
|
|
||||||
_pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG;
|
|
||||||
|
|
||||||
if (ffmpeg.av_frame_get_buffer(_pDstFrame, 0) < 0)
|
|
||||||
throw new Exception($"Failed to allocate {_dstPixFmt} frame buffer.");
|
|
||||||
|
|
||||||
// sws_getContext: Initialize software scaler (colorspace converter).
|
// sws_getContext: Initialize software scaler (colorspace converter).
|
||||||
// Input: RGBA (Unity capture format, 4 bytes/pixel)
|
// Input: RGBA (Unity capture format, 4 bytes/pixel)
|
||||||
@@ -830,7 +885,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
/// Reference - CQP options per encoder:
|
/// Reference - CQP options per encoder:
|
||||||
/// NVENC: "qp" (single value applied to I/P/B frames)
|
/// NVENC: "qp" (single value applied to I/P/B frames)
|
||||||
/// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately)
|
/// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately)
|
||||||
/// QSV: "global_quality" (Intel unique option)
|
/// QSV: AVCodecContext.global_quality (Intel ICQ mode)
|
||||||
/// Software: "crf" (Constant Rate Factor, smarter quality control)
|
/// Software: "crf" (Constant Rate Factor, smarter quality control)
|
||||||
///
|
///
|
||||||
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
|
||||||
@@ -852,11 +907,21 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
_pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L;
|
_pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L;
|
||||||
_pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000;
|
_pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000;
|
||||||
|
|
||||||
// nal-hrd=cbr: x264/x265 dedicated option. Generates CBR bitstream matching
|
// HRD (Hypothetical Reference Decoder) signaling for CBR output, used for streaming compatibility.
|
||||||
// NAL HRD (Hypothetical Reference Decoder) specification. May be required for streaming compatibility.
|
// libx264: "nal-hrd=cbr" writes the NAL HRD information and pads filler data to keep the bitrate constant.
|
||||||
if (encoder == VideoEncoder.Libx264 || encoder == VideoEncoder.Libx265)
|
// FFmpeg's option description: "Signal HRD information (requires vbv-bufsize; cbr not allowed in .mp4)".
|
||||||
|
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/libx264.c ("nal-hrd": none/vbr/cbr)
|
||||||
|
// libx265: "x265-params" = "hrd=1" (--hrd: "Enable the signaling of HRD parameters to the decoder")
|
||||||
|
// + "strict-cbr=1" (--strict-cbr: "Enables stricter conditions to control bitrate deviance
|
||||||
|
// from the target bitrate in ABR mode").
|
||||||
|
// Reference: https://x265.readthedocs.io/en/master/cli.html
|
||||||
|
if (encoder == VideoEncoder.Libx264)
|
||||||
{
|
{
|
||||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "nal-hrd", "cbr", 0);
|
TrySetOption("nal-hrd", "cbr");
|
||||||
|
}
|
||||||
|
else if (encoder == VideoEncoder.Libx265)
|
||||||
|
{
|
||||||
|
TrySetOption("x265-params", "hrd=1:strict-cbr=1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -880,38 +945,77 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case RateControlMode.CQP:
|
case RateControlMode.CQP:
|
||||||
|
// Valid quality range per encoder. The range of the private options is read from the
|
||||||
|
// encoder's option definition (AVOption min/max); a declared minimum of -1 means "auto".
|
||||||
|
// NVENC: "qp" H.264/HEVC 0~51, AV1 0~255
|
||||||
|
// (libavcodec/nvenc_h264.c, nvenc_hevc.c: "qp" -1~51 / libavcodec/nvenc_av1.c: "qp" -1~255)
|
||||||
|
// AMF: "qp_i" H.264/HEVC 0~51, AV1 0~255
|
||||||
|
// (libavcodec/amfenc_h264.c, amfenc_hevc.c: "qp_i" -1~51 / libavcodec/amfenc_av1.c: "qp_i" -1~255)
|
||||||
|
// QSV: AVCodecContext.global_quality 1~51
|
||||||
|
// VVC: "qp" 0~63 (libavcodec/libvvenc.c: "qp" -1~63)
|
||||||
|
// Software: "crf" x264/x265 0~51 (https://x265.readthedocs.io/en/master/cli.html: --crf 0..51.0),
|
||||||
|
// VP9/AV1 0~63 (libavcodec/libvpxenc.c, libaomenc.c: "crf" -1~63 / libavcodec/libsvtav1.c: "crf" 0~63)
|
||||||
|
// Reference: https://github.com/FFmpeg/FFmpeg/tree/master/libavcodec
|
||||||
|
int qpMin, qpMax;
|
||||||
|
if (encoder.Kind == EncoderKind.QSV)
|
||||||
|
{
|
||||||
|
// QSV runs in ICQ (Intelligent Constant Quality) mode when global_quality > 0 and rc_max_rate is 0;
|
||||||
|
// global_quality 0 selects the encoder's default CQP. The ICQ quality is clamped to 1~51 by FFmpeg:
|
||||||
|
// libavcodec/qsvenc.c: q->param.mfx.ICQQuality = av_clip(avctx->global_quality, 1, 51);
|
||||||
|
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/qsvenc.c
|
||||||
|
qpMin = 1;
|
||||||
|
qpMax = 51;
|
||||||
|
}
|
||||||
|
else if (encoder.Kind == EncoderKind.NVENC) (qpMin, qpMax) = GetQualityRange("qp");
|
||||||
|
else if (encoder.Kind == EncoderKind.AMF) (qpMin, qpMax) = GetQualityRange("qp_i");
|
||||||
|
else if (encoder == VideoEncoder.LibVvenc) (qpMin, qpMax) = GetQualityRange("qp");
|
||||||
|
else (qpMin, qpMax) = GetQualityRange("crf"); // Software encoder (libx264, libx265, libvpx-vp9, etc.)
|
||||||
|
|
||||||
// Default value 18: Quality close to "visually lossless" in H.264.
|
// Default value 18: Quality close to "visually lossless" in H.264.
|
||||||
// Provides similar level of quality in other codecs as well.
|
// Provides similar level of quality in other codecs as well.
|
||||||
var qpValue = _settings.QualityValue is >= 0 and <= 51 ? _settings.QualityValue : 18;
|
const int defaultQuality = 18;
|
||||||
|
var requestedQp = _settings.QualityValue;
|
||||||
|
int qpValue;
|
||||||
|
if (requestedQp < 0)
|
||||||
|
{
|
||||||
|
qpValue = Math.Clamp(defaultQuality, qpMin, qpMax);
|
||||||
|
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Invalid quality value {requestedQp}. Using {qpValue}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
qpValue = Math.Clamp(requestedQp, qpMin, qpMax);
|
||||||
|
if (qpValue != requestedQp)
|
||||||
|
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Quality value {requestedQp} is outside the supported range {qpMin}~{qpMax}. Using {qpValue}.");
|
||||||
|
}
|
||||||
|
|
||||||
if (encoder.Kind == EncoderKind.NVENC)
|
if (encoder.Kind == EncoderKind.NVENC)
|
||||||
{
|
{
|
||||||
// NVENC: Applies same QP to I/P/B frames with single "qp" option
|
// NVENC: Applies same QP to I/P/B frames with single "qp" option
|
||||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0);
|
TrySetOption("qp", qpValue.ToString());
|
||||||
}
|
}
|
||||||
else if (encoder.Kind == EncoderKind.AMF)
|
else if (encoder.Kind == EncoderKind.AMF)
|
||||||
{
|
{
|
||||||
// AMF: QP for I-frame (qp_i) and P-frame (qp_p) must be set separately.
|
// AMF: QP for I-frame (qp_i) and P-frame (qp_p) must be set separately.
|
||||||
// Since B-frames are disabled, qp_b is not set.
|
// Since B-frames are disabled, qp_b is not set.
|
||||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_i", qpValue.ToString(), 0);
|
TrySetOption("qp_i", qpValue.ToString());
|
||||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_p", qpValue.ToString(), 0);
|
TrySetOption("qp_p", qpValue.ToString());
|
||||||
}
|
}
|
||||||
else if (encoder.Kind == EncoderKind.QSV)
|
else if (encoder.Kind == EncoderKind.QSV)
|
||||||
{
|
{
|
||||||
// QSV: Uses ICQ (Intelligent Constant Quality) mode with "global_quality" option
|
// QSV: ICQ (Intelligent Constant Quality) mode, configured with AVCodecContext.global_quality.
|
||||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "global_quality", qpValue.ToString(), 0);
|
_pCodecContext->global_quality = qpValue;
|
||||||
}
|
}
|
||||||
else if (encoder == VideoEncoder.LibVvenc)
|
else if (encoder == VideoEncoder.LibVvenc)
|
||||||
{
|
{
|
||||||
// libvvenc: Specifies Constant QP mode with "qp" option
|
// libvvenc: Specifies Constant QP mode with "qp" option
|
||||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0);
|
TrySetOption("qp", qpValue.ToString());
|
||||||
}
|
}
|
||||||
else // Software encoder (libx264, libx265, libvpx-vp9, etc.)
|
else
|
||||||
{
|
{
|
||||||
// CRF (Constant Rate Factor): Smarter quality control than QP.
|
// CRF (Constant Rate Factor): Smarter quality control than QP.
|
||||||
// Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality.
|
// Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality.
|
||||||
// Range: 0 (lossless) ~ 51 (lowest quality). Typically between 18 and 28.
|
// Range: 0 (lossless) ~ 51 (lowest quality) for x264/x265, 0~63 for VP9/AV1. Typically between 18 and 28.
|
||||||
ffmpeg.av_opt_set(_pCodecContext->priv_data, "crf", qpValue.ToString(), 0);
|
TrySetOption("crf", qpValue.ToString());
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -919,6 +1023,76 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a private option (priv_data) of the encoder.
|
||||||
|
/// Returns true when FFmpeg accepts the option. Otherwise logs a warning with the FFmpeg error and returns false.
|
||||||
|
/// </summary>
|
||||||
|
// ReSharper disable once UnusedMethodReturnValue.Local
|
||||||
|
private bool TrySetOption(string name, string value)
|
||||||
|
{
|
||||||
|
var ret = ffmpeg.av_opt_set(_pCodecContext->priv_data, name, value, 0);
|
||||||
|
if (ret >= 0) return true;
|
||||||
|
|
||||||
|
Melon<RenderMod>.Logger.Warning($"[Encoder] Option '{name}={value}' was not applied: {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MFX_MEMTYPE_VIDEO_MEMORY_DECODER_TARGET (0x0010): frames are in video memory and belong to video decoder render targets.
|
||||||
|
/// Reference: https://github.com/intel/libvpl/blob/main/api/vpl/mfxstructures.h
|
||||||
|
/// </summary>
|
||||||
|
private const int QsvMemTypeVideoMemoryDecoderTarget = 0x0010;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of surfaces in the QSV GPU frame pool (AVHWFramesContext.initial_pool_size) of GPU frame pool input mode.
|
||||||
|
/// Same value as the encoder frame pool of the FFmpeg hardware encoding example (initial_pool_size = 20).
|
||||||
|
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/doc/examples/vaapi_encode.c
|
||||||
|
/// </summary>
|
||||||
|
private const int QsvFramePoolSize = 20;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes AVQSVFramesContext.frame_type of a QSV frames context (AVHWFramesContext.hwctx).
|
||||||
|
/// FFmpeg.AutoGen has no AVQSVFramesContext type, so the field is written at its offset.
|
||||||
|
/// Field order of AVQSVFramesContext: mfxFrameSurface1* surfaces, int nb_surfaces, int frame_type, mfxFrameInfo* info
|
||||||
|
/// → frame_type is at offset sizeof(pointer) + sizeof(int) (12 bytes on 64-bit).
|
||||||
|
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.h
|
||||||
|
/// </summary>
|
||||||
|
private static void SetQsvFrameType(AVHWFramesContext* framesCtx, int frameType)
|
||||||
|
{
|
||||||
|
if (framesCtx->hwctx == null) return;
|
||||||
|
*(int*)((byte*)framesCtx->hwctx + IntPtr.Size + sizeof(int)) = frameType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Largest declared maximum that is treated as an actual quality range.
|
||||||
|
/// A declared maximum above this value is treated as unbounded, and the fallback range 0~51 is used.
|
||||||
|
/// The largest bounded quality range among the supported encoders is 0~255 (AV1 NVENC/AMF), while
|
||||||
|
/// libx264/libx265 declare "crf" as -1~FLT_MAX (libavcodec/libx264.c, libx265.c).
|
||||||
|
/// </summary>
|
||||||
|
private const double MaxMeaningfulQualityLimit = 1000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the valid range of a private quality option of the encoder from its option definition (AVOption min/max).
|
||||||
|
/// e.g. 0~51 for H.264/HEVC, 0~63 for VP9/AV1/VVC, 0~255 for AV1 NVENC/AMF.
|
||||||
|
/// The lower bound is at least 0 (a declared minimum of -1 means "auto").
|
||||||
|
/// When the option is missing or its declared maximum is unbounded (see <see cref="MaxMeaningfulQualityLimit"/>), returns 0~51,
|
||||||
|
/// the QP scale of H.264/HEVC 8-bit (x264/x265 CRF 0~51).
|
||||||
|
/// Reference: https://x265.readthedocs.io/en/master/cli.html (--crf 0..51.0)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="optionName">Name of the quality option in priv_data.</param>
|
||||||
|
private (int Min, int Max) GetQualityRange(string optionName)
|
||||||
|
{
|
||||||
|
const int fallbackMax = 51;
|
||||||
|
|
||||||
|
var option = ffmpeg.av_opt_find(_pCodecContext->priv_data, optionName, null, 0, 0);
|
||||||
|
if (option == null || !(option->max > 0 && option->max <= MaxMeaningfulQualityLimit))
|
||||||
|
return (0, fallbackMax);
|
||||||
|
|
||||||
|
var min = Math.Max(0, (int)Math.Ceiling(option->min));
|
||||||
|
var max = (int)Math.Floor(option->max);
|
||||||
|
return min <= max ? (min, max) : (0, fallbackMax);
|
||||||
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// Write Frame
|
// Write Frame
|
||||||
// ================================================================
|
// ================================================================
|
||||||
@@ -1018,12 +1192,50 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the format, size and color range of the destination frame (_pDstFrame) and allocates a new pixel buffer for it.
|
||||||
|
/// av_frame_get_buffer allocates memory matching format, width and height and sets the data[]/linesize[] pointers.
|
||||||
|
/// Second argument 0 means default alignment.
|
||||||
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_get_buffer)
|
||||||
|
/// </summary>
|
||||||
|
private void AllocateDstFrameBuffer()
|
||||||
|
{
|
||||||
|
_pDstFrame->format = (int)_dstPixFmt;
|
||||||
|
_pDstFrame->width = _settings.Width;
|
||||||
|
_pDstFrame->height = _settings.Height;
|
||||||
|
_pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG;
|
||||||
|
|
||||||
|
var ret = ffmpeg.av_frame_get_buffer(_pDstFrame, 0);
|
||||||
|
if (ret < 0)
|
||||||
|
throw new Exception($"Failed to allocate {_dstPixFmt} frame buffer — {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes the pixel buffer of the destination frame (_pDstFrame) writable before the next frame is written into it.
|
||||||
|
///
|
||||||
|
/// After avcodec_send_frame, the encoder may keep a reference to the buffer of the submitted frame
|
||||||
|
/// until that frame is encoded (e.g. QSV in system memory input mode). The buffer is then shared and not writable
|
||||||
|
/// (av_frame_is_writable returns 0).
|
||||||
|
/// In that case the frame releases its reference (av_frame_unref) and a new buffer is allocated.
|
||||||
|
/// The encoder keeps the previous buffer until it no longer needs it. The previous contents are not copied,
|
||||||
|
/// because every plane is overwritten by the next frame.
|
||||||
|
///
|
||||||
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_is_writable, av_frame_unref)
|
||||||
|
/// </summary>
|
||||||
|
private void PrepareDstFrameForWrite()
|
||||||
|
{
|
||||||
|
if (ffmpeg.av_frame_is_writable(_pDstFrame) != 0) return;
|
||||||
|
|
||||||
|
ffmpeg.av_frame_unref(_pDstFrame);
|
||||||
|
AllocateDstFrameBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Encodes a frame by directly copying already pre-converted NV12 or YUV420P frame data.
|
/// Encodes a frame by directly copying already pre-converted NV12 or YUV420P frame data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void WritePreConvertedFrame(byte* buffer)
|
private void WritePreConvertedFrame(byte* buffer)
|
||||||
{
|
{
|
||||||
ffmpeg.av_frame_make_writable(_pDstFrame);
|
PrepareDstFrameForWrite();
|
||||||
|
|
||||||
var width = _settings.Width;
|
var width = _settings.Width;
|
||||||
var height = _settings.Height;
|
var height = _settings.Height;
|
||||||
@@ -1137,20 +1349,17 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
/// Core logic for actual frame encoding.
|
/// Core logic for actual frame encoding.
|
||||||
///
|
///
|
||||||
/// Processing flow:
|
/// Processing flow:
|
||||||
/// 1. av_frame_make_writable: Check if destination frame buffer is writable
|
/// 1. PrepareDstFrameForWrite: Makes the destination frame buffer writable
|
||||||
/// (Allocates a new buffer if encoder is still referencing previous frame's buffer)
|
/// (Allocates a new buffer if encoder is still referencing previous frame's buffer)
|
||||||
/// 2. sws_scale: RGBA → YUV420P/NV12 colorspace conversion
|
/// 2. sws_scale: RGBA → YUV420P/NV12 colorspace conversion
|
||||||
/// 3. (QSV hw_frames mode) av_hwframe_transfer_data: Upload NV12 → QSV hardware surface
|
/// 3. (QSV hw_frames mode) av_hwframe_transfer_data: Upload NV12 → QSV hardware surface
|
||||||
/// 4. Pass to encoder after setting PTS
|
/// 4. Pass to encoder after setting PTS
|
||||||
///
|
///
|
||||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_make_writable)
|
|
||||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_scale)
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_scale)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void WriteFrame()
|
private void WriteFrame()
|
||||||
{
|
{
|
||||||
// Ensure writable state since encoder might still be referencing previous frame's buffer.
|
PrepareDstFrameForWrite();
|
||||||
// Internally allocates a new buffer if referenced.
|
|
||||||
ffmpeg.av_frame_make_writable(_pDstFrame);
|
|
||||||
|
|
||||||
// ── Colorspace conversion: RGBA → YUV420P or NV12 ──
|
// ── Colorspace conversion: RGBA → YUV420P or NV12 ──
|
||||||
// sws_scale parameters explanation:
|
// sws_scale parameters explanation:
|
||||||
@@ -1311,8 +1520,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
/// 2. av_write_trailer: Writes container trailer (end-of-file metadata).
|
/// 2. av_write_trailer: Writes container trailer (end-of-file metadata).
|
||||||
/// For MP4, the moov atom (index info) is completed here.
|
/// For MP4, the moov atom (index info) is completed here.
|
||||||
/// ⚠️ If trailer is not written, the file becomes corrupted and unplayable!
|
/// ⚠️ If trailer is not written, the file becomes corrupted and unplayable!
|
||||||
|
/// av_write_trailer also flushes the output buffer and returns the write error of the output (AVIOContext.error), if any.
|
||||||
|
///
|
||||||
|
/// Throws an exception when a flush or the trailer fails (e.g. the disk is full), because the file is then incomplete.
|
||||||
|
/// End() runs once: after it returns or throws, further calls return immediately.
|
||||||
///
|
///
|
||||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
|
||||||
|
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavformat/mux.c (av_write_trailer)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void End()
|
public void End()
|
||||||
{
|
{
|
||||||
@@ -1320,39 +1534,31 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
{
|
{
|
||||||
if (_disposed || !_isInitialized) return;
|
if (_disposed || !_isInitialized) return;
|
||||||
|
|
||||||
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
|
||||||
SendFrameAndWritePackets(null);
|
SendFrameAndWritePackets(null);
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Log error occurring during flush process
|
|
||||||
Melon<RenderMod>.Logger.Error($"Error during video encoder flush: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1.5. Flush remaining samples in audio FIFO + flush audio encoder
|
// 1.5. Flush remaining samples in audio FIFO + flush audio encoder
|
||||||
if (_audioEnabled)
|
if (_audioEnabled)
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
FlushAudioEncoder();
|
FlushAudioEncoder();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Melon<RenderMod>.Logger.Error($"Error during audio encoder flush: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Write container trailer (moov atom and file termination metadata)
|
// 2. Write container trailer (moov atom and file termination metadata)
|
||||||
if (_pFormatContext != null)
|
if (_pFormatContext != null)
|
||||||
{
|
{
|
||||||
ffmpeg.av_write_trailer(_pFormatContext);
|
var ret = ffmpeg.av_write_trailer(_pFormatContext);
|
||||||
|
if (ret < 0)
|
||||||
|
throw new Exception($"Failed to write the container trailer — {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
_isInitialized = false;
|
_isInitialized = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// Resource Cleanup (Dispose)
|
// Resource Cleanup (Dispose)
|
||||||
@@ -1361,11 +1567,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Actual resource cleanup logic. Cleanup order is important:
|
/// Actual resource cleanup logic. Cleanup order is important:
|
||||||
///
|
///
|
||||||
/// 1. Call End() (if not called yet) → flush delayed frames + write trailer
|
/// 1. SwsContext (scaler) → independent, can be freed first
|
||||||
/// 2. SwsContext (scaler) → independent, can be freed first
|
/// 2. AVFrame (src/dst) → freed before codec as codec may reference them
|
||||||
/// 3. AVFrame (src/dst) → freed before codec as codec may reference them
|
/// 3. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together
|
||||||
/// 4. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together
|
/// 4. AVFormatContext (format/IO) → includes closing file handle
|
||||||
/// 5. AVFormatContext (format/IO) → includes closing file handle
|
///
|
||||||
|
/// Dispose does not finish the file (no encoder flush, no trailer). A file is complete only after End() returned.
|
||||||
|
/// Without End(), the output file is incomplete and is left to the caller (the renderer deletes it).
|
||||||
///
|
///
|
||||||
/// Reference:
|
/// Reference:
|
||||||
/// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
/// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
||||||
@@ -1379,12 +1587,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
|||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
_isInitialized = false;
|
||||||
// Trigger End() safely in case Dispose is called without calling End() first
|
|
||||||
if (_isInitialized)
|
|
||||||
{
|
|
||||||
try { End(); } catch { /* Ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release Sws scaler
|
// Release Sws scaler
|
||||||
if (_pSwsContext != null)
|
if (_pSwsContext != null)
|
||||||
|
|||||||
+1
-1
@@ -140,7 +140,7 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
|
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
|
||||||
{
|
{
|
||||||
RecycleState(state);
|
RecycleState(state);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+2
-1
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using ADOFAIRenderer.Data;
|
using ADOFAIRenderer.Data;
|
||||||
using ADOFAIRenderer.Util.FFmpeg;
|
using ADOFAIRenderer.Util.FFmpeg;
|
||||||
|
using ADOFAIRenderer.Util.OS;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ internal static class GPUUtil
|
|||||||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
||||||
|
|
||||||
var nvidiaVerInt = sub % 10 * 10000 + build;
|
var nvidiaVerInt = sub % 10 * 10000 + build;
|
||||||
_cachedNvidiaVersion = nvidiaVerInt / 100f;
|
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
|
||||||
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
||||||
return _cachedNvidiaVersion;
|
return _cachedNvidiaVersion;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
namespace ADOFAIRenderer.Util.OS;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using MelonLoader;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.OS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
||||||
|
/// <para>
|
||||||
|
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
|
||||||
|
/// No PowerShell, no UWP, no WinRT dependencies.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class OSNotification
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a native desktop notification.
|
||||||
|
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
||||||
|
/// the folder when the notification is clicked/dismissed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="title">The notification title.</param>
|
||||||
|
/// <param name="message">The notification body text.</param>
|
||||||
|
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
|
||||||
|
public static void Show(string title, string message, string? folderPath = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
WindowsNotification.Send(title, message, folderPath);
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
MacNotification.Send(title, message);
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
LinuxNotification.Send(title, message, folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows notification with two strategies:
|
||||||
|
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
||||||
|
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
||||||
|
/// </summary>
|
||||||
|
private static class WindowsNotification
|
||||||
|
{
|
||||||
|
public static void Send(string title, string message, string? folderPath)
|
||||||
|
{
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!TrySendToast(title, message, folderPath))
|
||||||
|
{
|
||||||
|
SendBalloon(title, message, folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "OSNotification-Win"
|
||||||
|
};
|
||||||
|
thread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Toast via PowerShell
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to show a Windows Toast notification via PowerShell.
|
||||||
|
/// Returns true if the process launched successfully, false otherwise.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TrySendToast(string title, string message, string? folderPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
||||||
|
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
||||||
|
|
||||||
|
string toastXml;
|
||||||
|
string psScript;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
{
|
||||||
|
var escapedFolder = folderPath.Replace("'", "''");
|
||||||
|
|
||||||
|
toastXml =
|
||||||
|
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
||||||
|
"<visual><binding template=\"ToastGeneric\">" +
|
||||||
|
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||||
|
"</binding></visual>" +
|
||||||
|
"<actions>" +
|
||||||
|
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||||
|
"</actions></toast>";
|
||||||
|
|
||||||
|
psScript =
|
||||||
|
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
||||||
|
"$null = New-Item -Path $protocolPath -Force\n" +
|
||||||
|
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
||||||
|
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
||||||
|
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
||||||
|
"$null = New-Item -Path $cmdPath -Force\n" +
|
||||||
|
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||||
|
$"$xml.LoadXml('{toastXml}')\n" +
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toastXml =
|
||||||
|
"<toast><visual><binding template=\"ToastGeneric\">" +
|
||||||
|
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||||
|
"</binding></visual></toast>";
|
||||||
|
|
||||||
|
psScript =
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||||
|
$"$xml.LoadXml('{toastXml}')\n" +
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
||||||
|
string base64 = Convert.ToBase64String(bytes);
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
process.WaitForExit(5000);
|
||||||
|
|
||||||
|
// If PowerShell exited with error, treat as failure
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
string stderr = process.StandardError.ReadToEnd().Trim();
|
||||||
|
if (!string.IsNullOrEmpty(stderr))
|
||||||
|
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Balloon via Shell_NotifyIcon
|
||||||
|
|
||||||
|
// ReSharper disable InconsistentNaming
|
||||||
|
private const int NIM_ADD = 0x00;
|
||||||
|
private const int NIM_DELETE = 0x02;
|
||||||
|
|
||||||
|
private const int NIF_MESSAGE = 0x01;
|
||||||
|
private const int NIF_ICON = 0x02;
|
||||||
|
private const int NIF_TIP = 0x04;
|
||||||
|
private const int NIF_INFO = 0x10;
|
||||||
|
|
||||||
|
private const int NIIF_INFO = 0x01;
|
||||||
|
|
||||||
|
private const int WM_USER = 0x0400;
|
||||||
|
private const int WM_TRAYICON = WM_USER + 1;
|
||||||
|
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
||||||
|
|
||||||
|
private const int WS_OVERLAPPED = 0x00000000;
|
||||||
|
private const uint WM_CLOSE = 0x0010;
|
||||||
|
private const int TRAY_ICON_UID = 29471;
|
||||||
|
// ReSharper restore InconsistentNaming
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr CreateWindowExW(
|
||||||
|
int dwExStyle, string lpClassName, string lpWindowName,
|
||||||
|
int dwStyle, int x, int y, int nWidth, int nHeight,
|
||||||
|
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct NotifyIconDataW
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public IntPtr hWnd;
|
||||||
|
public int uID;
|
||||||
|
public int uFlags;
|
||||||
|
public int uCallbackMessage;
|
||||||
|
public IntPtr hIcon;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string szTip;
|
||||||
|
public int dwState;
|
||||||
|
public int dwStateMask;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||||
|
public string szInfo;
|
||||||
|
public int uTimeoutOrVersion;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||||
|
public string szInfoTitle;
|
||||||
|
public int dwInfoFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct Msg
|
||||||
|
{
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public uint message;
|
||||||
|
public IntPtr wParam;
|
||||||
|
public IntPtr lParam;
|
||||||
|
public uint time;
|
||||||
|
public int pt_x;
|
||||||
|
public int pt_y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SendBalloon(string title, string message, string? folderPath)
|
||||||
|
{
|
||||||
|
if (title.Length > 63) title = title.Substring(0, 63);
|
||||||
|
if (message.Length > 255) message = message.Substring(0, 255);
|
||||||
|
|
||||||
|
IntPtr hWnd = IntPtr.Zero;
|
||||||
|
var nid = new NotifyIconDataW();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hwndMessage = new IntPtr(-3);
|
||||||
|
hWnd = CreateWindowExW(
|
||||||
|
0, "Static", "ADOFAIRendererNotify",
|
||||||
|
WS_OVERLAPPED, 0, 0, 0, 0,
|
||||||
|
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
|
||||||
|
if (hWnd == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
||||||
|
|
||||||
|
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
||||||
|
nid.hWnd = hWnd;
|
||||||
|
nid.uID = TRAY_ICON_UID;
|
||||||
|
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
||||||
|
nid.uCallbackMessage = WM_TRAYICON;
|
||||||
|
nid.hIcon = hIcon;
|
||||||
|
nid.szTip = "ADOFAI Renderer";
|
||||||
|
nid.szInfo = message;
|
||||||
|
nid.szInfoTitle = title;
|
||||||
|
nid.dwInfoFlags = NIIF_INFO;
|
||||||
|
nid.uTimeoutOrVersion = 4;
|
||||||
|
|
||||||
|
Shell_NotifyIconW(NIM_ADD, ref nid);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
{
|
||||||
|
var capturedFolder = folderPath;
|
||||||
|
var closeTimer = new System.Threading.Timer(_ =>
|
||||||
|
{
|
||||||
|
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
||||||
|
catch { /* ignored */ }
|
||||||
|
}, null, 12000, Timeout.Infinite);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
||||||
|
{
|
||||||
|
if (msg.message == WM_CLOSE)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (msg.message == WM_TRAYICON)
|
||||||
|
{
|
||||||
|
int notifyMsg = (int)(long)msg.lParam;
|
||||||
|
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo();
|
||||||
|
psi.FileName = capturedFolder;
|
||||||
|
psi.UseShellExecute = true;
|
||||||
|
Process.Start(psi);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning(
|
||||||
|
$"[OSNotification] Cannot open folder: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TranslateMessage(ref msg);
|
||||||
|
DispatchMessageW(ref msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
closeTimer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Thread.Sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (hWnd != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
nid.uFlags = 0;
|
||||||
|
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
||||||
|
DestroyWindow(hWnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region macOS — osascript
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static class MacNotification
|
||||||
|
{
|
||||||
|
public static void Send(string title, string message)
|
||||||
|
{
|
||||||
|
// AppleScript string escaping: backslash and double-quote
|
||||||
|
string escapedTitle = EscapeAppleScript(title);
|
||||||
|
string escapedMessage = EscapeAppleScript(message);
|
||||||
|
|
||||||
|
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
||||||
|
// Each argument is passed directly to the process without shell interpretation.
|
||||||
|
var script =
|
||||||
|
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "osascript",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-e");
|
||||||
|
startInfo.ArgumentList.Add(script);
|
||||||
|
|
||||||
|
RunProcess(startInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeAppleScript(string value)
|
||||||
|
{
|
||||||
|
// In AppleScript string literals, escape backslash and double-quote
|
||||||
|
return value
|
||||||
|
.Replace("\\", "\\\\")
|
||||||
|
.Replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Linux — notify-send / gdbus
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
||||||
|
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
private static class LinuxNotification
|
||||||
|
{
|
||||||
|
public static void Send(string title, string message, string? folderPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
{
|
||||||
|
ShowWithAction(title, message, folderPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShowSimple(title, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowSimple(string title, string message)
|
||||||
|
{
|
||||||
|
// Try notify-send first
|
||||||
|
if (TryNotifySend(title, message))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Fallback to gdbus
|
||||||
|
TryGdbus(title, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowWithAction(string title, string message, string folderPath)
|
||||||
|
{
|
||||||
|
// Run on a background thread because notify-send with --action blocks
|
||||||
|
// until the notification is dismissed or the action is clicked.
|
||||||
|
System.Threading.Tasks.Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "notify-send",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-a");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||||
|
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
||||||
|
startInfo.ArgumentList.Add(title);
|
||||||
|
startInfo.ArgumentList.Add(message);
|
||||||
|
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
if (output == "open_folder")
|
||||||
|
{
|
||||||
|
var openInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "xdg-open",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
openInfo.ArgumentList.Add(folderPath);
|
||||||
|
|
||||||
|
RunProcess(openInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
||||||
|
|
||||||
|
// Fallback: show without action
|
||||||
|
ShowSimple(title, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNotifySend(string title, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "notify-send",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-a");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||||
|
startInfo.ArgumentList.Add(title);
|
||||||
|
startInfo.ArgumentList.Add(message);
|
||||||
|
|
||||||
|
RunProcess(startInfo);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryGdbus(string title, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Direct D-Bus call to org.freedesktop.Notifications
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "gdbus",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("call");
|
||||||
|
startInfo.ArgumentList.Add("--session");
|
||||||
|
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
||||||
|
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
||||||
|
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
||||||
|
startInfo.ArgumentList.Add("0"); // replaces_id
|
||||||
|
startInfo.ArgumentList.Add(""); // app_icon
|
||||||
|
startInfo.ArgumentList.Add(title); // summary
|
||||||
|
startInfo.ArgumentList.Add(message); // body
|
||||||
|
startInfo.ArgumentList.Add("[]"); // actions
|
||||||
|
startInfo.ArgumentList.Add("{}"); // hints
|
||||||
|
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
||||||
|
|
||||||
|
RunProcess(startInfo);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Shared utility
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
||||||
|
/// </summary>
|
||||||
|
private static void RunProcess(ProcessStartInfo startInfo)
|
||||||
|
{
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.OS;
|
||||||
|
|
||||||
|
internal static class RegistryUtil
|
||||||
|
{
|
||||||
|
private static readonly Type? RegistryKeyType;
|
||||||
|
private static readonly Type? RegistryHiveType;
|
||||||
|
private static readonly Type? RegistryViewType;
|
||||||
|
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
|
||||||
|
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
|
||||||
|
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
|
||||||
|
private static readonly System.Reflection.MethodInfo? GetValueMethod;
|
||||||
|
|
||||||
|
static RegistryUtil()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
||||||
|
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
||||||
|
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
||||||
|
|
||||||
|
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
||||||
|
{
|
||||||
|
var mscorlib = typeof(object).Assembly;
|
||||||
|
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
||||||
|
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
||||||
|
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RegistryKeyType != null)
|
||||||
|
{
|
||||||
|
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
||||||
|
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
||||||
|
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
||||||
|
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CloseKey(object? key)
|
||||||
|
{
|
||||||
|
if (key == null || RegistryKeyType == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (key is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var closeMethod = RegistryKeyType.GetMethod("Close");
|
||||||
|
closeMethod?.Invoke(key, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
||||||
|
{
|
||||||
|
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||||
|
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||||
|
|
||||||
|
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||||
|
if (baseKey == null) return null;
|
||||||
|
|
||||||
|
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
||||||
|
CloseKey(key);
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return subkeyNames;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static string? GetHKLMValue(string keyPath, string valueName)
|
||||||
|
{
|
||||||
|
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||||
|
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||||
|
|
||||||
|
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||||
|
if (baseKey == null) return null;
|
||||||
|
|
||||||
|
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
||||||
|
CloseKey(key);
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Threading;
|
|
||||||
using MelonLoader;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
|
||||||
/// <para>
|
|
||||||
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
|
|
||||||
/// No PowerShell, no UWP, no WinRT dependencies.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
internal static class OSNotification
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sends a native desktop notification.
|
|
||||||
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
|
||||||
/// the folder when the notification is clicked/dismissed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="title">The notification title.</param>
|
|
||||||
/// <param name="message">The notification body text.</param>
|
|
||||||
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
|
|
||||||
public static void Show(string title, string message, string? folderPath = null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
||||||
{
|
|
||||||
WindowsNotification.Send(title, message, folderPath);
|
|
||||||
}
|
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
||||||
{
|
|
||||||
MacNotification.Send(title, message);
|
|
||||||
}
|
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
|
||||||
{
|
|
||||||
LinuxNotification.Send(title, message, folderPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Windows notification with two strategies:
|
|
||||||
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
|
||||||
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
|
||||||
/// </summary>
|
|
||||||
private static class WindowsNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!TrySendToast(title, message, folderPath))
|
|
||||||
{
|
|
||||||
SendBalloon(title, message, folderPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{
|
|
||||||
IsBackground = true,
|
|
||||||
Name = "OSNotification-Win"
|
|
||||||
};
|
|
||||||
thread.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Toast via PowerShell
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to show a Windows Toast notification via PowerShell.
|
|
||||||
/// Returns true if the process launched successfully, false otherwise.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TrySendToast(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
|
||||||
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
|
||||||
|
|
||||||
string toastXml;
|
|
||||||
string psScript;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
var escapedFolder = folderPath.Replace("'", "''");
|
|
||||||
|
|
||||||
toastXml =
|
|
||||||
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
|
||||||
"<visual><binding template=\"ToastGeneric\">" +
|
|
||||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
|
||||||
"</binding></visual>" +
|
|
||||||
"<actions>" +
|
|
||||||
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
|
||||||
"</actions></toast>";
|
|
||||||
|
|
||||||
psScript =
|
|
||||||
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
|
||||||
"$null = New-Item -Path $protocolPath -Force\n" +
|
|
||||||
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
|
||||||
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
|
||||||
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
|
||||||
"$null = New-Item -Path $cmdPath -Force\n" +
|
|
||||||
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
|
||||||
$"$xml.LoadXml('{toastXml}')\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toastXml =
|
|
||||||
"<toast><visual><binding template=\"ToastGeneric\">" +
|
|
||||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
|
||||||
"</binding></visual></toast>";
|
|
||||||
|
|
||||||
psScript =
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
|
||||||
$"$xml.LoadXml('{toastXml}')\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
|
||||||
string base64 = Convert.ToBase64String(bytes);
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "powershell.exe",
|
|
||||||
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
process.WaitForExit(5000);
|
|
||||||
|
|
||||||
// If PowerShell exited with error, treat as failure
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
{
|
|
||||||
string stderr = process.StandardError.ReadToEnd().Trim();
|
|
||||||
if (!string.IsNullOrEmpty(stderr))
|
|
||||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Balloon via Shell_NotifyIcon
|
|
||||||
|
|
||||||
// ReSharper disable InconsistentNaming
|
|
||||||
private const int NIM_ADD = 0x00;
|
|
||||||
private const int NIM_DELETE = 0x02;
|
|
||||||
|
|
||||||
private const int NIF_MESSAGE = 0x01;
|
|
||||||
private const int NIF_ICON = 0x02;
|
|
||||||
private const int NIF_TIP = 0x04;
|
|
||||||
private const int NIF_INFO = 0x10;
|
|
||||||
|
|
||||||
private const int NIIF_INFO = 0x01;
|
|
||||||
|
|
||||||
private const int WM_USER = 0x0400;
|
|
||||||
private const int WM_TRAYICON = WM_USER + 1;
|
|
||||||
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
|
||||||
|
|
||||||
private const int WS_OVERLAPPED = 0x00000000;
|
|
||||||
private const uint WM_CLOSE = 0x0010;
|
|
||||||
private const int TRAY_ICON_UID = 29471;
|
|
||||||
// ReSharper restore InconsistentNaming
|
|
||||||
|
|
||||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern IntPtr CreateWindowExW(
|
|
||||||
int dwExStyle, string lpClassName, string lpWindowName,
|
|
||||||
int dwStyle, int x, int y, int nWidth, int nHeight,
|
|
||||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
||||||
private struct NotifyIconDataW
|
|
||||||
{
|
|
||||||
public int cbSize;
|
|
||||||
public IntPtr hWnd;
|
|
||||||
public int uID;
|
|
||||||
public int uFlags;
|
|
||||||
public int uCallbackMessage;
|
|
||||||
public IntPtr hIcon;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
|
||||||
public string szTip;
|
|
||||||
public int dwState;
|
|
||||||
public int dwStateMask;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
|
||||||
public string szInfo;
|
|
||||||
public int uTimeoutOrVersion;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
|
||||||
public string szInfoTitle;
|
|
||||||
public int dwInfoFlags;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct Msg
|
|
||||||
{
|
|
||||||
public IntPtr hwnd;
|
|
||||||
public uint message;
|
|
||||||
public IntPtr wParam;
|
|
||||||
public IntPtr lParam;
|
|
||||||
public uint time;
|
|
||||||
public int pt_x;
|
|
||||||
public int pt_y;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SendBalloon(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
if (title.Length > 63) title = title.Substring(0, 63);
|
|
||||||
if (message.Length > 255) message = message.Substring(0, 255);
|
|
||||||
|
|
||||||
IntPtr hWnd = IntPtr.Zero;
|
|
||||||
var nid = new NotifyIconDataW();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var hwndMessage = new IntPtr(-3);
|
|
||||||
hWnd = CreateWindowExW(
|
|
||||||
0, "Static", "ADOFAIRendererNotify",
|
|
||||||
WS_OVERLAPPED, 0, 0, 0, 0,
|
|
||||||
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
|
||||||
|
|
||||||
if (hWnd == IntPtr.Zero)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
|
||||||
|
|
||||||
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
|
||||||
nid.hWnd = hWnd;
|
|
||||||
nid.uID = TRAY_ICON_UID;
|
|
||||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
|
||||||
nid.uCallbackMessage = WM_TRAYICON;
|
|
||||||
nid.hIcon = hIcon;
|
|
||||||
nid.szTip = "ADOFAI Renderer";
|
|
||||||
nid.szInfo = message;
|
|
||||||
nid.szInfoTitle = title;
|
|
||||||
nid.dwInfoFlags = NIIF_INFO;
|
|
||||||
nid.uTimeoutOrVersion = 4;
|
|
||||||
|
|
||||||
Shell_NotifyIconW(NIM_ADD, ref nid);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
var capturedFolder = folderPath;
|
|
||||||
var closeTimer = new System.Threading.Timer(_ =>
|
|
||||||
{
|
|
||||||
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
|
||||||
catch { /* ignored */ }
|
|
||||||
}, null, 12000, Timeout.Infinite);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
|
||||||
{
|
|
||||||
if (msg.message == WM_CLOSE)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (msg.message == WM_TRAYICON)
|
|
||||||
{
|
|
||||||
int notifyMsg = (int)(long)msg.lParam;
|
|
||||||
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var psi = new ProcessStartInfo();
|
|
||||||
psi.FileName = capturedFolder;
|
|
||||||
psi.UseShellExecute = true;
|
|
||||||
Process.Start(psi);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning(
|
|
||||||
$"[OSNotification] Cannot open folder: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TranslateMessage(ref msg);
|
|
||||||
DispatchMessageW(ref msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
closeTimer.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Thread.Sleep(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (hWnd != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
nid.uFlags = 0;
|
|
||||||
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
|
||||||
DestroyWindow(hWnd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region macOS — osascript
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
|
||||||
/// </summary>
|
|
||||||
private static class MacNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message)
|
|
||||||
{
|
|
||||||
// AppleScript string escaping: backslash and double-quote
|
|
||||||
string escapedTitle = EscapeAppleScript(title);
|
|
||||||
string escapedMessage = EscapeAppleScript(message);
|
|
||||||
|
|
||||||
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
|
||||||
// Each argument is passed directly to the process without shell interpretation.
|
|
||||||
var script =
|
|
||||||
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "osascript",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-e");
|
|
||||||
startInfo.ArgumentList.Add(script);
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EscapeAppleScript(string value)
|
|
||||||
{
|
|
||||||
// In AppleScript string literals, escape backslash and double-quote
|
|
||||||
return value
|
|
||||||
.Replace("\\", "\\\\")
|
|
||||||
.Replace("\"", "\\\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Linux — notify-send / gdbus
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
|
||||||
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
private static class LinuxNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
ShowWithAction(title, message, folderPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ShowSimple(title, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ShowSimple(string title, string message)
|
|
||||||
{
|
|
||||||
// Try notify-send first
|
|
||||||
if (TryNotifySend(title, message))
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Fallback to gdbus
|
|
||||||
TryGdbus(title, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ShowWithAction(string title, string message, string folderPath)
|
|
||||||
{
|
|
||||||
// Run on a background thread because notify-send with --action blocks
|
|
||||||
// until the notification is dismissed or the action is clicked.
|
|
||||||
System.Threading.Tasks.Task.Run(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "notify-send",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-a");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
|
||||||
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
|
||||||
startInfo.ArgumentList.Add(title);
|
|
||||||
startInfo.ArgumentList.Add(message);
|
|
||||||
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
|
||||||
process.WaitForExit();
|
|
||||||
|
|
||||||
if (output == "open_folder")
|
|
||||||
{
|
|
||||||
var openInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "xdg-open",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
openInfo.ArgumentList.Add(folderPath);
|
|
||||||
|
|
||||||
RunProcess(openInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
|
||||||
|
|
||||||
// Fallback: show without action
|
|
||||||
ShowSimple(title, message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryNotifySend(string title, string message)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "notify-send",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-a");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
|
||||||
startInfo.ArgumentList.Add(title);
|
|
||||||
startInfo.ArgumentList.Add(message);
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TryGdbus(string title, string message)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Direct D-Bus call to org.freedesktop.Notifications
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "gdbus",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("call");
|
|
||||||
startInfo.ArgumentList.Add("--session");
|
|
||||||
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
|
||||||
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
|
||||||
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
|
||||||
startInfo.ArgumentList.Add("0"); // replaces_id
|
|
||||||
startInfo.ArgumentList.Add(""); // app_icon
|
|
||||||
startInfo.ArgumentList.Add(title); // summary
|
|
||||||
startInfo.ArgumentList.Add(message); // body
|
|
||||||
startInfo.ArgumentList.Add("[]"); // actions
|
|
||||||
startInfo.ArgumentList.Add("{}"); // hints
|
|
||||||
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Shared utility
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
|
||||||
/// </summary>
|
|
||||||
private static void RunProcess(ProcessStartInfo startInfo)
|
|
||||||
{
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
|
||||||
{
|
|
||||||
internal static class RegistryUtil
|
|
||||||
{
|
|
||||||
private static readonly Type? RegistryKeyType;
|
|
||||||
private static readonly Type? RegistryHiveType;
|
|
||||||
private static readonly Type? RegistryViewType;
|
|
||||||
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
|
|
||||||
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
|
|
||||||
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
|
|
||||||
private static readonly System.Reflection.MethodInfo? GetValueMethod;
|
|
||||||
|
|
||||||
static RegistryUtil()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
|
||||||
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
|
||||||
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
|
||||||
|
|
||||||
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
|
||||||
{
|
|
||||||
var mscorlib = typeof(object).Assembly;
|
|
||||||
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
|
||||||
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
|
||||||
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RegistryKeyType != null)
|
|
||||||
{
|
|
||||||
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
|
||||||
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
|
||||||
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
|
||||||
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CloseKey(object? key)
|
|
||||||
{
|
|
||||||
if (key == null || RegistryKeyType == null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (key is IDisposable disposable)
|
|
||||||
{
|
|
||||||
disposable.Dispose();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var closeMethod = RegistryKeyType.GetMethod("Close");
|
|
||||||
closeMethod?.Invoke(key, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
|
||||||
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
|
||||||
{
|
|
||||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
|
||||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
|
||||||
|
|
||||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
|
||||||
if (baseKey == null) return null;
|
|
||||||
|
|
||||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
|
||||||
if (key == null)
|
|
||||||
{
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
|
||||||
CloseKey(key);
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return subkeyNames;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
|
||||||
public static string? GetHKLMValue(string keyPath, string valueName)
|
|
||||||
{
|
|
||||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
|
||||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
|
||||||
|
|
||||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
|
||||||
if (baseKey == null) return null;
|
|
||||||
|
|
||||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
|
||||||
if (key == null)
|
|
||||||
{
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
|
||||||
CloseKey(key);
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
||||||
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
||||||
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
|
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
|
||||||
|
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
private enum GPUMode
|
private enum GPUMode
|
||||||
@@ -189,6 +190,14 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
||||||
var targetVideoTime = songTime + vidOffset;
|
var targetVideoTime = songTime + vidOffset;
|
||||||
|
|
||||||
|
// When looping is enabled, the playback position is the elapsed time wrapped into [0, Duration).
|
||||||
|
// When it is disabled, the position keeps increasing and the last frame stays on screen after the end.
|
||||||
|
var videoDuration = _decoder.Duration;
|
||||||
|
if (_isLooping && targetVideoTime >= 0 && videoDuration > 0)
|
||||||
|
{
|
||||||
|
targetVideoTime %= videoDuration;
|
||||||
|
}
|
||||||
|
|
||||||
if (targetVideoTime < 0)
|
if (targetVideoTime < 0)
|
||||||
{
|
{
|
||||||
if (_targetTexture)
|
if (_targetTexture)
|
||||||
@@ -307,6 +316,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_computeShader.SetInt(Width, w);
|
_computeShader.SetInt(Width, w);
|
||||||
_computeShader.SetInt(Height, h);
|
_computeShader.SetInt(Height, h);
|
||||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||||
|
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||||
|
|
||||||
int threadGroupsX = (w + 7) / 8;
|
int threadGroupsX = (w + 7) / 8;
|
||||||
int threadGroupsY = (h + 7) / 8;
|
int threadGroupsY = (h + 7) / 8;
|
||||||
@@ -341,6 +351,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_computeShader.SetInt(Width, w);
|
_computeShader.SetInt(Width, w);
|
||||||
_computeShader.SetInt(Height, h);
|
_computeShader.SetInt(Height, h);
|
||||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||||
|
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||||
|
|
||||||
int threadGroupsX = (w + 7) / 8;
|
int threadGroupsX = (w + 7) / 8;
|
||||||
int threadGroupsY = (h + 7) / 8;
|
int threadGroupsY = (h + 7) / 8;
|
||||||
|
|||||||
Reference in new issue
Block a user