Author SHA256 Message Date
copecone 72d8d71928 Upgrade Version 2026-10-11 20:26:41 +09:00
copecone 413778e165 Fix Decoder Forward Seek Returning One Frame Ahead 2026-10-11 20:24:11 +09:00
copecone 36552511f5 Fix Decoder Invalid Packet Handling 2026-10-11 19:59:22 +09:00
copecone a1f9fa36ce Fix Video Stream Time Base Causing Frame Duration Jitter 2026-10-11 19:47:55 +09:00
copecone 00971a6730 Fix Encoder Quality Options Not Being Applied 2026-10-11 18:13:42 +09:00
copecone bd24152a2e Move NTSC Handling 2026-10-11 16:45:18 +09:00
copecone 7aa8ae4b54 Move OS-dependent Utilities Into OS Package 2026-10-11 16:44:04 +09:00
copecone dee3b2dbcf Fix Render State Cleanup on Initialization Failure 2026-10-11 16:08:00 +09:00
copecone 7eec3b4660 Fix Render Freeze on Encoder Thread Failure 2026-10-11 15:46:52 +09:00
copecone ce70d89cdb Fix Potential Floating Point Error in NVIDIA Graphics Driver Version Detection 2026-10-11 12:54:18 +09:00
copecone 7db1905906 Add .mcp.json to .gitignore 2026-10-10 22:30:26 +09:00
copecone bfc79ee4d3 Fix Label Generation Condition 2026-10-10 22:25:45 +09:00
copecone b4c950c9f7 Upgrade Version 2026-10-10 16:38:12 +09:00
copecone 723f4446f5 Add Two-Step Confirmation for Preset Deletion in RenderConfigWindow 2026-10-10 16:28:00 +09:00
copecone 6f4c632b41 Implement Dynamic Localization in RenderConfigWindow 2026-10-10 16:25:15 +09:00
copecone 863dcd2357 Utilize O5Kit System in Warning and Download Dialogs 2026-10-10 15:57:40 +09:00
copecone 00048b7b06 Upgrade Version 2026-10-10 02:01:12 +09:00
20 changed files with 1857 additions and 1449 deletions

No files matched your search

+10 -4
View File
@@ -32,9 +32,11 @@ Thumbs.db
desktop.ini desktop.ini
## Agent Metadata ## Agent Metadata
.antigravitycli/ /.antigravitycli/
.agent/ /.agent/
GEMINI.md /GEMINI.md
/.mcp.json
/CLAUDE.md
# Decompiled Game Code # Decompiled Game Code
*.cs.txt *.cs.txt
@@ -42,4 +44,8 @@ GEMINI.md
# Developer Description Files # Developer Description Files
/AsyncInputAutoplayFix.md /AsyncInputAutoplayFix.md
/AudioMixerExplanation.md /AudioMixerExplanation.md
/O5Theme.md /O5Theme.md
# Developer Utility Files
/REVIEW_CHECKLIST.md
/REVIEW_FFMPEG.md
+121 -27
View File
@@ -14,6 +14,7 @@ using ADOFAIRenderer.UI.Window;
using ADOFAIRenderer.Util; using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.FFmpeg; using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using FFmpeg.AutoGen; using FFmpeg.AutoGen;
using HarmonyLib; using HarmonyLib;
using MelonLoader; using MelonLoader;
@@ -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
@@ -195,12 +240,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();
@@ -283,8 +331,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 +388,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 +423,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 +464,6 @@ internal class RenderManager : MonoBehaviour
Application.wantsToQuit -= quitDelegate; Application.wantsToQuit -= quitDelegate;
RenderConfigWindow.CreateWindow(); RenderConfigWindow.CreateWindow();
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
}); });
} }
@@ -439,7 +499,10 @@ 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 +525,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 +581,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();
@@ -632,14 +702,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 +725,17 @@ 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) videoFFmpeg.End();
videoFFmpeg.Dispose(); videoFFmpeg.Dispose();
lock (_allVideoFFmpegInstances) lock (_allVideoFFmpegInstances)
@@ -674,12 +744,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}");
videoFFmpeg.Dispose();
MarkFailed("encoder thread error. See the log for details.");
try
{
videoFFmpeg.Dispose();
}
catch (Exception disposeEx)
{
Melon<RenderMod>.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}");
}
lock (_allVideoFFmpegInstances) lock (_allVideoFFmpegInstances)
{ {
_allVideoFFmpegInstances.Remove(videoFFmpeg); _allVideoFFmpegInstances.Remove(videoFFmpeg);
@@ -708,7 +788,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 +802,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 })
@@ -796,15 +877,20 @@ internal class RenderManager : MonoBehaviour
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 +899,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 +942,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)
+1
View File
@@ -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",
+2 -1
View File
@@ -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
View File
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
using UnityEngine; using UnityEngine;
using Object = UnityEngine.Object; using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.4-INDEV", "ADOFAI Editor Forum")] [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.7-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;
+94 -168
View File
@@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using MelonLoader;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using UnityEngine.UIElements; using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button;
using Image = UnityEngine.UI.Image; using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI; namespace ADOFAIRenderer.UI;
@@ -29,6 +31,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
{ {
public static CommonWarningDialogUI? Instance { get; private set; } public static CommonWarningDialogUI? Instance { get; private set; }
private readonly List<VisualElement> _disabledElements = new(); private readonly List<VisualElement> _disabledElements = new();
private O5Window? _window;
private void Awake() private void Awake()
{ {
@@ -37,6 +40,9 @@ internal class CommonWarningDialogUI : MonoBehaviour
private void OnDestroy() private void OnDestroy()
{ {
_window?.Dispose();
_window = null;
foreach (var elem in _disabledElements) foreach (var elem in _disabledElements)
{ {
if (elem != null) if (elem != null)
@@ -83,202 +89,122 @@ internal class CommonWarningDialogUI : MonoBehaviour
// Set UI layer (5) // Set UI layer (5)
gameObject.layer = 5; gameObject.layer = 5;
// 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);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f; scaler.matchWidthOrHeight = 0.5f;
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>();
bgRect.anchorMin = Vector2.zero; bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one; bgRect.anchorMax = Vector2.one;
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
// 2. Create modal container {
var modalContainerObj = new GameObject("ModalContainer"); Id = "RendererMod_WarningDialog",
modalContainerObj.transform.SetParent(bgPanelObj.transform, false); Title = title,
modalContainerObj.layer = 5; Size = new Vector2(540, 240),
Draggable = true,
var modalImage = modalContainerObj.AddComponent<Image>(); Resizable = false,
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI ShowCloseButton = false,
ShowOutline = true,
var modalOutline = modalContainerObj.AddComponent<Outline>(); Padding = new RectOffset(16, 16, 16, 16)
modalOutline.effectColor = Color.white; });
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
// 3. Build content inside window.Content
var modalRect = modalContainerObj.GetComponent<RectTransform>(); var content = _window.Content;
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f); var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
modalRect.pivot = new Vector2(0.5f, 0.5f); vLayout.spacing = 16f;
modalRect.sizeDelta = new Vector2(650, 360); vLayout.padding = new RectOffset(16, 16, 12, 12);
vLayout.childControlWidth = true;
// 3. Create title text vLayout.childControlHeight = true;
var titleObj = new GameObject("TitleText"); vLayout.childForceExpandWidth = true;
titleObj.transform.SetParent(modalContainerObj.transform, false); vLayout.childForceExpandHeight = false;
titleObj.layer = 5;
// Description text
var titleText = titleObj.AddComponent<TextMeshProUGUI>(); var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
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;
var descRect = descObj.GetComponent<RectTransform>(); descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120);
// 5. Create button container
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
buttonContainerObj.layer = 5;
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 6. Dynamic button layout and styling // ReSharper disable once InconsistentNaming
float totalWidth = 560f; var descLE = descText.gameObject.AddComponent<LayoutElement>();
float spacing = 15f; descLE.flexibleWidth = 1f;
float buttonHeight = 45f; descLE.flexibleHeight = 1f;
int numButtons = buttons.Length;
float buttonWidth = numButtons == 1 // Button action row
? 200f var btnRowObj = new GameObject("ButtonRow");
: (totalWidth - (spacing * (numButtons - 1))) / numButtons; btnRowObj.transform.SetParent(content, false);
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
btnRowLE.preferredHeight = 40f;
btnRowLE.flexibleWidth = 1f;
float startX = numButtons == 1 var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
? 0 hLayout.spacing = 10f;
: (-totalWidth / 2f) + (buttonWidth / 2f); hLayout.childAlignment = TextAnchor.MiddleCenter;
hLayout.childControlWidth = true;
hLayout.childControlHeight = true;
hLayout.childForceExpandWidth = true;
hLayout.childForceExpandHeight = true;
for (int i = 0; i < numButtons; i++) for (var i = 0; i < buttons.Length; i++)
{ {
var btnData = buttons[i]; var btnData = buttons[i];
var btnObj = new GameObject($"Button_{i}"); var btn = O5Factory.Button(
btnObj.transform.SetParent(buttonContainerObj.transform, false); ctx: ctx,
btnObj.layer = 5; parent: btnRowRect,
onClick: () =>
{
try
{
btnData.OnClick();
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
},
text: btnData.Text,
id: $"btn_dialog_{i}",
height: 40f
);
// Define default colors if (btnData.BackgroundColor.HasValue)
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
var btnImage = btnObj.AddComponent<Image>();
btnImage.color = baseColor;
btnImage.raycastTarget = true;
var btn = btnObj.AddComponent<Button>();
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
// Register click callback
btn.onClick.AddListener(() =>
{ {
try btn.NormalColor = btnData.BackgroundColor.Value;
{ btn.UpdateVisual();
btnData.OnClick(); }
}
catch (Exception ex)
{
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
});
var btnRect = btnObj.GetComponent<RectTransform>();
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 le = btn.Rect.gameObject.AddComponent<LayoutElement>();
var txtObj = new GameObject("Text"); le.flexibleWidth = 1f;
txtObj.transform.SetParent(btnObj.transform, false); le.preferredHeight = 40f;
txtObj.layer = 5;
var text = txtObj.AddComponent<TextMeshProUGUI>();
text.text = btnData.Text;
text.fontSize = 20;
text.font = font;
text.color = Color.white;
text.alignment = TextAlignmentOptions.Center;
var txtRect = txtObj.GetComponent<RectTransform>();
txtRect.anchorMin = Vector2.zero;
txtRect.anchorMax = Vector2.one;
txtRect.offsetMin = Vector2.zero;
txtRect.offsetMax = Vector2.zero;
} }
} }
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
{
button.transition = Selectable.Transition.ColorTint;
var colors = button.colors;
colors.normalColor = normalColor;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
}
private Color ApplyColorBrightness(Color color, float factor)
{
float r = Mathf.Clamp01(color.r * factor);
float g = Mathf.Clamp01(color.g * factor);
float b = Mathf.Clamp01(color.b * factor);
return new Color(r, g, b, color.a);
}
} }
+149 -207
View File
@@ -4,11 +4,13 @@ using System.IO.Compression;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using MelonLoader;
using MelonLoader.Utils;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using MelonLoader;
using MelonLoader.Utils;
namespace ADOFAIRenderer.UI; namespace ADOFAIRenderer.UI;
@@ -22,6 +24,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
private GameObject _statusTextObj = null!; private GameObject _statusTextObj = null!;
private TextMeshProUGUI _statusText = null!; private TextMeshProUGUI _statusText = null!;
private O5Window? _window;
private bool _isDownloading; private bool _isDownloading;
private string DownloadUrl => Application.platform switch private string DownloadUrl => Application.platform switch
{ {
@@ -103,14 +106,16 @@ internal class FFmpegDownloadUI : MonoBehaviour
scaler.matchWidthOrHeight = 0.5f; scaler.matchWidthOrHeight = 0.5f;
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
// 2. Create modal container {
var modalContainerObj = new GameObject("ModalContainer"); Id = "RendererMod_FFmpegDownloadDialog",
modalContainerObj.transform.SetParent(bgPanelObj.transform, false); Title = "FFmpeg Library Installation Required",
modalContainerObj.layer = 5; Size = new Vector2(580, 270),
Draggable = true,
var modalImage = modalContainerObj.AddComponent<Image>(); Resizable = false,
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); ShowCloseButton = false,
ShowOutline = true,
var modalOutline = modalContainerObj.AddComponent<Outline>(); Padding = new RectOffset(16, 16, 16, 16)
modalOutline.effectColor = Color.white; });
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
var content = _window.Content;
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f); var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
modalRect.anchorMax = new Vector2(0.5f, 0.5f); vLayout.spacing = 14f;
modalRect.pivot = new Vector2(0.5f, 0.5f); vLayout.padding = new RectOffset(16, 16, 10, 10);
modalRect.sizeDelta = new Vector2(600, 320); vLayout.childControlWidth = true;
vLayout.childControlHeight = true;
// 4. Create title text vLayout.childForceExpandWidth = true;
var titleObj = new GameObject("TitleText"); vLayout.childForceExpandHeight = false;
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5; // 3. Description text
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
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,197 +161,152 @@ 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;
var descRect = descObj.GetComponent<RectTransform>(); descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -60);
descRect.sizeDelta = new Vector2(-40, 100);
// 6. Create button container
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
_buttonContainerObj.layer = 5;
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 7. Create download button
var downloadBtnObj = new GameObject("DownloadButton");
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
downloadBtnObj.layer = 5;
var downloadImg = downloadBtnObj.AddComponent<Image>();
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
downloadImg.raycastTarget = true;
var downloadBtn = downloadBtnObj.AddComponent<Button>();
SetupButtonStyle(downloadBtn, new Color(0.0f, 0.8f, 1.0f, 1.0f), new Color(0.2f, 0.9f, 1.0f, 1.0f), new Color(0.0f, 0.6f, 0.8f, 1.0f));
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess);
else
{
downloadBtn.onClick.AddListener(OpenFFmpegWebsite);
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }
}
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>(); var descLE = descText.gameObject.AddComponent<LayoutElement>();
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f); descLE.flexibleWidth = 1f;
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f); descLE.flexibleHeight = 1f;
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
downloadBtnRect.anchoredPosition = new Vector2(-15, 0); // 4. Button container
downloadBtnRect.sizeDelta = new Vector2(180, 45); _buttonContainerObj = new GameObject("ButtonContainer");
_buttonContainerObj.transform.SetParent(content, false);
var downloadTextObj = new GameObject("DownloadText"); _buttonContainerObj.layer = 5;
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
downloadTextObj.layer = 5; var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
btnContainerLE.preferredHeight = 40f;
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>(); btnContainerLE.flexibleWidth = 1f;
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
downloadText.fontSize = 20; var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
downloadText.font = font; btnLayout.spacing = 10f;
downloadText.color = Color.white; btnLayout.childAlignment = TextAnchor.MiddleCenter;
downloadText.alignment = TextAlignmentOptions.Center; btnLayout.childControlWidth = true;
btnLayout.childControlHeight = true;
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>(); btnLayout.childForceExpandWidth = true;
downloadTextRect.anchorMin = Vector2.zero; btnLayout.childForceExpandHeight = true;
downloadTextRect.anchorMax = Vector2.one;
downloadTextRect.offsetMin = Vector2.zero; Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
downloadTextRect.offsetMax = Vector2.zero; ? StartDownloadProcess
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
// 8. Create cancel button
var cancelBtnObj = new GameObject("CancelButton"); var downloadBtn = O5Factory.Button(
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false); ctx: ctx,
cancelBtnObj.layer = 5; parent: _buttonContainerObj.transform,
onClick: onDownloadClick,
var cancelImg = cancelBtnObj.AddComponent<Image>(); text: Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website",
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f); id: "btn_ffmpeg_download",
cancelImg.raycastTarget = true; height: 40f
);
var cancelBtn = cancelBtnObj.AddComponent<Button>(); downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
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)); downloadBtn.UpdateVisual();
cancelBtn.onClick.AddListener(CancelProcess);
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>(); downloadBtnLE.flexibleWidth = 1f;
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f); downloadBtnLE.preferredHeight = 40f;
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
cancelBtnRect.pivot = new Vector2(0f, 0.5f); var cancelBtn = O5Factory.Button(
cancelBtnRect.anchoredPosition = new Vector2(15, 0); ctx: ctx,
cancelBtnRect.sizeDelta = new Vector2(180, 45); parent: _buttonContainerObj.transform,
onClick: CancelProcess,
var cancelTextObj = new GameObject("CancelText"); text: "Cancel",
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false); id: "btn_ffmpeg_cancel",
cancelTextObj.layer = 5; height: 40f
);
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>(); var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
cancelText.text = "Cancel"; cancelBtnLE.flexibleWidth = 1f;
cancelText.fontSize = 20; cancelBtnLE.preferredHeight = 40f;
cancelText.font = font;
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f); // 5. Progress bar container
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;
var barBgOutline = _progressBarObj.AddComponent<Outline>(); barBgImg.color = ctx.Theme.ObjectBG;
barBgOutline.effectColor = Color.white;
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f); var barOutlineObj = new GameObject("BarOutline");
barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
var barRect = _progressBarObj.GetComponent<RectTransform>(); barOutlineObj.layer = 5;
barRect.anchorMin = new Vector2(0.5f, 0);
barRect.anchorMax = new Vector2(0.5f, 0); var barOutlineImg = barOutlineObj.AddComponent<Image>();
barRect.pivot = new Vector2(0.5f, 0); barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
barRect.anchoredPosition = new Vector2(0, 75); barOutlineImg.type = Image.Type.Sliced;
barRect.sizeDelta = new Vector2(480, 24); barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
barOutlineImg.raycastTarget = false;
// 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);
_fillRect.anchorMax = new Vector2(0, 1); _fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f); _fillRect.pivot = new Vector2(0, 0.5f);
_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;
progressTextRect.anchorMax = Vector2.one; progressTextRect.anchorMax = Vector2.one;
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>();
statusRect.anchorMin = new Vector2(0, 0);
statusRect.anchorMax = new Vector2(1, 0);
statusRect.pivot = new Vector2(0.5f, 0);
statusRect.anchoredPosition = new Vector2(0, 35);
statusRect.sizeDelta = new Vector2(0, 30);
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor) var statusRect = _statusTextObj.GetComponent<RectTransform>();
{ statusRect.anchorMin = Vector2.zero;
button.transition = Selectable.Transition.ColorTint; statusRect.anchorMax = Vector2.one;
var colors = button.colors; statusRect.offsetMin = Vector2.zero;
colors.normalColor = normalColor; statusRect.offsetMax = Vector2.zero;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
} }
private void Update() private void Update()
@@ -643,6 +583,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
private void OnDestroy() private void OnDestroy()
{ {
_window?.Dispose();
_window = null;
_cts?.Dispose(); _cts?.Dispose();
if (Instance == this) if (Instance == this)
{ {
+112
View File
@@ -0,0 +1,112 @@
using System;
using ADOFAIRenderer.Localization;
using TMPro;
using UnityEngine;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
/// automatically updating the displayed text whenever the active language changes.
/// </summary>
internal class LocalizedText : MonoBehaviour
{
private TextMeshProUGUI? _textComponent;
private string? _key;
private Func<string>? _dynamicProvider;
/// <summary>
/// Gets or sets the localization key. Setting this will clear any dynamic provider.
/// </summary>
public string? Key
{
get => _key;
set
{
_key = value;
_dynamicProvider = null;
Refresh();
}
}
/// <summary>
/// Sets a dynamic string provider function.
/// </summary>
public void SetDynamic(Func<string> provider)
{
_dynamicProvider = provider;
_key = null;
Refresh();
}
private void Awake()
{
EnsureComponent();
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
}
private void OnEnable()
{
Refresh();
}
private void OnDestroy()
{
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
}
private void HandleLanguageChanged(SystemLanguage language)
{
Refresh();
}
private void EnsureComponent()
{
if (_textComponent == null)
{
_textComponent = GetComponent<TextMeshProUGUI>();
}
}
/// <summary>
/// Refreshes the text using either the dynamic provider or the static localization key.
/// </summary>
public void Refresh()
{
EnsureComponent();
if (_textComponent == null) return;
if (_dynamicProvider != null)
{
_textComponent.text = _dynamicProvider();
}
else if (!string.IsNullOrEmpty(_key))
{
_textComponent.text = LocalizationManager.Get(_key);
}
}
/// <summary>
/// Binds a TextMeshProUGUI component to a localization key.
/// </summary>
public static LocalizedText Bind(TextMeshProUGUI target, string key)
{
if (target == null) throw new ArgumentNullException(nameof(target));
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
component._textComponent = target;
component.Key = key;
return component;
}
/// <summary>
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
/// </summary>
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
{
if (target == null) throw new ArgumentNullException(nameof(target));
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
component._textComponent = target;
component.SetDynamic(provider);
return component;
}
}
+394 -182
View File
@@ -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>();
+1 -1
View File
@@ -108,7 +108,7 @@ internal static class AudioSpectrumGenerator
return; return;
} }
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1; var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums) lock (CachedSpectrums)
{ {
-5
View File
@@ -15,11 +15,6 @@ internal class AudioSyncCalculator
{ {
_sampleRate = sampleRate; _sampleRate = sampleRate;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps); _fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
// NTSC
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
} }
public void Reset() public void Reset()
+143 -120
View File
@@ -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
@@ -371,6 +378,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 +458,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 +676,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 +693,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)}");
@@ -1212,61 +1247,94 @@ 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; /// <summary>
if (_hasCachedFrame) /// Decodes forward from the current decoder position until the first frame whose presentation time is at least
/// <paramref name="timeSeconds"/> minus half a frame, and keeps that frame in <see cref="_pCachedFrame"/>
/// for the next <c>TryRead*</c> call.
/// Frames before the target are discarded without GPU-to-host memory transfer; only the target frame is transferred.
/// When the end of the stream is reached before the target, no frame is cached.
/// </summary>
/// <param name="timeSeconds">Target time in seconds.</param>
private void DecodeToTarget(double timeSeconds)
{
while (true)
{
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
if (ret == 0)
{ {
ffmpeg.av_frame_unref(_pCachedFrame); double currentPts = GetFrameTimestamp(_pDecodedFrame);
_hasCachedFrame = false;
skipped++; // Check with half-frame margin to accommodate floating point rounding
if (currentPts >= timeSeconds - (0.5 / FPS))
{
// Cache the first matching frame in system RAM for immediate consumption
if (_isHwDecoding && _pDecodedFrame->format == (int)_hwPixFmt)
{
ffmpeg.av_frame_unref(_pCachedFrame);
int transferRet = ffmpeg.av_hwframe_transfer_data(_pCachedFrame, _pDecodedFrame, 0);
if (transferRet < 0)
{
ffmpeg.av_frame_unref(_pDecodedFrame);
throw new Exception($"Failed to transfer cached hardware frame during seek: {GetFFmpegErrorString(transferRet)}");
}
ffmpeg.av_frame_copy_props(_pCachedFrame, _pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
_downloadedPixFmt = (AVPixelFormat)_pCachedFrame->format;
}
else
{
ffmpeg.av_frame_unref(_pCachedFrame);
ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame);
}
_hasCachedFrame = true;
return;
}
// Discard non-matching intermediate frame without transferring to CPU
GetTimestampAndUpdateIndex(_pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
continue;
} }
while (skipped < count) if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{ {
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); if (_isDraining) return;
if (ret == 0)
{
GetTimestampAndUpdateIndex(_pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
skipped++;
continue;
}
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN)) if (!ReadAndSendPacket())
{ {
if (_isDraining) break; _isDraining = true;
ffmpeg.avcodec_send_packet(_pCodecContext, null);
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)}");
} }
} }
else if (ret == ffmpeg.AVERROR_EOF)
{
return;
}
else
{
throw new Exception($"Error during seek decoding: {GetFFmpegErrorString(ret)}");
}
} }
} }
/// <summary> /// <summary>
/// Seeks to a specific playback time in seconds using backward keyframe seek followed by forward decode. /// Moves the playback position to a time in seconds.
/// Intermediate frames are discarded without GPU-to-host memory transfer to maximize seek speed; /// The next <c>TryRead*</c> call returns the first frame whose presentation time is at least
/// only the target frame is transferred and preserved in <see cref="_pCachedFrame"/>. /// <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> /// </summary>
/// <param name="timeSeconds">Target time in seconds to seek to.</param> /// <param name="timeSeconds">Target time in seconds to seek to.</param>
public void SeekToTime(double timeSeconds) public void SeekToTime(double timeSeconds)
@@ -1276,13 +1344,38 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
if (!_isOpened || _disposed) return; if (!_isOpened || _disposed) return;
if (timeSeconds < 0) timeSeconds = 0; 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 // Earliest presentation time that satisfies the target (target minus half a frame).
bool useSkip = targetFrameIndex >= _currentFrameIndex && targetFrameIndex - _currentFrameIndex < 60; double threshold = timeSeconds - (0.5 / FPS);
if (useSkip)
// Index of the first frame that satisfies the target. The epsilon keeps a frame that lies exactly on the
// margin from being excluded by floating point rounding.
double targetFrameIndex = Math.Ceiling(timeSeconds * FPS - 0.5 - 1e-6);
if (_hasCachedFrame)
{ {
SkipFrames((int)(targetFrameIndex - _currentFrameIndex)); // A frame is already positioned for the next read (e.g. by a previous SeekToTime call).
double cachedPts = GetFrameTimestamp(_pCachedFrame);
if (cachedPts >= threshold)
{
// The cached frame is the first frame that satisfies the target.
// When the target lies before it, the frames in between are no longer available and a keyframe seek is required.
if (cachedPts - threshold < 1.0 / FPS) return;
}
else if (targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
{
// The cached frame lies before the target: drop it and continue decoding forward.
GetTimestampAndUpdateIndex(_pCachedFrame);
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
DecodeToTarget(timeSeconds);
return;
}
}
else if (targetFrameIndex > _currentFrameIndex && targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
{
// The target frame has not been read yet and is close: decode forward instead of seeking.
DecodeToTarget(timeSeconds);
return; return;
} }
@@ -1310,77 +1403,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_isDraining = false; _isDraining = false;
// Decode frames until reaching the target presentation timestamp // Decode frames until reaching the target presentation timestamp
while (true) DecodeToTarget(timeSeconds);
{
ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
if (ret == 0)
{
double currentPts = GetFrameTimestamp(_pDecodedFrame);
// Check with half-frame margin to accommodate floating point rounding
if (currentPts >= timeSeconds - (0.5 / FPS))
{
// Cache the first matching frame in system RAM for immediate consumption
if (_isHwDecoding && _pDecodedFrame->format == (int)_hwPixFmt)
{
ffmpeg.av_frame_unref(_pCachedFrame);
int transferRet = ffmpeg.av_hwframe_transfer_data(_pCachedFrame, _pDecodedFrame, 0);
if (transferRet < 0)
{
ffmpeg.av_frame_unref(_pDecodedFrame);
throw new Exception($"Failed to transfer cached hardware frame during seek: {GetFFmpegErrorString(transferRet)}");
}
ffmpeg.av_frame_copy_props(_pCachedFrame, _pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
_downloadedPixFmt = (AVPixelFormat)_pCachedFrame->format;
}
else
{
ffmpeg.av_frame_unref(_pCachedFrame);
ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame);
}
_hasCachedFrame = true;
break;
}
// Discard non-matching intermediate frame without transferring to CPU
long ptsValue = _pDecodedFrame->best_effort_timestamp != ffmpeg.AV_NOPTS_VALUE
? _pDecodedFrame->best_effort_timestamp
: _pDecodedFrame->pts;
if (ptsValue == ffmpeg.AV_NOPTS_VALUE)
{
_currentFrameIndex++;
}
else
{
_currentFrameIndex = (long)Math.Round(currentPts * FPS);
}
ffmpeg.av_frame_unref(_pDecodedFrame);
continue;
}
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
if (_isDraining) break;
if (!ReadAndSendPacket())
{
_isDraining = true;
ffmpeg.avcodec_send_packet(_pCodecContext, null);
}
}
else if (ret == ffmpeg.AVERROR_EOF)
{
break;
}
else
{
throw new Exception($"Error during seek decoding: {GetFFmpegErrorString(ret)}");
}
}
} }
} }
+119 -22
View File
@@ -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>
@@ -437,16 +437,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
@@ -728,6 +723,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)
@@ -830,7 +835,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 +857,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 +895,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 +973,49 @@ 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>
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>
/// 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
// ================================================================ // ================================================================
+1 -1
View File
@@ -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
View File
@@ -4,6 +4,7 @@ using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ADOFAIRenderer.Data; using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util.FFmpeg; using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
@@ -121,7 +122,7 @@ internal static class GPUUtil
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue; !int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
var nvidiaVerInt = sub % 10 * 10000 + build; var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100f; _cachedNvidiaVersion = nvidiaVerInt / 100.0;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}"); Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion; return _cachedNvidiaVersion;
} }
@@ -8,7 +8,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MelonLoader; using MelonLoader;
namespace ADOFAIRenderer.Util; namespace ADOFAIRenderer.Util.OS;
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")] [SuppressMessage("ReSharper", "UnusedMember.Local")]
+577
View File
@@ -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
}
+129
View File
@@ -0,0 +1,129 @@
using System;
namespace ADOFAIRenderer.Util.OS;
internal static class RegistryUtil
{
private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType;
private static readonly Type? RegistryViewType;
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
private static readonly System.Reflection.MethodInfo? GetValueMethod;
static RegistryUtil()
{
try
{
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
{
var mscorlib = typeof(object).Assembly;
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
}
if (RegistryKeyType != null)
{
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
}
}
catch
{
// ignored
}
}
private static void CloseKey(object? key)
{
if (key == null || RegistryKeyType == null) return;
try
{
if (key is IDisposable disposable)
{
disposable.Dispose();
}
else
{
var closeMethod = RegistryKeyType.GetMethod("Close");
closeMethod?.Invoke(key, null);
}
}
catch
{
// ignored
}
}
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
CloseKey(key);
CloseKey(baseKey);
return subkeyNames;
}
catch
{
return null;
}
}
// ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
CloseKey(key);
CloseKey(baseKey);
return value;
}
catch
{
return null;
}
}
}
-578
View File
@@ -1,578 +0,0 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util
{
/// <summary>
/// Sends native desktop notifications on Windows, macOS, and Linux.
/// <para>
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
/// No PowerShell, no UWP, no WinRT dependencies.
/// </para>
/// <para>
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
/// </para>
/// <para>
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
/// </para>
/// </summary>
internal static class OSNotification
{
/// <summary>
/// Sends a native desktop notification.
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
/// the folder when the notification is clicked/dismissed.
/// </summary>
/// <param name="title">The notification title.</param>
/// <param name="message">The notification body text.</param>
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
public static void Show(string title, string message, string? folderPath = null)
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
WindowsNotification.Send(title, message, folderPath);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
MacNotification.Send(title, message);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
LinuxNotification.Send(title, message, folderPath);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
}
}
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
/// <summary>
/// Windows notification with two strategies:
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
/// </summary>
private static class WindowsNotification
{
public static void Send(string title, string message, string? folderPath)
{
var thread = new Thread(() =>
{
try
{
if (!TrySendToast(title, message, folderPath))
{
SendBalloon(title, message, folderPath);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
}
})
{
IsBackground = true,
Name = "OSNotification-Win"
};
thread.Start();
}
#region Toast via PowerShell
/// <summary>
/// Attempts to show a Windows Toast notification via PowerShell.
/// Returns true if the process launched successfully, false otherwise.
/// </summary>
private static bool TrySendToast(string title, string message, string? folderPath)
{
try
{
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
string toastXml;
string psScript;
if (!string.IsNullOrEmpty(folderPath))
{
var escapedFolder = folderPath.Replace("'", "''");
toastXml =
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
"<visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual>" +
"<actions>" +
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
"</actions></toast>";
psScript =
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
"$null = New-Item -Path $protocolPath -Force\n" +
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
"$null = New-Item -Path $cmdPath -Force\n" +
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
$"$xml.LoadXml('{toastXml}')\n" +
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
else
{
toastXml =
"<toast><visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual></toast>";
psScript =
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
$"$xml.LoadXml('{toastXml}')\n" +
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
string base64 = Convert.ToBase64String(bytes);
var startInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
process.WaitForExit(5000);
// If PowerShell exited with error, treat as failure
if (process.ExitCode != 0)
{
string stderr = process.StandardError.ReadToEnd().Trim();
if (!string.IsNullOrEmpty(stderr))
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
return false;
}
return true;
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
return false;
}
}
#endregion
#region Balloon via Shell_NotifyIcon
// ReSharper disable InconsistentNaming
private const int NIM_ADD = 0x00;
private const int NIM_DELETE = 0x02;
private const int NIF_MESSAGE = 0x01;
private const int NIF_ICON = 0x02;
private const int NIF_TIP = 0x04;
private const int NIF_INFO = 0x10;
private const int NIIF_INFO = 0x01;
private const int WM_USER = 0x0400;
private const int WM_TRAYICON = WM_USER + 1;
private const int NIN_BALLOONUSERCLICK = 0x0405;
private const int WS_OVERLAPPED = 0x00000000;
private const uint WM_CLOSE = 0x0010;
private const int TRAY_ICON_UID = 29471;
// ReSharper restore InconsistentNaming
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr CreateWindowExW(
int dwExStyle, string lpClassName, string lpWindowName,
int dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconDataW
{
public int cbSize;
public IntPtr hWnd;
public int uID;
public int uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public int dwState;
public int dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
public int uTimeoutOrVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public int dwInfoFlags;
}
[StructLayout(LayoutKind.Sequential)]
private struct Msg
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int pt_x;
public int pt_y;
}
private static void SendBalloon(string title, string message, string? folderPath)
{
if (title.Length > 63) title = title.Substring(0, 63);
if (message.Length > 255) message = message.Substring(0, 255);
IntPtr hWnd = IntPtr.Zero;
var nid = new NotifyIconDataW();
try
{
var hwndMessage = new IntPtr(-3);
hWnd = CreateWindowExW(
0, "Static", "ADOFAIRendererNotify",
WS_OVERLAPPED, 0, 0, 0, 0,
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
if (hWnd == IntPtr.Zero)
{
MelonLogger.Warning("[OSNotification] Cannot create message window.");
return;
}
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
nid.hWnd = hWnd;
nid.uID = TRAY_ICON_UID;
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
nid.uCallbackMessage = WM_TRAYICON;
nid.hIcon = hIcon;
nid.szTip = "ADOFAI Renderer";
nid.szInfo = message;
nid.szInfoTitle = title;
nid.dwInfoFlags = NIIF_INFO;
nid.uTimeoutOrVersion = 4;
Shell_NotifyIconW(NIM_ADD, ref nid);
if (!string.IsNullOrEmpty(folderPath))
{
var capturedFolder = folderPath;
var closeTimer = new System.Threading.Timer(_ =>
{
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
catch { /* ignored */ }
}, null, 12000, Timeout.Infinite);
try
{
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
{
if (msg.message == WM_CLOSE)
break;
if (msg.message == WM_TRAYICON)
{
int notifyMsg = (int)(long)msg.lParam;
if (notifyMsg == NIN_BALLOONUSERCLICK)
{
try
{
var psi = new ProcessStartInfo();
psi.FileName = capturedFolder;
psi.UseShellExecute = true;
Process.Start(psi);
}
catch (Exception ex)
{
MelonLogger.Warning(
$"[OSNotification] Cannot open folder: {ex.Message}");
}
break;
}
}
TranslateMessage(ref msg);
DispatchMessageW(ref msg);
}
}
finally
{
closeTimer.Dispose();
}
}
else
{
Thread.Sleep(500);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
}
finally
{
if (hWnd != IntPtr.Zero)
{
nid.uFlags = 0;
Shell_NotifyIconW(NIM_DELETE, ref nid);
DestroyWindow(hWnd);
}
}
}
#endregion
}
#endregion
#region macOS — osascript
/// <summary>
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
/// </summary>
private static class MacNotification
{
public static void Send(string title, string message)
{
// AppleScript string escaping: backslash and double-quote
string escapedTitle = EscapeAppleScript(title);
string escapedMessage = EscapeAppleScript(message);
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
// Each argument is passed directly to the process without shell interpretation.
var script =
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
var startInfo = new ProcessStartInfo
{
FileName = "osascript",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-e");
startInfo.ArgumentList.Add(script);
RunProcess(startInfo);
}
private static string EscapeAppleScript(string value)
{
// In AppleScript string literals, escape backslash and double-quote
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
}
}
#endregion
#region Linux — notify-send / gdbus
/// <summary>
/// Linux notification using <c>notify-send</c> (libnotify CLI).
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
/// </summary>
private static class LinuxNotification
{
public static void Send(string title, string message, string? folderPath)
{
if (!string.IsNullOrEmpty(folderPath))
{
ShowWithAction(title, message, folderPath);
}
else
{
ShowSimple(title, message);
}
}
private static void ShowSimple(string title, string message)
{
// Try notify-send first
if (TryNotifySend(title, message))
return;
// Fallback to gdbus
TryGdbus(title, message);
}
private static void ShowWithAction(string title, string message, string folderPath)
{
// Run on a background thread because notify-send with --action blocks
// until the notification is dismissed or the action is clicked.
System.Threading.Tasks.Task.Run(() =>
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message);
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (output == "open_folder")
{
var openInfo = new ProcessStartInfo
{
FileName = "xdg-open",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
openInfo.ArgumentList.Add(folderPath);
RunProcess(openInfo);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
// Fallback: show without action
ShowSimple(title, message);
}
});
}
private static bool TryNotifySend(string title, string message)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message);
RunProcess(startInfo);
return true;
}
catch
{
return false;
}
}
private static void TryGdbus(string title, string message)
{
try
{
// Direct D-Bus call to org.freedesktop.Notifications
var startInfo = new ProcessStartInfo
{
FileName = "gdbus",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("call");
startInfo.ArgumentList.Add("--session");
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
startInfo.ArgumentList.Add("0"); // replaces_id
startInfo.ArgumentList.Add(""); // app_icon
startInfo.ArgumentList.Add(title); // summary
startInfo.ArgumentList.Add(message); // body
startInfo.ArgumentList.Add("[]"); // actions
startInfo.ArgumentList.Add("{}"); // hints
startInfo.ArgumentList.Add("-1"); // expire_timeout
RunProcess(startInfo);
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
}
}
}
#endregion
#region Shared utility
/// <summary>
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
/// </summary>
private static void RunProcess(ProcessStartInfo startInfo)
{
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
}
#endregion
}
}
-130
View File
@@ -1,130 +0,0 @@
using System;
namespace ADOFAIRenderer.Util
{
internal static class RegistryUtil
{
private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType;
private static readonly Type? RegistryViewType;
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
private static readonly System.Reflection.MethodInfo? GetValueMethod;
static RegistryUtil()
{
try
{
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
{
var mscorlib = typeof(object).Assembly;
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
}
if (RegistryKeyType != null)
{
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
}
}
catch
{
// ignored
}
}
private static void CloseKey(object? key)
{
if (key == null || RegistryKeyType == null) return;
try
{
if (key is IDisposable disposable)
{
disposable.Dispose();
}
else
{
var closeMethod = RegistryKeyType.GetMethod("Close");
closeMethod?.Invoke(key, null);
}
}
catch
{
// ignored
}
}
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
CloseKey(key);
CloseKey(baseKey);
return subkeyNames;
}
catch
{
return null;
}
}
// ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
CloseKey(key);
CloseKey(baseKey);
return value;
}
catch
{
return null;
}
}
}
}