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 1800 additions and 1392 deletions

No files matched your search

+9 -3
View File
@@ -32,9 +32,11 @@ Thumbs.db
desktop.ini
## Agent Metadata
.antigravitycli/
.agent/
GEMINI.md
/.antigravitycli/
/.agent/
/GEMINI.md
/.mcp.json
/CLAUDE.md
# Decompiled Game Code
*.cs.txt
@@ -43,3 +45,7 @@ GEMINI.md
/AsyncInputAutoplayFix.md
/AudioMixerExplanation.md
/O5Theme.md
# Developer Utility Files
/REVIEW_CHECKLIST.md
/REVIEW_FFMPEG.md
+118 -24
View File
@@ -14,6 +14,7 @@ using ADOFAIRenderer.UI.Window;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using FFmpeg.AutoGen;
using HarmonyLib;
using MelonLoader;
@@ -91,7 +92,50 @@ internal class RenderManager : MonoBehaviour
public long frameCount;
public bool skipPressToStart;
public string levelPath = "";
public bool IsRenderCancelled { get; private set; }
private readonly object _abortStateLock = new();
private volatile bool _isRenderCancelled;
private volatile bool _renderFailed;
private volatile string? _failureReason;
// ReSharper disable once MemberCanBePrivate.Global
public bool IsRenderCancelled => _isRenderCancelled;
public bool IsRenderFailed => _renderFailed;
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
private void MarkCancelled()
{
lock (_abortStateLock)
{
if (!_renderFailed) _isRenderCancelled = true;
}
}
// The first failure reason wins; ignored once the render is already cancelled or failed.
private void MarkFailed(string reason)
{
lock (_abortStateLock)
{
if (_isRenderCancelled || _renderFailed) return;
_failureReason = reason;
_renderFailed = true;
}
}
private void ClearAbortState()
{
lock (_abortStateLock)
{
_isRenderCancelled = false;
_renderFailed = false;
_failureReason = null;
}
}
private string BuildAbortMessage() => IsRenderFailed
? $"Rendering failed: {_failureReason ?? "unknown error"}"
: "Rendering was cancelled by user.";
public RenderConfig Configuration = null!;
// Render Sessions State (OOP Instance Fields)
@@ -102,7 +146,7 @@ internal class RenderManager : MonoBehaviour
public int frameCountBase;
public OfflineAudioMixer? AudioMixer { get; private set; }
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
private AudioSyncCalculator? SyncCalculator { get; set; }
private FrameReader? _frameReader;
private FrameHookPipeline? _frameHookPipeline;
@@ -151,6 +195,7 @@ internal class RenderManager : MonoBehaviour
public long frameCountForVideo;
public long lastVideoFrameIndex = -1;
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
public long lastRequestedVideoFrameIndex = -1;
public int readbackFailureCount;
public int throttleCount;
@@ -170,8 +215,8 @@ internal class RenderManager : MonoBehaviour
internal AutoResetEvent FrameAvailableEvent = new(false);
public Thread? RenderThread;
public long passCount;
public bool endRequested;
public bool endSuccessful;
public volatile bool endRequested;
public volatile bool endSuccessful;
public long endFrame = -1;
// Private Instance Fields
@@ -195,12 +240,15 @@ internal class RenderManager : MonoBehaviour
public void ResetRenderState(bool success)
{
IsRenderCancelled = !success;
if (success) _isRenderCancelled = false;
else MarkCancelled();
stage = RenderStage.None;
}
public void CleanUpActiveRender()
{
if (stage != RenderStage.None)
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
ResetRenderState(success: false);
AudioSpectrumGenerator.Clear();
@@ -283,8 +331,15 @@ internal class RenderManager : MonoBehaviour
}
if (Instance.stage < RenderStage.OnScene) return;
if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true })
{
MarkFailed("the render thread terminated unexpectedly.");
CancelRender();
return;
}
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
{
MarkFailed("the level failed or the game controller was lost during rendering.");
ResetRenderState(success: false);
StopFFmpeg(false);
EndRender();
@@ -333,6 +388,12 @@ internal class RenderManager : MonoBehaviour
while (Instance.stage >= RenderStage.Render)
{
if (RenderThread is not { IsAlive: true })
{
isThrottling = false;
return;
}
int count;
lock (ImageQueue)
{
@@ -362,7 +423,7 @@ internal class RenderManager : MonoBehaviour
if (videoFrameIndex > lastVideoFrameIndex)
{
lastVideoFrameIndex = videoFrameIndex;
lastRequestedVideoFrameIndex = videoFrameIndex;
Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex);
var targetTexture = texture;
if (HasActiveFrameHooks && _frameHookPipeline != null)
@@ -403,7 +464,6 @@ internal class RenderManager : MonoBehaviour
Application.wantsToQuit -= quitDelegate;
RenderConfigWindow.CreateWindow();
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
});
}
@@ -440,6 +500,9 @@ internal class RenderManager : MonoBehaviour
yield return new WaitForSecondsRealtime(1);
}
// Cancelled or failed during the countdown: do not start the encoder
if (stage != RenderStage.OnScene) yield break;
frameCount = 0; framesSent = 0;
_renderStopwatch.Restart();
@@ -462,13 +525,20 @@ internal class RenderManager : MonoBehaviour
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
ResetFrameHookState();
// Release the encoder/thread and restore time, input offset, stage and hooks before showing the dialog
MarkFailed($"failed to initialize the video encoder: {ex.Message}");
ResetRenderState(success: false);
StopFFmpeg(false);
RestoreRenderEnvironment();
CommonWarningDialogUI.ShowDialog(
"Encoder Error",
$"Failed to initialize video encoder.\nError: {ex.Message}",
new DialogButton("OK", () =>
{
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
})
);
yield break;
@@ -511,10 +581,10 @@ internal class RenderManager : MonoBehaviour
ImageQueue.Clear();
}
passCount = 0;
IsRenderCancelled = false;
ClearAbortState();
frameCountForVideo = 0;
lastVideoFrameIndex = -1;
lastRequestedVideoFrameIndex = -1;
Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
endRequested = false;
endSuccessful = false;
FrameAvailableEvent.Reset();
@@ -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 effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
var frameTime = frameIdx / effectiveVideoFps;
var channels = Configuration.AudioConfig.AudioChannels;
var samplesNeeded = SyncCalculator.GetNextFrameSamples();
var samplesNeeded = syncCalculator.GetNextFrameSamples();
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
try
@@ -655,17 +725,17 @@ internal class RenderManager : MonoBehaviour
continue;
}
if (IsRenderCancelled) break;
if (IsRenderAborted) break;
if (endRequested)
{
if (passCount > lastRequestedVideoFrameIndex) break;
if (passCount > Volatile.Read(ref lastRequestedVideoFrameIndex)) break;
}
FrameAvailableEvent.WaitOne(50);
}
if (!IsRenderCancelled) videoFFmpeg.End();
if (!IsRenderAborted) videoFFmpeg.End();
videoFFmpeg.Dispose();
lock (_allVideoFFmpegInstances)
@@ -674,12 +744,22 @@ internal class RenderManager : MonoBehaviour
}
_activeVideoFFmpeg = null;
endSuccessful = !IsRenderCancelled;
endSuccessful = !IsRenderAborted;
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
MarkFailed("encoder thread error. See the log for details.");
try
{
videoFFmpeg.Dispose();
}
catch (Exception disposeEx)
{
Melon<RenderMod>.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}");
}
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
@@ -708,7 +788,7 @@ internal class RenderManager : MonoBehaviour
endRequested = true;
if (!success)
{
IsRenderCancelled = true;
MarkCancelled();
}
FrameAvailableEvent.Set();
skipPressToStart = false;
@@ -722,7 +802,8 @@ internal class RenderManager : MonoBehaviour
if (success) StartCoroutine(StopRenderCoroutine());
else
{
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED";
Melon<RenderMod>.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources.");
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
if (RenderThread is { IsAlive: true })
@@ -796,15 +877,20 @@ internal class RenderManager : MonoBehaviour
CurrentOutPath = null;
}
var result = BuildResult(false,
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
var result = BuildResult(false, BuildAbortMessage());
TriggerOnComplete(result);
}
}
private IEnumerator StopRenderCoroutine()
{
yield return new WaitUntil(() => endSuccessful);
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
if (!endSuccessful)
{
MarkFailed("the render thread terminated unexpectedly.");
CancelRender();
yield break;
}
var result = BuildResult(success: true);
TriggerOnComplete(result);
@@ -813,6 +899,15 @@ internal class RenderManager : MonoBehaviour
}
private void EndRender()
{
RestoreRenderEnvironment();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
}
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
private void RestoreRenderEnvironment()
{
Time.timeScale = 1f;
Time.captureDeltaTime = 0f;
@@ -847,12 +942,11 @@ internal class RenderManager : MonoBehaviour
_frameReader?.Dispose();
ResetFrameHookState();
AudioMixer?.Reset(); AudioMixer = null;
SyncCalculator.Reset();
SyncCalculator = null;
AudioSpectrumGenerator.Clear();
RenderContext.Reset();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
}
private RenderResult BuildResult(bool success, string? errorMessage = null)
+1
View File
@@ -5,6 +5,7 @@
"ui.render_config.preset_name_placeholder": "New preset name...",
"ui.render_config.preset_save": "Save",
"ui.render_config.preset_delete": "Delete",
"ui.render_config.preset_delete_confirm": "Confirm?",
"ui.render_config.preset_overwrite_title": "Overwrite Preset",
"ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?",
"ui.render_config.preset_delete_title": "Delete Preset",
+2 -1
View File
@@ -5,6 +5,7 @@
"ui.render_config.preset_name_placeholder": "새 프리셋 이름...",
"ui.render_config.preset_save": "저장",
"ui.render_config.preset_delete": "삭제",
"ui.render_config.preset_delete_confirm": "삭제 확인",
"ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기",
"ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?",
"ui.render_config.preset_delete_title": "프리셋 삭제",
@@ -23,7 +24,7 @@
"ui.render_config.audio_encoding": "오디오 인코딩 설정",
"ui.render_config.global_settings": "전역 설정",
"ui.render_config.advanced_settings": "고급 설정",
"ui.render_config.go_render": "렌더 시작",
"ui.render_config.go_render": "렌더링하러 가다",
"ui.render_config.codec": "코덱",
"ui.render_config.rate_control": "비트레이트 제어 모드",
"ui.render_config.bitrate": "비트레이트 (kbps)",
+1 -1
View File
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
using UnityEngine;
using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.4-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.7-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer;
+75 -149
View File
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using MelonLoader;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button;
using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI;
@@ -29,6 +31,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
{
public static CommonWarningDialogUI? Instance { get; private set; }
private readonly List<VisualElement> _disabledElements = new();
private O5Window? _window;
private void Awake()
{
@@ -37,6 +40,9 @@ internal class CommonWarningDialogUI : MonoBehaviour
private void OnDestroy()
{
_window?.Dispose();
_window = null;
foreach (var elem in _disabledElements)
{
if (elem != null)
@@ -87,9 +93,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
// Canvas setup
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
canvas.sortingOrder = 32766;
// Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
@@ -98,13 +103,15 @@ internal class CommonWarningDialogUI : MonoBehaviour
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background
var bgPanelObj = new GameObject("BackgroundPanel");
var ctx = RendererUIManager.Context;
// 1. Create full screen backdrop (scrim) to block background input
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>();
@@ -113,112 +120,66 @@ internal class CommonWarningDialogUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = ModAssets.NanumSquareNeoNormal;
// 2. Create actual native O5Window
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
{
Id = "RendererMod_WarningDialog",
Title = title,
Size = new Vector2(540, 240),
Draggable = true,
Resizable = false,
ShowCloseButton = false,
ShowOutline = true,
Padding = new RectOffset(16, 16, 16, 16)
});
// 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
// 3. Build content inside window.Content
var content = _window.Content;
var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
vLayout.spacing = 16f;
vLayout.padding = new RectOffset(16, 16, 12, 12);
vLayout.childControlWidth = true;
vLayout.childControlHeight = true;
vLayout.childForceExpandWidth = true;
vLayout.childForceExpandHeight = false;
var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(650, 360);
// 3. Create title text
var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5;
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = title;
titleText.fontSize = 24;
titleText.font = font;
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 4. Create description text
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
// Description text
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
descText.text = description;
descText.fontSize = 20;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 8;
descText.textWrappingMode = TextWrappingModes.Normal;
descText.lineSpacing = 6;
descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
var descRect = descObj.GetComponent<RectTransform>();
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120);
// ReSharper disable once InconsistentNaming
var descLE = descText.gameObject.AddComponent<LayoutElement>();
descLE.flexibleWidth = 1f;
descLE.flexibleHeight = 1f;
// 5. Create button container
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
buttonContainerObj.layer = 5;
// Button action row
var btnRowObj = new GameObject("ButtonRow");
btnRowObj.transform.SetParent(content, false);
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
btnRowLE.preferredHeight = 40f;
btnRowLE.flexibleWidth = 1f;
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);
var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
hLayout.spacing = 10f;
hLayout.childAlignment = TextAnchor.MiddleCenter;
hLayout.childControlWidth = true;
hLayout.childControlHeight = true;
hLayout.childForceExpandWidth = true;
hLayout.childForceExpandHeight = true;
// 6. Dynamic button layout and styling
float totalWidth = 560f;
float spacing = 15f;
float buttonHeight = 45f;
int numButtons = buttons.Length;
float buttonWidth = numButtons == 1
? 200f
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
float startX = numButtons == 1
? 0
: (-totalWidth / 2f) + (buttonWidth / 2f);
for (int i = 0; i < numButtons; i++)
for (var i = 0; i < buttons.Length; i++)
{
var btnData = buttons[i];
var btnObj = new GameObject($"Button_{i}");
btnObj.transform.SetParent(buttonContainerObj.transform, false);
btnObj.layer = 5;
// Define default colors
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
var btnImage = btnObj.AddComponent<Image>();
btnImage.color = baseColor;
btnImage.raycastTarget = true;
var btn = btnObj.AddComponent<Button>();
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
// Register click callback
btn.onClick.AddListener(() =>
var btn = O5Factory.Button(
ctx: ctx,
parent: btnRowRect,
onClick: () =>
{
try
{
@@ -226,59 +187,24 @@ internal class CommonWarningDialogUI : MonoBehaviour
}
catch (Exception ex)
{
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
});
},
text: btnData.Text,
id: $"btn_dialog_{i}",
height: 40f
);
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 txtObj = new GameObject("Text");
txtObj.transform.SetParent(btnObj.transform, false);
txtObj.layer = 5;
var text = txtObj.AddComponent<TextMeshProUGUI>();
text.text = btnData.Text;
text.fontSize = 20;
text.font = font;
text.color = Color.white;
text.alignment = TextAlignmentOptions.Center;
var txtRect = txtObj.GetComponent<RectTransform>();
txtRect.anchorMin = Vector2.zero;
txtRect.anchorMax = Vector2.one;
txtRect.offsetMin = Vector2.zero;
txtRect.offsetMax = Vector2.zero;
}
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
if (btnData.BackgroundColor.HasValue)
{
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;
btn.NormalColor = btnData.BackgroundColor.Value;
btn.UpdateVisual();
}
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);
var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
le.flexibleWidth = 1f;
le.preferredHeight = 40f;
}
}
}
+124 -182
View File
@@ -4,11 +4,13 @@ using System.IO.Compression;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using MelonLoader;
using MelonLoader.Utils;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using MelonLoader;
using MelonLoader.Utils;
namespace ADOFAIRenderer.UI;
@@ -22,6 +24,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
private GameObject _statusTextObj = null!;
private TextMeshProUGUI _statusText = null!;
private O5Window? _window;
private bool _isDownloading;
private string DownloadUrl => Application.platform switch
{
@@ -104,13 +107,15 @@ internal class FFmpegDownloadUI : MonoBehaviour
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background
var bgPanelObj = new GameObject("BackgroundPanel");
var ctx = RendererUIManager.Context;
// 1. Create full screen backdrop
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>();
@@ -119,51 +124,31 @@ internal class FFmpegDownloadUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = ModAssets.NanumSquareNeoNormal;
// 2. Create actual native O5Window
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
{
Id = "RendererMod_FFmpegDownloadDialog",
Title = "FFmpeg Library Installation Required",
Size = new Vector2(580, 270),
Draggable = true,
Resizable = false,
ShowCloseButton = false,
ShowOutline = true,
Padding = new RectOffset(16, 16, 16, 16)
});
// 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var content = _window.Content;
var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
vLayout.spacing = 14f;
vLayout.padding = new RectOffset(16, 16, 10, 10);
vLayout.childControlWidth = true;
vLayout.childControlHeight = true;
vLayout.childForceExpandWidth = true;
vLayout.childForceExpandHeight = false;
var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(600, 320);
// 4. Create title text
var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5;
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = "FFmpeg Library Installation Required";
titleText.fontSize = 26;
titleText.font = font;
titleText.color = Color.white;
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 5. Create description text
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
// 3. Description text
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
var descStrBuilder = new StringBuilder();
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
if (Application.platform == RuntimePlatform.OSXPlayer)
@@ -176,139 +161,104 @@ internal class FFmpegDownloadUI : MonoBehaviour
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
}
descText.text = descStrBuilder.ToString();
descText.fontSize = 16;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 10;
descText.textWrappingMode = TextWrappingModes.Normal;
descText.lineSpacing = 5;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
var descRect = descObj.GetComponent<RectTransform>();
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -60);
descRect.sizeDelta = new Vector2(-40, 100);
var descLE = descText.gameObject.AddComponent<LayoutElement>();
descLE.flexibleWidth = 1f;
descLE.flexibleHeight = 1f;
// 6. Create button container
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
// 4. Button container
_buttonContainerObj = new GameObject("ButtonContainer");
_buttonContainerObj.transform.SetParent(content, 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);
var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
btnContainerLE.preferredHeight = 40f;
btnContainerLE.flexibleWidth = 1f;
// 7. Create download button
var downloadBtnObj = new GameObject("DownloadButton");
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
downloadBtnObj.layer = 5;
var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
btnLayout.spacing = 10f;
btnLayout.childAlignment = TextAnchor.MiddleCenter;
btnLayout.childControlWidth = true;
btnLayout.childControlHeight = true;
btnLayout.childForceExpandWidth = true;
btnLayout.childForceExpandHeight = true;
var downloadImg = downloadBtnObj.AddComponent<Image>();
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
downloadImg.raycastTarget = true;
Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
? StartDownloadProcess
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
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 downloadBtn = O5Factory.Button(
ctx: ctx,
parent: _buttonContainerObj.transform,
onClick: onDownloadClick,
text: Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website",
id: "btn_ffmpeg_download",
height: 40f
);
downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
downloadBtn.UpdateVisual();
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>();
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
downloadBtnRect.anchoredPosition = new Vector2(-15, 0);
downloadBtnRect.sizeDelta = new Vector2(180, 45);
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
downloadBtnLE.flexibleWidth = 1f;
downloadBtnLE.preferredHeight = 40f;
var downloadTextObj = new GameObject("DownloadText");
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
downloadTextObj.layer = 5;
var cancelBtn = O5Factory.Button(
ctx: ctx,
parent: _buttonContainerObj.transform,
onClick: CancelProcess,
text: "Cancel",
id: "btn_ffmpeg_cancel",
height: 40f
);
var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
cancelBtnLE.flexibleWidth = 1f;
cancelBtnLE.preferredHeight = 40f;
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
downloadText.fontSize = 20;
downloadText.font = font;
downloadText.color = Color.white;
downloadText.alignment = TextAlignmentOptions.Center;
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>();
downloadTextRect.anchorMin = Vector2.zero;
downloadTextRect.anchorMax = Vector2.one;
downloadTextRect.offsetMin = Vector2.zero;
downloadTextRect.offsetMax = Vector2.zero;
// 8. Create cancel button
var cancelBtnObj = new GameObject("CancelButton");
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
cancelBtnObj.layer = 5;
var cancelImg = cancelBtnObj.AddComponent<Image>();
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f);
cancelImg.raycastTarget = true;
var cancelBtn = cancelBtnObj.AddComponent<Button>();
SetupButtonStyle(cancelBtn, new Color(0.2f, 0.2f, 0.23f, 1f), new Color(0.25f, 0.25f, 0.28f, 1f), new Color(0.15f, 0.15f, 0.18f, 1f));
cancelBtn.onClick.AddListener(CancelProcess);
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>();
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
cancelBtnRect.pivot = new Vector2(0f, 0.5f);
cancelBtnRect.anchoredPosition = new Vector2(15, 0);
cancelBtnRect.sizeDelta = new Vector2(180, 45);
var cancelTextObj = new GameObject("CancelText");
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false);
cancelTextObj.layer = 5;
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
cancelText.text = "Cancel";
cancelText.fontSize = 20;
cancelText.font = font;
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
cancelText.alignment = TextAlignmentOptions.Center;
var cancelTextRect = cancelTextObj.GetComponent<RectTransform>();
cancelTextRect.anchorMin = Vector2.zero;
cancelTextRect.anchorMax = Vector2.one;
cancelTextRect.offsetMin = Vector2.zero;
cancelTextRect.offsetMax = Vector2.zero;
// 9. Create progress bar container
// 5. Progress bar container
_progressBarObj = new GameObject("ProgressBarContainer");
_progressBarObj.transform.SetParent(modalContainerObj.transform, false);
_progressBarObj.transform.SetParent(content, false);
_progressBarObj.layer = 5;
_progressBarObj.SetActive(false);
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
pbLE.preferredHeight = 24f;
pbLE.flexibleWidth = 1f;
var barBgImg = _progressBarObj.AddComponent<Image>();
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
barBgImg.sprite = ctx.Sprites.RoundedControl;
barBgImg.type = Image.Type.Sliced;
barBgImg.color = ctx.Theme.ObjectBG;
var barBgOutline = _progressBarObj.AddComponent<Outline>();
barBgOutline.effectColor = Color.white;
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
var barOutlineObj = new GameObject("BarOutline");
barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
barOutlineObj.layer = 5;
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0);
barRect.anchorMax = new Vector2(0.5f, 0);
barRect.pivot = new Vector2(0.5f, 0);
barRect.anchoredPosition = new Vector2(0, 75);
barRect.sizeDelta = new Vector2(480, 24);
var barOutlineImg = barOutlineObj.AddComponent<Image>();
barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
barOutlineImg.type = Image.Type.Sliced;
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.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImg = _progressBarFillObj.AddComponent<Image>();
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
fillImg.sprite = ctx.Sprites.RoundedControl;
fillImg.type = Image.Type.Sliced;
fillImg.color = ctx.Theme.ObjectActiveBright;
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0);
@@ -317,20 +267,17 @@ internal class FFmpegDownloadUI : MonoBehaviour
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = Vector2.zero;
// 11. Create progress text
// Progress text
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.fontSize = 16;
_progressText.font = font;
_progressText.fontSize = 13;
_progressText.font = ctx.Fonts.Regular;
_progressText.color = Color.white;
_progressText.alignment = TextAlignmentOptions.Center;
var progressTextOutline = progressTextObj.AddComponent<Outline>();
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
progressTextOutline.effectDistance = new Vector2(1, -1);
_progressText.raycastTarget = false;
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
progressTextRect.anchorMin = Vector2.zero;
@@ -338,35 +285,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
progressTextRect.offsetMin = Vector2.zero;
progressTextRect.offsetMax = Vector2.zero;
// 12. Create status text
// 6. Status text
_statusTextObj = new GameObject("StatusText");
_statusTextObj.transform.SetParent(modalContainerObj.transform, false);
_statusTextObj.transform.SetParent(content, false);
_statusTextObj.layer = 5;
_statusTextObj.SetActive(false);
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
statusLE.preferredHeight = 22f;
statusLE.flexibleWidth = 1f;
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
_statusText.fontSize = 16;
_statusText.font = font;
_statusText.fontSize = 14;
_statusText.font = ctx.Fonts.Regular;
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
_statusText.alignment = TextAlignmentOptions.Center;
_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)
{
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;
statusRect.anchorMin = Vector2.zero;
statusRect.anchorMax = Vector2.one;
statusRect.offsetMin = Vector2.zero;
statusRect.offsetMax = Vector2.zero;
}
private void Update()
@@ -643,6 +583,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
private void OnDestroy()
{
_window?.Dispose();
_window = null;
_cts?.Dispose();
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.Data;
using ADOFAIRenderer.Localization;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.OS;
using MelonLoader;
using O5Kit.Control;
using O5Kit.Core;
@@ -26,6 +26,10 @@ internal static class RenderConfigWindow
private static O5InputField? _presetNameInput;
private static O5Button? _savePresetButton;
private static O5Button? _deletePresetButton;
// ReSharper disable once InconsistentNaming
private static LayoutElement? _deletePresetButtonLE;
private static bool _isDeletePending;
private static Coroutine? _deleteResetCoroutine;
private static string? _lastLoadedPresetName;
// Controls cache for validation & sync
@@ -87,14 +91,20 @@ internal static class RenderConfigWindow
Size = new Vector2(900, 600), Draggable = true, Resizable = true,
ShowCloseButton = true
});
if (_configWindow.TitleText != null)
{
LocalizedText.Bind(_configWindow.TitleText, "ui.render_config.window_title");
}
_configWindow.CloseRequested += win =>
{
CloseAllPickers();
ResetDeletePendingState();
win.Rect.gameObject.SetActive(false);
};
BuildUI(_configWindow.Content);
LocalizationManager.OnLanguageChanged += OnLanguageChanged;
}
else
{
@@ -145,14 +155,9 @@ internal static class RenderConfigWindow
};
// Presets (Collapsible Card)
var (_, presetContent) = O5Factory.Card(
var (_, presetContent) = CreateCard(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.presets"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
"ui.render_config.presets"
);
var presets = PresetManager.GetPresets();
@@ -177,7 +182,7 @@ internal static class RenderConfigWindow
_presetDropdown = CreateDropdownField(
ctx, presetContent,
LocalizationManager.Get("ui.render_config.preset_select"),
"ui.render_config.preset_select",
initialPresetName, initialPresetName,
presetNames, p => p,
"dd_presets",
@@ -213,18 +218,24 @@ internal static class RenderConfigWindow
icon: null,
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>();
inputLE.flexibleWidth = 1f;
inputLE.preferredHeight = 40f;
_savePresetButton = O5Factory.Button(
_savePresetButton = CreateButton(
ctx: ctx,
parent: actionsRowRect,
onClick: () => OnSavePresetClicked(config),
text: LocalizationManager.Get("ui.render_config.preset_save"),
textKey: "ui.render_config.preset_save",
id: "btn_preset_save",
onClick: () => OnSavePresetClicked(config),
height: 40f
);
// ReSharper disable once InconsistentNaming
var saveLE = _savePresetButton.Rect.GetComponent<LayoutElement>() ?? _savePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
saveLE.preferredWidth = 90f;
saveLE.flexibleWidth = 0f;
@@ -241,104 +252,97 @@ internal static class RenderConfigWindow
fonts: ctx.Fonts,
tween: ctx.Tween
);
_deletePresetButton = O5Factory.Button(
_deletePresetButton = CreateButton(
ctx: redCtx,
parent: actionsRowRect,
onClick: () => OnDeletePresetClicked(config),
text: LocalizationManager.Get("ui.render_config.preset_delete"),
textKey: "ui.render_config.preset_delete",
id: "btn_preset_delete",
onClick: () => OnDeletePresetClicked(config),
height: 40f
);
var deleteLE = _deletePresetButton.Rect.GetComponent<LayoutElement>() ?? _deletePresetButton.Rect.gameObject.AddComponent<LayoutElement>();
deleteLE.preferredWidth = 90f;
deleteLE.flexibleWidth = 0f;
deleteLE.preferredHeight = 40f;
if (_deletePresetButton.Label != null)
{
LocalizedText.Bind(_deletePresetButton.Label, () =>
_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();
// 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개)
var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f);
_renderFpsSlider = O5Factory.Slider(
_renderFpsSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topLeft),
labelKey: "ui.render_config.render_fps", id: "slider_render_fps",
defaultValue: 60, min: 0.01, max: 3000, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => Snap(renderFpsSnap, value),
inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; MarkConfigModified(); },
text: LocalizationManager.Get("ui.render_config.render_fps"),
id: "slider_render_fps", showFill: true
onChanged: val => { config.RenderFPS = val; MarkConfigModified(); }
);
_videoFPSSlider = O5Factory.Slider(
_videoFPSSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topRight),
labelKey: "ui.render_config.video_fps", id: "slider_video_fps",
defaultValue: 60, min: 0.01, max: 600, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => Snap(videoFpsSnap, value),
inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; MarkConfigModified(); },
text: LocalizationManager.Get("ui.render_config.video_fps"),
id: "slider_video_fps", showFill: true
onChanged: val => { config.VideoFPS = val; MarkConfigModified(); }
);
_renderWidthSlider = O5Factory.Slider(
_renderWidthSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topLeft),
labelKey: "ui.render_config.render_width", id: "slider_render_width",
defaultValue: 2560, min: 0, max: 15360, value: 2560,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Snap(renderWidthSnap, value), inputFilter: value => Math.Max(value, 2),
onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); MarkConfigModified(); },
text: LocalizationManager.Get("ui.render_config.render_width"),
id: "slider_render_width", showFill: true
onChanged: val => { config.RenderWidth = (int)Math.Round(val); MarkConfigModified(); }
);
_renderHeightSlider = O5Factory.Slider(
_renderHeightSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topRight),
labelKey: "ui.render_config.render_height", id: "slider_render_height",
defaultValue: 1440, min: 0, max: 8640, value: 1440,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Snap(renderHeightSnap, value), inputFilter: value => Math.Max(value, 2),
onComplete: null, onChanged: val => { config.RenderHeight = (int)Math.Round(val); MarkConfigModified(); },
text: LocalizationManager.Get("ui.render_config.render_height"),
id: "slider_render_height", showFill: true
onChanged: val => { config.RenderHeight = (int)Math.Round(val); MarkConfigModified(); }
);
_delayAfterEndSlider = O5Factory.Slider(
_delayAfterEndSlider = CreateSlider(
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,
format: "F3", clampMode: ClampMode.All,
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
onChanged: val => { config.DelayAfterEnd = val; MarkConfigModified(); }
);
_showJudgementToggle = CreateToggleField(
ctx, CreateFieldSlot(topRight), "",
false, config.ShowJudgementText,
LocalizationManager.Get("ui.render_config.show_judgement"),
"toggle_show_judgement",
val => { config.ShowJudgementText = val; MarkConfigModified(); },
ctx, CreateFieldSlot(topRight),
labelKey: "",
defaultValue: false,
value: config.ShowJudgementText,
toggleTextKey: "ui.render_config.show_judgement",
id: "toggle_show_judgement",
onChanged: val => { config.ShowJudgementText = val; MarkConfigModified(); },
generateLabel: false
);
// 4. Video Encoding Config (Collapsible Card)
var (_, videoContent) = O5Factory.Card(
var (_, videoContent) = CreateCard(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.video_encoding"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
"ui.render_config.video_encoding"
);
var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent);
var codecs = (Codec[])Enum.GetValues(typeof(Codec));
_codecDropdown = CreateDropdownField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.codec"),
"ui.render_config.codec",
Codec.H264, config.VideoConfig.Codec,
codecs, c => c.ToString(),
"dd_video_codec",
@@ -351,7 +355,7 @@ internal static class RenderConfigWindow
_bitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.bitrate"),
"ui.render_config.bitrate",
"30000", config.VideoConfig.Bitrate.ToString(),
"input_bitrate", out _bitrateLabel,
TMPro.TMP_InputField.CharacterValidation.Integer,
@@ -360,7 +364,7 @@ internal static class RenderConfigWindow
_qpInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.qp"),
"ui.render_config.qp",
"18", config.VideoConfig.QP.ToString(),
"input_qp", out _qpLabel,
TMPro.TMP_InputField.CharacterValidation.Integer,
@@ -370,7 +374,7 @@ internal static class RenderConfigWindow
var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode));
_rateControlDropdown = CreateDropdownField(
ctx, CreateFieldSlot(videoRight),
LocalizationManager.Get("ui.render_config.rate_control"),
"ui.render_config.rate_control",
RateControlMode.CBR, config.VideoConfig.RateControlMode,
rateModes, m => m.ToString(),
"dd_rate_control",
@@ -384,7 +388,7 @@ internal static class RenderConfigWindow
_maxBitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoRight),
LocalizationManager.Get("ui.render_config.max_bitrate"),
"ui.render_config.max_bitrate",
"50000", config.VideoConfig.MaxBitrate.ToString(),
"input_max_bitrate", out _maxBitrateLabel,
TMPro.TMP_InputField.CharacterValidation.Integer,
@@ -392,61 +396,55 @@ internal static class RenderConfigWindow
);
_forceSoftwareToggle = CreateToggleField(
ctx, CreateFieldSlot(videoRight), "",
false, config.VideoConfig.ForceSoftwareEncoder,
LocalizationManager.Get("ui.render_config.force_software"),
"toggle_force_software",
val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); },
ctx, CreateFieldSlot(videoRight),
labelKey: "",
defaultValue: false,
value: config.VideoConfig.ForceSoftwareEncoder,
toggleTextKey: "ui.render_config.force_software",
id: "toggle_force_software",
onChanged: val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); },
generateLabel: true
);
_keyframeIntervalSlider = O5Factory.Slider(
_keyframeIntervalSlider = CreateSlider(
ctx: ctx, parent: videoContent,
labelKey: "ui.render_config.keyframe_interval", id: "slider_keyframe_interval",
defaultValue: 2, min: 0.1, max: 10, value: 2,
format: "F1", clampMode: ClampMode.All,
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
onChanged: val => { config.VideoConfig.KeyframeInterval = val; MarkConfigModified(); }
);
// 5. Audio Encoding Config (Collapsible Card)
var (_, audioContent) = O5Factory.Card(
var (_, audioContent) = CreateCard(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.audio_encoding"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
"ui.render_config.audio_encoding"
);
var (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent);
_renderAudioToggle = CreateToggleField(
ctx, CreateFieldSlot(audioLeft), "",
true, config.AudioConfig.RenderAudio,
LocalizationManager.Get("ui.render_config.render_audio"),
"toggle_render_audio",
val => { config.AudioConfig.RenderAudio = val; MarkConfigModified(); },
ctx, CreateFieldSlot(audioLeft),
labelKey: "",
defaultValue: true,
value: config.AudioConfig.RenderAudio,
toggleTextKey: "ui.render_config.render_audio",
id: "toggle_render_audio",
onChanged: val => { config.AudioConfig.RenderAudio = val; MarkConfigModified(); },
generateLabel: false
);
_audioBitrateSlider = O5Factory.Slider(
_audioBitrateSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(audioRight),
labelKey: "ui.render_config.audio_bitrate", id: "slider_audio_bitrate",
defaultValue: 256, min: 64, max: 384, value: 256,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Snap(audioBitrateSnap, value),
inputFilter: null, onComplete: null, 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
onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; MarkConfigModified(); }
);
var channels = new[] { 1, 2 };
_channelsDropdown = CreateDropdownField(
ctx, CreateFieldSlot(audioLeft),
LocalizationManager.Get("ui.render_config.channels"),
"ui.render_config.channels",
2, config.AudioConfig.AudioChannels,
channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)",
"dd_channels",
@@ -455,7 +453,7 @@ internal static class RenderConfigWindow
_sampleRateDropdown = CreateDropdownField(
ctx, CreateFieldSlot(audioRight),
LocalizationManager.Get("ui.render_config.sample_rate"),
"ui.render_config.sample_rate",
48000, config.AudioConfig.AudioSampleRate,
CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz",
"dd_sample_rate",
@@ -463,19 +461,14 @@ internal static class RenderConfigWindow
);
// 6. Global Settings (Collapsible Card)
var (_, globalContent) = O5Factory.Card(
var (_, globalContent) = CreateCard(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.global_settings"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
"ui.render_config.global_settings"
);
_outputDirInput = CreateFullWidthInputField(
ctx, globalContent,
LocalizationManager.Get("ui.render_config.output_dir"),
"ui.render_config.output_dir",
GlobalSettings.DefaultOutputDirectory,
GlobalSettings.EffectiveOutputDirectory,
"input_output_dir",
@@ -495,8 +488,10 @@ internal static class RenderConfigWindow
);
var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent);
O5Factory.Button(
CreateButton(
ctx, globalLeft,
"ui.render_config.browse",
"btn_browse_output",
() =>
{
var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value)
@@ -512,39 +507,32 @@ internal static class RenderConfigWindow
GlobalSettings.Save();
}
});
},
LocalizationManager.Get("ui.render_config.browse"),
"btn_browse_output"
}
);
O5Factory.Button(
CreateButton(
ctx, globalRight,
"ui.render_config.reset_default",
"btn_reset_output",
() =>
{
_outputDirInput.Set(GlobalSettings.DefaultOutputDirectory);
GlobalSettings.Current.OutputDirectory = null;
GlobalSettings.Save();
},
LocalizationManager.Get("ui.render_config.reset_default"),
"btn_reset_output"
}
);
// 7. Advanced Settings (Collapsible Card)
var (_, advContent) = O5Factory.Card(
var (_, advContent) = CreateCard(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.advanced_settings"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
"ui.render_config.advanced_settings"
);
var (advLeft, advRight) = CreateTwoColumnPanel(advContent);
var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" };
_fileExtensionDropdown = CreateDropdownField(
ctx, CreateFieldSlot(advLeft),
LocalizationManager.Get("ui.render_config.file_extension"),
"ui.render_config.file_extension",
"mkv", config.AdvancedConfig.FileExtension,
extensions, e => e,
"dd_adv_file_extension",
@@ -556,11 +544,13 @@ internal static class RenderConfigWindow
);
_processAudioToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft), "",
true, config.AdvancedConfig.ProcessAudio,
LocalizationManager.Get("ui.render_config.process_audio"),
"toggle_process_audio",
val =>
ctx, CreateFieldSlot(advLeft),
labelKey: "",
defaultValue: true,
value: config.AdvancedConfig.ProcessAudio,
toggleTextKey: "ui.render_config.process_audio",
id: "toggle_process_audio",
onChanged: val =>
{
config.AdvancedConfig.ProcessAudio = val;
MarkConfigModified();
@@ -570,7 +560,7 @@ internal static class RenderConfigWindow
_randomSeedInput = CreateInputField(
ctx, CreateFieldSlot(advRight),
LocalizationManager.Get("ui.render_config.random_seed"),
"ui.render_config.random_seed",
"", config.AdvancedConfig.RandomSeed?.ToString() ?? "",
"input_random_seed",
TMPro.TMP_InputField.CharacterValidation.Integer,
@@ -583,11 +573,13 @@ internal static class RenderConfigWindow
);
_nonTileToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight), "",
false, config.AdvancedConfig.NonTile,
LocalizationManager.Get("ui.render_config.non_tile"),
"toggle_non_tile",
val =>
ctx, CreateFieldSlot(advRight),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.NonTile,
toggleTextKey: "ui.render_config.non_tile",
id: "toggle_non_tile",
onChanged: val =>
{
config.AdvancedConfig.NonTile = val;
MarkConfigModified();
@@ -596,9 +588,7 @@ internal static class RenderConfigWindow
);
// 7-1. Planet Color Settings (Optional)
var planetColorsLabel = O5Factory.ControlText(ctx, advContent, 13f, noPad: true);
planetColorsLabel.text = LocalizationManager.Get("ui.render_config.planet_colors");
planetColorsLabel.color = ctx.Theme.TextDim;
var planetColorsLabel = CreateLabel(ctx, advContent, "ui.render_config.planet_colors");
// ReSharper disable once InconsistentNaming
var planetLabelLE = planetColorsLabel.gameObject.AddComponent<LayoutElement>();
planetLabelLE.preferredHeight = 18f;
@@ -609,11 +599,13 @@ internal static class RenderConfigWindow
var fireSlot = CreateFieldSlot(fireCol);
_firePlanetToggle = CreateToggleField(
ctx, fireSlot, "",
false, config.AdvancedConfig.firePlanetColor.HasValue,
LocalizationManager.Get("ui.render_config.fire_planet"),
"toggle_fire_planet",
val =>
ctx, fireSlot,
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.firePlanetColor.HasValue,
toggleTextKey: "ui.render_config.fire_planet",
id: "toggle_fire_planet",
onChanged: val =>
{
_firePlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.firePlanetColor = val ? _firePlanetPicker?.Value : null;
@@ -643,11 +635,13 @@ internal static class RenderConfigWindow
var iceSlot = CreateFieldSlot(iceCol);
_icePlanetToggle = CreateToggleField(
ctx, iceSlot, "",
false, config.AdvancedConfig.icePlanetColor.HasValue,
LocalizationManager.Get("ui.render_config.ice_planet"),
"toggle_ice_planet",
val =>
ctx, iceSlot,
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.icePlanetColor.HasValue,
toggleTextKey: "ui.render_config.ice_planet",
id: "toggle_ice_planet",
onChanged: val =>
{
_icePlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.icePlanetColor = val ? _icePlanetPicker?.Value : null;
@@ -677,11 +671,13 @@ internal static class RenderConfigWindow
var windSlot = CreateFieldSlot(windCol);
_windPlanetToggle = CreateToggleField(
ctx, windSlot, "",
false, config.AdvancedConfig.windPlanetColor.HasValue,
LocalizationManager.Get("ui.render_config.wind_planet"),
"toggle_wind_planet",
val =>
ctx, windSlot,
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.windPlanetColor.HasValue,
toggleTextKey: "ui.render_config.wind_planet",
id: "toggle_wind_planet",
onChanged: val =>
{
_windPlanetPicker?.SetBlocked(!val);
config.AdvancedConfig.windPlanetColor = val ? _windPlanetPicker?.Value : null;
@@ -710,20 +706,17 @@ internal static class RenderConfigWindow
}
// 8. Go Render Button
O5Factory.Button(
CreateButton(
ctx, scrollContent,
TryGoRender,
LocalizationManager.Get("ui.render_config.go_render"),
"ui.render_config.go_render",
"btn_go_render",
onClick: TryGoRender,
height: 48f
);
// 9. Version Label
var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true);
var versionText = $"ADOFAI Renderer v{Melon<RenderMod>.Instance.Info.Version}";
if (DateTime.UtcNow >= RenderMod.ExpirationTime) versionText += " " + LocalizationManager.Get("ui.render_config.expired");
versionLabel.text = versionText;
LocalizedText.Bind(versionLabel, GetVersionText);
versionLabel.alignment = TMPro.TextAlignmentOptions.Center;
versionLabel.color = ctx.Theme.TextDim;
@@ -847,6 +840,10 @@ internal static class RenderConfigWindow
{
if (_renderFpsSlider != null) target.RenderFPS = _renderFpsSlider.Value;
if (_videoFPSSlider != null) target.VideoFPS = _videoFPSSlider.Value;
target.RenderFPS = AlignNTSCFramerate(target.RenderFPS);
target.VideoFPS = AlignNTSCFramerate(target.VideoFPS);
if (_renderWidthSlider != null) target.RenderWidth = (int)Math.Round(_renderWidthSlider.Value);
if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value);
if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value;
@@ -902,6 +899,20 @@ internal static class RenderConfigWindow
target.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null)
? _windPlanetPicker.Value
: null;
return;
// ReSharper disable once InconsistentNaming
double AlignNTSCFramerate(double framerate)
{
const double tolerance = 0.001;
if (Math.Abs(framerate - 29.97) < tolerance) framerate = 30000.0 / 1001;
else if (Math.Abs(framerate - 59.94) < tolerance) framerate = 60000.0 / 1001;
else if (Math.Abs(framerate - 23.976) < tolerance || Math.Abs(framerate - 23.98) < tolerance) framerate = 24000.0 / 1001;
return framerate;
}
}
private static string CustomPresetLabel => LocalizationManager.Get("ui.render_config.preset_custom");
@@ -926,6 +937,7 @@ internal static class RenderConfigWindow
private static void OnPresetSelected(string? selectedName, RenderConfig config)
{
ResetDeletePendingState();
if (string.IsNullOrEmpty(selectedName) || selectedName == CustomPresetLabel) return;
var preset = PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, selectedName, StringComparison.OrdinalIgnoreCase));
if (preset == null) return;
@@ -953,7 +965,7 @@ internal static class RenderConfigWindow
{
SyncUItoConfig(config);
var name = _presetNameInput?.Value?.Trim();
var name = _presetNameInput?.Value.Trim();
if (string.IsNullOrWhiteSpace(name) || name == CustomPresetLabel)
{
CommonWarningDialogUI.ShowDialog(
@@ -1010,20 +1022,62 @@ internal static class RenderConfigWindow
}
}
var ctx = RendererUIManager.Context;
CommonWarningDialogUI.ShowDialog(
LocalizationManager.Get("ui.render_config.preset_delete_title"),
LocalizationManager.Get("ui.render_config.preset_delete_desc", targetPresetName),
new DialogButton(
LocalizationManager.Get("ui.render_config.confirm"),
() => DoDeletePreset(targetPresetName, config),
ctx.Theme.SoftRed
),
new DialogButton(
LocalizationManager.Get("ui.render_config.cancel"),
() => { }
)
);
if (!_isDeletePending)
{
_isDeletePending = true;
if (_deletePresetButtonLE != null) _deletePresetButtonLE.preferredWidth = 110f;
if (_deletePresetButton != null)
{
if (_deletePresetButton.Label != null)
{
_deletePresetButton.Label.text = LocalizationManager.Get("ui.render_config.preset_delete_confirm");
}
_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)
@@ -1048,6 +1102,7 @@ internal static class RenderConfigWindow
private static void ReloadPresetDropdown(string? selectName = null)
{
ResetDeletePendingState();
var presets = PresetManager.GetPresets();
var names = presets.Select(p => p.Name).ToList();
if (names.Count == 0)
@@ -1087,6 +1142,26 @@ internal static class RenderConfigWindow
_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)
{
Melon<RenderMod>.Logger.Error(message);
@@ -1189,23 +1264,139 @@ internal static class RenderConfigWindow
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(
O5Context ctx,
Transform colParent,
string labelText,
string labelKey,
string? defaultValue,
string value,
string id,
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
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(
O5Context ctx,
Transform colParent,
string labelText,
string labelKey,
string? defaultValue,
string value,
string id,
@@ -1214,8 +1405,9 @@ internal static class RenderConfigWindow
Action<string>? onChanged = null)
{
label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = labelText;
label.text = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
@@ -1228,11 +1420,16 @@ internal static class RenderConfigWindow
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
placeholder: labelText,
placeholder: LocalizationManager.Get(labelKey),
icon: null,
id: id
);
if (inputField.Placeholder != null)
{
LocalizedText.Bind(inputField.Placeholder, labelKey);
}
inputField.InputField.characterValidation = characterValidation;
return inputField;
}
@@ -1291,7 +1488,7 @@ internal static class RenderConfigWindow
private static O5InputField CreateFullWidthInputField(
O5Context ctx,
Transform parent,
string labelText,
string labelKey,
string? defaultValue,
string value,
string id,
@@ -1309,8 +1506,9 @@ internal static class RenderConfigWindow
layout.childForceExpandHeight = false;
var label = O5Factory.ControlText(ctx, containerRect, 13f, noPad: true);
label.text = labelText;
label.text = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
@@ -1323,21 +1521,26 @@ internal static class RenderConfigWindow
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
placeholder: labelText,
placeholder: LocalizationManager.Get(labelKey),
icon: null,
id: id
);
if (input.Placeholder != null)
{
LocalizedText.Bind(input.Placeholder, labelKey);
}
return input;
}
private static O5Toggle CreateToggleField(
O5Context ctx,
Transform colParent,
string labelText,
string labelKey,
bool? defaultValue,
bool value,
string toggleText,
string toggleTextKey,
string id,
Action<bool>? onChanged = null,
bool generateLabel = true)
@@ -1345,8 +1548,9 @@ internal static class RenderConfigWindow
if (generateLabel)
{
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;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
@@ -1354,21 +1558,28 @@ internal static class RenderConfigWindow
labelLE.minHeight = 18f;
}
return O5Factory.Toggle(
var toggle = O5Factory.Toggle(
ctx: ctx,
parent: colParent,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
text: toggleText,
text: !string.IsNullOrEmpty(toggleTextKey) ? LocalizationManager.Get(toggleTextKey) : "",
id: id
);
if (toggle.Label != null && !string.IsNullOrEmpty(toggleTextKey))
{
LocalizedText.Bind(toggle.Label, toggleTextKey);
}
return toggle;
}
private static O5Dropdown<T> CreateDropdownField<T>(
O5Context ctx,
Transform colParent,
string labelText,
string labelKey,
T defaultValue,
T value,
IReadOnlyList<T> values,
@@ -1377,8 +1588,9 @@ internal static class RenderConfigWindow
Action<T>? onChanged = null)
{
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = labelText;
label.text = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
+1 -1
View File
@@ -108,7 +108,7 @@ internal static class AudioSpectrumGenerator
return;
}
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
{
-5
View File
@@ -15,11 +15,6 @@ internal class AudioSyncCalculator
{
_sampleRate = sampleRate;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
// NTSC
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
}
public void Reset()
+134 -111
View File
@@ -191,6 +191,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
private bool _isOpened;
private bool _disposed;
private bool _isDraining;
/// <summary>Number of video packets skipped because the decoder rejected them as invalid data.</summary>
private int _skippedPacketCount;
/// <summary>Number of skipped packets for which a warning is logged.</summary>
private const int MaxLoggedSkippedPackets = 5;
private readonly object _lock = new();
// Fields to detect SwsContext updates
@@ -371,6 +378,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
if (_videoStreamIndex == -1)
throw new Exception("Failed to find video stream.");
// Only the video stream is used. The demuxer does not need to return the packets of the other streams
// (audio, subtitles, ...) once they are marked as discarded.
// Reference: AVStream.discard - "Selects which packets can be discarded at will and do not need to be demuxed."
// https://ffmpeg.org/doxygen/trunk/structAVStream.html
for (int i = 0; i < _pFormatContext->nb_streams; i++)
{
if (i != _videoStreamIndex)
_pFormatContext->streams[i]->discard = AVDiscard.AVDISCARD_ALL;
}
var stream = _pFormatContext->streams[_videoStreamIndex];
_timeBase = stream->time_base;
@@ -441,9 +458,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
TotalFrames = 0;
}
Duration = _pFormatContext->duration > 0
? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE
: (stream->duration > 0 ? stream->duration * ffmpeg.av_q2d(stream->time_base) : 0);
// Duration of the video stream (AVStream.duration, in the stream time base).
// When the stream does not carry a duration, the container duration (AVFormatContext.duration,
// in AV_TIME_BASE units, the longest of all streams) is used.
// Reference: https://ffmpeg.org/doxygen/trunk/structAVStream.html, https://ffmpeg.org/doxygen/trunk/structAVFormatContext.html
Duration = stream->duration > 0
? stream->duration * ffmpeg.av_q2d(stream->time_base)
: _pFormatContext->duration > 0 ? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE : 0;
// 9. Allocate frame and packet buffers
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
@@ -655,6 +676,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads packets from the container until a packet belonging to the video stream is found,
/// then sends it to the decoder via <c>avcodec_send_packet</c>.
/// A packet that the decoder rejects as invalid data (<c>AVERROR_INVALIDDATA</c>, e.g. a damaged packet)
/// is skipped and the next packet is read. Any other decoder error is thrown.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
/// </summary>
/// <returns>True if a video packet was sent; false on EOF.</returns>
@@ -670,6 +693,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
{
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
ffmpeg.av_packet_unref(_pPacket);
if (ret == ffmpeg.AVERROR_INVALIDDATA)
{
// The warning is logged for the first few packets only (a damaged file can have many).
_skippedPacketCount++;
if (_skippedPacketCount <= MaxLoggedSkippedPackets)
{
Melon<RenderMod>.Logger.Warning($"[FFmpegNativeDecoder] Skipped an invalid video packet ({_skippedPacketCount}): {GetFFmpegErrorString(ret)}");
}
continue;
}
if (ret < 0)
{
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
@@ -1212,107 +1247,24 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Decodes and discards a specified number of frames immediately without GPU-to-CPU transfer overhead.
/// Maximum distance in frames over which <see cref="SeekToTime"/> moves forward by decoding.
/// Decoding forward needs no keyframe seek and no decoder flush, which is cheaper than seeking for short distances.
/// </summary>
/// <param name="count">Number of frames to discard.</param>
private void SkipFrames(int count)
{
lock (_lock)
{
if (!_isOpened || _disposed) return;
if (count <= 0) return;
int skipped = 0;
if (_hasCachedFrame)
{
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
skipped++;
}
while (skipped < count)
{
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
if (ret == 0)
{
GetTimestampAndUpdateIndex(_pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
skipped++;
continue;
}
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
if (_isDraining) break;
if (!ReadAndSendPacket())
{
_isDraining = true;
ffmpeg.avcodec_send_packet(_pCodecContext, null);
}
}
else if (ret == ffmpeg.AVERROR_EOF)
{
break;
}
else
{
throw new Exception($"Error skipping frames: {GetFFmpegErrorString(ret)}");
}
}
}
}
private const int MaxForwardDecodeFrames = 60;
/// <summary>
/// Seeks to a specific playback time in seconds using backward keyframe seek followed by forward decode.
/// Intermediate frames are discarded without GPU-to-host memory transfer to maximize seek speed;
/// only the target frame is transferred and preserved in <see cref="_pCachedFrame"/>.
/// 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 to seek to.</param>
public void SeekToTime(double timeSeconds)
/// <param name="timeSeconds">Target time in seconds.</param>
private void DecodeToTarget(double timeSeconds)
{
lock (_lock)
{
if (!_isOpened || _disposed) return;
if (timeSeconds < 0) timeSeconds = 0;
double targetFrameIndex = timeSeconds * FPS;
// If already at target index or very close future, skip frames instead of seek to avoid I-frame seek latency
bool useSkip = targetFrameIndex >= _currentFrameIndex && targetFrameIndex - _currentFrameIndex < 60;
if (useSkip)
{
SkipFrames((int)(targetFrameIndex - _currentFrameIndex));
return;
}
if (_hasCachedFrame)
{
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
}
long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase));
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
{
// Fallback to beginning of file on seek failure
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
{
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
}
}
// Flush decoder internal buffers
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
ffmpeg.avcodec_flush_buffers(_pCodecContext);
_isDraining = false;
// Decode frames until reaching the target presentation timestamp
while (true)
{
ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
if (ret == 0)
{
double currentPts = GetFrameTimestamp(_pDecodedFrame);
@@ -1343,28 +1295,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
_hasCachedFrame = true;
break;
return;
}
// 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);
}
GetTimestampAndUpdateIndex(_pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
continue;
}
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
if (_isDraining) break;
if (_isDraining) return;
if (!ReadAndSendPacket())
{
@@ -1374,7 +1316,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else if (ret == ffmpeg.AVERROR_EOF)
{
break;
return;
}
else
{
@@ -1382,6 +1324,87 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
}
}
/// <summary>
/// Moves the playback position to a time in seconds.
/// The next <c>TryRead*</c> call returns the first frame whose presentation time is at least
/// <paramref name="timeSeconds"/> minus half a frame (the frame nearest to the target time).
/// Both paths below return that same frame:
/// - Forward decode: the target is ahead of the current position by less than <see cref="MaxForwardDecodeFrames"/> frames.
/// Frames are decoded and discarded up to the target.
/// - Keyframe seek: all other cases (target before the current position, far ahead, or the target frame already read).
/// A backward keyframe seek is followed by a forward decode up to the target.
/// The target frame is transferred and preserved in <see cref="_pCachedFrame"/>; when it is already there, nothing is decoded.
/// </summary>
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
public void SeekToTime(double timeSeconds)
{
lock (_lock)
{
if (!_isOpened || _disposed) return;
if (timeSeconds < 0) timeSeconds = 0;
// Earliest presentation time that satisfies the target (target minus half a frame).
double threshold = timeSeconds - (0.5 / FPS);
// Index of the first frame that satisfies the target. The epsilon keeps a frame that lies exactly on the
// margin from being excluded by floating point rounding.
double targetFrameIndex = Math.Ceiling(timeSeconds * FPS - 0.5 - 1e-6);
if (_hasCachedFrame)
{
// A frame is already positioned for the next read (e.g. by a previous SeekToTime call).
double cachedPts = GetFrameTimestamp(_pCachedFrame);
if (cachedPts >= threshold)
{
// The cached frame is the first frame that satisfies the target.
// When the target lies before it, the frames in between are no longer available and a keyframe seek is required.
if (cachedPts - threshold < 1.0 / FPS) return;
}
else if (targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
{
// The cached frame lies before the target: drop it and continue decoding forward.
GetTimestampAndUpdateIndex(_pCachedFrame);
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
DecodeToTarget(timeSeconds);
return;
}
}
else if (targetFrameIndex > _currentFrameIndex && targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
{
// The target frame has not been read yet and is close: decode forward instead of seeking.
DecodeToTarget(timeSeconds);
return;
}
if (_hasCachedFrame)
{
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
}
long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase));
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
{
// Fallback to beginning of file on seek failure
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
{
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
}
}
// Flush decoder internal buffers
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
ffmpeg.avcodec_flush_buffers(_pCodecContext);
_isDraining = false;
// Decode frames until reaching the target presentation timestamp
DecodeToTarget(timeSeconds);
}
}
/// <summary>
+119 -22
View File
@@ -83,7 +83,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary>
public double FPS;
/// <summary>Video codec type to use (H264, H265, AV1, VP9).</summary>
/// <summary>Video codec type to use (H264, H265, AV1, VP9, VVC).</summary>
public Codec Codec;
/// <summary>Bitrate control mode (CQP, CBR, VBR).</summary>
@@ -437,16 +437,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
// framerate: Framerate value giving hints to the encoder (reciprocal of time_base).
//
// RationalUtil: Converts FPS (double) into fractional form (numerator/denominator).
// e.g., 29.97 → 30000/1001 (NTSC standard)
// e.g., 60.00 -> 60/1
//
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS);
// NTSC
if (Math.Abs(_settings.FPS - 29.97) < 0.001) fpsRational = (30000, 1001);
if (Math.Abs(_settings.FPS - 59.94) < 0.001) fpsRational = (60000, 1001);
if (Math.Abs(_settings.FPS - 23.976) < 0.001 || Math.Abs(_settings.FPS - 23.98) < 0.001) fpsRational = (24000, 1001);
_pCodecContext->time_base = new AVRational
{ num = fpsRational.Denominator, den = fpsRational.Numerator };
_pCodecContext->framerate = new AVRational
@@ -728,6 +723,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0)
throw new Exception("Failed to copy codec parameters to stream.");
// Stream time base: unit of the timestamps stored in the container.
// Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks.
// When it is left unset, the muxer uses 1/90000, in which the interval of rates such as
// 59.94 fps (1501.5 ticks), 23.976 fps (3753.75 ticks) or 165 fps (545.45 ticks) is not an integer,
// and the frame durations alternate between two values.
// The muxer may still adjust the value in avformat_write_header (e.g. mp4 scales it to at least 10000);
// packet timestamps are rescaled to the final value in SendFrameAndWritePackets.
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/mux.c (init_muxer: default 1/90000)
_pVideoStream->time_base = _pCodecContext->time_base;
// ── Step 4.5 (Optional): Initialize audio stream ──
// If EncoderSettings.Audio is set, add audio stream.
if (_settings.Audio.HasValue)
@@ -830,7 +835,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// Reference - CQP options per encoder:
/// NVENC: "qp" (single value applied to I/P/B frames)
/// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately)
/// QSV: "global_quality" (Intel unique option)
/// QSV: AVCodecContext.global_quality (Intel ICQ mode)
/// Software: "crf" (Constant Rate Factor, smarter quality control)
///
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
@@ -852,11 +857,21 @@ namespace ADOFAIRenderer.Util.FFmpeg
_pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L;
_pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000;
// nal-hrd=cbr: x264/x265 dedicated option. Generates CBR bitstream matching
// NAL HRD (Hypothetical Reference Decoder) specification. May be required for streaming compatibility.
if (encoder == VideoEncoder.Libx264 || encoder == VideoEncoder.Libx265)
// HRD (Hypothetical Reference Decoder) signaling for CBR output, used for streaming compatibility.
// libx264: "nal-hrd=cbr" writes the NAL HRD information and pads filler data to keep the bitrate constant.
// FFmpeg's option description: "Signal HRD information (requires vbv-bufsize; cbr not allowed in .mp4)".
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/libx264.c ("nal-hrd": none/vbr/cbr)
// libx265: "x265-params" = "hrd=1" (--hrd: "Enable the signaling of HRD parameters to the decoder")
// + "strict-cbr=1" (--strict-cbr: "Enables stricter conditions to control bitrate deviance
// from the target bitrate in ABR mode").
// Reference: https://x265.readthedocs.io/en/master/cli.html
if (encoder == VideoEncoder.Libx264)
{
ffmpeg.av_opt_set(_pCodecContext->priv_data, "nal-hrd", "cbr", 0);
TrySetOption("nal-hrd", "cbr");
}
else if (encoder == VideoEncoder.Libx265)
{
TrySetOption("x265-params", "hrd=1:strict-cbr=1");
}
}
break;
@@ -880,38 +895,77 @@ namespace ADOFAIRenderer.Util.FFmpeg
break;
case RateControlMode.CQP:
// Valid quality range per encoder. The range of the private options is read from the
// encoder's option definition (AVOption min/max); a declared minimum of -1 means "auto".
// NVENC: "qp" H.264/HEVC 0~51, AV1 0~255
// (libavcodec/nvenc_h264.c, nvenc_hevc.c: "qp" -1~51 / libavcodec/nvenc_av1.c: "qp" -1~255)
// AMF: "qp_i" H.264/HEVC 0~51, AV1 0~255
// (libavcodec/amfenc_h264.c, amfenc_hevc.c: "qp_i" -1~51 / libavcodec/amfenc_av1.c: "qp_i" -1~255)
// QSV: AVCodecContext.global_quality 1~51
// VVC: "qp" 0~63 (libavcodec/libvvenc.c: "qp" -1~63)
// Software: "crf" x264/x265 0~51 (https://x265.readthedocs.io/en/master/cli.html: --crf 0..51.0),
// VP9/AV1 0~63 (libavcodec/libvpxenc.c, libaomenc.c: "crf" -1~63 / libavcodec/libsvtav1.c: "crf" 0~63)
// Reference: https://github.com/FFmpeg/FFmpeg/tree/master/libavcodec
int qpMin, qpMax;
if (encoder.Kind == EncoderKind.QSV)
{
// QSV runs in ICQ (Intelligent Constant Quality) mode when global_quality > 0 and rc_max_rate is 0;
// global_quality 0 selects the encoder's default CQP. The ICQ quality is clamped to 1~51 by FFmpeg:
// libavcodec/qsvenc.c: q->param.mfx.ICQQuality = av_clip(avctx->global_quality, 1, 51);
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/qsvenc.c
qpMin = 1;
qpMax = 51;
}
else if (encoder.Kind == EncoderKind.NVENC) (qpMin, qpMax) = GetQualityRange("qp");
else if (encoder.Kind == EncoderKind.AMF) (qpMin, qpMax) = GetQualityRange("qp_i");
else if (encoder == VideoEncoder.LibVvenc) (qpMin, qpMax) = GetQualityRange("qp");
else (qpMin, qpMax) = GetQualityRange("crf"); // Software encoder (libx264, libx265, libvpx-vp9, etc.)
// Default value 18: Quality close to "visually lossless" in H.264.
// Provides similar level of quality in other codecs as well.
var qpValue = _settings.QualityValue is >= 0 and <= 51 ? _settings.QualityValue : 18;
const int defaultQuality = 18;
var requestedQp = _settings.QualityValue;
int qpValue;
if (requestedQp < 0)
{
qpValue = Math.Clamp(defaultQuality, qpMin, qpMax);
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Invalid quality value {requestedQp}. Using {qpValue}.");
}
else
{
qpValue = Math.Clamp(requestedQp, qpMin, qpMax);
if (qpValue != requestedQp)
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Quality value {requestedQp} is outside the supported range {qpMin}~{qpMax}. Using {qpValue}.");
}
if (encoder.Kind == EncoderKind.NVENC)
{
// NVENC: Applies same QP to I/P/B frames with single "qp" option
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0);
TrySetOption("qp", qpValue.ToString());
}
else if (encoder.Kind == EncoderKind.AMF)
{
// AMF: QP for I-frame (qp_i) and P-frame (qp_p) must be set separately.
// Since B-frames are disabled, qp_b is not set.
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_i", qpValue.ToString(), 0);
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_p", qpValue.ToString(), 0);
TrySetOption("qp_i", qpValue.ToString());
TrySetOption("qp_p", qpValue.ToString());
}
else if (encoder.Kind == EncoderKind.QSV)
{
// QSV: Uses ICQ (Intelligent Constant Quality) mode with "global_quality" option
ffmpeg.av_opt_set(_pCodecContext->priv_data, "global_quality", qpValue.ToString(), 0);
// QSV: ICQ (Intelligent Constant Quality) mode, configured with AVCodecContext.global_quality.
_pCodecContext->global_quality = qpValue;
}
else if (encoder == VideoEncoder.LibVvenc)
{
// libvvenc: Specifies Constant QP mode with "qp" option
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0);
TrySetOption("qp", qpValue.ToString());
}
else // Software encoder (libx264, libx265, libvpx-vp9, etc.)
else
{
// CRF (Constant Rate Factor): Smarter quality control than QP.
// Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality.
// Range: 0 (lossless) ~ 51 (lowest quality). Typically between 18 and 28.
ffmpeg.av_opt_set(_pCodecContext->priv_data, "crf", qpValue.ToString(), 0);
// Range: 0 (lossless) ~ 51 (lowest quality) for x264/x265, 0~63 for VP9/AV1. Typically between 18 and 28.
TrySetOption("crf", qpValue.ToString());
}
break;
default:
@@ -919,6 +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
// ================================================================
+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);
return;
+2 -1
View File
@@ -4,6 +4,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using MelonLoader;
using UnityEngine;
@@ -121,7 +122,7 @@ internal static class GPUUtil
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100f;
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
@@ -8,7 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using MelonLoader;
namespace ADOFAIRenderer.Util;
namespace ADOFAIRenderer.Util.OS;
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")]
+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;
}
}
}
}